fix: enforce platform resource allowlists

This commit is contained in:
2026-07-27 09:56:33 +08:00
parent bc06d9b5f9
commit 98995d6968
7 changed files with 1302 additions and 105 deletions

View File

@@ -28,3 +28,11 @@ The tree page consumes `parent_identity`; the corresponding menu API must expose
- Corrected the safety, e-commerce, staff, and user API paths to match the registered backend routes exactly.
- Added audit assertions for exact path/mode/page-kind contracts, separate read-only UI surfaces, identifier leakage, and tree semantics.
- Detail drawers now filter `id` and `*_id` fields before rendering.
## Fix round 2
- The resource catalogue now contains exactly the 46 backend `ExpectedResources` names, including the append-only `saf_event_disposal` contract rather than a synthetic dashboard entry.
- Every definition has its own backend write allowlist and required `*_identity` fields; no resource inherits a generic `name/status` schema.
- Safety disposal is a detail action on `saf_event` (`/safety/saf_event/:identity/disposals`) and has no standalone menu or page.
- `ec_category` and `platform_menu` use tree pages. The shared tree preserves identity-first behavior and uses backend `parent_id` only for in-memory hierarchy adaptation when `parent_identity` is unavailable.
- The audit transpiles and evaluates `resources.ts`, then compares the resulting definitions, allowlists, required identities, modes, page types, tree semantics, and identifier-safety rules to the complete expected contract.

File diff suppressed because it is too large Load Diff

View File

@@ -1,57 +1,66 @@
export type ResourceMode = 'writable' | 'readonly' | 'append_only';
export type ResourcePageKind = 'list' | 'tree' | 'dashboard';
export type ResourcePageKind = 'list' | 'tree';
export type ResourceField = { key: string; label: string; required?: boolean };
export type ResourceUiDefinition = { key: string; name: string; resource: string; title: string; mode: ResourceMode; pageKind: ResourcePageKind; fields: ResourceField[] };
const fields: ResourceField[] = [{ key: 'name', label: '名称' }, { key: 'status', label: '状态' }];
/** Exact front-end representation of the platform HTTP resource contract. */
export type DetailAction = { name: string; resource: string; fields: ResourceField[] };
export type ResourceUiDefinition = { key: string; name: string; resource: string; title: string; mode: ResourceMode; pageKind: ResourcePageKind; fields: ResourceField[]; requiredIdentities: string[]; detailActions?: DetailAction[] };
const field = (value: string): ResourceField => ({ key: value.replace(/!$/, ''), label: value.replace(/!$/, ''), required: value.endsWith('!') || undefined });
const define = (name: string, resource: string, mode: ResourceMode, pageKind: ResourcePageKind, keys: string[], detailActions?: DetailAction[]): ResourceUiDefinition => {
const fields = keys.map(field);
return { key: name.replace(/_/g, '-'), name, resource, title: name, mode, pageKind, fields, requiredIdentities: fields.filter((item) => item.required && item.key.endsWith('_identity')).map((item) => item.key), ...(detailActions ? { detailActions } : {}) };
};
const action = (name: string, resource: string, keys: string[]): DetailAction => ({ name, resource, fields: keys.map(field) });
/** Exact UI contract for every backend ExpectedResources entry. */
export const resources: ResourceUiDefinition[] = [
{ key: 'overview', name: 'overview', resource: '/dashboard/overview', title: 'overview', mode: 'readonly', pageKind: 'dashboard', fields },
{ key: 'gas-basic', name: 'gas_basic', resource: '/gas/gas_basic', title: 'gas_basic', mode: 'writable', pageKind: 'list', fields },
{ key: 'gas-account', name: 'gas_account', resource: '/gas/gas_account', title: 'gas_account', mode: 'writable', pageKind: 'list', fields },
{ key: 'delivery-basic', name: 'delivery_basic', resource: '/delivery/delivery_basic', title: 'delivery_basic', mode: 'writable', pageKind: 'list', fields },
{ key: 'delivery-account', name: 'delivery_account', resource: '/delivery/delivery_account', title: 'delivery_account', mode: 'writable', pageKind: 'list', fields },
{ key: 'delivery-task', name: 'delivery_task', resource: '/delivery/delivery_task', title: 'delivery_task', mode: 'writable', pageKind: 'list', fields },
{ key: 'delivery-track', name: 'delivery_track', resource: '/delivery/delivery_track', title: 'delivery_track', mode: 'writable', pageKind: 'list', fields },
{ key: 'delivery-track-point', name: 'delivery_track_point', resource: '/delivery/delivery_track_point', title: 'delivery_track_point', mode: 'writable', pageKind: 'list', fields },
{ key: 'staff-account', name: 'staff_account', resource: '/staff/account', title: 'staff_account', mode: 'writable', pageKind: 'list', fields },
{ key: 'staff-credential', name: 'staff_credential', resource: '/staff/credential', title: 'staff_credential', mode: 'writable', pageKind: 'list', fields },
{ key: 'user-account', name: 'user_account', resource: '/user/account', title: 'user_account', mode: 'writable', pageKind: 'list', fields },
{ key: 'user-address', name: 'user_address', resource: '/user/address', title: 'user_address', mode: 'writable', pageKind: 'list', fields },
{ key: 'user-service-relation', name: 'user_service_relation', resource: '/user/service_relation', title: 'user_service_relation', mode: 'writable', pageKind: 'list', fields },
{ key: 'dev-smart-cylinder-valve', name: 'dev_smart_cylinder_valve', resource: '/device/dev_smart_cylinder_valve', title: 'dev_smart_cylinder_valve', mode: 'writable', pageKind: 'list', fields },
{ key: 'dev-device-binding', name: 'dev_device_binding', resource: '/device/dev_device_binding', title: 'dev_device_binding', mode: 'writable', pageKind: 'list', fields },
{ key: 'dev-telemetry', name: 'dev_telemetry', resource: '/device/dev_telemetry', title: 'dev_telemetry', mode: 'readonly', pageKind: 'list', fields },
{ key: 'saf-rule', name: 'saf_rule', resource: '/safety/saf_rule', title: 'saf_rule', mode: 'writable', pageKind: 'list', fields },
{ key: 'saf-event', name: 'saf_event', resource: '/safety/saf_event', title: 'saf_event', mode: 'writable', pageKind: 'list', fields },
{ key: 'saf-inspection', name: 'saf_inspection', resource: '/safety/saf_inspection', title: 'saf_inspection', mode: 'writable', pageKind: 'list', fields },
{ key: 'ec-category', name: 'ec_category', resource: '/ec/ec_category', title: 'ec_category', mode: 'writable', pageKind: 'list', fields },
{ key: 'ec-product', name: 'ec_product', resource: '/ec/ec_product', title: 'ec_product', mode: 'writable', pageKind: 'list', fields },
{ key: 'ec-product-attribute', name: 'ec_product_attribute', resource: '/ec/ec_product_attribute', title: 'ec_product_attribute', mode: 'writable', pageKind: 'list', fields },
{ key: 'ec-product-image', name: 'ec_product_image', resource: '/ec/ec_product_image', title: 'ec_product_image', mode: 'writable', pageKind: 'list', fields },
{ key: 'ec-cart', name: 'ec_cart', resource: '/ec/ec_cart', title: 'ec_cart', mode: 'writable', pageKind: 'list', fields },
{ key: 'ec-order', name: 'ec_order', resource: '/ec/ec_order', title: 'ec_order', mode: 'writable', pageKind: 'list', fields },
{ key: 'ec-order-item', name: 'ec_order_item', resource: '/ec/ec_order_item', title: 'ec_order_item', mode: 'writable', pageKind: 'list', fields },
{ key: 'ec-review', name: 'ec_review', resource: '/ec/ec_review', title: 'ec_review', mode: 'writable', pageKind: 'list', fields },
{ key: 'fin-payment', name: 'fin_payment', resource: '/finance/fin_payment', title: 'fin_payment', mode: 'writable', pageKind: 'list', fields },
{ key: 'fin-settlement', name: 'fin_settlement', resource: '/finance/fin_settlement', title: 'fin_settlement', mode: 'writable', pageKind: 'list', fields },
{ key: 'fin-reconciliation', name: 'fin_reconciliation', resource: '/finance/fin_reconciliation', title: 'fin_reconciliation', mode: 'writable', pageKind: 'list', fields },
{ key: 'cnt-content', name: 'cnt_content', resource: '/content/cnt_content', title: 'cnt_content', mode: 'writable', pageKind: 'list', fields },
{ key: 'ntf-template', name: 'ntf_template', resource: '/notification/ntf_template', title: 'ntf_template', mode: 'writable', pageKind: 'list', fields },
{ key: 'cs-ticket', name: 'cs_ticket', resource: '/customer_service/cs_ticket', title: 'cs_ticket', mode: 'writable', pageKind: 'list', fields },
{ key: 'platfrom-account', name: 'platfrom_account', resource: '/platform/platfrom_account', title: 'platfrom_account', mode: 'writable', pageKind: 'list', fields },
{ key: 'platform-role', name: 'platform_role', resource: '/platform/platform_role', title: 'platform_role', mode: 'writable', pageKind: 'list', fields },
{ key: 'platform-menu', name: 'platform_menu', resource: '/platform/platform_menu', title: 'platform_menu', mode: 'writable', pageKind: 'tree', fields },
{ key: 'wallet', name: 'wallet', resource: '/wallet/wallet', title: 'wallet', mode: 'readonly', pageKind: 'list', fields },
{ key: 'wallet-ledger', name: 'wallet_ledger', resource: '/wallet/wallet_ledger', title: 'wallet_ledger', mode: 'readonly', pageKind: 'list', fields },
{ key: 'wallet-recharge', name: 'wallet_recharge', resource: '/wallet/wallet_recharge', title: 'wallet_recharge', mode: 'readonly', pageKind: 'list', fields },
{ key: 'wallet-withdrawal', name: 'wallet_withdrawal', resource: '/wallet/wallet_withdrawal', title: 'wallet_withdrawal', mode: 'readonly', pageKind: 'list', fields },
{ key: 'report', name: 'report', resource: '/report/report', title: 'report', mode: 'readonly', pageKind: 'list', fields },
{ key: 'report-item', name: 'report_item', resource: '/report/report_item', title: 'report_item', mode: 'readonly', pageKind: 'list', fields },
{ key: 'report-metric-snapshot', name: 'report_metric_snapshot', resource: '/report/report_metric_snapshot', title: 'report_metric_snapshot', mode: 'readonly', pageKind: 'list', fields },
{ key: 'aud-operation-log', name: 'aud_operation_log', resource: '/audit/aud_operation_log', title: 'aud_operation_log', mode: 'readonly', pageKind: 'list', fields },
{ key: 'aud-export-log', name: 'aud_export_log', resource: '/audit/aud_export_log', title: 'aud_export_log', mode: 'readonly', pageKind: 'list', fields },
{ key: 'aud-approval', name: 'aud_approval', resource: '/audit/aud_approval', title: 'aud_approval', mode: 'readonly', pageKind: 'list', fields },
define('gas_basic', '/gas/gas_basic', 'writable', 'list', ['code!', 'name!', 'credit_code', 'principal', 'address', 'longitude', 'latitude']),
define('gas_account', '/gas/gas_account', 'writable', 'list', ['username!', 'password!', 'display_name', 'role_code', 'gas_basic_identity!']),
define('delivery_basic', '/delivery/delivery_basic', 'writable', 'list', ['delivery_code!', 'name!', 'gas_basic_identity', 'principal', 'address']),
define('delivery_account', '/delivery/delivery_account', 'writable', 'list', ['username!', 'password!', 'display_name', 'role_code', 'delivery_basic_identity!']),
define('delivery_task', '/delivery/delivery_task', 'writable', 'list', ['ec_order_identity!', 'delivery_basic_identity!', 'staff_account_identity']),
define('delivery_track', '/delivery/delivery_track', 'writable', 'list', ['delivery_task_identity!', 'started_at', 'completed_at']),
define('delivery_track_point', '/delivery/delivery_track_point', 'writable', 'list', ['delivery_track_identity!', 'point_type!', 'occurred_at!', 'longitude!', 'latitude!']),
define('staff_account', '/staff/account', 'writable', 'list', ['username!', 'password!', 'name!', 'phone', 'avatar', 'role_code', 'gas_basic_identity', 'delivery_basic_identity', 'work_status']),
define('staff_credential', '/staff/credential', 'writable', 'list', ['staff_account_identity!', 'credential_type!', 'credential_no', 'expired_at']),
define('user_account', '/user/account', 'writable', 'list', ['username!', 'password!', 'name!', 'phone', 'avatar', 'real_name']),
define('user_address', '/user/address', 'writable', 'list', ['user_account_identity!', 'address!', 'longitude', 'latitude', 'is_default']),
define('user_service_relation', '/user/service_relation', 'writable', 'list', ['user_account_identity!', 'gas_basic_identity', 'delivery_basic_identity', 'staff_account_identity']),
define('dev_smart_cylinder_valve', '/device/dev_smart_cylinder_valve', 'writable', 'list', ['device_no!', 'model', 'online_status', 'owner_identity']),
define('dev_device_binding', '/device/dev_device_binding', 'writable', 'list', ['smart_cylinder_valve_identity!', 'user_account_identity!', 'effective_at', 'expired_at']),
define('dev_telemetry', '/device/dev_telemetry', 'readonly', 'list', ['smart_cylinder_valve_identity', 'recorded_at', 'payload']),
define('saf_rule', '/safety/saf_rule', 'writable', 'list', ['rule_code!', 'version_no', 'threshold', 'action', 'gray_scope']),
define('saf_event', '/safety/saf_event', 'writable', 'list', ['event_code!', 'level!', 'title!', 'smart_cylinder_valve_identity!', 'sla_at'], [action('saf_event_disposal', '/safety/saf_event/:identity/disposals', ['action!', 'reason!'])]),
define('saf_inspection', '/safety/saf_inspection', 'writable', 'list', ['user_account_identity!', 'staff_account_identity!', 'result!', 'evidence_uri']),
define('saf_event_disposal', '/safety/saf_event/:identity/disposals', 'append_only', 'list', ['action!', 'reason!']),
define('ec_category', '/ec/ec_category', 'writable', 'tree', ['parent_identity', 'name!', 'sort_no']),
define('ec_product', '/ec/ec_product', 'writable', 'list', ['ec_category_identity!', 'product_code!', 'name!', 'price_amount!', 'stock_quantity']),
define('ec_product_attribute', '/ec/ec_product_attribute', 'writable', 'list', ['ec_product_identity!', 'name!', 'value!', 'sort_no']),
define('ec_product_image', '/ec/ec_product_image', 'writable', 'list', ['ec_product_identity!', 'image_uri!', 'sort_no', 'is_cover']),
define('ec_cart', '/ec/ec_cart', 'writable', 'list', ['user_account_identity!', 'ec_product_identity!', 'quantity!', 'selected']),
define('ec_order', '/ec/ec_order', 'writable', 'list', ['user_account_identity!', 'gas_basic_identity', 'delivery_basic_identity', 'order_no!', 'total_amount!']),
define('ec_order_item', '/ec/ec_order_item', 'writable', 'list', ['ec_order_identity!', 'ec_product_identity!', 'product_snapshot!', 'quantity!', 'sale_amount!']),
define('ec_review', '/ec/ec_review', 'writable', 'list', ['ec_order_identity!', 'ec_product_identity!', 'user_account_identity!', 'score!', 'content!']),
define('fin_payment', '/finance/fin_payment', 'writable', 'list', ['ec_order_identity!', 'channel!', 'amount!', 'paid_at']),
define('fin_settlement', '/finance/fin_settlement', 'writable', 'list', ['settlement_no!', 'subject_type!', 'subject_identity!', 'period_start!', 'period_end!']),
define('fin_reconciliation', '/finance/fin_reconciliation', 'writable', 'list', ['channel!', 'bill_date!', 'difference_amount!']),
define('cnt_content', '/content/cnt_content', 'writable', 'list', ['content_type!', 'title!', 'body!', 'version_no', 'publish_status']),
define('ntf_template', '/notification/ntf_template', 'writable', 'list', ['template_code!', 'channel!', 'content!']),
define('cs_ticket', '/customer_service/cs_ticket', 'writable', 'list', ['user_account_identity!', 'ticket_no!', 'category!', 'priority!']),
define('platfrom_account', '/platform/platfrom_account', 'writable', 'list', ['username!', 'password!', 'display_name', 'avatar', 'platform_role_code', 'phone']),
define('platform_role', '/platform/platform_role', 'writable', 'list', ['role_code!', 'name!', 'data_scope!']),
define('platform_menu', '/platform/platform_menu', 'writable', 'tree', ['parent_identity', 'menu_code!', 'name!', 'icon', 'path', 'sort_no']),
define('wallet', '/wallet/wallet', 'readonly', 'list', ['owner_identity', 'balance_amount', 'status']),
define('wallet_ledger', '/wallet/wallet_ledger', 'readonly', 'list', ['wallet_identity', 'change_amount', 'balance_after']),
define('wallet_recharge', '/wallet/wallet_recharge', 'readonly', 'list', ['wallet_identity', 'amount', 'status']),
define('wallet_withdrawal', '/wallet/wallet_withdrawal', 'readonly', 'list', ['wallet_identity', 'amount', 'status']),
define('report', '/report/report', 'readonly', 'list', ['report_code', 'name', 'status']),
define('report_item', '/report/report_item', 'readonly', 'list', ['report_identity', 'metric_code', 'value']),
define('report_metric_snapshot', '/report/report_metric_snapshot', 'readonly', 'list', ['report_identity', 'metric_code', 'value', 'captured_at']),
define('aud_operation_log', '/audit/aud_operation_log', 'readonly', 'list', ['operator_identity', 'action', 'object_identity', 'created_at']),
define('aud_export_log', '/audit/aud_export_log', 'readonly', 'list', ['operator_identity', 'resource_type', 'created_at']),
define('aud_approval', '/audit/aud_approval', 'readonly', 'list', ['operator_identity', 'status', 'handled_at']),
];
export const resourceByName = Object.fromEntries(resources.map((definition) => [definition.resource, definition])) as Record<string, ResourceUiDefinition>;
export function getResource(resourcePath: string): ResourceUiDefinition {
const definition = resourceByName[resourcePath];

View File

@@ -1,7 +1,7 @@
import { DEFAULT_LAYOUT } from '../base';
import type { AppRouteRecordRaw } from '../types';
const routes: AppRouteRecordRaw[] = [{ path: '/ec', name: 'ec', component: DEFAULT_LAYOUT, meta: { locale: 'menu.platform.ec', requiresAuth: true, icon: 'icon-apps', order: 16 }, children: [
{ path: 'ec-category', name: 'ec-ec-category', component: () => import('@/views/ec/ec_category/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_category', requiresAuth: true, roles: ['*'] } },
{ path: 'ec-category', name: 'ec-ec-category', component: () => import('@/views/ec/ec_category/TreePage.vue'), meta: { locale: 'menu.platform.ec.ec_category', requiresAuth: true, roles: ['*'] } },
{ path: 'ec-product', name: 'ec-ec-product', component: () => import('@/views/ec/ec_product/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product', requiresAuth: true, roles: ['*'] } },
{ path: 'ec-product-attribute', name: 'ec-ec-product-attribute', component: () => import('@/views/ec/ec_product_attribute/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product_attribute', requiresAuth: true, roles: ['*'] } },
{ path: 'ec-product-image', name: 'ec-ec-product-image', component: () => import('@/views/ec/ec_product_image/ListPage.vue'), meta: { locale: 'menu.platform.ec.ec_product_image', requiresAuth: true, roles: ['*'] } },

View File

@@ -1,6 +1,6 @@
<template><CrudListPage :definition="definition" /></template>
<template><TreePage :definition="definition" /></template>
<script setup lang="ts">
import CrudListPage from '@/views/shared/CrudListPage.vue';
import TreePage from '@/views/shared/TreePage.vue';
import { getResource } from '@/api/resources';
const definition = getResource('/ec/ec_category');
</script>

View File

@@ -6,17 +6,19 @@
<div class="pagination"><a-pagination :total="total" :current="page" :page-size="pageSize" show-total @change="changePage" /></div>
</a-card>
<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 v-model="form[field.key]" :placeholder="`请输入${field.label}`" /></a-form-item></a-form></a-drawer>
<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-drawer>
<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 class="detail-actions"><a-button v-for="action in definition.detailActions" :key="action.name" 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-input v-model="actionForm[field.key]" /></a-form-item></a-form></a-modal>
</template>
<script setup lang="ts">
import { Message, Modal } from '@arco-design/web-vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { resourceApi } from '@/api/resource';
import type { ResourceUiDefinition } from '@/api/resources';
import type { DetailAction, ResourceUiDefinition } from '@/api/resources';
type Row = Record<string, unknown>;
const props = defineProps<{ definition: ResourceUiDefinition }>();
const loading = ref(false); const page = ref(1); const pageSize = 20; const total = ref(0); const list = ref<Row[]>([]); const filters = reactive({ keyword: '' });
const formVisible = ref(false); const detailVisible = ref(false); const editingIdentity = ref(''); const form = reactive<Record<string, string>>({}); const detail = ref<Row>({});
const actionVisible = ref(false); const activeAction = ref<DetailAction>(); const actionForm = reactive<Record<string, string>>({});
const canCreate = computed(() => props.definition.mode !== 'readonly'); const canEdit = computed(() => props.definition.mode === 'writable'); const canArchive = computed(() => props.definition.mode === 'writable');
const displayFields = computed(() => props.definition.fields.filter((field) => field.key !== 'status'));
const detailEntries = computed(() => Object.entries(detail.value).filter(([key]) => key !== 'id' && !key.endsWith('_id')));
@@ -25,6 +27,8 @@ async function load() { loading.value = true; try { const result = await resourc
function openCreate() { editingIdentity.value = ''; resetForm(); formVisible.value = true; }
function openEdit(row: Row) { editingIdentity.value = String(row.identity ?? ''); resetForm(row); formVisible.value = true; }
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; action.fields.forEach((field) => { actionForm[field.key] = ''; }); actionVisible.value = true; }
async function submitDetailAction() { const action = activeAction.value; if (!action) return; if (action.fields.some((field) => field.required && !actionForm[field.key])) { Message.warning('请填写必填字段'); return; } try { await resourceApi.create(action.resource.replace(':identity', String(detail.value.identity)), actionForm); Message.success('操作成功'); actionVisible.value = false; await openDetail(detail.value); } catch (error) { Message.error((error as Error).message); } }
async function save() { if (props.definition.fields.some((field) => field.required && !form[field.key])) { Message.warning('请填写必填字段'); return; } try { if (editingIdentity.value) await resourceApi.update(props.definition.resource, editingIdentity.value, form); else await resourceApi.create(props.definition.resource, form); Message.success('保存成功'); formVisible.value = false; await load(); } catch (error) { Message.error((error as Error).message); } }
function confirmArchive(row: Row) { Modal.warning({ title: '确认归档', content: '归档后该记录将不再参与日常业务。', onOk: async () => { try { await resourceApi.archive(props.definition.resource, String(row.identity)); Message.success('已归档'); await load(); } catch (error) { Message.error((error as Error).message); } } }); }
async function changePage(next: number) { page.value = next; await load(); }

View File

@@ -1,24 +1,13 @@
<template>
<a-card :title="definition.title" :bordered="false">
<template #extra><a-button @click="load">刷新</a-button></template>
<a-tree :data="tree" :loading="loading" :field-names="{ key: 'identity', title: 'name', children: 'children' }" />
</a-card>
</template>
<template><a-card :title="definition.title" :bordered="false"><template #extra><a-button @click="load">刷新</a-button></template><a-tree :data="tree" :loading="loading" :field-names="{ key: 'identity', title: 'name', children: 'children' }" /></a-card></template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { computed, onMounted, ref } from 'vue';
import { resourceApi } from '@/api/resource';
import type { ResourceUiDefinition } from '@/api/resources';
type Node = Record<string, unknown> & { identity: string; parent_identity?: string; children: Node[] };
type Node = Record<string, unknown> & { identity: string; parent_identity?: string; id?: number; parent_id?: number; children: Node[] };
const props = defineProps<{ definition: ResourceUiDefinition }>();
const loading = ref(false); const list = ref<Node[]>([]);
const tree = computed(() => {
const nodes = new Map<string, Node>(); const roots: Node[] = [];
list.value.forEach((item) => nodes.set(item.identity, { ...item, children: [] }));
nodes.forEach((item) => { const parent = item.parent_identity && nodes.get(item.parent_identity); if (parent) parent.children.push(item); else roots.push(item); });
return roots;
});
const tree = computed(() => { const byIdentity = new Map<string, Node>(); const byInternalId = new Map<number, Node>(); const roots: Node[] = []; list.value.forEach((item) => { const node = { ...item, children: [] }; byIdentity.set(node.identity, node); if (typeof node.id === 'number') byInternalId.set(node.id, node); }); byIdentity.forEach((item) => { const parent = item.parent_identity ? byIdentity.get(item.parent_identity) : typeof item.parent_id === 'number' ? byInternalId.get(item.parent_id) : undefined; if (parent) parent.children.push(item); else roots.push(item); }); return roots; });
async function load() { loading.value = true; try { list.value = (await resourceApi.list<Node>(props.definition.resource, 1, 500)).list; } catch (error) { Message.error((error as Error).message); } finally { loading.value = false; } }
onMounted(load);
</script>