feat(platform-admin): align frontend with backend contracts

This commit is contained in:
david
2026-07-28 14:26:09 +08:00
parent bc8138f978
commit 4cc17c9efb
6 changed files with 438 additions and 68 deletions

View File

@@ -6,11 +6,12 @@
<a-button v-if="canCreate" type="primary" @click="openCreate">新建</a-button>
</a-space>
</template>
<a-form :model="filters" layout="inline" class="filters" @submit.prevent="load">
<a-form :model="filters" layout="inline" class="filters" @submit.prevent="search">
<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-button @click="resetSearch">重置</a-button>
</a-form>
<a-form v-if="definition.name === 'wallet_basic'" :model="walletOwner" layout="inline" class="wallet-owner" @submit.prevent="getOwnerWallet">
<a-form-item label="归属类型">
@@ -28,26 +29,32 @@
<a-table :data="list" :loading="loading" :pagination="false" row-key="identity">
<template #columns>
<a-table-column title="业务标识" data-index="identity" :width="220" ellipsis tooltip />
<a-table-column v-for="field in displayFields" :key="field.key" :title="field.label" :data-index="field.key" ellipsis tooltip />
<a-table-column title="操作" :width="190" fixed="right">
<a-table-column v-for="field in displayFields" :key="field.key" :title="field.label" ellipsis tooltip>
<template #cell="{ record }">
{{ displayFieldValue(field, record) }}
</template>
</a-table-column>
<a-table-column title="操作" :width="260" fixed="right">
<template #cell="{ record }">
<a-space>
<a-button size="mini" @click="openDetail(record)">详情</a-button>
<a-button v-if="canEdit" size="mini" @click="openEdit(record)">编辑</a-button>
<a-button v-if="canArchive" size="mini" status="danger" @click="confirmArchive(record)">归档</a-button>
<a-button v-if="canEdit" size="mini" :disabled="isProtectedRecord(record)" @click="openEdit(record)">编辑</a-button>
<a-button v-if="canChangeStatus" size="mini" :disabled="isProtectedRecord(record)" @click="openStatus(record)">状态</a-button>
<a-button v-if="canArchive" size="mini" status="danger" :disabled="isProtectedRecord(record)" @click="confirmArchive(record)">归档</a-button>
</a-space>
</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-empty v-if="!loading && list.length === 0" description="暂无符合条件的数据" />
<div class="pagination"><a-pagination :total="total" :current="page" :page-size="pageSize" show-total show-jumper @change="changePage" /></div>
</a-card>
<a-drawer :visible="formVisible" :title="editingIdentity ? `编辑${definition.title}` : `新建${definition.title}`" :width="480" @cancel="formVisible = false" @ok="save">
<a-drawer :visible="formVisible" :title="editingIdentity ? `编辑${definition.title}` : `新建${definition.title}`" :width="520" :ok-loading="saving" @cancel="formVisible = false" @ok="save">
<a-form :model="form" layout="vertical">
<a-form-item v-for="field in formFields" :key="field.key" :label="field.label" :required="isResourceFieldRequired(field, formMode)">
<a-switch v-if="field.type === 'boolean'" v-model="form[field.key]" />
<a-input-number v-else-if="field.type === 'number'" 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 }" />
@@ -55,7 +62,7 @@
<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'" allow-clear allow-search>
<a-select v-else-if="field.type === 'identity' || field.type === 'identity-list'" v-model="form[field.key]" :multiple="field.type === 'identity-list'" allow-clear allow-search :loading="relationLoading[field.relation ?? '']" @search="(value: string) => searchRelation(field.relation, value)">
<a-option v-for="option in relationOptions[field.relation ?? ''] ?? []" :key="String(option.identity)" :value="String(option.identity)">
{{ optionLabel(option) }}
</a-option>
@@ -70,27 +77,38 @@
<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">
<a-descriptions-item v-for="[key, value] in detailEntries" :key="key" :label="fieldLabel(key)">
<pre v-if="typeof value === 'object'" class="json-value">{{ formatValue(value) }}</pre>
<template v-else>{{ value ?? '-' }}</template>
<template v-else>{{ displayValue(key, value) }}</template>
</a-descriptions-item>
</a-descriptions>
<a-tabs v-if="detailCollections.length" class="detail-collections">
<a-tab-pane v-for="collection in detailCollections" :key="collection.key" :title="collection.title">
<a-table :data="collection.rows" :pagination="false" size="small">
<template #columns>
<a-table-column v-for="column in collection.columns" :key="column" :title="fieldLabel(column)">
<template #cell="{ record }">{{ displayValue(column, record[column]) }}</template>
</a-table-column>
</template>
</a-table>
</a-tab-pane>
</a-tabs>
<a-space class="detail-actions">
<a-button v-for="action in definition.detailActions" :key="action.name" type="primary" :status="action.danger ? 'danger' : 'normal'" @click="openDetailAction(action)">{{ action.name }}</a-button>
</a-space>
</a-drawer>
<a-modal :visible="actionVisible" :title="activeAction?.name" @cancel="actionVisible = false" @ok="submitDetailAction">
<a-modal :visible="actionVisible" :title="activeAction?.name" :ok-loading="actionSubmitting" :ok-button-props="{ status: activeAction?.danger ? 'danger' : 'normal' }" @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-input-number v-if="field.type === 'number'" v-model="actionForm[field.key]" />
<a-input-number v-if="field.type === 'number' || field.type === 'money'" v-model="actionForm[field.key]" :precision="field.type === 'money' ? 2 : 0" />
<a-switch v-else-if="field.type === 'boolean'" v-model="actionForm[field.key]" />
<a-textarea v-else-if="field.type === 'textarea'" v-model="actionForm[field.key]" />
<a-date-picker v-else-if="field.type === 'datetime'" v-model="actionForm[field.key]" show-time value-format="YYYY-MM-DDTHH:mm:ssZ" />
<a-select v-else-if="field.type === 'select'" v-model="actionForm[field.key]">
<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="actionForm[field.key]" :multiple="field.type === 'identity-list'" allow-search>
<a-select v-else-if="field.type === 'identity' || field.type === 'identity-list'" v-model="actionForm[field.key]" :multiple="field.type === 'identity-list'" allow-search :loading="relationLoading[field.relation ?? '']" @search="(value: string) => searchRelation(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="actionForm[field.key]" />
@@ -101,7 +119,9 @@
<script setup lang="ts">
import { Message, Modal } from '@arco-design/web-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import dayjs from 'dayjs';
import { computed, onMounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { resourceApi } from '@/api/resource';
import { platformApi, type PlatformRole } from '@/api/platform';
import {
@@ -109,16 +129,28 @@ import {
isMissingField,
isResourceFieldRequired,
} from '@/api/resource-form';
import type { DetailAction, ResourceUiDefinition } from '@/api/resources';
import type {
DetailAction,
ResourceField,
ResourceUiDefinition,
} from '@/api/resources';
import { useUserStore } from '@/store';
type Row = Record<string, unknown>;
const props = defineProps<{ definition: ResourceUiDefinition }>();
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const loading = ref(false);
const page = ref(1);
const saving = ref(false);
const actionSubmitting = ref(false);
const page = ref(Math.max(1, Number(route.query.page) || 1));
const pageSize = 20;
const total = ref(0);
const list = ref<Row[]>([]);
const filters = reactive({ keyword: '' });
const filters = reactive({
keyword: typeof route.query.keyword === 'string' ? route.query.keyword : '',
});
const walletOwner = reactive({ type: 'user', identity: '' });
const formVisible = ref(false);
const detailVisible = ref(false);
@@ -130,25 +162,84 @@ const activeAction = ref<DetailAction>();
const actionForm = reactive<Record<string, any>>({});
const roleOptions = ref<PlatformRole[]>([]);
const relationOptions = reactive<Record<string, Row[]>>({});
const canCreate = computed(() => ['writable', 'append_only'].includes(props.definition.mode) || ['product_info', 'gasorder_contract', 'gasorder_basic'].includes(props.definition.name));
const canEdit = computed(() => props.definition.mode === 'writable' || ['product_info', 'gasorder_contract'].includes(props.definition.name));
const canArchive = computed(() => props.definition.mode === 'writable');
const relationLoading = reactive<Record<string, boolean>>({});
const relationSearchTimers = new Map<string, ReturnType<typeof setTimeout>>();
const platformRootWriteResources = new Set([
'platform_account',
'platform_role',
'platform_menu',
]);
const requiresRootWrite = computed(() =>
platformRootWriteResources.has(props.definition.name),
);
const canCreate = computed(
() =>
props.definition.canCreate &&
(!requiresRootWrite.value || userStore.role === 'root'),
);
const canEdit = computed(
() =>
props.definition.canEdit &&
(props.definition.name === 'platform_account' ||
!requiresRootWrite.value ||
userStore.role === 'root'),
);
const canChangeStatus = computed(
() =>
props.definition.canChangeStatus &&
(!requiresRootWrite.value || userStore.role === 'root'),
);
const canArchive = computed(
() =>
props.definition.canArchive &&
(!requiresRootWrite.value || userStore.role === 'root'),
);
const formMode = computed<'create' | 'edit'>(() =>
editingIdentity.value ? 'edit' : 'create',
);
const formFields = computed(() =>
props.definition.fields.filter(
(field) => formMode.value === 'create' || field.type !== 'password',
(field) =>
(formMode.value === 'create' || field.type !== 'password') &&
!(
formMode.value === 'edit' &&
props.definition.name === 'platform_account' &&
userStore.role !== 'root' &&
field.key === 'platform_role_code'
),
),
);
const displayFields = computed(() =>
props.definition.fields.filter((field) => field.key !== 'status'),
props.definition.fields
.filter((field) => field.key !== 'password')
.slice(0, 6),
);
const detailEntries = computed(() =>
Object.entries(detail.value).filter(
([key]) => key !== 'id' && !key.endsWith('_id'),
([key, value]) =>
key !== 'id' &&
!key.endsWith('_id') &&
!Array.isArray(value) &&
!(value && typeof value === 'object' && key !== 'order' && key !== 'contract'),
),
);
const detailCollections = computed(() =>
Object.entries(detail.value)
.filter(([, value]) => Array.isArray(value) && value.length > 0)
.map(([key, value]) => {
const rows = value as Row[];
const columns = [
...new Set(
rows.flatMap((row) =>
Object.keys(row).filter(
(column) => column !== 'id' && !column.endsWith('_id'),
),
),
),
].slice(0, 8);
return { key, title: fieldLabel(key), rows, columns };
}),
);
const currentIdentity = computed(() =>
String(
detail.value.identity ??
@@ -161,7 +252,12 @@ const currentIdentity = computed(() =>
function resetForm(data?: Row) {
for (const field of props.definition.fields) {
const value = data?.[field.key];
form[field.key] = value == null ? undefined : value;
form[field.key] =
value == null
? undefined
: field.type === 'money'
? Number(value) / 100
: value;
}
}
@@ -183,6 +279,24 @@ async function load() {
}
}
async function syncQuery() {
const query: Record<string, string> = {};
if (page.value > 1) query.page = String(page.value);
if (filters.keyword.trim()) query.keyword = filters.keyword.trim();
await router.replace({ query });
}
async function search() {
page.value = 1;
await syncQuery();
await load();
}
async function resetSearch() {
filters.keyword = '';
await search();
}
function openCreate() {
editingIdentity.value = '';
resetForm();
@@ -195,6 +309,10 @@ function openEdit(row: Row) {
formVisible.value = true;
}
function isProtectedRecord(row: Row) {
return props.definition.name === 'platform_role' && row.is_system === true;
}
async function openDetail(row: Row) {
try {
detail.value = await resourceApi.detail<Row>(
@@ -224,6 +342,14 @@ async function openDetailAction(action: DetailAction) {
return;
}
}
if (action.danger && !(action.fields?.length)) {
Modal.warning({
title: `确认${action.name}`,
content: '该操作会改变业务状态,执行后可能无法直接撤销。',
onOk: () => executeDetailAction(action),
});
return;
}
actionVisible.value = true;
}
@@ -238,6 +364,11 @@ async function submitDetailAction() {
Message.warning('请填写必填字段');
return;
}
await executeDetailAction(action);
}
async function executeDetailAction(action: DetailAction) {
actionSubmitting.value = true;
try {
const payload = {
...buildResourcePayload(action.fields ?? [], actionForm),
@@ -253,9 +384,11 @@ async function submitDetailAction() {
}
Message.success('操作成功');
actionVisible.value = false;
await openDetail(detail.value);
await Promise.all([openDetail({ identity: currentIdentity.value }), load()]);
} catch (error) {
Message.error((error as Error).message);
} finally {
actionSubmitting.value = false;
}
}
@@ -270,9 +403,10 @@ async function save() {
Message.warning('请填写必填字段');
return;
}
saving.value = true;
try {
const payload = buildResourcePayload(
props.definition.fields,
formFields.value,
form,
formMode.value,
);
@@ -288,9 +422,33 @@ async function save() {
await load();
} catch (error) {
Message.error((error as Error).message);
} finally {
saving.value = false;
}
}
function openStatus(row: Row) {
detail.value = row;
openDetailAction({
name: '修改状态',
resource: `${props.definition.resource}/:identity/status`,
method: 'PATCH',
fields: [
{
key: 'status',
label: '目标状态',
type: 'select',
required: true,
options: [
{ label: '启用', value: 'enabled' },
{ label: '停用', value: 'disabled' },
{ label: '归档', value: 'archived' },
],
},
],
});
}
function confirmArchive(row: Row) {
Modal.warning({
title: '确认归档',
@@ -312,6 +470,7 @@ function confirmArchive(row: Row) {
async function changePage(next: number) {
page.value = next;
await syncQuery();
await load();
}
@@ -336,6 +495,104 @@ function formatValue(value: unknown) {
return JSON.stringify(value, null, 2);
}
function fieldLabel(key: string) {
const aliases: Record<string, string> = {
identity: '业务标识',
created_at: '创建时间',
updated_at: '更新时间',
version: '版本',
items: '订单明细',
assignments: '分配记录',
statuses: '状态记录',
tracks: '运行轨迹',
confirmations: '确认记录',
payments: '支付记录',
revisions: '修订记录',
products: '合同气瓶',
order: '订单',
contract: '合同',
};
const normalizedKey = key.endsWith('_masked')
? key.slice(0, -'_masked'.length)
: key;
return (
props.definition.fields.find((field) => field.key === normalizedKey)?.label ??
aliases[key] ??
key
);
}
function relationLabel(field: ResourceField, identity: string) {
const match = (relationOptions[field.relation ?? ''] ?? []).find(
(option) => String(option.identity) === identity,
);
return match ? `${optionLabel(match)} · ${identity}` : identity;
}
function displayFieldValue(field: ResourceField, row: Row) {
const value = row[field.key] ?? row[`${field.key}_masked`];
if (
(field.type === 'identity' || field.type === 'identity-list') &&
typeof value === 'string'
)
return relationLabel(field, value);
return displayValue(field.key, value);
}
function displayValue(key: string, value: unknown) {
if (value == null || value === '') return '-';
if (typeof value === 'boolean') return value ? '是' : '否';
if (
key === 'amount' ||
key.endsWith('_amount') ||
key === 'unit_price' ||
key === 'balance' ||
key === 'withdrawal_balance' ||
key.endsWith('_balance_after')
) {
const amount = Number(value);
return Number.isFinite(amount) ? `¥${(amount / 100).toFixed(2)}` : String(value);
}
if (
key.endsWith('_at') ||
key === 'created_at' ||
key === 'updated_at'
) {
const date = dayjs(String(value));
return date.isValid() ? date.format('YYYY-MM-DD HH:mm:ss') : String(value);
}
if (typeof value === 'object') return formatValue(value);
return String(value);
}
async function loadRelation(resource: string, keyword = '') {
relationLoading[resource] = true;
try {
relationOptions[resource] = (
await resourceApi.list<Row>(
resource,
1,
100,
keyword ? { keyword } : {},
)
).list;
} catch (error) {
Message.error(`关联数据加载失败:${(error as Error).message}`);
} finally {
relationLoading[resource] = false;
}
}
function searchRelation(resource: string | undefined, keyword: string) {
if (!resource) return;
const timer = relationSearchTimers.get(resource);
if (timer) clearTimeout(timer);
relationSearchTimers.set(
resource,
setTimeout(() => loadRelation(resource, keyword.trim()), 250),
);
}
onMounted(async () => {
await load();
const actionFields = props.definition.detailActions?.flatMap((item) => item.fields ?? []) ?? [];
@@ -345,9 +602,7 @@ onMounted(async () => {
.filter((value): value is string => Boolean(value)),
);
await Promise.all(
[...relationPaths].map(async (resource) => {
relationOptions[resource] = (await resourceApi.list<Row>(resource, 1, 200)).list;
}),
[...relationPaths].map((resource) => loadRelation(resource)),
);
if (props.definition.fields.some((field) => field.key === 'platform_role_code')) {
const result = await platformApi.listRole();
@@ -357,6 +612,15 @@ onMounted(async () => {
}
});
watch(
() => props.definition.resource,
async () => {
page.value = 1;
filters.keyword = '';
await load();
},
);
function optionLabel(option: Row) {
return String(option.name ?? option.title ?? option.code ?? option.username ?? option.contract_no ?? option.order_no ?? option.identity);
}

View File

@@ -3,15 +3,16 @@
<template #extra>
<a-space>
<a-button @click="load">刷新</a-button>
<a-button v-if="canWrite" type="primary" @click="openCreate">新增</a-button>
<a-button v-if="canCreate" type="primary" @click="openCreate">新增</a-button>
</a-space>
</template>
<a-tree :data="tree" :loading="loading" :field-names="{ key: 'identity', title: 'name', children: 'children' }">
<template #title="node">
<a-space>
{{ node.title }}
<a-button v-if="canWrite" size="mini" @click.stop="openEdit(node)">编辑</a-button>
<a-button v-if="canWrite" size="mini" status="danger" @click.stop="confirmArchive(node)">归档</a-button>
<a-button v-if="canEdit" size="mini" @click.stop="openEdit(node)">编辑</a-button>
<a-button v-if="canChangeStatus" size="mini" @click.stop="confirmStatus(node)">{{ node.status === 'enabled' ? '停用' : '启用' }}</a-button>
<a-button v-if="canArchive" size="mini" status="danger" @click.stop="confirmArchive(node)">归档</a-button>
</a-space>
</template>
</a-tree>
@@ -19,8 +20,13 @@
<a-drawer :visible="formVisible" :title="editingIdentity ? `编辑${definition.title}` : `新增${definition.title}`" :width="480" @cancel="formVisible = false" @ok="save">
<a-form :model="form" layout="vertical">
<a-form-item v-for="field in definition.fields" :key="field.key" :label="field.label" :required="field.required">
<a-input-number v-if="field.type === 'number'" v-model="form[field.key]" />
<a-input-number v-if="field.type === 'number' || field.type === 'money'" v-model="form[field.key]" :precision="field.type === 'money' ? 2 : 0" />
<a-switch v-else-if="field.type === 'boolean'" v-model="form[field.key]" />
<a-select v-else-if="field.type === 'identity'" v-model="form[field.key]" allow-clear allow-search>
<a-option v-for="option in list.filter((item) => item.identity !== editingIdentity)" :key="option.identity" :value="option.identity">
{{ option.name ?? option.menu_code ?? option.identity }}
</a-option>
</a-select>
<a-input v-else v-model="form[field.key]" />
</a-form-item>
</a-form>
@@ -33,6 +39,7 @@ import { computed, onMounted, reactive, ref } from 'vue';
import { resourceApi } from '@/api/resource';
import { buildResourcePayload, isMissingField } from '@/api/resource-form';
import type { ResourceUiDefinition } from '@/api/resources';
import { useUserStore } from '@/store';
type Node = Record<string, unknown> & {
identity: string;
@@ -40,12 +47,25 @@ type Node = Record<string, unknown> & {
children: Node[];
};
const props = defineProps<{ definition: ResourceUiDefinition }>();
const userStore = useUserStore();
const loading = ref(false);
const list = ref<Node[]>([]);
const formVisible = ref(false);
const editingIdentity = ref('');
const form = reactive<Record<string, any>>({});
const canWrite = computed(() => props.definition.mode === 'writable');
const rootAllowed = computed(
() => props.definition.name !== 'platform_menu' || userStore.role === 'root',
);
const canCreate = computed(
() => props.definition.canCreate && rootAllowed.value,
);
const canEdit = computed(() => props.definition.canEdit && rootAllowed.value);
const canChangeStatus = computed(
() => props.definition.canChangeStatus && rootAllowed.value,
);
const canArchive = computed(
() => props.definition.canArchive && rootAllowed.value,
);
const tree = computed(() => {
const byIdentity = new Map<string, Node>();
const roots: Node[] = [];
@@ -68,6 +88,27 @@ function reset(data?: Node) {
}
}
function confirmStatus(node: Node) {
const status = node.status === 'enabled' ? 'disabled' : 'enabled';
Modal.warning({
title: status === 'enabled' ? '确认启用' : '确认停用',
content: `确定要${status === 'enabled' ? '启用' : '停用'}${String(node.name ?? node.identity)}”吗?`,
onOk: async () => {
try {
await resourceApi.updateStatus(
props.definition.resource,
node.identity,
status,
);
Message.success('状态已更新');
await load();
} catch (error) {
Message.error((error as Error).message);
}
},
});
}
function openCreate() {
editingIdentity.value = '';
reset();