- 新增来源后缀配置(来源下拉、启停、备注,同一时间仅启用一个) - 管理端列表/详情与打手端列表/详情/大厅展示「商品名(后缀)」 - 打手端 DTO 透出 sourceProvider,仅展示,不改写商品名 - 新增来源选项接口与前后端单测
589 lines
20 KiB
TypeScript
589 lines
20 KiB
TypeScript
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined } from '@ant-design/icons'
|
||
import {
|
||
Alert,
|
||
App,
|
||
Button,
|
||
Card,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Popconfirm,
|
||
Select,
|
||
Space,
|
||
Switch,
|
||
Tag,
|
||
Typography,
|
||
} from 'antd'
|
||
import { useEffect, useMemo, useState } from 'react'
|
||
|
||
import {
|
||
addAdminMockCustomShop,
|
||
deleteAdminMockCustomShop,
|
||
fetchAdminMockCustomShops,
|
||
fetchAdminScheduledJobsConfig,
|
||
fetchAdminWorkerAnnouncement,
|
||
fetchAdminWorkerProductNameSuffix,
|
||
fetchAdminWorkerProductNameSuffixSources,
|
||
renameAdminMockCustomShop,
|
||
runAdminScheduledJob,
|
||
saveAdminWorkerAnnouncement,
|
||
saveAdminWorkerProductNameSuffix,
|
||
saveAdminScheduledJobsConfig,
|
||
} from '@/services/admin'
|
||
import type { AdminScheduledJobItem, AdminScheduledJobRuntimeState } from '@/types/admin'
|
||
import type { WorkerAnnouncementConfig, WorkerProductNameSuffixItem } from '@/types/worker-platform'
|
||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||
import { normalizeProductNameSuffixText } from '@/utils/product-name-source-suffix'
|
||
|
||
import HallConfigPanel from './HallConfigPanel'
|
||
|
||
function isDepositUnfreezeJob(job: AdminScheduledJobItem) {
|
||
return job.type === 'deposit_unfreeze' || job.id === 'deposit-unfreeze'
|
||
}
|
||
|
||
export default function AnnouncementPanel() {
|
||
return (
|
||
<Space direction="vertical" size={16} style={{ width: '100%' }}>
|
||
<AnnouncementCard />
|
||
<ProductNameSourceSuffixCard />
|
||
<DepositUnfreezeCard />
|
||
<MockCustomShopsCard />
|
||
<HallConfigPanel />
|
||
</Space>
|
||
)
|
||
}
|
||
|
||
function DepositUnfreezeCard() {
|
||
const { message } = App.useApp()
|
||
const queryClient = useQueryClient()
|
||
const [saving, setSaving] = useState(false)
|
||
const [running, setRunning] = useState(false)
|
||
const [draft, setDraft] = useState<AdminScheduledJobItem | null>(null)
|
||
|
||
const jobsQuery = useQuery({
|
||
queryKey: ['admin-scheduled-jobs'],
|
||
queryFn: fetchAdminScheduledJobsConfig,
|
||
})
|
||
const source = jobsQuery.data?.data
|
||
const currentJob = source?.source.jobs.find(isDepositUnfreezeJob) || null
|
||
const runtime: AdminScheduledJobRuntimeState | null =
|
||
source?.runtime.find((item) => item.id === (draft?.id || currentJob?.id)) || null
|
||
|
||
useEffect(() => {
|
||
setDraft(currentJob ? { ...currentJob } : null)
|
||
}, [currentJob])
|
||
|
||
function patchJob(patch: Partial<AdminScheduledJobItem>) {
|
||
setDraft((prev) => (prev ? { ...prev, ...patch } : prev))
|
||
}
|
||
|
||
async function saveJob() {
|
||
if (!draft || !source) return
|
||
setSaving(true)
|
||
try {
|
||
await saveAdminScheduledJobsConfig({
|
||
...source.source,
|
||
jobs: source.source.jobs.map((item) => (item.id === draft.id ? draft : item)),
|
||
})
|
||
await queryClient.invalidateQueries({ queryKey: ['admin-scheduled-jobs'] })
|
||
message.success('押金解冻配置已保存')
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '保存押金解冻配置失败')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
async function runNow() {
|
||
const jobId = draft?.id || currentJob?.id || ''
|
||
if (!jobId) return
|
||
setRunning(true)
|
||
try {
|
||
await runAdminScheduledJob(jobId)
|
||
await queryClient.invalidateQueries({ queryKey: ['admin-scheduled-jobs'] })
|
||
message.success('押金解冻任务已执行')
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '执行押金解冻失败')
|
||
} finally {
|
||
setRunning(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Card
|
||
title="押金到期解冻"
|
||
bordered={false}
|
||
extra={
|
||
<Space wrap>
|
||
<Button icon={<ReloadOutlined />} loading={running} onClick={() => void runNow()}>
|
||
立即解冻
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
icon={<SaveOutlined />}
|
||
loading={saving}
|
||
disabled={!draft}
|
||
onClick={() => void saveJob()}
|
||
>
|
||
保存
|
||
</Button>
|
||
</Space>
|
||
}
|
||
>
|
||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||
验收后的报酬与押金进入待解冻队列,到期自动转入打手可用余额;
|
||
解冻天数与报酬延迟在「财务」面板配置,调小后存量待解冻也会按新天数提前到期。
|
||
</Typography.Paragraph>
|
||
{draft ? (
|
||
<Space direction="vertical" size={12} style={{ width: '100%', maxWidth: 520 }}>
|
||
<Space wrap>
|
||
<Switch
|
||
checked={draft.enabled !== false}
|
||
onChange={(enabled) => patchJob({ enabled })}
|
||
/>
|
||
<Tag color={draft.enabled !== false ? 'green' : 'default'}>
|
||
{draft.enabled !== false ? '已启用' : '已停用'}
|
||
</Tag>
|
||
</Space>
|
||
<Space wrap>
|
||
<Typography.Text type="secondary">执行间隔(秒)</Typography.Text>
|
||
<InputNumber
|
||
min={30}
|
||
max={3600}
|
||
value={draft.intervalSeconds}
|
||
onChange={(value) => patchJob({ intervalSeconds: Number(value || 60) })}
|
||
/>
|
||
<Typography.Text type="secondary">每次解冻数量</Typography.Text>
|
||
<InputNumber
|
||
min={1}
|
||
max={2000}
|
||
value={Number(draft.config?.scanLimit ?? 100)}
|
||
onChange={(value) =>
|
||
patchJob({ config: { ...draft.config, scanLimit: Number(value || 100) } })
|
||
}
|
||
/>
|
||
</Space>
|
||
</Space>
|
||
) : (
|
||
<Typography.Text type="secondary">加载中…</Typography.Text>
|
||
)}
|
||
{runtime ? (
|
||
<Alert
|
||
style={{ marginTop: 16, maxWidth: 640 }}
|
||
type={
|
||
runtime.lastStatus === 'ok' || runtime.lastStatus === 'success'
|
||
? 'success'
|
||
: runtime.lastStatus
|
||
? 'warning'
|
||
: 'info'
|
||
}
|
||
showIcon
|
||
message={runtime.lastMessage || '尚未运行'}
|
||
description={[
|
||
`扫描 ${runtime.lastCheckedCount ?? 0}`,
|
||
`解冻 ${runtime.lastAsset ?? 0}`,
|
||
`上次:${formatAdminDateTime(runtime.lastFinishedAt || runtime.lastRunAt)}`,
|
||
`下次:${formatAdminDateTime(runtime.nextRunAt)}`,
|
||
].join(' · ')}
|
||
/>
|
||
) : null}
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function AnnouncementCard() {
|
||
const { message } = App.useApp()
|
||
const queryClient = useQueryClient()
|
||
const [form] = Form.useForm<WorkerAnnouncementConfig>()
|
||
const [saving, setSaving] = useState(false)
|
||
const announcementQuery = useQuery({
|
||
queryKey: ['admin-worker-announcement'],
|
||
queryFn: fetchAdminWorkerAnnouncement,
|
||
})
|
||
|
||
useEffect(() => {
|
||
if (announcementQuery.data?.data) form.setFieldsValue(announcementQuery.data.data)
|
||
}, [announcementQuery.data, form])
|
||
|
||
async function saveAnnouncement(values: WorkerAnnouncementConfig) {
|
||
setSaving(true)
|
||
try {
|
||
await saveAdminWorkerAnnouncement({
|
||
enabled: values.enabled !== false,
|
||
content: String(values.content || '').trim(),
|
||
})
|
||
await queryClient.invalidateQueries({ queryKey: ['admin-worker-announcement'] })
|
||
message.success('公告已保存')
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '保存公告失败')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Card title="打手端滚动公告" bordered={false}>
|
||
<Form form={form} layout="vertical" onFinish={saveAnnouncement} style={{ maxWidth: 760 }}>
|
||
<Form.Item name="enabled" valuePropName="checked" label="展示公告">
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="content"
|
||
label="公告内容"
|
||
rules={[{ required: true, whitespace: true, message: '请填写公告内容' }]}
|
||
extra="公告会显示在打手端各页面顶部并横向滚动;正文一改动,打手端下次进入会弹出公告弹窗,需点「我已知晓」才关闭。"
|
||
>
|
||
<Input.TextArea rows={4} maxLength={300} showCount placeholder="请输入打手端公告内容" />
|
||
</Form.Item>
|
||
<Space>
|
||
<Button type="primary" htmlType="submit" loading={saving}>
|
||
保存公告
|
||
</Button>
|
||
<Typography.Text type="secondary">关闭后打手端不显示公告栏。</Typography.Text>
|
||
</Space>
|
||
</Form>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
type ProductNameSuffixDraft = WorkerProductNameSuffixItem & { rowKey: string }
|
||
|
||
let productNameSuffixRowSeed = 0
|
||
|
||
function createProductNameSuffixRow(
|
||
source = '',
|
||
suffix = '',
|
||
enabled = false,
|
||
note = '',
|
||
): ProductNameSuffixDraft {
|
||
productNameSuffixRowSeed += 1
|
||
return {
|
||
rowKey: `product-name-suffix-${productNameSuffixRowSeed}`,
|
||
source,
|
||
suffix,
|
||
enabled,
|
||
note,
|
||
}
|
||
}
|
||
|
||
/** 商品名来源后缀:按工单来源在商品名后追加「(文字)」,只影响展示,不改库里的商品名。 */
|
||
function ProductNameSourceSuffixCard() {
|
||
const { message } = App.useApp()
|
||
const queryClient = useQueryClient()
|
||
const [saving, setSaving] = useState(false)
|
||
const [items, setItems] = useState<ProductNameSuffixDraft[]>([])
|
||
const suffixQuery = useQuery({
|
||
queryKey: ['admin-worker-product-name-suffix'],
|
||
queryFn: fetchAdminWorkerProductNameSuffix,
|
||
staleTime: 5 * 60 * 1000,
|
||
})
|
||
const sourcesQuery = useQuery({
|
||
queryKey: ['admin-worker-product-name-suffix-sources'],
|
||
queryFn: fetchAdminWorkerProductNameSuffixSources,
|
||
staleTime: 5 * 60 * 1000,
|
||
})
|
||
|
||
useEffect(() => {
|
||
const remoteItems = suffixQuery.data?.data.items || []
|
||
setItems(
|
||
remoteItems.map((item) =>
|
||
createProductNameSuffixRow(item.source, item.suffix, item.enabled, item.note),
|
||
),
|
||
)
|
||
}, [suffixQuery.data])
|
||
|
||
// 下拉选项来自订单来源字段;已配置但暂时没有工单的来源也保留,避免编辑时丢值。
|
||
const sourceOptions = useMemo(() => {
|
||
const values = new Map<string, string>()
|
||
for (const item of items) {
|
||
const text = String(item.source || '').trim()
|
||
if (!text) continue
|
||
const key = text.toLowerCase()
|
||
if (!values.has(key)) values.set(key, text)
|
||
}
|
||
for (const value of sourcesQuery.data?.data.items || []) {
|
||
const text = String(value || '').trim()
|
||
if (!text) continue
|
||
const key = text.toLowerCase()
|
||
if (!values.has(key)) values.set(key, text)
|
||
}
|
||
return [...values.values()]
|
||
.sort((a, b) => a.localeCompare(b))
|
||
.map((value) => ({ value, label: value }))
|
||
}, [items, sourcesQuery.data])
|
||
|
||
function patchRow(rowKey: string, patch: Partial<WorkerProductNameSuffixItem>) {
|
||
setItems((prev) => prev.map((item) => (item.rowKey === rowKey ? { ...item, ...patch } : item)))
|
||
}
|
||
|
||
function toggleRow(rowKey: string, enabled: boolean) {
|
||
setItems((prev) =>
|
||
prev.map((item) => {
|
||
if (item.rowKey === rowKey) return { ...item, enabled }
|
||
// 同一时间只能启用一个来源:启用当前行时自动停用其他行。
|
||
return enabled ? { ...item, enabled: false } : item
|
||
}),
|
||
)
|
||
}
|
||
|
||
function removeRow(rowKey: string) {
|
||
setItems((prev) => prev.filter((item) => item.rowKey !== rowKey))
|
||
}
|
||
|
||
async function save() {
|
||
const normalized: WorkerProductNameSuffixItem[] = []
|
||
const seenSources = new Set<string>()
|
||
let hasEnabledItem = false
|
||
for (const [index, item] of items.entries()) {
|
||
const source = String(item.source || '').trim()
|
||
const suffix = normalizeProductNameSuffixText(item.suffix)
|
||
if (!source && !suffix) continue
|
||
if (!source || !suffix) {
|
||
message.error(`第 ${index + 1} 行:来源和后缀都要填写`)
|
||
return
|
||
}
|
||
const normalizedSource = source.toLowerCase()
|
||
if (seenSources.has(normalizedSource)) {
|
||
message.error(`来源「${source}」重复,一行只能配置一个来源`)
|
||
return
|
||
}
|
||
seenSources.add(normalizedSource)
|
||
const enabled = item.enabled === true && !hasEnabledItem
|
||
if (enabled) hasEnabledItem = true
|
||
normalized.push({
|
||
source,
|
||
suffix,
|
||
enabled,
|
||
note: String(item.note || '').trim(),
|
||
})
|
||
}
|
||
if (normalized.length > 50) {
|
||
message.error('最多配置 50 条来源后缀')
|
||
return
|
||
}
|
||
|
||
setSaving(true)
|
||
try {
|
||
await saveAdminWorkerProductNameSuffix({ items: normalized })
|
||
await queryClient.invalidateQueries({ queryKey: ['admin-worker-product-name-suffix'] })
|
||
message.success('商品名来源后缀已保存')
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '保存商品名来源后缀失败')
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<Card
|
||
title="商品名来源后缀"
|
||
bordered={false}
|
||
extra={
|
||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={() => void save()}>
|
||
保存
|
||
</Button>
|
||
}
|
||
>
|
||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||
来源下拉数据取自工单的「来源」字段;选中来源后填写后缀,启用后展示为「商品名(后缀)」。
|
||
同一时间只能启用一个来源,启用新来源会自动停用其他行;未启用或没有匹配配置的来源不追加。
|
||
备注仅后台可见。只影响列表/详情的展示,不会改写工单里的商品名。
|
||
</Typography.Paragraph>
|
||
{sourcesQuery.isError ? (
|
||
<Typography.Text type="danger" style={{ display: 'block', marginBottom: 8 }}>
|
||
读取工单来源选项失败,已配置的来源仍可编辑;请稍后刷新重试。
|
||
</Typography.Text>
|
||
) : null}
|
||
{items.length === 0 ? (
|
||
<Typography.Text type="secondary">
|
||
还没有配置,所有商品名保持原样;点击「添加一行」开始配置。
|
||
</Typography.Text>
|
||
) : null}
|
||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||
{items.map((item, index) => {
|
||
const suffix = normalizeProductNameSuffixText(item.suffix)
|
||
return (
|
||
<Space
|
||
key={item.rowKey}
|
||
size={8}
|
||
wrap
|
||
align="center"
|
||
style={{ width: '100%', padding: '6px 0', borderBottom: '1px solid #f0f0f0' }}
|
||
>
|
||
<Switch
|
||
checked={item.enabled === true}
|
||
checkedChildren="启用"
|
||
unCheckedChildren="停用"
|
||
onChange={(checked) => toggleRow(item.rowKey, checked)}
|
||
/>
|
||
<Select
|
||
showSearch
|
||
allowClear
|
||
loading={sourcesQuery.isLoading}
|
||
optionFilterProp="label"
|
||
style={{ width: 220 }}
|
||
placeholder="选择来源"
|
||
options={sourceOptions}
|
||
value={item.source || undefined}
|
||
onChange={(value) => patchRow(item.rowKey, { source: String(value || '') })}
|
||
/>
|
||
<Input
|
||
addonBefore="后缀"
|
||
style={{ width: 200 }}
|
||
maxLength={34}
|
||
placeholder="如 91卡券"
|
||
value={item.suffix}
|
||
onChange={(event) => patchRow(item.rowKey, { suffix: event.target.value })}
|
||
/>
|
||
<Input
|
||
style={{ width: 220 }}
|
||
maxLength={200}
|
||
placeholder="备注(选填)"
|
||
value={item.note}
|
||
onChange={(event) => patchRow(item.rowKey, { note: event.target.value })}
|
||
/>
|
||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||
{suffix
|
||
? item.enabled
|
||
? `示例:商品名(${suffix})`
|
||
: '未启用,不追加'
|
||
: '未填写后缀时不追加'}
|
||
</Typography.Text>
|
||
<Button
|
||
danger
|
||
type="text"
|
||
icon={<DeleteOutlined />}
|
||
aria-label={`删除第 ${index + 1} 行`}
|
||
onClick={() => removeRow(item.rowKey)}
|
||
>
|
||
删除
|
||
</Button>
|
||
</Space>
|
||
)
|
||
})}
|
||
<Button
|
||
icon={<PlusOutlined />}
|
||
disabled={items.length >= 50}
|
||
onClick={() => setItems((prev) => [...prev, createProductNameSuffixRow()])}
|
||
>
|
||
添加一行
|
||
</Button>
|
||
</Space>
|
||
</Card>
|
||
)
|
||
}
|
||
|
||
function MockCustomShopsCard() {
|
||
const { message, modal } = App.useApp()
|
||
const queryClient = useQueryClient()
|
||
const [adding, setAdding] = useState(false)
|
||
const [addForm] = Form.useForm<{ name: string }>()
|
||
const customShopsQuery = useQuery({
|
||
queryKey: ['admin-mock-custom-shops'],
|
||
queryFn: fetchAdminMockCustomShops,
|
||
})
|
||
const shops = customShopsQuery.data?.data.items || []
|
||
|
||
async function refresh() {
|
||
await queryClient.invalidateQueries({ queryKey: ['admin-mock-custom-shops'] })
|
||
}
|
||
|
||
async function runShopAction(action: () => Promise<unknown>, successMessage: string) {
|
||
try {
|
||
await action()
|
||
await refresh()
|
||
message.success(successMessage)
|
||
} catch (error) {
|
||
message.error(error instanceof Error ? error.message : '操作失败')
|
||
}
|
||
}
|
||
|
||
async function addShop(values: { name: string }) {
|
||
setAdding(true)
|
||
try {
|
||
await runShopAction(
|
||
() => addAdminMockCustomShop(String(values.name || '').trim()),
|
||
'自定义店铺已新增',
|
||
)
|
||
addForm.resetFields()
|
||
} finally {
|
||
setAdding(false)
|
||
}
|
||
}
|
||
|
||
function renameShop(name: string) {
|
||
let nextName = ''
|
||
modal.confirm({
|
||
title: '重命名自定义店铺',
|
||
content: (
|
||
<Input
|
||
defaultValue={name}
|
||
maxLength={64}
|
||
placeholder="请输入新的店铺名称"
|
||
onChange={(event) => {
|
||
nextName = String(event.target.value || '')
|
||
}}
|
||
/>
|
||
),
|
||
onOk: async () => {
|
||
const trimmed = nextName.trim()
|
||
if (!trimmed || trimmed === name) return
|
||
await runShopAction(() => renameAdminMockCustomShop(name, trimmed), '自定义店铺已重命名')
|
||
},
|
||
})
|
||
}
|
||
|
||
return (
|
||
<Card title="本地 Mock 自定义店铺" bordered={false}>
|
||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
|
||
在接单工单「本地 Mock」中录入过的外部平台店铺会自动记忆到这里;也可以直接维护,
|
||
维护后的店铺会出现在 Mock 表单的下拉选项中。
|
||
</Typography.Paragraph>
|
||
<Form form={addForm} layout="inline" onFinish={addShop} style={{ marginBottom: 12 }}>
|
||
<Form.Item
|
||
name="name"
|
||
rules={[{ required: true, whitespace: true, message: '请输入店铺名称' }]}
|
||
style={{ minWidth: 260 }}
|
||
>
|
||
<Input maxLength={64} placeholder="输入店铺名称,如:抖音-某某小店" />
|
||
</Form.Item>
|
||
<Form.Item>
|
||
<Button type="primary" htmlType="submit" loading={adding}>
|
||
新增店铺
|
||
</Button>
|
||
</Form.Item>
|
||
</Form>
|
||
{shops.length === 0 ? (
|
||
<Typography.Text type="secondary">
|
||
还没有自定义店铺,先在上方新增或通过本地 Mock 录入。
|
||
</Typography.Text>
|
||
) : (
|
||
<Space size={[8, 8]} wrap>
|
||
{shops.map((name) => (
|
||
<Space key={name} size={4} wrap>
|
||
<Typography.Text>{name}</Typography.Text>
|
||
<Button size="small" type="text" onClick={() => renameShop(name)}>
|
||
重命名
|
||
</Button>
|
||
<Popconfirm
|
||
title={`删除自定义店铺「${name}」?`}
|
||
description="不影响已用它生成的工单,仅从 Mock 下拉选项移除。"
|
||
okText="删除"
|
||
okButtonProps={{ danger: true }}
|
||
onConfirm={() =>
|
||
runShopAction(() => deleteAdminMockCustomShop(name), '自定义店铺已删除')
|
||
}
|
||
>
|
||
<Button size="small" type="text" danger>
|
||
删除
|
||
</Button>
|
||
</Popconfirm>
|
||
</Space>
|
||
))}
|
||
</Space>
|
||
)}
|
||
</Card>
|
||
)
|
||
}
|