Files
order_site/apps/frontend/src/pages/admin/panels/AnnouncementPanel.tsx
T

356 lines
12 KiB
TypeScript

import { useQuery, useQueryClient } from '@tanstack/react-query'
import { ReloadOutlined, SaveOutlined } from '@ant-design/icons'
import {
Alert,
App,
Button,
Card,
Form,
Input,
InputNumber,
Popconfirm,
Space,
Switch,
Tag,
Typography,
} from 'antd'
import { useEffect, useState } from 'react'
import {
addAdminMockCustomShop,
deleteAdminMockCustomShop,
fetchAdminMockCustomShops,
fetchAdminScheduledJobsConfig,
fetchAdminWorkerAnnouncement,
renameAdminMockCustomShop,
runAdminScheduledJob,
saveAdminWorkerAnnouncement,
saveAdminScheduledJobsConfig,
} from '@/services/admin'
import type { AdminScheduledJobItem, AdminScheduledJobRuntimeState } from '@/types/admin'
import type { WorkerAnnouncementConfig } from '@/types/worker-platform'
import { formatAdminDateTime } from '@/utils/admin-time'
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 />
<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>
)
}
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>
)
}