From 33c80d6927e72db4896404e1a76241800df072f0 Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Tue, 7 Jul 2026 00:16:25 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(withdraws):=20=E6=8F=90=E7=8E=B0?= =?UTF-8?q?=E8=B4=A6=E6=9C=AC=E6=A0=A1=E9=AA=8C=E5=8C=BA=E5=88=86=E7=8E=B0?= =?UTF-8?q?=E9=87=91/=E9=82=80=E8=AF=B7=E9=87=91=E4=B8=A4=E6=9C=AC?= =?UTF-8?q?=E8=B4=A6=E5=B1=95=E7=A4=BA=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 配合后端 ledger-check 分账改造: - 异常清单按「现金」「邀请金」分别标注(缺扣款/缺退款/重复退款/ 非退款终态退款/余额差额),便于定位问题落在哪本账。 - 绿色(校验正常)时分别展示两账的余额与流水净额。 - WithdrawLedgerCheck 类型同步后端新增的 7 个 invite_* 字段。 修复现象:此前邀请奖励金提现单被误判为「缺扣款/缺退款流水」, 导致提现审核页现金账本报红。 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/39 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- src/app/(main)/withdraws/page.tsx | 27 +++++++++++++++++++++------ src/lib/types.ts | 9 +++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/app/(main)/withdraws/page.tsx b/src/app/(main)/withdraws/page.tsx index 478bd54..66b46e9 100644 --- a/src/app/(main)/withdraws/page.tsx +++ b/src/app/(main)/withdraws/page.tsx @@ -624,12 +624,27 @@ export default function WithdrawsPage() { const rejectAmount = rejectTargets.reduce((sum, item) => sum + item.amount_cents, 0); const ledgerIssues = ledger ? [ - ledger.balance_diff_cents ? `余额差额 ${yuan(ledger.balance_diff_cents)}` : '', - ledger.missing_withdraw_txn_count ? `缺扣款流水 ${ledger.missing_withdraw_txn_count} 笔` : '', - ledger.missing_refund_txn_count ? `缺退款流水 ${ledger.missing_refund_txn_count} 笔` : '', - ledger.duplicate_refund_txn_count ? `重复退款流水 ${ledger.duplicate_refund_txn_count} 类` : '', + // 普通现金账(coin_cash) + ledger.balance_diff_cents ? `现金余额差额 ${yuan(ledger.balance_diff_cents)}` : '', + ledger.missing_withdraw_txn_count ? `现金缺扣款流水 ${ledger.missing_withdraw_txn_count} 笔` : '', + ledger.missing_refund_txn_count ? `现金缺退款流水 ${ledger.missing_refund_txn_count} 笔` : '', + ledger.duplicate_refund_txn_count ? `现金重复退款流水 ${ledger.duplicate_refund_txn_count} 类` : '', ledger.refund_txn_on_non_terminal_count - ? `非退款终态存在退款流水 ${ledger.refund_txn_on_non_terminal_count} 笔` + ? `现金非退款终态存在退款流水 ${ledger.refund_txn_on_non_terminal_count} 笔` + : '', + // 邀请奖励金账(invite_cash) + ledger.invite_balance_diff_cents ? `邀请金余额差额 ${yuan(ledger.invite_balance_diff_cents)}` : '', + ledger.invite_missing_withdraw_txn_count + ? `邀请金缺扣款流水 ${ledger.invite_missing_withdraw_txn_count} 笔` + : '', + ledger.invite_missing_refund_txn_count + ? `邀请金缺退款流水 ${ledger.invite_missing_refund_txn_count} 笔` + : '', + ledger.invite_duplicate_refund_txn_count + ? `邀请金重复退款流水 ${ledger.invite_duplicate_refund_txn_count} 类` + : '', + ledger.invite_refund_txn_on_non_terminal_count + ? `邀请金非退款终态存在退款流水 ${ledger.invite_refund_txn_on_non_terminal_count} 笔` : '', ].filter(Boolean) : []; @@ -669,7 +684,7 @@ export default function WithdrawsPage() { message={ledger.ok ? '现金账本校验正常' : '现金账本存在异常'} description={ ledger.ok - ? `账户余额合计 ${yuan(ledger.cash_balance_total_cents)},现金流水净额 ${yuan(ledger.cash_transaction_total_cents)}` + ? `现金 余额 ${yuan(ledger.cash_balance_total_cents)} / 流水 ${yuan(ledger.cash_transaction_total_cents)};邀请金 余额 ${yuan(ledger.invite_cash_balance_total_cents)} / 流水 ${yuan(ledger.invite_cash_transaction_total_cents)}` : ledgerIssues.join(';') } action={ diff --git a/src/lib/types.ts b/src/lib/types.ts index 7f5ec3b..2e34983 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -233,6 +233,7 @@ export interface WithdrawBulkResult { export interface WithdrawLedgerCheck { ok: boolean; + // 普通现金账(coin_cash) cash_balance_total_cents: number; cash_transaction_total_cents: number; balance_diff_cents: number; @@ -240,6 +241,14 @@ export interface WithdrawLedgerCheck { missing_refund_txn_count: number; duplicate_refund_txn_count: number; refund_txn_on_non_terminal_count: number; + // 邀请奖励金账(invite_cash:与普通现金物理隔离,各自对账) + invite_cash_balance_total_cents: number; + invite_cash_transaction_total_cents: number; + invite_balance_diff_cents: number; + invite_missing_withdraw_txn_count: number; + invite_missing_refund_txn_count: number; + invite_duplicate_refund_txn_count: number; + invite_refund_txn_on_non_terminal_count: number; } export interface WxpayHealthCheck { From bc677817e460a41c0f3c4fa7bcb40446eed92475 Mon Sep 17 00:00:00 2001 From: zhuzihao Date: Wed, 8 Jul 2026 00:57:11 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(marquee):=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E8=BD=AE=E6=92=AD=E7=A7=8D=E5=AD=90=E9=A2=84=E8=A7=88=E6=94=B9?= =?UTF-8?q?=E5=86=85=E8=81=94=E5=B8=B8=E9=A9=BB=E3=80=81=E9=9A=8F=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E6=BA=90=E6=A8=A1=E5=BC=8F=E5=AE=9E=E6=97=B6=E5=88=B7?= =?UTF-8?q?=E6=96=B0=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 系统配置「首页轮播种子」页:预览从点按钮弹窗改为常驻在「数据源」开关下方, 切换「混播/只真实/只种子」即时重拉、所见即所得。 - 预览请求带上当前选中的 mode(配套 app-server 预览接口的 mode 入参),不依赖 PATCH 落库时序;拨动开关(乐观更新)即刻预览对应模式,切换失败回滚预览也随之回滚。 - 移除原「预览效果」弹窗及相关状态;「只真实」无数据时给空态提示。 Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/40 Co-authored-by: zhuzihao Co-committed-by: zhuzihao --- src/app/(main)/config/HomeMarqueeSeeds.tsx | 166 +++++++++++++-------- 1 file changed, 106 insertions(+), 60 deletions(-) diff --git a/src/app/(main)/config/HomeMarqueeSeeds.tsx b/src/app/(main)/config/HomeMarqueeSeeds.tsx index d138052..9f54e0c 100644 --- a/src/app/(main)/config/HomeMarqueeSeeds.tsx +++ b/src/app/(main)/config/HomeMarqueeSeeds.tsx @@ -42,13 +42,15 @@ interface BulkVals { enabled: boolean; } -interface PreviewItem { +interface RealRecord { masked_user: string; saved_amount_cents: number; - time: string; + created_at: string; + user_id: number; } const yuan = (cents: number) => (cents / 100).toFixed(2); +const MODE_LABEL: Record = { mixed: '混播', real: '只真实', seed: '只种子' }; /** 首页轮播「种子」管理(真实记录不足时的兜底假数据,现为「生成规则」)。「系统配置 / 首页」里的一个区块。 */ export default function HomeMarqueeSeeds() { @@ -66,9 +68,12 @@ export default function HomeMarqueeSeeds() { const [bulkOpen, setBulkOpen] = useState(false); const [bulkForm] = Form.useForm(); - const [previewOpen, setPreviewOpen] = useState(false); - const [previewItems, setPreviewItems] = useState([]); - const [previewLoading, setPreviewLoading] = useState(false); + // 「可展示的真实记录」分页浏览(替代原随机预览):翻遍所有 success+省>0 的真实记录 + const REC_PAGE_SIZE = 8; + const [recItems, setRecItems] = useState([]); + const [recTotal, setRecTotal] = useState(0); + const [recPage, setRecPage] = useState(0); // 0-based + const [recLoading, setRecLoading] = useState(false); const load = async () => { setLoading(true); @@ -94,6 +99,7 @@ export default function HomeMarqueeSeeds() { const changeMode = async (m: string) => { const prev = mode; setMode(m); + setRecPage(0); // 切模式回第 1 页(不同模式条数不同,避免停在越界页) setModeSaving(true); try { await api.patch('/admin/api/marquee-seeds/mode', { mode: m }); @@ -168,22 +174,29 @@ export default function HomeMarqueeSeeds() { } }; - // ===== 预览实际 feed ===== - const fetchPreview = async () => { - setPreviewLoading(true); + // ===== 可展示的记录:按「当前模式」分页浏览全部(不去重)===== + // 只真实=真实记录;只种子=各启用种子按生成逻辑各出一行;混播=真实+种子。与 app 同口径洗牌+去连簇, + // 后端固定种子 → 翻页稳定、能翻遍全部。 + const fetchRecords = async (page: number, m: string) => { + setRecLoading(true); try { - const { data } = await api.get<{ items: PreviewItem[] }>('/admin/api/marquee-seeds/preview?limit=8'); - setPreviewItems(data.items); + const { data } = await api.get<{ items: RealRecord[]; total: number }>( + '/admin/api/marquee-seeds/real-records', + { params: { offset: page * REC_PAGE_SIZE, limit: REC_PAGE_SIZE, mode: m } }, + ); + setRecItems(data.items); + setRecTotal(data.total); } catch (e) { message.error(errMsg(e)); } finally { - setPreviewLoading(false); + setRecLoading(false); } }; - const openPreview = () => { - setPreviewOpen(true); - fetchPreview(); - }; + // 页码或模式变化都重拉(切模式时 changeMode 会把页码重置到 0)。 + useEffect(() => { + fetchRecords(recPage, mode); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [recPage, mode]); const toggle = async (s: Seed, enabled: boolean) => { try { @@ -300,6 +313,84 @@ export default function HomeMarqueeSeeds() { 混播=真实优先+种子补位;只真实=不掺假数据(真实不足则少显示);只种子=只用下方种子/合成(演示用) + + {/* 可展示的真实记录:分页浏览全部(success+省>0,不去重、稳定倒序);app 轮播即从这些记录 + 随机打乱、同一用户去连簇后展示。翻页可看全所有真实用户的展示信息。 */} +
+
+ + 可展示的记录 · {MODE_LABEL[mode] ?? mode} · 共 {recTotal} 条 + + 当前模式下 app 轮播能露出的全部记录(不去重):只真实=真实、只种子=各种子生成、混播=真实+种子; + 已按 app 口径洗牌+去连簇,顺序固定便于翻页看全 + + + + + + 第 {recTotal === 0 ? 0 : recPage + 1}/{Math.max(1, Math.ceil(recTotal / REC_PAGE_SIZE))} 页 + + + +
+ +
+ {recItems.length === 0 && !recLoading ? ( +
+ 当前模式暂无可展示的记录 +
+ ) : ( + recItems.map((it, i) => ( +
+ + {it.masked_user} 比价后节省{' '} + {yuan(it.saved_amount_cents)} 元 + + {it.user_id > 0 ? `u${it.user_id}` : '种子'} + + + {it.created_at} +
+ )) + )} +
+
+
+ - - - - } - > -

- 客户端实际会看到的混播结果:真实记录会插队,种子随机抽取、金额/用户名每次都不同。点「换一批」可重新随机。 -

- -
- {previewItems.map((it, i) => ( -
- - {it.masked_user} 比价后节省{' '} - {yuan(it.saved_amount_cents)} 元 - - {it.time} -
- ))} -
-
- ); } From 557348bd6b32765c79bb8d2afc40086f0d84d8f6 Mon Sep 17 00:00:00 2001 From: liujiahui Date: Thu, 9 Jul 2026 01:11:29 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E6=9D=83=E9=99=90=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=BA=BA=E5=91=98=E6=94=AF=E6=8C=81=E3=80=8C?= =?UTF-8?q?=E8=87=AA=E5=AE=9A=E4=B9=89=E3=80=8D=E8=A7=92=E8=89=B2=EF=BC=88?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=89=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 角色下拉末尾加「自定义」,选中时「可见页面」从只读矩阵切成可勾选(复用 PermEditor),提交带 pages_override; 编辑自定义用户回填已勾选页;列表里 custom 显示「自定义」,列表内切到自定义转去编辑页勾选。 AdminInfo 加 pages_override 字段。npm run build 通过。 配套后端 PR:shaguabijia-app-server#admin-custom-perms。 --------- Co-authored-by: no_gen_mu Reviewed-on: https://gitea.shaguabijia.com/WonderableAI/shaguabijia-admin-web/pulls/41 Co-authored-by: liujiahui Co-committed-by: liujiahui --- src/app/(main)/admins/page.tsx | 59 ++++++++++++++++++++++++++++------ src/lib/types.ts | 5 +-- 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/src/app/(main)/admins/page.tsx b/src/app/(main)/admins/page.tsx index ac8b70a..713e1a5 100644 --- a/src/app/(main)/admins/page.tsx +++ b/src/app/(main)/admins/page.tsx @@ -22,6 +22,10 @@ function genPassword(len = 10): string { type View = 'list' | 'person' | 'roles' | 'roleForm'; +// 「自定义」哨兵角色:不是共享角色,选它时可见页由本人逐页勾选(存 pages_override),与后端一致。 +const CUSTOM_ROLE = 'custom'; +const CUSTOM_LABEL = '自定义'; + // ===== 只读权限矩阵:角色详情 / 人员「可见页面」预览共用(深色=有,灰色=无)===== function PermMatrix({ catalog, pages }: { catalog: PermissionGroup[]; pages: string[] }) { const has = useMemo(() => new Set(pages), [pages]); @@ -109,6 +113,11 @@ export default function AdminsPage() { return m; }, [roles]); const roleOptions = roles.map((r) => ({ value: r.name, label: r.label })); + // 人员表单角色下拉:真实角色 + 末尾「自定义」。选「自定义」→ 下方可见页改为逐页勾选。 + const personRoleOptions = [...roleOptions, { value: CUSTOM_ROLE, label: CUSTOM_LABEL }]; + // 角色 key → 展示名(custom 显示「自定义」;查不到回退 key 原文) + const roleLabelOf = (name: string) => + (name === CUSTOM_ROLE ? CUSTOM_LABEL : roleByName[name]?.label ?? name); const loadAll = async () => { setLoading(true); @@ -134,11 +143,14 @@ export default function AdminsPage() { const [personName, setPersonName] = useState(''); const [personRole, setPersonRole] = useState(''); const [initPw, setInitPw] = useState(''); + // 「自定义」角色时逐页勾选的可见页集(role==custom 才用到) + const [personPages, setPersonPages] = useState([]); const openCreatePerson = () => { setPersonEditing(null); setPersonName(''); setPersonRole(roles.find((r) => !r.is_builtin)?.name ?? roles[0]?.name ?? 'operator'); + setPersonPages([]); setInitPw(genPassword()); setView('person'); }; @@ -146,6 +158,8 @@ export default function AdminsPage() { setPersonEditing(a); setPersonName(a.username); setPersonRole(a.role); + // 编辑「自定义」用户时,回填其已勾选的可见页(供继续增删) + setPersonPages(a.role === CUSTOM_ROLE ? (a.pages_override ?? []) : []); setInitPw(''); // 编辑态密码只读展示 a.password(已确定的登录密码),不用 initPw setView('person'); }; @@ -160,18 +174,30 @@ export default function AdminsPage() { ); + const isCustom = personRole === CUSTOM_ROLE; const submitPerson = async () => { + if (isCustom && personPages.length === 0) { message.error('自定义角色请至少勾选一个可见页面'); return; } try { if (personEditing) { - // 密码只读、不改;只提交变更的角色 - if (personRole !== personEditing.role) { - await api.patch(`/admin/api/admins/${personEditing.id}`, { role: personRole }); + // 密码只读、不改;提交变更的角色 +(自定义时)勾选的可见页 + const roleChanged = personRole !== personEditing.role; + const pagesChanged = isCustom + && JSON.stringify(personPages) !== JSON.stringify(personEditing.pages_override ?? []); + if (roleChanged || pagesChanged) { + const payload: Record = {}; + if (roleChanged) payload.role = personRole; + // 自定义:带上勾选集(切到 custom 或改现有 custom 勾选都要);切回普通角色不带,后端会清空 override + if (isCustom) payload.pages_override = personPages; + await api.patch(`/admin/api/admins/${personEditing.id}`, payload); } message.success('已保存'); } else { if (personName.trim().length < 3) { message.error('用户名至少 3 位'); return; } await api.post('/admin/api/admins', { - username: personName.trim(), password: initPw, role: personRole, + username: personName.trim(), + password: initPw, + role: personRole, + ...(isCustom ? { pages_override: personPages } : {}), }); message.success('已创建,请把初始密码转交本人'); } @@ -184,8 +210,10 @@ export default function AdminsPage() { // ===== 列表操作 ===== const changeRole = (a: AdminInfo, role: string) => { if (role === a.role) return; + // 改成「自定义」需要逐页勾选 → 转去编辑页(列表里没有页选择器,直接切会得到空可见页) + if (role === CUSTOM_ROLE) { openEditPerson({ ...a, role: CUSTOM_ROLE }); return; } modal.confirm({ - title: `把 ${a.username} 的角色改为 ${role}?`, + title: `把 ${a.username} 的角色改为 ${roleLabelOf(role)}?`, onOk: async () => { try { await api.patch(`/admin/api/admins/${a.id}`, { role }); message.success('已更新'); loadAll(); } catch (e) { message.error(errMsg(e)); } @@ -266,8 +294,9 @@ export default function AdminsPage() { { title: '用户名', dataIndex: 'username' }, { title: '角色', dataIndex: 'role', width: 170, + // options 含「自定义」→ custom 用户在列表里显示为「自定义」;选它会转去编辑页勾选(见 changeRole) render: (r: string, a: AdminInfo) => ( - changeRole(a, v)} /> ), }, { title: '状态', dataIndex: 'status', width: 90, render: (s: string) => {s === 'active' ? 'active' : '已禁用'} }, @@ -341,16 +370,26 @@ export default function AdminsPage() { 选择角色 * {isSuper && 设置角色权限 ›} - +
+ {isCustom + ? '「自定义」:下方逐页勾选该成员可见的页面,仅对本人生效(不影响任何角色)' + : '可见页面完全由所选角色决定;如需调整请到「角色设置」修改该角色'} +
可见页面 - 跟随所选角色,此处只读(深色=可见) + + {isCustom ? '勾选本成员可见的页面' : '跟随所选角色,此处只读(深色=可见)'} +
- g.pages.map((p) => p.key)) : rolePagesPreview} /> + {isCustom ? ( + + ) : ( + g.pages.map((p) => p.key)) : rolePagesPreview} /> + )}
diff --git a/src/lib/types.ts b/src/lib/types.ts index 2e34983..ecb6de1 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -3,11 +3,12 @@ export interface AdminInfo { id: number; username: string; - role: string; // super_admin / finance / operator / 自定义角色名 + role: string; // super_admin / finance / operator / custom(自定义) / 自定义角色名 status: string; created_at: string; last_login_at: string | null; - pages?: string[]; // 当前角色有效可见页 key(登录 / /me 下发);super_admin = 全部页。左侧导航按此过滤 + pages?: string[]; // 当前有效可见页 key(登录 / /me 下发);super_admin = 全部页;role==custom = pages_override。左侧导航按此过滤 + pages_override?: string[] | null; // 「自定义」勾选的可见页原始集(role==custom 时非空);列表接口下发,供编辑回填勾选 password?: string | null; // 明文登录密码(仅账号列表下发);无留存(脚本建的超管)为 null → 不显示 }