feat: 标准资源使用独立页面并优化详情布局
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
172
frontend/platform_admin/src/views/resource/ResourceFieldForm.vue
Normal file
172
frontend/platform_admin/src/views/resource/ResourceFieldForm.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user