From 1717ee8b6a7391ca08dcddf4f3fec7af6cdc0139 Mon Sep 17 00:00:00 2001 From: ouzhou Date: Wed, 10 Jun 2026 22:26:36 +0800 Subject: [PATCH 01/19] =?UTF-8?q?feat(dashboard):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E4=B8=89=E6=95=B0=E5=85=B1=E4=BA=AB=E4=BF=9D=E5=AD=98+?= =?UTF-8?q?=E6=99=BA=E8=83=BD=E6=A0=A1=E9=AA=8C+=E4=BF=9D=E5=BA=95;?= =?UTF-8?q?=E8=BD=AE=E6=92=AD=E7=A7=8D=E5=AD=90=E6=89=B9=E9=87=8F=E6=93=8D?= =?UTF-8?q?=E4=BD=9C=20(#7)?= 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) --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/7 Co-authored-by: ouzhou Co-committed-by: ouzhou --- 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) : '…'} +

+
+ + + + + + + +
); } From c66d90a6e1e2700b258d9a5816ddecb4c28dacc7 Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Wed, 10 Jun 2026 22:26:41 +0800 Subject: [PATCH 02/19] =?UTF-8?q?feat(force-onboarding):=20=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E5=88=97=E8=A1=A8=E5=8A=A0=E3=80=8C=E5=BC=80=E5=90=AF?= =?UTF-8?q?=E6=96=B0=E6=89=8B=E5=BC=95=E5=AF=BC=E3=80=8D=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E6=80=A7=E6=93=8D=E4=BD=9C=20(#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点一下把该用户标记为未看过引导:Ta 下次打开 App 重走一遍新手引导,看完后端自动恢复(只触发这一次);未消费前显示「待重看」可撤销。operator 角色可见。 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/8 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- src/app/(main)/users/page.tsx | 31 +++++++++++++++++++++++++++++++ src/lib/types.ts | 2 ++ 2 files changed, 33 insertions(+) diff --git a/src/app/(main)/users/page.tsx b/src/app/(main)/users/page.tsx index 79d8214..d88f238 100644 --- a/src/app/(main)/users/page.tsx +++ b/src/app/(main)/users/page.tsx @@ -123,6 +123,28 @@ export default function UsersPage() { }); }; + // 一次性操作:点一下把该用户标记为「未看过引导」,下次打开 App 重看一遍,看完后端自动恢复(只触发这一次)。 + // 用户尚未重看前可「撤销」(把 force_onboarding 置回 false),纠正误点。 + const setForceOnboarding = (u: UserListItem, enable: boolean) => { + Modal.confirm({ + title: enable + ? `确认让用户 ${u.phone} 重看一次新手引导?` + : `撤销用户 ${u.phone} 的「重看新手引导」?`, + content: enable + ? '点一下即把该用户标记为「未看过引导」:Ta 下次打开 App 会重走一遍新手引导,看完自动恢复——只触发这一次。仅对已支持该功能的 App 版本生效。' + : '该用户尚未重看,撤销后下次打开 App 不再触发。', + onOk: async () => { + try { + await api.post(`/admin/api/users/${u.id}/force-onboarding`, { enabled: enable }); + message.success(enable ? '已开启:该用户下次打开 App 会重看一次' : '已撤销'); + reload(); + } catch (e) { + message.error(errMsg(e)); + } + }, + }); + }; + const columns: ColumnsType = [ { title: 'ID', dataIndex: 'id', width: 70 }, { title: '手机号', dataIndex: 'phone' }, @@ -155,6 +177,15 @@ export default function UsersPage() { {u.debug_trace_enabled ? '关调试链接' : '开调试链接'} )} + {canStatus && + u.status !== 'deleted' && + (u.force_onboarding ? ( + + 待重看 setForceOnboarding(u, false)}>撤销 + + ) : ( + setForceOnboarding(u, true)}>开启新手引导 + ))}
), }, diff --git a/src/lib/types.ts b/src/lib/types.ts index 80070cc..869432b 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -29,6 +29,8 @@ export interface UserListItem { status: string; // 调试链接权限:开了的用户在 App 比价结果页/记录页可复制 price.shaguabijia.com 的 trace 调试链接 debug_trace_enabled: boolean; + // 运营「一键开启新手引导」:true = 该用户下次打开 App 会被强制重走新手引导,走完自动取消 + force_onboarding: boolean; wechat_openid: string | null; created_at: string; last_login_at: string; From 2eff4a71be33655a1a22441362931846fb2dea13 Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 12 Jun 2026 00:51:51 +0800 Subject: [PATCH 03/19] =?UTF-8?q?feat(onboarding):=20=E5=88=A0=E3=80=8C?= =?UTF-8?q?=E6=8C=89=E7=94=A8=E6=88=B7=E5=BC=BA=E5=88=B6=E5=BC=95=E5=AF=BC?= =?UTF-8?q?=E3=80=8DUI=20+=20=E6=96=B0=E5=A2=9E=E8=AE=BE=E5=A4=87=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - users 页删 force_onboarding 操作 + types 字段 - 新增设备管理页(/devices): 按设备列表 + 行内「重走引导」+「全部重设」危险操作 + 菜单项 Co-Authored-By: Claude Opus 4.8 --- src/app/(main)/devices/page.tsx | 99 +++++++++++++++++++++++++++++++++ src/app/(main)/layout.tsx | 2 + src/app/(main)/users/page.tsx | 31 ----------- src/lib/types.ts | 9 ++- 4 files changed, 108 insertions(+), 33 deletions(-) create mode 100644 src/app/(main)/devices/page.tsx diff --git a/src/app/(main)/devices/page.tsx b/src/app/(main)/devices/page.tsx new file mode 100644 index 0000000..105f340 --- /dev/null +++ b/src/app/(main)/devices/page.tsx @@ -0,0 +1,99 @@ +'use client'; + +import type { ColumnsType } from 'antd/es/table'; +import { Button, Modal, Popconfirm, Space, Table, message } from 'antd'; +import { api, errMsg } from '@/lib/api'; +import { canDo } from '@/lib/auth'; +import { useCursorList } from '@/lib/useCursorList'; +import type { DeviceOnboardingItem } from '@/lib/types'; + +const dt = (v: string) => new Date(v).toLocaleString('zh-CN'); +const EMPTY: Record = {}; + +export default function DevicesPage() { + // 后端按设备聚合、全量返回(next_cursor 恒 null),故无筛选/加载更多。 + const { items, loading, reload } = useCursorList( + '/admin/api/onboarding/devices', + EMPTY, + ); + const canReset = canDo(['operator']); + + const resetDevice = async (d: DeviceOnboardingItem) => { + try { + await api.post(`/admin/api/onboarding/devices/${encodeURIComponent(d.device_id)}/reset`); + message.success('已重置:该设备所有账号下次打开 App 重走引导'); + reload(); + } catch (e) { + message.error(errMsg(e)); + } + }; + + const resetAll = () => { + Modal.confirm({ + title: '确认全部重设新手引导?', + content: + '清空所有设备的引导完成记录,库里所有用户下次打开 App 都会重走一遍新手引导。此操作不可逆。', + okText: '确认全部重设', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await api.post('/admin/api/onboarding/reset-all', { confirm: true }); + message.success('已全部重设:所有用户下次打开 App 会重走引导'); + reload(); + } catch (e) { + message.error(errMsg(e)); + } + }, + }); + }; + + const columns: ColumnsType = [ + { title: '设备(ANDROID_ID)', dataIndex: 'device_id' }, + { title: '走过引导的账号数', dataIndex: 'account_count', width: 160 }, + { title: '最近完成引导', dataIndex: 'last_completed_at', render: dt, width: 200 }, + { + title: '操作', + key: 'op', + width: 120, + render: (_: unknown, d: DeviceOnboardingItem) => + canReset ? ( + resetDevice(d)} + okText="确认" + cancelText="取消" + > + 重走引导 + + ) : ( + - + ), + }, + ]; + + return ( +
+

设备管理

+

+ 列出走过新手引导的设备(按硬件 ANDROID_ID 聚合)。「重走引导」删除该设备的引导完成记录, + 使其上所有账号下次打开 App 重走一遍。没走过引导的设备本就会引导,不在此列。 +

+ {canReset && ( + + + + )} +
+ + ); +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index 8672cc2..cd4b4c5 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -9,6 +9,7 @@ import { FundProjectionScreenOutlined, LogoutOutlined, MessageOutlined, + MobileOutlined, MoneyCollectOutlined, SettingOutlined, TeamOutlined, @@ -23,6 +24,7 @@ const { Sider, Header, Content } = Layout; const MENU = [ { key: '/dashboard', icon: , label: '数据大盘' }, { key: '/users', icon: , label: '用户管理' }, + { key: '/devices', icon: , label: '设备管理' }, { key: '/withdraws', icon: , label: '提现管理' }, { key: '/price-reports', icon: , label: '上报审核' }, { key: '/feedbacks', icon: , label: '反馈工单' }, diff --git a/src/app/(main)/users/page.tsx b/src/app/(main)/users/page.tsx index d88f238..79d8214 100644 --- a/src/app/(main)/users/page.tsx +++ b/src/app/(main)/users/page.tsx @@ -123,28 +123,6 @@ export default function UsersPage() { }); }; - // 一次性操作:点一下把该用户标记为「未看过引导」,下次打开 App 重看一遍,看完后端自动恢复(只触发这一次)。 - // 用户尚未重看前可「撤销」(把 force_onboarding 置回 false),纠正误点。 - const setForceOnboarding = (u: UserListItem, enable: boolean) => { - Modal.confirm({ - title: enable - ? `确认让用户 ${u.phone} 重看一次新手引导?` - : `撤销用户 ${u.phone} 的「重看新手引导」?`, - content: enable - ? '点一下即把该用户标记为「未看过引导」:Ta 下次打开 App 会重走一遍新手引导,看完自动恢复——只触发这一次。仅对已支持该功能的 App 版本生效。' - : '该用户尚未重看,撤销后下次打开 App 不再触发。', - onOk: async () => { - try { - await api.post(`/admin/api/users/${u.id}/force-onboarding`, { enabled: enable }); - message.success(enable ? '已开启:该用户下次打开 App 会重看一次' : '已撤销'); - reload(); - } catch (e) { - message.error(errMsg(e)); - } - }, - }); - }; - const columns: ColumnsType = [ { title: 'ID', dataIndex: 'id', width: 70 }, { title: '手机号', dataIndex: 'phone' }, @@ -177,15 +155,6 @@ export default function UsersPage() { {u.debug_trace_enabled ? '关调试链接' : '开调试链接'} )} - {canStatus && - u.status !== 'deleted' && - (u.force_onboarding ? ( - - 待重看 setForceOnboarding(u, false)}>撤销 - - ) : ( - setForceOnboarding(u, true)}>开启新手引导 - ))} ), }, diff --git a/src/lib/types.ts b/src/lib/types.ts index 869432b..23b151f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -29,13 +29,18 @@ export interface UserListItem { status: string; // 调试链接权限:开了的用户在 App 比价结果页/记录页可复制 price.shaguabijia.com 的 trace 调试链接 debug_trace_enabled: boolean; - // 运营「一键开启新手引导」:true = 该用户下次打开 App 会被强制重走新手引导,走完自动取消 - force_onboarding: boolean; wechat_openid: string | null; created_at: string; last_login_at: string; } +// 设备维度新手引导:一台设备(ANDROID_ID)聚合,走过引导的账号数 + 最近完成时间。 +export interface DeviceOnboardingItem { + device_id: string; + account_count: number; + last_completed_at: string; +} + export interface UserOverview { user: UserListItem; coin_balance: number; From 94d550db9b71b51f2e13e82e74913262d239acc8 Mon Sep 17 00:00:00 2001 From: ouzhou Date: Sat, 13 Jun 2026 23:43:52 +0800 Subject: [PATCH 04/19] =?UTF-8?q?feat(admin-web):=20=E4=BD=99=E9=A2=9D?= =?UTF-8?q?=E8=AE=BE=E5=80=BC/=E6=89=B9=E9=87=8F=E3=80=81=E6=97=B6?= =?UTF-8?q?=E5=8C=BA=E4=B8=8E=E5=A4=8D=E7=94=A8=E6=95=B4=E9=A1=BF=E3=80=81?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=88=97=E8=A1=A8=E6=8E=92=E5=BA=8F=E7=AD=9B?= =?UTF-8?q?=E9=80=89=20(#9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调金币/现金:新增「设为指定值」模式;修 InputNumber addonAfter 废弃告警; 弹窗抽成共享组件 AdjustBalanceModals(列表页/详情页复用,内含防竞态余额拉取) - 新增 lib/format.ts 统一金额/时间格式化:区分 UTC 字段与钱包流水(北京 wall-clock), 修正提现详情现金流水快 8 小时、用户注册时间慢 8 小时等偏差 - 提现页:账本校验(ledger-check)改按需触发,写操作后不再重算;现金流水时区修正 - 用户详情页:加载失败 Result 兜底(不再永久转圈)+ 现金流水 tab + 调金币/现金/封禁入口 - 金币审计页:加「只看不一致」过滤;截断提示改用后端精确 truncated 旗标 - 用户列表:注册/最近登录/ID 列排序 + 渠道/昵称/时间范围筛选 + 批量封禁解封/开关调试链接 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/9 Co-authored-by: ouzhou Co-committed-by: ouzhou --- src/app/(main)/ad-audit/page.tsx | 50 ++-- src/app/(main)/users/[id]/page.tsx | 170 +++++++++++-- src/app/(main)/users/page.tsx | 327 ++++++++++++++++--------- src/app/(main)/withdraws/page.tsx | 63 +++-- src/components/AdjustBalanceModals.tsx | 201 +++++++++++++++ src/lib/format.ts | 47 ++++ src/lib/types.ts | 5 +- 7 files changed, 694 insertions(+), 169 deletions(-) create mode 100644 src/components/AdjustBalanceModals.tsx create mode 100644 src/lib/format.ts diff --git a/src/app/(main)/ad-audit/page.tsx b/src/app/(main)/ad-audit/page.tsx index ef9c4ef..3d5cdad 100644 --- a/src/app/(main)/ad-audit/page.tsx +++ b/src/app/(main)/ad-audit/page.tsx @@ -6,6 +6,7 @@ import { Alert, Button, Card, + Checkbox, DatePicker, InputNumber, Select, @@ -17,10 +18,9 @@ import { } from 'antd'; import dayjs, { type Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; +import { formatUtcTime } from '@/lib/format'; import type { AdCoinAudit, AdCoinAuditRow } from '@/lib/types'; -const dt = (v: string) => new Date(v).toLocaleString('zh-CN'); - const SCENE_TAG: Record = { reward_video: { color: 'blue', label: '看视频' }, feed: { color: 'purple', label: '信息流' }, @@ -48,6 +48,8 @@ export default function AdAuditPage() { const [scene, setScene] = useState(); const [limit, setLimit] = useState(100); const [data, setData] = useState(null); + const [queriedLimit, setQueriedLimit] = useState(100); // 本次结果对应的 limit,截断提示里展示 + const [onlyMismatch, setOnlyMismatch] = useState(false); // 只看不一致(✗)行 const [loading, setLoading] = useState(false); const load = useCallback(async () => { @@ -59,15 +61,17 @@ export default function AdAuditPage() { user_id: userId ?? undefined, scene: scene ?? undefined, limit, + only_mismatch: onlyMismatch || undefined, }, }); setData(res.data); + setQueriedLimit(limit); } catch (e) { message.error(errMsg(e)); } finally { setLoading(false); } - }, [date, userId, scene, limit]); + }, [date, userId, scene, limit, onlyMismatch]); useEffect(() => { load(); @@ -85,7 +89,7 @@ export default function AdAuditPage() { return {t.label}; }, }, - { title: '时间', dataIndex: 'created_at', render: dt, width: 175 }, + { title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 175 }, { title: '用户', dataIndex: 'user_id', width: 70 }, { title: '状态', @@ -186,6 +190,9 @@ export default function AdAuditPage() { style={{ width: 120 }} options={[50, 100, 200, 500].map((n) => ({ value: n, label: `${n} 条` }))} /> + setOnlyMismatch(e.target.checked)}> + 只看不一致(✗) + @@ -193,17 +200,30 @@ export default function AdAuditPage() { {data && (
- {data.mismatch_count > 0 ? ( - - ) : ( - - )} + + {data.mismatch_count > 0 ? ( + + ) : ( + + )} + {onlyMismatch && ( + + )} + {data.truncated && ( + + )} +
)} diff --git a/src/app/(main)/users/[id]/page.tsx b/src/app/(main)/users/[id]/page.tsx index a53f7f2..1a49a32 100644 --- a/src/app/(main)/users/[id]/page.tsx +++ b/src/app/(main)/users/[id]/page.tsx @@ -1,32 +1,94 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; import type { ColumnsType } from 'antd/es/table'; -import { Card, Col, Descriptions, Row, Spin, Statistic, Table, Tabs, Tag } from 'antd'; -import { api } from '@/lib/api'; +import { + Button, + Card, + Col, + Descriptions, + Modal, + Result, + Row, + Space, + Spin, + Statistic, + Table, + Tabs, + Tag, + message, +} from 'antd'; +import { api, errMsg } from '@/lib/api'; +import { canDo } from '@/lib/auth'; +import { formatUtcTime, formatWallTime, yuan } from '@/lib/format'; import { useCursorList } from '@/lib/useCursorList'; -import type { CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types'; - -const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`; -const dt = (v: string) => new Date(v).toLocaleString('zh-CN'); +import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals'; +import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types'; export default function UserDetailPage() { const params = useParams<{ id: string }>(); const uid = Number(params.id); const [data, setData] = useState(null); + const [err, setErr] = useState(null); + const [coinOpen, setCoinOpen] = useState(false); + const [cashOpen, setCashOpen] = useState(false); - useEffect(() => { - api.get(`/admin/api/users/${uid}`).then((r) => setData(r.data)); + const loadData = useCallback(() => { + setErr(null); + api + .get(`/admin/api/users/${uid}`) + .then((r) => setData(r.data)) + .catch((e) => setErr(errMsg(e, '加载用户详情失败'))); }, [uid]); + useEffect(() => { + loadData(); + }, [loadData]); + const coins = useCursorList('/admin/api/wallet/coin-transactions', { user_id: uid }); + const cash = useCursorList('/admin/api/wallet/cash-transactions', { user_id: uid }); const withdraws = useCursorList('/admin/api/withdraws', { user_id: uid }); + const canCoins = canDo(['finance']); + const canStatus = canDo(['operator']); + + const toggleStatus = () => { + if (!data) return; + const next = data.user.status === 'active' ? 'disabled' : 'active'; + Modal.confirm({ + title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${data.user.phone}?`, + onOk: async () => { + try { + await api.post(`/admin/api/users/${uid}/status`, { status: next }); + message.success('已更新状态'); + loadData(); + } catch (e) { + message.error(errMsg(e)); + } + }, + }); + }; + + if (err) { + return ( + + 重试 + + } + /> + ); + } if (!data) return ; const coinCols: ColumnsType = [ - { title: '时间', dataIndex: 'created_at', render: dt }, + // 金币流水为北京 wall-clock 口径,原样显示 + { title: '时间', dataIndex: 'created_at', render: (v: string) => formatWallTime(v) }, { title: '变动', dataIndex: 'amount', @@ -42,17 +104,58 @@ export default function UserDetailPage() { { title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' }, ]; + const cashCols: ColumnsType = [ + // 现金流水为北京 wall-clock 口径,原样显示 + { title: '时间', dataIndex: 'created_at', render: (v: string) => formatWallTime(v) }, + { + title: '变动', + dataIndex: 'amount_cents', + render: (v: number) => ( + 0 ? '#3f8600' : '#cf1322' }}> + {v > 0 ? '+' : ''} + {yuan(v)} + + ), + }, + { title: '余额', dataIndex: 'balance_after_cents', render: yuan }, + { title: '类型', dataIndex: 'biz_type' }, + { title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' }, + ]; + const wdCols: ColumnsType = [ - { title: '时间', dataIndex: 'created_at', render: dt }, + // 提现单为 UTC 口径 + { title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v) }, { title: '金额', dataIndex: 'amount_cents', render: yuan }, { title: '状态', dataIndex: 'status', render: (s: string) => {s} }, { title: '单号', dataIndex: 'out_bill_no' }, { title: '失败原因', dataIndex: 'fail_reason', render: (v: string | null) => v || '-' }, ]; + const loadMoreLink = (list: { loadMore: () => void; nextCursor: number | null }) => ( + + ); + return (
-

用户详情 #{uid}

+ +

用户详情 #{uid}

+ + {canCoins && } + {canCoins && } + {canStatus && data.user.status !== 'deleted' && ( + + )} + +
{data.user.phone} @@ -64,7 +167,7 @@ export default function UserDetailPage() { {data.user.wechat_openid ? '已绑定' : '未绑定'} - {dt(data.user.created_at)} + {formatUtcTime(data.user.created_at)} @@ -115,11 +218,23 @@ export default function UserDetailPage() { loading={coins.loading} pagination={false} /> - + {loadMoreLink(coins)} + + ), + }, + { + key: 'cash', + label: '现金流水', + children: ( + <> +
+ {loadMoreLink(cash)} ), }, @@ -138,6 +253,25 @@ export default function UserDetailPage() { }, ]} /> + + setCoinOpen(false)} + onDone={() => { + loadData(); + coins.reload(); + }} + /> + setCashOpen(false)} + onDone={() => { + loadData(); + cash.reload(); + }} + /> ); } diff --git a/src/app/(main)/users/page.tsx b/src/app/(main)/users/page.tsx index 79d8214..0ee25f3 100644 --- a/src/app/(main)/users/page.tsx +++ b/src/app/(main)/users/page.tsx @@ -1,33 +1,42 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import type { Key } from 'react'; import { useRouter } from 'next/navigation'; import type { ColumnsType } from 'antd/es/table'; -import { - Button, - Form, - Input, - InputNumber, - Modal, - Select, - Space, - Table, - Tag, - message, -} from 'antd'; +import type { SorterResult } from 'antd/es/table/interface'; +import { Button, DatePicker, Input, Modal, Select, Space, Table, Tag, message } from 'antd'; +import dayjs, { type Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; +import { formatUtcTime } from '@/lib/format'; import { useCursorList } from '@/lib/useCursorList'; -import type { UserListItem, UserOverview } from '@/lib/types'; +import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModals'; +import type { UserListItem } from '@/lib/types'; + +const { RangePicker } = DatePicker; const STATUS_COLOR: Record = { active: 'green', disabled: 'red', deleted: 'default' }; -const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`; + +type SortField = 'id' | 'created_at' | 'last_login_at'; export default function UsersPage() { const router = useRouter(); + + // 筛选草稿:点「查询」才应用(避免输入即刷新);状态/渠道/日期一并随查询提交 const [phone, setPhone] = useState(''); + const [nickname, setNickname] = useState(''); const [status, setStatus] = useState(); - const [filters, setFilters] = useState>({}); + const [channel, setChannel] = useState(); + const [regRange, setRegRange] = useState<[Dayjs, Dayjs] | null>(null); + const [loginRange, setLoginRange] = useState<[Dayjs, Dayjs] | null>(null); + const [applied, setApplied] = useState>({}); + // 排序(服务端):点列头即时生效,与「查询」按钮分开 + const [sortBy, setSortBy] = useState('id'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + + const filters: Record = { ...applied, sort_by: sortBy, sort_order: sortOrder }; + const filterKey = JSON.stringify(filters); const { items, nextCursor, loading, loadMore, reload } = useCursorList( '/admin/api/users', filters, @@ -37,56 +46,56 @@ export default function UsersPage() { const canStatus = canDo(['operator']); 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 [selectedRowKeys, setSelectedRowKeys] = useState([]); - 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(() => {}); + // 筛选/排序变化后清空已选(避免选中项跨筛选错位) + useEffect(() => { + setSelectedRowKeys([]); + }, [filterKey]); + + const search = () => + setApplied({ + phone: phone || undefined, + nickname: nickname || undefined, + status, + register_channel: channel || undefined, + created_from: regRange?.[0] ? regRange[0].startOf('day').toISOString() : undefined, + created_to: regRange?.[1] ? regRange[1].endOf('day').toISOString() : undefined, + last_login_from: loginRange?.[0] ? loginRange[0].startOf('day').toISOString() : undefined, + last_login_to: loginRange?.[1] ? loginRange[1].endOf('day').toISOString() : undefined, + }); + + const resetFilters = () => { + setPhone(''); + setNickname(''); + setStatus(undefined); + setChannel(undefined); + setRegRange(null); + setLoginRange(null); + setSortBy('id'); + setSortOrder('desc'); + setApplied({}); }; - const search = () => setFilters({ phone: phone || undefined, status }); - - const submitCoins = async () => { - const v = await coinForm.validateFields(); - try { - await api.post(`/admin/api/users/${coinUser!.id}/coins`, v); - message.success('已调整金币'); - setCoinUser(null); - coinForm.resetFields(); - } catch (e) { - message.error(errMsg(e)); + const onTableChange = ( + _pagination: unknown, + _filters: unknown, + sorter: SorterResult | SorterResult[], + ) => { + const s = Array.isArray(sorter) ? sorter[0] : sorter; + if (s && s.order && s.field) { + setSortBy(s.field as SortField); + setSortOrder(s.order === 'ascend' ? 'asc' : 'desc'); + } else { + // 取消排序 → 回默认(ID 倒序) + setSortBy('id'); + setSortOrder('desc'); } }; - // 发/扣现金:输入元,×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 sortOrderOf = (field: SortField) => + sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null; const toggleStatus = (u: UserListItem) => { const next = u.status === 'active' ? 'disabled' : 'active'; @@ -123,8 +132,64 @@ export default function UsersPage() { }); }; + const selectedUsers = items.filter((u) => selectedRowKeys.includes(u.id)); + const selectedActive = selectedUsers.filter((u) => u.status === 'active'); + const selectedDisabled = selectedUsers.filter((u) => u.status === 'disabled'); + + // 批量:循环调现有逐用户接口(各自已带审计),Promise.allSettled 汇总成败,不新增 bulk 端点 + const runBulk = async ( + targets: UserListItem[], + label: string, + call: (u: UserListItem) => Promise, + ) => { + const results = await Promise.allSettled(targets.map(call)); + const ok = results.filter((r) => r.status === 'fulfilled').length; + const fail = results.length - ok; + if (fail) message.warning(`${label}完成:成功 ${ok}/${results.length},失败 ${fail}`); + else message.success(`${label}完成:${ok} 个`); + setSelectedRowKeys([]); + reload(); + }; + + const bulkStatus = (target: 'disabled' | 'active') => { + const targets = target === 'disabled' ? selectedActive : selectedDisabled; + const label = target === 'disabled' ? '批量封禁' : '批量解封'; + if (!targets.length) { + message.warning(`没有可${target === 'disabled' ? '封禁(active)' : '解封(disabled)'}的选中用户`); + return; + } + Modal.confirm({ + title: `确认${label} ${targets.length} 个用户?`, + content: target === 'disabled' ? '仅对选中的 active 用户生效' : '仅对选中的 disabled 用户生效', + okButtonProps: { danger: target === 'disabled' }, + onOk: () => + runBulk(targets, label, (u) => + api.post(`/admin/api/users/${u.id}/status`, { status: target }), + ), + }); + }; + + const bulkDebugTrace = (enabled: boolean) => { + // 只对「非 deleted 且当前态不同」的选中用户操作,避免无谓请求 + const targets = selectedUsers.filter( + (u) => u.status !== 'deleted' && u.debug_trace_enabled !== enabled, + ); + const label = enabled ? '批量开调试链接' : '批量关调试链接'; + if (!targets.length) { + message.warning('没有需要变更的选中用户'); + return; + } + Modal.confirm({ + title: `确认${label} ${targets.length} 个用户?`, + onOk: () => + runBulk(targets, label, (u) => + api.post(`/admin/api/users/${u.id}/debug-trace`, { enabled }), + ), + }); + }; + const columns: ColumnsType = [ - { title: 'ID', dataIndex: 'id', width: 70 }, + { title: 'ID', dataIndex: 'id', width: 80, sorter: true, sortOrder: sortOrderOf('id') }, { title: '手机号', dataIndex: 'phone' }, { title: '昵称', dataIndex: 'nickname', render: (v: string | null) => v || '-' }, { title: '渠道', dataIndex: 'register_channel', width: 90 }, @@ -137,16 +202,29 @@ export default function UsersPage() { { title: '注册时间', dataIndex: 'created_at', - render: (v: string) => new Date(v).toLocaleString('zh-CN'), + width: 160, + sorter: true, + sortOrder: sortOrderOf('created_at'), + render: (v: string) => formatUtcTime(v), + }, + { + title: '最近登录', + dataIndex: 'last_login_at', + width: 160, + sorter: true, + sortOrder: sortOrderOf('last_login_at'), + render: (v: string) => formatUtcTime(v), }, { title: '操作', key: 'op', + fixed: 'right', + width: 230, render: (_: unknown, u: UserListItem) => ( - + e.stopPropagation()}> router.push(`/users/${u.id}`)}>详情 - {canCoins && openAdjust(u, 'coin')}>调金币} - {canCoins && openAdjust(u, 'cash')}>调现金} + {canCoins && setCoinUser(u)}>调金币} + {canCoins && setCashUser(u)}>调现金} {canStatus && u.status !== 'deleted' && ( toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'} )} @@ -170,74 +248,103 @@ export default function UsersPage() { onChange={(e) => setPhone(e.target.value)} onPressEnter={search} allowClear - style={{ width: 160 }} + style={{ width: 150 }} + /> + setNickname(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 150 }} /> + setRegRange(v as [Dayjs, Dayjs] | null)} + allowClear + /> + setLoginRange(v as [Dayjs, Dayjs] | null)} + allowClear + /> + -
+ + {canStatus && selectedUsers.length > 0 && ( + + + 已选 {selectedUsers.length} 个(active {selectedActive.length}、disabled{' '} + {selectedDisabled.length}) + + + + + + + + )} + +
({ disabled: u.status === 'deleted' }), + } + : undefined + } + columns={columns} + dataSource={items} + loading={loading} + pagination={false} + scroll={{ x: 1100 }} + onChange={onTableChange} + />
- setCoinUser(null)} - destroyOnHidden - > -

