feat: 标准资源使用独立页面并优化详情布局

This commit is contained in:
czl231
2026-08-11 00:49:41 +08:00
parent 7242048abf
commit 13daa996a2
39 changed files with 3826 additions and 2106 deletions

View File

@@ -1,209 +0,0 @@
/* 功能账户资料页布局、头像和响应式样式。版本v1.0.0 */
.account-profile-page {
min-height: 100%;
padding: 0 20px 28px;
background: var(--color-fill-2);
}
.profile-loading {
display: block;
width: 100%;
}
.summary-card,
.form-card {
margin-top: 16px;
border-radius: 8px;
}
.summary-content {
display: grid;
grid-template-columns: 180px minmax(360px, 1fr);
gap: 44px;
align-items: center;
width: min(900px, 100%);
min-height: 152px;
margin: 0 auto;
padding: 24px 32px;
}
.avatar-column {
display: flex;
flex-direction: column;
align-items: center;
width: 180px;
}
.avatar-control {
position: relative;
padding: 0;
background: transparent;
border: 0;
}
.avatar-control.editable {
cursor: pointer;
}
.profile-avatar {
overflow: hidden;
background: var(--color-fill-3);
}
.profile-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-badge {
position: absolute;
right: 2px;
bottom: 4px;
display: grid;
width: 38px;
height: 38px;
color: rgb(var(--primary-6));
font-size: 20px;
background: var(--color-bg-2);
border: 4px solid var(--color-bg-2);
border-radius: 50%;
place-items: center;
}
.avatar-input {
display: none;
}
.avatar-help {
margin-top: 10px;
color: var(--color-text-3);
font-size: 12px;
}
.identity-summary {
width: 100%;
max-width: 500px;
}
.identity-summary h2 {
margin: 0 0 16px;
color: var(--color-text-1);
font-size: 20px;
line-height: 28px;
}
.identity-summary dl {
margin: 0;
}
.identity-summary dl div {
display: flex;
align-items: center;
min-height: 36px;
font-size: 15px;
}
.identity-summary dt {
width: 88px;
color: var(--color-text-3);
text-align: left;
}
.identity-summary dd {
min-width: 0;
margin-left: 8px;
color: var(--color-text-1);
}
.form-card {
padding-bottom: 12px;
}
.profile-form {
width: min(760px, calc(100% - 48px));
margin: 0 auto;
padding: 20px 0 4px;
}
.profile-form :deep(.arco-form-item-label-col) {
flex: 0 0 116px;
justify-content: flex-start;
}
.profile-form :deep(.arco-form-item-content-flex) {
width: 100%;
max-width: 640px;
}
.profile-form :deep(.arco-form-item-label-required-symbol) {
display: inline-flex;
align-items: center;
justify-content: center;
width: 8px;
margin-right: 4px;
font-size: 0;
}
.profile-form :deep(.arco-form-item-label-required-symbol svg) {
display: none;
}
.profile-form :deep(.arco-form-item-label-required-symbol::before) {
color: rgb(var(--danger-6));
font-size: 14px;
line-height: 1;
content: '*';
}
.profile-form :deep(.arco-input-wrapper),
.profile-form :deep(.arco-input-number),
.profile-form :deep(.arco-select-view),
.profile-form :deep(.arco-picker),
.profile-form :deep(.arco-textarea-wrapper) {
background: var(--color-bg-2);
border-color: var(--color-border-2);
}
.detail-value {
width: 100%;
min-height: 36px;
padding: 7px 0;
overflow: hidden;
color: var(--color-text-1);
line-height: 22px;
text-overflow: ellipsis;
border-bottom: 1px solid var(--color-fill-3);
white-space: nowrap;
}
.form-actions {
margin-top: 12px;
margin-bottom: 4px;
}
@media (max-width: 760px) {
.account-profile-page {
padding: 0 10px 20px;
}
.summary-content {
grid-template-columns: 1fr;
gap: 20px;
justify-items: center;
padding: 24px 16px;
}
.identity-summary {
width: min(420px, 100%);
}
.profile-form {
width: calc(100% - 24px);
padding: 16px 0 0;
}
.profile-form :deep(.arco-form-item-label-col) {
flex-basis: 96px;
}
}

View File

@@ -1,455 +0,0 @@
<!-- 功能工作人员与用户账户独立资料页版本v1.0.0 -->
<template>
<div class="account-profile-page">
<a-page-header
:title="`${definition.title}资料`"
subtitle="查看和维护账户基本信息"
@back="goBack"
>
<template v-if="!editing" #extra>
<a-button type="primary" @click="startEdit">
<template #icon><icon-edit /></template>
编辑资料
</a-button>
</template>
</a-page-header>
<a-spin :loading="loading" class="profile-loading" tip="正在加载资料">
<a-card :bordered="false" class="summary-card">
<div class="summary-content">
<div class="avatar-column">
<button
class="avatar-control"
:class="{ editable: editing }"
type="button"
:disabled="!editing"
aria-label="选择本地头像"
@click="chooseAvatar"
>
<a-avatar :size="120" class="profile-avatar">
<img :src="avatarPreview" alt="账户头像" />
</a-avatar>
<span v-if="editing" class="camera-badge">
<icon-camera />
</span>
</button>
<input
ref="avatarInput"
class="avatar-input"
type="file"
accept="image/jpeg,image/png,.jpg,.jpeg,.png"
@change="handleAvatarChange"
/>
<span v-if="editing" class="avatar-help">JPG/PNG最大 2 MB</span>
</div>
<div class="identity-summary">
<h2>{{ displayName }}</h2>
<dl>
<div>
<dt>用户名</dt>
<dd>{{ String(detail.username ?? '-') }}</dd>
</div>
<div>
<dt>唯一标识</dt>
<dd>
<IdentityText
v-if="detail.identity"
:value="String(detail.identity)"
/>
<template v-else>-</template>
</dd>
</div>
<div>
<dt>创建时间</dt>
<dd>{{ formatDate(detail.created_at) }}</dd>
</div>
</dl>
</div>
</div>
</a-card>
<a-card :bordered="false" class="form-card">
<a-tabs default-active-key="basic">
<a-tab-pane key="basic" title="基本信息">
<a-form :model="form" class="profile-form" layout="horizontal">
<a-form-item
v-for="field in profileFields"
:key="field.key"
:label="field.label"
:required="editing && isResourceFieldRequired(field, 'edit')"
>
<div v-if="!editing" class="detail-value">
{{ displayProfileValue(field) }}
</div>
<template v-else>
<a-switch
v-if="field.type === 'boolean'"
v-model="form[field.key]"
/>
<a-input-number
v-else-if="field.type === 'number' || field.type === 'money'"
v-model="form[field.key]"
:precision="field.type === 'money' ? 2 : 0"
/>
<a-date-picker
v-else-if="field.type === 'date'"
v-model="form[field.key]"
value-format="YYYY-MM-DD"
/>
<a-date-picker
v-else-if="field.type === 'datetime'"
v-model="form[field.key]"
show-time
value-format="YYYY-MM-DDTHH:mm:ssZ"
/>
<a-textarea
v-else-if="field.type === 'textarea'"
v-model="form[field.key]"
:auto-size="{ minRows: 3, maxRows: 8 }"
/>
<a-select
v-else-if="field.type === 'select'"
v-model="form[field.key]"
allow-clear
>
<a-option
v-for="option in field.options"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</a-option>
</a-select>
<a-select
v-else-if="field.type === 'identity' || field.type === 'identity-list'"
v-model="form[field.key]"
:multiple="field.type === 'identity-list'"
:loading="relationLoading[field.relation ?? '']"
allow-clear
allow-search
>
<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="form[field.key]"
:placeholder="`请输入${field.label}`"
/>
</template>
</a-form-item>
<a-form-item v-if="editing" class="form-actions">
<a-space>
<a-button type="primary" :loading="saving" @click="save">
保存
</a-button>
<a-button :disabled="saving" @click="cancelEdit">取消</a-button>
</a-space>
</a-form-item>
</a-form>
</a-tab-pane>
</a-tabs>
</a-card>
</a-spin>
</div>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { IconCamera, IconEdit } from '@arco-design/web-vue/es/icon';
import dayjs from 'dayjs';
import {
computed,
onBeforeUnmount,
onMounted,
reactive,
ref,
watch,
} from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { avatarApi } from '@/api/avatar';
import { resourceApi } from '@/api/resource';
import {
buildResourcePayload,
isMissingField,
isResourceFieldRequired,
} from '@/api/resource-form';
import { getResource, type ResourceField } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue';
import { DEFAULT_USER_AVATAR } from '@/constants/avatar';
type Row = Record<string, unknown>;
const route = useRoute();
const router = useRouter();
const loading = ref(false);
const saving = ref(false);
const detail = ref<Row>({});
const form = reactive<Record<string, any>>({});
const avatarInput = ref<HTMLInputElement>();
const avatarPreview = ref(DEFAULT_USER_AVATAR);
const selectedAvatar = ref<File>();
const relationOptions = reactive<Record<string, Row[]>>({});
const relationLoading = reactive<Record<string, boolean>>({});
let objectURL = '';
const definition = computed(() => getResource(String(route.meta.resource)));
const identity = computed(() => String(route.params.identity ?? ''));
const editing = computed(() => route.query.mode === 'edit');
const profileFields = computed<ResourceField[]>(() =>
definition.value.fields.filter(
(field) => !['username', 'password', 'avatar'].includes(field.key),
),
);
const displayName = computed(() =>
String(
detail.value.name ??
detail.value.real_name ??
detail.value.username ??
'账户资料',
),
);
/** 返回资料页所属的工作人员或用户列表。 */
function goBack() {
router.push({ name: String(route.meta.listRouteName) });
}
/** 将当前资料页切换为可编辑状态,并保留直达 URL 状态。 */
function startEdit() {
router.replace({ query: { ...route.query, mode: 'edit' } });
}
/** 移除编辑参数,恢复为只读详情状态。 */
async function leaveEditMode() {
const query = { ...route.query };
delete query.mode;
await router.replace({ query });
}
/** 将服务端详情复制到表单,金额字段保持前端元单位。 */
function resetForm() {
for (const field of profileFields.value) {
const value = detail.value[field.key];
form[field.key] =
value == null
? undefined
: field.type === 'money'
? Number(value) / 100
: value;
}
}
/** 加载账户详情、关联下拉选项和受保护头像。 */
async function loadProfile() {
loading.value = true;
try {
detail.value = await resourceApi.detail<Row>(
definition.value.resource,
identity.value,
);
resetForm();
await Promise.all([loadRelations(), loadAvatar()]);
} catch (error) {
Message.error((error as Error).message);
} finally {
loading.value = false;
}
}
/** 加载资料表单所需的关联资源选项。 */
async function loadRelations() {
const resources = new Set(
profileFields.value
.map((field) => field.relation)
.filter((value): value is string => Boolean(value)),
);
await Promise.all(
[...resources].map(async (resource) => {
relationLoading[resource] = true;
try {
relationOptions[resource] = (
await resourceApi.list<Row>(resource, 1, 100)
).list;
} catch {
relationOptions[resource] = [];
} finally {
relationLoading[resource] = false;
}
}),
);
}
/** 加载需要 JWT 的头像并生成仅限当前页面生命周期的 Blob URL。 */
async function loadAvatar() {
revokeObjectURL();
selectedAvatar.value = undefined;
const blob = await avatarApi.load(definition.value.resource, identity.value);
if (!blob) {
avatarPreview.value = DEFAULT_USER_AVATAR;
return;
}
objectURL = URL.createObjectURL(blob);
avatarPreview.value = objectURL;
}
/** 打开浏览器本地图片选择器。 */
function chooseAvatar() {
if (editing.value) avatarInput.value?.click();
}
/** 校验前端图片类型、大小、解码结果和像素尺寸。 */
async function validateAvatarFile(file: File) {
if (!['image/jpeg', 'image/png'].includes(file.type)) {
throw new Error('头像仅支持 JPG 或 PNG 格式');
}
if (file.size <= 0 || file.size > 2 * 1024 * 1024) {
throw new Error('头像大小不能超过 2 MB');
}
const bitmap = await createImageBitmap(file);
try {
if (
bitmap.width <= 0 ||
bitmap.height <= 0 ||
bitmap.width > 4096 ||
bitmap.height > 4096
) {
throw new Error('头像尺寸不能超过 4096×4096 像素');
}
} finally {
bitmap.close();
}
}
/** 选择头像后立即本地预览,文件在点击保存前不会上传。 */
async function handleAvatarChange(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
try {
await validateAvatarFile(file);
revokeObjectURL();
selectedAvatar.value = file;
objectURL = URL.createObjectURL(file);
avatarPreview.value = objectURL;
} catch (error) {
Message.warning((error as Error).message);
}
}
/** 取消编辑并重新加载服务端头像与表单值。 */
async function cancelEdit() {
resetForm();
await Promise.all([leaveEditMode(), loadAvatar()]);
}
/** 验证表单后保存资料;新头像在资料更新前完成受控上传。 */
async function save() {
if (
profileFields.value.some(
(field) =>
isResourceFieldRequired(field, 'edit') &&
isMissingField(form[field.key]),
)
) {
Message.warning('请填写必填字段');
return;
}
saving.value = true;
try {
const payload = buildResourcePayload(profileFields.value, form, 'edit');
if (selectedAvatar.value) {
const uploaded = await avatarApi.upload(selectedAvatar.value);
payload.avatar = uploaded.uri;
}
await resourceApi.update(
definition.value.resource,
identity.value,
payload,
);
Message.success('资料保存成功');
await leaveEditMode();
await loadProfile();
} catch (error) {
Message.error((error as Error).message);
} finally {
saving.value = false;
}
}
/** 格式化后端时间字段,兼容普通字符串与 GORM 时间对象。 */
function formatDate(value: unknown) {
const raw =
value && typeof value === 'object' && 'Time' in value
? (value as { Time?: unknown }).Time
: value;
const date = dayjs(String(raw ?? ''));
return date.isValid() ? date.format('YYYY-MM-DD HH:mm:ss') : '-';
}
/** 生成人可读的关联资源选项名称。 */
function optionLabel(option: Row) {
return String(
option.name ??
option.title ??
option.code ??
option.username ??
option.identity,
);
}
/** 将只读资料字段转换为适合页面展示的文本。 */
function displayProfileValue(field: ResourceField) {
const value = form[field.key];
if (isMissingField(value)) return field.emptyText ?? '-';
if (field.options) {
const selected = field.options.find(
(option) => String(option.value) === String(value),
);
if (selected) return selected.label;
}
if (field.type === 'boolean') return value === true ? '是' : '否';
if (field.type === 'money') {
const amount = Number(value);
return Number.isFinite(amount) ? `¥${amount.toFixed(2)}` : String(value);
}
if (field.type === 'date' || field.type === 'datetime')
return formatDate(value);
if (field.type === 'identity' && typeof value === 'string') {
const match = (relationOptions[field.relation ?? ''] ?? []).find(
(option) => String(option.identity) === value,
);
return match ? optionLabel(match) : value;
}
if (field.type === 'identity-list' && Array.isArray(value)) {
return value.join('、');
}
return String(value);
}
/** 释放浏览器创建的头像对象 URL避免页面切换后的内存泄漏。 */
function revokeObjectURL() {
if (objectURL) URL.revokeObjectURL(objectURL);
objectURL = '';
}
onMounted(loadProfile);
onBeforeUnmount(revokeObjectURL);
watch(
() => [route.meta.resource, route.params.identity],
([resource, nextIdentity], [previousResource, previousIdentity]) => {
if (resource !== previousResource || nextIdentity !== previousIdentity)
loadProfile();
},
);
</script>
<style scoped lang="less" src="./AccountProfilePage.less"></style>

View File

@@ -0,0 +1,192 @@
<!--
功能展示账户类资源的头像用户名唯一标识和创建时间摘要
版本v1.0.0
-->
<template>
<a-card class="account-card" :bordered="false">
<div class="account-summary">
<div class="avatar-column">
<button
class="avatar-button"
type="button"
:disabled="mode === 'detail' || !avatarEnabled"
:aria-label="mode === 'detail' || !avatarEnabled ? '账户头像' : '选择账户头像'"
@click="chooseAvatar"
>
<a-avatar :size="120" class="account-avatar">
<img :src="avatarUrl" alt="账户头像" />
</a-avatar>
<span v-if="mode !== 'detail' && avatarEnabled" class="camera-badge"><icon-camera /></span>
</button>
<input
ref="avatarInput"
class="avatar-input"
type="file"
accept="image/jpeg,image/png"
@change="onAvatarSelected"
/>
<span v-if="mode !== 'detail' && avatarEnabled" class="avatar-help">JPG/PNG最大 2 MB</span>
<a-button v-if="mode !== 'detail' && avatarEnabled && canClear" type="text" size="mini" @click="emit('clear-avatar')">
恢复默认头像
</a-button>
</div>
<div class="identity-summary">
<h2>{{ summaryTitle }}</h2>
<dl>
<div>
<dt>用户名</dt>
<dd>{{ String(record.username ?? '保存后确定') }}</dd>
</div>
<div>
<dt>唯一标识</dt>
<dd class="identity-value">{{ String(record.identity ?? '保存后生成') }}</dd>
</div>
<div>
<dt>创建时间</dt>
<dd>{{ displayRawValue('created_at', record.created_at) }}</dd>
</div>
</dl>
</div>
</div>
</a-card>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue';
import { IconCamera } from '@arco-design/web-vue/es/icon';
import { displayRawValue } from '@/api/resource-display';
import type { ResourceRow, RecordPageMode } from '@/api/resource-page-rules';
const props = defineProps<{
mode: RecordPageMode;
title: string;
record: ResourceRow;
avatarUrl: string;
avatarEnabled: boolean;
canClear: boolean;
}>();
const emit = defineEmits<{
'select-avatar': [file: File];
'clear-avatar': [];
}>();
const avatarInput = ref<HTMLInputElement>();
const summaryTitle = computed(() =>
String(
props.record.name ??
props.record.display_name ??
props.record.real_name ??
props.record.username ??
(props.mode === 'create' ? `新建${props.title}` : props.title),
),
);
function chooseAvatar() {
if (props.mode !== 'detail' && props.avatarEnabled)
avatarInput.value?.click();
}
function onAvatarSelected(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (file) emit('select-avatar', file);
input.value = '';
}
</script>
<style scoped lang="less">
.account-card {
border-radius: 10px;
}
.account-summary {
display: grid;
grid-template-columns: 180px minmax(360px, 500px);
justify-content: center;
align-items: center;
gap: 44px;
min-height: 168px;
padding: 18px 32px;
}
.avatar-column {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.avatar-button {
position: relative;
padding: 0;
background: transparent;
border: 0;
cursor: pointer;
}
.avatar-button:disabled {
cursor: default;
}
.account-avatar {
background: var(--color-fill-3);
}
.account-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-badge {
position: absolute;
right: 2px;
bottom: 2px;
display: grid;
width: 34px;
height: 34px;
place-items: center;
color: rgb(var(--primary-6));
background: var(--color-bg-2);
border: 3px solid var(--color-bg-2);
border-radius: 50%;
box-shadow: 0 2px 8px rgb(0 0 0 / 10%);
}
.avatar-input {
display: none;
}
.avatar-help {
color: var(--color-text-3);
font-size: 12px;
}
.identity-summary h2 {
margin: 0 0 14px;
color: var(--color-text-1);
font-size: 20px;
}
.identity-summary dl,
.identity-summary dd {
margin: 0;
}
.identity-summary dl > div {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
align-items: center;
min-height: 38px;
}
.identity-summary dt {
color: var(--color-text-3);
text-align: right;
}
.identity-summary dd {
color: var(--color-text-1);
font-size: 15px;
}
.identity-value {
color: rgb(var(--primary-6)) !important;
word-break: break-all;
}
@media (max-width: 760px) {
.account-summary {
grid-template-columns: 1fr;
gap: 20px;
padding: 20px 8px;
}
.identity-summary {
width: min(100%, 440px);
margin: 0 auto;
}
}
</style>

View File

@@ -0,0 +1,164 @@
<!--
功能承载资源详情页中的审核流转归属调整和菜单分配等短业务操作
版本v1.0.0
-->
<template>
<a-modal
:visible="visible"
:title="action?.name"
:width="680"
:ok-loading="submitting"
:ok-button-props="{ status: action?.danger ? 'danger' : 'normal' }"
@cancel="close"
@ok="submit"
>
<a-alert v-if="action?.danger" class="action-alert" type="error" show-icon>
这是高风险操作可能改变业务状态且无法直接撤销请确认目标记录和填写内容准确
</a-alert>
<a-alert v-if="isOwnershipAction" class="action-alert" type="warning" show-icon>
智能气阀只能有一个当前归属选择新归属前请清空原归属操作会写入审计记录
</a-alert>
<a-form :model="form" layout="vertical">
<ResourceFieldForm
v-model="form"
:fields="action?.fields ?? []"
:required-keys="requiredKeys"
:relation-options="relations.options"
:relation-loading="relations.loading"
@search-relation="relations.search"
/>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
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 { ResourceRow } from '@/api/resource-page-rules';
import ResourceFieldForm from './ResourceFieldForm.vue';
import { useResourceRelations } from './use-resource-relations';
const props = defineProps<{
visible: boolean;
action?: DetailAction;
identity: string;
record: ResourceRow;
}>();
const emit = defineEmits<{
'update:visible': [value: boolean];
completed: [];
}>();
const form = reactive<Record<string, any>>({});
const submitting = ref(false);
const relations = useResourceRelations();
const requiredKeys = computed(() =>
(props.action?.fields ?? [])
.filter((field) => field.required)
.map((field) => field.key),
);
const isOwnershipAction = computed(
() => props.action?.resource === '/product_info/:identity',
);
const ownershipKeys = [
'warehouse_identity',
'gas_basic_identity',
'delivery_basic_identity',
'user_account_identity',
];
watch(
() => [props.visible, props.action?.name] as const,
async ([visible]) => {
if (!visible || !props.action) return;
for (const field of props.action.fields ?? []) {
form[field.key] = isOwnershipAction.value
? props.record[field.key]
: undefined;
}
await relations.preload(props.action.fields ?? []);
if (props.action.resource.includes('/menu')) {
try {
const [menus, assigned] = await Promise.all([
platformApi.listMenu(),
platformApi.listRoleMenuIdentities(props.identity),
]);
relations.options['/platform_menu'] = menus.list;
form.menu_identities = assigned.menu_identities;
} catch (error) {
Message.error((error as Error).message);
close();
}
}
},
);
function close() {
emit('update:visible', false);
}
async function submit() {
const action = props.action;
if (!action) return;
if (
(action.fields ?? []).some(
(field) => field.required && isMissingField(form[field.key]),
)
) {
Message.warning('请填写必填字段');
return;
}
const invalidPassword = (action.fields ?? []).find(
(field) =>
field.type === 'password' &&
!isMissingField(form[field.key]) &&
String(form[field.key]).length < 6,
);
if (invalidPassword) {
Message.warning(`${invalidPassword.label}长度不能少于 6 位`);
return;
}
if (isOwnershipAction.value) {
const selected = ownershipKeys.filter((key) => !isMissingField(form[key]));
if (selected.length > 1) {
Message.warning('智能气阀只能选择一个当前归属');
return;
}
}
submitting.value = true;
try {
const payload = buildResourcePayload(action.fields ?? [], form, 'create');
if (isOwnershipAction.value) {
for (const key of ownershipKeys) payload[key] = String(form[key] ?? '');
}
if (action.resource.includes('/menu')) {
await platformApi.replaceRoleMenus(
props.identity,
payload.menu_identities as string[],
);
} else {
await resourceApi.action(
action.resource.replace(':identity', props.identity),
action.method ?? 'POST',
payload,
);
}
Message.success('操作成功');
close();
emit('completed');
} catch (error) {
Message.error((error as Error).message);
} finally {
submitting.value = false;
}
}
</script>
<style scoped lang="less">
.action-alert {
margin-bottom: 16px;
}
</style>

View File

@@ -0,0 +1,214 @@
<!--
功能以响应式信息卡和子表页签展示标准资源详情
版本v1.1.0
-->
<template>
<div class="detail-stack">
<a-card title="基本信息" :bordered="false" class="detail-card">
<div class="detail-grid">
<div v-for="entry in entries" :key="entry.key" class="detail-item" :class="{ 'detail-item-wide': entry.wide }">
<span class="detail-label">{{ entry.label }}</span>
<pre v-if="entry.objectValue" class="json-value">{{ entry.value }}</pre>
<IdentityText v-else-if="entry.key === 'identity'" :value="String(entry.value)" />
<span v-else class="detail-value">{{ entry.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="collection.title">
<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="resourceFieldLabel(definition, column)"
:width="column.includes('identity') ? 220 : 160"
ellipsis
tooltip
>
<template #cell="{ record }">
<IdentityText v-if="column.includes('identity') && record[column]" :value="String(record[column])" />
<template v-else>{{ displayRawValue(column, record[column]) }}</template>
</template>
</a-table-column>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
</a-card>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import IdentityText from '@/components/IdentityText.vue';
import {
displayRawValue,
displayResourceField,
isEmptyDeletedAt,
primaryRecord,
resourceFieldLabel,
} from '@/api/resource-display';
import type { ResourceUiDefinition } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
const props = defineProps<{
definition: ResourceUiDefinition;
detail: ResourceRow;
relationOptions: Record<string, ResourceRow[]>;
accountSummary: boolean;
}>();
type DetailEntry = {
key: string;
label: string;
value: string;
objectValue: boolean;
wide: boolean;
};
const entries = computed<DetailEntry[]>(() => {
const row = primaryRecord(props.detail);
const excluded = new Set(['id', 'password', 'password_hash', 'avatar']);
if (props.accountSummary) {
for (const key of ['username', 'identity', 'created_at']) excluded.add(key);
}
const preferred = [
'identity',
'status',
...props.definition.fields.map((field) => field.key),
'created_at',
'updated_at',
];
const keys = [...new Set([...preferred, ...Object.keys(row)])];
return keys.flatMap((key) => {
const maskedKey = `${key}_masked`;
const actualKey = Object.prototype.hasOwnProperty.call(row, key)
? key
: Object.prototype.hasOwnProperty.call(row, maskedKey)
? maskedKey
: '';
if (!actualKey || excluded.has(key) || key.endsWith('_id')) return [];
const value = row[actualKey];
if (Array.isArray(value)) return [];
if (['deleted_at', 'DeletedAt'].includes(key) && isEmptyDeletedAt(value))
return [];
const field = props.definition.fields.find((item) => item.key === key);
const display = field
? displayResourceField(field, row, props.relationOptions)
: displayRawValue(actualKey, value);
const objectValue = typeof value === 'object' && value !== null;
return [
{
key,
label: resourceFieldLabel(props.definition, actualKey),
value: display,
objectValue,
wide:
objectValue ||
/(address|terms|content|body|remark|reason|params|args)$/.test(key),
},
];
});
});
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.endsWith('_id'),
),
),
),
].slice(0, 8);
return {
key,
title: resourceFieldLabel(props.definition, key),
rows,
columns,
};
}),
);
</script>
<style scoped lang="less">
.detail-stack {
display: grid;
gap: 12px;
}
.detail-card {
border-radius: 10px;
}
.detail-card :deep(.arco-card-header) {
height: 48px;
padding: 0 24px;
}
.detail-card :deep(.arco-card-body) {
padding: 20px 24px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px 42px;
padding: 0;
}
.detail-item {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
align-items: start;
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);
text-align: right;
padding-right: 18px;
}
.detail-value {
color: var(--color-text-1);
white-space: pre-wrap;
word-break: break-word;
}
.json-value {
max-height: 360px;
margin: 0;
padding: 12px;
overflow: auto;
color: var(--color-text-1);
background: var(--color-fill-1);
border-radius: 6px;
white-space: pre-wrap;
}
@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;
grid-template-columns: 100px minmax(0, 1fr);
}
}
</style>

View File

@@ -0,0 +1,172 @@
<!--
功能渲染标准资源的新建编辑和业务动作字段控件
版本v1.0.0
-->
<template>
<div class="field-grid">
<a-form-item
v-for="field in fields"
:key="field.key"
:class="{ 'field-wide': isWide(field), 'field-readonly': disabledSet.has(field.key) }"
:label="field.label"
:required="requiredKeys.includes(field.key)"
>
<a-switch
v-if="field.type === 'boolean'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
/>
<a-input-number
v-else-if="field.type === 'number' || field.type === 'money'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
:precision="field.type === 'money' ? 2 : 0"
/>
<a-date-picker
v-else-if="field.type === 'date'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
value-format="YYYY-MM-DD"
/>
<a-date-picker
v-else-if="field.type === 'datetime'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
show-time
value-format="YYYY-MM-DDTHH:mm:ssZ"
/>
<a-textarea
v-else-if="field.type === 'textarea'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
:auto-size="{ minRows: 3, maxRows: 10 }"
/>
<a-input-password
v-else-if="field.type === 'password'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
:placeholder="requiredKeys.includes(field.key) ? '请输入至少 6 个字符' : '留空表示不修改密码'"
/>
<a-select
v-else-if="field.key === 'platform_role_code'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
allow-clear
>
<a-option v-for="role in roleOptions" :key="role.role_code" :value="role.role_code">
{{ role.name }}
</a-option>
</a-select>
<a-select
v-else-if="field.type === 'select'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
allow-clear
>
<a-option v-for="option in field.options" :key="option.value" :value="option.value">
{{ option.label }}
</a-option>
</a-select>
<a-select
v-else-if="field.type === 'identity' || field.type === 'identity-list'"
v-model="model[field.key]"
:disabled="disabledSet.has(field.key)"
:multiple="field.type === 'identity-list'"
:placeholder="field.placeholder"
:loading="relationLoading[field.relation ?? '']"
allow-clear
allow-search
@search="(value: string) => emit('search-relation', 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="disabledSet.has(field.key)"
:placeholder="disabledSet.has(field.key) ? '创建后不可修改' : `请输入${field.label}`"
/>
<div v-if="disabledSet.has(field.key)" class="readonly-hint">创建后不可修改</div>
</a-form-item>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { optionLabel } from '@/api/resource-display';
import type { ResourceField } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
import type { PlatformRole } from '@/api/platform';
const props = withDefaults(
defineProps<{
fields: ResourceField[];
requiredKeys?: string[];
disabledKeys?: string[];
relationOptions?: Record<string, ResourceRow[]>;
relationLoading?: Record<string, boolean>;
roleOptions?: PlatformRole[];
}>(),
{
requiredKeys: () => [],
disabledKeys: () => [],
relationOptions: () => ({}),
relationLoading: () => ({}),
roleOptions: () => [],
},
);
const emit = defineEmits<{
'search-relation': [resource: string | undefined, keyword: string];
}>();
const model = defineModel<Record<string, any>>({ required: true });
const disabledSet = computed(() => new Set(props.disabledKeys));
function isWide(field: ResourceField) {
return (
field.type === 'textarea' ||
field.type === 'identity-list' ||
/(address|terms|content|body|remark|reason|params|args)$/.test(field.key)
);
}
</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);
}
.readonly-hint {
margin-top: 4px;
color: var(--color-text-3);
font-size: 12px;
}
:deep(.arco-input-number),
:deep(.arco-picker) {
width: 100%;
}
@media (max-width: 900px) {
.field-grid {
grid-template-columns: 1fr;
}
.field-wide {
grid-column: auto;
}
}
</style>

View File

@@ -0,0 +1,96 @@
/*
* 功能:定义标准资源独立页面的布局、卡片和响应式样式。
* 版本v1.2.0
*/
.record-page {
display: grid;
align-content: start;
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 {
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;
}
.form-content {
container: record-form / inline-size;
}
.form-alert,
.workflow-alert {
margin-bottom: 18px;
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 20px;
padding-top: 16px;
}
.status-editor {
display: flex;
align-items: center;
gap: 12px;
}
@media (max-width: 760px) {
.record-page {
padding: 12px;
}
.form-card :deep(.arco-card-header) {
padding: 0 16px;
}
.form-card :deep(.arco-card-body) {
padding: 20px 16px 24px;
}
.page-header {
flex-direction: column;
}
.section-card :deep(.arco-card-header),
.section-card :deep(.arco-card-body) {
padding-right: 16px;
padding-left: 16px;
}
}
@container record-form (max-width: 899px) {
.form-content :deep(.field-grid) {
grid-template-columns: 1fr;
}
.form-content :deep(.field-wide) {
grid-column: auto;
}
}

View File

@@ -0,0 +1,483 @@
<!--
功能承载平台总后台全部标准资源的新建详情和编辑独立页面
版本v1.2.0
-->
<template>
<div class="record-page">
<header class="page-header">
<div>
<a-breadcrumb>
<a-breadcrumb-item><a-link @click="goBack">{{ definition.title }}列表</a-link></a-breadcrumb-item>
<a-breadcrumb-item>{{ modeLabel }}</a-breadcrumb-item>
</a-breadcrumb>
</div>
<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="errorStatus" :title="errorTitle" :subtitle="errorMessage">
<template #extra>
<a-space>
<a-button v-if="errorStatus === 'error'" type="primary" @click="initialize">重新加载</a-button>
<a-button @click="goBack">返回列表</a-button>
</a-space>
</template>
</a-result>
<template v-else>
<ResourceAccountSummary
v-if="accountSummary"
:mode="mode"
:title="definition.title"
:record="summaryRecord"
:avatar-url="avatarUrl"
:avatar-enabled="hasAvatarField"
:can-clear="avatarCanClear"
@select-avatar="selectAvatar"
@clear-avatar="clearAvatar"
/>
<template v-if="mode === 'detail'">
<ResourceDetailContent
:definition="definition"
:detail="detail"
:relation-options="relations.options"
:account-summary="accountSummary"
/>
<ResourceWalletSummary
v-if="definition.walletOwnerType"
:wallet="wallet"
@view="viewWallet"
/>
<a-card v-if="definition.name === 'gas_basic'" title="状态管理" :bordered="false" class="section-card">
<div class="status-editor">
<a-tag :color="recordStatusColor(Number(record.status))">
{{ recordStatusLabel(Number(record.status)) }}
</a-tag>
<span>启用状态</span>
<a-switch
:model-value="Number(record.status) === 1"
:loading="statusSaving"
:disabled="![0, 1, 2].includes(Number(record.status))"
checked-text="启用"
unchecked-text="停用"
@change="updateGasStatus"
/>
</div>
</a-card>
<a-card v-if="visibleActions.length" title="业务操作" :bordered="false" class="section-card">
<a-alert 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-space>
</a-card>
</template>
<a-card v-else :bordered="false" class="form-card">
<template #title>
<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"
:fields="formFields"
:required-keys="requiredKeys"
:disabled-keys="readonlyKeys"
:relation-options="relations.options"
:relation-loading="relations.loading"
:role-options="roleOptions"
@search-relation="relations.search"
/>
</a-form>
<div class="form-actions">
<a-button type="primary" :loading="saving" @click="save">保存</a-button>
<a-button :disabled="saving" @click="cancelForm">取消</a-button>
</div>
</div>
</a-card>
</template>
<ResourceActionDialog
v-model:visible="actionVisible"
:action="activeAction"
:identity="recordIdentity"
:record="record"
@completed="reloadDetail"
/>
</div>
</template>
<script setup lang="ts">
import { Message } 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 { ApiError } from '@/api/http';
import { platformApi, type PlatformRole } from '@/api/platform';
import { resourceApi } from '@/api/resource';
import { buildResourcePayload } from '@/api/resource-form';
import {
resetResourceRecordForm,
validateResourceRecordForm,
} from '@/api/resource-record-form';
import {
createBlockedReason,
editableFields,
editBlockedReason,
fieldRequired,
pageFields,
type RecordPageMode,
type ResourceRow,
updatePayloadFields,
usesAccountSummary,
} from '@/api/resource-page-rules';
import {
createdRecordListRoute,
recordRouteLocation,
safeReturnPath,
} from '@/api/resource-navigation';
import {
primaryRecord,
recordPageErrorTitle,
recordPageModeLabel,
recordStatusColor,
recordStatusLabel,
} from '@/api/resource-display';
import { getResource, type DetailAction } from '@/api/resources';
import { useUserStore } from '@/store';
import ResourceAccountSummary from './ResourceAccountSummary.vue';
import ResourceActionDialog from './ResourceActionDialog.vue';
import ResourceDetailContent from './ResourceDetailContent.vue';
import ResourceFieldForm from './ResourceFieldForm.vue';
import ResourceWalletSummary from './ResourceWalletSummary.vue';
import { createResourceRecordNavigation } from './resource-record-navigation';
import { useResourceAvatar } from './use-resource-avatar';
import { useResourceRelations } from './use-resource-relations';
import { useUnsavedRecord } from './use-unsaved-record';
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const definition = computed(() => getResource(String(route.meta.resource)));
const mode = computed<RecordPageMode>(() => route.meta.recordMode ?? 'detail');
const identity = computed(() => String(route.params.identity ?? ''));
const listRouteName = computed(() => String(route.meta.listRouteName ?? ''));
const returnPath = computed(() => safeReturnPath(route.query.return_to));
const context = computed(() => ({
role: userStore.role,
accountIdentity: userStore.accountId ?? '',
}));
const detail = ref<ResourceRow>({});
const record = computed(() => primaryRecord(detail.value));
const form = reactive<Record<string, any>>({});
const loading = ref(true);
const saving = ref(false);
const statusSaving = ref(false);
const errorMessage = ref('');
const errorStatus = ref<'403' | '404' | 'error'>('error');
const wallet = ref<ResourceRow>();
const roleOptions = ref<PlatformRole[]>([]);
const relations = useResourceRelations();
const actionVisible = ref(false);
const activeAction = ref<DetailAction>();
const avatar = useResourceAvatar();
const avatarUrl = avatar.url;
const avatarCanClear = avatar.canClear;
const selectAvatar = avatar.select;
const clearAvatar = avatar.clear;
const accountSummary = computed(() => usesAccountSummary(definition.value));
const hasAvatarField = computed(() =>
definition.value.fields.some((field) => field.key === 'avatar'),
);
const formFields = computed(() =>
mode.value === 'detail'
? []
: pageFields(
definition.value,
mode.value,
record.value,
context.value,
).filter((field) => field.key !== 'avatar'),
);
const payloadFields = computed(() =>
mode.value === 'edit'
? updatePayloadFields(definition.value, record.value, context.value).filter(
(field) => field.key !== 'avatar',
)
: formFields.value,
);
const editableKeySet = computed(
() =>
new Set(
editableFields(definition.value, record.value, context.value).map(
(field) => field.key,
),
),
);
const readonlyKeys = computed(() =>
mode.value === 'edit'
? formFields.value
.filter((field) => !editableKeySet.value.has(field.key))
.map((field) => field.key)
: [],
);
const requiredKeys = computed(() =>
payloadFields.value
.filter((field) =>
fieldRequired(definition.value, field, mode.value as 'create' | 'edit'),
)
.map((field) => field.key),
);
const summaryRecord = computed(() =>
mode.value === 'detail' ? record.value : { ...record.value, ...form },
);
const recordIdentity = computed(() =>
String(record.value.identity ?? identity.value),
);
const blockReason = computed(() =>
mode.value === 'create'
? createBlockedReason(definition.value, context.value)
: mode.value === 'edit'
? editBlockedReason(definition.value, record.value, context.value)
: '',
);
const canEditRecord = computed(
() => !editBlockedReason(definition.value, record.value, context.value),
);
const visibleActions = computed(() =>
(definition.value.detailActions ?? []).filter(
(action) =>
!action.visibleFor ||
action.visibleFor.values.includes(
record.value[action.visibleFor.field] as string | number,
),
),
);
const modeLabel = computed(() => recordPageModeLabel(mode.value));
const errorTitle = computed(() => recordPageErrorTitle(errorStatus.value));
function snapshot() {
return JSON.stringify({ form, avatar: avatar.marker() });
}
const unsaved = useUnsavedRecord(snapshot, () => mode.value !== 'detail');
const { goEdit, viewWallet, goBack, requestBack } =
createResourceRecordNavigation({
router,
listRouteName: () => listRouteName.value,
identity: () => identity.value,
returnPath: () => returnPath.value,
sourcePath: () => route.fullPath,
mode: () => mode.value,
walletIdentity: () => String(wallet.value?.identity ?? ''),
confirmDiscard: unsaved.confirmDiscard,
});
const cancelForm = requestBack;
async function initialize() {
if (mode.value === 'detail' && route.query.mode === 'edit') {
await router.replace(
recordRouteLocation(
listRouteName.value,
'edit',
identity.value,
returnPath.value,
),
);
return;
}
loading.value = true;
errorMessage.value = '';
try {
if (mode.value !== 'create')
detail.value = await resourceApi.detail<ResourceRow>(
definition.value.resource,
identity.value,
);
if (blockReason.value) {
errorStatus.value = '403';
errorMessage.value = blockReason.value;
return;
}
resetResourceRecordForm(form, formFields.value, record.value, {
relationKey:
mode.value === 'create' ? String(route.query.relation_key ?? '') : '',
ownerIdentity:
mode.value === 'create' ? String(route.query.owner_identity ?? '') : '',
staffType:
mode.value === 'create' ? String(route.meta.staffType ?? '') : '',
});
await loadRelationsAndRoles();
// 钱包和头像属于附加信息,读取失败时不能阻断主详情或基础表单。
await Promise.allSettled([loadWallet(), loadAvatar()]);
unsaved.markInitialized();
} catch (error) {
const message = (error as Error).message;
errorStatus.value =
(error instanceof ApiError && error.status === 404) ||
message.includes('记录不存在')
? '404'
: (error instanceof ApiError && error.status === 403) ||
message.includes('无权访问')
? '403'
: 'error';
errorMessage.value = message;
} finally {
loading.value = false;
}
}
async function loadRelationsAndRoles() {
await relations.preload(
mode.value === 'detail' ? definition.value.fields : formFields.value,
);
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,
);
}
}
async function loadWallet() {
if (
!definition.value.walletOwnerType ||
mode.value === 'create' ||
!recordIdentity.value
)
return;
wallet.value = (
await resourceApi.list<ResourceRow>('/wallet_basic', 1, 1, {
owner_type: definition.value.walletOwnerType,
owner_identities: recordIdentity.value,
})
).list[0];
}
async function loadAvatar() {
if (!hasAvatarField.value || mode.value === 'create' || !recordIdentity.value)
return;
await avatar.load(definition.value.resource, recordIdentity.value);
}
async function save() {
const validationError = validateResourceRecordForm(
form,
payloadFields.value,
requiredKeys.value,
);
if (validationError) {
Message.warning(validationError);
return;
}
saving.value = true;
try {
const payload = buildResourcePayload(
payloadFields.value,
form,
mode.value as 'create' | 'edit',
);
await avatar.applyToPayload(payload);
if (mode.value === 'create') {
const created = await resourceApi.create<ResourceRow>(
definition.value.resource,
payload,
);
const createdRow = { ...form, ...created };
const targetList = createdRecordListRoute(
definition.value.name,
listRouteName.value,
createdRow,
);
unsaved.allowNextNavigation();
Message.success('创建成功');
await router.replace(
recordRouteLocation(
targetList,
'detail',
String(created.identity),
returnPath.value,
),
);
} else {
await resourceApi.update(
definition.value.resource,
identity.value,
payload,
);
await resourceApi.detail<ResourceRow>(
definition.value.resource,
identity.value,
);
unsaved.allowNextNavigation();
Message.success('保存成功');
await router.replace(
recordRouteLocation(
listRouteName.value,
'detail',
identity.value,
returnPath.value,
),
);
}
} catch (error) {
Message.error((error as Error).message);
} finally {
saving.value = false;
}
}
async function reloadDetail() {
detail.value = await resourceApi.detail<ResourceRow>(
definition.value.resource,
identity.value,
);
await Promise.allSettled([loadWallet(), loadAvatar()]);
}
async function updateGasStatus(enabled: string | number | boolean) {
statusSaving.value = true;
try {
await resourceApi.updateStatus(
definition.value.resource,
recordIdentity.value,
Boolean(enabled) ? 1 : 2,
);
Message.success('状态修改成功');
await reloadDetail();
} catch (error) {
Message.error((error as Error).message);
} finally {
statusSaving.value = false;
}
}
function openAction(action: DetailAction) {
activeAction.value = action;
actionVisible.value = true;
}
onMounted(initialize);
</script>
<style scoped lang="less" src="./ResourceRecordPage.less"></style>

