Răsfoiți Sursa

feat: migrate yellow page styles

彭峰 2 luni în urmă
părinte
comite
a94706576e

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

@@ -34,6 +34,7 @@
 - Manager 修改密码已迁为 Angular 专用页面和 `changeOwnPassword`:验证当前口令、限制新密码 8–72 位、拒绝复用原密码,保存后撤销该管理员全部 Parse 会话并强制重新登录。旧 MarkDown 五个动作依赖未迁入的 MarkDown 主表、`wwwroot/UploadFiles/MarkDown` 本地目录和 `/PreView/md` 渲染页,均由 `legacyMarkdownBlocker` 返回数据或运行时阻塞。
 - User/Message 的五个站内信动作缺少 Message 收发主表;`MailTemp` 只是邮件模板,不被误用。Mobile 两个空控制器动作只剩已丢失的 Razor 视图,无法恢复页面基线。栏目 `ZhiDing` 的旧实现会把所有有效栏目强制扁平为根节点,现有 `saveNode/nodeBatch` 已安全维护层级并拒绝该破坏性修复;`UnionNode_Merge` 同时依赖未迁入的 `ZL_Commodities`,不能只改内容表形成半合并。以上均由专用云函数返回细分原因。
 - Office/OA 中字段完全匹配的 `MisType` 已迁为 Angular“OA 流程类型”页与 `misTypes/saveMisType/misTypeBatch`,支持帐套隔离、搜索、防重、原子旧 ID 和 `MisProcedure.typeId` 引用保护删除;用户选择与邮件通讯录复用现有用户管理,模型选择为部分迁移。其余旧 `OA_AuditFlow/OA_AuditFlowItem/Mis_Model/OA_Sign/OA_Document` 主表及套红文件运行时未迁入;目标空的 `MisProcedure/MisProLevel/MisSign/DocModel` 是不同版本结构,不被强行冒充,21 个动作由 `legacyOABlocker` 逐项说明。
+- `Page` 控制器中与旧字段完全匹配的 `PageStyle` 已迁为 Angular“黄页样式”页与 `pageStyles/savePageStyle/pageStyleBatch`:支持帐套隔离、名称搜索与防重、路径和状态校验、原子分配旧 `PageNodeid`、单一默认样式及 `PageTemplate.userGroup` 引用保护删除。目标 `PageTemplate` 虽保留旧字段但为空,且没有 `PageReg` 用户黄页申请主表或 `SiteOption` 黄页配置层;内容管理仅部分复用现有 `CommonModel`,其余 15 个旧入口由 `legacyPageBlocker` 返回逐项数据或基础设施原因。
 - 旧 `NodeAdd_Submit`、`Node_API` 与 `UnionNode_Move` 的核心栏目生命周期已迁为专用 `saveNode/nodeBatch`:新增栏目以临时来源键完成 Parse 初始写入,再使用 PostgreSQL 事务锁原子分配 `nodeId/orderId` 并替换为旧 `sourceKey`;保存会校验同级名称/目录重复、父栏目帐套和回收状态,移动会阻断自身/下级循环并递归修正所有后代深度。回收、恢复和每批 1–100 个栏目迁移已接入 Angular;永久删除仅在没有内容、下级栏目、节点权限和模型模板引用时开放,通用保存与删除不能绕过。
 - 旧 `SpecialAdd_Save` 与 `UnionSpecial_Move` 已迁为专用 `saveSpecial/specialBatch`:新增专题使用临时来源键和 PostgreSQL 事务锁原子分配 `specId/orderId`,保存时校验全帐套名称/目录唯一、父专题归属与层级循环;删除会拒绝仍有下级专题的记录。旧 `UnionSpecial_Merge` 被明确列为数据阻塞:`CommonModel.specialId` 只存在于 Parse Schema、PostgreSQL 物理列缺失,商品主表也未迁移,无法安全改写内容/商品专题关系。
 - 旧 `ModelManage/Field/Order_Submit` 的安全部分已迁为 `saveModelMetadata/saveModelFieldMetadata/modelFieldOrder`:现有模型可编辑名称、条目文案、图标、说明和内容模板元数据,现有字段可编辑别名、提示、显示/搜索配置,并可在同模型内批量排序;`modelId/modelType/tableName/fieldId/modelId/fieldName/fieldType/sourceKey` 等结构字段通用只读。新增、复制、删除模型或字段会显式返回 501,因为旧实现同时执行 PostgreSQL 建表/改列、标签生成和模板文件写入,目标托管环境没有该 DDL/旧模板运行时。

Fișier diff suprimat deoarece este prea mare
+ 7 - 2
scripts/deploy-admin-functions.mjs


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

@@ -212,6 +212,24 @@ const explicit = new Map([
   ['Office / OA/OAConfig', ['blocked-infrastructure', '旧动作只有已丢失的 OAConfig Razor 视图,目标没有对应配置模型']],
   ['Office / OA/SelModelFieds', ['partial', '目标 Model 数据可只读查看;旧按 ModelType 回填字段的 OA Razor 交互未迁入']],
   ['Office / OA/SignManage', ['blocked-data', '目标缺少 OA_Sign 签章列表;MisSign 是不等价的考勤时间表']],
+  ['Page/ApplyAudit', ['blocked-data', '目标 Schema 缺少旧 PageReg 用户黄页申请主表与审核状态']],
+  ['Page/ApplyInfo', ['blocked-data', '目标缺少 PageReg 申请详情、用户动态字段和样式关联']],
+  ['Page/PageStyle', ['implemented', '已由 pageStyles 提供帐套隔离、名称搜索、排序和分页列表']],
+  ['Page/PageStyleAdd', ['implemented', '已由 Angular 黄页样式新建/编辑抽屉替代']],
+  ['Page/PageContent', ['partial', '现有 CommonModel 可管理普通内容;PageReg 与黄页专属模型范围缺失']],
+  ['Page/EditContent', ['partial', '现有内容详情可查看;PageReg 与 PageTemplate 黄页编辑上下文缺失']],
+  ['Page/Content_Edit', ['partial', '现有内容主字段可安全编辑;黄页模型附表与 PageReg 关系尚未迁入']],
+  ['Page/PageConfig', ['blocked-infrastructure', '目标没有 SiteOption.RegPageStart/Page_ContentStatus/Page_UserCanNode 配置层']],
+  ['Page/PageTemplate', ['blocked-data', 'PageTemplate 为空且目标缺少 PageReg 用户归属,无法还原用户与公共模板树']],
+  ['Page/PageTemplateAdd', ['blocked-data', '目标缺少 PageReg,不能确定模板 RegID、用户和样式上下文']],
+  ['Page/SetPageOrder', ['blocked-data', '目标没有可按 PageReg 隔离的页面模板集合']],
+  ['Page/PageConfig_Update', ['blocked-infrastructure', '目标云函数架构没有旧黄页站点配置持久层']],
+  ['Page/PageTemplate_Add', ['blocked-data', '目标缺少 PageReg 用户归属和旧模型复选关系,不能安全新增页面模板']],
+  ['Page/SetPageOrder_Batch', ['blocked-data', '目标没有可按 PageReg 隔离的模板排序集合']],
+  ['Page/SetPageOrder_UpMove', ['blocked-data', '目标没有可按 PageReg 隔离的相邻模板顺序']],
+  ['Page/SetPageOrder_DownMove', ['blocked-data', '目标没有可按 PageReg 隔离的相邻模板顺序']],
+  ['Page/Style_Add', ['implemented', '已由 savePageStyle 校验路径、状态、防重并原子分配旧 PageNodeid']],
+  ['Page/Apply_Update', ['blocked-data', '目标缺少 PageReg 申请主表、黄页模型附表和用户申请字段']],
   ['Design / Interactive/InteractiveList', ['blocked-data', '目标缺少旧 Design_Pub 交互提交表及 sceneid、uid、fname 数据;DesignAnswer/Pub 不等价']],
   ['Design / Interactive/Interactive_API', ['blocked-data', '目标没有可按旧 Design_Pub ID 删除的交互提交记录']],
   ['Office / iServer/Default', ['blocked-data', '目标 Schema 缺少旧 IServer 服务工单主表与回复表']],

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

@@ -120,6 +120,7 @@ let baikeRestore = null;
 let temporaryCrmClientTypeId = '';
 let temporaryShopFareTemplateId = '';
 let temporaryMisTypeId = '';
+let temporaryPageStyleId = '';
 const temporaryLessonIds = [];
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
@@ -680,6 +681,13 @@ try {
   const misTypeList = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'misTypes', className: 'MisType', page: 1, pageSize: 20, search: misTypeSuffix }); if (misTypeList.total !== 1 || misTypeList.results?.[0]?.objectId !== temporaryMisTypeId) throw new Error('OA 流程类型专用列表失败');
   const updatedMisType = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveMisType', className: 'MisType', objectId: temporaryMisTypeId, fields: { typeName: `更新流程类型-${misTypeSuffix}`, typeDescribe: '已更新' } }); if (updatedMisType.typeName !== `更新流程类型-${misTypeSuffix}` || Number(updatedMisType.id) !== Number(createdMisType.id)) throw new Error('OA 流程类型专用编辑失败');
   const deletedMisType = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'misTypeBatch', className: 'MisType', action: 'delete', objectIds: [temporaryMisTypeId] }); if (deletedMisType.updated !== 1) throw new Error('OA 流程类型专用删除失败'); temporaryMisTypeId = '';
