瀏覽代碼

feat: migrate shop fare administration

彭峰 2 月之前
父節點
當前提交
a177517ed0

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

@@ -27,6 +27,7 @@
 - CRM 中仍有真实数据的 `CType/CTypeAdd/CType_Add/CType_Del` 已迁为 `crmClientTypes/saveCrmClientType/crmClientTypeBatch` 与 Angular“CRM 客户类型”页面:只管理 `CRMSAttr.ztype=ctype`,按帐套隔离、名称防重并通过 PostgreSQL 事务锁原子分配旧字符串编号。旧 `CRMS_Client`、`CRMS_Contact`、`IServer`、经销商申请和 Licence/PDF 链均未进入目标 Schema;`ContactInfo` 是企业微信联系人,未被错误当作 CRM 联系人复用。
 - 旧 `ECharts/ECharts` 六个动作依赖 `Content_Chart` 的 TName/ChartTitle/ToolBox/Package/option/SType/Tag 数据、七类 .NET 配置生成器、`SiteInfo.SiteUrl` 与 `/Common/ShowM` 渲染页;目标 Schema 中不存在任何 chart/echart/graph 数据类。所有动作通过 `legacyEChartsBlocker` 返回逐项原因,不用问卷统计或普通 Angular 图表冒充旧图表编辑器。
 - Exam 模块已把仍有历史数据的 `ExamSysPapers/ExamSysQuestions/PaperQuestions/ExamType/ContentPublish/PublishNode` 接入 Angular 只读列表、搜索、分页和详情,并关闭通用写入,防止破坏试卷 `qids/questList`、复合答案和报刊父子结构。教材版本、学校、班级、班级学员、旧 Course、答卷评分数据均未迁入;对应 21 个动作由 `legacyExamBlocker` 返回数据或运行时阻塞。`CourseBinding` 明确不冒充旧考试 Course。
