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 1e038a6..ecbb961 100644 --- a/package-lock.json +++ b/package-lock.json @@ -551,9 +551,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -570,9 +567,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -589,9 +583,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -608,9 +599,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -627,9 +615,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -646,9 +631,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -665,9 +647,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -684,9 +663,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -703,9 +679,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -728,9 +701,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -753,9 +723,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -778,9 +745,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -803,9 +767,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -828,9 +789,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -853,9 +811,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -878,9 +833,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1017,9 +969,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1036,9 +985,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1055,9 +1001,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1074,9 +1017,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ 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 82b2a5b..e7462f1 100644 --- a/src/app/(main)/feedbacks/page.tsx +++ b/src/app/(main)/feedbacks/page.tsx @@ -22,6 +22,7 @@ import { canDo } from '@/lib/auth'; import { formatUtcTime } from '@/lib/format'; import { usePagedList } from '@/lib/usePagedList'; import type { Feedback } from '@/lib/types'; +import FeedbackQrConfig from './FeedbackQrConfig'; const { Text, Paragraph } = Typography; const { RangePicker } = DatePicker; @@ -204,6 +205,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}

@@ -998,28 +918,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 f7e1f77..febd7c4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -30,6 +30,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; @@ -90,6 +92,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; @@ -124,6 +134,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; @@ -176,6 +210,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;