소스 검색

feat: migrate admin shortcut navigation

彭峰 1 개월 전
부모
커밋
e0e8c2b4a3

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

@@ -37,8 +37,9 @@
 - 旧多级数据字典 `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` 均无节假日外键,未伪造删除级联。
+- 旧 `Config/SearchFunc/SearchFunc_API/SearchFuncAdd/SearchFuncAdd_Submit` 已映射到目标 `Search`(后台快捷入口,非内容搜索日志):`searchNavigations` 按管理后台/用户中心类型、启停和推荐状态筛选,`saveSearchNavigation` 白名单维护名称、站内路径、图标、打开方式、移动端、尺寸、颜色、用户组和推荐级别,`searchNavigationBatch` 覆盖启停、推荐/取消推荐、排序和删除。目标兼容层只能在不指定字段时还原旧数据;可回滚的临时创建—读取—删除实测证实 `sourceKey` 无法还原保留名为 `id` 的数字业务键,因此 Angular 隐藏新增,云函数返回 501。旧 `starturl` 依赖不存在的 `SiteConfig.SiteOption.Admin_StartUrl` 持久层,同样明确 501,未以浏览器本地存储伪造全局配置。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 65 项已实现、32 项部分实现、114 项数据阻塞、193 项基础设施阻塞、638 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 66 项已实现、35 项部分实现、114 项数据阻塞、193 项基础设施阻塞、634 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

+ 40 - 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: '后台快捷入口', 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'
@@ -42,7 +42,8 @@ const CLASS_SYSTEM_FIELDS = {
   GradeCate: new Set(['cateId','sourceKey']),
   Grade: new Set(['gradeId','cate','parentId','grade','sourceKey']),
   Currency: new Set(['id','sourceKey']),
-  SysHoliday: new Set(['id','cdate','cadminId','cuserId','sourceKey'])
+  SysHoliday: new Set(['id','cdate','cadminId','cuserId','sourceKey']),
+  Search: new Set(['id','type','state','time','adminId','orderId','linkType','linkState','sourceKey'])
 };
 
 function inputOf(request) {
@@ -932,6 +933,41 @@ async function handler(request, response) {
       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 === 'searchNavigations') {
+      const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const type = Number(input.type == null ? 1 : input.type); const state = Number(input.state == null ? -100 : input.state); const elite = Number(input.elite == null ? -100 : input.elite);
+      if (![1,2].includes(type) || ![-100,1,2].includes(state) || ![-100,0,1].includes(elite)) fail(400, '快捷入口筛选条件无效');
+      const fields = await schemaFor('Search'); const query = new Parse.Query('Search'); applyTenant(query, fields, context, input.companyId); query.limit(1000); const search = String(input.search || '').trim().toLowerCase();
+      let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => Number(entry.get('type')) === type && (state === -100 || Number(entry.get('state')) === state) && (elite === -100 || (elite === 1 ? Number(entry.get('eliteLevel')) > 0 : Number(entry.get('eliteLevel')) === 0)) && (!search || String(entry.get('name') || '').toLowerCase().includes(search) || String(entry.get('fileUrl') || '').toLowerCase().includes(search)));
+      results.sort((left, right) => Number(left.get('orderId') || 0) - Number(right.get('orderId') || 0) || 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: 'Search', page, pageSize, total, results: results.map(serializeObject) } });
+    }
+    if (operation === 'saveSearchNavigation') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
+      if (!objectId) fail(501, 'migration_blocked: Search.id 与 Parse 对象保留 id 冲突,且 PostgreSQL 物理表缺少可写旧 ID 列,不能安全新增快捷入口');
+      const name = String(payload.name || '').trim(); const fileUrl = String(payload.fileUrl || '').trim(); const ico = String(payload.ico || '').trim(); const bkcolor = String(payload.bkcolor || '').trim(); const userGroup = String(payload.userGroup || '').trim();
+      const openType = Number(payload.openType); const mobile = Number(payload.mobile); const size = Number(payload.size); const eliteLevel = Number(payload.eliteLevel);
+      if (!name || name.length > 100) fail(400, '快捷入口名称长度应为 1 至 100 位');
+      if (!fileUrl || fileUrl.length > 500 || !fileUrl.startsWith('/') || fileUrl.startsWith('//') || /^[a-z]+:/i.test(fileUrl)) fail(400, '快捷入口地址必须是 1 至 500 位的站内路径');
+      if (ico.length > 100 || (bkcolor && !/^#[0-9a-f]{6}$/i.test(bkcolor))) fail(400, '图标或背景颜色格式无效');
+      if (![0,1].includes(openType) || ![0,1].includes(mobile) || !Number.isInteger(size) || size < 1 || size > 4 || !Number.isInteger(eliteLevel) || eliteLevel < 0 || eliteLevel > 10) fail(400, '快捷入口展示参数无效');
+      if (userGroup && !/^\d+(,\d+)*$/.test(userGroup)) fail(400, '用户组必须使用逗号分隔的正整数 ID');
+      const fields = await schemaFor('Search'); const query = new Parse.Query('Search'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true });
+      target.set('name', name); target.set('fileUrl', fileUrl); target.set('ico', ico); target.set('openType', openType); target.set('mobile', mobile); target.set('size', size); target.set('bkcolor', bkcolor); target.set('userGroup', userGroup); target.set('eliteLevel', eliteLevel);
+      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-search-navigation', 'Search', target.id); return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'searchNavigationBatch') {
+      if (String(input.className || '') !== 'Search') fail(400, '只能处理快捷入口'); const action = String(input.action || '');
+      if (action === 'starturl') fail(501, 'migration_blocked: 目标云函数架构没有旧 SiteConfig.SiteOption.Admin_StartUrl 持久层,不能伪造全局后台起始页');
+      if (!['enable','disable','elite','unelite','delete','order'].includes(action)) fail(400, '不支持的快捷入口批量操作');
+      let requestedIds = Array.isArray(input.objectIds) ? input.objectIds : [input.objectId]; let orders = [];
+      if (action === 'order') { orders = Array.isArray(input.orders) ? input.orders.map((entry) => ({ objectId: String(entry && entry.objectId || '').trim(), orderId: Number(entry && entry.orderId) })) : []; if (!orders.length || orders.length > 100 || orders.some((entry) => !entry.objectId || !Number.isInteger(entry.orderId) || entry.orderId < 1 || entry.orderId > 1000000)) fail(400, '排序必须包含 1 至 100 条有效入口及正整数顺序'); requestedIds = orders.map((entry) => entry.objectId); }
+      const objectIds = [...new Set(requestedIds.map((value) => String(value || '').trim()).filter(Boolean))]; if (!objectIds.length || objectIds.length > 100 || (action === 'order' && objectIds.length !== orders.length)) fail(400, '每次请选择 1 至 100 条唯一快捷入口');
+      const fields = await schemaFor('Search'); const query = new Parse.Query('Search'); 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 { 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 === 'saveExamClass') {
       const objectId = String(input.objectId || '');
       const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
@@ -1195,7 +1231,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'].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'].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));
@@ -1229,6 +1265,7 @@ async function handler(request, response) {
       if (className === 'GradeCate' || className === 'Grade') fail(400, '多级数据字典必须走专用保存流程');
       if (className === 'Currency') fail(400, '货币必须走专用保存流程');
       if (className === 'SysHoliday') fail(400, '节假日必须走专用保存流程');
+      if (className === 'Search') 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

@@ -117,6 +117,10 @@ const explicit = new Map([
   ['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']],
+  ['Config/SearchFunc', ['implemented', '已提供管理后台/用户中心类型、启用状态、推荐状态筛选及排序分页']],
+  ['Config/SearchFunc_API', ['partial', '已覆盖启停、推荐、取消推荐、排序与删除;设为全局后台起始页缺少 SiteConfig 持久层']],
+  ['Config/SearchFuncAdd', ['partial', '已由 Search 专用现有入口编辑抽屉替代;新增因旧 id 不可持久化而阻塞']],
+  ['Config/SearchFuncAdd_Submit', ['partial', '已由 saveSearchNavigation 白名单更新替代;新增明确返回 migration_blocked']],
 ]);
 
 const blockedDataModules = new Map([

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

@@ -239,6 +239,11 @@ try {
   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('节假日列表返回异常');
+  const searchNavigationSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'Search' });
+  const searchNavigationFieldMap = Object.fromEntries((searchNavigationSchema.fields || []).map((field) => [field.name, field]));
+  if (searchNavigationSchema.creatable !== false || ['id','type','state','time','adminId','orderId','linkType','linkState','sourceKey'].some((name) => searchNavigationFieldMap[name]?.writable !== false)) throw new Error('快捷入口结构字段未从新增或通用编辑中隔离');
+  const searchNavigationPage = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'searchNavigations', className: 'Search', type: 1, state: -100, elite: -100, page: 1, pageSize: 20 });
+  if (!Array.isArray(searchNavigationPage.results) || !searchNavigationPage.results.length || searchNavigationPage.results.some((entry) => Number(entry.type) !== 1)) 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' } });
@@ -431,6 +436,10 @@ try {
   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 阻塞原因');
+  const searchNavigationCreateBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveSearchNavigation', className: 'Search', fields: { name: '禁止无旧 ID 快捷入口' } }, 501);
+  if (!String(searchNavigationCreateBlocked.message).includes('保留 id')) throw new Error('快捷入口新增未返回明确旧 ID 阻塞原因');
+  const searchNavigationStartBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'searchNavigationBatch', className: 'Search', action: 'starturl', objectIds: ['not-used'] }, 501);
+  if (!String(searchNavigationStartBlocked.message).includes('SiteConfig')) throw new Error('快捷入口全局起始页未返回明确阻塞原因');
   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}` } });
@@ -666,7 +675,7 @@ try {
   const productBlocked = await callLegacyFunction('', { action: 'product_list' }, 501);
   if (!String(productBlocked.retmsg).includes('ZL_Commodities')) throw new Error('缺商品主表的读取接口未明确阻塞');
 
-  console.log('Cloud smoke passed: admin auth/tenant/account lifecycle/group sync/node/special/guest-category/bar/exam-class/exam-point/knowledge/exam-teacher/dictionary/grade-dictionary/currency/holiday/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/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

@@ -30,6 +30,7 @@ export const ADMIN_NAVIGATION = [
   { 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: 'panels-top-left', route: '/admin/resources/Search' },
   { 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

@@ -45,6 +45,10 @@ describe('legacy admin coverage inventory', () => {
     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('Config/SearchFunc')?.status).toBe('implemented');
+    expect(byKey.get('Config/SearchFunc_API')?.status).toBe('partial');
+    expect(byKey.get('Config/SearchFuncAdd')?.status).toBe('partial');
+    expect(byKey.get('Config/SearchFuncAdd_Submit')?.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');

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

@@ -902,29 +902,29 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Config",
     "action": "SearchFunc",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已提供管理后台/用户中心类型、启用状态、推荐状态筛选及排序分页"
   },
   {
     "module": "Config",
     "action": "SearchFunc_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已覆盖启停、推荐、取消推荐、排序与删除;设为全局后台起始页缺少 SiteConfig 持久层"
   },
   {
     "module": "Config",
     "action": "SearchFuncAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已由 Search 专用现有入口编辑抽屉替代;新增因旧 id 不可持久化而阻塞"
   },
   {
     "module": "Config",
     "action": "SearchFuncAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "已由 saveSearchNavigation 白名单更新替代;新增明确返回 migration_blocked"
   },
   {
     "module": "Config",

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

@@ -8,6 +8,9 @@
   @if (className() === 'Grade') {
     <div class="bulk-toolbar"><strong>多级字典范围</strong><select [ngModel]="filterGradeCategoryId()" (ngModelChange)="filterGradeCategoryId.set(+$event); filterGradeParentId.set(0); load(1)"><option [ngValue]="0">请选择分类</option>@for (category of gradeCategories(); track category['objectId']) { <option [ngValue]="gradeCategoryIdOf(category)">{{ category['cateName'] }}({{ category['cateId'] }})</option> }</select><input class="bulk-node" type="number" min="0" [ngModel]="filterGradeParentId()" (ngModelChange)="filterGradeParentId.set(+$event)" placeholder="父选项 ID" /><button type="button" [disabled]="!filterGradeCategoryId()" (click)="load(1)">查看该层</button></div>
   }
+  @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 (bulkEnabled() && selectedIds().length) {
     <div class="bulk-toolbar"><strong>已选 {{ selectedIds().length }} 条</strong>
       @if (className() === '_User') {
@@ -32,6 +35,8 @@
         <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 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 {
         <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>
@@ -73,6 +78,9 @@
         @if (className() === 'Model' || className() === 'ModelField') {
           <section class="account-create-note"><strong>仅编辑安全元数据</strong><span>模型编号、表名、字段名、字段类型和排序属于数据库结构,已锁定。新增、删除、复制或改变字段类型需要 PostgreSQL DDL 与旧模板运行时,当前会明确返回迁移阻塞。</span></section>
         }
+        @if (className() === 'Search') {
+          <section class="account-create-note"><strong>现有快捷入口编辑</strong><span>旧数字 ID 在目标兼容层不可写,因此仅允许编辑现有入口;启停、推荐和排序请在列表批量工具中操作。旧全局后台起始页依赖的 SiteConfig 持久层已不存在。</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)" /> }

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

@@ -515,6 +515,45 @@ describe('AdminResourceComponent', () => {
     expect(component.bulkMessage()).toContain('2 条节假日记录');
   });
 
+  it('loads shortcut navigation through the dedicated filtered operation', async () => {
+    component.className.set('Search');
+    component.schema.set({ className: 'Search', label: '后台快捷入口', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.filterSearchNavigationType.set(2); component.filterSearchNavigationState.set(1); component.filterSearchNavigationElite.set(0); component.search.set('学习');
+    functions.admin.and.resolveTo({ className: 'Search', page: 2, pageSize: 20, total: 21, results: [] });
+
+    await component.load(2);
+
+    expect(functions.admin.calls.first().args).toEqual(['searchNavigations', { className: 'Search', page: 2, pageSize: 20, search: '学习', type: 2, state: 1, elite: 0 }]);
+  });
+
+  it('updates an existing shortcut through the validated dedicated operation', async () => {
+    component.className.set('Search');
+    component.schema.set({ className: 'Search', label: '后台快捷入口', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.draftId.set('search-object');
+    component.draft.set({ name: '学习记录', fileUrl: '/admin/resources/DailyStudyRecord', ico: 'list', openType: 0, mobile: 1, size: 2, bkcolor: '#1E86EA', userGroup: '1,2', eliteLevel: 1, id: 19, state: 1 });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveSearchNavigation'
+      ? { objectId: 'search-object', id: 19, name: '学习记录' }
+      : { className: 'Search', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveSearchNavigation', {
+      className: 'Search', objectId: 'search-object', fields: { name: '学习记录', fileUrl: '/admin/resources/DailyStudyRecord', ico: 'list', openType: 0, mobile: 1, size: 2, bkcolor: '#1E86EA', userGroup: '1,2', eliteLevel: 1, id: 19, state: 1 },
+    }]);
+  });
+
+  it('orders selected shortcuts through the dedicated batch operation', async () => {
+    component.className.set('Search'); component.selectedIds.set(['search-a', 'search-b']); component.searchNavigationOrderStart.set(7);
+    functions.admin.and.callFake(async (operation: string) => operation === 'searchNavigationBatch'
+      ? { action: 'order', updated: 2, results: [] }
+      : { className: 'Search', page: 1, pageSize: 20, total: 0, results: [] });
+
+    await component.applyBulkSearchNavigationAction('order');
+
+    expect(functions.admin.calls.first().args).toEqual(['searchNavigationBatch', { className: 'Search', action: 'order', objectIds: ['search-a', 'search-b'], orders: [{ objectId: 'search-a', orderId: 7 }, { objectId: 'search-b', orderId: 8 }] }]);
+    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');

+ 32 - 5
src/app/admin/pages/admin-resource.component.ts

@@ -91,18 +91,23 @@ export class AdminResourceComponent implements OnInit {
   readonly updatingGrade = signal(false);
   readonly updatingCurrency = signal(false);
   readonly updatingHoliday = signal(false);
+  readonly filterSearchNavigationType = signal(1);
+  readonly filterSearchNavigationState = signal(-100);
+  readonly filterSearchNavigationElite = signal(-100);
+  readonly searchNavigationOrderStart = signal(1);
+  readonly updatingSearchNavigation = 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', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
+    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 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'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search'].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));
@@ -116,6 +121,7 @@ export class AdminResourceComponent implements OnInit {
       this.className.set(params.get('className') ?? '');
       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);
       void this.load(1, true);
     });
   }
@@ -125,9 +131,10 @@ 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' : 'list';
+      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : '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(); }
       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();
@@ -192,7 +199,8 @@ export class AdminResourceComponent implements OnInit {
                                 : this.className() === 'GradeCate' ? 'saveGradeCategory'
                                   : this.className() === 'Grade' ? 'saveGradeOption'
                                     : this.className() === 'Currency' ? 'saveCurrency'
-                                      : this.className() === 'SysHoliday' ? 'saveHoliday' : 'save';
+                                      : this.className() === 'SysHoliday' ? 'saveHoliday'
+                                        : this.className() === 'Search' ? 'saveSearchNavigation' : '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();
@@ -515,11 +523,30 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量删除节假日失败'); }
     finally { this.updatingHoliday.set(false); }
   }
+  async applyBulkSearchNavigationAction(action: 'enable' | 'disable' | 'elite' | 'unelite' | 'order' | 'delete'): Promise<void> {
+    const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (this.className() !== 'Search' || !objectIds.length) { this.bulkError.set('请先选择快捷入口'); return; }
+    if (action === 'delete' && !confirm(`确认删除选中的 ${objectIds.length} 条快捷入口?`)) return;
+    const payload: Record<string, unknown> = { className: 'Search', action, objectIds };
+    if (action === 'order') {
+      const start = this.searchNavigationOrderStart();
+      if (!Number.isInteger(start) || start < 1 || start + objectIds.length - 1 > 1000000) { this.bulkError.set('请输入有效的起始排序'); return; }
+      payload['orders'] = objectIds.map((objectId, index) => ({ objectId, orderId: start + index }));
+    }
+    this.updatingSearchNavigation.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('searchNavigationBatch', payload); await this.load(); this.bulkMessage.set(`已处理 ${result.updated} 条快捷入口`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '批量处理快捷入口失败'); }
+    finally { this.updatingSearchNavigation.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 { await this.functions.admin('delete', { className: this.className(), objectId }); await this.load(); }
+    try {
+      if (this.className() === 'Search') await this.functions.admin('searchNavigationBatch', { className: 'Search', action: 'delete', objectIds: [objectId] });
+      else await this.functions.admin('delete', { className: this.className(), objectId });
+      await this.load();
+    }
     catch (error) { this.error.set(error instanceof Error ? error.message : '删除失败'); }
   }
   display(value: unknown): string {