From 0e8921eca84575eb58258f02fdf55e6b0a66c8bf Mon Sep 17 00:00:00 2001 From: zzhyyyyy <2685922758@qq.com> Date: Sat, 20 Jun 2026 23:34:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(ops):=20=E5=8F=8D=E9=A6=88=E4=BA=8C?= =?UTF-8?q?=E7=BB=B4=E7=A0=81=E9=85=8D=E7=BD=AE=20+=20=E6=8F=90=E7=8E=B0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=A5=96=E5=8A=B1=E9=9D=A2=E6=9D=BF=20+=20?= =?UTF-8?q?=E5=AA=92=E4=BD=93=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feedbacks:新增 FeedbackQrConfig 二维码配置 - withdraws:新增 UserRewardPanel,页面精简 - 新增 src/lib/media.ts,types 扩展;price-reports 小改 - .env.production 增加 NEXT_PUBLIC_MEDIA_BASE;nginx/README/deploy 配套 Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.production | 4 + README.md | 16 +- deploy/nginx/admin.shaguabijia.com.conf | 12 + package-lock.json | 62 +--- src/app/(main)/feedbacks/FeedbackQrConfig.tsx | 273 ++++++++++++++++++ src/app/(main)/feedbacks/page.tsx | 28 +- src/app/(main)/price-reports/page.tsx | 5 +- src/app/(main)/withdraws/UserRewardPanel.tsx | 220 ++++++++++++++ src/app/(main)/withdraws/page.tsx | 161 ++--------- src/lib/media.ts | 11 + src/lib/types.ts | 43 +++ 11 files changed, 635 insertions(+), 200 deletions(-) create mode 100644 src/app/(main)/feedbacks/FeedbackQrConfig.tsx create mode 100644 src/app/(main)/withdraws/UserRewardPanel.tsx create mode 100644 src/lib/media.ts diff --git a/.env.production b/.env.production index b8d05f1..b6dc125 100644 --- a/.env.production +++ b/.env.production @@ -1,3 +1,7 @@ # 生产:前端与 admin API 同域(admin.shaguabijia.com),nginx 把 /admin/api 反代到后端 8771。 # 留空 = axios 用相对路径,请求落到当前域名,由 nginx 转发。 NEXT_PUBLIC_API_BASE= + +# 媒体(反馈二维码 / 上报截图)的公开地址。媒体由 App 后端的 /media 托管,默认不在 admin 域。 +# 设为可公开访问媒体的源(如 https://api.shaguabijia.com),或在 admin nginx 增加 /media 反代后留空走同域。 +NEXT_PUBLIC_MEDIA_BASE= diff --git a/README.md b/README.md index f3ce81b..06ba5fb 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,12 @@ Next.js 15 + Ant Design 5,对接 `shaguabijia-app-server` 的 admin API(`app/adm 鉴权用独立 JWT)。本仓只是前端。 ## 功能(P0) + 数据大盘 / 用户管理(列表+360详情+封禁+调金币) / 提现管理(列表+重试+对账) / 反馈工单 / 管理员账号(super) / 审计日志。按角色显示操作:finance=钱、operator=用户+反馈、super=全部。 ## 本地开发 + ```bash # 1. 起 admin 后端(在 shaguabijia-app-server 目录) conda activate price && uvicorn app.admin.main:admin_app --port 8771 @@ -17,11 +19,13 @@ python scripts/create_admin.py --username admin --role super_admin # 3. 起前端 npm install && npm run dev # → http://localhost:3001 ``` + `.env.local` 已指向 `http://localhost:8771`。 ## 生产部署(阿里云,域名 admin.shaguabijia.com) ### ① 后端 admin(在 app-server 机器,复用同一 codebase) + ```bash scp deploy/shaguabijia-admin.service server:/etc/systemd/system/ # 文件在 app-server/deploy/ ssh server "systemctl daemon-reload && systemctl enable --now shaguabijia-admin" @@ -29,6 +33,7 @@ ssh server "cd /opt/shaguabijia-app-server && .venv/bin/python scripts/create_ad ``` ### ② 前端 + ```bash npm run build rsync -avz --exclude node_modules --exclude .next/cache ./ server:/opt/shaguabijia-admin-web/ @@ -36,13 +41,16 @@ ssh server "cd /opt/shaguabijia-admin-web && npm install --omit=dev && npm run b ``` ### ③ nginx + ```bash scp deploy/nginx/admin.shaguabijia.com.conf server:/etc/nginx/conf.d/ ssh server "nginx -t && systemctl reload nginx" ``` ## ⚠️ 上线必查 -- [ ] app-server 的 `.env` 里 `ADMIN_JWT_SECRET` 改成 ≥32 字节强随机串(默认 `change-me-admin` 可伪造 token) -- [ ] nginx 打开 IP 白名单(运营出口 IP)+ HTTPS 证书就位 -- [ ] `.env.production` 的 `NEXT_PUBLIC_API_BASE` 留空(同域,nginx 反代 /admin/api) -- [ ] 首个 super_admin 已建、能登录 + +- app-server 的 `.env` 里 `ADMIN_JWT_SECRET` 改成 ≥32 字节强随机串(默认 `change-me-admin` 可伪造 token) +- nginx 打开 IP 白名单(运营出口 IP)+ HTTPS 证书就位 +- `.env.production` 的 `NEXT_PUBLIC_API_BASE` 留空(同域,nginx 反代 /admin/api) +- 首个 super_admin 已建、能登录 + diff --git a/deploy/nginx/admin.shaguabijia.com.conf b/deploy/nginx/admin.shaguabijia.com.conf index bfb6800..f76eb32 100644 --- a/deploy/nginx/admin.shaguabijia.com.conf +++ b/deploy/nginx/admin.shaguabijia.com.conf @@ -32,6 +32,18 @@ server { proxy_read_timeout 60s; } + # 用户上传媒体(上报截图 / 反馈截图 / 反馈二维码)由 App 后端(:8770)的 /media 托管。 + # admin 页面要展示这些图,经此同域反代过去(免跨域 + 免 https 页面引 http 图被拦)。 + # 前提:App 后端与本 admin 同机;若分机,把 127.0.0.1:8770 换成 App 后端可达地址。 + # 配好此段后,前端 .env.production 的 NEXT_PUBLIC_MEDIA_BASE 留空即走同域。 + location /media/ { + proxy_pass http://127.0.0.1:8770; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + } + # 其余 → Next.js 前端 location / { proxy_pass http://127.0.0.1:3001; diff --git a/package-lock.json b/package-lock.json index a0bf487..05545a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -274,9 +274,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -293,9 +290,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -312,9 +306,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -331,9 +322,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -350,9 +338,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -369,9 +354,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -388,9 +370,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -407,9 +386,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -426,9 +402,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -451,9 +424,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -476,9 +446,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -501,9 +468,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -526,9 +490,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -551,9 +512,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -576,9 +534,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -601,9 +556,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -740,9 +692,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -759,9 +708,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -778,9 +724,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -797,9 +740,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2438,7 +2378,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)/feedbacks/FeedbackQrConfig.tsx b/src/app/(main)/feedbacks/FeedbackQrConfig.tsx new file mode 100644 index 0000000..c2eb881 --- /dev/null +++ b/src/app/(main)/feedbacks/FeedbackQrConfig.tsx @@ -0,0 +1,273 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { Button, Card, Image, Input, Space, Spin, Switch, Tag, Typography, Upload, message } from 'antd'; +import { DeleteOutlined, UploadOutlined } from '@ant-design/icons'; +import { api, errMsg } from '@/lib/api'; +import { canDo } from '@/lib/auth'; +import { mediaUrl } from '@/lib/media'; +import type { FeedbackQrConfig as QrConfig } from '@/lib/types'; + +const { Text } = Typography; + +const ACCEPT = ['image/jpeg', 'image/png', 'image/webp']; + +/** 反馈页「加群二维码」卡配置。改完 App 意见反馈页下次进入即同步。「反馈工单」页的一个区块。 */ +export default function FeedbackQrConfig() { + const [cfg, setCfg] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(false); + + // 本地编辑态(文案 + 开关),保存时一次性 PATCH + const [enabled, setEnabled] = useState(true); + const [title, setTitle] = useState(''); + const [groupName, setGroupName] = useState(''); + const [subtitle, setSubtitle] = useState(''); + + const canEdit = canDo(['operator']); + + const sync = (c: QrConfig) => { + setCfg(c); + setEnabled(c.enabled); + setTitle(c.title); + setGroupName(c.group_name); + setSubtitle(c.subtitle); + }; + + const load = async () => { + setLoading(true); + try { + const { data } = await api.get('/admin/api/feedback-config'); + sync(data); + } finally { + setLoading(false); + } + }; + useEffect(() => { + load(); + }, []); + + const save = async () => { + if (!title.trim() || !groupName.trim() || !subtitle.trim()) { + message.warning('三行文案都需填写'); + return; + } + setSaving(true); + try { + const { data } = await api.patch('/admin/api/feedback-config', { + enabled, + title: title.trim(), + group_name: groupName.trim(), + subtitle: subtitle.trim(), + }); + sync(data); + message.success('已保存,App 反馈页下次进入即可见'); + } catch (e) { + message.error(errMsg(e)); + } finally { + setSaving(false); + } + }; + + // antd Upload:beforeUpload 里自行 POST(multipart),返回 false 阻止其默认上传。 + const beforeUpload = (file: File) => { + if (!ACCEPT.includes(file.type)) { + message.error('仅支持 JPEG / PNG / WebP 图片'); + return Upload.LIST_IGNORE; + } + if (file.size > 5 * 1024 * 1024) { + message.error('图片不能超过 5MB'); + return Upload.LIST_IGNORE; + } + void uploadImage(file); + return false; + }; + + const uploadImage = async (file: File) => { + const form = new FormData(); + form.append('file', file); + setUploading(true); + try { + const { data } = await api.post('/admin/api/feedback-config/image', form); + sync(data); + message.success('二维码已更新,App 反馈页下次进入即可见'); + } catch (e) { + message.error(errMsg(e)); + } finally { + setUploading(false); + } + }; + + const removeImage = async () => { + setUploading(true); + try { + const { data } = await api.delete('/admin/api/feedback-config/image'); + sync(data); + message.success('已移除二维码,App 将显示本地兜底图'); + } catch (e) { + message.error(errMsg(e)); + } finally { + setUploading(false); + } + }; + + return ( + + 更新于 {new Date(cfg.updated_at).toLocaleString('zh-CN')} + + ) : null + } + > +

+ 改这里的二维码、文案、显隐,App 意见反馈页底部那张「加群二维码」卡会同步(用户下次进入反馈页生效)。 + 每次改动进审计日志。 +

+ {loading || !cfg ? ( + + ) : ( + + {/* 左:所见即所得预览(对齐 App 卡片布局) */} +
+ + App 实际效果预览 + +
+ {cfg.image_url ? ( + + ) : ( +
+ 本地兜底图 +
+ )} +
+
{title || '(标题)'}
+
+ 直通 + 「{groupName || '群名'}」 +
+
{subtitle || '(标语)'}
+
+
+ {!enabled && ( +
+ 当前为「隐藏」:App 反馈页不显示这张卡 +
+ )} +
+ + {/* 右:编辑控件 */} + + + 显示该卡片: + + {!enabled && 已隐藏} + + + + + + + {cfg.image_url && ( + + )} + + JPEG/PNG/WebP,≤5MB + + + +
+ + 第一行(操作提示) + + setTitle(e.target.value)} + placeholder="如:长按图片保存二维码" + /> +
+
+ + 第二行加粗群名(App 显示为:直通「群名」) + + setGroupName(e.target.value)} + placeholder="如:傻瓜比价官方群" + /> +
+
+ + 第三行(标语) + + setSubtitle(e.target.value)} + placeholder="如:一起唠嗑共创、解锁新玩法" + /> +
+ + + {!canEdit && 仅 operator / super_admin 可修改} +
+
+ )} +
+ ); +} diff --git a/src/app/(main)/feedbacks/page.tsx b/src/app/(main)/feedbacks/page.tsx index 9d1fc11..e1cadf6 100644 --- a/src/app/(main)/feedbacks/page.tsx +++ b/src/app/(main)/feedbacks/page.tsx @@ -2,11 +2,13 @@ import { useState } from 'react'; import type { ColumnsType } from 'antd/es/table'; -import { Button, Select, Space, Table, Tag, message } from 'antd'; +import { Button, Image, Select, Space, Table, Tag, message } from 'antd'; import { api, errMsg } from '@/lib/api'; import { canDo } from '@/lib/auth'; +import { mediaUrl } from '@/lib/media'; import { useCursorList } from '@/lib/useCursorList'; import type { Feedback } from '@/lib/types'; +import FeedbackQrConfig from './FeedbackQrConfig'; const dt = (v: string) => new Date(v).toLocaleString('zh-CN'); @@ -33,6 +35,29 @@ export default function FeedbacksPage() { { title: 'ID', dataIndex: 'id', width: 70 }, { title: '用户', dataIndex: 'user_id', width: 80 }, { title: '内容', dataIndex: 'content' }, + { + title: '截图', + dataIndex: 'images', + width: 150, + render: (imgs: string[] | null) => + imgs && imgs.length ? ( + + + {imgs.map((src, i) => ( + + ))} + + + ) : ( + + ), + }, { title: '联系方式', dataIndex: 'contact', width: 140 }, { title: '状态', @@ -57,6 +82,7 @@ export default function FeedbacksPage() { return (

反馈工单

+ - - #{detail.order.user_id} - - {detail.user ? {detail.user.status} : '-'} - - {detail.user?.phone || '-'} - {detail.user?.nickname || '-'} - - {detail.user?.wechat_nickname || '-'} - - - {yuan(detail.user?.cash_balance_cents || 0)} - - - {detail.user?.withdraw_total ?? '-'} - - - {yuan(detail.user?.withdraw_success_cents || 0)} - - +

风险提示 {detail.risk_score ? {detail.risk_score}分 : null}

@@ -1008,28 +929,6 @@ export default function WithdrawsPage() {

处理时间线

- -
-

最近提现

- - - -
-

最近现金流水

-
- )} diff --git a/src/lib/media.ts b/src/lib/media.ts new file mode 100644 index 0000000..c63eb0d --- /dev/null +++ b/src/lib/media.ts @@ -0,0 +1,11 @@ +// 媒体(上报截图 / 反馈截图 / 反馈二维码等)由 App 后端的 /media 静态服务托管,admin 域不一定同源: +// - dev:NEXT_PUBLIC_MEDIA_BASE 指向 App 后端(http://localhost:8770)。 +// - 生产:留空 = 走同域相对路径,由 admin nginx 的 /media 反代到后端(见 deploy/nginx/*.conf), +// 避免跨域 / 混合内容(https 页面引 http 图会被浏览器拦)。 +const MEDIA_BASE = process.env.NEXT_PUBLIC_MEDIA_BASE || ''; + +/** 把后端返回的相对媒体路径(/media/...)拼成可加载的 URL;已是 http(s) 绝对地址的原样返回。 */ +export function mediaUrl(p: string): string { + if (!p) return p; + return p.startsWith('http') ? p : MEDIA_BASE + p; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 869432b..aacef43 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -84,6 +84,14 @@ export interface WithdrawOrder { updated_at: string; } +// 提现列表项:WithdrawOrder + 列表接口联表带出的用户字段。 +// 详情抽屉的 recent_withdraws 仍是裸 WithdrawOrder(无这些字段)。 +export interface WithdrawListItem extends WithdrawOrder { + phone: string | null; + nickname: string | null; + cumulative_success_cents: number; // 累计成功提现(分) +} + export interface WithdrawSummary { reviewing_count: number; reviewing_amount_cents: number; @@ -118,6 +126,30 @@ export interface WithdrawDetail { audit_logs: AuditLog[]; } +// 提现详情抽屉「用户统计区」:按时间窗口算的提现 + 看广告行为统计。 +// *_cash_cents = 该来源累计金币折算成可提现现金(分);现金余额是当前快照不随窗口。 +export interface UserRewardStats { + withdraw_success_cents: number; // 累计提现 + cash_balance_cents: number; // 现金余额(快照) + withdraw_total: number; // 提现总次数(全部状态) + traditional_task_cash_cents: number; // 传统任务提现 + reward_video_count: number; // 累计激励视频数 + reward_video_avg_ecpm: number; // 平均激励视频 eCPM(分/千次) + reward_video_cash_cents: number; // 激励视频提现 + feed_count: number; // 累计信息流广告数(份) + feed_avg_ecpm: number; // 平均信息流广告 eCPM(分/千次) + feed_cash_cents: number; // 信息流广告提现 +} + +// 金币发放记录(提现详情底部表)。ecpm 为原始分/千次,非广告来源为 null。 +export interface UserCoinRecord { + source: string; // reward_video / signin_boost / feed / signin + source_label: string; // 激励视频 / 签到膨胀 / 信息流广告 / 签到 + created_at: string; + ecpm: string | null; + coin: number; +} + export interface WithdrawBulkItemResult { out_bill_no: string; ok: boolean; @@ -170,6 +202,17 @@ export interface Feedback { created_at: string; } +// 反馈页「加群二维码」卡配置(运营后台改 → App 意见反馈页同步)。 +// image_url 为后端 /media 相对路径(留空=App 走本地兜底图);其余为卡片三行文案与显隐开关。 +export interface FeedbackQrConfig { + enabled: boolean; + image_url: string | null; + title: string; // 第一行(操作提示),如「长按图片保存二维码」 + group_name: string; // 第二行加粗群名,App 渲染为 直通「{group_name}」 + subtitle: string; // 第三行(标语) + updated_at: string | null; +} + export interface AuditLog { id: number; admin_id: number;