240 lines
7.2 KiB
TypeScript
240 lines
7.2 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import {
|
||
Button,
|
||
Card,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Select,
|
||
Space,
|
||
Switch,
|
||
Tabs,
|
||
TimePicker,
|
||
Typography,
|
||
message,
|
||
} from 'antd';
|
||
import dayjs, { type Dayjs } from 'dayjs';
|
||
import {
|
||
WECOM_REPORT_KIND_LABELS,
|
||
WECOM_REPORT_KINDS,
|
||
WECOM_REPORT_WEEKDAY_OPTIONS,
|
||
type WecomReportKind,
|
||
type WecomReportPreviewDto,
|
||
type WecomReportPushDto,
|
||
} from '@dukang/shared-types';
|
||
import { request } from '../lib/api';
|
||
import { fmtTime } from '../lib/constants';
|
||
|
||
type FormValues = {
|
||
name: string;
|
||
webhookUrl: string;
|
||
enabled: boolean;
|
||
mentionWecomUserId?: string;
|
||
sendTime: Dayjs;
|
||
sendWeekday: number;
|
||
sendMonthDay: number;
|
||
};
|
||
|
||
function ReportKindPane({
|
||
kind,
|
||
row,
|
||
onSaved,
|
||
}: {
|
||
kind: WecomReportKind;
|
||
row: WecomReportPushDto | undefined;
|
||
onSaved: () => void;
|
||
}) {
|
||
const [form] = Form.useForm<FormValues>();
|
||
const [saving, setSaving] = useState(false);
|
||
const [previewing, setPreviewing] = useState(false);
|
||
const [sending, setSending] = useState(false);
|
||
const [preview, setPreview] = useState<WecomReportPreviewDto | null>(null);
|
||
|
||
useEffect(() => {
|
||
if (!row) return;
|
||
form.setFieldsValue({
|
||
name: row.name,
|
||
webhookUrl: row.webhookUrl,
|
||
enabled: row.enabled,
|
||
mentionWecomUserId: row.mentionWecomUserId ?? undefined,
|
||
sendTime: dayjs().hour(row.sendHour).minute(row.sendMinute).second(0),
|
||
sendWeekday: row.sendWeekday,
|
||
sendMonthDay: row.sendMonthDay,
|
||
});
|
||
}, [form, row]);
|
||
|
||
async function save() {
|
||
const values = await form.validateFields();
|
||
setSaving(true);
|
||
try {
|
||
await request(`/admin/wecom-reports/${kind}`, {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
name: values.name,
|
||
webhookUrl: values.webhookUrl,
|
||
enabled: values.enabled,
|
||
mentionWecomUserId: values.mentionWecomUserId?.trim() || null,
|
||
sendHour: values.sendTime.hour(),
|
||
sendMinute: values.sendTime.minute(),
|
||
sendWeekday: values.sendWeekday,
|
||
sendMonthDay: values.sendMonthDay,
|
||
}),
|
||
});
|
||
message.success('已保存');
|
||
onSaved();
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '保存失败');
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function openPreview() {
|
||
setPreviewing(true);
|
||
try {
|
||
const data = await request<WecomReportPreviewDto>(`/admin/wecom-reports/${kind}/preview`, {
|
||
method: 'POST',
|
||
body: '{}',
|
||
});
|
||
setPreview(data);
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '预览失败');
|
||
} finally {
|
||
setPreviewing(false);
|
||
}
|
||
}
|
||
|
||
async function sendNow() {
|
||
setSending(true);
|
||
try {
|
||
const res = await request<{ message: string }>(`/admin/wecom-reports/${kind}/send`, {
|
||
method: 'POST',
|
||
body: '{}',
|
||
});
|
||
message.success(res.message || '已发送');
|
||
onSaved();
|
||
} catch (e) {
|
||
message.error(e instanceof Error ? e.message : '发送失败');
|
||
} finally {
|
||
setSending(false);
|
||
}
|
||
}
|
||
|
||
const label = WECOM_REPORT_KIND_LABELS[kind];
|
||
|
||
return (
|
||
<>
|
||
<Form form={form} layout="vertical" style={{ maxWidth: 640 }}>
|
||
<Form.Item name="enabled" label="启用定时推送" valuePropName="checked">
|
||
<Switch />
|
||
</Form.Item>
|
||
<Form.Item name="name" label="名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||
<Input maxLength={64} />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="webhookUrl"
|
||
label="群机器人 Webhook"
|
||
extra="企业微信群「群机器人」Webhook,与「消息推送」分开配置。"
|
||
rules={[{ required: true, message: '请填写 Webhook' }]}
|
||
>
|
||
<Input.TextArea rows={2} />
|
||
</Form.Item>
|
||
<Form.Item name="mentionWecomUserId" label="@成员 userid">
|
||
<Input placeholder="可选,企业微信成员账号" />
|
||
</Form.Item>
|
||
<Form.Item
|
||
name="sendTime"
|
||
label="发送时刻"
|
||
extra={
|
||
kind === 'daily'
|
||
? '按北京时间当天该时刻发送当日数据。'
|
||
: kind === 'weekly'
|
||
? '按北京时间该星期该时刻发送上一自然周。'
|
||
: '按北京时间每月该日该时刻发送上一自然月。'
|
||
}
|
||
rules={[{ required: true, message: '请选择时刻' }]}
|
||
>
|
||
<TimePicker format="HH:mm" minuteStep={1} allowClear={false} />
|
||
</Form.Item>
|
||
{kind === 'weekly' ? (
|
||
<Form.Item name="sendWeekday" label="发送星期" rules={[{ required: true }]}>
|
||
<Select options={WECOM_REPORT_WEEKDAY_OPTIONS} />
|
||
</Form.Item>
|
||
) : null}
|
||
{kind === 'monthly' ? (
|
||
<Form.Item name="sendMonthDay" label="每月几号" rules={[{ required: true }]}>
|
||
<InputNumber min={1} max={31} />
|
||
</Form.Item>
|
||
) : null}
|
||
<Typography.Paragraph type="secondary">
|
||
{row?.lastSentAt
|
||
? `上次发送:${fmtTime(row.lastSentAt)}(${row.lastSentPeriod ?? '—'})`
|
||
: '尚未发送'}
|
||
</Typography.Paragraph>
|
||
<Space wrap>
|
||
<Button type="primary" loading={saving} onClick={() => void save()}>
|
||
保存
|
||
</Button>
|
||
<Button loading={previewing} onClick={() => void openPreview()}>
|
||
预览
|
||
</Button>
|
||
<Button loading={sending} onClick={() => void sendNow()}>
|
||
立即发送
|
||
</Button>
|
||
</Space>
|
||
</Form>
|
||
<Modal
|
||
title={`预览${label}`}
|
||
open={!!preview}
|
||
onCancel={() => setPreview(null)}
|
||
footer={<Button onClick={() => setPreview(null)}>关闭</Button>}
|
||
width={560}
|
||
>
|
||
<Typography.Paragraph type="secondary">
|
||
区间 {preview?.rangeLabel}
|
||
</Typography.Paragraph>
|
||
<pre style={{ whiteSpace: 'pre-wrap', margin: 0 }}>{preview?.markdown}</pre>
|
||
</Modal>
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default function WecomReportsPage() {
|
||
const [rows, setRows] = useState<WecomReportPushDto[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const load = useCallback(() => {
|
||
setLoading(true);
|
||
return request<WecomReportPushDto[]>('/admin/wecom-reports')
|
||
.then(setRows)
|
||
.catch((e) => message.error(e instanceof Error ? e.message : '加载失败'))
|
||
.finally(() => setLoading(false));
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
const byKind = new Map(rows.map((r) => [r.kind, r]));
|
||
|
||
return (
|
||
<div>
|
||
<Typography.Title level={4}>企微机器人 · 报告</Typography.Title>
|
||
<Typography.Paragraph type="secondary">
|
||
日报 / 周报 / 月报走企微群机器人 Webhook,配置与「消息推送」相互独立。口径与概览一致:数量为期末存量,新增为区间内发生额;订单金额按已付
|
||
payAmount,核销按核销单。
|
||
</Typography.Paragraph>
|
||
<Card loading={loading}>
|
||
<Tabs
|
||
items={WECOM_REPORT_KINDS.map((kind) => ({
|
||
key: kind,
|
||
label: WECOM_REPORT_KIND_LABELS[kind],
|
||
children: <ReportKindPane kind={kind} row={byKind.get(kind)} onSaved={() => void load()} />,
|
||
}))}
|
||
/>
|
||
</Card>
|
||
</div>
|
||
);
|
||
}
|