傻瓜比价运营后台前端 (Next.js 15 + Ant Design 5)
admin 后台 P0 + 配置后台化:登录/大盘/用户(360+封号+调金币)/提现(重试+对账)/反馈/管理员/审计/系统配置;对接 shaguabijia-app-server 的 admin API(独立 JWT + RBAC + 审计)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# 生产:前端与 admin API 同域(admin.shaguabijia.com),nginx 把 /admin/api 反代到后端 8771。
|
||||
# 留空 = axios 用相对路径,请求落到当前域名,由 nginx 转发。
|
||||
NEXT_PUBLIC_API_BASE=
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
.env*.local
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
.DS_Store
|
||||
data/
|
||||
@@ -0,0 +1,48 @@
|
||||
# 傻瓜比价 · 运营后台(前端)
|
||||
|
||||
Next.js 15 + Ant Design 5,对接 `shaguabijia-app-server` 的 admin API(`app/admin/`)。
|
||||
后端是独立 FastAPI app(端口 8771,复用 app-server 的 models/repositories + 同一 PostgreSQL,
|
||||
鉴权用独立 JWT)。本仓只是前端。
|
||||
|
||||
## 功能(P0)
|
||||
数据大盘 / 用户管理(列表+360详情+封禁+调金币) / 提现管理(列表+重试+对账) /
|
||||
反馈工单 / 管理员账号(super) / 审计日志。按角色显示操作:finance=钱、operator=用户+反馈、super=全部。
|
||||
|
||||
## 本地开发
|
||||
```bash
|
||||
# 1. 起 admin 后端(在 shaguabijia-app-server 目录)
|
||||
conda activate price && uvicorn app.admin.main:admin_app --port 8771
|
||||
# 2. 建首个超管(打印密码)
|
||||
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"
|
||||
ssh server "cd /opt/shaguabijia-app-server && .venv/bin/python scripts/create_admin.py --username admin --role super_admin"
|
||||
```
|
||||
|
||||
### ② 前端
|
||||
```bash
|
||||
npm run build
|
||||
rsync -avz --exclude node_modules --exclude .next/cache ./ server:/opt/shaguabijia-admin-web/
|
||||
ssh server "cd /opt/shaguabijia-admin-web && npm install --omit=dev && npm run build && pm2 start ecosystem.config.js"
|
||||
```
|
||||
|
||||
### ③ 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 已建、能登录
|
||||
@@ -0,0 +1,45 @@
|
||||
# admin.shaguabijia.com — 傻瓜比价运营后台
|
||||
# 前端(Next.js,pm2 :3001) + /admin/api 反代到 admin 后端(uvicorn :8771)
|
||||
# 前后端同域,前端 axios 用相对路径,无需 CORS。
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name admin.shaguabijia.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name admin.shaguabijia.com;
|
||||
|
||||
ssl_certificate /etc/nginx/ssl/admin.shaguabijia.com.pem;
|
||||
ssl_certificate_key /etc/nginx/ssl/admin.shaguabijia.com.key;
|
||||
|
||||
# ⚠️ 强烈建议:IP 白名单(只放运营出口 IP)。后台能看全量用户数据 + 操作真钱。
|
||||
# 取消注释并填真实 IP:
|
||||
# allow 1.2.3.4;
|
||||
# deny all;
|
||||
|
||||
client_max_body_size 10m;
|
||||
|
||||
# admin API → 后端 8771。X-Forwarded-For 用 $remote_addr 覆盖客户端传入值,
|
||||
# 防伪造审计 IP(对齐 app/admin/deps.py get_client_ip 的注释要求)。
|
||||
location /admin/api/ {
|
||||
proxy_pass http://127.0.0.1:8771;
|
||||
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;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# 运营后台 · 本地启动指南
|
||||
|
||||
> 这套后台 = 两个服务:**后端**(FastAPI,端口 8771)+ **前端**(Next.js,端口 3001)。
|
||||
> 数据库是本机 **PostgreSQL**(端口 5432)。
|
||||
> 访问地址:**http://localhost:3001** 账号 **admin** / 密码 **admin12345**
|
||||
|
||||
---
|
||||
|
||||
## 开机后通常不用管的
|
||||
|
||||
- **PG 开机自启**:本机 PostgreSQL 是 Homebrew `postgresql@16` 装的(`brew services` + LaunchAgent),登录系统时自动起,一般不用手动启。
|
||||
- **表 / 账号 / 依赖都是持久的**:不用重新建表、建账号、`npm install`。
|
||||
|
||||
所以**重启电脑后,只需起 后端 + 前端 两个服务**(下面方式一或方式二二选一)。
|
||||
|
||||
---
|
||||
|
||||
## 方式一:前台启动(开发推荐,能看实时日志,Ctrl+C 停)
|
||||
|
||||
开两个终端窗口。
|
||||
|
||||
**终端 1 — 后端**
|
||||
```bash
|
||||
cd ~/Desktop/codes/shaguabijia-app-server
|
||||
conda activate price
|
||||
uvicorn app.admin.main:admin_app --port 8771 --reload
|
||||
```
|
||||
|
||||
**终端 2 — 前端**
|
||||
```bash
|
||||
cd ~/Desktop/codes/shaguabijia-admin-web
|
||||
npm run dev
|
||||
```
|
||||
|
||||
然后浏览器开 **http://localhost:3001**。停止:各终端按 `Ctrl+C`。
|
||||
|
||||
> `--reload`:改后端代码自动重启(省得手动重起)。前端 `npm run dev` 自带热重载。
|
||||
|
||||
---
|
||||
|
||||
## 方式二:后台启动(一条命令拉起,日志落 /tmp)
|
||||
|
||||
```bash
|
||||
cd ~/Desktop/codes/shaguabijia-admin-web
|
||||
./start.sh # 确保 PG + 起后端(8771) + 起前端(3001),全后台
|
||||
./stop.sh # 停掉后端 + 前端(PG 不动,它给别的也可能用)
|
||||
```
|
||||
|
||||
看日志:
|
||||
```bash
|
||||
tail -f /tmp/admin-api.log # 后端
|
||||
tail -f /tmp/admin-web.log # 前端
|
||||
```
|
||||
|
||||
> 第一次用脚本前若提示无权限:`chmod +x start.sh stop.sh`(已预先加过执行权限,正常不用再加)。
|
||||
|
||||
---
|
||||
|
||||
## 访问
|
||||
|
||||
- 地址:**http://localhost:3001**
|
||||
- 账号:**admin** / 密码:**admin12345**
|
||||
- 前端第一次点进某个页面会**现编译、慢几秒**,之后就快。
|
||||
|
||||
---
|
||||
|
||||
## 仅这些情况才需要(平时不用)
|
||||
|
||||
```bash
|
||||
# ① 新电脑 / 删过 node_modules → 装前端依赖
|
||||
cd ~/Desktop/codes/shaguabijia-admin-web && npm install
|
||||
|
||||
# ② 全新数据库、没建过 admin 表 → 建表
|
||||
cd ~/Desktop/codes/shaguabijia-app-server && conda activate price && alembic upgrade head
|
||||
|
||||
# ③ 没有管理员账号 → 建一个超管(密码可自改)
|
||||
cd ~/Desktop/codes/shaguabijia-app-server && conda activate price && \
|
||||
python scripts/create_admin.py --username admin --password 'admin12345' --role super_admin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 排障
|
||||
|
||||
| 现象 | 原因 / 解决 |
|
||||
|---|---|
|
||||
| 后端报数据库连接失败 | PG 没起 → `brew services start postgresql@16` |
|
||||
| 端口被占(8771/3001 起不来) | `./stop.sh`,或 `lsof -ti:8771 \| xargs kill`(3001 同理),再重起 |
|
||||
| 登录转圈 / 失败,控制台 CORS 报错 | 前端端口必须在后端 CORS 白名单(`app/admin/main.py` 的 `_dev_origins`)。前端默认 3001,已配好;若你改了前端端口,记得同步这里并重启后端 |
|
||||
| 改了后端代码不生效 | 用 `--reload`(方式一已带、脚本已带);否则手动重起后端 |
|
||||
| 控制台 antd 波纹 React 版本警告 | 已禁用 wave,不再出现;纯 dev 噪音、不影响功能 |
|
||||
|
||||
---
|
||||
|
||||
## 部署到线上?
|
||||
|
||||
本文档只讲本地。生产部署(systemd + pm2 + nginx + 域名 admin.shaguabijia.com)见 [../README.md](../README.md)。
|
||||
@@ -0,0 +1,15 @@
|
||||
// pm2 部署配置(对齐 hire 项目)。生产:pm2 start ecosystem.config.js
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'shaguabijia-admin-web',
|
||||
cwd: '/opt/shaguabijia-admin-web',
|
||||
script: 'npm',
|
||||
args: 'start', // → next start -p 3001
|
||||
env: { NODE_ENV: 'production' },
|
||||
instances: 1,
|
||||
autorestart: true,
|
||||
max_memory_restart: '300M',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
Generated
+2495
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "shaguabijia-admin-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3001",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.5.2",
|
||||
"@ant-design/nextjs-registry": "^1.0.2",
|
||||
"antd": "^5.22.0",
|
||||
"axios": "^1.7.7",
|
||||
"dayjs": "^1.11.13",
|
||||
"next": "^15.0.3",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
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 type { AdminInfo } from '@/lib/types';
|
||||
|
||||
const ROLES = [
|
||||
{ value: 'super_admin', label: 'super_admin' },
|
||||
{ value: 'finance', label: 'finance' },
|
||||
{ value: 'operator', label: 'operator' },
|
||||
];
|
||||
const dt = (v: string | null) => (v ? new Date(v).toLocaleString('zh-CN') : '从未');
|
||||
|
||||
export default function AdminsPage() {
|
||||
const [admins, setAdmins] = useState<AdminInfo[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<AdminInfo[]>('/admin/api/admins');
|
||||
setAdmins(data);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const create = async () => {
|
||||
const v = await form.validateFields();
|
||||
try {
|
||||
await api.post('/admin/api/admins', v);
|
||||
message.success('已创建');
|
||||
setCreateOpen(false);
|
||||
form.resetFields();
|
||||
load();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
const changeRole = (a: AdminInfo, role: string) => {
|
||||
if (role === a.role) return;
|
||||
Modal.confirm({
|
||||
title: `把 ${a.username} 的角色改为 ${role}?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.patch(`/admin/api/admins/${a.id}`, { role });
|
||||
message.success('已更新');
|
||||
load();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const toggle = (a: AdminInfo) => {
|
||||
const next = a.status === 'active' ? 'disabled' : 'active';
|
||||
Modal.confirm({
|
||||
title: `${next === 'disabled' ? '禁用' : '启用'}管理员 ${a.username}?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.patch(`/admin/api/admins/${a.id}`, { status: next });
|
||||
message.success('已更新');
|
||||
load();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnsType<AdminInfo> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '用户名', dataIndex: 'username' },
|
||||
{
|
||||
title: '角色',
|
||||
dataIndex: 'role',
|
||||
render: (r: string, a: AdminInfo) => (
|
||||
<Select
|
||||
size="small"
|
||||
value={r}
|
||||
style={{ width: 140 }}
|
||||
options={ROLES}
|
||||
onChange={(v) => changeRole(a, v)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
render: (s: string) => <Tag color={s === 'active' ? 'green' : 'red'}>{s}</Tag>,
|
||||
},
|
||||
{ title: '最后登录', dataIndex: 'last_login_at', render: dt },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'op',
|
||||
render: (_: unknown, a: AdminInfo) => (
|
||||
<a onClick={() => toggle(a)}>{a.status === 'active' ? '禁用' : '启用'}</a>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>管理员账号</h2>
|
||||
<Button type="primary" style={{ marginBottom: 16 }} onClick={() => setCreateOpen(true)}>
|
||||
新建管理员
|
||||
</Button>
|
||||
<Table rowKey="id" columns={columns} dataSource={admins} loading={loading} pagination={false} />
|
||||
|
||||
<Modal
|
||||
title="新建管理员"
|
||||
open={createOpen}
|
||||
onOk={create}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" initialValues={{ role: 'operator' }}>
|
||||
<Form.Item name="username" label="用户名" rules={[{ required: true, min: 3 }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码(≥8 位)" rules={[{ required: true, min: 8 }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
|
||||
<Select options={ROLES} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
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 type { AuditLog } from '@/lib/types';
|
||||
|
||||
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
|
||||
|
||||
export default function AuditLogsPage() {
|
||||
const [action, setAction] = useState('');
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
||||
const { items, nextCursor, loading, loadMore } = useCursorList<AuditLog>(
|
||||
'/admin/api/audit-logs',
|
||||
filters,
|
||||
50,
|
||||
);
|
||||
|
||||
const search = () => setFilters({ action: action || undefined });
|
||||
|
||||
const columns: ColumnsType<AuditLog> = [
|
||||
{ title: '时间', dataIndex: 'created_at', render: dt, width: 180 },
|
||||
{ title: '操作者', dataIndex: 'admin_username', width: 120 },
|
||||
{ title: '动作', dataIndex: 'action', render: (a: string) => <Tag>{a}</Tag>, width: 170 },
|
||||
{
|
||||
title: '对象',
|
||||
key: 'target',
|
||||
width: 140,
|
||||
render: (_: unknown, l: AuditLog) => `${l.target_type}${l.target_id ? '#' + l.target_id : ''}`,
|
||||
},
|
||||
{
|
||||
title: '详情',
|
||||
dataIndex: 'detail',
|
||||
render: (d: Record<string, unknown> | null) =>
|
||||
d ? <code style={{ fontSize: 12 }}>{JSON.stringify(d)}</code> : '-',
|
||||
},
|
||||
{ title: 'IP', dataIndex: 'ip', render: (v: string | null) => v || '-', width: 130 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>审计日志</h2>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Input
|
||||
placeholder="动作(如 user.coins.grant)"
|
||||
value={action}
|
||||
onChange={(e) => setAction(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
<Button type="primary" onClick={search}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 1000 }}
|
||||
/>
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||
{nextCursor ? '加载更多' : '没有更多了'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button, Card, Input, InputNumber, Space, Spin, Tag, Tooltip, message } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
|
||||
interface ConfigItem {
|
||||
key: string;
|
||||
label: string;
|
||||
group: string;
|
||||
type: string; // int / int_list / dict_str_int
|
||||
help: string | null;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
default: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
value: any;
|
||||
overridden: boolean;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function toEdit(item: ConfigItem): any {
|
||||
if (item.type === 'int_list') return (item.value as number[]).join(', ');
|
||||
if (item.type === 'dict_str_int') return JSON.stringify(item.value);
|
||||
return item.value;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function fromEdit(type: string, raw: any): any {
|
||||
if (type === 'int') return Number(raw);
|
||||
if (type === 'int_list') {
|
||||
return String(raw)
|
||||
.split(',')
|
||||
.map((s) => parseInt(s.trim(), 10));
|
||||
}
|
||||
if (type === 'dict_str_int') return JSON.parse(raw);
|
||||
return raw;
|
||||
}
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [items, setItems] = useState<ConfigItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const [edits, setEdits] = useState<Record<string, any>>({});
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.get<ConfigItem[]>('/admin/api/config');
|
||||
setItems(data);
|
||||
setEdits(Object.fromEntries(data.map((i) => [i.key, toEdit(i)])));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const save = async (item: ConfigItem) => {
|
||||
let value;
|
||||
try {
|
||||
value = fromEdit(item.type, edits[item.key]);
|
||||
} catch {
|
||||
message.error('格式不对(列表用逗号分隔、映射用 JSON)');
|
||||
return;
|
||||
}
|
||||
setSaving(item.key);
|
||||
try {
|
||||
await api.patch(`/admin/api/config/${item.key}`, { value });
|
||||
message.success('已保存,即时生效');
|
||||
load();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <Spin style={{ display: 'block', marginTop: 80 }} />;
|
||||
|
||||
const groups = [...new Set(items.map((i) => i.group))];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>系统配置</h2>
|
||||
<p style={{ color: '#999' }}>
|
||||
改完即生效(业务下次读取用新值)。涉及金额 / 上限,改前请确认。每次改动都进审计日志。
|
||||
</p>
|
||||
{groups.map((g) => (
|
||||
<Card key={g} title={g} size="small" style={{ marginBottom: 16 }}>
|
||||
{items
|
||||
.filter((i) => i.group === g)
|
||||
.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
style={{ marginBottom: 12, paddingBottom: 12, borderBottom: '1px solid #f0f0f0' }}
|
||||
>
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<b>{item.label}</b>{' '}
|
||||
{item.overridden ? <Tag color="blue">已改</Tag> : <Tag>默认</Tag>}
|
||||
{item.help && (
|
||||
<Tooltip title={item.help}>
|
||||
<span style={{ color: '#999', marginLeft: 6, fontSize: 12, cursor: 'help' }}>
|
||||
ⓘ
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<Space wrap>
|
||||
{item.type === 'int' ? (
|
||||
<InputNumber
|
||||
value={edits[item.key]}
|
||||
onChange={(v) => setEdits({ ...edits, [item.key]: v })}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={edits[item.key]}
|
||||
onChange={(e) => setEdits({ ...edits, [item.key]: e.target.value })}
|
||||
style={{ width: 380 }}
|
||||
placeholder={
|
||||
item.type === 'int_list' ? '逗号分隔,如 10, 20, 30' : 'JSON,如 {"key": 100}'
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
loading={saving === item.key}
|
||||
onClick={() => save(item)}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
<span style={{ color: '#bbb', fontSize: 12 }}>
|
||||
默认 {JSON.stringify(item.default)}
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Card, Col, Row, Spin, Statistic } from 'antd';
|
||||
import { api } from '@/lib/api';
|
||||
import type { DashboardOverview } from '@/lib/types';
|
||||
|
||||
const yuan = (cents: number) => `¥${(cents / 100).toFixed(2)}`;
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [data, setData] = useState<DashboardOverview | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
api
|
||||
.get<DashboardOverview>('/admin/api/stats/overview')
|
||||
.then((r) => setData(r.data))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
if (loading || !data) return <Spin style={{ display: 'block', marginTop: 80 }} />;
|
||||
|
||||
const cards: { title: string; value: string | number; suffix?: string; danger?: boolean }[] = [
|
||||
{ title: '总用户', value: data.users.total },
|
||||
{ title: '今日活跃 DAU', value: data.users.dau },
|
||||
{ title: '今日新增', value: data.users.new_today },
|
||||
{ title: '封禁用户', value: data.users.disabled, danger: data.users.disabled > 0 },
|
||||
{ title: '累计发放金币', value: data.coins.granted_total },
|
||||
{ title: '累计成功提现', value: yuan(data.cash.withdraw_success_cents) },
|
||||
{ title: '待处理提现', value: data.cash.withdraw_pending_count, danger: data.cash.withdraw_pending_count > 0 },
|
||||
{ title: '比价成功率', value: (data.comparison.success_rate * 100).toFixed(1), suffix: '%' },
|
||||
{ title: '比价总次数', value: data.comparison.total },
|
||||
{ title: '待处理反馈', value: data.feedback.new, danger: data.feedback.new > 0 },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>数据大盘</h2>
|
||||
<Row gutter={[16, 16]}>
|
||||
{cards.map((c) => (
|
||||
<Col key={c.title} xs={12} sm={8} md={6} xl={4}>
|
||||
<Card>
|
||||
<Statistic
|
||||
title={c.title}
|
||||
value={c.value}
|
||||
suffix={c.suffix}
|
||||
valueStyle={c.danger ? { color: '#cf1322' } : undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
{!data.cps.available && (
|
||||
<Alert style={{ marginTop: 16 }} type="info" showIcon message={`CPS 收入:${data.cps.note}`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Button, Select, Space, Table, Tag, message } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { useCursorList } from '@/lib/useCursorList';
|
||||
import type { Feedback } from '@/lib/types';
|
||||
|
||||
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
|
||||
|
||||
export default function FeedbacksPage() {
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
||||
const { items, nextCursor, loading, loadMore, reload } = useCursorList<Feedback>(
|
||||
'/admin/api/feedbacks',
|
||||
filters,
|
||||
);
|
||||
const canHandle = canDo(['operator']);
|
||||
|
||||
const handle = async (f: Feedback) => {
|
||||
try {
|
||||
await api.post(`/admin/api/feedbacks/${f.id}/handle`);
|
||||
message.success('已标记处理');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Feedback> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '用户', dataIndex: 'user_id', width: 80 },
|
||||
{ title: '内容', dataIndex: 'content' },
|
||||
{ title: '联系方式', dataIndex: 'contact', width: 140 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: string) => <Tag color={s === 'new' ? 'orange' : 'green'}>{s}</Tag>,
|
||||
},
|
||||
{ title: '时间', dataIndex: 'created_at', render: dt, width: 180 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'op',
|
||||
width: 100,
|
||||
render: (_: unknown, f: Feedback) =>
|
||||
canHandle && f.status === 'new' ? (
|
||||
<a onClick={() => handle(f)}>标记处理</a>
|
||||
) : (
|
||||
<span style={{ color: '#ccc' }}>-</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>反馈工单</h2>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
setFilters({ status: v });
|
||||
}}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'new', label: '待处理' },
|
||||
{ value: 'handled', label: '已处理' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<Table rowKey="id" columns={columns} dataSource={items} loading={loading} pagination={false} />
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||
{nextCursor ? '加载更多' : '没有更多了'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
FileSearchOutlined,
|
||||
LogoutOutlined,
|
||||
MessageOutlined,
|
||||
MoneyCollectOutlined,
|
||||
SettingOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Avatar, Dropdown, Layout, Menu } from 'antd';
|
||||
import { clearAuth, getAdmin, getToken } from '@/lib/auth';
|
||||
import type { AdminInfo } from '@/lib/types';
|
||||
|
||||
const { Sider, Header, Content } = Layout;
|
||||
|
||||
const MENU = [
|
||||
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据大盘' },
|
||||
{ key: '/users', icon: <UserOutlined />, label: '用户管理' },
|
||||
{ key: '/withdraws', icon: <MoneyCollectOutlined />, label: '提现管理' },
|
||||
{ key: '/feedbacks', icon: <MessageOutlined />, label: '反馈工单' },
|
||||
{ key: '/config', icon: <SettingOutlined />, label: '系统配置' },
|
||||
{ key: '/admins', icon: <TeamOutlined />, label: '管理员', superOnly: true },
|
||||
{ key: '/audit-logs', icon: <FileSearchOutlined />, label: '审计日志' },
|
||||
];
|
||||
|
||||
export default function MainLayout({ children }: { children: React.ReactNode }) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [admin, setAdmin] = useState<AdminInfo | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getToken()) {
|
||||
router.replace('/login');
|
||||
return;
|
||||
}
|
||||
setAdmin(getAdmin());
|
||||
}, [router]);
|
||||
|
||||
if (!admin) return null; // 守卫期间不闪烁内容
|
||||
|
||||
const items = MENU.filter((m) => !m.superOnly || admin.role === 'super_admin').map((m) => ({
|
||||
key: m.key,
|
||||
icon: m.icon,
|
||||
label: m.label,
|
||||
}));
|
||||
|
||||
// 选中态:取路径一级(/users/123 → /users)
|
||||
const selectedKey = '/' + (pathname.split('/')[1] || 'dashboard');
|
||||
|
||||
const logout = () => {
|
||||
clearAuth();
|
||||
router.replace('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
<Sider theme="dark" breakpoint="lg" collapsible>
|
||||
<div
|
||||
style={{
|
||||
height: 48,
|
||||
margin: 16,
|
||||
color: '#fff',
|
||||
fontWeight: 600,
|
||||
textAlign: 'center',
|
||||
lineHeight: '48px',
|
||||
}}
|
||||
>
|
||||
运营后台
|
||||
</div>
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[selectedKey]}
|
||||
items={items}
|
||||
onClick={({ key }) => router.push(key)}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout>
|
||||
<Header
|
||||
style={{
|
||||
background: '#fff',
|
||||
padding: '0 24px',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: logout },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<span style={{ cursor: 'pointer' }}>
|
||||
<Avatar size="small" icon={<UserOutlined />} /> {admin.username}({admin.role})
|
||||
</span>
|
||||
</Dropdown>
|
||||
</Header>
|
||||
<Content style={{ margin: 24 }}>{children}</Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client';
|
||||
|
||||
import { 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 { 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');
|
||||
|
||||
export default function UserDetailPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const uid = Number(params.id);
|
||||
const [data, setData] = useState<UserOverview | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<UserOverview>(`/admin/api/users/${uid}`).then((r) => setData(r.data));
|
||||
}, [uid]);
|
||||
|
||||
const coins = useCursorList<CoinTxn>('/admin/api/wallet/coin-transactions', { user_id: uid });
|
||||
const withdraws = useCursorList<WithdrawOrder>('/admin/api/withdraws', { user_id: uid });
|
||||
|
||||
if (!data) return <Spin style={{ display: 'block', marginTop: 80 }} />;
|
||||
|
||||
const coinCols: ColumnsType<CoinTxn> = [
|
||||
{ title: '时间', dataIndex: 'created_at', render: dt },
|
||||
{
|
||||
title: '变动',
|
||||
dataIndex: 'amount',
|
||||
render: (v: number) => (
|
||||
<span style={{ color: v > 0 ? '#3f8600' : '#cf1322' }}>
|
||||
{v > 0 ? '+' : ''}
|
||||
{v}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ title: '余额', dataIndex: 'balance_after' },
|
||||
{ title: '类型', dataIndex: 'biz_type' },
|
||||
{ title: '备注', dataIndex: 'remark', render: (v: string | null) => v || '-' },
|
||||
];
|
||||
|
||||
const wdCols: ColumnsType<WithdrawOrder> = [
|
||||
{ title: '时间', dataIndex: 'created_at', render: dt },
|
||||
{ title: '金额', dataIndex: 'amount_cents', render: yuan },
|
||||
{ title: '状态', dataIndex: 'status', render: (s: string) => <Tag>{s}</Tag> },
|
||||
{ title: '单号', dataIndex: 'out_bill_no' },
|
||||
{ title: '失败原因', dataIndex: 'fail_reason', render: (v: string | null) => v || '-' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>用户详情 #{uid}</h2>
|
||||
<Card style={{ marginBottom: 16 }}>
|
||||
<Descriptions column={3}>
|
||||
<Descriptions.Item label="手机号">{data.user.phone}</Descriptions.Item>
|
||||
<Descriptions.Item label="昵称">{data.user.nickname || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag>{data.user.status}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="渠道">{data.user.register_channel}</Descriptions.Item>
|
||||
<Descriptions.Item label="微信绑定">
|
||||
{data.user.wechat_openid ? '已绑定' : '未绑定'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="注册时间">{dt(data.user.created_at)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} md={4}>
|
||||
<Card>
|
||||
<Statistic title="金币余额" value={data.coin_balance} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Card>
|
||||
<Statistic title="现金余额" value={yuan(data.cash_balance_cents)} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Card>
|
||||
<Statistic title="累计赚金币" value={data.total_coin_earned} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Card>
|
||||
<Statistic title="比价次数" value={data.comparison_total} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Card>
|
||||
<Statistic title="成功提现" value={yuan(data.withdraw_success_cents)} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Card>
|
||||
<Statistic title="反馈数" value={data.feedback_total} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'coins',
|
||||
label: '金币流水',
|
||||
children: (
|
||||
<>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={coinCols}
|
||||
dataSource={coins.items}
|
||||
loading={coins.loading}
|
||||
pagination={false}
|
||||
/>
|
||||
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
||||
<a onClick={coins.loadMore} style={{ pointerEvents: coins.nextCursor ? 'auto' : 'none', opacity: coins.nextCursor ? 1 : 0.4 }}>
|
||||
加载更多
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'withdraws',
|
||||
label: '提现记录',
|
||||
children: (
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={wdCols}
|
||||
dataSource={withdraws.items}
|
||||
loading={withdraws.loading}
|
||||
pagination={false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
'use client';
|
||||
|
||||
import { useState } 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 { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { useCursorList } from '@/lib/useCursorList';
|
||||
import type { UserListItem } from '@/lib/types';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = { active: 'green', disabled: 'red', deleted: 'default' };
|
||||
|
||||
export default function UsersPage() {
|
||||
const router = useRouter();
|
||||
const [phone, setPhone] = useState('');
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
||||
const { items, nextCursor, loading, loadMore, reload } = useCursorList<UserListItem>(
|
||||
'/admin/api/users',
|
||||
filters,
|
||||
);
|
||||
|
||||
const canCoins = canDo(['finance']);
|
||||
const canStatus = canDo(['operator']);
|
||||
|
||||
const [coinUser, setCoinUser] = useState<UserListItem | null>(null);
|
||||
const [coinForm] = Form.useForm();
|
||||
|
||||
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 toggleStatus = (u: UserListItem) => {
|
||||
const next = u.status === 'active' ? 'disabled' : 'active';
|
||||
Modal.confirm({
|
||||
title: `确认${next === 'disabled' ? '封禁' : '解封'}用户 ${u.phone}?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.post(`/admin/api/users/${u.id}/status`, { status: next });
|
||||
message.success('已更新状态');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnsType<UserListItem> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '手机号', dataIndex: 'phone' },
|
||||
{ title: '昵称', dataIndex: 'nickname', render: (v: string | null) => v || '-' },
|
||||
{ title: '渠道', dataIndex: 'register_channel', width: 90 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: string) => <Tag color={STATUS_COLOR[s]}>{s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '注册时间',
|
||||
dataIndex: 'created_at',
|
||||
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'op',
|
||||
render: (_: unknown, u: UserListItem) => (
|
||||
<Space>
|
||||
<a onClick={() => router.push(`/users/${u.id}`)}>详情</a>
|
||||
{canCoins && <a onClick={() => setCoinUser(u)}>调金币</a>}
|
||||
{canStatus && u.status !== 'deleted' && (
|
||||
<a onClick={() => toggleStatus(u)}>{u.status === 'active' ? '封禁' : '解封'}</a>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>用户管理</h2>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Input
|
||||
placeholder="手机号前缀"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
onPressEnter={search}
|
||||
allowClear
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ value: 'active', label: 'active' },
|
||||
{ value: 'disabled', label: 'disabled' },
|
||||
{ value: 'deleted', label: 'deleted' },
|
||||
]}
|
||||
/>
|
||||
<Button type="primary" onClick={search}>
|
||||
查询
|
||||
</Button>
|
||||
</Space>
|
||||
<Table rowKey="id" columns={columns} dataSource={items} loading={loading} pagination={false} />
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||
{nextCursor ? '加载更多' : '没有更多了'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={`调整金币 - ${coinUser?.phone ?? ''}`}
|
||||
open={!!coinUser}
|
||||
onOk={submitCoins}
|
||||
onCancel={() => setCoinUser(null)}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={coinForm} layout="vertical">
|
||||
<Form.Item name="amount" label="金币变动(正=增加,负=扣减)" rules={[{ required: true }]}>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="原因(入审计)" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Button, Modal, Select, Space, Table, Tag, message } from 'antd';
|
||||
import { api, errMsg } from '@/lib/api';
|
||||
import { canDo } from '@/lib/auth';
|
||||
import { useCursorList } from '@/lib/useCursorList';
|
||||
import type { WithdrawOrder } from '@/lib/types';
|
||||
|
||||
const yuan = (c: number) => `¥${(c / 100).toFixed(2)}`;
|
||||
const dt = (v: string) => new Date(v).toLocaleString('zh-CN');
|
||||
const STATUS_COLOR: Record<string, string> = { pending: 'orange', success: 'green', failed: 'red' };
|
||||
|
||||
export default function WithdrawsPage() {
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
const [filters, setFilters] = useState<Record<string, unknown>>({});
|
||||
const { items, nextCursor, loading, loadMore, reload } = useCursorList<WithdrawOrder>(
|
||||
'/admin/api/withdraws',
|
||||
filters,
|
||||
);
|
||||
const canManage = canDo(['finance']);
|
||||
|
||||
const retry = (o: WithdrawOrder) => {
|
||||
Modal.confirm({
|
||||
title: `重新向微信查询单 ${o.out_bill_no} 的状态?`,
|
||||
content: '会按微信最新状态归一化(成功/退款/撤销未确认)。',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await api.post(`/admin/api/withdraws/${o.out_bill_no}/refresh`);
|
||||
message.success('已查询');
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const reconcile = () => {
|
||||
Modal.confirm({
|
||||
title: '批量对账?',
|
||||
content: '扫描超过 15 分钟仍 pending 的提现单,逐单向微信查实并归一化。',
|
||||
onOk: async () => {
|
||||
try {
|
||||
const { data } = await api.post<{ checked: number; resolved: number }>(
|
||||
'/admin/api/withdraws/reconcile',
|
||||
);
|
||||
message.success(`对账完成:检查 ${data.checked} 单,解决 ${data.resolved} 单`);
|
||||
reload();
|
||||
} catch (e) {
|
||||
message.error(errMsg(e));
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const columns: ColumnsType<WithdrawOrder> = [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{ title: '用户', dataIndex: 'user_id', width: 80 },
|
||||
{ title: '金额', dataIndex: 'amount_cents', render: yuan, width: 100 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (s: string) => <Tag color={STATUS_COLOR[s]}>{s}</Tag>,
|
||||
},
|
||||
{ title: '微信状态', dataIndex: 'wechat_state', render: (v: string | null) => v || '-', width: 130 },
|
||||
{ title: '单号', dataIndex: 'out_bill_no' },
|
||||
{ title: '失败原因', dataIndex: 'fail_reason', render: (v: string | null) => v || '-' },
|
||||
{ title: '时间', dataIndex: 'created_at', render: dt, width: 180 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'op',
|
||||
width: 100,
|
||||
render: (_: unknown, o: WithdrawOrder) =>
|
||||
canManage && o.status === 'pending' ? (
|
||||
<a onClick={() => retry(o)}>重试查单</a>
|
||||
) : (
|
||||
<span style={{ color: '#ccc' }}>-</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>提现管理</h2>
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
placeholder="状态"
|
||||
value={status}
|
||||
onChange={(v) => {
|
||||
setStatus(v);
|
||||
setFilters({ status: v });
|
||||
}}
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'pending', label: 'pending' },
|
||||
{ value: 'success', label: 'success' },
|
||||
{ value: 'failed', label: 'failed' },
|
||||
]}
|
||||
/>
|
||||
{canManage && (
|
||||
<Button danger onClick={reconcile}>
|
||||
批量对账
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
<Table
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
loading={loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||||
<Button onClick={loadMore} disabled={!nextCursor} loading={loading}>
|
||||
{nextCursor ? '加载更多' : '没有更多了'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Providers } from './providers';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: '傻瓜比价 · 运营后台',
|
||||
description: '傻瓜比价 App 运营管理后台',
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body style={{ margin: 0 }}>
|
||||
<Providers>{children}</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Card, Form, Input, message } 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 [loading, setLoading] = useState(false);
|
||||
|
||||
const onFinish = async (values: { username: string; password: string }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await api.post<LoginResponse>('/admin/api/auth/login', values);
|
||||
setAuth(data.access_token, data.admin);
|
||||
message.success('登录成功');
|
||||
router.replace('/dashboard');
|
||||
} catch (e) {
|
||||
message.error(errMsg(e, '登录失败'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#f0f2f5',
|
||||
}}
|
||||
>
|
||||
<Card title="傻瓜比价 · 运营后台" style={{ width: 360 }}>
|
||||
<Form onFinish={onFinish} layout="vertical">
|
||||
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
|
||||
<Input autoFocus autoComplete="username" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }]}>
|
||||
<Input.Password autoComplete="current-password" />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading} block>
|
||||
登录
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { getToken } from '@/lib/auth';
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.replace(getToken() ? '/dashboard' : '/login');
|
||||
}, [router]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import { AntdRegistry } from '@ant-design/nextjs-registry';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<AntdRegistry>
|
||||
{/* wave 禁用:消除 antd 5 波纹在 Next15 dev 下的 React 版本兼容误报(纯噪音,后台不需要波纹) */}
|
||||
<ConfigProvider locale={zhCN} wave={{ disabled: true }}>
|
||||
{children}
|
||||
</ConfigProvider>
|
||||
</AntdRegistry>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// dev 指向本机 8771;生产留空 = 相对路径,由 nginx 同域反代到后端。
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE || '';
|
||||
|
||||
export const api = axios.create({ baseURL: API_BASE });
|
||||
|
||||
// 每个请求自动带 admin token
|
||||
api.interceptors.request.use((config) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const token = localStorage.getItem('admin_token');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// 401 → 清登录态跳登录页(token 过期/被禁)
|
||||
api.interceptors.response.use(
|
||||
(resp) => resp,
|
||||
(error) => {
|
||||
if (error.response?.status === 401 && typeof window !== 'undefined') {
|
||||
localStorage.removeItem('admin_token');
|
||||
localStorage.removeItem('admin_info');
|
||||
if (!window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
},
|
||||
);
|
||||
|
||||
// 统一从 axios error 取后端的 detail 文案
|
||||
export function errMsg(e: unknown, fallback = '操作失败'): string {
|
||||
const detail = (e as { response?: { data?: { detail?: unknown } } })?.response?.data?.detail;
|
||||
return typeof detail === 'string' ? detail : fallback;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { AdminInfo } from './types';
|
||||
|
||||
const TOKEN_KEY = 'admin_token';
|
||||
const ADMIN_KEY = 'admin_info';
|
||||
|
||||
export function setAuth(token: string, admin: AdminInfo): void {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
localStorage.setItem(ADMIN_KEY, JSON.stringify(admin));
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function getAdmin(): AdminInfo | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const s = localStorage.getItem(ADMIN_KEY);
|
||||
return s ? (JSON.parse(s) as AdminInfo) : null;
|
||||
}
|
||||
|
||||
export function clearAuth(): void {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(ADMIN_KEY);
|
||||
}
|
||||
|
||||
export function isSuperAdmin(): boolean {
|
||||
return getAdmin()?.role === 'super_admin';
|
||||
}
|
||||
|
||||
/** 当前 admin 是否有权做某操作(super_admin 恒可;否则角色需在 allowed 内)。与后端 require_role 对齐。 */
|
||||
export function canDo(allowed: string[]): boolean {
|
||||
const role = getAdmin()?.role;
|
||||
return role === 'super_admin' || (role != null && allowed.includes(role));
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
// 与后端 app/admin/schemas/* 对齐的类型定义。
|
||||
|
||||
export interface AdminInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
role: string; // super_admin / finance / operator
|
||||
status: string;
|
||||
created_at: string;
|
||||
last_login_at: string | null;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
admin: AdminInfo;
|
||||
}
|
||||
|
||||
export interface CursorPage<T> {
|
||||
items: T[];
|
||||
next_cursor: number | null;
|
||||
}
|
||||
|
||||
export interface UserListItem {
|
||||
id: number;
|
||||
phone: string;
|
||||
nickname: string | null;
|
||||
register_channel: string;
|
||||
status: string;
|
||||
wechat_openid: string | null;
|
||||
created_at: string;
|
||||
last_login_at: string;
|
||||
}
|
||||
|
||||
export interface UserOverview {
|
||||
user: UserListItem;
|
||||
coin_balance: number;
|
||||
cash_balance_cents: number;
|
||||
total_coin_earned: number;
|
||||
comparison_total: number;
|
||||
comparison_success: number;
|
||||
withdraw_total: number;
|
||||
withdraw_success_cents: number;
|
||||
feedback_total: number;
|
||||
}
|
||||
|
||||
export interface CoinTxn {
|
||||
id: number;
|
||||
user_id: number;
|
||||
amount: number;
|
||||
balance_after: number;
|
||||
biz_type: string;
|
||||
ref_id: string | null;
|
||||
remark: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CashTxn {
|
||||
id: number;
|
||||
user_id: number;
|
||||
amount_cents: number;
|
||||
balance_after_cents: number;
|
||||
biz_type: string;
|
||||
ref_id: string | null;
|
||||
remark: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface WithdrawOrder {
|
||||
id: number;
|
||||
user_id: number;
|
||||
out_bill_no: string;
|
||||
amount_cents: number;
|
||||
status: string; // pending / success / failed
|
||||
wechat_state: string | null;
|
||||
transfer_bill_no: string | null;
|
||||
fail_reason: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Feedback {
|
||||
id: number;
|
||||
user_id: number;
|
||||
content: string;
|
||||
contact: string;
|
||||
images: string[] | null;
|
||||
status: string; // new / handled
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AuditLog {
|
||||
id: number;
|
||||
admin_id: number;
|
||||
admin_username: string;
|
||||
action: string;
|
||||
target_type: string;
|
||||
target_id: string | null;
|
||||
detail: Record<string, unknown> | null;
|
||||
ip: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
users: {
|
||||
total: number;
|
||||
active: number;
|
||||
disabled: number;
|
||||
deleted: number;
|
||||
new_today: number;
|
||||
dau: number;
|
||||
};
|
||||
coins: { granted_total: number };
|
||||
cash: {
|
||||
withdraw_success_cents: number;
|
||||
withdraw_pending_count: number;
|
||||
withdraw_success_count: number;
|
||||
withdraw_failed_count: number;
|
||||
};
|
||||
comparison: { total: number; success: number; success_rate: number };
|
||||
feedback: { new: number };
|
||||
cps: { available: boolean; note: string };
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { api } from './api';
|
||||
import type { CursorPage } from './types';
|
||||
|
||||
/**
|
||||
* 游标分页列表 hook(对接后端 CursorPage)。
|
||||
* filters 变化自动重载第一页;loadMore 追加下一页;reload 重置(写操作后刷新)。
|
||||
*/
|
||||
export function useCursorList<T>(url: string, filters: Record<string, unknown>, limit = 20) {
|
||||
const [items, setItems] = useState<T[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const filtersKey = JSON.stringify(filters);
|
||||
|
||||
const load = useCallback(
|
||||
async (cursor: number | null) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, unknown> = { ...JSON.parse(filtersKey), limit };
|
||||
if (cursor != null) params.cursor = cursor;
|
||||
const { data } = await api.get<CursorPage<T>>(url, { params });
|
||||
setItems((prev) => (cursor == null ? data.items : [...prev, ...data.items]));
|
||||
setNextCursor(data.next_cursor);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[url, filtersKey, limit],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
load(null);
|
||||
}, [load]);
|
||||
|
||||
return {
|
||||
items,
|
||||
nextCursor,
|
||||
loading,
|
||||
loadMore: () => load(nextCursor),
|
||||
reload: () => load(null),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
# 后台一键启动运营后台:确保 PG + 后端(8771) + 前端(3001),日志落 /tmp。
|
||||
# 停止用 ./stop.sh。详见 docs/本地启动指南.md
|
||||
set -e
|
||||
|
||||
SERVER_DIR="$HOME/Desktop/codes/shaguabijia-app-server"
|
||||
WEB_DIR="$HOME/Desktop/codes/shaguabijia-admin-web"
|
||||
|
||||
# 1. 确保本机 PG 在跑(已启动则 no-op)
|
||||
brew services start postgresql@16 >/dev/null 2>&1 || true
|
||||
|
||||
# 2. 后端(conda 环境 price)
|
||||
cd "$SERVER_DIR"
|
||||
# shellcheck disable=SC1091
|
||||
source "$HOME/miniconda3/etc/profile.d/conda.sh"
|
||||
conda activate price
|
||||
nohup uvicorn app.admin.main:admin_app --port 8771 --reload > /tmp/admin-api.log 2>&1 &
|
||||
echo "✅ 后端 8771 已起 (日志: tail -f /tmp/admin-api.log)"
|
||||
|
||||
# 3. 前端
|
||||
cd "$WEB_DIR"
|
||||
nohup npm run dev > /tmp/admin-web.log 2>&1 &
|
||||
echo "✅ 前端 3001 已起 (日志: tail -f /tmp/admin-web.log)"
|
||||
|
||||
echo ""
|
||||
echo "⏳ 等几秒编译,然后开 http://localhost:3001 (admin / admin12345)"
|
||||
echo " 停止: ./stop.sh"
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# 停掉运营后台的 后端(8771) + 前端(3001)。PG 不停(开机自启,别的服务也可能用)。
|
||||
# 详见 docs/本地启动指南.md
|
||||
|
||||
if lsof -ti:8771 >/dev/null 2>&1; then
|
||||
lsof -ti:8771 | xargs kill && echo "✅ 后端(8771)已停"
|
||||
else
|
||||
echo "— 后端(8771)未在跑"
|
||||
fi
|
||||
|
||||
if lsof -ti:3001 >/dev/null 2>&1; then
|
||||
lsof -ti:3001 | xargs kill && echo "✅ 前端(3001)已停"
|
||||
else
|
||||
echo "— 前端(3001)未在跑"
|
||||
fi
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user