Parcourir la source

feat: migrate font shape management

彭峰 il y a 1 mois
Parent
commit
5a74aba8f5

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

@@ -40,8 +40,9 @@
 - 旧 `Config/SearchFunc/SearchFunc_API/SearchFuncAdd/SearchFuncAdd_Submit` 已映射到目标 `Search`(后台快捷入口,非内容搜索日志):`searchNavigations` 按管理后台/用户中心类型、启停和推荐状态筛选,`saveSearchNavigation` 白名单维护名称、站内路径、图标、打开方式、移动端、尺寸、颜色、用户组和推荐级别,`searchNavigationBatch` 覆盖启停、推荐/取消推荐、排序和删除。目标兼容层只能在不指定字段时还原旧数据;可回滚的临时创建—读取—删除实测证实 `sourceKey` 无法还原保留名为 `id` 的数字业务键,因此 Angular 隐藏新增,云函数返回 501。旧 `starturl` 依赖不存在的 `SiteConfig.SiteOption.Admin_StartUrl` 持久层,同样明确 501,未以浏览器本地存储伪造全局配置。
 - 旧 `Config/APIInfo/ReadFile` 页面的文档功能已迁为 Angular 云函数接口目录:`apiCatalog` 从实时 `Function` 元数据安全返回名称、路径、描述、参数、响应类型和更新时间,不暴露 `code`;界面可搜索并复制路径。`FontIcon` 替换为完整 Lucide 图标库的搜索、分页和点击复制页。旧 API 密钥/IP 策略、Swagger 开关、协议文件、进程关闭、`KeyboardConfig/Hotkey` XML 快捷键与 `PrizeConfig/PrizeTask` 奖励规则在目标 Schema/云函数运行时均无等价执行层;`legacyConfigBlocker` 对全部 11 个旧请求返回逐项 501 原因,避免伪保存。
 - 旧广告后台 `Extend/AD/ZoneManage/Zone_API/InfoManage/Info_API` 已映射到目标 `AdZone/AdInfo`:`adZones/adInfos` 支持帐套隔离、关键词、广告位类型/归属和启停/审核状态筛选;`saveAdZone/saveAdInfo` 对现有记录白名单维护版位、素材、链接、有效期、权重和状态,`adZoneBatch/adInfoBatch` 覆盖每批 1–100 条启停、审核、取消审核和删除,删除广告位前会阻止仍被广告内容引用。Angular 新增广告位、广告内容导航和当前生效广告预览;旧 HTML 仅作为源码文本展示,不在管理端执行脚本。可回滚创建探测证明 `AdZone.id/AdInfo.id` 被 Parse 当作保留字段拒绝,省略 `id` 又不会生成旧编号,因此两个新增分支明确返回 501,现有编辑分支继续可用。
+- 旧字体图后台的 `Shape/ShapeInfo/ShapeInfo_Submit/Shape_API/ShapeType/ShapeTypeInfo/ShapeTypeInfo_Submit/ShapeType_API` 已映射到目标 `FontPicShape/FontPicShapeType`:专用列表支持帐套、关键词和分类筛选;现有素材可维护名称、备注、分类并安全预览原路径,现有分类可维护名称和备注,删除分类前检查素材引用,素材/分类批量删除限制每次 1–100 条。可回滚探测证明省略 `id` 创建不会生成旧编号,因此新增素材/分类明确 501。图片替换也明确列为基础设施阻塞:线上实测 `Parse.File` SDK 与 `/parse/files` REST 端点均返回“要求 mongodb:// 或 mongodb+srv:// 连接串”,说明当前 PostgreSQL 部署的 Parse 文件存储适配器配置错误;Angular 不展示失效上传控件,云函数收到 `file` 时返回 501,避免伪成功。线上完整 Schema 核验只有 `FontPicShape/FontPicShapeType/UserSIcon`,其中 `UserSIcon` 是积分流水而非字体图;旧 `FontPic` 成品图和 `FontPicDraft` 草稿主表及数据均不存在,其 8 个动作由 `legacyFontPicBlocker` 返回逐项数据阻塞原因。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 73 项已实现、41 项部分实现、115 项数据阻塞、203 项基础设施阻塞、610 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 77 项已实现、45 项部分实现、123 项数据阻塞、203 项基础设施阻塞、594 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

+ 44 - 3
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: '百科', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', Agency: '代理机构', DeliveryCenter: '交付中心'
+  GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', FontPicShape: '字体图形素材', FontPicShapeType: '字体图形分类', 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','UserSIcon','UserUserPoint','UserExpDomP','UserExpHis'
@@ -45,7 +45,9 @@ const CLASS_SYSTEM_FIELDS = {
   SysHoliday: new Set(['id','cdate','cadminId','cuserId','sourceKey']),
   Search: new Set(['id','type','state','time','adminId','orderId','linkType','linkState','sourceKey']),
   AdZone: new Set(['id','cdate','cadmin','image','sourceKey']),
-  AdInfo: new Set(['id','cdate','zoneId','ztype','extend1','extend2','extend3','sourceKey'])
+  AdInfo: new Set(['id','cdate','zoneId','ztype','extend1','extend2','extend3','sourceKey']),
+  FontPicShape: new Set(['id','shape','typeId','userId','userName','sourceKey','createTime','updateTime']),
+  FontPicShapeType: new Set(['id','sourceKey','createTime','updateTime'])
 };
 
 function inputOf(request) {
@@ -1052,6 +1054,43 @@ async function handler(request, response) {
       const infoFields = await schemaFor('AdInfo'); const infoQuery = new Parse.Query('AdInfo'); applyTenant(infoQuery, infoFields, context, input.companyId); infoQuery.limit(1000); const now = Date.now(); let results = await infoQuery.find({ useMasterKey: true }); results = results.filter((entry) => String(entry.get('zoneId') || '') === zoneId && Number(entry.get('zstatus')) === 99 && (!entry.get('sdate') || new Date(entry.get('sdate')).getTime() <= now) && (!entry.get('edate') || new Date(entry.get('edate')).getTime() >= now)); results.sort((left, right) => Number(right.get('priority') || 0) - Number(left.get('priority') || 0) || Number(left.get('id') || 0) - Number(right.get('id') || 0));
       return response.json({ success: true, data: { zone: serializeObject(zone), total: results.length, results: results.map(serializeObject) } });
     }
+    if (operation === 'fontShapeTypes') {
+      const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(1000, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim().toLowerCase(); if (search.length > 100) fail(400, '图形分类搜索词过长');
+      const fields = await schemaFor('FontPicShapeType'); const query = new Parse.Query('FontPicShapeType'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => !search || [entry.get('name'),entry.get('remarks')].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: 'FontPicShapeType', page, pageSize, total, results: results.map(serializeObject) } });
+    }
+    if (operation === 'fontShapes') {
+      const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const typeId = Number(input.typeId || 0); const search = String(input.search || '').trim().toLowerCase(); if (!Number.isInteger(typeId) || typeId < 0 || search.length > 100) fail(400, '图形素材筛选条件无效');
+      const fields = await schemaFor('FontPicShape'); const query = new Parse.Query('FontPicShape'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => (!typeId || Number(entry.get('typeId')) === typeId) && (!search || [entry.get('name'),entry.get('remarks'),entry.get('userName')].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: 'FontPicShape', page, pageSize, total, results: results.map(serializeObject) } });
+    }
+    if (operation === 'saveFontShapeType') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; if (!objectId) fail(501, 'migration_blocked: FontPicShapeType.id 与 Parse 保留 id 冲突,且省略 id 创建不会生成旧分类编号,不能安全新增图形分类');
+      const name = String(payload.name || '').trim(); const remarks = String(payload.remarks || '').trim(); if (!name || name.length > 100 || remarks.length > 2000) fail(400, '图形分类名称或备注无效');
+      const fields = await schemaFor('FontPicShapeType'); const query = new Parse.Query('FontPicShapeType'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); target.set('name', name); target.set('remarks', remarks); target.set('updateTime', 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-font-shape-type', 'FontPicShapeType', target.id); return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'saveFontShape') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; if (!objectId) fail(501, 'migration_blocked: FontPicShape.id 与 Parse 保留 id 冲突,且省略 id 创建不会生成旧素材编号,不能安全新增图形素材');
+      if (input.file) fail(501, 'migration_blocked: 当前 Parse Server 的 SDK 与 /files REST 接口均错误调用 MongoDB 文件适配器,而本项目使用 PostgreSQL;修复服务端文件存储配置前不能替换图形文件');
+      const name = String(payload.name || '').trim(); const remarks = String(payload.remarks || '').trim(); const typeId = Number(input.typeId == null ? payload.typeId || 0 : input.typeId); if (!name || name.length > 100 || remarks.length > 2000 || !Number.isInteger(typeId) || typeId < 1) fail(400, '图形素材名称、分类或备注无效');
+      const typeFields = await schemaFor('FontPicShapeType'); const typeQuery = new Parse.Query('FontPicShapeType'); applyTenant(typeQuery, typeFields, context, input.companyId); typeQuery.limit(1000); const types = await typeQuery.find({ useMasterKey: true }); if (!types.some((entry) => Number(entry.get('id')) === typeId)) fail(404, '目标图形分类不存在或不属于当前帐套');
+      const fields = await schemaFor('FontPicShape'); const query = new Parse.Query('FontPicShape'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
+      target.set('name', name); target.set('typeId', typeId); target.set('remarks', remarks); target.set('updateTime', 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-font-shape', 'FontPicShape', target.id); return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'fontShapeTypeBatch') {
+      if (String(input.className || '') !== 'FontPicShapeType' || 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('FontPicShapeType'); const query = new Parse.Query('FontPicShapeType'); 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, '部分图形分类不存在或不属于当前帐套'); const typeIds = new Set(targets.map((entry) => Number(entry.get('id'))).filter((value) => value > 0));
+      if (typeIds.size) { const shapeFields = await schemaFor('FontPicShape'); const refsQuery = new Parse.Query('FontPicShape'); applyTenant(refsQuery, shapeFields, context, input.companyId); refsQuery.limit(1000); const refs = await refsQuery.find({ useMasterKey: true }); if (refs.length >= 1000) fail(409, '图形素材数量超过安全核验上限,不能删除分类'); if (refs.some((entry) => typeIds.has(Number(entry.get('typeId'))))) fail(409, '图形分类仍有素材引用,不能删除'); }
+      await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'delete-font-shape-type', 'FontPicShapeType', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
+    }
+    if (operation === 'fontShapeBatch') {
+      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 === '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]);
+    }
     if (operation === 'saveExamClass') {
       const objectId = String(input.objectId || '');
       const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
@@ -1315,7 +1354,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'].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'].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));
@@ -1351,6 +1390,7 @@ async function handler(request, response) {
       if (className === 'SysHoliday') fail(400, '节假日必须走专用保存流程');
       if (className === 'Search') fail(400, '快捷入口必须走专用保存流程');
       if (className === 'AdZone' || className === 'AdInfo') fail(400, '广告位与广告内容必须走专用保存流程');
+      if (className === 'FontPicShape' || className === 'FontPicShapeType') 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);
@@ -1364,6 +1404,7 @@ async function handler(request, response) {
       if (!classWritable || !objectId) fail(400, '该记录不允许删除'); const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); const object = await query.get(objectId, { useMasterKey: true });
       if (className === 'Model' || className === 'ModelField') fail(501, 'migration_blocked: 删除模型或字段需要同步删除 PostgreSQL 物理表/列并验证历史数据,托管云函数中未开放此 DDL 流程');
       if (className === 'AdZone' || className === 'AdInfo') fail(400, '广告位与广告内容必须走专用批量删除流程');
+      if (className === 'FontPicShape' || className === 'FontPicShapeType') 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 } }); }

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

@@ -145,6 +145,22 @@ const explicit = new Map([
   ['Extend / AD/InfoAdd', ['partial', '现有广告编辑和广告位选择已迁移;新增因旧 id 不可持久化而阻塞']],
   ['Extend / AD/InfoAdd_Submit', ['partial', '现有广告由 saveAdInfo 校验广告位、时间、权重和内容后更新;新增明确阻塞']],
   ['Extend / AD/ADPreView', ['partial', '已提供当前生效广告预览;旧 HTML 只显示源码而不执行脚本,避免管理端 XSS']],
+  ['FontPic/Index', ['blocked-data', '目标 Schema 缺少旧 FontPic 成品字体图主表与历史数据']],
+  ['FontPic/FontPicInfo', ['blocked-data', '目标 Schema 缺少旧 FontPic 成品字体图详情数据']],
+  ['FontPic/FontPicInfo_Submit', ['blocked-data', '目标 Schema 缺少旧 FontPic 成品字体图持久化模型']],
+  ['FontPic/FontPic_API', ['blocked-data', '目标 Schema 缺少旧 FontPic 成品字体图可删除记录']],
+  ['FontPic/Shape', ['implemented', '已由 fontShapes 按帐套、分类和关键词分页筛选图形素材']],
+  ['FontPic/ShapeInfo', ['partial', '现有图形素材编辑、分类选择和安全预览已迁移;新增因旧 id 不可持久化而阻塞']],
+  ['FontPic/ShapeInfo_Submit', ['partial', '现有素材由 saveFontShape 更新元数据;新增受旧 id 阻塞,图片替换受 Parse 文件适配器错误阻塞']],
+  ['FontPic/Shape_API', ['implemented', '已由 fontShapeBatch 覆盖每批 1–100 条帐套隔离删除']],
+  ['FontPic/ShapeType', ['implemented', '已由 fontShapeTypes 帐套隔离分类列表替代']],
+  ['FontPic/ShapeTypeInfo', ['partial', '现有图形分类编辑已迁移;新增因旧 id 不可持久化而阻塞']],
+  ['FontPic/ShapeTypeInfo_Submit', ['partial', '现有分类由 saveFontShapeType 白名单更新;新增明确返回 migration_blocked']],
+  ['FontPic/ShapeType_API', ['implemented', '已覆盖分类列表和引用保护删除']],
+  ['FontPic/Draft', ['blocked-data', '目标 Schema 缺少旧 FontPicDraft 草稿表与历史数据']],
+  ['FontPic/DraftInfo', ['blocked-data', '目标 Schema 缺少旧 FontPicDraft 草稿详情数据']],
+  ['FontPic/DraftInfo_Submit', ['blocked-data', '目标 Schema 缺少旧 FontPicDraft 草稿持久化模型']],
+  ['FontPic/Draft_API', ['blocked-data', '目标 Schema 缺少旧 FontPicDraft 可删除记录']],
 ]);
 
 const blockedDataModules = new Map([

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

@@ -97,6 +97,9 @@ let contentHitObjectId = '';
 let contentHitOriginal = 0;
 let adZoneRestore = null;
 let adInfoRestore = null;
+let fontShapeRestore = null;
+let temporaryFontShapeId = '';
+let temporaryFontShapeTypeId = '';
 const temporaryLessonIds = [];
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
@@ -276,6 +279,20 @@ try {
   if (Number(toggledInfo.results?.[0]?.zstatus) !== (infoToggleAction === 'audit' ? 99 : 0)) throw new Error('广告内容审核批量操作异常');
   await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveAdInfo', className: 'AdInfo', objectId: sampleAdInfo.objectId, zoneId: String(sampleAdInfo.zoneId), fields: { ...sampleAdInfo, remind: adInfoRestore.remind, zstatus: adInfoRestore.zstatus } }); adInfoRestore = null;
   const adPreview = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'adPreview', zoneObjectId: sampleAdZone.objectId }); if (!adPreview.zone?.objectId || !Array.isArray(adPreview.results)) throw new Error('广告位当前生效广告预览异常');
+  const fontShapeTypeSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'FontPicShapeType' }); const fontShapeTypeFieldMap = Object.fromEntries((fontShapeTypeSchema.fields || []).map((field) => [field.name, field])); if (fontShapeTypeSchema.creatable !== false || ['id','sourceKey','createTime','updateTime'].some((name) => fontShapeTypeFieldMap[name]?.writable !== false)) throw new Error('图形分类系统字段未隔离');
+  const fontShapeSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'FontPicShape' }); const fontShapeFieldMap = Object.fromEntries((fontShapeSchema.fields || []).map((field) => [field.name, field])); if (fontShapeSchema.creatable !== false || ['id','shape','typeId','userId','userName','sourceKey','createTime','updateTime'].some((name) => fontShapeFieldMap[name]?.writable !== false)) throw new Error('图形素材系统字段未隔离');
+  const fontShapeTypes = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'fontShapeTypes', className: 'FontPicShapeType', page: 1, pageSize: 1000, search: '' }); if (!fontShapeTypes.results?.length) throw new Error('图形分类列表缺少历史数据');
+  const allFontShapes = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'fontShapes', className: 'FontPicShape', page: 1, pageSize: 20, typeId: 0, search: '' }); const sampleFontShape = allFontShapes.results?.[0]; const sampleFontShapeType = fontShapeTypes.results.find((entry) => Number(entry.id) === Number(sampleFontShape?.typeId)); if (!sampleFontShape?.objectId || !sampleFontShapeType?.objectId) throw new Error('图形素材与分类历史关系异常'); const filteredFontShapes = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'fontShapes', className: 'FontPicShape', page: 1, pageSize: 20, typeId: Number(sampleFontShapeType.id), search: '' }); if (!filteredFontShapes.results?.length || filteredFontShapes.results.some((entry) => Number(entry.typeId) !== Number(sampleFontShapeType.id))) throw new Error('图形素材按分类筛选异常');
+  const fontShapeCreateBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveFontShape', className: 'FontPicShape', fields: {} }, 501); const fontShapeTypeCreateBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveFontShapeType', className: 'FontPicShapeType', fields: {} }, 501); if (!String(fontShapeCreateBlocked.message).includes('FontPicShape.id') || !String(fontShapeTypeCreateBlocked.message).includes('FontPicShapeType.id')) throw new Error('图形素材新增未返回旧 ID 阻塞原因');
+  for (const action of ['Index','FontPicInfo','FontPicInfo_Submit','FontPic_API','Draft','DraftInfo','DraftInfo_Submit','Draft_API']) { const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyFontPicBlocker', action }, 501); if (!String(blocked.message).startsWith('migration_blocked:')) throw new Error(`旧字体图动作 ${action} 未返回明确阻塞原因`); }
+  fontShapeRestore = { objectId: sampleFontShape.objectId, shape: String(sampleFontShape.shape || ''), remarks: String(sampleFontShape.remarks || ''), typeId: Number(sampleFontShape.typeId), updateTime: sampleFontShape.updateTime };
+  const shapeProbeRemarks = `${fontShapeRestore.remarks} [angular-smoke]`.trim(); const updatedShape = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveFontShape', className: 'FontPicShape', objectId: sampleFontShape.objectId, typeId: fontShapeRestore.typeId, fields: { ...sampleFontShape, remarks: shapeProbeRemarks } }); if (updatedShape.remarks !== shapeProbeRemarks || Number(updatedShape.typeId) !== fontShapeRestore.typeId || String(updatedShape.shape || '') !== fontShapeRestore.shape) throw new Error('图形素材元数据专用编辑异常');
+  const fileBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveFontShape', className: 'FontPicShape', objectId: sampleFontShape.objectId, typeId: fontShapeRestore.typeId, fields: { ...sampleFontShape, remarks: shapeProbeRemarks }, file: { name: 'font-shape-smoke.png', mimeType: 'image/png', base64: 'iVBORw0KGgo=' } }, 501); if (!String(fileBlocked.message).includes('文件适配器') || !String(fileBlocked.message).includes('PostgreSQL')) throw new Error('图形文件基础设施阻塞未返回明确原因');
+  const shapeRestoreBody = { shape: fontShapeRestore.shape, remarks: fontShapeRestore.remarks, typeId: fontShapeRestore.typeId, ...(fontShapeRestore.updateTime ? { updateTime: fontShapeRestore.updateTime } : {}) }; await jsonRequest(`${PARSE_URL}/classes/FontPicShape/${fontShapeRestore.objectId}`, { method: 'PUT', body: JSON.stringify(shapeRestoreBody) }, true); fontShapeRestore = null;
+  const originalTypeRemarks = String(sampleFontShapeType.remarks || ''); const updatedShapeType = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveFontShapeType', className: 'FontPicShapeType', objectId: sampleFontShapeType.objectId, fields: { ...sampleFontShapeType, remarks: `${originalTypeRemarks} [angular-smoke]`.trim() } }); if (!String(updatedShapeType.remarks).endsWith('[angular-smoke]')) throw new Error('图形分类专用编辑失败'); await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveFontShapeType', className: 'FontPicShapeType', objectId: sampleFontShapeType.objectId, fields: { ...sampleFontShapeType, remarks: originalTypeRemarks } });
+  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 = '';
   await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'save', className: 'Node', fields: { nodeName: '禁止通用新增' } }, 400);
   const nodeSuffix = Date.now().toString(36);
   const rootNode = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveNode', className: 'Node', parentId: 0, fields: { nodeName: `云函数冒烟栏目-${nodeSuffix}`, nodeDir: `smoke-${nodeSuffix}`, nodeType: 1, contentModel: '54' } });
@@ -711,10 +728,13 @@ try {
   const productBlocked = await callLegacyFunction('', { action: 'product_list' }, 501);
   if (!String(productBlocked.retmsg).includes('ZL_Commodities')) throw new Error('缺商品主表的读取接口未明确阻塞');
 
-  console.log('Cloud smoke passed: admin auth/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/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/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/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; });
+  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 (contentHitObjectId) await jsonRequest(`${PARSE_URL}/classes/CommonModel/${contentHitObjectId}`, { method: 'PUT', body: JSON.stringify({ hits: contentHitOriginal }) }, true).catch((error) => {
     console.error(`公开内容浏览量恢复失败:${error.message}`);
     process.exitCode = 1;

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

@@ -35,6 +35,8 @@ export const ADMIN_NAVIGATION = [
   { label: '后台快捷入口', icon: 'panels-top-left', route: '/admin/resources/Search' },
   { label: '广告位管理', icon: 'gallery-horizontal-end', route: '/admin/resources/AdZone' },
   { label: '广告内容管理', icon: 'megaphone', route: '/admin/resources/AdInfo' },
+  { label: '字体图形素材', icon: 'shapes', route: '/admin/resources/FontPicShape' },
+  { label: '字体图形分类', icon: 'tags', route: '/admin/resources/FontPicShapeType' },
   { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '迁移状态', icon: 'route', route: '/admin/migration' },
 ] as const;

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

@@ -24,6 +24,7 @@ import {
   Search,
   Shapes,
   TableProperties,
+  Tags,
   Users,
 } from 'lucide-angular';
 import { ADMIN_NAVIGATION } from '../admin.constants';
@@ -44,7 +45,7 @@ export class AdminShellComponent {
   readonly icons = {
     Bell, BookOpen, Braces, CalendarDays, ChartNoAxesCombined, ChevronLeft, ChevronRight,
     ClipboardCheck, Files, GalleryHorizontalEnd, GraduationCap, Languages, LayoutDashboard, ListChecks,
-    LogOut, Megaphone, Menu, Route, Search, Shapes, TableProperties, Users,
+    LogOut, Megaphone, Menu, Route, Search, Shapes, TableProperties, Tags, Users,
   };
 
   constructor(readonly sessions: AdminSessionService) {}

+ 5 - 0
src/app/admin/legacy-admin-coverage.generated.spec.ts

@@ -61,6 +61,11 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Extend / AD/Info_API')?.status).toBe('implemented');
     expect(byKey.get('Extend / AD/InfoAdd_Submit')?.status).toBe('partial');
     expect(byKey.get('Extend / AD/ADPreView')?.status).toBe('partial');
+    expect(byKey.get('FontPic/Index')?.status).toBe('blocked-data');
+    expect(byKey.get('FontPic/Shape')?.status).toBe('implemented');
+    expect(byKey.get('FontPic/ShapeInfo_Submit')?.status).toBe('partial');
+    expect(byKey.get('FontPic/ShapeType_API')?.status).toBe('implemented');
+    expect(byKey.get('FontPic/Draft_API')?.status).toBe('blocked-data');
     expect(byKey.get('Exam/Teacher_Add')?.status).toBe('implemented');
     expect(byKey.get('User/User_API')?.status).toBe('partial');
     expect(byKey.get('Order/OrderList')?.status).toBe('blocked-data');

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

@@ -2540,113 +2540,113 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "FontPic",
     "action": "Index",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 FontPic 成品字体图主表与历史数据"
   },
   {
     "module": "FontPic",
     "action": "FontPicInfo",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 FontPic 成品字体图详情数据"
   },
   {
     "module": "FontPic",
     "action": "FontPicInfo_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 FontPic 成品字体图持久化模型"
   },
   {
     "module": "FontPic",
     "action": "FontPic_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 FontPic 成品字体图可删除记录"
   },
   {
     "module": "FontPic",
     "action": "Shape",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 fontShapes 按帐套、分类和关键词分页筛选图形素材"
   },
   {
     "module": "FontPic",
     "action": "ShapeInfo",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有图形素材编辑、分类选择和安全预览已迁移;新增因旧 id 不可持久化而阻塞"
   },
   {
     "module": "FontPic",
     "action": "ShapeInfo_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有素材由 saveFontShape 更新元数据;新增受旧 id 阻塞,图片替换受 Parse 文件适配器错误阻塞"
   },
   {
     "module": "FontPic",
     "action": "Shape_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 fontShapeBatch 覆盖每批 1–100 条帐套隔离删除"
   },
   {
     "module": "FontPic",
     "action": "ShapeType",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 fontShapeTypes 帐套隔离分类列表替代"
   },
   {
     "module": "FontPic",
     "action": "ShapeTypeInfo",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有图形分类编辑已迁移;新增因旧 id 不可持久化而阻塞"
   },
   {
     "module": "FontPic",
     "action": "ShapeTypeInfo_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有分类由 saveFontShapeType 白名单更新;新增明确返回 migration_blocked"
   },
   {
     "module": "FontPic",
     "action": "ShapeType_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已覆盖分类列表和引用保护删除"
   },
   {
     "module": "FontPic",
     "action": "Draft",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 FontPicDraft 草稿表与历史数据"
   },
   {
     "module": "FontPic",
     "action": "DraftInfo",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 FontPicDraft 草稿详情数据"
   },
   {
     "module": "FontPic",
     "action": "DraftInfo_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 FontPicDraft 草稿持久化模型"
   },
   {
     "module": "FontPic",
     "action": "Draft_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 FontPicDraft 可删除记录"
   },
   {
     "module": "Content / Guest",

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

@@ -17,6 +17,9 @@
   @if (className() === 'AdInfo') {
     <div class="bulk-toolbar"><strong>广告内容范围</strong><select [ngModel]="filterAdInfoZoneId()" (ngModelChange)="filterAdInfoZoneId.set($event); load(1)"><option value="">全部广告位</option>@for (zone of adZones(); track zone['objectId']) { <option [value]="adZoneIdOf(zone)">{{ adZoneLabel(zone) }}</option> }</select><select [ngModel]="filterAdStatus()" (ngModelChange)="filterAdStatus.set(+$event); load(1)"><option [ngValue]="-100">全部状态</option><option [ngValue]="99">已审核</option><option [ngValue]="0">未审核</option></select></div>
   }
+  @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 (bulkEnabled() && selectedIds().length) {
     <div class="bulk-toolbar"><strong>已选 {{ selectedIds().length }} 条</strong>
       @if (className() === '_User') {
@@ -47,6 +50,8 @@
         <button type="button" [disabled]="updatingAdvertisement()" (click)="applyBulkAdvertisementAction('active')">启用</button><button type="button" [disabled]="updatingAdvertisement()" (click)="applyBulkAdvertisementAction('pause')">停用</button><button class="danger-action" type="button" [disabled]="updatingAdvertisement()" (click)="applyBulkAdvertisementAction('delete')">删除</button>
       } @else if (className() === 'AdInfo') {
         <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 {
         <button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('audit')">审核</button><button class="danger-action" type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('recycle')">回收</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('recover')">恢复</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('elite')">加精</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('top-section')">版块置顶</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('top-global')">全局置顶</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('top-undo')">取消排序</button>
         <input class="bulk-node" type="number" min="1" [ngModel]="targetBarCategoryId()" (ngModelChange)="targetBarCategoryId.set(+$event)" placeholder="版块 ID" /><button type="button" [disabled]="updatingBar() || !targetBarCategoryId()" (click)="applyBulkBarAction('move')">移动版块</button>
@@ -94,6 +99,9 @@
         @if (className() === 'AdZone' || className() === 'AdInfo') {
           <section class="account-create-note"><strong>现有{{ className() === 'AdZone' ? '广告位' : '广告内容' }}编辑</strong><span>旧数字 ID 与 Parse 保留字段冲突,创建探测不会生成旧编号,因此当前只允许安全编辑现有记录;新增请求会返回明确迁移阻塞。</span></section>
         }
+        @if (className() === 'FontPicShape' || className() === 'FontPicShapeType') {
+          <section class="account-create-note"><strong>现有{{ className() === 'FontPicShape' ? '图形素材' : '图形分类' }}编辑</strong><span>目标兼容层无法生成旧数字 ID,因此仅维护现有记录;新增请求返回明确迁移阻塞。成品字体图和草稿没有目标 Schema,已单独列入数据阻塞。</span></section>
+        }
         @for (field of schema()?.fields || []; track field.name) { @if (field.writable) {
           <label><span>{{ field.name }} <small>{{ field.type }}{{ field.required ? ' · 必填' : '' }}</small></span>
             @if (field.type === 'Boolean') { <input type="checkbox" [ngModel]="draft()[field.name] === true" (ngModelChange)="setField(field, $event)" /> }
@@ -115,6 +123,14 @@
             <div class="ad-preview-list">@for (ad of adPreview(); track ad['objectId']) { <article><strong>{{ ad['name'] }}</strong><span>权重 {{ ad['priority'] || 0 }} · {{ display(ad['sdate']) }} 至 {{ display(ad['edate']) }}</span>@if (ad['image'] || ad['images']) { <code>{{ ad['image'] || ad['images'] }}</code> }@if (ad['text']) { <p>{{ ad['text'] }}</p> }@if (ad['html']) { <pre>{{ ad['html'] }}</pre> }</article> } @empty { @if (!adPreviewLoading() && !adPreviewError()) { <div class="content-action-status">当前没有生效广告</div> } }</div>
           </section>
         }
+        @if (className() === 'FontPicShape') {
+          <section class="content-controls">
+            <header><div><strong>图形素材与分类</strong><span>可维护名称、备注和分类;原图路径只读,图片替换等待服务端文件存储适配器修复</span></div>@if (draftId()) { <b>ID {{ draft()['id'] }}</b> }</header>
+            <label><span>所属图形分类</span><select [ngModel]="targetFontShapeTypeId()" (ngModelChange)="targetFontShapeTypeId.set(+$event)"><option [ngValue]="0">请选择分类</option>@for (type of fontShapeTypes(); track type['objectId']) { <option [ngValue]="fontShapeTypeIdOf(type)">{{ fontShapeTypeLabel(type) }}</option> }</select></label>
+            @if (fontShapePreview()) { <div><img [src]="fontShapePreview()" alt="图形素材预览" width="120" height="120" /><code>{{ fontShapePreview() }}</code></div> }
+            <div class="content-action-status error">图片替换暂不可用:当前 Parse Server 的 SDK 与 /files REST 接口都错误要求 MongoDB 连接串;本项目数据库为 PostgreSQL。云函数会对此返回明确 501,不会伪装上传成功。</div>
+          </section>
+        }
         @if (className() === 'CommonModel' && draftId()) {
           <section class="content-controls">
             <header><div><strong>内容审核与归档</strong><span>专用云函数同步旧 ContentManage 状态与节点移动语义</span></div><b [class.recycled]="contentStatusValue() === -2">{{ contentStatusLabel(contentStatusValue()) }}</b></header>

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

@@ -603,6 +603,45 @@ describe('AdminResourceComponent', () => {
     expect(component.adPreview()[0]['html']).toContain('<script>');
   });
 
+  it('loads font-shape materials through the dedicated category filter', async () => {
+    component.className.set('FontPicShape');
+    component.schema.set({ className: 'FontPicShape', label: '字体图形素材', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.filterFontShapeTypeId.set(2); component.search.set('鹿');
+    functions.admin.and.resolveTo({ className: 'FontPicShape', page: 2, pageSize: 20, total: 21, results: [] });
+
+    await component.load(2);
+
+    expect(functions.admin.calls.first().args).toEqual(['fontShapes', { className: 'FontPicShape', page: 2, pageSize: 20, search: '鹿', typeId: 2 }]);
+  });
+
+  it('updates existing font-shape metadata without sending the blocked file field', async () => {
+    component.className.set('FontPicShape');
+    component.schema.set({ className: 'FontPicShape', label: '字体图形素材', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.draftId.set('shape-object'); component.targetFontShapeTypeId.set(2);
+    component.draft.set({ objectId: 'shape-object', id: 2, name: '鹿', typeId: 999, shape: '/legacy/deer.png', remarks: '动物' });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveFontShape'
+      ? { objectId: 'shape-object', id: 2, name: '鹿', typeId: 2, shape: '/legacy/deer.png' }
+      : { className: 'FontPicShape', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveFontShape', {
+      className: 'FontPicShape', objectId: 'shape-object', fields: { objectId: 'shape-object', id: 2, name: '鹿', typeId: 999, shape: '/legacy/deer.png', remarks: '动物' }, typeId: 2,
+    }]);
+  });
+
+  it('deletes selected font-shape categories through the reference-safe operation', async () => {
+    component.className.set('FontPicShapeType'); component.selectedIds.set(['shape-type-a']); spyOn(window, 'confirm').and.returnValue(true);
+    functions.admin.and.callFake(async (operation: string) => operation === 'fontShapeTypeBatch'
+      ? { action: 'delete', updated: 1, results: [] }
+      : { className: 'FontPicShapeType', page: 1, pageSize: 20, total: 0, results: [] });
+
+    await component.applyBulkFontShapeDelete();
+
+    expect(functions.admin.calls.first().args).toEqual(['fontShapeTypeBatch', { className: 'FontPicShapeType', action: 'delete', objectIds: ['shape-type-a'] }]);
+    expect(component.bulkMessage()).toContain('1 条图形分类');
+  });
+
   it('updates a forum post through the dedicated workflow operation', async () => {
     component.className.set('GuestBar');
     component.draftId.set('bar-object');

+ 37 - 7
src/app/admin/pages/admin-resource.component.ts

@@ -105,18 +105,23 @@ export class AdminResourceComponent implements OnInit {
   readonly adPreviewLoading = signal(false);
   readonly adPreviewError = signal('');
   readonly updatingAdvertisement = signal(false);
+  readonly filterFontShapeTypeId = signal(0);
+  readonly targetFontShapeTypeId = signal(0);
+  readonly fontShapeTypes = signal<Record<string, unknown>[]>([]);
+  readonly fontShapePreview = signal('');
+  readonly updatingFontShape = signal(false);
   readonly selectedIds = signal<string[]>([]);
   readonly bulkError = signal('');
   readonly bulkMessage = signal('');
   readonly icons = { ChevronLeft, ChevronRight, Eye, KeyRound, LoaderCircle, Pencil, Plus, RefreshCw, Search, Trash2, X };
   readonly columns = computed(() => {
     const fields = this.schema()?.fields ?? [];
-    const preferred = ['objectId', 'name', 'title', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'realName', 'zoneId', 'ztype', 'priority', 'zstatus', 'sdate', 'edate', 'fileUrl', 'type', 'state', 'eliteLevel', 'orderId', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
+    const preferred = ['objectId', 'name', 'title', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'realName', 'zoneId', 'typeId', 'shape', 'ztype', 'priority', 'zstatus', 'sdate', 'edate', 'fileUrl', 'type', 'state', 'eliteLevel', 'orderId', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
     const rank = (name: string): number => { const index = preferred.indexOf(name); return index < 0 ? preferred.length : index; };
     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'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType'].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));
@@ -132,6 +137,7 @@ export class AdminResourceComponent implements OnInit {
       this.filterGradeCategoryId.set(0); this.filterGradeParentId.set(0);
       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();
       void this.load(1, true);
     });
   }
@@ -142,12 +148,14 @@ export class AdminResourceComponent implements OnInit {
       if (reloadSchema || !this.schema()) this.schema.set(await this.functions.admin<AdminClassSchema>('schema', { className: this.className() }));
       if (this.className() === 'Grade' && reloadSchema) { await this.loadGradeCategories(); if (!this.filterGradeCategoryId() && this.gradeCategories().length) this.filterGradeCategoryId.set(this.gradeCategoryIdOf(this.gradeCategories()[0])); }
       if (this.className() === 'AdInfo' && reloadSchema) await this.loadAdZones();
-      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : 'list';
+      if (this.className() === 'FontPicShape' && reloadSchema) await this.loadFontShapeTypes();
+      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' : '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();
       this.page.set(await this.functions.admin<ResourcePage>(pageOperation, pagePayload));
       if (this.className() === '_User' && reloadSchema) await this.loadUserGroups();
       if (this.className() === 'Datadic' && reloadSchema) await this.loadDictionaryCategories();
@@ -155,19 +163,20 @@ 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.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.editorOpen.set(true); }
   async openEdit(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     this.error.set('');
     try {
       const detail = await this.functions.admin<Record<string, unknown>>('get', { 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.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.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();
+      if (this.className() === 'FontPicShape' && !this.fontShapeTypes().length) await this.loadFontShapeTypes();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '详情加载失败'); }
   }
-  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.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(); }
+  closeEditor(): void { this.editorOpen.set(false); this.draft.set({}); this.draftId.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(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -217,7 +226,9 @@ export class AdminResourceComponent implements OnInit {
                                       : this.className() === 'SysHoliday' ? 'saveHoliday'
                                         : this.className() === 'Search' ? 'saveSearchNavigation'
                                           : this.className() === 'AdZone' ? 'saveAdZone'
-                                            : this.className() === 'AdInfo' ? 'saveAdInfo' : 'save';
+                                            : this.className() === 'AdInfo' ? 'saveAdInfo'
+                                              : this.className() === 'FontPicShape' ? 'saveFontShape'
+                                                : this.className() === 'FontPicShapeType' ? 'saveFontShapeType' : 'save';
       const payload: Record<string, unknown> = { className: this.className(), objectId: this.draftId() || undefined, fields: this.draft() };
       if (this.className() === 'Node') payload['parentId'] = this.targetNodeParentId();
       if (this.className() === 'Special') payload['pid'] = this.targetSpecialParentId();
@@ -229,6 +240,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'Datadic') payload['categoryId'] = this.targetDictionaryCategoryId();
       if (this.className() === 'Grade') { payload['categoryId'] = this.targetGradeCategoryId(); payload['parentId'] = this.targetGradeParentId(); }
       if (this.className() === 'AdInfo') payload['zoneId'] = this.targetAdZoneId();
+      if (this.className() === 'FontPicShape') payload['typeId'] = this.targetFontShapeTypeId();
       await this.functions.admin(operation, payload);
       this.closeEditor(); await this.load();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
@@ -577,6 +589,15 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.adPreviewError.set(error instanceof Error ? error.message : '广告位预览加载失败'); }
     finally { this.adPreviewLoading.set(false); }
   }
+  async applyBulkFontShapeDelete(): Promise<void> {
+    const className = this.className(); const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (!['FontPicShape', 'FontPicShapeType'].includes(className) || !objectIds.length) { this.bulkError.set('请先选择图形素材或分类'); return; }
+    if (!confirm(`确认删除选中的 ${objectIds.length} 条${className === 'FontPicShape' ? '图形素材' : '图形分类'}?`)) return;
+    this.updatingFontShape.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>(className === 'FontPicShape' ? 'fontShapeBatch' : 'fontShapeTypeBatch', { className, action: 'delete', objectIds }); await this.load(); this.bulkMessage.set(`已删除 ${result.updated} 条${className === 'FontPicShape' ? '图形素材' : '图形分类'}`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '图形删除失败'); }
+    finally { this.updatingFontShape.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     const verb = ['CommonModel', 'Node', 'Guestbook'].includes(this.className()) ? '移入回收站' : '删除';
@@ -585,6 +606,8 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'Search') await this.functions.admin('searchNavigationBatch', { className: 'Search', action: 'delete', objectIds: [objectId] });
       else if (this.className() === 'AdZone') await this.functions.admin('adZoneBatch', { className: 'AdZone', action: 'delete', objectIds: [objectId] });
       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 await this.functions.admin('delete', { className: this.className(), objectId });
       await this.load();
     }
@@ -604,6 +627,8 @@ export class AdminResourceComponent implements OnInit {
   gradeCategoryIdOf(category: Record<string, unknown>): number { return Number(category['cateId']) || 0; }
   adZoneIdOf(zone: Record<string, unknown>): string { return String(zone['id'] || ''); }
   adZoneLabel(zone: Record<string, unknown>): string { return `${String(zone['name'] || '未命名广告位')}(${this.adZoneIdOf(zone)})`; }
+  fontShapeTypeIdOf(type: Record<string, unknown>): number { return Number(type['id']) || 0; }
+  fontShapeTypeLabel(type: Record<string, unknown>): string { return `${String(type['name'] || '未命名分类')}(${this.fontShapeTypeIdOf(type)})`; }
   contentStatusValue(): number { return Number(this.draft()['status']) || 0; }
   contentStatusLabel(status: number): string { return ({ [-3]: '草稿', [-2]: '回收站', [-1]: '未通过', 0: '待审核', 99: '已审核' } as Record<number, string>)[status] || String(status); }
   nodeStatusValue(): number { return Number(this.draft()['zstatus'] ?? 99); }
@@ -627,6 +652,10 @@ export class AdminResourceComponent implements OnInit {
     try { const result = await this.functions.admin<ResourcePage>('adZones', { className: 'AdZone', page: 1, pageSize: 1000, search: '', type: '', status: -100 }); this.adZones.set(result.results || []); }
     catch (error) { this.adZones.set([]); this.error.set(error instanceof Error ? error.message : '广告位选项加载失败'); }
   }
+  private async loadFontShapeTypes(): Promise<void> {
+    try { const result = await this.functions.admin<ResourcePage>('fontShapeTypes', { className: 'FontPicShapeType', page: 1, pageSize: 1000, search: '' }); this.fontShapeTypes.set(result.results || []); }
+    catch (error) { this.fontShapeTypes.set([]); this.error.set(error instanceof Error ? error.message : '图形分类选项加载失败'); }
+  }
   private resetPasswordFields(): void { this.newPassword.set(''); this.confirmPassword.set(''); this.passwordMessage.set(''); this.passwordError.set(''); }
   private resetCreateUserFields(): void { this.createUsername.set(''); this.createPassword.set(''); this.createConfirmPassword.set(''); this.createDisplayName.set(''); this.createMobile.set(''); this.createInviteUsername.set(''); this.createUserError.set(''); }
   private resetUserActions(detail: Record<string, unknown> = {}): void { this.userGroups.set([]); this.selectedUserGroupId.set(Number(detail['legacyGroupId'] ?? (detail['legacyUserData'] as Record<string, unknown> | undefined)?.['GroupID']) || 0); this.userActionReason.set(''); this.userActionError.set(''); this.userActionMessage.set(''); }
@@ -643,4 +672,5 @@ export class AdminResourceComponent implements OnInit {
   private resetDictionaryActions(detail: Record<string, unknown> = {}): void { this.targetDictionaryCategoryId.set(Number(detail['diccate']) || 0); }
   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'] || '')); }
 }