+- 商城 `DeliverType/DeliverAdd/DeliverAdd_Submit/Deliver_API` 已由 `ShopFareTlp` 真实历史数据、`shopFareTemplates/saveShopFareTemplate/shopFareTemplateBatch` 和 Angular“商城运费模板”页面迁移:校验 Express JSON、名称防重并原子分配旧数字 ID。订单/物流单、Manufacturers 厂商和 Trademark 品牌表均不存在,剩余 12 个动作返回细分阻塞;`DeliveryCenter` 不被误作运费模板。
 - 旧 `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/旧模板运行时。

文件差異過大導致無法顯示
+ 7 - 2
scripts/deploy-admin-functions.mjs


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

@@ -141,6 +141,22 @@ const explicit = new Map([
   ['Exam/PublishAdd', ['partial', '现有 ContentPublish 详情可查看;报刊父项上下文与专用编辑器尚未复刻']],
   ['Exam/PublishAdd_Submit', ['partial', 'ContentPublish title/imgPath/json/pid/cdate/attachFile 写入尚未开放专用校验流程']],
   ['Exam/PublishDesign', ['blocked-infrastructure', '旧报刊可视化设计器的前端资产、布局协议与渲染运行时未迁入']],
+  ['Shop / Exp/DeliverType', ['implemented', '已由 shopFareTemplates 提供帐套隔离、名称搜索、分页和旧运费规则列表']],
+  ['Shop / Exp/DeliverAdd', ['implemented', '已由 Angular 商城运费模板新建/编辑抽屉替代']],
+  ['Shop / Exp/DeliverAdd_Submit', ['implemented', '已由 saveShopFareTemplate 校验计价方式、规则 JSON、名称防重并原子分配旧编号']],
+  ['Shop / Exp/Deliver_API', ['implemented', '已由 shopFareTemplateBatch 覆盖每批 1–100 条帐套隔离删除']],
+  ['Shop / Exp/OrderSend', ['blocked-data', '目标 Schema 缺少旧 OrderList 订单主表与 Order_Exp 物流单']],
+  ['Shop / Exp/OrderSend_Submit', ['blocked-infrastructure', '目标缺少订单物流状态、物流单、用户收件手机、SMSConfig 发货模板和 SafeMobile 日志链']],
+  ['Shop / Exp/Factory', ['blocked-data', '目标 Schema 缺少旧 Manufacturers 厂商主表与历史厂商数据']],
+  ['Shop / Exp/FactoryAdd', ['blocked-data', '目标无可编辑或新增的 Manufacturers 厂商记录']],
+  ['Shop / Exp/FactoryAdd_Submit', ['blocked-data', '目标缺少厂商名称、简称、地址、电话、传真、网站、分类、图片和介绍持久层']],
+  ['Shop / Exp/Factory_API', ['blocked-data', '目标没有可删除或启用的厂商记录']],
+  ['Shop / Exp/Factory_select', ['blocked-data', '目标没有可按 Producername 搜索选择的厂商数据']],
+  ['Shop / Exp/Trademark', ['blocked-data', '目标 Schema 缺少旧 Trademark 品牌主表与历史品牌数据']],
+  ['Shop / Exp/TrademarkAdd', ['blocked-data', '目标无可编辑或新增的 Trademark 品牌记录']],
+  ['Shop / Exp/Trademark_Submit', ['blocked-data', '目标缺少品牌名称、厂商、分类、启用/置顶/推荐、图片和介绍持久层']],
+  ['Shop / Exp/Trademark_API', ['blocked-data', '目标没有可删除或启用的品牌记录']],
+  ['Shop / Exp/Trademark_select', ['blocked-data', '目标没有可按 Trname 搜索选择的品牌数据']],
   ['Content / Node/NodeManage', ['implemented', '已由 Node 资源列表、分页与专用结构操作替代']],
   ['Content / Node/NodeSearch', ['implemented', '已覆盖按栏目名称搜索并保持帐套隔离']],
   ['Content / Node/NodeAdd', ['implemented', '已由 Node 动态表单与父节点结构控件替代']],

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

@@ -118,6 +118,7 @@ let userLevelRestore = null;
 let temporaryUserLevelId = '';
 let baikeRestore = null;
 let temporaryCrmClientTypeId = '';
+let temporaryShopFareTemplateId = '';
 const temporaryLessonIds = [];
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
@@ -653,6 +654,13 @@ try {
   for (const action of ['AddEngLishQuestion','Setting','VersionList','AddVersion','SchoolManage','AddSchool','ClassRoomManage','AddClassRoom','StudentList','CourseManage','AddCourse','ToScore','Papers_Add','Question_Add','Version_GetList','Version_Add','School_Add','ClassRoom_Add','Course_Add','Setting_Update','PublishDesign']) {
     const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyExamBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧考试动作 ${action} 未返回明确迁移阻塞`);
   }
+  const fareSuffix = Date.now().toString(36); const fareRules = [{ name: '冒烟快递', mode: '1', enabled: true, price: '10', plus: '1', free_sel: '0', free_num: '', free_money: '' }]; const createdFare = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveShopFareTemplate', className: 'ShopFareTlp', fields: { tlpName: `冒烟运费-${fareSuffix}`, priceMode: 1, express: JSON.stringify(fareRules), remind: '临时运费模板', remind2: '' } }); temporaryShopFareTemplateId = String(createdFare.objectId || ''); if (!temporaryShopFareTemplateId || !Number(createdFare.id) || createdFare.sourceKey !== `[["ID",${Number(createdFare.id)}]]`) throw new Error(`商城运费模板原子编号创建异常:${JSON.stringify(createdFare)}`);
+  const fareList = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'shopFareTemplates', className: 'ShopFareTlp', page: 1, pageSize: 20, search: fareSuffix }); if (fareList.total !== 1 || fareList.results?.[0]?.objectId !== temporaryShopFareTemplateId) throw new Error('商城运费模板专用列表失败');
+  const updatedFare = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveShopFareTemplate', className: 'ShopFareTlp', objectId: temporaryShopFareTemplateId, fields: { tlpName: `更新运费-${fareSuffix}`, priceMode: 1, express: JSON.stringify(fareRules), remind: '已更新', remind2: '' } }); if (updatedFare.tlpName !== `更新运费-${fareSuffix}` || Number(updatedFare.id) !== Number(createdFare.id)) throw new Error('商城运费模板专用编辑失败');
+  const deletedFare = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'shopFareTemplateBatch', className: 'ShopFareTlp', action: 'delete', objectIds: [temporaryShopFareTemplateId] }); if (deletedFare.updated !== 1) throw new Error('商城运费模板专用删除失败'); temporaryShopFareTemplateId = '';
+  for (const action of ['OrderSend','OrderSend_Submit','Factory','FactoryAdd','FactoryAdd_Submit','Factory_API','Factory_select','Trademark','TrademarkAdd','Trademark_Submit','Trademark_API','Trademark_select']) {
+    const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyShopExpBlocker', action }, 501); if (!String(blocked.message).includes('migration_blocked')) throw new Error(`旧商城配送动作 ${action} 未返回明确迁移阻塞`);
+  }
   await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'contentBatch', className: 'CommonModel', action: 'status', objectIds: [temporaryAppointmentCommonObjectId], status: 99 });
 
   const page = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'list', className: 'CourseAppointment', page: 1, pageSize: 2 });
@@ -859,6 +867,7 @@ try {
   if (userLevelRestore) await jsonRequest(`${PARSE_URL}/classes/UserLevel/${userLevelRestore.objectId}`, { method: 'PUT', body: JSON.stringify(userLevelRestore.fields) }, true).catch((error) => { console.error(`积分等级冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
   if (temporaryUserLevelId) await jsonRequest(`${PARSE_URL}/classes/UserLevel/${temporaryUserLevelId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时积分等级清理失败:${error.message}`); process.exitCode = 1; });
   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 (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

@@ -49,6 +49,7 @@ export const ADMIN_NAVIGATION = [
   { label: '积分等级', icon: 'badge-cent', route: '/admin/resources/UserLevel' },
   { label: '资金积分流水', icon: 'wallet-cards', route: '/admin/resources/UserMoneyLog' },
   { label: 'CRM 客户类型', icon: 'contact-round', route: '/admin/resources/CRMSAttr' },
+  { label: '商城运费模板', icon: 'truck', route: '/admin/resources/ShopFareTlp' },
   { 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' },

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

@@ -2302,113 +2302,113 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Shop / Exp",
     "action": "DeliverType",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 shopFareTemplates 提供帐套隔离、名称搜索、分页和旧运费规则列表"
   },
   {
     "module": "Shop / Exp",
     "action": "DeliverAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Angular 商城运费模板新建/编辑抽屉替代"
   },
   {
     "module": "Shop / Exp",
     "action": "DeliverAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 saveShopFareTemplate 校验计价方式、规则 JSON、名称防重并原子分配旧编号"
   },
   {
     "module": "Shop / Exp",
     "action": "Deliver_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 shopFareTemplateBatch 覆盖每批 1–100 条帐套隔离删除"
   },
   {
     "module": "Shop / Exp",
     "action": "OrderSend",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 OrderList 订单主表与 Order_Exp 物流单"
   },
   {
     "module": "Shop / Exp",
     "action": "OrderSend_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "目标缺少订单物流状态、物流单、用户收件手机、SMSConfig 发货模板和 SafeMobile 日志链"
   },
   {
     "module": "Shop / Exp",
     "action": "Factory",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 Manufacturers 厂商主表与历史厂商数据"
   },
   {
     "module": "Shop / Exp",
     "action": "FactoryAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标无可编辑或新增的 Manufacturers 厂商记录"
   },
   {
     "module": "Shop / Exp",
     "action": "FactoryAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少厂商名称、简称、地址、电话、传真、网站、分类、图片和介绍持久层"
   },
   {
     "module": "Shop / Exp",
     "action": "Factory_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可删除或启用的厂商记录"
   },
   {
     "module": "Shop / Exp",
     "action": "Factory_select",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可按 Producername 搜索选择的厂商数据"
   },
   {
     "module": "Shop / Exp",
     "action": "Trademark",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 Trademark 品牌主表与历史品牌数据"
   },
   {
     "module": "Shop / Exp",
     "action": "TrademarkAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标无可编辑或新增的 Trademark 品牌记录"
   },
   {
     "module": "Shop / Exp",
     "action": "Trademark_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标缺少品牌名称、厂商、分类、启用/置顶/推荐、图片和介绍持久层"
   },
   {
     "module": "Shop / Exp",
     "action": "Trademark_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可删除或启用的品牌记录"
   },
   {
     "module": "Shop / Exp",
     "action": "Trademark_select",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标没有可按 Trname 搜索选择的品牌数据"
   },
   {
     "module": "Shop / Exp",

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

@@ -87,6 +87,8 @@
         <button class="danger-action" type="button" [disabled]="updatingUserLevel()" (click)="applyBulkUserLevelDelete()">删除等级</button>
       } @else if (className() === 'CRMSAttr') {
         <button class="danger-action" type="button" [disabled]="updatingCrmType()" (click)="applyBulkCrmClientTypeDelete()">删除客户类型</button>
+      } @else if (className() === 'ShopFareTlp') {
+        <button class="danger-action" type="button" [disabled]="updatingShopFare()" (click)="applyBulkShopFareDelete()">删除运费模板</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 {
@@ -160,6 +162,9 @@
         @if (className() === 'CRMSAttr') {
           <section class="account-create-note"><strong>CRM 客户类型</strong><span>仅展示旧 ztype=ctype 记录;编号由云函数在帐套内原子分配,名称防重。客户、联系人、服务记录和经销商申请主表未迁入,不会以其他数据类冒充。</span></section>
         }
+        @if (className() === 'ShopFareTlp') {
+          <section class="account-create-note"><strong>商城运费模板</strong><span>模板名称、计价方式、运费规则 JSON 与两项备注由专用云函数校验;旧数字 ID 在帐套内原子分配。DeliveryCenter 是不同业务,不参与本页。</span></section>
+        }
         @if (['ExamSysPapers', 'ExamSysQuestions', 'PaperQuestions', 'ExamType', 'ContentPublish', 'PublishNode'].includes(className())) {
           <section class="account-create-note"><strong>考试与报刊历史数据只读</strong><span>已保留帐套隔离列表、搜索和详情;试卷题目关系、答案解析、发布父子结构与旧数字 ID 不能通过通用表单修改,等待对应专用写入链完成。</span></section>
         }
@@ -171,7 +176,8 @@
         }
         @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)" /> }
+            @if (className() === 'ShopFareTlp' && field.name === 'express') { <textarea rows="12" [ngModel]="inputValue(field)" (ngModelChange)="setField(field, $event)" placeholder='[{"name":"快递","mode":"1","enabled":true,"price":"10"}]'></textarea> }
+            @else if (field.type === 'Boolean') { <input type="checkbox" [ngModel]="draft()[field.name] === true" (ngModelChange)="setField(field, $event)" /> }
             @else if (field.type === 'Object' || field.type === 'Array') { <textarea rows="5" [class.invalid]="fieldErrors()[field.name]" [ngModel]="inputValue(field)" (ngModelChange)="setField(field, $event)"></textarea>@if (fieldErrors()[field.name]; as fieldError) { <small class="field-error">{{ fieldError }}</small> } }
             @else { <input [type]="field.type === 'Number' ? 'number' : field.type === 'Date' ? 'datetime-local' : 'text'" [ngModel]="inputValue(field)" (ngModelChange)="setField(field, $event)" [placeholder]="field.type === 'Pointer' ? (field.targetClass + ' objectId') : field.name" /> }
           </label>

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

@@ -991,4 +991,29 @@ describe('AdminResourceComponent', () => {
     expect(functions.admin.calls.first().args).toEqual(['crmClientTypeBatch', { className: 'CRMSAttr', action: 'delete', objectIds: ['crm-a', 'crm-b'] }]);
     expect(component.bulkMessage()).toContain('2 个客户类型');
   });
+
+  it('loads 商城运费模板 through the dedicated operation', async () => {
+    component.className.set('ShopFareTlp'); component.schema.set({ className: 'ShopFareTlp', label: '商城运费模板', writable: true, creatable: true, supportsSoftDelete: false, fields: [] }); component.search.set('默认'); functions.admin.and.resolveTo({ className: 'ShopFareTlp', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.load(1);
+
+    expect(functions.admin).toHaveBeenCalledWith('shopFareTemplates', { className: 'ShopFareTlp', page: 1, pageSize: 20, search: '默认' });
+  });
+
+  it('saves 商城运费模板 through the protected lifecycle operation', async () => {
+    component.className.set('ShopFareTlp'); component.draft.set({ tlpName: '默认快递', priceMode: 1, express: '[]', remind: '', remind2: '' }); functions.admin.and.callFake(async (operation: string) => operation === 'saveShopFareTemplate' ? { objectId: 'fare-object', id: 2 } : { className: 'ShopFareTlp', page: 1, pageSize: 20, total: 2, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveShopFareTemplate', { className: 'ShopFareTlp', objectId: undefined, fields: { tlpName: '默认快递', priceMode: 1, express: '[]', remind: '', remind2: '' } }]);
+  });
+
+  it('deletes selected 商城运费模板 through the dedicated batch operation', async () => {
+    spyOn(window, 'confirm').and.returnValue(true); component.className.set('ShopFareTlp'); component.selectedIds.set(['fare-a', 'fare-b']); functions.admin.and.callFake(async (operation: string) => operation === 'shopFareTemplateBatch' ? { action: 'delete', updated: 2 } : { className: 'ShopFareTlp', page: 1, pageSize: 20, total: 0, results: [] });
+
+    await component.applyBulkShopFareDelete();
+
+    expect(functions.admin.calls.first().args).toEqual(['shopFareTemplateBatch', { className: 'ShopFareTlp', action: 'delete', objectIds: ['fare-a', 'fare-b'] }]);
+    expect(component.bulkMessage()).toContain('2 个运费模板');
+  });
 });

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

@@ -142,18 +142,19 @@ export class AdminResourceComponent implements OnInit {
   readonly filterBaikeElite = signal(-100);
   readonly updatingBaike = signal(false);
   readonly updatingCrmType = signal(false);
+  readonly updatingShopFare = 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', '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', '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', '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', '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'].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'].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));
@@ -189,7 +190,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' : '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' : '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(); }
@@ -283,7 +284,8 @@ export class AdminResourceComponent implements OnInit {
                                                             : this.className() === 'StoreStyle' ? 'saveStoreStyle'
                                                               : this.className() === 'ContentTagKey' ? 'saveContentTagKeys'
                                                                 : this.className() === 'UserLevel' ? 'saveUserLevel'
-                                                                  : this.className() === 'CRMSAttr' ? 'saveCrmClientType' : 'save';
+                                                                  : this.className() === 'CRMSAttr' ? 'saveCrmClientType'
+                                                                    : this.className() === 'ShopFareTlp' ? 'saveShopFareTemplate' : '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();
@@ -742,6 +744,15 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : '客户类型删除失败'); }
     finally { this.updatingCrmType.set(false); }
   }
+  async applyBulkShopFareDelete(): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'ShopFareTlp' || !objectIds.length) { this.bulkError.set('请先选择商城运费模板'); return; }
+    if (!confirm(`确认删除选中的 ${objectIds.length} 个运费模板?`)) return;
+    this.updatingShopFare.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('shopFareTemplateBatch', { className: 'ShopFareTlp', action: 'delete', objectIds }); await this.load(); this.bulkMessage.set(`已删除 ${result.updated} 个运费模板`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '运费模板删除失败'); }
+    finally { this.updatingShopFare.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; }

部分文件因文件數量過多而無法顯示