feat(force-onboarding): 用户列表加「开启新手引导」一次性操作 (#13)
Co-authored-by: zzhyyyyy <2685922758@qq.com> Reviewed-on: #13 Co-authored-by: zhuzihao <zhuzihao@wonderable.ai> Co-committed-by: zhuzihao <zhuzihao@wonderable.ai>
This commit was merged in pull request #13.
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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 已建、能登录
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Generated
-60
@@ -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": [
|
||||
|
||||
@@ -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<QrConfig | null>(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<QrConfig>('/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<QrConfig>('/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<QrConfig>('/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<QrConfig>('/admin/api/feedback-config/image');
|
||||
sync(data);
|
||||
message.success('已移除二维码,App 将显示本地兜底图');
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title="反馈页二维码卡(App 意见反馈页底部)"
|
||||
style={{ marginBottom: 16 }}
|
||||
extra={
|
||||
cfg?.updated_at ? (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
更新于 {new Date(cfg.updated_at).toLocaleString('zh-CN')}
|
||||
</Text>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<p style={{ color: '#999', marginTop: 0 }}>
|
||||
改这里的二维码、文案、显隐,App 意见反馈页底部那张「加群二维码」卡会同步(用户下次进入反馈页生效)。
|
||||
每次改动进审计日志。
|
||||
</p>
|
||||
{loading || !cfg ? (
|
||||
<Spin style={{ display: 'block', margin: '24px 0' }} />
|
||||
) : (
|
||||
<Space align="start" size={32} wrap>
|
||||
{/* 左:所见即所得预览(对齐 App 卡片布局) */}
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
App 实际效果预览
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
width: 320,
|
||||
display: 'flex',
|
||||
gap: 14,
|
||||
alignItems: 'center',
|
||||
padding: 14,
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 14,
|
||||
boxShadow: '0 1px 4px rgba(0,0,0,0.06)',
|
||||
opacity: enabled ? 1 : 0.4,
|
||||
}}
|
||||
>
|
||||
{cfg.image_url ? (
|
||||
<Image
|
||||
src={mediaUrl(cfg.image_url)}
|
||||
width={88}
|
||||
height={88}
|
||||
style={{ objectFit: 'cover', borderRadius: 8, border: '1px solid #f0f0f0' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 88,
|
||||
height: 88,
|
||||
borderRadius: 8,
|
||||
border: '1px dashed #d9d9d9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#bbb',
|
||||
fontSize: 12,
|
||||
textAlign: 'center',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
本地兜底图
|
||||
</div>
|
||||
)}
|
||||
<div style={{ fontSize: 13, color: '#1a1a1a', lineHeight: '21px' }}>
|
||||
<div>{title || '(标题)'}</div>
|
||||
<div>
|
||||
直通
|
||||
<b>「{groupName || '群名'}」</b>
|
||||
</div>
|
||||
<div>{subtitle || '(标语)'}</div>
|
||||
</div>
|
||||
</div>
|
||||
{!enabled && (
|
||||
<div style={{ color: '#fa8c16', fontSize: 12, marginTop: 6 }}>
|
||||
当前为「隐藏」:App 反馈页不显示这张卡
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 右:编辑控件 */}
|
||||
<Space direction="vertical" size="middle" style={{ minWidth: 320 }}>
|
||||
<Space>
|
||||
<span>显示该卡片:</span>
|
||||
<Switch checked={enabled} disabled={!canEdit} onChange={setEnabled} />
|
||||
{!enabled && <Tag color="orange">已隐藏</Tag>}
|
||||
</Space>
|
||||
|
||||
<Space wrap>
|
||||
<Upload accept="image/*" showUploadList={false} beforeUpload={beforeUpload} disabled={!canEdit}>
|
||||
<Button icon={<UploadOutlined />} loading={uploading} disabled={!canEdit}>
|
||||
{cfg.image_url ? '更换二维码' : '上传二维码'}
|
||||
</Button>
|
||||
</Upload>
|
||||
{cfg.image_url && (
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
danger
|
||||
loading={uploading}
|
||||
disabled={!canEdit}
|
||||
onClick={removeImage}
|
||||
>
|
||||
移除二维码
|
||||
</Button>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
JPEG/PNG/WebP,≤5MB
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
第一行(操作提示)
|
||||
</Text>
|
||||
<Input
|
||||
value={title}
|
||||
maxLength={40}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="如:长按图片保存二维码"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
第二行加粗群名(App 显示为:直通「群名」)
|
||||
</Text>
|
||||
<Input
|
||||
value={groupName}
|
||||
maxLength={40}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
placeholder="如:傻瓜比价官方群"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
第三行(标语)
|
||||
</Text>
|
||||
<Input
|
||||
value={subtitle}
|
||||
maxLength={60}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setSubtitle(e.target.value)}
|
||||
placeholder="如:一起唠嗑共创、解锁新玩法"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="primary" loading={saving} disabled={!canEdit} onClick={save}>
|
||||
保存文案 / 开关
|
||||
</Button>
|
||||
{!canEdit && <Text type="secondary">仅 operator / super_admin 可修改</Text>}
|
||||
</Space>
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<h2>反馈工单</h2>
|
||||
<FeedbackQrConfig />
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
|
||||
@@ -20,6 +20,7 @@ import { CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { mediaUrl } from '@/lib/media';
|
||||
import { usePagedList } from '@/lib/usePagedList';
|
||||
import type { PriceReport, PriceReportSummary } from '@/lib/types';
|
||||
|
||||
@@ -30,9 +31,7 @@ const { Text } = Typography;
|
||||
const dt = (v: string | null) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-');
|
||||
const yuan = (c: number | null) => (c == null ? '-' : `¥${(c / 100).toFixed(2)}`);
|
||||
|
||||
// 截图是 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);
|
||||
// 截图 URL 解析见 @/lib/media(dev 指向 :8770,生产走 nginx /media 同域反代)。
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
pending: 'gold',
|
||||
|
||||
@@ -131,6 +131,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 selectedUsers = items.filter((u) => selectedRowKeys.includes(u.id));
|
||||
const selectedActive = selectedUsers.filter((u) => u.status === 'active');
|
||||
const selectedDisabled = selectedUsers.filter((u) => u.status === 'disabled');
|
||||
@@ -238,6 +260,15 @@ export default function UsersPage() {
|
||||
{u.debug_trace_enabled ? '关调试链接' : '开调试链接'}
|
||||
</a>
|
||||
)}
|
||||
{canStatus &&
|
||||
u.status !== 'deleted' &&
|
||||
(u.force_onboarding ? (
|
||||
<span style={{ color: '#fa8c16' }}>
|
||||
待重看 <a onClick={() => setForceOnboarding(u, false)}>撤销</a>
|
||||
</span>
|
||||
) : (
|
||||
<a onClick={() => setForceOnboarding(u, true)}>开启新手引导</a>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Radio,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
import type { CursorPage, UserCoinRecord, UserRewardStats, WithdrawUserSnapshot } from '@/lib/types';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
|
||||
// 后端时间为 UTC(无时区串补 Z),按北京时区展示(与提现列表口径一致)
|
||||
const apiTime = (v: string) => {
|
||||
const hasTz = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(v);
|
||||
return dayjs(hasTz ? v : `${v}Z`);
|
||||
};
|
||||
const dt = (v: string) => apiTime(v).format('YYYY-MM-DD HH:mm');
|
||||
|
||||
const SOURCE_COLOR: Record<string, string> = {
|
||||
reward_video: 'blue',
|
||||
signin_boost: 'cyan',
|
||||
feed: 'purple',
|
||||
signin: 'green',
|
||||
};
|
||||
|
||||
interface Props {
|
||||
userId: number;
|
||||
user: WithdrawUserSnapshot | null;
|
||||
}
|
||||
|
||||
/** 提现详情抽屉:用户基本信息 + 互斥时间筛选 + 看广告/提现统计区 + 金币发放记录表。 */
|
||||
export default function UserRewardPanel({ userId, user }: Props) {
|
||||
const [mode, setMode] = useState<'all' | 'range'>('all'); // all=注册至今 / range=自定义区间
|
||||
const [range, setRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
const [stats, setStats] = useState<UserRewardStats | null>(null);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
|
||||
const [records, setRecords] = useState<UserCoinRecord[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<number | null>(null);
|
||||
const [recordsLoading, setRecordsLoading] = useState(false);
|
||||
|
||||
// 时间窗口参数:注册至今=不传;自定义区间(选齐两端)=date_from/date_to。
|
||||
const params =
|
||||
mode === 'range' && range?.[0] && range?.[1]
|
||||
? {
|
||||
date_from: range[0].startOf('day').toISOString(),
|
||||
date_to: range[1].endOf('day').toISOString(),
|
||||
}
|
||||
: {};
|
||||
const paramsKey = JSON.stringify(params);
|
||||
|
||||
const loadStats = useCallback(async () => {
|
||||
setStatsLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<UserRewardStats>(`/admin/api/users/${userId}/reward-stats`, {
|
||||
params: JSON.parse(paramsKey),
|
||||
});
|
||||
setStats(data);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setStatsLoading(false);
|
||||
}
|
||||
}, [userId, paramsKey]);
|
||||
|
||||
const loadRecords = useCallback(
|
||||
async (cursor: number | null) => {
|
||||
setRecordsLoading(true);
|
||||
try {
|
||||
const reqParams: Record<string, unknown> = { ...JSON.parse(paramsKey), limit: 20 };
|
||||
if (cursor != null) reqParams.cursor = cursor;
|
||||
const { data } = await api.get<CursorPage<UserCoinRecord>>(
|
||||
`/admin/api/users/${userId}/coin-records`,
|
||||
{ params: reqParams },
|
||||
);
|
||||
setRecords((prev) => (cursor == null ? data.items : [...prev, ...data.items]));
|
||||
setNextCursor(data.next_cursor);
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setRecordsLoading(false);
|
||||
}
|
||||
},
|
||||
[userId, paramsKey],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadStats();
|
||||
loadRecords(null);
|
||||
}, [loadStats, loadRecords]);
|
||||
|
||||
const regDays = user?.created_at ? dayjs().diff(apiTime(user.created_at), 'day') : null;
|
||||
|
||||
const columns: ColumnsType<UserCoinRecord> = [
|
||||
{ title: '日期时间', dataIndex: 'created_at', width: 150, render: dt },
|
||||
{
|
||||
title: '赚取途径',
|
||||
dataIndex: 'source_label',
|
||||
width: 120,
|
||||
render: (label: string, r) => <Tag color={SOURCE_COLOR[r.source] ?? 'default'}>{label}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'ECPM',
|
||||
dataIndex: 'ecpm',
|
||||
width: 100,
|
||||
render: (v: string | null) => v ?? <Text type="secondary">-</Text>,
|
||||
},
|
||||
{ title: '发放金币数', dataIndex: 'coin', width: 100, render: (v: number) => <b>{v}</b> },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 用户基本信息 + 互斥时间筛选(同一行) */}
|
||||
<Space
|
||||
wrap
|
||||
align="center"
|
||||
style={{ width: '100%', justifyContent: 'space-between', marginBottom: 12 }}
|
||||
>
|
||||
<Space size="large" wrap>
|
||||
<span>
|
||||
手机号 <Text strong>{user?.phone || '-'}</Text>
|
||||
</span>
|
||||
<span>
|
||||
昵称 <Text strong>{user?.nickname || '-'}</Text>
|
||||
</span>
|
||||
<span>
|
||||
微信昵称 <Text strong>{user?.wechat_nickname || '-'}</Text>
|
||||
</span>
|
||||
<span>
|
||||
注册天数 <Text strong>{regDays != null ? `${regDays} 天` : '-'}</Text>
|
||||
</span>
|
||||
</Space>
|
||||
<Space wrap>
|
||||
<Radio.Group
|
||||
size="small"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value)}
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
>
|
||||
<Radio.Button value="all">注册至今</Radio.Button>
|
||||
<Radio.Button value="range">自定义区间</Radio.Button>
|
||||
</Radio.Group>
|
||||
<RangePicker
|
||||
size="small"
|
||||
disabled={mode !== 'range'}
|
||||
value={range}
|
||||
onChange={(v) => setRange(v as [Dayjs, Dayjs] | null)}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{/* 统计区(受时间筛选;现金余额为当前快照) */}
|
||||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 8 }}>
|
||||
<Descriptions.Item label="累计提现">
|
||||
{stats ? yuan(stats.withdraw_success_cents) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="现金余额">
|
||||
{stats ? yuan(stats.cash_balance_cents) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提现总次数">{stats?.withdraw_total ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="传统任务提现">
|
||||
{stats ? yuan(stats.traditional_task_cash_cents) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="累计激励视频数">
|
||||
{stats?.reward_video_count ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="平均激励视频ECPM">
|
||||
{stats ? `${stats.reward_video_avg_ecpm} 分/千` : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="激励视频提现">
|
||||
{stats ? yuan(stats.reward_video_cash_cents) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="累计信息流广告数">{stats?.feed_count ?? '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="平均信息流广告ECPM">
|
||||
{stats ? `${stats.feed_avg_ecpm} 分/千` : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="信息流广告提现">
|
||||
{stats ? yuan(stats.feed_cash_cents) : '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
统计随上方时间筛选(现金余额除外,为当前快照);eCPM 单位分/千次,同金币审计。
|
||||
</Text>
|
||||
|
||||
{/* 金币记录 */}
|
||||
<h4 style={{ margin: '16px 0 8px' }}>金币记录</h4>
|
||||
<Table
|
||||
rowKey={(r) => `${r.source}-${r.created_at}-${r.coin}-${r.ecpm ?? ''}`}
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={records}
|
||||
loading={recordsLoading || statsLoading}
|
||||
pagination={false}
|
||||
/>
|
||||
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => loadRecords(nextCursor)}
|
||||
disabled={!nextCursor}
|
||||
loading={recordsLoading}
|
||||
>
|
||||
{nextCursor ? '加载更多' : '没有更多了'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -38,18 +38,19 @@ import {
|
||||
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 { formatUtcTime, utcDayjs, utcFromNow, yuan } from '@/lib/format';
|
||||
import { usePagedList } from '@/lib/usePagedList';
|
||||
import type {
|
||||
AuditLog,
|
||||
CashTxn,
|
||||
WithdrawBulkResult,
|
||||
WithdrawDetail,
|
||||
WithdrawLedgerCheck,
|
||||
WithdrawListItem,
|
||||
WithdrawOrder,
|
||||
WithdrawSummary,
|
||||
WxpayHealthCheck,
|
||||
} from '@/lib/types';
|
||||
import UserRewardPanel from './UserRewardPanel';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { RangePicker } = DatePicker;
|
||||
@@ -101,19 +102,12 @@ const QUICK_FILTER_STATUS: Record<string, string> = {
|
||||
};
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'created_at', label: '申请时间' },
|
||||
{ value: 'updated_at', label: '更新时间' },
|
||||
{ value: 'amount_cents', label: '提现金额' },
|
||||
{ value: 'id', label: '记录 ID' },
|
||||
];
|
||||
const DATE_FIELD_OPTIONS = [
|
||||
{ value: 'created_at', label: '申请时间' },
|
||||
{ value: 'updated_at', label: '更新时间' },
|
||||
];
|
||||
const CASH_BIZ_LABEL: Record<string, string> = {
|
||||
exchange_in: '金币兑入',
|
||||
withdraw: '提现扣款',
|
||||
withdraw_refund: '提现退款',
|
||||
};
|
||||
|
||||
function statusTag(status: string) {
|
||||
return <Tag color={STATUS_COLOR[status]}>{STATUS_LABEL[status] || status}</Tag>;
|
||||
@@ -186,9 +180,7 @@ export default function WithdrawsPage() {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
const [dateField, setDateField] = useState<'created_at' | 'updated_at'>('created_at');
|
||||
const [sortBy, setSortBy] = useState<'created_at' | 'updated_at' | 'amount_cents' | 'id'>(
|
||||
'created_at',
|
||||
);
|
||||
const [sortBy, setSortBy] = useState<'created_at' | 'amount_cents'>('created_at');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [quickFilter, setQuickFilter] = useState<string | undefined>(undefined);
|
||||
|
||||
@@ -203,7 +195,7 @@ export default function WithdrawsPage() {
|
||||
filters.sort_order = sortOrder;
|
||||
const filterKey = JSON.stringify(filters);
|
||||
const { items, total, page, pageSize, loading, onChange: onPageChange, reload } =
|
||||
usePagedList<WithdrawOrder>('/admin/api/withdraws', filters, 20);
|
||||
usePagedList<WithdrawListItem>('/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');
|
||||
@@ -469,37 +461,37 @@ export default function WithdrawsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnsType<WithdrawOrder> = [
|
||||
const columns: ColumnsType<WithdrawListItem> = [
|
||||
{
|
||||
title: '用户',
|
||||
dataIndex: 'user_id',
|
||||
width: 110,
|
||||
render: (v: number) => <Text strong>#{v}</Text>,
|
||||
},
|
||||
{
|
||||
title: '提现实名',
|
||||
dataIndex: 'user_name',
|
||||
title: '手机号',
|
||||
dataIndex: 'phone',
|
||||
width: 130,
|
||||
render: (v: string | null) => v || <Text type="secondary">未填写</Text>,
|
||||
render: (v: string | null) => v || <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
title: '用户昵称',
|
||||
dataIndex: 'nickname',
|
||||
width: 130,
|
||||
render: (v: string | null) => v || <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '提现金额',
|
||||
dataIndex: 'amount_cents',
|
||||
width: 120,
|
||||
render: (v: number) => <Text strong>{yuan(v)}</Text>,
|
||||
},
|
||||
{
|
||||
title: '累计提现',
|
||||
dataIndex: 'cumulative_success_cents',
|
||||
width: 120,
|
||||
render: (v: number) => yuan(v ?? 0),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: statusTag,
|
||||
},
|
||||
{
|
||||
title: '微信状态',
|
||||
dataIndex: 'wechat_state',
|
||||
width: 150,
|
||||
render: (v: string | null) => v || <Text type="secondary">-</Text>,
|
||||
},
|
||||
{
|
||||
title: '提交时间',
|
||||
dataIndex: 'created_at',
|
||||
@@ -512,7 +504,7 @@ export default function WithdrawsPage() {
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '提现单号',
|
||||
title: '提交单号',
|
||||
dataIndex: 'out_bill_no',
|
||||
ellipsis: true,
|
||||
},
|
||||
@@ -527,7 +519,7 @@ export default function WithdrawsPage() {
|
||||
key: 'op',
|
||||
fixed: 'right',
|
||||
width: 210,
|
||||
render: (_: unknown, o: WithdrawOrder) => {
|
||||
render: (_: unknown, o: WithdrawListItem) => {
|
||||
if (!canManage) return <Text type="secondary">-</Text>;
|
||||
if (o.status === 'reviewing') {
|
||||
return (
|
||||
@@ -576,37 +568,6 @@ export default function WithdrawsPage() {
|
||||
},
|
||||
];
|
||||
|
||||
const cashColumns: ColumnsType<CashTxn> = [
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'biz_type',
|
||||
width: 130,
|
||||
render: (v: string) => CASH_BIZ_LABEL[v] || v,
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount_cents',
|
||||
width: 100,
|
||||
render: (v: number) => (
|
||||
<Text style={{ color: v < 0 ? '#cf1322' : '#389e0d' }}>
|
||||
{v > 0 ? '+' : ''}
|
||||
{yuan(v)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{ title: '余额', dataIndex: 'balance_after_cents', width: 100, render: yuan },
|
||||
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' },
|
||||
// 现金流水是北京 wall-clock 口径,原样显示,不能当 UTC 再 +8
|
||||
{ title: '时间', dataIndex: 'created_at', width: 150, render: (v: string) => formatWallTime(v) },
|
||||
];
|
||||
|
||||
const recentWithdrawColumns: ColumnsType<WithdrawOrder> = [
|
||||
{ title: '单号', dataIndex: 'out_bill_no', ellipsis: true },
|
||||
{ title: '金额', dataIndex: 'amount_cents', width: 90, render: yuan },
|
||||
{ title: '状态', dataIndex: 'status', width: 90, render: statusTag },
|
||||
{ title: '时间', dataIndex: 'created_at', width: 150, render: dt },
|
||||
];
|
||||
|
||||
const auditTimeline = detail
|
||||
? [
|
||||
{
|
||||
@@ -704,34 +665,14 @@ export default function WithdrawsPage() {
|
||||
)}
|
||||
|
||||
<Row gutter={12} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} xl={5}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card size="small">
|
||||
<Statistic title="待审核" value={summary?.reviewing_count || 0} suffix="笔" />
|
||||
<Text type="secondary">金额 {yuan(summary?.reviewing_amount_cents || 0)}</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={5}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Card size="small">
|
||||
<Statistic title="今日拒绝" value={summary?.today_rejected_count || 0} suffix="笔" />
|
||||
<Text type="secondary">拒绝原因会同步给用户</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={5}>
|
||||
<Card size="small">
|
||||
<Statistic title="打款中" value={summary?.pending_count || 0} suffix="笔" />
|
||||
<Text type="secondary">可刷新查单</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={4}>
|
||||
<Card size="small">
|
||||
<Statistic title="失败待处理" value={summary?.failed_count || 0} suffix="笔" />
|
||||
<Text type="secondary">需关注退款结果</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={5}>
|
||||
<Card size="small">
|
||||
<Statistic title="今日到账" value={summary?.today_success_count || 0} suffix="笔" />
|
||||
<Text type="secondary">金额 {yuan(summary?.today_success_amount_cents || 0)}</Text>
|
||||
<Statistic title="待审核金额" value={yuan(summary?.reviewing_amount_cents || 0)} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -772,9 +713,9 @@ export default function WithdrawsPage() {
|
||||
allowClear
|
||||
enterButton="搜索"
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="用户ID / 手机号 / 单号 / 原因"
|
||||
placeholder="用户手机号"
|
||||
value={searchDraft}
|
||||
style={{ width: 320 }}
|
||||
style={{ width: 240 }}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSearchDraft(value);
|
||||
@@ -821,9 +762,7 @@ export default function WithdrawsPage() {
|
||||
style={{ width: 130 }}
|
||||
suffixIcon={<SortAscendingOutlined />}
|
||||
options={SORT_OPTIONS}
|
||||
onChange={(value: 'created_at' | 'updated_at' | 'amount_cents' | 'id') =>
|
||||
setSortBy(value)
|
||||
}
|
||||
onChange={(value: 'created_at' | 'amount_cents') => setSortBy(value)}
|
||||
/>
|
||||
<Select
|
||||
value={sortOrder}
|
||||
@@ -958,26 +897,7 @@ export default function WithdrawsPage() {
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Descriptions bordered size="small" column={2} title="用户与钱包">
|
||||
<Descriptions.Item label="用户 ID">#{detail.order.user_id}</Descriptions.Item>
|
||||
<Descriptions.Item label="账号状态">
|
||||
{detail.user ? <Tag>{detail.user.status}</Tag> : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="手机号">{detail.user?.phone || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="昵称">{detail.user?.nickname || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="微信昵称">
|
||||
{detail.user?.wechat_nickname || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="现金余额">
|
||||
{yuan(detail.user?.cash_balance_cents || 0)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="提现总次数">
|
||||
{detail.user?.withdraw_total ?? '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="成功提现">
|
||||
{yuan(detail.user?.withdraw_success_cents || 0)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<UserRewardPanel userId={detail.order.user_id} user={detail.user} />
|
||||
|
||||
<div>
|
||||
<h3>风险提示 {detail.risk_score ? <Tag color="red">{detail.risk_score}分</Tag> : null}</h3>
|
||||
@@ -998,28 +918,6 @@ export default function WithdrawsPage() {
|
||||
<h3>处理时间线</h3>
|
||||
<Timeline items={auditTimeline} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>最近提现</h3>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={recentWithdrawColumns}
|
||||
dataSource={detail.recent_withdraws}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3>最近现金流水</h3>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
columns={cashColumns}
|
||||
dataSource={detail.recent_cash_transactions}
|
||||
pagination={false}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user