diff --git a/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md index ffbd95d..f696f6e 100644 --- a/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md +++ b/.superpowers/sdd/2026-07-27-platform-admin-full-audit/task-6-report.md @@ -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. diff --git a/frontend/platform_admin/scripts/audit-check.mjs b/frontend/platform_admin/scripts/audit-check.mjs index 24c0b73..3d3f246 100644 --- a/frontend/platform_admin/scripts/audit-check.mjs +++ b/frontend/platform_admin/scripts/audit-check.mjs @@ -1,54 +1,1241 @@ import fs from 'node:fs'; import path from 'node:path'; - +import vm from 'node:vm'; +import ts from 'typescript'; const contracts = [ - ['dashboard', 'overview', '/dashboard/overview', 'readonly', 'dashboard'], - ['gas', 'gas_basic', '/gas/gas_basic', 'writable', 'list'], ['gas', 'gas_account', '/gas/gas_account', 'writable', 'list'], - ['delivery', 'delivery_basic', '/delivery/delivery_basic', 'writable', 'list'], ['delivery', 'delivery_account', '/delivery/delivery_account', 'writable', 'list'], ['delivery', 'delivery_task', '/delivery/delivery_task', 'writable', 'list'], ['delivery', 'delivery_track', '/delivery/delivery_track', 'writable', 'list'], ['delivery', 'delivery_track_point', '/delivery/delivery_track_point', 'writable', 'list'], - ['staff', 'staff_account', '/staff/account', 'writable', 'list'], ['staff', 'staff_credential', '/staff/credential', 'writable', 'list'], - ['user', 'user_account', '/user/account', 'writable', 'list'], ['user', 'user_address', '/user/address', 'writable', 'list'], ['user', 'user_service_relation', '/user/service_relation', 'writable', 'list'], - ['device', 'dev_smart_cylinder_valve', '/device/dev_smart_cylinder_valve', 'writable', 'list'], ['device', 'dev_device_binding', '/device/dev_device_binding', 'writable', 'list'], ['device', 'dev_telemetry', '/device/dev_telemetry', 'readonly', 'list'], - ['safety', 'saf_rule', '/safety/saf_rule', 'writable', 'list'], ['safety', 'saf_event', '/safety/saf_event', 'writable', 'list'], ['safety', 'saf_inspection', '/safety/saf_inspection', 'writable', 'list'], - ['ec', 'ec_category', '/ec/ec_category', 'writable', 'list'], ['ec', 'ec_product', '/ec/ec_product', 'writable', 'list'], ['ec', 'ec_product_attribute', '/ec/ec_product_attribute', 'writable', 'list'], ['ec', 'ec_product_image', '/ec/ec_product_image', 'writable', 'list'], ['ec', 'ec_cart', '/ec/ec_cart', 'writable', 'list'], ['ec', 'ec_order', '/ec/ec_order', 'writable', 'list'], ['ec', 'ec_order_item', '/ec/ec_order_item', 'writable', 'list'], ['ec', 'ec_review', '/ec/ec_review', 'writable', 'list'], - ['finance', 'fin_payment', '/finance/fin_payment', 'writable', 'list'], ['finance', 'fin_settlement', '/finance/fin_settlement', 'writable', 'list'], ['finance', 'fin_reconciliation', '/finance/fin_reconciliation', 'writable', 'list'], - ['content', 'cnt_content', '/content/cnt_content', 'writable', 'list'], ['notification', 'ntf_template', '/notification/ntf_template', 'writable', 'list'], ['customer_service', 'cs_ticket', '/customer_service/cs_ticket', 'writable', 'list'], - ['platform', 'platfrom_account', '/platform/platfrom_account', 'writable', 'list'], ['platform', 'platform_role', '/platform/platform_role', 'writable', 'list'], ['platform', 'platform_menu', '/platform/platform_menu', 'writable', 'tree'], - ['wallet', 'wallet', '/wallet/wallet', 'readonly', 'list'], ['wallet', 'wallet_ledger', '/wallet/wallet_ledger', 'readonly', 'list'], ['wallet', 'wallet_recharge', '/wallet/wallet_recharge', 'readonly', 'list'], ['wallet', 'wallet_withdrawal', '/wallet/wallet_withdrawal', 'readonly', 'list'], - ['report', 'report', '/report/report', 'readonly', 'list'], ['report', 'report_item', '/report/report_item', 'readonly', 'list'], ['report', 'report_metric_snapshot', '/report/report_metric_snapshot', 'readonly', 'list'], - ['audit', 'aud_operation_log', '/audit/aud_operation_log', 'readonly', 'list'], ['audit', 'aud_export_log', '/audit/aud_export_log', 'readonly', 'list'], ['audit', 'aud_approval', '/audit/aud_approval', 'readonly', 'list'], + { + "domain": "gas", + "name": "gas_basic", + "resource": "/gas/gas_basic", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "code", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "credit_code", + "required": false + }, + { + "key": "principal", + "required": false + }, + { + "key": "address", + "required": false + }, + { + "key": "longitude", + "required": false + }, + { + "key": "latitude", + "required": false + } + ] + }, + { + "domain": "gas", + "name": "gas_account", + "resource": "/gas/gas_account", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "username", + "required": true + }, + { + "key": "password", + "required": true + }, + { + "key": "display_name", + "required": false + }, + { + "key": "role_code", + "required": false + }, + { + "key": "gas_basic_identity", + "required": true + } + ] + }, + { + "domain": "delivery", + "name": "delivery_basic", + "resource": "/delivery/delivery_basic", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "delivery_code", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "gas_basic_identity", + "required": false + }, + { + "key": "principal", + "required": false + }, + { + "key": "address", + "required": false + } + ] + }, + { + "domain": "delivery", + "name": "delivery_account", + "resource": "/delivery/delivery_account", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "username", + "required": true + }, + { + "key": "password", + "required": true + }, + { + "key": "display_name", + "required": false + }, + { + "key": "role_code", + "required": false + }, + { + "key": "delivery_basic_identity", + "required": true + } + ] + }, + { + "domain": "delivery", + "name": "delivery_task", + "resource": "/delivery/delivery_task", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "ec_order_identity", + "required": true + }, + { + "key": "delivery_basic_identity", + "required": true + }, + { + "key": "staff_account_identity", + "required": false + } + ] + }, + { + "domain": "delivery", + "name": "delivery_track", + "resource": "/delivery/delivery_track", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "delivery_task_identity", + "required": true + }, + { + "key": "started_at", + "required": false + }, + { + "key": "completed_at", + "required": false + } + ] + }, + { + "domain": "delivery", + "name": "delivery_track_point", + "resource": "/delivery/delivery_track_point", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "delivery_track_identity", + "required": true + }, + { + "key": "point_type", + "required": true + }, + { + "key": "occurred_at", + "required": true + }, + { + "key": "longitude", + "required": true + }, + { + "key": "latitude", + "required": true + } + ] + }, + { + "domain": "staff", + "name": "staff_account", + "resource": "/staff/account", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "username", + "required": true + }, + { + "key": "password", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "phone", + "required": false + }, + { + "key": "avatar", + "required": false + }, + { + "key": "role_code", + "required": false + }, + { + "key": "gas_basic_identity", + "required": false + }, + { + "key": "delivery_basic_identity", + "required": false + }, + { + "key": "work_status", + "required": false + } + ] + }, + { + "domain": "staff", + "name": "staff_credential", + "resource": "/staff/credential", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "staff_account_identity", + "required": true + }, + { + "key": "credential_type", + "required": true + }, + { + "key": "credential_no", + "required": false + }, + { + "key": "expired_at", + "required": false + } + ] + }, + { + "domain": "user", + "name": "user_account", + "resource": "/user/account", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "username", + "required": true + }, + { + "key": "password", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "phone", + "required": false + }, + { + "key": "avatar", + "required": false + }, + { + "key": "real_name", + "required": false + } + ] + }, + { + "domain": "user", + "name": "user_address", + "resource": "/user/address", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "user_account_identity", + "required": true + }, + { + "key": "address", + "required": true + }, + { + "key": "longitude", + "required": false + }, + { + "key": "latitude", + "required": false + }, + { + "key": "is_default", + "required": false + } + ] + }, + { + "domain": "user", + "name": "user_service_relation", + "resource": "/user/service_relation", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "user_account_identity", + "required": true + }, + { + "key": "gas_basic_identity", + "required": false + }, + { + "key": "delivery_basic_identity", + "required": false + }, + { + "key": "staff_account_identity", + "required": false + } + ] + }, + { + "domain": "device", + "name": "dev_smart_cylinder_valve", + "resource": "/device/dev_smart_cylinder_valve", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "device_no", + "required": true + }, + { + "key": "model", + "required": false + }, + { + "key": "online_status", + "required": false + }, + { + "key": "owner_identity", + "required": false + } + ] + }, + { + "domain": "device", + "name": "dev_device_binding", + "resource": "/device/dev_device_binding", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "smart_cylinder_valve_identity", + "required": true + }, + { + "key": "user_account_identity", + "required": true + }, + { + "key": "effective_at", + "required": false + }, + { + "key": "expired_at", + "required": false + } + ] + }, + { + "domain": "device", + "name": "dev_telemetry", + "resource": "/device/dev_telemetry", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "smart_cylinder_valve_identity", + "required": false + }, + { + "key": "recorded_at", + "required": false + }, + { + "key": "payload", + "required": false + } + ] + }, + { + "domain": "safety", + "name": "saf_rule", + "resource": "/safety/saf_rule", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "rule_code", + "required": true + }, + { + "key": "version_no", + "required": false + }, + { + "key": "threshold", + "required": false + }, + { + "key": "action", + "required": false + }, + { + "key": "gray_scope", + "required": false + } + ] + }, + { + "domain": "safety", + "name": "saf_event", + "resource": "/safety/saf_event", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "event_code", + "required": true + }, + { + "key": "level", + "required": true + }, + { + "key": "title", + "required": true + }, + { + "key": "smart_cylinder_valve_identity", + "required": true + }, + { + "key": "sla_at", + "required": false + } + ] + }, + { + "domain": "safety", + "name": "saf_inspection", + "resource": "/safety/saf_inspection", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "user_account_identity", + "required": true + }, + { + "key": "staff_account_identity", + "required": true + }, + { + "key": "result", + "required": true + }, + { + "key": "evidence_uri", + "required": false + } + ] + }, + { + "domain": "safety", + "name": "saf_event_disposal", + "resource": "/safety/saf_event/:identity/disposals", + "mode": "append_only", + "pageKind": "list", + "fields": [ + { + "key": "action", + "required": true + }, + { + "key": "reason", + "required": true + } + ] + }, + { + "domain": "ec", + "name": "ec_category", + "resource": "/ec/ec_category", + "mode": "writable", + "pageKind": "tree", + "fields": [ + { + "key": "parent_identity", + "required": false + }, + { + "key": "name", + "required": true + }, + { + "key": "sort_no", + "required": false + } + ] + }, + { + "domain": "ec", + "name": "ec_product", + "resource": "/ec/ec_product", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "ec_category_identity", + "required": true + }, + { + "key": "product_code", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "price_amount", + "required": true + }, + { + "key": "stock_quantity", + "required": false + } + ] + }, + { + "domain": "ec", + "name": "ec_product_attribute", + "resource": "/ec/ec_product_attribute", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "ec_product_identity", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "value", + "required": true + }, + { + "key": "sort_no", + "required": false + } + ] + }, + { + "domain": "ec", + "name": "ec_product_image", + "resource": "/ec/ec_product_image", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "ec_product_identity", + "required": true + }, + { + "key": "image_uri", + "required": true + }, + { + "key": "sort_no", + "required": false + }, + { + "key": "is_cover", + "required": false + } + ] + }, + { + "domain": "ec", + "name": "ec_cart", + "resource": "/ec/ec_cart", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "user_account_identity", + "required": true + }, + { + "key": "ec_product_identity", + "required": true + }, + { + "key": "quantity", + "required": true + }, + { + "key": "selected", + "required": false + } + ] + }, + { + "domain": "ec", + "name": "ec_order", + "resource": "/ec/ec_order", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "user_account_identity", + "required": true + }, + { + "key": "gas_basic_identity", + "required": false + }, + { + "key": "delivery_basic_identity", + "required": false + }, + { + "key": "order_no", + "required": true + }, + { + "key": "total_amount", + "required": true + } + ] + }, + { + "domain": "ec", + "name": "ec_order_item", + "resource": "/ec/ec_order_item", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "ec_order_identity", + "required": true + }, + { + "key": "ec_product_identity", + "required": true + }, + { + "key": "product_snapshot", + "required": true + }, + { + "key": "quantity", + "required": true + }, + { + "key": "sale_amount", + "required": true + } + ] + }, + { + "domain": "ec", + "name": "ec_review", + "resource": "/ec/ec_review", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "ec_order_identity", + "required": true + }, + { + "key": "ec_product_identity", + "required": true + }, + { + "key": "user_account_identity", + "required": true + }, + { + "key": "score", + "required": true + }, + { + "key": "content", + "required": true + } + ] + }, + { + "domain": "finance", + "name": "fin_payment", + "resource": "/finance/fin_payment", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "ec_order_identity", + "required": true + }, + { + "key": "channel", + "required": true + }, + { + "key": "amount", + "required": true + }, + { + "key": "paid_at", + "required": false + } + ] + }, + { + "domain": "finance", + "name": "fin_settlement", + "resource": "/finance/fin_settlement", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "settlement_no", + "required": true + }, + { + "key": "subject_type", + "required": true + }, + { + "key": "subject_identity", + "required": true + }, + { + "key": "period_start", + "required": true + }, + { + "key": "period_end", + "required": true + } + ] + }, + { + "domain": "finance", + "name": "fin_reconciliation", + "resource": "/finance/fin_reconciliation", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "channel", + "required": true + }, + { + "key": "bill_date", + "required": true + }, + { + "key": "difference_amount", + "required": true + } + ] + }, + { + "domain": "content", + "name": "cnt_content", + "resource": "/content/cnt_content", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "content_type", + "required": true + }, + { + "key": "title", + "required": true + }, + { + "key": "body", + "required": true + }, + { + "key": "version_no", + "required": false + }, + { + "key": "publish_status", + "required": false + } + ] + }, + { + "domain": "notification", + "name": "ntf_template", + "resource": "/notification/ntf_template", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "template_code", + "required": true + }, + { + "key": "channel", + "required": true + }, + { + "key": "content", + "required": true + } + ] + }, + { + "domain": "customer_service", + "name": "cs_ticket", + "resource": "/customer_service/cs_ticket", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "user_account_identity", + "required": true + }, + { + "key": "ticket_no", + "required": true + }, + { + "key": "category", + "required": true + }, + { + "key": "priority", + "required": true + } + ] + }, + { + "domain": "platform", + "name": "platfrom_account", + "resource": "/platform/platfrom_account", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "username", + "required": true + }, + { + "key": "password", + "required": true + }, + { + "key": "display_name", + "required": false + }, + { + "key": "avatar", + "required": false + }, + { + "key": "platform_role_code", + "required": false + }, + { + "key": "phone", + "required": false + } + ] + }, + { + "domain": "platform", + "name": "platform_role", + "resource": "/platform/platform_role", + "mode": "writable", + "pageKind": "list", + "fields": [ + { + "key": "role_code", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "data_scope", + "required": true + } + ] + }, + { + "domain": "platform", + "name": "platform_menu", + "resource": "/platform/platform_menu", + "mode": "writable", + "pageKind": "tree", + "fields": [ + { + "key": "parent_identity", + "required": false + }, + { + "key": "menu_code", + "required": true + }, + { + "key": "name", + "required": true + }, + { + "key": "icon", + "required": false + }, + { + "key": "path", + "required": false + }, + { + "key": "sort_no", + "required": false + } + ] + }, + { + "domain": "wallet", + "name": "wallet", + "resource": "/wallet/wallet", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "owner_identity", + "required": false + }, + { + "key": "balance_amount", + "required": false + }, + { + "key": "status", + "required": false + } + ] + }, + { + "domain": "wallet", + "name": "wallet_ledger", + "resource": "/wallet/wallet_ledger", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "wallet_identity", + "required": false + }, + { + "key": "change_amount", + "required": false + }, + { + "key": "balance_after", + "required": false + } + ] + }, + { + "domain": "wallet", + "name": "wallet_recharge", + "resource": "/wallet/wallet_recharge", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "wallet_identity", + "required": false + }, + { + "key": "amount", + "required": false + }, + { + "key": "status", + "required": false + } + ] + }, + { + "domain": "wallet", + "name": "wallet_withdrawal", + "resource": "/wallet/wallet_withdrawal", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "wallet_identity", + "required": false + }, + { + "key": "amount", + "required": false + }, + { + "key": "status", + "required": false + } + ] + }, + { + "domain": "report", + "name": "report", + "resource": "/report/report", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "report_code", + "required": false + }, + { + "key": "name", + "required": false + }, + { + "key": "status", + "required": false + } + ] + }, + { + "domain": "report", + "name": "report_item", + "resource": "/report/report_item", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "report_identity", + "required": false + }, + { + "key": "metric_code", + "required": false + }, + { + "key": "value", + "required": false + } + ] + }, + { + "domain": "report", + "name": "report_metric_snapshot", + "resource": "/report/report_metric_snapshot", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "report_identity", + "required": false + }, + { + "key": "metric_code", + "required": false + }, + { + "key": "value", + "required": false + }, + { + "key": "captured_at", + "required": false + } + ] + }, + { + "domain": "audit", + "name": "aud_operation_log", + "resource": "/audit/aud_operation_log", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "operator_identity", + "required": false + }, + { + "key": "action", + "required": false + }, + { + "key": "object_identity", + "required": false + }, + { + "key": "created_at", + "required": false + } + ] + }, + { + "domain": "audit", + "name": "aud_export_log", + "resource": "/audit/aud_export_log", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "operator_identity", + "required": false + }, + { + "key": "resource_type", + "required": false + }, + { + "key": "created_at", + "required": false + } + ] + }, + { + "domain": "audit", + "name": "aud_approval", + "resource": "/audit/aud_approval", + "mode": "readonly", + "pageKind": "list", + "fields": [ + { + "key": "operator_identity", + "required": false + }, + { + "key": "status", + "required": false + }, + { + "key": "handled_at", + "required": false + } + ] + } ]; - -const resourceSource = fs.readFileSync('src/api/resources.ts', 'utf8'); -const resourceEntry = (name, resource, mode, pageKind) => new RegExp(`name: '${name}', resource: '${resource}', title: '[^']+', mode: '${mode}', pageKind: '${pageKind}'`); +const source = fs.readFileSync('src/api/resources.ts', 'utf8'); +const compiled = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText; +const resourceModule = { exports: {} }; +vm.runInNewContext(compiled, { module: resourceModule, exports: resourceModule.exports }); +const resources = resourceModule.exports.resources; const files = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => entry.isDirectory() ? files(path.join(dir, entry.name)) : [path.join(dir, entry.name)]) : []; - +const sameFields = (actual, expected) => Array.isArray(actual) && actual.length === expected.length && actual.every((field, index) => field.key === expected[index].key && Boolean(field.required) === Boolean(expected[index].required)); function checkContracts() { const failures = []; - if (contracts.length !== 46) failures.push(`catalogue: expected 46 resources, got ${contracts.length}`); - for (const [domain, name, resource, mode, pageKind] of contracts) { - const label = `${domain}/${name}`; - if (!resourceEntry(name, resource, mode, pageKind).test(resourceSource)) failures.push(`${label}: missing or incorrect api contract`); - const route = path.join('src/router/routes/modules', domain === 'dashboard' ? 'platform.ts' : `${domain}.ts`); - if (!fs.existsSync(route) || !fs.readFileSync(route, 'utf8').includes(name)) failures.push(`${label}: missing route`); - const page = pageKind === 'dashboard' ? path.join('src/views/dashboard/DashboardPage.vue') : path.join('src/views', domain, name, pageKind === 'tree' ? 'TreePage.vue' : 'ListPage.vue'); + if (!Array.isArray(resources) || resources.length !== contracts.length) failures.push(`catalogue: expected ${contracts.length} resources`); + for (const contract of contracts) { + const definition = resources.find((resource) => resource.name === contract.name); + const label = `${contract.domain}/${contract.name}`; + if (!definition || definition.resource !== contract.resource || definition.mode !== contract.mode || definition.pageKind !== contract.pageKind || !sameFields(definition.fields, contract.fields)) failures.push(`${label}: mismatched contract or allowlist`); + const requiredIdentity = contract.fields.filter((field) => field.required && field.key.endsWith('_identity')).map((field) => field.key); + if (definition && JSON.stringify(definition.requiredIdentities ?? []) !== JSON.stringify(requiredIdentity)) failures.push(`${label}: required identity fields do not match allowlist`); + if (contract.name === 'saf_event_disposal') continue; + const route = path.join('src/router/routes/modules', contract.domain + '.ts'); + if (!fs.existsSync(route) || !fs.readFileSync(route, 'utf8').includes(contract.name)) failures.push(`${label}: missing route`); + const page = path.join('src/views', contract.domain, contract.name, contract.pageKind === 'tree' ? 'TreePage.vue' : 'ListPage.vue'); if (!fs.existsSync(page)) failures.push(`${label}: missing view`); - if (mode === 'readonly' && pageKind !== 'dashboard' && (!fs.existsSync(page) || !fs.readFileSync(page, 'utf8').includes('ReadOnlyListPage'))) failures.push(`${label}: readonly page exposes write surface`); + if (contract.mode === 'readonly' && (!fs.existsSync(page) || !fs.readFileSync(page, 'utf8').includes('ReadOnlyListPage'))) failures.push(`${label}: readonly page exposes write surface`); } + const names = resources.map((resource) => resource.name).sort(); + const expectedNames = contracts.map((contract) => contract.name).sort(); + if (JSON.stringify(names) !== JSON.stringify(expectedNames)) failures.push('catalogue: resource names do not equal ExpectedResources'); + if (resources.some((resource) => resource.fields.some((field) => field.key === 'id' || field.key.endsWith('_id')))) failures.push('catalogue: internal identifier leaked through fields'); const readonlyPage = fs.readFileSync('src/views/shared/ReadOnlyListPage.vue', 'utf8'); - if (/(CrudListPage|resourceApi\.(create|update|archive|updateStatus))/.test(readonlyPage)) failures.push('readonly shared page: contains write surface'); + if (/(CrudListPage|resourceApi\\.(create|update|archive|updateStatus))/.test(readonlyPage)) failures.push('readonly shared page: contains write surface'); const treePage = fs.readFileSync('src/views/shared/TreePage.vue', 'utf8'); - if (!treePage.includes(' resource.name === 'saf_event'); + if (!safetyEvent?.detailActions?.some((item) => item.name === 'saf_event_disposal' && item.resource === '/safety/saf_event/:identity/disposals')) failures.push('safety event: missing disposal detail action'); + if (files('src/router/routes/modules').some((file) => fs.readFileSync(file, 'utf8').includes('saf_event_disposal')) || fs.existsSync('src/views/safety/saf_event_disposal')) failures.push('saf_event_disposal: must not have independent menu or page'); const crudSource = fs.readFileSync('src/views/shared/CrudListPage.vue', 'utf8'); if (!crudSource.includes("key !== 'id'") || !crudSource.includes("key.endsWith('_id')")) failures.push('detail view: does not remove internal identifiers'); - const identityLeaks = files('src/api').concat(files('src/views')).filter((file) => /\.(ts|vue)$/.test(file) && !file.endsWith('CrudListPage.vue') && !file.endsWith('ReadOnlyListPage.vue')).filter((file) => /\b[A-Za-z][A-Za-z0-9]*_id\b|\bid\b/.test(fs.readFileSync(file, 'utf8'))); - if (identityLeaks.length) failures.push(`identity leak: ${identityLeaks.join(', ')}`); - if (resourceSource.includes('saf_event_disposal') || files('src/router/routes/modules').some((file) => fs.readFileSync(file, 'utf8').includes('saf_event_disposal'))) failures.push('saf_event_disposal: must not be displayed as an independent resource'); - const obsoletePaths = /\/(?:commerce|staff\/staff_|user\/user_)/; - const stalePathFiles = files('src/api').concat(files('src/views')).filter((file) => /\.(ts|vue)$/.test(file) && obsoletePaths.test(fs.readFileSync(file, 'utf8'))); - if (stalePathFiles.length) failures.push(`obsolete resource path: ${stalePathFiles.join(', ')}`); + const leakedUiFiles = files('src/api').concat(files('src/views')).filter((file) => /\.(ts|vue)$/.test(file) && !file.endsWith('CrudListPage.vue') && !file.endsWith('ReadOnlyListPage.vue') && !file.endsWith('TreePage.vue')).filter((file) => /\b[A-Za-z][A-Za-z0-9]*_id\b|\bid\b/.test(fs.readFileSync(file, 'utf8'))); + if (leakedUiFiles.length) failures.push(`identity leak: ${leakedUiFiles.join(', ')}`); + if (/data-index="(?:id|[^" ]+_id)"|\{\{\s*[^}]*\b(?:id|[A-Za-z][A-Za-z0-9]*_id)\b/.test(treePage)) failures.push('tree page: displays internal identifier'); return failures; } - const failures = checkContracts(); for (const failure of failures) console.log(failure); if (failures.length) process.exitCode = 1; diff --git a/frontend/platform_admin/src/api/resources.ts b/frontend/platform_admin/src/api/resources.ts index 82b0da1..1c09dd4 100644 --- a/frontend/platform_admin/src/api/resources.ts +++ b/frontend/platform_admin/src/api/resources.ts @@ -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; export function getResource(resourcePath: string): ResourceUiDefinition { const definition = resourceByName[resourcePath]; diff --git a/frontend/platform_admin/src/router/routes/modules/ec.ts b/frontend/platform_admin/src/router/routes/modules/ec.ts index 835ed68..917cc35 100644 --- a/frontend/platform_admin/src/router/routes/modules/ec.ts +++ b/frontend/platform_admin/src/router/routes/modules/ec.ts @@ -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: ['*'] } }, diff --git a/frontend/platform_admin/src/views/ec/ec_category/ListPage.vue b/frontend/platform_admin/src/views/ec/ec_category/TreePage.vue similarity index 51% rename from frontend/platform_admin/src/views/ec/ec_category/ListPage.vue rename to frontend/platform_admin/src/views/ec/ec_category/TreePage.vue index 331cbd3..e7de76b 100644 --- a/frontend/platform_admin/src/views/ec/ec_category/ListPage.vue +++ b/frontend/platform_admin/src/views/ec/ec_category/TreePage.vue @@ -1,6 +1,6 @@ - + diff --git a/frontend/platform_admin/src/views/shared/CrudListPage.vue b/frontend/platform_admin/src/views/shared/CrudListPage.vue index 1d3d2f1..8b1d039 100644 --- a/frontend/platform_admin/src/views/shared/CrudListPage.vue +++ b/frontend/platform_admin/src/views/shared/CrudListPage.vue @@ -6,17 +6,19 @@ - {{ value ?? '-' }} + {{ value ?? '-' }}{{ action.name }} +