新增接单平台第一期闭环

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
@@ -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 || '-'
}