From ea211fdf45d41c8c1eb9f63a73018222d4803181 Mon Sep 17 00:00:00 2001
From: OuYingJun1024 <1034284404@qq.com>
Date: Wed, 10 Jun 2026 20:48:43 +0800
Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=E9=A6=96=E9=A1=B5=E4=B8=89?=
=?UTF-8?q?=E6=95=B0=E5=85=B1=E4=BA=AB=E4=BF=9D=E5=AD=98+=E6=99=BA?=
=?UTF-8?q?=E8=83=BD=E6=A0=A1=E9=AA=8C+=E4=BF=9D=E5=BA=95;=E8=BD=AE?=
=?UTF-8?q?=E6=92=AD=E7=A7=8D=E5=AD=90=E6=89=B9=E9=87=8F=E6=93=8D=E4=BD=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- HomeStatsConfig: 三指标共享保存/立即更新;智能关系校验弹窗;真实值保底;初始基数护栏提示;saveAll 半失败合并
- HomeMarqueeSeeds: 勾选 + 批量启用/停用/删除
- 附带并行会话 WIP:users 页
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/app/(main)/dashboard/HomeMarqueeSeeds.tsx | 55 ++-
src/app/(main)/dashboard/HomeStatsConfig.tsx | 318 +++++++++++++-----
src/app/(main)/users/page.tsx | 70 +++-
3 files changed, 353 insertions(+), 90 deletions(-)
diff --git a/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx b/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx
index 8b18666..4fe0db7 100644
--- a/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx
+++ b/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx
@@ -53,6 +53,7 @@ const yuan = (cents: number) => (cents / 100).toFixed(2);
export default function HomeMarqueeSeeds() {
const [seeds, setSeeds] = useState([]);
const [loading, setLoading] = useState(true);
+ const [selectedRowKeys, setSelectedRowKeys] = useState([]); // 批量操作选中行
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState(null);
@@ -70,6 +71,8 @@ export default function HomeMarqueeSeeds() {
try {
const { data } = await api.get('/admin/api/marquee-seeds');
setSeeds(data);
+ // 列表刷新后清掉已不存在的选中 id(单行删除/外部变更后防悬空,顶部计数与批量操作才准)
+ setSelectedRowKeys((prev) => prev.filter((k) => data.some((s) => s.id === k)));
} finally {
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 = [
{
title: '用户名',
@@ -229,7 +259,7 @@ export default function HomeMarqueeSeeds() {
首页「用户xxx 比价后节省xx元」轮播的兜底假数据。真实比价记录不足时随机抽取这些补齐;停用的不参与混播。
用户名留空则每次展示随机合成;金额为区间则每次随机取值。
-
+
@@ -239,11 +269,34 @@ export default function HomeMarqueeSeeds() {
+
+
+
+
+
+
+ {selectedRowKeys.length > 0 && (
+ 已选 {selectedRowKeys.length} 条
+ )}
setSelectedRowKeys(keys.map(Number)),
+ }}
columns={columns}
dataSource={seeds}
pagination={false}
diff --git a/src/app/(main)/dashboard/HomeStatsConfig.tsx b/src/app/(main)/dashboard/HomeStatsConfig.tsx
index 0b446b2..cb6d6b5 100644
--- a/src/app/(main)/dashboard/HomeStatsConfig.tsx
+++ b/src/app/(main)/dashboard/HomeStatsConfig.tsx
@@ -7,6 +7,7 @@ import {
Checkbox,
Col,
InputNumber,
+ Modal,
Popconfirm,
Radio,
Row,
@@ -32,7 +33,7 @@ interface StatItem {
random_kind: 'mult' | 'add'; // 自增长方式:×倍率 / +绝对增量
random_step_min: number; // 绝对增量区间(基础单位)
random_step_max: number;
- real_offset: number; // 真实值基数偏移(基础单位)
+ real_offset: number; // 真实值保底值(基础单位):展示=max(真实,保底)。沿用 real_offset 列名(语义已改)
allow_decrease: boolean; // 是否允许展示值下降
random_current: number | null;
random_last_tick_at: string | null;
@@ -54,7 +55,7 @@ function decomposeInterval(sec: number): { num: number; unit: IntervalUnit } {
interface Edit {
mode: 'real' | 'manual' | 'random';
manual: number | null; // 展示单位
- realOffset: number; // 真实值偏移(展示单位)
+ realOffset: number; // 真实值保底值(展示单位)
growthKind: 'mult' | 'add'; // 自增长方式
multMin: number; // 1.0 ~
multMax: number;
@@ -127,7 +128,7 @@ function predictNext(it: StatItem): string | null {
const hi = Math.floor((cur * it.random_mult_max) / 1000);
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;
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,
+): { 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 {
+ // 自定义不暴露间隔,固定为每天(每日「更新时间」刷新一次);其余模式用所选间隔。
+ 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 = {
+ 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() {
const [items, setItems] = useState([]);
@@ -188,52 +284,67 @@ export default function HomeStatsConfig() {
const patch = (metric: string, p: Partial) =>
setEdits((s) => ({ ...s, [metric]: { ...s[metric], ...p } }));
- // immediate=true:点「立即更新」,保存配置 + 马上刷新展示值(不等更新钟点)。
- const save = async (it: StatItem, immediate = false) => {
- const e = edits[it.metric];
- // 自定义不暴露间隔,固定为每天(每日「更新时间」刷新一次);其余模式用所选间隔。
- 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 = {
- 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(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(`/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;
+ // 三项共享:一次保存 / 立即更新全部三个指标(各打一份 PATCH)。
+ // 校验三者最终关系,不合理则弹窗劝阻、确认才提交。
+ const saveAll = async (immediate = false) => {
+ const proceed = async () => {
+ setSaving('__all__');
+ // 逐个 PATCH(顺序提交,各自 commit 一行)。results 在 try 外声明,半失败时也能把已成功的合并进 state。
+ const results: StatItem[] = [];
+ try {
+ for (const it of items) {
+ const { data } = await api.patch(
+ `/admin/api/dashboard-display/${it.metric}`,
+ buildBody(it.metric, edits[it.metric], immediate),
+ );
+ results.push(data);
+ }
message.success(
- effectiveNow
- ? '已保存,客户端下次进首页即可见'
- : `已保存,展示值将于北京时间 ${nextRefreshLabel(data.random_tick_seconds, data.random_anchor_minutes)} 刷新后生效`,
+ immediate
+ ? '三项已立即更新,客户端下次进首页即可见'
+ : '三项配置已保存,展示值将按各自「更新时间」刷新后生效(自定义/立即更新即时生效)',
);
+ } 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: (
+
+
检测到三项门面数据关系不太真实:
+
+ {rel.warnings.map((w, i) => (
+ -
+ {w}
+
+ ))}
+
+
+ 当前预计:帮助用户 {fmtNum(rel.users)} · 完成比价 {fmtNum(rel.compares)} · 累计节省{' '}
+ {fmtNum(rel.savedYuan)}元
+
+
+ ),
+ onOk: proceed,
+ });
+ } else {
+ await proceed();
}
};
@@ -241,7 +352,7 @@ export default function HomeStatsConfig() {
首页数据配置
- 客户端首页三个门面数字。每个指标独立选模式:真实值(实时查库,可加基数偏移)/{' '}
+ 客户端首页三个门面数字。每个指标独立选模式:真实值(实时查库,可设保底值)/{' '}
自定义(固定值)/ 自增长(到对齐钟点按倍率或固定增量增长)。
默认只增不减(防门面缩水);每次改动进审计日志。
@@ -260,6 +371,20 @@ export default function HomeStatsConfig() {
e.manual != null &&
it.random_current != null &&
(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 (
- 基数偏移:
- patch(it.metric, { realOffset: v ?? 0 })}
- style={{ width: 160 }}
- />
- {u}
-
- ⓘ
-
-
+
+
+ 保底值:
+ patch(it.metric, { realOffset: v ?? 0 })}
+ style={{ width: 160 }}
+ />
+ {u}
+
+ ⓘ
+
+
+ {realBlocked && (
+
+ 真实值与保底取大后仍低于当前展示值,默认不下调(如需下调请勾「允许数字下降」)
+
+ )}
+
)}
{e.mode === 'random' && (
@@ -431,17 +563,26 @@ export default function HomeStatsConfig() {
{e.mode === 'random' && (
-
- 初始基数:
- patch(it.metric, { initial: v })}
- style={{ width: 200 }}
- placeholder="留空=用真实值播种"
- />
- {u}
-
+
+
+ 初始基数:
+ patch(it.metric, { initial: v })}
+ style={{ width: 200 }}
+ />
+ {u}
+
+ ⓘ
+
+
+ {initialBlocked && (
+
+ 低于当前展示值,默认不下调(如需下调请勾「允许数字下降」)
+
+ )}
+
)}
-
-
- save(it, true)}
- >
-
-
-
- 线上当前:
- {it.mode === 'real' ? '真实值' : it.mode === 'manual' ? '自定义' : '自增长'}
-
-
+
+ 线上当前:
+ {it.mode === 'real' ? '真实值' : it.mode === 'manual' ? '自定义' : '自增长'}
+
@@ -496,6 +626,22 @@ export default function HomeStatsConfig() {
})}
)}
+ {!loading && (
+
+
+
+ saveAll(true)}
+ >
+
+
+ 三项配置共用,一次保存全部生效
+
+
+ )}
);
}
diff --git a/src/app/(main)/users/page.tsx b/src/app/(main)/users/page.tsx
index f13ea44..79d8214 100644
--- a/src/app/(main)/users/page.tsx
+++ b/src/app/(main)/users/page.tsx
@@ -18,9 +18,10 @@ import {
import { api, errMsg } from '@/lib/api';
import { canDo } from '@/lib/auth';
import { useCursorList } from '@/lib/useCursorList';
-import type { UserListItem } from '@/lib/types';
+import type { UserListItem, UserOverview } from '@/lib/types';
const STATUS_COLOR: Record = { active: 'green', disabled: 'red', deleted: 'default' };
+const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
export default function UsersPage() {
const router = useRouter();
@@ -37,6 +38,20 @@ export default function UsersPage() {
const [coinUser, setCoinUser] = useState(null);
const [coinForm] = Form.useForm();
+ const [cashUser, setCashUser] = useState(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(`/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 });
@@ -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 next = u.status === 'active' ? 'disabled' : 'active';
Modal.confirm({
@@ -109,7 +145,8 @@ export default function UsersPage() {
render: (_: unknown, u: UserListItem) => (
router.push(`/users/${u.id}`)}>详情
- {canCoins && setCoinUser(u)}>调金币}
+ {canCoins && openAdjust(u, 'coin')}>调金币}
+ {canCoins && openAdjust(u, 'cash')}>调现金}
{canStatus && u.status !== 'deleted' && (
toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'}
)}
@@ -163,8 +200,11 @@ export default function UsersPage() {
open={!!coinUser}
onOk={submitCoins}
onCancel={() => setCoinUser(null)}
- destroyOnClose
+ destroyOnHidden
>
+
+ 当前余额:金币 {balances ? balances.coin : '…'} | 现金 {balances ? yuan(balances.cashCents) : '…'}
+
@@ -174,6 +214,30 @@ export default function UsersPage() {
+
+ setCashUser(null)}
+ destroyOnHidden
+ >
+
+ 当前余额:金币 {balances ? balances.coin : '…'} | 现金 {balances ? yuan(balances.cashCents) : '…'}
+
+
+
+
+
+
+
+
+
);
}