Files
order_site/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx
T
zhaoxing 13869d345f feat(worker-platform): 商品名按来源追加可配置后缀
- 新增来源后缀配置(来源下拉、启停、备注,同一时间仅启用一个)
- 管理端列表/详情与打手端列表/详情/大厅展示「商品名(后缀)」
- 打手端 DTO 透出 sourceProvider,仅展示,不改写商品名
- 新增来源选项接口与前后端单测
2026-11-02 14:25:53 +08:00

589 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}