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

feat: migrate content addon administration

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

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

@@ -44,8 +44,9 @@
 - 旧问卷后台 `SurveyManage/Survey_API/SurveyAdd/SurveyAdd_Submit/SurveyItemList/SurveyItemAdd/SurveyItemAdd_Submit/SurveyItem_API/SurveyResultChart/SurveyResultList` 已按真实兼容模型 `DesignAsk/DesignQuestion/DesignAnswer` 迁移:问卷支持状态和关键词筛选、启停与删除,题目按问卷分页、编辑、排序和删除,答卷只读展示并可删除,结果统计从旧 `answer` JSON 聚合各选项计数且单独报告不可解析历史答卷。目标 `SurveyItem/SurveyLog` 属于新的考试测评模型,没有错误拼接到旧问卷。兼容层的 `count` 对 `DesignAsk` 错报 0,专用云函数改为读取实际行后分页;线上实际存在 2 份问卷、96 道题和 105 份答卷。可回滚 `sourceKey` 创建探测证明无法生成旧数字 ID,因此新增问卷和题目明确 501,现有记录编辑继续可用。Angular 新增问卷、题目、答卷入口、问卷范围筛选、批量工具和安全统计视图。
 - 旧客服后台 `Default/Seat/SeatAdd/SeatAdd_Submit/CodeAdd/CodeAdd_Submit/Seat_API/CodeList` 已映射到 `ServiceSeat` 与 `Temp(useType=12)`:`serviceSeats/saveServiceSeat` 支持帐套隔离列表、现有编辑、绑定同帐套有效账号、默认席位互斥、排序与原子生成 `sId/sourceKey`,`serviceCodes/saveServiceCode` 只读取并白名单维护客服欢迎语的名称、HTML 内容和主题,`serviceBatch` 覆盖席位及欢迎语删除。Angular 新增客服席位、客服欢迎语入口、账号绑定控件和批量删除。旧逻辑在用户名不存在时以固定密码 `123456` 自动开户,该弱密码行为不迁移:云函数明确 501,并要求先走现有安全开户流程。`Temp.id` 与 Parse 保留字段冲突且省略 ID 无法生成旧编号,因此欢迎语新增明确阻塞。目标 Schema 完全缺少 `ChatMsg` 或等价消息类,`MsgEx/MsgInfo/MsgList` 以及 `Seat_API.chat_del` 由 `legacyServiceChatBlocker` 返回逐项数据阻塞原因。
 - 旧店铺后台 `StoreManage/Store_API/StoreStyle/Style_API/StoreStyleAdd/StoreStyleAdd_Submit/StoreUpdate/StoreUpdate_Submit` 已映射到规范化 `StoreApplication/StoreStyle`:店铺按状态和关键词筛选,支持审核、取消审核、推荐、取消推荐,现有资料可维护同帐套所属账号、样式、联系方式、地址和展示内容;样式可维护三类模板路径、缩略图和说明,删除前检查店铺引用。Angular 新增店铺、店铺样式入口、状态工具和归属/样式选择。店铺与样式旧数字 ID 都不能通过兼容层安全新增,店铺创建还依赖已丢失的模型 6 动态字段链,因此新增明确 501。旧 `ZL_Commodities` 商品主表完全缺失,目标 `Product/StoreProduct` 只有图片等扩展碎片,无法核验店铺商品引用,所以真实删除店铺与 `Product` 页面均明确数据阻塞,未冒险制造孤儿商品。
+- 旧内容附加功能 `TagKey/TagKey_Submit` 已迁移为 Angular 全局内容标签词库:保留管道符分隔、移除空白/引号/反斜杠和原顺序语义,改由 `saveContentTagKeys` 管理云函数写入 Parse Config,不再依赖旧 ASP.NET 服务器的 `/js/ICMS/tags.json` 本地文件。线上完整 Schema 核验不存在 `Comment/ContentAudit/ContentVerBak` 或等价类,因此 `Comment/Commont_API`、4 个内容审核流动作以及 `VerBak/VerBak_Del` 均由 `legacyContentAddonBlocker` 返回逐项数据阻塞原因,未用 `CommonModel` 状态伪造丢失的审批历史。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 89 项已实现、59 项部分实现、127 项数据阻塞、203 项基础设施阻塞、564 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 91 项已实现、59 项部分实现、135 项数据阻塞、203 项基础设施阻塞、554 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

Разница между файлами не показана из-за своего большого размера
+ 12 - 1
scripts/deploy-admin-functions.mjs


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

@@ -145,6 +145,16 @@ const explicit = new Map([
   ['Extend / AD/InfoAdd', ['partial', '现有广告编辑和广告位选择已迁移;新增因旧 id 不可持久化而阻塞']],
   ['Extend / AD/InfoAdd_Submit', ['partial', '现有广告由 saveAdInfo 校验广告位、时间、权重和内容后更新;新增明确阻塞']],
   ['Extend / AD/ADPreView', ['partial', '已提供当前生效广告预览;旧 HTML 只显示源码而不执行脚本,避免管理端 XSS']],
+  ['Content / Addon/Comment', ['blocked-data', '目标 Schema 缺少旧 ZL_Comment 评论主表与历史评论数据']],
+  ['Content / Addon/Commont_API', ['blocked-data', '目标无可审核、取消审核或删除的旧评论记录']],
+  ['Content / Addon/ConAudit_API', ['blocked-data', '目标缺少 ContentAudit 记录、节点审核流程与管理员步骤状态']],
+  ['Content / Addon/ConAudit_SJ_API', ['blocked-data', '目标缺少送审确认/驳回记录和可追溯流程状态']],
+  ['Content / Addon/ConAudit', ['blocked-data', '目标 Schema 缺少旧 ZL_Content_Audit 审核列表及历史数据']],
+  ['Content / Addon/ConAuditDetail', ['blocked-data', '目标缺少审核明细、备注、节点和操作人流转记录']],
+  ['Content / Addon/TagKey', ['implemented', '已由 Angular 全局标签词库编辑页替代旧 tags.json 文件页']],
+  ['Content / Addon/TagKey_Submit', ['implemented', '已由 saveContentTagKeys 清理标签并持久化到 Parse Config']],
+  ['Content / Addon/VerBak', ['blocked-data', '目标 Schema 缺少旧 ZL_Content_VerBak 内容/商品版本快照与历史数据']],
+  ['Content / Addon/VerBak_Del', ['blocked-data', '目标无可定位和删除的旧 ZL_Content_VerBak 版本记录']],
   ['FontPic/Index', ['blocked-data', '目标 Schema 缺少旧 FontPic 成品字体图主表与历史数据']],
   ['FontPic/FontPicInfo', ['blocked-data', '目标 Schema 缺少旧 FontPic 成品字体图详情数据']],
   ['FontPic/FontPicInfo_Submit', ['blocked-data', '目标 Schema 缺少旧 FontPic 成品字体图持久化模型']],

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

@@ -113,6 +113,7 @@ let storeApplicationRestore = null;
 let storeStyleRestore = null;
 let temporaryStoreApplicationId = '';
 let temporaryStoreStyleId = '';
+let contentTagsRestore = null;
 const temporaryLessonIds = [];
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
@@ -340,6 +341,9 @@ try {
   const temporaryStoreApplication = await jsonRequest(`${PARSE_URL}/classes/StoreApplication`, { method: 'POST', body: JSON.stringify({ storeName: '__store_style_ref_smoke__', userId: Number(sampleStoreApplication.userId), userName: sampleStoreApplication.userName, storeStyleId: Number(sampleStoreStyle.id), storeState: 0, sourceKey: `smoke-store-application-${Date.now()}`, company }) }, true); temporaryStoreApplicationId = temporaryStoreApplication.objectId; const referencedStoreStyle = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'storeBatch', className: 'StoreStyle', action: 'delete', objectIds: [sampleStoreStyle.objectId] }, 409); if (!String(referencedStoreStyle.message).includes('店铺引用')) throw new Error('店铺样式引用删除保护未返回明确原因'); await jsonRequest(`${PARSE_URL}/classes/StoreApplication/${temporaryStoreApplicationId}`, { method: 'DELETE' }, true); temporaryStoreApplicationId = '';
   const temporaryStoreStyle = await jsonRequest(`${PARSE_URL}/classes/StoreStyle`, { method: 'POST', body: JSON.stringify({ styleName: '__store_style_delete_smoke__', templateIndex: 'index.html', templateContent: 'content.html', templateList: 'list.html', sourceKey: `smoke-store-style-${Date.now()}`, company }) }, true); temporaryStoreStyleId = temporaryStoreStyle.objectId; const readTemporaryStoreStyle = await jsonRequest(`${PARSE_URL}/classes/StoreStyle/${temporaryStoreStyleId}`, {}, true); if (readTemporaryStoreStyle.id != null) throw new Error('店铺样式省略 id 的创建探测意外生成了旧编号'); const deletedStoreStyle = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'storeBatch', className: 'StoreStyle', action: 'delete', objectIds: [temporaryStoreStyleId] }); if (deletedStoreStyle.updated !== 1) throw new Error('店铺样式批量删除失败'); temporaryStoreStyleId = '';
   const productPageBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyStoreProductBlocker' }, 501); if (!String(productPageBlocked.message).includes('ZL_Commodities')) throw new Error('店铺商品页未返回商品主表阻塞原因');
