傻瓜比价运营后台前端 (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:
2026-06-04 21:05:29 +08:00
commit 125b0e1339
29 changed files with 4177 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# 生产:前端与 admin API 同域(admin.shaguabijia.com),nginx 把 /admin/api 反代到后端 8771。
# 留空 = axios 用相对路径,请求落到当前域名,由 nginx 转发。
NEXT_PUBLIC_API_BASE=
+8
View File
@@ -0,0 +1,8 @@
node_modules
.next
out
.env*.local
*.tsbuildinfo
next-env.d.ts
.DS_Store
data/
+48
View File
@@ -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 已建、能登录
+45
View File
@@ -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";
}
}
+97
View File
@@ -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)。
+15
View File
@@ -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',
},
],
};
+6
View File
@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
};
module.exports = nextConfig;
+2495
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -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"
}
}
+140
View File
@@ -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>
);
}
+72
View File
@@ -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>
);
}
+146
View File
@@ -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>
);
}
+58
View File
@@ -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>
);
}
+84
View File
@@ -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>
);
}
+109
View File
@@ -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>
);
}
+143
View File
@@ -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>
);
}
+155
View File
@@ -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>
);
}
+125
View File
@@ -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>
);
}
+17
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+13
View File
@@ -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;
}
+16
View File
@@ -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>
);
}
+36
View File
@@ -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;
}
+35
View File
@@ -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));
}
+123
View File
@@ -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 };
}
+45
View File
@@ -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),
};
}
Executable
+27
View File
@@ -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"
Executable
+15
View File
@@ -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
+21
View File
@@ -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"]
}