Przeglądaj źródła

feat: migrate survey administration

彭峰 1 miesiąc temu
rodzic
commit
6623d14f96

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

@@ -41,8 +41,9 @@
 - 旧 `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,现有编辑分支继续可用。
 - 旧字体图后台的 `Shape/ShapeInfo/ShapeInfo_Submit/Shape_API/ShapeType/ShapeTypeInfo/ShapeTypeInfo_Submit/ShapeType_API` 已映射到目标 `FontPicShape/FontPicShapeType`:专用列表支持帐套、关键词和分类筛选;现有素材可维护名称、备注、分类并安全预览原路径,现有分类可维护名称和备注,删除分类前检查素材引用,素材/分类批量删除限制每次 1–100 条。可回滚探测证明省略 `id` 创建不会生成旧编号,因此新增素材/分类明确 501。图片替换也明确列为基础设施阻塞:线上实测 `Parse.File` SDK 与 `/parse/files` REST 端点均返回“要求 mongodb:// 或 mongodb+srv:// 连接串”,说明当前 PostgreSQL 部署的 Parse 文件存储适配器配置错误;Angular 不展示失效上传控件,云函数收到 `file` 时返回 501,避免伪成功。线上完整 Schema 核验只有 `FontPicShape/FontPicShapeType/UserSIcon`,其中 `UserSIcon` 是积分流水而非字体图;旧 `FontPic` 成品图和 `FontPicDraft` 草稿主表及数据均不存在,其 8 个动作由 `legacyFontPicBlocker` 返回逐项数据阻塞原因。
+- 旧问卷后台 `SurveyManage/Survey_API/SurveyAdd/SurveyAdd_Submit/SurveyItemList/SurveyItemAdd/SurveyItemAdd_Submit/SurveyItem_API/SurveyResultChart/SurveyResultList` 已按真实兼容模型 `DesignAsk/DesignQuestion/DesignAnswer` 迁移:问卷支持状态和关键词筛选、启停与删除,题目按问卷分页、编辑、排序和删除,答卷只读展示并可删除,结果统计从旧 `answer` JSON 聚合各选项计数且单独报告不可解析历史答卷。目标 `SurveyItem/SurveyLog` 属于新的考试测评模型,没有错误拼接到旧问卷。兼容层的 `count` 对 `DesignAsk` 错报 0,专用云函数改为读取实际行后分页;线上实际存在 2 份问卷、96 道题和 105 份答卷。可回滚 `sourceKey` 创建探测证明无法生成旧数字 ID,因此新增问卷和题目明确 501,现有记录编辑继续可用。Angular 新增问卷、题目、答卷入口、问卷范围筛选、批量工具和安全统计视图。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 77 项已实现、45 项部分实现、123 项数据阻塞、203 项基础设施阻塞、594 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 83 项已实现、49 项部分实现、123 项数据阻塞、203 项基础设施阻塞、584 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

+ 49 - 4
scripts/deploy-admin-functions.mjs

@@ -15,12 +15,12 @@ const CLASS_LABELS = {
   GradeCate: '多级字典分类', Grade: '多级字典选项',
   Currency: '货币管理', SysHoliday: '节假日管理',
   Product: '商品', StoreProduct: '门店商品', StoreApplication: '门店申请', ShopFareTlp: '运费模板', ShopMoneyRegular: '金额规则', PayPlat: '支付平台',
-  GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', FontPicShape: '字体图形素材', FontPicShapeType: '字体图形分类', Agency: '代理机构', DeliveryCenter: '交付中心'
+  GuestBar: '互动社区', Guestbook: '留言', Guestcate: '留言分类', Feedback: '反馈', Baike: '百科', Search: '后台快捷入口', AdZone: '广告位管理', AdInfo: '广告内容管理', FontPicShape: '字体图形素材', FontPicShapeType: '字体图形分类', DesignAsk: '问卷调查', DesignQuestion: '问卷题目', DesignAnswer: '问卷答卷', 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'
 ]);
-const READ_ONLY_CLASSES = new Set(['_Role','Permission','PayPlat','ThirdPlatInfo','GuestBar']);
+const READ_ONLY_CLASSES = new Set(['_Role','Permission','PayPlat','ThirdPlatInfo','GuestBar','DesignAnswer']);
 const SYSTEM_FIELDS = new Set(['objectId','createdAt','updatedAt','ACL','company','password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','adminRoleKey','isAdmin','roles','role','isDisabled','legacyUserId','legacyGroupId','legacyUserData','groupId']);
 const HIDDEN_FIELDS = new Set(['password','authData','sessionToken','legacyPasswordHash','legacyPasswordHashType','appPassword','newapiToken','fmodeApiToken','useMasterKey','adminPassword','randNumber','apiKey','appSecret','secret','token']);
 const GENERIC_WRITE_TYPES = new Set(['String','Number','Boolean','Date','Pointer','Object','Array']);
@@ -47,7 +47,10 @@ const CLASS_SYSTEM_FIELDS = {
   AdZone: new Set(['id','cdate','cadmin','image','sourceKey']),
   AdInfo: new Set(['id','cdate','zoneId','ztype','extend1','extend2','extend3','sourceKey']),
   FontPicShape: new Set(['id','shape','typeId','userId','userName','sourceKey','createTime','updateTime']),
-  FontPicShapeType: new Set(['id','sourceKey','createTime','updateTime'])
+  FontPicShapeType: new Set(['id','sourceKey','createTime','updateTime']),
+  DesignAsk: new Set(['id','cdate','cuser','adminId','sourceKey']),
+  DesignQuestion: new Set(['id','askId','cdate','cuser','orderId','sourceKey']),
+  DesignAnswer: new Set(['id','askId','ip','cdate','userId','sourceKey'])
 };
 
 function inputOf(request) {
@@ -1091,6 +1094,46 @@ async function handler(request, response) {
     if (operation === 'legacyFontPicBlocker') {
       const action = String(input.action || ''); const blockers = { Index: '目标 Schema 缺少旧 FontPic 成品字体图主表与历史数据', FontPicInfo: '目标 Schema 缺少旧 FontPic 成品字体图详情数据', FontPicInfo_Submit: '目标 Schema 缺少旧 FontPic 成品字体图持久化模型', FontPic_API: '目标 Schema 缺少旧 FontPic 成品字体图可删除记录', Draft: '目标 Schema 缺少旧 FontPicDraft 草稿表与历史数据', DraftInfo: '目标 Schema 缺少旧 FontPicDraft 草稿详情数据', DraftInfo_Submit: '目标 Schema 缺少旧 FontPicDraft 草稿持久化模型', Draft_API: '目标 Schema 缺少旧 FontPicDraft 可删除记录' }; if (!Object.prototype.hasOwnProperty.call(blockers, action)) fail(400, '不支持的旧字体图动作'); fail(501, 'migration_blocked: ' + blockers[action]);
     }
+    if (operation === 'surveys') {
+      const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const search = String(input.search || '').trim().toLowerCase(); const status = Number(input.status == null ? -100 : input.status); if (search.length > 100 || ![-100,0,99].includes(status)) fail(400, '问卷筛选条件无效');
+      const fields = await schemaFor('DesignAsk'); const query = new Parse.Query('DesignAsk'); 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) && (!search || [entry.get('title'),entry.get('category'),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: 'DesignAsk', page, pageSize, total, results: results.map(serializeObject) } });
+    }
+    if (operation === 'surveyQuestions') {
+      const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const askId = Number(input.askId || 0); const search = String(input.search || '').trim().toLowerCase(); if (!Number.isInteger(askId) || askId < 1 || search.length > 100) fail(400, '问卷题目筛选条件无效');
+      const askFields = await schemaFor('DesignAsk'); const askQuery = new Parse.Query('DesignAsk'); applyTenant(askQuery, askFields, context, input.companyId); askQuery.limit(1000); const asks = await askQuery.find({ useMasterKey: true }); if (!asks.some((entry) => Number(entry.get('id')) === askId)) fail(404, '问卷不存在或不属于当前帐套');
+      const fields = await schemaFor('DesignQuestion'); const query = new Parse.Query('DesignQuestion'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => Number(entry.get('askId')) === askId && (!search || [entry.get('qtitle'),entry.get('qcontent'),entry.get('qoption')].some((value) => String(value || '').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: 'DesignQuestion', askId, page, pageSize, total, results: results.map(serializeObject) } });
+    }
+    if (operation === 'surveyResults') {
+      const page = Math.max(1, Number(input.page) || 1); const pageSize = Math.min(100, Math.max(1, Number(input.pageSize) || 20)); const askId = Number(input.askId || 0); if (!Number.isInteger(askId) || askId < 1) fail(400, '问卷答卷筛选条件无效');
+      const askFields = await schemaFor('DesignAsk'); const askQuery = new Parse.Query('DesignAsk'); applyTenant(askQuery, askFields, context, input.companyId); askQuery.limit(1000); const asks = await askQuery.find({ useMasterKey: true }); if (!asks.some((entry) => Number(entry.get('id')) === askId)) fail(404, '问卷不存在或不属于当前帐套');
+      const fields = await schemaFor('DesignAnswer'); const query = new Parse.Query('DesignAnswer'); applyTenant(query, fields, context, input.companyId); query.limit(1000); let results = await query.find({ useMasterKey: true }); results = results.filter((entry) => Number(entry.get('askId')) === askId); results.sort((left, right) => Number(right.get('id') || 0) - Number(left.get('id') || 0)); const total = results.length; results = results.slice((page - 1) * pageSize, page * pageSize); return response.json({ success: true, data: { className: 'DesignAnswer', askId, page, pageSize, total, results: results.map(serializeObject) } });
+    }
+    if (operation === 'saveSurvey') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; if (!objectId) fail(501, 'migration_blocked: DesignAsk.id 与 Parse 保留 id 冲突,sourceKey 创建探测不能还原旧问卷编号,不能安全新增问卷');
+      const title = String(payload.title || '').trim(); const category = String(payload.category || '').trim(); const remind = String(payload.remind || '').trim(); const preViewImg = String(payload.preViewImg || '').trim(); const startRaw = payload.startDate && typeof payload.startDate === 'object' ? payload.startDate.iso : payload.startDate; const endRaw = payload.endDate && typeof payload.endDate === 'object' ? payload.endDate.iso : payload.endDate; const startDate = new Date(String(startRaw || '')); const endDate = new Date(String(endRaw || '')); const ztype = Number(payload.ztype || 0); const ipinterval = Number(payload.ipinterval || 0); const zstatus = Number(payload.zstatus == null ? 0 : payload.zstatus); const flags = { isIplimit: Number(payload.isIplimit || 0), isNeedLogin: Number(payload.isNeedLogin || 0), isShowResult: Number(payload.isShowResult || 0), isEnableVcode: Number(payload.isEnableVcode || 0) };
+      if (!title || title.length > 200 || category.length > 100 || remind.length > 10000 || preViewImg.length > 2000 || Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate < startDate || endDate.getTime() < Date.now() || !Number.isInteger(ztype) || ztype < 0 || !Number.isFinite(ipinterval) || ipinterval < 0 || ![0,99].includes(zstatus) || Object.values(flags).some((value) => ![0,1].includes(value))) fail(400, '问卷标题、时间、状态或访问设置无效');
+      const fields = await schemaFor('DesignAsk'); const query = new Parse.Query('DesignAsk'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); target.set('title', title); target.set('category', category); target.set('remind', remind); target.set('preViewImg', preViewImg); target.set('startDate', startDate); target.set('endDate', endDate); target.set('ztype', ztype); target.set('ipinterval', ipinterval); target.set('zstatus', zstatus); for (const [name,value] of Object.entries(flags)) target.set(name, value); await target.save(null, { useMasterKey: true }); await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-survey', 'DesignAsk', target.id); return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'saveSurveyQuestion') {
+      const objectId = String(input.objectId || ''); const payload = input.fields && typeof input.fields === 'object' ? input.fields : {}; if (!objectId) fail(501, 'migration_blocked: DesignQuestion.id 与 Parse 保留 id 冲突,sourceKey 创建探测不能还原旧题目编号,不能安全新增问卷题目');
+      const qtitle = String(payload.qtitle || '').trim(); const qcontent = String(payload.qcontent || '').trim(); const qtype = String(payload.qtype || '').trim().toLowerCase(); const qoption = String(payload.qoption || '').trim(); const qflag = String(payload.qflag || '').trim(); const required = payload.required === true || payload.required === 1 || payload.required === '1' || payload.required === 'true' ? 1 : 0; if (!qtitle || qtitle.length > 1000 || qcontent.length > 20000 || !/^[a-z][a-z0-9_-]{0,30}$/.test(qtype) || qoption.length > 20000 || qflag.length > 5000) fail(400, '问卷题目内容或类型无效');
+      if (['radio','checkbox'].includes(qtype)) { let options; try { options = JSON.parse(qoption); } catch { fail(400, '单选/多选题选项必须是有效 JSON 数组'); } if (!Array.isArray(options) || !options.length || options.length > 100 || options.some((item) => !item || typeof item !== 'object' || !String(item.text || '').trim() || String(item.text || '').length > 500 || String(item.value || '').length > 500)) fail(400, '单选/多选题选项格式无效'); }
+      if (qflag) { let flag; try { flag = JSON.parse(qflag); } catch { fail(400, '题目扩展设置必须是有效 JSON'); } if (!flag || typeof flag !== 'object' || Array.isArray(flag)) fail(400, '题目扩展设置必须是 JSON 对象'); }
+      const fields = await schemaFor('DesignQuestion'); const query = new Parse.Query('DesignQuestion'); applyTenant(query, fields, context, input.companyId); const target = await query.get(objectId, { useMasterKey: true }); const askId = Number(target.get('askId') || 0); const askFields = await schemaFor('DesignAsk'); const askQuery = new Parse.Query('DesignAsk'); applyTenant(askQuery, askFields, context, input.companyId); askQuery.limit(1000); const asks = await askQuery.find({ useMasterKey: true }); if (!asks.some((entry) => Number(entry.get('id')) === askId)) fail(409, '题目所属问卷已不存在,不能继续编辑');
+      target.set('qtitle', qtitle); target.set('qcontent', qcontent); target.set('qtype', qtype); target.set('qoption', qoption); target.set('qflag', qflag || '{}'); target.set('required', required); await target.save(null, { useMasterKey: true }); await target.fetch({ useMasterKey: true }); await audit({ ...context, company: target.get('company') || context.company }, 'update-survey-question', 'DesignQuestion', target.id); return response.json({ success: true, data: serializeObject(target) });
+    }
+    if (operation === 'surveyBatch') {
+      const className = String(input.className || ''); const action = String(input.action || ''); const allowed = className === 'DesignAsk' ? ['start','stop','delete'] : className === 'DesignQuestion' ? ['delete','sort'] : className === 'DesignAnswer' ? ['delete'] : []; if (!allowed.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(className); const query = new Parse.Query(className); 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 if (className === 'DesignAsk') { for (const target of targets) target.set('zstatus', action === 'start' ? 99 : 0); await Parse.Object.saveAll(targets, { useMasterKey: true }); } else { const orders = Array.isArray(input.orders) ? input.orders : []; const orderMap = new Map(orders.map((item) => [String(item && item.objectId || ''), Number(item && item.orderId)])); if (orderMap.size !== objectIds.length || [...orderMap.values()].some((value) => !Number.isInteger(value) || value < 1 || value > 1000000)) fail(400, '题目排序数据无效'); for (const target of targets) 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 }, 'survey-' + className + '-' + action, className, target.id); return response.json({ success: true, data: { className, action, updated: targets.length, results: action === 'delete' ? [] : targets.map(serializeObject) } });
+    }
+    if (operation === 'surveyResultChart') {
+      const askId = Number(input.askId || 0); if (!Number.isInteger(askId) || askId < 1) fail(400, '缺少有效问卷编号'); const askFields = await schemaFor('DesignAsk'); const askQuery = new Parse.Query('DesignAsk'); applyTenant(askQuery, askFields, context, input.companyId); askQuery.limit(1000); const asks = await askQuery.find({ useMasterKey: true }); const survey = asks.find((entry) => Number(entry.get('id')) === askId); if (!survey) fail(404, '问卷不存在或不属于当前帐套');
+      const questionFields = await schemaFor('DesignQuestion'); const questionQuery = new Parse.Query('DesignQuestion'); applyTenant(questionQuery, questionFields, context, input.companyId); questionQuery.limit(1000); const questions = (await questionQuery.find({ useMasterKey: true })).filter((entry) => Number(entry.get('askId')) === askId); const answerFields = await schemaFor('DesignAnswer'); const answerQuery = new Parse.Query('DesignAnswer'); applyTenant(answerQuery, answerFields, context, input.companyId); answerQuery.limit(1000); const answers = (await answerQuery.find({ useMasterKey: true })).filter((entry) => Number(entry.get('askId')) === askId); const counts = new Map(); let invalidAnswers = 0;
+      for (const entry of answers) { let details; try { details = JSON.parse(String(entry.get('answer') || '')); } catch { invalidAnswers++; continue; } if (!Array.isArray(details)) { invalidAnswers++; continue; } for (const detail of details) { const qid = String(detail && detail.qid || ''); let values = detail && detail.answer; if (!Array.isArray(values)) values = values == null || values === '' ? [] : [values]; for (const value of values) { const key = qid + '\u0000' + String(value); counts.set(key, (counts.get(key) || 0) + 1); } } }
+      const chart = questions.sort((left, right) => Number(left.get('orderId') || 0) - Number(right.get('orderId') || 0) || Number(left.get('id') || 0) - Number(right.get('id') || 0)).map((question) => { let options = []; try { const parsed = JSON.parse(String(question.get('qoption') || '[]')); if (Array.isArray(parsed)) options = parsed; } catch {} const qid = String(question.get('id') || ''); const known = new Set(options.map((option) => String(option && option.value || ''))); let otherCount = 0; for (const [key,value] of counts) if (key.startsWith(qid + '\u0000') && !known.has(key.slice(qid.length + 1))) otherCount += value; return { id: qid, title: String(question.get('qtitle') || ''), type: String(question.get('qtype') || ''), options: options.map((option) => ({ text: String(option && option.text || ''), value: String(option && option.value || ''), count: counts.get(qid + '\u0000' + String(option && option.value || '')) || 0 })), otherCount }; }); return response.json({ success: true, data: { survey: serializeObject(survey), responseCount: answers.length, invalidAnswers, questions: chart } });
+    }
     if (operation === 'saveExamClass') {
       const objectId = String(input.objectId || '');
       const payload = input.fields && typeof input.fields === 'object' ? input.fields : {};
@@ -1354,7 +1397,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','AdZone','AdInfo','FontPicShape','FontPicShapeType'].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','FontPicShape','FontPicShapeType','DesignAsk','DesignQuestion'].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));
@@ -1391,6 +1434,7 @@ async function handler(request, response) {
       if (className === 'Search') fail(400, '快捷入口必须走专用保存流程');
       if (className === 'AdZone' || className === 'AdInfo') fail(400, '广告位与广告内容必须走专用保存流程');
       if (className === 'FontPicShape' || className === 'FontPicShapeType') fail(400, '字体图形素材与分类必须走专用保存流程');
+      if (className === 'DesignAsk' || className === 'DesignQuestion' || className === 'DesignAnswer') 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);
@@ -1405,6 +1449,7 @@ async function handler(request, response) {
       if (className === 'Model' || className === 'ModelField') fail(501, 'migration_blocked: 删除模型或字段需要同步删除 PostgreSQL 物理表/列并验证历史数据,托管云函数中未开放此 DDL 流程');
       if (className === 'AdZone' || className === 'AdInfo') fail(400, '广告位与广告内容必须走专用批量删除流程');
       if (className === 'FontPicShape' || className === 'FontPicShapeType') fail(400, '字体图形素材与分类必须走专用批量删除流程');
+      if (className === 'DesignAsk' || className === 'DesignQuestion' || className === 'DesignAnswer') 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

@@ -161,6 +161,16 @@ const explicit = new Map([
   ['FontPic/DraftInfo', ['blocked-data', '目标 Schema 缺少旧 FontPicDraft 草稿详情数据']],
   ['FontPic/DraftInfo_Submit', ['blocked-data', '目标 Schema 缺少旧 FontPicDraft 草稿持久化模型']],
   ['FontPic/Draft_API', ['blocked-data', '目标 Schema 缺少旧 FontPicDraft 可删除记录']],
+  ['Extend / Survey/SurveyManage', ['implemented', '已由 surveys 按帐套、状态和关键词分页读取旧 DesignAsk 问卷']],
+  ['Extend / Survey/Survey_API', ['implemented', '已由 surveyBatch 覆盖问卷启停/删除及答卷嵌入明细删除']],
+  ['Extend / Survey/SurveyAdd', ['partial', '现有问卷编辑页已迁移;新增因旧 id 无法持久化而明确阻塞']],
+  ['Extend / Survey/SurveyAdd_Submit', ['partial', '现有问卷由 saveSurvey 校验时间、状态和访问设置后更新;新增明确阻塞']],
+  ['Extend / Survey/SurveyItemList', ['implemented', '已由 surveyQuestions 校验问卷归属后按排序分页读取题目']],
+  ['Extend / Survey/SurveyItemAdd', ['partial', '现有题目编辑页已迁移;新增因旧 id 无法持久化而明确阻塞']],
+  ['Extend / Survey/SurveyItemAdd_Submit', ['partial', '现有题目由 saveSurveyQuestion 校验题型、选项和扩展 JSON 后更新;新增明确阻塞']],
+  ['Extend / Survey/SurveyItem_API', ['implemented', '已由 surveyBatch 覆盖题目删除和按选择顺序重排']],
+  ['Extend / Survey/SurveyResultChart', ['implemented', '已由 surveyResultChart 从旧 answer JSON 安全聚合选项计数并报告不可解析答卷']],
+  ['Extend / Survey/SurveyResultList', ['implemented', '已由 surveyResults 提供帐套与问卷隔离的答卷分页和只读详情']],
 ]);
 
 const blockedDataModules = new Map([

Plik diff jest za duży
+ 13 - 0
scripts/smoke-admin-functions.mjs


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

@@ -37,6 +37,9 @@ export const ADMIN_NAVIGATION = [
   { label: '广告内容管理', icon: 'megaphone', route: '/admin/resources/AdInfo' },
   { label: '字体图形素材', icon: 'shapes', route: '/admin/resources/FontPicShape' },
   { label: '字体图形分类', icon: 'tags', route: '/admin/resources/FontPicShapeType' },
+  { label: '问卷调查', icon: 'clipboard-list', route: '/admin/resources/DesignAsk' },
+  { label: '问卷题目', icon: 'list-todo', route: '/admin/resources/DesignQuestion' },
+  { label: '问卷答卷', icon: 'chart-bar-big', route: '/admin/resources/DesignAnswer' },
   { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '迁移状态', icon: 'route', route: '/admin/migration' },
 ] as const;

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

@@ -7,15 +7,18 @@ import {
   Braces,
   CalendarDays,
   ChartNoAxesCombined,
+  ChartBarBig,
   ChevronLeft,
   ChevronRight,
   ClipboardCheck,
+  ClipboardList,
   Files,
   GalleryHorizontalEnd,
   GraduationCap,
   Languages,
   LayoutDashboard,
   ListChecks,
+  ListTodo,
   LogOut,
   LucideAngularModule,
   Menu,
@@ -43,8 +46,8 @@ export class AdminShellComponent {
   readonly collapsed = signal(false);
   readonly mobileOpen = signal(false);
   readonly icons = {
-    Bell, BookOpen, Braces, CalendarDays, ChartNoAxesCombined, ChevronLeft, ChevronRight,
-    ClipboardCheck, Files, GalleryHorizontalEnd, GraduationCap, Languages, LayoutDashboard, ListChecks,
+    Bell, BookOpen, Braces, CalendarDays, ChartBarBig, ChartNoAxesCombined, ChevronLeft, ChevronRight,
+    ClipboardCheck, ClipboardList, Files, GalleryHorizontalEnd, GraduationCap, Languages, LayoutDashboard, ListChecks, ListTodo,
     LogOut, Megaphone, Menu, Route, Search, Shapes, TableProperties, Tags, Users,
   };
 

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

@@ -66,6 +66,10 @@ describe('legacy admin coverage inventory', () => {
     expect(byKey.get('FontPic/ShapeInfo_Submit')?.status).toBe('partial');
     expect(byKey.get('FontPic/ShapeType_API')?.status).toBe('implemented');
     expect(byKey.get('FontPic/Draft_API')?.status).toBe('blocked-data');
+    expect(byKey.get('Extend / Survey/SurveyManage')?.status).toBe('implemented');
+    expect(byKey.get('Extend / Survey/SurveyAdd_Submit')?.status).toBe('partial');
+    expect(byKey.get('Extend / Survey/SurveyItem_API')?.status).toBe('implemented');
+    expect(byKey.get('Extend / Survey/SurveyResultChart')?.status).toBe('implemented');
     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

@@ -5991,71 +5991,71 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Extend / Survey",
     "action": "SurveyManage",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 surveys 按帐套、状态和关键词分页读取旧 DesignAsk 问卷"
   },
   {
     "module": "Extend / Survey",
     "action": "Survey_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 surveyBatch 覆盖问卷启停/删除及答卷嵌入明细删除"
   },
   {
     "module": "Extend / Survey",
     "action": "SurveyAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有问卷编辑页已迁移;新增因旧 id 无法持久化而明确阻塞"
   },
   {
     "module": "Extend / Survey",
     "action": "SurveyAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有问卷由 saveSurvey 校验时间、状态和访问设置后更新;新增明确阻塞"
   },
   {
     "module": "Extend / Survey",
     "action": "SurveyItemList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 surveyQuestions 校验问卷归属后按排序分页读取题目"
   },
   {
     "module": "Extend / Survey",
     "action": "SurveyItemAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有题目编辑页已迁移;新增因旧 id 无法持久化而明确阻塞"
   },
   {
     "module": "Extend / Survey",
     "action": "SurveyItemAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有题目由 saveSurveyQuestion 校验题型、选项和扩展 JSON 后更新;新增明确阻塞"
   },
   {
     "module": "Extend / Survey",
     "action": "SurveyItem_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 surveyBatch 覆盖题目删除和按选择顺序重排"
   },
   {
     "module": "Extend / Survey",
     "action": "SurveyResultChart",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 surveyResultChart 从旧 answer JSON 安全聚合选项计数并报告不可解析答卷"
   },
   {
     "module": "Extend / Survey",
     "action": "SurveyResultList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 surveyResults 提供帐套与问卷隔离的答卷分页和只读详情"
   },
   {
     "module": "Template",

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

@@ -20,6 +20,16 @@
   @if (className() === 'FontPicShape') {
     <div class="bulk-toolbar"><strong>图形素材范围</strong><select [ngModel]="filterFontShapeTypeId()" (ngModelChange)="filterFontShapeTypeId.set(+$event); load(1)"><option [ngValue]="0">全部分类</option>@for (type of fontShapeTypes(); track type['objectId']) { <option [ngValue]="fontShapeTypeIdOf(type)">{{ fontShapeTypeLabel(type) }}</option> }</select></div>
   }
+  @if (className() === 'DesignAsk') {
+    <div class="bulk-toolbar"><strong>问卷状态</strong><select [ngModel]="filterSurveyStatus()" (ngModelChange)="filterSurveyStatus.set(+$event); load(1)"><option [ngValue]="-100">全部状态</option><option [ngValue]="99">已启用</option><option [ngValue]="0">已停用</option></select></div>
+  }
+  @if (className() === 'DesignQuestion' || className() === 'DesignAnswer') {
+    <div class="bulk-toolbar"><strong>{{ className() === 'DesignQuestion' ? '题目所属问卷' : '答卷所属问卷' }}</strong><select [ngModel]="filterSurveyId()" (ngModelChange)="filterSurveyId.set(+$event); surveyChart.set(null); load(1)"><option [ngValue]="0">请选择问卷</option>@for (survey of surveys(); track survey['objectId']) { <option [ngValue]="surveyIdOf(survey)">{{ surveyLabel(survey) }}</option> }</select>@if (className() === 'DesignAnswer') { <button type="button" [disabled]="!filterSurveyId() || surveyChartLoading()" (click)="loadSurveyChart()">{{ surveyChartLoading() ? '统计中…' : '查看结果统计' }}</button> }</div>
+  }
+  @if (className() === 'DesignAnswer' && surveyChartError()) { <div class="bulk-notice error">{{ surveyChartError() }}</div> }
+  @if (className() === 'DesignAnswer' && surveyChart(); as chart) {
+    <section class="content-controls"><header><div><strong>{{ chart.survey['title'] }} · 结果统计</strong><span>{{ chart.responseCount }} 份答卷;{{ chart.invalidAnswers }} 份旧答卷内容无法解析</span></div></header>@for (question of chart.questions; track question.id) { <article><strong>{{ question.title }}</strong><span>{{ question.type }} · 题目 ID {{ question.id }}</span>@for (option of question.options; track option.value) { <div><code>{{ option.text || option.value }}</code><b>{{ option.count }}</b></div> }@if (question.otherCount) { <div><code>其他 / 文本回答</code><b>{{ question.otherCount }}</b></div> }</article> }</section>
+  }
   @if (bulkEnabled() && selectedIds().length) {
     <div class="bulk-toolbar"><strong>已选 {{ selectedIds().length }} 条</strong>
       @if (className() === '_User') {
@@ -52,6 +62,12 @@
         <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 if (className() === 'FontPicShape' || className() === 'FontPicShapeType') {
         <button class="danger-action" type="button" [disabled]="updatingFontShape()" (click)="applyBulkFontShapeDelete()">删除</button>
+      } @else if (className() === 'DesignAsk') {
+        <button type="button" [disabled]="updatingSurvey()" (click)="applyBulkSurveyAction('start')">启用</button><button type="button" [disabled]="updatingSurvey()" (click)="applyBulkSurveyAction('stop')">停用</button><button class="danger-action" type="button" [disabled]="updatingSurvey()" (click)="applyBulkSurveyAction('delete')">删除</button>
+      } @else if (className() === 'DesignQuestion') {
+        <input class="bulk-node" type="number" min="1" max="1000000" [ngModel]="surveyOrderStart()" (ngModelChange)="surveyOrderStart.set(+$event)" placeholder="起始排序" /><button type="button" [disabled]="updatingSurvey()" (click)="applyBulkSurveyAction('sort')">按选择顺序排列</button><button class="danger-action" type="button" [disabled]="updatingSurvey()" (click)="applyBulkSurveyAction('delete')">删除</button>
+      } @else if (className() === 'DesignAnswer') {
+        <button class="danger-action" type="button" [disabled]="updatingSurvey()" (click)="applyBulkSurveyAction('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>
@@ -102,6 +118,12 @@
         @if (className() === 'FontPicShape' || className() === 'FontPicShapeType') {
           <section class="account-create-note"><strong>现有{{ className() === 'FontPicShape' ? '图形素材' : '图形分类' }}编辑</strong><span>目标兼容层无法生成旧数字 ID,因此仅维护现有记录;新增请求返回明确迁移阻塞。成品字体图和草稿没有目标 Schema,已单独列入数据阻塞。</span></section>
         }
+        @if (className() === 'DesignAsk' || className() === 'DesignQuestion') {
+          <section class="account-create-note"><strong>现有{{ className() === 'DesignAsk' ? '问卷' : '题目' }}编辑</strong><span>兼容层可读取旧 ID,但新增探测证明 sourceKey 不能生成旧数字 ID,因此仅允许维护现有记录;新增请求返回明确迁移阻塞。题目排序、问卷启停和删除请使用列表批量工具。</span></section>
+        }
+        @if (className() === 'DesignAnswer') {
+          <section class="account-create-note"><strong>答卷只读详情</strong><span>旧答卷答案明细已折叠在 answer JSON 字段中;可查看与批量删除,不能从后台篡改作答内容。</span><code>问卷 {{ draft()['askId'] }} · 答卷 {{ draft()['id'] }} · 用户 {{ draft()['userId'] || '匿名' }} · {{ display(draft()['cdate']) }}</code><pre>{{ display(draft()['answer']) }}</pre></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)" /> }

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

@@ -642,6 +642,52 @@ describe('AdminResourceComponent', () => {
     expect(component.bulkMessage()).toContain('1 条图形分类');
   });
 
+  it('loads survey questions through the selected legacy survey id', async () => {
+    component.className.set('DesignQuestion');
+    component.schema.set({ className: 'DesignQuestion', label: '问卷题目', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.filterSurveyId.set(2); component.search.set('阅读');
+    functions.admin.and.resolveTo({ className: 'DesignQuestion', page: 2, pageSize: 20, total: 21, results: [] });
+
+    await component.load(2, false);
+
+    expect(functions.admin.calls.first().args).toEqual(['surveyQuestions', { className: 'DesignQuestion', page: 2, pageSize: 20, search: '阅读', askId: 2 }]);
+  });
+
+  it('updates existing survey metadata through the dedicated operation', async () => {
+    component.className.set('DesignAsk');
+    component.schema.set({ className: 'DesignAsk', label: '问卷调查', writable: true, creatable: false, supportsSoftDelete: false, fields: [] });
+    component.draftId.set('survey-object'); component.draft.set({ objectId: 'survey-object', id: 2, title: '第二组问题', zstatus: 99 });
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveSurvey'
+      ? { objectId: 'survey-object', id: 2, title: '第二组问题', zstatus: 99 }
+      : { className: 'DesignAsk', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveSurvey', { className: 'DesignAsk', objectId: 'survey-object', fields: { objectId: 'survey-object', id: 2, title: '第二组问题', zstatus: 99 } }]);
+  });
+
+  it('starts selected surveys through the dedicated batch operation', async () => {
+    component.className.set('DesignAsk'); component.selectedIds.set(['survey-a']);
+    functions.admin.and.callFake(async (operation: string) => operation === 'surveyBatch'
+      ? { action: 'start', updated: 1, results: [] }
+      : { className: 'DesignAsk', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.applyBulkSurveyAction('start');
+
+    expect(functions.admin.calls.first().args).toEqual(['surveyBatch', { className: 'DesignAsk', action: 'start', objectIds: ['survey-a'] }]);
+    expect(component.bulkMessage()).toContain('1 条问卷记录');
+  });
+
+  it('loads a privacy-safe aggregate for the selected survey results', async () => {
+    component.className.set('DesignAnswer'); component.filterSurveyId.set(2);
+    functions.admin.and.resolveTo({ survey: { id: 2, title: '第二组问题' }, responseCount: 35, invalidAnswers: 1, questions: [] });
+
+    await component.loadSurveyChart();
+
+    expect(functions.admin).toHaveBeenCalledWith('surveyResultChart', { askId: 2 });
+    expect(component.surveyChart()?.responseCount).toBe(35);
+  });
+
   it('updates a forum post through the dedicated workflow operation', async () => {
     component.className.set('GuestBar');
     component.draftId.set('bar-object');

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

@@ -7,6 +7,13 @@ import { ChevronLeft, ChevronRight, Eye, KeyRound, LoaderCircle, LucideAngularMo
 import { AdminClassSchema, AdminFieldSchema, ResourcePage } from '../admin.models';
 import { CloudFunctionsService } from '../cloud-functions.service';
 
+interface SurveyChart {
+  survey: Record<string, unknown>;
+  responseCount: number;
+  invalidAnswers: number;
+  questions: { id: string; title: string; type: string; options: { text: string; value: string; count: number }[]; otherCount: number }[];
+}
+
 @Component({
   selector: 'app-admin-resource',
   standalone: true,
@@ -110,18 +117,26 @@ export class AdminResourceComponent implements OnInit {
   readonly fontShapeTypes = signal<Record<string, unknown>[]>([]);
   readonly fontShapePreview = signal('');
   readonly updatingFontShape = signal(false);
+  readonly filterSurveyStatus = signal(-100);
+  readonly filterSurveyId = signal(0);
+  readonly surveys = signal<Record<string, unknown>[]>([]);
+  readonly surveyOrderStart = signal(1);
+  readonly surveyChart = signal<SurveyChart | null>(null);
+  readonly surveyChartLoading = signal(false);
+  readonly surveyChartError = signal('');
+  readonly updatingSurvey = 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', 'zoneId', 'typeId', 'shape', 'ztype', 'priority', 'zstatus', 'sdate', 'edate', 'fileUrl', 'type', 'state', 'eliteLevel', 'orderId', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
+    const preferred = ['objectId', 'name', 'title', 'qtitle', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'realName', 'askId', 'zoneId', 'typeId', 'shape', 'qtype', 'required', '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', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer'].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));
@@ -138,6 +153,7 @@ export class AdminResourceComponent implements OnInit {
       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();
       this.filterFontShapeTypeId.set(0); this.resetFontShapeActions();
+      this.filterSurveyStatus.set(-100); this.filterSurveyId.set(0); this.resetSurveyActions();
       void this.load(1, true);
     });
   }
@@ -149,13 +165,16 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'Grade' && reloadSchema) { await this.loadGradeCategories(); if (!this.filterGradeCategoryId() && this.gradeCategories().length) this.filterGradeCategoryId.set(this.gradeCategoryIdOf(this.gradeCategories()[0])); }
       if (this.className() === 'AdInfo' && reloadSchema) await this.loadAdZones();
       if (this.className() === 'FontPicShape' && reloadSchema) await this.loadFontShapeTypes();
-      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : this.className() === 'FontPicShape' ? 'fontShapes' : this.className() === 'FontPicShapeType' ? 'fontShapeTypes' : 'list';
+      if (['DesignQuestion', 'DesignAnswer'].includes(this.className()) && reloadSchema) { await this.loadSurveys(); if (!this.filterSurveyId() && this.surveys().length) this.filterSurveyId.set(this.surveyIdOf(this.surveys()[0])); }
+      const pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : this.className() === 'FontPicShape' ? 'fontShapes' : this.className() === 'FontPicShapeType' ? 'fontShapeTypes' : this.className() === 'DesignAsk' ? 'surveys' : this.className() === 'DesignQuestion' ? 'surveyQuestions' : this.className() === 'DesignAnswer' ? 'surveyResults' : '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(); }
       if (pageOperation === 'fontShapes') pagePayload['typeId'] = this.filterFontShapeTypeId();
+      if (pageOperation === 'surveys') pagePayload['status'] = this.filterSurveyStatus();
+      if (pageOperation === 'surveyQuestions' || pageOperation === 'surveyResults') pagePayload['askId'] = this.filterSurveyId();
       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();
@@ -163,20 +182,20 @@ 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.resetAdActions(); this.resetFontShapeActions(); 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.resetFontShapeActions(); this.resetSurveyActions(); 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.resetAdActions(detail); this.resetFontShapeActions(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.resetFontShapeActions(detail); this.resetSurveyActions(); 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();
       if (this.className() === 'FontPicShape' && !this.fontShapeTypes().length) await this.loadFontShapeTypes();
     } 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(); this.resetAdActions(); this.resetFontShapeActions(); }
+  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(); this.resetFontShapeActions(); this.resetSurveyActions(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -228,7 +247,9 @@ export class AdminResourceComponent implements OnInit {
                                           : this.className() === 'AdZone' ? 'saveAdZone'
                                             : this.className() === 'AdInfo' ? 'saveAdInfo'
                                               : this.className() === 'FontPicShape' ? 'saveFontShape'
-                                                : this.className() === 'FontPicShapeType' ? 'saveFontShapeType' : 'save';
+                                                : this.className() === 'FontPicShapeType' ? 'saveFontShapeType'
+                                                  : this.className() === 'DesignAsk' ? 'saveSurvey'
+                                                    : this.className() === 'DesignQuestion' ? 'saveSurveyQuestion' : '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();
@@ -598,6 +619,30 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : '图形删除失败'); }
     finally { this.updatingFontShape.set(false); }
   }
+  async applyBulkSurveyAction(action: 'start' | 'stop' | 'sort' | 'delete'): Promise<void> {
+    const className = this.className(); const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    const allowed = className === 'DesignAsk' ? ['start', 'stop', 'delete'] : className === 'DesignQuestion' ? ['sort', 'delete'] : className === 'DesignAnswer' ? ['delete'] : [];
+    if (!allowed.includes(action) || !objectIds.length) { this.bulkError.set('请先选择可执行该操作的问卷记录'); return; }
+    if (action === 'delete' && !confirm(`确认删除选中的 ${objectIds.length} 条问卷记录?`)) return;
+    const payload: Record<string, unknown> = { className, action, objectIds };
+    if (action === 'sort') {
+      const start = this.surveyOrderStart();
+      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.updatingSurvey.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('surveyBatch', payload); await this.load(); this.bulkMessage.set(`已处理 ${result.updated} 条问卷记录`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '问卷批量操作失败'); }
+    finally { this.updatingSurvey.set(false); }
+  }
+  async loadSurveyChart(): Promise<void> {
+    this.surveyChart.set(null); this.surveyChartError.set('');
+    if (this.className() !== 'DesignAnswer' || !this.filterSurveyId()) return;
+    this.surveyChartLoading.set(true);
+    try { this.surveyChart.set(await this.functions.admin<SurveyChart>('surveyResultChart', { askId: this.filterSurveyId() })); }
+    catch (error) { this.surveyChartError.set(error instanceof Error ? error.message : '问卷统计加载失败'); }
+    finally { this.surveyChartLoading.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     const verb = ['CommonModel', 'Node', 'Guestbook'].includes(this.className()) ? '移入回收站' : '删除';
@@ -608,6 +653,7 @@ export class AdminResourceComponent implements OnInit {
       else if (this.className() === 'AdInfo') await this.functions.admin('adInfoBatch', { className: 'AdInfo', action: 'delete', objectIds: [objectId] });
       else if (this.className() === 'FontPicShape') await this.functions.admin('fontShapeBatch', { className: 'FontPicShape', action: 'delete', objectIds: [objectId] });
       else if (this.className() === 'FontPicShapeType') await this.functions.admin('fontShapeTypeBatch', { className: 'FontPicShapeType', action: 'delete', objectIds: [objectId] });
+      else if (['DesignAsk', 'DesignQuestion', 'DesignAnswer'].includes(this.className())) await this.functions.admin('surveyBatch', { className: this.className(), action: 'delete', objectIds: [objectId] });
       else await this.functions.admin('delete', { className: this.className(), objectId });
       await this.load();
     }
@@ -629,6 +675,8 @@ export class AdminResourceComponent implements OnInit {
   adZoneLabel(zone: Record<string, unknown>): string { return `${String(zone['name'] || '未命名广告位')}(${this.adZoneIdOf(zone)})`; }
   fontShapeTypeIdOf(type: Record<string, unknown>): number { return Number(type['id']) || 0; }
   fontShapeTypeLabel(type: Record<string, unknown>): string { return `${String(type['name'] || '未命名分类')}(${this.fontShapeTypeIdOf(type)})`; }
+  surveyIdOf(survey: Record<string, unknown>): number { return Number(survey['id']) || 0; }
+  surveyLabel(survey: Record<string, unknown>): string { return `${String(survey['title'] || '未命名问卷')}(${this.surveyIdOf(survey)})`; }
   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); }
@@ -656,6 +704,10 @@ export class AdminResourceComponent implements OnInit {
     try { const result = await this.functions.admin<ResourcePage>('fontShapeTypes', { className: 'FontPicShapeType', page: 1, pageSize: 1000, search: '' }); this.fontShapeTypes.set(result.results || []); }
     catch (error) { this.fontShapeTypes.set([]); this.error.set(error instanceof Error ? error.message : '图形分类选项加载失败'); }
   }
+  private async loadSurveys(): Promise<void> {
+    try { const result = await this.functions.admin<ResourcePage>('surveys', { className: 'DesignAsk', page: 1, pageSize: 100, search: '', status: -100 }); this.surveys.set(result.results || []); }
+    catch (error) { this.surveys.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(''); }
@@ -673,4 +725,5 @@ export class AdminResourceComponent implements OnInit {
   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); }
   private resetFontShapeActions(detail: Record<string, unknown> = {}): void { this.targetFontShapeTypeId.set(Number(detail['typeId']) || this.filterFontShapeTypeId()); this.fontShapePreview.set(String(detail['shape'] || '')); }
+  private resetSurveyActions(): void { this.surveyOrderStart.set(1); this.surveyChart.set(null); this.surveyChartError.set(''); this.surveyChartLoading.set(false); }
 }

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików