Просмотр исходного кода

feat: migrate design resource administration

彭峰 2 месяцев назад
Родитель
Сommit
09eb84f04f

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

@@ -43,6 +43,7 @@
 - 旧 `Push` 后台依赖 `Mobile_PushAPI/Mobile_PushTlp/Mobile_PushMsg` 三张表、极光 AppKey/AppSecret、服务端 JPush SDK 和 `ZL_UserBase.UC` 设备注册号。签约文档与线上 110 个 Schema 均没有 push/notification/device/message 数据类,当前客户端也没有等价设备注册消费者;12 个配置、模板、发送和历史入口由 `legacyPushBlocker` 返回逐项数据或基础设施原因,不存储无消费者的密钥,也不伪报消息已发送。
 - 旧 `Extend/PWA` 会在 ASP.NET 站点上读写 `SiteConfig.SiteOption`,缩放八种图标,生成 `manifest.json`,注入页面 head,并从 `pwa_sw_js.tlp` 覆盖站点根目录 `/sw.js`。当前 Angular 构建没有 service-worker 发布链,云函数也不能修改已部署静态资产;6 个页面和提交动作由 `legacyPWABlocker` 明确为基础设施阻塞,不提供“保存成功但客户端不生效”的伪配置。
 - `Extend/QuartzTask` 管理的是 `QuartzNetTask` 任务记录、任务内日志及常驻进程中的 `IScheduler/ScheduleTaskHandler`,创建、更新、立即执行、暂停、恢复和删除都必须同步调度器。目标只存在语义不同且为空的 `SenTask`,云函数没有 Quartz 控制面;含 GET/POST 两个同名 `TaskAdd` 在内的 11 个动作由 `legacyQuartzTaskBlocker` 分别返回数据或基础设施阻塞原因。
+- 旧 `Design/Resource` 的历史资源已从线上 `DesignRes` 恢复到 Angular“可视化设计资源”页:`designResources` 提供帐套隔离、分页、搜索和 `ztype` 精确筛选,`saveDesignResource` 只允许更新名称、类型、用途、状态及 `style/use/fun` 元数据,`designResourceBatch` 覆盖单条和批量删除。`id/vpath/previewimg/cdate/userid/sourceKey` 均锁为只读;线上 Parse 文件适配器仍错误要求 MongoDB 连接串,而当前项目使用 PostgreSQL,因此新增及替换原文件/预览图继续以 `migration_blocked` 明确拒绝,不会伪造可用资源路径。
 - 旧 `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/旧模板运行时。

+ 23 - 2
scripts/deploy-admin-functions.mjs

@@ -15,7 +15,7 @@ const CLASS_LABELS = {
   GradeCate: '多级字典分类', Grade: '多级字典选项',
   Currency: '货币管理', SysHoliday: '节假日管理',
   Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '商城运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
-  GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科审核', Pub: '互动模块定义', PubTw: '提问互动提交', PubWTHD: '问题回答提交', PubZXDC: '在线调查提交', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', FontPicShape: '字体图形素材', FontPicShapeType: '字体图形分类', DesignAsk: '问卷调查', DesignQuestion: '问卷题目', DesignAnswer: '问卷答卷', 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: '在线调查提交', 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: '交付中心'
 };
 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'
@@ -51,6 +51,7 @@ const CLASS_SYSTEM_FIELDS = {
   DesignAsk: new Set(['id','cdate','cuser','adminId','sourceKey']),
   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']),
   ServiceSeat: new Set(['sId','sAdminId','sRemrk','sDateTime','sourceKey']),
   Temp: new Set(['id','useType','str3','str5','str6','describe','cdate','userId','sourceKey']),
   StoreApplication: new Set(['id','userId','userName','addTime','storeState','storeCommendState','storeModelId','sourceKey']),
@@ -1128,6 +1129,24 @@ async function handler(request, response) {
       if (String(input.className || '') !== 'FontPicShape' || 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('FontPicShape'); const query = new Parse.Query('FontPicShape'); 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-font-shape', 'FontPicShape', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
     }
+    if (operation === 'designResources') {
+      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 || '').trim().toLowerCase(); const search = String(input.search || '').trim().toLowerCase(); if (type.length > 32 || search.length > 100) fail(400, '设计资源筛选条件无效');
+      const fields = await schemaFor('DesignRes'); const query = new Parse.Query('DesignRes'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => (!type || String(entry.get('ztype') || '').toLowerCase() === type) && (!search || [entry.get('name'),entry.get('useage'),entry.get('vpath'),entry.get('previewimg')].some((value) => String(value || '').toLowerCase().includes(search)))); results.sort((left, right) => Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize);
+      return response.json({ success: true, data: { className: 'DesignRes', page, pageSize, total, results: results.map(serializeObject) } });
+    }
+    if (operation === 'saveDesignResource') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
+      if (!objectId) fail(501, 'migration_blocked: 新增设计资源必须上传原文件并分配 DesignRes.id;当前 PostgreSQL Parse 文件适配器配置错误,且 id 与 Parse 保留属性冲突');
+      if (input.file || input.cover) fail(501, 'migration_blocked: 当前 Parse Server 的 SDK 与 /files REST 接口均错误调用 MongoDB 文件适配器,本项目使用 PostgreSQL;修复服务端文件存储前不能新增或替换设计资源文件');
+      const name = String(payload.name || '').trim(); const ztype = String(payload.ztype || '').trim(); const useage = String(payload.useage || '').trim(); const style = String(payload.style || ''); const use = String(payload.use || ''); const fun = String(payload.fun || ''); const zstatus = Number(payload.zstatus == null || payload.zstatus === '' ? 0 : payload.zstatus);
+      if (!name || name.length > 100 || !/^[a-zA-Z0-9_-]{1,32}$/.test(ztype) || useage.length > 100 || style.length > 20000 || use.length > 20000 || fun.length > 20000 || !Number.isInteger(zstatus) || zstatus < -100 || zstatus > 999) fail(400, '设计资源名称、类型、用途、状态或代码字段无效');
+      const fields = await schemaFor('DesignRes'); const query = new Parse.Query('DesignRes'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
+      target.set('name', name); target.set('ztype', ztype); target.set('useage', useage); target.set('style', style); target.set('use', use); target.set('fun', fun); target.set('zstatus', zstatus); 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-resource', 'DesignRes', target.id); return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'designResourceBatch') {
+      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 === 'legacyFontPicBlocker') {
       const action = String(input.action || ''); const blockers = { Index: '目标 Schema 缺少旧 FontPic 成品字体图主表与历史数据', FontPicInfo: '目标 Schema 缺少旧 FontPic 成品字体图详情数据', FontPicInfo_Submit: '目标 Schema 缺少旧 FontPic 成品字体图持久化模型', FontPic_API: '目标 Schema 缺少旧 FontPic 成品字体图可删除记录', Draft: '目标 Schema 缺少旧 FontPicDraft 草稿表与历史数据', DraftInfo: '目标 Schema 缺少旧 FontPicDraft 草稿详情数据', DraftInfo_Submit: '目标 Schema 缺少旧 FontPicDraft 草稿持久化模型', Draft_API: '目标 Schema 缺少旧 FontPicDraft 可删除记录' }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧字体图动作'); fail(501, 'migration_blocked: ' + blockers[action]);
     }
@@ -1928,7 +1947,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','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','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));
@@ -1974,6 +1993,7 @@ async function handler(request, response) {
       if (className === 'MisType') fail(400, 'OA 流程类型必须走专用保存流程');
       if (className === 'PageStyle') fail(400, '黄页样式必须走专用保存流程');
       if (className === 'PlatComp') fail(400, '协同办公企业必须走专用保存流程');
+      if (className === 'DesignRes') fail(400, '设计资源必须走专用保存流程');
       if (className === 'CommonModel' && !objectId) fail(400, '内容新增必须同时写入模型附表,不能走通用保存');
       let object;
       if (objectId) { const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); object = await query.get(objectId, { useMasterKey: true }); } else object = new Parse.Object(className);
@@ -1996,6 +2016,7 @@ async function handler(request, response) {
       if (className === 'MisType') fail(400, 'OA 流程类型必须走专用批量流程');
       if (className === 'PageStyle') fail(400, '黄页样式必须走专用批量流程');
       if (className === 'PlatComp') fail(400, '协同办公企业必须走专用批量流程');
+      if (className === 'DesignRes') 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 } }); }
       if (className === 'ExamPoint') { const pointId = Number(object.get('id')) || 0; const companyId = pointerId(object.get('company')); const children = await Psql.one('SELECT COUNT(*)::int AS count FROM "ExamPoint" WHERE "company"=$1 AND "tid"=$2', [companyId, pointId]); if (Number(children.count)) fail(409, '考点仍有下级考点,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-exam-point', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }

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

@@ -309,6 +309,10 @@ const explicit = new Map([
   ['Extend / QuartzTask/TaskDeleteRange', ['blocked-infrastructure', '目标没有可批量注销 Quartz 作业与删除任务记录的控制面']],
   ['Extend / QuartzTask/TaskPause', ['blocked-infrastructure', '云函数环境没有常驻 Quartz 调度器或可暂停作业']],
   ['Extend / QuartzTask/TaskResume', ['blocked-infrastructure', '云函数环境没有常驻 Quartz 调度器或可恢复作业']],
+  ['Design / Resource/ResList', ['implemented', '已由 DesignRes 专用帐套列表替代,支持分页、名称/用途/路径搜索和 ztype 精确筛选']],
+  ['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 / Interactive/InteractiveList', ['blocked-data', '目标缺少旧 Design_Pub 交互提交表及 sceneid、uid、fname 数据;DesignAnswer/Pub 不等价']],
   ['Design / Interactive/Interactive_API', ['blocked-data', '目标没有可按旧 Design_Pub ID 删除的交互提交记录']],
   ['Office / iServer/Default', ['blocked-data', '目标 Schema 缺少旧 IServer 服务工单主表与回复表']],

+ 10 - 0
scripts/smoke-admin-functions.mjs

@@ -100,6 +100,8 @@ let adInfoRestore = null;
 let fontShapeRestore = null;
 let temporaryFontShapeId = '';
 let temporaryFontShapeTypeId = '';
+let designResourceRestore = null;
+let temporaryDesignResourceId = '';
 let surveyRestore = null;
 let surveyQuestionRestore = null;
 let temporarySurveyId = '';
@@ -316,6 +318,12 @@ try {
   const referencedTypeDelete = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'fontShapeTypeBatch', className: 'FontPicShapeType', action: 'delete', objectIds: [sampleFontShapeType.objectId] }, 409); if (!String(referencedTypeDelete.message).includes('素材引用')) throw new Error('图形分类引用删除保护未返回明确原因');
   const tempType = await jsonRequest(`${PARSE_URL}/classes/FontPicShapeType`, { method: 'POST', body: JSON.stringify({ name: '__font_shape_type_batch_smoke__', remarks: 'temporary', sourceKey: `smoke-font-shape-type-${Date.now()}`, company }) }, true); temporaryFontShapeTypeId = tempType.objectId; const deletedTempType = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'fontShapeTypeBatch', className: 'FontPicShapeType', action: 'delete', objectIds: [temporaryFontShapeTypeId] }); if (deletedTempType.updated !== 1) throw new Error('图形分类批量删除失败'); temporaryFontShapeTypeId = '';
   const tempShape = await jsonRequest(`${PARSE_URL}/classes/FontPicShape`, { method: 'POST', body: JSON.stringify({ name: '__font_shape_batch_smoke__', shape: '', typeId: Number(sampleFontShapeType.id), remarks: 'temporary', sourceKey: `smoke-font-shape-${Date.now()}`, company }) }, true); temporaryFontShapeId = tempShape.objectId; const deletedTempShape = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'fontShapeBatch', className: 'FontPicShape', action: 'delete', objectIds: [temporaryFontShapeId] }); if (deletedTempShape.updated !== 1) throw new Error('图形素材批量删除失败'); temporaryFontShapeId = '';
+  const designResourceSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'DesignRes' }); const designResourceFieldMap = Object.fromEntries((designResourceSchema.fields || []).map((field) => [field.name, field])); if (designResourceSchema.creatable !== false || ['id','vpath','previewimg','cdate','userid','sourceKey'].some((name) => designResourceFieldMap[name]?.writable !== false)) throw new Error('设计资源结构字段未隔离');
+  const designResources = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'designResources', className: 'DesignRes', page: 1, pageSize: 20, type: '', search: '' }); const sampleDesignResource = designResources.results?.[0]; if (!sampleDesignResource?.objectId || !sampleDesignResource.ztype || !sampleDesignResource.vpath) throw new Error('设计资源专用列表缺少历史数据'); const filteredDesignResources = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'designResources', className: 'DesignRes', page: 1, pageSize: 20, type: String(sampleDesignResource.ztype), search: '' }); if (!filteredDesignResources.results?.length || filteredDesignResources.results.some((entry) => String(entry.ztype).toLowerCase() !== String(sampleDesignResource.ztype).toLowerCase())) throw new Error('设计资源按类型筛选异常');
+  const designResourceCreateBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveDesignResource', className: 'DesignRes', fields: {} }, 501); if (!String(designResourceCreateBlocked.message).includes('文件') || !String(designResourceCreateBlocked.message).includes('DesignRes.id')) throw new Error('设计资源新增未返回明确阻塞');
+  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 surveySchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'DesignAsk' }); const surveyFieldMap = Object.fromEntries((surveySchema.fields || []).map((field) => [field.name, field])); if (surveySchema.creatable !== false || surveySchema.writable !== true || ['id','cdate','cuser','adminId','sourceKey'].some((name) => surveyFieldMap[name]?.writable !== false)) throw new Error('问卷系统字段未隔离');
   const surveyQuestionSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'DesignQuestion' }); const surveyQuestionFieldMap = Object.fromEntries((surveyQuestionSchema.fields || []).map((field) => [field.name, field])); if (surveyQuestionSchema.creatable !== false || surveyQuestionSchema.writable !== true || ['id','askId','cdate','cuser','orderId','sourceKey'].some((name) => surveyQuestionFieldMap[name]?.writable !== false)) throw new Error('问卷题目系统字段未隔离');
   const surveyAnswerSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'DesignAnswer' }); if (surveyAnswerSchema.writable !== false || surveyAnswerSchema.creatable !== false) throw new Error('问卷答卷未保持只读');
@@ -926,6 +934,8 @@ try {
   if (fontShapeRestore) await jsonRequest(`${PARSE_URL}/classes/FontPicShape/${fontShapeRestore.objectId}`, { method: 'PUT', body: JSON.stringify({ shape: fontShapeRestore.shape, remarks: fontShapeRestore.remarks, typeId: fontShapeRestore.typeId, ...(fontShapeRestore.updateTime ? { updateTime: fontShapeRestore.updateTime } : {}) }) }, true).catch((error) => { console.error(`图形素材冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
   if (temporaryFontShapeId) await jsonRequest(`${PARSE_URL}/classes/FontPicShape/${temporaryFontShapeId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时图形素材清理失败:${error.message}`); process.exitCode = 1; });
   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 (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; });
   if (surveyQuestionRestore) await jsonRequest(`${PARSE_URL}/classes/DesignQuestion/${surveyQuestionRestore.objectId}`, { method: 'PUT', body: JSON.stringify(surveyQuestionRestore.fields) }, true).catch((error) => { console.error(`问卷题目冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
   if (temporarySurveyAnswerId) await jsonRequest(`${PARSE_URL}/classes/DesignAnswer/${temporarySurveyAnswerId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时问卷答卷清理失败:${error.message}`); process.exitCode = 1; });

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

@@ -5235,29 +5235,29 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Design / Resource",
     "action": "ResList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 DesignRes 专用帐套列表替代,支持分页、名称/用途/路径搜索和 ztype 精确筛选"
   },
   {
     "module": "Design / Resource",
     "action": "ResourceAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有资源可编辑元数据并查看原文件/预览图路径;新增与文件替换受 Parse 文件适配器阻塞"
   },
   {
     "module": "Design / Resource",
     "action": "ResourceAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "saveDesignResource 已白名单更新 Name/style/use/Useage/ZStatus/ZType/fun 并保护旧 ID 与文件路径;上传明确返回 migration_blocked"
   },
   {
     "module": "Design / Resource",
     "action": "Resource_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 designResourceBatch 覆盖帐套隔离的单条与每批 1–100 条资源记录删除"
   },
   {
     "module": "User / Role",

+ 8 - 0
src/app/admin/pages/admin-resource.component.html

@@ -20,6 +20,9 @@
   @if (className() === 'FontPicShape') {
     <div class="bulk-toolbar"><strong>图形素材范围</strong><select [ngModel]="filterFontShapeTypeId()" (ngModelChange)="filterFontShapeTypeId.set(+$event); load(1)"><option [ngValue]="0">全部分类</option>@for (type of fontShapeTypes(); track type['objectId']) { <option [ngValue]="fontShapeTypeIdOf(type)">{{ fontShapeTypeLabel(type) }}</option> }</select></div>
   }
+  @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() === 'DesignAsk') {
     <div class="bulk-toolbar"><strong>问卷状态</strong><select [ngModel]="filterSurveyStatus()" (ngModelChange)="filterSurveyStatus.set(+$event); load(1)"><option [ngValue]="-100">全部状态</option><option [ngValue]="99">已启用</option><option [ngValue]="0">已停用</option></select></div>
   }
@@ -77,6 +80,8 @@
         <button type="button" [disabled]="updatingAdvertisement()" (click)="applyBulkAdvertisementAction('audit')">审核</button><button type="button" [disabled]="updatingAdvertisement()" (click)="applyBulkAdvertisementAction('unaudit')">取消审核</button><button class="danger-action" type="button" [disabled]="updatingAdvertisement()" (click)="applyBulkAdvertisementAction('delete')">删除</button>
       } @else if (className() === 'FontPicShape' || className() === 'FontPicShapeType') {
         <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() === 'DesignAsk') {
         <button type="button" [disabled]="updatingSurvey()" (click)="applyBulkSurveyAction('start')">启用</button><button type="button" [disabled]="updatingSurvey()" (click)="applyBulkSurveyAction('stop')">停用</button><button class="danger-action" type="button" [disabled]="updatingSurvey()" (click)="applyBulkSurveyAction('delete')">删除</button>
       } @else if (className() === 'DesignQuestion') {
@@ -206,6 +211,9 @@
         @if (className() === 'UserPromotion') {
           <section class="account-create-note"><strong>用户推广关系只读</strong><span>从旧用户数据的 ParentUserID 实时还原邀请人和直属推广人数;日期范围按旧逻辑统计该期间注册的直属成员,不修改已建立的邀请链。</span><code>用户 {{ draft()['userId'] }} · 邀请人 {{ draft()['parentUserName'] || '无' }}({{ draft()['parentUserId'] || 0 }})· 推广 {{ draft()['promotionCount'] || 0 }} 人</code></section>
         }
+        @if (className() === 'DesignRes') {
+          <section class="account-create-note"><strong>历史设计资源元数据</strong><span>可编辑名称、类型、用途、状态及样式/函数字段;原文件和预览图保留旧路径并只读。Parse 文件适配器修复前,新增与替换文件会明确阻断。</span><code>{{ draft()['vpath'] || '无原文件路径' }} · {{ draft()['previewimg'] || '无预览图路径' }}</code></section>
+        }
         @if (className() === 'Baike') {
           <section class="account-create-note"><strong>百科词条只读详情</strong><span>目标仅保留 Baike 主记录;可在列表执行审核和推荐状态操作。旧 BaikeEdit 版本表、版本应用/驳回链与积分奖励运行时已缺失,不提供伪版本操作。</span></section>
         }

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

@@ -642,6 +642,45 @@ describe('AdminResourceComponent', () => {
     expect(component.bulkMessage()).toContain('1 条图形分类');
   });
 
+  it('loads design resources through the dedicated type filter', async () => {
+    component.className.set('DesignRes');
+    component.schema.set({ className: 'DesignRes', label: '可视化设计资源', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.filterDesignResourceType.set('img'); component.search.set('背景');
+    functions.admin.and.resolveTo({ className: 'DesignRes', page: 2, pageSize: 20, total: 21, results: [] });
+
+    await component.load(2);
+
+    expect(functions.admin.calls.first().args).toEqual(['designResources', { className: 'DesignRes', page: 2, pageSize: 20, search: '背景', type: 'img' }]);
+  });
+
+  it('updates existing design-resource metadata through the dedicated operation', async () => {
+    component.className.set('DesignRes');
+    component.schema.set({ className: 'DesignRes', label: '可视化设计资源', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.draftId.set('design-resource-object');
+    component.draft.set({ objectId: 'design-resource-object', id: 2, name: '背景', ztype: 'img', useage: 'bk_pc', vpath: '/legacy/background.png', previewimg: '/legacy/background.thumb.png' });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveDesignResource'
+      ? { objectId: 'design-resource-object', id: 2, name: '背景', ztype: 'img', useage: 'bk_pc', vpath: '/legacy/background.png' }
+      : { className: 'DesignRes', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveDesignResource', {
+      className: 'DesignRes', objectId: 'design-resource-object', fields: { objectId: 'design-resource-object', id: 2, name: '背景', ztype: 'img', useage: 'bk_pc', vpath: '/legacy/background.png', previewimg: '/legacy/background.thumb.png' },
+    }]);
+  });
+
+  it('deletes selected design resources through the dedicated batch operation', async () => {
+    component.className.set('DesignRes'); component.selectedIds.set(['design-resource-a']); spyOn(window, 'confirm').and.returnValue(true);
+    functions.admin.and.callFake(async (operation: string) => operation === 'designResourceBatch'
+      ? { action: 'delete', updated: 1, results: [] }
+      : { className: 'DesignRes', page: 1, pageSize: 20, total: 0, results: [] });
+
+    await component.applyBulkDesignResourceDelete();
+
+    expect(functions.admin.calls.first().args).toEqual(['designResourceBatch', { className: 'DesignRes', action: 'delete', objectIds: ['design-resource-a'] }]);
+    expect(component.bulkMessage()).toContain('1 个设计资源');
+  });
+
   it('loads survey questions through the selected legacy survey id', async () => {
     component.className.set('DesignQuestion');
     component.schema.set({ className: 'DesignQuestion', label: '问卷题目', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });

+ 17 - 2
src/app/admin/pages/admin-resource.component.ts

@@ -128,6 +128,8 @@ export class AdminResourceComponent implements OnInit {
   readonly fontShapeTypes = signal<Record<string, unknown>[]>([]);
   readonly fontShapePreview = signal('');
   readonly updatingFontShape = signal(false);
+  readonly filterDesignResourceType = signal('');
+  readonly updatingDesignResource = signal(false);
   readonly filterSurveyStatus = signal(-100);
   readonly filterSurveyId = signal(0);
   readonly surveys = signal<Record<string, unknown>[]>([]);
@@ -180,7 +182,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', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer', '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', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer', 'DesignRes', '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));
@@ -197,6 +199,7 @@ export class AdminResourceComponent implements OnInit {
       this.filterSearchNavigationType.set(1); this.filterSearchNavigationState.set(-100); this.filterSearchNavigationElite.set(-100); this.searchNavigationOrderStart.set(1);
       this.filterAdZoneType.set(''); this.filterAdStatus.set(-100); this.filterAdInfoZoneId.set(''); this.resetAdActions();
       this.filterFontShapeTypeId.set(0); this.resetFontShapeActions();
+      this.filterDesignResourceType.set('');
       this.filterSurveyStatus.set(-100); this.filterSurveyId.set(0); this.resetSurveyActions();
       this.resetServiceActions();
       this.filterStoreStatus.set(-100); this.resetStoreActions();
@@ -218,13 +221,14 @@ 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() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : this.className() === 'FontPicShape' ? 'fontShapes' : this.className() === 'FontPicShapeType' ? 'fontShapeTypes' : 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() === '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 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(); }
       if (pageOperation === 'adZones') { pagePayload['type'] = this.filterAdZoneType(); pagePayload['status'] = this.filterAdStatus(); }
       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 === 'surveys') pagePayload['status'] = this.filterSurveyStatus();
       if (pageOperation === 'surveyQuestions' || pageOperation === 'surveyResults') pagePayload['askId'] = this.filterSurveyId();
       if (pageOperation === 'storeApplications') pagePayload['status'] = this.filterStoreStatus();
@@ -307,6 +311,7 @@ export class AdminResourceComponent implements OnInit {
                                             : this.className() === 'AdInfo' ? 'saveAdInfo'
                                               : this.className() === 'FontPicShape' ? 'saveFontShape'
                                                 : this.className() === 'FontPicShapeType' ? 'saveFontShapeType'
+                                                  : this.className() === 'DesignRes' ? 'saveDesignResource'
                                                   : this.className() === 'DesignAsk' ? 'saveSurvey'
                                                     : this.className() === 'DesignQuestion' ? 'saveSurveyQuestion'
                                                       : this.className() === 'ServiceSeat' ? 'saveServiceSeat'
@@ -718,6 +723,15 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : '图形删除失败'); }
     finally { this.updatingFontShape.set(false); }
   }
+  async applyBulkDesignResourceDelete(): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'DesignRes' || !objectIds.length) { this.bulkError.set('请先选择设计资源'); return; }
+    if (!confirm(`确认删除选中的 ${objectIds.length} 个设计资源记录?历史资源文件保持原路径,不执行旧服务器文件删除。`)) return;
+    this.updatingDesignResource.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('designResourceBatch', { className: 'DesignRes', action: 'delete', objectIds }); await this.load(); this.bulkMessage.set(`已删除 ${result.updated} 个设计资源记录`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '设计资源删除失败'); }
+    finally { this.updatingDesignResource.set(false); }
+  }
   async applyBulkSurveyAction(action: 'start' | 'stop' | 'sort' | 'delete'): Promise<void> {
     const className = this.className(); const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
     const allowed = className === 'DesignAsk' ? ['start', 'stop', 'delete'] : className === 'DesignQuestion' ? ['sort', 'delete'] : className === 'DesignAnswer' ? ['delete'] : [];
@@ -856,6 +870,7 @@ export class AdminResourceComponent implements OnInit {
       else if (this.className() === 'AdInfo') await this.functions.admin('adInfoBatch', { className: 'AdInfo', action: 'delete', objectIds: [objectId] });
       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 (['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] });
       else if (['StoreApplication', 'StoreStyle'].includes(this.className())) await this.functions.admin('storeBatch', { className: this.className(), action: 'delete', objectIds: [objectId] });