feat: 统一账户角色展示并优化资源页面

This commit is contained in:
czl231
2026-08-11 02:01:00 +08:00
parent 9cf16ccb3c
commit ff345dc42a
28 changed files with 555 additions and 76 deletions

View File

@@ -14,6 +14,7 @@
"contract:sync": "node scripts/sync-backend-contract.mjs",
"contract:check": "node scripts/check-backend-contract.mjs",
"resource-pages:check": "node scripts/check-resource-pages.mjs",
"account-roles:check": "node scripts/check-account-role-presentation.mjs",
"audit:platform": "node scripts/check-backend-contract.mjs",
"lint": "biome lint .",
"lint:fix": "biome lint --write .",

View File

@@ -0,0 +1,74 @@
/**
* 功能:静态检查三套管理后台的账户角色中文展示与无效头像摘要规则。
* 版本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)), '..');
const frontendRoot = resolve(appRoot, '..');
function source(path) {
return readFileSync(resolve(frontendRoot, path), 'utf8');
}
function expectIncludes(content, marker, message) {
if (!content.includes(marker)) throw new Error(`账户角色检查失败:${message}`);
}
const platformResources = source('platform_admin/src/api/resources.ts');
const platformPage = source(
'platform_admin/src/views/resource/ResourceRecordPage.vue',
);
const platformSummary = source(
'platform_admin/src/views/resource/ResourceAccountSummary.vue',
);
expectIncludes(
platformResources,
"fixedAdminRole('气站管理员')",
'平台总后台缺少气站管理员中文映射',
);
expectIncludes(
platformResources,
"fixedAdminRole('配送点管理员')",
'平台总后台缺少配送点管理员中文映射',
);
expectIncludes(
platformResources,
"define('delivery_account', '配送点账户'",
'配送点账户名称未统一',
);
expectIncludes(
platformPage,
"mode.value !== 'create' || hasAvatarField.value",
'无头像能力的新建账户页仍可能显示无效占位摘要',
);
expectIncludes(
platformSummary,
'v-if="avatarEnabled" class="avatar-column"',
'无头像能力的账户仍可能显示装饰头像',
);
for (const app of ['gas_admin', 'delivery_admin']) {
const resources = source(`${app}/src/api/resources.ts`);
const listPage = source(`${app}/src/views/shared/CrudListPage.vue`);
expectIncludes(
resources,
"fixedAdminRole('配送点管理员')",
`${app} 缺少配送点管理员中文映射`,
);
expectIncludes(
listPage,
"'/gas_account': { admin: '气站管理员' }",
`${app} 缺少气站管理员显示规则`,
);
expectIncludes(
listPage,
"'/delivery_account': { admin: '配送点管理员' }",
`${app} 缺少配送点管理员显示规则`,
);
}
console.log('账户角色检查通过:三套后台中文角色、未知角色和头像摘要规则均已覆盖。');

View File

@@ -33,7 +33,7 @@ export type PlatformMenu = { identity: string; parent_identity?: string; group_c
export const platformApi = {
overview: () => request<DashboardOverview>('/dashboard/overview'),
listAccount: () => request<{ total: number; list: Record<string, unknown>[] }>('/platform_account'),
listRole: () => resourceApi.list<PlatformRole>('/platform_role'),
listRole: () => resourceApi.list<PlatformRole>('/platform_role', 1, 100),
createRole: (data: Record<string, unknown>) => resourceApi.create<PlatformRole>('/platform_role', data),
listMenu: () => request<{ total: number; list: PlatformMenu[] }>('/platform_menu'),
listRoleMenuIdentities: (identity: string) =>

View File

@@ -161,13 +161,21 @@ export function displayResourceField(
field: ResourceField,
row: ResourceRow,
relationOptions: Record<string, ResourceRow[]>,
fieldOptions: Record<string, Array<{ label: string; value: string | number }>> = {},
) {
const value = row[field.key] ?? row[`${field.key}_masked`];
if (value == null || value === '') return field.emptyText ?? '-';
const option = field.options?.find(
const hasOptionSource =
Object.prototype.hasOwnProperty.call(fieldOptions, field.key) ||
Boolean(field.options);
const options = fieldOptions[field.key] ?? field.options;
const option = options?.find(
(item) => String(item.value) === String(value),
);
if (option) return option.label;
if (hasOptionSource && field.unknownValueLabel) {
return `${field.unknownValueLabel}${String(value)}`;
}
if (field.type === 'identity' && typeof value === 'string') {
return relationLabel(field, value, relationOptions);
}

View File

@@ -33,14 +33,13 @@ const pageRules: Record<string, ResourcePageRule> = {
],
},
gas_account: {
editKeys: ['display_name', 'role_code', 'gas_basic_identity'],
editKeys: ['display_name', 'gas_basic_identity'],
accountSummary: true,
},
delivery_basic: {
editKeys: ['name', 'gas_basic_identity', 'principal', 'address'],
},
delivery_account: {
createHiddenKeys: ['role_code'],
editKeys: ['display_name', 'delivery_basic_identity'],
accountSummary: true,
},

View File

@@ -18,7 +18,7 @@ export function resetResourceRecordForm(
const value = row[field.key];
form[field.key] =
value == null
? undefined
? field.defaultValue
: field.type === 'money'
? Number(value) / 100
: value;

View File

@@ -29,6 +29,9 @@ export type ResourceField = {
emptyText?: string;
placeholder?: string;
options?: Array<{ label: string; value: string | number }>;
defaultValue?: string | number | boolean;
readonlyOnCreate?: boolean;
unknownValueLabel?: string;
};
export type DetailAction = {
@@ -68,7 +71,7 @@ const fieldLabels: Record<string, string> = {
username: '用户名',
password: '密码',
display_name: '显示名称',
role_code: '角色编码',
role_code: '角色',
platform_role_code: '平台角色',
credit_code: '统一社会信用代码',
principal: '负责人',
@@ -281,6 +284,20 @@ function relation(key: string, resource: string, required = false): ResourceFiel
return f(key, { type: 'identity', relation: resource, required });
}
/** 创建固定管理员角色字段,页面展示中文名称,接口仍使用稳定编码。 */
function fixedAdminRole(label: string): ResourceField {
return f('role_code', {
label: '角色',
listLabel: '角色',
type: 'select',
required: true,
options: [{ label, value: 'admin' }],
defaultValue: 'admin',
readonlyOnCreate: true,
unknownValueLabel: '未知角色',
});
}
function define(
name: string,
title: string,
@@ -319,9 +336,9 @@ const reason = [f('reason', { required: true })];
export const resources: ResourceUiDefinition[] = [
{ ...define('gas_basic', '气站管理', 'writable', [f('code', { required: true }), f('name', { required: true }), f('credit_code'), f('principal'), f('address'), f('longitude'), f('latitude')]), accountManagement: { resource: '/gas_account', relationKey: 'gas_basic_identity', title: '气站账户' }, walletOwnerType: 'gas' },
define('gas_account', '气站账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('role_code'), relation('gas_basic_identity', '/gas_basic', true)]),
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'), f('role_code'), relation('delivery_basic_identity', '/delivery_basic', true)]),
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_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' },
@@ -418,7 +435,7 @@ export const resources: ResourceUiDefinition[] = [
define('fin_reconciliation', '财务对账', 'readonly', []),
define('cms_content', '内容', 'writable', [f('content_type', { required: true }), f('title', { required: true }), f('body', { required: true }), f('version_no'), f('publish_status')]),
define('cs_ticket', '客服工单', 'writable', [relation('user_account_identity', '/user_account', true), f('ticket_no', { required: true }), f('category', { required: true }), f('priority', { required: true })]),
define('platform_account', '平台账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('avatar'), f('platform_role_code', { required: true }), f('phone')]),
define('platform_account', '平台账户', 'writable', [f('username', { required: true }), f('password', { required: true }), f('display_name'), f('avatar'), f('platform_role_code', { required: true, unknownValueLabel: '未知角色' }), f('phone')]),
define('platform_role', '平台角色', 'writable', [f('role_code', { required: true }), f('name', { required: true }), f('location_scope', { required: true, type: 'select', options: [{ label: '脱敏坐标', value: 'standard' }, { label: '精确坐标', value: 'precise' }] })], 'list', [
{ name: '分配菜单', resource: '/platform_role/:identity/menu', method: 'PUT', fields: [f('menu_identities', { type: 'identity-list', relation: '/platform_menu' })] },
]),

View File

@@ -1,11 +1,11 @@
<!--
功能展示账户类资源的头像用户名唯一标识和创建时间摘要
版本v1.0.0
版本v1.1.0
-->
<template>
<a-card class="account-card" :bordered="false">
<div class="account-summary">
<div class="avatar-column">
<div class="account-summary" :class="{ 'account-summary-text': !avatarEnabled }">
<div v-if="avatarEnabled" class="avatar-column">
<button
class="avatar-button"
type="button"
@@ -106,6 +106,15 @@ function onAvatarSelected(event: Event) {
min-height: 168px;
padding: 18px 32px;
}
.account-summary-text {
grid-template-columns: minmax(420px, 720px);
justify-content: start;
min-height: 116px;
padding: 14px 32px;
}
.account-summary-text .identity-summary {
width: 100%;
}
.avatar-column {
display: flex;
flex-direction: column;
@@ -188,5 +197,10 @@ function onAvatarSelected(event: Event) {
width: min(100%, 440px);
margin: 0 auto;
}
.account-summary-text {
grid-template-columns: 1fr;
min-height: auto;
padding: 14px 8px;
}
}
</style>

View File

@@ -1,6 +1,6 @@
<!--
功能以响应式信息卡和子表页签展示标准资源详情
版本v1.1.0
版本v1.2.0
-->
<template>
<div class="detail-stack">
@@ -58,6 +58,10 @@ const props = defineProps<{
definition: ResourceUiDefinition;
detail: ResourceRow;
relationOptions: Record<string, ResourceRow[]>;
fieldOptions?: Record<
string,
Array<{ label: string; value: string | number }>
>;
accountSummary: boolean;
}>();
@@ -97,7 +101,12 @@ const entries = computed<DetailEntry[]>(() => {
return [];
const field = props.definition.fields.find((item) => item.key === key);
const display = field
? displayResourceField(field, row, props.relationOptions)
? displayResourceField(
field,
row,
props.relationOptions,
props.fieldOptions,
)
: displayRawValue(actualKey, value);
const objectValue = typeof value === 'object' && value !== null;
return [
@@ -161,7 +170,8 @@ const collections = computed(() =>
}
.detail-item {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
grid-template-columns: max-content minmax(0, 1fr);
column-gap: 12px;
align-items: start;
min-height: 38px;
padding: 8px 0;
@@ -172,8 +182,7 @@ const collections = computed(() =>
}
.detail-label {
color: var(--color-text-3);
text-align: right;
padding-right: 18px;
white-space: nowrap;
}
.detail-value {
color: var(--color-text-1);
@@ -208,7 +217,6 @@ const collections = computed(() =>
.detail-item,
.detail-item-wide {
grid-column: auto;
grid-template-columns: 100px minmax(0, 1fr);
}
}
</style>

View File

@@ -1,6 +1,6 @@
<!--
功能渲染标准资源的新建编辑和业务动作字段控件
版本v1.0.0
版本v1.1.0
-->
<template>
<div class="field-grid">
@@ -63,7 +63,7 @@
:disabled="disabledSet.has(field.key)"
allow-clear
>
<a-option v-for="option in field.options" :key="option.value" :value="option.value">
<a-option v-for="option in selectOptions(field)" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
</a-select>
@@ -90,9 +90,8 @@
v-else
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
:placeholder="disabledSet.has(field.key) ? '创建后不可修改' : `请输入${field.label}`"
:placeholder="disabledSet.has(field.key) ? '' : `请输入${field.label}`"
/>
<div v-if="disabledSet.has(field.key)" class="readonly-hint">创建后不可修改</div>
</a-form-item>
</div>
</template>
@@ -135,6 +134,24 @@ function isWide(field: ResourceField) {
/(address|terms|content|body|remark|reason|params|args)$/.test(field.key)
);
}
/** 为历史未知编码补充只读选项,避免下拉框退回显示原始值。 */
function selectOptions(field: ResourceField) {
const options = [...(field.options ?? [])];
const value = model.value[field.key];
if (
field.unknownValueLabel &&
value != null &&
value !== '' &&
!options.some((option) => String(option.value) === String(value))
) {
options.push({
label: `${field.unknownValueLabel}${String(value)}`,
value,
});
}
return options;
}
</script>
<style scoped lang="less">
@@ -152,11 +169,6 @@ function isWide(field: ResourceField) {
.field-readonly :deep(.arco-input-number) {
background: var(--color-fill-1);
}
.readonly-hint {
margin-top: 4px;
color: var(--color-text-3);
font-size: 12px;
}
:deep(.arco-input-number),
:deep(.arco-picker) {
width: 100%;

View File

@@ -1,6 +1,6 @@
/*
* 功能:定义标准资源独立页面的布局、卡片和响应式样式。
* 版本v1.2.0
* 版本v1.3.0
*/
.record-page {
display: grid;
@@ -52,7 +52,6 @@
.form-content {
container: record-form / inline-size;
}
.form-alert,
.workflow-alert {
margin-bottom: 18px;
}

View File

@@ -1,6 +1,6 @@
<!--
功能承载平台总后台全部标准资源的新建详情和编辑独立页面
版本v1.2.0
版本v1.3.0
-->
<template>
<div class="record-page">
@@ -33,7 +33,7 @@
<template v-else>
<ResourceAccountSummary
v-if="accountSummary"
v-if="accountSummaryVisible"
:mode="mode"
:title="definition.title"
:record="summaryRecord"
@@ -49,6 +49,7 @@
:definition="definition"
:detail="detail"
:relation-options="relations.options"
:field-options="fieldOptions"
:account-summary="accountSummary"
/>
<ResourceWalletSummary
@@ -93,9 +94,6 @@
<div class="form-shell">基本信息</div>
</template>
<div class="form-shell form-content">
<a-alert v-if="mode === 'edit' && readonlyKeys.length" class="form-alert" type="info" show-icon>
灰色字段为创建后不可修改的信息不会提交到更新接口
</a-alert>
<a-form :model="form" layout="vertical">
<ResourceFieldForm
v-model="form"
@@ -196,6 +194,9 @@ const errorMessage = ref('');
const errorStatus = ref<'403' | '404' | 'error'>('error');
const wallet = ref<ResourceRow>();
const roleOptions = ref<PlatformRole[]>([]);
const fieldOptions = reactive<
Record<string, Array<{ label: string; value: string | number }>>
>({});
const relations = useResourceRelations();
const actionVisible = ref(false);
const activeAction = ref<DetailAction>();
@@ -206,6 +207,11 @@ const selectAvatar = avatar.select;
const clearAvatar = avatar.clear;
const accountSummary = computed(() => usesAccountSummary(definition.value));
const accountSummaryVisible = computed(
() =>
accountSummary.value &&
(mode.value !== 'create' || hasAvatarField.value),
);
const hasAvatarField = computed(() =>
definition.value.fields.some((field) => field.key === 'avatar'),
);
@@ -235,11 +241,15 @@ const editableKeySet = computed(
),
);
const readonlyKeys = computed(() =>
mode.value === 'edit'
mode.value === 'create'
? formFields.value
.filter((field) => field.readonlyOnCreate)
.map((field) => field.key)
: mode.value === 'edit'
? formFields.value
.filter((field) => !editableKeySet.value.has(field.key))
.map((field) => field.key)
: [],
: [],
);
const requiredKeys = computed(() =>
payloadFields.value
@@ -353,9 +363,19 @@ async function loadRelationsAndRoles() {
if (
definition.value.fields.some((field) => field.key === 'platform_role_code')
) {
roleOptions.value = (await platformApi.listRole()).list.filter(
(role) => !role.is_system && role.status === 1,
);
try {
const roles = (await platformApi.listRole()).list;
fieldOptions.platform_role_code = roles.map((role) => ({
label: role.name,
value: role.role_code,
}));
roleOptions.value = roles.filter(
(role) => !role.is_system && role.status === 1,
);
} catch (error) {
fieldOptions.platform_role_code = [];
Message.warning(`平台角色加载失败:${(error as Error).message}`);
}
}
}

View File

@@ -51,7 +51,7 @@
v-if="field.type === 'identity' && !field.displayRelationLabel && identityFieldValue(field, record)"
:value="identityFieldValue(field, record)"
/>
<template v-else>{{ displayResourceField(field, record, relations.options) }}</template>
<template v-else>{{ displayListField(field, record) }}</template>
</template>
</a-table-column>
<a-table-column v-if="definition.accountManagement" title="账户数" :width="90">
@@ -148,6 +148,7 @@ import {
import { editBlockedReason, type ResourceRow } from '@/api/resource-page-rules';
import { safeReturnPath } from '@/api/resource-navigation';
import { resourceApi } from '@/api/resource';
import { platformApi } from '@/api/platform';
import type { ResourceField, ResourceUiDefinition } from '@/api/resources';
import { useUserStore } from '@/store';
import { useResourceRelations } from '../resource/use-resource-relations';
@@ -167,6 +168,9 @@ const filters = reactive({
keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '',
});
const relations = useResourceRelations();
const fieldOptions = reactive<
Record<string, Array<{ label: string; value: string | number }>>
>({});
const extras = useResourceListExtras(definitionRef, list);
const statusVisible = ref(false);
const statusSaving = ref(false);
@@ -426,8 +430,38 @@ function columnWidth(field: ResourceField) {
return 180;
}
/** 使用资源静态选项或运行时平台角色名称显示列表字段。 */
function displayListField(field: ResourceField, row: ResourceRow) {
return displayResourceField(field, row, relations.options, fieldOptions);
}
/** 平台角色由后端维护,列表加载全部角色用于编码转中文名称。 */
async function loadFieldOptions() {
if (
!props.definition.fields.some(
(field) => field.key === 'platform_role_code',
)
) {
delete fieldOptions.platform_role_code;
return;
}
try {
const roles = (await platformApi.listRole()).list;
fieldOptions.platform_role_code = roles.map((role) => ({
label: role.name,
value: role.role_code,
}));
} catch (error) {
fieldOptions.platform_role_code = [];
Message.warning(`平台角色加载失败:${(error as Error).message}`);
}
}
onMounted(async () => {
await relations.preload(displayFields.value);
await Promise.all([
relations.preload(displayFields.value),
loadFieldOptions(),
]);
await load();
});
watch(
@@ -435,7 +469,10 @@ watch(
async () => {
page.value = 1;
filters.keyword = '';
await relations.preload(displayFields.value);
await Promise.all([
relations.preload(displayFields.value),
loadFieldOptions(),
]);
await load();
},
);