View File

@@ -0,0 +1,98 @@
<!--
功能展示气站配送点工作人员和用户的关联钱包摘要
版本v1.1.0
-->
<template>
<a-card title="钱包信息" :bordered="false" class="wallet-card">
<div v-if="!wallet" class="wallet-empty">
<icon-info-circle />
<span>尚未开通钱包</span>
</div>
<div v-else class="wallet-grid">
<div><span>钱包标识</span><IdentityText :value="String(wallet.identity ?? '')" /></div>
<div><span>余额</span><strong>{{ displayRawValue('balance', wallet.balance) }}</strong></div>
<div><span>可提现余额</span><strong>{{ displayRawValue('withdrawal_balance', wallet.withdrawal_balance) }}</strong></div>
<div>
<span>状态</span>
<a-tag :color="recordStatusColor(Number(wallet.status))">
{{ recordStatusLabel(Number(wallet.status)) }}
</a-tag>
</div>
<div class="wallet-action">
<span>操作</span>
<a-button type="text" @click="emit('view')">查看钱包详情</a-button>
</div>
</div>
</a-card>
</template>
<script setup lang="ts">
import { IconInfoCircle } from '@arco-design/web-vue/es/icon';
import IdentityText from '@/components/IdentityText.vue';
import {
displayRawValue,
recordStatusColor,
recordStatusLabel,
} from '@/api/resource-display';
import type { ResourceRow } from '@/api/resource-page-rules';
defineProps<{ wallet?: ResourceRow }>();
const emit = defineEmits<{ view: [] }>();
</script>
<style scoped lang="less">
.wallet-card {
border-radius: 10px;
}
.wallet-card :deep(.arco-card-header) {
height: 48px;
padding: 0 24px;
}
.wallet-card :deep(.arco-card-body) {
padding: 18px 24px;
}
.wallet-empty {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 28px;
color: var(--color-text-3);
}
.wallet-empty :deep(svg) {
font-size: 18px;
}
.wallet-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 24px;
}
.wallet-grid > div {
display: grid;
gap: 8px;
min-width: 0;
}
.wallet-grid span {
color: var(--color-text-3);
}
.wallet-grid strong {
color: var(--color-text-1);
font-size: 18px;
}
.wallet-action :deep(.arco-btn) {
justify-content: flex-start;
padding-left: 0;
}
@media (max-width: 900px) {
.wallet-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 760px) {
.wallet-card :deep(.arco-card-header),
.wallet-card :deep(.arco-card-body) {
padding-right: 16px;
padding-left: 16px;
}
}
</style>

