feat: 新增财务模块并优化组织管理、角色权限与我的订单

- 新增财务菜单与应收应付、充值记录、账户流水页面及路由

- 组织管理改为左右分栏,优化左侧树层级展示与选中交互

- 角色权限分配改为列表形式,支持各级菜单独立勾选

- 我的订单工具栏按钮样式与独立 loading 交互优化

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-04 12:15:14 +08:00
parent 5cd6a5462d
commit 2063621daf
25 changed files with 3589 additions and 1428 deletions

File diff suppressed because it is too large Load Diff

1048
docs/数据权限设计.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -508,16 +508,51 @@ const menuData = [
hideInMenu: true, hideInMenu: true,
parentRoute: '/user', parentRoute: '/user',
}, },
// 一级菜单 - 财务
{
level: 1,
name: '财务',
permissionCode: 'Finance',
route: '/finance',
component: null,
icon: 'icon-storage',
order: 6,
hideInMenu: false,
},
// 二级菜单 - 财务
{ {
level: 2, level: 2,
name: '充值记录', name: '充值记录',
permissionCode: 'User.RechargeRecord', permissionCode: 'Finance.RechargeRecord',
route: '/user/rechargeRecord', route: '/finance/rechargeRecord',
component: 'views/user/recharge/rechargeRecord', component: 'views/finance/rechargeRecord/index',
icon: null,
order: 1,
hideInMenu: false,
parentRoute: '/finance',
},
{
level: 2,
name: '资金流水',
permissionCode: 'Finance.AccountFlow',
route: '/finance/accountFlow',
component: 'views/finance/accountFlow/index',
icon: null, icon: null,
order: 2, order: 2,
hideInMenu: false, hideInMenu: false,
parentRoute: '/user', parentRoute: '/finance',
},
{
level: 2,
name: '应收应付',
permissionCode: 'Finance.ReceivablePayable',
route: '/finance/receivablePayable',
component: 'views/finance/receivablePayable/index',
icon: null,
order: 3,
hideInMenu: false,
parentRoute: '/finance',
}, },
// 一级菜单 - 系统 // 一级菜单 - 系统

View File

@ -103,6 +103,42 @@ server {
try_files /index.html =200; try_files /index.html =200;
} }
# ============ 系统管理前端路由(^~ 须比下方 /system/ API 正则代理更优先)============
# 页面路径为 camelCase;API 为 kebab(如 /system/dict/list、/system/batch-email/…),勿用宽泛前缀匹配 dict
location = /system/dict {
try_files /index.html =200;
}
location = /system/warehouseAddress {
try_files /index.html =200;
}
location = /system/announcement {
try_files /index.html =200;
}
location ^~ /system/users {
try_files /index.html =200;
}
location ^~ /system/roles {
try_files /index.html =200;
}
location ^~ /system/menus {
try_files /index.html =200;
}
location ^~ /system/organizations {
try_files /index.html =200;
}
location ^~ /system/batchEmail {
try_files /index.html =200;
}
location ^~ /system/otherConfig {
try_files /index.html =200;
}
location ^~ /system/xhsAiAssistant {
try_files /index.html =200;
}
location ^~ /system/dataPerm/ {
try_files /index.html =200;
}
# ============ 微信公众号服务器回调(admin API,非 itfx)============ # ============ 微信公众号服务器回调(admin API,非 itfx)============
location ^~ /api/apiwechat/ { location ^~ /api/apiwechat/ {
rewrite ^/api/apiwechat/(.*)$ /admin/apiwechat/$1 break; rewrite ^/api/apiwechat/(.*)$ /admin/apiwechat/$1 break;

View File

@ -39,6 +39,19 @@ server {
location ^~ /operation/outboundScan { try_files /index.html =200; } location ^~ /operation/outboundScan { try_files /index.html =200; }
location ^~ /operation/printLabel { try_files /index.html =200; } location ^~ /operation/printLabel { try_files /index.html =200; }
# 系统管理前端路由(^~ 须比下方 /system/ API 正则代理更优先)
location = /system/dict { try_files /index.html =200; }
location = /system/warehouseAddress { try_files /index.html =200; }
location = /system/announcement { try_files /index.html =200; }
location ^~ /system/users { try_files /index.html =200; }
location ^~ /system/roles { try_files /index.html =200; }
location ^~ /system/menus { try_files /index.html =200; }
location ^~ /system/organizations { try_files /index.html =200; }
location ^~ /system/batchEmail { try_files /index.html =200; }
location ^~ /system/otherConfig { try_files /index.html =200; }
location ^~ /system/xhsAiAssistant { try_files /index.html =200; }
location ^~ /system/dataPerm/ { try_files /index.html =200; }
location ^~ /api/apiwechat/ { location ^~ /api/apiwechat/ {
rewrite ^/api/apiwechat/(.*)$ /admin/apiwechat/$1 break; rewrite ^/api/apiwechat/(.*)$ /admin/apiwechat/$1 break;
proxy_pass http://api.nanafa.com; proxy_pass http://api.nanafa.com;

View File

@ -0,0 +1,57 @@
import { axiosInstance } from '@/api/rbac'
import { omsUrl, parseListPayload } from '@/api/oms/common'
export interface ReceivablePayableListParams {
page: number
pageSize: number
keyword?: string
feeType?: string
costItem?: string
chargingStage?: string
customerId?: number
startTime?: string
endTime?: string
}
export interface ReceivablePayableRow {
serviceNo: string
clientNo?: string
trackNo?: string
customerCode?: string
orderStatus: string
orderStatusName?: string
isPaid: number
payTime?: string
receiveTime?: string
shipTime?: string
productCode?: string
channelCode?: string
consigneeCountryName?: string
feeType: string
feeTypeName?: string
costItem: string
amount: number
currency: string
downstreamProvider?: string
priceNo?: string
priceEffectiveTime?: string
chargingStage?: string
chargingStageName?: string
}
export async function getReceivablePayableList(params: ReceivablePayableListParams) {
const res = await axiosInstance.get(omsUrl('/finance/receivable-payable/list'), {
params: {
page: params.page,
pageSize: params.pageSize,
keyword: params.keyword || undefined,
feeType: params.feeType && params.feeType !== 'all' ? params.feeType : undefined,
costItem: params.costItem || undefined,
chargingStage: params.chargingStage && params.chargingStage !== 'all' ? params.chargingStage : undefined,
customerId: params.customerId || undefined,
startTime: params.startTime || undefined,
endTime: params.endTime || undefined,
},
})
return parseListPayload<ReceivablePayableRow>(res.data)
}

View File

@ -36,6 +36,8 @@
showPagination?: boolean; showPagination?: boolean;
pageSize?: number; pageSize?: number;
currentPage?: number; currentPage?: number;
/** 树形层级展示(保留缩进/展开箭头,适用于组织等多级树) */
hierarchical?: boolean;
}>(), }>(),
{ {
selectedKeys: () => [], selectedKeys: () => [],
@ -62,6 +64,7 @@
showPagination: true, showPagination: true,
pageSize: 10, pageSize: 10,
currentPage: 1, currentPage: 1,
hierarchical: false,
} }
); );
@ -268,6 +271,14 @@
if (nodeData) emit('nodeClick', key, nodeData); if (nodeData) emit('nodeClick', key, nodeData);
} }
function selectTreeNode(key: string) {
if (!key) return;
selectedKeysModel.value = [key];
const nodeData = getNodeData(key);
emit('select', [key], { node: nodeData });
if (nodeData) emit('nodeClick', key, nodeData);
}
defineExpose({ defineExpose({
getNodeData, getNodeData,
getNodeIndex, getNodeIndex,
@ -317,7 +328,10 @@
</div> </div>
</div> </div>
<div class="interface-tree"> <div
class="interface-tree"
:class="{ 'interface-tree--hierarchical': hierarchical }"
>
<div v-if="loading" class="loading-state"> <div v-if="loading" class="loading-state">
<a-spin /> <a-spin />
<p>{{ loadingText }}</p> <p>{{ loadingText }}</p>
@ -335,14 +349,20 @@
<template #title="{ key, title }"> <template #title="{ key, title }">
<div <div
class="tree-node" class="tree-node"
:class="{ selected: selectedKeysModel.includes(key) }" :class="{
selected: selectedKeysModel.includes(key),
'tree-node--hierarchical': hierarchical,
'tree-node--branch': hierarchical && getNodeData(key)?.isLeaf !== true,
[`tree-node--depth-${getNodeData(key)?.depth || 1}`]: hierarchical,
}"
@click="hierarchical && selectTreeNode(key)"
> >
<table class="node-table"> <table class="node-table">
<tbody> <tbody>
<tr> <tr>
<td class="node-content-cell"> <td class="node-content-cell">
<span v-if="selectedKeysModel.includes(key)" class="selected-dot"></span> <span v-if="selectedKeysModel.includes(key) && !hierarchical" class="selected-dot"></span>
<span v-if="getNodeData(key)?.isLeaf && showNodeIndex" class="node-number">{{ getNodeIndex(key) }}</span> <span v-if="getNodeData(key)?.isLeaf && showNodeIndex && !hierarchical" class="node-number">{{ getNodeIndex(key) }}</span>
<span v-if="getNodeData(key)?.isLeaf && showMethod" class="post-method">{{ methodLabel }}</span> <span v-if="getNodeData(key)?.isLeaf && showMethod" class="post-method">{{ methodLabel }}</span>
<span class="node-title">{{ title }}</span> <span class="node-title">{{ title }}</span>
<span v-if="getNodeData(key)?.isLeaf && showMapCount" class="map-count"> <span v-if="getNodeData(key)?.isLeaf && showMapCount" class="map-count">
@ -421,7 +441,10 @@
</td> </td>
</tr> </tr>
<tr <tr
v-if="!getNodeData(key)?.isLeaf && (getNodeData(key)?.subtitle || $slots['branch-node-subtitle'])" v-if="
(hierarchical || getNodeData(key)?.isLeaf !== true) &&
(getNodeData(key)?.subtitle || $slots['branch-node-subtitle'])
"
> >
<td colspan="2" class="node-subtitle-cell"> <td colspan="2" class="node-subtitle-cell">
<slot name="branch-node-subtitle" :node-data="getNodeData(key)" :node-key="key"> <slot name="branch-node-subtitle" :node-data="getNodeData(key)" :node-key="key">
@ -616,55 +639,211 @@
border-radius: 16px; border-radius: 16px;
padding-right: 4px; padding-right: 4px;
:deep(.arco-tree), &:not(.interface-tree--hierarchical) {
:deep(.arco-tree-show-line), :deep(.arco-tree),
:deep(.arco-tree-size-medium), :deep(.arco-tree-show-line),
:deep(.arco-tree-node), :deep(.arco-tree-size-medium),
:deep(.arco-tree-node-title), :deep(.arco-tree-node),
:deep(.arco-tree-node-title-text), :deep(.arco-tree-node-title),
:deep(.arco-tree-node-content), :deep(.arco-tree-node-title-text),
:deep(.arco-tree-node-content-wrapper) { :deep(.arco-tree-node-content),
display: contents !important; :deep(.arco-tree-node-content-wrapper) {
padding: 0 !important; display: contents !important;
margin: 0 !important; padding: 0 !important;
background: transparent !important; margin: 0 !important;
border: none !important; background: transparent !important;
box-shadow: none !important; border: none !important;
box-shadow: none !important;
}
:deep(.arco-tree-node-icon),
:deep(.arco-tree-node-indent),
:deep(.arco-tree-node-switcher),
:deep(.arco-icon),
:deep(.arco-tree-node-title .arco-icon),
:deep(.arco-tree-node-line),
:deep(.arco-tree-node-line-top),
:deep(.arco-tree-node-line-bottom) {
display: none !important;
}
:deep(.arco-tree-node-wrapper),
:deep(.arco-tree-node-selected),
:deep(.arco-tree-node-selected > .arco-tree-node-title) {
display: contents !important;
padding: 0 !important;
margin: 0 !important;
background: transparent !important;
border: none !important;
}
:deep(.arco-tree-node-content) {
display: block !important;
}
:deep(.arco-tree-list),
:deep(.arco-tree-list-holder) {
display: contents !important;
padding: 0 !important;
margin: 0 !important;
}
} }
:deep(.arco-tree-node-icon), &.interface-tree--hierarchical {
:deep(.arco-tree-node-indent), overflow-y: auto;
:deep(.arco-tree-node-switcher), padding-right: 2px;
:deep(.arco-icon),
:deep(.arco-tree-node-title .arco-icon),
:deep(.arco-tree-node-line),
:deep(.arco-tree-node-line-top),
:deep(.arco-tree-node-line-bottom) {
display: none !important;
}
:deep(.arco-tree-node-wrapper), :deep(.arco-tree-node) {
:deep(.arco-tree-node-selected), align-items: flex-start;
:deep(.arco-tree-node-selected > .arco-tree-node-title) { }
display: contents !important;
padding: 0 !important;
margin: 0 !important;
background: transparent !important;
border: none !important;
}
:deep(.arco-tree-node-content) { :deep(.arco-tree-node-switcher) {
display: block !important; display: inline-flex !important;
} align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.72);
margin-top: 10px;
}
:deep(.arco-tree-list), :deep(.arco-tree-node-indent-block) {
:deep(.arco-tree-list-holder) { display: inline-block !important;
display: contents !important; }
padding: 0 !important;
margin: 0 !important; :deep(.arco-tree-node-title),
:deep(.arco-tree-node-title-text),
:deep(.arco-tree-node-content),
:deep(.arco-tree-node-content-wrapper) {
flex: 1;
min-width: 0;
width: 100%;
padding: 0 !important;
background: transparent !important;
}
:deep(.arco-tree-node-title) {
cursor: pointer;
}
:deep(.arco-tree-node-title-text) {
display: block;
width: 100%;
}
:deep(.arco-tree-node-selected .arco-tree-node-title),
:deep(.arco-tree-node-selected .arco-tree-node-content) {
background: transparent !important;
}
:deep(.arco-tree-node-line),
:deep(.arco-tree-node-line-top),
:deep(.arco-tree-node-line-bottom) {
border-color: rgba(255, 255, 255, 0.18) !important;
}
.tree-node {
width: 100%;
margin: 2px 0 6px;
padding: 8px 10px;
border-radius: 10px;
border-left-width: 3px;
border-left-style: solid;
transform: none;
cursor: pointer;
box-sizing: border-box;
&:hover {
transform: none;
}
&.selected {
background: #fff394 !important;
border-left-color: #fff394 !important;
box-shadow: none;
color: #000 !important;
.node-title,
.node-subtitle {
color: #000 !important;
}
&.tree-node--branch .node-title::before {
background: rgba(0, 0, 0, 0.45);
}
}
}
.tree-node--depth-1 {
border-left-color: var(--app-primary-color);
background: rgba(255, 255, 255, 0.08);
.node-title {
font-size: 14px;
font-weight: 700;
}
}
.tree-node--depth-2 {
border-left-color: rgba(45, 140, 140, 0.75);
background: rgba(255, 255, 255, 0.05);
.node-title {
font-size: 13px;
font-weight: 600;
}
}
.tree-node--depth-3 {
border-left-color: rgba(255, 255, 255, 0.35);
background: rgba(255, 255, 255, 0.03);
.node-title {
font-size: 13px;
font-weight: 500;
}
}
.tree-node--depth-4,
.tree-node--depth-5,
.tree-node--depth-6 {
border-left-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.02);
padding: 6px 10px;
.node-title {
font-size: 12px;
font-weight: 500;
}
}
.tree-node--branch .node-title::before {
content: '';
display: inline-block;
width: 6px;
height: 6px;
margin-right: 6px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.45);
vertical-align: middle;
transform: translateY(-1px);
}
.node-subtitle {
color: rgba(255, 255, 255, 0.58);
font-size: 11px;
}
} }
} }
.interface-tree--hierarchical::-webkit-scrollbar {
width: 4px;
display: block;
}
.interface-tree--hierarchical::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.18);
border-radius: 4px;
}
.interface-tree::-webkit-scrollbar { .interface-tree::-webkit-scrollbar {
display: none; display: none;
} }

