功能:优化用户地址账户名称展示
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -56,3 +56,7 @@ tmp/
|
||||
caddy/bin/
|
||||
# apps-repository/
|
||||
scripts/fixtures/platform-demo-seed-result.json
|
||||
|
||||
# 后端本机运行日志与上传文件不得进入版本库。
|
||||
backend/api/logs/
|
||||
backend/api/runtime/
|
||||
|
||||
48
docs/操作日志_用户地址账户名称展示_20260811.md
Normal file
48
docs/操作日志_用户地址账户名称展示_20260811.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 用户地址账户名称展示操作日志
|
||||
|
||||
操作时间:2026-08-11
|
||||
|
||||
操作类型:扩展
|
||||
|
||||
影响模块:平台总后台用户地址列表
|
||||
|
||||
## 操作前状态
|
||||
|
||||
用户地址列表的“用户唯一标识”列直接显示截断标识,详情页则显示“账户名称+标识”,同一关系在两个页面的识别方式不一致。
|
||||
|
||||
## 具体操作
|
||||
|
||||
1. 为资源字段增加仅列表生效的关系名称展示配置。
|
||||
2. 用户地址列表列名改为“用户账户”,主内容显示账户名称。
|
||||
3. 名称悬停展示完整唯一标识,名称旁提供复制按钮。
|
||||
4. 关系名称缺失时降级显示完整唯一标识。
|
||||
5. 详情页及其他资源保持原展示规则。
|
||||
6. 新增静态回归检查和 v1.1 项目文档。
|
||||
|
||||
## 操作后状态
|
||||
|
||||
用户地址列表可以直接按账户名称识别所属用户,同时仍可查看、复制完整唯一标识。详情页继续显示账户名称和唯一标识。
|
||||
|
||||
## 代码变更
|
||||
|
||||
- `frontend/platform_admin/src/api/resources.ts`:新增字段级列表关系名称配置并应用于用户地址。
|
||||
- `frontend/platform_admin/src/views/shared/CrudListPage.vue`:新增配置驱动的关系名称列表分流及名称降级。
|
||||
- `frontend/platform_admin/src/views/shared/RelationNameText.vue`:新增关系名称、标识悬停与复制组件。
|
||||
- `frontend/platform_admin/src/views/shared/resource-list-field-display.ts`:拆分关系名称、标识读取和列宽判断,控制共享列表文件规模。
|
||||
- `frontend/platform_admin/scripts/check-user-address-relation-display.mjs`:新增静态回归检查。
|
||||
- `frontend/platform_admin/package.json`:新增检查命令。
|
||||
- `docs/项目文档_用户地址受控展示_v1.1.md`:新增版本化项目文档。
|
||||
|
||||
## 验证结果
|
||||
|
||||
- `pnpm.cmd user-address-display:check`:通过,账户名称、标识降级、悬停与复制入口均已覆盖。
|
||||
- `pnpm.cmd type:check`:通过。
|
||||
- 项目内 Biome 对本次前端文件执行定向检查:通过;仅报告项目现有的 Vue 模板识别告警,无错误。
|
||||
- `pnpm.cmd contract:check`:通过,共校验 48 个资源。
|
||||
- `pnpm.cmd build`:通过,Vite 成功构建 2611 个模块。
|
||||
- `git diff --check`:通过。
|
||||
- `http://localhost:5173`:服务可访问并启用 Vite 热更新;自动化浏览器无平台登录态,未读取受保护的真实用户地址列表。
|
||||
|
||||
## 风险评估
|
||||
|
||||
风险较低。新配置默认关闭且只应用于用户地址所属用户字段;不改变接口、权限、详情页或其他资源展示。关系数据加载失败时通过标识降级避免空白。
|
||||
60
docs/项目文档_用户地址受控展示_v1.1.md
Normal file
60
docs/项目文档_用户地址受控展示_v1.1.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 用户地址受控展示项目文档 v1.1
|
||||
|
||||
## 1. 项目概述
|
||||
|
||||
- 项目名称:平台总后台用户地址受控展示。
|
||||
- 实施范围:平台 API 的 `user_address` 资源,以及端口 5173 的平台总后台列表、详情和编辑页。
|
||||
- 主要功能:受控展示真实地址及坐标;用户地址列表以账户名称识别所属用户,并保留唯一标识查看与复制能力。
|
||||
- 技术栈:Go、Gin、GORM、Vue 3、TypeScript、Arco Design。
|
||||
|
||||
## 2. 目录结构
|
||||
|
||||
```text
|
||||
platforms/
|
||||
├── backend/api/internal/logic/platform/user/
|
||||
│ └── relation.go # 用户地址受控位置字段恢复
|
||||
├── frontend/platform_admin/
|
||||
│ ├── scripts/
|
||||
│ │ └── check-user-address-relation-display.mjs # 用户账户列表展示检查
|
||||
│ └── src/
|
||||
│ ├── api/resources.ts # 用户地址字段级列表展示配置
|
||||
│ └── views/shared/
|
||||
│ ├── CrudListPage.vue # 字段配置驱动的列表分流
|
||||
│ ├── RelationNameText.vue # 名称、标识悬停与复制组件
|
||||
│ └── resource-list-field-display.ts # 列表字段展示工具
|
||||
└── docs/
|
||||
└── 11-数据接口与安全.md # 用户地址资源级安全例外
|
||||
```
|
||||
|
||||
## 3. 核心文件说明
|
||||
|
||||
### 3.1 用户地址字段配置
|
||||
|
||||
`frontend/platform_admin/src/api/resources.ts` 通过 `listRelationNameOnly` 明确声明列表以关系名称展示。该配置只在用户地址的所属用户字段启用,详情页和其他资源不读取该规则。
|
||||
|
||||
### 3.2 列表展示组件
|
||||
|
||||
`RelationNameText.vue` 以账户名称作为列表主内容,悬停显示完整用户唯一标识,并提供独立复制按钮。关系选项尚未加载或账户名称缺失时,列表降级显示完整唯一标识,确保记录仍可定位。
|
||||
|
||||
## 4. 行为变化
|
||||
|
||||
| 场景 | 变更前 | 变更后 |
|
||||
| --- | --- | --- |
|
||||
| 用户地址列表列名 | 用户唯一标识 | 用户账户 |
|
||||
| 用户地址列表内容 | 截断的唯一标识 | 账户名称 |
|
||||
| 完整标识获取 | 点击标识复制 | 悬停查看、复制按钮复制 |
|
||||
| 关系名称缺失 | 显示原始标识 | 保持显示完整原始标识 |
|
||||
| 用户地址详情 | 名称与标识 | 保持不变 |
|
||||
| 其他资源关系字段 | 原有规则 | 保持不变 |
|
||||
|
||||
## 5. 安全与兼容性
|
||||
|
||||
- 不修改后端接口、数据库结构或敏感字段授权范围。
|
||||
- 唯一标识来自已授权列表响应,不新增敏感数据请求。
|
||||
- 字段级开关默认关闭,其他资源保持向下兼容。
|
||||
|
||||
## 6. 维护与验证
|
||||
|
||||
- 新增类似关系名称列表展示时,应显式配置字段,不按资源名称硬编码。
|
||||
- 执行 `pnpm user-address-display:check` 验证目标交互规则。
|
||||
- 执行 `pnpm type:check`、定向 Biome 检查和 `pnpm build` 验证前端。
|
||||
@@ -18,6 +18,7 @@
|
||||
"avatar-retry:check": "node scripts/check-avatar-upload-cache.mjs",
|
||||
"staff-organization:check": "node scripts/check-staff-organization-linkage.mjs",
|
||||
"staff-relations:check": "node scripts/check-staff-relation-policy.mjs",
|
||||
"user-address-display:check": "node scripts/check-user-address-relation-display.mjs",
|
||||
"audit:platform": "node scripts/check-backend-contract.mjs",
|
||||
"lint": "biome lint .",
|
||||
"lint:fix": "biome lint --write .",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* 功能:静态检查用户地址列表的账户名称展示、标识降级与复制入口。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const appRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function source(path) {
|
||||
return readFileSync(resolve(appRoot, path), 'utf8');
|
||||
}
|
||||
|
||||
function expectIncludes(content, marker, message) {
|
||||
if (!content.includes(marker)) throw new Error(`用户地址展示检查失败:${message}`);
|
||||
}
|
||||
|
||||
const resources = source('src/api/resources.ts');
|
||||
const listPage = source('src/views/shared/CrudListPage.vue');
|
||||
const relationName = source('src/views/shared/RelationNameText.vue');
|
||||
const fieldDisplay = source('src/views/shared/resource-list-field-display.ts');
|
||||
|
||||
expectIncludes(resources, "listLabel: '用户账户'", '列表列名未改为用户账户');
|
||||
expectIncludes(resources, 'listRelationNameOnly: true', '用户地址未启用账户名称展示');
|
||||
expectIncludes(listPage, '<RelationNameText', '列表未使用账户名称组件');
|
||||
expectIncludes(fieldDisplay, 'return match ? optionLabel(match) : identity;', '关系名称缺失时未降级显示标识');
|
||||
expectIncludes(relationName, '用户唯一标识:${identity}', '缺少完整标识悬停提示');
|
||||
expectIncludes(relationName, '@click.stop="copyIdentity"', '缺少标识复制入口');
|
||||
|
||||
console.log('用户地址展示检查通过:账户名称、标识降级、悬停与复制入口均已覆盖。');
|
||||
@@ -34,6 +34,7 @@ export type ResourceField = {
|
||||
required?: boolean;
|
||||
relation?: string;
|
||||
displayRelationLabel?: boolean;
|
||||
listRelationNameOnly?: boolean;
|
||||
displayPrecision?: number;
|
||||
emptyText?: string;
|
||||
placeholder?: string;
|
||||
@@ -358,7 +359,7 @@ export const resources: ResourceUiDefinition[] = [
|
||||
{ ...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, { staffRelation: { roles: 'context', lockPrefilled: true, showIdentityCopy: 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, emptyText: '未填写', placeholder: '未填写' }), f('longitude', { displayPrecision: 6, emptyText: '未填写', placeholder: '未填写' }), f('latitude', { displayPrecision: 6, emptyText: '未填写', placeholder: '未填写' }), f('is_default')]),
|
||||
define('user_address', '用户地址', 'writable', [relation('user_account_identity', '/user_account', true, { listLabel: '用户账户', listRelationNameOnly: true }), f('address', { required: true, emptyText: '未填写', placeholder: '未填写' }), f('longitude', { displayPrecision: 6, emptyText: '未填写', placeholder: '未填写' }), f('latitude', { displayPrecision: 6, emptyText: '未填写', placeholder: '未填写' }), f('is_default')]),
|
||||
define('user_service_relation', '用户服务关系', 'writable', [relation('user_account_identity', '/user_account', true), relation('gas_basic_identity', '/gas_basic'), relation('delivery_basic_identity', '/delivery_basic'), relation('staff_account_identity', '/staff_account', false, { staffRelation: { roles: ['installer', 'delivery', 'operations'], enabledOnly: true } })]),
|
||||
|
||||
define('producer_account', '生产商管理', 'writable', [f('producer_code', { required: true }), f('name', { required: true }), f('credit_code'), f('principal'), f('phone'), f('address'), f('username', { required: true }), f('password', { required: true }), f('display_name'), f('role_code'), f('remark')]),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<!--
|
||||
功能:展示标准资源列表,并将新建、详情和编辑入口导航到独立页面。
|
||||
版本:v2.1.0
|
||||
版本:v2.2.0
|
||||
-->
|
||||
<template>
|
||||
<a-card :title="listTitle" :bordered="false">
|
||||
@@ -42,10 +42,10 @@
|
||||
v-for="field in displayFields"
|
||||
:key="field.key"
|
||||
:title="field.listLabel ?? field.label"
|
||||
:width="columnWidth(field)"
|
||||
:width="resourceListColumnWidth(definition.name, field)"
|
||||
:align="isProtectedListAvatarField(definition.name, field.key) ? 'center' : 'left'"
|
||||
:ellipsis="!isProtectedListAvatarField(definition.name, field.key)"
|
||||
:tooltip="!isProtectedListAvatarField(definition.name, field.key)"
|
||||
:ellipsis="!isProtectedListAvatarField(definition.name, field.key) && !field.listRelationNameOnly"
|
||||
:tooltip="!isProtectedListAvatarField(definition.name, field.key) && !field.listRelationNameOnly"
|
||||
>
|
||||
<template #cell="{ record }">
|
||||
<ProtectedAvatarThumbnail
|
||||
@@ -56,6 +56,11 @@
|
||||
:refresh-key="avatarRefreshKey"
|
||||
:loader="avatarLoader"
|
||||
/>
|
||||
<RelationNameText
|
||||
v-else-if="field.listRelationNameOnly && identityFieldValue(field, record)"
|
||||
:name="relationListName(field, record, relations.options)"
|
||||
:identity="identityFieldValue(field, record)"
|
||||
/>
|
||||
<IdentityText
|
||||
v-else-if="field.type === 'identity' && !field.displayRelationLabel && identityFieldValue(field, record)"
|
||||
:value="identityFieldValue(field, record)"
|
||||
@@ -168,6 +173,12 @@ import type { ResourceField, ResourceUiDefinition } from '@/api/resources';
|
||||
import { useUserStore } from '@/store';
|
||||
import { useResourceRelations } from '../resource/use-resource-relations';
|
||||
import ProtectedAvatarThumbnail from './ProtectedAvatarThumbnail.vue';
|
||||
import RelationNameText from './RelationNameText.vue';
|
||||
import {
|
||||
identityFieldValue,
|
||||
relationListName,
|
||||
resourceListColumnWidth,
|
||||
} from './resource-list-field-display';
|
||||
import {
|
||||
createProtectedListAvatarLoader,
|
||||
isProtectedListAvatarField,
|
||||
@@ -446,23 +457,6 @@ async function changePage(next: number) {
|
||||
await load();
|
||||
}
|
||||
|
||||
function identityFieldValue(field: ResourceField, row: ResourceRow) {
|
||||
return String(row[field.key] ?? row[`${field.key}_masked`] ?? '');
|
||||
}
|
||||
|
||||
function columnWidth(field: ResourceField) {
|
||||
if (isProtectedListAvatarField(props.definition.name, field.key)) return 72;
|
||||
if (field.type === 'datetime' || field.type === 'date') return 180;
|
||||
if (field.type === 'money' || field.type === 'number') return 140;
|
||||
if (field.type === 'boolean') return 110;
|
||||
if (field.type === 'identity' || field.type === 'identity-list') return 240;
|
||||
if (field.type === 'textarea') return 280;
|
||||
if (field.key.includes('phone')) return 150;
|
||||
if (/(name|title|address|content|remark|reason|terms)$/.test(field.key))
|
||||
return 220;
|
||||
return 180;
|
||||
}
|
||||
|
||||
/** 使用资源静态选项或运行时平台角色名称显示列表字段。 */
|
||||
function displayListField(field: ResourceField, row: ResourceRow) {
|
||||
return displayResourceField(field, row, relations.options, fieldOptions);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<!-- 功能描述:列表以关系名称为主展示,并提供完整唯一标识的悬停查看与复制。版本:v1.0.0。 -->
|
||||
<template>
|
||||
<div class="relation-name-text">
|
||||
<a-tooltip :content="`用户唯一标识:${identity}`">
|
||||
<span class="relation-name">{{ displayName }}</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip content="复制用户唯一标识">
|
||||
<button
|
||||
class="copy-button"
|
||||
type="button"
|
||||
:aria-label="`复制用户唯一标识 ${identity}`"
|
||||
@click.stop="copyIdentity"
|
||||
>
|
||||
<icon-copy />
|
||||
</button>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { IconCopy } from '@arco-design/web-vue/es/icon';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{ name: string; identity: string }>();
|
||||
const displayName = computed(() => props.name || props.identity);
|
||||
|
||||
/** 复制完整用户唯一标识,并给出明确操作反馈。 */
|
||||
async function copyIdentity() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(props.identity);
|
||||
Message.success('用户唯一标识已复制');
|
||||
} catch {
|
||||
Message.error('复制失败,请从悬浮提示中复制');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.relation-name-text {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.relation-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--color-text-1);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
color: rgb(var(--primary-6));
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.copy-button:hover,
|
||||
.copy-button:focus-visible {
|
||||
background: var(--color-fill-2);
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 功能描述:集中处理标准资源列表字段的关系名称、唯一标识和列宽展示。
|
||||
* 版本:v1.0.0
|
||||
*/
|
||||
import { optionLabel } from '@/api/resource-display';
|
||||
import type { ResourceRow } from '@/api/resource-page-rules';
|
||||
import type { ResourceField } from '@/api/resources';
|
||||
import { isProtectedListAvatarField } from './protected-list-avatar-loader';
|
||||
|
||||
/** 读取关系列表字段的完整唯一标识。 */
|
||||
export function identityFieldValue(field: ResourceField, row: ResourceRow) {
|
||||
return String(row[field.key] ?? row[`${field.key}_masked`] ?? '');
|
||||
}
|
||||
|
||||
/** 获取列表关系的可读名称,关系未加载时降级为完整唯一标识。 */
|
||||
export function relationListName(
|
||||
field: ResourceField,
|
||||
row: ResourceRow,
|
||||
relationOptions: Record<string, ResourceRow[]>,
|
||||
) {
|
||||
const identity = identityFieldValue(field, row);
|
||||
const match = (relationOptions[field.relation ?? ''] ?? []).find(
|
||||
(option) => String(option.identity) === identity,
|
||||
);
|
||||
return match ? optionLabel(match) : identity;
|
||||
}
|
||||
|
||||
/** 按字段类型返回标准列表列宽。 */
|
||||
export function resourceListColumnWidth(
|
||||
definitionName: string,
|
||||
field: ResourceField,
|
||||
) {
|
||||
if (isProtectedListAvatarField(definitionName, field.key)) return 72;
|
||||
if (field.type === 'datetime' || field.type === 'date') return 180;
|
||||
if (field.type === 'money' || field.type === 'number') return 140;
|
||||
if (field.type === 'boolean') return 110;
|
||||
if (field.type === 'identity' || field.type === 'identity-list') return 240;
|
||||
if (field.type === 'textarea') return 280;
|
||||
if (field.key.includes('phone')) return 150;
|
||||
if (/(name|title|address|content|remark|reason|terms)$/.test(field.key)) {
|
||||
return 220;
|
||||
}
|
||||
return 180;
|
||||
}
|
||||
Reference in New Issue
Block a user