重构配送点独立资源页面

This commit is contained in:
czl231
2026-08-22 20:35:57 +08:00
parent da0185d1aa
commit 25254cb773
21 changed files with 1594 additions and 1285 deletions

View 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 可稳定收藏。
- 风险:支付和退款名称修正可能影响依赖旧错误名称的自定义代码。缓解:仅配送端定义改用后端正式名称,菜单地址不变,并由契约检查持续保护。

View 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 字段仍按原后端文本契约展示或填写。

View File

@@ -14,6 +14,7 @@
"contract:sync": "node scripts/sync-backend-contract.mjs", "contract:sync": "node scripts/sync-backend-contract.mjs",
"contract:check": "node scripts/check-backend-contract.mjs", "contract:check": "node scripts/check-backend-contract.mjs",
"profile:check": "node scripts/check-delivery-profile-page.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", "audit:platform": "node scripts/check-backend-contract.mjs",
"lint": "biome lint .", "lint": "biome lint .",
"lint:fix": "biome lint --write .", "lint:fix": "biome lint --write .",

View 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}`);

View 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 '';
}

View 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 });
}

View 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 '';
}

View File

@@ -1,3 +1,7 @@
/**
* 功能描述:定义配送点后台资源能力、字段契约和受控业务动作。
* 版本v1.1.0。
*/
export type ResourceMode = export type ResourceMode =
| 'writable' | 'writable'
| 'readonly' | 'readonly'
@@ -234,6 +238,12 @@ const fieldLabels: Record<string, string> = {
withdrawable: '计入可提现余额', withdrawable: '计入可提现余额',
}; };
/** 返回资源字段的中文名称,未知字段保留原键用于排障。 */
export function resourceFieldLabel(key: string) {
const normalized = key.endsWith('_masked') ? key.slice(0, -7) : key;
return fieldLabels[normalized] ?? normalized;
}
const numbers = new Set([ const numbers = new Set([
'sort_no', 'stock_quantity', 'quantity', 'score', 'version_no', 'sort_no', 'stock_quantity', 'quantity', 'score', 'version_no',
'attempt_no', 'target_product_status', 'attempt_no', 'target_product_status',
@@ -525,9 +535,9 @@ const deliveryOverrides: ResourceUiDefinition[] = [
]), ]),
define('wallet_basic', '钱包', 'readonly', []), define('wallet_basic', '钱包', 'readonly', []),
define('wallet_bank', '银行卡', 'readonly', []), define('wallet_bank', '银行卡', 'readonly', []),
define('wallet_payment', '支付记录', 'readonly', []), define('payment_order', '支付记录', 'readonly', []),
define('wallet_record', '钱包流水', '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_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('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', []), define('fin_settlement', '结算结果', 'readonly', []),

File diff suppressed because one or more lines are too long

View File

@@ -1,15 +1,10 @@
/**
* 功能描述:配置配送点后台菜单、列表与独立记录页面路由。
* 版本v1.1.0。
*/
import { DEFAULT_LAYOUT } from '../base'; import { DEFAULT_LAYOUT } from '../base';
import type { AppRouteRecordRaw } from '../types'; import type { AppRouteRecordRaw } from '../types';
import { child, createChild, group } from './resource-route-builder';
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,
});
const routes: AppRouteRecordRaw[] = [ 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: [ { 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'), child('profile', 'basic', '本点资料', '/delivery_profile', 'delivery_profile'),
]), ]),
group('staff', 'staff', '配送人员管理', 'icon-user-group', 30, [ 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', 'delivery', '配送人员列表', '/staff_account', 'staff_delivery'),
child('staff', 'credentials', '人员资质', '/staff_credential', 'staff', { hideInMenu: true, activeMenu: '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' }), child('contract', 'candidates', '合同可选气瓶', '/product_info', 'gasorder_contract', { hideInMenu: true, activeMenu: 'contract-contracts' }),
]), ]),
group('gasorder', 'gasorder', '配送订单', 'icon-list', 60, [ 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'), child('gasorder', 'orders', '订单列表', '/gasorder_basic', 'gasorder_basic'),
]), ]),
group('finance', 'finance', '财务管理', 'icon-bar-chart', 70, [ group('finance', 'finance', '财务管理', 'icon-bar-chart', 70, [
child('finance', 'wallet', '钱包', '/wallet_basic', 'wallet_basic'), child('finance', 'wallet', '钱包', '/wallet_basic', 'wallet_basic'),
child('finance', 'banks', '银行卡', '/wallet_bank', 'wallet_bank'), 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', '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', 'recharge', '钱包充值', '/wallet_recharge', 'wallet_recharge'),
child('finance', 'withdrawals', '提现申请', '/wallet_apply_cash', 'wallet_apply_cash'), child('finance', 'withdrawals', '提现申请', '/wallet_apply_cash', 'wallet_apply_cash'),
child('finance', 'settlements', '结算结果', '/fin_settlement', 'fin_settlement'), child('finance', 'settlements', '结算结果', '/fin_settlement', 'fin_settlement'),

View File

@@ -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', '编辑')] : []),
];
});
}

View File

@@ -1,3 +1,4 @@
/** 功能描述扩展配送点后台路由元数据类型。版本v1.1.0。 */
import 'vue-router'; import 'vue-router';
declare module 'vue-router' { declare module 'vue-router' {
@@ -6,6 +7,10 @@ declare module 'vue-router' {
menuCode?: string; // Server-assigned menu domain required by this route menuCode?: string; // Server-assigned menu domain required by this route
staffType?: 'installer' | 'delivery' | 'operations'; staffType?: 'installer' | 'delivery' | 'operations';
createMode?: boolean; 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) requiresAuth: boolean; // Whether login is required to access the current page (every route must declare)
icon?: string; // The icon show in the side menu icon?: string; // The icon show in the side menu
locale?: string; // The locale name show in side menu and breadcrumb locale?: string; // The locale name show in side menu and breadcrumb

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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; }
}

View File

@@ -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>

View File

@@ -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

View 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>

View File

@@ -9,7 +9,7 @@
:key="String(route.name)" :key="String(route.name)"
:definition="definition" :definition="definition"
/> />
<CrudListPage <ResourceListPage
v-else v-else
:key="String(route.name)" :key="String(route.name)"
:definition="definition" :definition="definition"
@@ -20,8 +20,8 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { getResource } from '@/api/resources'; import { getResource } from '@/api/resources';
import CrudListPage from './CrudListPage.vue';
import DeliveryProfilePage from './DeliveryProfilePage.vue'; import DeliveryProfilePage from './DeliveryProfilePage.vue';
import ResourceListPage from './ResourceListPage.vue';
import TreePage from './TreePage.vue'; import TreePage from './TreePage.vue';
const route = useRoute(); const route = useRoute();