重构配送点独立资源页面
This commit is contained in:
56
docs/操作日志_配送点独立资源页面_20260822.md
Normal file
56
docs/操作日志_配送点独立资源页面_20260822.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 操作日志:配送点独立资源页面
|
||||
|
||||
操作时间:2026-08-22 20:18:39
|
||||
操作类型:扩展、修改
|
||||
影响模块:配送点管理系统标准资源列表、独立记录页、前端路由与资源契约
|
||||
|
||||
## 操作前状态
|
||||
|
||||
配送点标准资源的新建、详情和编辑均在 `CrudListPage.vue` 抽屉中完成,没有稳定的记录 URL;支付和退款前端资源名与配送点后端实际路径不一致。
|
||||
|
||||
## 具体操作
|
||||
|
||||
1. 新增基于资源能力的路由生成器,为非单例资源生成详情、新建和编辑独立路由。
|
||||
2. 新增共享记录页、详情内容、字段表单、业务动作对话框和未保存离开保护。
|
||||
3. 新增标准列表页,将新建、详情和编辑入口改为路由跳转,并移除已停用的抽屉页面。
|
||||
4. 为可编辑资源增加后端更新字段白名单,保留受控合同编辑限制和危险操作二次确认。
|
||||
5. 将配送端支付、退款资源统一为 `payment_order`、`payment_refund`,同步后端契约快照。
|
||||
6. 增加页面能力矩阵自动检查,并更新项目文档。
|
||||
7. 依据 5173 页面重新对齐灰色页面底、面包屑、分区卡片、详情信息网格、双列表单和底部操作区。
|
||||
|
||||
## 操作后状态
|
||||
|
||||
17 类资源具备独立详情页,9 类资源具备独立新建页,5 类资源具备独立编辑页。列表查询来源可以恢复,新建和编辑保存后进入详情,未保存离开会提示确认。配送点资料仍使用专用只读卡片。
|
||||
|
||||
## 代码变更
|
||||
|
||||
- 新增:`src/views/resource/` 下 5 个记录页组件与组合函数。
|
||||
- 新增:`src/views/shared/ResourceListPage.vue`。
|
||||
- 新增:`src/router/routes/modules/resource-route-builder.ts`。
|
||||
- 新增:`src/api/resource-display.ts`、`resource-navigation.ts`、`resource-record-form.ts`。
|
||||
- 修改:`platform.ts`、`ResourcePage.vue`、`resources.ts`、路由类型、资源契约和包脚本。
|
||||
- 删除:`src/views/shared/CrudListPage.vue`(已无活动引用,可由 Git 恢复)。
|
||||
|
||||
## 行为变化
|
||||
|
||||
- 变更前:列表抽屉承载记录操作,浏览器地址不随记录变化。
|
||||
- 变更后:每个允许的操作拥有独立 URL,详情页集中承载业务动作。
|
||||
- 兼容性:现有列表菜单地址、后端接口和配送点数据范围保持不变。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- `resource-pages:check`:通过,详情 17、新建 9、编辑 5。
|
||||
- `contract:check`:通过,18 个资源与后端契约一致。
|
||||
- `profile:check`:通过,资料专用只读页未回退。
|
||||
- `type:check`:通过。
|
||||
- `build`:通过,2622 个模块完成生产构建。
|
||||
- `go test ./internal/logic/delivery`:通过。
|
||||
- `lint`:通过;仅报告项目既有警告,未产生失败项。
|
||||
- 浏览器回归:工作人员列表、详情、编辑、正式新建地址、返回链路、未保存保护、配送订单新建页、支付列表均通过。
|
||||
- 布局回归:工作人员详情、编辑和配送订单新建页已在应用内浏览器逐页截图检查,与 5173 的页面结构和响应式断点一致。
|
||||
|
||||
## 风险评估
|
||||
|
||||
- 风险:后端新增可编辑字段后,前端白名单可能未同步。缓解:维护指南要求同步白名单并执行页面契约检查。
|
||||
- 风险:旧书签若直接指向抽屉状态无法恢复。缓解:原列表 URL 保持不变,新的记录 URL 可稳定收藏。
|
||||
- 风险:支付和退款名称修正可能影响依赖旧错误名称的自定义代码。缓解:仅配送端定义改用后端正式名称,菜单地址不变,并由契约检查持续保护。
|
||||
76
docs/项目文档_配送点独立资源页面_v1.0.md
Normal file
76
docs/项目文档_配送点独立资源页面_v1.0.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# 项目文档:配送点独立资源页面 v1.0
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
本次将配送点管理系统中标准资源的新建、详情和编辑操作从列表抽屉迁移为独立 URL 页面。页面框架参考平台总后台的共享记录页模式,同时严格使用配送点 JWT 数据范围和配送点后端现有接口。
|
||||
|
||||
技术栈为 Vue 3、TypeScript、Vue Router、Arco Design Vue、Vite。运行环境要求 Node.js 20.19.0 及以上,前端默认访问地址为 `http://localhost:5176`。
|
||||
|
||||
## 2. 页面能力范围
|
||||
|
||||
- 17 类非单例资源提供独立详情页。
|
||||
- 9 类具有后端 POST 接口的资源提供独立新建页。
|
||||
- 5 类具有后端 PUT 接口的资源提供独立编辑页。
|
||||
- 配送点资料继续使用专用只读卡片,不生成记录子路由。
|
||||
- `/staff/add` 和 `/gasorder/create` 保留为正式新建地址,其余资源使用列表路径下的 `/new`。
|
||||
- 只读资源仅生成详情页;追加型资源生成新建和详情页,不生成编辑页。
|
||||
|
||||
## 3. 目录结构
|
||||
|
||||
```text
|
||||
frontend/delivery_admin/
|
||||
├── scripts/
|
||||
│ └── check-resource-pages.mjs # 校验页面矩阵与后端 CRUD 契约
|
||||
└── src/
|
||||
├── api/
|
||||
│ ├── resource-display.ts # 中文字段、状态和详情值展示
|
||||
│ ├── resource-navigation.ts # 独立页面路由和安全返回地址
|
||||
│ └── resource-record-form.ts # 表单初始化、字段白名单和校验
|
||||
├── router/routes/modules/
|
||||
│ └── resource-route-builder.ts # 根据资源能力生成独立路由
|
||||
├── views/resource/
|
||||
│ ├── ResourceRecordPage.vue # 新建、详情、编辑三态容器
|
||||
│ ├── ResourceDetailContent.vue # 主记录和集合详情展示
|
||||
│ ├── ResourceFieldForm.vue # 通用资源字段表单
|
||||
│ ├── ResourceActionDialog.vue # 详情业务动作与危险确认
|
||||
│ └── use-unsaved-record.ts # 未保存离开保护
|
||||
└── views/shared/
|
||||
└── ResourceListPage.vue # 跳转独立页面的标准列表
|
||||
```
|
||||
|
||||
## 4. 核心实现
|
||||
|
||||
`resource-route-builder.ts` 根据资源定义中的 `canCreate`、`canEdit` 和页面类型生成隐藏子路由。每条记录拥有稳定 URL,列表查询参数通过 `return_to` 保存,详情返回后可恢复来源列表状态。
|
||||
|
||||
`ResourceRecordPage.vue` 通过路由元数据 `recordMode` 在新建、详情、编辑三态间切换。新建成功进入详情,编辑成功返回详情;未保存表单离开时弹出确认。合同等受控资源的业务动作位于详情页,危险操作继续二次确认。
|
||||
|
||||
页面布局与平台总后台保持一致:使用灰色页面背景、顶部面包屑和操作栏;详情由“基本信息”“关联记录”“业务操作”分区卡片组成;新建和编辑使用双列响应式表单,并将保存、取消操作固定在表单内容底部。
|
||||
|
||||
`resource-record-form.ts` 为五类可编辑资源声明后端更新字段白名单,防止用户名、合同编号等只读字段出现在编辑页或被无效提交。
|
||||
|
||||
支付与退款资源统一使用后端正式名称 `payment_order`、`payment_refund`,菜单地址仍保持 `/finance/payments`、`/finance/refunds`,避免接口路径不一致导致 404。
|
||||
|
||||
## 5. 维护指南
|
||||
|
||||
新增标准资源时,先同步后端契约,再在 `resources.ts` 中配置资源字段和能力。若后端支持编辑,必须同步维护 `resource-record-form.ts` 的更新字段白名单。完成后运行:
|
||||
|
||||
```powershell
|
||||
npm.cmd run contract:sync
|
||||
npm.cmd run resource-pages:check
|
||||
npm.cmd run type:check
|
||||
npm.cmd run build
|
||||
```
|
||||
|
||||
不要为只读或追加型资源手工开放编辑路由。单例资料、树形页面或强业务定制页面应继续使用专用组件,不纳入通用记录页生成器。
|
||||
|
||||
## 6. 变更记录
|
||||
|
||||
- 新增共享独立记录页、标准列表页、路由生成器和未保存保护。
|
||||
- 删除已停用的抽屉式 `CrudListPage.vue`。
|
||||
- 修复支付、退款资源与后端契约名称不一致的问题。
|
||||
- 新增 17/9/5 页面能力矩阵自动检查。
|
||||
- 保持配送点资料专用只读页面和现有公共后端接口不变。
|
||||
|
||||
## 7. 已知边界
|
||||
|
||||
独立页面只使用配送端已存在的关系查询和 CRUD 能力,不引入平台端头像上传、合同附件上传、平台角色或跨组织账户摘要接口。普通头像和附件 URI 字段仍按原后端文本契约展示或填写。
|
||||
@@ -14,6 +14,7 @@
|
||||
"contract:sync": "node scripts/sync-backend-contract.mjs",
|
||||
"contract:check": "node scripts/check-backend-contract.mjs",
|
||||
"profile:check": "node scripts/check-delivery-profile-page.mjs",
|
||||
"resource-pages:check": "node scripts/check-resource-pages.mjs",
|
||||
"audit:platform": "node scripts/check-backend-contract.mjs",
|
||||
"lint": "biome lint .",
|
||||
"lint:fix": "biome lint --write .",
|
||||
|
||||
64
frontend/delivery_admin/scripts/check-resource-pages.mjs
Normal file
64
frontend/delivery_admin/scripts/check-resource-pages.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 功能描述:校验配送点资源的独立详情、新建、编辑页面与后端契约保持一致。
|
||||
* 版本:v1.0.0。
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const contract = JSON.parse(fs.readFileSync(
|
||||
path.join(projectRoot, 'src/contracts/delivery-resources.json'),
|
||||
'utf8',
|
||||
));
|
||||
const read = (relativePath) => fs.readFileSync(path.join(projectRoot, relativePath), 'utf8');
|
||||
const routeKeys = new Set(contract.routes.map((route) => `${route.method} ${route.path}`));
|
||||
const listResources = contract.resources.filter(
|
||||
(resource) => resource.pageKind === 'list' && resource.name !== 'delivery_profile',
|
||||
);
|
||||
const creatable = new Set([
|
||||
'staff_account', 'staff_credential', 'user_account', 'user_address',
|
||||
'gasorder_contract', 'gasorder_contract_product', 'gasorder_basic',
|
||||
'wallet_recharge', 'wallet_apply_cash',
|
||||
]);
|
||||
const editable = new Set([
|
||||
'staff_account', 'staff_credential', 'user_account', 'user_address', 'gasorder_contract',
|
||||
]);
|
||||
|
||||
/** 在条件不成立时中止检查,并给出可直接定位的原因。 */
|
||||
function assert(condition, message) {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
for (const resource of listResources) {
|
||||
assert(
|
||||
routeKeys.has(`GET ${resource.path}/:identity`),
|
||||
`${resource.name} 缺少详情接口`,
|
||||
);
|
||||
if (creatable.has(resource.name)) {
|
||||
assert(routeKeys.has(`POST ${resource.path}`), `${resource.name} 缺少新建接口`);
|
||||
}
|
||||
if (editable.has(resource.name)) {
|
||||
assert(routeKeys.has(`PUT ${resource.path}/:identity`), `${resource.name} 缺少编辑接口`);
|
||||
}
|
||||
}
|
||||
|
||||
assert(listResources.length === 17, `详情页面资源应为 17 个,当前为 ${listResources.length}`);
|
||||
assert(creatable.size === 9, `新建页面资源应为 9 个,当前为 ${creatable.size}`);
|
||||
assert(editable.size === 5, `编辑页面资源应为 5 个,当前为 ${editable.size}`);
|
||||
|
||||
const builder = read('src/router/routes/modules/resource-route-builder.ts');
|
||||
const platform = read('src/router/routes/modules/platform.ts');
|
||||
const resourcePage = read('src/views/shared/ResourcePage.vue');
|
||||
const definitions = read('src/api/resources.ts');
|
||||
assert(builder.includes("recordRoute('detail', ':identity'"), '路由生成器未创建详情页');
|
||||
assert(builder.includes("recordRoute('create', 'new'"), '路由生成器未创建新建页');
|
||||
assert(builder.includes("recordRoute('edit', ':identity/edit'"), '路由生成器未创建编辑页');
|
||||
assert(platform.includes("createChild('add'"), '工作人员正式新建路由必须保留 /staff/add');
|
||||
assert(platform.includes("createChild('create'"), '配送订单正式新建路由必须保留 /gasorder/create');
|
||||
assert(resourcePage.includes('ResourceListPage'), '标准资源列表尚未切换到独立页面导航');
|
||||
assert(!resourcePage.includes('CrudListPage'), '活动列表仍依赖抽屉式 CrudListPage');
|
||||
assert(definitions.includes("define('payment_order'"), '支付资源必须使用后端名称 payment_order');
|
||||
assert(definitions.includes("define('payment_refund'"), '退款资源必须使用后端名称 payment_refund');
|
||||
|
||||
console.log(`独立资源页面契约通过:详情 ${listResources.length},新建 ${creatable.size},编辑 ${editable.size}`);
|
||||
86
frontend/delivery_admin/src/api/resource-display.ts
Normal file
86
frontend/delivery_admin/src/api/resource-display.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* 功能描述:统一配送点资源独立页面的字段名称和值展示。
|
||||
* 版本:v1.0.0。
|
||||
*/
|
||||
import dayjs from 'dayjs';
|
||||
import { resourceFieldLabel, type ResourceUiDefinition } from './resources';
|
||||
import type { ResourceRow } from './resource-record-form';
|
||||
|
||||
const aliases: Record<string, string> = {
|
||||
id: 'ID',
|
||||
identity: '系统唯一标识',
|
||||
created_at: '创建时间',
|
||||
updated_at: '更新时间',
|
||||
items: '订单明细',
|
||||
assignments: '分配记录',
|
||||
statuses: '状态记录',
|
||||
tracks: '运行轨迹',
|
||||
confirmations: '确认记录',
|
||||
payments: '支付记录',
|
||||
revisions: '修订记录',
|
||||
products: '合同气瓶',
|
||||
};
|
||||
|
||||
const moneyKeys = new Set([
|
||||
'amount', 'unit_price', 'balance', 'withdrawal_balance', 'delivery_fee',
|
||||
'discount_amount', 'total_amount', 'default_delivery_fee', 'fee',
|
||||
]);
|
||||
|
||||
/** 返回聚合详情中的主记录。 */
|
||||
export function primaryRecord(detail: ResourceRow): ResourceRow {
|
||||
for (const key of ['order', 'contract', 'track', 'user']) {
|
||||
if (detail[key] && typeof detail[key] === 'object') return detail[key] as ResourceRow;
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** 返回字段中文名称。 */
|
||||
export function detailFieldLabel(key: string) {
|
||||
return aliases[key] ?? resourceFieldLabel(key);
|
||||
}
|
||||
|
||||
/** 返回通用记录状态名称。 */
|
||||
export function recordStatusLabel(status: number) {
|
||||
return { 0: '待审核', 1: '启用', 2: '停用', 3: '已归档', 4: '已冻结' }[status] ?? '未知状态';
|
||||
}
|
||||
|
||||
/** 返回通用记录状态颜色。 */
|
||||
export function recordStatusColor(status: number) {
|
||||
return { 0: 'orange', 1: 'green', 2: 'red', 3: 'gray', 4: 'purple' }[status] ?? 'gray';
|
||||
}
|
||||
|
||||
/** 将接口字段值转换为中文可读内容。 */
|
||||
export function displayResourceValue(
|
||||
definition: ResourceUiDefinition,
|
||||
key: string,
|
||||
value: unknown,
|
||||
) {
|
||||
if (value == null || value === '') return '-';
|
||||
const field = definition.fields.find((item) => item.key === key);
|
||||
const option = field?.options?.find((item) => String(item.value) === String(value));
|
||||
if (option) return option.label;
|
||||
if (key === 'role_code') {
|
||||
return { delivery: '配送人员', installer: '安装人员', operations: '运维人员' }[String(value)]
|
||||
?? String(value);
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? '是' : '否';
|
||||
if (key === 'status') return recordStatusLabel(Number(value));
|
||||
if (moneyKeys.has(key) || key.endsWith('_amount') || key.endsWith('_balance_after')) {
|
||||
const amount = Number(value);
|
||||
return Number.isFinite(amount) ? `¥${(amount / 100).toFixed(2)}` : String(value);
|
||||
}
|
||||
if (key.endsWith('_at') || key === 'created_at' || key === 'updated_at') {
|
||||
const date = dayjs(String(value));
|
||||
return date.isValid() ? date.format('YYYY-MM-DD HH:mm:ss') : String(value);
|
||||
}
|
||||
if (typeof value === 'object') return JSON.stringify(value, null, 2);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** 返回合同编辑阻止原因。 */
|
||||
export function recordEditReason(definition: ResourceUiDefinition, row: ResourceRow) {
|
||||
if (definition.name !== 'gasorder_contract') return '';
|
||||
if (Number(row.contract_status) !== 0) return '只有草稿状态的合同可以编辑';
|
||||
if (![0, 1].includes(Number(row.status))) return '停用、已归档或已冻结的合同不可编辑';
|
||||
return '';
|
||||
}
|
||||
39
frontend/delivery_admin/src/api/resource-navigation.ts
Normal file
39
frontend/delivery_admin/src/api/resource-navigation.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 功能描述:构造配送点资源列表与独立记录页之间的安全导航地址。
|
||||
* 版本:v1.0.0。
|
||||
*/
|
||||
import type { RouteLocationRaw, Router } from 'vue-router';
|
||||
|
||||
export type RecordNavigationMode = 'create' | 'detail' | 'edit';
|
||||
|
||||
/** 只接受站内绝对路径,防止 return_to 触发外部跳转。 */
|
||||
export function safeReturnPath(value: unknown) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
!value.startsWith('/') ||
|
||||
value.startsWith('//') ||
|
||||
value.includes('\\') ||
|
||||
/[\r\n]/.test(value)
|
||||
)
|
||||
return '';
|
||||
return value;
|
||||
}
|
||||
|
||||
/** 返回指定资源记录模式对应的命名路由。 */
|
||||
export function recordRouteLocation(
|
||||
listRouteName: string,
|
||||
mode: RecordNavigationMode,
|
||||
identity: string,
|
||||
returnTo: string,
|
||||
): RouteLocationRaw {
|
||||
return {
|
||||
name: `${listRouteName}-${mode}`,
|
||||
...(mode === 'create' ? {} : { params: { identity } }),
|
||||
query: returnTo ? { return_to: returnTo } : {},
|
||||
};
|
||||
}
|
||||
|
||||
/** 返回来源列表;没有安全来源时回退到列表命名路由。 */
|
||||
export function returnToList(router: Router, returnPath: string, listRouteName: string) {
|
||||
return router.push(returnPath || { name: listRouteName });
|
||||
}
|
||||
72
frontend/delivery_admin/src/api/resource-record-form.ts
Normal file
72
frontend/delivery_admin/src/api/resource-record-form.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 功能描述:初始化与校验配送点资源独立页面表单。
|
||||
* 版本:v1.0.0。
|
||||
*/
|
||||
import { isMissingField } from './resource-form';
|
||||
import type { ResourceField } from './resources';
|
||||
|
||||
export type ResourceRow = Record<string, unknown>;
|
||||
|
||||
const editableFieldKeys: Record<string, Set<string>> = {
|
||||
staff_account: new Set(['name', 'phone', 'avatar', 'work_status']),
|
||||
staff_credential: new Set(['staff_account_identity', 'credential_type', 'credential_no', 'expired_at']),
|
||||
user_account: new Set(['name', 'phone', 'avatar', 'real_name']),
|
||||
user_address: new Set(['user_account_identity', 'address', 'longitude', 'latitude', 'is_default']),
|
||||
gasorder_contract: new Set([
|
||||
'title', 'terms', 'file_uri', 'default_delivery_fee',
|
||||
'signed_at', 'effective_at', 'expired_at',
|
||||
]),
|
||||
};
|
||||
|
||||
/** 按后端更新契约返回当前页面允许提交的字段,避免展示无效的只读字段。 */
|
||||
export function recordFormFields(
|
||||
resourceName: string,
|
||||
fields: ResourceField[],
|
||||
mode: 'create' | 'edit',
|
||||
) {
|
||||
const available = fields.filter((field) => field.key !== 'identity');
|
||||
if (mode === 'create') return available;
|
||||
const editable = editableFieldKeys[resourceName];
|
||||
return editable
|
||||
? available.filter((field) => editable.has(field.key))
|
||||
: available.filter((field) => field.type !== 'password');
|
||||
}
|
||||
|
||||
/** 使用详情快照初始化表单,并应用来源关系预填。 */
|
||||
export function resetResourceRecordForm(
|
||||
form: Record<string, any>,
|
||||
fields: ResourceField[],
|
||||
row: ResourceRow,
|
||||
prefill: { relationKey?: string; ownerIdentity?: string },
|
||||
) {
|
||||
for (const key of Object.keys(form)) delete form[key];
|
||||
for (const field of fields) {
|
||||
const value = row[field.key];
|
||||
form[field.key] =
|
||||
value == null
|
||||
? field.defaultValue
|
||||
: field.type === 'money'
|
||||
? Number(value) / 100
|
||||
: value;
|
||||
}
|
||||
if (prefill.relationKey && prefill.ownerIdentity)
|
||||
form[prefill.relationKey] = prefill.ownerIdentity;
|
||||
}
|
||||
|
||||
/** 返回表单首个校验错误;空字符串表示可以提交。 */
|
||||
export function validateResourceRecordForm(
|
||||
form: Record<string, any>,
|
||||
fields: ResourceField[],
|
||||
mode: 'create' | 'edit',
|
||||
) {
|
||||
const required = fields.filter(
|
||||
(field) => field.required && !(mode === 'edit' && field.type === 'password'),
|
||||
);
|
||||
if (required.some((field) => isMissingField(form[field.key]))) return '请填写必填字段';
|
||||
const password = fields.find(
|
||||
(field) => field.type === 'password' && !isMissingField(form[field.key]),
|
||||
);
|
||||
if (password && Array.from(String(form[password.key])).length < 6)
|
||||
return '密码长度不能少于 6 个字符';
|
||||
return '';
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* 功能描述:定义配送点后台资源能力、字段契约和受控业务动作。
|
||||
* 版本:v1.1.0。
|
||||
*/
|
||||
export type ResourceMode =
|
||||
| 'writable'
|
||||
| 'readonly'
|
||||
@@ -234,6 +238,12 @@ const fieldLabels: Record<string, string> = {
|
||||
withdrawable: '计入可提现余额',
|
||||
};
|
||||
|
||||
/** 返回资源字段的中文名称,未知字段保留原键用于排障。 */
|
||||
export function resourceFieldLabel(key: string) {
|
||||
const normalized = key.endsWith('_masked') ? key.slice(0, -7) : key;
|
||||
return fieldLabels[normalized] ?? normalized;
|
||||
}
|
||||
|
||||
const numbers = new Set([
|
||||
'sort_no', 'stock_quantity', 'quantity', 'score', 'version_no',
|
||||
'attempt_no', 'target_product_status',
|
||||
@@ -525,9 +535,9 @@ const deliveryOverrides: ResourceUiDefinition[] = [
|
||||
]),
|
||||
define('wallet_basic', '钱包', 'readonly', []),
|
||||
define('wallet_bank', '银行卡', 'readonly', []),
|
||||
define('wallet_payment', '支付记录', 'readonly', []),
|
||||
define('payment_order', '支付记录', 'readonly', []),
|
||||
define('wallet_record', '钱包流水', 'readonly', []),
|
||||
define('wallet_refund', '退款记录', 'readonly', []),
|
||||
define('payment_refund', '退款记录', 'readonly', []),
|
||||
define('wallet_recharge', '钱包充值', 'append_only', [f('request_no', { required: true }), f('amount', { required: true }), ...reason, f('remark')]),
|
||||
define('wallet_apply_cash', '提现申请', 'append_only', [relation('wallet_bank_identity', '/wallet_bank'), f('request_no', { required: true }), f('amount', { required: true }), withdrawalChannelField, f('remark')]),
|
||||
define('fin_settlement', '结算结果', 'readonly', []),
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,15 +1,10 @@
|
||||
/**
|
||||
* 功能描述:配置配送点后台菜单、列表与独立记录页面路由。
|
||||
* 版本:v1.1.0。
|
||||
*/
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
|
||||
const resourcePage = () => import('@/views/shared/ResourcePage.vue');
|
||||
const child = (domain: string, path: string, title: string, resource: string, menuCode: string, meta: Record<string, unknown> = {}): AppRouteRecordRaw => ({
|
||||
path, name: `${domain}-${path}`, component: resourcePage,
|
||||
meta: { title, resource, requiresAuth: true, menuCode, ...meta },
|
||||
});
|
||||
const group = (path: string, name: string, title: string, icon: string, order: number, children: AppRouteRecordRaw[]): AppRouteRecordRaw => ({
|
||||
path: `/${path}`, name, component: DEFAULT_LAYOUT, redirect: `/${path}/${children[0].path}`,
|
||||
meta: { title, requiresAuth: true, icon, order, menuCode: name }, children,
|
||||
});
|
||||
import { child, createChild, group } from './resource-route-builder';
|
||||
|
||||
const routes: AppRouteRecordRaw[] = [
|
||||
{ path: '/dashboard', name: 'dashboard', component: DEFAULT_LAYOUT, redirect: '/dashboard/overview', meta: { title: '数据概览', requiresAuth: true, icon: 'icon-dashboard', order: 10, menuCode: 'dashboard', hideInMenu: true }, children: [
|
||||
@@ -20,7 +15,7 @@ const routes: AppRouteRecordRaw[] = [
|
||||
child('profile', 'basic', '本点资料', '/delivery_profile', 'delivery_profile'),
|
||||
]),
|
||||
group('staff', 'staff', '配送人员管理', 'icon-user-group', 30, [
|
||||
child('staff', 'add', '新增配送人员', '/staff_account', 'staff_add', { createMode: true }),
|
||||
createChild('add', '新增配送人员', '/staff_account', 'staff_add', 'staff-delivery'),
|
||||
child('staff', 'delivery', '配送人员列表', '/staff_account', 'staff_delivery'),
|
||||
child('staff', 'credentials', '人员资质', '/staff_credential', 'staff', { hideInMenu: true, activeMenu: 'staff-delivery' }),
|
||||
]),
|
||||
@@ -35,15 +30,15 @@ const routes: AppRouteRecordRaw[] = [
|
||||
child('contract', 'candidates', '合同可选气瓶', '/product_info', 'gasorder_contract', { hideInMenu: true, activeMenu: 'contract-contracts' }),
|
||||
]),
|
||||
group('gasorder', 'gasorder', '配送订单', 'icon-list', 60, [
|
||||
child('gasorder', 'create', '创建订单', '/gasorder_basic', 'gasorder_create', { createMode: true }),
|
||||
createChild('create', '创建订单', '/gasorder_basic', 'gasorder_create', 'gasorder-orders'),
|
||||
child('gasorder', 'orders', '订单列表', '/gasorder_basic', 'gasorder_basic'),
|
||||
]),
|
||||
group('finance', 'finance', '财务管理', 'icon-bar-chart', 70, [
|
||||
child('finance', 'wallet', '钱包', '/wallet_basic', 'wallet_basic'),
|
||||
child('finance', 'banks', '银行卡', '/wallet_bank', 'wallet_bank'),
|
||||
child('finance', 'payments', '支付记录', '/wallet_payment', 'wallet_payment'),
|
||||
child('finance', 'payments', '支付记录', '/payment_order', 'payment_order'),
|
||||
child('finance', 'records', '钱包流水', '/wallet_record', 'wallet_record'),
|
||||
child('finance', 'refunds', '退款记录', '/wallet_refund', 'wallet_refund'),
|
||||
child('finance', 'refunds', '退款记录', '/payment_refund', 'payment_refund'),
|
||||
child('finance', 'recharge', '钱包充值', '/wallet_recharge', 'wallet_recharge'),
|
||||
child('finance', 'withdrawals', '提现申请', '/wallet_apply_cash', 'wallet_apply_cash'),
|
||||
child('finance', 'settlements', '结算结果', '/fin_settlement', 'fin_settlement'),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 功能描述:为配送点标准资源构造列表菜单与新建、详情、编辑独立路由。
|
||||
* 版本:v1.0.0。
|
||||
*/
|
||||
import { getResource } from '@/api/resources';
|
||||
import { DEFAULT_LAYOUT } from '../base';
|
||||
import type { AppRouteRecordRaw } from '../types';
|
||||
|
||||
const resourcePage = () => import('@/views/shared/ResourcePage.vue');
|
||||
const resourceRecordPage = () => import('@/views/resource/ResourceRecordPage.vue');
|
||||
|
||||
/** 创建标准资源列表路由。 */
|
||||
export function child(
|
||||
domain: string,
|
||||
path: string,
|
||||
title: string,
|
||||
resource: string,
|
||||
menuCode: string,
|
||||
meta: Record<string, unknown> = {},
|
||||
): AppRouteRecordRaw {
|
||||
return {
|
||||
path,
|
||||
name: `${domain}-${path}`,
|
||||
component: resourcePage,
|
||||
meta: { title, resource, requiresAuth: true, menuCode, ...meta },
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建具有既有菜单地址的正式新建路由。 */
|
||||
export function createChild(
|
||||
path: string,
|
||||
title: string,
|
||||
resource: string,
|
||||
menuCode: string,
|
||||
listRouteName: string,
|
||||
): AppRouteRecordRaw {
|
||||
return {
|
||||
path,
|
||||
name: `${listRouteName}-create`,
|
||||
component: resourceRecordPage,
|
||||
meta: {
|
||||
title,
|
||||
resource,
|
||||
requiresAuth: true,
|
||||
menuCode,
|
||||
recordMode: 'create',
|
||||
listRouteName,
|
||||
canonicalCreate: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建菜单分组并展开各资源的独立记录路由。 */
|
||||
export function group(
|
||||
path: string,
|
||||
name: string,
|
||||
title: string,
|
||||
icon: string,
|
||||
order: number,
|
||||
children: AppRouteRecordRaw[],
|
||||
): AppRouteRecordRaw {
|
||||
return {
|
||||
path: `/${path}`,
|
||||
name,
|
||||
component: DEFAULT_LAYOUT,
|
||||
redirect: `/${path}/${children[0].path}`,
|
||||
meta: { title, requiresAuth: true, icon, order, menuCode: name },
|
||||
children: expandResourceChildren(children),
|
||||
};
|
||||
}
|
||||
|
||||
/** 为列表资源生成缺失的新建页以及全部详情、编辑页。 */
|
||||
function expandResourceChildren(children: AppRouteRecordRaw[]) {
|
||||
const canonicalCreateResources = new Set(
|
||||
children.filter((route) => route.meta?.canonicalCreate).map((route) => String(route.meta?.resource)),
|
||||
);
|
||||
return children.flatMap((route) => {
|
||||
if (route.meta?.recordMode === 'create') return [route];
|
||||
const resource = String(route.meta?.resource ?? '');
|
||||
if (!resource || resource === '/delivery_profile') return [route];
|
||||
const definition = getResource(resource);
|
||||
if (definition.pageKind !== 'list') return [route];
|
||||
const listRouteName = String(route.name);
|
||||
const activeMenu = String(route.meta?.activeMenu ?? route.name);
|
||||
const recordRoute = (
|
||||
mode: 'create' | 'detail' | 'edit',
|
||||
suffix: string,
|
||||
titlePrefix: string,
|
||||
): AppRouteRecordRaw => ({
|
||||
path: `${route.path}/${suffix}`,
|
||||
name: `${listRouteName}-${mode}`,
|
||||
component: resourceRecordPage,
|
||||
meta: {
|
||||
...route.meta,
|
||||
requiresAuth: true,
|
||||
title: `${titlePrefix}${definition.title}`,
|
||||
hideInMenu: true,
|
||||
activeMenu,
|
||||
listRouteName,
|
||||
recordMode: mode,
|
||||
},
|
||||
});
|
||||
return [
|
||||
route,
|
||||
...(definition.canCreate && !canonicalCreateResources.has(resource)
|
||||
? [recordRoute('create', 'new', '新建')]
|
||||
: []),
|
||||
recordRoute('detail', ':identity', ''),
|
||||
...(definition.canEdit ? [recordRoute('edit', ':identity/edit', '编辑')] : []),
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
/** 功能描述:扩展配送点后台路由元数据类型。版本:v1.1.0。 */
|
||||
import 'vue-router';
|
||||
|
||||
declare module 'vue-router' {
|
||||
@@ -6,6 +7,10 @@ declare module 'vue-router' {
|
||||
menuCode?: string; // Server-assigned menu domain required by this route
|
||||
staffType?: 'installer' | 'delivery' | 'operations';
|
||||
createMode?: boolean;
|
||||
resource?: string;
|
||||
recordMode?: 'create' | 'detail' | 'edit';
|
||||
listRouteName?: string;
|
||||
canonicalCreate?: boolean;
|
||||
requiresAuth: boolean; // Whether login is required to access the current page (every route must declare)
|
||||
icon?: string; // The icon show in the side menu
|
||||
locale?: string; // The locale name show in side menu and breadcrumb
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
<!-- 功能描述:承载独立详情页的状态审核和专用业务动作。版本:v1.0.0。 -->
|
||||
<template>
|
||||
<a-modal
|
||||
:visible="visible"
|
||||
:title="action?.name"
|
||||
:ok-loading="submitting"
|
||||
:ok-button-props="{ status: action?.danger ? 'danger' : 'normal' }"
|
||||
@cancel="emit('update:visible', false)"
|
||||
@ok="submit"
|
||||
>
|
||||
<a-alert v-if="action?.danger" class="action-alert" type="error" show-icon>
|
||||
这是高风险操作,可能改变业务状态且无法直接撤销。请确认目标记录和填写内容准确。
|
||||
</a-alert>
|
||||
<ResourceFieldForm
|
||||
v-if="action"
|
||||
:fields="action.fields ?? []"
|
||||
:model="form"
|
||||
mode="create"
|
||||
:relation-options="relationOptions"
|
||||
:relation-loading="relationLoading"
|
||||
@search-relation="(resource, keyword) => emit('searchRelation', resource, keyword)"
|
||||
/>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
|
||||
import type { ResourceRow } from '@/api/resource-record-form';
|
||||
import type { DetailAction } from '@/api/resources';
|
||||
import ResourceFieldForm from './ResourceFieldForm.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
action?: DetailAction;
|
||||
identity: string;
|
||||
currentStatus?: number;
|
||||
relationOptions: Record<string, ResourceRow[]>;
|
||||
relationLoading: Record<string, boolean>;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [visible: boolean];
|
||||
success: [];
|
||||
searchRelation: [resource: string | undefined, keyword: string];
|
||||
}>();
|
||||
const form = reactive<Record<string, any>>({});
|
||||
const submitting = ref(false);
|
||||
|
||||
watch(
|
||||
() => [props.visible, props.action] as const,
|
||||
([visible, action]) => {
|
||||
if (!visible || !action) return;
|
||||
for (const key of Object.keys(form)) delete form[key];
|
||||
for (const field of action.fields ?? []) form[field.key] = field.defaultValue;
|
||||
if (action.resource.endsWith('/:identity/status') && [1, 2].includes(Number(props.currentStatus)))
|
||||
form.status = props.currentStatus;
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
/** 校验并执行当前业务动作。 */
|
||||
async function submit() {
|
||||
const action = props.action;
|
||||
if (!action) return;
|
||||
if ((action.fields ?? []).some((field) => field.required && isMissingField(form[field.key]))) {
|
||||
Message.warning('请填写必填字段');
|
||||
return;
|
||||
}
|
||||
if (action.resource.endsWith('/:identity/status') && Number(form.status) === Number(props.currentStatus)) {
|
||||
Message.warning('审核状态未发生变化');
|
||||
return;
|
||||
}
|
||||
const execute = async () => {
|
||||
submitting.value = true;
|
||||
try {
|
||||
const path = action.resource.replace(':identity', props.identity);
|
||||
const payload = buildResourcePayload(action.fields ?? [], form);
|
||||
await resourceApi.action(path, action.method ?? 'POST', payload);
|
||||
Message.success('操作成功');
|
||||
emit('update:visible', false);
|
||||
emit('success');
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
if (!action.danger) return execute();
|
||||
Modal.warning({
|
||||
title: `确认${action.name}`,
|
||||
content: '执行后可能无法直接撤销,请再次确认目标记录和填写内容。',
|
||||
hideCancel: false,
|
||||
onOk: execute,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.action-alert {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<!-- 功能描述:以信息表和集合页签展示配送点资源详情。版本:v1.0.0。 -->
|
||||
<template>
|
||||
<div class="detail-stack">
|
||||
<a-card title="基本信息" :bordered="false" class="detail-card">
|
||||
<div class="detail-grid">
|
||||
<div
|
||||
v-for="[key, value] in entries"
|
||||
:key="key"
|
||||
class="detail-item"
|
||||
:class="{ 'detail-item-wide': wideKeys.has(key) || isObject(value) }"
|
||||
>
|
||||
<span class="detail-label">{{ detailFieldLabel(key) }}</span>
|
||||
<a-popover v-if="isObject(value)" position="left">
|
||||
<a-button type="text" size="mini">查看内容</a-button>
|
||||
<template #content>
|
||||
<pre class="json-value">{{ displayResourceValue(definition, key, value) }}</pre>
|
||||
</template>
|
||||
</a-popover>
|
||||
<IdentityText v-else-if="isIdentity(key, value)" :value="String(value)" />
|
||||
<span v-else class="detail-value">{{ displayResourceValue(definition, key, value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="collections.length" title="关联记录" :bordered="false" class="detail-card">
|
||||
<a-tabs>
|
||||
<a-tab-pane v-for="collection in collections" :key="collection.key" :title="detailFieldLabel(collection.key)">
|
||||
<div class="collection-table-shell">
|
||||
<a-table :data="collection.rows" :pagination="false" size="small" table-layout-fixed>
|
||||
<template #columns>
|
||||
<a-table-column
|
||||
v-for="column in collection.columns"
|
||||
:key="column"
|
||||
:title="detailFieldLabel(column)"
|
||||
:width="column.endsWith('_at') ? 150 : 180"
|
||||
ellipsis
|
||||
tooltip
|
||||
>
|
||||
<template #cell="{ record }">
|
||||
<IdentityText v-if="isIdentity(column, record[column])" :value="String(record[column])" />
|
||||
<template v-else>{{ displayResourceValue(definition, column, record[column]) }}</template>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
detailFieldLabel,
|
||||
displayResourceValue,
|
||||
primaryRecord,
|
||||
} from '@/api/resource-display';
|
||||
import type { ResourceRow } from '@/api/resource-record-form';
|
||||
import type { ResourceUiDefinition } from '@/api/resources';
|
||||
import IdentityText from '@/components/IdentityText.vue';
|
||||
|
||||
const props = defineProps<{ definition: ResourceUiDefinition; detail: ResourceRow }>();
|
||||
const wideKeys = new Set(['address', 'terms', 'remark', 'reason', 'content', 'body']);
|
||||
|
||||
const record = computed(() => primaryRecord(props.detail));
|
||||
const entries = computed(() =>
|
||||
Object.entries(record.value).filter(
|
||||
([key, value]) =>
|
||||
key !== 'id' &&
|
||||
key !== 'DeletedAt' &&
|
||||
key !== 'deleted_at' &&
|
||||
!key.endsWith('_id') &&
|
||||
!key.endsWith('_masked') &&
|
||||
!Array.isArray(value),
|
||||
),
|
||||
);
|
||||
const collections = computed(() =>
|
||||
Object.entries(props.detail)
|
||||
.filter(([, value]) => Array.isArray(value) && value.length > 0)
|
||||
.map(([key, value]) => {
|
||||
const rows = value as ResourceRow[];
|
||||
const columns = [
|
||||
...new Set(
|
||||
rows.flatMap((row) =>
|
||||
Object.keys(row).filter(
|
||||
(column) => column !== 'id' && column !== 'DeletedAt' &&
|
||||
column !== 'deleted_at' && !column.endsWith('_id'),
|
||||
),
|
||||
),
|
||||
),
|
||||
].slice(0, 10);
|
||||
return { key, rows, columns };
|
||||
}),
|
||||
);
|
||||
|
||||
/** 判断字段是否应使用可复制唯一标识组件。 */
|
||||
function isIdentity(key: string, value: unknown) {
|
||||
return Boolean(value && (key === 'identity' || key.endsWith('_identity')));
|
||||
}
|
||||
|
||||
/** 判断详情值是否需要折叠到弹出层查看。 */
|
||||
function isObject(value: unknown) {
|
||||
return Boolean(value && typeof value === 'object');
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-stack {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 20px;
|
||||
}
|
||||
.detail-card {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.detail-card :deep(.arco-card-header) {
|
||||
height: 48px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.detail-card :deep(.arco-card-body) {
|
||||
min-width: 0;
|
||||
padding: 20px 24px;
|
||||
}
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px 42px;
|
||||
}
|
||||
.detail-item {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
column-gap: 12px;
|
||||
align-items: start;
|
||||
min-width: 0;
|
||||
min-height: 38px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--color-border-1);
|
||||
}
|
||||
.detail-item-wide { grid-column: 1 / -1; }
|
||||
.detail-label { color: var(--color-text-3); white-space: nowrap; }
|
||||
.detail-value {
|
||||
min-width: 0;
|
||||
color: var(--color-text-1);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.json-value {
|
||||
max-height: 320px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.collection-table-shell {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
@media (max-width: 1280px) {
|
||||
.detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.detail-card :deep(.arco-card-header),
|
||||
.detail-card :deep(.arco-card-body) { padding-right: 16px; padding-left: 16px; }
|
||||
.detail-grid { grid-template-columns: 1fr; gap: 4px; }
|
||||
.detail-item,
|
||||
.detail-item-wide { grid-column: auto; }
|
||||
}
|
||||
</style>
|
||||
138
frontend/delivery_admin/src/views/resource/ResourceFieldForm.vue
Normal file
138
frontend/delivery_admin/src/views/resource/ResourceFieldForm.vue
Normal file
@@ -0,0 +1,138 @@
|
||||
<!-- 功能描述:渲染配送点资源新建、编辑和动作表单字段。版本:v1.0.0。 -->
|
||||
<template>
|
||||
<a-form :model="model" layout="vertical" class="field-grid">
|
||||
<a-form-item
|
||||
v-for="field in fields"
|
||||
:key="field.key"
|
||||
:class="{ 'field-wide': isWide(field), 'field-readonly': field.readonly }"
|
||||
:label="field.label"
|
||||
:required="isRequired(field)"
|
||||
>
|
||||
<a-switch v-if="field.type === 'boolean'" v-model="model[field.key]" :disabled="field.readonly" />
|
||||
<a-input-number
|
||||
v-else-if="field.type === 'number' || field.type === 'money'"
|
||||
v-model="model[field.key]"
|
||||
:precision="field.type === 'money' ? 2 : 0"
|
||||
:disabled="field.readonly"
|
||||
/>
|
||||
<a-date-picker
|
||||
v-else-if="field.type === 'date'"
|
||||
v-model="model[field.key]"
|
||||
value-format="YYYY-MM-DD"
|
||||
:disabled="field.readonly"
|
||||
/>
|
||||
<a-date-picker
|
||||
v-else-if="field.type === 'datetime'"
|
||||
v-model="model[field.key]"
|
||||
show-time
|
||||
value-format="YYYY-MM-DDTHH:mm:ssZ"
|
||||
:disabled="field.readonly"
|
||||
/>
|
||||
<a-textarea
|
||||
v-else-if="field.type === 'textarea'"
|
||||
v-model="model[field.key]"
|
||||
:auto-size="{ minRows: 3, maxRows: 8 }"
|
||||
:disabled="field.readonly"
|
||||
/>
|
||||
<a-input-password
|
||||
v-else-if="field.type === 'password'"
|
||||
v-model="model[field.key]"
|
||||
:placeholder="mode === 'edit' ? '留空表示不修改' : `请输入${field.label}`"
|
||||
/>
|
||||
<a-select
|
||||
v-else-if="field.type === 'select'"
|
||||
v-model="model[field.key]"
|
||||
allow-clear
|
||||
:disabled="field.readonly"
|
||||
>
|
||||
<a-option
|
||||
v-for="option in field.options ?? []"
|
||||
:key="String(option.value)"
|
||||
:value="option.value"
|
||||
:disabled="option.disabled"
|
||||
>
|
||||
{{ option.label }}{{ option.disabled ? '(暂未开通)' : '' }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<a-select
|
||||
v-else-if="field.type === 'identity' || field.type === 'identity-list'"
|
||||
v-model="model[field.key]"
|
||||
:multiple="field.type === 'identity-list'"
|
||||
allow-clear
|
||||
allow-search
|
||||
:disabled="field.readonly"
|
||||
:loading="relationLoading[field.relation ?? '']"
|
||||
@search="(value: string) => emit('searchRelation', field.relation, value)"
|
||||
>
|
||||
<a-option
|
||||
v-for="option in relationOptions[field.relation ?? ''] ?? []"
|
||||
:key="String(option.identity)"
|
||||
:value="String(option.identity)"
|
||||
>
|
||||
{{ optionLabel(option) }}
|
||||
</a-option>
|
||||
</a-select>
|
||||
<a-input
|
||||
v-else
|
||||
v-model="model[field.key]"
|
||||
:disabled="field.readonly"
|
||||
:placeholder="field.placeholder ?? `请输入${field.label}`"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ResourceField } from '@/api/resources';
|
||||
import type { ResourceRow } from '@/api/resource-record-form';
|
||||
|
||||
const props = defineProps<{
|
||||
fields: ResourceField[];
|
||||
model: Record<string, any>;
|
||||
mode: 'create' | 'edit';
|
||||
relationOptions: Record<string, ResourceRow[]>;
|
||||
relationLoading: Record<string, boolean>;
|
||||
}>();
|
||||
const emit = defineEmits<{
|
||||
searchRelation: [resource: string | undefined, keyword: string];
|
||||
}>();
|
||||
|
||||
/** 判断当前模式下字段是否必填。 */
|
||||
function isRequired(field: ResourceField) {
|
||||
return Boolean(field.required && !(props.mode === 'edit' && field.type === 'password'));
|
||||
}
|
||||
|
||||
/** 长文本和多选关系占据整行,其余字段按双列排列。 */
|
||||
function isWide(field: ResourceField) {
|
||||
return field.type === 'textarea' || field.type === 'identity-list' ||
|
||||
/(address|terms|content|body|remark|reason|params|args)$/.test(field.key);
|
||||
}
|
||||
|
||||
/** 返回关联候选的可读名称。 */
|
||||
function optionLabel(option: ResourceRow) {
|
||||
return String(
|
||||
option.name ?? option.title ?? option.code ?? option.username ??
|
||||
option.contract_no ?? option.order_no ?? option.identity,
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.field-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 4px 32px;
|
||||
}
|
||||
.field-wide { grid-column: 1 / -1; }
|
||||
.field-readonly :deep(.arco-input-wrapper),
|
||||
.field-readonly :deep(.arco-select-view),
|
||||
.field-readonly :deep(.arco-picker),
|
||||
.field-readonly :deep(.arco-input-number) { background: var(--color-fill-1); }
|
||||
:deep(.arco-input-number),
|
||||
:deep(.arco-picker),
|
||||
:deep(.arco-select) { width: 100%; min-width: 0; }
|
||||
@media (max-width: 900px) {
|
||||
.field-grid { grid-template-columns: 1fr; }
|
||||
.field-wide { grid-column: auto; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
/* 功能描述:对齐平台总后台的资源独立页面布局。版本:v1.0.0。 */
|
||||
.record-page {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
gap: 12px;
|
||||
min-height: 100%;
|
||||
padding: 16px 20px 20px;
|
||||
background: var(--color-fill-2);
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 4px;
|
||||
}
|
||||
.state-card {
|
||||
display: grid;
|
||||
min-height: 260px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.section-card,
|
||||
.form-card {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.section-card :deep(.arco-card-header) {
|
||||
height: 48px;
|
||||
padding: 0 24px;
|
||||
}
|
||||
.section-card :deep(.arco-card-body) {
|
||||
padding: 18px 24px;
|
||||
}
|
||||
.form-card {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.form-card :deep(.arco-card-header) {
|
||||
padding: 0 32px;
|
||||
}
|
||||
.form-card :deep(.arco-card-body) {
|
||||
padding: 24px 32px 28px;
|
||||
}
|
||||
.form-shell {
|
||||
width: 100%;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.workflow-alert { margin-bottom: 18px; }
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.record-page { padding: 12px; }
|
||||
.page-header { flex-direction: column; }
|
||||
.form-card :deep(.arco-card-header) { padding: 0 16px; }
|
||||
.form-card :deep(.arco-card-body) { padding: 20px 16px 24px; }
|
||||
.section-card :deep(.arco-card-header),
|
||||
.section-card :deep(.arco-card-body) { padding-right: 16px; padding-left: 16px; }
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
<!-- 功能描述:承载配送点标准资源的新建、详情和编辑独立页面。版本:v1.0.0。 -->
|
||||
<template>
|
||||
<div class="record-page">
|
||||
<header class="page-header">
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<a-link @click="requestListBack">{{ definition.title }}列表</a-link>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ modeLabel }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
<a-space>
|
||||
<a-button v-if="mode === 'detail' && canEditRecord" type="primary" @click="goEdit">
|
||||
<template #icon><icon-edit /></template>编辑
|
||||
</a-button>
|
||||
<a-button @click="requestBack"><template #icon><icon-left /></template>返回</a-button>
|
||||
</a-space>
|
||||
</header>
|
||||
|
||||
<a-card v-if="loading" :bordered="false" class="state-card">
|
||||
<a-spin tip="正在加载记录…" />
|
||||
</a-card>
|
||||
<a-result v-else-if="errorMessage" status="error" title="页面加载失败" :subtitle="errorMessage">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="loadRecord">重新加载</a-button>
|
||||
<a-button @click="requestListBack">返回列表</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-result>
|
||||
|
||||
<template v-else>
|
||||
<template v-if="mode === 'detail'">
|
||||
<ResourceDetailContent :definition="definition" :detail="detail" />
|
||||
<a-card
|
||||
v-if="visibleActions.length || definition.canChangeStatus || definition.canArchive"
|
||||
title="业务操作"
|
||||
:bordered="false"
|
||||
class="section-card"
|
||||
>
|
||||
<a-alert v-if="visibleActions.length" class="workflow-alert" type="warning" show-icon>
|
||||
业务操作会按服务端状态校验并保留审计记录,请确认目标记录和填写内容准确。
|
||||
</a-alert>
|
||||
<a-space wrap>
|
||||
<a-button
|
||||
v-for="action in visibleActions"
|
||||
:key="action.name"
|
||||
type="primary"
|
||||
:status="action.danger ? 'danger' : 'normal'"
|
||||
@click="openAction(action)"
|
||||
>{{ action.name }}</a-button>
|
||||
<a-button v-if="definition.canChangeStatus" @click="openStatusAction">审核状态</a-button>
|
||||
<a-button v-if="definition.canArchive" status="danger" @click="confirmArchive">删除</a-button>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<a-card v-else title="基本信息" :bordered="false" class="form-card">
|
||||
<div class="form-shell form-content">
|
||||
<ResourceFieldForm
|
||||
:fields="formFields"
|
||||
:model="form"
|
||||
:mode="mode"
|
||||
:relation-options="relationOptions"
|
||||
:relation-loading="relationLoading"
|
||||
@search-relation="searchRelation"
|
||||
/>
|
||||
<div class="form-actions">
|
||||
<a-button type="primary" :loading="saving" @click="save">保存</a-button>
|
||||
<a-button :disabled="saving" @click="requestBack">取消</a-button>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<ResourceActionDialog
|
||||
v-model:visible="actionVisible"
|
||||
:action="activeAction"
|
||||
:identity="identity"
|
||||
:current-status="Number(record.status)"
|
||||
:relation-options="relationOptions"
|
||||
:relation-loading="relationLoading"
|
||||
@search-relation="searchRelation"
|
||||
@success="loadRecord"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message, Modal } from '@arco-design/web-vue';
|
||||
import { IconEdit, IconLeft } from '@arco-design/web-vue/es/icon';
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
import { buildResourcePayload } from '@/api/resource-form';
|
||||
import {
|
||||
resetResourceRecordForm,
|
||||
recordFormFields,
|
||||
validateResourceRecordForm,
|
||||
type ResourceRow,
|
||||
} from '@/api/resource-record-form';
|
||||
import { primaryRecord, recordEditReason } from '@/api/resource-display';
|
||||
import { recordRouteLocation, returnToList, safeReturnPath } from '@/api/resource-navigation';
|
||||
import { getResource, type DetailAction, type ResourceField } from '@/api/resources';
|
||||
import ResourceActionDialog from './ResourceActionDialog.vue';
|
||||
import ResourceDetailContent from './ResourceDetailContent.vue';
|
||||
import ResourceFieldForm from './ResourceFieldForm.vue';
|
||||
import { useUnsavedRecord } from './use-unsaved-record';
|
||||
|
||||
type RecordPageMode = 'create' | 'detail' | 'edit';
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const definition = computed(() => getResource(String(route.meta.resource)));
|
||||
const mode = computed(() => String(route.meta.recordMode) as RecordPageMode);
|
||||
const listRouteName = computed(() => String(route.meta.listRouteName));
|
||||
const returnPath = computed(() => safeReturnPath(route.query.return_to));
|
||||
const identity = computed(() => String(route.params.identity ?? ''));
|
||||
const modeLabel = computed(() => ({ create: '新建', detail: '详情', edit: '编辑' })[mode.value]);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const errorMessage = ref('');
|
||||
const detail = ref<ResourceRow>({});
|
||||
const record = computed(() => primaryRecord(detail.value));
|
||||
const form = reactive<Record<string, any>>({});
|
||||
const relationOptions = reactive<Record<string, ResourceRow[]>>({});
|
||||
const relationLoading = reactive<Record<string, boolean>>({});
|
||||
const relationTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
const actionVisible = ref(false);
|
||||
const activeAction = ref<DetailAction>();
|
||||
const formFields = computed(() => recordFormFields(
|
||||
definition.value.name,
|
||||
definition.value.fields,
|
||||
mode.value as 'create' | 'edit',
|
||||
));
|
||||
const canEditRecord = computed(
|
||||
() => definition.value.canEdit && !recordEditReason(definition.value, record.value),
|
||||
);
|
||||
const visibleActions = computed(() =>
|
||||
(definition.value.detailActions ?? []).filter(
|
||||
(action) =>
|
||||
!action.visibleFor ||
|
||||
action.visibleFor.values.includes(record.value[action.visibleFor.field] as string | number),
|
||||
),
|
||||
);
|
||||
const unsaved = useUnsavedRecord(
|
||||
() => JSON.stringify(form),
|
||||
() => mode.value !== 'detail',
|
||||
);
|
||||
|
||||
/** 加载详情或初始化新建表单。 */
|
||||
async function loadRecord() {
|
||||
loading.value = true;
|
||||
errorMessage.value = '';
|
||||
try {
|
||||
if (mode.value === 'create') {
|
||||
detail.value = {};
|
||||
} else {
|
||||
detail.value = await resourceApi.detail<ResourceRow>(definition.value.resource, identity.value);
|
||||
if (mode.value === 'edit') {
|
||||
const reason = recordEditReason(definition.value, record.value);
|
||||
if (reason) throw new Error(reason);
|
||||
}
|
||||
}
|
||||
if (mode.value !== 'detail') {
|
||||
resetResourceRecordForm(form, formFields.value, record.value, {
|
||||
relationKey: typeof route.query.relation_key === 'string' ? route.query.relation_key : undefined,
|
||||
ownerIdentity: typeof route.query.owner_identity === 'string' ? route.query.owner_identity : undefined,
|
||||
});
|
||||
queueMicrotask(unsaved.markInitialized);
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage.value = (error as Error).message;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存新建或编辑表单并进入记录详情。 */
|
||||
async function save() {
|
||||
const validation = validateResourceRecordForm(form, formFields.value, mode.value as 'create' | 'edit');
|
||||
if (validation) return Message.warning(validation);
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload = buildResourcePayload(formFields.value, form, mode.value as 'create' | 'edit');
|
||||
const saved = mode.value === 'create'
|
||||
? await resourceApi.create<ResourceRow>(definition.value.resource, payload)
|
||||
: await resourceApi.update<ResourceRow>(definition.value.resource, identity.value, payload);
|
||||
const savedIdentity = String(saved?.identity ?? identity.value);
|
||||
if (!savedIdentity) throw new Error('保存成功但响应缺少记录标识');
|
||||
Message.success('保存成功');
|
||||
unsaved.allowNextNavigation();
|
||||
await router.replace(recordRouteLocation(listRouteName.value, 'detail', savedIdentity, returnPath.value));
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回列表或编辑来源详情。 */
|
||||
function requestBack() {
|
||||
const leave = () => mode.value === 'edit'
|
||||
? router.push(recordRouteLocation(listRouteName.value, 'detail', identity.value, returnPath.value))
|
||||
: returnToList(router, returnPath.value, listRouteName.value);
|
||||
if (mode.value === 'detail') return leave();
|
||||
return unsaved.confirmDiscard(leave);
|
||||
}
|
||||
|
||||
/** 从面包屑直接返回来源列表,并复用未保存确认。 */
|
||||
function requestListBack() {
|
||||
const leave = () => returnToList(router, returnPath.value, listRouteName.value);
|
||||
if (mode.value === 'detail') return leave();
|
||||
return unsaved.confirmDiscard(leave);
|
||||
}
|
||||
|
||||
/** 从详情进入编辑页。 */
|
||||
function goEdit() {
|
||||
return router.push(recordRouteLocation(listRouteName.value, 'edit', identity.value, returnPath.value));
|
||||
}
|
||||
|
||||
/** 打开详情业务动作。 */
|
||||
function openAction(action: DetailAction) {
|
||||
activeAction.value = action;
|
||||
actionVisible.value = true;
|
||||
}
|
||||
|
||||
/** 打开通用状态审核动作。 */
|
||||
function openStatusAction() {
|
||||
openAction({
|
||||
name: '审核状态',
|
||||
resource: `${definition.value.resource}/:identity/status`,
|
||||
method: 'PATCH',
|
||||
fields: [{
|
||||
key: 'status', label: '目标状态', type: 'select', required: true,
|
||||
options: [{ label: '启用', value: 1 }, { label: '停用', value: 2 }],
|
||||
}],
|
||||
});
|
||||
}
|
||||
|
||||
/** 二次确认后归档当前记录。 */
|
||||
function confirmArchive() {
|
||||
Modal.warning({
|
||||
title: '确认删除',
|
||||
content: '删除后该记录将被归档,不再参与日常业务。',
|
||||
hideCancel: false,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await resourceApi.archive(definition.value.resource, identity.value);
|
||||
Message.success('已删除');
|
||||
await returnToList(router, returnPath.value, listRouteName.value);
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 加载关联候选,配送人员关系固定为配送角色。 */
|
||||
async function loadRelation(resource: string, keyword = '') {
|
||||
relationLoading[resource] = true;
|
||||
try {
|
||||
const filters: Record<string, string> = keyword ? { keyword } : {};
|
||||
if (resource === '/staff_account') filters.role_code = 'delivery';
|
||||
relationOptions[resource] = (await resourceApi.list<ResourceRow>(resource, 1, 100, filters)).list;
|
||||
} catch (error) {
|
||||
Message.error(`关联数据加载失败:${(error as Error).message}`);
|
||||
} finally {
|
||||
relationLoading[resource] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 对关联搜索做短延迟合并。 */
|
||||
function searchRelation(resource: string | undefined, keyword: string) {
|
||||
if (!resource) return;
|
||||
const timer = relationTimers.get(resource);
|
||||
if (timer) clearTimeout(timer);
|
||||
relationTimers.set(resource, setTimeout(() => loadRelation(resource, keyword.trim()), 250));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const paths = new Set(
|
||||
[...definition.value.fields, ...(definition.value.detailActions ?? []).flatMap((item) => item.fields ?? [])]
|
||||
.map((field: ResourceField) => field.relation)
|
||||
.filter((value): value is string => Boolean(value)),
|
||||
);
|
||||
await Promise.all([...paths].map((resource) => loadRelation(resource)));
|
||||
await loadRecord();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="less" src="./ResourceRecordPage.less"></style>
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* 功能描述:统一资源新建和编辑页面的未保存离开保护。
|
||||
* 版本:v1.0.0。
|
||||
*/
|
||||
import { Modal } from '@arco-design/web-vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { onBeforeRouteLeave } from 'vue-router';
|
||||
|
||||
/** 创建基于表单快照的离开保护。 */
|
||||
export function useUnsavedRecord(snapshot: () => string, active: () => boolean) {
|
||||
const initial = ref('');
|
||||
const bypass = ref(false);
|
||||
const dirty = computed(() => active() && initial.value !== '' && snapshot() !== initial.value);
|
||||
|
||||
function markInitialized() {
|
||||
initial.value = snapshot();
|
||||
}
|
||||
|
||||
function allowNextNavigation() {
|
||||
bypass.value = true;
|
||||
}
|
||||
|
||||
/** 对页面内返回动作进行一次明确确认。 */
|
||||
function confirmDiscard(action: () => unknown) {
|
||||
if (!dirty.value) return action();
|
||||
Modal.warning({
|
||||
title: '放弃未保存的修改?',
|
||||
content: '当前页面内容尚未保存,离开后将无法恢复。',
|
||||
hideCancel: false,
|
||||
onOk: () => {
|
||||
allowNextNavigation();
|
||||
action();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function beforeUnload(event: BeforeUnloadEvent) {
|
||||
if (!dirty.value) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = '';
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => {
|
||||
if (bypass.value || !dirty.value) return true;
|
||||
return new Promise<boolean>((resolve) =>
|
||||
Modal.warning({
|
||||
title: '内容尚未保存',
|
||||
content: '确定离开当前页面并放弃修改吗?',
|
||||
hideCancel: false,
|
||||
onOk: () => resolve(true),
|
||||
onCancel: () => resolve(false),
|
||||
}),
|
||||
);
|
||||
});
|
||||
onMounted(() => window.addEventListener('beforeunload', beforeUnload));
|
||||
onBeforeUnmount(() => window.removeEventListener('beforeunload', beforeUnload));
|
||||
|
||||
return { dirty, markInitialized, allowNextNavigation, confirmDiscard };
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
222
frontend/delivery_admin/src/views/shared/ResourceListPage.vue
Normal file
222
frontend/delivery_admin/src/views/shared/ResourceListPage.vue
Normal file
@@ -0,0 +1,222 @@
|
||||
<!-- 功能描述:展示配送点标准资源列表并导航到独立记录页。版本:v1.0.0。 -->
|
||||
<template>
|
||||
<a-card :title="definition.title" :bordered="false">
|
||||
<a-alert
|
||||
v-if="definition.detailActions?.length"
|
||||
class="workflow-alert"
|
||||
type="warning"
|
||||
show-icon
|
||||
title="此资源包含受控业务状态"
|
||||
>
|
||||
请从详情页中的专用操作推进流程,操作结果由服务端状态校验并保留审计记录。
|
||||
</a-alert>
|
||||
<div class="list-toolbar">
|
||||
<a-form :model="filters" layout="inline" @submit="search">
|
||||
<a-form-item label="关键字">
|
||||
<a-input v-model="filters.keyword" allow-clear placeholder="关键字段模糊搜索" />
|
||||
</a-form-item>
|
||||
<a-button type="primary" html-type="submit">查询</a-button>
|
||||
<a-button @click="resetSearch">重置</a-button>
|
||||
</a-form>
|
||||
<a-space>
|
||||
<a-button :loading="loading" @click="load">
|
||||
<template #icon><icon-refresh /></template>刷新
|
||||
</a-button>
|
||||
<a-button v-if="definition.canCreate" type="primary" @click="openCreate">
|
||||
<template #icon><icon-plus /></template>新建
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<a-table :data="list" :loading="loading" :pagination="false" row-key="identity">
|
||||
<template #columns>
|
||||
<a-table-column title="ID" data-index="id" :width="80" />
|
||||
<a-table-column title="唯一标识" :width="150">
|
||||
<template #cell="{ record }"><IdentityText :value="String(record.identity)" /></template>
|
||||
</a-table-column>
|
||||
<a-table-column
|
||||
v-for="field in displayFields"
|
||||
:key="field.key"
|
||||
:title="field.listLabel ?? field.label"
|
||||
:width="columnWidth(field)"
|
||||
ellipsis
|
||||
tooltip
|
||||
>
|
||||
<template #cell="{ record }">
|
||||
<IdentityText
|
||||
v-if="(field.type === 'identity' || field.listCopyable) && fieldValue(field, record)"
|
||||
:value="fieldValue(field, record)"
|
||||
/>
|
||||
<template v-else>{{ displayResourceValue(definition, field.key, record[field.key] ?? record[`${field.key}_masked`]) }}</template>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="状态" :width="100">
|
||||
<template #cell="{ record }">
|
||||
<a-tag :color="recordStatusColor(Number(record.status))">
|
||||
{{ recordStatusLabel(Number(record.status)) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</a-table-column>
|
||||
<a-table-column title="操作" :width="220" fixed="right">
|
||||
<template #cell="{ record }">
|
||||
<a-space>
|
||||
<a-button v-if="definition.name === 'staff_account'" size="mini" @click="viewCredentials(record)">查看资质</a-button>
|
||||
<a-button size="mini" @click="openRecord('detail', record)">详情</a-button>
|
||||
<a-tooltip v-if="definition.canEdit" :content="recordEditReason(definition, record) || '编辑记录'">
|
||||
<span>
|
||||
<a-button
|
||||
size="mini"
|
||||
:disabled="Boolean(recordEditReason(definition, record))"
|
||||
@click="openRecord('edit', record)"
|
||||
>编辑</a-button>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table-column>
|
||||
</template>
|
||||
</a-table>
|
||||
<div class="pagination">
|
||||
<a-pagination
|
||||
:total="total"
|
||||
:current="page"
|
||||
:page-size="pageSize"
|
||||
show-total
|
||||
show-jumper
|
||||
@change="changePage"
|
||||
/>
|
||||
</div>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { resourceApi } from '@/api/resource';
|
||||
import {
|
||||
displayResourceValue,
|
||||
recordEditReason,
|
||||
recordStatusColor,
|
||||
recordStatusLabel,
|
||||
} from '@/api/resource-display';
|
||||
import { recordRouteLocation } from '@/api/resource-navigation';
|
||||
import type { ResourceRow } from '@/api/resource-record-form';
|
||||
import type { ResourceField, ResourceUiDefinition } from '@/api/resources';
|
||||
import IdentityText from '@/components/IdentityText.vue';
|
||||
|
||||
const props = defineProps<{ definition: ResourceUiDefinition }>();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loading = ref(false);
|
||||
const page = ref(Math.max(1, Number(route.query.page) || 1));
|
||||
const pageSize = 50;
|
||||
const total = ref(0);
|
||||
const list = ref<ResourceRow[]>([]);
|
||||
const filters = reactive({ keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '' });
|
||||
const displayFields = computed(() =>
|
||||
props.definition.fields
|
||||
.filter((field) => !['identity', 'password', 'avatar', 'status'].includes(field.key))
|
||||
.slice(0, 6),
|
||||
);
|
||||
|
||||
/** 返回列表列宽。 */
|
||||
function columnWidth(field: ResourceField) {
|
||||
if (field.type === 'datetime' || field.type === 'date') return 180;
|
||||
if (field.type === 'money' || field.type === 'number') return 140;
|
||||
return field.type === 'textarea' ? 240 : 160;
|
||||
}
|
||||
|
||||
/** 返回兼容脱敏字段的文本值。 */
|
||||
function fieldValue(field: ResourceField, row: ResourceRow) {
|
||||
return String(row[field.key] ?? row[`${field.key}_masked`] ?? '');
|
||||
}
|
||||
|
||||
/** 加载当前列表和来源关系筛选。 */
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const serverFilters: Record<string, string> = filters.keyword ? { keyword: filters.keyword } : {};
|
||||
if (typeof route.query.owner_identity === 'string' && typeof route.query.relation_key === 'string')
|
||||
serverFilters[route.query.relation_key] = route.query.owner_identity;
|
||||
const result = await resourceApi.list<ResourceRow>(
|
||||
props.definition.resource,
|
||||
page.value,
|
||||
pageSize,
|
||||
serverFilters,
|
||||
);
|
||||
list.value = result.list;
|
||||
total.value = result.total;
|
||||
} catch (error) {
|
||||
Message.error((error as Error).message);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 同步列表查询参数。 */
|
||||
async function syncQuery() {
|
||||
const query: Record<string, string> = {};
|
||||
if (typeof route.query.owner_identity === 'string') query.owner_identity = route.query.owner_identity;
|
||||
if (typeof route.query.relation_key === 'string') query.relation_key = route.query.relation_key;
|
||||
if (page.value > 1) query.page = String(page.value);
|
||||
if (filters.keyword.trim()) query.keyword = filters.keyword.trim();
|
||||
await router.replace({ query });
|
||||
}
|
||||
|
||||
async function search() {
|
||||
page.value = 1;
|
||||
await syncQuery();
|
||||
await load();
|
||||
}
|
||||
|
||||
async function resetSearch() {
|
||||
filters.keyword = '';
|
||||
await search();
|
||||
}
|
||||
|
||||
/** 打开当前列表对应的正式新建页。 */
|
||||
function openCreate() {
|
||||
return router.push(recordRouteLocation(String(route.name), 'create', '', route.fullPath));
|
||||
}
|
||||
|
||||
/** 打开独立详情或编辑页。 */
|
||||
function openRecord(mode: 'detail' | 'edit', row: ResourceRow) {
|
||||
const reason = mode === 'edit' ? recordEditReason(props.definition, row) : '';
|
||||
if (reason) return Message.warning(reason);
|
||||
return router.push(
|
||||
recordRouteLocation(String(route.name), mode, String(row.identity ?? ''), route.fullPath),
|
||||
);
|
||||
}
|
||||
|
||||
/** 进入当前配送人员的资质列表并保留来源。 */
|
||||
function viewCredentials(row: ResourceRow) {
|
||||
return router.push({
|
||||
name: 'staff-credentials',
|
||||
query: {
|
||||
owner_identity: String(row.identity ?? ''),
|
||||
relation_key: 'staff_account_identity',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function changePage(next: number) {
|
||||
page.value = next;
|
||||
await syncQuery();
|
||||
await load();
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(() => props.definition.resource, async () => {
|
||||
page.value = 1;
|
||||
filters.keyword = '';
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workflow-alert { margin-bottom: 16px; }
|
||||
.list-toolbar { display: flex; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
|
||||
.pagination { display: flex; justify-content: flex-end; margin-top: 16px; }
|
||||
@media (max-width: 768px) { .list-toolbar { align-items: stretch; flex-direction: column; } }
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@
|
||||
:key="String(route.name)"
|
||||
:definition="definition"
|
||||
/>
|
||||
<CrudListPage
|
||||
<ResourceListPage
|
||||
v-else
|
||||
:key="String(route.name)"
|
||||
:definition="definition"
|
||||
@@ -20,8 +20,8 @@
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { getResource } from '@/api/resources';
|
||||
import CrudListPage from './CrudListPage.vue';
|
||||
import DeliveryProfilePage from './DeliveryProfilePage.vue';
|
||||
import ResourceListPage from './ResourceListPage.vue';
|
||||
import TreePage from './TreePage.vue';
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
Reference in New Issue
Block a user