Explorar o código

feat: migrate visual scene administration

彭峰 hai 1 mes
pai
achega
0ddec996ff

+ 1 - 0
docs/migration/admin-migration-status.md

@@ -46,6 +46,7 @@
 - 旧 `Design/Resource` 的历史资源已从线上 `DesignRes` 恢复到 Angular“可视化设计资源”页:`designResources` 提供帐套隔离、分页、搜索和 `ztype` 精确筛选,`saveDesignResource` 只允许更新名称、类型、用途、状态及 `style/use/fun` 元数据,`designResourceBatch` 覆盖单条和批量删除。`id/vpath/previewimg/cdate/userid/sourceKey` 均锁为只读;线上 Parse 文件适配器仍错误要求 MongoDB 连接串,而当前项目使用 PostgreSQL,因此新增及替换原文件/预览图继续以 `migration_blocked` 明确拒绝,不会伪造可用资源路径。
 - 旧 `User/Role` 的 9 个管理员角色、3 个用户角色与 `ARoleAuth` 权限记录已迁到 Angular“系统角色”页及 `roles/saveRole/roleAuthorization/saveRoleAuthorization/roleBatch`:支持角色类型切换、搜索、分页、名称防重、原子 `roleId`、12 个旧权限域完整编辑及权限码格式校验。角色类型创建后不得在 `admin/user` 之间迁移;删除前同时检查 `Manager.adminRole` 和 `_User.legacyUserData.UserRole`,无账号引用时才级联删除 `ARoleAuth`。Parse 原生 `_Role` 为空,`Permission` 是另一套网页设计师模型,二者都没有被误当作旧 `ZL_Role`。交付程序中 `B_ARoleAuth.Check/CheckEx/AuthCheckEx` 均固定返回 `true`,因此保留的旧权限码不用来绕过当前云函数的独立管理员会话与帐套校验。
 - `Extend/Safe` 的 15 个入口已完成数据与运行时审计:线上全部 Schema 仅命中不相关的 `SafeMobile`,没有旧 `Safe_IP`、非法词库、扫描结果或站点保护配置。旧聚合 `API` 还依赖服务器站点文件、五秒后返回的 Razor 局部页、`ContentWordHelper` 常驻线程与进度缓存;`SiteProtect_Submit` 只更新旧 `SiteConfig.Safe_ContentCheck_Models`,`ContentCheckConfig_Submit` 直接写 `IllegalWordsVerify.keywordsVPath` 服务器文件。当前内容写入链不消费这些配置,单纯存入 Parse Config 会伪造安全能力;因此 IP 三项按数据缺失、其余按基础设施缺失由 `legacySafeBlocker` 返回逐项 `migration_blocked`。
+- 旧 `Design/Scene` 的普通场景管理已迁到 Angular“可视化场景”页:`designScenes` 复刻 `ZType=0、TlpID=0` 及正常/回收站状态范围,提供帐套隔离、搜索和分页;`saveDesignScene` 只更新旧编辑页允许的标题、用户、积分、状态、搜索标记、预览图、缩略图、说明和 Meta,并从同帐套 `_User` 同步用户名。`designSceneBatch` 覆盖回收、恢复与彻底删除;`createDesignSceneTemplate` 完整复制布局/组件/资源数据,分别用 PostgreSQL advisory lock 原子分配 `DesignTlp.id` 和模板副本 `DesignScence.id`,任一步失败都会补偿清理。普通场景的新建仍依赖已丢失的可视化设计器初始化协议,因此专用保存接口只接受现有场景。
 - 旧 `NodeAdd_Submit`、`Node_API` 与 `UnionNode_Move` 的核心栏目生命周期已迁为专用 `saveNode/nodeBatch`:新增栏目以临时来源键完成 Parse 初始写入,再使用 PostgreSQL 事务锁原子分配 `nodeId/orderId` 并替换为旧 `sourceKey`;保存会校验同级名称/目录重复、父栏目帐套和回收状态,移动会阻断自身/下级循环并递归修正所有后代深度。回收、恢复和每批 1–100 个栏目迁移已接入 Angular;永久删除仅在没有内容、下级栏目、节点权限和模型模板引用时开放,通用保存与删除不能绕过。
 - 旧 `SpecialAdd_Save` 与 `UnionSpecial_Move` 已迁为专用 `saveSpecial/specialBatch`:新增专题使用临时来源键和 PostgreSQL 事务锁原子分配 `specId/orderId`,保存时校验全帐套名称/目录唯一、父专题归属与层级循环;删除会拒绝仍有下级专题的记录。旧 `UnionSpecial_Merge` 被明确列为数据阻塞:`CommonModel.specialId` 只存在于 Parse Schema、PostgreSQL 物理列缺失,商品主表也未迁移,无法安全改写内容/商品专题关系。
 - 旧 `ModelManage/Field/Order_Submit` 的安全部分已迁为 `saveModelMetadata/saveModelFieldMetadata/modelFieldOrder`:现有模型可编辑名称、条目文案、图标、说明和内容模板元数据,现有字段可编辑别名、提示、显示/搜索配置,并可在同模型内批量排序;`modelId/modelType/tableName/fieldId/modelId/fieldName/fieldType/sourceKey` 等结构字段通用只读。新增、复制、删除模型或字段会显式返回 501,因为旧实现同时执行 PostgreSQL 建表/改列、标签生成和模板文件写入,目标托管环境没有该 DDL/旧模板运行时。

+ 32 - 3
scripts/deploy-admin-functions.mjs

@@ -15,12 +15,12 @@ const CLASS_LABELS = {
   GradeCate: '多级字典分类', Grade: '多级字典选项',
   Currency: '货币管理', SysHoliday: '节假日管理',
   Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '商城运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
-  GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科审核', Pub: '互动模块定义', PubTw: '提问互动提交', PubWTHD: '问题回答提交', PubZXDC: '在线调查提交', Role: '系统角色', ARoleAuth: '角色权限', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', FontPicShape: '字体图形素材', FontPicShapeType: '字体图形分类', DesignAsk: '问卷调查', DesignQuestion: '问卷题目', DesignAnswer: '问卷答卷', DesignRes: '可视化设计资源', ServiceSeat: '客服席位', Temp: '客服欢迎语', StoreApplication: '店铺管理', StoreStyle: '店铺样式', PageStyle: '黄页样式', PlatComp: '协同办公企业', ContentTagKey: '内容标签词库', UserLevel: '积分等级', UserMoneyLog: '资金积分流水', UserPromotion: '用户推广关系', CRMSAttr: 'CRM 客户类型', SysLog: '系统日志', MisType: 'OA 流程类型', Agency: '代理机构', DeliveryCenter: '交付中心'
+  GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科审核', Pub: '互动模块定义', PubTw: '提问互动提交', PubWTHD: '问题回答提交', PubZXDC: '在线调查提交', Role: '系统角色', ARoleAuth: '角色权限', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', FontPicShape: '字体图形素材', FontPicShapeType: '字体图形分类', DesignAsk: '问卷调查', DesignQuestion: '问卷题目', DesignAnswer: '问卷答卷', DesignRes: '可视化设计资源', DesignScence: '可视化场景', DesignTlp: '可视化模板', ServiceSeat: '客服席位', Temp: '客服欢迎语', StoreApplication: '店铺管理', StoreStyle: '店铺样式', PageStyle: '黄页样式', PlatComp: '协同办公企业', ContentTagKey: '内容标签词库', UserLevel: '积分等级', UserMoneyLog: '资金积分流水', UserPromotion: '用户推广关系', CRMSAttr: 'CRM 客户类型', SysLog: '系统日志', MisType: 'OA 流程类型', Agency: '代理机构', DeliveryCenter: '交付中心'
 };
 const ALLOWED_CLASSES = new Set([
   'PageTemplate','PaperQuestions','PayPlat','Permission','PlatComp','App','DesignAnswer','DesignAsk','Attachment','DesignPage','DesignQuestion','DesignRes','Feedback','DesignScence','StudentAchieve','ContentArticle','DesignSiteInfo','Profile','AdInfo','AdZone','ARoleAuth','Baike','ExamSysQuestions','ContactInfo','VocabularyWord','AssessmentProfile','Agency','MemoryPracticeRecord','DeliveryCenter','CourseBinding','PracticeRecord','CourseAppointment','Account','SurveyItem','SurveyLog','LessonRecord','DailyStudyRecord','CommonModel','ContentPublish','Company','_Role','_User','CRMSAttr','Currency','Datadic','Datadiccategory','DesignTlp','DocModel','DocPermission','ExamClass','ExamSysPapers','ExamType','ExamPoint','ExTeacher','FontPicShape','FontPicShapeType','Grade','GradeCate','Group','GroupModel','GuestBar','Guestbook','Guestcate','MailTemp','Manager','MisProcedure','MisProLevel','MisSign','MisType','PageStyle','Model','ModelField','Node','NodeAuth','NodeModelTemplate','Product','PlatUserRole','Pub','PubTw','PubWTHD','PubZXDC','PublishNode','QuestionsKnowledge','Role','StoreProduct','SafeMobile','Search','SenTask','ServiceSeat','ShopFareTlp','ShopMoneyRegular','Special','StoreApplication','StoreStyle','SysCSSManage','SysHoliday','SysLog','Temp','ThirdPlatInfo','UserCredit','UserDummyPoint','UserFriendGroup','UserLevel','UserMoneyLog','UserPromotion','UserSIcon','UserUserPoint','UserExpDomP','UserExpHis'
 ]);
-const READ_ONLY_CLASSES = new Set(['_Role','Permission','ARoleAuth','PayPlat','ThirdPlatInfo','GuestBar','DesignAnswer','Baike','ExamSysPapers','ExamSysQuestions','PaperQuestions','ExamType','ContentPublish','PublishNode','SysLog','Pub','PubTw','PubWTHD','PubZXDC']);
+const READ_ONLY_CLASSES = new Set(['_Role','Permission','ARoleAuth','DesignTlp','PayPlat','ThirdPlatInfo','GuestBar','DesignAnswer','Baike','ExamSysPapers','ExamSysQuestions','PaperQuestions','ExamType','ContentPublish','PublishNode','SysLog','Pub','PubTw','PubWTHD','PubZXDC']);
 const SYSTEM_FIELDS = new Set(['objectId','createdAt','updatedAt','ACL','company','password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','adminRoleKey','isAdmin','roles','role','isDisabled','legacyUserId','legacyGroupId','legacyUserData','legacyUserPlat','groupId']);
 const HIDDEN_FIELDS = new Set(['password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','apiKey','appSecret','secret','token']);
 const GENERIC_WRITE_TYPES = new Set(['String','Number','Boolean','Date','Pointer','Object','Array']);
@@ -52,6 +52,8 @@ const CLASS_SYSTEM_FIELDS = {
   DesignQuestion: new Set(['id','askId','cdate','cuser','orderId','sourceKey']),
   DesignAnswer: new Set(['id','askId','ip','cdate','userId','sourceKey']),
   DesignRes: new Set(['id','vpath','previewimg','cdate','userid','sourceKey']),
+  DesignScence: new Set(['id','guid','comp','page','path','cdate','cuser','tlpId','ztype','scence','siteId','update','company','orderId','labelArr','resource','userName','accessPwd','sourceKey']),
+  DesignTlp: new Set(['id','cdate','defBy','isDef','price','score','ztype','classId','company','sourceKey']),
   Role: new Set(['roleId','ztype','zstatus','nodeId','auth','auth2','auth3','cadminId','cdate','sourceKey']),
   ARoleAuth: new Set(['id','rid','adminId','sourceKey']),
   ServiceSeat: new Set(['sId','sAdminId','sRemrk','sDateTime','sourceKey']),
@@ -1149,6 +1151,31 @@ async function handler(request, response) {
       if (String(input.className || '') !== 'DesignRes' || String(input.action || '') !== 'delete') fail(400, '不支持的设计资源批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个设计资源');
       const fields = await schemaFor('DesignRes'); const query = new Parse.Query('DesignRes'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分设计资源不存在或不属于当前帐套'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-design-resource', 'DesignRes', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
     }
+    if (operation === 'designScenes') {
+      const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const status = Number(input.status == null ? -100 : input.status); const search = String(input.search || '').trim().toLowerCase(); if (![-100,-2].includes(status) || search.length > 100) fail(400, '场景状态或搜索词无效');
+      const fields = await schemaFor('DesignScence'); const query = new Parse.Query('DesignScence'); applyTenant(query, fields, context, input.companyId); query.equalTo('ztype', 0); query.equalTo('tlpId', 0); if (status === -2) query.equalTo('status', -2); else query.notEqualTo('status', -2); query.limit(1000); let results = await query.find({ useMasterKey: true }); if (results.length >= 1000) fail(409, '场景数量超过安全分页上限,请先缩小帐套范围'); results = results.filter((entry) => !search || [entry.get('title'),entry.get('userName'),entry.get('remind'),entry.get('path')].some((value) => String(value || '').toLowerCase().includes(search))); results.sort((left, right) => Number(right.get('id') || 0) - Number(left.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize);
+      return response.json({ success: true, data: { className: 'DesignScence', page, pageSize, total, status, results: results.map(serializeObject) } });
+    }
+    if (operation === 'saveDesignScene') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; if (!objectId) fail(501, 'migration_blocked: 旧 SceneAdd 只允许编辑现有场景;新建场景依赖已丢失的可视化设计器初始化协议');
+      const title = String(payload.title || '').trim(); const userId = Number(payload.userId); const score = Number(payload.score == null || payload.score === '' ? 0 : payload.score); const status = Number(payload.status == null || payload.status === '' ? 0 : payload.status); const seflag = String(payload.seflag || '').trim(); const previewImg = String(payload.previewImg || '').trim(); const thumbImg = String(payload.thumbImg || '').trim(); const remind = String(payload.remind || '').trim(); const meta = String(payload.meta || '');
+      if (title.length > 200 || !Number.isInteger(userId) || userId < 1 || !Number.isFinite(score) || Math.abs(score) > 1000000000 || !Number.isInteger(status) || status < -2 || status > 999 || seflag.length > 255 || previewImg.length > 1000 || thumbImg.length > 1000 || remind.length > 2000 || meta.length > 20000) fail(400, '场景标题、用户、积分、状态、图片路径或元数据无效');
+      const fields = await schemaFor('DesignScence'); const query = new Parse.Query('DesignScence'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); if (Number(target.get('ztype')) !== 0 || Number(target.get('tlpId')) !== 0) fail(409, '该记录不是可编辑的普通场景'); const companyId = pointerId(target.get('company')) || pointerId(context.company); if (!companyId) fail(409, '场景缺少帐套'); const owner = await Psql.oneOrNone('SELECT "username" FROM "_User" WHERE "company"=$1 AND (COALESCE("legacyUserId",0)=$2 OR CASE WHEN COALESCE("legacyUserData"->>\'UserID\',\'\') ~ \'^[0-9]+$\' THEN ("legacyUserData"->>\'UserID\')::numeric ELSE 0 END=$2) AND COALESCE("isDeleted",FALSE)=FALSE LIMIT 1', [companyId, userId]); if (!owner) fail(404, '场景所属用户不存在或不属于当前帐套');
+      target.set('title', title); target.set('userId', userId); target.set('userName', String(owner.username || '')); target.set('score', score); target.set('status', status); target.set('seflag', seflag); target.set('previewImg', previewImg); target.set('thumbImg', thumbImg); target.set('remind', remind); target.set('meta', meta); target.set('update', new Date()); try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '场景元数据写入失败: ' + String(error && error.message || error)); } await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-design-scene', 'DesignScence', target.id); return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'designSceneBatch') {
+      if (String(input.className || '') !== 'DesignScence') fail(400, '场景批量操作的数据类无效'); const action = String(input.action || ''); if (!['recycle','restore','delete'].includes(action)) fail(400, '不支持的场景批量操作'); const objectIds = [...new Set((Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]).map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 个场景');
+      const fields = await schemaFor('DesignScence'); const query = new Parse.Query('DesignScence'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.equalTo('ztype', 0); query.equalTo('tlpId', 0); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分场景不存在、不属于当前帐套或不是普通场景'); if (action === 'delete') await Parse.Object.destroyAll(targets, { useMasterKey: true }); else { for (const target of targets) { target.set('status', action === 'recycle' ? -2 : 0); target.set('update', new Date()); } await Parse.Object.saveAll(targets, { useMasterKey: true }); } for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'design-scene-' + action, 'DesignScence', target.id); return response.json({ success: true, data: { action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
+    }
+    if (operation === 'createDesignSceneTemplate') {
+      const objectId = String(input.objectId || ''); if (!objectId) fail(400, '缺少场景 objectId'); const fields = await schemaFor('DesignScence'); const query = new Parse.Query('DesignScence'); applyTenant(query, fields, context, input.companyId); const source = await query.get(objectId, { useMasterKey: true }); if (Number(source.get('ztype')) !== 0 || Number(source.get('tlpId')) !== 0) fail(409, '只能将普通场景复制为模板'); const company = source.get('company') || context.company; const companyId = pointerId(company); if (!companyId) fail(409, '场景缺少帐套'); const now = new Date(); const suffix = Date.now() + ':' + Math.random().toString(36).slice(2,10); const template = new Parse.Object('DesignTlp'); let clone = null;
+      template.set('company', company); template.set('sourceKey', 'cloud:design-template:' + companyId + ':' + suffix); template.set('tlpName', String(source.get('title') || '')); template.set('previewImg', String(source.get('previewImg') || '')); template.set('ztype', 1); template.set('zstatus', 0); template.set('score', 0); template.set('price', 0); template.set('isDef', 0); template.set('cdate', now);
+      try {
+        await template.save(null, { useMasterKey: true }); const templateRows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-design-template-id:\'||$1))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "DesignTlp",lock_row WHERE "company"=$1) UPDATE "DesignTlp" SET "id"=next_id.id,"sourceKey"=\'[["ID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, template.id]); const templateId = Number(templateRows[0] && templateRows[0].id); if (!templateId) throw new Error('无法分配模板编号');
+        clone = new Parse.Object('DesignScence'); for (const name of ['comp','meta','page','path','cuser','remind','scence','siteId','orderId','labelArr','resource','thumbImg','userId','userName','previewImg']) { const value = source.get(name); if (value !== undefined && fields[name]) clone.set(name, toParseValue(fields[name], safeValue(value))); } const hex = () => Math.floor((1 + Math.random()) * 0x10000).toString(16).slice(1); clone.set('guid', hex()+hex()+'-'+hex()+'-4'+hex().slice(1)+'-'+hex()+'-'+hex()+hex()+hex()); clone.set('title', String(source.get('title') || '')); clone.set('company', company); clone.set('sourceKey', 'cloud:design-scene-template:' + companyId + ':' + suffix); clone.set('cdate', now); clone.set('update', now); clone.set('tlpId', templateId); clone.set('ztype', 1); clone.set('status', 0); clone.set('score', 0); clone.set('accessPwd', ''); clone.set('seflag', ''); await clone.save(null, { useMasterKey: true }); const sceneRows = await Psql.query('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-design-scene-id:\'||$1))),next_id AS MATERIALIZED (SELECT COALESCE(MAX("id"),0)+1 AS id FROM "DesignScence",lock_row WHERE "company"=$1) UPDATE "DesignScence" SET "id"=next_id.id,"sourceKey"=\'[["ID",\'||next_id.id::text||\']]\',"updatedAt"=NOW() FROM next_id WHERE "objectId"=$2 RETURNING next_id.id', [companyId, clone.id]); if (!Number(sceneRows[0] && sceneRows[0].id)) throw new Error('无法分配模板场景编号');
+      } catch (error) { if (clone && clone.id) await clone.destroy({ useMasterKey: true }).catch(() => undefined); if (template.id) await template.destroy({ useMasterKey: true }).catch(() => undefined); fail(422, '场景复制为模板失败: ' + String(error && error.message || error)); }
+      await Promise.all([template.fetch({ useMasterKey: true }), clone.fetch({ useMasterKey: true })]); await audit({ ...context, company }, 'create-template-from-design-scene', 'DesignScence', source.id); return response.json({ success: true, data: { template: serializeObject(template), scene: serializeObject(clone) } });
+    }
     if (operation === 'roles') {
       const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const type = String(input.type || 'admin').trim().toLowerCase(); const search = String(input.search || '').trim().toLowerCase(); if (!['admin','user','-100'].includes(type) || search.length > 100) fail(400, '角色类型或搜索词无效');
       const fields = await schemaFor('Role'); const query = new Parse.Query('Role'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => (type === '-100' || String(entry.get('ztype') || 'admin').toLowerCase() === type) && (!search || [entry.get('roleName'),entry.get('description')].some((value) => String(value || '').toLowerCase().includes(search)))); results.sort((left, right) => Number(left.get('roleId') || 0) - Number(right.get('roleId') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize);
@@ -1994,7 +2021,7 @@ async function handler(request, response) {
     const classWritable = !READ_ONLY_CLASSES.has(className);
     if (operation === 'schema') {
       const fieldList = Object.entries(fields).filter(([name]) => !HIDDEN_FIELDS.has(name) && !/(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(name)).map(([name, field]) => ({ name, type: field.type, targetClass: field.targetClass, required: field.required === true, writable: classWritable && !isSystemField(className, name) && GENERIC_WRITE_TYPES.has(field.type) }));
-      return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, creatable: classWritable && !['CommonModel','Model','ModelField','Guestbook','Currency','SysHoliday','Search','AdZone','AdInfo','FontPicShape','FontPicShapeType','DesignAsk','DesignQuestion','DesignRes','Temp','StoreApplication','StoreStyle','UserLevel'].includes(className), supportsSoftDelete: Boolean(fields.isDeleted) } });
+      return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, creatable: classWritable && !['CommonModel','Model','ModelField','Guestbook','Currency','SysHoliday','Search','AdZone','AdInfo','FontPicShape','FontPicShapeType','DesignAsk','DesignQuestion','DesignRes','DesignScence','Temp','StoreApplication','StoreStyle','UserLevel'].includes(className), supportsSoftDelete: Boolean(fields.isDeleted) } });
     }
     if (operation === 'list') {
       const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20));
@@ -2041,6 +2068,7 @@ async function handler(request, response) {
       if (className === 'PageStyle') fail(400, '黄页样式必须走专用保存流程');
       if (className === 'PlatComp') fail(400, '协同办公企业必须走专用保存流程');
       if (className === 'DesignRes') fail(400, '设计资源必须走专用保存流程');
+      if (className === 'DesignScence') fail(400, '可视化场景必须走专用保存流程');
       if (className === 'Role') fail(400, '系统角色必须走专用保存流程');
       if (className === 'CommonModel' && !objectId) fail(400, '内容新增必须同时写入模型附表,不能走通用保存');
       let object;
@@ -2065,6 +2093,7 @@ async function handler(request, response) {
       if (className === 'PageStyle') fail(400, '黄页样式必须走专用批量流程');
       if (className === 'PlatComp') fail(400, '协同办公企业必须走专用批量流程');
       if (className === 'DesignRes') fail(400, '设计资源必须走专用批量流程');
+      if (className === 'DesignScence') fail(400, '可视化场景必须走专用批量流程');
       if (className === 'Role') fail(400, '系统角色必须走专用批量流程');
       if (className === 'Guestcate') { const cateid = Number(object.get('cateid')) || 0; const companyId = pointerId(object.get('company')); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "Guestcate" WHERE "company"=$1 AND "parentId"=$2) AS children,(SELECT COUNT(*)::int FROM "Guestbook" WHERE "company"=$1 AND "cateid"=$2) AS messages,(SELECT COUNT(*)::int FROM "GuestBar" WHERE "company"=$1 AND "cateId"=$2) AS posts', [companyId, cateid]); if (Number(refs.children) || Number(refs.messages) || Number(refs.posts)) fail(409, '分类仍被下级分类、留言或帖子引用,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-guest-category', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
       if (className === 'ExamClass') { const classId = Number(object.get('cId')) || 0; const companyId = pointerId(object.get('company')); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "ExamClass" WHERE "company"=$1 AND "cClassid"=$2) AS children,(SELECT COUNT(*)::int FROM "ExamSysQuestions" WHERE "company"=$1 AND "pClass"=$2) AS questions', [companyId, classId]); if (Number(refs.children) || Number(refs.questions)) fail(409, '试题分类仍被下级分类或试题引用,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-exam-class', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }

+ 5 - 0
scripts/generate-admin-coverage.mjs

@@ -313,6 +313,11 @@ const explicit = new Map([
   ['Design / Resource/ResourceAdd', ['partial', '现有资源可编辑元数据并查看原文件/预览图路径;新增与文件替换受 Parse 文件适配器阻塞']],
   ['Design / Resource/ResourceAdd_Submit', ['partial', 'saveDesignResource 已白名单更新 Name/style/use/Useage/ZStatus/ZType/fun 并保护旧 ID 与文件路径;上传明确返回 migration_blocked']],
   ['Design / Resource/Resource_API', ['implemented', '已由 designResourceBatch 覆盖帐套隔离的单条与每批 1–100 条资源记录删除']],
+  ['Design / Scene/SceneList', ['implemented', '已由 designScenes 按旧 ZType=0、TlpID=0、Status<>-2 条件提供帐套隔离、搜索和分页']],
+  ['Design / Scene/SceneListRecycle', ['implemented', 'Angular 场景页可切换回收站,designScenes 严格筛选 Status=-2']],
+  ['Design / Scene/SceneAdd', ['implemented', '已由 Angular 现有场景编辑抽屉替代,并将布局、组件、资源、路径、编号和模板关系锁为只读']],
+  ['Design / Scene/SceneAdd_Submit', ['implemented', 'saveDesignScene 白名单更新旧页 9 个字段、校验帐套用户并同步 UserName 与更新时间']],
+  ['Design / Scene/Scene_API', ['implemented', 'designSceneBatch 覆盖回收、恢复、彻底删除;createDesignSceneTemplate 原子分配模板与副本场景编号并提供失败补偿']],
   ['User / Role/UserRole', ['implemented', '已由 roles 用户角色列表覆盖帐套隔离、搜索和分页']],
   ['User / Role/UserRoleAdd', ['implemented', '已由 Angular 用户角色新建/编辑抽屉替代']],
   ['User / Role/UserRoleAdd_Submit', ['implemented', 'saveRole 已覆盖用户角色名称防重、原子旧编号和白名单保存']],

+ 14 - 1
scripts/smoke-admin-functions.mjs

@@ -102,6 +102,9 @@ let temporaryFontShapeId = '';
 let temporaryFontShapeTypeId = '';
 let designResourceRestore = null;
 let temporaryDesignResourceId = '';
+let temporaryDesignSceneId = '';
+let temporaryDesignSceneTemplateId = '';
+let temporaryDesignSceneCloneId = '';
 let temporaryRoleId = '';
 let temporaryRoleAuthId = '';
 let surveyRestore = null;
@@ -326,6 +329,13 @@ try {
   const restoreDesignField = (name) => Object.prototype.hasOwnProperty.call(sampleDesignResource, name) ? (name === 'zstatus' ? Number(sampleDesignResource[name]) : sampleDesignResource[name]) : { __op: 'Delete' }; designResourceRestore = { objectId: sampleDesignResource.objectId, fields: Object.fromEntries(['name','style','use','useage','zstatus','ztype','fun'].map((name) => [name, restoreDesignField(name)])) }; const designProbeName = `${String(sampleDesignResource.name || '')} [angular-smoke]`.trim(); const updatedDesignResource = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveDesignResource', className: 'DesignRes', objectId: sampleDesignResource.objectId, fields: { ...sampleDesignResource, name: designProbeName } }); if (updatedDesignResource.name !== designProbeName || updatedDesignResource.vpath !== sampleDesignResource.vpath || updatedDesignResource.previewimg !== sampleDesignResource.previewimg) throw new Error('设计资源元数据专用编辑异常');
   const designFileBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveDesignResource', className: 'DesignRes', objectId: sampleDesignResource.objectId, fields: { ...sampleDesignResource, name: designProbeName }, file: { name: 'design-resource-smoke.png', mimeType: 'image/png', base64: 'iVBORw0KGgo=' } }, 501); if (!String(designFileBlocked.message).includes('文件适配器') || !String(designFileBlocked.message).includes('PostgreSQL')) throw new Error('设计资源文件阻塞未返回明确原因'); await jsonRequest(`${PARSE_URL}/classes/DesignRes/${designResourceRestore.objectId}`, { method: 'PUT', body: JSON.stringify(designResourceRestore.fields) }, true); designResourceRestore = null;
   const temporaryDesignResource = await jsonRequest(`${PARSE_URL}/classes/DesignRes`, { method: 'POST', body: JSON.stringify({ name: '__design_resource_batch_smoke__', ztype: 'img', useage: 'smoke', vpath: '/smoke/design-resource.png', sourceKey: `smoke-design-resource-${Date.now()}`, company }) }, true); temporaryDesignResourceId = temporaryDesignResource.objectId; const deletedTemporaryDesignResource = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'designResourceBatch', className: 'DesignRes', action: 'delete', objectIds: [temporaryDesignResourceId] }); if (deletedTemporaryDesignResource.updated !== 1) throw new Error('设计资源批量删除失败'); temporaryDesignResourceId = '';
+  const designSceneSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'DesignScence' }); const designSceneFieldMap = Object.fromEntries((designSceneSchema.fields || []).map((field) => [field.name, field])); if (designSceneSchema.writable !== true || designSceneSchema.creatable !== false || ['id','guid','comp','page','path','cdate','cuser','tlpId','ztype','scence','siteId','update','company','orderId','labelArr','resource','userName','accessPwd','sourceKey'].some((name) => designSceneFieldMap[name]?.writable !== false)) throw new Error('可视化场景结构字段未隔离'); const designTemplateSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'DesignTlp' }); if (designTemplateSchema.writable !== false || designTemplateSchema.fields?.some((field) => field.writable)) throw new Error('场景模板未禁止通用写入'); const designSceneCreateBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveDesignScene', className: 'DesignScence', fields: {} }, 501); if (!String(designSceneCreateBlocked.message).includes('设计器初始化协议')) throw new Error('场景新增未返回明确阻塞');
+  const designSceneSourceKey = `smoke-design-scene-${Date.now()}`; const temporaryDesignScene = await jsonRequest(`${PARSE_URL}/classes/DesignScence`, { method: 'POST', body: JSON.stringify({ company, sourceKey: designSceneSourceKey, guid: randomBytes(16).toString('hex'), title: '__design_scene_smoke__', userId: memberLegacyId, userName: memberUsername, score: 1, status: 0, seflag: 'smoke', previewImg: '/smoke/scene-preview.png', thumbImg: '/smoke/scene-thumb.png', remind: 'temporary scene', meta: '{"smoke":true}', page: '{"page":true}', comp: '{"components":[]}', scence: '{}', resource: '[]', path: '/smoke/scene', ztype: 0, tlpId: 0 }) }, true); temporaryDesignSceneId = temporaryDesignScene.objectId;
+  const normalDesignScenes = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'designScenes', className: 'DesignScence', status: -100, page: 1, pageSize: 100, search: '__design_scene_smoke__' }); if (normalDesignScenes.total !== 1 || normalDesignScenes.results?.[0]?.objectId !== temporaryDesignSceneId || normalDesignScenes.results.some((entry) => Number(entry.ztype) !== 0 || Number(entry.tlpId) !== 0 || Number(entry.status) === -2)) throw new Error('普通可视化场景范围筛选异常');
+  const updatedDesignScene = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveDesignScene', className: 'DesignScence', objectId: temporaryDesignSceneId, fields: { title: '__design_scene_smoke_updated__', userId: memberLegacyId, score: 8, status: 0, seflag: 'scene-key', previewImg: '/smoke/scene-preview-2.png', thumbImg: '/smoke/scene-thumb-2.png', remind: 'updated temporary scene', meta: '{"smoke":"updated"}' } }); if (updatedDesignScene.title !== '__design_scene_smoke_updated__' || Number(updatedDesignScene.userId) !== memberLegacyId || updatedDesignScene.userName !== memberUsername || updatedDesignScene.page !== '{"page":true}' || updatedDesignScene.comp !== '{"components":[]}' || Number(updatedDesignScene.tlpId) !== 0) throw new Error('可视化场景白名单编辑或用户名同步异常');
+  const createdDesignTemplate = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'createDesignSceneTemplate', className: 'DesignScence', objectId: temporaryDesignSceneId }); temporaryDesignSceneTemplateId = String(createdDesignTemplate.template?.objectId || ''); temporaryDesignSceneCloneId = String(createdDesignTemplate.scene?.objectId || ''); if (!temporaryDesignSceneTemplateId || !temporaryDesignSceneCloneId || !Number(createdDesignTemplate.template?.id) || !Number(createdDesignTemplate.scene?.id) || Number(createdDesignTemplate.template?.ztype) !== 1 || Number(createdDesignTemplate.scene?.ztype) !== 1 || Number(createdDesignTemplate.scene?.tlpId) !== Number(createdDesignTemplate.template?.id) || createdDesignTemplate.scene?.page !== '{"page":true}' || Number(createdDesignTemplate.scene?.score) !== 0 || createdDesignTemplate.scene?.accessPwd !== '' || createdDesignTemplate.scene?.seflag !== '') throw new Error('场景复制为模板的数据或原子编号异常');
+  const recycledDesignScene = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'designSceneBatch', className: 'DesignScence', action: 'recycle', objectIds: [temporaryDesignSceneId] }); if (recycledDesignScene.updated !== 1 || Number(recycledDesignScene.results?.[0]?.status) !== -2) throw new Error('场景移入回收站失败'); const recycledDesignScenes = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'designScenes', className: 'DesignScence', status: -2, page: 1, pageSize: 100, search: '__design_scene_smoke_updated__' }); if (recycledDesignScenes.results?.[0]?.objectId !== temporaryDesignSceneId) throw new Error('场景回收站列表异常'); const restoredDesignScene = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'designSceneBatch', className: 'DesignScence', action: 'restore', objectIds: [temporaryDesignSceneId] }); if (restoredDesignScene.updated !== 1 || Number(restoredDesignScene.results?.[0]?.status) !== 0) throw new Error('场景恢复失败'); const deletedDesignScene = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'designSceneBatch', className: 'DesignScence', action: 'delete', objectIds: [temporaryDesignSceneId] }); if (deletedDesignScene.updated !== 1) throw new Error('场景彻底删除失败'); temporaryDesignSceneId = '';
+  await jsonRequest(`${PARSE_URL}/classes/DesignScence/${temporaryDesignSceneCloneId}`, { method: 'DELETE' }, true); temporaryDesignSceneCloneId = ''; await jsonRequest(`${PARSE_URL}/classes/DesignTlp/${temporaryDesignSceneTemplateId}`, { method: 'DELETE' }, true); temporaryDesignSceneTemplateId = '';
   const roleSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'Role' }); const roleFieldMap = Object.fromEntries((roleSchema.fields || []).map((field) => [field.name, field])); if (roleSchema.writable !== true || roleSchema.creatable !== true || ['roleId','ztype','zstatus','nodeId','auth','auth2','auth3','cadminId','cdate','sourceKey'].some((name) => roleFieldMap[name]?.writable !== false)) throw new Error('角色结构字段未隔离'); const roleAuthSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'ARoleAuth' }); if (roleAuthSchema.writable !== false || roleAuthSchema.fields?.some((field) => field.writable)) throw new Error('角色权限类未禁止通用写入');
   const adminRoles = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'roles', className: 'Role', type: 'admin', page: 1, pageSize: 100, search: '' }); const userRoles = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'roles', className: 'Role', type: 'user', page: 1, pageSize: 100, search: '' }); if (adminRoles.total < 9 || adminRoles.results.some((entry) => String(entry.ztype) !== 'admin') || userRoles.total < 3 || userRoles.results.some((entry) => String(entry.ztype) !== 'user')) throw new Error('管理员/用户角色分类列表异常'); const referencedRole = adminRoles.results.find((entry) => Number(entry.roleId) === 1); if (!referencedRole) throw new Error('缺少历史超级管理员角色'); const referencedRoleDelete = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'roleBatch', className: 'Role', action: 'delete', objectIds: [referencedRole.objectId] }, 409); if (!String(referencedRoleDelete.message).includes('引用')) throw new Error('角色账号引用保护未返回明确原因');
   const roleProbeName = `__angular_role_smoke_${Date.now()}__`; const createdRole = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveRole', className: 'Role', roleType: 'admin', fields: { roleName: roleProbeName, description: 'temporary role' } }); temporaryRoleId = createdRole.objectId; if (!temporaryRoleId || !Number(createdRole.roleId) || createdRole.ztype !== 'admin') throw new Error('角色原子新增失败'); const duplicateRole = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveRole', className: 'Role', roleType: 'admin', fields: { roleName: roleProbeName, description: 'duplicate' } }, 409); if (!String(duplicateRole.message).includes('重复')) throw new Error('角色名称防重异常');
@@ -934,7 +944,7 @@ try {
   const revokedPayload = await revokedResponse.json().catch(() => ({}));
   if (revokedResponse.status !== 401 || !String(revokedPayload.message || revokedPayload.error || '').toLowerCase().includes('sessiontoken')) throw new Error(`已退出管理员会话仍可使用: ${revokedResponse.status}`);
 
-  console.log('Cloud smoke passed: admin auth/logout/tenant/account lifecycle/group sync/node/special/guest-category/bar/exam-class/exam-point/knowledge/exam-teacher/dictionary/grade-dictionary/currency/holiday/search-navigation/advertising/font-shapes/surveys/customer-service/stores/content-tags/shop-vendor-blockers/user-level/money-ledgers/user-addon-blockers/ask-blockers/biao-calc-blockers/office-chart-blocker/chat-blockers/design-class-blockers/com-blockers/common-blockers/site-config-blockers/baike/api-catalog/config-blockers/model/guestbook lifecycle/content workflow/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
+  console.log('Cloud smoke passed: admin auth/logout/tenant/account lifecycle/group sync/node/special/guest-category/bar/exam-class/exam-point/knowledge/exam-teacher/dictionary/grade-dictionary/currency/holiday/search-navigation/advertising/font-shapes/design-scenes/surveys/customer-service/stores/content-tags/shop-vendor-blockers/user-level/money-ledgers/user-addon-blockers/ask-blockers/biao-calc-blockers/office-chart-blocker/chat-blockers/design-class-blockers/com-blockers/common-blockers/site-config-blockers/baike/api-catalog/config-blockers/model/guestbook lifecycle/content workflow/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {
   if (adZoneRestore) await jsonRequest(`${PARSE_URL}/classes/AdZone/${adZoneRestore.objectId}`, { method: 'PUT', body: JSON.stringify({ remind: adZoneRestore.remind, zstatus: adZoneRestore.zstatus }) }, true).catch((error) => { console.error(`广告位冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
   if (adInfoRestore) await jsonRequest(`${PARSE_URL}/classes/AdInfo/${adInfoRestore.objectId}`, { method: 'PUT', body: JSON.stringify({ remind: adInfoRestore.remind, zstatus: adInfoRestore.zstatus }) }, true).catch((error) => { console.error(`广告内容冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
@@ -943,6 +953,9 @@ try {
   if (temporaryFontShapeTypeId) await jsonRequest(`${PARSE_URL}/classes/FontPicShapeType/${temporaryFontShapeTypeId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时图形分类清理失败:${error.message}`); process.exitCode = 1; });
   if (designResourceRestore) await jsonRequest(`${PARSE_URL}/classes/DesignRes/${designResourceRestore.objectId}`, { method: 'PUT', body: JSON.stringify(designResourceRestore.fields) }, true).catch((error) => { console.error(`设计资源冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
   if (temporaryDesignResourceId) await jsonRequest(`${PARSE_URL}/classes/DesignRes/${temporaryDesignResourceId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时设计资源清理失败:${error.message}`); process.exitCode = 1; });
+  if (temporaryDesignSceneId) await jsonRequest(`${PARSE_URL}/classes/DesignScence/${temporaryDesignSceneId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时可视化场景清理失败:${error.message}`); process.exitCode = 1; });
+  if (temporaryDesignSceneCloneId) await jsonRequest(`${PARSE_URL}/classes/DesignScence/${temporaryDesignSceneCloneId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时模板场景副本清理失败:${error.message}`); process.exitCode = 1; });
+  if (temporaryDesignSceneTemplateId) await jsonRequest(`${PARSE_URL}/classes/DesignTlp/${temporaryDesignSceneTemplateId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时可视化模板清理失败:${error.message}`); process.exitCode = 1; });
   if (temporaryRoleAuthId) await jsonRequest(`${PARSE_URL}/classes/ARoleAuth/${temporaryRoleAuthId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时角色权限清理失败:${error.message}`); process.exitCode = 1; });
   if (temporaryRoleId) await jsonRequest(`${PARSE_URL}/classes/Role/${temporaryRoleId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时角色清理失败:${error.message}`); process.exitCode = 1; });
   if (surveyRestore) await jsonRequest(`${PARSE_URL}/classes/DesignAsk/${surveyRestore.objectId}`, { method: 'PUT', body: JSON.stringify(surveyRestore.fields) }, true).catch((error) => { console.error(`问卷冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });

+ 3 - 0
src/app/admin/admin.constants.ts

@@ -39,6 +39,9 @@ export const ADMIN_NAVIGATION = [
   { label: '广告内容管理', icon: 'megaphone', route: '/admin/resources/AdInfo' },
   { label: '字体图形素材', icon: 'shapes', route: '/admin/resources/FontPicShape' },
   { label: '字体图形分类', icon: 'tags', route: '/admin/resources/FontPicShapeType' },
+  { label: '可视化设计资源', icon: 'image', route: '/admin/resources/DesignRes' },
+  { label: '可视化场景', icon: 'panels-top-left', route: '/admin/resources/DesignScence' },
+  { label: '系统角色', icon: 'shield-check', route: '/admin/resources/Role' },
   { label: '问卷调查', icon: 'clipboard-list', route: '/admin/resources/DesignAsk' },
   { label: '问卷题目', icon: 'list-todo', route: '/admin/resources/DesignQuestion' },
   { label: '问卷答卷', icon: 'chart-bar-big', route: '/admin/resources/DesignAnswer' },

+ 5 - 2
src/app/admin/layout/admin-shell.component.ts

@@ -18,6 +18,7 @@ import {
   GalleryHorizontalEnd,
   GraduationCap,
   Headphones,
+  Image,
   KeyRound,
   Languages,
   LayoutDashboard,
@@ -30,9 +31,11 @@ import {
   Megaphone,
   MessageSquareText,
   Palette,
+  PanelsTopLeft,
   Route,
   Search,
   ScrollText,
+  ShieldCheck,
   Shapes,
   Store,
   TableProperties,
@@ -58,8 +61,8 @@ export class AdminShellComponent {
   readonly mobileOpen = signal(false);
   readonly icons = {
     BadgeCent, Bell, BookMarked, BookOpen, Braces, CalendarDays, ChartBarBig, ChartNoAxesCombined, ChevronLeft, ChevronRight,
-    ClipboardCheck, ClipboardList, Files, GalleryHorizontalEnd, GraduationCap, Headphones, KeyRound, Languages, LayoutDashboard, ListChecks, ListTodo, ScrollText,
-    LayoutTemplate, LogOut, Megaphone, Menu, MessageSquareText, Palette, Route, Search, Shapes, Store, TableProperties, Tags, Users, WalletCards, Workflow,
+    ClipboardCheck, ClipboardList, Files, GalleryHorizontalEnd, GraduationCap, Headphones, Image, KeyRound, Languages, LayoutDashboard, ListChecks, ListTodo, ScrollText,
+    LayoutTemplate, LogOut, Megaphone, Menu, MessageSquareText, Palette, PanelsTopLeft, Route, Search, ShieldCheck, Shapes, Store, TableProperties, Tags, Users, WalletCards, Workflow,
   };
 
   constructor(readonly sessions: AdminSessionService) {}

+ 10 - 10
src/app/admin/legacy-admin-coverage.generated.ts

@@ -5508,36 +5508,36 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Design / Scene",
     "action": "SceneList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 designScenes 按旧 ZType=0、TlpID=0、Status<>-2 条件提供帐套隔离、搜索和分页"
   },
   {
     "module": "Design / Scene",
     "action": "SceneListRecycle",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "Angular 场景页可切换回收站,designScenes 严格筛选 Status=-2"
   },
   {
     "module": "Design / Scene",
     "action": "SceneAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Angular 现有场景编辑抽屉替代,并将布局、组件、资源、路径、编号和模板关系锁为只读"
   },
   {
     "module": "Design / Scene",
     "action": "SceneAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "saveDesignScene 白名单更新旧页 9 个字段、校验帐套用户并同步 UserName 与更新时间"
   },
   {
     "module": "Design / Scene",
     "action": "Scene_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "designSceneBatch 覆盖回收、恢复、彻底删除;createDesignSceneTemplate 原子分配模板与副本场景编号并提供失败补偿"
   },
   {
     "module": "Office / Service",

+ 11 - 1
src/app/admin/pages/admin-resource.component.html

@@ -23,6 +23,9 @@
   @if (className() === 'DesignRes') {
     <div class="bulk-toolbar"><strong>设计资源类型</strong><input maxlength="32" [ngModel]="filterDesignResourceType()" (ngModelChange)="filterDesignResourceType.set($event)" (keyup.enter)="load(1)" placeholder="如 img(精确匹配)" /><button type="button" (click)="load(1)">筛选</button></div>
   }
+  @if (className() === 'DesignScence') {
+    <div class="bulk-toolbar"><strong>场景范围</strong><select [ngModel]="filterDesignSceneStatus()" (ngModelChange)="filterDesignSceneStatus.set(+$event); load(1)"><option [ngValue]="-100">正常场景</option><option [ngValue]="-2">回收站</option></select></div>
+  }
   @if (className() === 'Role') {
     <div class="bulk-toolbar"><strong>角色类型</strong><select [ngModel]="filterRoleType()" (ngModelChange)="filterRoleType.set($event); load(1)"><option value="admin">管理员角色</option><option value="user">用户角色</option></select></div>
   }
@@ -85,6 +88,9 @@
         <button class="danger-action" type="button" [disabled]="updatingFontShape()" (click)="applyBulkFontShapeDelete()">删除</button>
       } @else if (className() === 'DesignRes') {
         <button class="danger-action" type="button" [disabled]="updatingDesignResource()" (click)="applyBulkDesignResourceDelete()">删除资源记录</button>
+      } @else if (className() === 'DesignScence') {
+        @if (filterDesignSceneStatus() === -2) { <button type="button" [disabled]="updatingDesignScene()" (click)="applyBulkDesignSceneAction('restore')">恢复场景</button><button class="danger-action" type="button" [disabled]="updatingDesignScene()" (click)="applyBulkDesignSceneAction('delete')">彻底删除</button> }
+        @else { <button class="danger-action" type="button" [disabled]="updatingDesignScene()" (click)="applyBulkDesignSceneAction('recycle')">移入回收站</button> }
       } @else if (className() === 'Role') {
         <button class="danger-action" type="button" [disabled]="savingRoleAuthorization()" (click)="applyBulkRoleDelete()">删除角色</button>
       } @else if (className() === 'DesignAsk') {
@@ -127,7 +133,7 @@
   } @else {
     <div class="table-scroll"><table><thead><tr>@if (bulkEnabled()) { <th class="select-col"><input type="checkbox" aria-label="全选当前页" [checked]="allPageSelected()" (change)="togglePageSelection($any($event.target).checked)" /></th> }@for (field of columns(); track field.name) { <th>{{ field.name }}</th> }<th class="actions">操作</th></tr></thead><tbody>
       @for (row of page()?.results || []; track row['objectId']) {
-        <tr>@if (bulkEnabled()) { <td class="select-col"><input type="checkbox" [attr.aria-label]="'选择 ' + row['objectId']" [checked]="isSelected(row['objectId'])" (change)="toggleSelection('' + row['objectId'], $any($event.target).checked)" /></td> }@for (field of columns(); track field.name) { <td [title]="display(row[field.name])">{{ display(row[field.name]) }}</td> }<td class="actions"><button type="button" [attr.aria-label]="schema()?.writable ? '编辑' : '查看'" (click)="openEdit(row)"><lucide-icon [img]="schema()?.writable ? icons.Pencil : icons.Eye" [size]="16" /></button>@if (schema()?.writable && !['Model', 'ModelField', 'StoreApplication', 'ContentTagKey'].includes(className())) { <button class="danger" type="button" [attr.aria-label]="['CommonModel', 'Node', 'Guestbook'].includes(className()) ? '移入回收站' : '删除'" (click)="remove(row)"><lucide-icon [img]="icons.Trash2" [size]="16" /></button> }</td></tr>
+        <tr>@if (bulkEnabled()) { <td class="select-col"><input type="checkbox" [attr.aria-label]="'选择 ' + row['objectId']" [checked]="isSelected(row['objectId'])" (change)="toggleSelection('' + row['objectId'], $any($event.target).checked)" /></td> }@for (field of columns(); track field.name) { <td [title]="display(row[field.name])">{{ display(row[field.name]) }}</td> }<td class="actions"><button type="button" [attr.aria-label]="schema()?.writable ? '编辑' : '查看'" (click)="openEdit(row)"><lucide-icon [img]="schema()?.writable ? icons.Pencil : icons.Eye" [size]="16" /></button>@if (schema()?.writable && !['Model', 'ModelField', 'StoreApplication', 'ContentTagKey'].includes(className())) { <button class="danger" type="button" [attr.aria-label]="className() === 'DesignScence' && filterDesignSceneStatus() === -2 ? '彻底删除' : ['CommonModel', 'Node', 'Guestbook', 'DesignScence'].includes(className()) ? '移入回收站' : '删除'" (click)="remove(row)"><lucide-icon [img]="icons.Trash2" [size]="16" /></button> }</td></tr>
       } @empty { <tr><td class="empty-cell" [attr.colspan]="columns().length + (bulkEnabled() ? 2 : 1)">没有符合条件的数据</td></tr> }
     </tbody></table></div>
     <footer class="pagination"><span>第 {{ page()?.page || 1 }} 页,共 {{ pageCount() }} 页</span><div><button type="button" [disabled]="(page()?.page || 1) <= 1 || loading()" (click)="load((page()?.page || 1) - 1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" />上一页</button><button type="button" [disabled]="(page()?.page || 1) * (page()?.pageSize || 20) >= (page()?.total || 0) || loading()" (click)="load((page()?.page || 1) + 1)">下一页<lucide-icon [img]="icons.ChevronRight" [size]="17" /></button></div></footer>
@@ -219,6 +225,10 @@
         @if (className() === 'DesignRes') {
           <section class="account-create-note"><strong>历史设计资源元数据</strong><span>可编辑名称、类型、用途、状态及样式/函数字段;原文件和预览图保留旧路径并只读。Parse 文件适配器修复前,新增与替换文件会明确阻断。</span><code>{{ draft()['vpath'] || '无原文件路径' }} · {{ draft()['previewimg'] || '无预览图路径' }}</code></section>
         }
+        @if (className() === 'DesignScence') {
+          <section class="account-create-note"><strong>普通可视化场景</strong><span>仅编辑旧 SceneAdd 允许的标题、所属用户、积分、状态、搜索标记、预览图、缩略图、说明和 Meta;页面、组件、资源 JSON、路径、旧编号和模板关系保持只读。</span><code>场景 ID {{ draft()['id'] }} · 用户 {{ draft()['userName'] || '—' }}({{ draft()['userId'] || '—' }})</code></section>
+          @if (draftId()) { <section class="content-controls"><header><div><strong>复制为模板</strong><span>完整复制现有布局与组件数据,并原子分配新的模板 ID 和模板场景 ID;失败时自动清理半成品。</span></div></header><button type="button" [disabled]="updatingDesignScene()" (click)="createDesignSceneTemplate()">{{ updatingDesignScene() ? '正在复制…' : '创建可视化模板' }}</button>@if (designSceneActionError()) { <div class="content-action-status error">{{ designSceneActionError() }}</div> }@if (designSceneActionMessage()) { <div class="content-action-status success">{{ designSceneActionMessage() }}</div> }</section> }
+        }
         @if (className() === 'Role') {
           <section class="account-create-note"><strong>{{ (draft()['ztype'] || filterRoleType()) === 'user' ? '用户角色' : '管理员角色' }}</strong><span>角色编号、类型、状态和创建人由云函数维护;类型创建后不可迁移。旧 B_ARoleAuth 权限校验在交付程序中固定返回 true,下方完整保留旧权限码,但不用它绕过新云函数的管理员校验。</span>@if (draftId()) { <code>角色 ID {{ draft()['roleId'] }} · {{ draft()['ztype'] }}</code> }</section>
           @if (draftId()) {

+ 47 - 0
src/app/admin/pages/admin-resource.component.spec.ts

@@ -681,6 +681,53 @@ describe('AdminResourceComponent', () => {
     expect(component.bulkMessage()).toContain('1 个设计资源');
   });
 
+  it('loads normal and recycled visual scenes through the dedicated status filter', async () => {
+    component.className.set('DesignScence');
+    component.schema.set({ className: 'DesignScence', label: '可视化场景', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.filterDesignSceneStatus.set(-2); component.search.set('英语');
+    functions.admin.and.resolveTo({ className: 'DesignScence', page: 2, pageSize: 20, total: 3, results: [] });
+
+    await component.load(2);
+
+    expect(functions.admin.calls.first().args).toEqual(['designScenes', { className: 'DesignScence', page: 2, pageSize: 20, search: '英语', status: -2 }]);
+  });
+
+  it('updates only existing visual-scene metadata through the dedicated operation', async () => {
+    component.className.set('DesignScence');
+    component.schema.set({ className: 'DesignScence', label: '可视化场景', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.draftId.set('scene-object');
+    component.draft.set({ objectId: 'scene-object', id: 8, title: '英语场景', userId: 1, score: 10, status: 0, page: '{"protected":true}' });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveDesignScene'
+      ? { objectId: 'scene-object', id: 8, title: '英语场景', userId: 1 }
+      : { className: 'DesignScence', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveDesignScene', { className: 'DesignScence', objectId: 'scene-object', fields: { objectId: 'scene-object', id: 8, title: '英语场景', userId: 1, score: 10, status: 0, page: '{"protected":true}' } }]);
+  });
+
+  it('recycles and permanently deletes visual scenes through explicit batch actions', async () => {
+    component.className.set('DesignScence'); component.selectedIds.set(['scene-a']); spyOn(window, 'confirm').and.returnValue(true);
+    functions.admin.and.callFake(async (operation: string) => operation === 'designSceneBatch'
+      ? { action: 'recycle', updated: 1 }
+      : { className: 'DesignScence', page: 1, pageSize: 20, total: 0, results: [] });
+
+    await component.applyBulkDesignSceneAction('recycle');
+    expect(functions.admin.calls.first().args).toEqual(['designSceneBatch', { className: 'DesignScence', action: 'recycle', objectIds: ['scene-a'] }]);
+    expect(component.bulkMessage()).toContain('已回收 1 个场景');
+  });
+
+  it('creates a template and its scene clone from the current visual scene', async () => {
+    component.className.set('DesignScence'); component.draftId.set('scene-object'); spyOn(window, 'confirm').and.returnValue(true);
+    functions.admin.and.resolveTo({ template: { id: 9, tlpName: '英语场景' }, scene: { id: 10 } });
+
+    await component.createDesignSceneTemplate();
+
+    expect(functions.admin).toHaveBeenCalledWith('createDesignSceneTemplate', { className: 'DesignScence', objectId: 'scene-object' });
+    expect(component.designSceneActionMessage()).toContain('模板 ID 9');
+    expect(component.designSceneActionMessage()).toContain('副本场景 ID 10');
+  });
+
   it('loads legacy roles through the dedicated role-type filter', async () => {
     component.className.set('Role');
     component.schema.set({ className: 'Role', label: '系统角色', writable: true, creatable: true, supportsSoftDelete: false, fields: [] });

+ 34 - 5
src/app/admin/pages/admin-resource.component.ts

@@ -130,6 +130,10 @@ export class AdminResourceComponent implements OnInit {
   readonly updatingFontShape = signal(false);
   readonly filterDesignResourceType = signal('');
   readonly updatingDesignResource = signal(false);
+  readonly filterDesignSceneStatus = signal(-100);
+  readonly updatingDesignScene = signal(false);
+  readonly designSceneActionError = signal('');
+  readonly designSceneActionMessage = signal('');
   readonly filterRoleType = signal<'admin' | 'user'>('admin');
   readonly roleAuthorization = signal<Record<string, string>>({});
   readonly savingRoleAuthorization = signal(false);
@@ -191,7 +195,7 @@ export class AdminResourceComponent implements OnInit {
     return fields.map((field, index) => ({ field, index })).sort((a, b) => rank(a.field.name) - rank(b.field.name) || a.index - b.index).map(({ field }) => field).slice(0, 7);
   });
   readonly pageCount = computed(() => Math.max(1, Math.ceil((this.page()?.total ?? 0) / (this.page()?.pageSize || 20))));
-  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'Role', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer', 'DesignRes', 'ServiceSeat', 'Temp', 'StoreApplication', 'StoreStyle', 'UserLevel', 'Baike', 'CRMSAttr', 'ShopFareTlp', 'MisType', 'PageStyle', 'PlatComp'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'Role', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer', 'DesignRes', 'DesignScence', 'ServiceSeat', 'Temp', 'StoreApplication', 'StoreStyle', 'UserLevel', 'Baike', 'CRMSAttr', 'ShopFareTlp', 'MisType', 'PageStyle', 'PlatComp'].includes(this.className()));
   readonly allPageSelected = computed(() => {
     const ids = (this.page()?.results || []).map((row) => String(row['objectId'] || '')).filter(Boolean);
     return Boolean(ids.length) && ids.every((id) => this.selectedIds().includes(id));
@@ -209,6 +213,7 @@ export class AdminResourceComponent implements OnInit {
       this.filterAdZoneType.set(''); this.filterAdStatus.set(-100); this.filterAdInfoZoneId.set(''); this.resetAdActions();
       this.filterFontShapeTypeId.set(0); this.resetFontShapeActions();
       this.filterDesignResourceType.set('');
+      this.filterDesignSceneStatus.set(-100); this.resetDesignSceneActions();
       this.filterRoleType.set('admin'); this.resetRoleActions();
       this.filterSurveyStatus.set(-100); this.filterSurveyId.set(0); this.resetSurveyActions();
       this.resetServiceActions();
@@ -231,7 +236,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'FontPicShape' && reloadSchema) await this.loadFontShapeTypes();
       if (['DesignQuestion', 'DesignAnswer'].includes(this.className()) && reloadSchema) { await this.loadSurveys(); if (!this.filterSurveyId() && this.surveys().length) this.filterSurveyId.set(this.surveyIdOf(this.surveys()[0])); }
       if (this.className() === 'StoreApplication' && reloadSchema) await this.loadStoreStyles();
-      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === 'Role' ? 'roles' : this.className() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : this.className() === 'FontPicShape' ? 'fontShapes' : this.className() === 'FontPicShapeType' ? 'fontShapeTypes' : this.className() === 'DesignRes' ? 'designResources' : this.className() === 'DesignAsk' ? 'surveys' : this.className() === 'DesignQuestion' ? 'surveyQuestions' : this.className() === 'DesignAnswer' ? 'surveyResults' : this.className() === 'ServiceSeat' ? 'serviceSeats' : this.className() === 'Temp' ? 'serviceCodes' : this.className() === 'StoreApplication' ? 'storeApplications' : this.className() === 'StoreStyle' ? 'storeStyles' : this.className() === 'ContentTagKey' ? 'contentTagKeys' : this.className() === 'UserLevel' ? 'userLevels' : this.className() === 'UserMoneyLog' ? 'userMoneyLogs' : this.className() === 'UserPromotion' ? 'userPromotions' : this.className() === 'Baike' ? 'baikeEntries' : this.className() === 'CRMSAttr' ? 'crmClientTypes' : this.className() === 'ShopFareTlp' ? 'shopFareTemplates' : this.className() === 'SysLog' ? 'systemLogs' : this.className() === 'MisType' ? 'misTypes' : this.className() === 'PageStyle' ? 'pageStyles' : this.className() === 'PlatComp' ? 'platformCompanies' : 'list';
+      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === 'Role' ? 'roles' : this.className() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : this.className() === 'FontPicShape' ? 'fontShapes' : this.className() === 'FontPicShapeType' ? 'fontShapeTypes' : this.className() === 'DesignRes' ? 'designResources' : this.className() === 'DesignScence' ? 'designScenes' : this.className() === 'DesignAsk' ? 'surveys' : this.className() === 'DesignQuestion' ? 'surveyQuestions' : this.className() === 'DesignAnswer' ? 'surveyResults' : this.className() === 'ServiceSeat' ? 'serviceSeats' : this.className() === 'Temp' ? 'serviceCodes' : this.className() === 'StoreApplication' ? 'storeApplications' : this.className() === 'StoreStyle' ? 'storeStyles' : this.className() === 'ContentTagKey' ? 'contentTagKeys' : this.className() === 'UserLevel' ? 'userLevels' : this.className() === 'UserMoneyLog' ? 'userMoneyLogs' : this.className() === 'UserPromotion' ? 'userPromotions' : this.className() === 'Baike' ? 'baikeEntries' : this.className() === 'CRMSAttr' ? 'crmClientTypes' : this.className() === 'ShopFareTlp' ? 'shopFareTemplates' : this.className() === 'SysLog' ? 'systemLogs' : this.className() === 'MisType' ? 'misTypes' : this.className() === 'PageStyle' ? 'pageStyles' : this.className() === 'PlatComp' ? 'platformCompanies' : 'list';
       const pagePayload: Record<string, unknown> = { className: this.className(), page: pageNumber, pageSize: 20, search: this.search().trim() };
       if (pageOperation === 'gradeOptions') { pagePayload['categoryId'] = this.filterGradeCategoryId(); pagePayload['parentId'] = this.filterGradeParentId(); }
       if (pageOperation === 'searchNavigations') { pagePayload['type'] = this.filterSearchNavigationType(); pagePayload['state'] = this.filterSearchNavigationState(); pagePayload['elite'] = this.filterSearchNavigationElite(); }
@@ -239,6 +244,7 @@ export class AdminResourceComponent implements OnInit {
       if (pageOperation === 'adInfos') { pagePayload['zoneId'] = this.filterAdInfoZoneId(); pagePayload['status'] = this.filterAdStatus(); }
       if (pageOperation === 'fontShapes') pagePayload['typeId'] = this.filterFontShapeTypeId();
       if (pageOperation === 'designResources') pagePayload['type'] = this.filterDesignResourceType().trim();
+      if (pageOperation === 'designScenes') pagePayload['status'] = this.filterDesignSceneStatus();
       if (pageOperation === 'roles') pagePayload['type'] = this.filterRoleType();
       if (pageOperation === 'surveys') pagePayload['status'] = this.filterSurveyStatus();
       if (pageOperation === 'surveyQuestions' || pageOperation === 'surveyResults') pagePayload['askId'] = this.filterSurveyId();
@@ -254,14 +260,14 @@ export class AdminResourceComponent implements OnInit {
     finally { this.loading.set(false); }
   }
 
-  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); this.resetGuestCategoryActions(); this.resetBarActions(); this.resetExamClassActions(); this.resetExamPointActions(); this.resetKnowledgeActions(); this.resetTeacherActions(); this.resetDictionaryActions(); this.resetGradeActions(); this.resetAdActions(); this.resetFontShapeActions(); this.resetRoleActions(); this.resetSurveyActions(); this.resetServiceActions(); this.resetStoreActions(); this.resetTagKeyActions(); this.resetPlatformCompanyActions(); this.editorOpen.set(true); }
+  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); this.resetGuestCategoryActions(); this.resetBarActions(); this.resetExamClassActions(); this.resetExamPointActions(); this.resetKnowledgeActions(); this.resetTeacherActions(); this.resetDictionaryActions(); this.resetGradeActions(); this.resetAdActions(); this.resetFontShapeActions(); this.resetDesignSceneActions(); this.resetRoleActions(); this.resetSurveyActions(); this.resetServiceActions(); this.resetStoreActions(); this.resetTagKeyActions(); this.resetPlatformCompanyActions(); this.editorOpen.set(true); }
   async openEdit(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     this.error.set('');
     try {
       const detailOperation = this.className() === 'ContentTagKey' ? 'getContentTagKeys' : this.className() === 'UserMoneyLog' ? 'getUserMoneyLog' : this.className() === 'UserPromotion' ? 'getUserPromotion' : this.className() === 'Role' ? 'roleAuthorization' : 'get';
       const detail = await this.functions.admin<Record<string, unknown>>(detailOperation, { className: this.className(), objectId });
-      this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(detail); this.resetContentActions(detail); this.resetNodeActions(detail); this.resetSpecialActions(detail); this.resetGuestbookActions(detail); this.resetGuestCategoryActions(detail); this.resetBarActions(detail); this.resetExamClassActions(detail); this.resetExamPointActions(detail); this.resetKnowledgeActions(detail); this.resetTeacherActions(detail); this.resetDictionaryActions(detail); this.resetGradeActions(detail); this.resetAdActions(detail); this.resetFontShapeActions(detail); this.resetRoleActions(detail); this.resetSurveyActions(); this.resetServiceActions(detail); this.resetStoreActions(detail); this.resetTagKeyActions(detail); this.resetPlatformCompanyActions(detail); this.editorOpen.set(true);
+      this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(detail); this.resetContentActions(detail); this.resetNodeActions(detail); this.resetSpecialActions(detail); this.resetGuestbookActions(detail); this.resetGuestCategoryActions(detail); this.resetBarActions(detail); this.resetExamClassActions(detail); this.resetExamPointActions(detail); this.resetKnowledgeActions(detail); this.resetTeacherActions(detail); this.resetDictionaryActions(detail); this.resetGradeActions(detail); this.resetAdActions(detail); this.resetFontShapeActions(detail); this.resetDesignSceneActions(); this.resetRoleActions(detail); this.resetSurveyActions(); this.resetServiceActions(detail); this.resetStoreActions(detail); this.resetTagKeyActions(detail); this.resetPlatformCompanyActions(detail); this.editorOpen.set(true);
       if (this.className() === '_User') await this.loadUserGroups();
       if (this.className() === 'AdInfo' && !this.adZones().length) await this.loadAdZones();
       if (this.className() === 'AdZone') await this.loadAdPreview();
@@ -323,6 +329,7 @@ export class AdminResourceComponent implements OnInit {
                                               : this.className() === 'FontPicShape' ? 'saveFontShape'
                                                 : this.className() === 'FontPicShapeType' ? 'saveFontShapeType'
                                                   : this.className() === 'DesignRes' ? 'saveDesignResource'
+                                                    : this.className() === 'DesignScence' ? 'saveDesignScene'
                                                     : this.className() === 'Role' ? 'saveRole'
                                                   : this.className() === 'DesignAsk' ? 'saveSurvey'
                                                     : this.className() === 'DesignQuestion' ? 'saveSurveyQuestion'
@@ -745,6 +752,26 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : '设计资源删除失败'); }
     finally { this.updatingDesignResource.set(false); }
   }
+  async applyBulkDesignSceneAction(action: 'recycle' | 'restore' | 'delete'): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'DesignScence' || !objectIds.length) { this.bulkError.set('请先选择可视化场景'); return; }
+    if ((action === 'recycle' || action === 'delete') && !confirm(action === 'delete' ? `确认彻底删除选中的 ${objectIds.length} 个回收站场景?此操作不可恢复。` : `确认将选中的 ${objectIds.length} 个场景移入回收站?`)) return;
+    this.updatingDesignScene.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('designSceneBatch', { className: 'DesignScence', action, objectIds }); await this.load(); this.bulkMessage.set(action === 'recycle' ? `已回收 ${result.updated} 个场景` : action === 'restore' ? `已恢复 ${result.updated} 个场景` : `已彻底删除 ${result.updated} 个场景`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '场景批量操作失败'); }
+    finally { this.updatingDesignScene.set(false); }
+  }
+  async createDesignSceneTemplate(): Promise<void> {
+    this.designSceneActionError.set(''); this.designSceneActionMessage.set('');
+    if (this.className() !== 'DesignScence' || !this.draftId()) { this.designSceneActionError.set('只能将现有普通场景复制为模板'); return; }
+    if (!confirm('确认复制当前场景为一个新的可视化模板?')) return;
+    this.updatingDesignScene.set(true);
+    try {
+      const result = await this.functions.admin<{ template: Record<string, unknown>; scene: Record<string, unknown> }>('createDesignSceneTemplate', { className: 'DesignScence', objectId: this.draftId() });
+      this.designSceneActionMessage.set(`模板已创建:${String(result.template['tlpName'] || '未命名')}(模板 ID ${String(result.template['id'] || '—')},副本场景 ID ${String(result.scene['id'] || '—')})`);
+    } catch (error) { this.designSceneActionError.set(error instanceof Error ? error.message : '场景复制为模板失败'); }
+    finally { this.updatingDesignScene.set(false); }
+  }
   setRoleAuthorizationField(name: string, value: string): void {
     this.roleAuthorization.update((current) => ({ ...current, [name]: value }));
   }
@@ -896,7 +923,7 @@ export class AdminResourceComponent implements OnInit {
   }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
-    const verb = ['CommonModel', 'Node', 'Guestbook'].includes(this.className()) ? '移入回收站' : '删除';
+    const verb = this.className() === 'DesignScence' && this.filterDesignSceneStatus() === -2 ? '彻底删除' : ['CommonModel', 'Node', 'Guestbook', 'DesignScence'].includes(this.className()) ? '移入回收站' : '删除';
     if (!objectId || !confirm(`确认${verb} ${this.className()} / ${objectId}?`)) return;
     try {
       if (this.className() === 'Search') await this.functions.admin('searchNavigationBatch', { className: 'Search', action: 'delete', objectIds: [objectId] });
@@ -905,6 +932,7 @@ export class AdminResourceComponent implements OnInit {
       else if (this.className() === 'FontPicShape') await this.functions.admin('fontShapeBatch', { className: 'FontPicShape', action: 'delete', objectIds: [objectId] });
       else if (this.className() === 'FontPicShapeType') await this.functions.admin('fontShapeTypeBatch', { className: 'FontPicShapeType', action: 'delete', objectIds: [objectId] });
       else if (this.className() === 'DesignRes') await this.functions.admin('designResourceBatch', { className: 'DesignRes', action: 'delete', objectIds: [objectId] });
+      else if (this.className() === 'DesignScence') await this.functions.admin('designSceneBatch', { className: 'DesignScence', action: this.filterDesignSceneStatus() === -2 ? 'delete' : 'recycle', objectIds: [objectId] });
       else if (this.className() === 'Role') await this.functions.admin('roleBatch', { className: 'Role', action: 'delete', objectIds: [objectId] });
       else if (['DesignAsk', 'DesignQuestion', 'DesignAnswer'].includes(this.className())) await this.functions.admin('surveyBatch', { className: this.className(), action: 'delete', objectIds: [objectId] });
       else if (['ServiceSeat', 'Temp'].includes(this.className())) await this.functions.admin('serviceBatch', { className: this.className(), action: 'delete', objectIds: [objectId] });
@@ -992,6 +1020,7 @@ export class AdminResourceComponent implements OnInit {
   private resetGradeActions(detail: Record<string, unknown> = {}): void { this.targetGradeCategoryId.set(Number(detail['cate']) || this.filterGradeCategoryId()); this.targetGradeParentId.set(Number(detail['parentId']) || this.filterGradeParentId()); }
   private resetAdActions(detail: Record<string, unknown> = {}): void { this.targetAdZoneId.set(String(detail['zoneId'] || this.filterAdInfoZoneId() || '')); this.adPreview.set([]); this.adPreviewError.set(''); this.adPreviewLoading.set(false); }
   private resetFontShapeActions(detail: Record<string, unknown> = {}): void { this.targetFontShapeTypeId.set(Number(detail['typeId']) || this.filterFontShapeTypeId()); this.fontShapePreview.set(String(detail['shape'] || '')); }
+  private resetDesignSceneActions(): void { this.designSceneActionError.set(''); this.designSceneActionMessage.set(''); this.updatingDesignScene.set(false); }
   private resetRoleActions(detail: Record<string, unknown> = {}): void { const authorization = detail['authorization'] && typeof detail['authorization'] === 'object' ? detail['authorization'] as Record<string, unknown> : {}; this.roleAuthorization.set(Object.fromEntries(this.roleAuthorizationFields.map(([name]) => [name, String(authorization[name] || '')]))); this.roleAuthorizationError.set(''); this.roleAuthorizationMessage.set(''); this.savingRoleAuthorization.set(false); }
   private resetSurveyActions(): void { this.surveyOrderStart.set(1); this.surveyChart.set(null); this.surveyChartError.set(''); this.surveyChartLoading.set(false); }
   private resetServiceActions(detail: Record<string, unknown> = {}): void { this.targetServiceSeatUsername.set(String(detail['sRemrk'] || '')); }