View File

@@ -0,0 +1,68 @@
/**
* 功能:封装资源详情、新建、编辑和关联钱包页面之间的导航动作。
* 版本v1.0.0
*/
import type { Router } from 'vue-router';
import { recordRouteLocation, returnToList } from '@/api/resource-navigation';
import type { RecordPageMode } from '@/api/resource-page-rules';
type ResourceRecordNavigationOptions = {
router: Router;
listRouteName: () => string;
identity: () => string;
returnPath: () => string;
sourcePath: () => string;
mode: () => RecordPageMode;
walletIdentity: () => string;
confirmDiscard: (action: () => unknown) => unknown;
};
/** 创建当前资源记录页需要的全部导航动作。 */
export function createResourceRecordNavigation(
options: ResourceRecordNavigationOptions,
) {
function currentRecordLocation(targetMode: 'detail' | 'edit') {
return recordRouteLocation(
options.listRouteName(),
targetMode,
options.identity(),
options.returnPath(),
);
}
function goEdit() {
return options.router.push(currentRecordLocation('edit'));
}
function viewWallet() {
const identity = options.walletIdentity();
if (!identity) return;
return options.router.push(
recordRouteLocation(
'finance-wallets',
'detail',
identity,
options.sourcePath(),
),
);
}
function goBack() {
return returnToList(
options.router,
options.returnPath(),
options.listRouteName(),
);
}
function requestBack() {
if (options.mode() === 'detail') return goBack();
const leave = () =>
options.mode() === 'edit'
? options.router.push(currentRecordLocation('detail'))
: goBack();
return options.confirmDiscard(leave);
}
return { goEdit, viewWallet, goBack, requestBack };
}

View File

@@ -0,0 +1,90 @@
/**
* 功能:管理资源页头像的鉴权读取、本地预览、清除和保存前上传。
* 版本v1.0.0
*/
import { Message } from '@arco-design/web-vue';
import { onBeforeUnmount, ref } from 'vue';
import { avatarApi } from '@/api/avatar';
import { DEFAULT_USER_AVATAR } from '@/constants/avatar';
export function useResourceAvatar() {
const url = ref(DEFAULT_USER_AVATAR);
const file = ref<File>();
const cleared = ref(false);
const canClear = ref(false);
let objectURL = '';
function revoke() {
if (objectURL) URL.revokeObjectURL(objectURL);
objectURL = '';
}
/** 加载现有受保护头像,记录没有头像时继续使用默认图。 */
async function load(resource: string, identity: string) {
revoke();
url.value = DEFAULT_USER_AVATAR;
canClear.value = false;
file.value = undefined;
cleared.value = false;
if (!identity) return;
const blob = await avatarApi.load(resource, identity);
if (!blob) return;
objectURL = URL.createObjectURL(blob);
url.value = objectURL;
canClear.value = true;
}
/** 校验并预览用户选择的新头像。 */
function select(next: File) {
if (
!['image/jpeg', 'image/png'].includes(next.type) ||
next.size <= 0 ||
next.size > 2 * 1024 * 1024
) {
Message.warning('请选择不超过 2 MB 的 JPG 或 PNG 图片');
return;
}
revoke();
objectURL = URL.createObjectURL(next);
url.value = objectURL;
file.value = next;
cleared.value = false;
canClear.value = true;
}
/** 标记清除头像,真正写入空值发生在保存资料时。 */
function clear() {
revoke();
url.value = DEFAULT_USER_AVATAR;
file.value = undefined;
cleared.value = true;
canClear.value = false;
}
/** 将头像变化写入资源更新载荷。 */
async function applyToPayload(payload: Record<string, unknown>) {
if (file.value) payload.avatar = (await avatarApi.upload(file.value)).uri;
else if (cleared.value) payload.avatar = '';
}
function marker() {
return file.value
? `${file.value.name}:${file.value.size}:${file.value.lastModified}`
: cleared.value
? 'clear'
: 'unchanged';
}
onBeforeUnmount(revoke);
return {
url,
file,
cleared,
canClear,
load,
select,
clear,
applyToPayload,
marker,
};
}

View File

@@ -0,0 +1,54 @@
/**
* 功能:为资源表单和详情页加载、缓存并搜索关联资源选项。
* 版本v1.0.0
*/
import { Message } from '@arco-design/web-vue';
import { reactive } from 'vue';
import { resourceApi } from '@/api/resource';
import type { ResourceField } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
export function useResourceRelations() {
const options = reactive<Record<string, ResourceRow[]>>({});
const loading = reactive<Record<string, boolean>>({});
const timers = new Map<string, ReturnType<typeof setTimeout>>();
/** 加载一类关联资源,工作人员关系默认只选择配送人员。 */
async function load(resource: string, keyword = '') {
loading[resource] = true;
try {
const filters: Record<string, string> = keyword ? { keyword } : {};
if (resource === '/staff_account') filters.role_code = 'delivery';
options[resource] = (
await resourceApi.list<ResourceRow>(resource, 1, 100, filters)
).list;
} catch (error) {
Message.error(`关联数据加载失败:${(error as Error).message}`);
} finally {
loading[resource] = false;
}
}
/** 预加载当前页面实际使用的全部关系字段。 */
async function preload(fields: ResourceField[]) {
const resources = new Set(
fields
.map((field) => field.relation)
.filter((value): value is string => Boolean(value)),
);
await Promise.all([...resources].map((resource) => load(resource)));
}
/** 对远程关系选项进行防抖搜索。 */
function search(resource: string | undefined, keyword: string) {
if (!resource) return;
const timer = timers.get(resource);
if (timer) clearTimeout(timer);
timers.set(
resource,
setTimeout(() => load(resource, keyword.trim()), 250),
);
}
return { options, loading, load, preload, search };
}

View File

@@ -0,0 +1,65 @@
/**
* 功能:统一资源新建和编辑页面的未保存离开保护。
* 版本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

@@ -1,179 +0,0 @@
<template>
<a-card :title="definition.title" :bordered="false">
<div class="list-toolbar">
<a-form :model="filters" layout="inline" class="filters" @submit.prevent="load">
<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-form>
<a-space class="list-actions"><a-button @click="load">刷新</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.label" :width="columnWidth(field)" ellipsis tooltip>
<template #cell="{ record }">
<IdentityText v-if="field.type === 'identity' && record[field.key]" :value="String(record[field.key])" />
<template v-else>{{ record[field.key] }}</template>
</template>
</a-table-column>
<a-table-column title="操作" :width="90"><template #cell="{ record }"><a-button size="mini" @click="openDetail(record)">详情</a-button></template></a-table-column>
</template>
</a-table>
<div class="pagination"><a-pagination :total="total" :current="page" :page-size="pageSize" show-total @change="changePage" /></div>
</a-card>
<a-drawer :visible="detailVisible" title="详情" :width="540" @cancel="detailVisible = false">
<a-descriptions :column="1" bordered><a-descriptions-item v-for="[key, value] in detailEntries" :key="key" :label="key">{{ value ?? '-' }}</a-descriptions-item></a-descriptions>
<a-space v-if="definition.detailActions?.length" class="detail-actions">
<a-button v-for="action in definition.detailActions" :key="action.name" :status="action.payload?.status === 'rejected' ? 'danger' : 'normal'" type="primary" @click="openDetailAction(action)">{{ action.name }}</a-button>
</a-space>
</a-drawer>
<a-modal :visible="actionVisible" :title="activeAction?.name" @cancel="actionVisible = false" @ok="submitDetailAction">
<a-form :model="actionForm" layout="vertical">
<a-form-item v-for="field in activeAction?.fields" :key="field.key" :label="field.label" :required="field.required">
<a-textarea v-if="field.type === 'textarea'" v-model="actionForm[field.key]" />
<a-input v-else v-model="actionForm[field.key]" />
</a-form-item>
</a-form>
</a-modal>
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { resourceApi } from '@/api/resource';
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
import type { DetailAction, ResourceField, ResourceUiDefinition } from '@/api/resources';
import IdentityText from '@/components/IdentityText.vue';
type Row = Record<string, unknown>;
const props = defineProps<{ definition: ResourceUiDefinition }>();
const loading = ref(false);
const page = ref(1);
const pageSize = 50;
const total = ref(0);
const list = ref<Row[]>([]);
const filters = reactive({ keyword: '' });
const detail = ref<Row>({});
const detailVisible = ref(false);
const actionVisible = ref(false);
const activeAction = ref<DetailAction>();
const actionForm = reactive<Record<string, any>>({});
const displayFields = computed(() =>
props.definition.fields.filter((field) => field.key !== 'identity' && field.key !== 'status'),
);
function columnWidth(field: ResourceField) {
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 === 'select') return 140;
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;
}
const detailEntries = computed(() =>
Object.entries(detail.value).filter(
([key]) => key !== 'id' && !key.endsWith('_id'),
),
);
async function load() {
loading.value = true;
try {
const result = await resourceApi.list<Row>(
props.definition.resource,
page.value,
pageSize,
filters.keyword ? { keyword: filters.keyword } : {},
);
list.value = result.list;
total.value = result.total;
} catch (error) {
Message.error((error as Error).message);
} finally {
loading.value = false;
}
}
async function openDetail(row: Row) {
try {
detail.value = await resourceApi.detail<Row>(
props.definition.resource,
String(row.identity),
);
detailVisible.value = true;
} catch (error) {
Message.error((error as Error).message);
}
}
function openDetailAction(action: DetailAction) {
activeAction.value = action;
for (const field of action.fields) actionForm[field.key] = undefined;
actionVisible.value = true;
}
async function submitDetailAction() {
const action = activeAction.value;
if (!action) return;
if (
action.fields.some(
(field) => field.required && isMissingField(actionForm[field.key]),
)
) {
Message.warning('请填写必填字段');
return;
}
try {
const payload = {
...action.payload,
...buildResourcePayload(action.fields, actionForm),
};
await resourceApi.create(
action.resource.replace(':identity', String(detail.value.identity)),
payload,
);
Message.success('审批完成');
actionVisible.value = false;
await openDetail(detail.value);
await load();
} catch (error) {
Message.error((error as Error).message);
}
}
async function changePage(next: number) {
page.value = next;
await load();
}
onMounted(load);
</script>
<style scoped lang="less">
.filters {
flex: 1 1 420px;
}
.list-toolbar {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px 24px;
margin-bottom: 16px;
}
.list-actions {
margin-left: auto;
}
.pagination {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.detail-actions {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,117 @@
/**
* 功能:加载资源列表所需的钱包、账户数量和配送点数量等扩展数据。
* 版本v1.0.0
*/
import { ref, type Ref } from 'vue';
import { resourceApi } from '@/api/resource';
import type { ResourceUiDefinition } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
export function useResourceListExtras(
definition: Ref<ResourceUiDefinition> | { value: ResourceUiDefinition },
list: Ref<ResourceRow[]>,
) {
const accountCounts = ref<Record<string, number>>({});
const deliveryPointCounts = ref<Record<string, number>>({});
const walletByOwner = ref<Record<string, ResourceRow>>({});
async function loadAllRows(
resource: string,
filters: Record<string, string> = {},
) {
const rows: ResourceRow[] = [];
for (let page = 1; page <= 100; page += 1) {
const result = await resourceApi.list<ResourceRow>(
resource,
page,
100,
filters,
);
rows.push(...result.list);
if (rows.length >= result.total || result.list.length < 100) break;
}
return rows;
}
async function loadAccountCounts() {
const management = definition.value.accountManagement;
if (!management || !list.value.length) {
accountCounts.value = {};
return;
}
const filterKey =
management.relationKey === 'gas_basic_identity'
? 'gas_basic_identities'
: 'delivery_basic_identities';
const accounts = await loadAllRows(management.resource, {
[filterKey]: list.value
.map((row) => String(row.identity ?? ''))
.filter(Boolean)
.join(','),
});
accountCounts.value = accounts.reduce<Record<string, number>>(
(counts, account) => {
const identity = String(account[management.relationKey] ?? '');
if (identity) counts[identity] = (counts[identity] ?? 0) + 1;
return counts;
},
{},
);
}
async function loadDeliveryPointCounts() {
if (definition.value.name !== 'gas_basic' || !list.value.length) {
deliveryPointCounts.value = {};
return;
}
const rows = await loadAllRows('/delivery_basic', {
gas_basic_identities: list.value
.map((row) => String(row.identity ?? ''))
.filter(Boolean)
.join(','),
});
deliveryPointCounts.value = rows.reduce<Record<string, number>>(
(counts, row) => {
const identity = String(row.gas_basic_identity ?? '');
if (identity) counts[identity] = (counts[identity] ?? 0) + 1;
return counts;
},
{},
);
}
async function loadWallets() {
const ownerType = definition.value.walletOwnerType;
if (!ownerType || !list.value.length) {
walletByOwner.value = {};
return;
}
const wallets = (
await resourceApi.list<ResourceRow>('/wallet_basic', 1, 100, {
owner_type: ownerType,
owner_identities: list.value
.map((row) => String(row.identity ?? ''))
.filter(Boolean)
.join(','),
})
).list;
walletByOwner.value = wallets.reduce<Record<string, ResourceRow>>(
(result, wallet) => {
if (wallet.owner_type === ownerType)
result[String(wallet.owner_identity ?? '')] = wallet;
return result;
},
{},
);
}
async function loadExtras() {
await Promise.all([
loadAccountCounts(),
loadDeliveryPointCounts(),
loadWallets(),
]);
}
return { accountCounts, deliveryPointCounts, walletByOwner, loadExtras };
}