完善接单平台上传与物品规则
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
import { Image, Typography } from 'antd'
|
||||
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
|
||||
type ImagePreviewListProps = {
|
||||
files?: UploadedFile[]
|
||||
imageUrls?: string[]
|
||||
size?: number
|
||||
}
|
||||
|
||||
export default function ImagePreviewList({
|
||||
files = [],
|
||||
imageUrls = [],
|
||||
size = 56,
|
||||
}: ImagePreviewListProps) {
|
||||
const items = normalizePreviewItems(files, imageUrls)
|
||||
if (items.length === 0) {
|
||||
return <Typography.Text type="secondary">-</Typography.Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<Image.PreviewGroup>
|
||||
<div className="image-preview-list">
|
||||
{items.map((item) => (
|
||||
<Image
|
||||
key={item.url}
|
||||
src={item.thumbnailUrl || item.url}
|
||||
preview={{ src: item.mediumUrl || item.url }}
|
||||
width={size}
|
||||
height={size}
|
||||
className="image-preview-item"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePreviewItems(files: UploadedFile[], imageUrls: string[]) {
|
||||
const normalizedFiles = files
|
||||
.map((file) => ({
|
||||
url: file.url || file.mediumUrl || file.thumbnailUrl,
|
||||
thumbnailUrl: file.thumbnailUrl || file.url,
|
||||
mediumUrl: file.mediumUrl || file.url,
|
||||
}))
|
||||
.filter((item) => Boolean(item.url))
|
||||
const fileUrlSet = new Set(normalizedFiles.map((item) => item.url))
|
||||
const normalizedUrls = imageUrls
|
||||
.map((url) => String(url || '').trim())
|
||||
.filter((url) => url && !fileUrlSet.has(url))
|
||||
.map((url) => ({ url, thumbnailUrl: url, mediumUrl: url }))
|
||||
return [...normalizedFiles, ...normalizedUrls]
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { PlusOutlined } from '@ant-design/icons'
|
||||
import { App, Upload } from 'antd'
|
||||
import type { UploadFile, UploadProps } from 'antd'
|
||||
|
||||
import { uploadFile } from '@/services/files'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
|
||||
type ImageUploadProps = {
|
||||
value?: UploadedFile[]
|
||||
onChange?: (files: UploadedFile[]) => void
|
||||
scene: string
|
||||
scope: 'admin' | 'worker'
|
||||
maxCount?: number
|
||||
}
|
||||
|
||||
export default function ImageUpload({
|
||||
value = [],
|
||||
onChange,
|
||||
scene,
|
||||
scope,
|
||||
maxCount = 6,
|
||||
}: ImageUploadProps) {
|
||||
const { message } = App.useApp()
|
||||
const fileList: UploadFile[] = value.map((file) => ({
|
||||
uid: file.objectKey || file.url,
|
||||
name: file.filename || '图片',
|
||||
status: 'done',
|
||||
url: file.thumbnailUrl || file.url,
|
||||
thumbUrl: file.thumbnailUrl || file.url,
|
||||
}))
|
||||
|
||||
const customRequest: UploadProps['customRequest'] = async (options) => {
|
||||
try {
|
||||
const uploaded = await uploadFile(options.file as File, scene, scope)
|
||||
onChange?.([...value, uploaded].slice(-maxCount))
|
||||
options.onSuccess?.(uploaded)
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '上传失败')
|
||||
options.onError?.(error instanceof Error ? error : new Error('上传失败'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Upload
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
listType="picture-card"
|
||||
fileList={fileList}
|
||||
maxCount={maxCount}
|
||||
customRequest={customRequest}
|
||||
onRemove={(file) => {
|
||||
onChange?.(
|
||||
value.filter((item) => (item.objectKey || item.url) !== file.uid),
|
||||
)
|
||||
return true
|
||||
}}
|
||||
showUploadList={{ showPreviewIcon: false }}
|
||||
>
|
||||
{value.length >= maxCount ? null : (
|
||||
<button className="image-upload-trigger" type="button">
|
||||
<PlusOutlined />
|
||||
<span>上传</span>
|
||||
</button>
|
||||
)}
|
||||
</Upload>
|
||||
)
|
||||
}
|
||||
@@ -36,9 +36,13 @@ http.interceptors.response.use(
|
||||
},
|
||||
(error) => {
|
||||
const responseMessage =
|
||||
typeof error?.response?.data?.msg === 'string' ? error.response.data.msg.trim() : ''
|
||||
typeof error?.response?.data?.msg === 'string'
|
||||
? error.response.data.msg.trim()
|
||||
: ''
|
||||
const fallbackMessage = resolveFallbackHttpMessage(error)
|
||||
const normalizedError = new Error(responseMessage || fallbackMessage) as Error & {
|
||||
const normalizedError = new Error(
|
||||
responseMessage || fallbackMessage,
|
||||
) as Error & {
|
||||
errorCode?: string
|
||||
status?: number
|
||||
}
|
||||
@@ -51,7 +55,10 @@ http.interceptors.response.use(
|
||||
normalizedError.status = error.response.status
|
||||
}
|
||||
|
||||
if (error?.config?.url && String(error.config.url).startsWith('/api/v1/admin')) {
|
||||
if (
|
||||
error?.config?.url &&
|
||||
String(error.config.url).startsWith('/api/v1/admin')
|
||||
) {
|
||||
const status = Number(error?.response?.status || 0)
|
||||
const errorCode = String(error?.response?.data?.errorCode || '').trim()
|
||||
|
||||
@@ -67,7 +74,10 @@ http.interceptors.response.use(
|
||||
}
|
||||
}
|
||||
|
||||
if (error?.config?.url && String(error.config.url).startsWith('/api/v1/worker')) {
|
||||
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()
|
||||
|
||||
@@ -118,8 +128,13 @@ function resolveFallbackHttpMessage(error: unknown) {
|
||||
return '网络连接错误'
|
||||
}
|
||||
|
||||
if (typeof (error as { response?: { statusText?: string } })?.response?.statusText === 'string') {
|
||||
return String((error as { response: { statusText: string } }).response.statusText).trim()
|
||||
if (
|
||||
typeof (error as { response?: { statusText?: string } })?.response
|
||||
?.statusText === 'string'
|
||||
) {
|
||||
return String(
|
||||
(error as { response: { statusText: string } }).response.statusText,
|
||||
).trim()
|
||||
}
|
||||
|
||||
return '接口请求失败'
|
||||
@@ -145,7 +160,9 @@ function shouldClearWorkerSession(errorCode: string) {
|
||||
].includes(errorCode)
|
||||
}
|
||||
|
||||
function request<T>(config: Parameters<typeof http.request<ApiEnvelope<T>>>[0]) {
|
||||
function request<T>(
|
||||
config: Parameters<typeof http.request<ApiEnvelope<T>>>[0],
|
||||
) {
|
||||
return http.request<ApiEnvelope<T>, ApiEnvelope<T>>(config)
|
||||
}
|
||||
|
||||
@@ -163,7 +180,20 @@ export function apiGetBlob(url: string, params?: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
export function apiPost<T>(url: string, data?: unknown) {
|
||||
return request<T>({ method: 'POST', url, data: data as Record<string, unknown> })
|
||||
return request<T>({
|
||||
method: 'POST',
|
||||
url,
|
||||
data: data as Record<string, unknown>,
|
||||
})
|
||||
}
|
||||
|
||||
export function apiPostForm<T>(url: string, data: FormData) {
|
||||
return request<T>({
|
||||
method: 'POST',
|
||||
url,
|
||||
data,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
export function apiDelete<T>(url: string) {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
CheckOutlined,
|
||||
FormOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SendOutlined,
|
||||
SyncOutlined,
|
||||
WarningOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
@@ -26,11 +28,14 @@ import {
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||
import PageHeader from '@/components/admin/PageHeader'
|
||||
import {
|
||||
acceptAdminWorkOrder,
|
||||
createAdminMockWorkOrder,
|
||||
creditAdminWorkerWallet,
|
||||
fetchAdminWorkCategories,
|
||||
fetchAdminWorkProductRules,
|
||||
fetchAdminWorkerLevels,
|
||||
fetchAdminWorkerPlatformSummary,
|
||||
fetchAdminWorkerUsers,
|
||||
@@ -38,19 +43,36 @@ import {
|
||||
markAdminWorkOrderProblem,
|
||||
publishAdminWorkOrder,
|
||||
reviewAdminWorkerUser,
|
||||
saveAdminWorkCategory,
|
||||
saveAdminWorkProductRule,
|
||||
saveAdminWorkerLevel,
|
||||
submitAdminWorkOrderMaterial,
|
||||
syncAdminWorkerOrdersFromSource,
|
||||
} from '@/services/admin'
|
||||
import type { WorkOrder, WorkerLevel, WorkerUser } from '@/types/worker-platform'
|
||||
import type {
|
||||
CollectField,
|
||||
UploadedFile,
|
||||
WorkCategory,
|
||||
WorkOrder,
|
||||
WorkProductRule,
|
||||
WorkerLevel,
|
||||
WorkerUser,
|
||||
} from '@/types/worker-platform'
|
||||
|
||||
export default function AdminWorkerPlatformPage() {
|
||||
return (
|
||||
<section className="page-stack">
|
||||
<PageHeader title="接单平台" description="管理接单员、接单工单、等级权限和本地 Mock 工单。" />
|
||||
<PageHeader
|
||||
title="接单平台"
|
||||
description="管理接单员、接单工单、等级权限和本地 Mock 工单。"
|
||||
/>
|
||||
<SummaryCards />
|
||||
<Tabs
|
||||
destroyOnHidden={false}
|
||||
items={[
|
||||
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
|
||||
{ key: 'rules', label: '物品规则', children: <ProductRulesPanel /> },
|
||||
{ key: 'categories', label: '分类', children: <CategoriesPanel /> },
|
||||
{ key: 'workers', label: '接单员', children: <WorkersPanel /> },
|
||||
{ key: 'levels', label: '等级权限', children: <LevelsPanel /> },
|
||||
]}
|
||||
@@ -69,10 +91,16 @@ function SummaryCards() {
|
||||
return (
|
||||
<div className="metric-grid four">
|
||||
<Card>
|
||||
<Statistic title="待审核接单员" value={summary?.pendingWorkerCount || 0} />
|
||||
<Statistic
|
||||
title="待审核接单员"
|
||||
value={summary?.pendingWorkerCount || 0}
|
||||
/>
|
||||
</Card>
|
||||
<Card>
|
||||
<Statistic title="待完善订单" value={summary?.pendingMaterialCount || 0} />
|
||||
<Statistic
|
||||
title="待完善订单"
|
||||
value={summary?.pendingMaterialCount || 0}
|
||||
/>
|
||||
</Card>
|
||||
<Card>
|
||||
<Statistic title="大厅订单" value={summary?.openOrderCount || 0} />
|
||||
@@ -89,7 +117,9 @@ function WorkOrdersPanel() {
|
||||
const queryClient = useQueryClient()
|
||||
const [status, setStatus] = useState('')
|
||||
const [problemOrder, setProblemOrder] = useState<WorkOrder | null>(null)
|
||||
const [materialOrder, setMaterialOrder] = useState<WorkOrder | null>(null)
|
||||
const [problemForm] = Form.useForm()
|
||||
const [materialForm] = Form.useForm()
|
||||
|
||||
const ordersQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-orders', status],
|
||||
@@ -98,12 +128,19 @@ function WorkOrdersPanel() {
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-orders'],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-summary'],
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
async function runAction(action: () => Promise<unknown>, successMessage: string) {
|
||||
async function runAction(
|
||||
action: () => Promise<unknown>,
|
||||
successMessage: string,
|
||||
) {
|
||||
try {
|
||||
await action()
|
||||
message.success(successMessage)
|
||||
@@ -116,13 +153,47 @@ function WorkOrdersPanel() {
|
||||
async function submitProblem(values: { note?: string }) {
|
||||
if (!problemOrder) return
|
||||
await runAction(
|
||||
() => markAdminWorkOrderProblem(problemOrder.workOrderId, values.note || ''),
|
||||
() =>
|
||||
markAdminWorkOrderProblem(problemOrder.workOrderId, values.note || ''),
|
||||
'已标记问题单',
|
||||
)
|
||||
setProblemOrder(null)
|
||||
problemForm.resetFields()
|
||||
}
|
||||
|
||||
async function submitMaterial(values: { fields?: Record<string, string> }) {
|
||||
if (!materialOrder) return
|
||||
await runAction(
|
||||
() =>
|
||||
submitAdminWorkOrderMaterial(
|
||||
materialOrder.workOrderId,
|
||||
values.fields || {},
|
||||
),
|
||||
'订单资料已保存',
|
||||
)
|
||||
setMaterialOrder(null)
|
||||
materialForm.resetFields()
|
||||
}
|
||||
|
||||
async function submitSourceSync(values: { platformOrderId?: string }) {
|
||||
try {
|
||||
const result = await syncAdminWorkerOrdersFromSource({
|
||||
platformOrderId: String(values.platformOrderId || '').trim(),
|
||||
})
|
||||
message.success(
|
||||
`同步完成,新增 ${result.data.createdCount} 个,跳过 ${result.data.skippedCount} 个`,
|
||||
)
|
||||
await refreshAll()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '同步失败')
|
||||
}
|
||||
}
|
||||
|
||||
function openMaterialModal(row: WorkOrder) {
|
||||
setMaterialOrder(row)
|
||||
materialForm.setFieldsValue({ fields: getMaterialFields(row) })
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<WorkOrder> = [
|
||||
{
|
||||
title: '订单',
|
||||
@@ -136,35 +207,93 @@ function WorkOrdersPanel() {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: '金额', width: 110, render: (_, row) => formatMoney(row.rewardAmount) },
|
||||
{ title: '押金', width: 110, render: (_, row) => formatMoney(row.requiredDepositAmount) },
|
||||
{
|
||||
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>,
|
||||
render: (_, row) => (
|
||||
<Tag color={resolveStatusColor(row.status)}>
|
||||
{formatStatus(row.status)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '接单员',
|
||||
width: 140,
|
||||
render: (_, row) => row.worker?.displayName || row.worker?.username || '-',
|
||||
render: (_, row) =>
|
||||
row.worker?.displayName || row.worker?.username || '-',
|
||||
},
|
||||
{
|
||||
title: '资料',
|
||||
width: 160,
|
||||
render: (_, row) => renderMaterialSummary(row),
|
||||
},
|
||||
{
|
||||
title: '验收图',
|
||||
width: 170,
|
||||
render: (_, row) => (
|
||||
<ImagePreviewList
|
||||
files={getAcceptanceFiles(row)}
|
||||
imageUrls={getAcceptanceImageUrls(row)}
|
||||
size={44}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 260,
|
||||
width: 340,
|
||||
render: (_, row) => (
|
||||
<Space wrap>
|
||||
{row.status === 'pending_material' ? (
|
||||
<Button
|
||||
icon={<FormOutlined />}
|
||||
onClick={() => openMaterialModal(row)}
|
||||
>
|
||||
补资料
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status === 'unassigned' ? (
|
||||
<Button icon={<SendOutlined />} onClick={() => runAction(() => publishAdminWorkOrder(row.workOrderId), '已发布到大厅')}>
|
||||
<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
|
||||
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
|
||||
icon={<WarningOutlined />}
|
||||
danger
|
||||
onClick={() => setProblemOrder(row)}
|
||||
>
|
||||
问题单
|
||||
</Button>
|
||||
) : null}
|
||||
@@ -175,6 +304,25 @@ function WorkOrdersPanel() {
|
||||
|
||||
return (
|
||||
<section className="platform-panel-stack">
|
||||
<Card title="从源订单同步" bordered={false}>
|
||||
<Form layout="inline" onFinish={submitSourceSync}>
|
||||
<Form.Item
|
||||
label="平台订单号"
|
||||
name="platformOrderId"
|
||||
rules={[{ required: true, message: '请输入平台订单号' }]}
|
||||
>
|
||||
<Input
|
||||
placeholder="用户填写链接 A 的订单号"
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<SyncOutlined />} htmlType="submit">
|
||||
同步
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card title="生成本地接单工单 Mock" bordered={false}>
|
||||
<MockWorkOrderForm onCreated={refreshAll} />
|
||||
</Card>
|
||||
@@ -197,7 +345,11 @@ function WorkOrdersPanel() {
|
||||
]}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} loading={ordersQuery.isFetching} onClick={() => ordersQuery.refetch()}>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={ordersQuery.isFetching}
|
||||
onClick={() => ordersQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -222,11 +374,40 @@ function WorkOrdersPanel() {
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={problemForm} layout="vertical" onFinish={submitProblem}>
|
||||
<Form.Item label="问题备注" name="note" rules={[{ required: true, message: '请填写问题备注' }]}>
|
||||
<Form.Item
|
||||
label="问题备注"
|
||||
name="note"
|
||||
rules={[{ required: true, message: '请填写问题备注' }]}
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title="人工补充资料"
|
||||
open={Boolean(materialOrder)}
|
||||
onCancel={() => setMaterialOrder(null)}
|
||||
onOk={() => materialForm.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={materialForm} layout="vertical" onFinish={submitMaterial}>
|
||||
{getRequirementFields(materialOrder).map((field) => (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
name={['fields', field.key]}
|
||||
rules={
|
||||
field.required
|
||||
? [{ required: true, message: `请填写${field.label}` }]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
))}
|
||||
</Form>
|
||||
</Modal>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -256,7 +437,11 @@ function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
|
||||
return (
|
||||
<Form
|
||||
layout="inline"
|
||||
initialValues={{ productName: '指挥官秘钥1个', rewardAmount: 25, materialComplete: false }}
|
||||
initialValues={{
|
||||
productName: '指挥官秘钥1个',
|
||||
rewardAmount: 25,
|
||||
materialComplete: false,
|
||||
}}
|
||||
onFinish={submit}
|
||||
>
|
||||
<Form.Item label="订单号" name="platformOrderId">
|
||||
@@ -268,11 +453,20 @@ function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
|
||||
<Form.Item label="接单金额" name="rewardAmount">
|
||||
<InputNumber min={0.01} step={1} addonAfter="元" />
|
||||
</Form.Item>
|
||||
<Form.Item label="资料完整" name="materialComplete" valuePropName="checked">
|
||||
<Form.Item
|
||||
label="资料完整"
|
||||
name="materialComplete"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" icon={<PlusOutlined />} htmlType="submit" loading={loading}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
htmlType="submit"
|
||||
loading={loading}
|
||||
>
|
||||
生成
|
||||
</Button>
|
||||
</Form.Item>
|
||||
@@ -280,6 +474,332 @@ function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
|
||||
)
|
||||
}
|
||||
|
||||
function ProductRulesPanel() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const rulesQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-product-rules'],
|
||||
queryFn: () => fetchAdminWorkProductRules(),
|
||||
})
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-categories'],
|
||||
queryFn: () => fetchAdminWorkCategories(),
|
||||
})
|
||||
|
||||
async function saveRule(values: {
|
||||
ruleKey: string
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId?: string
|
||||
skuCode?: string
|
||||
productName?: string
|
||||
matchType?: string
|
||||
categoryId?: number
|
||||
rewardAmount?: number
|
||||
requiredDepositAmount?: number
|
||||
depositThresholdAmount?: number
|
||||
fieldsText?: string
|
||||
enabled?: boolean
|
||||
autoCreate?: boolean
|
||||
}) {
|
||||
try {
|
||||
await saveAdminWorkProductRule(values)
|
||||
message.success('物品规则已保存')
|
||||
form.resetFields()
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-product-rules'],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-summary'],
|
||||
}),
|
||||
])
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<WorkProductRule> = [
|
||||
{
|
||||
title: '规则',
|
||||
minWidth: 260,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong>
|
||||
{row.productName || row.skuCode}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary">{row.ruleKey}</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'SKU',
|
||||
dataIndex: 'skuCode',
|
||||
width: 180,
|
||||
render: (value) => String(value || '-'),
|
||||
},
|
||||
{ title: '分类', width: 140, render: (_, row) => row.categoryName || '-' },
|
||||
{
|
||||
title: '接单金额',
|
||||
width: 120,
|
||||
render: (_, row) => formatMoney(row.rewardAmount),
|
||||
},
|
||||
{
|
||||
title: '押金',
|
||||
width: 120,
|
||||
render: (_, row) => formatMoney(row.requiredDepositAmount),
|
||||
},
|
||||
{
|
||||
title: '匹配',
|
||||
width: 130,
|
||||
render: (_, row) => (row.matchType === 'exact' ? '精确' : '包含'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 150,
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Tag color={row.enabled ? 'green' : 'default'}>
|
||||
{row.enabled ? '启用' : '停用'}
|
||||
</Tag>
|
||||
{row.autoCreate ? <Tag color="blue">自动创建</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="platform-panel-stack">
|
||||
<Card title="保存物品规则" bordered={false}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
className="worker-rule-form"
|
||||
initialValues={{
|
||||
platform: 'kuaishou',
|
||||
matchType: 'contains',
|
||||
rewardAmount: 25,
|
||||
depositThresholdAmount: 200,
|
||||
enabled: true,
|
||||
autoCreate: false,
|
||||
fieldsText:
|
||||
'gameAccount:游戏账号\nserverName:区服\nroleName:角色名',
|
||||
}}
|
||||
onFinish={saveRule}
|
||||
>
|
||||
<div className="worker-rule-grid">
|
||||
<Form.Item
|
||||
label="规则标识"
|
||||
name="ruleKey"
|
||||
rules={[{ required: true, message: '请输入规则标识' }]}
|
||||
>
|
||||
<Input placeholder="skin-training" />
|
||||
</Form.Item>
|
||||
<Form.Item label="来源" name="provider">
|
||||
<Input placeholder="留空通配,如 91kaquan" />
|
||||
</Form.Item>
|
||||
<Form.Item label="平台" name="platform">
|
||||
<Input placeholder="kuaishou" />
|
||||
</Form.Item>
|
||||
<Form.Item label="店铺" name="shopId">
|
||||
<Input placeholder="留空通配" />
|
||||
</Form.Item>
|
||||
<Form.Item label="SKU" name="skuCode">
|
||||
<Input placeholder="优先精确匹配" />
|
||||
</Form.Item>
|
||||
<Form.Item label="商品名" name="productName">
|
||||
<Input placeholder="用于包含/精确匹配" />
|
||||
</Form.Item>
|
||||
<Form.Item label="匹配方式" name="matchType">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'contains', label: '包含' },
|
||||
{ value: 'exact', label: '精确' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="分类" name="categoryId">
|
||||
<Select
|
||||
allowClear
|
||||
loading={categoriesQuery.isLoading}
|
||||
options={(categoriesQuery.data?.data.items || []).map(
|
||||
(item) => ({
|
||||
value: item.categoryId,
|
||||
label: item.name,
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="接单金额" name="rewardAmount">
|
||||
<InputNumber
|
||||
min={0.01}
|
||||
step={1}
|
||||
addonAfter="元"
|
||||
className="full-width"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="押金阈值" name="depositThresholdAmount">
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={10}
|
||||
addonAfter="元"
|
||||
className="full-width"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="固定押金" name="requiredDepositAmount">
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={10}
|
||||
addonAfter="元"
|
||||
className="full-width"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="排序" name="sortOrder">
|
||||
<InputNumber min={0} max={9999} className="full-width" />
|
||||
</Form.Item>
|
||||
<Form.Item label="启用" name="enabled" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="自动创建"
|
||||
name="autoCreate"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item label="资料字段" name="fieldsText">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存规则
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card
|
||||
title="物品规则"
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={rulesQuery.isFetching}
|
||||
onClick={() => rulesQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
bordered={false}
|
||||
>
|
||||
<Table<WorkProductRule>
|
||||
rowKey="ruleId"
|
||||
loading={rulesQuery.isLoading}
|
||||
dataSource={rulesQuery.data?.data.items || []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function CategoriesPanel() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ['admin-worker-platform-categories'],
|
||||
queryFn: () => fetchAdminWorkCategories(),
|
||||
})
|
||||
|
||||
async function saveCategory(values: {
|
||||
categoryKey: string
|
||||
name: string
|
||||
sortOrder?: number
|
||||
status?: string
|
||||
}) {
|
||||
try {
|
||||
await saveAdminWorkCategory(values)
|
||||
message.success('分类已保存')
|
||||
form.resetFields()
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-categories'],
|
||||
})
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<WorkCategory> = [
|
||||
{ title: '分类', dataIndex: 'name' },
|
||||
{ title: '标识', dataIndex: 'categoryKey' },
|
||||
{ title: '排序', dataIndex: 'sortOrder', width: 100 },
|
||||
{
|
||||
title: '状态',
|
||||
width: 120,
|
||||
render: (_, row) => (
|
||||
<Tag color={row.status === 'active' ? 'green' : 'default'}>
|
||||
{row.status === 'active' ? '启用' : '停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<section className="platform-panel-stack">
|
||||
<Card title="保存分类" bordered={false}>
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
initialValues={{ status: 'active', sortOrder: 100 }}
|
||||
onFinish={saveCategory}
|
||||
>
|
||||
<Form.Item
|
||||
label="标识"
|
||||
name="categoryKey"
|
||||
rules={[{ required: true, message: '请输入标识' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="排序" name="sortOrder">
|
||||
<InputNumber min={0} max={9999} />
|
||||
</Form.Item>
|
||||
<Form.Item label="状态" name="status">
|
||||
<Select
|
||||
style={{ width: 110 }}
|
||||
options={[
|
||||
{ value: 'active', label: '启用' },
|
||||
{ value: 'disabled', label: '停用' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
<Card title="分类列表" bordered={false}>
|
||||
<Table<WorkCategory>
|
||||
rowKey="categoryId"
|
||||
loading={categoriesQuery.isLoading}
|
||||
dataSource={categoriesQuery.data?.data.items || []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkersPanel() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -294,8 +814,12 @@ function WorkersPanel() {
|
||||
|
||||
async function refreshWorkers() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-workers'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-workers'],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-summary'],
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -331,26 +855,44 @@ function WorkersPanel() {
|
||||
minWidth: 220,
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong>{row.displayName || row.username}</Typography.Text>
|
||||
<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: 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>,
|
||||
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}
|
||||
{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>
|
||||
),
|
||||
@@ -374,7 +916,11 @@ function WorkersPanel() {
|
||||
]}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} loading={workersQuery.isFetching} onClick={() => workersQuery.refetch()}>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={workersQuery.isFetching}
|
||||
onClick={() => workersQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -398,8 +944,17 @@ function WorkersPanel() {
|
||||
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
|
||||
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 />
|
||||
@@ -419,11 +974,18 @@ function LevelsPanel() {
|
||||
})
|
||||
const [form] = Form.useForm()
|
||||
|
||||
async function saveLevel(values: { levelKey: string; name: string; depositFreeAmount?: number; maxActiveOrders?: number }) {
|
||||
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'] })
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['admin-worker-platform-levels'],
|
||||
})
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
}
|
||||
@@ -432,7 +994,10 @@ function LevelsPanel() {
|
||||
const columns: TableColumnsType<WorkerLevel> = [
|
||||
{ title: '等级', dataIndex: 'name' },
|
||||
{ title: '标识', dataIndex: 'levelKey' },
|
||||
{ title: '免押额度', render: (_, row) => formatMoney(row.depositFreeAmount) },
|
||||
{
|
||||
title: '免押额度',
|
||||
render: (_, row) => formatMoney(row.depositFreeAmount),
|
||||
},
|
||||
{ title: '最大同时接单', dataIndex: 'maxActiveOrders' },
|
||||
{ title: '状态', dataIndex: 'status' },
|
||||
]
|
||||
@@ -443,13 +1008,26 @@ function LevelsPanel() {
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
initialValues={{ levelKey: 'regular', name: '普通接单员', depositFreeAmount: 0, maxActiveOrders: 3 }}
|
||||
initialValues={{
|
||||
levelKey: 'regular',
|
||||
name: '普通接单员',
|
||||
depositFreeAmount: 0,
|
||||
maxActiveOrders: 3,
|
||||
}}
|
||||
onFinish={saveLevel}
|
||||
>
|
||||
<Form.Item label="标识" name="levelKey" rules={[{ required: true, message: '请输入标识' }]}>
|
||||
<Form.Item
|
||||
label="标识"
|
||||
name="levelKey"
|
||||
rules={[{ required: true, message: '请输入标识' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Form.Item
|
||||
label="名称"
|
||||
name="name"
|
||||
rules={[{ required: true, message: '请输入名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="免押额度" name="depositFreeAmount">
|
||||
@@ -459,7 +1037,9 @@ function LevelsPanel() {
|
||||
<InputNumber min={1} max={999} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">保存</Button>
|
||||
<Button type="primary" htmlType="submit">
|
||||
保存
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
@@ -476,6 +1056,77 @@ function LevelsPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
function getRequirementFields(order: WorkOrder | null): CollectField[] {
|
||||
if (!order) return []
|
||||
const rawFields = Array.isArray(order.requirement?.fields)
|
||||
? order.requirement.fields
|
||||
: []
|
||||
return rawFields
|
||||
.map((item) => {
|
||||
const source = asRecord(item)
|
||||
const key = String(source.key || '').trim()
|
||||
if (!key) return null
|
||||
return {
|
||||
key,
|
||||
label: String(source.label || key).trim(),
|
||||
required: source.required !== false,
|
||||
}
|
||||
})
|
||||
.filter((item): item is CollectField => Boolean(item))
|
||||
}
|
||||
|
||||
function getMaterialFields(order: WorkOrder): Record<string, string> {
|
||||
const material = asRecord(order.material)
|
||||
const collect = asRecord(material.collect)
|
||||
const fields = asRecord(collect.fields || material.fields)
|
||||
return Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [key, String(value || '')]),
|
||||
)
|
||||
}
|
||||
|
||||
function renderMaterialSummary(order: WorkOrder) {
|
||||
const fields = getMaterialFields(order)
|
||||
const entries = Object.entries(fields).filter(([, value]) =>
|
||||
String(value || '').trim(),
|
||||
)
|
||||
if (entries.length === 0) {
|
||||
return <Typography.Text type="secondary">-</Typography.Text>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cell-stack">
|
||||
{entries.slice(0, 2).map(([key, value]) => (
|
||||
<Typography.Text key={key} ellipsis>
|
||||
{key}: {value}
|
||||
</Typography.Text>
|
||||
))}
|
||||
{entries.length > 2 ? (
|
||||
<Typography.Text type="secondary">
|
||||
还有 {entries.length - 2} 项
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getAcceptanceFiles(row: WorkOrder): UploadedFile[] {
|
||||
return Array.isArray(row.acceptance?.files) ? row.acceptance.files : []
|
||||
}
|
||||
|
||||
function getAcceptanceImageUrls(row: WorkOrder): string[] {
|
||||
return Array.isArray(row.acceptance?.imageUrls)
|
||||
? row.acceptance.imageUrls
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {}
|
||||
}
|
||||
|
||||
function formatMoney(value: number | undefined) {
|
||||
return `¥${((Number(value || 0) || 0) / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
import type { TableColumnsType } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import { fetchWorkerMyOrders, submitWorkerAcceptance } from '@/services/worker'
|
||||
import type { WorkOrder } from '@/types/worker-platform'
|
||||
import type { UploadedFile, WorkOrder } from '@/types/worker-platform'
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ value: '', label: '全部' },
|
||||
@@ -31,6 +33,7 @@ export default function WorkerOrdersPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [status, setStatus] = useState('')
|
||||
const [submittingOrder, setSubmittingOrder] = useState<WorkOrder | null>(null)
|
||||
const [acceptanceFiles, setAcceptanceFiles] = useState<UploadedFile[]>([])
|
||||
const [form] = Form.useForm()
|
||||
|
||||
const ordersQuery = useQuery({
|
||||
@@ -39,18 +42,20 @@ export default function WorkerOrdersPage() {
|
||||
retry: false,
|
||||
})
|
||||
|
||||
async function submitAcceptance(values: { note?: string; imageUrls?: string }) {
|
||||
async function submitAcceptance(values: { note?: string }) {
|
||||
if (!submittingOrder) return
|
||||
if (acceptanceFiles.length === 0) {
|
||||
message.error('请上传验收图片')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await submitWorkerAcceptance(submittingOrder.workOrderId, {
|
||||
note: values.note,
|
||||
imageUrls: String(values.imageUrls || '')
|
||||
.split('\n')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
files: acceptanceFiles,
|
||||
})
|
||||
message.success('已提交验收')
|
||||
setSubmittingOrder(null)
|
||||
setAcceptanceFiles([])
|
||||
form.resetFields()
|
||||
await queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] })
|
||||
} catch (error) {
|
||||
@@ -65,19 +70,40 @@ export default function WorkerOrdersPage() {
|
||||
render: (_, row) => (
|
||||
<div className="cell-stack">
|
||||
<Typography.Text strong>{row.productName}</Typography.Text>
|
||||
<Typography.Text type="secondary">{row.platformOrderId}</Typography.Text>
|
||||
<Typography.Text type="secondary">
|
||||
{row.platformOrderId}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
width: 130,
|
||||
render: (_, row) => <Typography.Text strong>{formatMoney(row.rewardAmount)}</Typography.Text>,
|
||||
render: (_, row) => (
|
||||
<Typography.Text strong>
|
||||
{formatMoney(row.rewardAmount)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 140,
|
||||
render: (_, row) => <Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag>,
|
||||
render: (_, row) => (
|
||||
<Tag color={resolveStatusColor(row.status)}>
|
||||
{formatStatus(row.status)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '验收图片',
|
||||
width: 170,
|
||||
render: (_, row) => (
|
||||
<ImagePreviewList
|
||||
files={getAcceptanceFiles(row)}
|
||||
imageUrls={getAcceptanceImageUrls(row)}
|
||||
size={46}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '问题备注',
|
||||
@@ -90,7 +116,13 @@ export default function WorkerOrdersPage() {
|
||||
width: 140,
|
||||
render: (_, row) =>
|
||||
['in_progress', 'problem'].includes(row.status) ? (
|
||||
<Button icon={<CheckOutlined />} onClick={() => setSubmittingOrder(row)}>
|
||||
<Button
|
||||
icon={<CheckOutlined />}
|
||||
onClick={() => {
|
||||
setSubmittingOrder(row)
|
||||
setAcceptanceFiles([])
|
||||
}}
|
||||
>
|
||||
提交验收
|
||||
</Button>
|
||||
) : null,
|
||||
@@ -102,8 +134,17 @@ export default function WorkerOrdersPage() {
|
||||
<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()}>
|
||||
<Select
|
||||
value={status}
|
||||
options={STATUS_OPTIONS}
|
||||
style={{ width: 140 }}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={ordersQuery.isFetching}
|
||||
onClick={() => ordersQuery.refetch()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
</Space>
|
||||
@@ -121,7 +162,10 @@ export default function WorkerOrdersPage() {
|
||||
<Modal
|
||||
title="提交验收"
|
||||
open={Boolean(submittingOrder)}
|
||||
onCancel={() => setSubmittingOrder(null)}
|
||||
onCancel={() => {
|
||||
setSubmittingOrder(null)
|
||||
setAcceptanceFiles([])
|
||||
}}
|
||||
onOk={() => form.submit()}
|
||||
destroyOnHidden
|
||||
>
|
||||
@@ -129,12 +173,14 @@ export default function WorkerOrdersPage() {
|
||||
<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 label="验收图片" required>
|
||||
<ImageUpload
|
||||
scene="worker-acceptance"
|
||||
scope="worker"
|
||||
value={acceptanceFiles}
|
||||
onChange={setAcceptanceFiles}
|
||||
maxCount={6}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
@@ -142,6 +188,18 @@ export default function WorkerOrdersPage() {
|
||||
)
|
||||
}
|
||||
|
||||
function getAcceptanceFiles(row: WorkOrder): UploadedFile[] {
|
||||
return Array.isArray(row.acceptance?.files) ? row.acceptance.files : []
|
||||
}
|
||||
|
||||
function getAcceptanceImageUrls(row: WorkOrder): string[] {
|
||||
return Array.isArray(row.acceptance?.imageUrls)
|
||||
? row.acceptance.imageUrls
|
||||
.map((item) => String(item || '').trim())
|
||||
.filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
function formatMoney(value: number | undefined) {
|
||||
return `¥${((Number(value || 0) || 0) / 100).toFixed(2)}`
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
WorkCategory,
|
||||
WorkOrder,
|
||||
WorkProductRule,
|
||||
WorkerLevel,
|
||||
WorkerListResponse,
|
||||
WorkerUser,
|
||||
@@ -16,7 +18,27 @@ export function fetchAdminWorkerPlatformSummary() {
|
||||
}
|
||||
|
||||
export function fetchAdminWorkerLevels() {
|
||||
return apiGet<{ items: WorkerLevel[] }>('/api/v1/admin/worker-platform/levels')
|
||||
return apiGet<{ items: WorkerLevel[] }>(
|
||||
'/api/v1/admin/worker-platform/levels',
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminWorkCategories() {
|
||||
return apiGet<{ items: WorkCategory[] }>(
|
||||
'/api/v1/admin/worker-platform/categories',
|
||||
)
|
||||
}
|
||||
|
||||
export function saveAdminWorkCategory(payload: {
|
||||
categoryKey: string
|
||||
name: string
|
||||
sortOrder?: number
|
||||
status?: string
|
||||
}) {
|
||||
return apiPost<{ category: WorkCategory }>(
|
||||
'/api/v1/admin/worker-platform/categories',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function saveAdminWorkerLevel(payload: {
|
||||
@@ -25,18 +47,57 @@ export function saveAdminWorkerLevel(payload: {
|
||||
depositFreeAmount?: number
|
||||
maxActiveOrders?: number
|
||||
}) {
|
||||
return apiPost<{ level: WorkerLevel }>('/api/v1/admin/worker-platform/levels', payload)
|
||||
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)
|
||||
return apiGet<WorkerListResponse<WorkerUser>>(
|
||||
'/api/v1/admin/worker-platform/workers',
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchAdminWorkProductRules(params?: Record<string, unknown>) {
|
||||
return apiGet<{ items: WorkProductRule[] }>(
|
||||
'/api/v1/admin/worker-platform/product-rules',
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
export function saveAdminWorkProductRule(payload: {
|
||||
ruleKey: string
|
||||
provider?: string
|
||||
platform?: string
|
||||
shopId?: string
|
||||
skuCode?: string
|
||||
productName?: string
|
||||
matchType?: string
|
||||
categoryId?: number
|
||||
enabled?: boolean
|
||||
autoCreate?: boolean
|
||||
rewardAmount?: number
|
||||
requiredDepositAmount?: number
|
||||
depositThresholdAmount?: number
|
||||
fieldsText?: string
|
||||
sortOrder?: number
|
||||
}) {
|
||||
return apiPost<{ rule: WorkProductRule }>(
|
||||
'/api/v1/admin/worker-platform/product-rules',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
return apiPost<{ worker: WorkerUser }>(
|
||||
`/api/v1/admin/worker-platform/workers/${workerId}/review`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function creditAdminWorkerWallet(
|
||||
@@ -50,7 +111,10 @@ export function creditAdminWorkerWallet(
|
||||
}
|
||||
|
||||
export function fetchAdminWorkOrders(params?: Record<string, unknown>) {
|
||||
return apiGet<WorkerListResponse<WorkOrder>>('/api/v1/admin/worker-platform/orders', params)
|
||||
return apiGet<WorkerListResponse<WorkOrder>>(
|
||||
'/api/v1/admin/worker-platform/orders',
|
||||
params,
|
||||
)
|
||||
}
|
||||
|
||||
export function createAdminMockWorkOrder(payload: {
|
||||
@@ -59,17 +123,49 @@ export function createAdminMockWorkOrder(payload: {
|
||||
rewardAmount?: number
|
||||
materialComplete?: boolean
|
||||
}) {
|
||||
return apiPost<{ order: WorkOrder }>('/api/v1/admin/worker-platform/orders/mock', payload)
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
'/api/v1/admin/worker-platform/orders/mock',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function syncAdminWorkerOrdersFromSource(payload: {
|
||||
platformOrderId?: string
|
||||
orderId?: number
|
||||
}) {
|
||||
return apiPost<{
|
||||
created: WorkOrder[]
|
||||
skipped: Array<{ orderItemId: number; reason: string }>
|
||||
createdCount: number
|
||||
skippedCount: number
|
||||
}>('/api/v1/admin/worker-platform/source-orders/sync', payload)
|
||||
}
|
||||
|
||||
export function submitAdminWorkOrderMaterial(
|
||||
workOrderId: number,
|
||||
fields: Record<string, string>,
|
||||
) {
|
||||
return apiPost<{ order: WorkOrder; complete: boolean }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/material`,
|
||||
{ fields },
|
||||
)
|
||||
}
|
||||
|
||||
export function publishAdminWorkOrder(workOrderId: number) {
|
||||
return apiPost<{ order: WorkOrder }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/publish`)
|
||||
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 })
|
||||
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`)
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/admin/worker-platform/orders/${workOrderId}/accept`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { apiPostForm } from '@/lib/http'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { optimizeImageForUpload } from '@/utils/image-upload'
|
||||
|
||||
type UploadScope = 'admin' | 'worker'
|
||||
|
||||
export async function uploadFile(
|
||||
file: File,
|
||||
scene: string,
|
||||
scope: UploadScope,
|
||||
) {
|
||||
const uploadTarget = await optimizeImageForUpload(file, scene)
|
||||
const form = new FormData()
|
||||
form.append('file', uploadTarget)
|
||||
form.append('scene', scene)
|
||||
const endpoint =
|
||||
scope === 'admin'
|
||||
? '/api/v1/admin/files/upload'
|
||||
: '/api/v1/worker/files/upload'
|
||||
const response = await apiPostForm<{ file: UploadedFile }>(endpoint, form)
|
||||
return response.data.file
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { apiGet, apiPost } from '@/lib/http'
|
||||
import type {
|
||||
CollectField,
|
||||
UploadedFile,
|
||||
WorkOrder,
|
||||
WorkerListResponse,
|
||||
WorkerLoginResponse,
|
||||
@@ -13,7 +14,10 @@ export function registerWorker(payload: {
|
||||
displayName?: string
|
||||
phone?: string
|
||||
}) {
|
||||
return apiPost<{ worker: WorkerUser; reviewRequired: boolean }>('/api/v1/worker/auth/register', payload)
|
||||
return apiPost<{ worker: WorkerUser; reviewRequired: boolean }>(
|
||||
'/api/v1/worker/auth/register',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function loginWorker(payload: { username: string; password: string }) {
|
||||
@@ -21,7 +25,11 @@ export function loginWorker(payload: { username: string; password: string }) {
|
||||
}
|
||||
|
||||
export function fetchWorkerSession() {
|
||||
return apiGet<{ authenticated: boolean; expiresAt: string; worker: WorkerUser }>('/api/v1/worker/auth/session')
|
||||
return apiGet<{
|
||||
authenticated: boolean
|
||||
expiresAt: string
|
||||
worker: WorkerUser
|
||||
}>('/api/v1/worker/auth/session')
|
||||
}
|
||||
|
||||
export function logoutWorker() {
|
||||
@@ -29,15 +37,22 @@ export function logoutWorker() {
|
||||
}
|
||||
|
||||
export function fetchWorkerProfile() {
|
||||
return apiGet<{ worker: WorkerUser; permissions: Record<string, unknown> }>('/api/v1/worker/profile')
|
||||
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)
|
||||
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`)
|
||||
return apiPost<{ order: WorkOrder }>(
|
||||
`/api/v1/worker/hall/orders/${workOrderId}/grab`,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchWorkerMyOrders(params?: Record<string, unknown>) {
|
||||
@@ -46,14 +61,20 @@ export function fetchWorkerMyOrders(params?: Record<string, unknown>) {
|
||||
|
||||
export function submitWorkerAcceptance(
|
||||
workOrderId: number,
|
||||
payload: { note?: string; imageUrls?: string[] },
|
||||
payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[] },
|
||||
) {
|
||||
return apiPost<{ order: WorkOrder }>(`/api/v1/worker/orders/${workOrderId}/submit-acceptance`, payload)
|
||||
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'>
|
||||
order: Pick<
|
||||
WorkOrder,
|
||||
'workOrderNo' | 'platformOrderId' | 'productName' | 'status'
|
||||
>
|
||||
fields: CollectField[]
|
||||
}>('/api/v1/collect/lookup', { orderNo })
|
||||
}
|
||||
@@ -62,5 +83,8 @@ export function submitCollectOrder(payload: {
|
||||
orderNo: string
|
||||
fields: Record<string, string>
|
||||
}) {
|
||||
return apiPost<{ complete: boolean; order: Record<string, unknown> }>('/api/v1/collect/submit', payload)
|
||||
return apiPost<{ complete: boolean; order: Record<string, unknown> }>(
|
||||
'/api/v1/collect/submit',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -657,7 +657,8 @@ select {
|
||||
|
||||
.payload-text {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
||||
monospace;
|
||||
font-size: 12px;
|
||||
color: #4b5563;
|
||||
word-break: break-all;
|
||||
@@ -1272,7 +1273,9 @@ select {
|
||||
}
|
||||
|
||||
.platform-shop-row {
|
||||
grid-template-columns: auto auto minmax(120px, 0.9fr) minmax(140px, 1fr) minmax(220px, 1.5fr) auto auto;
|
||||
grid-template-columns:
|
||||
auto auto minmax(120px, 0.9fr) minmax(140px, 1fr)
|
||||
minmax(220px, 1.5fr) auto auto;
|
||||
}
|
||||
|
||||
.platform-rule-head {
|
||||
@@ -1337,7 +1340,9 @@ select {
|
||||
|
||||
.cloud-records-filter {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 0.28fr) minmax(220px, 0.36fr) minmax(360px, 1fr) auto;
|
||||
grid-template-columns:
|
||||
minmax(180px, 0.28fr) minmax(220px, 0.36fr) minmax(360px, 1fr)
|
||||
auto;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1682,6 +1687,47 @@ select {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.image-upload-trigger {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 72px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.image-upload-trigger span {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.image-preview-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.image-preview-item {
|
||||
object-fit: cover;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.worker-rule-form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.worker-rule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 0 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.worker-page-head {
|
||||
align-items: stretch;
|
||||
|
||||
@@ -35,6 +35,50 @@ export type WorkerLevel = {
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type WorkCategory = {
|
||||
categoryId: number
|
||||
categoryKey: string
|
||||
name: string
|
||||
sortOrder: number
|
||||
status: string
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type WorkProductRule = {
|
||||
ruleId: number
|
||||
ruleKey: string
|
||||
provider: string
|
||||
platform: string
|
||||
shopId: string
|
||||
skuCode: string
|
||||
productName: string
|
||||
matchType: 'contains' | 'exact' | string
|
||||
categoryId: number | null
|
||||
categoryName: string
|
||||
enabled: boolean
|
||||
autoCreate: boolean
|
||||
rewardAmount: number
|
||||
requiredDepositAmount: number
|
||||
depositThresholdAmount: number
|
||||
requirement: {
|
||||
fields: CollectField[]
|
||||
}
|
||||
sortOrder: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type UploadedFile = {
|
||||
objectKey: string
|
||||
url: string
|
||||
thumbnailUrl: string
|
||||
mediumUrl: string
|
||||
filename: string
|
||||
contentType: string
|
||||
size: number
|
||||
}
|
||||
|
||||
export type WorkOrderStatus =
|
||||
| 'pending_material'
|
||||
| 'unassigned'
|
||||
@@ -59,7 +103,13 @@ export type WorkOrder = {
|
||||
freezeDepositAmount?: number
|
||||
material: Record<string, unknown>
|
||||
requirement: Record<string, unknown>
|
||||
acceptance: Record<string, unknown>
|
||||
acceptance: {
|
||||
note?: string
|
||||
files?: UploadedFile[]
|
||||
imageUrls?: string[]
|
||||
submittedAt?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
problemNote: string
|
||||
worker: null | {
|
||||
workerId: number
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
const IMAGE_UPLOAD_MAX_SIDE: Record<string, number> = {
|
||||
'worker-acceptance': 1920,
|
||||
'collect-material': 1920,
|
||||
avatar: 512,
|
||||
}
|
||||
|
||||
const IMAGE_UPLOAD_QUALITY: Record<string, number> = {
|
||||
'worker-acceptance': 0.86,
|
||||
'collect-material': 0.86,
|
||||
avatar: 0.82,
|
||||
}
|
||||
|
||||
export async function optimizeImageForUpload(file: File, scene: string) {
|
||||
if (!file.type.startsWith('image/')) return file
|
||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type))
|
||||
return file
|
||||
if (typeof document === 'undefined') return file
|
||||
|
||||
const maxSide = IMAGE_UPLOAD_MAX_SIDE[scene] ?? 1600
|
||||
const quality = IMAGE_UPLOAD_QUALITY[scene] ?? 0.84
|
||||
|
||||
try {
|
||||
const image = await loadImage(file)
|
||||
const { width, height } = fitSize(
|
||||
image.naturalWidth,
|
||||
image.naturalHeight,
|
||||
maxSide,
|
||||
)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return file
|
||||
context.fillStyle = '#ffffff'
|
||||
context.fillRect(0, 0, width, height)
|
||||
context.drawImage(image, 0, 0, width, height)
|
||||
const blob = await canvasToBlob(canvas, 'image/webp', quality)
|
||||
if (!blob || blob.size >= file.size) return file
|
||||
return new File([blob], replaceFileExt(file.name, 'webp'), {
|
||||
type: 'image/webp',
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
} catch {
|
||||
return file
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(file: File) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const url = URL.createObjectURL(file)
|
||||
const image = new Image()
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
resolve(image)
|
||||
}
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('图片读取失败'))
|
||||
}
|
||||
image.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function fitSize(width: number, height: number, maxSide: number) {
|
||||
if (width <= 0 || height <= 0) return { width: 1, height: 1 }
|
||||
const scale = Math.min(1, maxSide / Math.max(width, height))
|
||||
return {
|
||||
width: Math.max(1, Math.round(width * scale)),
|
||||
height: Math.max(1, Math.round(height * scale)),
|
||||
}
|
||||
}
|
||||
|
||||
function canvasToBlob(
|
||||
canvas: HTMLCanvasElement,
|
||||
type: string,
|
||||
quality: number,
|
||||
) {
|
||||
return new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob(resolve, type, quality)
|
||||
})
|
||||
}
|
||||
|
||||
function replaceFileExt(filename: string, ext: string) {
|
||||
const base = filename.replace(/\.[^.]+$/, '')
|
||||
return `${base || 'image'}.${ext}`
|
||||
}
|
||||
Reference in New Issue
Block a user