356 lines
12 KiB
TypeScript
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>
|
|
)
|
|
}
|