+  const contentTagSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'ContentTagKey' }); if (contentTagSchema.className !== 'ContentTagKey' || contentTagSchema.writable !== true || contentTagSchema.creatable !== false || contentTagSchema.fields?.some((field) => field.writable)) throw new Error('内容标签虚拟 Schema 异常');
+  const contentTagPage = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'contentTagKeys', className: 'ContentTagKey', page: 1, pageSize: 20, search: '' }); const originalContentTags = Array.isArray(contentTagPage.results?.[0]?.tags) ? contentTagPage.results[0].tags.map(String) : []; contentTagsRestore = originalContentTags; const tagProbe = `angular-smoke-${Date.now()}`; const probeContentTags = originalContentTags.length >= 1000 ? [...originalContentTags.slice(0, 999), tagProbe] : [...originalContentTags, tagProbe]; const savedContentTags = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveContentTagKeys', className: 'ContentTagKey', objectId: 'global-content-tags', tags: probeContentTags }); if (savedContentTags.count !== probeContentTags.length || savedContentTags.tags?.at(-1) !== tagProbe) throw new Error('内容标签 Parse Config 保存异常'); const readContentTags = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'getContentTagKeys', className: 'ContentTagKey', objectId: 'global-content-tags' }); if (readContentTags.tags?.at(-1) !== tagProbe) throw new Error('内容标签 Parse Config 写后读失败'); await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveContentTagKeys', className: 'ContentTagKey', objectId: 'global-content-tags', tags: originalContentTags }); contentTagsRestore = null;
+  for (const action of ['Comment','Commont_API','ConAudit_API','ConAudit_SJ_API','ConAudit','ConAuditDetail','VerBak','VerBak_Del']) { const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyContentAddonBlocker', action }, 501); if (!String(blocked.message).startsWith('migration_blocked:')) throw new Error(`内容附加动作 ${action} 未返回明确数据阻塞`); }
   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' } });
@@ -775,7 +779,7 @@ 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/font-shapes/surveys/customer-service/stores/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/surveys/customer-service/stores/content-tags/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; });
@@ -795,6 +799,7 @@ try {
   if (storeStyleRestore) await jsonRequest(`${PARSE_URL}/classes/StoreStyle/${storeStyleRestore.objectId}`, { method: 'PUT', body: JSON.stringify(storeStyleRestore.fields) }, true).catch((error) => { console.error(`店铺样式冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
   if (temporaryStoreApplicationId) await jsonRequest(`${PARSE_URL}/classes/StoreApplication/${temporaryStoreApplicationId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时店铺清理失败:${error.message}`); process.exitCode = 1; });
   if (temporaryStoreStyleId) await jsonRequest(`${PARSE_URL}/classes/StoreStyle/${temporaryStoreStyleId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时店铺样式清理失败:${error.message}`); process.exitCode = 1; });
+  if (contentTagsRestore) await jsonRequest(`${PARSE_URL}/config`, { method: 'PUT', body: JSON.stringify({ params: { legacyContentTags: contentTagsRestore } }) }, 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;

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

@@ -44,6 +44,7 @@ export const ADMIN_NAVIGATION = [
   { label: '客服欢迎语', icon: 'message-square-text', route: '/admin/resources/Temp' },
   { label: '店铺管理', icon: 'store', route: '/admin/resources/StoreApplication' },
   { label: '店铺样式', icon: 'palette', route: '/admin/resources/StoreStyle' },
+  { label: '内容标签词库', icon: 'tags', route: '/admin/resources/ContentTagKey' },
   { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '迁移状态', icon: 'route', route: '/admin/migration' },
 ] as const;

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

@@ -78,6 +78,11 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Shop / Store/Store_API')?.status).toBe('partial');
     expect(byKey.get('Shop / Store/StoreUpdate_Submit')?.status).toBe('partial');
     expect(byKey.get('Shop / Store/Product')?.status).toBe('blocked-data');
+    expect(byKey.get('Content / Addon/TagKey')?.status).toBe('implemented');
+    expect(byKey.get('Content / Addon/TagKey_Submit')?.status).toBe('implemented');
+    expect(byKey.get('Content / Addon/Comment')?.status).toBe('blocked-data');
+    expect(byKey.get('Content / Addon/ConAudit')?.status).toBe('blocked-data');
+    expect(byKey.get('Content / Addon/VerBak')?.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');

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

@@ -76,71 +76,71 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Content / Addon",
     "action": "Comment",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 ZL_Comment 评论主表与历史评论数据"
   },
   {
     "module": "Content / Addon",
     "action": "Commont_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标无可审核、取消审核或删除的旧评论记录"
   },
   {
     "module": "Content / Addon",
     "action": "ConAudit_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少 ContentAudit 记录、节点审核流程与管理员步骤状态"
   },
   {
     "module": "Content / Addon",
     "action": "ConAudit_SJ_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少送审确认/驳回记录和可追溯流程状态"
   },
   {
     "module": "Content / Addon",
     "action": "ConAudit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 ZL_Content_Audit 审核列表及历史数据"
   },
   {
     "module": "Content / Addon",
     "action": "ConAuditDetail",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少审核明细、备注、节点和操作人流转记录"
   },
   {
     "module": "Content / Addon",
     "action": "TagKey",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Angular 全局标签词库编辑页替代旧 tags.json 文件页"
   },
   {
     "module": "Content / Addon",
     "action": "TagKey_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 saveContentTagKeys 清理标签并持久化到 Parse Config"
   },
   {
     "module": "Content / Addon",
     "action": "VerBak",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 ZL_Content_VerBak 内容/商品版本快照与历史数据"
   },
   {
     "module": "Content / Addon",
     "action": "VerBak_Del",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标无可定位和删除的旧 ZL_Content_VerBak 版本记录"
   },
   {
     "module": "Shop / Addon",

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

@@ -91,7 +91,7 @@
   } @else {
     <div class="table-scroll"><table><thead><tr>@if (bulkEnabled()) { <th class="select-col"><input type="checkbox" aria-label="全选当前页" [checked]="allPageSelected()" (change)="togglePageSelection($any($event.target).checked)" /></th> }@for (field of columns(); track field.name) { <th>{{ field.name }}</th> }<th class="actions">操作</th></tr></thead><tbody>
       @for (row of page()?.results || []; track row['objectId']) {
-        <tr>@if (bulkEnabled()) { <td class="select-col"><input type="checkbox" [attr.aria-label]="'选择 ' + row['objectId']" [checked]="isSelected(row['objectId'])" (change)="toggleSelection('' + row['objectId'], $any($event.target).checked)" /></td> }@for (field of columns(); track field.name) { <td [title]="display(row[field.name])">{{ display(row[field.name]) }}</td> }<td class="actions"><button type="button" [attr.aria-label]="schema()?.writable ? '编辑' : '查看'" (click)="openEdit(row)"><lucide-icon [img]="schema()?.writable ? icons.Pencil : icons.Eye" [size]="16" /></button>@if (schema()?.writable && !['Model', 'ModelField', 'StoreApplication'].includes(className())) { <button class="danger" type="button" [attr.aria-label]="['CommonModel', 'Node', 'Guestbook'].includes(className()) ? '移入回收站' : '删除'" (click)="remove(row)"><lucide-icon [img]="icons.Trash2" [size]="16" /></button> }</td></tr>
+        <tr>@if (bulkEnabled()) { <td class="select-col"><input type="checkbox" [attr.aria-label]="'选择 ' + row['objectId']" [checked]="isSelected(row['objectId'])" (change)="toggleSelection('' + row['objectId'], $any($event.target).checked)" /></td> }@for (field of columns(); track field.name) { <td [title]="display(row[field.name])">{{ display(row[field.name]) }}</td> }<td class="actions"><button type="button" [attr.aria-label]="schema()?.writable ? '编辑' : '查看'" (click)="openEdit(row)"><lucide-icon [img]="schema()?.writable ? icons.Pencil : icons.Eye" [size]="16" /></button>@if (schema()?.writable && !['Model', 'ModelField', 'StoreApplication', 'ContentTagKey'].includes(className())) { <button class="danger" type="button" [attr.aria-label]="['CommonModel', 'Node', 'Guestbook'].includes(className()) ? '移入回收站' : '删除'" (click)="remove(row)"><lucide-icon [img]="icons.Trash2" [size]="16" /></button> }</td></tr>
       } @empty { <tr><td class="empty-cell" [attr.colspan]="columns().length + (bulkEnabled() ? 2 : 1)">没有符合条件的数据</td></tr> }
     </tbody></table></div>
     <footer class="pagination"><span>第 {{ page()?.page || 1 }} 页,共 {{ pageCount() }} 页</span><div><button type="button" [disabled]="(page()?.page || 1) <= 1 || loading()" (click)="load((page()?.page || 1) - 1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" />上一页</button><button type="button" [disabled]="(page()?.page || 1) * (page()?.pageSize || 20) >= (page()?.total || 0) || loading()" (click)="load((page()?.page || 1) + 1)">下一页<lucide-icon [img]="icons.ChevronRight" [size]="17" /></button></div></footer>
@@ -139,6 +139,9 @@
         @if (className() === 'StoreApplication' || className() === 'StoreStyle') {
           <section class="account-create-note"><strong>现有{{ className() === 'StoreApplication' ? '店铺' : '店铺样式' }}编辑</strong><span>{{ className() === 'StoreApplication' ? '旧模型 6 动态字段创建链和商品主表均已缺失,因此仅维护现有店铺且禁止真实删除;审核与推荐可在列表操作。' : '旧数字 ID 无法安全生成,因此仅维护现有样式;仍被店铺引用的样式不能删除。' }}</span></section>
         }
+        @if (className() === 'ContentTagKey') {
+          <section class="content-controls"><header><div><strong>全局内容标签</strong><span>对应旧 /js/ICMS/tags.json,改由管理云函数写入 Parse Config,不依赖服务器本地文件系统</span></div><b>{{ draft()['count'] || 0 }} 个</b></header><label><span>标签内容 <small>使用 | 或换行分隔,最多 1000 个</small></span><textarea rows="14" [ngModel]="tagKeyContent()" (ngModelChange)="tagKeyContent.set($event)" placeholder="教育|英语|阅读"></textarea></label><div class="content-action-status">保存时按旧逻辑移除空白、引号和反斜杠;顺序保持不变。</div></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)" /> }

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

@@ -835,4 +835,37 @@ describe('AdminResourceComponent', () => {
     expect(functions.admin.calls.first().args).toEqual(['nodeBatch', { className: 'Node', action: 'recover', objectIds: ['node-a', 'node-b'] }]);
     expect(component.bulkMessage()).toContain('2 个栏目');
   });
+
+  it('loads and opens the virtual global content-tag resource through dedicated cloud operations', async () => {
+    component.className.set('ContentTagKey');
+    functions.admin.and.callFake(async (operation: string) => operation === 'schema'
+      ? { className: 'ContentTagKey', label: '内容标签词库', writable: true, creatable: false, supportsSoftDelete: false, fields: [] }
+      : operation === 'contentTagKeys'
+        ? { className: 'ContentTagKey', page: 1, pageSize: 20, total: 1, results: [{ objectId: 'global-content-tags', count: 2, content: '教育|英语' }] }
+        : { objectId: 'global-content-tags', count: 2, content: '教育|英语', tags: ['教育', '英语'] });
+
+    await component.load(1, true);
+    await component.openEdit({ objectId: 'global-content-tags' });
+
+    expect(functions.admin).toHaveBeenCalledWith('contentTagKeys', { className: 'ContentTagKey', page: 1, pageSize: 20, search: '' });
+    expect(functions.admin).toHaveBeenCalledWith('getContentTagKeys', { className: 'ContentTagKey', objectId: 'global-content-tags' });
+    expect(component.tagKeyContent()).toBe('教育|英语');
+  });
+
+  it('saves global content tags through Parse Config instead of generic CRUD', async () => {
+    component.className.set('ContentTagKey');
+    component.schema.set({ className: 'ContentTagKey', label: '内容标签词库', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.draftId.set('global-content-tags');
+    component.draft.set({ objectId: 'global-content-tags', count: 2, content: '教育|英语', tags: ['教育', '英语'] });
+    component.tagKeyContent.set('教育 | 英语\n阅读');
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveContentTagKeys'
+      ? { objectId: 'global-content-tags', count: 3, content: '教育|英语|阅读', tags: ['教育', '英语', '阅读'] }
+      : { className: 'ContentTagKey', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveContentTagKeys', {
+      className: 'ContentTagKey', objectId: 'global-content-tags', fields: { objectId: 'global-content-tags', count: 2, content: '教育|英语', tags: ['教育', '英语'] }, content: '教育 | 英语\n阅读',
+    }]);
+  });
 });

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

@@ -132,13 +132,14 @@ export class AdminResourceComponent implements OnInit {
   readonly targetStoreUsername = signal('');
   readonly storeStyles = signal<Record<string, unknown>[]>([]);
   readonly updatingStore = signal(false);
+  readonly tagKeyContent = signal('');
   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', 'qtitle', 'sName', 'storeName', 'styleName', 'str1', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'userName', 'realName', 'sRemrk', 'askId', 'zoneId', 'typeId', 'shape', 'qtype', 'required', 'ztype', 'priority', 'zstatus', 'storeState', 'storeCommendState', 'storeStyleId', 'sDefault', 'sIndex', 'str4', 'sdate', 'edate', 'fileUrl', 'type', 'state', 'eliteLevel', 'orderId', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
+    const preferred = ['objectId', 'name', 'title', 'qtitle', 'sName', 'storeName', 'styleName', 'count', 'content', 'str1', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'userName', 'realName', 'sRemrk', 'askId', 'zoneId', 'typeId', 'shape', 'qtype', 'required', 'ztype', 'priority', 'zstatus', 'storeState', 'storeCommendState', 'storeStyleId', 'sDefault', 'sIndex', 'str4', '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);
   });
@@ -163,6 +164,7 @@ export class AdminResourceComponent implements OnInit {
       this.filterSurveyStatus.set(-100); this.filterSurveyId.set(0); this.resetSurveyActions();
       this.resetServiceActions();
       this.filterStoreStatus.set(-100); this.resetStoreActions();
+      this.resetTagKeyActions();
       void this.load(1, true);
     });
   }
@@ -176,7 +178,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'FontPicShape' && reloadSchema) await this.loadFontShapeTypes();
       if (['DesignQuestion', 'DesignAnswer'].includes(this.className()) && reloadSchema) { await this.loadSurveys(); if (!this.filterSurveyId() && this.surveys().length) this.filterSurveyId.set(this.surveyIdOf(this.surveys()[0])); }
       if (this.className() === 'StoreApplication' && reloadSchema) await this.loadStoreStyles();
-      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === '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' : '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() === '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' : '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(); }
@@ -193,20 +195,21 @@ export class AdminResourceComponent implements OnInit {
     finally { this.loading.set(false); }
   }
 
-  openCreate(): void { this.draftId.set(''); this.draft.set({}); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(); this.resetContentActions(); this.resetNodeActions(); this.resetSpecialActions(); this.resetGuestbookActions(); this.resetGuestCategoryActions(); this.resetBarActions(); this.resetExamClassActions(); this.resetExamPointActions(); this.resetKnowledgeActions(); this.resetTeacherActions(); this.resetDictionaryActions(); this.resetGradeActions(); this.resetAdActions(); this.resetFontShapeActions(); this.resetSurveyActions(); this.resetServiceActions(); this.resetStoreActions(); 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.resetSurveyActions(); this.resetServiceActions(); this.resetStoreActions(); this.resetTagKeyActions(); 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.resetFontShapeActions(detail); this.resetSurveyActions(); this.resetServiceActions(detail); this.resetStoreActions(detail); this.editorOpen.set(true);
+      const detailOperation = this.className() === 'ContentTagKey' ? 'getContentTagKeys' : 'get';
+      const detail = await this.functions.admin<Record<string, unknown>>(detailOperation, { className: this.className(), objectId });
+      this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(detail); this.resetContentActions(detail); this.resetNodeActions(detail); this.resetSpecialActions(detail); this.resetGuestbookActions(detail); this.resetGuestCategoryActions(detail); this.resetBarActions(detail); this.resetExamClassActions(detail); this.resetExamPointActions(detail); this.resetKnowledgeActions(detail); this.resetTeacherActions(detail); this.resetDictionaryActions(detail); this.resetGradeActions(detail); this.resetAdActions(detail); this.resetFontShapeActions(detail); this.resetSurveyActions(); this.resetServiceActions(detail); this.resetStoreActions(detail); this.resetTagKeyActions(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(); this.resetFontShapeActions(); this.resetSurveyActions(); this.resetServiceActions(); this.resetStoreActions(); }
+  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(); this.resetSurveyActions(); this.resetServiceActions(); this.resetStoreActions(); this.resetTagKeyActions(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -264,7 +267,8 @@ export class AdminResourceComponent implements OnInit {
                                                       : this.className() === 'ServiceSeat' ? 'saveServiceSeat'
                                                         : this.className() === 'Temp' ? 'saveServiceCode'
                                                           : this.className() === 'StoreApplication' ? 'saveStoreApplication'
-                                                            : this.className() === 'StoreStyle' ? 'saveStoreStyle' : 'save';
+                                                            : this.className() === 'StoreStyle' ? 'saveStoreStyle'
+                                                              : this.className() === 'ContentTagKey' ? 'saveContentTagKeys' : '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();
@@ -279,6 +283,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'FontPicShape') payload['typeId'] = this.targetFontShapeTypeId();
       if (this.className() === 'ServiceSeat') payload['username'] = this.targetServiceSeatUsername();
       if (this.className() === 'StoreApplication') { payload['username'] = this.targetStoreUsername(); payload['styleId'] = this.targetStoreStyleId(); }
+      if (this.className() === 'ContentTagKey') payload['content'] = this.tagKeyContent();
       await this.functions.admin(operation, payload);
       this.closeEditor(); await this.load();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
@@ -772,4 +777,5 @@ export class AdminResourceComponent implements OnInit {
   private resetSurveyActions(): void { this.surveyOrderStart.set(1); this.surveyChart.set(null); this.surveyChartError.set(''); this.surveyChartLoading.set(false); }
   private resetServiceActions(detail: Record<string, unknown> = {}): void { this.targetServiceSeatUsername.set(String(detail['sRemrk'] || '')); }
   private resetStoreActions(detail: Record<string, unknown> = {}): void { this.targetStoreUsername.set(String(detail['userName'] || '')); this.targetStoreStyleId.set(Number(detail['storeStyleId']) || 0); }
+  private resetTagKeyActions(detail: Record<string, unknown> = {}): void { this.tagKeyContent.set(String(detail['content'] || '')); }
 }

Некоторые файлы не были показаны из-за большого количества измененных файлов