View File

@ -479,7 +479,10 @@ const menuNameToLocaleKeyMap: Record<string, string> = {
'客户管理': 'menu.user.customer', '客户管理': 'menu.user.customer',
'个人中心': 'menu.user.profile', '个人中心': 'menu.user.profile',
'资金充值': 'menu.user.recharge', '资金充值': 'menu.user.recharge',
'充值记录': 'menu.user.rechargeRecord', '财务': 'menu.finance',
'充值记录': 'menu.finance.rechargeRecord',
'资金流水': 'menu.finance.accountFlow',
'应收应付': 'menu.finance.receivablePayable',
'订单': 'menu.order', '订单': 'menu.order',
'我要查价': 'menu.order.priceCheck', '我要查价': 'menu.order.priceCheck',
'我要下单': 'menu.order.placeOrder', '我要下单': 'menu.order.placeOrder',
@ -539,6 +542,10 @@ const menuNameToLocaleKeyMap: Record<string, string> = {
'技能卡片': 'menu.xhs.skills', '技能卡片': 'menu.xhs.skills',
'拟人设置': 'menu.xhs.humanSettings', '拟人设置': 'menu.xhs.humanSettings',
'AI 助手': 'menu.system.xhsAiAssistant', 'AI 助手': 'menu.system.xhsAiAssistant',
'数据权限': 'menu.system.dataPerm',
'用户数据权限': 'menu.system.dataPermUsers',
'权限模板': 'menu.system.dataPermTemplates',
'权限审计': 'menu.system.dataPermAudit',
}; };
// 格式化菜单标题 // 格式化菜单标题
@ -904,9 +911,9 @@ const onBalanceMenuClick = (event: any) => {
if (key === 'recharge') { if (key === 'recharge') {
router.push({ name: 'VipRechargeDeposit' }); router.push({ name: 'VipRechargeDeposit' });
} else if (key === 'rechargeRecord') { } else if (key === 'rechargeRecord') {
router.push({ name: 'VipRechargeRechargeRecord' }); router.push({ name: 'FinanceRechargeRecord' });
} else if (key === 'accountFlow') { } else if (key === 'accountFlow') {
router.push({ name: 'UserAccountFlow' }); router.push({ name: 'FinanceAccountFlow' });
} }
}; };

View File

@ -63,8 +63,10 @@ export default {
'menu.user.subAccount': 'Sub Accounts', 'menu.user.subAccount': 'Sub Accounts',
'menu.user.profile': 'Personal Center', 'menu.user.profile': 'Personal Center',
'menu.user.recharge': 'Recharge', 'menu.user.recharge': 'Recharge',
'menu.user.rechargeRecord': 'Recharge Records', 'menu.finance': 'Finance',
'menu.user.accountFlow': 'Account Transactions', 'menu.finance.rechargeRecord': 'Recharge Records',
'menu.finance.accountFlow': 'Account Transactions',
'menu.finance.receivablePayable': 'Receivables & Payables',
'menu.arcoWebsite': 'Arco Design', 'menu.arcoWebsite': 'Arco Design',
'menu.faq': 'FAQ', 'menu.faq': 'FAQ',
'menu.example': 'Example', 'menu.example': 'Example',
@ -312,6 +314,7 @@ export default {
'order.myOrders.actions.print': 'Print', 'order.myOrders.actions.print': 'Print',
'order.myOrders.actions.export': 'Export', 'order.myOrders.actions.export': 'Export',
'order.myOrders.actions.modifyOrder': 'Edit order', 'order.myOrders.actions.modifyOrder': 'Edit order',
'order.myOrders.actions.more': 'More',
'order.myOrders.pagination.totalRecords': 'Total {count} records', 'order.myOrders.pagination.totalRecords': 'Total {count} records',
'order.myOrders.pagination.perPage': 'per page', 'order.myOrders.pagination.perPage': 'per page',
@ -1020,7 +1023,7 @@ export default {
// System - Role Management (Additional) // System - Role Management (Additional)
'system.role.modal.add': 'Add Role', 'system.role.modal.add': 'Add Role',
'system.role.modal.edit': 'Edit Role', 'system.role.modal.edit': 'Edit Role',
'system.role.modal.assignPermissions': 'Assign Permissions', 'system.role.modal.assignPermissions': 'Assign Menus',
'system.role.form.name': 'Role Name', 'system.role.form.name': 'Role Name',
'system.role.form.namePlaceholder': 'Enter role name', 'system.role.form.namePlaceholder': 'Enter role name',
'system.role.form.code': 'Role Code', 'system.role.form.code': 'Role Code',
@ -1051,6 +1054,9 @@ export default {
'system.role.permission.total': 'Total', 'system.role.permission.total': 'Total',
'system.role.permission.selectedCount': '{selected} / {total} selected', 'system.role.permission.selectedCount': '{selected} / {total} selected',
'system.role.permission.noMatch': 'No matching permissions', 'system.role.permission.noMatch': 'No matching permissions',
'system.role.permission.col.level1': 'Level 1 menu',
'system.role.permission.col.level2': 'Level 2 menu',
'system.role.permission.col.level3': 'Level 3 buttons',
'system.role.tag.creating': 'Creating', 'system.role.tag.creating': 'Creating',
'system.role.tag.boundWarehouse': 'Warehouse bound', 'system.role.tag.boundWarehouse': 'Warehouse bound',
'system.role.message.deleteSuccess': 'Delete successful', 'system.role.message.deleteSuccess': 'Delete successful',
@ -1124,6 +1130,12 @@ export default {
'system.org.message.addSuccess': 'Add successfully', 'system.org.message.addSuccess': 'Add successfully',
'system.org.message.addFailed': 'Add failed', 'system.org.message.addFailed': 'Add failed',
'system.org.message.getDetailFailed': 'Failed to get organization details', 'system.org.message.getDetailFailed': 'Failed to get organization details',
'system.org.loading': 'Loading organizations...',
'system.org.level.root': 'Level 1',
'system.org.level.n': 'Level {n}',
'system.org.level.children': '{count} sub-orgs',
'system.org.users.emptySelect': 'Select an organization',
'system.org.users.emptyDesc': 'Select an organization on the left to view its users',
// System - Position Management // System - Position Management
'system.position.search.placeholder.orgName': 'Enter organization name', 'system.position.search.placeholder.orgName': 'Enter organization name',
@ -1761,6 +1773,47 @@ export default {
'user.accountFlow.table.balanceAfter': 'Balance after', 'user.accountFlow.table.balanceAfter': 'Balance after',
'user.accountFlow.table.remark': 'Remark', 'user.accountFlow.table.remark': 'Remark',
'user.accountFlow.table.customerCode': 'Customer code', 'user.accountFlow.table.customerCode': 'Customer code',
// Finance - receivable & payable
'finance.receivablePayable.search.keywordPlaceholder': 'Service no. / client no. / track no.',
'finance.receivablePayable.search.feeTypePlaceholder': 'Fee type',
'finance.receivablePayable.search.feeTypeAll': 'All',
'finance.receivablePayable.search.costItemPlaceholder': 'Cost item',
'finance.receivablePayable.search.chargingStagePlaceholder': 'Charging stage',
'finance.receivablePayable.search.chargingStageAll': 'All',
'finance.receivablePayable.search.customerIdPlaceholder': 'Customer ID (optional)',
'finance.receivablePayable.search.dateRange': 'Order created time',
'finance.receivablePayable.search.actions.search': 'Search',
'finance.receivablePayable.search.actions.reset': 'Reset',
'finance.receivablePayable.feeType.receivable': 'Receivable',
'finance.receivablePayable.feeType.payable': 'Payable',
'finance.receivablePayable.chargingStage.forecast': 'Forecast',
'finance.receivablePayable.chargingStage.inbound': 'Inbound',
'finance.receivablePayable.chargingStage.outbound': 'Outbound',
'finance.receivablePayable.isPaid.yes': 'Paid',
'finance.receivablePayable.isPaid.no': 'Unpaid',
'finance.receivablePayable.message.loadError': 'Failed to load receivables & payables',
'finance.receivablePayable.table.serviceNo': 'Service no.',
'finance.receivablePayable.table.clientNo': 'Client no.',
'finance.receivablePayable.table.trackNo': 'Track no.',
'finance.receivablePayable.table.customerCode': 'Customer code',
'finance.receivablePayable.table.feeType': 'Fee type',
'finance.receivablePayable.table.costItem': 'Cost item',
'finance.receivablePayable.table.amount': 'Amount',
'finance.receivablePayable.table.currency': 'Currency',
'finance.receivablePayable.table.chargingStage': 'Charging stage',
'finance.receivablePayable.table.orderStatus': 'Order status',
'finance.receivablePayable.table.isPaid': 'Payment status',
'finance.receivablePayable.table.productCode': 'Product code',
'finance.receivablePayable.table.channelCode': 'Channel code',
'finance.receivablePayable.table.consigneeCountryName': 'Destination country',
'finance.receivablePayable.table.downstreamProvider': 'Downstream provider',
'finance.receivablePayable.table.priceNo': 'Price no.',
'finance.receivablePayable.table.priceEffectiveTime': 'Price effective time',
'finance.receivablePayable.table.payTime': 'Pay time',
'finance.receivablePayable.table.receiveTime': 'Receive time',
'finance.receivablePayable.table.shipTime': 'Ship time',
'user.recharge.record.message.exportError': 'Export failed', 'user.recharge.record.message.exportError': 'Export failed',
'user.recharge.record.pagination.total': '{count} total', 'user.recharge.record.pagination.total': '{count} total',
'user.recharge.record.pagination.prev': 'Prev', 'user.recharge.record.pagination.prev': 'Prev',

View File

@ -63,8 +63,10 @@ export default {
'menu.user.subAccount': '子账号', 'menu.user.subAccount': '子账号',
'menu.user.profile': '个人中心', 'menu.user.profile': '个人中心',
'menu.user.recharge': '资金充值', 'menu.user.recharge': '资金充值',
'menu.user.rechargeRecord': '充值记录', 'menu.finance': '财务',
'menu.user.accountFlow': '资金流水', 'menu.finance.rechargeRecord': '充值记录',
'menu.finance.accountFlow': '资金流水',
'menu.finance.receivablePayable': '应收应付',
'menu.arcoWebsite': 'Arco Design', 'menu.arcoWebsite': 'Arco Design',
'menu.faq': '常见问题', 'menu.faq': '常见问题',
'menu.example': '示例页', 'menu.example': '示例页',
@ -198,6 +200,7 @@ export default {
'order.myOrders.actions.print': '打印', 'order.myOrders.actions.print': '打印',
'order.myOrders.actions.export': '导出', 'order.myOrders.actions.export': '导出',
'order.myOrders.actions.modifyOrder': '修改订单', 'order.myOrders.actions.modifyOrder': '修改订单',
'order.myOrders.actions.more': '更多',
'order.myOrders.pagination.totalRecords': '共{count}条记录', 'order.myOrders.pagination.totalRecords': '共{count}条记录',
'order.myOrders.pagination.perPage': '条/页', 'order.myOrders.pagination.perPage': '条/页',
@ -906,7 +909,7 @@ export default {
// 系统 - 角色管理(补充) // 系统 - 角色管理(补充)
'system.role.modal.add': '新增角色', 'system.role.modal.add': '新增角色',
'system.role.modal.edit': '编辑角色', 'system.role.modal.edit': '编辑角色',
'system.role.modal.assignPermissions': '分配权限', 'system.role.modal.assignPermissions': '分配菜单',
'system.role.form.name': '角色名称', 'system.role.form.name': '角色名称',
'system.role.form.namePlaceholder': '请输入角色名称', 'system.role.form.namePlaceholder': '请输入角色名称',
'system.role.form.code': '角色编码', 'system.role.form.code': '角色编码',
@ -937,6 +940,9 @@ export default {
'system.role.permission.total': '总计', 'system.role.permission.total': '总计',
'system.role.permission.selectedCount': '已选 {selected} / 共 {total}', 'system.role.permission.selectedCount': '已选 {selected} / 共 {total}',
'system.role.permission.noMatch': '未找到匹配的权限', 'system.role.permission.noMatch': '未找到匹配的权限',
'system.role.permission.col.level1': '一级菜单',
'system.role.permission.col.level2': '二级菜单',
'system.role.permission.col.level3': '三级按钮',
'system.role.tag.creating': '新建中', 'system.role.tag.creating': '新建中',
'system.role.tag.boundWarehouse': '已绑仓库', 'system.role.tag.boundWarehouse': '已绑仓库',
'system.role.message.deleteSuccess': '删除成功', 'system.role.message.deleteSuccess': '删除成功',
@ -1010,6 +1016,12 @@ export default {
'system.org.message.addSuccess': '新增成功', 'system.org.message.addSuccess': '新增成功',
'system.org.message.addFailed': '新增失败', 'system.org.message.addFailed': '新增失败',
'system.org.message.getDetailFailed': '获取组织详情失败', 'system.org.message.getDetailFailed': '获取组织详情失败',
'system.org.loading': '正在加载组织...',
'system.org.level.root': '一级组织',
'system.org.level.n': '{n} 级组织',
'system.org.level.children': '{count} 个下级',
'system.org.users.emptySelect': '请选择组织',
'system.org.users.emptyDesc': '请从左侧选择组织,查看该组织下的用户列表',
// 系统 - 职位管理 // 系统 - 职位管理
'system.position.search.placeholder.orgName': '请输入组织名称', 'system.position.search.placeholder.orgName': '请输入组织名称',
@ -1780,6 +1792,47 @@ export default {
'user.accountFlow.table.balanceAfter': '变动后余额', 'user.accountFlow.table.balanceAfter': '变动后余额',
'user.accountFlow.table.remark': '备注', 'user.accountFlow.table.remark': '备注',
'user.accountFlow.table.customerCode': '客户编码', 'user.accountFlow.table.customerCode': '客户编码',
// 财务 - 应收应付
'finance.receivablePayable.search.keywordPlaceholder': '运单号 / 客户单号 / 跟踪号',
'finance.receivablePayable.search.feeTypePlaceholder': '费用类型',
'finance.receivablePayable.search.feeTypeAll': '全部',
'finance.receivablePayable.search.costItemPlaceholder': '费用项',
'finance.receivablePayable.search.chargingStagePlaceholder': '计费阶段',
'finance.receivablePayable.search.chargingStageAll': '全部',
'finance.receivablePayable.search.customerIdPlaceholder': '客户ID(选填)',
'finance.receivablePayable.search.dateRange': '订单创建时间',
'finance.receivablePayable.search.actions.search': '搜索',
'finance.receivablePayable.search.actions.reset': '重置',
'finance.receivablePayable.feeType.receivable': '应收',
'finance.receivablePayable.feeType.payable': '应付',
'finance.receivablePayable.chargingStage.forecast': '预报',
'finance.receivablePayable.chargingStage.inbound': '入库',
'finance.receivablePayable.chargingStage.outbound': '出库',
'finance.receivablePayable.isPaid.yes': '已支付',
'finance.receivablePayable.isPaid.no': '未支付',
'finance.receivablePayable.message.loadError': '加载应收应付失败',
'finance.receivablePayable.table.serviceNo': '运单号',
'finance.receivablePayable.table.clientNo': '客户单号',
'finance.receivablePayable.table.trackNo': '跟踪号',
'finance.receivablePayable.table.customerCode': '客户编码',
'finance.receivablePayable.table.feeType': '费用类型',
'finance.receivablePayable.table.costItem': '费用项',
'finance.receivablePayable.table.amount': '金额',
'finance.receivablePayable.table.currency': '币种',
'finance.receivablePayable.table.chargingStage': '计费阶段',
'finance.receivablePayable.table.orderStatus': '订单状态',
'finance.receivablePayable.table.isPaid': '支付状态',
'finance.receivablePayable.table.productCode': '产品编码',
'finance.receivablePayable.table.channelCode': '渠道编码',
'finance.receivablePayable.table.consigneeCountryName': '目的国',
'finance.receivablePayable.table.downstreamProvider': '下游服务商',
'finance.receivablePayable.table.priceNo': '价格编号',
'finance.receivablePayable.table.priceEffectiveTime': '价格生效时间',
'finance.receivablePayable.table.payTime': '支付时间',
'finance.receivablePayable.table.receiveTime': '收货时间',
'finance.receivablePayable.table.shipTime': '发货时间',
'user.recharge.record.message.exportError': '导出失败', 'user.recharge.record.message.exportError': '导出失败',
'user.recharge.record.pagination.total': '共 {count} 条', 'user.recharge.record.pagination.total': '共 {count} 条',
'user.recharge.record.pagination.prev': '上一页', 'user.recharge.record.pagination.prev': '上一页',

View File

@ -0,0 +1,48 @@
import { DEFAULT_LAYOUT } from '../base';
import { AppRouteRecordRaw } from '../types';
const FINANCE: AppRouteRecordRaw = {
path: '/finance',
name: 'Finance',
component: DEFAULT_LAYOUT,
meta: {
locale: 'menu.finance',
icon: 'icon-storage',
requiresAuth: true,
order: 6,
},
children: [
{
path: 'rechargeRecord',
name: 'FinanceRechargeRecord',
component: () => import('@/views/finance/rechargeRecord/index.vue'),
meta: {
locale: 'menu.finance.rechargeRecord',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'accountFlow',
name: 'FinanceAccountFlow',
component: () => import('@/views/finance/accountFlow/index.vue'),
meta: {
locale: 'menu.finance.accountFlow',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'receivablePayable',
name: 'FinanceReceivablePayable',
component: () => import('@/views/finance/receivablePayable/index.vue'),
meta: {
locale: 'menu.finance.receivablePayable',
requiresAuth: true,
roles: ['*'],
},
},
],
};
export default FINANCE;

View File

@ -55,26 +55,6 @@ const USER: AppRouteRecordRaw = {
hideInMenu: true, hideInMenu: true,
}, },
}, },
{
path: 'rechargeRecord',
name: 'VipRechargeRechargeRecord',
component: () => import('@/views/user/recharge/rechargeRecord.vue'),
meta: {
locale: 'menu.user.rechargeRecord',
requiresAuth: true,
roles: ['*'],
},
},
{
path: 'accountFlow',
name: 'UserAccountFlow',
component: () => import('@/views/user/accountFlow/index.vue'),
meta: {
locale: 'menu.user.accountFlow',
requiresAuth: true,
roles: ['*'],
},
},
], ],
}; };

View File

@ -61,7 +61,9 @@ export function resolveLoginRouteNameFromPath(path: string): string {
path.startsWith('/user/profile') || path.startsWith('/user/profile') ||
path.startsWith('/user/recharge') || path.startsWith('/user/recharge') ||
path.startsWith('/user/sub-account') || path.startsWith('/user/sub-account') ||
path === '/user/rechargeRecord' path.startsWith('/finance/rechargeRecord') ||
path.startsWith('/finance/accountFlow') ||
path.startsWith('/finance/receivablePayable')
) { ) {
return CUSTOMER_LOGIN_ROUTE_NAME return CUSTOMER_LOGIN_ROUTE_NAME
} }

View File

@ -0,0 +1,98 @@
import type { OrganizationTreeDto } from '@/api/rbac'
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types'
export function buildOrganizationLeftMenuTree(
data: OrganizationTreeDto[],
getTypeLabel?: (type?: number, depth?: number, childCount?: number) => string,
depth = 1
): CustomLeftMenuTreeNode[] {
return data.map((item) => {
const hasChildren = Boolean(item.children?.length)
const childCount = item.children?.length ?? 0
return {
key: `org-${item.id}`,
title: item.name || item.code || '',
isLeaf: !hasChildren,
id: item.id,
name: item.name,
code: item.code || '',
depth,
childCount,
subtitle: getTypeLabel?.(item.type, depth, childCount),
children: hasChildren
? buildOrganizationLeftMenuTree(item.children!, getTypeLabel, depth + 1)
: undefined,
}
})
}
export function findOrganizationByMenuKey(
key: string,
tree: OrganizationTreeDto[]
): OrganizationTreeDto | undefined {
if (!key.startsWith('org-')) return undefined
const id = key.slice(4)
function walk(nodes: OrganizationTreeDto[]): OrganizationTreeDto | undefined {
for (const node of nodes) {
if (node.id === id) return node
if (node.children?.length) {
const found = walk(node.children)
if (found) return found
}
}
return undefined
}
return walk(tree)
}
export function filterOrganizationMenuTree(
nodes: CustomLeftMenuTreeNode[],
keyword: string
): CustomLeftMenuTreeNode[] {
const trimmed = keyword.trim()
if (!trimmed) return nodes
const lower = trimmed.toLowerCase()
function filter(list: CustomLeftMenuTreeNode[]): CustomLeftMenuTreeNode[] {
const result: CustomLeftMenuTreeNode[] = []
list.forEach((node) => {
const children = node.children?.length ? filter(node.children) : []
const selfMatch =
node.title?.toLowerCase().includes(lower) ||
node.code?.toLowerCase().includes(lower)
if (selfMatch || children.length) {
result.push({
...node,
children: children.length ? children : selfMatch ? node.children : undefined,
})
}
})
return result
}
return filter(nodes)
}
export function collectOrganizationMenuExpandKeys(
nodes: CustomLeftMenuTreeNode[],
maxDepth = 2,
depth = 1
): string[] {
const keys: string[] = []
nodes.forEach((node) => {
if (node.children?.length && depth <= maxDepth) {
keys.push(node.key)
keys.push(...collectOrganizationMenuExpandKeys(node.children, maxDepth, depth + 1))
}
})
return keys
}
export function findFirstOrganizationMenuKey(nodes: CustomLeftMenuTreeNode[]): string | undefined {
if (!nodes.length) return undefined
const first = nodes[0]
return first.key
}

View File

@ -0,0 +1,113 @@
import type { PermissionDefinitionDto, PermissionGroupDto } from '@/api/rbac'
export interface PermissionAssignButton {
code: string
name: string
}
export interface PermissionAssignRow {
rowKey: string
level1Code: string
level1Name: string
level2Code: string
level2Name: string
/** 二级菜单下是否还有按钮级子权限 */
level2HasChildren: boolean
buttons: PermissionAssignButton[]
}
export function buildPermissionAssignRows(
groups: PermissionGroupDto[]
): PermissionAssignRow[] {
const rows: PermissionAssignRow[] = []
groups.forEach((group) => {
const level1Code = group.code || ''
const level1Name = group.name || level1Code
;(group.permissions || []).forEach((perm) => {
const row = mapPermissionToRow(perm, level1Code, level1Name)
if (row) rows.push(row)
})
})
return rows
}
function mapPermissionToRow(
perm: PermissionDefinitionDto,
level1Code: string,
level1Name: string
): PermissionAssignRow | null {
const level2Code = perm.code || ''
const level2Name = perm.name || level2Code
if (!level2Code) return null
const childButtons = (perm.children || [])
.filter((child) => child.code)
.map((child) => ({
code: child.code!,
name: child.name || child.code!,
}))
return {
rowKey: level2Code,
level1Code,
level1Name,
level2Code,
level2Name,
level2HasChildren: childButtons.length > 0,
buttons: childButtons,
}
}
export function computeLevel1RowSpans(rows: PermissionAssignRow[]): number[] {
const spans = new Array<number>(rows.length).fill(1)
let index = 0
while (index < rows.length) {
let end = index + 1
while (end < rows.length && rows[end].level1Code === rows[index].level1Code) {
end += 1
}
spans[index] = end - index
for (let i = index + 1; i < end; i += 1) {
spans[i] = 0
}
index = end
}
return spans
}
export function filterPermissionAssignRows(
rows: PermissionAssignRow[],
keyword: string
): PermissionAssignRow[] {
const trimmed = keyword.trim()
if (!trimmed) return rows
const lower = trimmed.toLowerCase()
return rows.filter((row) => {
if (row.level1Name.toLowerCase().includes(lower)) return true
if (row.level1Code.toLowerCase().includes(lower)) return true
if (row.level2Name.toLowerCase().includes(lower)) return true
if (row.level2Code.toLowerCase().includes(lower)) return true
return row.buttons.some(
(btn) =>
btn.name.toLowerCase().includes(lower) ||
btn.code.toLowerCase().includes(lower)
)
})
}
export function collectPermissionCodesFromRows(rows: PermissionAssignRow[]): string[] {
const codes = new Set<string>()
rows.forEach((row) => {
if (row.level1Code) codes.add(row.level1Code)
if (row.level2Code) codes.add(row.level2Code)
row.buttons.forEach((btn) => {
if (btn.code) codes.add(btn.code)
})
})
return Array.from(codes)
}

View File

@ -92,8 +92,14 @@ const routeNameMap: Record<string, string> = {
'/user': 'User', '/user': 'User',
'user_recharge': 'VipRechargeDeposit', 'user_recharge': 'VipRechargeDeposit',
'/user/recharge': 'VipRechargeDeposit', '/user/recharge': 'VipRechargeDeposit',
'user_recharge_record': 'VipRechargeRechargeRecord', 'finance': 'Finance',
'/user/rechargeRecord': 'VipRechargeRechargeRecord', '/finance': 'Finance',
'finance_recharge_record': 'FinanceRechargeRecord',
'/finance/rechargeRecord': 'FinanceRechargeRecord',
'finance_account_flow': 'FinanceAccountFlow',
'/finance/accountFlow': 'FinanceAccountFlow',
'finance_receivable_payable': 'FinanceReceivablePayable',
'/finance/receivablePayable': 'FinanceReceivablePayable',
// 渠道相关 // 渠道相关
'channel': 'Channel', 'channel': 'Channel',

View File

@ -91,7 +91,7 @@ import dayjs from 'dayjs'
import { getAccountFlowList } from '@/api/customer/accountFlow' import { getAccountFlowList } from '@/api/customer/accountFlow'
defineOptions({ defineOptions({
name: 'UserAccountFlow', name: 'FinanceAccountFlow',
}) })
const { t } = useI18n() const { t } = useI18n()

View File

@ -0,0 +1,299 @@
<template>
<div class="receivable-payable-page">
<div class="receivable-payable-page-card">
<div class="table-toolbar">
<div class="toolbar-left">
<a-space wrap :size="12">
<a-input
v-model="keyword"
allow-clear
:placeholder="t('finance.receivablePayable.search.keywordPlaceholder')"
style="width: 220px"
@press-enter="handleSearch"
/>
<a-select
v-model="feeTypeFilter"
:placeholder="t('finance.receivablePayable.search.feeTypePlaceholder')"
allow-clear
style="width: 140px"
>
<a-option value="all">{{ t('finance.receivablePayable.search.feeTypeAll') }}</a-option>
<a-option value="receivable">{{ t('finance.receivablePayable.feeType.receivable') }}</a-option>
<a-option value="payable">{{ t('finance.receivablePayable.feeType.payable') }}</a-option>
</a-select>
<a-input
v-model="costItem"
allow-clear
:placeholder="t('finance.receivablePayable.search.costItemPlaceholder')"
style="width: 140px"
@press-enter="handleSearch"
/>
<a-select
v-model="chargingStageFilter"
:placeholder="t('finance.receivablePayable.search.chargingStagePlaceholder')"
allow-clear
style="width: 140px"
>
<a-option value="all">{{ t('finance.receivablePayable.search.chargingStageAll') }}</a-option>
<a-option value="forecast">{{ t('finance.receivablePayable.chargingStage.forecast') }}</a-option>
<a-option value="inbound">{{ t('finance.receivablePayable.chargingStage.inbound') }}</a-option>
<a-option value="outbound">{{ t('finance.receivablePayable.chargingStage.outbound') }}</a-option>
</a-select>
<a-input-number
v-model="customerIdFilter"
:placeholder="t('finance.receivablePayable.search.customerIdPlaceholder')"
:min="1"
:precision="0"
allow-clear
hide-button
style="width: 140px"
/>
<a-range-picker
v-model="dateRange"
show-time
format="YYYY-MM-DD HH:mm:ss"
:placeholder="[
t('finance.receivablePayable.search.dateRange'),
t('finance.receivablePayable.search.dateRange'),
]"
style="width: 380px"
/>
<a-button type="primary" @click="handleSearch">
{{ t('finance.receivablePayable.search.actions.search') }}
</a-button>
<a-button @click="handleReset">
{{ t('finance.receivablePayable.search.actions.reset') }}
</a-button>
</a-space>
</div>
<div class="toolbar-right">
<div class="pagination-controls">
<PaginationPro
:total-count="totalCount"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</div>
</div>
</div>
<a-spin class="list-spin" :loading="listLoading">
<div ref="tableContainerRef" class="list-wrap">
<HandsontablePro
:columns="hotColumns"
:data="tableRows"
:total="totalCount"
:current-page="currentPage"
:page-size="pageSize"
:read-only="true"
:stretch-columns="true"
:custom-cell-renderer="customCellRenderer"
:height="handsontableHeight"
/>
</div>
</a-spin>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import dayjs from 'dayjs'
import {
getReceivablePayableList,
type ReceivablePayableRow,
} from '@/api/finance/receivablePayable'
defineOptions({
name: 'FinanceReceivablePayable',
})
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 400))
const tableRows = ref<ReceivablePayableRow[]>([])
const totalCount = ref(0)
const listLoading = ref(false)
const keyword = ref('')
const feeTypeFilter = ref('all')
const costItem = ref('')
const chargingStageFilter = ref('all')
const customerIdFilter = ref<number | undefined>(undefined)
const dateRange = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(50)
const hotColumns = computed(() => [
{ key: 'serviceNo', label: t('finance.receivablePayable.table.serviceNo'), width: 160 },
{ key: 'clientNo', label: t('finance.receivablePayable.table.clientNo'), width: 140 },
{ key: 'trackNo', label: t('finance.receivablePayable.table.trackNo'), width: 140 },
{ key: 'customerCode', label: t('finance.receivablePayable.table.customerCode'), width: 110 },
{ key: 'feeTypeName', label: t('finance.receivablePayable.table.feeType'), width: 90 },
{ key: 'costItem', label: t('finance.receivablePayable.table.costItem'), width: 120 },
{ key: 'amount', label: t('finance.receivablePayable.table.amount'), width: 100 },
{ key: 'currency', label: t('finance.receivablePayable.table.currency'), width: 80 },
{ key: 'chargingStageName', label: t('finance.receivablePayable.table.chargingStage'), width: 90 },
{ key: 'orderStatusName', label: t('finance.receivablePayable.table.orderStatus'), width: 100 },
{ key: 'isPaid', label: t('finance.receivablePayable.table.isPaid'), width: 90 },
{ key: 'productCode', label: t('finance.receivablePayable.table.productCode'), width: 120 },
{ key: 'channelCode', label: t('finance.receivablePayable.table.channelCode'), width: 120 },
{ key: 'consigneeCountryName', label: t('finance.receivablePayable.table.consigneeCountryName'), width: 100 },
{ key: 'downstreamProvider', label: t('finance.receivablePayable.table.downstreamProvider'), width: 120 },
{ key: 'priceNo', label: t('finance.receivablePayable.table.priceNo'), width: 120 },
{ key: 'priceEffectiveTime', label: t('finance.receivablePayable.table.priceEffectiveTime'), width: 170 },
{ key: 'payTime', label: t('finance.receivablePayable.table.payTime'), width: 170 },
{ key: 'receiveTime', label: t('finance.receivablePayable.table.receiveTime'), width: 170 },
{ key: 'shipTime', label: t('finance.receivablePayable.table.shipTime'), width: 170 },
])
function formatMoney(value: unknown) {
const num = Number(value)
return Number.isFinite(num) ? num.toFixed(2) : '--'
}
const customCellRenderer = (rowData: ReceivablePayableRow, colKey: string, value: any) => {
if (colKey === 'amount') {
const num = Number(value ?? 0)
const color = rowData.feeType === 'payable' ? '#f53f3f' : '#00b42a'
return `<span style="color:${color};font-weight:500;">${formatMoney(num)}</span>`
}
if (colKey === 'isPaid') {
const paid = Number(value) === 1
const text = paid
? t('finance.receivablePayable.isPaid.yes')
: t('finance.receivablePayable.isPaid.no')
const style = paid
? 'color:#00b42a;background:#e8ffea;border:1px solid #b9f0c2;'
: 'color:#4e5969;background:#f2f3f5;border:1px solid #e5e6eb;'
return `<span style="display:inline-flex;align-items:center;height:24px;padding:0 10px;border-radius:4px;font-size:12px;font-weight:500;${style}">${text}</span>`
}
return null
}
function buildListParams() {
const params: Record<string, any> = {
page: currentPage.value,
pageSize: pageSize.value,
}
const kw = keyword.value.trim()
if (kw) params.keyword = kw
if (feeTypeFilter.value && feeTypeFilter.value !== 'all') {
params.feeType = feeTypeFilter.value
}
const costItemValue = costItem.value.trim()
if (costItemValue) params.costItem = costItemValue
if (chargingStageFilter.value && chargingStageFilter.value !== 'all') {
params.chargingStage = chargingStageFilter.value
}
if (customerIdFilter.value != null && !Number.isNaN(customerIdFilter.value)) {
params.customerId = customerIdFilter.value
}
if (dateRange.value?.length === 2 && dateRange.value[0] && dateRange.value[1]) {
params.startTime = dayjs(dateRange.value[0]).format('YYYY-MM-DD HH:mm:ss')
params.endTime = dayjs(dateRange.value[1]).format('YYYY-MM-DD HH:mm:ss')
}
return params
}
async function loadList() {
listLoading.value = true
try {
const { items, total } = await getReceivablePayableList(buildListParams() as any)
totalCount.value = total
tableRows.value = items || []
} catch (e: any) {
Message.error(e?.message || t('finance.receivablePayable.message.loadError'))
tableRows.value = []
totalCount.value = 0
} finally {
listLoading.value = false
}
}
function handleSearch() {
currentPage.value = 1
loadList()
}
function handleReset() {
keyword.value = ''
feeTypeFilter.value = 'all'
costItem.value = ''
chargingStageFilter.value = 'all'
customerIdFilter.value = undefined
dateRange.value = []
currentPage.value = 1
loadList()
}
function handlePageChange(page: number) {
currentPage.value = page
loadList()
}
function handlePageSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1
loadList()
}
onMounted(() => {
loadList()
})
</script>
<style lang="less" scoped>
.receivable-payable-page {
padding: 16px;
background: #f0f3f4;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.receivable-payable-page-card {
background: #fff;
border-radius: 4px;
padding: 16px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
flex-shrink: 0;
}
.list-spin {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.list-wrap {
flex: 1;
min-height: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>

View File

@ -142,7 +142,7 @@ import {
} from '@/api/recharge/rechargeRecord' } from '@/api/recharge/rechargeRecord'
defineOptions({ defineOptions({
name: 'VipRechargeRechargeRecord', name: 'FinanceRechargeRecord',
}) })
const { t } = useI18n() const { t } = useI18n()

View File

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed, onMounted, nextTick } from 'vue'; import { ref, computed, onMounted, nextTick } from 'vue';
import { IconSettings } from '@arco-design/web-vue/es/icon'; import { IconDown } from '@arco-design/web-vue/es/icon';
import { Message, Modal } from '@arco-design/web-vue'; import { Message, Modal } from '@arco-design/web-vue';
import HandsontablePro from '@/components/HandsontablePro/index.vue'; import HandsontablePro from '@/components/HandsontablePro/index.vue';
import PaginationPro from '@/components/PaginationPro/index.vue'; import PaginationPro from '@/components/PaginationPro/index.vue';
@ -183,7 +183,31 @@
const selectedRowCount = ref(0); const selectedRowCount = ref(0);
/** 表格选中行(用于批量操作) */ /** 表格选中行(用于批量操作) */
const selectedOrderRows = ref<any[]>([]); const selectedOrderRows = ref<any[]>([]);
const actionSubmitting = ref(false); /** 当前正在执行的 toolbar 操作(仅对应按钮显示 loading,避免全部按钮联动) */
const actionLoadingKey = ref<string | null>(null);
function isActionLoading(key: string) {
return actionLoadingKey.value === key;
}
function isActionBusy(exceptKey?: string) {
const current = actionLoadingKey.value;
return current != null && current !== exceptKey;
}
async function withOrderActionLoading<T>(key: string, fn: () => Promise<T>): Promise<T> {
if (actionLoadingKey.value) {
return Promise.reject(new Error('busy'));
}
actionLoadingKey.value = key;
try {
return await fn();
} finally {
if (actionLoadingKey.value === key) {
actionLoadingKey.value = null;
}
}
}
const printConnectionState = useHiprintClientState(); const printConnectionState = useHiprintClientState();
const singlePrintVisible = ref(false); const singlePrintVisible = ref(false);
const batchPrintVisible = ref(false); const batchPrintVisible = ref(false);
@ -217,9 +241,8 @@
const handleCreateTrackOrder = async () => { const handleCreateTrackOrder = async () => {
const rows = requireSelectedRows(); const rows = requireSelectedRows();
if (!rows.length) return; if (!rows.length) return;
actionSubmitting.value = true; await withOrderActionLoading('track', async () => {
const resultBlocks: string[] = []; const resultBlocks: string[] = [];
try {
for (const row of rows) { for (const row of rows) {
const id = getOrderRowId(row)!; const id = getOrderRowId(row)!;
const waybillNo = row.waybillNo || row.serviceNo || String(id); const waybillNo = row.waybillNo || row.serviceNo || String(id);
@ -232,9 +255,7 @@
} }
trackUpsertResultText.value = resultBlocks.join('\n\n'); trackUpsertResultText.value = resultBlocks.join('\n\n');
trackUpsertModalVisible.value = true; trackUpsertModalVisible.value = true;
} finally { }).catch(() => undefined);
actionSubmitting.value = false;
}
}; };
function formatDownstreamForwardResult( function formatDownstreamForwardResult(
@ -263,9 +284,8 @@
const handleForwardDownstreamCreate = async () => { const handleForwardDownstreamCreate = async () => {
const rows = requireSelectedRows(); const rows = requireSelectedRows();
if (!rows.length) return; if (!rows.length) return;
actionSubmitting.value = true; await withOrderActionLoading('downstream', async () => {
const resultBlocks: string[] = []; const resultBlocks: string[] = [];
try {
for (const row of rows) { for (const row of rows) {
const id = getOrderRowId(row)!; const id = getOrderRowId(row)!;
const waybillNo = row.waybillNo || row.serviceNo || String(id); const waybillNo = row.waybillNo || row.serviceNo || String(id);
@ -278,9 +298,7 @@
} }
downstreamForwardResultText.value = resultBlocks.join('\n\n'); downstreamForwardResultText.value = resultBlocks.join('\n\n');
downstreamForwardModalVisible.value = true; downstreamForwardModalVisible.value = true;
} finally { }).catch(() => undefined);
actionSubmitting.value = false;
}
}; };
function getOrderRowId(row: any): number | string | undefined { function getOrderRowId(row: any): number | string | undefined {
@ -350,69 +368,73 @@
const handlePay = async () => { const handlePay = async () => {
const rows = requireSelectedRows(); const rows = requireSelectedRows();
if (!rows.length) return; if (!rows.length) return;
actionSubmitting.value = true;
try { try {
for (const row of rows) { await withOrderActionLoading('pay', async () => {
await postOrderPay({ id: getOrderRowId(row)! }); for (const row of rows) {
} await postOrderPay({ id: getOrderRowId(row)! });
Message.success('支付请求已提交'); }
notifyCustomerBalanceChanged(); Message.success('支付请求已提交');
await refreshListAndTabCounts(); notifyCustomerBalanceChanged();
await refreshListAndTabCounts();
});
} catch (e: any) { } catch (e: any) {
Message.error(e?.message || '操作失败'); if (e?.message !== 'busy') {
} finally { Message.error(e?.message || '操作失败');
actionSubmitting.value = false; }
} }
}; };
const handlePreDeclare = async () => { const handlePreDeclare = async () => {
const rows = requireSelectedRows(); const rows = requireSelectedRows();
if (!rows.length) return; if (!rows.length) return;
actionSubmitting.value = true;
try { try {
for (const row of rows) { await withOrderActionLoading('predeclare', async () => {
await postOrderPreDeclare({ id: getOrderRowId(row)! }); for (const row of rows) {
} await postOrderPreDeclare({ id: getOrderRowId(row)! });
Message.success('预报已提交'); }
await refreshListAndTabCounts(); Message.success('预报已提交');
await refreshListAndTabCounts();
});
} catch (e: any) { } catch (e: any) {
Message.error(e?.message || '操作失败'); if (e?.message !== 'busy') {
} finally { Message.error(e?.message || '操作失败');
actionSubmitting.value = false; }
} }
}; };
const handleIntercept = async () => { const handleIntercept = async () => {
const rows = requireSelectedRows(); const rows = requireSelectedRows();
if (!rows.length) return; if (!rows.length) return;
actionSubmitting.value = true;
try { try {
for (const row of rows) { await withOrderActionLoading('intercept', async () => {
await postOrderIntercept({ id: getOrderRowId(row)! }); for (const row of rows) {
} await postOrderIntercept({ id: getOrderRowId(row)! });
Message.success('拦截已提交'); }
await refreshListAndTabCounts(); Message.success('拦截已提交');
await refreshListAndTabCounts();
});
} catch (e: any) { } catch (e: any) {
Message.error(e?.message || '操作失败'); if (e?.message !== 'busy') {
} finally { Message.error(e?.message || '操作失败');
actionSubmitting.value = false; }
} }
}; };
const handleCancelIntercept = async () => { const handleCancelIntercept = async () => {
const rows = requireSelectedRows(); const rows = requireSelectedRows();
if (!rows.length) return; if (!rows.length) return;
actionSubmitting.value = true;
try { try {
for (const row of rows) { await withOrderActionLoading('cancelIntercept', async () => {
await postOrderCancelIntercept({ id: getOrderRowId(row)! }); for (const row of rows) {
} await postOrderCancelIntercept({ id: getOrderRowId(row)! });
Message.success('已取消拦截'); }
await refreshListAndTabCounts(); Message.success('已取消拦截');
await refreshListAndTabCounts();
});
} catch (e: any) { } catch (e: any) {
Message.error(e?.message || '操作失败'); if (e?.message !== 'busy') {
} finally { Message.error(e?.message || '操作失败');
actionSubmitting.value = false; }
} }
}; };
@ -428,18 +450,19 @@
title: '确认删除', title: '确认删除',
content: `确定删除选中的 ${rows.length} 条订单吗?`, content: `确定删除选中的 ${rows.length} 条订单吗?`,
onOk: async () => { onOk: async () => {
actionSubmitting.value = true;
try { try {
for (const row of rows) { await withOrderActionLoading('delete', async () => {
await postOrderDelete({ id: getOrderRowId(row)! }); for (const row of rows) {
} await postOrderDelete({ id: getOrderRowId(row)! });
Message.success('已删除'); }
await refreshListAndTabCounts(); Message.success('已删除');
await refreshListAndTabCounts();
});
} catch (e: any) { } catch (e: any) {
Message.error(e?.message || '删除失败'); if (e?.message !== 'busy') {
Message.error(e?.message || '删除失败');
}
throw e; throw e;
} finally {
actionSubmitting.value = false;
} }
}, },
}); });
@ -471,21 +494,22 @@
Message.warning('请先在顶部打开并连接打印开关'); Message.warning('请先在顶部打开并连接打印开关');
return; return;
} }
actionSubmitting.value = true;
try { try {
const row = rows[0]; await withOrderActionLoading('singlePrint', async () => {
const { printUrl } = await postOrderPrint({ id: getOrderRowId(row)! }); const row = rows[0];
if (!isUsablePrintUrl(printUrl)) { const { printUrl } = await postOrderPrint({ id: getOrderRowId(row)! });
Message.info('未获取到打印文件,已打开打印弹窗供预览布局'); if (!isUsablePrintUrl(printUrl)) {
Message.info('未获取到打印文件,已打开打印弹窗供预览布局');
singlePrintVisible.value = true;
return;
}
singlePrintItems.value = [buildPrintItem(row, printUrl)];
singlePrintVisible.value = true; singlePrintVisible.value = true;
return; });
}
singlePrintItems.value = [buildPrintItem(row, printUrl)];
singlePrintVisible.value = true;
} catch (e: any) { } catch (e: any) {
Message.error(e?.message || '打印失败'); if (e?.message !== 'busy') {
} finally { Message.error(e?.message || '打印失败');
actionSubmitting.value = false; }
} }
}; };
@ -498,45 +522,47 @@
Message.warning('请先在顶部打开并连接打印开关'); Message.warning('请先在顶部打开并连接打印开关');
return; return;
} }
actionSubmitting.value = true;
try { try {
const items: Array<{ id: number | string; label: string; url: string }> = []; await withOrderActionLoading('batchPrint', async () => {
for (const row of rows) { const items: Array<{ id: number | string; label: string; url: string }> = [];
const { printUrl } = await postOrderPrint({ id: getOrderRowId(row)! }); for (const row of rows) {
if (isUsablePrintUrl(printUrl)) { const { printUrl } = await postOrderPrint({ id: getOrderRowId(row)! });
items.push(buildPrintItem(row, printUrl)); if (isUsablePrintUrl(printUrl)) {
items.push(buildPrintItem(row, printUrl));
}
} }
} if (!items.length) {
if (!items.length) { Message.info('未获取到可打印文件,已打开打印弹窗供预览布局');
Message.info('未获取到可打印文件,已打开打印弹窗供预览布局'); batchPrintVisible.value = true;
return;
}
batchPrintItems.value = items;
batchPrintVisible.value = true; batchPrintVisible.value = true;
return; });
}
batchPrintItems.value = items;
batchPrintVisible.value = true;
} catch (e: any) { } catch (e: any) {
Message.error(e?.message || '批量打印失败'); if (e?.message !== 'busy') {
} finally { Message.error(e?.message || '批量打印失败');
actionSubmitting.value = false; }
} }
}; };
const handleExport = async () => { const handleExport = async () => {
actionSubmitting.value = true;
try { try {
const status = activeTab.value === ORDER_STATUS_ALL ? ORDER_STATUS_ALL : activeTab.value; await withOrderActionLoading('export', async () => {
const { downloadUrl } = await postOrderExport({ const status = activeTab.value === ORDER_STATUS_ALL ? ORDER_STATUS_ALL : activeTab.value;
status, const { downloadUrl } = await postOrderExport({
keyword: '', status,
createTimeStart: '', keyword: '',
createTimeEnd: '', createTimeStart: '',
createTimeEnd: '',
});
if (downloadUrl) window.open(downloadUrl, '_blank');
Message.success(downloadUrl ? '导出链接已打开' : '导出已提交');
}); });
if (downloadUrl) window.open(downloadUrl, '_blank');
Message.success(downloadUrl ? '导出链接已打开' : '导出已提交');
} catch (e: any) { } catch (e: any) {
Message.error(e?.message || '导出失败'); if (e?.message !== 'busy') {
} finally { Message.error(e?.message || '导出失败');
actionSubmitting.value = false; }
} }
}; };
@ -675,15 +701,46 @@
</div> </div>
<div class="actions-right"> <div class="actions-right">
<a-button class="btn-pay" :loading="actionSubmitting" @click="handlePay">{{ t('order.myOrders.actions.pay') }}</a-button> <a-button
<a-button class="btn-predeclare" :loading="actionSubmitting" @click="handlePreDeclare">{{ t('order.myOrders.actions.predeclare') }}</a-button> class="btn-pay"
<a-button class="btn-track" :loading="actionSubmitting" @click="handleCreateTrackOrder">{{ t('order.myOrders.actions.createTrackOrder') }}</a-button> :loading="isActionLoading('pay')"
<a-button class="btn-downstream" :loading="actionSubmitting" @click="handleForwardDownstreamCreate">{{ t('order.myOrders.actions.forwardDownstreamCreate') }}</a-button> :disabled="isActionBusy('pay')"
<a-button class="btn-print" :loading="actionSubmitting" @click="handleSinglePrint">单个打印</a-button> @click="handlePay"
<a-button class="btn-print" :loading="actionSubmitting" @click="handleBatchPrint">批量打印</a-button> >{{ t('order.myOrders.actions.pay') }}</a-button>
<a-dropdown trigger="click" position="bl"> <a-button
<a-button class="btn-settings"> type="primary"
<icon-settings /> :loading="isActionLoading('predeclare')"
:disabled="isActionBusy('predeclare')"
@click="handlePreDeclare"
>{{ t('order.myOrders.actions.predeclare') }}</a-button>
<a-button
class="btn-track"
:loading="isActionLoading('track')"
:disabled="isActionBusy('track')"
@click="handleCreateTrackOrder"
>{{ t('order.myOrders.actions.createTrackOrder') }}</a-button>
<a-button
class="btn-downstream"
:loading="isActionLoading('downstream')"
:disabled="isActionBusy('downstream')"
@click="handleForwardDownstreamCreate"
>{{ t('order.myOrders.actions.forwardDownstreamCreate') }}</a-button>
<a-button
type="primary"
:loading="isActionLoading('singlePrint')"
:disabled="isActionBusy('singlePrint')"
@click="handleSinglePrint"
>单个打印</a-button>
<a-button
type="primary"
:loading="isActionLoading('batchPrint')"
:disabled="isActionBusy('batchPrint')"
@click="handleBatchPrint"
>批量打印</a-button>
<a-dropdown trigger="click" position="bl" :disabled="!!actionLoadingKey">
<a-button type="text" class="btn-more" :disabled="!!actionLoadingKey">
{{ t('order.myOrders.actions.more') }}
<icon-down class="btn-more-arrow" />
</a-button> </a-button>
<template #content> <template #content>
<a-doption @click="onMoreActionClick('intercept')">{{ t('order.myOrders.actions.intercept') }}</a-doption> <a-doption @click="onMoreActionClick('intercept')">{{ t('order.myOrders.actions.intercept') }}</a-doption>
@ -850,204 +907,78 @@
.actions-right { .actions-right {
display: flex; display: flex;
flex-wrap: wrap;
gap: var(--app-button-gap, 8px); gap: var(--app-button-gap, 8px);
align-items: center; align-items: center;
padding: 16px 0; padding: 12px 0;
flex-shrink: 0;
.arco-btn { :deep(.arco-btn) {
height: 32px;
padding: 0 12px;
border-radius: 4px; border-radius: 4px;
font-weight: 500; font-size: 14px;
transition: all 0.3s ease; font-weight: 400;
border: 2px solid; line-height: 30px;
position: relative; }
overflow: hidden;
padding: 8px 16px;
height: auto;
// 支付按钮 - 淡黄色 :deep(.arco-btn.btn-pay) {
&.btn-pay { color: #d46b08;
background: #fffbe6; background: #fffbe6;
border-color: #ffd666; border-color: #ffd666;
&:hover:not(:disabled) {
color: #d46b08; color: #d46b08;
background: #fff7d1;
border-color: #ffc53d;
}
}
&:hover:not(:disabled) { :deep(.arco-btn.btn-track),
background: #fff7d1; :deep(.arco-btn.btn-downstream) {
border-color: #ffc53d; color: #fff;
} background: #000;
border-color: #000;
&:active:not(:disabled) { &:hover:not(:disabled) {
background: #fff1b8; color: #fff;
border-color: #ffb020; background: #262626;
} border-color: #262626;
} }
// 预报按钮 - 主题色 &:active:not(:disabled) {
&.btn-predeclare { color: #fff;
background: var(--app-primary-color); background: #141414;
border-color: var(--app-primary-color); border-color: #141414;
color: #ffffff; }
}
&:hover:not(:disabled) { :deep(.arco-btn.btn-more) {
background: var(--app-primary-color-hover); height: 32px;
border-color: var(--app-primary-color-hover); padding: 0 4px;
} color: #4e5969;
background: transparent !important;
border: none !important;
box-shadow: none !important;
display: inline-flex;
align-items: center;
gap: 2px;
&:active:not(:disabled) { .btn-more-arrow {
background: var(--app-primary-color-hover); font-size: 12px;
border-color: var(--app-primary-color-hover); margin-left: 2px;
} transition: transform 0.2s ease;
} }
&.btn-track { &:hover:not(:disabled) {
background: #fff;
border-color: var(--app-primary-color);
color: var(--app-primary-color); color: var(--app-primary-color);
background: transparent !important;
&:hover:not(:disabled) { border: none !important;
background: rgba(var(--primary-6), 0.06);
}
} }
&.btn-downstream { &:active:not(:disabled) {
background: #f6ffed; color: var(--app-primary-color-hover);
border-color: #95de64; background: transparent !important;
color: #389e0d;
&:hover:not(:disabled) {
background: #d9f7be;
}
}
// 拦截按钮 - 主题色
&.btn-intercept {
background: var(--app-primary-color);
border-color: var(--app-primary-color);
color: #ffffff;
&:hover:not(:disabled) {
background: var(--app-primary-color-hover);
border-color: var(--app-primary-color-hover);
}
&:active:not(:disabled) {
background: var(--app-primary-color-hover);
border-color: var(--app-primary-color-hover);
}
}
// 取消拦截按钮 - 主题色
&.btn-cancel-intercept {
background: var(--app-primary-color);
border-color: var(--app-primary-color);
color: #ffffff;
&:hover:not(:disabled) {
background: var(--app-primary-color-hover);
border-color: var(--app-primary-color-hover);
}
&:active:not(:disabled) {
background: var(--app-primary-color-hover);
border-color: var(--app-primary-color-hover);
}
}
// 删除按钮 - 主题色
&.btn-delete {
background: var(--app-primary-color);
border-color: var(--app-primary-color);
color: #ffffff;
&:hover:not(:disabled) {
background: var(--app-primary-color-hover);
border-color: var(--app-primary-color-hover);
}
&:active:not(:disabled) {
background: var(--app-primary-color-hover);
border-color: var(--app-primary-color-hover);
}
}
// 打印按钮 - 主题色
&.btn-print {
background: var(--app-primary-color);
border-color: var(--app-primary-color);
color: #ffffff;
&:hover:not(:disabled) {
background: var(--app-primary-color-hover);
border-color: var(--app-primary-color-hover);
}
&:active:not(:disabled) {
background: var(--app-primary-color-hover);
border-color: var(--app-primary-color-hover);
}
}
// 导出按钮 - 主题色
&.btn-export {
background: var(--app-primary-color);
border-color: var(--app-primary-color);
color: #ffffff;
&:hover:not(:disabled) {
background: var(--app-primary-color-hover);
border-color: var(--app-primary-color-hover);
}
&:active:not(:disabled) {
background: var(--app-primary-color-hover);
border-color: var(--app-primary-color-hover);
}
}
// 修改订单按钮 - 主题色
&.btn-modify {
background: var(--app-primary-color);
border-color: var(--app-primary-color);
color: #ffffff;
&:hover:not(:disabled) {
background: var(--app-primary-color-hover);
border-color: var(--app-primary-color-hover);
}
&:active:not(:disabled) {
background: var(--app-primary-color-hover);
border-color: var(--app-primary-color-hover);
}
}
// 设置按钮 - 主题色
&.btn-settings {
background: var(--app-primary-color);
border-color: var(--app-primary-color);
color: #ffffff;
padding: 8px 12px;
min-width: 36px;
&:hover:not(:disabled) {
background: var(--app-primary-color-hover);
border-color: var(--app-primary-color-hover);
}
&:active:not(:disabled) {
background: var(--app-primary-color-hover);
border-color: var(--app-primary-color-hover);
}
}
// 禁用状态
&:disabled {
opacity: 1;
cursor: not-allowed;
background: rgba(169, 169, 169, 0.8) !important;
border-color: rgba(169, 169, 169, 0.8) !important;
color: #ffffff !important;
transform: none !important;
box-shadow: none !important;
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -108,8 +108,6 @@
allow-clear allow-clear
style="width: 220px" style="width: 220px"
/> />
<a-button size="small" @click="expandAllPermissions">{{ t('system.role.permission.expandAll') }}</a-button>
<a-button size="small" @click="collapseAllPermissions">{{ t('system.role.permission.collapseAll') }}</a-button>
<a-button size="small" @click="checkAllPermissions">{{ t('system.role.permission.checkAll') }}</a-button> <a-button size="small" @click="checkAllPermissions">{{ t('system.role.permission.checkAll') }}</a-button>
<a-button size="small" @click="uncheckAllPermissions">{{ t('system.role.permission.uncheckAll') }}</a-button> <a-button size="small" @click="uncheckAllPermissions">{{ t('system.role.permission.uncheckAll') }}</a-button>
</a-space> </a-space>
@ -117,16 +115,66 @@
{{ t('system.role.permission.selectedCount', { selected: selectedPermissions.length, total: totalPermissionCount }) }} {{ t('system.role.permission.selectedCount', { selected: selectedPermissions.length, total: totalPermissionCount }) }}
</span> </span>
</div> </div>
<div class="section-card permission-tree-card"> <div class="section-card permission-list-card">
<a-tree <a-table
v-if="filteredPermissionTreeData.length > 0" v-if="filteredPermissionRows.length > 0"
v-model:checked-keys="selectedPermissions" :data="filteredPermissionRows"
v-model:expanded-keys="permissionExpandedKeys" :pagination="false"
checkable :bordered="{ cell: true }"
:data="filteredPermissionTreeData" :span-method="permissionSpanMethod"
:field-names="{ title: 'name', key: 'code', children: 'children' }" row-key="rowKey"
check-strictly size="small"
/> :scroll="{ y: permissionTableScrollY }"
>
<template #columns>
<a-table-column
:title="t('system.role.permission.col.level1')"
data-index="level1Name"
:width="180"
>
<template #cell="{ record }">
<a-checkbox
:model-value="isPermissionChecked(record.level1Code)"
@change="(checked) => togglePermission(record.level1Code, checked)"
>
{{ record.level1Name }}
</a-checkbox>
</template>
</a-table-column>
<a-table-column
:title="t('system.role.permission.col.level2')"
data-index="level2Name"
:width="200"
>
<template #cell="{ record }">
<a-checkbox
:model-value="isPermissionChecked(record.level2Code)"
@change="(checked) => togglePermission(record.level2Code, checked)"
>
{{ record.level2Name }}
</a-checkbox>
</template>
</a-table-column>
<a-table-column :title="t('system.role.permission.col.level3')">
<template #cell="{ record }">
<a-checkbox-group
v-if="record.level2HasChildren"
v-model="selectedPermissions"
class="permission-button-group"
>
<a-checkbox
v-for="btn in record.buttons"
:key="btn.code"
:value="btn.code"
>
{{ btn.name }}
</a-checkbox>
</a-checkbox-group>
<span v-else class="permission-no-buttons">—</span>
</template>
</a-table-column>
</template>
</a-table>
<div v-else class="permission-empty"> <div v-else class="permission-empty">
{{ permissionKeyword ? t('system.role.permission.noMatch') : t('system.role.permission.loading') }} {{ permissionKeyword ? t('system.role.permission.noMatch') : t('system.role.permission.loading') }}
</div> </div>
@ -151,12 +199,17 @@ import type {
RoleCreateDto, RoleCreateDto,
RoleUpdateDto, RoleUpdateDto,
PermissionGroupDto, PermissionGroupDto,
PermissionDefinitionDto,
} from '@/api/rbac' } from '@/api/rbac'
import { useWarehouseList } from '@/composables/useWarehouseList' import { useWarehouseList } from '@/composables/useWarehouseList'
import { hasPermission } from '@/utils/rbac-permission' import { hasPermission } from '@/utils/rbac-permission'
import { handleApiError } from '@/utils/rbac-error-handler' import { handleApiError } from '@/utils/rbac-error-handler'
import { buildFlatLeftMenuTree, findLeftMenuItemByKey } from '@/utils/rbacLeftMenuTree' import { buildFlatLeftMenuTree, findLeftMenuItemByKey } from '@/utils/rbacLeftMenuTree'
import {
buildPermissionAssignRows,
collectPermissionCodesFromRows,
computeLevel1RowSpans,
filterPermissionAssignRows,
} from '@/utils/rbacPermissionAssignRows'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const { t } = useI18n() const { t } = useI18n()
@ -192,7 +245,7 @@ const permissionLoading = ref(false)
const permissionSaving = ref(false) const permissionSaving = ref(false)
const permissionTree = ref<PermissionGroupDto[]>([]) const permissionTree = ref<PermissionGroupDto[]>([])
const selectedPermissions = ref<string[]>([]) const selectedPermissions = ref<string[]>([])
const permissionExpandedKeys = ref<string[]>([]) const permissionTableScrollY = 'calc(100vh - 340px)'
const internalRoles = computed(() => const internalRoles = computed(() =>
dataSource.value.filter((role) => { dataSource.value.filter((role) => {
@ -212,24 +265,16 @@ const selectedKeys = computed(() =>
selectedRole.value?.id ? [`item-${selectedRole.value.id}`] : [] selectedRole.value?.id ? [`item-${selectedRole.value.id}`] : []
) )
const permissionTreeData = computed(() => { const permissionRows = computed(() => buildPermissionAssignRows(permissionTree.value))
const treeData: PermissionDefinitionDto[] = []
permissionTree.value.forEach((group: PermissionGroupDto) => {
if (group.permissions?.length) {
treeData.push({
code: group.code || '',
name: group.name || '',
children: group.permissions || [],
})
}
})
return treeData
})
const totalPermissionCount = computed(() => countLeafCodes(permissionTreeData.value)) const filteredPermissionRows = computed(() =>
filterPermissionAssignRows(permissionRows.value, permissionKeyword.value)
)
const filteredPermissionTreeData = computed(() => const level1RowSpans = computed(() => computeLevel1RowSpans(filteredPermissionRows.value))
filterPermissionTree(permissionTreeData.value, permissionKeyword.value.trim())
const totalPermissionCount = computed(() =>
collectPermissionCodesFromRows(permissionRows.value).length
) )
const roleTags = computed((): RbacDetailTag[] => { const roleTags = computed((): RbacDetailTag[] => {
@ -261,38 +306,30 @@ const roleTabs = computed(() => {
return tabs return tabs
}) })
function countLeafCodes(nodes: PermissionDefinitionDto[]): number { function permissionSpanMethod({
let count = 0 rowIndex,
nodes.forEach((node) => { columnIndex,
if (node.children?.length) { }: {
count += countLeafCodes(node.children) rowIndex: number
} else if (node.code) { columnIndex: number
count += 1 }) {
} if (columnIndex !== 0) return { rowspan: 1, colspan: 1 }
}) const rowspan = level1RowSpans.value[rowIndex] ?? 1
return count if (rowspan === 0) return { rowspan: 0, colspan: 0 }
return { rowspan, colspan: 1 }
} }
function filterPermissionTree( function isPermissionChecked(code: string) {
nodes: PermissionDefinitionDto[], return selectedPermissions.value.includes(code)
keyword: string }
): PermissionDefinitionDto[] {
if (!keyword) return nodes function togglePermission(code: string, checked: boolean | (string | number | boolean)) {
const lower = keyword.toLowerCase() if (!code) return
const result: PermissionDefinitionDto[] = [] const enabled = checked === true
nodes.forEach((node) => { const next = new Set(selectedPermissions.value)
const children = node.children?.length ? filterPermissionTree(node.children, keyword) : [] if (enabled) next.add(code)
const selfMatch = else next.delete(code)
(node.name || '').toLowerCase().includes(lower) || selectedPermissions.value = Array.from(next)
(node.code || '').toLowerCase().includes(lower)
if (selfMatch || children.length) {
result.push({
...node,
children: children.length ? children : selfMatch ? node.children : undefined,
})
}
})
return result
} }
function formatTime(value?: string | null) { function formatTime(value?: string | null) {
@ -365,7 +402,6 @@ async function loadRolePermissions(roleId: string) {
try { try {
await loadPermissionTree() await loadPermissionTree()
selectedPermissions.value = (await RoleService.getRbacRoleGetRolePermissions(roleId)) || [] selectedPermissions.value = (await RoleService.getRbacRoleGetRolePermissions(roleId)) || []
permissionExpandedKeys.value = expandAllCodes(permissionTreeData.value)
} catch (error) { } catch (error) {
handleApiError(error) handleApiError(error)
} finally { } finally {
@ -373,13 +409,12 @@ async function loadRolePermissions(roleId: string) {
} }
} }
function expandAllCodes(nodes: PermissionDefinitionDto[]): string[] { function checkAllPermissions() {
const keys: string[] = [] selectedPermissions.value = collectPermissionCodesFromRows(permissionRows.value)
nodes.forEach((node) => { }
if (node.code) keys.push(node.code)
if (node.children?.length) keys.push(...expandAllCodes(node.children)) function uncheckAllPermissions() {
}) selectedPermissions.value = []
return keys
} }
function handleSearch() { function handleSearch() {
@ -466,22 +501,6 @@ async function handleSavePermissions() {
} }
} }
function expandAllPermissions() {
permissionExpandedKeys.value = expandAllCodes(filteredPermissionTreeData.value)
}
function collapseAllPermissions() {
permissionExpandedKeys.value = []
}
function checkAllPermissions() {
selectedPermissions.value = expandAllCodes(permissionTreeData.value)
}
function uncheckAllPermissions() {
selectedPermissions.value = []
}
watch( watch(
() => selectedRole.value?.id, () => selectedRole.value?.id,
async (roleId) => { async (roleId) => {
@ -494,12 +513,6 @@ watch(
} }
) )
watch(permissionKeyword, (kw) => {
if (kw.trim() && activeTab.value === 'permissions') {
permissionExpandedKeys.value = expandAllCodes(filteredPermissionTreeData.value)
}
})
onMounted(async () => { onMounted(async () => {
await loadData() await loadData()
await loadPermissionTree() await loadPermissionTree()
@ -519,10 +532,31 @@ onMounted(async () => {
gap: 0; gap: 0;
} }
.permission-tree-card { .permission-list-card {
min-height: 320px; min-height: 320px;
max-height: calc(100vh - 340px); }
overflow: auto;
.permission-button-group {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
padding: 4px 0;
}
.permission-no-buttons {
color: var(--color-text-4);
}
.permission-list-card :deep(.arco-checkbox) {
align-items: flex-start;
}
.permission-list-card :deep(.arco-table-th) {
background: #fafafa;
}
.permission-list-card :deep(.arco-table-td) {
vertical-align: top;
} }
.perm-summary { .perm-summary {

View File

@ -264,7 +264,7 @@ function goRecharge() {
} }
function goRechargeRecord() { function goRechargeRecord() {
router.push({ name: 'VipRechargeRechargeRecord' }) router.push({ name: 'FinanceRechargeRecord' })
} }
function goMyOrders() { function goMyOrders() {

View File

@ -280,7 +280,7 @@ const reset = () => {
}; };
const goToRecord = () => { const goToRecord = () => {
router.push({ name: 'VipRechargeRechargeRecord' }); router.push({ name: 'FinanceRechargeRecord' });
}; };
</script> </script>

View File

@ -100,6 +100,26 @@ export default defineConfig(({ mode }) => {
target: 'http://api.nanafa.com/admin', target: 'http://api.nanafa.com/admin',
changeOrigin: true, changeOrigin: true,
secure: false, secure: false,
bypass(req) {
const path = req.url?.split('?')[0] ?? '';
const systemSpaExact = ['/system/dict', '/system/warehouseAddress', '/system/announcement'];
if (systemSpaExact.some((p) => path === p || path === `${p}/`)) {
return '/index.html';
}
const systemSpaPrefixes = [
'/system/users',
'/system/roles',
'/system/menus',
'/system/organizations',
'/system/batchEmail',
'/system/otherConfig',
'/system/xhsAiAssistant',
'/system/dataPerm',
];
if (systemSpaPrefixes.some((p) => path === p || path.startsWith(`${p}/`))) {
return '/index.html';
}
},
}, },
// 小红书 OMS-Xhs 接口(开发默认走本机 Admin API,与桌面 Agent 一致) // 小红书 OMS-Xhs 接口(开发默认走本机 Admin API,与桌面 Agent 一致)
'/xhs': { '/xhs': {
@ -265,9 +285,14 @@ export default defineConfig(({ mode }) => {
'/system/organizations', '/system/organizations',
'/system/batchEmail', '/system/batchEmail',
'/system/xhsAiAssistant', '/system/xhsAiAssistant',
'/system/otherConfig',
'/system/dataPerm',
// User 相关前端路由 // User 相关前端路由
'/user/recharge', '/user/recharge',
'/user/rechargeRecord', // Finance 相关前端路由
'/finance/rechargeRecord',
'/finance/accountFlow',
'/finance/receivablePayable',
]; ];
// 直接在 configureServer 中设置中间件,确保在代理之前执行 // 直接在 configureServer 中设置中间件,确保在代理之前执行