Selaa lähdekoodia

feat: migrate customer service administration

彭峰 1 kuukausi sitten
vanhempi
commit
de21a90f21

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

@@ -42,8 +42,9 @@
 - 旧广告后台 `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 新增问卷、题目、答卷入口、问卷范围筛选、批量工具和安全统计视图。
+- 旧客服后台 `Default/Seat/SeatAdd/SeatAdd_Submit/CodeAdd/CodeAdd_Submit/Seat_API/CodeList` 已映射到 `ServiceSeat` 与 `Temp(useType=12)`:`serviceSeats/saveServiceSeat` 支持帐套隔离列表、现有编辑、绑定同帐套有效账号、默认席位互斥、排序与原子生成 `sId/sourceKey`,`serviceCodes/saveServiceCode` 只读取并白名单维护客服欢迎语的名称、HTML 内容和主题,`serviceBatch` 覆盖席位及欢迎语删除。Angular 新增客服席位、客服欢迎语入口、账号绑定控件和批量删除。旧逻辑在用户名不存在时以固定密码 `123456` 自动开户,该弱密码行为不迁移:云函数明确 501,并要求先走现有安全开户流程。`Temp.id` 与 Parse 保留字段冲突且省略 ID 无法生成旧编号,因此欢迎语新增明确阻塞。目标 Schema 完全缺少 `ChatMsg` 或等价消息类,`MsgEx/MsgInfo/MsgList` 以及 `Seat_API.chat_del` 由 `legacyServiceChatBlocker` 返回逐项数据阻塞原因。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 83 项已实现、49 项部分实现、123 项数据阻塞、203 项基础设施阻塞、584 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 86 项已实现、54 项部分实现、126 项数据阻塞、203 项基础设施阻塞、573 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 8 - 2
scripts/deploy-admin-functions.mjs


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

@@ -171,6 +171,17 @@ const explicit = new Map([
   ['Extend / Survey/SurveyItem_API', ['implemented', '已由 surveyBatch 覆盖题目删除和按选择顺序重排']],
   ['Extend / Survey/SurveyResultChart', ['implemented', '已由 surveyResultChart 从旧 answer JSON 安全聚合选项计数并报告不可解析答卷']],
   ['Extend / Survey/SurveyResultList', ['implemented', '已由 surveyResults 提供帐套与问卷隔离的答卷分页和只读详情']],
+  ['Office / Service/Default', ['implemented', '已由 Angular 客服席位导航替代旧重定向']],
+  ['Office / Service/Seat', ['implemented', '已由 serviceSeats 帐套隔离、关键词和排序分页列表替代']],
+  ['Office / Service/SeatAdd', ['partial', '客服席位新建/编辑页已迁移;旧固定密码自动开户行为因安全原因不迁移']],
+  ['Office / Service/SeatAdd_Submit', ['partial', 'saveServiceSeat 可绑定现有同帐套账号并原子生成席位 ID;缺失账号时明确阻塞弱密码开户']],
+  ['Office / Service/CodeAdd', ['partial', '现有 useType=12 欢迎语编辑页已迁移;新增受 Temp.id 保留字段冲突阻塞']],
+  ['Office / Service/CodeAdd_Submit', ['partial', '现有欢迎语由 saveServiceCode 白名单更新名称、HTML 内容和主题;新增明确阻塞']],
+  ['Office / Service/Seat_API', ['partial', 'serviceBatch 已覆盖席位和欢迎语删除;chat_del 因 ChatMsg 数据类缺失而阻塞']],
+  ['Office / Service/CodeList', ['implemented', '已由 serviceCodes 仅按 useType=12 帐套隔离读取客服欢迎语']],
+  ['Office / Service/MsgEx', ['blocked-data', '目标 Schema 缺少旧 ChatMsg 会话消息表与历史数据']],
+  ['Office / Service/MsgInfo', ['blocked-data', '目标 Schema 缺少旧 ChatMsg 单条消息详情数据']],
+  ['Office / Service/MsgList', ['blocked-data', '目标 Schema 缺少旧 ChatMsg 消息列表数据']],
 ]);
 
 const blockedDataModules = new Map([

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

@@ -105,6 +105,10 @@ let surveyQuestionRestore = null;
 let temporarySurveyId = '';
 const temporarySurveyQuestionIds = [];
 let temporarySurveyAnswerId = '';
+let serviceSeatRestore = null;
+let serviceCodeRestore = null;
+let temporaryServiceSeatId = '';
+let temporaryServiceCodeId = '';
 const temporaryLessonIds = [];
 try {
   const sample = await jsonRequest(`${PARSE_URL}/classes/Company?limit=1&keys=objectId`, {}, true);
@@ -311,6 +315,16 @@ try {
   const temporarySurvey = await jsonRequest(`${PARSE_URL}/classes/DesignAsk`, { method: 'POST', body: JSON.stringify({ title: '__survey_batch_smoke__', startDate: { __type: 'Date', iso: new Date().toISOString() }, endDate: { __type: 'Date', iso: new Date(Date.now() + 86400000).toISOString() }, sourceKey: `smoke-survey-${Date.now()}`, company }) }, true); temporarySurveyId = temporarySurvey.objectId; const deletedTemporarySurvey = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'surveyBatch', className: 'DesignAsk', action: 'delete', objectIds: [temporarySurveyId] }); if (deletedTemporarySurvey.updated !== 1) throw new Error('问卷批量删除失败'); temporarySurveyId = '';
   for (let index = 0; index < 2; index++) { const createdQuestion = await jsonRequest(`${PARSE_URL}/classes/DesignQuestion`, { method: 'POST', body: JSON.stringify({ askId: surveyId, qtitle: `__survey_sort_smoke_${index}__`, qtype: 'radio', qoption: JSON.stringify([{ text: 'A', value: 'A' }]), qflag: '{}', required: 0, orderId: 0, sourceKey: `smoke-survey-question-${Date.now()}-${index}`, company }) }, true); temporarySurveyQuestionIds.push(createdQuestion.objectId); } const sortedQuestions = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'surveyBatch', className: 'DesignQuestion', action: 'sort', objectIds: [...temporarySurveyQuestionIds], orders: temporarySurveyQuestionIds.map((objectId, index) => ({ objectId, orderId: 700 + index })) }); if (sortedQuestions.results?.map((entry) => Number(entry.orderId)).sort().join(',') !== '700,701') throw new Error('问卷题目批量排序失败'); const deletedTemporaryQuestions = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'surveyBatch', className: 'DesignQuestion', action: 'delete', objectIds: [...temporarySurveyQuestionIds] }); if (deletedTemporaryQuestions.updated !== 2) throw new Error('问卷题目批量删除失败'); temporarySurveyQuestionIds.length = 0;
   const temporaryAnswer = await jsonRequest(`${PARSE_URL}/classes/DesignAnswer`, { method: 'POST', body: JSON.stringify({ askId: surveyId, answer: '[]', sourceKey: `smoke-survey-answer-${Date.now()}`, company }) }, true); temporarySurveyAnswerId = temporaryAnswer.objectId; const deletedTemporaryAnswer = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'surveyBatch', className: 'DesignAnswer', action: 'delete', objectIds: [temporarySurveyAnswerId] }); if (deletedTemporaryAnswer.updated !== 1) throw new Error('问卷答卷批量删除失败'); temporarySurveyAnswerId = '';
+  const serviceSeatSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'ServiceSeat' }); const serviceSeatFieldMap = Object.fromEntries((serviceSeatSchema.fields || []).map((field) => [field.name, field])); if (serviceSeatSchema.creatable !== true || ['sId','sAdminId','sRemrk','sDateTime','sourceKey'].some((name) => serviceSeatFieldMap[name]?.writable !== false)) throw new Error('客服席位系统字段未隔离');
+  const serviceCodeSchema = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'schema', className: 'Temp' }); const serviceCodeFieldMap = Object.fromEntries((serviceCodeSchema.fields || []).map((field) => [field.name, field])); if (serviceCodeSchema.creatable !== false || ['id','useType','str3','str5','str6','describe','cdate','userId','sourceKey'].some((name) => serviceCodeFieldMap[name]?.writable !== false)) throw new Error('客服欢迎语系统字段未隔离');
+  const serviceSeats = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'serviceSeats', className: 'ServiceSeat', page: 1, pageSize: 20, search: '' }); const sampleServiceSeat = serviceSeats.results?.[0]; if (!sampleServiceSeat?.objectId || !Number(sampleServiceSeat.sId) || !sampleServiceSeat.sRemrk) throw new Error('客服席位历史数据读取异常');
+  const serviceCodes = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'serviceCodes', className: 'Temp', page: 1, pageSize: 20, search: '' }); const sampleServiceCode = serviceCodes.results?.[0]; if (!sampleServiceCode?.objectId || Number(sampleServiceCode.useType) !== 12) throw new Error('客服欢迎语历史数据读取异常');
+  serviceSeatRestore = { objectId: sampleServiceSeat.objectId, fields: { sName: sampleServiceSeat.sName, sFaceImg: sampleServiceSeat.sFaceImg, sRemrk: sampleServiceSeat.sRemrk, sAdminId: Number(sampleServiceSeat.sAdminId || 0), sDefault: Number(sampleServiceSeat.sDefault || 0), sIndex: Number(sampleServiceSeat.sIndex || 0), sDateTime: sampleServiceSeat.sDateTime } }; const seatProbeName = `${String(sampleServiceSeat.sName || '')} [angular-smoke]`.trim(); const updatedServiceSeat = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveServiceSeat', className: 'ServiceSeat', objectId: sampleServiceSeat.objectId, username: sampleServiceSeat.sRemrk, fields: { ...sampleServiceSeat, sName: seatProbeName, sDefault: Number(sampleServiceSeat.sDefault || 0), sIndex: Number(sampleServiceSeat.sIndex || 0) } }); if (updatedServiceSeat.sName !== seatProbeName || Number(updatedServiceSeat.sAdminId) !== Number(sampleServiceSeat.sAdminId)) throw new Error('客服席位专用编辑失败'); await jsonRequest(`${PARSE_URL}/classes/ServiceSeat/${serviceSeatRestore.objectId}`, { method: 'PUT', body: JSON.stringify(serviceSeatRestore.fields) }, true); serviceSeatRestore = null;
+  const weakAccountBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveServiceSeat', className: 'ServiceSeat', username: `missing_service_${Date.now()}`, fields: { sName: '临时客服', sDefault: 0, sIndex: 0 } }, 501); if (!String(weakAccountBlocked.message).includes('固定密码 123456')) throw new Error('客服弱密码开户未返回明确安全阻塞');
+  const createdServiceSeat = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveServiceSeat', className: 'ServiceSeat', username: sampleServiceSeat.sRemrk, fields: { sName: '__service_seat_create_smoke__', sFaceImg: '', sDefault: 0, sIndex: 999 } }); temporaryServiceSeatId = String(createdServiceSeat.objectId || ''); if (!temporaryServiceSeatId || !Number(createdServiceSeat.sId) || createdServiceSeat.sourceKey !== `[["S_ID",${Number(createdServiceSeat.sId)}]]`) throw new Error('客服席位原子编号创建异常'); const deletedServiceSeat = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'serviceBatch', className: 'ServiceSeat', action: 'delete', objectIds: [temporaryServiceSeatId] }); if (deletedServiceSeat.updated !== 1) throw new Error('客服席位批量删除失败'); temporaryServiceSeatId = '';
+  serviceCodeRestore = { objectId: sampleServiceCode.objectId, fields: { str1: sampleServiceCode.str1, str2: sampleServiceCode.str2, str4: sampleServiceCode.str4 } }; const codeProbeName = `${String(sampleServiceCode.str1 || '')} [angular-smoke]`.trim(); const updatedServiceCode = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveServiceCode', className: 'Temp', objectId: sampleServiceCode.objectId, fields: { ...sampleServiceCode, str1: codeProbeName } }); if (updatedServiceCode.str1 !== codeProbeName || Number(updatedServiceCode.useType) !== 12) throw new Error('客服欢迎语专用编辑失败'); await jsonRequest(`${PARSE_URL}/classes/Temp/${serviceCodeRestore.objectId}`, { method: 'PUT', body: JSON.stringify(serviceCodeRestore.fields) }, true); serviceCodeRestore = null;
+  const serviceCodeCreateBlocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'saveServiceCode', className: 'Temp', fields: {} }, 501); if (!String(serviceCodeCreateBlocked.message).includes('Temp.id')) throw new Error('客服欢迎语新增未返回旧 ID 阻塞原因'); const directServiceCode = await jsonRequest(`${PARSE_URL}/classes/Temp`, { method: 'POST', body: JSON.stringify({ useType: 12, str1: '__service_code_delete_smoke__', str2: 'temporary', str3: `smoke-${Date.now()}`, str4: 'def', sourceKey: `smoke-service-code-${Date.now()}`, company }) }, true); temporaryServiceCodeId = directServiceCode.objectId; const readDirectServiceCode = await jsonRequest(`${PARSE_URL}/classes/Temp/${temporaryServiceCodeId}`, {}, true); if (readDirectServiceCode.id != null) throw new Error('客服欢迎语省略 id 的创建探测意外生成了旧编号'); const deletedServiceCode = await callFunction('xiaoshu/admin/gateway', login.sessionToken, { operation: 'serviceBatch', className: 'Temp', action: 'delete', objectIds: [temporaryServiceCodeId] }); if (deletedServiceCode.updated !== 1) throw new Error('客服欢迎语批量删除失败'); temporaryServiceCodeId = '';
+  for (const action of ['MsgEx','MsgInfo','MsgList','chat_del']) { const blocked = await callFunctionError('xiaoshu/admin/gateway', login.sessionToken, { operation: 'legacyServiceChatBlocker', action }, 501); if (!String(blocked.message).startsWith('migration_blocked:')) throw new Error(`旧客服消息动作 ${action} 未返回明确阻塞原因`); }
   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' } });
@@ -746,7 +760,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/search-navigation/advertising/font-shapes/surveys/api-catalog/config-blockers/model/guestbook lifecycle/content workflow/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
+  console.log('Cloud smoke passed: admin auth/tenant/account lifecycle/group sync/node/special/guest-category/bar/exam-class/exam-point/knowledge/exam-teacher/dictionary/grade-dictionary/currency/holiday/search-navigation/advertising/font-shapes/surveys/customer-service/api-catalog/config-blockers/model/guestbook lifecycle/content workflow/CRUD reads, CMS projections, normalized learning joins, app action coverage, public content, session scope, explicit blocked APIs.');
 } finally {
   if (adZoneRestore) await jsonRequest(`${PARSE_URL}/classes/AdZone/${adZoneRestore.objectId}`, { method: 'PUT', body: JSON.stringify({ remind: adZoneRestore.remind, zstatus: adZoneRestore.zstatus }) }, true).catch((error) => { console.error(`广告位冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
   if (adInfoRestore) await jsonRequest(`${PARSE_URL}/classes/AdInfo/${adInfoRestore.objectId}`, { method: 'PUT', body: JSON.stringify({ remind: adInfoRestore.remind, zstatus: adInfoRestore.zstatus }) }, true).catch((error) => { console.error(`广告内容冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
@@ -758,6 +772,10 @@ try {
   if (temporarySurveyAnswerId) await jsonRequest(`${PARSE_URL}/classes/DesignAnswer/${temporarySurveyAnswerId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时问卷答卷清理失败:${error.message}`); process.exitCode = 1; });
   for (const objectId of temporarySurveyQuestionIds) await jsonRequest(`${PARSE_URL}/classes/DesignQuestion/${objectId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时问卷题目清理失败:${error.message}`); process.exitCode = 1; });
   if (temporarySurveyId) await jsonRequest(`${PARSE_URL}/classes/DesignAsk/${temporarySurveyId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时问卷清理失败:${error.message}`); process.exitCode = 1; });
+  if (serviceSeatRestore) await jsonRequest(`${PARSE_URL}/classes/ServiceSeat/${serviceSeatRestore.objectId}`, { method: 'PUT', body: JSON.stringify(serviceSeatRestore.fields) }, true).catch((error) => { console.error(`客服席位冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
+  if (serviceCodeRestore) await jsonRequest(`${PARSE_URL}/classes/Temp/${serviceCodeRestore.objectId}`, { method: 'PUT', body: JSON.stringify(serviceCodeRestore.fields) }, true).catch((error) => { console.error(`客服欢迎语冒烟数据恢复失败:${error.message}`); process.exitCode = 1; });
+  if (temporaryServiceSeatId) await jsonRequest(`${PARSE_URL}/classes/ServiceSeat/${temporaryServiceSeatId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时客服席位清理失败:${error.message}`); process.exitCode = 1; });
+  if (temporaryServiceCodeId) await jsonRequest(`${PARSE_URL}/classes/Temp/${temporaryServiceCodeId}`, { method: 'DELETE' }, true).catch((error) => { console.error(`临时客服欢迎语清理失败:${error.message}`); process.exitCode = 1; });
   if (contentHitObjectId) await jsonRequest(`${PARSE_URL}/classes/CommonModel/${contentHitObjectId}`, { method: 'PUT', body: JSON.stringify({ hits: contentHitOriginal }) }, true).catch((error) => {
     console.error(`公开内容浏览量恢复失败:${error.message}`);
     process.exitCode = 1;

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

@@ -40,6 +40,8 @@ export const ADMIN_NAVIGATION = [
   { 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: 'headphones', route: '/admin/resources/ServiceSeat' },
+  { label: '客服欢迎语', icon: 'message-square-text', route: '/admin/resources/Temp' },
   { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '迁移状态', icon: 'route', route: '/admin/migration' },
 ] as const;

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

@@ -15,6 +15,7 @@ import {
   Files,
   GalleryHorizontalEnd,
   GraduationCap,
+  Headphones,
   Languages,
   LayoutDashboard,
   ListChecks,
@@ -23,6 +24,7 @@ import {
   LucideAngularModule,
   Menu,
   Megaphone,
+  MessageSquareText,
   Route,
   Search,
   Shapes,
@@ -47,8 +49,8 @@ export class AdminShellComponent {
   readonly mobileOpen = signal(false);
   readonly icons = {
     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,
+    ClipboardCheck, ClipboardList, Files, GalleryHorizontalEnd, GraduationCap, Headphones, Languages, LayoutDashboard, ListChecks, ListTodo,
+    LogOut, Megaphone, Menu, MessageSquareText, Route, Search, Shapes, TableProperties, Tags, Users,
   };
 
   constructor(readonly sessions: AdminSessionService) {}

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

@@ -70,6 +70,10 @@ describe('legacy admin coverage inventory', () => {
     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('Office / Service/Seat')?.status).toBe('implemented');
+    expect(byKey.get('Office / Service/SeatAdd_Submit')?.status).toBe('partial');
+    expect(byKey.get('Office / Service/CodeList')?.status).toBe('implemented');
+    expect(byKey.get('Office / Service/MsgEx')?.status).toBe('blocked-data');
     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');

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

@@ -5543,78 +5543,78 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Office / Service",
     "action": "Default",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 Angular 客服席位导航替代旧重定向"
   },
   {
     "module": "Office / Service",
     "action": "Seat",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 serviceSeats 帐套隔离、关键词和排序分页列表替代"
   },
   {
     "module": "Office / Service",
     "action": "SeatAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "客服席位新建/编辑页已迁移;旧固定密码自动开户行为因安全原因不迁移"
   },
   {
     "module": "Office / Service",
     "action": "SeatAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "saveServiceSeat 可绑定现有同帐套账号并原子生成席位 ID;缺失账号时明确阻塞弱密码开户"
   },
   {
     "module": "Office / Service",
     "action": "CodeAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有 useType=12 欢迎语编辑页已迁移;新增受 Temp.id 保留字段冲突阻塞"
   },
   {
     "module": "Office / Service",
     "action": "CodeAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有欢迎语由 saveServiceCode 白名单更新名称、HTML 内容和主题;新增明确阻塞"
   },
   {
     "module": "Office / Service",
     "action": "Seat_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "serviceBatch 已覆盖席位和欢迎语删除;chat_del 因 ChatMsg 数据类缺失而阻塞"
   },
   {
     "module": "Office / Service",
     "action": "CodeList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 serviceCodes 仅按 useType=12 帐套隔离读取客服欢迎语"
   },
   {
     "module": "Office / Service",
     "action": "MsgEx",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 ChatMsg 会话消息表与历史数据"
   },
   {
     "module": "Office / Service",
     "action": "MsgInfo",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 ChatMsg 单条消息详情数据"
   },
   {
     "module": "Office / Service",
     "action": "MsgList",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "目标 Schema 缺少旧 ChatMsg 消息列表数据"
   },
   {
     "module": "Site",

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

@@ -68,6 +68,8 @@
         <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 if (className() === 'ServiceSeat' || className() === 'Temp') {
+        <button class="danger-action" type="button" [disabled]="updatingService()" (click)="applyBulkServiceDelete()">删除</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>
@@ -124,6 +126,9 @@
         @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>
         }
+        @if (className() === 'Temp') {
+          <section class="account-create-note"><strong>现有客服欢迎语编辑</strong><span>仅展示 useType=12 的客服欢迎语。旧数字 ID 无法安全生成,因此新增请求返回明确迁移阻塞;欢迎语 HTML 作为数据保存,不在管理端执行。</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)" /> }
@@ -153,6 +158,9 @@
             <div class="content-action-status error">图片替换暂不可用:当前 Parse Server 的 SDK 与 /files REST 接口都错误要求 MongoDB 连接串;本项目数据库为 PostgreSQL。云函数会对此返回明确 501,不会伪装上传成功。</div>
           </section>
         }
+        @if (className() === 'ServiceSeat') {
+          <section class="content-controls"><header><div><strong>客服账号绑定</strong><span>只允许绑定当前帐套已存在且具有旧用户 ID 的有效账号;不会复刻旧系统用固定密码 123456 自动开户的不安全行为</span></div>@if (draftId()) { <b>席位 {{ draft()['sId'] }}</b> }</header><label><span>绑定登录账号</span><input [ngModel]="targetServiceSeatUsername()" (ngModelChange)="targetServiceSeatUsername.set($event)" autocomplete="off" placeholder="当前帐套用户名" /></label></section>
+        }
         @if (className() === 'CommonModel' && draftId()) {
           <section class="content-controls">
             <header><div><strong>内容审核与归档</strong><span>专用云函数同步旧 ContentManage 状态与节点移动语义</span></div><b [class.recycled]="contentStatusValue() === -2">{{ contentStatusLabel(contentStatusValue()) }}</b></header>

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

@@ -688,6 +688,41 @@ describe('AdminResourceComponent', () => {
     expect(component.surveyChart()?.responseCount).toBe(35);
   });
 
+  it('loads customer-service seats through the dedicated operation', async () => {
+    component.className.set('ServiceSeat');
+    component.schema.set({ className: 'ServiceSeat', label: '客服席位', writable: true, creatable: true, supportsSoftDelete: false, fields: [] });
+    component.search.set('admin'); functions.admin.and.resolveTo({ className: 'ServiceSeat', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.load(1, false);
+
+    expect(functions.admin).toHaveBeenCalledWith('serviceSeats', { className: 'ServiceSeat', page: 1, pageSize: 20, search: 'admin' });
+  });
+
+  it('creates a service seat while binding an existing account', async () => {
+    component.className.set('ServiceSeat');
+    component.schema.set({ className: 'ServiceSeat', label: '客服席位', writable: true, creatable: true, supportsSoftDelete: false, fields: [] });
+    component.draft.set({ sName: '在线客服', sDefault: 1, sIndex: 2 }); component.targetServiceSeatUsername.set('admin');
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveServiceSeat'
+      ? { objectId: 'seat-object', sId: 2, sName: '在线客服', sRemrk: 'admin' }
+      : { className: 'ServiceSeat', page: 1, pageSize: 20, total: 2, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveServiceSeat', { className: 'ServiceSeat', objectId: undefined, fields: { sName: '在线客服', sDefault: 1, sIndex: 2 }, username: 'admin' }]);
+  });
+
+  it('deletes selected service welcome messages through the scoped operation', async () => {
+    component.className.set('Temp'); component.selectedIds.set(['code-object']); spyOn(window, 'confirm').and.returnValue(true);
+    functions.admin.and.callFake(async (operation: string) => operation === 'serviceBatch'
+      ? { action: 'delete', updated: 1, results: [] }
+      : { className: 'Temp', page: 1, pageSize: 20, total: 0, results: [] });
+
+    await component.applyBulkServiceDelete();
+
+    expect(functions.admin.calls.first().args).toEqual(['serviceBatch', { className: 'Temp', action: 'delete', objectIds: ['code-object'] }]);
+    expect(component.bulkMessage()).toContain('1 条客服记录');
+  });
+
   it('updates a forum post through the dedicated workflow operation', async () => {
     component.className.set('GuestBar');
     component.draftId.set('bar-object');

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

@@ -125,18 +125,20 @@ export class AdminResourceComponent implements OnInit {
   readonly surveyChartLoading = signal(false);
   readonly surveyChartError = signal('');
   readonly updatingSurvey = signal(false);
+  readonly targetServiceSeatUsername = signal('');
+  readonly updatingService = 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', '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 preferred = ['objectId', 'name', 'title', 'qtitle', 'sName', 'str1', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'realName', 'sRemrk', 'askId', 'zoneId', 'typeId', 'shape', 'qtype', 'required', 'ztype', 'priority', 'zstatus', 'sDefault', 'sIndex', 'str4', 'sdate', 'edate', 'fileUrl', 'type', 'state', 'eliteLevel', 'orderId', 'mobile', 'sourceKey', 'status', 'createdAt', 'updatedAt'];
     const rank = (name: string): number => { const index = preferred.indexOf(name); return index < 0 ? preferred.length : index; };
     return fields.map((field, index) => ({ field, index })).sort((a, b) => rank(a.field.name) - rank(b.field.name) || a.index - b.index).map(({ field }) => field).slice(0, 7);
   });
   readonly pageCount = computed(() => Math.max(1, Math.ceil((this.page()?.total ?? 0) / (this.page()?.pageSize || 20))));
-  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer'].includes(this.className()));
+  readonly bulkEnabled = computed(() => ['_User', 'CommonModel', 'Node', 'Special', 'Guestbook', 'GuestBar', 'Datadiccategory', 'Datadic', 'GradeCate', 'Grade', 'Currency', 'SysHoliday', 'Search', 'AdZone', 'AdInfo', 'FontPicShape', 'FontPicShapeType', 'DesignAsk', 'DesignQuestion', 'DesignAnswer', 'ServiceSeat', 'Temp'].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));
@@ -154,6 +156,7 @@ export class AdminResourceComponent implements OnInit {
       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();
+      this.resetServiceActions();
       void this.load(1, true);
     });
   }
@@ -166,7 +169,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'AdInfo' && reloadSchema) await this.loadAdZones();
       if (this.className() === 'FontPicShape' && reloadSchema) await this.loadFontShapeTypes();
       if (['DesignQuestion', 'DesignAnswer'].includes(this.className()) && reloadSchema) { await this.loadSurveys(); if (!this.filterSurveyId() && this.surveys().length) this.filterSurveyId.set(this.surveyIdOf(this.surveys()[0])); }
-      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 pageOperation = this.className() === 'Grade' && this.filterGradeCategoryId() > 0 ? 'gradeOptions' : this.className() === 'Search' ? 'searchNavigations' : this.className() === 'AdZone' ? 'adZones' : this.className() === 'AdInfo' ? 'adInfos' : this.className() === 'FontPicShape' ? 'fontShapes' : this.className() === 'FontPicShapeType' ? 'fontShapeTypes' : this.className() === 'DesignAsk' ? 'surveys' : this.className() === 'DesignQuestion' ? 'surveyQuestions' : this.className() === 'DesignAnswer' ? 'surveyResults' : this.className() === 'ServiceSeat' ? 'serviceSeats' : this.className() === 'Temp' ? 'serviceCodes' : '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(); }
@@ -182,20 +185,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.resetSurveyActions(); 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.resetServiceActions(); 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.resetSurveyActions(); 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.resetServiceActions(detail); this.editorOpen.set(true);
       if (this.className() === '_User') await this.loadUserGroups();
       if (this.className() === 'AdInfo' && !this.adZones().length) await this.loadAdZones();
       if (this.className() === 'AdZone') await this.loadAdPreview();
       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(); this.resetSurveyActions(); }
+  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(); this.resetServiceActions(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -249,7 +252,9 @@ export class AdminResourceComponent implements OnInit {
                                               : this.className() === 'FontPicShape' ? 'saveFontShape'
                                                 : this.className() === 'FontPicShapeType' ? 'saveFontShapeType'
                                                   : this.className() === 'DesignAsk' ? 'saveSurvey'
-                                                    : this.className() === 'DesignQuestion' ? 'saveSurveyQuestion' : 'save';
+                                                    : this.className() === 'DesignQuestion' ? 'saveSurveyQuestion'
+                                                      : this.className() === 'ServiceSeat' ? 'saveServiceSeat'
+                                                        : this.className() === 'Temp' ? 'saveServiceCode' : '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();
@@ -262,6 +267,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'Grade') { payload['categoryId'] = this.targetGradeCategoryId(); payload['parentId'] = this.targetGradeParentId(); }
       if (this.className() === 'AdInfo') payload['zoneId'] = this.targetAdZoneId();
       if (this.className() === 'FontPicShape') payload['typeId'] = this.targetFontShapeTypeId();
+      if (this.className() === 'ServiceSeat') payload['username'] = this.targetServiceSeatUsername();
       await this.functions.admin(operation, payload);
       this.closeEditor(); await this.load();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
@@ -643,6 +649,15 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.surveyChartError.set(error instanceof Error ? error.message : '问卷统计加载失败'); }
     finally { this.surveyChartLoading.set(false); }
   }
+  async applyBulkServiceDelete(): Promise<void> {
+    const className = this.className(); const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    if (!['ServiceSeat', 'Temp'].includes(className) || !objectIds.length) { this.bulkError.set('请先选择客服席位或欢迎语'); return; }
+    if (!confirm(`确认删除选中的 ${objectIds.length} 条${className === 'ServiceSeat' ? '客服席位' : '欢迎语'}?`)) return;
+    this.updatingService.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('serviceBatch', { className, action: 'delete', objectIds }); await this.load(); this.bulkMessage.set(`已删除 ${result.updated} 条客服记录`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '客服记录删除失败'); }
+    finally { this.updatingService.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     const verb = ['CommonModel', 'Node', 'Guestbook'].includes(this.className()) ? '移入回收站' : '删除';
@@ -654,6 +669,7 @@ export class AdminResourceComponent implements OnInit {
       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 if (['ServiceSeat', 'Temp'].includes(this.className())) await this.functions.admin('serviceBatch', { className: this.className(), action: 'delete', objectIds: [objectId] });
       else await this.functions.admin('delete', { className: this.className(), objectId });
       await this.load();
     }
@@ -726,4 +742,5 @@ export class AdminResourceComponent implements OnInit {
   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); }
+  private resetServiceActions(detail: Record<string, unknown> = {}): void { this.targetServiceSeatUsername.set(String(detail['sRemrk'] || '')); }
 }

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä