Selaa lähdekoodia

feat: migrate advertising management

彭峰 1 kuukausi sitten
vanhempi
commit
99dc03a6dc

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

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

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

@@ -15,7 +15,7 @@ const CLASS_LABELS = {
   GradeCate: '多级字典分类', Grade: '多级字典选项',
   Currency: '货币管理', SysHoliday: '节假日管理',
   Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
-  GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科', Search: '后台快捷入口', Agency: '代理机构', DeliveryCenter: '交付中心'
+  GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', Agency: '代理机构', DeliveryCenter: '交付中心'
 };
 const ALLOWED_CLASSES = new Set([
   'PageTemplate','PaperQuestions','PayPlat','Permission','PlatComp','App','DesignAnswer','DesignAsk','Attachment','DesignPage','DesignQuestion','DesignRes','Feedback','DesignScence','StudentAchieve','ContentArticle','DesignSiteInfo','Profile','AdInfo','AdZone','ARoleAuth','Baike','ExamSysQuestions','ContactInfo','VocabularyWord','AssessmentProfile','Agency','MemoryPracticeRecord','DeliveryCenter','CourseBinding','PracticeRecord','CourseAppointment','Account','SurveyItem','SurveyLog','LessonRecord','DailyStudyRecord','CommonModel','ContentPublish','Company','_Role','_User','CRMSAttr','Currency','Datadic','Datadiccategory','DesignTlp','DocModel','DocPermission','ExamClass','ExamSysPapers','ExamType','ExamPoint','ExTeacher','FontPicShape','FontPicShapeType','Grade','GradeCate','Group','GroupModel','GuestBar','Guestbook','Guestcate','MailTemp','Manager','MisProcedure','MisProLevel','MisSign','MisType','PageStyle','Model','ModelField','Node','NodeAuth','NodeModelTemplate','Product','PlatUserRole','Pub','PubTw','PubWTHD','PubZXDC','PublishNode','QuestionsKnowledge','Role','StoreProduct','SafeMobile','Search','SenTask','ServiceSeat','ShopFareTlp','ShopMoneyRegular','Special','StoreApplication','StoreStyle','SysCSSManage','SysHoliday','SysLog','Temp','ThirdPlatInfo','UserCredit','UserDummyPoint','UserFriendGroup','UserLevel','UserSIcon','UserUserPoint','UserExpDomP','UserExpHis'
@@ -43,7 +43,9 @@ const CLASS_SYSTEM_FIELDS = {
   Grade: new Set(['gradeId','cate','parentId','grade','sourceKey']),
   Currency: new Set(['id','sourceKey']),
   SysHoliday: new Set(['id','cdate','cadminId','cuserId','sourceKey']),
-  Search: new Set(['id','type','state','time','adminId','orderId','linkType','linkState','sourceKey'])
+  Search: new Set(['id','type','state','time','adminId','orderId','linkType','linkState','sourceKey']),
+  AdZone: new Set(['id','cdate','cadmin','image','sourceKey']),
+  AdInfo: new Set(['id','cdate','zoneId','ztype','extend1','extend2','extend3','sourceKey'])
 };
 
 function inputOf(request) {
@@ -989,6 +991,67 @@ async function handler(request, response) {
       else { const orderMap = new Map(orders.map((entry) => [entry.objectId, entry.orderId])); for (const target of targets) { if (action === 'enable') target.set('state', 1); else if (action === 'disable') target.set('state', 2); else if (action === 'elite') target.set('eliteLevel', 1); else if (action === 'unelite') target.set('eliteLevel', 0); else target.set('orderId', orderMap.get(target.id)); } await Parse.Object.saveAll(targets, { useMasterKey: true }); }
       for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'search-navigation-' + action, 'Search', target.id); return response.json({ success: true, data: { action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
     }
+    if (operation === 'adZones') {
+      const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(1000, Math.max(1, Number(input.pageSize) || 20)); const status = Number(input.status == null ? -100 : input.status); const type = String(input.type || '').trim(); const search = String(input.search || '').trim().toLowerCase();
+      if (![-100,0,99].includes(status) || type.length > 50 || search.length > 100) fail(400, '广告位筛选条件无效');
+      const fields = await schemaFor('AdZone'); const query = new Parse.Query('AdZone'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true });
+      results = results.filter((entry) => (status === -100 || Number(entry.get('zstatus')) === status) && (!type || String(entry.get('ztype') || '') === type) && (!search || [entry.get('name'),entry.get('remind'),entry.get('ztype')].some((value) => String(value || '').toLowerCase().includes(search))));
+      results.sort((left, right) => Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize);
+      return response.json({ success: true, data: { className: 'AdZone', page, pageSize, total, results: results.map(serializeObject) } });
+    }
+    if (operation === 'adInfos') {
+      const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const status = Number(input.status == null ? -100 : input.status); const zoneId = String(input.zoneId || '').trim(); const search = String(input.search || '').trim().toLowerCase();
+      if (![-100,0,99].includes(status) || (zoneId && !/^\d+$/.test(zoneId)) || search.length > 100) fail(400, '广告内容筛选条件无效');
+      const fields = await schemaFor('AdInfo'); const query = new Parse.Query('AdInfo'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true });
+      results = results.filter((entry) => (status === -100 || Number(entry.get('zstatus')) === status) && (!zoneId || String(entry.get('zoneId') || '') === zoneId) && (!search || [entry.get('name'),entry.get('linkUrl'),entry.get('text'),entry.get('remind')].some((value) => String(value || '').toLowerCase().includes(search))));
+      results.sort((left, right) => Number(left.get('id') || 0) - Number(right.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize);
+      return response.json({ success: true, data: { className: 'AdInfo', page, pageSize, total, results: results.map(serializeObject) } });
+    }
+    if (operation === 'saveAdZone') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
+      if (!objectId) fail(501, 'migration_blocked: AdZone.id 与 Parse 对象保留 id 冲突,且省略 id 创建不会生成旧广告位编号,不能安全新增广告位');
+      const name = String(payload.name || '').trim(); const remind = String(payload.remind || '').trim(); const ztype = String(payload.ztype || '').trim(); const showType = String(payload.showType || '').trim(); const zstatus = Number(payload.zstatus);
+      if (!name || name.length > 100 || remind.length > 2000 || !ztype || ztype.length > 50 || !showType || showType.length > 50 || ![0,99].includes(zstatus)) fail(400, '广告位名称、类型、展示方式、状态或备注无效');
+      const fields = await schemaFor('AdZone'); const query = new Parse.Query('AdZone'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
+      target.set('name', name); target.set('remind', remind); target.set('ztype', ztype); target.set('showType', showType); target.set('zstatus', zstatus); const adminId = Number(context.current.get('legacyUserId')) || 0; if (adminId) target.set('cadmin', adminId);
+      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-ad-zone', 'AdZone', target.id); return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'saveAdInfo') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
+      if (!objectId) fail(501, 'migration_blocked: AdInfo.id 与 Parse 对象保留 id 冲突,且省略 id 创建不会生成旧广告编号,不能安全新增广告内容');
+      const name = String(payload.name || '').trim(); const zoneId = String(input.zoneId == null ? payload.zoneId || '' : input.zoneId).trim(); const hasPriority = Object.prototype.hasOwnProperty.call(payload, 'priority'); const priority = hasPriority ? Number(payload.priority) : 0; const zstatus = Number(payload.zstatus);
+      const valueOfDate = (value) => value && typeof value === 'object' && value.iso ? value.iso : value; const sdate = new Date(valueOfDate(payload.sdate)); const edate = new Date(valueOfDate(payload.edate));
+      const strings = { linkUrl: String(payload.linkUrl || '').trim(), text: String(payload.text || ''), html: String(payload.html || ''), images: String(payload.images || '').trim(), image: String(payload.image || '').trim(), remind: String(payload.remind || '').trim(), style: String(payload.style || '') };
+      if (!name || name.length > 100 || !/^\d+$/.test(zoneId) || (hasPriority && (String(payload.priority).trim() === '' || !Number.isInteger(priority) || priority < 0 || priority > 1000000)) || ![0,99].includes(zstatus)) fail(400, '广告名称、广告位、权重或状态无效');
+      if (Number.isNaN(sdate.getTime()) || Number.isNaN(edate.getTime()) || edate < sdate) fail(400, '广告到期时间必须大于或等于开始时间');
+      if (strings.linkUrl.length > 2000 || strings.text.length > 20000 || strings.html.length > 200000 || strings.images.length > 10000 || strings.image.length > 2000 || strings.remind.length > 2000 || strings.style.length > 10000) fail(400, '广告内容字段超过允许长度');
+      const zoneFields = await schemaFor('AdZone'); const zoneQuery = new Parse.Query('AdZone'); applyTenant(zoneQuery, zoneFields, context, input.companyId); zoneQuery.limit(1000); const zones = await zoneQuery.find({ useMasterKey: true }); if (!zones.some((entry) => String(entry.get('id') || '') === zoneId)) fail(404, '目标广告位不存在或不属于当前帐套');
+      const fields = await schemaFor('AdInfo'); const query = new Parse.Query('AdInfo'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
+      target.set('name', name); target.set('zoneId', zoneId); if (hasPriority) target.set('priority', String(priority)); target.set('zstatus', zstatus); target.set('sdate', sdate); target.set('edate', edate); for (const [field, value] of Object.entries(strings)) if (Object.prototype.hasOwnProperty.call(payload, field)) 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-ad-info', 'AdInfo', target.id); return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'adZoneBatch') {
+      if (String(input.className || '') !== 'AdZone') fail(400, '只能处理广告位'); const action = String(input.action || ''); if (!['active','pause','delete'].includes(action)) 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('AdZone'); const query = new Parse.Query('AdZone'); 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, '部分广告位不存在或不属于当前帐套');
+      if (action === 'delete') { const zoneIds = new Set(targets.map((target) => String(target.get('id') || ''))); const infoFields = await schemaFor('AdInfo'); const refsQuery = new Parse.Query('AdInfo'); applyTenant(refsQuery, infoFields, context, input.companyId); refsQuery.limit(1000); const refs = await refsQuery.find({ useMasterKey: true }); if (refs.length >= 1000) fail(409, '广告内容数量超过安全核验上限,不能删除广告位'); if (refs.some((entry) => zoneIds.has(String(entry.get('zoneId') || '')))) fail(409, '广告位仍有关联广告内容,不能删除'); await Parse.Object.destroyAll(targets, { useMasterKey: true }); }
+      else { for (const target of targets) target.set('zstatus', action === 'active' ? 99 : 0); await Parse.Object.saveAll(targets, { useMasterKey: true }); }
+      for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'ad-zone-' + action, 'AdZone', target.id); return response.json({ success: true, data: { action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
+    }
+    if (operation === 'adInfoBatch') {
+      if (String(input.className || '') !== 'AdInfo') fail(400, '只能处理广告内容'); const action = String(input.action || ''); if (!['audit','unaudit','delete'].includes(action)) 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('AdInfo'); const query = new Parse.Query('AdInfo'); 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, '部分广告内容不存在或不属于当前帐套');
+      if (action === 'delete') await Parse.Object.destroyAll(targets, { useMasterKey: true }); else { for (const target of targets) target.set('zstatus', action === 'audit' ? 99 : 0); await Parse.Object.saveAll(targets, { useMasterKey: true }); }
+      for (const target of targets) await audit({ ...context, company: target.get('company') || context.company }, 'ad-info-' + action, 'AdInfo', target.id); return response.json({ success: true, data: { action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
+    }
+    if (operation === 'adPreview') {
+      const objectId = String(input.zoneObjectId || ''); if (!objectId) fail(400, '缺少广告位 objectId'); const zoneFields = await schemaFor('AdZone'); const zoneQuery = new Parse.Query('AdZone'); applyTenant(zoneQuery, zoneFields, context, input.companyId); const zone = await zoneQuery.get(objectId, { useMasterKey: true }); const zoneId = String(zone.get('id') || '');
+      const infoFields = await schemaFor('AdInfo'); const infoQuery = new Parse.Query('AdInfo'); applyTenant(infoQuery, infoFields, context, input.companyId); infoQuery.limit(1000); const now = Date.now(); let results = await infoQuery.find({ useMasterKey: true }); results = results.filter((entry) => String(entry.get('zoneId') || '') === zoneId && Number(entry.get('zstatus')) === 99 && (!entry.get('sdate') || new Date(entry.get('sdate')).getTime() <= now) && (!entry.get('edate') || new Date(entry.get('edate')).getTime() >= now)); results.sort((left, right) => Number(right.get('priority') || 0) - Number(left.get('priority') || 0) || Number(left.get('id') || 0) - Number(right.get('id') || 0));
+      return response.json({ success: true, data: { zone: serializeObject(zone), total: results.length, results: results.map(serializeObject) } });
+    }
     if (operation === 'saveExamClass') {
       const objectId = String(input.objectId || '');
       const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
@@ -1252,7 +1315,7 @@ async function handler(request, response) {
     const classWritable = !READ_ONLY_CLASSES.has(className);
     if (operation === 'schema') {
       const fieldList = Object.entries(fields).filter(([name]) => !HIDDEN_FIELDS.has(name) && !/(?:password|secret|sessiontoken|masterkey|privatekey)/i.test(name)).map(([name, field]) => ({ name, type: field.type, targetClass: field.targetClass, required: field.required === true, writable: classWritable && !isSystemField(className, name) && GENERIC_WRITE_TYPES.has(field.type) }));
-      return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, creatable: classWritable && !['CommonModel','Model','ModelField','Guestbook','Currency','SysHoliday','Search'].includes(className), supportsSoftDelete: Boolean(fields.isDeleted) } });
+      return response.json({ success: true, data: { className, label: CLASS_LABELS[className] || className, fields: fieldList, writable: classWritable, creatable: classWritable && !['CommonModel','Model','ModelField','Guestbook','Currency','SysHoliday','Search','AdZone','AdInfo'].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));
@@ -1287,6 +1350,7 @@ async function handler(request, response) {
       if (className === 'Currency') fail(400, '货币必须走专用保存流程');
       if (className === 'SysHoliday') fail(400, '节假日必须走专用保存流程');
       if (className === 'Search') fail(400, '快捷入口必须走专用保存流程');
+      if (className === 'AdZone' || className === 'AdInfo') 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);
@@ -1299,6 +1363,7 @@ async function handler(request, response) {
     if (operation === 'delete') {
       if (!classWritable || !objectId) fail(400, '该记录不允许删除'); const query = new Parse.Query(className); applyTenant(query, fields, context, input.companyId); const object = await query.get(objectId, { useMasterKey: true });
       if (className === 'Model' || className === 'ModelField') fail(501, 'migration_blocked: 删除模型或字段需要同步删除 PostgreSQL 物理表/列并验证历史数据,托管云函数中未开放此 DDL 流程');
+      if (className === 'AdZone' || className === 'AdInfo') fail(400, '广告位与广告内容必须走专用批量删除流程');
       if (className === 'Guestcate') { const cateid = Number(object.get('cateid')) || 0; const companyId = pointerId(object.get('company')); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "Guestcate" WHERE "company"=$1 AND "parentId"=$2) AS children,(SELECT COUNT(*)::int FROM "Guestbook" WHERE "company"=$1 AND "cateid"=$2) AS messages,(SELECT COUNT(*)::int FROM "GuestBar" WHERE "company"=$1 AND "cateId"=$2) AS posts', [companyId, cateid]); if (Number(refs.children) || Number(refs.messages) || Number(refs.posts)) fail(409, '分类仍被下级分类、留言或帖子引用,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-guest-category', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
       if (className === 'ExamClass') { const classId = Number(object.get('cId')) || 0; const companyId = pointerId(object.get('company')); const refs = await Psql.one('SELECT (SELECT COUNT(*)::int FROM "ExamClass" WHERE "company"=$1 AND "cClassid"=$2) AS children,(SELECT COUNT(*)::int FROM "ExamSysQuestions" WHERE "company"=$1 AND "pClass"=$2) AS questions', [companyId, classId]); if (Number(refs.children) || Number(refs.questions)) fail(409, '试题分类仍被下级分类或试题引用,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-exam-class', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }
       if (className === 'ExamPoint') { const pointId = Number(object.get('id')) || 0; const companyId = pointerId(object.get('company')); const children = await Psql.one('SELECT COUNT(*)::int AS count FROM "ExamPoint" WHERE "company"=$1 AND "tid"=$2', [companyId, pointId]); if (Number(children.count)) fail(409, '考点仍有下级考点,不能删除'); await object.destroy({ useMasterKey: true }); await audit(context, 'delete-exam-point', className, objectId); return response.json({ success: true, data: { objectId, softDeleted: false } }); }

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

@@ -135,6 +135,16 @@ const explicit = new Map([
   ['Config/Hotkey_API', ['blocked-infrastructure', '目标无可执行快捷键启停、删除、重载或脚本匹配的全局运行时']],
   ['Config/Prize', ['blocked-infrastructure', '目标 Schema 缺少 PrizeConfig/PrizeTask 配置模型及签到、任务规则数据']],
   ['Config/Prize_Submit', ['blocked-infrastructure', '目标无法持久化旧奖励积分、重复周期和过期规则']],
+  ['Extend / AD/Default', ['implemented', '已由 Angular 广告位导航进入专用列表']],
+  ['Extend / AD/ZoneManage', ['implemented', '已由 adZones 帐套隔离列表、名称搜索、类型与启停筛选替代']],
+  ['Extend / AD/Zone_API', ['implemented', '已由 adZoneBatch 覆盖每批 1–100 条启用、停用与引用保护删除']],
+  ['Extend / AD/ZoneAdd', ['partial', '现有广告位编辑页已迁移;新增因旧 id 与 Parse 保留字段冲突而明确阻塞']],
+  ['Extend / AD/ZoneAdd_Submit', ['partial', '现有广告位由 saveAdZone 白名单更新;无旧编号的新增明确返回 migration_blocked']],
+  ['Extend / AD/InfoManage', ['implemented', '已由 adInfos 按帐套、广告位、审核状态和关键词分页筛选']],
+  ['Extend / AD/Info_API', ['implemented', '已由 adInfoBatch 覆盖审核、取消审核和批量删除']],
+  ['Extend / AD/InfoAdd', ['partial', '现有广告编辑和广告位选择已迁移;新增因旧 id 不可持久化而阻塞']],
+  ['Extend / AD/InfoAdd_Submit', ['partial', '现有广告由 saveAdInfo 校验广告位、时间、权重和内容后更新;新增明确阻塞']],
+  ['Extend / AD/ADPreView', ['partial', '已提供当前生效广告预览;旧 HTML 只显示源码而不执行脚本,避免管理端 XSS']],
 ]);
 
 const blockedDataModules = new Map([

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

@@ -95,6 +95,8 @@ let temporaryGuestbookReplyId = '';
 let temporaryGuestBarId = '';
 let contentHitObjectId = '';
 let contentHitOriginal = 0;
+let adZoneRestore = null;
+let adInfoRestore = null;
 const temporaryLessonIds = [];
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
@@ -246,6 +248,34 @@ try {
   if (!Array.isArray(searchNavigationPage.results) || !searchNavigationPage.results.length || searchNavigationPage.results.some((entry) => Number(entry.type) !== 1)) throw new Error('后台快捷入口筛选列表返回异常');
   const apiCatalog = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'apiCatalog' });
   if (!Array.isArray(apiCatalog.results) || apiCatalog.results.length < 9 || apiCatalog.results.some((entry) => !String(entry.path || '').startsWith('xiaoshu/') || Object.prototype.hasOwnProperty.call(entry, 'code'))) throw new Error('云函数接口目录缺失或泄露了源码');
+  const adZoneSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'AdZone' });
+  const adZoneFieldMap = Object.fromEntries((adZoneSchema.fields || []).map((field) => [field.name, field]));
+  if (adZoneSchema.creatable !== false || ['id','cdate','cadmin','image','sourceKey'].some((name) => adZoneFieldMap[name]?.writable !== false)) throw new Error('广告位系统字段未从新增或通用编辑中隔离');
+  const adInfoSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'AdInfo' });
+  const adInfoFieldMap = Object.fromEntries((adInfoSchema.fields || []).map((field) => [field.name, field]));
+  if (adInfoSchema.creatable !== false || ['id','cdate','zoneId','ztype','extend1','extend2','extend3','sourceKey'].some((name) => adInfoFieldMap[name]?.writable !== false)) throw new Error('广告内容系统字段未从新增或通用编辑中隔离');
+  const adZonePage = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'adZones', className: 'AdZone', page: 1, pageSize: 20, status: -100, type: '' });
+  const sampleAdZone = adZonePage.results?.[0]; if (!sampleAdZone?.objectId || !sampleAdZone.id) throw new Error('广告位专用列表没有返回历史广告位');
+  const adInfoPage = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'adInfos', className: 'AdInfo', page: 1, pageSize: 20, status: -100, zoneId: String(sampleAdZone.id) });
+  const sampleAdInfo = adInfoPage.results?.[0]; if (!sampleAdInfo?.objectId || String(sampleAdInfo.zoneId) !== String(sampleAdZone.id)) throw new Error('广告内容按广告位筛选异常');
+  const zoneCreateBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveAdZone', className: 'AdZone', fields: {} }, 501);
+  const infoCreateBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveAdInfo', className: 'AdInfo', fields: {} }, 501);
+  if (!String(zoneCreateBlocked.message).includes('AdZone.id') || !String(infoCreateBlocked.message).includes('AdInfo.id')) throw new Error('广告新增未返回旧 ID 明确阻塞原因');
+  adZoneRestore = { objectId: sampleAdZone.objectId, remind: String(sampleAdZone.remind || ''), zstatus: Number(sampleAdZone.zstatus) };
+  const zoneProbeRemind = `${adZoneRestore.remind} [angular-smoke]`.trim();
+  const updatedAdZone = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveAdZone', className: 'AdZone', objectId: sampleAdZone.objectId, fields: { ...sampleAdZone, remind: zoneProbeRemind } });
+  if (updatedAdZone.remind !== zoneProbeRemind) throw new Error('广告位专用编辑未写入备注');
+  const zoneToggleAction = adZoneRestore.zstatus === 99 ? 'pause' : 'active'; const toggledZone = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'adZoneBatch', className: 'AdZone', action: zoneToggleAction, objectIds: [sampleAdZone.objectId] });
+  if (Number(toggledZone.results?.[0]?.zstatus) !== (zoneToggleAction === 'active' ? 99 : 0)) throw new Error('广告位启停批量操作异常');
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveAdZone', className: 'AdZone', objectId: sampleAdZone.objectId, fields: { ...sampleAdZone, remind: adZoneRestore.remind, zstatus: adZoneRestore.zstatus } }); adZoneRestore = null;
+  adInfoRestore = { objectId: sampleAdInfo.objectId, remind: String(sampleAdInfo.remind || ''), zstatus: Number(sampleAdInfo.zstatus) };
+  const infoProbeRemind = `${adInfoRestore.remind} [angular-smoke]`.trim();
+  const updatedAdInfo = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveAdInfo', className: 'AdInfo', objectId: sampleAdInfo.objectId, zoneId: String(sampleAdInfo.zoneId), fields: { ...sampleAdInfo, remind: infoProbeRemind } });
+  if (updatedAdInfo.remind !== infoProbeRemind) throw new Error('广告内容专用编辑未写入备注');
+  const infoToggleAction = adInfoRestore.zstatus === 99 ? 'unaudit' : 'audit'; const toggledInfo = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'adInfoBatch', className: 'AdInfo', action: infoToggleAction, objectIds: [sampleAdInfo.objectId] });
+  if (Number(toggledInfo.results?.[0]?.zstatus) !== (infoToggleAction === 'audit' ? 99 : 0)) throw new Error('广告内容审核批量操作异常');
+  await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveAdInfo', className: 'AdInfo', objectId: sampleAdInfo.objectId, zoneId: String(sampleAdInfo.zoneId), fields: { ...sampleAdInfo, remind: adInfoRestore.remind, zstatus: adInfoRestore.zstatus } }); adInfoRestore = null;
+  const adPreview = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'adPreview', zoneObjectId: sampleAdZone.objectId }); if (!adPreview.zone?.objectId || !Array.isArray(adPreview.results)) throw new Error('广告位当前生效广告预览异常');
   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' } });
@@ -681,8 +711,10 @@ 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/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/api-catalog/config-blockers/model/guestbook lifecycle/content workflow/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {
+  if (adZoneRestore) await jsonRequest(`${PARSE_URL}/classes/AdZone/${adZoneRestore.objectId}`, { method: 'PUT', body: JSON.stringify({ remind: adZoneRestore.remind, zstatus: adZoneRestore.zstatus }) }, true).catch((error) => { console.error(`广告位冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
+  if (adInfoRestore) await jsonRequest(`${PARSE_URL}/classes/AdInfo/${adInfoRestore.objectId}`, { method: 'PUT', body: JSON.stringify({ remind: adInfoRestore.remind, zstatus: adInfoRestore.zstatus }) }, true).catch((error) => { console.error(`广告内容冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
   if (contentHitObjectId) await jsonRequest(`${PARSE_URL}/classes/CommonModel/${contentHitObjectId}`, { method: 'PUT', body: JSON.stringify({ hits: contentHitOriginal }) }, true).catch((error) => {
     console.error(`公开内容浏览量恢复失败:${error.message}`);
     process.exitCode = 1;

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

@@ -33,6 +33,8 @@ export const ADMIN_NAVIGATION = [
   { label: '货币管理', icon: 'circle-dollar-sign', route: '/admin/resources/Currency' },
   { label: '节假日管理', icon: 'calendar-off', route: '/admin/resources/SysHoliday' },
   { label: '后台快捷入口', icon: 'panels-top-left', route: '/admin/resources/Search' },
+  { label: '广告位管理', icon: 'gallery-horizontal-end', route: '/admin/resources/AdZone' },
+  { label: '广告内容管理', icon: 'megaphone', route: '/admin/resources/AdInfo' },
   { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '迁移状态', icon: 'route', route: '/admin/migration' },
 ] as const;

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

@@ -11,6 +11,7 @@ import {
   ChevronRight,
   ClipboardCheck,
   Files,
+  GalleryHorizontalEnd,
   GraduationCap,
   Languages,
   LayoutDashboard,
@@ -18,6 +19,7 @@ import {
   LogOut,
   LucideAngularModule,
   Menu,
+  Megaphone,
   Route,
   Search,
   Shapes,
@@ -41,8 +43,8 @@ export class AdminShellComponent {
   readonly mobileOpen = signal(false);
   readonly icons = {
     Bell, BookOpen, Braces, CalendarDays, ChartNoAxesCombined, ChevronLeft, ChevronRight,
-    ClipboardCheck, Files, GraduationCap, Languages, LayoutDashboard, ListChecks,
-    LogOut, Menu, Route, Search, Shapes, TableProperties, Users,
+    ClipboardCheck, Files, GalleryHorizontalEnd, GraduationCap, Languages, LayoutDashboard, ListChecks,
+    LogOut, Megaphone, Menu, Route, Search, Shapes, TableProperties, Users,
   };
 
   constructor(readonly sessions: AdminSessionService) {}

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

@@ -56,6 +56,11 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('Config/FontIcon')?.status).toBe('implemented');
     expect(byKey.get('Config/Hotkey_API')?.status).toBe('blocked-infrastructure');
     expect(byKey.get('Config/Prize_Submit')?.status).toBe('blocked-infrastructure');
+    expect(byKey.get('Extend / AD/ZoneManage')?.status).toBe('implemented');
+    expect(byKey.get('Extend / AD/ZoneAdd')?.status).toBe('partial');
+    expect(byKey.get('Extend / AD/Info_API')?.status).toBe('implemented');
+    expect(byKey.get('Extend / AD/InfoAdd_Submit')?.status).toBe('partial');
+    expect(byKey.get('Extend / AD/ADPreView')?.status).toBe('partial');
     expect(byKey.get('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

@@ -6,71 +6,71 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Extend / AD",
     "action": "Default",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Angular 广告位导航进入专用列表"
   },
   {
     "module": "Extend / AD",
     "action": "ZoneManage",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 adZones 帐套隔离列表、名称搜索、类型与启停筛选替代"
   },
   {
     "module": "Extend / AD",
     "action": "Zone_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 adZoneBatch 覆盖每批 1–100 条启用、停用与引用保护删除"
   },
   {
     "module": "Extend / AD",
     "action": "ZoneAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有广告位编辑页已迁移;新增因旧 id 与 Parse 保留字段冲突而明确阻塞"
   },
   {
     "module": "Extend / AD",
     "action": "ZoneAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有广告位由 saveAdZone 白名单更新;无旧编号的新增明确返回 migration_blocked"
   },
   {
     "module": "Extend / AD",
     "action": "InfoManage",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 adInfos 按帐套、广告位、审核状态和关键词分页筛选"
   },
   {
     "module": "Extend / AD",
     "action": "Info_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 adInfoBatch 覆盖审核、取消审核和批量删除"
   },
   {
     "module": "Extend / AD",
     "action": "InfoAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有广告编辑和广告位选择已迁移;新增因旧 id 不可持久化而阻塞"
   },
   {
     "module": "Extend / AD",
     "action": "InfoAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有广告由 saveAdInfo 校验广告位、时间、权重和内容后更新;新增明确阻塞"
   },
   {
     "module": "Extend / AD",
     "action": "ADPreView",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已提供当前生效广告预览;旧 HTML 只显示源码而不执行脚本,避免管理端 XSS"
   },
   {
     "module": "Content / Addon",

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

@@ -11,6 +11,12 @@
   @if (className() === 'Search') {
     <div class="bulk-toolbar"><strong>快捷入口范围</strong><select [ngModel]="filterSearchNavigationType()" (ngModelChange)="filterSearchNavigationType.set(+$event); load(1)"><option [ngValue]="1">管理后台</option><option [ngValue]="2">用户中心</option></select><select [ngModel]="filterSearchNavigationState()" (ngModelChange)="filterSearchNavigationState.set(+$event); load(1)"><option [ngValue]="-100">全部状态</option><option [ngValue]="1">已启用</option><option [ngValue]="2">已停用</option></select><select [ngModel]="filterSearchNavigationElite()" (ngModelChange)="filterSearchNavigationElite.set(+$event); load(1)"><option [ngValue]="-100">全部推荐</option><option [ngValue]="1">已推荐</option><option [ngValue]="0">未推荐</option></select></div>
   }
+  @if (className() === 'AdZone') {
+    <div class="bulk-toolbar"><strong>广告位范围</strong><input [ngModel]="filterAdZoneType()" (ngModelChange)="filterAdZoneType.set($event)" (keyup.enter)="load(1)" placeholder="广告位类型(精确匹配)" /><select [ngModel]="filterAdStatus()" (ngModelChange)="filterAdStatus.set(+$event); load(1)"><option [ngValue]="-100">全部状态</option><option [ngValue]="99">已启用</option><option [ngValue]="0">已停用</option></select><button type="button" (click)="load(1)">筛选</button></div>
+  }
+  @if (className() === 'AdInfo') {
+    <div class="bulk-toolbar"><strong>广告内容范围</strong><select [ngModel]="filterAdInfoZoneId()" (ngModelChange)="filterAdInfoZoneId.set($event); load(1)"><option value="">全部广告位</option>@for (zone of adZones(); track zone['objectId']) { <option [value]="adZoneIdOf(zone)">{{ adZoneLabel(zone) }}</option> }</select><select [ngModel]="filterAdStatus()" (ngModelChange)="filterAdStatus.set(+$event); load(1)"><option [ngValue]="-100">全部状态</option><option [ngValue]="99">已审核</option><option [ngValue]="0">未审核</option></select></div>
+  }
   @if (bulkEnabled() && selectedIds().length) {
     <div class="bulk-toolbar"><strong>已选 {{ selectedIds().length }} 条</strong>
       @if (className() === '_User') {
@@ -37,6 +43,10 @@
         <button class="danger-action" type="button" [disabled]="updatingHoliday()" (click)="applyBulkHolidayDelete()">删除</button>
       } @else if (className() === 'Search') {
         <button type="button" [disabled]="updatingSearchNavigation()" (click)="applyBulkSearchNavigationAction('enable')">启用</button><button type="button" [disabled]="updatingSearchNavigation()" (click)="applyBulkSearchNavigationAction('disable')">停用</button><button type="button" [disabled]="updatingSearchNavigation()" (click)="applyBulkSearchNavigationAction('elite')">推荐</button><button type="button" [disabled]="updatingSearchNavigation()" (click)="applyBulkSearchNavigationAction('unelite')">取消推荐</button><input class="bulk-node" type="number" min="1" max="1000000" [ngModel]="searchNavigationOrderStart()" (ngModelChange)="searchNavigationOrderStart.set(+$event)" placeholder="起始排序" /><button type="button" [disabled]="updatingSearchNavigation()" (click)="applyBulkSearchNavigationAction('order')">按选择顺序排列</button><button class="danger-action" type="button" [disabled]="updatingSearchNavigation()" (click)="applyBulkSearchNavigationAction('delete')">删除</button>
+      } @else if (className() === 'AdZone') {
+        <button type="button" [disabled]="updatingAdvertisement()" (click)="applyBulkAdvertisementAction('active')">启用</button><button type="button" [disabled]="updatingAdvertisement()" (click)="applyBulkAdvertisementAction('pause')">停用</button><button class="danger-action" type="button" [disabled]="updatingAdvertisement()" (click)="applyBulkAdvertisementAction('delete')">删除</button>
+      } @else if (className() === 'AdInfo') {
+        <button type="button" [disabled]="updatingAdvertisement()" (click)="applyBulkAdvertisementAction('audit')">审核</button><button type="button" [disabled]="updatingAdvertisement()" (click)="applyBulkAdvertisementAction('unaudit')">取消审核</button><button class="danger-action" type="button" [disabled]="updatingAdvertisement()" (click)="applyBulkAdvertisementAction('delete')">删除</button>
       } @else {
         <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>
@@ -81,6 +91,9 @@
         @if (className() === 'Search') {
           <section class="account-create-note"><strong>现有快捷入口编辑</strong><span>旧数字 ID 在目标兼容层不可写,因此仅允许编辑现有入口;启停、推荐和排序请在列表批量工具中操作。旧全局后台起始页依赖的 SiteConfig 持久层已不存在。</span></section>
         }
+        @if (className() === 'AdZone' || className() === 'AdInfo') {
+          <section class="account-create-note"><strong>现有{{ className() === 'AdZone' ? '广告位' : '广告内容' }}编辑</strong><span>旧数字 ID 与 Parse 保留字段冲突,创建探测不会生成旧编号,因此当前只允许安全编辑现有记录;新增请求会返回明确迁移阻塞。</span></section>
+        }
         @for (field of schema()?.fields || []; track field.name) { @if (field.writable) {
           <label><span>{{ field.name }} <small>{{ field.type }}{{ field.required ? ' · 必填' : '' }}</small></span>
             @if (field.type === 'Boolean') { <input type="checkbox" [ngModel]="draft()[field.name] === true" (ngModelChange)="setField(field, $event)" /> }
@@ -88,6 +101,20 @@
             @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>
         } }
+        @if (className() === 'AdInfo') {
+          <section class="content-controls ad-info-controls">
+            <header><div><strong>广告归属</strong><span>只能选择当前帐套真实广告位;名称、素材、链接、时间、权重与审核状态由专用云函数校验</span></div>@if (draftId()) { <b>ID {{ draft()['id'] }}</b> }</header>
+            <label><span>所属广告位</span><select [ngModel]="targetAdZoneId()" (ngModelChange)="targetAdZoneId.set($event)"><option value="">请选择广告位</option>@for (zone of adZones(); track zone['objectId']) { <option [value]="adZoneIdOf(zone)">{{ adZoneLabel(zone) }}</option> }</select>@if (!adZones().length) { <small>当前帐套没有可用广告位</small> }</label>
+          </section>
+        }
+        @if (className() === 'AdZone' && draftId()) {
+          <section class="content-controls ad-preview-controls">
+            <header><div><strong>当前生效广告预览</strong><span>仅展示已启用且处于开始/到期时间范围内的关联广告;HTML 以源码文本显示,避免在管理端执行旧脚本</span></div><b>ID {{ draft()['id'] }}</b></header>
+            <button type="button" [disabled]="adPreviewLoading()" (click)="loadAdPreview()">{{ adPreviewLoading() ? '加载中…' : '刷新预览' }}</button>
+            @if (adPreviewError()) { <div class="content-action-status error">{{ adPreviewError() }}</div> }
+            <div class="ad-preview-list">@for (ad of adPreview(); track ad['objectId']) { <article><strong>{{ ad['name'] }}</strong><span>权重 {{ ad['priority'] || 0 }} · {{ display(ad['sdate']) }} 至 {{ display(ad['edate']) }}</span>@if (ad['image'] || ad['images']) { <code>{{ ad['image'] || ad['images'] }}</code> }@if (ad['text']) { <p>{{ ad['text'] }}</p> }@if (ad['html']) { <pre>{{ ad['html'] }}</pre> }</article> } @empty { @if (!adPreviewLoading() && !adPreviewError()) { <div class="content-action-status">当前没有生效广告</div> } }</div>
+          </section>
+        }
         @if (className() === 'CommonModel' && draftId()) {
           <section class="content-controls">
             <header><div><strong>内容审核与归档</strong><span>专用云函数同步旧 ContentManage 状态与节点移动语义</span></div><b [class.recycled]="contentStatusValue() === -2">{{ contentStatusLabel(contentStatusValue()) }}</b></header>

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

@@ -554,6 +554,55 @@ describe('AdminResourceComponent', () => {
     expect(component.bulkMessage()).toContain('2 条快捷入口');
   });
 
+  it('loads advertising content through the dedicated zone and status filters', async () => {
+    component.className.set('AdInfo');
+    component.schema.set({ className: 'AdInfo', label: '广告内容管理', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.filterAdInfoZoneId.set('5'); component.filterAdStatus.set(99); component.search.set('横幅');
+    functions.admin.and.resolveTo({ className: 'AdInfo', page: 2, pageSize: 20, total: 21, results: [] });
+
+    await component.load(2);
+
+    expect(functions.admin.calls.first().args).toEqual(['adInfos', { className: 'AdInfo', page: 2, pageSize: 20, search: '横幅', zoneId: '5', status: 99 }]);
+  });
+
+  it('updates an existing advertisement through the zone-safe operation', async () => {
+    component.className.set('AdInfo');
+    component.schema.set({ className: 'AdInfo', label: '广告内容管理', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.draftId.set('ad-info-object'); component.targetAdZoneId.set('5');
+    component.draft.set({ objectId: 'ad-info-object', id: 5, name: '矩形横幅广告', zoneId: '999', priority: '2', zstatus: 99, sdate: '2026-08-01T00:00:00.000Z', edate: '2026-09-01T00:00:00.000Z' });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveAdInfo'
+      ? { objectId: 'ad-info-object', id: 5, name: '矩形横幅广告', zoneId: '5' }
+      : { className: 'AdInfo', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveAdInfo', {
+      className: 'AdInfo', objectId: 'ad-info-object', fields: { objectId: 'ad-info-object', id: 5, name: '矩形横幅广告', zoneId: '999', priority: '2', zstatus: 99, sdate: '2026-08-01T00:00:00.000Z', edate: '2026-09-01T00:00:00.000Z' }, zoneId: '5',
+    }]);
+  });
+
+  it('pauses selected advertising zones through the dedicated batch operation', async () => {
+    component.className.set('AdZone'); component.selectedIds.set(['zone-a', 'zone-b']);
+    functions.admin.and.callFake(async (operation: string) => operation === 'adZoneBatch'
+      ? { action: 'pause', updated: 2, results: [] }
+      : { className: 'AdZone', page: 1, pageSize: 20, total: 2, results: [] });
+
+    await component.applyBulkAdvertisementAction('pause');
+
+    expect(functions.admin.calls.first().args).toEqual(['adZoneBatch', { className: 'AdZone', action: 'pause', objectIds: ['zone-a', 'zone-b'] }]);
+    expect(component.bulkMessage()).toContain('2 条广告位');
+  });
+
+  it('loads active campaign previews without executing legacy HTML', async () => {
+    component.className.set('AdZone'); component.draftId.set('zone-object');
+    functions.admin.and.resolveTo({ zone: { objectId: 'zone-object', id: '1' }, total: 1, results: [{ objectId: 'ad-object', name: '文字广告', html: '<script>unsafe()</script>' }] });
+
+    await component.loadAdPreview();
+
+    expect(functions.admin).toHaveBeenCalledWith('adPreview', { zoneObjectId: 'zone-object' });
+    expect(component.adPreview()[0]['html']).toContain('<script>');
+  });
+
   it('updates a forum post through the dedicated workflow operation', async () => {
     component.className.set('GuestBar');
     component.draftId.set('bar-object');

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

@@ -96,18 +96,27 @@ export class AdminResourceComponent implements OnInit {
   readonly filterSearchNavigationElite = signal(-100);
   readonly searchNavigationOrderStart = signal(1);
   readonly updatingSearchNavigation = signal(false);
+  readonly filterAdZoneType = signal('');
+  readonly filterAdStatus = signal(-100);
+  readonly filterAdInfoZoneId = signal('');
+  readonly targetAdZoneId = signal('');
+  readonly adZones = signal<Record<string, unknown>[]>([]);
+  readonly adPreview = signal<Record<string, unknown>[]>([]);
+  readonly adPreviewLoading = signal(false);
+  readonly adPreviewError = signal('');
+  readonly updatingAdvertisement = signal(false);
   readonly selectedIds = signal<string[]>([]);
   readonly bulkError = signal('');
   readonly bulkMessage = signal('');
   readonly icons = { ChevronLeft, ChevronRight, Eye, KeyRound, LoaderCircle, Pencil, Plus, RefreshCw, Search, Trash2, X };
   readonly columns = computed(() => {
     const fields = this.schema()?.fields ?? [];
-    const preferred = ['objectId', 'name', 'title', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'realName', 'fileUrl', 'type', 'state', 'eliteLevel', 'orderId', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
+    const preferred = ['objectId', 'name', 'title', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'realName', 'zoneId', 'ztype', 'priority', 'zstatus', 'sdate', 'edate', 'fileUrl', 'type', 'state', 'eliteLevel', 'orderId', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
     const rank = (name: string): number => { const index = preferred.indexOf(name); return index < 0 ? preferred.length : index; };
     return fields.map((field, index) => ({ field, index })).sort((a, b) => rank(a.field.name) - rank(b.field.name) || a.index - b.index).map(({ field }) => field).slice(0, 7);
   });
   readonly pageCount = computed(() => Math.max(1, Math.ceil((this.page()?.total ?? 0) / (this.page()?.pageSize || 20))));
-  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'AdZone', 'AdInfo'].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));
@@ -122,6 +131,7 @@ export class AdminResourceComponent implements OnInit {
       this.search.set('');
       this.filterGradeCategoryId.set(0); this.filterGradeParentId.set(0);
       this.filterSearchNavigationType.set(1); this.filterSearchNavigationState.set(-100); this.filterSearchNavigationElite.set(-100); this.searchNavigationOrderStart.set(1);
+      this.filterAdZoneType.set(''); this.filterAdStatus.set(-100); this.filterAdInfoZoneId.set(''); this.resetAdActions();
       void this.load(1, true);
     });
   }
@@ -131,10 +141,13 @@ export class AdminResourceComponent implements OnInit {
     try {
       if (reloadSchema || !this.schema()) this.schema.set(await this.functions.admin<AdminClassSchema>('schema', { className: this.className() }));
       if (this.className() === 'Grade' && reloadSchema) { await this.loadGradeCategories(); if (!this.filterGradeCategoryId() && this.gradeCategories().length) this.filterGradeCategoryId.set(this.gradeCategoryIdOf(this.gradeCategories()[0])); }
-      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : 'list';
+      if (this.className() === 'AdInfo' && reloadSchema) await this.loadAdZones();
+      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : 'list';
       const pagePayload: Record<string, unknown> = { className: this.className(), page: pageNumber, pageSize: 20, search: this.search().trim() };
       if (pageOperation === 'gradeOptions') { pagePayload['categoryId'] = this.filterGradeCategoryId(); pagePayload['parentId'] = this.filterGradeParentId(); }
       if (pageOperation === 'searchNavigations') { pagePayload['type'] = this.filterSearchNavigationType(); pagePayload['state'] = this.filterSearchNavigationState(); pagePayload['elite'] = this.filterSearchNavigationElite(); }
+      if (pageOperation === 'adZones') { pagePayload['type'] = this.filterAdZoneType(); pagePayload['status'] = this.filterAdStatus(); }
+      if (pageOperation === 'adInfos') { pagePayload['zoneId'] = this.filterAdInfoZoneId(); pagePayload['status'] = this.filterAdStatus(); }
       this.page.set(await this.functions.admin<ResourcePage>(pageOperation, pagePayload));
       if (this.className() === '_User' && reloadSchema) await this.loadUserGroups();
       if (this.className() === 'Datadic' && reloadSchema) await this.loadDictionaryCategories();
@@ -142,17 +155,19 @@ 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.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.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.editorOpen.set(true);
+      this.draftId.set(objectId); this.draft.set(detail); this.fieldErrors.set({}); this.fieldInputs.set({}); this.resetPasswordFields(); this.resetCreateUserFields(); this.resetUserActions(detail); this.resetContentActions(detail); this.resetNodeActions(detail); this.resetSpecialActions(detail); this.resetGuestbookActions(detail); this.resetGuestCategoryActions(detail); this.resetBarActions(detail); this.resetExamClassActions(detail); this.resetExamPointActions(detail); this.resetKnowledgeActions(detail); this.resetTeacherActions(detail); this.resetDictionaryActions(detail); this.resetGradeActions(detail); this.resetAdActions(detail); this.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();
     } 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(); }
+  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(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -200,7 +215,9 @@ export class AdminResourceComponent implements OnInit {
                                   : this.className() === 'Grade' ? 'saveGradeOption'
                                     : this.className() === 'Currency' ? 'saveCurrency'
                                       : this.className() === 'SysHoliday' ? 'saveHoliday'
-                                        : this.className() === 'Search' ? 'saveSearchNavigation' : 'save';
+                                        : this.className() === 'Search' ? 'saveSearchNavigation'
+                                          : this.className() === 'AdZone' ? 'saveAdZone'
+                                            : this.className() === 'AdInfo' ? 'saveAdInfo' : '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();
@@ -211,6 +228,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'ExTeacher') payload['classId'] = this.targetTeacherClassId();
       if (this.className() === 'Datadic') payload['categoryId'] = this.targetDictionaryCategoryId();
       if (this.className() === 'Grade') { payload['categoryId'] = this.targetGradeCategoryId(); payload['parentId'] = this.targetGradeParentId(); }
+      if (this.className() === 'AdInfo') payload['zoneId'] = this.targetAdZoneId();
       await this.functions.admin(operation, payload);
       this.closeEditor(); await this.load();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
@@ -538,12 +556,35 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量处理快捷入口失败'); }
     finally { this.updatingSearchNavigation.set(false); }
   }
+  async applyBulkAdvertisementAction(action: 'active' | 'pause' | 'audit' | 'unaudit' | 'delete'): Promise<void> {
+    const className = this.className(); const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (!['AdZone', 'AdInfo'].includes(className) || !objectIds.length) { this.bulkError.set('请先选择广告位或广告内容'); return; }
+    if (action === 'delete' && !confirm(`确认删除选中的 ${objectIds.length} 条${className === 'AdZone' ? '广告位' : '广告内容'}?`)) return;
+    if (className === 'AdZone' && !['active', 'pause', 'delete'].includes(action)) { this.bulkError.set('广告位操作无效'); return; }
+    if (className === 'AdInfo' && !['audit', 'unaudit', 'delete'].includes(action)) { this.bulkError.set('广告内容操作无效'); return; }
+    this.updatingAdvertisement.set(true);
+    try {
+      const result = await this.functions.admin<{ updated: number }>(className === 'AdZone' ? 'adZoneBatch' : 'adInfoBatch', { className, action, objectIds });
+      await this.load(); this.bulkMessage.set(`已处理 ${result.updated} 条${className === 'AdZone' ? '广告位' : '广告内容'}`);
+    } catch (error) { this.bulkError.set(error instanceof Error ? error.message : '广告批量操作失败'); }
+    finally { this.updatingAdvertisement.set(false); }
+  }
+  async loadAdPreview(): Promise<void> {
+    this.adPreview.set([]); this.adPreviewError.set('');
+    if (this.className() !== 'AdZone' || !this.draftId()) return;
+    this.adPreviewLoading.set(true);
+    try { const result = await this.functions.admin<{ results: Record<string, unknown>[] }>('adPreview', { zoneObjectId: this.draftId() }); this.adPreview.set(result.results || []); }
+    catch (error) { this.adPreviewError.set(error instanceof Error ? error.message : '广告位预览加载失败'); }
+    finally { this.adPreviewLoading.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     const verb = ['CommonModel', 'Node', 'Guestbook'].includes(this.className()) ? '移入回收站' : '删除';
     if (!objectId || !confirm(`确认${verb} ${this.className()} / ${objectId}?`)) return;
     try {
       if (this.className() === 'Search') await this.functions.admin('searchNavigationBatch', { className: 'Search', action: 'delete', objectIds: [objectId] });
+      else if (this.className() === 'AdZone') await this.functions.admin('adZoneBatch', { className: 'AdZone', action: 'delete', objectIds: [objectId] });
+      else if (this.className() === 'AdInfo') await this.functions.admin('adInfoBatch', { className: 'AdInfo', action: 'delete', objectIds: [objectId] });
       else await this.functions.admin('delete', { className: this.className(), objectId });
       await this.load();
     }
@@ -561,6 +602,8 @@ export class AdminResourceComponent implements OnInit {
   groupLabel(group: Record<string, unknown>): string { return `${String(group['groupName'] || group['name'] || '未命名组')}(${this.groupIdOf(group)})`; }
   dictionaryCategoryIdOf(category: Record<string, unknown>): number { return Number(category['diccateid']) || 0; }
   gradeCategoryIdOf(category: Record<string, unknown>): number { return Number(category['cateId']) || 0; }
+  adZoneIdOf(zone: Record<string, unknown>): string { return String(zone['id'] || ''); }
+  adZoneLabel(zone: Record<string, unknown>): string { return `${String(zone['name'] || '未命名广告位')}(${this.adZoneIdOf(zone)})`; }
   contentStatusValue(): number { return Number(this.draft()['status']) || 0; }
   contentStatusLabel(status: number): string { return ({ [-3]: '草稿', [-2]: '回收站', [-1]: '未通过', 0: '待审核', 99: '已审核' } as Record<number, string>)[status] || String(status); }
   nodeStatusValue(): number { return Number(this.draft()['zstatus'] ?? 99); }
@@ -580,6 +623,10 @@ export class AdminResourceComponent implements OnInit {
     try { const result = await this.functions.admin<{ results: Record<string, unknown>[] }>('gradeCategories', {}); this.gradeCategories.set(result.results || []); }
     catch (error) { this.gradeCategories.set([]); this.error.set(error instanceof Error ? error.message : '多级字典分类加载失败'); }
   }
+  private async loadAdZones(): Promise<void> {
+    try { const result = await this.functions.admin<ResourcePage>('adZones', { className: 'AdZone', page: 1, pageSize: 1000, search: '', type: '', status: -100 }); this.adZones.set(result.results || []); }
+    catch (error) { this.adZones.set([]); this.error.set(error instanceof Error ? error.message : '广告位选项加载失败'); }
+  }
   private resetPasswordFields(): void { this.newPassword.set(''); this.confirmPassword.set(''); this.passwordMessage.set(''); this.passwordError.set(''); }
   private resetCreateUserFields(): void { this.createUsername.set(''); this.createPassword.set(''); this.createConfirmPassword.set(''); this.createDisplayName.set(''); this.createMobile.set(''); this.createInviteUsername.set(''); this.createUserError.set(''); }
   private resetUserActions(detail: Record<string, unknown> = {}): void { this.userGroups.set([]); this.selectedUserGroupId.set(Number(detail['legacyGroupId'] ?? (detail['legacyUserData'] as Record<string, unknown> | undefined)?.['GroupID']) || 0); this.userActionReason.set(''); this.userActionError.set(''); this.userActionMessage.set(''); }
@@ -595,4 +642,5 @@ export class AdminResourceComponent implements OnInit {
   private resetTeacherActions(detail: Record<string, unknown> = {}): void { this.targetTeacherClassId.set(Number(detail['tclsss']) || 0); }
   private resetDictionaryActions(detail: Record<string, unknown> = {}): void { this.targetDictionaryCategoryId.set(Number(detail['diccate']) || 0); }
   private resetGradeActions(detail: Record<string, unknown> = {}): void { this.targetGradeCategoryId.set(Number(detail['cate']) || this.filterGradeCategoryId()); this.targetGradeParentId.set(Number(detail['parentId']) || this.filterGradeParentId()); }
+  private resetAdActions(detail: Record<string, unknown> = {}): void { this.targetAdZoneId.set(String(detail['zoneId'] || this.filterAdInfoZoneId() || '')); this.adPreview.set([]); this.adPreviewError.set(''); this.adPreviewLoading.set(false); }
 }