setApproveModalItem(null)}
onOk={handleApproveSubmit}
+ confirmLoading={approving}
okText="审核通过并正式入库"
okType="primary"
destroyOnClose
@@ -519,6 +569,7 @@ export default function ConsignmentSubmissionsPanel() {
open={Boolean(rejectModalItem)}
onCancel={() => setRejectModalItem(null)}
onOk={handleRejectConfirm}
+ confirmLoading={rejecting}
okText="确认驳回"
okType="danger"
>
diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx
index 841d2591..00f2ff8f 100644
--- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx
+++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx
@@ -2913,8 +2913,8 @@ export default function WorkOrdersPanel() {
若使用内部寄售库存履约,无需向外部打手支付佣金(打手支出 ¥0.00)
- 。系统会立刻占用库存,并跳过「待打手提交资料」,直接进入默认 3
- 天赠送冷静期;冷静期结束后再用库存账号赠送并验收。
+ 。系统会立刻占用库存,并跳过「待打手提交资料」,直接进入该商品规则设定的赠送冷静期(未单独配置时为
+ 72 小时);冷静期结束后再用库存账号赠送并验收。
}
diff --git a/apps/frontend/src/utils/admin-auth.ts b/apps/frontend/src/utils/admin-auth.ts
index 99e404f3..29f4a338 100644
--- a/apps/frontend/src/utils/admin-auth.ts
+++ b/apps/frontend/src/utils/admin-auth.ts
@@ -58,6 +58,25 @@ export function hasAdminPermission(permission: string) {
return getAdminPermissions().includes(String(permission || '').trim())
}
+/**
+ * 解析管理端权限:会话接口返回的权限列表优先,缺失时回退到登录时写入本地的权限快照。
+ *
+ * 为什么需要两层:
+ * - 本地快照可能过期(管理员改权限后该账号没重新登录),页面拿到 `/admin/auth/session`
+ * 的最新列表后必须以它为准;
+ * - 会话接口失败时不能让整个面板的按钮全消失,所以保留本地回退。
+ */
+export function resolveAdminPermission(
+ sessionPermissions: string[] | null | undefined,
+ permission: string,
+): boolean {
+ const code = String(permission || '').trim()
+ if (!code) return false
+ // 空数组也算"拿到了最新列表",此时判定为无权限,不回退本地快照。
+ if (Array.isArray(sessionPermissions)) return sessionPermissions.includes(code)
+ return hasAdminPermission(code)
+}
+
export function hasAdminRole(role: 'admin' | 'operator' | 'support' | 'finance') {
if (!hasAdminSession()) {
return false
diff --git a/apps/frontend/tests/consignment-permissions.test.ts b/apps/frontend/tests/consignment-permissions.test.ts
new file mode 100644
index 00000000..c82d71ab
--- /dev/null
+++ b/apps/frontend/tests/consignment-permissions.test.ts
@@ -0,0 +1,68 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import { resolveAdminPermission } from '../src/utils/admin-auth.ts'
+
+type StorageLike = {
+ getItem: (key: string) => string | null
+ setItem: (key: string, value: string) => void
+ removeItem: (key: string) => void
+}
+
+function installLocalStorage(entries: Record) {
+ const store = new Map(Object.entries(entries))
+ const storage: StorageLike = {
+ getItem: (key) => (store.has(key) ? (store.get(key) as string) : null),
+ setItem: (key, value) => {
+ store.set(key, value)
+ },
+ removeItem: (key) => {
+ store.delete(key)
+ },
+ }
+ ;(globalThis as { localStorage?: StorageLike }).localStorage = storage
+ return store
+}
+
+function installAdminSessionSnapshot(permissions: string[]) {
+ return installLocalStorage({
+ 'order-site-admin-token': 'token',
+ 'order-site-admin-token-expires-at': new Date(Date.now() + 60_000).toISOString(),
+ 'order-site-admin-permissions': JSON.stringify(permissions),
+ })
+}
+
+test('会话接口返回的权限列表优先于本地快照(管理员刚收回权限的场景)', () => {
+ installAdminSessionSnapshot(['consignment.manage'])
+
+ // 本地快照里有 manage,但会话接口的最新列表没有 → 以会话为准
+ assert.equal(resolveAdminPermission(['consignment.view'], 'consignment.manage'), false)
+ assert.equal(resolveAdminPermission(['consignment.view'], 'consignment.view'), true)
+})
+
+test('会话返回空权限数组时判定为无权限,不回退本地快照', () => {
+ installAdminSessionSnapshot(['consignment.manage', 'consignment.review'])
+
+ assert.equal(resolveAdminPermission([], 'consignment.manage'), false)
+ assert.equal(resolveAdminPermission([], 'consignment.review'), false)
+})
+
+test('会话权限缺失(接口失败/未返回)时回退本地快照', () => {
+ installAdminSessionSnapshot(['consignment.review'])
+
+ assert.equal(resolveAdminPermission(undefined, 'consignment.review'), true)
+ assert.equal(resolveAdminPermission(null, 'consignment.manage'), false)
+})
+
+test('没有登录会话时即使快照里有权限也不放行', () => {
+ installLocalStorage({ 'order-site-admin-permissions': JSON.stringify(['consignment.manage']) })
+
+ assert.equal(resolveAdminPermission(undefined, 'consignment.manage'), false)
+})
+
+test('空权限名一律拒绝(避免调用方漏传参数时误放行)', () => {
+ installAdminSessionSnapshot(['consignment.manage'])
+
+ assert.equal(resolveAdminPermission(['consignment.manage'], ''), false)
+ assert.equal(resolveAdminPermission([''], 'consignment.manage'), false)
+})
diff --git a/docs/寄卖系统优化方案.md b/docs/寄卖系统优化方案.md
index 1553549e..89dc2f66 100644
--- a/docs/寄卖系统优化方案.md
+++ b/docs/寄卖系统优化方案.md
@@ -19,7 +19,8 @@
| `6b940796` | 样式修复(cooldownMinutes prefer-const) | — |
| `e828e677` | 权限打通(19 接口改 `requireAdminPermission`)、指派占库改条件更新、撤销守卫 | 新增 4 个回归测试 |
| `632dddc4` | 上一轮:H5 渠道整体下线 + 凭据字段修复 + 状态机封闭(撤销守卫 / 库存绑定唯一性 / 生命周期解绑)+ 押金归属取台账 | 迁移 `079`、`consignment-*` 五个文件、`work-order-*` 三个仓库、两个前端面板 |
-| *(工作区未提交)* | 本轮 · 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` |
+| `5d11bf20` | 批次 C+D:指派部分成功补偿、凭证核销如实反馈、空规格不放行 + 履约/指派服务端规格复检 | `consignment-fulfillment-service.ts`、`consignment-matching-service.ts`、`consignment-repo.ts`、履约路由、`WorkOrdersPanel.tsx` |
+| 本批提交(批次 E,同一提交内含本文档,故不写自身 hash) | 前端四权限门控收口、`allocation_status` 约束与权限文案修正、面板错误态/防双提交/真实待审计数 | `admin-auth.ts`、两个寄卖面板、`AdminWorkerPlatformPage.tsx`、迁移 `080` |
**结论**:功能主干 3 天连成闭环(库存 → 提报审核 → 匹配 → 内部现货 / 打手押金两条履约 → 赠送冷静期 → 验收结算 → 撤销回库),但最近三次提交全是**上线后回填正确性**(权限被静默清除、并发占库、撤销资金挂账)。
当前主要矛盾不是"缺功能",而是:**状态机不封闭 + 库存绑定无唯一性约束 + 工单终态对寄卖零感知**(本轮已处理);入口侧"H5 不可提交 / 手工录入丢凭据"两条断链已通过"下线 H5 + 修字段名"解决。
@@ -273,10 +274,29 @@
前端在"部分匹配"候选上先 `modal.confirm` 危险确认,再带 `allowPartial: true` 重试,高配充低配的合法玩法保留但不可误触。
3. ⏳ 匹配下推 SQL + 批量匹配接口 + 工单列表角标(P2-8)未做:当前仍是"取全量在库 → 内存比对"。
-### 批次 E · 权限门控与运营体验(P1/P2)
-1. 前端四权限门控(P1-8 剩余部分:库存/提报面板尚无 `consignment.view/manage/review` 门控);打手端菜单收口(P1-9 剩余)。
-2. 报表按文档 §六补齐节省赏金等指标(P2-7);`allocation_status` CHECK 约束(P2-19 剩余)。
-3. 面板错误态、双提交、徽标、搜索、押金回显、文案/时区等体验项(P2-1~P2-18)。
+### 批次 E · 权限门控与运营体验(P1/P2)—— 🔶 部分完成(本轮)
+
+1. ✅ **前端四权限门控(P1-8 剩余部分)**:
+ - 新增 `resolveAdminPermission(sessionPermissions, code)`(`utils/admin-auth.ts`):会话接口的最新权限列表优先,
+ 接口异常时才回退登录时的本地快照;**空数组也判定为无权限**,不回退(管理员刚收回权限的场景)。
+ - 寄售仓库面板:`consignment.manage` 控制"手动入库 / 编辑 / 作废 / 退单回库 / 匹配开关"(无权限时开关禁用并给 tooltip 说明)。
+ - 寄卖提报面板:`consignment.review` 控制"审核入库 / 驳回"。
+ - `AdminWorkerPlatformPage`:无 `consignment.view` 时不渲染"寄售仓库 / 寄卖提报审核"两个 tab,
+ 并把 URL 或 localStorage 里的旧选择拉回工单页(否则会落在空白 tab 上)。
+2. ✅ **`allocation_status` 收口(P2-19 剩余)**:迁移 `080` 先把历史非法值归位到「待安排」(备注留痕)再加
+ `CHECK (allocation_status IN ('待安排','已安排'))`;编辑接口新增拒绝 `allocationStatus`(400
+ `consignment_allocation_status_update_forbidden`),与"编辑接口不可改 status"同口径。顺带修正 077 遗留的
+ "收集链接/玩家提交"权限描述文案(该渠道已在 079 下线)。
+3. ✅ **体验项(部分)**:两个面板加 `isError` 错误态(含重试按钮);审核通过/驳回弹窗加 `confirmLoading` 与
+ 提交中守卫(防双提交);待审徽标改用后端真实总数(原来只统计当前页,翻页就变);编辑弹窗押金不再兜底 `¥100`
+ (会把"无押金"悄悄写成 100 元);履约拦截弹窗的冷静期文案不再硬编码"3 天",改为"按商品规则设定(未配置时 72 小时)"。
+4. ✅ **核实并撤回一条误报**:原审计提到"倒计时时区偏移"——`deadlineAt` 由 node-pg 解析 `timestamptz` 后
+ 以带 `Z` 的 ISO 字符串下发,前端 `new Date(deadlineAt)` 无偏移问题,已用真实库数据确认,不需要改。
+5. ⏳ 未做:
+ - 报表补齐(P2-7:节省赏金 / 出库单数 / 批次回款);
+ - 搜索字段后端不支持(面板承诺按联系方式/游戏 ID 搜索,后端只搜部分字段);
+ - 打手端"按账号状态隐藏寄卖入口":后端 `POST /consignment/publish` 已强制 `requireActiveWorker`,
+ 前端要隐藏入口需要 `/worker/me` 暴露账号状态字段,本次未动接口。
---
@@ -285,4 +305,4 @@
1. **撤销边界**:✅ 定案 —— **已验收一律禁止撤销**,历史数据不做兼容(用户:"这是测试阶段,不用管历史记录")。买家退款/纠纷走售后流程;若日后确有"验收后退款"场景,另开"售后回收库存"流程(回库或作废 + 押金重算)。
2. **`worker_id` 拆分**:✅ 定案 —— 直接新增 `assigned_worker_id` 并让 `worker_id` 固定为卖家(迁移 `079`,无兼容期,不回填历史)。
3. **押金策略**:⏳ 未定 —— `pending_review` 超期自动驳回退押金的时限(如 7 天)、以及打手自助撤回是否允许,涉及运营对账口径,需业务确认。
-4. **批次顺序**:✅ 定案 —— A(入口可用)→ B(资金安全)→ C/D(资金与货物一致性、防错发)均已落地;E(权限门控与运营体验)为下一批。
+4. **批次顺序**:✅ 定案 —— A(入口可用)→ B(资金安全)→ C/D(资金与货物一致性、防错发)已完成;E(权限门控与运营体验)本轮完成权限与约束部分,报表/搜索/打手端入口为剩余项。