- 当前余额:金币 {balances ? balances.coin : '…'} | 现金 {balances ? yuan(balances.cashCents) : '…'} -

-
- - - - - - - -
- - setCashUser(null)} - destroyOnHidden - > -

- 当前余额:金币 {balances ? balances.coin : '…'} | 现金 {balances ? yuan(balances.cashCents) : '…'} -

-
- - - - - - - -
+ setCoinUser(null)} /> + setCashUser(null)} /> ); } diff --git a/src/app/(main)/withdraws/page.tsx b/src/app/(main)/withdraws/page.tsx index b869749..aef4d36 100644 --- a/src/app/(main)/withdraws/page.tsx +++ b/src/app/(main)/withdraws/page.tsx @@ -36,10 +36,9 @@ import { SyncOutlined, } from '@ant-design/icons'; import dayjs, { type Dayjs } from 'dayjs'; -import relativeTime from 'dayjs/plugin/relativeTime'; -import 'dayjs/locale/zh-cn'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; +import { formatUtcTime, formatWallTime, utcDayjs, utcFromNow, yuan } from '@/lib/format'; import { useCursorList } from '@/lib/useCursorList'; import type { AuditLog, @@ -52,19 +51,12 @@ import type { WxpayHealthCheck, } from '@/lib/types'; -dayjs.extend(relativeTime); -dayjs.locale('zh-cn'); - const { Text } = Typography; const { RangePicker } = DatePicker; -const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`; -const apiTime = (v: string) => { - const hasTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v); - return dayjs(hasTimezone ? v : `${v}Z`); -}; -const dt = (v: string) => apiTime(v).format('YYYY-MM-DD HH:mm'); -const ago = (v: string) => apiTime(v).fromNow(); +// 提现单 / 审计 / 用户均为 UTC 口径(server_default=func.now());现金流水为北京 wall-clock。 +const dt = (v: string) => formatUtcTime(v); +const ago = (v: string) => utcFromNow(v); const STATUS_COLOR: Record = { reviewing: 'gold', @@ -161,7 +153,7 @@ function riskTags(detail: WithdrawDetail | null) { const tags: string[] = []; if (!detail.order.user_name) tags.push('缺少提现实名'); if (detail.user?.status && detail.user.status !== 'active') tags.push(`账号状态:${detail.user.status}`); - if (detail.user && dayjs().diff(apiTime(detail.user.created_at), 'hour') < 24) tags.push('新注册用户'); + if (detail.user && dayjs().diff(utcDayjs(detail.user.created_at), 'hour') < 24) tags.push('新注册用户'); if ((detail.user?.withdraw_total || 0) >= 3) tags.push('多次提现用户'); if (detail.recent_withdraws.some((o) => o.status === 'rejected' || o.status === 'failed')) { tags.push('历史异常单'); @@ -181,6 +173,7 @@ export default function WithdrawsPage() { const [summary, setSummary] = useState(null); const [health, setHealth] = useState(null); const [ledger, setLedger] = useState(null); + const [ledgerLoading, setLedgerLoading] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [detail, setDetail] = useState(null); const [detailLoading, setDetailLoading] = useState(false); @@ -255,19 +248,28 @@ export default function WithdrawsPage() { } }, []); - const loadChecks = useCallback(async () => { + const loadHealth = useCallback(async () => { try { - const [{ data: healthData }, { data: ledgerData }] = await Promise.all([ - api.get('/admin/api/withdraws/health-check'), - api.get('/admin/api/withdraws/ledger-check'), - ]); - setHealth(healthData); - setLedger(ledgerData); + const { data } = await api.get('/admin/api/withdraws/health-check'); + setHealth(data); } catch (e) { message.error(errMsg(e)); } }, []); + // 账本校验是全表对账、相对重,故只在进页时拉一次 + 提供手动按钮,不随每次写操作重算。 + const loadLedger = useCallback(async () => { + setLedgerLoading(true); + try { + const { data } = await api.get('/admin/api/withdraws/ledger-check'); + setLedger(data); + } catch (e) { + message.error(errMsg(e)); + } finally { + setLedgerLoading(false); + } + }, []); + const loadDetail = useCallback(async (outBillNo: string) => { setDetailLoading(true); try { @@ -282,8 +284,9 @@ export default function WithdrawsPage() { useEffect(() => { void loadSummary(); - void loadChecks(); - }, [loadChecks, loadSummary]); + void loadHealth(); + void loadLedger(); + }, [loadHealth, loadLedger, loadSummary]); useEffect(() => { setSelectedRowKeys([]); @@ -292,7 +295,8 @@ export default function WithdrawsPage() { const refreshAfterChange = async (outBillNo?: string) => { setSelectedRowKeys([]); reload(); - await Promise.all([loadSummary(), loadChecks()]); + // 账本校验偏重,写操作后不重算(只刷 summary + health);需要时点账本 Alert 上的「重新校验」。 + await Promise.all([loadSummary(), loadHealth()]); if (outBillNo && drawerOpen && detail?.order.out_bill_no === outBillNo) { await loadDetail(outBillNo); } @@ -595,7 +599,8 @@ export default function WithdrawsPage() { }, { title: '余额', dataIndex: 'balance_after_cents', width: 100, render: yuan }, { title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' }, - { title: '时间', dataIndex: 'created_at', width: 150, render: dt }, + // 现金流水是北京 wall-clock 口径,原样显示,不能当 UTC 再 +8 + { title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) }, ]; const recentWithdrawColumns: ColumnsType = [ @@ -686,6 +691,16 @@ export default function WithdrawsPage() { ? `账户余额合计 ${yuan(ledger.cash_balance_total_cents)},现金流水净额 ${yuan(ledger.cash_transaction_total_cents)}` : ledgerIssues.join(';') } + action={ + + } /> )} diff --git a/src/components/AdjustBalanceModals.tsx b/src/components/AdjustBalanceModals.tsx new file mode 100644 index 0000000..5192b1f --- /dev/null +++ b/src/components/AdjustBalanceModals.tsx @@ -0,0 +1,201 @@ +'use client'; + +// 调金币 / 调现金弹窗(受控)。列表页与用户详情页共用,避免两处各写一套。 +// 每个弹窗自带「增减 / 设为指定值」两种模式;打开时拉一次 360 概览展示当前余额。 +import { useEffect, useState } from 'react'; +import { Form, Input, InputNumber, Modal, Segmented, message } from 'antd'; +import { api, errMsg } from '@/lib/api'; +import { yuan } from '@/lib/format'; +import type { UserOverview } from '@/lib/types'; + +export type AdjustTargetUser = { id: number; phone: string }; + +interface ModalProps { + user: AdjustTargetUser | null; + open: boolean; + onClose: () => void; + onDone?: () => void; // 调整成功后回调(刷新余额/列表) +} + +const balanceLine = (balances: { coin: number; cashCents: number } | null) => ( +

+ 当前余额:金币 {balances ? balances.coin : '…'} | 现金 {balances ? yuan(balances.cashCents) : '…'} +

+); + +// 弹窗打开时拉一次该用户余额;用户切换/关闭时丢弃过期响应(alive 标记防竞态)。 +function useBalances(userId: number | undefined, open: boolean) { + const [balances, setBalances] = useState<{ coin: number; cashCents: number } | null>(null); + useEffect(() => { + if (!open || userId == null) { + setBalances(null); + return; + } + let alive = true; + setBalances(null); + api + .get(`/admin/api/users/${userId}`) + .then((r) => { + if (alive) setBalances({ coin: r.data.coin_balance, cashCents: r.data.cash_balance_cents }); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, [userId, open]); + return balances; +} + +export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) { + const [form] = Form.useForm(); + const [mode, setMode] = useState<'delta' | 'set'>('delta'); + const balances = useBalances(user?.id, open); + + useEffect(() => { + if (open) { + setMode('delta'); + form.resetFields(); + } + }, [open, form]); + + const submit = async () => { + if (!user) return; + const v = await form.validateFields(); + try { + await api.post(`/admin/api/users/${user.id}/coins`, { mode, ...v }); + message.success(mode === 'set' ? '已设置金币' : '已调整金币'); + onClose(); + onDone?.(); + } catch (e) { + message.error(errMsg(e)); + } + }; + + return ( + + {balanceLine(balances)} +
+ + setMode(v as 'delta' | 'set')} + options={[ + { label: '增减', value: 'delta' }, + { label: '设为指定值', value: 'set' }, + ]} + /> + + + + + + + + +
+ ); +} + +export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) { + const [form] = Form.useForm(); + const [mode, setMode] = useState<'delta' | 'set'>('delta'); + const balances = useBalances(user?.id, open); + + useEffect(() => { + if (open) { + setMode('delta'); + form.resetFields(); + } + }, [open, form]); + + // 输入元,×100 转分后调后端(主要给无现金用户发钱直接测试提现) + const submit = async () => { + if (!user) return; + const v = await form.validateFields(); + const amountCents = Math.round(Number(v.amount_yuan) * 100); + if (mode === 'delta' && amountCents === 0) { + message.warning('金额不能为 0(且不小于 1 分)'); + return; + } + if (mode === 'set' && amountCents < 0) { + message.warning('目标金额不能为负'); + return; + } + try { + await api.post(`/admin/api/users/${user.id}/cash`, { + mode, + amount_cents: amountCents, + reason: v.reason, + }); + message.success( + mode === 'set' + ? `已设为 ¥${(amountCents / 100).toFixed(2)} 现金` + : `已${amountCents > 0 ? '发放' : '扣减'} ¥${(Math.abs(amountCents) / 100).toFixed(2)} 现金`, + ); + onClose(); + onDone?.(); + } catch (e) { + message.error(errMsg(e)); + } + }; + + return ( + + {balanceLine(balances)} +
+ + setMode(v as 'delta' | 'set')} + options={[ + { label: '增减', value: 'delta' }, + { label: '设为指定值', value: 'set' }, + ]} + /> + + + + + + + + +
+ ); +} diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..8a621e3 --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,47 @@ +// 全站统一的金额 / 时间格式化。 +// +// 后端有两种时间口径,序列化后从字符串无法区分,必须按字段语义选对函数: +// 1. UTC 口径:`server_default=func.now()` 的字段(用户/提现单/审计日志/广告发奖记录)。 +// 入库是 UTC,SQLite 下序列化不带时区、Postgres 下带 +00:00。统一当 UTC 解析后转北京显示。 +// 2. 北京 wall-clock 口径:钱包流水(coin_transaction / cash_transaction)。入库即 +// `datetime.now(CN_TZ).replace(tzinfo=None)`,字面就是北京时间且无时区,**不能再做时区换算**。 +// 历史上各页面混用 `new Date().toLocaleString`(按本地解析)与 `apiTime`(补 Z 当 UTC), +// 导致提现详情里现金流水时间快 8 小时。此模块收口,按口径区分。 +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import 'dayjs/locale/zh-cn'; + +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(relativeTime); +dayjs.locale('zh-cn'); + +const TZ = 'Asia/Shanghai'; + +/** 金额:分 → ¥元(两位小数)。 */ +export const yuan = (cents: number) => `¥${(cents / 100).toFixed(2)}`; + +const hasTz = (v: string) => /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v); + +/** 把 UTC 口径字符串解析为带时区的 dayjs(无时区后缀的补 Z 当 UTC)。 */ +export const utcDayjs = (v: string) => dayjs(hasTz(v) ? v : `${v}Z`); + +/** UTC 口径时间 → 北京时间显示(用户/提现单/审计/广告发奖记录)。 */ +export function formatUtcTime(v?: string | null, fmt = 'YYYY-MM-DD HH:mm'): string { + if (!v) return '-'; + return utcDayjs(v).tz(TZ).format(fmt); +} + +/** UTC 口径相对时间(如「3 分钟前」)。 */ +export function utcFromNow(v?: string | null): string { + if (!v) return '-'; + return utcDayjs(v).fromNow(); +} + +/** 北京 wall-clock 口径时间 → 原样格式化,不做时区换算(钱包金币/现金流水)。 */ +export function formatWallTime(v?: string | null, fmt = 'YYYY-MM-DD HH:mm'): string { + if (!v) return '-'; + return dayjs(v).format(fmt); +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 23b151f..1555f87 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -217,8 +217,9 @@ export interface AdCoinFormula { export interface AdCoinAudit { date: string; formula: AdCoinFormula; - total: number; - mismatch_count: number; + total: number; // 全量条数(不受 limit/only_mismatch 影响) + mismatch_count: number; // 全量不一致条数 + truncated: boolean; // 展示集是否被 limit 截断 items: AdCoinAuditRow[]; } From 05a1d06e61d3b62f48398170818f31844d348e30 Mon Sep 17 00:00:00 2001 From: ouzhou Date: Sun, 14 Jun 2026 22:54:24 +0800 Subject: [PATCH 05/19] =?UTF-8?q?fix(admin-web):=20=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E6=B8=B2=E6=9F=93=E6=94=B6=E5=8F=A3=E5=88=B0=20formatUtcTime,?= =?UTF-8?q?=E6=B6=88=E7=81=AD=20new=20Date().toLocaleString=20(#10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review 发现 4 处仍用 new Date(v).toLocaleString 渲染后端 UTC 口径时间戳—— 正是 lib/format.ts 头注释明令消灭的反模式(SQLite 本地/非中国浏览器会差 8h, 生产靠 +00:00 偏移侥幸正确)。统一改用 formatUtcTime 按北京显示: - admins: last_login_at - audit-logs: created_at - devices: completed_at - dashboard/HomeStatsConfig: ops_stat_config.updated_at 均为 server_default now()/datetime.now(utc) 的 UTC 口径字段。 HomeStatsConfig 的「下个更新时刻」用 epoch ms + 强制 Asia/Shanghai,正确,不动。 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/10 Co-authored-by: ouzhou Co-committed-by: ouzhou --- src/app/(main)/admins/page.tsx | 4 +- src/app/(main)/audit-logs/page.tsx | 27 +-- src/app/(main)/dashboard/HomeStatsConfig.tsx | 3 +- src/app/(main)/devices/page.tsx | 4 +- src/app/(main)/feedbacks/page.tsx | 214 +++++++++++++++++-- src/app/(main)/price-reports/page.tsx | 22 +- src/app/(main)/users/page.tsx | 38 ++-- src/app/(main)/withdraws/page.tsx | 52 ++--- src/lib/types.ts | 1 + src/lib/usePagedList.ts | 75 +++++++ 10 files changed, 344 insertions(+), 96 deletions(-) create mode 100644 src/lib/usePagedList.ts diff --git a/src/app/(main)/admins/page.tsx b/src/app/(main)/admins/page.tsx index f3ae92e..a8baa4e 100644 --- a/src/app/(main)/admins/page.tsx +++ b/src/app/(main)/admins/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'; import type { ColumnsType } from 'antd/es/table'; import { Button, Form, Input, Modal, Select, Table, Tag, message } from 'antd'; import { api, errMsg } from '@/lib/api'; +import { formatUtcTime } from '@/lib/format'; import type { AdminInfo } from '@/lib/types'; const ROLES = [ @@ -11,7 +12,8 @@ const ROLES = [ { value: 'finance', label: 'finance' }, { value: 'operator', label: 'operator' }, ]; -const dt = (v: string | null) => (v ? new Date(v).toLocaleString('zh-CN') : '从未'); +// last_login_at 为 UTC 口径(datetime.now(utc)),按北京显示(勿用 new Date().toLocaleString) +const dt = (v: string | null) => (v ? formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss') : '从未'); export default function AdminsPage() { const [admins, setAdmins] = useState([]); diff --git a/src/app/(main)/audit-logs/page.tsx b/src/app/(main)/audit-logs/page.tsx index ebe1f93..91fd662 100644 --- a/src/app/(main)/audit-logs/page.tsx +++ b/src/app/(main)/audit-logs/page.tsx @@ -3,19 +3,18 @@ import { useState } from 'react'; import type { ColumnsType } from 'antd/es/table'; import { Button, Input, Space, Table, Tag } from 'antd'; -import { useCursorList } from '@/lib/useCursorList'; +import { formatUtcTime } from '@/lib/format'; +import { usePagedList } from '@/lib/usePagedList'; import type { AuditLog } from '@/lib/types'; -const dt = (v: string) => new Date(v).toLocaleString('zh-CN'); +// 审计 created_at 为 UTC 口径(server_default now()),按北京显示(勿用 new Date().toLocaleString) +const dt = (v: string) => formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss'); export default function AuditLogsPage() { const [action, setAction] = useState(''); const [filters, setFilters] = useState>({}); - const { items, nextCursor, loading, loadMore } = useCursorList( - '/admin/api/audit-logs', - filters, - 50, - ); + const { items, total, page, pageSize, loading, onChange: onPageChange } = + usePagedList('/admin/api/audit-logs', filters, 50); const search = () => setFilters({ action: action || undefined }); @@ -59,14 +58,16 @@ export default function AuditLogsPage() { columns={columns} dataSource={items} loading={loading} - pagination={false} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 条`, + onChange: onPageChange, + }} scroll={{ x: 1000 }} /> -
- -
); } diff --git a/src/app/(main)/dashboard/HomeStatsConfig.tsx b/src/app/(main)/dashboard/HomeStatsConfig.tsx index cb6d6b5..9825843 100644 --- a/src/app/(main)/dashboard/HomeStatsConfig.tsx +++ b/src/app/(main)/dashboard/HomeStatsConfig.tsx @@ -19,6 +19,7 @@ import { message, } from 'antd'; import { api, errMsg } from '@/lib/api'; +import { formatUtcTime } from '@/lib/format'; interface StatItem { metric: string; // help_users / total_compares / total_saved @@ -601,7 +602,7 @@ export default function HomeStatsConfig() { {it.updated_at && ( - 更新于 {new Date(it.updated_at).toLocaleString('zh-CN')} + 更新于 {formatUtcTime(it.updated_at, 'YYYY-MM-DD HH:mm:ss')} )} diff --git a/src/app/(main)/devices/page.tsx b/src/app/(main)/devices/page.tsx index 105f340..f746de8 100644 --- a/src/app/(main)/devices/page.tsx +++ b/src/app/(main)/devices/page.tsx @@ -4,10 +4,12 @@ import type { ColumnsType } from 'antd/es/table'; import { Button, Modal, Popconfirm, Space, Table, message } from 'antd'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; +import { formatUtcTime } from '@/lib/format'; import { useCursorList } from '@/lib/useCursorList'; import type { DeviceOnboardingItem } from '@/lib/types'; -const dt = (v: string) => new Date(v).toLocaleString('zh-CN'); +// completed_at 为 UTC 口径(server_default now()),按北京显示(勿用 new Date().toLocaleString) +const dt = (v: string) => formatUtcTime(v); const EMPTY: Record = {}; export default function DevicesPage() { diff --git a/src/app/(main)/feedbacks/page.tsx b/src/app/(main)/feedbacks/page.tsx index 9d1fc11..61091bb 100644 --- a/src/app/(main)/feedbacks/page.tsx +++ b/src/app/(main)/feedbacks/page.tsx @@ -1,23 +1,100 @@ 'use client'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; +import type { Key } from 'react'; import type { ColumnsType } from 'antd/es/table'; -import { Button, Select, Space, Table, Tag, message } from 'antd'; +import type { SorterResult } from 'antd/es/table/interface'; +import { + Button, + DatePicker, + Image, + Input, + Modal, + Select, + Space, + Table, + Tag, + Typography, + message, +} from 'antd'; +import type { Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; +import { formatUtcTime } from '@/lib/format'; import { useCursorList } from '@/lib/useCursorList'; import type { Feedback } from '@/lib/types'; -const dt = (v: string) => new Date(v).toLocaleString('zh-CN'); +const { Text } = Typography; +const { RangePicker } = DatePicker; + +// 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。 +const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE || ''; +const mediaUrl = (p: string) => (p.startsWith('http') ? p : MEDIA_BASE + p); + +type SortField = 'id' | 'created_at'; export default function FeedbacksPage() { + // 筛选草稿:点「查询」才应用(避免输入即刷新) const [status, setStatus] = useState(); - const [filters, setFilters] = useState>({}); + const [userId, setUserId] = useState(''); + const [content, setContent] = useState(''); + const [createdRange, setCreatedRange] = useState<[Dayjs, Dayjs] | null>(null); + const [applied, setApplied] = useState>({}); + // 排序(服务端):点列头即时生效,与「查询」按钮分开 + const [sortBy, setSortBy] = useState('id'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + + const filters: Record = { ...applied, sort_by: sortBy, sort_order: sortOrder }; + const filterKey = JSON.stringify(filters); const { items, nextCursor, loading, loadMore, reload } = useCursorList( '/admin/api/feedbacks', filters, ); + const canHandle = canDo(['operator']); + const [selectedRowKeys, setSelectedRowKeys] = useState([]); + + // 筛选/排序变化后清空已选(避免选中项跨筛选错位) + useEffect(() => { + setSelectedRowKeys([]); + }, [filterKey]); + + const search = () => + setApplied({ + status, + user_id: userId.trim() ? Number(userId.trim()) : undefined, + content: content.trim() || undefined, + created_from: createdRange?.[0] ? createdRange[0].startOf('day').toISOString() : undefined, + created_to: createdRange?.[1] ? createdRange[1].endOf('day').toISOString() : undefined, + }); + + const resetFilters = () => { + setStatus(undefined); + setUserId(''); + setContent(''); + setCreatedRange(null); + setSortBy('id'); + setSortOrder('desc'); + setApplied({}); + }; + + const onTableChange = ( + _pagination: unknown, + _filters: unknown, + sorter: SorterResult | SorterResult[], + ) => { + const s = Array.isArray(sorter) ? sorter[0] : sorter; + if (s && s.order && s.field) { + setSortBy(s.field as SortField); + setSortOrder(s.order === 'ascend' ? 'asc' : 'desc'); + } else { + setSortBy('id'); + setSortOrder('desc'); + } + }; + + const sortOrderOf = (field: SortField) => + sortBy === field ? (sortOrder === 'asc' ? 'ascend' : 'descend') : null; const handle = async (f: Feedback) => { try { @@ -29,18 +106,75 @@ export default function FeedbacksPage() { } }; + const selectedNew = items.filter( + (f) => selectedRowKeys.includes(f.id) && f.status === 'new', + ); + + // 批量:循环调现有逐条 /handle 接口(各自已带审计),Promise.allSettled 汇总成败,不新增 bulk 端点 + const bulkHandle = () => { + if (!selectedNew.length) { + message.warning('没有可处理的选中项(仅待处理 new 态生效)'); + return; + } + Modal.confirm({ + title: `确认批量标记处理 ${selectedNew.length} 条反馈?`, + content: '仅对选中的待处理(new)反馈生效', + onOk: async () => { + const results = await Promise.allSettled( + selectedNew.map((f) => api.post(`/admin/api/feedbacks/${f.id}/handle`)), + ); + const ok = results.filter((r) => r.status === 'fulfilled').length; + const fail = results.length - ok; + if (fail) message.warning(`批量标记处理完成:成功 ${ok}/${results.length},失败 ${fail}`); + else message.success(`批量标记处理完成:${ok} 条`); + setSelectedRowKeys([]); + reload(); + }, + }); + }; + const columns: ColumnsType = [ - { title: 'ID', dataIndex: 'id', width: 70 }, + { title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') }, { title: '用户', dataIndex: 'user_id', width: 80 }, { title: '内容', dataIndex: 'content' }, - { title: '联系方式', dataIndex: 'contact', width: 140 }, + { + title: '截图', + dataIndex: 'images', + width: 180, + render: (imgs: string[] | null) => + imgs && imgs.length ? ( + + + {imgs.map((src, i) => ( + + ))} + + + ) : ( + + ), + }, + { title: '联系方式', dataIndex: 'contact', width: 140, render: (v: string) => v || '-' }, { title: '状态', dataIndex: 'status', width: 90, render: (s: string) => {s}, }, - { title: '时间', dataIndex: 'created_at', render: dt, width: 180 }, + { + title: '时间', + dataIndex: 'created_at', + width: 180, + sorter: true, + sortOrder: sortOrderOf('created_at'), + render: (v: string) => formatUtcTime(v), + }, { title: '操作', key: 'op', @@ -57,23 +191,75 @@ export default function FeedbacksPage() { return (

反馈工单

- + setUserId(e.target.value.replace(/\D/g, ''))} + onPressEnter={search} + allowClear + style={{ width: 120 }} + /> + setContent(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 180 }} + /> + setCreatedRange(v as [Dayjs, Dayjs] | null)} + allowClear + /> + + -
+ + {canHandle && selectedNew.length > 0 && ( + + 已选 {selectedNew.length} 条待处理 + + + + )} + +
({ disabled: f.status !== 'new' }), + } + : undefined + } + columns={columns} + dataSource={items} + loading={loading} + pagination={false} + onChange={onTableChange} + />
-
= { ...applied, sort_by: sortBy, sort_order: sortOrder }; const filterKey = JSON.stringify(filters); - const { items, nextCursor, loading, loadMore, reload } = useCursorList( - '/admin/api/users', - filters, - ); + const { items, total, page, pageSize, loading, onChange: onPageChange, reload } = + usePagedList('/admin/api/users', filters); const canCoins = canDo(['finance']); const canStatus = canDo(['operator']); @@ -190,8 +188,14 @@ export default function UsersPage() { const columns: ColumnsType = [ { title: 'ID', dataIndex: 'id', width: 80, sorter: true, sortOrder: sortOrderOf('id') }, - { title: '手机号', dataIndex: 'phone' }, - { title: '昵称', dataIndex: 'nickname', render: (v: string | null) => v || '-' }, + { title: '手机号', dataIndex: 'phone', width: 130 }, + { + title: '昵称', + dataIndex: 'nickname', + width: 150, + ellipsis: true, + render: (v: string | null) => v || '-', + }, { title: '渠道', dataIndex: 'register_channel', width: 90 }, { title: '状态', @@ -219,9 +223,9 @@ export default function UsersPage() { title: '操作', key: 'op', fixed: 'right', - width: 230, + width: 300, render: (_: unknown, u: UserListItem) => ( - e.stopPropagation()}> + e.stopPropagation()}> router.push(`/users/${u.id}`)}>详情 {canCoins && setCoinUser(u)}>调金币} {canCoins && setCashUser(u)}>调现金} @@ -333,15 +337,17 @@ export default function UsersPage() { columns={columns} dataSource={items} loading={loading} - pagination={false} - scroll={{ x: 1100 }} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 个用户`, + onChange: onPageChange, + }} + scroll={{ x: 1160 }} onChange={onTableChange} /> -
- -
setCoinUser(null)} /> setCashUser(null)} /> diff --git a/src/app/(main)/withdraws/page.tsx b/src/app/(main)/withdraws/page.tsx index aef4d36..a33a067 100644 --- a/src/app/(main)/withdraws/page.tsx +++ b/src/app/(main)/withdraws/page.tsx @@ -39,7 +39,7 @@ import dayjs, { type Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { formatUtcTime, formatWallTime, utcDayjs, utcFromNow, yuan } from '@/lib/format'; -import { useCursorList } from '@/lib/useCursorList'; +import { usePagedList } from '@/lib/usePagedList'; import type { AuditLog, CashTxn, @@ -181,7 +181,6 @@ export default function WithdrawsPage() { const [bulkRejecting, setBulkRejecting] = useState([]); const [rejectReason, setRejectReason] = useState(''); const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const [pageSize, setPageSize] = useState(20); const [searchDraft, setSearchDraft] = useState(''); const [keyword, setKeyword] = useState(''); const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null); @@ -202,11 +201,8 @@ export default function WithdrawsPage() { filters.sort_by = sortBy; filters.sort_order = sortOrder; const filterKey = JSON.stringify(filters); - const { items, nextCursor, loading, loadMore, reload } = useCursorList( - '/admin/api/withdraws', - filters, - pageSize, - ); + const { items, total, page, pageSize, loading, onChange: onPageChange, reload } = + usePagedList('/admin/api/withdraws', filters, 20); const canManage = canDo(['finance']); const selectedOrders = items.filter((item) => selectedRowKeys.includes(item.id)); const selectedReviewing = selectedOrders.filter((item) => item.status === 'reviewing'); @@ -290,7 +286,7 @@ export default function WithdrawsPage() { useEffect(() => { setSelectedRowKeys([]); - }, [filterKey, pageSize]); + }, [filterKey, page, pageSize]); const refreshAfterChange = async (outBillNo?: string) => { setSelectedRowKeys([]); @@ -887,31 +883,6 @@ export default function WithdrawsPage() {
)} - - - 当前已加载 {items.length} 条{nextCursor ? ',还有更多' : ',已到最后一页'} - - - 每次加载 -
`共 ${t} 条`, + onChange: onPageChange, + }} scroll={{ x: 1350 }} onRow={(record) => ({ onClick: () => openDetail(record), style: { cursor: 'pointer' }, })} /> -
- -
{ items: T[]; next_cursor: number | null; + total?: number | null; // offset 分页时为符合筛选条件的总条数(页码分页用);加载更多接口可能没有 } export interface UserListItem { diff --git a/src/lib/usePagedList.ts b/src/lib/usePagedList.ts new file mode 100644 index 0000000..00cad8f --- /dev/null +++ b/src/lib/usePagedList.ts @@ -0,0 +1,75 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { api } from './api'; +import type { CursorPage } from './types'; + +/** + * 页码分页列表 hook(对接后端 offset 分页 + total)。 + * 与 useCursorList(「加载更多」)并存:需要页码/跳页/共 N 条的主列表页用本 hook。 + * + * 约定:后端 cursor 即 offset,本 hook 按 (page-1)*pageSize 计算;CursorPage.total 为符合 + * 筛选条件的总条数(供 antd Table pagination 渲染)。filters 变化自动回第 1 页重载。 + */ +export function usePagedList( + url: string, + filters: Record, + initialPageSize = 20, +) { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(initialPageSize); + const [loading, setLoading] = useState(false); + const requestSeq = useRef(0); + + const filtersKey = JSON.stringify(filters); + + const load = useCallback( + async (p: number, ps: number) => { + const seq = requestSeq.current + 1; + requestSeq.current = seq; + setLoading(true); + try { + const params = { ...JSON.parse(filtersKey), limit: ps, cursor: (p - 1) * ps }; + const { data } = await api.get>(url, { params }); + if (requestSeq.current !== seq) return; // 丢弃过期响应(快速切页/筛选) + setItems(data.items); + setTotal(data.total ?? 0); + } finally { + if (requestSeq.current === seq) setLoading(false); + } + }, + [url, filtersKey], + ); + + // 筛选变化:回第 1 页重载 + useEffect(() => { + setPage(1); + load(1, pageSize); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [load]); + + // antd Table pagination.onChange(page, pageSize):页变或每页条数变都走这里 + const onChange = (p: number, ps: number) => { + if (ps !== pageSize) { + // 每页条数变 → 回第 1 页,避免越界空页 + setPageSize(ps); + setPage(1); + load(1, ps); + } else { + setPage(p); + load(p, ps); + } + }; + + return { + items, + total, + page, + pageSize, + loading, + onChange, + reload: () => load(page, pageSize), // 写操作后刷新当前页 + }; +} From f82f511b84a99db4b8a659d8e13634626bfe26fe Mon Sep 17 00:00:00 2001 From: ouzhou Date: Mon, 15 Jun 2026 23:13:22 +0800 Subject: [PATCH 06/19] =?UTF-8?q?feat(ad-revenue):=20=E5=B9=BF=E5=91=8A?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E6=8A=A5=E8=A1=A8=E9=A1=B5(=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E9=87=91=E5=B8=81=E5=AE=A1=E8=AE=A1=E9=A1=B5)=20(#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 /ad-revenue:聚合表 + 派生指标(平均 eCPM/毛利/发奖占比)+ 零依赖 SVG 趋势图 + 行展开下钻展示明细/发奖复算明细 - 删除旧 /ad-audit 页;侧栏「金币审计」→「广告数据」 - feedbacks 由「加载更多」改 antd 页码分页(usePagedList),内容列 3 行省略可展开 - types 用 AdRevenue* 替换 AdCoinAudit*;providers 统一主题圆角/页面底色 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/11 Co-authored-by: ouzhou Co-committed-by: ouzhou --- src/app/(main)/ad-audit/page.tsx | 247 ---------- src/app/(main)/ad-revenue/page.tsx | 746 +++++++++++++++++++++++++++++ src/app/(main)/feedbacks/page.tsx | 41 +- src/app/(main)/layout.tsx | 4 +- src/app/providers.tsx | 12 +- src/lib/types.ts | 76 ++- 6 files changed, 841 insertions(+), 285 deletions(-) delete mode 100644 src/app/(main)/ad-audit/page.tsx create mode 100644 src/app/(main)/ad-revenue/page.tsx diff --git a/src/app/(main)/ad-audit/page.tsx b/src/app/(main)/ad-audit/page.tsx deleted file mode 100644 index 3d5cdad..0000000 --- a/src/app/(main)/ad-audit/page.tsx +++ /dev/null @@ -1,247 +0,0 @@ -'use client'; - -import { useCallback, useEffect, useState } from 'react'; -import type { ColumnsType } from 'antd/es/table'; -import { - Alert, - Button, - Card, - Checkbox, - DatePicker, - InputNumber, - Select, - Space, - Table, - Tag, - Typography, - message, -} from 'antd'; -import dayjs, { type Dayjs } from 'dayjs'; -import { api, errMsg } from '@/lib/api'; -import { formatUtcTime } from '@/lib/format'; -import type { AdCoinAudit, AdCoinAuditRow } from '@/lib/types'; - -const SCENE_TAG: Record = { - reward_video: { color: 'blue', label: '看视频' }, - feed: { color: 'purple', label: '信息流' }, -}; - -const STATUS_TAG: Record = { - granted: 'green', - capped: 'orange', - ecpm_missing: 'red', -}; - -const fmtFactorRange = (a: number | null, b: number | null) => { - if (a == null) return '-'; - return a === b || b == null ? String(a) : `${a}→${b}`; -}; - -const fmtIndexRange = (a: number | null, b: number | null) => { - if (a == null) return '-'; - return a === b ? String(a) : `${a}–${b}`; -}; - -export default function AdAuditPage() { - const [date, setDate] = useState(dayjs()); - const [userId, setUserId] = useState(null); - const [scene, setScene] = useState(); - const [limit, setLimit] = useState(100); - const [data, setData] = useState(null); - const [queriedLimit, setQueriedLimit] = useState(100); // 本次结果对应的 limit,截断提示里展示 - const [onlyMismatch, setOnlyMismatch] = useState(false); // 只看不一致(✗)行 - const [loading, setLoading] = useState(false); - - const load = useCallback(async () => { - setLoading(true); - try { - const res = await api.get('/admin/api/ad-coin-audit', { - params: { - date: date.format('YYYY-MM-DD'), - user_id: userId ?? undefined, - scene: scene ?? undefined, - limit, - only_mismatch: onlyMismatch || undefined, - }, - }); - setData(res.data); - setQueriedLimit(limit); - } catch (e) { - message.error(errMsg(e)); - } finally { - setLoading(false); - } - }, [date, userId, scene, limit, onlyMismatch]); - - useEffect(() => { - load(); - // 仅首次自动拉今天;之后由「查询」按钮触发 - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const columns: ColumnsType = [ - { - title: '场景', - dataIndex: 'scene', - width: 90, - render: (s: string) => { - const t = SCENE_TAG[s] ?? { color: 'default', label: s }; - return {t.label}; - }, - }, - { title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 175 }, - { title: '用户', dataIndex: 'user_id', width: 70 }, - { - title: '状态', - dataIndex: 'status', - width: 110, - render: (s: string) => {s}, - }, - { title: 'eCPM', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' }, - { - title: '因子1', - dataIndex: 'ecpm_factor', - width: 70, - render: (v: number | null) => v ?? '-', - }, - { title: '份数', dataIndex: 'units', width: 60 }, - { - title: 'LT累计条数', - key: 'lt_index', - width: 100, - render: (_: unknown, r: AdCoinAuditRow) => fmtIndexRange(r.lt_index_start, r.lt_index_end), - }, - { - title: '因子2', - key: 'lt_factor', - width: 90, - render: (_: unknown, r: AdCoinAuditRow) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end), - }, - { - title: '应发金币', - dataIndex: 'expected_coin', - width: 100, - render: (v: number) => {v}, - }, - { title: '实发金币', dataIndex: 'actual_coin', width: 100 }, - { - title: '一致', - dataIndex: 'matched', - width: 70, - render: (m: boolean) => - m ? : ✗ 不符, - }, - ]; - - return ( -
-

看广告金币审计

- - 把「看视频赚金币」和「比价信息流广告」的发奖记录,用与正式发奖相同的公式复算一遍,核对实发金币是否按公式计算。纯只读对账。 - - - {data && ( - - 金币公式:{' '} - {data.formula.description} -
-
- eCPM 口径:{data.formula.ecpm_unit},计算时 ÷100 转元;金币:元 ={' '} - {data.formula.coin_per_yuan}:1;信息流每 {data.formula.feed_unit_seconds} 秒折 1 条。两个点位「LT累计条数」各自独立计数。 -
-
- 因子1(按 eCPM 元判档,阈值单位:元): - {data.formula.ecpm_factor_tiers - .map(([f, lo, hi]) => `${lo}~${hi ?? '∞'}元→${f}`) - .join(' | ')} -
-
- 因子2(LT 累计条数): - {data.formula.lt_factor_tiers - .map(([f, lo, hi]) => `${lo}${hi != null && hi !== lo ? `~${hi}` : ''}→${f}`) - .join(' | ')} -
-
-
- )} - - - d && setDate(d)} allowClear={false} /> - setUserId(v ?? null)} - style={{ width: 140 }} - /> - ({ value: n, label: `${n} 条` }))} - /> - setOnlyMismatch(e.target.checked)}> - 只看不一致(✗) - - - - - {data && ( -
- - {data.mismatch_count > 0 ? ( - - ) : ( - - )} - {onlyMismatch && ( - - )} - {data.truncated && ( - - )} - -
- )} - -
`${r.scene}-${r.record_id}`} - columns={columns} - dataSource={data?.items ?? []} - loading={loading} - pagination={false} - size="small" - scroll={{ x: 1100 }} - rowClassName={(r) => (r.matched ? '' : 'row-mismatch')} - /> - - - ); -} diff --git a/src/app/(main)/ad-revenue/page.tsx b/src/app/(main)/ad-revenue/page.tsx new file mode 100644 index 0000000..e5d2e04 --- /dev/null +++ b/src/app/(main)/ad-revenue/page.tsx @@ -0,0 +1,746 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import type { ColumnsType } from 'antd/es/table'; +import { + Alert, + Button, + Card, + Col, + DatePicker, + Divider, + InputNumber, + Modal, + Popover, + Row, + Select, + Space, + Statistic, + Table, + Tag, + Typography, + message, +} from 'antd'; +import { + FallOutlined, + InfoCircleOutlined, + QuestionCircleOutlined, + RiseOutlined, +} from '@ant-design/icons'; +import dayjs, { type Dayjs } from 'dayjs'; +import { api, errMsg } from '@/lib/api'; +import { formatUtcTime } from '@/lib/format'; +import type { + AdRevenueDaily, + AdRevenueImpression, + AdRevenueRecord, + AdRevenueReport, + AdRevenueRow, +} from '@/lib/types'; + +const { RangePicker } = DatePicker; + +// 广告类型标签 +const TYPE_TAG: Record = { + reward_video: { color: 'blue', label: '激励视频' }, + feed: { color: 'purple', label: '信息流' }, + draw: { color: 'geekblue', label: 'Draw 信息流' }, +}; + +// 我们的应用环境标签 +const APP_TAG: Record = { + prod: { color: 'green', label: '傻瓜比价(正式)' }, + test: { color: 'default', label: '测试应用' }, +}; + +// 发奖状态 → 颜色 + 中文(含「不发金币的原因」:提前关闭/未满10秒/缺eCPM/次数超限) +const STATUS_TAG: Record = { + granted: { color: 'green', label: '已发' }, + capped: { color: 'orange', label: '次数超限' }, + ecpm_missing: { color: 'red', label: '缺 eCPM' }, + too_short: { color: 'gold', label: '未满10秒' }, + closed_early: { color: 'default', label: '提前关闭' }, +}; + +const fmtFactorRange = (a: number | null, b: number | null) => { + if (a == null) return '-'; + return a === b || b == null ? String(a) : `${a}→${b}`; +}; + +const fmtIndexRange = (a: number | null, b: number | null) => { + if (a == null) return '-'; + return a === b ? String(a) : `${a}–${b}`; +}; + +// 金币计算公式(写死,展示用)——与后端 app/core/rewards.py 同源,改动以后端常量为准。 +// 单次奖励(元) = (eCPM元 ÷ 1000) × 因子1(eCPM 元档) × 因子2(LT 累计条数);1 元 = 10000 金币,四舍五入取整。 +const COIN_PER_YUAN = 10000; +// 因子1:按 eCPM(元/千次)判档,对应 AD_ECPM_FACTOR_TABLE +const ECPM_FACTOR_ROWS = [ + { key: '4', range: '> 400', factor: 0.6 }, + { key: '3', range: '201 – 400', factor: 0.4 }, + { key: '2', range: '101 – 200', factor: 0.3 }, + { key: '1', range: '0 – 100', factor: 0.1 }, +]; +// 因子2:按该账号累计第几条/份(不按天重置),对应 AD_LT_FACTOR_TABLE +const LT_FACTOR_ROWS = [ + { key: '1', lt: '第 1 条', factor: 2.0 }, + { key: '2', lt: '第 2 条', factor: 1.5 }, + { key: '3', lt: '第 3 条', factor: 1.3 }, + { key: '4', lt: '4 – 10 条', factor: 1.1 }, + { key: '5', lt: '第 11 条及以后', factor: 1.0 }, +]; + +// 展开行 - 展示明细:每条广告展示一行(时间/eCPM/收益/adn/底层位) +const IMPRESSION_COLUMNS: ColumnsType = [ + { title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 }, + { title: 'eCPM(分)', dataIndex: 'ecpm', width: 100 }, + { title: '预估收益(元)', dataIndex: 'revenue_yuan', width: 120, render: (v: number) => v.toFixed(4) }, + { + title: 'adn', + dataIndex: 'adn', + width: 90, + render: (v: string | null) => v ?? '-', + }, + { + title: '底层代码位ID', + dataIndex: 'slot_id', + render: (v: string | null) => v ?? '-', + }, +]; + +// 展开行 - 发奖明细:该组的逐条发奖复算明细(还原原金币审计的 eCPM/因子1/份数/LT/因子2 等列) +const DETAIL_COLUMNS: ColumnsType = [ + { title: '时间', dataIndex: 'created_at', render: (v: string) => formatUtcTime(v), width: 165 }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: (s: string) => { + const t = STATUS_TAG[s] ?? { color: 'default', label: s }; + return {t.label}; + }, + }, + { title: 'eCPM(分)', dataIndex: 'ecpm', width: 90, render: (v: string | null) => v ?? '-' }, + { title: '因子1', dataIndex: 'ecpm_factor', width: 70, render: (v: number | null) => v ?? '-' }, + { title: '份数', dataIndex: 'units', width: 60 }, + { + title: 'LT累计条数', + key: 'lt_index', + width: 100, + render: (_: unknown, r: AdRevenueRecord) => fmtIndexRange(r.lt_index_start, r.lt_index_end), + }, + { + title: '因子2', + key: 'lt_factor', + width: 90, + render: (_: unknown, r: AdRevenueRecord) => fmtFactorRange(r.lt_factor_start, r.lt_factor_end), + }, + { title: '应发金币', dataIndex: 'expected_coin', width: 100, render: (v: number) => {v} }, + { title: '实发金币', dataIndex: 'actual_coin', width: 100 }, + { + title: '一致', + dataIndex: 'matched', + width: 80, + render: (m: boolean) => (m ? : ✗ 不符), + }, +]; + +// 趋势图(纯 SVG,零依赖):柱=展示条数(左轴),线=预估收益元(右轴)。x 轴按传入点序。 +const CHART_BAR = '#69b1ff'; +const CHART_LINE = '#fa8c16'; + +interface TrendPoint { + label: string; // x 轴刻度文案(小时数 / MM-DD) + tip: string; // hover 原生 tooltip 全文 + impressions: number; + revenue: number; +} + +// 按小时聚合(0–23),数据源是 items(被 limit 截断时会偏少,调用处给警告) +function aggregateHourly(rows: AdRevenueRow[]): TrendPoint[] { + const byHour = Array.from({ length: 24 }, (_, h) => ({ hour: h, impressions: 0, revenue: 0 })); + for (const r of rows) { + if (r.hour == null || r.hour < 0 || r.hour > 23) continue; + byHour[r.hour].impressions += r.impressions; + byHour[r.hour].revenue += r.revenue_yuan; + } + return byHour.map((b) => ({ + label: String(b.hour), + tip: `${String(b.hour).padStart(2, '0')}:00 展示 ${b.impressions} 条 · 预估收益 ${b.revenue.toFixed(4)} 元`, + impressions: b.impressions, + revenue: b.revenue, + })); +} + +// 按天聚合,数据源是 daily(全量,不受 limit 影响);用 from..to 补齐空缺日为 0,轴连续 +function aggregateDaily(dateFrom: string, dateTo: string, daily: AdRevenueDaily[]): TrendPoint[] { + const map = new Map(daily.map((d) => [d.date, d])); + const out: TrendPoint[] = []; + let cur = dayjs(dateFrom); + let guard = 0; + while (cur.format('YYYY-MM-DD') <= dateTo && guard < 400) { + const ds = cur.format('YYYY-MM-DD'); + const d = map.get(ds); + const impressions = d?.impressions ?? 0; + const revenue = d?.revenue_yuan ?? 0; + out.push({ + label: ds.slice(5), + tip: `${ds} 展示 ${impressions} 条 · 预估收益 ${revenue.toFixed(4)} 元`, + impressions, + revenue, + }); + cur = cur.add(1, 'day'); + guard += 1; + } + return out; +} + +function TrendChart({ points }: { points: TrendPoint[] }) { + const n = points.length; + const maxImp = Math.max(1, ...points.map((p) => p.impressions)); + const maxRev = Math.max(1e-9, ...points.map((p) => p.revenue)); + + const W = 960; + const H = 280; + const padL = 48; + const padR = 56; + const padT = 16; + const padB = 32; + const plotW = W - padL - padR; + const plotH = H - padT - padB; + const step = plotW / Math.max(1, n); + const barW = Math.min(28, step * 0.55); + const yBase = padT + plotH; + + const barX = (i: number) => padL + i * step + (step - barW) / 2; + const cx = (i: number) => padL + i * step + step / 2; + const impH = (v: number) => (v / maxImp) * plotH; + const revY = (v: number) => yBase - (v / maxRev) * plotH; + const linePts = points.map((p, i) => `${cx(i)},${revY(p.revenue)}`).join(' '); + const labelEvery = Math.max(1, Math.ceil(n / 12)); // 至多 ~12 个 x 标签,避免拥挤 + + return ( + + {[0, 0.25, 0.5, 0.75, 1].map((t) => { + const y = yBase - t * plotH; + return ( + + + + {Math.round(maxImp * t)} + + + {(maxRev * t).toFixed(2)} + + + ); + })} + {points.map((p, i) => ( + + {p.tip} + + ))} + + {points.map((p, i) => ( + + {p.tip} + + ))} + {points.map((p, i) => + i % labelEvery === 0 || i === n - 1 ? ( + + {p.label} + + ) : null, + )} + + ); +} + +// 广告数据页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出 +// 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。 +export default function AdRevenuePage() { + const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]); + const [userId, setUserId] = useState(null); + const [adType, setAdType] = useState(); + const [granularity, setGranularity] = useState<'day' | 'hour'>('day'); + const [limit, setLimit] = useState(500); + const [queriedGranularity, setQueriedGranularity] = useState<'day' | 'hour'>('day'); // 本次结果对应的粒度,决定是否显示「小时」列 + const [queriedMultiDay, setQueriedMultiDay] = useState(false); // 本次结果是否跨多天,决定显示「日期」列 + 按天/按小时图 + const [data, setData] = useState(null); + const [queriedLimit, setQueriedLimit] = useState(500); + const [loading, setLoading] = useState(false); + const [formulaOpen, setFormulaOpen] = useState(false); + + // 当前选择是否跨多天:跨多天时「按小时」无意义,粒度强制按天 + const rangeMultiDay = range[0].format('YYYY-MM-DD') !== range[1].format('YYYY-MM-DD'); + + const load = useCallback(async () => { + const from = range[0].format('YYYY-MM-DD'); + const to = range[1].format('YYYY-MM-DD'); + const multiDay = from !== to; + const gran = multiDay ? 'day' : granularity; // 跨多天强制按天 + setLoading(true); + try { + const res = await api.get('/admin/api/ad-revenue-report', { + params: { + date_from: from, + date_to: to, + user_id: userId ?? undefined, + ad_type: adType ?? undefined, + granularity: gran, + limit, + }, + }); + setData(res.data); + setQueriedLimit(limit); + setQueriedGranularity(gran); + setQueriedMultiDay(multiDay); + } catch (e) { + message.error(errMsg(e)); + } finally { + setLoading(false); + } + }, [range, userId, adType, granularity, limit]); + + useEffect(() => { + load(); + // 仅首次自动拉今天;之后由「查询」按钮触发 + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const columns: ColumnsType = [ + ...(queriedMultiDay + ? [{ title: '日期', dataIndex: 'report_date', width: 110, fixed: 'left' } as ColumnsType[number]] + : []), + { title: '用户', dataIndex: 'user_id', width: 80 }, + ...(queriedGranularity === 'hour' + ? [ + { + title: '小时', + dataIndex: 'hour', + width: 90, + render: (h: number | null) => + h == null ? '-' : `${String(h).padStart(2, '0')}:00`, + } as ColumnsType[number], + ] + : []), + { + title: '广告类型', + dataIndex: 'ad_type', + width: 120, + render: (s: string) => { + const t = TYPE_TAG[s] ?? { color: 'default', label: s }; + return {t.label}; + }, + }, + { + title: '来源应用', + dataIndex: 'app_env', + width: 150, + render: (v: string | null) => { + if (!v) return 未知; + const t = APP_TAG[v] ?? { color: 'default', label: v }; + return {t.label}; + }, + }, + { + title: '广告位ID', + dataIndex: 'our_code_id', + width: 120, + render: (v: string | null) => + v ? {v} : -, + }, + { title: '展示条数', dataIndex: 'impressions', width: 90, align: 'right' }, + { + title: '预估收益(元)', + dataIndex: 'revenue_yuan', + width: 120, + align: 'right', + render: (v: number) => v.toFixed(4), + }, + { + title: '应发金币', + dataIndex: 'expected_coin', + width: 100, + align: 'right', + render: (v: number) => {v}, + }, + { title: '实发金币', dataIndex: 'actual_coin', width: 100, align: 'right' }, + { + title: '一致', + dataIndex: 'matched', + width: 80, + align: 'center', + render: (m: boolean) => (m ? : ✗ 不符), + }, + { + title: '底层 ADN', + dataIndex: 'adns', + render: (v: string[]) => + v.length ? v.map((a) => {a}) : -, + }, + ]; + + // 派生指标(全部基于全量 total_* 字段,不受 limit 截断影响,准): + // 平均 eCPM = 收益÷展示×1000;发奖成本(元)= 实发金币÷汇率;预估毛利 = 收益−发奖成本; + // 发奖占收益比 = 发奖成本÷收益;应发实发差额(金币)= 应发−实发(正=少发/负=多发)。 + const derived = data + ? { + avgEcpm: data.total_impressions + ? (data.total_revenue_yuan / data.total_impressions) * 1000 + : 0, + payoutYuan: data.total_actual_coin / COIN_PER_YUAN, + grossProfit: data.total_revenue_yuan - data.total_actual_coin / COIN_PER_YUAN, + payoutRatioPct: + data.total_revenue_yuan > 0 + ? (data.total_actual_coin / COIN_PER_YUAN / data.total_revenue_yuan) * 100 + : null, + coinGap: data.total_expected_coin - data.total_actual_coin, + } + : null; + + return ( +
+ +

广告数据

+ + + 「预估收益」为客户端在广告展示(onAdShow)时上报 eCPM 折算的预估值(每千次展示 ÷1000 + 累加),只要广告展示就计入、不论是否看完发奖;穿山甲会过滤无效/过短曝光,故预估值可能偏高, + 实际收益一律以穿山甲后台结算为准。测试应用多为 0。「广告位ID / 来源应用」为本期新增,历史记录留空。 +
+ } + > + + + + + + + + 日期 + v && v[0] && v[1] && setRange([v[0], v[1]])} + allowClear={false} + presets={[ + { label: '今天', value: [dayjs(), dayjs()] }, + { label: '近 7 天', value: [dayjs().subtract(6, 'day'), dayjs()] }, + { label: '近 30 天', value: [dayjs().subtract(29, 'day'), dayjs()] }, + ]} + /> + + + 用户 + setUserId(v ?? null)} + style={{ width: 130 }} + /> + + + 类型 + + + + 条数 +
`${r.report_date}-${r.user_id}-${r.ad_type}-${r.app_env ?? ''}-${r.our_code_id ?? ''}-${r.hour ?? ''}`} + columns={columns} + dataSource={data?.items ?? []} + loading={loading} + pagination={false} + size="small" + scroll={{ x: 1100 }} + rowClassName={(r) => (r.matched ? '' : 'row-mismatch')} + expandable={{ + rowExpandable: (r) => + (r.impression_records?.length ?? 0) > 0 || (r.records?.length ?? 0) > 0, + expandedRowRender: (r) => { + const imps = r.impression_records ?? []; + const recs = r.records ?? []; + return ( + +
+ 展示明细{' '} + ({imps.length} 条) + + style={{ marginTop: 8 }} + rowKey="id" + columns={IMPRESSION_COLUMNS} + dataSource={imps} + pagination={false} + size="small" + scroll={{ x: 700 }} + /> +
+
+ 发奖明细{' '} + ({recs.length} 条) + + style={{ marginTop: 8 }} + rowKey="record_id" + columns={DETAIL_COLUMNS} + dataSource={recs} + pagination={false} + size="small" + scroll={{ x: 900 }} + rowClassName={(d) => (d.matched ? '' : 'row-mismatch')} + locale={{ emptyText: '该组无发奖记录(只有展示,未发奖)' }} + /> +
+
+ ); + }, + }} + /> + setFormulaOpen(false)} + footer={null} + width={680} + > + + 单次奖励(元) =(单次 eCPM ÷ 1000)× 因子1(eCPM 档)× 因子2(LT 累计条数) + + + · eCPM 取自穿山甲 SDK getEcpm() 原值,单位「分/千次展示」,计算时 ÷100 转元;单次收益按「每千次 ÷1000」折到每次。 +
· 金币汇率:1 元 = {COIN_PER_YUAN.toLocaleString()} 金币,最终四舍五入取整。 +
· 因子2「LT 累计条数」按该账号累计第几条计(不按天重置);激励视频每次 1 条,信息流每满 10 秒折 1 条。 +
· 本表与发奖后端同源,本页「应发金币」即按此复算并与「实发金币」对账。 +
+ +
+ 因子1 ·按 eCPM(元/千次)判档 +
+ + + 因子2 ·按账号 LT 累计条数 +
+ + + + 示例:eCPM = 300 元/千次、账号第 1 条 → 0.3 × 0.4 × 2.0 = 0.24 元 = 2,400 金币。 + + + + + + ); +} diff --git a/src/app/(main)/feedbacks/page.tsx b/src/app/(main)/feedbacks/page.tsx index 61091bb..4e0d253 100644 --- a/src/app/(main)/feedbacks/page.tsx +++ b/src/app/(main)/feedbacks/page.tsx @@ -21,10 +21,10 @@ import type { Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { formatUtcTime } from '@/lib/format'; -import { useCursorList } from '@/lib/useCursorList'; +import { usePagedList } from '@/lib/usePagedList'; import type { Feedback } from '@/lib/types'; -const { Text } = Typography; +const { Text, Paragraph } = Typography; const { RangePicker } = DatePicker; // 截图是 app-server 的 /media 相对路径,本地由 :8770 提供(NEXT_PUBLIC_MEDIA_BASE);生产同域走 nginx 代理。 @@ -46,10 +46,8 @@ export default function FeedbacksPage() { const filters: Record = { ...applied, sort_by: sortBy, sort_order: sortOrder }; const filterKey = JSON.stringify(filters); - const { items, nextCursor, loading, loadMore, reload } = useCursorList( - '/admin/api/feedbacks', - filters, - ); + const { items, total, page, pageSize, loading, onChange: onPageChange, reload } = + usePagedList('/admin/api/feedbacks', filters); const canHandle = canDo(['operator']); const [selectedRowKeys, setSelectedRowKeys] = useState([]); @@ -136,7 +134,22 @@ export default function FeedbacksPage() { const columns: ColumnsType = [ { title: 'ID', dataIndex: 'id', width: 70, sorter: true, sortOrder: sortOrderOf('id') }, { title: '用户', dataIndex: 'user_id', width: 80 }, - { title: '内容', dataIndex: 'content' }, + { + title: '内容', + dataIndex: 'content', + width: 360, + render: (v: string) => + v ? ( + + {v} + + ) : ( + - + ), + }, { title: '截图', dataIndex: 'images', @@ -257,14 +270,16 @@ export default function FeedbacksPage() { columns={columns} dataSource={items} loading={loading} - pagination={false} + pagination={{ + current: page, + pageSize, + total, + showSizeChanger: true, + showTotal: (t) => `共 ${t} 条反馈`, + onChange: onPageChange, + }} onChange={onTableChange} /> -
- -
); } diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index cd4b4c5..efb8ba3 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -3,10 +3,10 @@ import { useEffect, useState } from 'react'; import { usePathname, useRouter } from 'next/navigation'; import { + BarChartOutlined, DashboardOutlined, FileSearchOutlined, FlagOutlined, - FundProjectionScreenOutlined, LogoutOutlined, MessageOutlined, MobileOutlined, @@ -28,7 +28,7 @@ const MENU = [ { key: '/withdraws', icon: , label: '提现管理' }, { key: '/price-reports', icon: , label: '上报审核' }, { key: '/feedbacks', icon: , label: '反馈工单' }, - { key: '/ad-audit', icon: , label: '金币审计' }, + { key: '/ad-revenue', icon: , label: '广告数据' }, { key: '/config', icon: , label: '系统配置' }, { key: '/admins', icon: , label: '管理员', superOnly: true }, { key: '/audit-logs', icon: , label: '审计日志' }, diff --git a/src/app/providers.tsx b/src/app/providers.tsx index a5493c3..62e2100 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -12,7 +12,17 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( {/* wave 禁用:消除 antd 5 波纹在 Next15 dev 下的 React 版本兼容误报(纯噪音,后台不需要波纹) */} - + {/* 全局主题:统一圆角到 8、页面底色用偏冷的浅灰,让白色卡片更有层次(全站生效) */} + {children} diff --git a/src/lib/types.ts b/src/lib/types.ts index 3cbd1b1..63e6bdd 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -188,40 +188,72 @@ export interface AuditLog { created_at: string; } -export interface AdCoinAuditRow { - scene: string; // reward_video / feed - record_id: number; - user_id: number; +// 广告收益报表聚合行下钻的单条展示明细 +export interface AdRevenueImpression { + id: number; created_at: string; - status: string; // granted / capped / ecpm_missing + ecpm: string; // 分/千次展示 + revenue_yuan: number; // 本次展示预估收益(元) + adn: string | null; + slot_id: string | null; // 底层 mediation rit +} + +// 广告收益报表聚合行下钻的单条发奖复算明细 +export interface AdRevenueRecord { + record_id: number; + created_at: string; + status: string; // granted / capped / ecpm_missing / too_short / closed_early ecpm: string | null; - ecpm_factor: number | null; - units: number; + ecpm_factor: number | null; // 因子1 + units: number; // 份数 lt_index_start: number | null; lt_index_end: number | null; - lt_factor_start: number | null; + lt_factor_start: number | null; // 因子2 lt_factor_end: number | null; expected_coin: number; actual_coin: number; matched: boolean; } -export interface AdCoinFormula { - description: string; - coin_per_yuan: number; - ecpm_unit: string; - feed_unit_seconds: number; - ecpm_factor_tiers: [number, number, number | null][]; - lt_factor_tiers: [number, number, number | null][]; +// 广告收益报表:按日期汇总的一天(按天趋势图用;全量,不受 limit 影响) +export interface AdRevenueDaily { + date: string; // 北京时间 YYYY-MM-DD + impressions: number; + revenue_yuan: number; + expected_coin: number; + actual_coin: number; } -export interface AdCoinAudit { - date: string; - formula: AdCoinFormula; - total: number; // 全量条数(不受 limit/only_mismatch 影响) - mismatch_count: number; // 全量不一致条数 - truncated: boolean; // 展示集是否被 limit 截断 - items: AdCoinAuditRow[]; +// 广告收益报表:按 日期 × 用户 × 广告类型 × 应用 × 代码位 聚合 +export interface AdRevenueRow { + report_date: string; // 该组所属日期 北京时间 YYYY-MM-DD + user_id: number; + ad_type: string; // reward_video / feed / draw + app_env: string | null; // prod(傻瓜比价) / test(测试);旧数据为空 + our_code_id: string | null; // 我们配置的代码位 104xxx;旧数据为空 + hour: number | null; // 北京时间小时 0–23(按小时粒度时有值;按天为 null) + impressions: number; // 展示条数(轮播每条各计一次) + revenue_yuan: number; // 收益(元),预估口径 + expected_coin: number; // 应发金币(公式复算,与金币审计同源) + actual_coin: number; // 实发金币(实际入账) + matched: boolean; // 该组应发==实发 + adns: string[]; // 底层填充 ADN 子渠道集合 + impression_records: AdRevenueImpression[]; // 该组逐条展示明细(展开下钻) + records: AdRevenueRecord[]; // 该组逐条发奖复算明细(展开下钻) +} + +export interface AdRevenueReport { + date_from: string; // 起始日 北京时间 YYYY-MM-DD + date_to: string; // 结束日 北京时间 YYYY-MM-DD(闭区间;单日时与 date_from 相同) + daily: AdRevenueDaily[]; // 按日期汇总序列(全量,供按天趋势图) + total: number; // 聚合组总数(全量,不受 limit 影响) + truncated: boolean; + total_impressions: number; + total_revenue_yuan: number; + total_expected_coin: number; + total_actual_coin: number; + mismatch_count: number; // 应发≠实发的组数 + items: AdRevenueRow[]; } export interface DashboardOverview { From 9134ea92551682661aabaf33ea75a54a276dbb4e Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 10:01:08 +0800 Subject: [PATCH 07/19] =?UTF-8?q?feat(cps):=20CPS=20=E5=88=86=E5=8F=91?= =?UTF-8?q?=E4=B8=8E=E5=AF=B9=E8=B4=A6=E5=90=8E=E5=8F=B0=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 群/活动管理 + 生成带 sid 短链(/c/{code}) + 对账统计(点击 PV/UV + 下单/佣金)。 Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 2 +- src/app/(main)/cps/page.tsx | 719 ++++++++++++++++++++++++++++++++++++ src/app/(main)/layout.tsx | 2 + src/lib/types.ts | 75 ++++ 4 files changed, 797 insertions(+), 1 deletion(-) create mode 100644 src/app/(main)/cps/page.tsx diff --git a/package-lock.json b/package-lock.json index a0bf487..5ae3994 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2438,7 +2438,7 @@ }, "node_modules/@next/swc-win32-x64-msvc": { "version": "15.5.19", - "resolved": "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.19.tgz", "integrity": "sha512-PhmojAHyqMne56HBLGu9dhDnHPuFmEjrXSQMM/nW0J6j849lk3ESrVtqNJcCk8CKOV7brpTTbaYAjwKPzKM69w==", "cpu": [ "x64" diff --git a/src/app/(main)/cps/page.tsx b/src/app/(main)/cps/page.tsx new file mode 100644 index 0000000..4683ac3 --- /dev/null +++ b/src/app/(main)/cps/page.tsx @@ -0,0 +1,719 @@ +'use client'; + +// CPS 优惠券分发与对账(当前仅美团)。单页 Tabs:对账统计 / 群管理 / 活动管理 / 订单明细。 +// 群管理生成带 sid 的券链接 → 发群 → 对账按 sid 拉回订单/佣金归群统计。 +import { useCallback, useEffect, useState } from 'react'; +import type { ColumnsType } from 'antd/es/table'; +import { + Button, + Card, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Statistic, + Table, + Tabs, + Tag, + Typography, + message, +} from 'antd'; +import { api, errMsg } from '@/lib/api'; +import { canDo } from '@/lib/auth'; +import { formatUtcTime, yuan } from '@/lib/format'; +import { usePagedList } from '@/lib/usePagedList'; +import type { + CpsActivity, + CpsGroup, + CpsGroupStat, + CpsOrder, + CpsReconcileResult, + CpsReferralLink, + CpsStats, +} from '@/lib/types'; + +const MT_STATUS: Record = { + '2': { text: '已付款', color: 'blue' }, + '3': { text: '已完成', color: 'cyan' }, + '4': { text: '已取消', color: 'red' }, + '5': { text: '风控', color: 'orange' }, + '6': { text: '已结算', color: 'green' }, +}; + +const LINK_TYPE_NAME: Record = { + '1': 'H5 长链', + '2': '短链(微信可发)', + '3': 'deeplink(唤起 App)', +}; + +const fmtRate = (r: string | null) => (r ? `${Number(r) / 100}%` : '-'); + +export default function CpsPage() { + return ( +
+

CPS 优惠券分发与对账

+ + 当前仅接美团联盟。每群一个 sid:在「群管理」给群生成带 sid 的券链接发群,「对账统计」按 sid + 拉回订单与佣金归群。点击数需独立跳转服务(第二期)。 + + }, + { key: 'groups', label: '群管理', children: }, + { key: 'activities', label: '活动管理', children: }, + { key: 'orders', label: '订单明细', children: }, + ]} + /> +
+ ); +} + +// ───────────── 对账统计 ───────────── +function StatsTab() { + const [days, setDays] = useState(30); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [reconciling, setReconciling] = useState(false); + const canReconcile = canDo(['finance']); + + const load = useCallback(async (d: number) => { + setLoading(true); + try { + const resp = await api.get('/admin/api/cps/stats', { params: { days: d } }); + setData(resp.data); + } catch (e) { + message.error(errMsg(e)); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + load(days); + }, [days, load]); + + const reconcile = async () => { + setReconciling(true); + try { + const resp = await api.post('/admin/api/cps/orders/reconcile', null, { + params: { days }, + }); + const r = resp.data; + message.success(`对账完成:拉取 ${r.fetched} 单(新增 ${r.inserted}、更新 ${r.updated})`); + load(days); + } catch (e) { + message.error(errMsg(e)); + } finally { + setReconciling(false); + } + }; + + const columns: ColumnsType = [ + { title: '群', dataIndex: 'name', width: 160, ellipsis: true }, + { + title: 'sid', + dataIndex: 'sid', + width: 130, + render: (v: string | null) => (v ? {v} : '-'), + }, + { title: '人数', dataIndex: 'member_count', width: 70, render: (v) => v ?? '-' }, + { title: '点击', dataIndex: 'click_pv', width: 70 }, + { title: '独立访客', dataIndex: 'click_uv', width: 80 }, + { title: '有效单', dataIndex: 'order_count', width: 70 }, + { + title: '取消/风控', + dataIndex: 'canceled_count', + width: 85, + render: (v: number) => (v ? {v} : 0), + }, + { + title: '点击→下单', + key: 'rate', + width: 90, + render: (_, r) => + r.click_uv ? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%` : '-', + }, + { title: '成交额', dataIndex: 'gmv_cents', width: 110, render: (v: number) => yuan(v) }, + { + title: '预估佣金', + dataIndex: 'est_commission_cents', + width: 110, + sorter: (a, b) => a.est_commission_cents - b.est_commission_cents, + render: (v: number) => {yuan(v)}, + }, + { + title: '已结算佣金', + dataIndex: 'settled_commission_cents', + width: 110, + render: (v: number) => {yuan(v)}, + }, + ]; + + return ( +
+ + 统计区间 +
r.sid ?? `gid-${r.group_id ?? 'none'}`} + columns={columns} + dataSource={data?.groups ?? []} + loading={loading} + pagination={false} + scroll={{ x: 1120 }} + /> + + ); +} + +// ───────────── 群管理 ───────────── +function GroupsTab() { + const [keyword, setKeyword] = useState(''); + const [applied, setApplied] = useState>({}); + const { items, total, page, pageSize, loading, onChange, reload } = usePagedList( + '/admin/api/cps/groups', + applied, + ); + const canManage = canDo(['operator']); + + const [editing, setEditing] = useState(null); // null=不开,{} 新建用单独标志 + const [createOpen, setCreateOpen] = useState(false); + const [linkGroup, setLinkGroup] = useState(null); + + const columns: ColumnsType = [ + { title: 'ID', dataIndex: 'id', width: 70 }, + { title: '群名', dataIndex: 'name', width: 180, ellipsis: true }, + { + title: 'sid', + dataIndex: 'sid', + width: 150, + render: (v: string) => {v}, + }, + { title: '人数', dataIndex: 'member_count', width: 80, render: (v) => v ?? '-' }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (s: string) => {s}, + }, + { title: '备注', dataIndex: 'remark', width: 160, ellipsis: true, render: (v) => v || '-' }, + { title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) }, + { + title: '操作', + key: 'op', + fixed: 'right', + width: 170, + render: (_, g) => ( + e.stopPropagation()}> + {canManage && setLinkGroup(g)}>生成链接} + {canManage && setEditing(g)}>编辑} + + ), + }, + ]; + + return ( +
+ + setKeyword(e.target.value)} + onPressEnter={() => setApplied({ keyword: keyword || undefined })} + allowClear + style={{ width: 180 }} + /> + + {canManage && ( + + )} + + +
`共 ${t} 个群`, + onChange, + }} + scroll={{ x: 1050 }} + /> + + setCreateOpen(false)} + onDone={reload} + /> + setEditing(null)} + onDone={reload} + /> + setLinkGroup(null)} /> + + ); +} + +function GroupFormModal({ + open, + group, + onClose, + onDone, +}: { + open: boolean; + group: CpsGroup | null; + onClose: () => void; + onDone: () => void; +}) { + const [form] = Form.useForm(); + const isEdit = !!group; + + useEffect(() => { + if (open) { + if (group) { + form.setFieldsValue({ + name: group.name, + member_count: group.member_count, + remark: group.remark, + status: group.status, + }); + } else { + form.resetFields(); + } + } + }, [open, group, form]); + + const submit = async () => { + const v = await form.validateFields(); + try { + if (isEdit && group) { + await api.patch(`/admin/api/cps/groups/${group.id}`, v); + message.success('已更新群'); + } else { + await api.post('/admin/api/cps/groups', v); + message.success('已新建群'); + } + onClose(); + onDone(); + } catch (e) { + message.error(errMsg(e)); + } + }; + + return ( + +
+ + + + {!isEdit && ( + + + + )} + + + + {isEdit && ( + + ({ + value: a.id, + label: `${a.name}${a.act_id ? ` (actId=${a.act_id})` : ''}`, + }))} + notFoundContent="无活动,请先到「活动管理」新建" + /> + + + + {result && ( +
+ 👇 发群就用这条链接(已带 sid={result.sid},点击会被统计): +
+ + {result.redirect_url} + +
+ {!result.redirect_url.startsWith('http') && ( + + ⚠️ 后端未配 CPS_REDIRECT_BASE,这是相对路径;请在 .env 配跳转域名后重启 + + )} + + 美团原始链接(参考/备用,直接发不带点击统计): + + {Object.entries(result.link_map).map(([type, url]) => ( +
+ {LINK_TYPE_NAME[type] ?? type} + + {url.length > 56 ? `${url.slice(0, 56)}…` : url} + +
+ ))} +
+ )} +
+ ); +} + +// ───────────── 活动管理 ───────────── +function ActivitiesTab() { + const { items, total, page, pageSize, loading, onChange, reload } = usePagedList( + '/admin/api/cps/activities', + {}, + ); + const canManage = canDo(['operator']); + const [createOpen, setCreateOpen] = useState(false); + + const columns: ColumnsType = [ + { title: 'ID', dataIndex: 'id', width: 70 }, + { title: '平台', dataIndex: 'platform', width: 90, render: (v: string) => {v} }, + { title: '活动名', dataIndex: 'name', width: 220, ellipsis: true }, + { + title: 'actId', + dataIndex: 'act_id', + width: 120, + render: (v: string | null) => v || '-', + }, + { + title: 'productViewSign', + dataIndex: 'product_view_sign', + width: 160, + ellipsis: true, + render: (v: string | null) => v || '-', + }, + { + title: '状态', + dataIndex: 'status', + width: 90, + render: (s: string) => {s}, + }, + { title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) }, + ]; + + return ( +
+ {canManage && ( + + + + )} +
`共 ${t} 个活动`, + onChange, + }} + scroll={{ x: 1000 }} + /> + setCreateOpen(false)} onDone={reload} /> + + ); +} + +function ActivityFormModal({ + open, + onClose, + onDone, +}: { + open: boolean; + onClose: () => void; + onDone: () => void; +}) { + const [form] = Form.useForm(); + + useEffect(() => { + if (open) form.resetFields(); + }, [open, form]); + + const submit = async () => { + const v = await form.validateFields(); + if (!v.act_id && !v.product_view_sign) { + message.warning('actId 与 productViewSign 至少填一个'); + return; + } + try { + await api.post('/admin/api/cps/activities', v); + message.success('已新建活动'); + onClose(); + onDone(); + } catch (e) { + message.error(errMsg(e)); + } + }; + + return ( + +
+ + + + + + + + + + + + + +
+ ); +} + +// ───────────── 订单明细 ───────────── +function OrdersTab() { + const [sid, setSid] = useState(''); + const [status, setStatus] = useState(); + const [applied, setApplied] = useState>({}); + const { items, total, page, pageSize, loading, onChange } = usePagedList( + '/admin/api/cps/orders', + applied, + ); + + const columns: ColumnsType = [ + { + title: '订单号', + dataIndex: 'order_id', + width: 200, + ellipsis: true, + render: (v: string) => {v}, + }, + { title: 'sid', dataIndex: 'sid', width: 120, render: (v) => v || '-' }, + { title: '商品', dataIndex: 'product_name', width: 200, ellipsis: true, render: (v) => v || '-' }, + { + title: '成交额', + dataIndex: 'pay_price_cents', + width: 100, + render: (v: number | null) => (v != null ? yuan(v) : '-'), + }, + { + title: '佣金', + dataIndex: 'commission_cents', + width: 90, + render: (v: number | null) => (v != null ? yuan(v) : '-'), + }, + { title: '佣金率', dataIndex: 'commission_rate', width: 80, render: fmtRate }, + { + title: '状态', + dataIndex: 'mt_status', + width: 90, + render: (s: string | null) => { + const m = s ? MT_STATUS[s] : null; + return m ? {m.text} : s || '-'; + }, + }, + { title: '支付时间', dataIndex: 'pay_time', width: 150, render: (v) => formatUtcTime(v) }, + ]; + + const search = () => setApplied({ sid: sid || undefined, mt_status: status }); + + return ( +
+ + setSid(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 150 }} + /> +
`共 ${t} 单`, + onChange, + }} + scroll={{ x: 1030 }} + /> + + ); +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index efb8ba3..f831cca 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -12,6 +12,7 @@ import { MobileOutlined, MoneyCollectOutlined, SettingOutlined, + ShareAltOutlined, TeamOutlined, UserOutlined, } from '@ant-design/icons'; @@ -29,6 +30,7 @@ const MENU = [ { key: '/price-reports', icon: , label: '上报审核' }, { key: '/feedbacks', icon: , label: '反馈工单' }, { key: '/ad-revenue', icon: , label: '广告数据' }, + { key: '/cps', icon: , label: 'CPS 分发' }, { key: '/config', icon: , label: '系统配置' }, { key: '/admins', icon: , label: '管理员', superOnly: true }, { key: '/audit-logs', icon: , label: '审计日志' }, diff --git a/src/lib/types.ts b/src/lib/types.ts index 63e6bdd..d3e5ea3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -303,3 +303,78 @@ export interface PriceReportSummary { rejected: number; total: number; } + +// ===== CPS 分发与对账 ===== +export interface CpsGroup { + id: number; + sid: string; // 美团二级渠道追踪位,每群唯一 + name: string; + member_count: number | null; + status: string; // active / archived + remark: string | null; + created_at: string; +} + +export interface CpsActivity { + id: number; + platform: string; // meituan / taobao / jd + name: string; + act_id: string | null; // 美团活动物料 ID + product_view_sign: string | null; + status: string; + remark: string | null; + created_at: string; +} + +export interface CpsOrder { + id: number; + order_id: string; + sid: string | null; + act_id: string | null; + pay_price_cents: number | null; + commission_cents: number | null; + commission_rate: string | null; // "300"=3% "10"=0.1% + mt_status: string | null; // 2付款 3完成 4取消 5风控 6结算 + invalid_reason: string | null; + product_name: string | null; + pay_time: string | null; +} + +export interface CpsReferralLink { + redirect_url: string; // ★ 我们的群发短链(/c/{code}),发群用这个,点击经我们统计 + code: string; + sid: string; + group_name: string; + activity_name: string; + target_url: string; // 实际 302 跳转的美团短链 + link_map: Record; // 美团原始 1长链/2短链/3deeplink(参考) +} + +export interface CpsReconcileResult { + fetched: number; + inserted: number; + updated: number; + pages: number; +} + +export interface CpsGroupStat { + group_id: number | null; // null = 未归群的 sid + sid: string | null; + name: string; + member_count: number | null; + click_pv: number; // 点击总次数 + click_uv: number; // 独立点击(ip+ua 近似去重) + order_count: number; // 有效单(不含取消/风控) + settled_count: number; + canceled_count: number; + gmv_cents: number; + est_commission_cents: number; + settled_commission_cents: number; +} + +export interface CpsStats { + groups: CpsGroupStat[]; + total_order_count: number; + total_est_commission_cents: number; + total_settled_commission_cents: number; +} From a393b5c0e3e516f859039606d817ccd516913a83 Mon Sep 17 00:00:00 2001 From: ouzhou Date: Wed, 17 Jun 2026 10:38:40 +0800 Subject: [PATCH 08/19] =?UTF-8?q?feat(config):=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=20bool=20=E5=BC=80=E5=85=B3=E6=8E=A7=E4=BB=B6=20+=20?= =?UTF-8?q?=E5=85=A8=E7=AB=99=20antd=20App.useApp=20=E8=BF=81=E7=A7=BB=20(?= =?UTF-8?q?#12)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 系统配置页支持 bool 类型(Switch 控件),供「提现自动对账」「比价期广告」等运营开关使用 - message / Modal.confirm 静态调用迁移到 App.useApp() 的 message / modal,消除 React 19 "Static function can not consume context" 告警;providers 用 容器包裹全站 Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: OuYingJun1024 <1034284404@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/12 Co-authored-by: ouzhou Co-committed-by: ouzhou --- src/app/(main)/ad-revenue/page.tsx | 3 ++- src/app/(main)/admins/page.tsx | 7 ++++--- src/app/(main)/config/page.tsx | 13 ++++++++++--- src/app/(main)/dashboard/HomeMarqueeSeeds.tsx | 3 ++- src/app/(main)/dashboard/HomeStatsConfig.tsx | 6 +++--- src/app/(main)/devices/page.tsx | 5 +++-- src/app/(main)/feedbacks/page.tsx | 6 +++--- src/app/(main)/price-reports/page.tsx | 5 +++-- src/app/(main)/users/[id]/page.tsx | 6 +++--- src/app/(main)/users/page.tsx | 11 ++++++----- src/app/(main)/withdraws/page.tsx | 13 +++++++------ src/app/login/page.tsx | 3 ++- src/app/providers.tsx | 6 ++++-- src/components/AdjustBalanceModals.tsx | 4 +++- 14 files changed, 55 insertions(+), 36 deletions(-) diff --git a/src/app/(main)/ad-revenue/page.tsx b/src/app/(main)/ad-revenue/page.tsx index e5d2e04..8515de2 100644 --- a/src/app/(main)/ad-revenue/page.tsx +++ b/src/app/(main)/ad-revenue/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import type { ColumnsType } from 'antd/es/table'; import { + App, Alert, Button, Card, @@ -19,7 +20,6 @@ import { Table, Tag, Typography, - message, } from 'antd'; import { FallOutlined, @@ -269,6 +269,7 @@ function TrendChart({ points }: { points: TrendPoint[] }) { // 广告数据页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出 // 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。 export default function AdRevenuePage() { + const { message } = App.useApp(); const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]); const [userId, setUserId] = useState(null); const [adType, setAdType] = useState(); diff --git a/src/app/(main)/admins/page.tsx b/src/app/(main)/admins/page.tsx index a8baa4e..4750ff3 100644 --- a/src/app/(main)/admins/page.tsx +++ b/src/app/(main)/admins/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import type { ColumnsType } from 'antd/es/table'; -import { Button, Form, Input, Modal, Select, Table, Tag, message } from 'antd'; +import { App, Button, Form, Input, Modal, Select, Table, Tag } from 'antd'; import { api, errMsg } from '@/lib/api'; import { formatUtcTime } from '@/lib/format'; import type { AdminInfo } from '@/lib/types'; @@ -16,6 +16,7 @@ const ROLES = [ const dt = (v: string | null) => (v ? formatUtcTime(v, 'YYYY-MM-DD HH:mm:ss') : '从未'); export default function AdminsPage() { + const { message, modal } = App.useApp(); const [admins, setAdmins] = useState([]); const [loading, setLoading] = useState(false); const [createOpen, setCreateOpen] = useState(false); @@ -49,7 +50,7 @@ export default function AdminsPage() { const changeRole = (a: AdminInfo, role: string) => { if (role === a.role) return; - Modal.confirm({ + modal.confirm({ title: `把 ${a.username} 的角色改为 ${role}?`, onOk: async () => { try { @@ -65,7 +66,7 @@ export default function AdminsPage() { const toggle = (a: AdminInfo) => { const next = a.status === 'active' ? 'disabled' : 'active'; - Modal.confirm({ + modal.confirm({ title: `${next === 'disabled' ? '禁用' : '启用'}管理员 ${a.username}?`, onOk: async () => { try { diff --git a/src/app/(main)/config/page.tsx b/src/app/(main)/config/page.tsx index 54069a5..4df3570 100644 --- a/src/app/(main)/config/page.tsx +++ b/src/app/(main)/config/page.tsx @@ -1,14 +1,14 @@ 'use client'; import { useEffect, useState } from 'react'; -import { Button, Card, Input, InputNumber, Space, Spin, Tag, Tooltip, message } from 'antd'; +import { App, Button, Card, Input, InputNumber, Space, Spin, Switch, Tag, Tooltip } from 'antd'; import { api, errMsg } from '@/lib/api'; interface ConfigItem { key: string; label: string; group: string; - type: string; // int / int_list / dict_str_int + type: string; // int / int_list / dict_str_int / bool help: string | null; // eslint-disable-next-line @typescript-eslint/no-explicit-any default: any; @@ -28,6 +28,7 @@ function toEdit(item: ConfigItem): any { // eslint-disable-next-line @typescript-eslint/no-explicit-any function fromEdit(type: string, raw: any): any { if (type === 'int') return Number(raw); + if (type === 'bool') return Boolean(raw); if (type === 'int_list') { return String(raw) .split(',') @@ -38,6 +39,7 @@ function fromEdit(type: string, raw: any): any { } export default function ConfigPage() { + const { message } = App.useApp(); const [items, setItems] = useState([]); const [loading, setLoading] = useState(true); // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -109,7 +111,12 @@ export default function ConfigPage() { )} - {item.type === 'int' ? ( + {item.type === 'bool' ? ( + setEdits({ ...edits, [item.key]: v })} + /> + ) : item.type === 'int' ? ( setEdits({ ...edits, [item.key]: v })} diff --git a/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx b/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx index 4fe0db7..ab915f8 100644 --- a/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx +++ b/src/app/(main)/dashboard/HomeMarqueeSeeds.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { + App, Button, Form, Input, @@ -12,7 +13,6 @@ import { Spin, Switch, Table, - message, } from 'antd'; import { api, errMsg } from '@/lib/api'; @@ -51,6 +51,7 @@ const yuan = (cents: number) => (cents / 100).toFixed(2); /** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「数据大盘」页的一个区块。 */ export default function HomeMarqueeSeeds() { + const { message } = App.useApp(); const [seeds, setSeeds] = useState([]); const [loading, setLoading] = useState(true); const [selectedRowKeys, setSelectedRowKeys] = useState([]); // 批量操作选中行 diff --git a/src/app/(main)/dashboard/HomeStatsConfig.tsx b/src/app/(main)/dashboard/HomeStatsConfig.tsx index 9825843..326920a 100644 --- a/src/app/(main)/dashboard/HomeStatsConfig.tsx +++ b/src/app/(main)/dashboard/HomeStatsConfig.tsx @@ -2,12 +2,12 @@ import { useEffect, useState } from 'react'; import { + App, Button, Card, Checkbox, Col, InputNumber, - Modal, Popconfirm, Radio, Row, @@ -16,7 +16,6 @@ import { Spin, Tag, Tooltip, - message, } from 'antd'; import { api, errMsg } from '@/lib/api'; import { formatUtcTime } from '@/lib/format'; @@ -249,6 +248,7 @@ function buildBody(metric: string, e: Edit, immediate: boolean): Record([]); const [loading, setLoading] = useState(true); const [edits, setEdits] = useState>({}); @@ -321,7 +321,7 @@ export default function HomeStatsConfig() { // 智能校验:按下后三者大概会呈现的关系是否合理,不合理则弹窗劝阻、确认才提交。 const rel = checkRelations(items, edits); if (rel && rel.warnings.length > 0) { - Modal.confirm({ + modal.confirm({ title: '数据关系校验', width: 480, okText: '仍要这样设置', diff --git a/src/app/(main)/devices/page.tsx b/src/app/(main)/devices/page.tsx index f746de8..99c03b7 100644 --- a/src/app/(main)/devices/page.tsx +++ b/src/app/(main)/devices/page.tsx @@ -1,7 +1,7 @@ 'use client'; import type { ColumnsType } from 'antd/es/table'; -import { Button, Modal, Popconfirm, Space, Table, message } from 'antd'; +import { App, Button, Popconfirm, Space, Table } from 'antd'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; import { formatUtcTime } from '@/lib/format'; @@ -13,6 +13,7 @@ const dt = (v: string) => formatUtcTime(v); const EMPTY: Record = {}; export default function DevicesPage() { + const { message, modal } = App.useApp(); // 后端按设备聚合、全量返回(next_cursor 恒 null),故无筛选/加载更多。 const { items, loading, reload } = useCursorList( '/admin/api/onboarding/devices', @@ -31,7 +32,7 @@ export default function DevicesPage() { }; const resetAll = () => { - Modal.confirm({ + modal.confirm({ title: '确认全部重设新手引导?', content: '清空所有设备的引导完成记录,库里所有用户下次打开 App 都会重走一遍新手引导。此操作不可逆。', diff --git a/src/app/(main)/feedbacks/page.tsx b/src/app/(main)/feedbacks/page.tsx index 4e0d253..82b2a5b 100644 --- a/src/app/(main)/feedbacks/page.tsx +++ b/src/app/(main)/feedbacks/page.tsx @@ -5,17 +5,16 @@ import type { Key } from 'react'; import type { ColumnsType } from 'antd/es/table'; import type { SorterResult } from 'antd/es/table/interface'; import { + App, Button, DatePicker, Image, Input, - Modal, Select, Space, Table, Tag, Typography, - message, } from 'antd'; import type { Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; @@ -34,6 +33,7 @@ const mediaUrl = (p: string) => (p.startsWith('http') ? p : MEDIA_BASE + p); type SortField = 'id' | 'created_at'; export default function FeedbacksPage() { + const { message, modal } = App.useApp(); // 筛选草稿:点「查询」才应用(避免输入即刷新) const [status, setStatus] = useState(); const [userId, setUserId] = useState(''); @@ -114,7 +114,7 @@ export default function FeedbacksPage() { message.warning('没有可处理的选中项(仅待处理 new 态生效)'); return; } - Modal.confirm({ + modal.confirm({ title: `确认批量标记处理 ${selectedNew.length} 条反馈?`, content: '仅对选中的待处理(new)反馈生效', onOk: async () => { diff --git a/src/app/(main)/price-reports/page.tsx b/src/app/(main)/price-reports/page.tsx index db2d17d..1bfbfd2 100644 --- a/src/app/(main)/price-reports/page.tsx +++ b/src/app/(main)/price-reports/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { + App, Button, Card, Image, @@ -13,7 +14,6 @@ import { Tabs, Tag, Typography, - message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons'; @@ -57,6 +57,7 @@ function statusTag(status: string) { } export default function PriceReportsPage() { + const { message, modal } = App.useApp(); const [activeStatus, setActiveStatus] = useState('pending'); const [summary, setSummary] = useState(null); const [rejecting, setRejecting] = useState(null); @@ -88,7 +89,7 @@ export default function PriceReportsPage() { }; const approve = (r: PriceReport) => { - Modal.confirm({ + modal.confirm({ title: '确认通过该上报?', icon: , content: ( diff --git a/src/app/(main)/users/[id]/page.tsx b/src/app/(main)/users/[id]/page.tsx index 1a49a32..2c1c1dc 100644 --- a/src/app/(main)/users/[id]/page.tsx +++ b/src/app/(main)/users/[id]/page.tsx @@ -4,11 +4,11 @@ import { useCallback, useEffect, useState } from 'react'; import { useParams } from 'next/navigation'; import type { ColumnsType } from 'antd/es/table'; import { + App, Button, Card, Col, Descriptions, - Modal, Result, Row, Space, @@ -17,7 +17,6 @@ import { Table, Tabs, Tag, - message, } from 'antd'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; @@ -27,6 +26,7 @@ import { AdjustCashModal, AdjustCoinModal } from '@/components/AdjustBalanceModa import type { CashTxn, CoinTxn, UserOverview, WithdrawOrder } from '@/lib/types'; export default function UserDetailPage() { + const { message, modal } = App.useApp(); const params = useParams<{ id: string }>(); const uid = Number(params.id); const [data, setData] = useState(null); @@ -56,7 +56,7 @@ export default function UserDetailPage() { const toggleStatus = () => { if (!data) return; const next = data.user.status === 'active' ? 'disabled' : 'active'; - Modal.confirm({ + modal.confirm({ title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${data.user.phone}?`, onOk: async () => { try { diff --git a/src/app/(main)/users/page.tsx b/src/app/(main)/users/page.tsx index 8e18458..5b0f537 100644 --- a/src/app/(main)/users/page.tsx +++ b/src/app/(main)/users/page.tsx @@ -5,7 +5,7 @@ import type { Key } from 'react'; import { useRouter } from 'next/navigation'; import type { ColumnsType } from 'antd/es/table'; import type { SorterResult } from 'antd/es/table/interface'; -import { Button, DatePicker, Input, Modal, Select, Space, Table, Tag, message } from 'antd'; +import { App, Button, DatePicker, Input, Select, Space, Table, Tag } from 'antd'; import dayjs, { type Dayjs } from 'dayjs'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; @@ -22,6 +22,7 @@ type SortField = 'id' | 'created_at' | 'last_login_at'; export default function UsersPage() { const router = useRouter(); + const { message, modal } = App.useApp(); // 筛选草稿:点「查询」才应用(避免输入即刷新);状态/渠道/日期一并随查询提交 const [phone, setPhone] = useState(''); @@ -97,7 +98,7 @@ export default function UsersPage() { const toggleStatus = (u: UserListItem) => { const next = u.status === 'active' ? 'disabled' : 'active'; - Modal.confirm({ + modal.confirm({ title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${u.phone}?`, onOk: async () => { try { @@ -113,7 +114,7 @@ export default function UsersPage() { const toggleDebugTrace = (u: UserListItem) => { const next = !u.debug_trace_enabled; - Modal.confirm({ + modal.confirm({ title: `确认${next ? '开启' : '关闭'}用户 ${u.phone} 的调试链接权限?`, content: next ? '开启后,该用户在 App 比价结果页/记录页可复制 trace 调试链接发给开发排障' @@ -156,7 +157,7 @@ export default function UsersPage() { message.warning(`没有可${target === 'disabled' ? '封禁(active)' : '解封(disabled)'}的选中用户`); return; } - Modal.confirm({ + modal.confirm({ title: `确认${label} ${targets.length} 个用户?`, content: target === 'disabled' ? '仅对选中的 active 用户生效' : '仅对选中的 disabled 用户生效', okButtonProps: { danger: target === 'disabled' }, @@ -177,7 +178,7 @@ export default function UsersPage() { message.warning('没有需要变更的选中用户'); return; } - Modal.confirm({ + modal.confirm({ title: `确认${label} ${targets.length} 个用户?`, onOk: () => runBulk(targets, label, (u) => diff --git a/src/app/(main)/withdraws/page.tsx b/src/app/(main)/withdraws/page.tsx index a33a067..afc9fdf 100644 --- a/src/app/(main)/withdraws/page.tsx +++ b/src/app/(main)/withdraws/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import type { Key } from 'react'; import { + App, Alert, Button, Card, @@ -22,7 +23,6 @@ import { Tag, Timeline, Typography, - message, } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { @@ -169,6 +169,7 @@ function bulkResultText(action: string, result: WithdrawBulkResult) { } export default function WithdrawsPage() { + const { message, modal } = App.useApp(); const [activeStatus, setActiveStatus] = useState('reviewing'); const [summary, setSummary] = useState(null); const [health, setHealth] = useState(null); @@ -305,7 +306,7 @@ export default function WithdrawsPage() { }; const approve = (o: WithdrawOrder) => { - Modal.confirm({ + modal.confirm({ title: `确认通过并打款 ${yuan(o.amount_cents)}?`, content: (
@@ -368,7 +369,7 @@ export default function WithdrawsPage() { } const amount = selectedReviewing.reduce((sum, item) => sum + item.amount_cents, 0); let confirmText = ''; - Modal.confirm({ + modal.confirm({ title: `确认批量通过 ${selectedReviewing.length} 笔提现?`, content: ( @@ -406,7 +407,7 @@ export default function WithdrawsPage() { message.warning('请选择打款中的提现单'); return; } - Modal.confirm({ + modal.confirm({ title: `确认批量刷新 ${selectedPending.length} 笔打款状态?`, content: '会逐笔向微信查单,并按最新状态归一化。', okText: '批量刷新查单', @@ -428,7 +429,7 @@ export default function WithdrawsPage() { }; const retry = (o: WithdrawOrder) => { - Modal.confirm({ + modal.confirm({ title: `重新查询 ${o.out_bill_no} 的微信状态?`, content: '会按微信最新状态归一化为成功、失败退款或撤销未确认。', okText: '刷新查单', @@ -450,7 +451,7 @@ export default function WithdrawsPage() { }; const reconcile = () => { - Modal.confirm({ + modal.confirm({ title: '批量对账?', content: '扫描超过 15 分钟仍处于打款中的提现单,逐单向微信查实并归一化。', okText: '开始对账', diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index c899571..d4bfd9d 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,13 +2,14 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; -import { Button, Card, Form, Input, message } from 'antd'; +import { App, Button, Card, Form, Input } from 'antd'; import { api, errMsg } from '@/lib/api'; import { setAuth } from '@/lib/auth'; import type { LoginResponse } from '@/lib/types'; export default function LoginPage() { const router = useRouter(); + const { message } = App.useApp(); const [loading, setLoading] = useState(false); const onFinish = async (values: { username: string; password: string }) => { diff --git a/src/app/providers.tsx b/src/app/providers.tsx index 62e2100..e81d986 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -5,7 +5,7 @@ // 必须在引入 antd 之前 import。 import '@ant-design/v5-patch-for-react-19'; import { AntdRegistry } from '@ant-design/nextjs-registry'; -import { ConfigProvider } from 'antd'; +import { App, ConfigProvider } from 'antd'; import zhCN from 'antd/locale/zh_CN'; export function Providers({ children }: { children: React.ReactNode }) { @@ -23,7 +23,9 @@ export function Providers({ children }: { children: React.ReactNode }) { }, }} > - {children} + {/* antd 容器:让 message/notification/Modal 能通过 App.useApp() 读到 ConfigProvider + 的动态主题/locale,消除 "Static function can not consume context" 告警(全站生效) */} + {children} ); diff --git a/src/components/AdjustBalanceModals.tsx b/src/components/AdjustBalanceModals.tsx index 5192b1f..8c287b7 100644 --- a/src/components/AdjustBalanceModals.tsx +++ b/src/components/AdjustBalanceModals.tsx @@ -3,7 +3,7 @@ // 调金币 / 调现金弹窗(受控)。列表页与用户详情页共用,避免两处各写一套。 // 每个弹窗自带「增减 / 设为指定值」两种模式;打开时拉一次 360 概览展示当前余额。 import { useEffect, useState } from 'react'; -import { Form, Input, InputNumber, Modal, Segmented, message } from 'antd'; +import { App, Form, Input, InputNumber, Modal, Segmented } from 'antd'; import { api, errMsg } from '@/lib/api'; import { yuan } from '@/lib/format'; import type { UserOverview } from '@/lib/types'; @@ -47,6 +47,7 @@ function useBalances(userId: number | undefined, open: boolean) { } export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) { + const { message } = App.useApp(); const [form] = Form.useForm(); const [mode, setMode] = useState<'delta' | 'set'>('delta'); const balances = useBalances(user?.id, open); @@ -110,6 +111,7 @@ export function AdjustCoinModal({ user, open, onClose, onDone }: ModalProps) { } export function AdjustCashModal({ user, open, onClose, onDone }: ModalProps) { + const { message } = App.useApp(); const [form] = Form.useForm(); const [mode, setMode] = useState<'delta' | 'set'>('delta'); const balances = useBalances(user?.id, open); From 654aca4c401e7377d48cd263168dd09517660293 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 19:15:27 +0800 Subject: [PATCH 09/19] =?UTF-8?q?feat(cps):=20=E5=88=86=E5=8F=91=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E4=BB=8E=E4=BB=85=E7=BE=8E=E5=9B=A2=E6=89=A9=E5=B1=95?= =?UTF-8?q?=E5=88=B0=E7=BE=8E=E5=9B=A2/=E6=B7=98=E5=AE=9D/=E4=BA=AC?= =?UTF-8?q?=E4=B8=9C=E5=A4=9A=E5=B9=B3=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 群/活动支持平台多选,sid 仅美团群保留 - 活动按平台切换录入(美团 actId/pvs、淘宝淘口令、京东链接) - 生成发群链接改批量勾选多活动(/cps/referral-links) - 统计表加平台/复制列,淘宝/京东无对账字段显示 "-" Co-Authored-By: Claude Opus 4.8 --- src/app/(main)/cps/page.tsx | 352 +++++++++++++++++++++--------------- src/lib/types.ts | 43 +++-- 2 files changed, 228 insertions(+), 167 deletions(-) diff --git a/src/app/(main)/cps/page.tsx b/src/app/(main)/cps/page.tsx index 4683ac3..fa3901e 100644 --- a/src/app/(main)/cps/page.tsx +++ b/src/app/(main)/cps/page.tsx @@ -1,12 +1,13 @@ 'use client'; -// CPS 优惠券分发与对账(当前仅美团)。单页 Tabs:对账统计 / 群管理 / 活动管理 / 订单明细。 -// 群管理生成带 sid 的券链接 → 发群 → 对账按 sid 拉回订单/佣金归群统计。 +// CPS 优惠券分发与对账。平台:美团(actId+sid,完整对账) / 淘宝(淘口令) / 京东(链接)。 +// 淘宝/京东无 API → 只统计点击,对账字段显示 "-"。单页 Tabs:对账统计/群管理/活动管理/订单明细。 import { useCallback, useEffect, useState } from 'react'; import type { ColumnsType } from 'antd/es/table'; import { Button, Card, + Checkbox, Form, Input, InputNumber, @@ -30,7 +31,7 @@ import type { CpsGroupStat, CpsOrder, CpsReconcileResult, - CpsReferralLink, + CpsReferralLinks, CpsStats, } from '@/lib/types'; @@ -42,21 +43,30 @@ const MT_STATUS: Record = { '6': { text: '已结算', color: 'green' }, }; -const LINK_TYPE_NAME: Record = { - '1': 'H5 长链', - '2': '短链(微信可发)', - '3': 'deeplink(唤起 App)', -}; +const PLATFORM_LABEL: Record = { meituan: '美团', taobao: '淘宝', jd: '京东' }; +const PLATFORM_COLOR: Record = { meituan: 'gold', taobao: 'volcano', jd: 'red' }; +const PLATFORM_OPTIONS = [ + { label: '美团', value: 'meituan' }, + { label: '淘宝', value: 'taobao' }, + { label: '京东', value: 'jd' }, +]; const fmtRate = (r: string | null) => (r ? `${Number(r) / 100}%` : '-'); +const DASH = -; +const platformTags = (ps: string[] | undefined) => + (ps || []).map((p) => ( + + {PLATFORM_LABEL[p] || p} + + )); export default function CpsPage() { return (

CPS 优惠券分发与对账

- 当前仅接美团联盟。每群一个 sid:在「群管理」给群生成带 sid 的券链接发群,「对账统计」按 sid - 拉回订单与佣金归群。点击数需独立跳转服务(第二期)。 + 美团(actId + sid,可完整对账下单/佣金) / 淘宝(淘口令) / 京东(链接)。淘宝、京东无开放 API, + 只统计点击,对账字段显示「-」。发群链接都是咱的落地页 /c/xxx。 = [ - { title: '群', dataIndex: 'name', width: 160, ellipsis: true }, + { title: '群', dataIndex: 'name', width: 140, ellipsis: true, fixed: 'left' }, + { title: '平台', key: 'platforms', width: 130, render: (_, r) => platformTags(r.platforms) }, + { title: '人数', dataIndex: 'member_count', width: 64, render: (v) => v ?? '-' }, + { title: '点击', dataIndex: 'click_pv', width: 64 }, + { title: '独立', dataIndex: 'click_uv', width: 64 }, { - title: 'sid', - dataIndex: 'sid', - width: 130, - render: (v: string | null) => (v ? {v} : '-'), + title: '复制', + dataIndex: 'copy_pv', + width: 64, + render: (v: number) => (v ? v : DASH), + }, + { + title: '有效单', + dataIndex: 'order_count', + width: 70, + render: (v: number | null) => (v == null ? DASH : v), }, - { title: '人数', dataIndex: 'member_count', width: 70, render: (v) => v ?? '-' }, - { title: '点击', dataIndex: 'click_pv', width: 70 }, - { title: '独立访客', dataIndex: 'click_uv', width: 80 }, - { title: '有效单', dataIndex: 'order_count', width: 70 }, { title: '取消/风控', dataIndex: 'canceled_count', - width: 85, - render: (v: number) => (v ? {v} : 0), + width: 80, + render: (v: number | null) => (v == null ? DASH : v ? {v} : 0), }, { title: '点击→下单', key: 'rate', - width: 90, + width: 88, render: (_, r) => - r.click_uv ? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%` : '-', + r.order_count == null ? DASH : r.click_uv ? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%` : '-', + }, + { + title: '成交额', + dataIndex: 'gmv_cents', + width: 96, + render: (v: number | null) => (v == null ? DASH : yuan(v)), }, - { title: '成交额', dataIndex: 'gmv_cents', width: 110, render: (v: number) => yuan(v) }, { title: '预估佣金', dataIndex: 'est_commission_cents', - width: 110, - sorter: (a, b) => a.est_commission_cents - b.est_commission_cents, - render: (v: number) => {yuan(v)}, + width: 100, + sorter: (a, b) => (a.est_commission_cents || 0) - (b.est_commission_cents || 0), + render: (v: number | null) => (v == null ? DASH : {yuan(v)}), }, { title: '已结算佣金', dataIndex: 'settled_commission_cents', - width: 110, - render: (v: number) => {yuan(v)}, + width: 100, + render: (v: number | null) => (v == null ? DASH : {yuan(v)}), }, ]; @@ -172,14 +193,12 @@ function StatsTab() { 刷新对账(拉美团订单) )} - - 佣金为美团预估口径,实际以「已结算」为准 - + 淘宝/京东无对账,「-」即无法对账;佣金以「已结算」为准 - +
); @@ -222,33 +241,33 @@ function GroupsTab() { ); const canManage = canDo(['operator']); - const [editing, setEditing] = useState(null); // null=不开,{} 新建用单独标志 + const [editing, setEditing] = useState(null); const [createOpen, setCreateOpen] = useState(false); const [linkGroup, setLinkGroup] = useState(null); const columns: ColumnsType = [ - { title: 'ID', dataIndex: 'id', width: 70 }, - { title: '群名', dataIndex: 'name', width: 180, ellipsis: true }, + { title: 'ID', dataIndex: 'id', width: 60 }, + { title: '群名', dataIndex: 'name', width: 160, ellipsis: true }, + { title: '平台', key: 'platforms', width: 140, render: (_, g) => platformTags(g.platforms) }, { title: 'sid', dataIndex: 'sid', - width: 150, - render: (v: string) => {v}, + width: 130, + render: (v: string | null) => (v ? {v} : DASH), }, - { title: '人数', dataIndex: 'member_count', width: 80, render: (v) => v ?? '-' }, + { title: '人数', dataIndex: 'member_count', width: 70, render: (v) => v ?? '-' }, { title: '状态', dataIndex: 'status', - width: 90, + width: 80, render: (s: string) => {s}, }, - { title: '备注', dataIndex: 'remark', width: 160, ellipsis: true, render: (v) => v || '-' }, { title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) }, { title: '操作', key: 'op', fixed: 'right', - width: 170, + width: 150, render: (_, g) => ( e.stopPropagation()}> {canManage && setLinkGroup(g)}>生成链接} @@ -272,9 +291,7 @@ function GroupsTab() { - {canManage && ( - - )} + {canManage && }
`共 ${t} 个群`, onChange, }} - scroll={{ x: 1050 }} + scroll={{ x: 1040 }} /> - setCreateOpen(false)} - onDone={reload} - /> - setEditing(null)} - onDone={reload} - /> + setCreateOpen(false)} onDone={reload} /> + setEditing(null)} onDone={reload} /> setLinkGroup(null)} /> ); @@ -323,18 +330,22 @@ function GroupFormModal({ }) { const [form] = Form.useForm(); const isEdit = !!group; + const watchPlatforms = (Form.useWatch('platforms', form) as string[] | undefined) || []; + const hasMeituan = watchPlatforms.includes('meituan'); useEffect(() => { if (open) { if (group) { form.setFieldsValue({ name: group.name, + platforms: group.platforms, member_count: group.member_count, remark: group.remark, status: group.status, }); } else { form.resetFields(); + form.setFieldsValue({ platforms: ['meituan'] }); } } }, [open, group, form]); @@ -357,26 +368,32 @@ function GroupFormModal({ }; return ( - +
- {!isEdit && ( + + + + {hasMeituan && !isEdit && ( )} + {hasMeituan && isEdit && ( + + 当前 sid:{group?.sid || '(无,保存后如缺会在生成美团链接时报错)'} + + )} @@ -400,13 +417,13 @@ function GroupFormModal({ function ReferralLinkModal({ group, onClose }: { group: CpsGroup | null; onClose: () => void }) { const [activities, setActivities] = useState([]); - const [activityId, setActivityId] = useState(); - const [result, setResult] = useState(null); + const [activityIds, setActivityIds] = useState([]); + const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); useEffect(() => { if (!group) { - setActivityId(undefined); + setActivityIds([]); setResult(null); return; } @@ -416,16 +433,19 @@ function ReferralLinkModal({ group, onClose }: { group: CpsGroup | null; onClose .catch((e) => message.error(errMsg(e))); }, [group]); + // 只列群平台范围内的活动 + const groupActivities = activities.filter((a) => (group?.platforms || []).includes(a.platform)); + const generate = async () => { - if (!group || !activityId) { - message.warning('请选择活动'); + if (!group || !activityIds.length) { + message.warning('请勾选要生成的活动'); return; } setLoading(true); try { - const resp = await api.post('/admin/api/cps/referral-link', { + const resp = await api.post('/admin/api/cps/referral-links', { group_id: group.id, - activity_id: activityId, + activity_ids: activityIds, }); setResult(resp.data); } catch (e) { @@ -437,68 +457,68 @@ function ReferralLinkModal({ group, onClose }: { group: CpsGroup | null; onClose return ( - - - - - - - - + + + + + + + + )} + {platform === 'taobao' && ( + + + + )} + {platform === 'jd' && ( + + + + )} + diff --git a/src/lib/types.ts b/src/lib/types.ts index d3e5ea3..3d2f4ab 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -307,8 +307,9 @@ export interface PriceReportSummary { // ===== CPS 分发与对账 ===== export interface CpsGroup { id: number; - sid: string; // 美团二级渠道追踪位,每群唯一 + sid: string | null; // 仅美团群有(渠道追踪位) name: string; + platforms: string[]; // meituan/taobao/jd 多选 member_count: number | null; status: string; // active / archived remark: string | null; @@ -321,6 +322,7 @@ export interface CpsActivity { name: string; act_id: string | null; // 美团活动物料 ID product_view_sign: string | null; + payload: string | null; // 淘宝整段淘口令 / 京东链接 status: string; remark: string | null; created_at: string; @@ -340,14 +342,17 @@ export interface CpsOrder { pay_time: string | null; } -export interface CpsReferralLink { - redirect_url: string; // ★ 我们的群发短链(/c/{code}),发群用这个,点击经我们统计 - code: string; - sid: string; - group_name: string; +export interface CpsReferralLinkItem { + activity_id: number; activity_name: string; - target_url: string; // 实际 302 跳转的美团短链 - link_map: Record; // 美团原始 1长链/2短链/3deeplink(参考) + platform: string; + redirect_url: string; // 我们的落地页 /c/{code}(发群用) + code: string; +} + +export interface CpsReferralLinks { + group_name: string; + results: CpsReferralLinkItem[]; } export interface CpsReconcileResult { @@ -358,18 +363,22 @@ export interface CpsReconcileResult { } export interface CpsGroupStat { - group_id: number | null; // null = 未归群的 sid + group_id: number | null; sid: string | null; name: string; + platforms: string[]; member_count: number | null; - click_pv: number; // 点击总次数 - click_uv: number; // 独立点击(ip+ua 近似去重) - order_count: number; // 有效单(不含取消/风控) - settled_count: number; - canceled_count: number; - gmv_cents: number; - est_commission_cents: number; - settled_commission_cents: number; + click_pv: number; + click_uv: number; + copy_pv: number; // 淘宝"复制口令"次数 + copy_uv: number; + // 对账类:淘宝/京东无法对账 → null(前端显示 "-") + order_count: number | null; + settled_count: number | null; + canceled_count: number | null; + gmv_cents: number | null; + est_commission_cents: number | null; + settled_commission_cents: number | null; } export interface CpsStats { From ecbce54e70e9dcc303c42144d3a70411b4cdf3d6 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 21:32:12 +0800 Subject: [PATCH 10/19] =?UTF-8?q?feat(cps):=20=E6=96=B0=E5=BB=BA=E6=B7=98?= =?UTF-8?q?=E5=AE=9D=E6=B4=BB=E5=8A=A8=E6=94=AF=E6=8C=81=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?/=E9=80=89=E6=8B=A9=E8=90=BD=E5=9C=B0=E9=A1=B5=E5=9B=BE=20+=20?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E7=BC=A9=E7=95=A5=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ActivityFormModal 淘宝分支加 ImagePicker(上传新图 / 选已有 / 预览),必填 - 活动列表加落地页图缩略图列 - types: CpsActivity.image_url Co-Authored-By: Claude Opus 4.8 --- src/app/(main)/cps/page.tsx | 110 +++++++++++++++++++++++++++++++++--- src/lib/types.ts | 1 + 2 files changed, 103 insertions(+), 8 deletions(-) diff --git a/src/app/(main)/cps/page.tsx b/src/app/(main)/cps/page.tsx index fa3901e..87e18e0 100644 --- a/src/app/(main)/cps/page.tsx +++ b/src/app/(main)/cps/page.tsx @@ -19,6 +19,7 @@ import { Tabs, Tag, Typography, + Upload, message, } from 'antd'; import { api, errMsg } from '@/lib/api'; @@ -556,6 +557,17 @@ function ActivitiesTab() { ellipsis: true, render: (v: string | null) => v || '-', }, + { + title: '落地页图', + dataIndex: 'image_url', + width: 80, + render: (v: string | null) => + v ? ( + + ) : ( + '-' + ), + }, { title: '状态', dataIndex: 'status', @@ -587,13 +599,86 @@ function ActivitiesTab() { showTotal: (t) => `共 ${t} 个活动`, onChange, }} - scroll={{ x: 1000 }} + scroll={{ x: 1080 }} /> setCreateOpen(false)} onDone={reload} /> ); } +// 淘宝活动落地页图选择器:上传新图 或 点选已有图。受控组件(value = image_url 绝对 URL)。 +function ImagePicker({ value, onChange }: { value?: string | null; onChange?: (v: string) => void }) { + const [existing, setExisting] = useState([]); + const [uploading, setUploading] = useState(false); + + useEffect(() => { + api + .get<{ images: string[] }>('/admin/api/cps/activity-images') + .then((r) => setExisting(r.data.images || [])) + .catch(() => {}); + }, []); + + const upload = async (file: File) => { + setUploading(true); + try { + const fd = new FormData(); + fd.append('file', file); + const r = await api.post<{ url: string }>('/admin/api/cps/upload-image', fd); + onChange?.(r.data.url); + setExisting((prev) => (prev.includes(r.data.url) ? prev : [r.data.url, ...prev])); + message.success('图片已上传'); + } catch (e) { + message.error(errMsg(e, '上传失败')); + } finally { + setUploading(false); + } + }; + + const thumb = (u: string, w: number, selected: boolean) => ( + 落地页图 onChange?.(u)} + style={{ + width: w, + height: Math.round(w * 1.27), + objectFit: 'cover', + borderRadius: 6, + cursor: 'pointer', + border: selected ? '2px solid #ff4d4f' : '2px solid #f0f0f0', + }} + /> + ); + + return ( +
+ { + upload(file); + return false; // 阻止 antd 默认上传,走我们自己的端点 + }} + > + + + {value && ( +
+ 当前: + {thumb(value, 90, true)} +
+ )} + {existing.length > 0 && ( +
+
或选择已有图:
+ {existing.map((u) => thumb(u, 56, u === value))} +
+ )} +
+ ); +} + function ActivityFormModal({ open, onClose, @@ -655,13 +740,22 @@ function ActivityFormModal({ )} {platform === 'taobao' && ( - - - + <> + + + + + + + )} {platform === 'jd' && ( Date: Wed, 17 Jun 2026 21:43:35 +0800 Subject: [PATCH 11/19] =?UTF-8?q?feat(cps):=20=E7=BE=A4/=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E5=8A=A0=E5=88=A0=E9=99=A4=E6=8C=89=E9=92=AE?= =?UTF-8?q?(Popconfirm=20=E4=BA=8C=E6=AC=A1=E7=A1=AE=E8=AE=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 群操作列 + 删除;活动列表新增操作列含删除 - 确认弹窗说明影响(已发链接仍可用 / 淘宝活动删后落地页用默认图) Co-Authored-By: Claude Opus 4.8 --- src/app/(main)/cps/page.tsx | 59 +++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 3 deletions(-) diff --git a/src/app/(main)/cps/page.tsx b/src/app/(main)/cps/page.tsx index 87e18e0..b82f8b4 100644 --- a/src/app/(main)/cps/page.tsx +++ b/src/app/(main)/cps/page.tsx @@ -12,6 +12,7 @@ import { Input, InputNumber, Modal, + Popconfirm, Select, Space, Statistic, @@ -246,6 +247,16 @@ function GroupsTab() { const [createOpen, setCreateOpen] = useState(false); const [linkGroup, setLinkGroup] = useState(null); + const delGroup = async (g: CpsGroup) => { + try { + await api.delete(`/admin/api/cps/groups/${g.id}`); + message.success('已删除群'); + reload(); + } catch (e) { + message.error(errMsg(e)); + } + }; + const columns: ColumnsType = [ { title: 'ID', dataIndex: 'id', width: 60 }, { title: '群名', dataIndex: 'name', width: 160, ellipsis: true }, @@ -268,11 +279,22 @@ function GroupsTab() { title: '操作', key: 'op', fixed: 'right', - width: 150, + width: 200, render: (_, g) => ( e.stopPropagation()}> {canManage && setLinkGroup(g)}>生成链接} {canManage && setEditing(g)}>编辑} + {canManage && ( + delGroup(g)} + > + 删除 + + )} ), }, @@ -308,7 +330,7 @@ function GroupsTab() { showTotal: (t) => `共 ${t} 个群`, onChange, }} - scroll={{ x: 1040 }} + scroll={{ x: 1090 }} /> setCreateOpen(false)} onDone={reload} /> @@ -535,6 +557,16 @@ function ActivitiesTab() { const canManage = canDo(['operator']); const [createOpen, setCreateOpen] = useState(false); + const delActivity = async (a: CpsActivity) => { + try { + await api.delete(`/admin/api/cps/activities/${a.id}`); + message.success('已删除活动'); + reload(); + } catch (e) { + message.error(errMsg(e)); + } + }; + const columns: ColumnsType = [ { title: 'ID', dataIndex: 'id', width: 60 }, { @@ -575,6 +607,27 @@ function ActivitiesTab() { render: (s: string) => {s}, }, { title: '创建时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) }, + ...(canManage + ? ([ + { + title: '操作', + key: 'op', + fixed: 'right' as const, + width: 80, + render: (_: unknown, a: CpsActivity) => ( + delActivity(a)} + > + 删除 + + ), + }, + ] as ColumnsType) + : []), ]; return ( @@ -599,7 +652,7 @@ function ActivitiesTab() { showTotal: (t) => `共 ${t} 个活动`, onChange, }} - scroll={{ x: 1080 }} + scroll={{ x: 1160 }} /> setCreateOpen(false)} onDone={reload} /> From cef5ea014050f6b6a07f258a763aabc51ff9a497 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 22:13:36 +0800 Subject: [PATCH 12/19] =?UTF-8?q?feat(cps):=20=E6=B4=BB=E5=8A=A8=E8=A1=A8?= =?UTF-8?q?=E5=8D=95=E6=94=AF=E6=8C=81=E7=BC=96=E8=BE=91=20+=20=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E5=8A=A0=E7=BC=96=E8=BE=91=E6=8C=89=E9=92=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ActivityFormModal 改 create+edit 双模式(回填字段, 编辑走 PATCH, 加状态切换) - 活动列表操作列加「编辑」 Co-Authored-By: Claude Opus 4.8 --- src/app/(main)/cps/page.tsx | 72 +++++++++++++++++++++++++++---------- 1 file changed, 54 insertions(+), 18 deletions(-) diff --git a/src/app/(main)/cps/page.tsx b/src/app/(main)/cps/page.tsx index b82f8b4..2231e7a 100644 --- a/src/app/(main)/cps/page.tsx +++ b/src/app/(main)/cps/page.tsx @@ -556,6 +556,7 @@ function ActivitiesTab() { ); const canManage = canDo(['operator']); const [createOpen, setCreateOpen] = useState(false); + const [editing, setEditing] = useState(null); const delActivity = async (a: CpsActivity) => { try { @@ -613,17 +614,20 @@ function ActivitiesTab() { title: '操作', key: 'op', fixed: 'right' as const, - width: 80, + width: 120, render: (_: unknown, a: CpsActivity) => ( - delActivity(a)} - > - 删除 - + + setEditing(a)}>编辑 + delActivity(a)} + > + 删除 + + ), }, ] as ColumnsType) @@ -652,9 +656,10 @@ function ActivitiesTab() { showTotal: (t) => `共 ${t} 个活动`, onChange, }} - scroll={{ x: 1160 }} + scroll={{ x: 1200 }} /> - setCreateOpen(false)} onDone={reload} /> + setCreateOpen(false)} onDone={reload} /> + setEditing(null)} onDone={reload} /> ); } @@ -734,22 +739,38 @@ function ImagePicker({ value, onChange }: { value?: string | null; onChange?: (v function ActivityFormModal({ open, + activity, onClose, onDone, }: { open: boolean; + activity: CpsActivity | null; onClose: () => void; onDone: () => void; }) { const [form] = Form.useForm(); + const isEdit = !!activity; const platform = (Form.useWatch('platform', form) as string | undefined) || 'meituan'; useEffect(() => { if (open) { - form.resetFields(); - form.setFieldsValue({ platform: 'meituan' }); + if (activity) { + form.setFieldsValue({ + name: activity.name, + platform: activity.platform, + act_id: activity.act_id, + product_view_sign: activity.product_view_sign, + payload: activity.payload, + image_url: activity.image_url, + remark: activity.remark, + status: activity.status, + }); + } else { + form.resetFields(); + form.setFieldsValue({ platform: 'meituan' }); + } } - }, [open, form]); + }, [open, activity, form]); const submit = async () => { const v = await form.validateFields(); @@ -763,8 +784,13 @@ function ActivityFormModal({ return; } try { - await api.post('/admin/api/cps/activities', v); - message.success('已新建活动'); + if (isEdit && activity) { + await api.patch(`/admin/api/cps/activities/${activity.id}`, v); + message.success('已更新活动'); + } else { + await api.post('/admin/api/cps/activities', v); + message.success('已新建活动'); + } onClose(); onDone(); } catch (e) { @@ -773,7 +799,7 @@ function ActivityFormModal({ }; return ( - + @@ -823,6 +849,16 @@ function ActivityFormModal({ + {isEdit && ( + + setPhone(e.target.value)} + onPressEnter={search} + allowClear + style={{ width: 150 }} + /> +
`共 ${t} 条`, + onChange, + }} + scroll={{ x: 1320 }} + onRow={(r) => ({ onClick: () => openDetail(r.id), style: { cursor: 'pointer' } })} + /> + + + {detailLoading && } + {detail && ( + + + + {detail.phone || `#${detail.user_id}`}{detail.nickname ? `(${detail.nickname})` : ''} + + {detail.status} + {detail.business_type} + {fmtMs(detail.total_ms)} + {detail.step_count ?? '-'} + {detail.llm_call_count ?? '-'} / {detail.retry_count ?? '-'} + {detail.source_platform_name || '-'}({cents(detail.source_price_cents)}) + {detail.best_platform_name || '-'}({cents(detail.best_price_cents)}) + {cents(detail.saved_amount_cents)}{detail.is_source_best ? '(源平台最便宜)' : ''} + {detail.store_name || '-'} + {detail.information || '-'} + + {detail.trace_url + ? {detail.trace_url} + : {detail.trace_id}(无 trace_url)} + + + {detail.longitude != null ? `${detail.longitude}, ${detail.latitude}` : '-'} + + + + + + {detail.device_model || '-'}({detail.device_manufacturer || '-'}) + {[detail.rom_vendor, detail.rom_name, detail.rom_version].filter(Boolean).join(' ') || '-'} + {detail.android_version || '-'}(SDK {detail.android_sdk ?? '-'}) + {detail.app_version || '-'}({detail.app_version_code ?? '-'}) + {detail.source_app_version || '-'} + + + + {platformResults.length > 0 && ( + +
String(i)} + pagination={false} + dataSource={platformResults} + columns={[ + { title: '平台', dataIndex: 'platform_name', render: (v, r: Record) => (v as string) || (r.platform_id as string) || '-' }, + { title: '结局', dataIndex: 'status', render: (s: string) => {STUCK_LABEL[s] || s || '-'} }, + { title: '说明', dataIndex: 'reason', render: (v) => (v as string) || '-' }, + ]} + /> + + )} + + {detail.comparison_results.length > 0 && ( + {JSON.stringify(detail.comparison_results, null, 2)}, + }]} + /> + )} + + + {detail.llm_calls && detail.llm_calls.length > 0 ? ( + ({ + key: String(i), + label: ( + + {c.scene || 'llm'} + {c.model} + {c.latency_ms ?? '-'}ms + {c.usage?.total_tokens ? {c.usage.total_tokens} tok : null} + {c.error ? error : null} + + ), + children: ( +
+ {c.input_messages.map((m, j) => ( +
+ {m.role} +
{pretty(m.content)}
+
+ ))} +
+ output +
{c.error ? `【error】${c.error}` : pretty(c.output)}
+
+
+ ), + }))} + /> + ) : ( + 无 LLM 调用明细(旧记录 / pricebot 未采集到 / 本次未调 LLM) + )} +
+ + {JSON.stringify(detail.raw_payload, null, 2)}, + }]} + /> + + )} + + + ); +} diff --git a/src/app/(main)/layout.tsx b/src/app/(main)/layout.tsx index f831cca..cc1336d 100644 --- a/src/app/(main)/layout.tsx +++ b/src/app/(main)/layout.tsx @@ -11,6 +11,7 @@ import { MessageOutlined, MobileOutlined, MoneyCollectOutlined, + ProfileOutlined, SettingOutlined, ShareAltOutlined, TeamOutlined, @@ -28,6 +29,7 @@ const MENU = [ { key: '/devices', icon: , label: '设备管理' }, { key: '/withdraws', icon: , label: '提现管理' }, { key: '/price-reports', icon: , label: '上报审核' }, + { key: '/comparison-records', icon: , label: '比价记录' }, { key: '/feedbacks', icon: , label: '反馈工单' }, { key: '/ad-revenue', icon: , label: '广告数据' }, { key: '/cps', icon: , label: 'CPS 分发' }, diff --git a/src/lib/types.ts b/src/lib/types.ts index 9bbebbe..f7e1f77 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -189,6 +189,70 @@ export interface AuditLog { } // 广告收益报表聚合行下钻的单条展示明细 +// ===== 比价记录(admin debug 页)===== +export interface ComparisonRecordListItem { + id: number; + user_id: number; + phone: string | null; + nickname: string | null; + business_type: string; + trace_id: string; + trace_url: string | null; + status: string; + information: string | null; + store_name: string | null; + source_platform_name: string | null; + best_platform_name: string | null; + source_price_cents: number | null; + best_price_cents: number | null; + saved_amount_cents: number | null; + total_ms: number | null; + step_count: number | null; + llm_call_count: number | null; + retry_count: number | null; + device_model: string | null; + rom_vendor: string | null; + rom_name: string | null; + android_version: string | null; + app_version: string | null; + created_at: string; +} + +// 单次 LLM 调用明细(pricebot chat() 收口落盘,server 按 trace 拉来) +export interface LlmCall { + ts?: number; + scene: string; + model: string; + input_messages: { role: string; content: string }[]; + output: string | null; + usage: { prompt_tokens?: number; completion_tokens?: number; total_tokens?: number } | null; + latency_ms: number | null; + error: string | null; +} + +export interface ComparisonRecordDetail extends ComparisonRecordListItem { + source_platform_id: string | null; + source_package: string | null; + best_platform_id: string | null; + best_deeplink: string | null; + is_source_best: boolean | null; + total_dish_count: number | null; + skipped_dish_count: number | null; + device_id: string | null; + items: { name: string; qty?: number; specs?: string[] }[]; + comparison_results: Record[]; + skipped_dish_names: string[]; + device_manufacturer: string | null; + rom_version: number | null; + android_sdk: number | null; + app_version_code: number | null; + source_app_version: string | null; + longitude: number | null; + latitude: number | null; + llm_calls: LlmCall[] | null; + raw_payload: Record | null; +} + export interface AdRevenueImpression { id: number; created_at: string; From 472912560c3f0743201b15193a5f1bc89b75d6a8 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 23:06:06 +0800 Subject: [PATCH 15/19] =?UTF-8?q?fix(cps):=20=E8=AF=A6=E6=83=85=E9=A1=B5?= =?UTF-8?q?=E6=8A=98=E7=BA=BF=E7=82=B9=E5=A4=A7=E5=B0=8F=E7=94=A8=20style.?= =?UTF-8?q?r(sizeField=20=E6=98=AF=E5=AD=97=E6=AE=B5=E6=98=A0=E5=B0=84?= =?UTF-8?q?=E4=B8=8D=E8=83=BD=E7=BB=99=E5=B8=B8=E9=87=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- src/app/(main)/cps/groups/[id]/page.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/app/(main)/cps/groups/[id]/page.tsx b/src/app/(main)/cps/groups/[id]/page.tsx index f8e00e9..c2760c5 100644 --- a/src/app/(main)/cps/groups/[id]/page.tsx +++ b/src/app/(main)/cps/groups/[id]/page.tsx @@ -94,7 +94,9 @@ export default function GroupDetailPage() { colorField: '指标', height: 420, legend: { color: { position: 'top' as const } }, - point: { sizeField: 3, shapeField: 'circle' }, + // 折线带数据点(每桶一个);大小用 style.r 固定 —— sizeField/shapeField 是数据字段映射, + // 给常量会被 G2 当字段名找而失效。 + point: { style: { r: 3 } }, // y 轴恒从 0 起、上限自适应取整;CPS 数据量小,固定 0 基线对比更直观。 scale: { y: { domainMin: 0, nice: true } }, axis: { x: { title: range === 'h' ? '小时' : '日期' }, y: { title: '次数' } }, From f4043f2abf36b2663476e20fdbc154effe19afa0 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 17 Jun 2026 23:32:36 +0800 Subject: [PATCH 16/19] =?UTF-8?q?fix(admin-web):=20=E6=AF=94=E4=BB=B7?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E9=A1=B5=E6=97=B6=E5=8C=BA/cancelled/?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E5=B1=95=E7=A4=BA=E4=BD=8D=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 列表时间改 formatWallTime(created_at 北京 wall-clock 口径, 原 formatUtcTime 快 8h) - cancelled 加颜色/中文(成功/失败/已终止) + 筛选选项 - 详情补 下单商品(items+跳过菜)/最优深链/device_id 展示位 - comparison_results 空值兜底 Co-Authored-By: Claude Opus 4.8 --- src/app/(main)/comparison-records/page.tsx | 45 ++++++++++++++++++---- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/src/app/(main)/comparison-records/page.tsx b/src/app/(main)/comparison-records/page.tsx index 04fb24e..113b9ac 100644 --- a/src/app/(main)/comparison-records/page.tsx +++ b/src/app/(main)/comparison-records/page.tsx @@ -8,11 +8,12 @@ import { } from 'antd'; import type { ColumnsType } from 'antd/es/table'; import { api, errMsg } from '@/lib/api'; -import { formatUtcTime, yuan } from '@/lib/format'; +import { formatWallTime, yuan } from '@/lib/format'; import { usePagedList } from '@/lib/usePagedList'; import type { ComparisonRecordDetail, ComparisonRecordListItem } from '@/lib/types'; -const STATUS_COLOR: Record = { success: 'green', failed: 'red' }; +const STATUS_COLOR: Record = { success: 'green', failed: 'red', cancelled: 'default' }; +const STATUS_LABEL: Record = { success: '成功', failed: '失败', cancelled: '已终止' }; // 「卡在哪一步」: platform_results[*].status 翻成人话(找店/加菜/起送/读价) const STUCK_LABEL: Record = { @@ -97,7 +98,7 @@ export default function ComparisonRecordsPage() { ), }, - { title: '状态', dataIndex: 'status', width: 72, render: (s: string) => {s} }, + { title: '状态', dataIndex: 'status', width: 72, render: (s: string) => {STATUS_LABEL[s] || s} }, { title: '源平台', dataIndex: 'source_platform_name', width: 90, render: (v) => v || '-' }, { title: '店/商品', dataIndex: 'store_name', width: 150, ellipsis: true, render: (v) => v || '-' }, { title: '最优', dataIndex: 'best_platform_name', width: 80, render: (v) => v || '-' }, @@ -126,7 +127,7 @@ export default function ComparisonRecordsPage() { ellipsis: true, render: (_, r) => [r.device_model, r.rom_name].filter(Boolean).join(' / ') || '-', }, - { title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatUtcTime(v) }, + { title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) }, { title: '操作', key: 'op', @@ -163,7 +164,11 @@ export default function ComparisonRecordsPage() { onChange={setStatus} allowClear style={{ width: 110 }} - options={[{ value: 'success', label: 'success' }, { value: 'failed', label: 'failed' }]} + options={[ + { value: 'success', label: '成功' }, + { value: 'failed', label: '失败' }, + { value: 'cancelled', label: '已终止' }, + ]} /> @@ -194,7 +199,7 @@ export default function ComparisonRecordsPage() { {detail.phone || `#${detail.user_id}`}{detail.nickname ? `(${detail.nickname})` : ''} - {detail.status} + {STATUS_LABEL[detail.status] || detail.status} {detail.business_type} {fmtMs(detail.total_ms)} {detail.step_count ?? '-'} @@ -212,6 +217,11 @@ export default function ComparisonRecordsPage() { {detail.longitude != null ? `${detail.longitude}, ${detail.latitude}` : '-'} + + {detail.best_deeplink + ? {detail.best_deeplink} + : '-'} + @@ -221,9 +231,30 @@ export default function ComparisonRecordsPage() { {detail.android_version || '-'}(SDK {detail.android_sdk ?? '-'}) {detail.app_version || '-'}({detail.app_version_code ?? '-'}) {detail.source_app_version || '-'} + {detail.device_id || '-'} + {((detail.items?.length ?? 0) > 0 || (detail.skipped_dish_names?.length ?? 0) > 0) && ( + + {(detail.items?.length ?? 0) > 0 ? ( +
    + {detail.items.map((it, i) => ( +
  • + {it.name}{it.qty != null ? ` ×${it.qty}` : ''} + {it.specs?.length ? ({it.specs.join('/')}) : null} +
  • + ))} +
+ ) : 无商品明细} + {(detail.skipped_dish_names?.length ?? 0) > 0 && ( +
+ 跳过{detail.skipped_dish_count != null ? ` ${detail.skipped_dish_count} ` : ''}个:{detail.skipped_dish_names.join('、')} +
+ )} +
+ )} + {platformResults.length > 0 && (
)} - {detail.comparison_results.length > 0 && ( + {(detail.comparison_results?.length ?? 0) > 0 && ( Date: Thu, 18 Jun 2026 17:32:20 +0800 Subject: [PATCH 17/19] =?UTF-8?q?feat(cps):=20=E7=BE=A4=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=E5=8A=A0=E6=8C=89=E5=A4=A9=E6=98=8E=E7=BB=86=E5=A4=A7?= =?UTF-8?q?=E8=A1=A8=E6=A0=BC(=E9=80=80=E6=AC=BE=E4=BD=A3=E9=87=91/?= =?UTF-8?q?=E5=87=80=E4=BD=A3=E9=87=91/=E8=BD=AC=E5=8C=96)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 天级范围(3/7/14/30天)显示每日明细表;今日小时模式隐藏(看上面折线) - 列:点击/独立/复制/点击→下单/有效单/取消风控/成交额/预估佣金/退款佣金/净佣金/结算佣金 - 净佣金=预估-退款(前端算);非美团群订单列显示 "-" Co-Authored-By: Claude Opus 4.8 --- src/app/(main)/cps/groups/[id]/page.tsx | 119 +++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/src/app/(main)/cps/groups/[id]/page.tsx b/src/app/(main)/cps/groups/[id]/page.tsx index c2760c5..2f60ddf 100644 --- a/src/app/(main)/cps/groups/[id]/page.tsx +++ b/src/app/(main)/cps/groups/[id]/page.tsx @@ -5,8 +5,10 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import dynamic from 'next/dynamic'; -import { Button, Card, Col, Empty, Row, Segmented, Space, Spin, Statistic, message } from 'antd'; +import type { ColumnsType } from 'antd/es/table'; +import { Button, Card, Col, Empty, Row, Segmented, Space, Spin, Statistic, Table, message } from 'antd'; import { api, errMsg } from '@/lib/api'; +import { yuan } from '@/lib/format'; // @ant-design/plots 依赖 DOM,关掉 SSR 仅在客户端渲染(否则 Next 预渲染报 document undefined)。 const Line = dynamic(() => import('@ant-design/plots').then((m) => m.Line), { ssr: false }); @@ -25,6 +27,26 @@ interface TsResp { points: TsPoint[]; } +// 按天大表格一行(订单侧字段对淘宝/京东群为 null → 显示 "-") +interface DailyRow { + date: string; + click_pv: number; + click_uv: number; + copy_pv: number; + order_count: number | null; + canceled_count: number | null; + gmv_cents: number | null; + est_commission_cents: number | null; + refund_profit_cents: number | null; + settled_commission_cents: number | null; +} +interface DailyResp { + group_name: string; + is_meituan: boolean; + days: number; + rows: DailyRow[]; +} + const RANGES = [ { label: '近 3 天', value: 'd3' }, { label: '近 7 天', value: 'd7' }, @@ -46,6 +68,11 @@ function rangeToQuery(r: string): string { return `granularity=day&days=${days[r] ?? 7}`; } +// 大表格只按天:天级范围 → 天数;今日小时(h) → null(此时隐藏表格,小时维度看上面折线) +function rangeToDays(r: string): number | null { + return ({ d3: 3, d7: 7, d14: 14, d30: 30 } as Record)[r] ?? null; +} + export default function GroupDetailPage() { const params = useParams(); const router = useRouter(); @@ -72,6 +99,22 @@ export default function GroupDetailPage() { load(); }, [load]); + // 按天大表格(独立于折线;今日小时模式 tableDays=null 时不取、不显示) + const [daily, setDaily] = useState(null); + const tableDays = rangeToDays(range); + const loadDaily = useCallback(async () => { + if (tableDays == null) return; + try { + const r = await api.get(`/admin/api/cps/groups/${id}/daily?days=${tableDays}`); + setDaily(r.data); + } catch (e) { + message.error(errMsg(e, '明细加载失败')); + } + }, [id, tableDays]); + useEffect(() => { + loadDaily(); + }, [loadDaily]); + const points = useMemo(() => data?.points ?? [], [data]); // 展平成「长表」给折线图:每个 (时间桶 × 指标) 一行,colorField 按指标分 4 条线。 const chartData = useMemo( @@ -103,6 +146,62 @@ export default function GroupDetailPage() { interaction: { tooltip: { shared: true } }, }; + const dailyColumns: ColumnsType = [ + { title: '日期', dataIndex: 'date', width: 64, fixed: 'left' }, + { title: '点击', dataIndex: 'click_pv', width: 56 }, + { title: '独立', dataIndex: 'click_uv', width: 56 }, + { title: '复制', dataIndex: 'copy_pv', width: 56, render: (v: number) => v || '-' }, + { + title: '点击→下单', + key: 'rate', + width: 86, + render: (_, r) => + r.order_count == null + ? '-' + : r.click_uv + ? `${((r.order_count / r.click_uv) * 100).toFixed(0)}%` + : '-', + }, + { title: '有效单', dataIndex: 'order_count', width: 64, render: (v: number | null) => (v == null ? '-' : v) }, + { + title: '取消/风控', + dataIndex: 'canceled_count', + width: 78, + render: (v: number | null) => (v == null ? '-' : v ? {v} : 0), + }, + { title: '成交额', dataIndex: 'gmv_cents', width: 84, render: (v: number | null) => (v == null ? '-' : yuan(v)) }, + { + title: '预估佣金', + dataIndex: 'est_commission_cents', + width: 84, + render: (v: number | null) => (v == null ? '-' : yuan(v)), + }, + { + title: '退款佣金', + dataIndex: 'refund_profit_cents', + width: 84, + render: (v: number | null) => + v == null ? '-' : v ? {yuan(v)} : yuan(0), + }, + { + title: '净佣金', + key: 'net', + width: 84, + render: (_, r) => + r.est_commission_cents == null ? ( + '-' + ) : ( + {yuan(r.est_commission_cents - (r.refund_profit_cents || 0))} + ), + }, + { + title: '结算佣金', + dataIndex: 'settled_commission_cents', + width: 84, + render: (v: number | null) => (v == null ? '-' : {yuan(v)}), + }, + ]; + return (
@@ -138,6 +237,24 @@ export default function GroupDetailPage() { )} + + {tableDays != null && ( + + {daily && !daily.is_meituan && ( +
+ 该群非美团渠道,订单/佣金类无对账数据,显示「-」 +
+ )} + + rowKey="date" + size="small" + columns={dailyColumns} + dataSource={daily?.rows ?? []} + pagination={false} + scroll={{ x: 900 }} + /> +
+ )}
); } From b7957e58c04a1fd54395ace1a475c0e4e935badf Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 19 Jun 2026 11:44:14 +0800 Subject: [PATCH 18/19] =?UTF-8?q?feat(cps):=20=E7=BE=A4=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E9=A1=B5=E5=8A=A0=E3=80=8C=E7=BE=A4=E5=86=85=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E3=80=8D=E5=8C=BA=E5=9D=97(=E5=A4=B4?= =?UTF-8?q?=E5=83=8F/=E6=98=B5=E7=A7=B0/=E9=A2=86=E5=88=B8=E6=AC=A1?= =?UTF-8?q?=E6=95=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 微信落地页授权来的用户列表:头像+昵称+领券次数(点复制)+点击次数+首次进入。 Co-Authored-By: Claude Opus 4.8 --- src/app/(main)/cps/groups/[id]/page.tsx | 73 ++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/src/app/(main)/cps/groups/[id]/page.tsx b/src/app/(main)/cps/groups/[id]/page.tsx index 2f60ddf..7c616e9 100644 --- a/src/app/(main)/cps/groups/[id]/page.tsx +++ b/src/app/(main)/cps/groups/[id]/page.tsx @@ -8,7 +8,7 @@ import dynamic from 'next/dynamic'; import type { ColumnsType } from 'antd/es/table'; import { Button, Card, Col, Empty, Row, Segmented, Space, Spin, Statistic, Table, message } from 'antd'; import { api, errMsg } from '@/lib/api'; -import { yuan } from '@/lib/format'; +import { formatUtcTime, yuan } from '@/lib/format'; // @ant-design/plots 依赖 DOM,关掉 SSR 仅在客户端渲染(否则 Next 预渲染报 document undefined)。 const Line = dynamic(() => import('@ant-design/plots').then((m) => m.Line), { ssr: false }); @@ -47,6 +47,16 @@ interface DailyResp { rows: DailyRow[]; } +// 群内微信用户(落地页授权拿到;nickname/headimgurl 仅 userinfo 授权后有) +interface WxUser { + openid: string; + nickname: string | null; + headimgurl: string | null; + first_seen: string; + visit_count: number; + copy_count: number; +} + const RANGES = [ { label: '近 3 天', value: 'd3' }, { label: '近 7 天', value: 'd7' }, @@ -115,6 +125,20 @@ export default function GroupDetailPage() { loadDaily(); }, [loadDaily]); + // 群内微信用户(独立于时间范围,累计画像) + const [wxUsers, setWxUsers] = useState([]); + const loadWxUsers = useCallback(async () => { + try { + const r = await api.get<{ users: WxUser[] }>(`/admin/api/cps/groups/${id}/wx-users`); + setWxUsers(r.data.users || []); + } catch (e) { + message.error(errMsg(e, '用户列表加载失败')); + } + }, [id]); + useEffect(() => { + loadWxUsers(); + }, [loadWxUsers]); + const points = useMemo(() => data?.points ?? [], [data]); // 展平成「长表」给折线图:每个 (时间桶 × 指标) 一行,colorField 按指标分 4 条线。 const chartData = useMemo( @@ -202,6 +226,38 @@ export default function GroupDetailPage() { }, ]; + const wxColumns: ColumnsType = [ + { + title: '用户', + key: 'user', + render: (_, u) => ( + + {u.headimgurl ? ( + + ) : ( + + )} + {u.nickname || '(未授权昵称)'} + + ), + }, + { + title: '领券次数', + dataIndex: 'copy_count', + width: 90, + sorter: (a, b) => a.copy_count - b.copy_count, + render: (v: number) => (v ? {v} : 0), + }, + { title: '点击次数', dataIndex: 'visit_count', width: 90 }, + { title: '首次进入', dataIndex: 'first_seen', width: 160, render: (v: string) => formatUtcTime(v) }, + ]; + return (
@@ -255,6 +311,21 @@ export default function GroupDetailPage() { /> )} + + +
+ 在微信里打开本群落地页并授权的用户。「领券次数」= 点了几次「复制口令」;昵称/头像需用户点领券时授权补充。 +
+ + rowKey="openid" + size="small" + columns={wxColumns} + dataSource={wxUsers} + pagination={false} + scroll={{ x: 560 }} + locale={{ emptyText: '暂无授权用户' }} + /> +
); } From 98417ff8c6ad9a4f27fd367488c092326baf8a03 Mon Sep 17 00:00:00 2001 From: marco Date: Sat, 20 Jun 2026 01:35:19 +0800 Subject: [PATCH 19/19] =?UTF-8?q?feat(ad):=20=E3=80=8C=E5=B9=BF=E5=91=8A?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E3=80=8D=E6=94=B9=E3=80=8C=E5=B9=BF=E5=91=8A?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E3=80=8D,=E5=8A=A0=E7=A9=BF=E5=B1=B1?= =?UTF-8?q?=E7=94=B2=E9=85=8D=E7=BD=AE=E9=9D=A2=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 顶部广告配置面板:app_id / 3 场景广告位ID / 激励验签密钥(Password) / 3 场景开关, 读写 /admin/api/ad-config - 原报表保留为下方「收益报表」;左侧菜单更名「广告管理」 Co-Authored-By: Claude Opus 4.8 --- src/app/(main)/ad-revenue/page.tsx | 95 +++++++++++++++++++++++++++++- src/app/(main)/layout.tsx | 2 +- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/app/(main)/ad-revenue/page.tsx b/src/app/(main)/ad-revenue/page.tsx index 8515de2..c6cb94e 100644 --- a/src/app/(main)/ad-revenue/page.tsx +++ b/src/app/(main)/ad-revenue/page.tsx @@ -10,6 +10,8 @@ import { Col, DatePicker, Divider, + Form, + Input, InputNumber, Modal, Popover, @@ -17,6 +19,7 @@ import { Select, Space, Statistic, + Switch, Table, Tag, Typography, @@ -268,6 +271,95 @@ function TrendChart({ points }: { points: TrendPoint[] }) { // 广告数据页:单表 —— 按 用户×类型×应用×代码位 聚合,一行同时给出 // 展示条数 / 收益 / 应发金币 / 实发金币 / 是否一致(发奖对账下沉到聚合级)。 +// 广告配置面板:穿山甲 app_id / 各场景广告位ID / 验签密钥 / 各场景开关。 +// 客户端(发版后)从 /api/v1/platform/ad-config 拉取;此处经 admin /ad-config 读写。 +interface AdCfg { + app_id: string; + reward_code_id: string; + compare_feed_code_id: string; + coupon_feed_code_id: string; + reward_mkey: string; + reward_enabled: boolean; + compare_ad_enabled: boolean; + coupon_ad_enabled: boolean; +} + +function AdConfigPanel() { + const { message } = App.useApp(); + const [form] = Form.useForm(); + const [saving, setSaving] = useState(false); + const load = useCallback(async () => { + try { + const r = await api.get('/admin/api/ad-config'); + form.setFieldsValue(r.data); + } catch (e) { + message.error(errMsg(e, '广告配置加载失败')); + } + }, [form, message]); + useEffect(() => { + load(); + }, [load]); + const save = async () => { + const v = await form.validateFields(); + setSaving(true); + try { + await api.patch('/admin/api/ad-config', v); + message.success('已保存。客户端下次拉取生效(应用ID需冷启,广告位ID即时)'); + load(); + } catch (e) { + message.error(errMsg(e, '保存失败')); + } finally { + setSaving(false); + } + }; + return ( + + 保存 + + } + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + export default function AdRevenuePage() { const { message } = App.useApp(); const [range, setRange] = useState<[Dayjs, Dayjs]>([dayjs(), dayjs()]); @@ -412,8 +504,9 @@ export default function AdRevenuePage() { return (
+ -

广告数据

+

收益报表