feat(consignment): 补齐寄卖前端权限门控,收口调拨状态与运营体验
权限门控(P1-8 剩余): - 新增 resolveAdminPermission(sessionPermissions, code):会话接口的最新权限列表优先, 接口异常才回退登录时的本地快照;空数组也判无权限,避免"刚被收回权限仍能点"。 - 寄售仓库面板:consignment.manage 控制手动入库/编辑/作废/退单回库/匹配开关(无权限时开关禁用)。 - 寄卖提报面板:consignment.review 控制审核入库/驳回。 - 接单平台页:无 consignment.view 不渲染两个寄卖 tab,并把 URL/localStorage 的旧选择拉回工单页。 数据约束(P2-19 剩余): - 迁移 080:allocation_status 历史非法值归位「待安排」(备注留痕)后加 CHECK(待安排/已安排), 并顺手修正 077 遗留的"收集链接/玩家提交"权限描述文案。 - 编辑接口拒绝直接改 allocationStatus(400 consignment_allocation_status_update_forbidden)。 运营体验: - 两个面板补 isError 错误态与重试;审核通过/驳回加 confirmLoading 与提交中守卫(防双提交)。 - 待审徽标改用后端真实总数(原来只统计当前页);编辑弹窗押金不再兜底 ¥100(会误写押金)。 - 履约拦截弹窗冷静期文案不再硬编码"3 天",改为按商品规则设定。 - 核实并撤回"倒计时时区偏移"误报:deadlineAt 为带 Z 的 ISO,前端无偏移问题。 验证:后端 412 例(409 通过)、前端 21 例(新增 5 例权限解析)全绿, typecheck / lint / format / check:sql / build 通过;迁移 001→080 在全新空库跑通并在 dev 库应用。
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
-- 080_consignment_allocation_status_and_copy.sql —— 寄卖收口:调拨状态加约束 + 修正下线渠道遗留文案
|
||||
--
|
||||
-- 背景:
|
||||
-- 1) consignment_inventories.allocation_status 自 077 起是自由文本,系统只会写「待安排 / 已安排」,
|
||||
-- 但没有任何约束挡住别的值进来;列表角标与筛选口径依赖这两个值,必须锁死。
|
||||
-- 2) 077 写入的权限描述还写着「收集链接」(该渠道已在 079 整体下线)与「玩家提交」,
|
||||
-- 需要在权限说明里改成实际口径(打手通过接单平台提报)。
|
||||
-- 历史数据不做兼容处理(测试阶段),仅把非法值归位到「待安排」并在备注留痕。
|
||||
|
||||
-- 1) 历史非法值归位(先归位再加约束,否则 ADD CONSTRAINT 会因为存量脏数据直接失败)
|
||||
UPDATE consignment_inventories
|
||||
SET
|
||||
remark = remark || ' [allocation_status 非法值归位: ' || allocation_status || ']',
|
||||
allocation_status = '待安排',
|
||||
updated_at = NOW()
|
||||
WHERE allocation_status NOT IN ('待安排', '已安排');
|
||||
|
||||
-- 2) 调拨状态约束:与系统写入口径一致,重复执行安全
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'consignment_inventories_allocation_status_check'
|
||||
AND conrelid = 'consignment_inventories'::regclass
|
||||
) THEN
|
||||
ALTER TABLE consignment_inventories
|
||||
DROP CONSTRAINT consignment_inventories_allocation_status_check;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
ALTER TABLE consignment_inventories
|
||||
ADD CONSTRAINT consignment_inventories_allocation_status_check
|
||||
CHECK (allocation_status IN ('待安排', '已安排'));
|
||||
|
||||
COMMENT ON COLUMN consignment_inventories.allocation_status IS
|
||||
'调拨状态:待安排 在库待匹配 / 已安排 已被工单占用;由系统在占库、回库时写入,编辑接口不可改';
|
||||
|
||||
-- 3) 权限描述修正(收集链接渠道已下线,提报来源改为接单平台打手端)
|
||||
INSERT INTO admin_permissions (permission_key, permission_name, permission_group, description)
|
||||
VALUES
|
||||
('consignment.view', '查看寄卖库存', '寄卖管理', '查看寄售仓库与寄卖提报单'),
|
||||
('consignment.manage', '管理寄卖库存', '寄卖管理', '录入、修改、下架寄售物品(状态与调拨由系统动作变更)'),
|
||||
('consignment.review', '审核寄卖提报', '寄卖管理', '审核接单平台提报的寄卖申请并入库'),
|
||||
('consignment.fulfill', '寄卖内部履约', '寄卖管理', '使用内部寄卖库存直接履约工单、指派打手与撤销重选')
|
||||
ON CONFLICT (permission_key) DO UPDATE SET
|
||||
permission_name = EXCLUDED.permission_name,
|
||||
permission_group = EXCLUDED.permission_group,
|
||||
description = EXCLUDED.description,
|
||||
updated_at = NOW();
|
||||
@@ -261,6 +261,14 @@ export async function updateAdminConsignmentInventory(
|
||||
errorCode: 'consignment_status_update_forbidden',
|
||||
})
|
||||
}
|
||||
// 调拨状态由系统在占库/回库时写入,前端也从不展示该字段的编辑入口;
|
||||
// 开放自由文本会让"待安排/已安排"之外的值进库,导致列表角标与筛选口径失真。
|
||||
if (payload.allocationStatus !== undefined) {
|
||||
throw createHttpError('编辑接口不支持直接修改调拨状态', {
|
||||
statusCode: 400,
|
||||
errorCode: 'consignment_allocation_status_update_forbidden',
|
||||
})
|
||||
}
|
||||
const updated = await updateConsignmentInventory(id, {
|
||||
itemName: payload.itemName !== undefined ? String(payload.itemName).trim() : undefined,
|
||||
system: payload.system !== undefined ? String(payload.system).trim() : undefined,
|
||||
|
||||
@@ -68,6 +68,7 @@ import {
|
||||
returnConsignmentInventoryToStockWithClient,
|
||||
} from '../../repositories/worker-platform/consignment-repo.js'
|
||||
import { ADMIN_PERMISSION_KEYS } from '../admin/admin-rbac-service.js'
|
||||
import { updateAdminConsignmentInventory } from './consignment-admin-service.js'
|
||||
import {
|
||||
assertConsignmentRevocableWorkOrderStatus,
|
||||
resolveRevocableConsignmentInventoryId,
|
||||
@@ -2027,3 +2028,23 @@ test('内部履约的电子凭证核销失败要写事件留痕(不能静默
|
||||
assert.ok(src.includes('consignment_fulfill_voucher_consume_failed'))
|
||||
assert.ok(src.includes('if (!voucherConsume.ok)'))
|
||||
})
|
||||
|
||||
test('编辑接口拒绝直接改调拨状态,避免自由文本进库破坏角标口径', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
updateAdminConsignmentInventory(1, {
|
||||
allocationStatus: '随便写的状态',
|
||||
}),
|
||||
(error: unknown) =>
|
||||
(error as { errorCode?: string }).errorCode ===
|
||||
'consignment_allocation_status_update_forbidden',
|
||||
)
|
||||
})
|
||||
|
||||
test('编辑接口仍然拒绝直接改物品状态(回归)', async () => {
|
||||
await assert.rejects(
|
||||
() => updateAdminConsignmentInventory(1, { status: 'archived' }),
|
||||
(error: unknown) =>
|
||||
(error as { errorCode?: string }).errorCode === 'consignment_status_update_forbidden',
|
||||
)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useLocation, useSearchParams } from 'react-router'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import type { WorkOrder } from '@/types/worker-platform'
|
||||
import { fetchAdminWorkerPlatformSummary } from '@/services/admin'
|
||||
import { getAdminRole } from '@/utils/admin-auth'
|
||||
import { getAdminRole, hasAdminPermission } from '@/utils/admin-auth'
|
||||
import AfterSalesPanel, { type AfterSalesPanelHandle } from './panels/AfterSalesPanel'
|
||||
import AnnouncementPanel from './panels/AnnouncementPanel'
|
||||
import CancelRequestsPanel from './panels/CancelRequestsPanel'
|
||||
@@ -36,11 +36,18 @@ export default function AdminWorkerPlatformPage() {
|
||||
const isAdmin = role === 'admin'
|
||||
const isSupport = role === 'support'
|
||||
const isFinance = role === 'finance'
|
||||
const canViewConsignment = hasAdminPermission('consignment.view')
|
||||
const isConsignmentTab =
|
||||
activeTab === 'consignment-inventory' || activeTab === 'consignment-submissions'
|
||||
// 没有 consignment.view 时这两个 tab 不渲染,把 URL/localStorage 里的旧选择也拉回工单页,
|
||||
// 否则会落在一个空的 tab 内容上。
|
||||
const visibleActiveTab = isFinance
|
||||
? 'finance'
|
||||
: !isAdmin && activeTab === 'notifications'
|
||||
? 'orders'
|
||||
: activeTab
|
||||
: !canViewConsignment && isConsignmentTab
|
||||
? 'orders'
|
||||
: activeTab
|
||||
|
||||
useEffect(() => {
|
||||
const tab = searchParams.get('tab')
|
||||
@@ -79,6 +86,23 @@ export default function AdminWorkerPlatformPage() {
|
||||
children: <AfterSalesPanel ref={afterSalesPanelRef} />,
|
||||
}
|
||||
|
||||
// 寄卖相关 tab 按权限渲染:后端所有寄卖接口都挂了 requireAdminPermission,
|
||||
// 没有 consignment.view 的人看到 tab 只会一路 403。
|
||||
const consignmentTabs = canViewConsignment
|
||||
? [
|
||||
{
|
||||
key: 'consignment-inventory',
|
||||
label: '寄售仓库',
|
||||
children: <ConsignmentInventoryPanel />,
|
||||
},
|
||||
{
|
||||
key: 'consignment-submissions',
|
||||
label: '寄卖提报审核',
|
||||
children: <ConsignmentSubmissionsPanel />,
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader
|
||||
@@ -96,16 +120,7 @@ export default function AdminWorkerPlatformPage() {
|
||||
: isSupport
|
||||
? [
|
||||
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
||||
{
|
||||
key: 'consignment-inventory',
|
||||
label: '寄售仓库',
|
||||
children: <ConsignmentInventoryPanel />,
|
||||
},
|
||||
{
|
||||
key: 'consignment-submissions',
|
||||
label: '寄卖提报审核',
|
||||
children: <ConsignmentSubmissionsPanel />,
|
||||
},
|
||||
...consignmentTabs,
|
||||
{
|
||||
key: 'cancel-requests',
|
||||
label: '打手退单审核',
|
||||
@@ -116,16 +131,7 @@ export default function AdminWorkerPlatformPage() {
|
||||
]
|
||||
: [
|
||||
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
||||
{
|
||||
key: 'consignment-inventory',
|
||||
label: '寄售仓库',
|
||||
children: <ConsignmentInventoryPanel />,
|
||||
},
|
||||
{
|
||||
key: 'consignment-submissions',
|
||||
label: '寄卖提报审核',
|
||||
children: <ConsignmentSubmissionsPanel />,
|
||||
},
|
||||
...consignmentTabs,
|
||||
{
|
||||
key: 'cancel-requests',
|
||||
label: '打手退单审核',
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Badge,
|
||||
Button,
|
||||
@@ -46,6 +47,8 @@ import {
|
||||
} from '@/services/admin/consignment'
|
||||
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
|
||||
import type { ConsignmentInventory } from '@/types/consignment'
|
||||
import { fetchAdminSession } from '@/services/admin/auth'
|
||||
import { resolveAdminPermission } from '@/utils/admin-auth'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -113,6 +116,16 @@ export default function ConsignmentInventoryPanel() {
|
||||
const [inboundForm] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
|
||||
// 写操作按钮统一按 consignment.manage 门控:后端全部挂了 requireAdminPermission,
|
||||
// 前端不隐藏会让无权限的人看到按钮却每次点击都 403。
|
||||
const adminSessionQuery = useQuery({
|
||||
queryKey: ['admin-session'],
|
||||
queryFn: fetchAdminSession,
|
||||
retry: false,
|
||||
})
|
||||
const sessionPermissions = adminSessionQuery.data?.data.user.permissions
|
||||
const canManageConsignment = resolveAdminPermission(sessionPermissions, 'consignment.manage')
|
||||
|
||||
// 查询活跃打手列表用于指派与关联
|
||||
const workersQuery = useQuery({
|
||||
queryKey: ['admin-worker-users-consignment-options'],
|
||||
@@ -444,11 +457,17 @@ export default function ConsignmentInventoryPanel() {
|
||||
width: 110,
|
||||
render: (_, record) => (
|
||||
<Tooltip
|
||||
title={record.allowMatching ? '当前允许系统与工单匹配' : '已禁止匹配,不进入候选池'}
|
||||
title={
|
||||
!canManageConsignment
|
||||
? '无寄卖管理权限,无法修改匹配开关'
|
||||
: record.allowMatching
|
||||
? '当前允许系统与工单匹配'
|
||||
: '已禁止匹配,不进入候选池'
|
||||
}
|
||||
>
|
||||
<Switch
|
||||
checked={record.allowMatching}
|
||||
disabled={record.status !== 'in_stock'}
|
||||
disabled={record.status !== 'in_stock' || !canManageConsignment}
|
||||
checkedChildren="开"
|
||||
unCheckedChildren="关"
|
||||
onChange={(checked) => handleToggleMatching(record, checked)}
|
||||
@@ -503,7 +522,7 @@ export default function ConsignmentInventoryPanel() {
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
{record.status === 'in_stock' ? (
|
||||
{record.status === 'in_stock' && canManageConsignment ? (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -514,7 +533,10 @@ export default function ConsignmentInventoryPanel() {
|
||||
...record,
|
||||
costPrice: record.costPriceFen / 100,
|
||||
workerId: record.workerId || undefined,
|
||||
depositAmount: record.depositAmountFen ? record.depositAmountFen / 100 : 100,
|
||||
// 没有押金就留空,不能兜底成 100:保存会把"无押金"悄悄写成 ¥100
|
||||
depositAmount: record.depositAmountFen
|
||||
? record.depositAmountFen / 100
|
||||
: undefined,
|
||||
loginMethod: (record.credentialsJson as any)?.loginMethod,
|
||||
account: (record.credentialsJson as any)?.account,
|
||||
password: (record.credentialsJson as any)?.password,
|
||||
@@ -535,7 +557,7 @@ export default function ConsignmentInventoryPanel() {
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{record.status === 'consumed' ? (
|
||||
{record.status === 'consumed' && canManageConsignment ? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
@@ -691,32 +713,51 @@ export default function ConsignmentInventoryPanel() {
|
||||
<Button icon={<ReloadOutlined />} onClick={refresh}>
|
||||
刷新
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
inboundForm.resetFields()
|
||||
inboundForm.setFieldsValue({
|
||||
system: '苹果',
|
||||
serverZone: '微信区',
|
||||
series: '暗潮',
|
||||
diamondLevel: 1,
|
||||
brillianceCount: 0,
|
||||
count: 1,
|
||||
allowMatching: true,
|
||||
itemName: '苹果微信暗潮1钻',
|
||||
})
|
||||
setInboundModalOpen(true)
|
||||
}}
|
||||
>
|
||||
手动入库
|
||||
</Button>
|
||||
{canManageConsignment ? (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
inboundForm.resetFields()
|
||||
inboundForm.setFieldsValue({
|
||||
system: '苹果',
|
||||
serverZone: '微信区',
|
||||
series: '暗潮',
|
||||
diamondLevel: 1,
|
||||
brillianceCount: 0,
|
||||
count: 1,
|
||||
allowMatching: true,
|
||||
itemName: '苹果微信暗潮1钻',
|
||||
})
|
||||
setInboundModalOpen(true)
|
||||
}}
|
||||
>
|
||||
手动入库
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{/* 库存表格 */}
|
||||
<Card size="small">
|
||||
{inventoriesQuery.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="读取寄卖库存失败"
|
||||
description={
|
||||
(inventoriesQuery.error as any)?.message ||
|
||||
'请检查网络或稍后重试;也可能是当前账号缺少 consignment.view 权限。'
|
||||
}
|
||||
action={
|
||||
<Button size="small" onClick={() => void inventoriesQuery.refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from '@ant-design/icons'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
@@ -33,8 +34,10 @@ import {
|
||||
fetchAdminConsignmentSubmissions,
|
||||
rejectAdminConsignmentSubmission,
|
||||
} from '@/services/admin/consignment'
|
||||
import { fetchAdminSession } from '@/services/admin/auth'
|
||||
import { fetchAdminWorkerUsers } from '@/services/admin/worker-platform'
|
||||
import type { ConsignmentSubmission } from '@/types/consignment'
|
||||
import { resolveAdminPermission } from '@/utils/admin-auth'
|
||||
|
||||
const { Text } = Typography
|
||||
|
||||
@@ -51,6 +54,17 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
const [viewingSubmission, setViewingSubmission] = useState<ConsignmentSubmission | null>(null)
|
||||
const [rejectNote, setRejectNote] = useState('')
|
||||
const [approveForm] = Form.useForm()
|
||||
const [approving, setApproving] = useState(false)
|
||||
const [rejecting, setRejecting] = useState(false)
|
||||
|
||||
// 通过/驳回要 consignment.review:后端接口已挂权限,前端不隐藏会让人点了必 403
|
||||
const adminSessionQuery = useQuery({
|
||||
queryKey: ['admin-session'],
|
||||
queryFn: fetchAdminSession,
|
||||
retry: false,
|
||||
})
|
||||
const sessionPermissions = adminSessionQuery.data?.data.user.permissions
|
||||
const canReviewConsignment = resolveAdminPermission(sessionPermissions, 'consignment.review')
|
||||
|
||||
const workersQuery = useQuery({
|
||||
queryKey: ['admin-worker-users-consignment-options'],
|
||||
@@ -82,10 +96,23 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
|
||||
const submissions = submissionsQuery.data?.data.items || []
|
||||
const submissionTotal = submissionsQuery.data?.data.total || 0
|
||||
const pendingCount = submissions.filter((s) => s.status === 'pending_review').length
|
||||
|
||||
// 待审徽标必须用后端总数:原来只统计当前页(pageSize=20 时最多显示 20,且翻页就变)。
|
||||
const pendingTotalQuery = useQuery({
|
||||
queryKey: ['admin-consignment-submissions-pending-total'],
|
||||
queryFn: () =>
|
||||
fetchAdminConsignmentSubmissions({
|
||||
status: 'pending_review',
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
}),
|
||||
refetchInterval: 60_000,
|
||||
})
|
||||
const pendingCount = pendingTotalQuery.data?.data.total || 0
|
||||
|
||||
async function handleApproveSubmit() {
|
||||
if (!approveModalItem) return
|
||||
if (!approveModalItem || approving) return
|
||||
setApproving(true)
|
||||
try {
|
||||
const values = await approveForm.validateFields()
|
||||
const costPriceFen = Math.round(Number(values.costPrice || 0) * 100)
|
||||
@@ -118,23 +145,28 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
|
||||
message.success('审核通过!已正式存入寄售库存待匹配!')
|
||||
setApproveModalItem(null)
|
||||
await submissionsQuery.refetch()
|
||||
await Promise.all([submissionsQuery.refetch(), pendingTotalQuery.refetch()])
|
||||
} catch (err: any) {
|
||||
if (err.errorFields) return
|
||||
message.error(err.message || '审核入库失败')
|
||||
} finally {
|
||||
setApproving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRejectConfirm() {
|
||||
if (!rejectModalItem) return
|
||||
if (!rejectModalItem || rejecting) return
|
||||
setRejecting(true)
|
||||
try {
|
||||
await rejectAdminConsignmentSubmission(rejectModalItem.id, { reviewNote: rejectNote })
|
||||
message.success('已驳回该提报申请')
|
||||
setRejectModalItem(null)
|
||||
setRejectNote('')
|
||||
await submissionsQuery.refetch()
|
||||
await Promise.all([submissionsQuery.refetch(), pendingTotalQuery.refetch()])
|
||||
} catch (err: any) {
|
||||
message.error(err.message || '驳回失败')
|
||||
} finally {
|
||||
setRejecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +288,7 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
{record.status === 'pending_review' ? (
|
||||
{record.status === 'pending_review' && canReviewConsignment ? (
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -338,6 +370,23 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
</Card>
|
||||
|
||||
<Card size="small">
|
||||
{submissionsQuery.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="读取寄卖提报单失败"
|
||||
description={
|
||||
(submissionsQuery.error as any)?.message ||
|
||||
'请检查网络或稍后重试;也可能是当前账号缺少 consignment.view 权限。'
|
||||
}
|
||||
action={
|
||||
<Button size="small" onClick={() => void submissionsQuery.refetch()}>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={submissionColumns}
|
||||
@@ -363,6 +412,7 @@ export default function ConsignmentSubmissionsPanel() {
|
||||
width={680}
|
||||
onCancel={() => 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"
|
||||
>
|
||||
|
||||
@@ -2913,8 +2913,8 @@ export default function WorkOrdersPanel() {
|
||||
</div>
|
||||
<div style={{ color: '#389e0d', marginTop: 4 }}>
|
||||
若使用内部寄售库存履约,<strong>无需向外部打手支付佣金(打手支出 ¥0.00)</strong>
|
||||
。系统会立刻占用库存,并跳过「待打手提交资料」,直接进入默认 3
|
||||
天赠送冷静期;冷静期结束后再用库存账号赠送并验收。
|
||||
。系统会立刻占用库存,并跳过「待打手提交资料」,直接进入该商品规则设定的赠送冷静期(未单独配置时为
|
||||
72 小时);冷静期结束后再用库存账号赠送并验收。
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string, string>) {
|
||||
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)
|
||||
})
|
||||
+26
-6
@@ -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(权限门控与运营体验)本轮完成权限与约束部分,报表/搜索/打手端入口为剩余项。
|
||||
|
||||
Reference in New Issue
Block a user