+  const pageStyleSuffix = Date.now().toString(36); const createdPageStyle = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'savePageStyle', className: 'PageStyle', fields: { pageNodeName: `冒烟黄页样式-${pageStyleSuffix}`, stylePath: `/Template/Page/${pageStyleSuffix}`, templateIndex: 'index.html', templateIndexPic: '/images/page-smoke.png', orderid: 1, isDefault: 1, istrue: 1 } }); temporaryPageStyleId = String(createdPageStyle.objectId || ''); if (!temporaryPageStyleId || !Number(createdPageStyle.pageNodeid) || createdPageStyle.sourceKey !== `[["PageNodeid",${Number(createdPageStyle.pageNodeid)}]]`) throw new Error(`黄页样式原子编号创建异常:${JSON.stringify(createdPageStyle)}`);
+  const pageStyleList = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'pageStyles', className: 'PageStyle', page: 1, pageSize: 20, search: pageStyleSuffix }); if (pageStyleList.total !== 1 || pageStyleList.results?.[0]?.objectId !== temporaryPageStyleId) throw new Error('黄页样式专用列表失败');
+  const updatedPageStyle = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'savePageStyle', className: 'PageStyle', objectId: temporaryPageStyleId, fields: { pageNodeName: `更新黄页样式-${pageStyleSuffix}`, stylePath: `/Template/Page/${pageStyleSuffix}`, templateIndex: 'index-updated.html', templateIndexPic: '/images/page-smoke.png', orderid: 2, isDefault: 1, istrue: 1 } }); if (updatedPageStyle.pageNodeName !== `更新黄页样式-${pageStyleSuffix}` || Number(updatedPageStyle.pageNodeid) !== Number(createdPageStyle.pageNodeid)) throw new Error('黄页样式专用编辑失败');
+  const deletedPageStyle = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'pageStyleBatch', className: 'PageStyle', action: 'delete', objectIds: [temporaryPageStyleId] }); if (deletedPageStyle.updated !== 1) throw new Error('黄页样式专用删除失败'); temporaryPageStyleId = '';
+  for (const action of ['ApplyAudit','ApplyInfo','PageContent','EditContent','Content_Edit','PageConfig','PageTemplate','PageTemplateAdd','SetPageOrder','PageConfig_Update','PageTemplate_Add','SetPageOrder_Batch','SetPageOrder_UpMove','SetPageOrder_DownMove','Apply_Update']) {
+    const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyPageBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧黄页动作 ${action} 未返回明确迁移阻塞`);
+  }
   for (const action of ['Default','FlowInfo','FlowInfo_Submit','Flow_API','SelFlowModel','FlowItem','FlowItemInfo','FlowItemInfo_Submit','FlowItem_API','AddMisModel','AddMisModel_Submit','AddSign','AddSign_Submit','AddSign_Delete','ApplyManage','ApplyManage_Delete','MisModelManage','MisModelManage_Delete','OAConfig','SelModelFieds','SignManage']) {
     const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyOABlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧 OA 动作 ${action} 未返回明确迁移阻塞`);
   }
@@ -918,6 +926,7 @@ try {
   if (temporaryCrmClientTypeId) await jsonRequest(`${PARSE_URL}/classes/CRMSAttr/${temporaryCrmClientTypeId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时 CRM 客户类型清理失败:${error.message}`); process.exitCode = 1; });
   if (temporaryShopFareTemplateId) await jsonRequest(`${PARSE_URL}/classes/ShopFareTlp/${temporaryShopFareTemplateId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时商城运费模板清理失败:${error.message}`); process.exitCode = 1; });
   if (temporaryMisTypeId) await jsonRequest(`${PARSE_URL}/classes/MisType/${temporaryMisTypeId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时 OA 流程类型清理失败:${error.message}`); process.exitCode = 1; });
+  if (temporaryPageStyleId) await jsonRequest(`${PARSE_URL}/classes/PageStyle/${temporaryPageStyleId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时黄页样式清理失败:${error.message}`); process.exitCode = 1; });
   if (baikeRestore) await jsonRequest(`${PARSE_URL}/classes/Baike/${baikeRestore.objectId}`, { method: 'PUT', body: JSON.stringify(baikeRestore.fields) }, 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}`);

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

@@ -53,6 +53,7 @@ export const ADMIN_NAVIGATION = [
   { label: '商城运费模板', icon: 'truck', route: '/admin/resources/ShopFareTlp' },
   { label: '系统日志', icon: 'scroll-text', route: '/admin/resources/SysLog' },
   { label: 'OA 流程类型', icon: 'workflow', route: '/admin/resources/MisType' },
+  { label: '黄页样式', icon: 'layout-template', route: '/admin/resources/PageStyle' },
   { label: '百科审核', icon: 'book-marked', route: '/admin/resources/Baike' },
   { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '考试试卷', icon: 'file-check-2', route: '/admin/resources/ExamSysPapers' },

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

@@ -4115,127 +4115,127 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Page",
     "action": "ApplyAudit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 PageReg 用户黄页申请主表与审核状态"
   },
   {
     "module": "Page",
     "action": "ApplyInfo",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少 PageReg 申请详情、用户动态字段和样式关联"
   },
   {
     "module": "Page",
     "action": "PageStyle",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 pageStyles 提供帐套隔离、名称搜索、排序和分页列表"
   },
   {
     "module": "Page",
     "action": "PageStyleAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Angular 黄页样式新建/编辑抽屉替代"
   },
   {
     "module": "Page",
     "action": "PageContent",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有 CommonModel 可管理普通内容;PageReg 与黄页专属模型范围缺失"
   },
   {
     "module": "Page",
     "action": "EditContent",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有内容详情可查看;PageReg 与 PageTemplate 黄页编辑上下文缺失"
   },
   {
     "module": "Page",
     "action": "Content_Edit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有内容主字段可安全编辑;黄页模型附表与 PageReg 关系尚未迁入"
   },
   {
     "module": "Page",
     "action": "PageConfig",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标没有 SiteOption.RegPageStart/Page_ContentStatus/Page_UserCanNode 配置层"
   },
   {
     "module": "Page",
     "action": "PageTemplate",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "PageTemplate 为空且目标缺少 PageReg 用户归属,无法还原用户与公共模板树"
   },
   {
     "module": "Page",
     "action": "PageTemplateAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少 PageReg,不能确定模板 RegID、用户和样式上下文"
   },
   {
     "module": "Page",
     "action": "SetPageOrder",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可按 PageReg 隔离的页面模板集合"
   },
   {
     "module": "Page",
     "action": "PageConfig_Update",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标云函数架构没有旧黄页站点配置持久层"
   },
   {
     "module": "Page",
     "action": "PageTemplate_Add",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少 PageReg 用户归属和旧模型复选关系,不能安全新增页面模板"
   },
   {
     "module": "Page",
     "action": "SetPageOrder_Batch",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可按 PageReg 隔离的模板排序集合"
   },
   {
     "module": "Page",
     "action": "SetPageOrder_UpMove",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可按 PageReg 隔离的相邻模板顺序"
   },
   {
     "module": "Page",
     "action": "SetPageOrder_DownMove",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可按 PageReg 隔离的相邻模板顺序"
   },
   {
     "module": "Page",
     "action": "Style_Add",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 savePageStyle 校验路径、状态、防重并原子分配旧 PageNodeid"
   },
   {
     "module": "Page",
     "action": "Apply_Update",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少 PageReg 申请主表、黄页模型附表和用户申请字段"
   },
   {
     "module": "Pay",

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

@@ -94,6 +94,8 @@
         <button class="danger-action" type="button" [disabled]="updatingShopFare()" (click)="applyBulkShopFareDelete()">删除运费模板</button>
       } @else if (className() === 'MisType') {
         <button class="danger-action" type="button" [disabled]="updatingMisType()" (click)="applyBulkMisTypeDelete()">删除流程类型</button>
+      } @else if (className() === 'PageStyle') {
+        <button class="danger-action" type="button" [disabled]="updatingPageStyle()" (click)="applyBulkPageStyleDelete()">删除黄页样式</button>
       } @else if (className() === 'Baike') {
         <button type="button" [disabled]="updatingBaike()" (click)="applyBulkBaikeAction('audit')">审核</button><button type="button" [disabled]="updatingBaike()" (click)="applyBulkBaikeAction('unaudit')">取消审核</button><button type="button" [disabled]="updatingBaike()" (click)="applyBulkBaikeAction('elite')">推荐</button><button type="button" [disabled]="updatingBaike()" (click)="applyBulkBaikeAction('unelite')">取消推荐</button>
       } @else {
@@ -173,6 +175,9 @@
         @if (className() === 'MisType') {
           <section class="account-create-note"><strong>OA 流程类型</strong><span>名称和说明由专用云函数校验,旧数字 ID 在帐套内原子分配;仍被流程引用的类型不能删除。</span></section>
         }
+        @if (className() === 'PageStyle') {
+          <section class="account-create-note"><strong>用户黄页样式</strong><span>名称、模板路径、缩略图、排序、启用与默认状态由专用云函数校验;旧 PageNodeid 在帐套内原子分配,同一帐套只保留一个默认样式,仍被页面模板引用的样式不能删除。</span></section>
+        }
         @if (['ExamSysPapers', 'ExamSysQuestions', 'PaperQuestions', 'ExamType', 'ContentPublish', 'PublishNode'].includes(className())) {
           <section class="account-create-note"><strong>考试与报刊历史数据只读</strong><span>已保留帐套隔离列表、搜索和详情;试卷题目关系、答案解析、发布父子结构与旧数字 ID 不能通过通用表单修改,等待对应专用写入链完成。</span></section>
         }

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

@@ -1038,4 +1038,18 @@ describe('AdminResourceComponent', () => {
     await component.applyBulkMisTypeDelete();
     expect(functions.admin.calls.first().args).toEqual(['misTypeBatch', { className: 'MisType', action: 'delete', objectIds: ['type-a', 'type-b'] }]); expect(component.bulkMessage()).toContain('2 个 OA 流程类型');
   });
+
+  it('loads and saves yellow-page styles through dedicated operations', async () => {
+    component.className.set('PageStyle'); component.schema.set({ className: 'PageStyle', label: '黄页样式', writable: true, creatable: true, supportsSoftDelete: false, fields: [] }); component.search.set('企业'); functions.admin.and.callFake(async (operation: string) => operation === 'savePageStyle' ? { objectId: 'page-style', pageNodeid: 1 } : { className: 'PageStyle', page: 1, pageSize: 20, total: 1, results: [] });
+    await component.load(1);
+    expect(functions.admin.calls.first().args).toEqual(['pageStyles', { className: 'PageStyle', page: 1, pageSize: 20, search: '企业' }]);
+    functions.admin.calls.reset(); component.draft.set({ pageNodeName: '企业黄页', stylePath: '/Template/Page', templateIndex: 'index.html', templateIndexPic: '/images/page.png', orderid: 2, isDefault: 1, istrue: 1 }); await component.save();
+    expect(functions.admin.calls.first().args).toEqual(['savePageStyle', { className: 'PageStyle', objectId: undefined, fields: { pageNodeName: '企业黄页', stylePath: '/Template/Page', templateIndex: 'index.html', templateIndexPic: '/images/page.png', orderid: 2, isDefault: 1, istrue: 1 } }]);
+  });
+
+  it('deletes selected yellow-page styles through the protected batch operation', async () => {
+    spyOn(window, 'confirm').and.returnValue(true); component.className.set('PageStyle'); component.selectedIds.set(['style-a', 'style-b']); functions.admin.and.callFake(async (operation: string) => operation === 'pageStyleBatch' ? { action: 'delete', updated: 2 } : { className: 'PageStyle', page: 1, pageSize: 20, total: 0, results: [] });
+    await component.applyBulkPageStyleDelete();
+    expect(functions.admin.calls.first().args).toEqual(['pageStyleBatch', { className: 'PageStyle', action: 'delete', objectIds: ['style-a', 'style-b'] }]); expect(component.bulkMessage()).toContain('2 个黄页样式');
+  });
 });

+ 16 - 4
src/app/admin/pages/admin-resource.component.ts

@@ -148,18 +148,19 @@ export class AdminResourceComponent implements OnInit {
   readonly filterSystemLogStartDate = signal('');
   readonly filterSystemLogEndDate = signal('');
   readonly updatingMisType = signal(false);
+  readonly updatingPageStyle = 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', 'typeName', 'tlpName', 'pName', 'pTitle', 'questionTitle', 'tName', 'newsName', 'nodeName', 'value', 'remark', 'qtitle', 'sName', 'storeName', 'styleName', 'count', 'content', 'str1', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'userName', 'realName', 'cname', 'type1', 'type2', 'detail', 'cdate', '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', 'typeName', 'pageNodeName', 'tlpName', 'pName', 'pTitle', 'questionTitle', 'tName', 'newsName', 'nodeName', 'value', 'remark', 'qtitle', 'sName', 'storeName', 'styleName', 'count', 'content', 'str1', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'userName', 'realName', 'cname', 'type1', 'type2', 'detail', 'cdate', '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);
   });
   readonly pageCount = computed(() => Math.max(1, Math.ceil((this.page()?.total ?? 0) / (this.page()?.pageSize || 20))));
-  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer', 'ServiceSeat', 'Temp', 'StoreApplication', 'StoreStyle', 'UserLevel', 'Baike', 'CRMSAttr', 'ShopFareTlp', 'MisType'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer', 'ServiceSeat', 'Temp', 'StoreApplication', 'StoreStyle', 'UserLevel', 'Baike', 'CRMSAttr', 'ShopFareTlp', 'MisType', 'PageStyle'].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));
@@ -196,7 +197,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' : this.className() === 'ContentTagKey' ? 'contentTagKeys' : this.className() === 'UserLevel' ? 'userLevels' : this.className() === 'UserMoneyLog' ? 'userMoneyLogs' : this.className() === 'Baike' ? 'baikeEntries' : this.className() === 'CRMSAttr' ? 'crmClientTypes' : this.className() === 'ShopFareTlp' ? 'shopFareTemplates' : this.className() === 'SysLog' ? 'systemLogs' : this.className() === 'MisType' ? 'misTypes' : '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' : this.className() === 'UserLevel' ? 'userLevels' : this.className() === 'UserMoneyLog' ? 'userMoneyLogs' : this.className() === 'Baike' ? 'baikeEntries' : this.className() === 'CRMSAttr' ? 'crmClientTypes' : this.className() === 'ShopFareTlp' ? 'shopFareTemplates' : this.className() === 'SysLog' ? 'systemLogs' : this.className() === 'MisType' ? 'misTypes' : this.className() === 'PageStyle' ? 'pageStyles' : '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(); }
@@ -293,7 +294,8 @@ export class AdminResourceComponent implements OnInit {
                                                                 : this.className() === 'UserLevel' ? 'saveUserLevel'
                                                                   : this.className() === 'CRMSAttr' ? 'saveCrmClientType'
                                                                     : this.className() === 'ShopFareTlp' ? 'saveShopFareTemplate'
-                                                                      : this.className() === 'MisType' ? 'saveMisType' : 'save';
+                                                                      : this.className() === 'MisType' ? 'saveMisType'
+                                                                        : this.className() === 'PageStyle' ? 'savePageStyle' : '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();
@@ -770,6 +772,15 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : 'OA 流程类型删除失败'); }
     finally { this.updatingMisType.set(false); }
   }
+  async applyBulkPageStyleDelete(): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'PageStyle' || !objectIds.length) { this.bulkError.set('请先选择黄页样式'); return; }
+    if (!confirm(`确认删除选中的 ${objectIds.length} 个黄页样式?`)) return;
+    this.updatingPageStyle.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('pageStyleBatch', { className: 'PageStyle', action: 'delete', objectIds }); await this.load(); this.bulkMessage.set(`已删除 ${result.updated} 个黄页样式`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '黄页样式删除失败'); }
+    finally { this.updatingPageStyle.set(false); }
+  }
   async applyBulkBaikeAction(action: 'audit' | 'unaudit' | 'elite' | 'unelite'): Promise<void> {
     const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
     if (this.className() !== 'Baike' || !objectIds.length) { this.bulkError.set('请先选择百科词条'); return; }
@@ -795,6 +806,7 @@ export class AdminResourceComponent implements OnInit {
       else if (this.className() === 'CRMSAttr') await this.functions.admin('crmClientTypeBatch', { className: 'CRMSAttr', action: 'delete', objectIds: [objectId] });
       else if (this.className() === 'ShopFareTlp') await this.functions.admin('shopFareTemplateBatch', { className: 'ShopFareTlp', action: 'delete', objectIds: [objectId] });
       else if (this.className() === 'MisType') await this.functions.admin('misTypeBatch', { className: 'MisType', action: 'delete', objectIds: [objectId] });
+      else if (this.className() === 'PageStyle') await this.functions.admin('pageStyleBatch', { className: 'PageStyle', action: 'delete', objectIds: [objectId] });
       else await this.functions.admin('delete', { className: this.className(), objectId });
       await this.load();
     }

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff