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) : '…'} +

+
+ + + + + + + +
); }