8c31cecb37
数据大盘新增/增强两块运营配置: - 首页轮播种子(新):列表/新增/编辑/批量生成/预览效果;用户名可空(留空自动合成)、金额区间(min~max) - 首页数据配置(增强):自增长「百分比/固定增量」切换、真实值基数偏移、「允许数字下降」(只增不减)开关、下次预计值、立即更新二次确认;倍率上限 1.5 - 依赖 @ant-design/v5-patch-for-react-19(React 19 兼容,providers 顶部 import) - start.sh/stop.sh 跨平台重写;.run-logs/ 加入 .gitignore Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
367 lines
11 KiB
TypeScript
367 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import {
|
|
Button,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Modal,
|
|
Popconfirm,
|
|
Space,
|
|
Spin,
|
|
Switch,
|
|
Table,
|
|
message,
|
|
} from 'antd';
|
|
import { api, errMsg } from '@/lib/api';
|
|
|
|
interface Seed {
|
|
id: number;
|
|
masked_user: string | null;
|
|
min_cents: number;
|
|
max_cents: number;
|
|
enabled: boolean;
|
|
sort_order: number;
|
|
created_at: string | null;
|
|
}
|
|
|
|
interface FormVals {
|
|
masked_user?: string;
|
|
min_yuan: number;
|
|
max_yuan: number;
|
|
sort_order: number;
|
|
enabled: boolean;
|
|
}
|
|
|
|
interface BulkVals {
|
|
count: number;
|
|
min_yuan: number;
|
|
max_yuan: number;
|
|
enabled: boolean;
|
|
}
|
|
|
|
interface PreviewItem {
|
|
masked_user: string;
|
|
saved_amount_cents: number;
|
|
time: string;
|
|
}
|
|
|
|
const yuan = (cents: number) => (cents / 100).toFixed(2);
|
|
|
|
/** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「数据大盘」页的一个区块。 */
|
|
export default function HomeMarqueeSeeds() {
|
|
const [seeds, setSeeds] = useState<Seed[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editing, setEditing] = useState<Seed | null>(null);
|
|
const [form] = Form.useForm<FormVals>();
|
|
|
|
const [bulkOpen, setBulkOpen] = useState(false);
|
|
const [bulkForm] = Form.useForm<BulkVals>();
|
|
|
|
const [previewOpen, setPreviewOpen] = useState(false);
|
|
const [previewItems, setPreviewItems] = useState<PreviewItem[]>([]);
|
|
const [previewLoading, setPreviewLoading] = useState(false);
|
|
|
|
const load = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const { data } = await api.get<Seed[]>('/admin/api/marquee-seeds');
|
|
setSeeds(data);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
useEffect(() => {
|
|
load();
|
|
}, []);
|
|
|
|
// ===== 单条 新增 / 编辑 =====
|
|
const openAdd = () => {
|
|
setEditing(null);
|
|
form.resetFields();
|
|
form.setFieldsValue({ masked_user: '', min_yuan: 5, max_yuan: 20, sort_order: 0, enabled: true });
|
|
setModalOpen(true);
|
|
};
|
|
const openEdit = (s: Seed) => {
|
|
setEditing(s);
|
|
form.resetFields();
|
|
form.setFieldsValue({
|
|
masked_user: s.masked_user ?? '',
|
|
min_yuan: s.min_cents / 100,
|
|
max_yuan: s.max_cents / 100,
|
|
sort_order: s.sort_order,
|
|
enabled: s.enabled,
|
|
});
|
|
setModalOpen(true);
|
|
};
|
|
const submit = async () => {
|
|
const v = await form.validateFields();
|
|
const body = {
|
|
masked_user: (v.masked_user ?? '').trim(), // 空串 → 后端清空为「自动合成」
|
|
min_cents: Math.round(v.min_yuan * 100),
|
|
max_cents: Math.round(v.max_yuan * 100),
|
|
sort_order: v.sort_order ?? 0,
|
|
enabled: v.enabled,
|
|
};
|
|
try {
|
|
if (editing) await api.patch(`/admin/api/marquee-seeds/${editing.id}`, body);
|
|
else await api.post('/admin/api/marquee-seeds', body);
|
|
message.success('已保存,客户端下次进首页生效');
|
|
setModalOpen(false);
|
|
load();
|
|
} catch (e) {
|
|
message.error(errMsg(e));
|
|
}
|
|
};
|
|
|
|
// ===== 批量生成 =====
|
|
const openBulk = () => {
|
|
bulkForm.resetFields();
|
|
bulkForm.setFieldsValue({ count: 10, min_yuan: 5, max_yuan: 30, enabled: true });
|
|
setBulkOpen(true);
|
|
};
|
|
const submitBulk = async () => {
|
|
const v = await bulkForm.validateFields();
|
|
try {
|
|
const { data } = await api.post<Seed[]>('/admin/api/marquee-seeds/bulk', {
|
|
count: v.count,
|
|
min_cents: Math.round(v.min_yuan * 100),
|
|
max_cents: Math.round(v.max_yuan * 100),
|
|
enabled: v.enabled,
|
|
});
|
|
message.success(`已生成 ${data.length} 条种子`);
|
|
setBulkOpen(false);
|
|
load();
|
|
} catch (e) {
|
|
message.error(errMsg(e));
|
|
}
|
|
};
|
|
|
|
// ===== 预览实际 feed =====
|
|
const fetchPreview = async () => {
|
|
setPreviewLoading(true);
|
|
try {
|
|
const { data } = await api.get<{ items: PreviewItem[] }>('/admin/api/marquee-seeds/preview?limit=8');
|
|
setPreviewItems(data.items);
|
|
} catch (e) {
|
|
message.error(errMsg(e));
|
|
} finally {
|
|
setPreviewLoading(false);
|
|
}
|
|
};
|
|
const openPreview = () => {
|
|
setPreviewOpen(true);
|
|
fetchPreview();
|
|
};
|
|
|
|
const toggle = async (s: Seed, enabled: boolean) => {
|
|
try {
|
|
await api.patch(`/admin/api/marquee-seeds/${s.id}`, { enabled });
|
|
setSeeds((prev) => prev.map((x) => (x.id === s.id ? { ...x, enabled } : x)));
|
|
} catch (e) {
|
|
message.error(errMsg(e));
|
|
}
|
|
};
|
|
const remove = async (s: Seed) => {
|
|
try {
|
|
await api.delete(`/admin/api/marquee-seeds/${s.id}`);
|
|
message.success('已删除');
|
|
load();
|
|
} catch (e) {
|
|
message.error(errMsg(e));
|
|
}
|
|
};
|
|
|
|
const columns = [
|
|
{
|
|
title: '用户名',
|
|
dataIndex: 'masked_user',
|
|
render: (u: string | null) =>
|
|
u ? u : <span style={{ color: '#bbb' }}>(自动生成)</span>,
|
|
},
|
|
{
|
|
title: '节省(元)',
|
|
width: 150,
|
|
render: (_: unknown, s: Seed) =>
|
|
s.min_cents === s.max_cents
|
|
? yuan(s.min_cents)
|
|
: `${yuan(s.min_cents)} ~ ${yuan(s.max_cents)}`,
|
|
},
|
|
{ title: '排序', dataIndex: 'sort_order', width: 70 },
|
|
{
|
|
title: '启用',
|
|
dataIndex: 'enabled',
|
|
width: 80,
|
|
render: (_: boolean, s: Seed) => (
|
|
<Switch size="small" checked={s.enabled} onChange={(v) => toggle(s, v)} />
|
|
),
|
|
},
|
|
{
|
|
title: '操作',
|
|
width: 130,
|
|
render: (_: unknown, s: Seed) => (
|
|
<Space>
|
|
<a onClick={() => openEdit(s)}>编辑</a>
|
|
<Popconfirm title="删除该种子?" onConfirm={() => remove(s)}>
|
|
<a style={{ color: '#cf1322' }}>删除</a>
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
];
|
|
|
|
// 金额上限不得小于下限(下限字段名)
|
|
const maxGteMin = (minField: string) => ({ getFieldValue }: { getFieldValue: (n: string) => number }) => ({
|
|
validator(_: unknown, value: number) {
|
|
const min = getFieldValue(minField);
|
|
if (value == null || min == null || value >= min) return Promise.resolve();
|
|
return Promise.reject(new Error('上限不能小于下限'));
|
|
},
|
|
});
|
|
|
|
return (
|
|
<div style={{ marginTop: 24 }}>
|
|
<h3 style={{ marginBottom: 4 }}>首页轮播种子</h3>
|
|
<p style={{ color: '#999', marginTop: 0 }}>
|
|
首页「用户xxx 比价后节省xx元」轮播的兜底假数据。真实比价记录不足时随机抽取这些补齐;停用的不参与混播。
|
|
用户名留空则每次展示随机合成;金额为区间则每次随机取值。
|
|
</p>
|
|
<Space style={{ marginBottom: 12 }}>
|
|
<Button type="primary" size="small" onClick={openAdd}>
|
|
新增种子
|
|
</Button>
|
|
<Button size="small" onClick={openBulk}>
|
|
批量生成
|
|
</Button>
|
|
<Button size="small" onClick={openPreview}>
|
|
预览效果
|
|
</Button>
|
|
</Space>
|
|
<Table
|
|
rowKey="id"
|
|
size="small"
|
|
loading={loading}
|
|
columns={columns}
|
|
dataSource={seeds}
|
|
pagination={false}
|
|
/>
|
|
|
|
{/* 新增 / 编辑 */}
|
|
<Modal
|
|
title={editing ? '编辑种子' : '新增种子'}
|
|
open={modalOpen}
|
|
onOk={submit}
|
|
onCancel={() => setModalOpen(false)}
|
|
forceRender
|
|
>
|
|
<Form form={form} layout="vertical" preserve={false}>
|
|
<Form.Item
|
|
name="masked_user"
|
|
label="用户名(脱敏)"
|
|
extra="留空则每次展示随机生成脱敏用户名(推荐,永不穿帮);也可固定填写,如 用户********a52"
|
|
>
|
|
<Input placeholder="留空 = 自动生成" allowClear />
|
|
</Form.Item>
|
|
<Space size="large" align="start">
|
|
<Form.Item name="min_yuan" label="节省下限(元)" rules={[{ required: true, message: '必填' }]}>
|
|
<InputNumber min={0} step={0.01} precision={2} style={{ width: 140 }} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="max_yuan"
|
|
label="节省上限(元)"
|
|
rules={[{ required: true, message: '必填' }, maxGteMin('min_yuan')]}
|
|
>
|
|
<InputNumber min={0} step={0.01} precision={2} style={{ width: 140 }} />
|
|
</Form.Item>
|
|
</Space>
|
|
<p style={{ color: '#999', marginTop: -8 }}>固定金额则上下限填一样;区间则每次轮播在区间内随机取值。</p>
|
|
<Form.Item name="sort_order" label="排序(仅后台列表用)">
|
|
<InputNumber min={0} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* 批量生成 */}
|
|
<Modal
|
|
title="批量生成种子"
|
|
open={bulkOpen}
|
|
onOk={submitBulk}
|
|
onCancel={() => setBulkOpen(false)}
|
|
forceRender
|
|
>
|
|
<p style={{ color: '#999', marginTop: 0 }}>
|
|
一次生成多条「自动生成用户名」的种子(冷启动铺量用)。金额均为下方区间,每条展示时各自随机取值。
|
|
</p>
|
|
<Form form={bulkForm} layout="vertical" preserve={false}>
|
|
<Form.Item name="count" label="数量" rules={[{ required: true, message: '必填' }]}>
|
|
<InputNumber min={1} max={200} style={{ width: '100%' }} />
|
|
</Form.Item>
|
|
<Space size="large" align="start">
|
|
<Form.Item name="min_yuan" label="节省下限(元)" rules={[{ required: true, message: '必填' }]}>
|
|
<InputNumber min={0} step={0.01} precision={2} style={{ width: 140 }} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="max_yuan"
|
|
label="节省上限(元)"
|
|
rules={[{ required: true, message: '必填' }, maxGteMin('min_yuan')]}
|
|
>
|
|
<InputNumber min={0} step={0.01} precision={2} style={{ width: 140 }} />
|
|
</Form.Item>
|
|
</Space>
|
|
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
|
|
{/* 预览实际 feed */}
|
|
<Modal
|
|
title="预览轮播效果"
|
|
open={previewOpen}
|
|
onCancel={() => setPreviewOpen(false)}
|
|
footer={
|
|
<Space>
|
|
<Button size="small" onClick={fetchPreview}>
|
|
换一批
|
|
</Button>
|
|
<Button size="small" type="primary" onClick={() => setPreviewOpen(false)}>
|
|
关闭
|
|
</Button>
|
|
</Space>
|
|
}
|
|
>
|
|
<p style={{ color: '#999', marginTop: 0 }}>
|
|
客户端实际会看到的混播结果:真实记录会插队,种子随机抽取、金额/用户名每次都不同。点「换一批」可重新随机。
|
|
</p>
|
|
<Spin spinning={previewLoading}>
|
|
<div style={{ minHeight: 120 }}>
|
|
{previewItems.map((it, i) => (
|
|
<div
|
|
key={i}
|
|
style={{
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
padding: '7px 4px',
|
|
borderBottom: '1px solid #f0f0f0',
|
|
}}
|
|
>
|
|
<span>
|
|
{it.masked_user} 比价后节省{' '}
|
|
<b style={{ color: '#fa541c' }}>{yuan(it.saved_amount_cents)} 元</b>
|
|
</span>
|
|
<span style={{ color: '#999' }}>{it.time}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</Spin>
|
|
</Modal>
|
|
</div>
|
|
);
|
|
}
|