新增接单平台第一期闭环

This commit is contained in:
yml2213
2026-07-25 13:58:53 +08:00
parent 06decc51ff
commit f6d3f00f21
27 changed files with 3987 additions and 0 deletions
+29
View File
@@ -3,7 +3,9 @@ import { Spin } from 'antd'
import { HashRouter, Navigate, Outlet, Route, Routes, useLocation } from 'react-router'
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
import { hasWorkerSession } from '@/utils/worker-auth'
import AdminLayout from '@/layouts/AdminLayout'
import WorkerLayout from '@/layouts/WorkerLayout'
const AdminDashboardPage = lazy(() => import('@/pages/admin/AdminDashboardPage'))
const AdminAuditLogsPage = lazy(() => import('@/pages/admin/AdminAuditLogsPage'))
@@ -22,8 +24,14 @@ const AdminTaskDetailPage = lazy(() => import('@/pages/admin/AdminTaskDetailPage
const AdminTasksPage = lazy(() => import('@/pages/admin/AdminTasksPage'))
const AdminUsersPage = lazy(() => import('@/pages/admin/AdminUsersPage'))
const AdminDevMockPage = lazy(() => import('@/pages/admin/AdminDevMockPage'))
const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlatformPage'))
const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage'))
const CollectPage = lazy(() => import('@/pages/collect/CollectPage'))
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage'))
const WorkerLoginPage = lazy(() => import('@/pages/worker/WorkerLoginPage'))
const WorkerOrdersPage = lazy(() => import('@/pages/worker/WorkerOrdersPage'))
const WorkerProfilePage = lazy(() => import('@/pages/worker/WorkerProfilePage'))
function RequireAdmin() {
const location = useLocation()
@@ -51,6 +59,16 @@ function LoginRoute() {
return <AdminLoginPage />
}
function RequireWorker() {
const location = useLocation()
if (!hasWorkerSession()) {
return <Navigate to="/worker/login" replace state={{ from: location }} />
}
return <Outlet />
}
export default function App() {
return (
<HashRouter>
@@ -66,6 +84,7 @@ export default function App() {
<Route path="orders/:orderId" element={<AdminOrderDetailPage />} />
<Route path="tasks" element={<AdminTasksPage />} />
<Route path="tasks/:taskId" element={<AdminTaskDetailPage />} />
<Route path="worker-platform" element={<AdminWorkerPlatformPage />} />
<Route path="kuaishou-industry" element={<AdminKuaishouIndustryPage />} />
<Route path="cloudtentacles-records" element={<AdminCloudtentaclesRecordsPage />} />
<Route path="dev-mock" element={<AdminDevMockPage />} />
@@ -77,7 +96,17 @@ export default function App() {
</Route>
</Route>
</Route>
<Route path="/worker/login" element={<WorkerLoginPage />} />
<Route element={<RequireWorker />}>
<Route path="/worker" element={<WorkerLayout />}>
<Route index element={<Navigate to="/worker/hall" replace />} />
<Route path="hall" element={<WorkerHallPage />} />
<Route path="orders" element={<WorkerOrdersPage />} />
<Route path="profile" element={<WorkerProfilePage />} />
</Route>
</Route>
<Route path="/claim/:token" element={<ClaimPage />} />
<Route path="/collect" element={<CollectPage />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
</Suspense>
@@ -11,6 +11,7 @@ import {
SettingOutlined,
ShopOutlined,
TeamOutlined,
TrophyOutlined,
UnorderedListOutlined,
UserOutlined,
} from '@ant-design/icons'
@@ -55,6 +56,7 @@ export default function AdminLayout() {
{ key: '/admin/dashboard', icon: <DashboardOutlined />, label: '概览' },
{ key: '/admin/orders', icon: <OrderedListOutlined />, label: '订单' },
{ key: '/admin/tasks', icon: <UnorderedListOutlined />, label: '任务' },
{ key: '/admin/worker-platform', icon: <TrophyOutlined />, label: '接单平台' },
{ key: '/admin/kuaishou-industry', icon: <SafetyCertificateOutlined />, label: '电子凭证' },
{ key: '/admin/cloudtentacles-records', icon: <FileSearchOutlined />, label: '发货记录' },
]
@@ -204,6 +206,7 @@ export default function AdminLayout() {
function resolveSelectedKey(pathname: string) {
if (pathname.startsWith('/admin/orders')) return '/admin/orders'
if (pathname.startsWith('/admin/tasks')) return '/admin/tasks'
if (pathname.startsWith('/admin/worker-platform')) return '/admin/worker-platform'
if (pathname.startsWith('/admin/kuaishou-industry')) return '/admin/kuaishou-industry'
if (pathname === '/admin/platform-shops') return '/admin/platform-shops'
return pathname
@@ -0,0 +1,98 @@
import {
ContainerOutlined,
LogoutOutlined,
ShoppingOutlined,
UserOutlined,
} from '@ant-design/icons'
import { App, Button, Layout, Menu, Space, Typography } from 'antd'
import type { MenuProps } from 'antd'
import { Outlet, useLocation, useNavigate } from 'react-router'
import { logoutWorker } from '@/services/worker'
import {
clearWorkerSession,
getWorkerStatus,
getWorkerUsername,
} from '@/utils/worker-auth'
const menuItems: MenuProps['items'] = [
{ key: '/worker/hall', icon: <ShoppingOutlined />, label: '抢单大厅' },
{ key: '/worker/orders', icon: <ContainerOutlined />, label: '我的订单' },
{ key: '/worker/profile', icon: <UserOutlined />, label: '个人中心' },
]
export default function WorkerLayout() {
const navigate = useNavigate()
const location = useLocation()
const { message } = App.useApp()
const username = getWorkerUsername() || '接单员'
const status = getWorkerStatus()
async function submitLogout() {
try {
await logoutWorker()
} catch {
// 接单端退出为无状态操作,本地清理优先。
} finally {
clearWorkerSession()
message.success('已退出登录')
navigate('/worker/login', { replace: true })
}
}
return (
<Layout className="admin-shell worker-shell">
<Layout.Sider width={220} className="admin-sider" trigger={null}>
<div className="admin-sider-inner">
<div className="admin-brand">
<div className="admin-brand-copy">
<strong></strong>
<span></span>
</div>
</div>
<div className="admin-sidebar-profile">
<Typography.Text strong>{username}</Typography.Text>
<Typography.Text type="secondary">{formatWorkerStatus(status)}</Typography.Text>
</div>
<Menu
mode="inline"
inlineIndent={16}
selectedKeys={[resolveSelectedKey(location.pathname)]}
items={menuItems}
className="admin-menu"
onClick={({ key }) => navigate(String(key))}
/>
<div className="admin-sider-footer">
<Button block icon={<LogoutOutlined />} onClick={submitLogout}>
退
</Button>
</div>
</div>
</Layout.Sider>
<Layout className="admin-main-shell">
<Layout.Header className="admin-topbar">
<Space className="admin-user" size={12}>
<Typography.Text type="secondary">{formatWorkerStatus(status)}</Typography.Text>
</Space>
</Layout.Header>
<Layout.Content className="admin-content">
<Outlet />
</Layout.Content>
</Layout>
</Layout>
)
}
function resolveSelectedKey(pathname: string) {
if (pathname.startsWith('/worker/orders')) return '/worker/orders'
if (pathname.startsWith('/worker/profile')) return '/worker/profile'
return '/worker/hall'
}
function formatWorkerStatus(status: string) {
if (status === 'active') return '已审核'
if (status === 'pending_review') return '待审核'
if (status === 'rejected') return '审核未通过'
if (status === 'disabled') return '已停用'
return '未登录'
}
+35
View File
@@ -1,5 +1,6 @@
import axios from 'axios'
import { clearAdminSession, getAdminToken } from '@/utils/admin-auth'
import { clearWorkerSession, getWorkerToken } from '@/utils/worker-auth'
export interface ApiEnvelope<T> {
code: number
@@ -66,6 +67,22 @@ http.interceptors.response.use(
}
}
if (error?.config?.url && String(error.config.url).startsWith('/api/v1/worker')) {
const status = Number(error?.response?.status || 0)
const errorCode = String(error?.response?.data?.errorCode || '').trim()
if (status === 401 && shouldClearWorkerSession(errorCode)) {
clearWorkerSession()
if (
window.location.hash.startsWith('#/worker') &&
!window.location.hash.startsWith('#/worker/login')
) {
window.location.hash = '#/worker/login'
}
}
}
return Promise.reject(normalizedError)
},
)
@@ -79,6 +96,14 @@ http.interceptors.request.use((config) => {
}
}
if (String(config.url || '').startsWith('/api/v1/worker')) {
const token = getWorkerToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
}
return config
})
@@ -110,6 +135,16 @@ function shouldClearAdminSession(errorCode: string) {
].includes(errorCode)
}
function shouldClearWorkerSession(errorCode: string) {
return [
'worker_auth_required',
'worker_auth_invalid',
'worker_auth_expired',
'worker_auth_user_invalid',
'worker_auth_stale',
].includes(errorCode)
}
function request<T>(config: Parameters<typeof http.request<ApiEnvelope<T>>>[0]) {
return http.request<ApiEnvelope<T>, ApiEnvelope<T>>(config)
}
@@ -0,0 +1,513 @@
import {
CheckOutlined,
PlusOutlined,
ReloadOutlined,
SendOutlined,
WarningOutlined,
} from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Statistic,
Switch,
Table,
Tabs,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import PageHeader from '@/components/admin/PageHeader'
import {
acceptAdminWorkOrder,
createAdminMockWorkOrder,
creditAdminWorkerWallet,
fetchAdminWorkerLevels,
fetchAdminWorkerPlatformSummary,
fetchAdminWorkerUsers,
fetchAdminWorkOrders,
markAdminWorkOrderProblem,
publishAdminWorkOrder,
reviewAdminWorkerUser,
saveAdminWorkerLevel,
} from '@/services/admin'
import type { WorkOrder, WorkerLevel, WorkerUser } from '@/types/worker-platform'
export default function AdminWorkerPlatformPage() {
return (
<section className="page-stack">
<PageHeader title="接单平台" description="管理接单员、接单工单、等级权限和本地 Mock 工单。" />
<SummaryCards />
<Tabs
destroyOnHidden={false}
items={[
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
{ key: 'workers', label: '接单员', children: <WorkersPanel /> },
{ key: 'levels', label: '等级权限', children: <LevelsPanel /> },
]}
/>
</section>
)
}
function SummaryCards() {
const summaryQuery = useQuery({
queryKey: ['admin-worker-platform-summary'],
queryFn: () => fetchAdminWorkerPlatformSummary(),
})
const summary = summaryQuery.data?.data
return (
<div className="metric-grid four">
<Card>
<Statistic title="待审核接单员" value={summary?.pendingWorkerCount || 0} />
</Card>
<Card>
<Statistic title="待完善订单" value={summary?.pendingMaterialCount || 0} />
</Card>
<Card>
<Statistic title="大厅订单" value={summary?.openOrderCount || 0} />
</Card>
<Card>
<Statistic title="代练中" value={summary?.inProgressOrderCount || 0} />
</Card>
</div>
)
}
function WorkOrdersPanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [status, setStatus] = useState('')
const [problemOrder, setProblemOrder] = useState<WorkOrder | null>(null)
const [problemForm] = Form.useForm()
const ordersQuery = useQuery({
queryKey: ['admin-worker-platform-orders', status],
queryFn: () => fetchAdminWorkOrders({ status }),
})
async function refreshAll() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }),
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'] }),
])
}
async function runAction(action: () => Promise<unknown>, successMessage: string) {
try {
await action()
message.success(successMessage)
await refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '操作失败')
}
}
async function submitProblem(values: { note?: string }) {
if (!problemOrder) return
await runAction(
() => markAdminWorkOrderProblem(problemOrder.workOrderId, values.note || ''),
'已标记问题单',
)
setProblemOrder(null)
problemForm.resetFields()
}
const columns: TableColumnsType<WorkOrder> = [
{
title: '订单',
minWidth: 300,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong>{row.productName}</Typography.Text>
<Typography.Text type="secondary">
{row.platformOrderId} · {row.workOrderNo}
</Typography.Text>
</div>
),
},
{ title: '金额', width: 110, render: (_, row) => formatMoney(row.rewardAmount) },
{ title: '押金', width: 110, render: (_, row) => formatMoney(row.requiredDepositAmount) },
{
title: '状态',
width: 130,
render: (_, row) => <Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag>,
},
{
title: '接单员',
width: 140,
render: (_, row) => row.worker?.displayName || row.worker?.username || '-',
},
{
title: '操作',
width: 260,
render: (_, row) => (
<Space wrap>
{row.status === 'unassigned' ? (
<Button icon={<SendOutlined />} onClick={() => runAction(() => publishAdminWorkOrder(row.workOrderId), '已发布到大厅')}>
</Button>
) : null}
{row.status === 'pending_acceptance' ? (
<Button icon={<CheckOutlined />} type="primary" onClick={() => runAction(() => acceptAdminWorkOrder(row.workOrderId), '已验收通过')}>
</Button>
) : null}
{['in_progress', 'pending_acceptance'].includes(row.status) ? (
<Button icon={<WarningOutlined />} danger onClick={() => setProblemOrder(row)}>
</Button>
) : null}
</Space>
),
},
]
return (
<section className="platform-panel-stack">
<Card title="生成本地接单工单 Mock" bordered={false}>
<MockWorkOrderForm onCreated={refreshAll} />
</Card>
<Card
title="接单工单"
extra={
<Space>
<Select
value={status}
style={{ width: 150 }}
options={[
{ value: '', label: '全部状态' },
{ value: 'pending_material', label: '待完善' },
{ value: 'unassigned', label: '未分配' },
{ value: 'open', label: '待抢单' },
{ value: 'in_progress', label: '代练中' },
{ value: 'pending_acceptance', label: '待验收' },
{ value: 'problem', label: '问题单' },
{ value: 'accepted', label: '已验收' },
]}
onChange={setStatus}
/>
<Button icon={<ReloadOutlined />} loading={ordersQuery.isFetching} onClick={() => ordersQuery.refetch()}>
</Button>
</Space>
}
bordered={false}
>
<Table<WorkOrder>
rowKey="workOrderId"
loading={ordersQuery.isLoading}
dataSource={ordersQuery.data?.data.items || []}
columns={columns}
pagination={false}
scroll={{ x: 1100 }}
/>
</Card>
<Modal
title="标记问题单"
open={Boolean(problemOrder)}
onCancel={() => setProblemOrder(null)}
onOk={() => problemForm.submit()}
destroyOnHidden
>
<Form form={problemForm} layout="vertical" onFinish={submitProblem}>
<Form.Item label="问题备注" name="note" rules={[{ required: true, message: '请填写问题备注' }]}>
<Input.TextArea rows={4} />
</Form.Item>
</Form>
</Modal>
</section>
)
}
function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
const { message } = App.useApp()
const [loading, setLoading] = useState(false)
async function submit(values: {
platformOrderId?: string
productName?: string
rewardAmount?: number
materialComplete?: boolean
}) {
setLoading(true)
try {
await createAdminMockWorkOrder(values)
message.success('Mock 工单已生成')
await onCreated()
} catch (error) {
message.error(error instanceof Error ? error.message : '生成失败')
} finally {
setLoading(false)
}
}
return (
<Form
layout="inline"
initialValues={{ productName: '指挥官秘钥1个', rewardAmount: 25, materialComplete: false }}
onFinish={submit}
>
<Form.Item label="订单号" name="platformOrderId">
<Input placeholder="留空自动生成" style={{ width: 180 }} />
</Form.Item>
<Form.Item label="商品" name="productName">
<Input style={{ width: 220 }} />
</Form.Item>
<Form.Item label="接单金额" name="rewardAmount">
<InputNumber min={0.01} step={1} addonAfter="元" />
</Form.Item>
<Form.Item label="资料完整" name="materialComplete" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item>
<Button type="primary" icon={<PlusOutlined />} htmlType="submit" loading={loading}>
</Button>
</Form.Item>
</Form>
)
}
function WorkersPanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [status, setStatus] = useState('')
const [creditWorker, setCreditWorker] = useState<WorkerUser | null>(null)
const [creditForm] = Form.useForm()
const workersQuery = useQuery({
queryKey: ['admin-worker-platform-workers', status],
queryFn: () => fetchAdminWorkerUsers({ status }),
})
async function refreshWorkers() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-workers'] }),
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'] }),
])
}
async function review(worker: WorkerUser, nextStatus: string) {
try {
await reviewAdminWorkerUser(worker.workerId, { status: nextStatus })
message.success('接单员状态已更新')
await refreshWorkers()
} catch (error) {
message.error(error instanceof Error ? error.message : '操作失败')
}
}
async function submitCredit(values: { amount?: number; note?: string }) {
if (!creditWorker) return
try {
await creditAdminWorkerWallet(creditWorker.workerId, {
amount: Number(values.amount || 0),
note: values.note,
})
message.success('余额已增加')
setCreditWorker(null)
creditForm.resetFields()
await refreshWorkers()
} catch (error) {
message.error(error instanceof Error ? error.message : '充值失败')
}
}
const columns: TableColumnsType<WorkerUser> = [
{
title: '接单员',
minWidth: 220,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong>{row.displayName || row.username}</Typography.Text>
<Typography.Text type="secondary">{row.username}</Typography.Text>
</div>
),
},
{ title: '等级', width: 140, render: (_, row) => row.level?.name || '-' },
{ title: '余额', width: 120, render: (_, row) => formatMoney(row.wallet.availableAmount) },
{ title: '冻结', width: 120, render: (_, row) => formatMoney(row.wallet.frozenDepositAmount) },
{
title: '状态',
width: 130,
render: (_, row) => <Tag color={row.status === 'active' ? 'green' : 'orange'}>{formatWorkerStatus(row.status)}</Tag>,
},
{
title: '操作',
width: 260,
render: (_, row) => (
<Space wrap>
{row.status !== 'active' ? <Button onClick={() => review(row, 'active')}></Button> : null}
{row.status !== 'rejected' ? <Button onClick={() => review(row, 'rejected')}></Button> : null}
<Button onClick={() => setCreditWorker(row)}></Button>
</Space>
),
},
]
return (
<Card
title="接单员"
extra={
<Space>
<Select
value={status}
style={{ width: 150 }}
options={[
{ value: '', label: '全部状态' },
{ value: 'pending_review', label: '待审核' },
{ value: 'active', label: '已通过' },
{ value: 'rejected', label: '已拒绝' },
{ value: 'disabled', label: '已停用' },
]}
onChange={setStatus}
/>
<Button icon={<ReloadOutlined />} loading={workersQuery.isFetching} onClick={() => workersQuery.refetch()}>
</Button>
</Space>
}
bordered={false}
>
<Table<WorkerUser>
rowKey="workerId"
loading={workersQuery.isLoading}
dataSource={workersQuery.data?.data.items || []}
columns={columns}
pagination={false}
scroll={{ x: 1000 }}
/>
<Modal
title="人工增加余额"
open={Boolean(creditWorker)}
onCancel={() => setCreditWorker(null)}
onOk={() => creditForm.submit()}
destroyOnHidden
>
<Form form={creditForm} layout="vertical" onFinish={submitCredit}>
<Form.Item label="金额" name="amount" rules={[{ required: true, message: '请输入金额' }]}>
<InputNumber min={0.01} step={10} addonAfter="元" className="full-width" />
</Form.Item>
<Form.Item label="备注" name="note">
<Input />
</Form.Item>
</Form>
</Modal>
</Card>
)
}
function LevelsPanel() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const levelsQuery = useQuery({
queryKey: ['admin-worker-platform-levels'],
queryFn: () => fetchAdminWorkerLevels(),
})
const [form] = Form.useForm()
async function saveLevel(values: { levelKey: string; name: string; depositFreeAmount?: number; maxActiveOrders?: number }) {
try {
await saveAdminWorkerLevel(values)
message.success('等级已保存')
await queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-levels'] })
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败')
}
}
const columns: TableColumnsType<WorkerLevel> = [
{ title: '等级', dataIndex: 'name' },
{ title: '标识', dataIndex: 'levelKey' },
{ title: '免押额度', render: (_, row) => formatMoney(row.depositFreeAmount) },
{ title: '最大同时接单', dataIndex: 'maxActiveOrders' },
{ title: '状态', dataIndex: 'status' },
]
return (
<section className="platform-panel-stack">
<Card title="保存等级" bordered={false}>
<Form
form={form}
layout="inline"
initialValues={{ levelKey: 'regular', name: '普通接单员', depositFreeAmount: 0, maxActiveOrders: 3 }}
onFinish={saveLevel}
>
<Form.Item label="标识" name="levelKey" rules={[{ required: true, message: '请输入标识' }]}>
<Input />
</Form.Item>
<Form.Item label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
<Input />
</Form.Item>
<Form.Item label="免押额度" name="depositFreeAmount">
<InputNumber min={0} step={10} addonAfter="元" />
</Form.Item>
<Form.Item label="最大同时接单" name="maxActiveOrders">
<InputNumber min={1} max={999} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form.Item>
</Form>
</Card>
<Card title="等级列表" bordered={false}>
<Table<WorkerLevel>
rowKey="levelId"
loading={levelsQuery.isLoading}
dataSource={levelsQuery.data?.data.items || []}
columns={columns}
pagination={false}
/>
</Card>
</section>
)
}
function formatMoney(value: number | undefined) {
return `${((Number(value || 0) || 0) / 100).toFixed(2)}`
}
function formatStatus(status: string) {
const labels: Record<string, string> = {
pending_material: '待完善',
unassigned: '未分配',
open: '待抢单',
in_progress: '代练中',
pending_acceptance: '待验收',
problem: '问题单',
accepted: '已验收',
cancelled: '已取消',
}
return labels[status] || status
}
function resolveStatusColor(status: string) {
if (status === 'accepted') return 'green'
if (status === 'problem') return 'red'
if (status === 'pending_acceptance') return 'gold'
if (status === 'open') return 'blue'
return 'default'
}
function formatWorkerStatus(status: string) {
const labels: Record<string, string> = {
pending_review: '待审核',
active: '已通过',
rejected: '已拒绝',
disabled: '已停用',
}
return labels[status] || status
}
@@ -0,0 +1,106 @@
import { SearchOutlined } from '@ant-design/icons'
import { App, Button, Card, Form, Input, Result, Space, Typography } from 'antd'
import { useState } from 'react'
import { lookupCollectOrder, submitCollectOrder } from '@/services/worker'
import type { CollectField } from '@/types/worker-platform'
type LookupOrder = {
workOrderNo: string
platformOrderId: string
productName: string
status: string
}
export default function CollectPage() {
const { message } = App.useApp()
const [orderNo, setOrderNo] = useState('')
const [order, setOrder] = useState<LookupOrder | null>(null)
const [fields, setFields] = useState<CollectField[]>([])
const [lookupLoading, setLookupLoading] = useState(false)
const [submitLoading, setSubmitLoading] = useState(false)
const [complete, setComplete] = useState(false)
async function submitLookup(values: { orderNo: string }) {
setLookupLoading(true)
setComplete(false)
try {
const response = await lookupCollectOrder(values.orderNo)
setOrderNo(values.orderNo)
setOrder(response.data.order)
setFields(response.data.fields)
} catch (error) {
setOrder(null)
setFields([])
message.error(error instanceof Error ? error.message : '查询失败')
} finally {
setLookupLoading(false)
}
}
async function submitMaterial(values: Record<string, string>) {
setSubmitLoading(true)
try {
const response = await submitCollectOrder({ orderNo, fields: values })
setComplete(response.data.complete)
message.success(response.data.complete ? '资料已完善' : '资料已提交')
} catch (error) {
message.error(error instanceof Error ? error.message : '提交失败')
} finally {
setSubmitLoading(false)
}
}
return (
<main className="collect-page">
<Card className="collect-card" bordered={false}>
<Typography.Title level={3}></Typography.Title>
<Typography.Paragraph type="secondary">
</Typography.Paragraph>
<Form layout="vertical" onFinish={submitLookup}>
<Space.Compact className="full-width">
<Form.Item
name="orderNo"
className="collect-order-input"
rules={[{ required: true, message: '请输入订单号' }]}
>
<Input placeholder="订单号" />
</Form.Item>
<Button type="primary" htmlType="submit" icon={<SearchOutlined />} loading={lookupLoading}>
</Button>
</Space.Compact>
</Form>
{complete ? (
<Result status="success" title="资料已提交完成" subTitle="订单已进入待分配状态,请等待处理。" />
) : null}
{order && !complete ? (
<Card className="platform-section-gap" title={order.productName} size="small">
<Typography.Paragraph type="secondary">
{order.platformOrderId}
</Typography.Paragraph>
<Form layout="vertical" onFinish={submitMaterial}>
{fields.map((field) => (
<Form.Item
key={field.key}
label={field.label}
name={field.key}
rules={field.required ? [{ required: true, message: `请填写${field.label}` }] : []}
>
<Input />
</Form.Item>
))}
<Button type="primary" htmlType="submit" loading={submitLoading}>
</Button>
</Form>
</Card>
) : null}
</Card>
</main>
)
}
@@ -0,0 +1,102 @@
import { ReloadOutlined, SearchOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { App, Button, Card, Empty, Input, Space, Spin, Tag, Typography } from 'antd'
import { useState } from 'react'
import { fetchWorkerHallOrders, grabWorkerOrder } from '@/services/worker'
import type { WorkOrder } from '@/types/worker-platform'
export default function WorkerHallPage() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [keyword, setKeyword] = useState('')
const [grabbingId, setGrabbingId] = useState(0)
const ordersQuery = useQuery({
queryKey: ['worker-hall-orders', keyword],
queryFn: () => fetchWorkerHallOrders({ keyword }),
retry: false,
})
const orders = ordersQuery.data?.data.items || []
async function submitGrab(order: WorkOrder) {
setGrabbingId(order.workOrderId)
try {
await grabWorkerOrder(order.workOrderId)
message.success('抢单成功')
await queryClient.invalidateQueries({ queryKey: ['worker-hall-orders'] })
} catch (error) {
message.error(error instanceof Error ? error.message : '抢单失败')
} finally {
setGrabbingId(0)
}
}
return (
<section className="page-stack">
<div className="worker-page-head">
<Typography.Title level={3}></Typography.Title>
<Space.Compact>
<Input
allowClear
value={keyword}
placeholder="搜索商品或订单号"
prefix={<SearchOutlined />}
onChange={(event) => setKeyword(event.target.value)}
onPressEnter={() => void ordersQuery.refetch()}
/>
<Button icon={<ReloadOutlined />} loading={ordersQuery.isFetching} onClick={() => ordersQuery.refetch()}>
</Button>
</Space.Compact>
</div>
{ordersQuery.isLoading ? <Spin /> : null}
{ordersQuery.error ? (
<Card>
<Typography.Text type="danger">
{ordersQuery.error instanceof Error ? ordersQuery.error.message : '读取抢单大厅失败'}
</Typography.Text>
</Card>
) : null}
{!ordersQuery.isLoading && orders.length === 0 ? (
<Empty description="暂无可抢订单" />
) : (
<div className="worker-order-grid">
{orders.map((order) => (
<Card key={order.workOrderId} className="worker-order-card" bordered={false}>
<Space direction="vertical" className="full-width" size={8}>
<Space className="worker-order-card-head">
<Typography.Text strong ellipsis>
{order.productName}
</Typography.Text>
<Tag color="blue">{order.categoryName || '默认分类'}</Tag>
</Space>
<Typography.Text type="secondary">{order.platformOrderId}</Typography.Text>
<Typography.Title level={4} className="worker-order-money">
{formatMoney(order.rewardAmount)}
</Typography.Title>
<Typography.Text type="secondary">
{formatMoney(order.freezeDepositAmount ?? order.requiredDepositAmount)}
</Typography.Text>
<Button
type="primary"
loading={grabbingId === order.workOrderId}
onClick={() => submitGrab(order)}
>
</Button>
</Space>
</Card>
))}
</div>
)}
</section>
)
}
function formatMoney(value: number | undefined) {
return `${((Number(value || 0) || 0) / 100).toFixed(2)}`
}
@@ -0,0 +1,119 @@
import { LockOutlined, UserOutlined } from '@ant-design/icons'
import { App, Button, Card, Form, Input, Tabs, Typography } from 'antd'
import { useState } from 'react'
import { Navigate, useNavigate } from 'react-router'
import { loginWorker, registerWorker } from '@/services/worker'
import { hasWorkerSession, setWorkerSession } from '@/utils/worker-auth'
export default function WorkerLoginPage() {
const navigate = useNavigate()
const { message } = App.useApp()
const [loading, setLoading] = useState(false)
if (hasWorkerSession()) {
return <Navigate to="/worker/hall" replace />
}
async function submitLogin(values: { username: string; password: string }) {
setLoading(true)
try {
const response = await loginWorker(values)
setWorkerSession(response.data.token, response.data.expiresAt, response.data.worker)
message.success('登录成功')
navigate('/worker/hall', { replace: true })
} catch (error) {
message.error(error instanceof Error ? error.message : '登录失败')
} finally {
setLoading(false)
}
}
async function submitRegister(values: {
username: string
password: string
displayName?: string
phone?: string
}) {
setLoading(true)
try {
await registerWorker(values)
message.success('注册成功,等待后台审核')
} catch (error) {
message.error(error instanceof Error ? error.message : '注册失败')
} finally {
setLoading(false)
}
}
return (
<main className="worker-auth-page">
<Card className="worker-auth-card" bordered={false}>
<Typography.Title level={3}></Typography.Title>
<Typography.Paragraph type="secondary">
</Typography.Paragraph>
<Tabs
items={[
{
key: 'login',
label: '登录',
children: (
<Form layout="vertical" onFinish={submitLogin}>
<Form.Item
label="账号"
name="username"
rules={[{ required: true, message: '请输入账号' }]}
>
<Input prefix={<UserOutlined />} autoComplete="username" />
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password prefix={<LockOutlined />} autoComplete="current-password" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
</Button>
</Form>
),
},
{
key: 'register',
label: '注册',
children: (
<Form layout="vertical" onFinish={submitRegister}>
<Form.Item
label="账号"
name="username"
rules={[{ required: true, message: '请输入账号' }]}
>
<Input prefix={<UserOutlined />} autoComplete="username" />
</Form.Item>
<Form.Item label="昵称" name="displayName">
<Input />
</Form.Item>
<Form.Item label="手机号" name="phone">
<Input />
</Form.Item>
<Form.Item
label="密码"
name="password"
rules={[{ required: true, min: 6, message: '密码至少 6 位' }]}
>
<Input.Password prefix={<LockOutlined />} autoComplete="new-password" />
</Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block>
</Button>
</Form>
),
},
]}
/>
</Card>
</main>
)
}
@@ -0,0 +1,164 @@
import { CheckOutlined, ReloadOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Button,
Form,
Input,
Modal,
Select,
Space,
Table,
Tag,
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import { fetchWorkerMyOrders, submitWorkerAcceptance } from '@/services/worker'
import type { WorkOrder } from '@/types/worker-platform'
const STATUS_OPTIONS = [
{ value: '', label: '全部' },
{ value: 'in_progress', label: '代练中' },
{ value: 'pending_acceptance', label: '待验收' },
{ value: 'problem', label: '问题单' },
{ value: 'accepted', label: '已验收' },
]
export default function WorkerOrdersPage() {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [status, setStatus] = useState('')
const [submittingOrder, setSubmittingOrder] = useState<WorkOrder | null>(null)
const [form] = Form.useForm()
const ordersQuery = useQuery({
queryKey: ['worker-my-orders', status],
queryFn: () => fetchWorkerMyOrders({ status }),
retry: false,
})
async function submitAcceptance(values: { note?: string; imageUrls?: string }) {
if (!submittingOrder) return
try {
await submitWorkerAcceptance(submittingOrder.workOrderId, {
note: values.note,
imageUrls: String(values.imageUrls || '')
.split('\n')
.map((item) => item.trim())
.filter(Boolean),
})
message.success('已提交验收')
setSubmittingOrder(null)
form.resetFields()
await queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] })
} catch (error) {
message.error(error instanceof Error ? error.message : '提交失败')
}
}
const columns: TableColumnsType<WorkOrder> = [
{
title: '订单',
minWidth: 280,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong>{row.productName}</Typography.Text>
<Typography.Text type="secondary">{row.platformOrderId}</Typography.Text>
</div>
),
},
{
title: '金额',
width: 130,
render: (_, row) => <Typography.Text strong>{formatMoney(row.rewardAmount)}</Typography.Text>,
},
{
title: '状态',
width: 140,
render: (_, row) => <Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag>,
},
{
title: '问题备注',
dataIndex: 'problemNote',
minWidth: 180,
render: (value) => String(value || '-'),
},
{
title: '操作',
width: 140,
render: (_, row) =>
['in_progress', 'problem'].includes(row.status) ? (
<Button icon={<CheckOutlined />} onClick={() => setSubmittingOrder(row)}>
</Button>
) : null,
},
]
return (
<section className="page-stack">
<div className="worker-page-head">
<Typography.Title level={3}></Typography.Title>
<Space>
<Select value={status} options={STATUS_OPTIONS} style={{ width: 140 }} onChange={setStatus} />
<Button icon={<ReloadOutlined />} loading={ordersQuery.isFetching} onClick={() => ordersQuery.refetch()}>
</Button>
</Space>
</div>
<Table<WorkOrder>
rowKey="workOrderId"
loading={ordersQuery.isLoading}
dataSource={ordersQuery.data?.data.items || []}
columns={columns}
pagination={false}
scroll={{ x: 900 }}
/>
<Modal
title="提交验收"
open={Boolean(submittingOrder)}
onCancel={() => setSubmittingOrder(null)}
onOk={() => form.submit()}
destroyOnHidden
>
<Form form={form} layout="vertical" onFinish={submitAcceptance}>
<Form.Item label="完成说明" name="note">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item
label="图片链接"
name="imageUrls"
extra="一期先支持粘贴图片链接,每行一个;后续再接真实上传。"
>
<Input.TextArea rows={4} placeholder="https://..." />
</Form.Item>
</Form>
</Modal>
</section>
)
}
function formatMoney(value: number | undefined) {
return `${((Number(value || 0) || 0) / 100).toFixed(2)}`
}
function formatStatus(status: string) {
const labels: Record<string, string> = {
in_progress: '代练中',
pending_acceptance: '待验收',
problem: '问题单',
accepted: '已验收',
}
return labels[status] || status
}
function resolveStatusColor(status: string) {
if (status === 'accepted') return 'green'
if (status === 'problem') return 'red'
if (status === 'pending_acceptance') return 'gold'
return 'blue'
}
@@ -0,0 +1,68 @@
import { ReloadOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import { Button, Card, Descriptions, Empty, Space, Statistic, Typography } from 'antd'
import { fetchWorkerProfile } from '@/services/worker'
export default function WorkerProfilePage() {
const profileQuery = useQuery({
queryKey: ['worker-profile'],
queryFn: () => fetchWorkerProfile(),
retry: false,
})
const worker = profileQuery.data?.data.worker
return (
<section className="page-stack">
<div className="worker-page-head">
<Typography.Title level={3}></Typography.Title>
<Button icon={<ReloadOutlined />} loading={profileQuery.isFetching} onClick={() => profileQuery.refetch()}>
</Button>
</div>
{!worker && !profileQuery.isLoading ? <Empty description="暂无接单员信息" /> : null}
{worker ? (
<>
<Card className="worker-profile-card" bordered={false}>
<Space size={32} wrap>
<Statistic title="账户余额" value={formatMoney(worker.wallet.availableAmount)} />
<Statistic title="冻结押金" value={formatMoney(worker.wallet.frozenDepositAmount)} />
<Statistic title="当前等级" value={worker.level?.name || '-'} />
<Statistic title="审核状态" value={formatStatus(worker.status)} />
</Space>
</Card>
<Card title="账号信息" bordered={false}>
<Descriptions column={2}>
<Descriptions.Item label="账号">{worker.username}</Descriptions.Item>
<Descriptions.Item label="昵称">{worker.displayName}</Descriptions.Item>
<Descriptions.Item label="手机号">{worker.phone || '-'}</Descriptions.Item>
<Descriptions.Item label="免押额度">
{formatMoney(worker.level?.permissions.depositFreeAmount || 0)}
</Descriptions.Item>
<Descriptions.Item label="最大同时接单">
{worker.level?.permissions.maxActiveOrders || 0}
</Descriptions.Item>
<Descriptions.Item label="审核备注">{worker.reviewNote || '-'}</Descriptions.Item>
</Descriptions>
</Card>
</>
) : null}
</section>
)
}
function formatMoney(value: number | undefined) {
return `${((Number(value || 0) || 0) / 100).toFixed(2)}`
}
function formatStatus(status: string) {
if (status === 'active') return '已审核'
if (status === 'pending_review') return '待审核'
if (status === 'rejected') return '审核未通过'
if (status === 'disabled') return '已停用'
return status || '-'
}
@@ -7,3 +7,4 @@ export * from './kuaishou-industry'
export * from './orders'
export * from './tasks'
export * from './dev-mock'
export * from './worker-platform'
@@ -0,0 +1,75 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
WorkOrder,
WorkerLevel,
WorkerListResponse,
WorkerUser,
} from '@/types/worker-platform'
export function fetchAdminWorkerPlatformSummary() {
return apiGet<{
pendingWorkerCount: number
pendingMaterialCount: number
openOrderCount: number
inProgressOrderCount: number
}>('/api/v1/admin/worker-platform/summary')
}
export function fetchAdminWorkerLevels() {
return apiGet<{ items: WorkerLevel[] }>('/api/v1/admin/worker-platform/levels')
}
export function saveAdminWorkerLevel(payload: {
levelKey: string
name: string
depositFreeAmount?: number
maxActiveOrders?: number
}) {
return apiPost<{ level: WorkerLevel }>('/api/v1/admin/worker-platform/levels', payload)
}
export function fetchAdminWorkerUsers(params?: Record<string, unknown>) {
return apiGet<WorkerListResponse<WorkerUser>>('/api/v1/admin/worker-platform/workers', params)
}
export function reviewAdminWorkerUser(
workerId: number,
payload: { status: string; reviewNote?: string; levelId?: number },
) {
return apiPost<{ worker: WorkerUser }>(`/api/v1/admin/worker-platform/workers/${workerId}/review`, payload)
}
export function creditAdminWorkerWallet(
workerId: number,
payload: { amount: number; note?: string },
) {
return apiPost<{ wallet: Record<string, unknown> }>(
`/api/v1/admin/worker-platform/workers/${workerId}/wallet-credit`,
payload,
)
}
export function fetchAdminWorkOrders(params?: Record<string, unknown>) {
return apiGet<WorkerListResponse<WorkOrder>>('/api/v1/admin/worker-platform/orders', params)
}
export function createAdminMockWorkOrder(payload: {
platformOrderId?: string
productName?: string
rewardAmount?: number
materialComplete?: boolean
}) {
return apiPost<{ order: WorkOrder }>('/api/v1/admin/worker-platform/orders/mock', payload)
}
export function publishAdminWorkOrder(workOrderId: number) {
return apiPost<{ order: WorkOrder }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/publish`)
}
export function markAdminWorkOrderProblem(workOrderId: number, note: string) {
return apiPost<{ order: WorkOrder }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/problem`, { note })
}
export function acceptAdminWorkOrder(workOrderId: number) {
return apiPost<{ order: WorkOrder }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/accept`)
}
+66
View File
@@ -0,0 +1,66 @@
import { apiGet, apiPost } from '@/lib/http'
import type {
CollectField,
WorkOrder,
WorkerListResponse,
WorkerLoginResponse,
WorkerUser,
} from '@/types/worker-platform'
export function registerWorker(payload: {
username: string
password: string
displayName?: string
phone?: string
}) {
return apiPost<{ worker: WorkerUser; reviewRequired: boolean }>('/api/v1/worker/auth/register', payload)
}
export function loginWorker(payload: { username: string; password: string }) {
return apiPost<WorkerLoginResponse>('/api/v1/worker/auth/login', payload)
}
export function fetchWorkerSession() {
return apiGet<{ authenticated: boolean; expiresAt: string; worker: WorkerUser }>('/api/v1/worker/auth/session')
}
export function logoutWorker() {
return apiPost<{ success: boolean }>('/api/v1/worker/auth/logout')
}
export function fetchWorkerProfile() {
return apiGet<{ worker: WorkerUser; permissions: Record<string, unknown> }>('/api/v1/worker/profile')
}
export function fetchWorkerHallOrders(params?: Record<string, unknown>) {
return apiGet<WorkerListResponse<WorkOrder>>('/api/v1/worker/hall/orders', params)
}
export function grabWorkerOrder(workOrderId: number) {
return apiPost<{ order: WorkOrder }>(`/api/v1/worker/hall/orders/${workOrderId}/grab`)
}
export function fetchWorkerMyOrders(params?: Record<string, unknown>) {
return apiGet<WorkerListResponse<WorkOrder>>('/api/v1/worker/orders', params)
}
export function submitWorkerAcceptance(
workOrderId: number,
payload: { note?: string; imageUrls?: string[] },
) {
return apiPost<{ order: WorkOrder }>(`/api/v1/worker/orders/${workOrderId}/submit-acceptance`, payload)
}
export function lookupCollectOrder(orderNo: string) {
return apiPost<{
order: Pick<WorkOrder, 'workOrderNo' | 'platformOrderId' | 'productName' | 'status'>
fields: CollectField[]
}>('/api/v1/collect/lookup', { orderNo })
}
export function submitCollectOrder(payload: {
orderNo: string
fields: Record<string, string>
}) {
return apiPost<{ complete: boolean; order: Record<string, unknown> }>('/api/v1/collect/submit', payload)
}
+70
View File
@@ -1622,3 +1622,73 @@ select {
font-size: 12px;
line-height: 1.5;
}
.worker-auth-page,
.collect-page {
min-height: 100vh;
display: grid;
place-items: center;
padding: 32px 16px;
background: #f5f7fb;
}
.worker-auth-card,
.collect-card {
width: min(520px, 100%);
}
.worker-page-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.worker-page-head h3 {
margin: 0;
}
.worker-order-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.worker-order-card .ant-card-body {
height: 100%;
}
.worker-order-card-head {
width: 100%;
justify-content: space-between;
}
.worker-order-money {
margin: 4px 0 0;
color: #f97316;
}
.worker-profile-card {
background: #c8b172;
}
.worker-profile-card .ant-statistic-title,
.worker-profile-card .ant-statistic-content {
color: #fff;
}
.collect-order-input {
flex: 1;
margin-bottom: 0;
}
@media (max-width: 720px) {
.worker-page-head {
align-items: stretch;
flex-direction: column;
}
.worker-order-grid {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,98 @@
export type WorkerUser = {
workerId: number
username: string
displayName: string
phone: string
status: string
reviewNote: string
level: null | {
levelId: number
levelKey: string
name: string
permissions: {
depositFreeAmount: number
maxActiveOrders: number
}
}
wallet: {
availableAmount: number
frozenDepositAmount: number
}
createdAt: string
updatedAt: string
reviewedAt: string | null
}
export type WorkerLevel = {
levelId: number
levelKey: string
name: string
sortOrder: number
status: string
depositFreeAmount: number
maxActiveOrders: number
createdAt: string
updatedAt: string
}
export type WorkOrderStatus =
| 'pending_material'
| 'unassigned'
| 'open'
| 'in_progress'
| 'pending_acceptance'
| 'problem'
| 'accepted'
| 'cancelled'
| string
export type WorkOrder = {
workOrderId: number
workOrderNo: string
platformOrderId: string
productName: string
categoryName: string
status: WorkOrderStatus
rewardAmount: number
requiredDepositAmount: number
depositThresholdAmount: number
freezeDepositAmount?: number
material: Record<string, unknown>
requirement: Record<string, unknown>
acceptance: Record<string, unknown>
problemNote: string
worker: null | {
workerId: number
username: string
displayName: string
}
publishedAt: string | null
assignedAt: string | null
submittedAt: string | null
acceptedAt: string | null
createdAt: string
updatedAt: string
}
export type Pagination = {
page: number
pageSize: number
total: number
}
export type WorkerLoginResponse = {
token: string
expiresAt: string
worker: WorkerUser
}
export type WorkerListResponse<T> = {
items: T[]
pagination: Pagination
}
export type CollectField = {
key: string
label: string
required: boolean
}
+70
View File
@@ -0,0 +1,70 @@
const WORKER_TOKEN_KEY = 'order-site-worker-token'
const WORKER_EXPIRES_AT_KEY = 'order-site-worker-token-expires-at'
const WORKER_ID_KEY = 'order-site-worker-id'
const WORKER_USERNAME_KEY = 'order-site-worker-username'
const WORKER_STATUS_KEY = 'order-site-worker-status'
export function getWorkerToken() {
return localStorage.getItem(WORKER_TOKEN_KEY) || ''
}
export function getWorkerTokenExpiresAt() {
return localStorage.getItem(WORKER_EXPIRES_AT_KEY) || ''
}
export function getWorkerId() {
const value = Number(localStorage.getItem(WORKER_ID_KEY) || 0)
return Number.isFinite(value) && value > 0 ? value : 0
}
export function getWorkerUsername() {
return localStorage.getItem(WORKER_USERNAME_KEY) || ''
}
export function getWorkerStatus() {
return localStorage.getItem(WORKER_STATUS_KEY) || ''
}
export function setWorkerSession(
token: string,
expiresAt: string,
worker?: { workerId: number; username: string; status: string },
) {
localStorage.setItem(WORKER_TOKEN_KEY, token)
localStorage.setItem(WORKER_EXPIRES_AT_KEY, expiresAt)
if (worker?.workerId) {
localStorage.setItem(WORKER_ID_KEY, String(worker.workerId))
}
if (worker?.username) {
localStorage.setItem(WORKER_USERNAME_KEY, worker.username)
}
if (worker?.status) {
localStorage.setItem(WORKER_STATUS_KEY, worker.status)
}
}
export function clearWorkerSession() {
localStorage.removeItem(WORKER_TOKEN_KEY)
localStorage.removeItem(WORKER_EXPIRES_AT_KEY)
localStorage.removeItem(WORKER_ID_KEY)
localStorage.removeItem(WORKER_USERNAME_KEY)
localStorage.removeItem(WORKER_STATUS_KEY)
}
export function hasWorkerSession() {
const token = getWorkerToken()
const expiresAt = getWorkerTokenExpiresAt()
if (!token) return false
if (isExpired(expiresAt)) {
clearWorkerSession()
return false
}
return true
}
function isExpired(expiresAt: string) {
if (!expiresAt) return false
const expiresAtMs = Date.parse(expiresAt)
return Number.isFinite(expiresAtMs) && expiresAtMs <= Date.now()
}