Parcourir la source

feat: migrate store administration

彭峰 il y a 2 mois
Parent
commit
180085713a

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

@@ -43,8 +43,9 @@
 - 旧字体图后台的 `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` 返回逐项数据阻塞原因。
+- 旧店铺后台 `StoreManage/Store_API/StoreStyle/Style_API/StoreStyleAdd/StoreStyleAdd_Submit/StoreUpdate/StoreUpdate_Submit` 已映射到规范化 `StoreApplication/StoreStyle`:店铺按状态和关键词筛选,支持审核、取消审核、推荐、取消推荐,现有资料可维护同帐套所属账号、样式、联系方式、地址和展示内容;样式可维护三类模板路径、缩略图和说明,删除前检查店铺引用。Angular 新增店铺、店铺样式入口、状态工具和归属/样式选择。店铺与样式旧数字 ID 都不能通过兼容层安全新增,店铺创建还依赖已丢失的模型 6 动态字段链,因此新增明确 501。旧 `ZL_Commodities` 商品主表完全缺失,目标 `Product/StoreProduct` 只有图片等扩展碎片,无法核验店铺商品引用,所以真实删除店铺与 `Product` 页面均明确数据阻塞,未冒险制造孤儿商品。
 - `_User`、`CommonModel`、`Node` 与 `Special` 表格已补齐当前页全选和批量工具栏,可直接批量停用/解锁/移动用户组,审核、回收、恢复、移动内容节点,回收、恢复、移动栏目,以及移动专题;翻页或重新查询会清空选择,避免误操作隐藏页记录。
-- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 86 项已实现、54 项部分实现、126 项数据阻塞、203 项基础设施阻塞、573 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
+- 迁移状态页已接入从反编译基线自动生成的 1,042 项旧后台动作台账,可按模块、action、原因与状态搜索筛选;当前保守登记为 89 项已实现、59 项部分实现、127 项数据阻塞、203 项基础设施阻塞、564 项待逐项核验。生成器和 `admin-coverage:check` 会阻止台账与源码基线漂移。
 - Parse 登录只在浏览器保存当前 `sessionToken`;`masterKey` 只在部署进程中使用。
 - 云函数网关强制校验管理员身份、`company` 帐套、类白名单、字段白名单和敏感字段过滤。
 - `_Session` 不开放,`Function` 源码不开放通用查询或编辑;用户密码只允许专用重置操作。

Fichier diff supprimé car celui-ci est trop grand
+ 18 - 2
scripts/deploy-admin-functions.mjs


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

@@ -182,6 +182,15 @@ const explicit = new Map([
   ['Office / Service/MsgEx', ['blocked-data', '目标 Schema 缺少旧 ChatMsg 会话消息表与历史数据']],
   ['Office / Service/MsgInfo', ['blocked-data', '目标 Schema 缺少旧 ChatMsg 单条消息详情数据']],
   ['Office / Service/MsgList', ['blocked-data', '目标 Schema 缺少旧 ChatMsg 消息列表数据']],
+  ['Shop / Store/StoreManage', ['implemented', '已由 storeApplications 按帐套、审核状态和关键词分页读取店铺']],
+  ['Shop / Store/Store_API', ['partial', 'storeBatch 已覆盖审核、取消审核、推荐和取消推荐;因商品主表缺失而阻止真实删除店铺']],
+  ['Shop / Store/StoreStyle', ['implemented', '已由 storeStyles 帐套隔离分页和关键词检索替代']],
+  ['Shop / Store/Style_API', ['implemented', '已由 storeBatch 覆盖带店铺引用保护的样式删除']],
+  ['Shop / Store/StoreStyleAdd', ['partial', '现有店铺样式编辑页已迁移;新增因旧 id 无法持久化而阻塞']],
+  ['Shop / Store/StoreStyleAdd_Submit', ['partial', '现有样式由 saveStoreStyle 白名单更新模板路径、缩略图与说明;新增明确阻塞']],
+  ['Shop / Store/StoreUpdate', ['partial', '现有店铺资料、所属账号和样式编辑页已迁移;旧模型 6 创建链缺失']],
+  ['Shop / Store/StoreUpdate_Submit', ['partial', '现有店铺由 saveStoreApplication 校验同帐套用户和样式后更新;新增明确阻塞']],
+  ['Shop / Store/Product', ['blocked-data', '旧 ZL_Commodities 商品主表缺失,目标 Product/StoreProduct 只有扩展碎片,无法重建商品列表']],
 ]);
 
 const blockedDataModules = new Map([

Fichier diff supprimé car celui-ci est trop grand
+ 9 - 0
scripts/smoke-admin-functions.mjs


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

@@ -42,6 +42,8 @@ export const ADMIN_NAVIGATION = [
   { 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: 'store', route: '/admin/resources/StoreApplication' },
+  { label: '店铺样式', icon: 'palette', route: '/admin/resources/StoreStyle' },
   { label: '题库管理', icon: 'list-checks', route: '/admin/resources/ExamSysQuestions' },
   { label: '迁移状态', icon: 'route', route: '/admin/migration' },
 ] as const;

+ 3 - 1
src/app/admin/layout/admin-shell.component.ts

@@ -25,9 +25,11 @@ import {
   Menu,
   Megaphone,
   MessageSquareText,
+  Palette,
   Route,
   Search,
   Shapes,
+  Store,
   TableProperties,
   Tags,
   Users,
@@ -50,7 +52,7 @@ export class AdminShellComponent {
   readonly icons = {
     Bell, BookOpen, Braces, CalendarDays, ChartBarBig, ChartNoAxesCombined, ChevronLeft, ChevronRight,
     ClipboardCheck, ClipboardList, Files, GalleryHorizontalEnd, GraduationCap, Headphones, Languages, LayoutDashboard, ListChecks, ListTodo,
-    LogOut, Megaphone, Menu, MessageSquareText, Route, Search, Shapes, TableProperties, Tags, Users,
+    LogOut, Megaphone, Menu, MessageSquareText, Palette, Route, Search, Shapes, Store, TableProperties, Tags, Users,
   };
 
   constructor(readonly sessions: AdminSessionService) {}

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

@@ -74,6 +74,10 @@ describe('legacy admin coverage inventory', () => {
     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('Shop / Store/StoreManage')?.status).toBe('implemented');
+    expect(byKey.get('Shop / Store/Store_API')?.status).toBe('partial');
+    expect(byKey.get('Shop / Store/StoreUpdate_Submit')?.status).toBe('partial');
+    expect(byKey.get('Shop / Store/Product')?.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');

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

@@ -5830,64 +5830,64 @@ export const LEGACY_ADMIN_COVERAGE: readonly LegacyAdminCoverageEntry[] = [
     "module": "Shop / Store",
     "action": "StoreManage",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 storeApplications 按帐套、审核状态和关键词分页读取店铺"
   },
   {
     "module": "Shop / Store",
     "action": "Store_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "storeBatch 已覆盖审核、取消审核、推荐和取消推荐;因商品主表缺失而阻止真实删除店铺"
   },
   {
     "module": "Shop / Store",
     "action": "StoreStyle",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 storeStyles 帐套隔离分页和关键词检索替代"
   },
   {
     "module": "Shop / Store",
     "action": "Style_API",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "implemented",
+    "reason": "已由 storeBatch 覆盖带店铺引用保护的样式删除"
   },
   {
     "module": "Shop / Store",
     "action": "StoreStyleAdd",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有店铺样式编辑页已迁移;新增因旧 id 无法持久化而阻塞"
   },
   {
     "module": "Shop / Store",
     "action": "StoreStyleAdd_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有样式由 saveStoreStyle 白名单更新模板路径、缩略图与说明;新增明确阻塞"
   },
   {
     "module": "Shop / Store",
     "action": "StoreUpdate",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有店铺资料、所属账号和样式编辑页已迁移;旧模型 6 创建链缺失"
   },
   {
     "module": "Shop / Store",
     "action": "StoreUpdate_Submit",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "partial",
+    "reason": "现有店铺由 saveStoreApplication 校验同帐套用户和样式后更新;新增明确阻塞"
   },
   {
     "module": "Shop / Store",
     "action": "Product",
     "occurrence": 1,
-    "status": "pending",
-    "reason": "尚未逐项迁移或完成等价性核验"
+    "status": "blocked-data",
+    "reason": "旧 ZL_Commodities 商品主表缺失,目标 Product/StoreProduct 只有扩展碎片,无法重建商品列表"
   },
   {
     "module": "User / Struct",

+ 14 - 1
src/app/admin/pages/admin-resource.component.html

@@ -30,6 +30,9 @@
   @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 (className() === 'StoreApplication') {
+    <div class="bulk-toolbar"><strong>店铺状态</strong><select [ngModel]="filterStoreStatus()" (ngModelChange)="filterStoreStatus.set(+$event); load(1)"><option [ngValue]="-100">全部状态</option><option [ngValue]="99">已审核</option><option [ngValue]="0">待审核</option><option [ngValue]="-1">未通过</option></select></div>
+  }
   @if (bulkEnabled() && selectedIds().length) {
     <div class="bulk-toolbar"><strong>已选 {{ selectedIds().length }} 条</strong>
       @if (className() === '_User') {
@@ -70,6 +73,10 @@
         <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 if (className() === 'StoreApplication') {
+        <button type="button" [disabled]="updatingStore()" (click)="applyBulkStoreAction('audit')">审核</button><button type="button" [disabled]="updatingStore()" (click)="applyBulkStoreAction('unaudit')">取消审核</button><button type="button" [disabled]="updatingStore()" (click)="applyBulkStoreAction('elite')">推荐</button><button type="button" [disabled]="updatingStore()" (click)="applyBulkStoreAction('unelite')">取消推荐</button>
+      } @else if (className() === 'StoreStyle') {
+        <button class="danger-action" type="button" [disabled]="updatingStore()" (click)="applyBulkStoreAction('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>
@@ -84,7 +91,7 @@
   } @else {
     <div class="table-scroll"><table><thead><tr>@if (bulkEnabled()) { <th class="select-col"><input type="checkbox" aria-label="全选当前页" [checked]="allPageSelected()" (change)="togglePageSelection($any($event.target).checked)" /></th> }@for (field of columns(); track field.name) { <th>{{ field.name }}</th> }<th class="actions">操作</th></tr></thead><tbody>
       @for (row of page()?.results || []; track row['objectId']) {
-        <tr>@if (bulkEnabled()) { <td class="select-col"><input type="checkbox" [attr.aria-label]="'选择 ' + row['objectId']" [checked]="isSelected(row['objectId'])" (change)="toggleSelection('' + row['objectId'], $any($event.target).checked)" /></td> }@for (field of columns(); track field.name) { <td [title]="display(row[field.name])">{{ display(row[field.name]) }}</td> }<td class="actions"><button type="button" [attr.aria-label]="schema()?.writable ? '编辑' : '查看'" (click)="openEdit(row)"><lucide-icon [img]="schema()?.writable ? icons.Pencil : icons.Eye" [size]="16" /></button>@if (schema()?.writable && !['Model', 'ModelField'].includes(className())) { <button class="danger" type="button" [attr.aria-label]="['CommonModel', 'Node', 'Guestbook'].includes(className()) ? '移入回收站' : '删除'" (click)="remove(row)"><lucide-icon [img]="icons.Trash2" [size]="16" /></button> }</td></tr>
+        <tr>@if (bulkEnabled()) { <td class="select-col"><input type="checkbox" [attr.aria-label]="'选择 ' + row['objectId']" [checked]="isSelected(row['objectId'])" (change)="toggleSelection('' + row['objectId'], $any($event.target).checked)" /></td> }@for (field of columns(); track field.name) { <td [title]="display(row[field.name])">{{ display(row[field.name]) }}</td> }<td class="actions"><button type="button" [attr.aria-label]="schema()?.writable ? '编辑' : '查看'" (click)="openEdit(row)"><lucide-icon [img]="schema()?.writable ? icons.Pencil : icons.Eye" [size]="16" /></button>@if (schema()?.writable && !['Model', 'ModelField', 'StoreApplication'].includes(className())) { <button class="danger" type="button" [attr.aria-label]="['CommonModel', 'Node', 'Guestbook'].includes(className()) ? '移入回收站' : '删除'" (click)="remove(row)"><lucide-icon [img]="icons.Trash2" [size]="16" /></button> }</td></tr>
       } @empty { <tr><td class="empty-cell" [attr.colspan]="columns().length + (bulkEnabled() ? 2 : 1)">没有符合条件的数据</td></tr> }
     </tbody></table></div>
     <footer class="pagination"><span>第 {{ page()?.page || 1 }} 页,共 {{ pageCount() }} 页</span><div><button type="button" [disabled]="(page()?.page || 1) <= 1 || loading()" (click)="load((page()?.page || 1) - 1)"><lucide-icon [img]="icons.ChevronLeft" [size]="17" />上一页</button><button type="button" [disabled]="(page()?.page || 1) * (page()?.pageSize || 20) >= (page()?.total || 0) || loading()" (click)="load((page()?.page || 1) + 1)">下一页<lucide-icon [img]="icons.ChevronRight" [size]="17" /></button></div></footer>
@@ -129,6 +136,9 @@
         @if (className() === 'Temp') {
           <section class="account-create-note"><strong>现有客服欢迎语编辑</strong><span>仅展示 useType=12 的客服欢迎语。旧数字 ID 无法安全生成,因此新增请求返回明确迁移阻塞;欢迎语 HTML 作为数据保存,不在管理端执行。</span></section>
         }
+        @if (className() === 'StoreApplication' || className() === 'StoreStyle') {
+          <section class="account-create-note"><strong>现有{{ className() === 'StoreApplication' ? '店铺' : '店铺样式' }}编辑</strong><span>{{ className() === 'StoreApplication' ? '旧模型 6 动态字段创建链和商品主表均已缺失,因此仅维护现有店铺且禁止真实删除;审核与推荐可在列表操作。' : '旧数字 ID 无法安全生成,因此仅维护现有样式;仍被店铺引用的样式不能删除。' }}</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)" /> }
@@ -161,6 +171,9 @@
         @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() === 'StoreApplication') {
+          <section class="content-controls"><header><div><strong>店铺归属与样式</strong><span>所属账号和样式必须真实存在于当前帐套</span></div>@if (draftId()) { <b>店铺 {{ draft()['id'] }}</b> }</header><label><span>所属登录账号</span><input [ngModel]="targetStoreUsername()" (ngModelChange)="targetStoreUsername.set($event)" autocomplete="off" /></label><label><span>店铺样式</span><select [ngModel]="targetStoreStyleId()" (ngModelChange)="targetStoreStyleId.set(+$event)"><option [ngValue]="0">不指定样式</option>@for (style of storeStyles(); track style['objectId']) { <option [ngValue]="storeStyleIdOf(style)">{{ storeStyleLabel(style) }}</option> }</select></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>

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

@@ -723,6 +723,33 @@ describe('AdminResourceComponent', () => {
     expect(component.bulkMessage()).toContain('1 条客服记录');
   });
 
+  it('loads stores through status-safe dedicated pagination', async () => {
+    component.className.set('StoreApplication'); component.schema.set({ className: 'StoreApplication', label: '店铺管理', writable: true, creatable: false, supportsSoftDelete: false, fields: [] }); component.filterStoreStatus.set(99); component.search.set('逐浪');
+    functions.admin.and.resolveTo({ className: 'StoreApplication', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.load(1, false);
+
+    expect(functions.admin).toHaveBeenCalledWith('storeApplications', { className: 'StoreApplication', page: 1, pageSize: 20, search: '逐浪', status: 99 });
+  });
+
+  it('updates an existing store through account and style validation', async () => {
+    component.className.set('StoreApplication'); component.schema.set({ className: 'StoreApplication', label: '店铺管理', writable: true, creatable: false, supportsSoftDelete: false, fields: [] }); component.draftId.set('store-object'); component.draft.set({ objectId: 'store-object', id: 1, storeName: '逐浪网店', userName: 'old' }); component.targetStoreUsername.set('admin'); component.targetStoreStyleId.set(2);
+    functions.admin.and.callFake(async (operation: string) => operation === 'saveStoreApplication' ? { objectId: 'store-object', id: 1, storeName: '逐浪网店', userName: 'admin', storeStyleId: 2 } : { className: 'StoreApplication', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.save();
+
+    expect(functions.admin.calls.first().args).toEqual(['saveStoreApplication', { className: 'StoreApplication', objectId: 'store-object', fields: { objectId: 'store-object', id: 1, storeName: '逐浪网店', userName: 'old' }, username: 'admin', styleId: 2 }]);
+  });
+
+  it('audits selected stores without exposing the blocked delete action', async () => {
+    component.className.set('StoreApplication'); component.selectedIds.set(['store-a']); functions.admin.and.callFake(async (operation: string) => operation === 'storeBatch' ? { action: 'audit', updated: 1, results: [] } : { className: 'StoreApplication', page: 1, pageSize: 20, total: 1, results: [] });
+
+    await component.applyBulkStoreAction('audit');
+
+    expect(functions.admin.calls.first().args).toEqual(['storeBatch', { className: 'StoreApplication', action: 'audit', objectIds: ['store-a'] }]);
+    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');

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

@@ -127,18 +127,23 @@ export class AdminResourceComponent implements OnInit {
   readonly updatingSurvey = signal(false);
   readonly targetServiceSeatUsername = signal('');
   readonly updatingService = signal(false);
+  readonly filterStoreStatus = signal(-100);
+  readonly targetStoreStyleId = signal(0);
+  readonly targetStoreUsername = signal('');
+  readonly storeStyles = signal<Record<string, unknown>[]>([]);
+  readonly updatingStore = 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', '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 preferred = ['objectId', 'name', 'title', 'qtitle', 'sName', 'storeName', 'styleName', 'str1', 'categoryname', 'dicname', 'cateName', 'gradeName', 'cClassName', 'testPoint', 'kName', 'tname', 'username', 'userName', 'realName', 'sRemrk', 'askId', 'zoneId', 'typeId', 'shape', 'qtype', 'required', 'ztype', 'priority', 'zstatus', 'storeState', 'storeCommendState', 'storeStyleId', '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', 'ServiceSeat', 'Temp'].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', 'StoreApplication', 'StoreStyle'].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));
@@ -157,6 +162,7 @@ export class AdminResourceComponent implements OnInit {
       this.filterFontShapeTypeId.set(0); this.resetFontShapeActions();
       this.filterSurveyStatus.set(-100); this.filterSurveyId.set(0); this.resetSurveyActions();
       this.resetServiceActions();
+      this.filterStoreStatus.set(-100); this.resetStoreActions();
       void this.load(1, true);
     });
   }
@@ -169,7 +175,8 @@ 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' : this.className() === 'ServiceSeat' ? 'serviceSeats' : this.className() === 'Temp' ? 'serviceCodes' : 'list';
+      if (this.className() === 'StoreApplication' && reloadSchema) await this.loadStoreStyles();
+      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' : this.className() === 'StoreApplication' ? 'storeApplications' : this.className() === 'StoreStyle' ? 'storeStyles' : '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(); }
@@ -178,6 +185,7 @@ export class AdminResourceComponent implements OnInit {
       if (pageOperation === 'fontShapes') pagePayload['typeId'] = this.filterFontShapeTypeId();
       if (pageOperation === 'surveys') pagePayload['status'] = this.filterSurveyStatus();
       if (pageOperation === 'surveyQuestions' || pageOperation === 'surveyResults') pagePayload['askId'] = this.filterSurveyId();
+      if (pageOperation === 'storeApplications') pagePayload['status'] = this.filterStoreStatus();
       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();
@@ -185,20 +193,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.resetServiceActions(); 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.resetStoreActions(); 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.resetServiceActions(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.resetServiceActions(detail); this.resetStoreActions(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(); this.resetServiceActions(); }
+  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(); this.resetStoreActions(); }
   setField(field: AdminFieldSchema, raw: unknown): void {
     let value = raw;
     if (field.type === 'Number') value = raw === '' ? null : Number(raw);
@@ -254,7 +262,9 @@ export class AdminResourceComponent implements OnInit {
                                                   : this.className() === 'DesignAsk' ? 'saveSurvey'
                                                     : this.className() === 'DesignQuestion' ? 'saveSurveyQuestion'
                                                       : this.className() === 'ServiceSeat' ? 'saveServiceSeat'
-                                                        : this.className() === 'Temp' ? 'saveServiceCode' : 'save';
+                                                        : this.className() === 'Temp' ? 'saveServiceCode'
+                                                          : this.className() === 'StoreApplication' ? 'saveStoreApplication'
+                                                            : this.className() === 'StoreStyle' ? 'saveStoreStyle' : '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();
@@ -268,6 +278,7 @@ export class AdminResourceComponent implements OnInit {
       if (this.className() === 'AdInfo') payload['zoneId'] = this.targetAdZoneId();
       if (this.className() === 'FontPicShape') payload['typeId'] = this.targetFontShapeTypeId();
       if (this.className() === 'ServiceSeat') payload['username'] = this.targetServiceSeatUsername();
+      if (this.className() === 'StoreApplication') { payload['username'] = this.targetStoreUsername(); payload['styleId'] = this.targetStoreStyleId(); }
       await this.functions.admin(operation, payload);
       this.closeEditor(); await this.load();
     } catch (error) { this.error.set(error instanceof Error ? error.message : '保存失败'); }
@@ -658,6 +669,16 @@ export class AdminResourceComponent implements OnInit {
     catch (error) { this.bulkError.set(error instanceof Error ? error.message : '客服记录删除失败'); }
     finally { this.updatingService.set(false); }
   }
+  async applyBulkStoreAction(action: 'audit' | 'unaudit' | 'elite' | 'unelite' | 'delete'): Promise<void> {
+    const className = this.className(); const objectIds = this.selectedIds(); this.bulkError.set(''); this.bulkMessage.set('');
+    const allowed = className === 'StoreApplication' ? ['audit', 'unaudit', 'elite', 'unelite'] : className === 'StoreStyle' ? ['delete'] : [];
+    if (!allowed.includes(action) || !objectIds.length) { this.bulkError.set(action === 'delete' && className === 'StoreApplication' ? '店铺删除因商品主表缺失而阻塞' : '请先选择可执行该操作的店铺记录'); return; }
+    if (action === 'delete' && !confirm(`确认删除选中的 ${objectIds.length} 条店铺样式?`)) return;
+    this.updatingStore.set(true);
+    try { const result = await this.functions.admin<{ updated: number }>('storeBatch', { className, action, objectIds }); await this.load(); this.bulkMessage.set(`已处理 ${result.updated} 条店铺记录`); }
+    catch (error) { this.bulkError.set(error instanceof Error ? error.message : '店铺批量操作失败'); }
+    finally { this.updatingStore.set(false); }
+  }
   async remove(row: Record<string, unknown>): Promise<void> {
     const objectId = String(row['objectId'] ?? '');
     const verb = ['CommonModel', 'Node', 'Guestbook'].includes(this.className()) ? '移入回收站' : '删除';
@@ -670,6 +691,7 @@ export class AdminResourceComponent implements OnInit {
       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 if (['StoreApplication', 'StoreStyle'].includes(this.className())) await this.functions.admin('storeBatch', { className: this.className(), action: 'delete', objectIds: [objectId] });
       else await this.functions.admin('delete', { className: this.className(), objectId });
       await this.load();
     }
@@ -693,6 +715,8 @@ export class AdminResourceComponent implements OnInit {
   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)})`; }
+  storeStyleIdOf(style: Record<string, unknown>): number { return Number(style['id']) || 0; }
+  storeStyleLabel(style: Record<string, unknown>): string { return `${String(style['styleName'] || '未命名样式')}(${this.storeStyleIdOf(style)})`; }
   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); }
@@ -724,6 +748,10 @@ export class AdminResourceComponent implements OnInit {
     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 async loadStoreStyles(): Promise<void> {
+    try { const result = await this.functions.admin<ResourcePage>('storeStyles', { className: 'StoreStyle', page: 1, pageSize: 100, search: '' }); this.storeStyles.set(result.results || []); }
+    catch (error) { this.storeStyles.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(''); }
@@ -743,4 +771,5 @@ export class AdminResourceComponent implements OnInit {
   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'] || '')); }
+  private resetStoreActions(detail: Record<string, unknown> = {}): void { this.targetStoreUsername.set(String(detail['userName'] || '')); this.targetStoreStyleId.set(Number(detail['storeStyleId']) || 0); }
 }

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff