Преглед на файлове

feat: migrate holiday management safely

彭峰 преди 1 месец
родител
ревизия
7f61ec3da7

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

@@ -36,8 +36,9 @@
 - 旧数据字典 `DicCateList/DictCate_API/DicCateSel/DicItemList/DicItem_API` 已按官方 `ZL_Datadiccategory/ZL_Datadic` 模型迁为 `saveDictionaryCategory/saveDictionaryItem/dictionaryCategories/dictionaryBatch`:分类与字典项分别分配旧 `DicCateID/DicID`,校验帐套内分类名称和分类内字典项名称防重,支持每批 1–100 条启用、停用和删除;分类仍有字典项时禁止删除。线上这两类的业务字段由迁移兼容层从登记数据还原,Parse Schema 虽声明字段但 PostgreSQL 物理表没有对应列,因此编号改为经 Parse 读取全局最大值并依靠唯一 `sourceKey` 冲突重试,未伪装成不可执行的物理列事务;新分类编号还会高于所有既有 `diccate` 引用,避免错误接管历史字典项。Angular 新增分类、字典项导航和帐套隔离分类选择控件。
 - 旧多级数据字典 `GradeCateManage/GCateAdd/GCateAdd_Submit/GCate_API/GradeOption/GOptionAdd/GOptionAdd_Submit/GOption_API` 已按官方 `ZL_GradeCate/ZL_Grade` 模型迁为 `saveGradeCategory/saveGradeOption/gradeCategories/gradeOptions/gradeBatch`:分类维护至少两级的 `gradeField` 层级别名,选项按分类和父选项自动推导 `grade`,校验同级名称防重、父级归属和最大层级;编辑选项只允许改名,不能篡改 `cate/parentId/grade`。分类和选项分别保护仍有选项、下级选项的删除,Angular 提供按分类及父选项查看当前层、专用新建表单与批量删除。`GradeCate` 的兼容层计数会错误返回 0,因此编号和读取同样使用 Parse 行结果与唯一 `sourceKey` 冲突重试,不依赖失真的 count 或缺失物理业务列。
 - 旧商城扩展 `Currency/Currency_API` 的现有货币管理已迁到目标 `Currency`:`saveCurrency` 白名单更新货币名称、2–10 位大写代码、符号、正数当前汇率与备注,并校验帐套内名称及代码唯一;`currencyBatch` 支持每批 1–100 条帐套隔离删除。`CurrencyAdd/CurrencyAdd_Submit` 明确列为基础设施阻塞:旧数字字段就叫 `id`,与 Parse SDK 对象保留 `id` 冲突,兼容层物理表又没有可写业务列,实测以 `sourceKey` 新增后重新读取仍无法得到旧数字 ID;Angular 已隐藏新增入口,云函数对新增返回 501,避免产生坏数据。
+- 旧系统 `Config/Holiday/Holiday_API` 的现有节假日管理已迁到 `SysHoliday`:`saveHoliday` 只允许更新名称、公/农历类型、`MM月DD日` 日期、影响天数、状态与展示文本,保护旧 ID、创建人、创建时间和来源键;`holidayBatch` 支持每批 1–100 条帐套隔离删除。`HolidayAdd/HolidayAdd_Submit` 因 `SysHoliday.id` 与 Parse 保留字段冲突且物理表缺少可写旧 ID 列而明确列为基础设施阻塞,Angular 隐藏新增入口,新增请求返回 501。目标 `CourseAppointment/LessonRecord` 均无节假日外键,未伪造删除级联。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 63 项已实现、32 项部分实现、114 项数据阻塞、191 项基础设施阻塞、642 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 65 项已实现、32 项部分实现、114 项数据阻塞、193 项基础设施阻塞、638 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

+ 29 - 3
scripts/deploy-admin-functions.mjs

@@ -13,7 +13,7 @@ const CLASS_LABELS = {
   ExamClass: '试题分类', ExamSysQuestions: '考试题库', ExamSysPapers: '试卷', ExamType: '考试类型', ExamPoint: '知识点', PaperQuestions: '试卷题目',
   Datadiccategory: '数据字典分类', Datadic: '数据字典项',
   GradeCate: '多级字典分类', Grade: '多级字典选项',
-  Currency: '货币管理',
+  Currency: '货币管理', SysHoliday: '节假日管理',
   Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
   GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科', Search: '搜索记录', Agency: '代理机构', DeliveryCenter: '交付中心'
 };
@@ -41,7 +41,8 @@ const CLASS_SYSTEM_FIELDS = {
   Datadic: new Set(['dicid','diccate','sourceKey']),
   GradeCate: new Set(['cateId','sourceKey']),
   Grade: new Set(['gradeId','cate','parentId','grade','sourceKey']),
-  Currency: new Set(['id','sourceKey'])
+  Currency: new Set(['id','sourceKey']),
+  SysHoliday: new Set(['id','cdate','cadminId','cuserId','sourceKey'])
 };
 
 function inputOf(request) {
@@ -907,6 +908,30 @@ async function handler(request, response) {
       if (String(input.className || '') !== 'Currency' || String(input.action || '') !== 'delete') fail(400, '不支持的货币批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条货币记录');
       const fields = await schemaFor('Currency'); const query = new Parse.Query('Currency'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分货币记录不存在或不属于当前帐套'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'currency-delete', 'Currency', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
     }
+    if (operation === 'saveHoliday') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
+      if (!objectId) fail(501, 'migration_blocked: SysHoliday.id 与 Parse 对象保留 id 冲突,且 PostgreSQL 物理表缺少可写旧 ID 列,不能安全新增节假日');
+      const name = String(payload.name || '').trim(); const htype = String(payload.htype || '').trim(); const hdate = String(payload.hdate || '').trim();
+      const hdateStart = Number(payload.hdateStart); const hdateEnd = Number(payload.hdateEnd); const zstatus = Number(payload.zstatus);
+      const strings = { ztype: String(payload.ztype || '').trim(), himage: String(payload.himage || '').trim(), remind: String(payload.remind || '').trim(), heffect: String(payload.heffect || '').trim(), hcontent: String(payload.hcontent || '').trim() };
+      if (!name || name.length > 100) fail(400, '节假日名称长度应为 1 至 100 位');
+      if (!['公历','农历'].includes(htype)) fail(400, '节假日类型只能是公历或农历');
+      if (!/^(0[1-9]|1[0-2])月(0[1-9]|[12][0-9]|3[01])日$/.test(hdate)) fail(400, '节假日日期必须使用 MM月DD日 格式');
+      if (!Number.isInteger(hdateStart) || hdateStart < 0 || hdateStart > 365 || !Number.isInteger(hdateEnd) || hdateEnd < 0 || hdateEnd > 365) fail(400, '提前与延后影响天数必须是 0 至 365 的整数');
+      if (![0,99].includes(zstatus)) fail(400, '节假日状态只能是停用或启用');
+      if (strings.ztype.length > 100 || strings.himage.length > 500 || strings.remind.length > 1000 || strings.heffect.length > 500 || strings.hcontent.length > 4000) fail(400, '节假日文本字段超过允许长度');
+      const fields = await schemaFor('SysHoliday'); const query = new Parse.Query('SysHoliday'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
+      const duplicateQuery = new Parse.Query('SysHoliday'); applyTenant(duplicateQuery, fields, context, input.companyId); duplicateQuery.limit(1000); const duplicates = await duplicateQuery.find({ useMasterKey: true });
+      if (duplicates.some((entry) => entry.id !== objectId && String(entry.get('name') || '').trim().toLowerCase() === name.toLowerCase() && String(entry.get('htype') || '') === htype && String(entry.get('hdate') || '') === hdate)) fail(409, '同一帐套的节假日名称、类型和日期不能完全重复');
+      target.set('name', name); target.set('htype', htype); target.set('hdate', hdate); target.set('hdateStart', hdateStart); target.set('hdateEnd', hdateEnd); target.set('zstatus', zstatus);
+      for (const [field, value] of Object.entries(strings)) target.set(field, value);
+      try { await target.save(null, { useMasterKey: true }); } catch (error) { fail(422, '节假日写入失败: ' + String(error && error.message || error)); }
+      await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-holiday', 'SysHoliday', target.id); return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'holidayBatch') {
+      if (String(input.className || '') !== 'SysHoliday' || String(input.action || '') !== 'delete') fail(400, '不支持的节假日批量操作'); const requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100) fail(400, '每次请选择 1 至 100 条节假日记录');
+      const fields = await schemaFor('SysHoliday'); const query = new Parse.Query('SysHoliday'); applyTenant(query, fields, context, input.companyId); query.containedIn('objectId', objectIds); query.limit(objectIds.length); const targets = await query.find({ useMasterKey: true }); if (targets.length !== objectIds.length) fail(404, '部分节假日记录不存在或不属于当前帐套'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'holiday-delete', 'SysHoliday', target.id); return response.json({ success: true, data: { action: 'delete', updated: targets.length, results: [] } });
+    }
     if (operation === 'saveExamClass') {
       const objectId = String(input.objectId || '');
       const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
@@ -1170,7 +1195,7 @@ async function handler(request, response) {
     const classWritable = !READ_ONLY_CLASSES.has(className);
     if (operation === 'schema') {
       const fieldList = Object.entries(fields).filter(([name]) => !HIDDEN_FIELDS.has(name) && !/(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(name)).map(([name, field]) => ({ name, type: field.type, targetClass: field.targetClass, required: field.required === true, writable: classWritable && !isSystemField(className, name) && GENERIC_WRITE_TYPES.has(field.type) }));
-      return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, creatable: classWritable && !['CommonModel','Model','ModelField','Guestbook','Currency'].includes(className), supportsSoftDelete: Boolean(fields.isDeleted) } });
+      return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, creatable: classWritable && !['CommonModel','Model','ModelField','Guestbook','Currency','SysHoliday'].includes(className), supportsSoftDelete: Boolean(fields.isDeleted) } });
     }
     if (operation === 'list') {
       const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20));
@@ -1203,6 +1228,7 @@ async function handler(request, response) {
       if (className === 'Datadiccategory' || className === 'Datadic') fail(400, '数据字典必须走专用保存流程');
       if (className === 'GradeCate' || className === 'Grade') fail(400, '多级数据字典必须走专用保存流程');
       if (className === 'Currency') fail(400, '货币必须走专用保存流程');
+      if (className === 'SysHoliday') fail(400, '节假日必须走专用保存流程');
       if (className === 'CommonModel' && !objectId) fail(400, '内容新增必须同时写入模型附表,不能走通用保存');
       let object;
       if (objectId) { const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); object = await query.get(objectId, { useMasterKey: true }); } else object = new Parse.Object(className);

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

@@ -113,6 +113,10 @@ const explicit = new Map([
   ['Shop / Exp/CurrencyAdd', ['blocked-infrastructure', 'Currency.id 与 Parse 保留 id 冲突,物理表又缺少可写旧 ID 列,Angular 已隐藏新增入口']],
   ['Shop / Exp/CurrencyAdd_Submit', ['blocked-infrastructure', '无法为新货币持久化旧数字 ID;saveCurrency 对新增明确返回 migration_blocked']],
   ['Shop / Exp/Currency_API', ['implemented', '已由 currencyBatch 帐套隔离批量删除替代']],
+  ['Config/Holiday', ['implemented', '已提供帐套节假日列表、搜索、分页、编辑与批量删除']],
+  ['Config/Holiday_API', ['implemented', '已由 saveHoliday 白名单更新和 holidayBatch 帐套隔离删除替代']],
+  ['Config/HolidayAdd', ['blocked-infrastructure', 'SysHoliday.id 与 Parse 保留 id 冲突,物理表又缺少可写旧 ID 列,Angular 已隐藏新增入口']],
+  ['Config/HolidayAdd_Submit', ['blocked-infrastructure', '无法为新节假日持久化旧数字 ID;saveHoliday 对新增明确返回 migration_blocked']],
 ]);
 
 const blockedDataModules = new Map([

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

@@ -234,6 +234,11 @@ try {
   const nodeSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'Node' });
   const nodeFieldMap = Object.fromEntries((nodeSchema.fields || []).map((field) => [field.name, field]));
   if (nodeFieldMap.nodeId?.writable !== false || nodeFieldMap.parentId?.writable !== false || nodeFieldMap.depth?.writable !== false || nodeFieldMap.zstatus?.writable !== false || nodeFieldMap.sourceKey?.writable !== false) throw new Error('栏目结构字段未从通用编辑中隔离');
+  const holidaySchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'SysHoliday' });
+  const holidayFieldMap = Object.fromEntries((holidaySchema.fields || []).map((field) => [field.name, field]));
+  if (holidaySchema.creatable !== false || ['id','cdate','cadminId','cuserId','sourceKey'].some((name) => holidayFieldMap[name]?.writable !== false)) throw new Error('节假日系统字段未从新增或通用编辑中隔离');
+  const holidayPage = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'list', className: 'SysHoliday', page: 1, pageSize: 20 });
+  if (!Array.isArray(holidayPage.results)) throw new Error('节假日列表返回异常');
   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' } });
@@ -424,6 +429,8 @@ try {
   await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'gradeBatch', className: 'GradeCate', action: 'delete', objectIds: [gradeCategory.objectId] }); temporaryGradeCategoryIds.pop();
   const currencyCreateBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveCurrency', className: 'Currency', fields: { title: '禁止无旧 ID 货币', currencyName: 'TST', currencySymbol: 'T$', currentExchange: 1.25, remark: '应被阻塞' } }, 501);
   if (!String(currencyCreateBlocked.message).includes('保留 id')) throw new Error('货币新增未返回明确旧 ID 阻塞原因');
+  const holidayCreateBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveHoliday', className: 'SysHoliday', fields: { name: '禁止无旧 ID 节假日' } }, 501);
+  if (!String(holidayCreateBlocked.message).includes('保留 id')) throw new Error('节假日新增未返回明确旧 ID 阻塞原因');
   if (dictionaryCategory.company?.__type !== 'Pointer' || JSON.stringify(dictionaryCategory).match(/accessToken|mch_key|"ak"|"sk"/i)) throw new Error('后台响应展开了帐套敏感配置');
   const examPointSuffix = Date.now().toString(36);
   const rootExamPoint = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveExamPoint', className: 'ExamPoint', parentId: 0, orderId: 0, fields: { testPoint: `冒烟考点-${examPointSuffix}` } });
@@ -659,7 +666,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/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/model/guestbook lifecycle/content workflow/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {
   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

@@ -29,6 +29,7 @@ export const ADMIN_NAVIGATION = [
   { label: '多级字典分类', icon: 'layers-3', route: '/admin/resources/GradeCate' },
   { label: '多级字典选项', icon: 'git-branch', route: '/admin/resources/Grade' },
   { label: '货币管理', icon: 'circle-dollar-sign', route: '/admin/resources/Currency' },
+  { label: '节假日管理', icon: 'calendar-off', route: '/admin/resources/SysHoliday' },
   { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '迁移状态', icon: 'route', route: '/admin/migration' },
 ] as const;

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

@@ -41,6 +41,10 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Shop / Exp/Currency')?.status).toBe('implemented');
     expect(byKey.get('Shop / Exp/CurrencyAdd_Submit')?.status).toBe('blocked-infrastructure');
     expect(byKey.get('Shop / Exp/Currency_API')?.status).toBe('implemented');
+    expect(byKey.get('Config/Holiday')?.status).toBe('implemented');
+    expect(byKey.get('Config/Holiday_API')?.status).toBe('implemented');
+    expect(byKey.get('Config/HolidayAdd')?.status).toBe('blocked-infrastructure');
+    expect(byKey.get('Config/HolidayAdd_Submit')?.status).toBe('blocked-infrastructure');
     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');

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

@@ -832,29 +832,29 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Config",
     "action": "Holiday",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已提供帐套节假日列表、搜索、分页、编辑与批量删除"
   },
   {
     "module": "Config",
     "action": "Holiday_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 saveHoliday 白名单更新和 holidayBatch 帐套隔离删除替代"
   },
   {
     "module": "Config",
     "action": "HolidayAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "SysHoliday.id 与 Parse 保留 id 冲突,物理表又缺少可写旧 ID 列,Angular 已隐藏新增入口"
   },
   {
     "module": "Config",
     "action": "HolidayAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-infrastructure",
+    "reason": "无法为新节假日持久化旧数字 ID;saveHoliday 对新增明确返回 migration_blocked"
   },
   {
     "module": "Config",

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

@@ -30,6 +30,8 @@
         <button class="danger-action" type="button" [disabled]="updatingGrade()" (click)="applyBulkGradeDelete()">删除</button>
       } @else if (className() === 'Currency') {
         <button class="danger-action" type="button" [disabled]="updatingCurrency()" (click)="applyBulkCurrencyDelete()">删除</button>
+      } @else if (className() === 'SysHoliday') {
+        <button class="danger-action" type="button" [disabled]="updatingHoliday()" (click)="applyBulkHolidayDelete()">删除</button>
       } @else {
         <button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('audit')">审核</button><button class="danger-action" type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('recycle')">回收</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('recover')">恢复</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('elite')">加精</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('top-section')">版块置顶</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('top-global')">全局置顶</button><button type="button" [disabled]="updatingBar()" (click)="applyBulkBarAction('top-undo')">取消排序</button>
         <input class="bulk-node" type="number" min="1" [ngModel]="targetBarCategoryId()" (ngModelChange)="targetBarCategoryId.set(+$event)" placeholder="版块 ID" /><button type="button" [disabled]="updatingBar() || !targetBarCategoryId()" (click)="applyBulkBarAction('move')">移动版块</button>

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

@@ -487,6 +487,34 @@ describe('AdminResourceComponent', () => {
     expect(component.bulkMessage()).toContain('1 条货币记录');
   });
 
+  it('updates an existing holiday through the validated dedicated operation', async () => {
+    component.className.set('SysHoliday');
+    component.schema.set({ className: 'SysHoliday', label: '节假日管理', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.draftId.set('holiday-object');
+    component.draft.set({ name: '春节', htype: '农历', hdate: '01月01日', hdateStart: 1, hdateEnd: 6, zstatus: 99, id: 999, sourceKey: 'blocked' });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveHoliday'
+      ? { objectId: 'holiday-object', id: 1, name: '春节' }
+      : { className: 'SysHoliday', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveHoliday', {
+      className: 'SysHoliday', objectId: 'holiday-object', fields: { name: '春节', htype: '农历', hdate: '01月01日', hdateStart: 1, hdateEnd: 6, zstatus: 99, id: 999, sourceKey: 'blocked' },
+    }]);
+  });
+
+  it('batch deletes selected holidays through the tenant-safe operation', async () => {
+    component.className.set('SysHoliday'); component.selectedIds.set(['holiday-a', 'holiday-b']); spyOn(window, 'confirm').and.returnValue(true);
+    functions.admin.and.callFake(async (operation: string) => operation === 'holidayBatch'
+      ? { action: 'delete', updated: 2, results: [] }
+      : { className: 'SysHoliday', page: 1, pageSize: 20, total: 0, results: [] });
+
+    await component.applyBulkHolidayDelete();
+
+    expect(functions.admin.calls.first().args).toEqual(['holidayBatch', { className: 'SysHoliday', action: 'delete', objectIds: ['holiday-a', 'holiday-b'] }]);
+    expect(component.bulkMessage()).toContain('2 条节假日记录');
+  });
+
   it('updates a forum post through the dedicated workflow operation', async () => {
     component.className.set('GuestBar');
     component.draftId.set('bar-object');

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

@@ -90,6 +90,7 @@ export class AdminResourceComponent implements OnInit {
   readonly gradeCategories = signal<Record<string, unknown>[]>([]);
   readonly updatingGrade = signal(false);
   readonly updatingCurrency = signal(false);
+  readonly updatingHoliday = signal(false);
   readonly selectedIds = signal<string[]>([]);
   readonly bulkError = signal('');
   readonly bulkMessage = signal('');
@@ -101,7 +102,7 @@ export class AdminResourceComponent implements OnInit {
     return fields.map((field, index) => ({ field, index })).sort((a, b) => rank(a.field.name) - rank(b.field.name) || a.index - b.index).map(({ field }) => field).slice(0, 7);
   });
   readonly pageCount = computed(() => Math.max(1, Math.ceil((this.page()?.total ?? 0) / (this.page()?.pageSize || 20))));
-  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday'].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));
@@ -190,7 +191,8 @@ export class AdminResourceComponent implements OnInit {
                               : this.className() === 'Datadic' ? 'saveDictionaryItem'
                                 : this.className() === 'GradeCate' ? 'saveGradeCategory'
                                   : this.className() === 'Grade' ? 'saveGradeOption'
-                                    : this.className() === 'Currency' ? 'saveCurrency' : 'save';
+                                    : this.className() === 'Currency' ? 'saveCurrency'
+                                      : this.className() === 'SysHoliday' ? 'saveHoliday' : '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();
@@ -504,6 +506,15 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量删除货币失败'); }
     finally { this.updatingCurrency.set(false); }
   }
+  async applyBulkHolidayDelete(): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'SysHoliday' || !objectIds.length) { this.bulkError.set('请先选择节假日记录'); return; }
+    if (!confirm(`确认删除选中的 ${objectIds.length} 条节假日记录?`)) return;
+    this.updatingHoliday.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('holidayBatch', { className: 'SysHoliday', action: 'delete', objectIds }); await this.load(); this.bulkMessage.set(`已删除 ${result.updated} 条节假日记录`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量删除节假日失败'); }
+    finally { this.updatingHoliday.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     const verb = ['CommonModel', 'Node', 'Guestbook'].includes(this.className()) ? '移入回收站' : '删除';