Compare commits

...

1 Commits

Author SHA1 Message Date
OuYingJun1024 ea211fdf45 feat(dashboard): 首页三数共享保存+智能校验+保底;轮播种子批量操作
- HomeStatsConfig: 三指标共享保存/立即更新;智能关系校验弹窗;真实值保底;初始基数护栏提示;saveAll 半失败合并
- HomeMarqueeSeeds: 勾选 + 批量启用/停用/删除
- 附带并行会话 WIP:users 页

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 20:48:43 +08:00
3 changed files with 353 additions and 90 deletions
+54 -1
View File
@@ -53,6 +53,7 @@ const yuan = (cents: number) => (cents / 100).toFixed(2);
export default function HomeMarqueeSeeds() { export default function HomeMarqueeSeeds() {
const [seeds, setSeeds] = useState<Seed[]>([]); const [seeds, setSeeds] = useState<Seed[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]); // 批量操作选中行
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Seed | null>(null); const [editing, setEditing] = useState<Seed | null>(null);
@@ -70,6 +71,8 @@ export default function HomeMarqueeSeeds() {
try { try {
const { data } = await api.get<Seed[]>('/admin/api/marquee-seeds'); const { data } = await api.get<Seed[]>('/admin/api/marquee-seeds');
setSeeds(data); setSeeds(data);
// 列表刷新后清掉已不存在的选中 id(单行删除/外部变更后防悬空,顶部计数与批量操作才准)
setSelectedRowKeys((prev) => prev.filter((k) => data.some((s) => s.id === k)));
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -175,6 +178,33 @@ export default function HomeMarqueeSeeds() {
} }
}; };
// ===== 批量操作(对选中多行)=====
const batchDelete = async () => {
try {
const { data } = await api.post<{ deleted: number }>('/admin/api/marquee-seeds/batch-delete', {
ids: selectedRowKeys,
});
message.success(`已删除 ${data.deleted}`);
setSelectedRowKeys([]);
load();
} catch (e) {
message.error(errMsg(e));
}
};
const batchSetEnabled = async (enabled: boolean) => {
try {
const { data } = await api.post<{ updated: number }>('/admin/api/marquee-seeds/batch-enable', {
ids: selectedRowKeys,
enabled,
});
message.success(`${enabled ? '启用' : '停用'} ${data.updated}`);
setSelectedRowKeys([]);
load();
} catch (e) {
message.error(errMsg(e));
}
};
const columns = [ const columns = [
{ {
title: '用户名', title: '用户名',
@@ -229,7 +259,7 @@ export default function HomeMarqueeSeeds() {
xxx xx元; xxx xx元;
; ;
</p> </p>
<Space style={{ marginBottom: 12 }}> <Space style={{ marginBottom: 12 }} wrap>
<Button type="primary" size="small" onClick={openAdd}> <Button type="primary" size="small" onClick={openAdd}>
</Button> </Button>
@@ -239,11 +269,34 @@ export default function HomeMarqueeSeeds() {
<Button size="small" onClick={openPreview}> <Button size="small" onClick={openPreview}>
</Button> </Button>
<span style={{ borderLeft: '1px solid #eee', height: 18 }} />
<Button size="small" disabled={!selectedRowKeys.length} onClick={() => batchSetEnabled(true)}>
</Button>
<Button size="small" disabled={!selectedRowKeys.length} onClick={() => batchSetEnabled(false)}>
</Button>
<Popconfirm
title={`确认删除选中的 ${selectedRowKeys.length} 条种子?`}
onConfirm={batchDelete}
disabled={!selectedRowKeys.length}
>
<Button size="small" danger disabled={!selectedRowKeys.length}>
</Button>
</Popconfirm>
{selectedRowKeys.length > 0 && (
<span style={{ color: '#999', fontSize: 12 }}> {selectedRowKeys.length} </span>
)}
</Space> </Space>
<Table <Table
rowKey="id" rowKey="id"
size="small" size="small"
loading={loading} loading={loading}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys.map(Number)),
}}
columns={columns} columns={columns}
dataSource={seeds} dataSource={seeds}
pagination={false} pagination={false}
+232 -86
View File
@@ -7,6 +7,7 @@ import {
Checkbox, Checkbox,
Col, Col,
InputNumber, InputNumber,
Modal,
Popconfirm, Popconfirm,
Radio, Radio,
Row, Row,
@@ -32,7 +33,7 @@ interface StatItem {
random_kind: 'mult' | 'add'; // 自增长方式:×倍率 / +绝对增量 random_kind: 'mult' | 'add'; // 自增长方式:×倍率 / +绝对增量
random_step_min: number; // 绝对增量区间(基础单位) random_step_min: number; // 绝对增量区间(基础单位)
random_step_max: number; random_step_max: number;
real_offset: number; // 真实值基数偏移(基础单位) real_offset: number; // 真实值保底值(基础单位):展示=max(真实,保底)。沿用 real_offset 列名(语义已改)
allow_decrease: boolean; // 是否允许展示值下降 allow_decrease: boolean; // 是否允许展示值下降
random_current: number | null; random_current: number | null;
random_last_tick_at: string | null; random_last_tick_at: string | null;
@@ -54,7 +55,7 @@ function decomposeInterval(sec: number): { num: number; unit: IntervalUnit } {
interface Edit { interface Edit {
mode: 'real' | 'manual' | 'random'; mode: 'real' | 'manual' | 'random';
manual: number | null; // 展示单位 manual: number | null; // 展示单位
realOffset: number; // 真实值偏移(展示单位) realOffset: number; // 真实值保底值(展示单位)
growthKind: 'mult' | 'add'; // 自增长方式 growthKind: 'mult' | 'add'; // 自增长方式
multMin: number; // 1.0 ~ multMin: number; // 1.0 ~
multMax: number; multMax: number;
@@ -127,7 +128,7 @@ function predictNext(it: StatItem): string | null {
const hi = Math.floor((cur * it.random_mult_max) / 1000); const hi = Math.floor((cur * it.random_mult_max) / 1000);
return `${d(lo)} ~ ${d(hi)} ${u}`; return `${d(lo)} ~ ${d(hi)} ${u}`;
} }
const target = it.mode === 'real' ? it.real_value + it.real_offset : it.manual_value ?? cur; const target = it.mode === 'real' ? Math.max(it.real_value, it.real_offset) : it.manual_value ?? cur;
const eff = !it.allow_decrease && target < cur ? cur : target; const eff = !it.allow_decrease && target < cur ? cur : target;
return `${it.mode === 'real' ? '≈ ' : ''}${d(eff)} ${u}`; return `${it.mode === 'real' ? '≈ ' : ''}${d(eff)} ${u}`;
} }
@@ -150,6 +151,101 @@ function nextRefreshLabel(tickSeconds: number, anchorMinutes: number): string {
}); });
} }
// ===== 三者关系「智能校验」(劝阻式,非硬拦)=====
// 触发阈值比「理想区间」宽,只在明显离谱时弹窗;理想区间在弹窗里作为建议展示。
const REL_PER_USER = { min: 2, max: 20, ideal: '4~8 次/人' }; // 人均比价 = 完成比价 / 帮助用户
const REL_PER_COMPARE = { min: 2, max: 30, ideal: '5~12 元/次' }; // 每次省额 = 累计节省(元) / 完成比价
function fmtNum(n: number): string {
return n >= 10000 ? `${(n / 10000).toFixed(2).replace(/\.?0+$/, '')}` : String(Math.round(n));
}
// 只增不减护栏的前端镜像:目标低于现值且未允许下降时,实际仍显示现值。
function guardedBase(allowDecrease: boolean, current: number | null, target: number | null): number | null {
if (target == null) return current;
if (!allowDecrease && current != null && target < current) return current;
return target;
}
// 本次保存后,该指标「大概会显示」的值(基础单位:total_saved 为分)。
function intendedBase(it: StatItem, e: Edit): number | null {
const cur = it.random_current;
if (e.mode === 'manual') {
const t = e.manual != null ? (dispToBase(it.metric, e.manual) as number) : cur;
return guardedBase(e.allowDecrease, cur, t);
}
if (e.mode === 'real') {
const t = Math.max(it.real_value, (dispToBase(it.metric, e.realOffset) as number) ?? 0);
return guardedBase(e.allowDecrease, cur, t);
}
// random:填了初始基数=设起点(过护栏);留空=保持现展示值
if (e.initial != null) return guardedBase(e.allowDecrease, cur, dispToBase(it.metric, e.initial) as number);
return cur;
}
// 校验三者关系。返回 null=数据不全无法判断(跳过校验);否则 warnings 为空=合理。
// 被改的指标用「本次要设的目标值」,另两个用各自当前展示值 random_current。
function checkRelations(
items: StatItem[],
edits: Record<string, Edit>,
): { warnings: string[]; users: number; compares: number; savedYuan: number } | null {
const valBase = (metric: string): number | null => {
const it = items.find((x) => x.metric === metric);
const e = it ? edits[metric] : undefined;
return it && e ? intendedBase(it, e) : null;
};
const users = valBase('help_users');
const compares = valBase('total_compares');
const savedBase = valBase('total_saved');
if (users == null || compares == null || savedBase == null) return null;
if (users <= 0 || compares <= 0 || savedBase <= 0) return null;
const savedYuan = savedBase / 100;
const warnings: string[] = [];
if (compares < users) {
warnings.push(`完成比价(${fmtNum(compares)})少于帮助用户(${fmtNum(users)}),正常应「次数 ≥ 人数」`);
}
const perUser = compares / users;
if (perUser < REL_PER_USER.min || perUser > REL_PER_USER.max) {
warnings.push(
`人均比价 ${perUser.toFixed(1)} 次/人,${perUser < REL_PER_USER.min ? '偏低' : '偏高'}(建议 ${REL_PER_USER.ideal})`,
);
}
const perCompare = savedYuan / compares;
if (perCompare < REL_PER_COMPARE.min || perCompare > REL_PER_COMPARE.max) {
warnings.push(
`每次省 ${perCompare.toFixed(1)} 元/次,${perCompare < REL_PER_COMPARE.min ? '偏低' : '偏高'}(建议 ${REL_PER_COMPARE.ideal})`,
);
}
return { warnings, users, compares, savedYuan };
}
// 把某指标的编辑态打包成 PATCH body(三项共用,逐个指标各打一份)。
function buildBody(metric: string, e: Edit, immediate: boolean): Record<string, number | string | boolean> {
// 自定义不暴露间隔,固定为每天(每日「更新时间」刷新一次);其余模式用所选间隔。
const intervalSec =
e.mode === 'manual'
? 86400
: Math.max(60, Math.round(e.intervalNum) * UNIT_SECONDS[e.intervalUnit]);
const anchorMin = e.anchorHour * 60 + e.anchorMinute; // 更新时间(全天 HH:MM),后端按间隔取相位
const body: Record<string, number | string | boolean> = {
mode: e.mode,
random_mult_min: Math.round(e.multMin * 1000),
random_mult_max: Math.round(e.multMax * 1000),
random_tick_seconds: intervalSec,
random_anchor_minutes: anchorMin,
random_kind: e.growthKind,
random_step_min: (dispToBase(metric, e.stepMin) as number) ?? 0,
random_step_max: (dispToBase(metric, e.stepMax) as number) ?? 0,
real_offset: (dispToBase(metric, e.realOffset) as number) ?? 0,
allow_decrease: e.allowDecrease,
};
if (e.manual != null) body.manual_value = dispToBase(metric, e.manual) as number;
if (e.initial != null) body.random_initial = dispToBase(metric, e.initial) as number;
if (immediate) body.apply_now = true;
return body;
}
/** 首页三门面数字(帮助用户/完成比价/累计节省)的展示模式配置。作为「数据大盘」页的一个区块。 */ /** 首页三门面数字(帮助用户/完成比价/累计节省)的展示模式配置。作为「数据大盘」页的一个区块。 */
export default function HomeStatsConfig() { export default function HomeStatsConfig() {
const [items, setItems] = useState<StatItem[]>([]); const [items, setItems] = useState<StatItem[]>([]);
@@ -188,52 +284,67 @@ export default function HomeStatsConfig() {
const patch = (metric: string, p: Partial<Edit>) => const patch = (metric: string, p: Partial<Edit>) =>
setEdits((s) => ({ ...s, [metric]: { ...s[metric], ...p } })); setEdits((s) => ({ ...s, [metric]: { ...s[metric], ...p } }));
// immediate=true:点「立即更新」,保存配置 + 马上刷新展示值(不等更新钟点)。 // 三项共享:一次保存 / 立即更新全部三个指标(各打一份 PATCH)。
const save = async (it: StatItem, immediate = false) => { // 校验三者最终关系,不合理则弹窗劝阻、确认才提交。
const e = edits[it.metric]; const saveAll = async (immediate = false) => {
// 自定义不暴露间隔,固定为每天(每日「更新时间」刷新一次);其余模式用所选间隔。 const proceed = async () => {
const intervalSec = setSaving('__all__');
e.mode === 'manual' // 逐个 PATCH(顺序提交,各自 commit 一行)。results 在 try 外声明,半失败时也能把已成功的合并进 state。
? 86400 const results: StatItem[] = [];
: Math.max(60, Math.round(e.intervalNum) * UNIT_SECONDS[e.intervalUnit]); try {
const anchorMin = e.anchorHour * 60 + e.anchorMinute; // 更新时间(全天 HH:MM),后端按间隔取相位 for (const it of items) {
const body: Record<string, number | string | boolean> = { const { data } = await api.patch<StatItem>(
mode: e.mode, `/admin/api/dashboard-display/${it.metric}`,
random_mult_min: Math.round(e.multMin * 1000), buildBody(it.metric, edits[it.metric], immediate),
random_mult_max: Math.round(e.multMax * 1000), );
random_tick_seconds: intervalSec, results.push(data);
random_anchor_minutes: anchorMin, }
random_kind: e.growthKind,
random_step_min: (dispToBase(it.metric, e.stepMin) as number) ?? 0,
random_step_max: (dispToBase(it.metric, e.stepMax) as number) ?? 0,
real_offset: (dispToBase(it.metric, e.realOffset) as number) ?? 0,
allow_decrease: e.allowDecrease,
};
if (e.manual != null) body.manual_value = dispToBase(it.metric, e.manual) as number;
if (e.initial != null) body.random_initial = dispToBase(it.metric, e.initial) as number;
if (immediate) body.apply_now = true;
setSaving(it.metric);
try {
// PATCH 返回更新后的该指标;只就地替换这一项 + 重置它的输入态,不整页 reload(消除闪烁)。
const { data } = await api.patch<StatItem>(`/admin/api/dashboard-display/${it.metric}`, body);
setItems((prev) => prev.map((x) => (x.metric === it.metric ? data : x)));
setEdits((s) => ({ ...s, [it.metric]: toEdit(data) }));
if (immediate) {
message.success('已立即更新,客户端下次进首页即可见');
} else {
const lastTickMs = data.random_last_tick_at ? new Date(data.random_last_tick_at).getTime() : 0;
const effectiveNow = Date.now() - lastTickMs < 10_000;
message.success( message.success(
effectiveNow immediate
? '已保存,客户端下次进首页即可见' ? '三项已立即更新,客户端下次进首页即可见'
: `已保存,展示值将于北京时间 ${nextRefreshLabel(data.random_tick_seconds, data.random_anchor_minutes)} 刷新后生效`, : '三项配置已保存,展示值将按各自「更新时间」刷新后生效(自定义/立即更新即时生效)',
); );
} catch (err) {
// 前 results.length 项后端已 commit,失败的是第 results.length+1 项
message.error(`${results.length + 1} 项保存失败(前 ${results.length} 项已成功): ${errMsg(err)}`);
} finally {
// 半失败也把已成功的就地合并进 state,避免前端显旧值与后端撕裂
if (results.length > 0) {
setItems((prev) => prev.map((x) => results.find((r) => r.metric === x.metric) ?? x));
setEdits((s) => ({ ...s, ...Object.fromEntries(results.map((d) => [d.metric, toEdit(d)])) }));
}
setSaving(null);
} }
} catch (err) { };
message.error(errMsg(err));
} finally { // 智能校验:按下后三者大概会呈现的关系是否合理,不合理则弹窗劝阻、确认才提交。
setSaving(null); const rel = checkRelations(items, edits);
if (rel && rel.warnings.length > 0) {
Modal.confirm({
title: '数据关系校验',
width: 480,
okText: '仍要这样设置',
cancelText: '返回修改',
content: (
<div style={{ fontSize: 13 }}>
<div style={{ marginBottom: 8 }}>:</div>
<ul style={{ paddingLeft: 18, margin: '0 0 8px' }}>
{rel.warnings.map((w, i) => (
<li key={i} style={{ color: '#d4380d', marginBottom: 2 }}>
{w}
</li>
))}
</ul>
<div style={{ color: '#888' }}>
当前预计:帮助用户 {fmtNum(rel.users)} · {fmtNum(rel.compares)} · {' '}
{fmtNum(rel.savedYuan)}
</div>
</div>
),
onOk: proceed,
});
} else {
await proceed();
} }
}; };
@@ -241,7 +352,7 @@ export default function HomeStatsConfig() {
<div style={{ marginTop: 24 }}> <div style={{ marginTop: 24 }}>
<h3 style={{ marginBottom: 4 }}></h3> <h3 style={{ marginBottom: 4 }}></h3>
<p style={{ color: '#999', marginTop: 0 }}> <p style={{ color: '#999', marginTop: 0 }}>
:<b></b>(,)/{' '} :<b></b>(,)/{' '}
<b></b>()/ <b></b>() <b></b>()/ <b></b>()
<b></b>(); <b></b>();
</p> </p>
@@ -260,6 +371,20 @@ export default function HomeStatsConfig() {
e.manual != null && e.manual != null &&
it.random_current != null && it.random_current != null &&
(dispToBase(it.metric, e.manual) as number) < it.random_current; (dispToBase(it.metric, e.manual) as number) < it.random_current;
// real 同理:max(真实值,保底值) 低于当前展示值且未允许下降 → 立即更新会被只增不减护栏挡住
const realTargetBase = Math.max(it.real_value, (dispToBase(it.metric, e.realOffset) as number) ?? 0);
const realBlocked =
e.mode === 'real' &&
!e.allowDecrease &&
it.random_current != null &&
realTargetBase < it.random_current;
// 自增长:填的初始基数低于当前展示值且未允许下降 → 会被只增不减护栏挡住(保持现值)
const initialBlocked =
e.mode === 'random' &&
!e.allowDecrease &&
e.initial != null &&
it.random_current != null &&
(dispToBase(it.metric, e.initial) as number) < it.random_current;
return ( return (
<Col key={it.metric} xs={24} md={12} xl={8}> <Col key={it.metric} xs={24} md={12} xl={8}>
<Card <Card
@@ -307,19 +432,26 @@ export default function HomeStatsConfig() {
)} )}
{e.mode === 'real' && ( {e.mode === 'real' && (
<Space wrap> <div>
<span>:</span> <Space wrap>
<InputNumber <span>:</span>
value={e.realOffset} <InputNumber
min={0} value={e.realOffset}
onChange={(v) => patch(it.metric, { realOffset: v ?? 0 })} min={0}
style={{ width: 160 }} onChange={(v) => patch(it.metric, { realOffset: v ?? 0 })}
/> style={{ width: 160 }}
<span style={{ color: '#666' }}>{u}</span> />
<Tooltip title="展示值 = 真实值 + 此基数。冷启动期真实数字小,加个体面的基数,且仍随真实增长。"> <span style={{ color: '#666' }}>{u}</span>
<span style={{ color: '#999', cursor: 'help' }}></span> <Tooltip title="展示值 = max(真实值, 保底值)。冷启动期真实数字小,显示保底撑场面;真实值涨过保底后显示纯真实、零差距。留 0 = 纯真实值。">
</Tooltip> <span style={{ color: '#999', cursor: 'help' }}></span>
</Space> </Tooltip>
</Space>
{realBlocked && (
<div style={{ color: '#fa8c16', fontSize: 12, marginTop: 4 }}>
,()
</div>
)}
</div>
)} )}
{e.mode === 'random' && ( {e.mode === 'random' && (
@@ -431,17 +563,26 @@ export default function HomeStatsConfig() {
</Space> </Space>
{e.mode === 'random' && ( {e.mode === 'random' && (
<Space wrap> <div>
<span>:</span> <Space wrap>
<InputNumber <span>:</span>
value={e.initial} <InputNumber
min={0} value={e.initial}
onChange={(v) => patch(it.metric, { initial: v })} min={0}
style={{ width: 200 }} onChange={(v) => patch(it.metric, { initial: v })}
placeholder="留空=用真实值播种" style={{ width: 200 }}
/> />
<span style={{ color: '#666' }}>{u}</span> <span style={{ color: '#666' }}>{u}</span>
</Space> <Tooltip title="填值=把展示值设为该数作为增长起点(受『只增不减』限制:低于当前展示值需先勾「允许数字下降」)。留空=保持现有展示值不变、继续按增量增长;仅当该指标从未启用过(还没有展示值)时,留空才用真实值播种。">
<span style={{ color: '#999', cursor: 'help' }}></span>
</Tooltip>
</Space>
{initialBlocked && (
<div style={{ color: '#fa8c16', fontSize: 12, marginTop: 4 }}>
,()
</div>
)}
</div>
)} )}
<Checkbox <Checkbox
@@ -474,21 +615,10 @@ export default function HomeStatsConfig() {
)} )}
</Space> </Space>
<Space> <Tag color={it.mode === 'real' ? 'green' : it.mode === 'manual' ? 'orange' : 'blue'}>
<Button type="primary" loading={saving === it.metric} onClick={() => save(it)}> 线:
{it.mode === 'real' ? '真实值' : it.mode === 'manual' ? '自定义' : '自增长'}
</Button> </Tag>
<Popconfirm
title="立即更新会马上推进一次(自增长走一档),不等更新时间。确定?"
onConfirm={() => save(it, true)}
>
<Button loading={saving === it.metric}></Button>
</Popconfirm>
<Tag color={it.mode === 'real' ? 'green' : it.mode === 'manual' ? 'orange' : 'blue'}>
线:
{it.mode === 'real' ? '真实值' : it.mode === 'manual' ? '自定义' : '自增长'}
</Tag>
</Space>
</Space> </Space>
</Card> </Card>
</Col> </Col>
@@ -496,6 +626,22 @@ export default function HomeStatsConfig() {
})} })}
</Row> </Row>
)} )}
{!loading && (
<div style={{ marginTop: 16 }}>
<Space>
<Button type="primary" loading={saving != null} onClick={() => saveAll(false)}>
</Button>
<Popconfirm
title="立即更新会马上把三项都推进一次(自增长各走一档),不等更新时间。确定?"
onConfirm={() => saveAll(true)}
>
<Button loading={saving != null}></Button>
</Popconfirm>
<span style={{ color: '#999', fontSize: 12 }}>,</span>
</Space>
</div>
)}
</div> </div>
); );
} }
+67 -3
View File
@@ -18,9 +18,10 @@ import {
import { api, errMsg } from '@/lib/api'; import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth'; import { canDo } from '@/lib/auth';
import { useCursorList } from '@/lib/useCursorList'; import { useCursorList } from '@/lib/useCursorList';
import type { UserListItem } from '@/lib/types'; import type { UserListItem, UserOverview } from '@/lib/types';
const STATUS_COLOR: Record<string, string> = { active: 'green', disabled: 'red', deleted: 'default' }; const STATUS_COLOR: Record<string, string> = { active: 'green', disabled: 'red', deleted: 'default' };
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
export default function UsersPage() { export default function UsersPage() {
const router = useRouter(); const router = useRouter();
@@ -37,6 +38,20 @@ export default function UsersPage() {
const [coinUser, setCoinUser] = useState<UserListItem | null>(null); const [coinUser, setCoinUser] = useState<UserListItem | null>(null);
const [coinForm] = Form.useForm(); const [coinForm] = Form.useForm();
const [cashUser, setCashUser] = useState<UserListItem | null>(null);
const [cashForm] = Form.useForm();
// 弹窗里展示该用户当前余额:列表行本身不带余额,开弹窗时拉一次 360 概览
const [balances, setBalances] = useState<{ coin: number; cashCents: number } | null>(null);
const openAdjust = (u: UserListItem, kind: 'coin' | 'cash') => {
setBalances(null);
if (kind === 'coin') setCoinUser(u);
else setCashUser(u);
api
.get<UserOverview>(`/admin/api/users/${u.id}`)
.then((r) => setBalances({ coin: r.data.coin_balance, cashCents: r.data.cash_balance_cents }))
.catch(() => {});
};
const search = () => setFilters({ phone: phone || undefined, status }); const search = () => setFilters({ phone: phone || undefined, status });
@@ -52,6 +67,27 @@ export default function UsersPage() {
} }
}; };
// 发/扣现金:输入元,×100 转分后调后端(主要给无现金用户发钱直接测试提现)
const submitCash = async () => {
const v = await cashForm.validateFields();
const amountCents = Math.round(Number(v.amount_yuan) * 100);
if (amountCents === 0) {
message.warning('金额不能为 0(且不小于 1 分)');
return;
}
try {
await api.post(`/admin/api/users/${cashUser!.id}/cash`, {
amount_cents: amountCents,
reason: v.reason,
});
message.success(`${amountCents > 0 ? '发放' : '扣减'} ¥${(Math.abs(amountCents) / 100).toFixed(2)} 现金`);
setCashUser(null);
cashForm.resetFields();
} catch (e) {
message.error(errMsg(e));
}
};
const toggleStatus = (u: UserListItem) => { const toggleStatus = (u: UserListItem) => {
const next = u.status === 'active' ? 'disabled' : 'active'; const next = u.status === 'active' ? 'disabled' : 'active';
Modal.confirm({ Modal.confirm({
@@ -109,7 +145,8 @@ export default function UsersPage() {
render: (_: unknown, u: UserListItem) => ( render: (_: unknown, u: UserListItem) => (
<Space> <Space>
<a onClick={() => router.push(`/users/${u.id}`)}></a> <a onClick={() => router.push(`/users/${u.id}`)}></a>
{canCoins && <a onClick={() => setCoinUser(u)}></a>} {canCoins && <a onClick={() => openAdjust(u, 'coin')}></a>}
{canCoins && <a onClick={() => openAdjust(u, 'cash')}></a>}
{canStatus && u.status !== 'deleted' && ( {canStatus && u.status !== 'deleted' && (
<a onClick={() => toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'}</a> <a onClick={() => toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'}</a>
)} )}
@@ -163,8 +200,11 @@ export default function UsersPage() {
open={!!coinUser} open={!!coinUser}
onOk={submitCoins} onOk={submitCoins}
onCancel={() => setCoinUser(null)} onCancel={() => setCoinUser(null)}
destroyOnClose destroyOnHidden
> >
<p style={{ color: '#888', marginBottom: 12 }}>
当前余额:金币 {balances ? balances.coin : '…'} {balances ? yuan(balances.cashCents) : '…'}
</p>
<Form form={coinForm} layout="vertical"> <Form form={coinForm} layout="vertical">
<Form.Item name="amount" label="金币变动(正=增加,负=扣减)" rules={[{ required: true }]}> <Form.Item name="amount" label="金币变动(正=增加,负=扣减)" rules={[{ required: true }]}>
<InputNumber style={{ width: '100%' }} /> <InputNumber style={{ width: '100%' }} />
@@ -174,6 +214,30 @@ export default function UsersPage() {
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
<Modal
title={`调现金 - ${cashUser?.phone ?? ''}`}
open={!!cashUser}
onOk={submitCash}
onCancel={() => setCashUser(null)}
destroyOnHidden
>
<p style={{ color: '#888', marginBottom: 12 }}>
当前余额:金币 {balances ? balances.coin : '…'} {balances ? yuan(balances.cashCents) : '…'}
</p>
<Form form={cashForm} layout="vertical">
<Form.Item
name="amount_yuan"
label="现金变动(元,正=发放,负=扣减;不可扣成负)"
rules={[{ required: true }]}
>
<InputNumber style={{ width: '100%' }} step={0.01} precision={2} addonAfter="元" />
</Form.Item>
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true }]}>
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
} }