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

@@ -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>