From 4b1f045806ab094e5e10f344cc0ae8adf53f9ef9 Mon Sep 17 00:00:00 2001 From: czl231 <3286836406@qq.com> Date: Tue, 11 Aug 2026 14:28:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=B7=A5=E4=BD=9C?= =?UTF-8?q?=E4=BA=BA=E5=91=98=E7=BB=84=E7=BB=87=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/操作日志_工作人员组织联动_20260811.md | 68 ++++++ docs/项目文档_标准资源全页管理_v1.0.md | 11 +- frontend/platform_admin/package.json | 1 + .../check-staff-organization-linkage.mjs | 138 +++++++++++++ frontend/platform_admin/src/api/resources.ts | 11 +- .../views/resource/ResourceActionDialog.vue | 9 +- .../src/views/resource/ResourceFieldForm.vue | 6 +- .../src/views/resource/ResourceRecordPage.vue | 18 +- .../resource-relation-linkage-policy.ts | 76 +++++++ .../resource/use-resource-relation-linkage.ts | 195 ++++++++++++++++++ .../views/resource/use-resource-relations.ts | 115 ++++++++++- 11 files changed, 626 insertions(+), 22 deletions(-) create mode 100644 docs/操作日志_工作人员组织联动_20260811.md create mode 100644 frontend/platform_admin/scripts/check-staff-organization-linkage.mjs create mode 100644 frontend/platform_admin/src/views/resource/resource-relation-linkage-policy.ts create mode 100644 frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts diff --git a/docs/操作日志_工作人员组织联动_20260811.md b/docs/操作日志_工作人员组织联动_20260811.md new file mode 100644 index 0000000..8662c27 --- /dev/null +++ b/docs/操作日志_工作人员组织联动_20260811.md @@ -0,0 +1,68 @@ +# 工作人员气站与配送点联动操作日志 + +操作时间:2026-08-11 14:18:22 +操作类型:扩展 +影响模块:平台总后台工作人员新建与编辑 + +## 操作前状态 + +- 5173 平台总后台工作人员表单中的气站和配送点是两个独立下拉框,用户可以选出不属于同一组织的组合,只能在保存时收到通用参数错误。 +- 关联选项固定预加载前 100 条;编辑记录不在当前选项中时会回显原始唯一标识,搜索也无法携带父级气站条件。 +- 平台、气站和配送点后端已经具备最终组织一致性校验;平台配送点列表也已支持 `gas_basic_identities` 过滤,无需新增接口或收紧业务规则。 +- 工作人员角色与组织归属没有一一对应的必填或互斥规则,平台主管配送点允许不设置所属气站。 + +## 具体操作 + +- 在资源字段中新增可选 `relationLinkage` 配置,仅为工作人员的所属配送点启用,其他标准资源和业务动作保持独立选择。 +- 工作人员字段名称调整为“所属气站”和“所属配送点”,下拉继续提交稳定唯一标识。 +- 实现双向联动: + - 选择气站后以 `gas_basic_identities` 查询直属配送点。 + - 先选普通配送点时自动补载并回填所属气站。 + - 选择平台主管配送点时保持气站为空。 + - 更换或清空气站时,仅在已取得配送点记录并确认不兼容后清空配送点,同时给出明确提示。 +- 编辑初始化按唯一标识补载当前气站和配送点,不因前 100 条限制丢失中文回显;历史不一致组合只提示,不在加载阶段静默修改。 +- 配送点搜索始终复用当前气站条件并保留当前选项;增加请求版本控制,旧搜索或旧筛选结果不能覆盖新结果。 +- 关联请求失败时保留现有选项并显示错误,不把网络问题误判为组织关系变化。 +- 保存前增加友好的组织组合校验,后端现有一致性校验继续作为最终防线;保存失败仍保留表单和头像状态。 + +## 操作后状态 + +- 新增工作人员、安装人员、配送人员和运维人员入口共用同一联动能力,新建与编辑均已覆盖。 +- 气站下拉选定后,配送点下拉只展示该气站直属记录;配送点先选时可自动确定所属气站。 +- 平台直属配送点仍可在气站为空时选择;选定气站后不会混入其配送点范围。 +- 用户服务关系、配送合同、智能气阀归属和业务动作弹窗未启用本次配置,行为保持不变。 +- 5175 气站后台和 5176 配送点后台不在本次范围,继续由服务端登录组织限定可选范围。 +- 后端接口、数据库结构、角色规则和现有组织校验均未修改。 + +## 代码变更 + +- `frontend/platform_admin/src/api/resources.ts`:新增字段级关联联动元数据,并仅配置工作人员所属配送点。 +- `frontend/platform_admin/src/views/resource/ResourceFieldForm.vue`:关联字段搜索和变更事件携带完整字段配置。 +- `frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts`:新增父子关联的预加载、筛选、回填、清理、搜索和校验协调器。 +- `frontend/platform_admin/src/views/resource/resource-relation-linkage-policy.ts`:新增可测试的父子关系判断、筛选参数和请求版本策略。 +- `frontend/platform_admin/src/views/resource/use-resource-relations.ts`:扩展关联过滤、选中项补载、当前值保留和过期请求保护。 +- `frontend/platform_admin/src/views/resource/ResourceRecordPage.vue`:在新建与编辑页接入显式联动和保存前校验。 +- `frontend/platform_admin/src/views/resource/ResourceActionDialog.vue`:适配新的字段级搜索事件,保持业务动作不启用联动。 +- `frontend/platform_admin/scripts/check-staff-organization-linkage.mjs`、`package.json`:新增组织联动行为检查命令。 +- `docs/项目文档_标准资源全页管理_v1.0.md`:补充组织联动设计、行为和维护约定。 + +## 验证结果 + +- `npm.cmd run staff-organization:check`:通过;覆盖同气站保留、跨气站清除、气站清空、平台主管配送点、加载失败不误清、保存前提示、筛选参数和请求版本。 +- `npm.cmd run type:check`:通过。 +- `npm.cmd run resource-pages:check`:通过,详情 46 类、新建 25 类、编辑 23 类。 +- `npm.cmd run account-roles:check`:通过,既有角色中文展示未回退。 +- `npm.cmd run avatar-retry:check`:通过,保存失败头像重试缓存未回退。 +- `npm.cmd run contract:check`:通过,48 个资源契约一致。 +- `npm.cmd run build`:通过,Vite 生产构建完成。 +- 变更文件定向 `biome lint`:通过,无新增错误;全项目 `npm.cmd run lint` 仍被既有 2 个错误、153 个警告和 12 个提示阻断,本次未扩大范围清理历史诊断。 +- 本地浏览器只读回归:工作人员新建页显示新字段名称;选择“和气示例气站 10”后配送点范围只出现其直属“和气示例配送点 10”,选中后两项正确回显;页面控制台无错误或警告;未提交测试数据。 +- `git diff --check`:通过,未发现空白符错误。 + +## 风险评估 + +- 平台配送点筛选每次最多返回 100 条;用户仍可通过关键字继续搜索同一气站范围,当前已选记录会独立补载并保留。 +- 如果旧记录存在不一致组合,页面会提示并在保存前要求修正,但不会在打开页面时自动修改数据。 +- 无法加载配送点详情时前端不会主动清空;此时后端仍会在保存时执行最终组织校验。 +- 联动配置当前只允许一个显式子字段;若未来同一表单需要多组父子关系,应先扩展协调器结构并新增覆盖测试。 +- 本次不按角色新增组织必填规则,不改变平台主管配送点的数据语义,也不影响气站后台、配送点后台或其他资源。 diff --git a/docs/项目文档_标准资源全页管理_v1.0.md b/docs/项目文档_标准资源全页管理_v1.0.md index abc705f..310aea0 100644 --- a/docs/项目文档_标准资源全页管理_v1.0.md +++ b/docs/项目文档_标准资源全页管理_v1.0.md @@ -31,7 +31,8 @@ platforms/ ├── frontend/platform_admin/ │ ├── scripts/ │ │ ├── check-backend-contract.mjs # 前后端资源契约检查 -│ │ └── check-resource-pages.mjs # 独立页面覆盖与抽屉残留检查 +│ │ ├── check-resource-pages.mjs # 独立页面覆盖与抽屉残留检查 +│ │ └── check-staff-organization-linkage.mjs # 工作人员组织联动检查 │ └── src/ │ ├── api/ │ │ ├── resource-page-rules.ts # 新建、编辑、只读和状态限制规则 @@ -44,7 +45,7 @@ platforms/ │ │ ├── finance-route.ts # 钱包与财务路由分组 │ │ └── platform.ts # 平台业务列表路由 │ └── views/ -│ ├── resource/ # 共享全页详情、表单、动作和摘要组件 +│ ├── resource/ # 共享全页详情、表单、动作、联动和摘要组件 │ └── shared/ │ ├── CrudListPage.vue # 列表与全页跳转入口 │ ├── ProtectedAvatarThumbnail.vue # 受控头像缩略图 @@ -89,6 +90,9 @@ platforms/ - 必填、密码最短 6 位、关系选项和金额转换复用统一校验与负载构建逻辑。 - 页面检测表单和头像变更;返回、取消或浏览器路由离开时均提示是否放弃未保存内容。 - 机构“账户管理”不再打开二级模态框,而是进入带机构过滤条件的隐藏账户列表及其独立 CRUD 页面。 +- 工作人员表单通过字段级可选配置启用气站与配送点联动:选择气站后只查询其直属配送点,先选普通配送点时自动回填所属气站;平台主管配送点仅在气站为空时可选。 +- 更换或清空气站时只在已确认配送点不兼容后清空当前值;关联数据加载失败会保留现值,编辑记录不在列表前 100 条时按唯一标识补载。 +- 配送点搜索始终携带当前气站条件,并以请求版本丢弃过期响应;未配置联动的用户服务关系、合同、产品和业务动作保持原行为。 ### 4.4 安全与兼容 @@ -112,6 +116,7 @@ platforms/ | 返回来源 | 关闭抽屉 | 安全 `return_to` 返回 | | 未保存修改 | 关闭即丢失 | 离开前二次确认 | | 账户列表头像 | 普通文本列固定显示“-” | 当前可视记录显示受控圆形缩略图,无头像回退默认图 | +| 工作人员组织选择 | 气站和配送点独立选择,保存时才发现不匹配 | 双向联动、按气站筛选、切换时清除不兼容配送点 | ## 6. 验证方法 @@ -119,6 +124,7 @@ platforms/ ```powershell npm.cmd run resource-pages:check +npm.cmd run staff-organization:check npm.cmd run account-roles:check npm.cmd run contract:check npm.cmd run build @@ -138,6 +144,7 @@ go test ./... - 服务端 Update DTO 变化时必须同步 `resource-page-rules.ts`,禁止直接把创建字段复用于更新。 - 新增详情专属内容时优先扩展详情区块或动作适配器,不把长流程塞回列表。 - 新增关联跳转时必须使用安全返回路径工具,不直接信任查询参数。 +- 新增父子关联表单时必须显式配置 `relationLinkage`,不得按关联资源地址做全局推断;涉及强制业务规则时应同步增加后端专用校验。 - 新增标准资源后必须运行页面覆盖检查,保证详情、新建和编辑能力与资源模式一致。 ## 8. 已知边界 diff --git a/frontend/platform_admin/package.json b/frontend/platform_admin/package.json index cc0358f..7e88197 100644 --- a/frontend/platform_admin/package.json +++ b/frontend/platform_admin/package.json @@ -16,6 +16,7 @@ "resource-pages:check": "node scripts/check-resource-pages.mjs", "account-roles:check": "node scripts/check-account-role-presentation.mjs", "avatar-retry:check": "node scripts/check-avatar-upload-cache.mjs", + "staff-organization:check": "node scripts/check-staff-organization-linkage.mjs", "audit:platform": "node scripts/check-backend-contract.mjs", "lint": "biome lint .", "lint:fix": "biome lint --write .", diff --git a/frontend/platform_admin/scripts/check-staff-organization-linkage.mjs b/frontend/platform_admin/scripts/check-staff-organization-linkage.mjs new file mode 100644 index 0000000..7b0b9e9 --- /dev/null +++ b/frontend/platform_admin/scripts/check-staff-organization-linkage.mjs @@ -0,0 +1,138 @@ +/** + * 功能:验证工作人员气站与配送点联动策略及页面隔离配置。 + * 版本:v1.0.0 + */ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { transformWithOxc } from 'vite'; + +const policyURL = new URL( + '../src/views/resource/resource-relation-linkage-policy.ts', + import.meta.url, +); +const source = await readFile(policyURL, 'utf8'); +const transformed = await transformWithOxc(source, policyURL.pathname); +const moduleURL = `data:text/javascript;base64,${Buffer.from(transformed.code).toString('base64')}`; +const { + createRelationRequestVersionGuard, + linkedRelationFilters, + linkedRelationValidationMessage, + optionParentIdentity, + shouldClearLinkedOption, +} = await import(moduleURL); + +const gasA = 'gas-a'; +const gasB = 'gas-b'; +const deliveryA = { identity: 'delivery-a', gas_basic_identity: gasA }; +const platformDelivery = { + identity: 'delivery-platform', + gas_basic_identity: '', +}; + +assert.equal( + optionParentIdentity(deliveryA, 'gas_basic_identity'), + gasA, + '普通配送点必须能回填所属气站', +); +assert.equal( + optionParentIdentity(platformDelivery, 'gas_basic_identity'), + '', + '平台主管配送点必须保持气站为空', +); + +assert.equal( + shouldClearLinkedOption(gasA, deliveryA, 'gas_basic_identity'), + false, + '同一气站的配送点必须保留', +); +assert.equal( + shouldClearLinkedOption(gasB, deliveryA, 'gas_basic_identity'), + true, + '切换到其他气站必须清空原配送点', +); +assert.equal( + shouldClearLinkedOption('', deliveryA, 'gas_basic_identity'), + true, + '清空气站时必须清空普通配送点', +); +assert.equal( + shouldClearLinkedOption('', platformDelivery, 'gas_basic_identity'), + false, + '清空气站时允许保留平台主管配送点', +); +assert.equal( + shouldClearLinkedOption(gasA, platformDelivery, 'gas_basic_identity'), + true, + '选择气站时平台主管配送点不应作为其直属配送点保留', +); +assert.equal( + shouldClearLinkedOption(gasA, undefined, 'gas_basic_identity'), + false, + '无法加载配送点记录时不得误清当前值', +); + +assert.match( + linkedRelationValidationMessage( + gasB, + 'delivery-a', + deliveryA, + 'gas_basic_identity', + ), + /不属于所属气站/, +); +assert.match( + linkedRelationValidationMessage( + gasA, + 'delivery-platform', + platformDelivery, + 'gas_basic_identity', + ), + /平台主管配送点/, +); +assert.match( + linkedRelationValidationMessage( + '', + 'delivery-a', + deliveryA, + 'gas_basic_identity', + ), + /确认所属气站/, +); +assert.equal( + linkedRelationValidationMessage( + gasA, + 'delivery-a', + deliveryA, + 'gas_basic_identity', + ), + '', +); +assert.deepEqual(linkedRelationFilters(gasA, 'gas_basic_identities'), { + gas_basic_identities: gasA, +}); +assert.equal( + linkedRelationFilters('', 'gas_basic_identities'), + undefined, + '未选气站时允许查询全部配送点,包括平台主管配送点', +); + +const requestGuard = createRelationRequestVersionGuard(); +const firstRequest = requestGuard.begin('/delivery_basic'); +const secondRequest = requestGuard.begin('/delivery_basic'); +assert.equal(requestGuard.isLatest('/delivery_basic', firstRequest), false); +assert.equal(requestGuard.isLatest('/delivery_basic', secondRequest), true); + +const resourcesSource = await readFile( + new URL('../src/api/resources.ts', import.meta.url), + 'utf8', +); +assert.equal( + (resourcesSource.match(/relationLinkage:/g) ?? []).length, + 1, + '联动配置只能显式启用于工作人员配送点字段', +); +assert.match(resourcesSource, /filterKey: 'gas_basic_identities'/); +assert.match(resourcesSource, /label: '所属气站'/); +assert.match(resourcesSource, /label: '所属配送点'/); + +console.log('工作人员气站与配送点联动检查通过'); diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index 3b6858d..aa4e898 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -18,6 +18,14 @@ export type ResourceFieldType = | 'textarea' | 'select'; +/** 关联下拉的父子联动配置;未配置的资源继续保持独立选择。 */ +export type ResourceRelationLinkage = { + parentKey: string; + optionParentKey: string; + filterKey: string; + backfillParent?: boolean; +}; + export type ResourceField = { key: string; label: string; @@ -32,6 +40,7 @@ export type ResourceField = { defaultValue?: string | number | boolean; readonlyOnCreate?: boolean; unknownValueLabel?: string; + relationLinkage?: ResourceRelationLinkage; }; export type DetailAction = { @@ -339,7 +348,7 @@ export const resources: ResourceUiDefinition[] = [ define('gas_account', '气站账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), fixedAdminRole('气站管理员'), relation('gas_basic_identity', '/gas_basic', true)]), { ...define('delivery_basic', '配送点管理', 'writable', [f('delivery_code', { required: true }), f('name', { required: true }), f('gas_basic_identity', { label: '气站', listLabel: '气站名称', type: 'identity', relation: '/gas_basic', displayRelationLabel: true, emptyText: '平台直属', placeholder: '请选择气站,留空表示平台直属' }), f('principal'), f('address')]), accountManagement: { resource: '/delivery_account', relationKey: 'delivery_basic_identity', title: '配送点账户' }, walletOwnerType: 'delivery' }, define('delivery_account', '配送点账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), fixedAdminRole('配送点管理员'), relation('delivery_basic_identity', '/delivery_basic', true)]), - { ...define('staff_account', '工作人员', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('role_code', { required: true, type: 'select', options: [{ label: '安装人员', value: 'installer' }, { label: '配送人员', value: 'delivery' }, { label: '运维人员', value: 'operations' }] }), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), f('work_status', { type: 'select', options: [{ label: '在岗', value: 'on_duty' }, { label: '离岗', value: 'off_duty' }] })]), walletOwnerType: 'staff' }, + { ...define('staff_account', '工作人员', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('role_code', { required: true, type: 'select', options: [{ label: '安装人员', value: 'installer' }, { label: '配送人员', value: 'delivery' }, { label: '运维人员', value: 'operations' }] }), f('gas_basic_identity', { label: '所属气站', type: 'identity', relation: '/gas_basic' }), f('delivery_basic_identity', { label: '所属配送点', type: 'identity', relation: '/delivery_basic', relationLinkage: { parentKey: 'gas_basic_identity', optionParentKey: 'gas_basic_identity', filterKey: 'gas_basic_identities', backfillParent: true } }), f('work_status', { type: 'select', options: [{ label: '在岗', value: 'on_duty' }, { label: '离岗', value: 'off_duty' }] })]), walletOwnerType: 'staff' }, define('staff_credential', '工作人员资质', 'writable', [relation('staff_account_identity', '/staff_account', true), f('credential_type', { required: true }), f('credential_no'), f('expired_at')]), { ...define('user_account', '用户账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('name', { required: true }), f('phone'), f('avatar'), f('real_name')]), walletOwnerType: 'user' }, define('user_address', '用户地址', 'writable', [relation('user_account_identity', '/user_account', true), f('address', { required: true }), f('longitude'), f('latitude'), f('is_default')]), diff --git a/frontend/platform_admin/src/views/resource/ResourceActionDialog.vue b/frontend/platform_admin/src/views/resource/ResourceActionDialog.vue index 3ad495c..0322756 100644 --- a/frontend/platform_admin/src/views/resource/ResourceActionDialog.vue +++ b/frontend/platform_admin/src/views/resource/ResourceActionDialog.vue @@ -25,7 +25,7 @@ :required-keys="requiredKeys" :relation-options="relations.options" :relation-loading="relations.loading" - @search-relation="relations.search" + @search-relation="searchRelation" /> @@ -37,7 +37,7 @@ import { computed, reactive, ref, watch } from 'vue'; import { buildResourcePayload, isMissingField } from '@/api/resource-form'; import { resourceApi } from '@/api/resource'; import { platformApi } from '@/api/platform'; -import type { DetailAction } from '@/api/resources'; +import type { DetailAction, ResourceField } from '@/api/resources'; import type { ResourceRow } from '@/api/resource-page-rules'; import ResourceFieldForm from './ResourceFieldForm.vue'; import { useResourceRelations } from './use-resource-relations'; @@ -70,6 +70,11 @@ const ownershipKeys = [ 'user_account_identity', ]; +/** 业务动作字段不启用记录页联动,只沿用普通关联搜索。 */ +function searchRelation(field: ResourceField, keyword: string) { + relations.search(field.relation, keyword); +} + watch( () => [props.visible, props.action?.name] as const, async ([visible]) => { diff --git a/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue b/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue index 6f20017..8858804 100644 --- a/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue +++ b/frontend/platform_admin/src/views/resource/ResourceFieldForm.vue @@ -76,7 +76,8 @@ :loading="relationLoading[field.relation ?? '']" allow-clear allow-search - @search="(value: string) => emit('search-relation', field.relation, value)" + @change="(value: unknown) => emit('change-relation', field, value)" + @search="(value: string) => emit('search-relation', field, value)" > (); const model = defineModel>({ required: true }); const disabledSet = computed(() => new Set(props.disabledKeys)); diff --git a/frontend/platform_admin/src/views/resource/ResourceRecordPage.vue b/frontend/platform_admin/src/views/resource/ResourceRecordPage.vue index f1373a8..0591b69 100644 --- a/frontend/platform_admin/src/views/resource/ResourceRecordPage.vue +++ b/frontend/platform_admin/src/views/resource/ResourceRecordPage.vue @@ -103,7 +103,8 @@ :relation-options="relations.options" :relation-loading="relations.loading" :role-options="roleOptions" - @search-relation="relations.search" + @change-relation="relationLinkage.change" + @search-relation="relationLinkage.search" />
@@ -169,6 +170,7 @@ import ResourceFieldForm from './ResourceFieldForm.vue'; import ResourceWalletSummary from './ResourceWalletSummary.vue'; import { createResourceRecordNavigation } from './resource-record-navigation'; import { useResourceAvatar } from './use-resource-avatar'; +import { useResourceRelationLinkage } from './use-resource-relation-linkage'; import { useResourceRelations } from './use-resource-relations'; import { useUnsavedRecord } from './use-unsaved-record'; @@ -198,6 +200,7 @@ const fieldOptions = reactive< Record> >({}); const relations = useResourceRelations(); +const relationLinkage = useResourceRelationLinkage(form, relations); const actionVisible = ref(false); const activeAction = ref(); const avatar = useResourceAvatar(); @@ -357,9 +360,11 @@ async function initialize() { } async function loadRelationsAndRoles() { - await relations.preload( - mode.value === 'detail' ? definition.value.fields : formFields.value, - ); + if (mode.value === 'detail') { + await relations.preload(definition.value.fields); + } else { + await relationLinkage.preload(formFields.value); + } if ( definition.value.fields.some((field) => field.key === 'platform_role_code') ) { @@ -410,6 +415,11 @@ async function save() { Message.warning(validationError); return; } + const linkageError = relationLinkage.validationMessage(); + if (linkageError) { + Message.warning(linkageError); + return; + } saving.value = true; try { const payload = buildResourcePayload( diff --git a/frontend/platform_admin/src/views/resource/resource-relation-linkage-policy.ts b/frontend/platform_admin/src/views/resource/resource-relation-linkage-policy.ts new file mode 100644 index 0000000..904e091 --- /dev/null +++ b/frontend/platform_admin/src/views/resource/resource-relation-linkage-policy.ts @@ -0,0 +1,76 @@ +/** + * 功能:提供父子关联下拉的纯业务判断,避免页面层重复推断组织归属。 + * 版本:v1.0.0 + */ +import type { ResourceRow } from '@/api/resource-page-rules'; + +/** 将表单或关联记录中的标识规范化为空字符串或稳定字符串。 */ +export function relationIdentity(value: unknown) { + return value == null ? '' : String(value).trim(); +} + +/** 读取子选项记录声明的父级标识。 */ +export function optionParentIdentity( + option: ResourceRow | undefined, + optionParentKey: string, +) { + return option ? relationIdentity(option[optionParentKey]) : ''; +} + +/** + * 仅在已取得子选项记录且父子关系明确不兼容时返回 true。 + * 父级为空时只允许平台主管子项;父级有值时只允许其直属子项。 + */ +export function shouldClearLinkedOption( + parentIdentity: string, + option: ResourceRow | undefined, + optionParentKey: string, +) { + if (!option) return false; + const optionParent = optionParentIdentity(option, optionParentKey); + return parentIdentity ? optionParent !== parentIdentity : Boolean(optionParent); +} + +/** 返回父子组合的保存前提示;无法取得子选项时交由后端最终校验。 */ +export function linkedRelationValidationMessage( + parentIdentity: string, + childIdentity: string, + option: ResourceRow | undefined, + optionParentKey: string, +) { + if (!childIdentity || !option) return ''; + const optionParent = optionParentIdentity(option, optionParentKey); + if (parentIdentity && !optionParent) { + return '平台主管配送点不能与所属气站同时选择'; + } + if (parentIdentity && optionParent !== parentIdentity) { + return '所选配送点不属于所属气站,请重新选择'; + } + if (!parentIdentity && optionParent) { + return '所选配送点已有所属气站,请确认所属气站'; + } + return ''; +} + +/** 根据当前父级生成服务端筛选条件;父级为空时保留全量可选范围。 */ +export function linkedRelationFilters( + parentIdentity: string, + filterKey: string, +) { + return parentIdentity ? { [filterKey]: parentIdentity } : undefined; +} + +/** 为同一关联资源生成递增版本号,用于识别并丢弃过期响应。 */ +export function createRelationRequestVersionGuard() { + const versions = new Map(); + return { + begin(resource: string) { + const version = (versions.get(resource) ?? 0) + 1; + versions.set(resource, version); + return version; + }, + isLatest(resource: string, version: number) { + return versions.get(resource) === version; + }, + }; +} diff --git a/frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts b/frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts new file mode 100644 index 0000000..dfc0c67 --- /dev/null +++ b/frontend/platform_admin/src/views/resource/use-resource-relation-linkage.ts @@ -0,0 +1,195 @@ +/** + * 功能:协调资源表单中显式启用的父子关联下拉、搜索和保存前校验。 + * 版本:v1.0.0 + */ +import { Message } from '@arco-design/web-vue'; +import type { ResourceField } from '@/api/resources'; +import { + linkedRelationFilters, + linkedRelationValidationMessage, + optionParentIdentity, + relationIdentity, + shouldClearLinkedOption, +} from './resource-relation-linkage-policy'; +import type { ResourceRelations } from './use-resource-relations'; + +type ActiveLinkage = { + childField: ResourceField; + parentField?: ResourceField; +}; + +export function useResourceRelationLinkage( + form: Record, + relations: ResourceRelations, +) { + let active: ActiveLinkage | undefined; + let initialized = false; + + function findOption(resource: string, identity: string) { + return (relations.options[resource] ?? []).find( + (row) => relationIdentity(row.identity) === identity, + ); + } + + function configure(fields: ResourceField[]) { + const childField = fields.find((field) => field.relationLinkage); + if (!childField?.relationLinkage) { + active = undefined; + return; + } + active = { + childField, + parentField: fields.find( + (field) => field.key === childField.relationLinkage?.parentKey, + ), + }; + } + + function childRequest() { + if (!active?.childField.relationLinkage) return {}; + const linkage = active.childField.relationLinkage; + const parentIdentity = relationIdentity(form[linkage.parentKey]); + const childIdentity = relationIdentity(form[active.childField.key]); + return { + filters: linkedRelationFilters(parentIdentity, linkage.filterKey), + preserveIdentities: childIdentity ? [childIdentity] : [], + }; + } + + async function reloadChildOptions() { + const resource = active?.childField.relation; + if (!resource) return; + relations.cancelSearch(resource); + await relations.load(resource, '', childRequest()); + } + + /** 预加载普通关联项,并单独按父级条件加载启用联动的子选项。 */ + async function preload(fields: ResourceField[]) { + initialized = false; + configure(fields); + if (!active?.childField.relationLinkage || !active.childField.relation) { + await relations.preload(fields); + initialized = true; + return; + } + + await relations.preload( + fields.filter((field) => field.key !== active?.childField.key), + ); + const linkage = active.childField.relationLinkage; + const parentIdentity = relationIdentity(form[linkage.parentKey]); + const childIdentity = relationIdentity(form[active.childField.key]); + if (parentIdentity && active.parentField?.relation) { + await relations.ensure(active.parentField.relation, parentIdentity); + } + if (childIdentity) { + await relations.ensure(active.childField.relation, childIdentity); + } + await reloadChildOptions(); + initialized = true; + + const warning = validationMessage(); + if (warning) Message.warning(`当前组织归属需要确认:${warning}`); + } + + async function handleParentChange(parentIdentity: string) { + if (!active?.childField.relationLinkage || !active.childField.relation) + return; + const linkage = active.childField.relationLinkage; + const childIdentity = relationIdentity(form[active.childField.key]); + if (childIdentity) { + const option = + findOption(active.childField.relation, childIdentity) ?? + (await relations.ensure(active.childField.relation, childIdentity)); + if ( + relationIdentity(form[linkage.parentKey]) !== parentIdentity || + relationIdentity(form[active.childField.key]) !== childIdentity + ) { + return; + } + if ( + shouldClearLinkedOption( + parentIdentity, + option, + linkage.optionParentKey, + ) + ) { + form[active.childField.key] = ''; + Message.info('所属气站已变更,请重新选择配送点'); + } + } + await reloadChildOptions(); + } + + async function handleChildChange(childIdentity: string) { + if (!active?.childField.relationLinkage || !active.childField.relation) + return; + const linkage = active.childField.relationLinkage; + if (!childIdentity) return; + const option = + findOption(active.childField.relation, childIdentity) ?? + (await relations.ensure(active.childField.relation, childIdentity)); + if ( + !option || + relationIdentity(form[active.childField.key]) !== childIdentity + ) { + return; + } + if (linkage.backfillParent) { + const nextParent = optionParentIdentity(option, linkage.optionParentKey); + const currentParent = relationIdentity(form[linkage.parentKey]); + if (nextParent && active.parentField?.relation) { + await relations.ensure(active.parentField.relation, nextParent); + if (relationIdentity(form[active.childField.key]) !== childIdentity) { + return; + } + } + if (currentParent !== nextParent) { + form[linkage.parentKey] = nextParent; + if (!nextParent && currentParent) { + Message.info('平台主管配送点不隶属于气站,已清空所属气站'); + } + } + } + await reloadChildOptions(); + } + + /** 响应用户主动修改关联字段;初始化回显不会触发自动清理。 */ + async function change(field: ResourceField, value: unknown) { + if (!initialized || !active?.childField.relationLinkage) return; + const identity = relationIdentity(value); + if (field.key === active.childField.relationLinkage.parentKey) { + await handleParentChange(identity); + } else if (field.key === active.childField.key) { + await handleChildChange(identity); + } + } + + /** 搜索联动子项时始终保留父级过滤和当前选项。 */ + function search(field: ResourceField, keyword: string) { + if (!field.relation) return; + if (field.key === active?.childField.key) { + relations.search(field.relation, keyword, childRequest()); + return; + } + relations.search(field.relation, keyword); + } + + /** 返回保存前的父子关系错误,空字符串代表当前组合可提交。 */ + function validationMessage() { + if (!active?.childField.relationLinkage || !active.childField.relation) + return ''; + const linkage = active.childField.relationLinkage; + const parentIdentity = relationIdentity(form[linkage.parentKey]); + const childIdentity = relationIdentity(form[active.childField.key]); + const option = findOption(active.childField.relation, childIdentity); + return linkedRelationValidationMessage( + parentIdentity, + childIdentity, + option, + linkage.optionParentKey, + ); + } + + return { preload, change, search, validationMessage }; +} diff --git a/frontend/platform_admin/src/views/resource/use-resource-relations.ts b/frontend/platform_admin/src/views/resource/use-resource-relations.ts index 85ac62b..a90cbfb 100644 --- a/frontend/platform_admin/src/views/resource/use-resource-relations.ts +++ b/frontend/platform_admin/src/views/resource/use-resource-relations.ts @@ -3,29 +3,95 @@ * 版本:v1.0.0 */ import { Message } from '@arco-design/web-vue'; -import { reactive } from 'vue'; +import { onBeforeUnmount, reactive } from 'vue'; import { resourceApi } from '@/api/resource'; import type { ResourceField } from '@/api/resources'; import type { ResourceRow } from '@/api/resource-page-rules'; +import { createRelationRequestVersionGuard } from './resource-relation-linkage-policy'; + +export type ResourceRelationLoadOptions = { + filters?: Record; + preserveIdentities?: string[]; +}; export function useResourceRelations() { const options = reactive>({}); const loading = reactive>({}); const timers = new Map>(); + const requestGuard = createRelationRequestVersionGuard(); - /** 加载一类关联资源,工作人员关系默认只选择配送人员。 */ - async function load(resource: string, keyword = '') { + /** 合并需要保留的当前选项,搜索和筛选时不让下拉框退回显示原始标识。 */ + function mergePreserved( + resource: string, + rows: ResourceRow[], + identities: string[] = [], + ) { + const merged = [...rows]; + const seen = new Set(rows.map((row) => String(row.identity ?? ''))); + for (const identity of identities.filter(Boolean)) { + if (seen.has(identity)) continue; + const current = (options[resource] ?? []).find( + (row) => String(row.identity ?? '') === identity, + ); + if (current) { + merged.push(current); + seen.add(identity); + } + } + return merged; + } + + /** 加载一类关联资源,并丢弃已经过期的搜索或筛选响应。 */ + async function load( + resource: string, + keyword = '', + request: ResourceRelationLoadOptions = {}, + ) { + const version = requestGuard.begin(resource); loading[resource] = true; try { - const filters: Record = keyword ? { keyword } : {}; + const filters: Record = { + ...(request.filters ?? {}), + ...(keyword ? { keyword } : {}), + }; if (resource === '/staff_account') filters.role_code = 'delivery'; - options[resource] = ( + const rows = ( await resourceApi.list(resource, 1, 100, filters) ).list; + if (!requestGuard.isLatest(resource, version)) return; + options[resource] = mergePreserved( + resource, + rows, + request.preserveIdentities, + ); } catch (error) { + if (!requestGuard.isLatest(resource, version)) return; Message.error(`关联数据加载失败:${(error as Error).message}`); } finally { - loading[resource] = false; + if (requestGuard.isLatest(resource, version)) { + loading[resource] = false; + } + } + } + + /** 按唯一标识补载当前选项,解决编辑记录不在列表前 100 条时的回显。 */ + async function ensure(resource: string, identity: string) { + if (!identity) return undefined; + const current = (options[resource] ?? []).find( + (row) => String(row.identity ?? '') === identity, + ); + if (current) return current; + try { + const row = await resourceApi.detail(resource, identity); + const resolved = (options[resource] ?? []).find( + (item) => String(item.identity ?? '') === identity, + ); + if (resolved) return resolved; + options[resource] = [...(options[resource] ?? []), row]; + return row; + } catch (error) { + Message.error(`关联数据加载失败:${(error as Error).message}`); + return undefined; } } @@ -40,15 +106,42 @@ export function useResourceRelations() { } /** 对远程关系选项进行防抖搜索。 */ - function search(resource: string | undefined, keyword: string) { + function search( + resource: string | undefined, + keyword: string, + request: ResourceRelationLoadOptions = {}, + ) { if (!resource) return; - const timer = timers.get(resource); - if (timer) clearTimeout(timer); + cancelSearch(resource); timers.set( resource, - setTimeout(() => load(resource, keyword.trim()), 250), + setTimeout(() => { + timers.delete(resource); + void load(resource, keyword.trim(), request); + }, 250), ); } - return { options, loading, load, preload, search }; + /** 取消关联资源尚未触发的防抖搜索。 */ + function cancelSearch(resource: string) { + const timer = timers.get(resource); + if (timer) clearTimeout(timer); + timers.delete(resource); + } + + onBeforeUnmount(() => { + for (const resource of timers.keys()) cancelSearch(resource); + }); + + return { + options, + loading, + load, + ensure, + preload, + search, + cancelSearch, + }; } + +export type ResourceRelations = ReturnType;