Compare commits
5 Commits
08f2315567
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b3fdce1d3 | ||
|
|
8e5ae8c7f1 | ||
|
|
e105b573da | ||
|
|
80e5d843ba | ||
|
|
492276fe46 |
209
AI_AUDIT_API.md
Normal file
209
AI_AUDIT_API.md
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
# AI 审核 API 文档
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
AI 审核模块提供自动内容审核功能,支持文章、评论、特色服务的自动审核。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
- ✅ 敏感词检测
|
||||||
|
- ✅ 广告内容检测
|
||||||
|
- ✅ 内容质量评估
|
||||||
|
- ✅ 自动审核决策
|
||||||
|
|
||||||
|
## API 端点
|
||||||
|
|
||||||
|
### 1. 审核文章
|
||||||
|
|
||||||
|
**端点**: `POST /api/audit/article/`
|
||||||
|
|
||||||
|
**认证**: 需要 JWT Token
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "文章标题",
|
||||||
|
"content": "文章内容"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"approved": true,
|
||||||
|
"reason": "审核通过",
|
||||||
|
"details": {
|
||||||
|
"quality_score": 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**拒绝示例**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"approved": false,
|
||||||
|
"reason": "内容包含敏感词:暴力",
|
||||||
|
"details": {
|
||||||
|
"sensitive_words": ["暴力"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 审核评论
|
||||||
|
|
||||||
|
**端点**: `POST /api/audit/comment/`
|
||||||
|
|
||||||
|
**认证**: 需要 JWT Token
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "评论内容"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"approved": true,
|
||||||
|
"reason": "审核通过"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. 审核特色服务
|
||||||
|
|
||||||
|
**端点**: `POST /api/audit/service/`
|
||||||
|
|
||||||
|
**认证**: 需要 JWT Token
|
||||||
|
|
||||||
|
**请求体**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "服务名称",
|
||||||
|
"description": "服务描述"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"approved": true,
|
||||||
|
"reason": "审核通过"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. 审核服务状态
|
||||||
|
|
||||||
|
**端点**: `GET /api/audit/status/`
|
||||||
|
|
||||||
|
**认证**: 需要 JWT Token
|
||||||
|
|
||||||
|
**响应**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "active",
|
||||||
|
"service": "AI Audit Service",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"features": [
|
||||||
|
"敏感词检测",
|
||||||
|
"广告检测",
|
||||||
|
"内容质量评估"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试用例
|
||||||
|
|
||||||
|
| 测试 | 输入 | 预期结果 | 状态 |
|
||||||
|
|------|------|----------|------|
|
||||||
|
| 文章审核 (正常) | 北京旅游攻略 | ✅ 通过 | ✅ |
|
||||||
|
| 文章审核 (敏感词) | 包含暴力内容 | ❌ 拒绝 | ✅ |
|
||||||
|
| 评论审核 (广告) | 加微信 123456 | ❌ 拒绝 | ✅ |
|
||||||
|
| 服务审核 (正常) | 老北京烤鸭店 | ✅ 通过 | ✅ |
|
||||||
|
| 内容质量 (太短) | 好 | ❌ 拒绝 | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 敏感词库
|
||||||
|
|
||||||
|
当前敏感词库包含:
|
||||||
|
- 暴力、恐怖、色情、赌博、毒品
|
||||||
|
- 诈骗、传销、假币、枪支、弹药
|
||||||
|
|
||||||
|
## 广告关键词
|
||||||
|
|
||||||
|
- 加微信、QQ 群、联系电话、手机号
|
||||||
|
- www.、.com、.cn、http
|
||||||
|
|
||||||
|
## 内容质量规则
|
||||||
|
|
||||||
|
- 最小长度:10 个字符
|
||||||
|
- 重复字符检测
|
||||||
|
- 中文内容比例检查
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 集成示例
|
||||||
|
|
||||||
|
### Python 示例
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
|
||||||
|
TOKEN = 'your_jwt_token'
|
||||||
|
HEADERS = {
|
||||||
|
'Authorization': f'Bearer {TOKEN}',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
# 审核文章
|
||||||
|
response = requests.post(
|
||||||
|
'http://cssc.datalibstar.com/api/audit/article/',
|
||||||
|
headers=HEADERS,
|
||||||
|
json={
|
||||||
|
'title': '北京旅游攻略',
|
||||||
|
'content': '北京是中国的首都...'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = response.json()
|
||||||
|
print(result['approved']) # True/False
|
||||||
|
```
|
||||||
|
|
||||||
|
### JavaScript 示例
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const TOKEN = 'your_jwt_token';
|
||||||
|
|
||||||
|
// 审核文章
|
||||||
|
fetch('http://cssc.datalibstar.com/api/audit/article/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${TOKEN}`,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: '北京旅游攻略',
|
||||||
|
content: '北京是中国的首都...'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
console.log(data.approved); // True/False
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 部署状态
|
||||||
|
|
||||||
|
- ✅ 本地开发环境
|
||||||
|
- ✅ 云服务器 (cssc.datalibstar.com)
|
||||||
|
- ✅ 所有测试用例通过
|
||||||
218
CLI_TEST_REPORT.md
Normal file
218
CLI_TEST_REPORT.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# CLI 工具测试报告
|
||||||
|
|
||||||
|
## 测试信息
|
||||||
|
|
||||||
|
- **测试日期**: 2026-04-14
|
||||||
|
- **测试环境**: 云服务器 (cssc.datalibstar.com)
|
||||||
|
- **测试版本**: CLI v1.0.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试结果汇总
|
||||||
|
|
||||||
|
| 测试类别 | 测试用例数 | 通过数 | 失败数 | 通过率 |
|
||||||
|
|----------|-----------|--------|--------|--------|
|
||||||
|
| 帮助命令 | 1 | 1 | 0 | 100% |
|
||||||
|
| 省份命令 | 1 | 1 | 0 | 100% |
|
||||||
|
| AI 审核命令 | 5 | 5 | 0 | 100% |
|
||||||
|
| **总计** | **7** | **7** | **0** | **100%** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 详细测试结果
|
||||||
|
|
||||||
|
### ✅ 测试 1: 帮助信息
|
||||||
|
|
||||||
|
**命令:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py help
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期:** 显示帮助信息
|
||||||
|
|
||||||
|
**实际:**
|
||||||
|
```
|
||||||
|
城市手册 - 命令行接口
|
||||||
|
|
||||||
|
用法:python cli.py <命令> [参数]
|
||||||
|
|
||||||
|
认证命令:
|
||||||
|
login <用户名> <密码> 登录获取 Token
|
||||||
|
|
||||||
|
省份命令:
|
||||||
|
provinces 获取所有省份
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
**结果:** ✅ 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ 测试 2: 获取省份列表
|
||||||
|
|
||||||
|
**命令:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py provinces
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期:** 返回 34 个省份
|
||||||
|
|
||||||
|
**实际:**
|
||||||
|
```
|
||||||
|
✅ 共 34 个省份:
|
||||||
|
1. 上海市 (ID: 3)
|
||||||
|
2. 云南省 (ID: 23)
|
||||||
|
3. 内蒙古自治区 (ID: 28)
|
||||||
|
...
|
||||||
|
34. 黑龙江省 (ID: 9)
|
||||||
|
```
|
||||||
|
|
||||||
|
**结果:** ✅ 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ 测试 3: AI 审核服务状态
|
||||||
|
|
||||||
|
**命令:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py audit status
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期:** 返回服务状态 active
|
||||||
|
|
||||||
|
**实际:**
|
||||||
|
```
|
||||||
|
✅ AI 审核服务状态:active
|
||||||
|
版本:1.0.0
|
||||||
|
功能:敏感词检测, 广告检测, 内容质量评估
|
||||||
|
```
|
||||||
|
|
||||||
|
**结果:** ✅ 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ 测试 4: AI 审核文章 (正常内容)
|
||||||
|
|
||||||
|
**命令:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py audit article '北京旅游攻略' '北京是中国的首都,有很多著名景点'
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期:** 审核通过
|
||||||
|
|
||||||
|
**实际:**
|
||||||
|
```
|
||||||
|
AI 审核结果:✅ 通过
|
||||||
|
原因:审核通过
|
||||||
|
详情:{
|
||||||
|
"quality_score": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**结果:** ✅ 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ 测试 5: AI 审核文章 (敏感词)
|
||||||
|
|
||||||
|
**命令:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py audit article '测试' '这是一个包含暴力内容的文章'
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期:** 审核拒绝,检测到敏感词
|
||||||
|
|
||||||
|
**实际:**
|
||||||
|
```
|
||||||
|
AI 审核结果:❌ 拒绝
|
||||||
|
原因:内容包含敏感词:暴力
|
||||||
|
详情:{
|
||||||
|
"sensitive_words": [
|
||||||
|
"暴力"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**结果:** ✅ 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ 测试 6: AI 审核评论 (广告)
|
||||||
|
|
||||||
|
**命令:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py audit comment '加微信 123456 了解更多'
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期:** 审核拒绝,检测到广告
|
||||||
|
|
||||||
|
**实际:**
|
||||||
|
```
|
||||||
|
AI 审核结果:❌ 拒绝
|
||||||
|
原因:疑似广告:加微信
|
||||||
|
```
|
||||||
|
|
||||||
|
**结果:** ✅ 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ✅ 测试 7: AI 审核服务 (正常)
|
||||||
|
|
||||||
|
**命令:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py audit service '老北京烤鸭' '正宗北京烤鸭,皮脆肉嫩'
|
||||||
|
```
|
||||||
|
|
||||||
|
**预期:** 审核通过
|
||||||
|
|
||||||
|
**实际:**
|
||||||
|
```
|
||||||
|
AI 审核结果:✅ 通过
|
||||||
|
原因:审核通过
|
||||||
|
```
|
||||||
|
|
||||||
|
**结果:** ✅ 通过
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 系统状态
|
||||||
|
|
||||||
|
### 容器状态
|
||||||
|
```
|
||||||
|
NAME STATUS
|
||||||
|
django_backend Up
|
||||||
|
postgres_db Up
|
||||||
|
react_frontend Up
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库状态
|
||||||
|
- 省份数量:34 ✅
|
||||||
|
- 用户数量:1 ✅
|
||||||
|
|
||||||
|
### API 状态
|
||||||
|
- 省份 API: ✅ 正常
|
||||||
|
- 用户 API: ✅ 正常
|
||||||
|
- AI 审核 API: ✅ 正常
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 结论
|
||||||
|
|
||||||
|
✅ **所有测试通过**
|
||||||
|
|
||||||
|
CLI 工具功能完整,可以正常操作:
|
||||||
|
- ✅ 省份查询
|
||||||
|
- ✅ AI 审核文章
|
||||||
|
- ✅ AI 审核评论
|
||||||
|
- ✅ AI 审核服务
|
||||||
|
- ✅ 服务状态查询
|
||||||
|
|
||||||
|
系统运行正常,可以通过命令行进行所有核心操作。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 测试人员
|
||||||
|
|
||||||
|
- **测试者**: AI Assistant
|
||||||
|
- **审核者**: 北极星
|
||||||
|
- **测试时间**: 2026-04-14 11:06 UTC
|
||||||
335
CLI_USAGE.md
Normal file
335
CLI_USAGE.md
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
# 城市手册 - 命令行接口使用指南
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
城市手册提供完整的命令行接口 (CLI),可以通过命令行操作所有核心功能。
|
||||||
|
|
||||||
|
## 运行方式
|
||||||
|
|
||||||
|
### 在服务器上运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/ubuntu/city-manual
|
||||||
|
docker compose exec -T backend python /app/cli.py <命令> [参数]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 本地运行(需要访问服务器数据库)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/project
|
||||||
|
python cli.py <命令> [参数]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 命令列表
|
||||||
|
|
||||||
|
### 认证命令
|
||||||
|
|
||||||
|
#### login - 登录获取 Token
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py login <用户名> <密码>
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py login admin Admin123!
|
||||||
|
```
|
||||||
|
|
||||||
|
**输出:**
|
||||||
|
```
|
||||||
|
✅ 登录成功
|
||||||
|
Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 省份命令
|
||||||
|
|
||||||
|
#### provinces - 获取所有省份
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py provinces
|
||||||
|
```
|
||||||
|
|
||||||
|
**输出:**
|
||||||
|
```
|
||||||
|
✅ 共 34 个省份:
|
||||||
|
1. 北京市 (ID: 1)
|
||||||
|
2. 天津市 (ID: 2)
|
||||||
|
3. 上海市 (ID: 3)
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### region - 获取省份详情
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py region <省份 ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py region 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 文章命令
|
||||||
|
|
||||||
|
#### article list - 获取文章列表
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py article list [数量]
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py article list 10
|
||||||
|
```
|
||||||
|
|
||||||
|
#### article create - 创建文章
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py article create <标题> <内容> <省份 ID> [类型]
|
||||||
|
```
|
||||||
|
|
||||||
|
**类型选项:** `basic`, `history`, `culture`, `practical`, `life`
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py article create "北京攻略" "北京是中国的首都..." 1 basic
|
||||||
|
```
|
||||||
|
|
||||||
|
#### article submit - 提交文章审核
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py article submit <文章 ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py article submit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 服务命令
|
||||||
|
|
||||||
|
#### service list - 获取服务列表
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py service list [数量]
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py service list 10
|
||||||
|
```
|
||||||
|
|
||||||
|
#### service create - 创建特色服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py service create <名称> <描述> <省份 ID> [分类]
|
||||||
|
```
|
||||||
|
|
||||||
|
**分类选项:** `clothing`, `food`, `accommodation`, `transport`, `entertainment`, `tourism`, `culture`
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py service create "老北京烤鸭" "正宗北京烤鸭" 1 food
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### AI 审核命令 🔥
|
||||||
|
|
||||||
|
#### audit status - AI 审核服务状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py audit status
|
||||||
|
```
|
||||||
|
|
||||||
|
**输出:**
|
||||||
|
```
|
||||||
|
✅ AI 审核服务状态:active
|
||||||
|
版本:1.0.0
|
||||||
|
功能:敏感词检测, 广告检测, 内容质量评估
|
||||||
|
```
|
||||||
|
|
||||||
|
#### audit article - AI 审核文章
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py audit article <标题> <内容>
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例 1 (正常内容):**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py audit article "北京攻略" "北京是中国的首都,有很多著名景点"
|
||||||
|
```
|
||||||
|
|
||||||
|
**输出:**
|
||||||
|
```
|
||||||
|
AI 审核结果:✅ 通过
|
||||||
|
原因:审核通过
|
||||||
|
详情:{
|
||||||
|
"quality_score": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例 2 (敏感词):**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py audit article "测试" "这是一个包含暴力内容的文章"
|
||||||
|
```
|
||||||
|
|
||||||
|
**输出:**
|
||||||
|
```
|
||||||
|
AI 审核结果:❌ 拒绝
|
||||||
|
原因:内容包含敏感词:暴力
|
||||||
|
详情:{
|
||||||
|
"sensitive_words": [
|
||||||
|
"暴力"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### audit comment - AI 审核评论
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py audit comment <内容>
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例 1 (正常):**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py audit comment "写得很好!"
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例 2 (广告):**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py audit comment "加微信 123456 了解更多"
|
||||||
|
```
|
||||||
|
|
||||||
|
**输出:**
|
||||||
|
```
|
||||||
|
AI 审核结果:❌ 拒绝
|
||||||
|
原因:疑似广告:加微信
|
||||||
|
```
|
||||||
|
|
||||||
|
#### audit service - AI 审核服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py audit service <名称> <描述>
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend python /app/cli.py audit service "老北京烤鸭" "正宗北京烤鸭,皮脆肉嫩"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速测试脚本
|
||||||
|
|
||||||
|
### 测试所有 AI 审核功能
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/ubuntu/city-manual
|
||||||
|
|
||||||
|
echo '=== AI 审核测试套件 ==='
|
||||||
|
|
||||||
|
echo '1. 测试正常文章'
|
||||||
|
docker compose exec -T backend python /app/cli.py audit article '北京攻略' '北京是中国的首都'
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '2. 测试敏感词文章'
|
||||||
|
docker compose exec -T backend python /app/cli.py audit article '测试' '包含暴力内容'
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '3. 测试广告评论'
|
||||||
|
docker compose exec -T backend python /app/cli.py audit comment '加微信 123456'
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '4. 测试正常服务'
|
||||||
|
docker compose exec -T backend python /app/cli.py audit service '烤鸭店' '正宗北京烤鸭'
|
||||||
|
|
||||||
|
echo ''
|
||||||
|
echo '=== 测试完成 ==='
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 完整工作流程示例
|
||||||
|
|
||||||
|
### 1. 创建并审核文章
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 查看省份列表
|
||||||
|
docker compose exec -T backend python /app/cli.py provinces
|
||||||
|
|
||||||
|
# 创建文章(使用北京市 ID=1)
|
||||||
|
docker compose exec -T backend python /app/cli.py article create "北京旅游攻略" "北京是中国的首都..." 1
|
||||||
|
|
||||||
|
# AI 预审
|
||||||
|
docker compose exec -T backend python /app/cli.py audit article "北京旅游攻略" "北京是中国的首都..."
|
||||||
|
|
||||||
|
# 提交审核
|
||||||
|
docker compose exec -T backend python /app/cli.py article submit 1
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 创建并审核服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 创建服务
|
||||||
|
docker compose exec -T backend python /app/cli.py service create "老北京烤鸭" "正宗北京烤鸭" 1 food
|
||||||
|
|
||||||
|
# AI 预审
|
||||||
|
docker compose exec -T backend python /app/cli.py audit service "老北京烤鸭" "正宗北京烤鸭"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 错误处理
|
||||||
|
|
||||||
|
### 常见错误
|
||||||
|
|
||||||
|
1. **认证失败**
|
||||||
|
```
|
||||||
|
❌ 错误:Authentication credentials were not provided.
|
||||||
|
```
|
||||||
|
解决:确保使用正确的用户名密码登录
|
||||||
|
|
||||||
|
2. **网络错误**
|
||||||
|
```
|
||||||
|
❌ 错误:network - <urlopen error...>
|
||||||
|
```
|
||||||
|
解决:检查 Docker 容器是否正常运行
|
||||||
|
|
||||||
|
3. **内容被拒绝**
|
||||||
|
```
|
||||||
|
AI 审核结果:❌ 拒绝
|
||||||
|
原因:内容包含敏感词:暴力
|
||||||
|
```
|
||||||
|
解决:修改内容,移除敏感词
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 系统状态检查
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查容器状态
|
||||||
|
docker compose ps
|
||||||
|
|
||||||
|
# 检查数据库
|
||||||
|
docker compose exec -T backend python /app/cli.py provinces
|
||||||
|
|
||||||
|
# 检查 AI 审核服务
|
||||||
|
docker compose exec -T backend python /app/cli.py audit status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 文档版本
|
||||||
|
|
||||||
|
- **版本**: 1.0.0
|
||||||
|
- **更新日期**: 2026-04-14
|
||||||
|
- **测试状态**: ✅ 所有命令测试通过
|
||||||
122
FEATURES.md
Normal file
122
FEATURES.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# 城市手册项目 - 功能清单
|
||||||
|
|
||||||
|
## ✅ 已完成功能
|
||||||
|
|
||||||
|
### 1. 基础框架
|
||||||
|
- [x] Django 4.2 后端框架
|
||||||
|
- [x] React 18 前端框架
|
||||||
|
- [x] PostgreSQL 数据库
|
||||||
|
- [x] Docker + Docker Compose 部署
|
||||||
|
- [x] Nginx 反向代理
|
||||||
|
- [x] JWT 认证系统
|
||||||
|
|
||||||
|
### 2. 用户系统
|
||||||
|
- [x] 用户注册/登录
|
||||||
|
- [x] JWT Token 认证
|
||||||
|
- [x] 个人中心
|
||||||
|
- [x] 用户角色 (普通用户/版主/AI 审核员/管理员)
|
||||||
|
|
||||||
|
### 3. 版块管理
|
||||||
|
- [x] 5 级行政区划 (省→市→县→乡镇→村)
|
||||||
|
- [x] 34 个省级行政区数据
|
||||||
|
- [x] 树形结构查询
|
||||||
|
- [x] 版块层级导航
|
||||||
|
|
||||||
|
### 4. 地图导航
|
||||||
|
- [x] 中国地图组件 (react-simple-maps)
|
||||||
|
- [x] 省份点击跳转
|
||||||
|
- [x] 悬停提示
|
||||||
|
- [x] 热力图显示
|
||||||
|
|
||||||
|
### 5. 内容管理
|
||||||
|
- [x] 文章 CRUD
|
||||||
|
- [x] 特色服务 CRUD (7 大分类)
|
||||||
|
- [x] 内容审核流程 (版主 + AI)
|
||||||
|
- [x] 发布状态管理
|
||||||
|
|
||||||
|
### 6. 交互功能
|
||||||
|
- [x] 评论系统
|
||||||
|
- [x] 评分系统 (1-5 星)
|
||||||
|
- [x] 点赞功能
|
||||||
|
- [x] 收藏功能
|
||||||
|
|
||||||
|
### 7. 版主系统
|
||||||
|
- [x] 版主申请
|
||||||
|
- [x] 军衔体系 (将军/校官/尉官/士兵)
|
||||||
|
- [x] 权限管理
|
||||||
|
- [x] 支持人数统计
|
||||||
|
|
||||||
|
### 8. AI 审核 🔥
|
||||||
|
- [x] 敏感词检测
|
||||||
|
- [x] 广告内容检测
|
||||||
|
- [x] 内容质量评估
|
||||||
|
- [x] 文章审核 API
|
||||||
|
- [x] 评论审核 API
|
||||||
|
- [x] 服务审核 API
|
||||||
|
- [x] 所有测试用例通过 ✅
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 进行中功能
|
||||||
|
|
||||||
|
| 功能 | 优先级 | 进度 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 搜索功能 | 中 | 0% |
|
||||||
|
| Django Admin 自定义 | 中 | 0% |
|
||||||
|
| 图片上传 | 中 | 0% |
|
||||||
|
| 分享功能 | 低 | 0% |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 待开发功能
|
||||||
|
|
||||||
|
- [ ] 数据抓取工具
|
||||||
|
- [ ] 商家入驻功能
|
||||||
|
- [ ] 多语言支持
|
||||||
|
- [ ] 移动 App
|
||||||
|
- [ ] 高级统计分析
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 项目统计
|
||||||
|
|
||||||
|
| 指标 | 数量 |
|
||||||
|
|------|------|
|
||||||
|
| Django Apps | 7 个 |
|
||||||
|
| 数据库模型 | 12 个 |
|
||||||
|
| API 端点 | 50+ |
|
||||||
|
| 前端页面 | 10+ |
|
||||||
|
| 代码行数 | 5000+ |
|
||||||
|
| Git 提交 | 10+ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 访问地址
|
||||||
|
|
||||||
|
- **网站**: http://cssc.datalibstar.com
|
||||||
|
- **Admin**: http://cssc.datalibstar.com/admin/
|
||||||
|
- **API**: http://cssc.datalibstar.com/api/
|
||||||
|
- **GraphQL**: http://cssc.datalibstar.com/graphql/
|
||||||
|
|
||||||
|
**管理员账号**: `admin` / `Admin123!`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 开发日志
|
||||||
|
|
||||||
|
### 2026-04-14
|
||||||
|
- ✅ 修复 nginx 静态资源配置
|
||||||
|
- ✅ 部署到云服务器
|
||||||
|
- ✅ 实现 AI 审核模块
|
||||||
|
- ✅ 所有 AI 审核测试通过
|
||||||
|
|
||||||
|
### 2026-04-13
|
||||||
|
- ✅ 添加中国地图导航
|
||||||
|
- ✅ 导入 34 个省份数据
|
||||||
|
- ✅ 修复前端构建问题
|
||||||
|
|
||||||
|
### 2026-04-10
|
||||||
|
- ✅ 完成基础框架搭建
|
||||||
|
- ✅ 实现所有数据库模型
|
||||||
|
- ✅ 实现所有 API 端点
|
||||||
|
- ✅ 实现前端核心页面
|
||||||
0
authentication/__init__.py
Normal file
0
authentication/__init__.py
Normal file
3
authentication/admin.py
Normal file
3
authentication/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
6
authentication/apps.py
Normal file
6
authentication/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'authentication'
|
||||||
0
authentication/migrations/__init__.py
Normal file
0
authentication/migrations/__init__.py
Normal file
3
authentication/models.py
Normal file
3
authentication/models.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
# Create your models here.
|
||||||
3
authentication/tests.py
Normal file
3
authentication/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
3
authentication/views.py
Normal file
3
authentication/views.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.shortcuts import render
|
||||||
|
|
||||||
|
# Create your views here.
|
||||||
254
backend/apps/core/ai_audit.py
Normal file
254
backend/apps/core/ai_audit.py
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
"""
|
||||||
|
AI 审核模块 - 自动审核内容
|
||||||
|
|
||||||
|
提供敏感词检测、内容质量评估等功能
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
class AIAuditService:
|
||||||
|
"""AI 审核服务类"""
|
||||||
|
|
||||||
|
# 敏感词库(示例,实际应该从数据库或配置文件加载)
|
||||||
|
SENSITIVE_WORDS = [
|
||||||
|
'暴力', '恐怖', '色情', '赌博', '毒品',
|
||||||
|
'诈骗', '传销', '假币', '枪支', '弹药',
|
||||||
|
]
|
||||||
|
|
||||||
|
# 广告关键词
|
||||||
|
AD_KEYWORDS = [
|
||||||
|
'加微信', 'QQ 群', '联系电话', '手机号',
|
||||||
|
'www.', '.com', '.cn', 'http',
|
||||||
|
]
|
||||||
|
|
||||||
|
# 最小内容长度
|
||||||
|
MIN_CONTENT_LENGTH = 10
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def check_sensitive_words(cls, text: str) -> Tuple[bool, List[str]]:
|
||||||
|
"""
|
||||||
|
检查敏感词
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 待检查文本
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(是否包含敏感词,敏感词列表)
|
||||||
|
"""
|
||||||
|
found_words = []
|
||||||
|
for word in cls.SENSITIVE_WORDS:
|
||||||
|
if word in text:
|
||||||
|
found_words.append(word)
|
||||||
|
|
||||||
|
return len(found_words) > 0, found_words
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def check_advertisement(cls, text: str) -> Tuple[bool, List[str]]:
|
||||||
|
"""
|
||||||
|
检查广告内容
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 待检查文本
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(是否包含广告,广告关键词列表)
|
||||||
|
"""
|
||||||
|
found_keywords = []
|
||||||
|
for keyword in cls.AD_KEYWORDS:
|
||||||
|
if keyword in text:
|
||||||
|
found_keywords.append(keyword)
|
||||||
|
|
||||||
|
return len(found_keywords) > 0, found_keywords
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def check_content_quality(cls, text: str) -> Dict:
|
||||||
|
"""
|
||||||
|
检查内容质量
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 待检查文本
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
质量评估结果
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
'is_valid': True,
|
||||||
|
'issues': [],
|
||||||
|
'score': 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查长度
|
||||||
|
if len(text) < cls.MIN_CONTENT_LENGTH:
|
||||||
|
result['is_valid'] = False
|
||||||
|
result['issues'].append(f'内容太短,最少需要{cls.MIN_CONTENT_LENGTH}个字符')
|
||||||
|
result['score'] -= 50
|
||||||
|
|
||||||
|
# 检查重复字符(刷屏检测)
|
||||||
|
if len(set(text)) < len(text) * 0.3:
|
||||||
|
result['is_valid'] = False
|
||||||
|
result['issues'].append('内容包含大量重复字符')
|
||||||
|
result['score'] -= 30
|
||||||
|
|
||||||
|
# 检查全角字符比例
|
||||||
|
chinese_chars = len(re.findall(r'[\u4e00-\u9fa5]', text))
|
||||||
|
if chinese_chars / max(len(text), 1) < 0.1:
|
||||||
|
result['issues'].append('中文内容比例较低')
|
||||||
|
result['score'] -= 10
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def audit_article(cls, title: str, content: str) -> Dict:
|
||||||
|
"""
|
||||||
|
审核文章
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: 文章标题
|
||||||
|
content: 文章内容
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
审核结果
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
'approved': True,
|
||||||
|
'reason': '',
|
||||||
|
'details': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查标题
|
||||||
|
sensitive, words = cls.check_sensitive_words(title)
|
||||||
|
if sensitive:
|
||||||
|
result['approved'] = False
|
||||||
|
result['reason'] = f'标题包含敏感词:{", ".join(words)}'
|
||||||
|
result['details']['sensitive_words'] = words
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 检查内容
|
||||||
|
sensitive, words = cls.check_sensitive_words(content)
|
||||||
|
if sensitive:
|
||||||
|
result['approved'] = False
|
||||||
|
result['reason'] = f'内容包含敏感词:{", ".join(words)}'
|
||||||
|
result['details']['sensitive_words'] = words
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 检查广告
|
||||||
|
is_ad, keywords = cls.check_advertisement(content)
|
||||||
|
if is_ad:
|
||||||
|
result['approved'] = False
|
||||||
|
result['reason'] = f'内容疑似广告:{", ".join(keywords)}'
|
||||||
|
result['details']['ad_keywords'] = keywords
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 检查内容质量
|
||||||
|
quality = cls.check_content_quality(content)
|
||||||
|
if not quality['is_valid']:
|
||||||
|
result['approved'] = False
|
||||||
|
result['reason'] = f'内容质量不达标:{", ".join(quality["issues"])}'
|
||||||
|
result['details']['quality'] = quality
|
||||||
|
return result
|
||||||
|
|
||||||
|
result['reason'] = '审核通过'
|
||||||
|
result['details']['quality_score'] = quality['score']
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def audit_comment(cls, content: str) -> Dict:
|
||||||
|
"""
|
||||||
|
审核评论
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: 评论内容
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
审核结果
|
||||||
|
"""
|
||||||
|
result = {
|
||||||
|
'approved': True,
|
||||||
|
'reason': '',
|
||||||
|
'details': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查敏感词
|
||||||
|
sensitive, words = cls.check_sensitive_words(content)
|
||||||
|
if sensitive:
|
||||||
|
result['approved'] = False
|
||||||
|
result['reason'] = f'包含敏感词:{", ".join(words)}'
|
||||||
|
result['details']['sensitive_words'] = words
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 检查广告
|
||||||
|
is_ad, keywords = cls.check_advertisement(content)
|
||||||
|
if is_ad:
|
||||||
|
result['approved'] = False
|
||||||
|
result['reason'] = f'疑似广告:{", ".join(keywords)}'
|
||||||
|
result['details']['ad_keywords'] = keywords
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 检查内容质量
|
||||||
|
quality = cls.check_content_quality(content)
|
||||||
|
if not quality['is_valid']:
|
||||||
|
result['approved'] = False
|
||||||
|
result['reason'] = f'内容质量不达标:{", ".join(quality["issues"])}'
|
||||||
|
result['details']['quality'] = quality
|
||||||
|
return result
|
||||||
|
|
||||||
|
result['reason'] = '审核通过'
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def audit_service(cls, name: str, description: str) -> Dict:
|
||||||
|
"""
|
||||||
|
审核特色服务
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: 服务名称
|
||||||
|
description: 服务描述
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
审核结果
|
||||||
|
"""
|
||||||
|
# 合并名称和描述进行检查
|
||||||
|
full_text = f"{name} {description}"
|
||||||
|
|
||||||
|
result = {
|
||||||
|
'approved': True,
|
||||||
|
'reason': '',
|
||||||
|
'details': {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 检查敏感词
|
||||||
|
sensitive, words = cls.check_sensitive_words(full_text)
|
||||||
|
if sensitive:
|
||||||
|
result['approved'] = False
|
||||||
|
result['reason'] = f'包含敏感词:{", ".join(words)}'
|
||||||
|
result['details']['sensitive_words'] = words
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 检查广告(服务本身可以包含联系方式,这里放宽检查)
|
||||||
|
# 只检查明显的垃圾广告
|
||||||
|
spam_keywords = ['加微信', 'QQ 群', '点击链接']
|
||||||
|
found_spam = [kw for kw in spam_keywords if kw in full_text]
|
||||||
|
if found_spam:
|
||||||
|
result['approved'] = False
|
||||||
|
result['reason'] = f'包含垃圾广告内容:{", ".join(found_spam)}'
|
||||||
|
result['details']['spam_keywords'] = found_spam
|
||||||
|
return result
|
||||||
|
|
||||||
|
# 检查内容质量
|
||||||
|
quality = cls.check_content_quality(description)
|
||||||
|
if not quality['is_valid']:
|
||||||
|
result['approved'] = False
|
||||||
|
result['reason'] = f'描述质量不达标:{", ".join(quality["issues"])}'
|
||||||
|
result['details']['quality'] = quality
|
||||||
|
return result
|
||||||
|
|
||||||
|
result['reason'] = '审核通过'
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# 单例实例
|
||||||
|
ai_audit_service = AIAuditService()
|
||||||
17
backend/apps/core/urls.py
Normal file
17
backend/apps/core/urls.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"""
|
||||||
|
AI 审核 API URL 配置
|
||||||
|
"""
|
||||||
|
from django.urls import path
|
||||||
|
from .views import (
|
||||||
|
audit_article,
|
||||||
|
audit_comment,
|
||||||
|
audit_service,
|
||||||
|
audit_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('audit/article/', audit_article, name='audit-article'),
|
||||||
|
path('audit/comment/', audit_comment, name='audit-comment'),
|
||||||
|
path('audit/service/', audit_service, name='audit-service'),
|
||||||
|
path('audit/status/', audit_status, name='audit-status'),
|
||||||
|
]
|
||||||
124
backend/apps/core/views.py
Normal file
124
backend/apps/core/views.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
AI 审核 API 视图
|
||||||
|
"""
|
||||||
|
from rest_framework import viewsets, permissions, status
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.permissions import IsAuthenticated, IsAdminUser
|
||||||
|
|
||||||
|
from .ai_audit import AIAuditService
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def audit_article(request):
|
||||||
|
"""
|
||||||
|
审核文章
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
{
|
||||||
|
"title": "文章标题",
|
||||||
|
"content": "文章内容"
|
||||||
|
}
|
||||||
|
|
||||||
|
返回:
|
||||||
|
{
|
||||||
|
"approved": true/false,
|
||||||
|
"reason": "审核结果说明",
|
||||||
|
"details": {...}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
title = request.data.get('title', '')
|
||||||
|
content = request.data.get('content', '')
|
||||||
|
|
||||||
|
if not title or not content:
|
||||||
|
return Response(
|
||||||
|
{'error': '标题和内容不能为空'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
result = AIAuditService.audit_article(title, content)
|
||||||
|
|
||||||
|
return Response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def audit_comment(request):
|
||||||
|
"""
|
||||||
|
审核评论
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
{
|
||||||
|
"content": "评论内容"
|
||||||
|
}
|
||||||
|
|
||||||
|
返回:
|
||||||
|
{
|
||||||
|
"approved": true/false,
|
||||||
|
"reason": "审核结果说明",
|
||||||
|
"details": {...}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
content = request.data.get('content', '')
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
return Response(
|
||||||
|
{'error': '评论内容不能为空'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
result = AIAuditService.audit_comment(content)
|
||||||
|
|
||||||
|
return Response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['POST'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def audit_service(request):
|
||||||
|
"""
|
||||||
|
审核特色服务
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
{
|
||||||
|
"name": "服务名称",
|
||||||
|
"description": "服务描述"
|
||||||
|
}
|
||||||
|
|
||||||
|
返回:
|
||||||
|
{
|
||||||
|
"approved": true/false,
|
||||||
|
"reason": "审核结果说明",
|
||||||
|
"details": {...}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
name = request.data.get('name', '')
|
||||||
|
description = request.data.get('description', '')
|
||||||
|
|
||||||
|
if not name or not description:
|
||||||
|
return Response(
|
||||||
|
{'error': '服务名称和描述不能为空'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
result = AIAuditService.audit_service(name, description)
|
||||||
|
|
||||||
|
return Response(result)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(['GET'])
|
||||||
|
@permission_classes([IsAuthenticated])
|
||||||
|
def audit_status(request):
|
||||||
|
"""
|
||||||
|
获取 AI 审核服务状态
|
||||||
|
"""
|
||||||
|
return Response({
|
||||||
|
'status': 'active',
|
||||||
|
'service': 'AI Audit Service',
|
||||||
|
'version': '1.0.0',
|
||||||
|
'features': [
|
||||||
|
'敏感词检测',
|
||||||
|
'广告检测',
|
||||||
|
'内容质量评估',
|
||||||
|
]
|
||||||
|
})
|
||||||
@@ -27,6 +27,7 @@ urlpatterns = [
|
|||||||
path('api/', include('apps.moderation.urls')),
|
path('api/', include('apps.moderation.urls')),
|
||||||
path('api/', include('apps.interactions.urls')),
|
path('api/', include('apps.interactions.urls')),
|
||||||
path('api/', include('apps.api.urls')),
|
path('api/', include('apps.api.urls')),
|
||||||
|
path('api/', include('apps.core.urls')), # AI 审核 API
|
||||||
|
|
||||||
# GraphQL
|
# GraphQL
|
||||||
path('graphql/', include('apps.api.graphql_urls')),
|
path('graphql/', include('apps.api.graphql_urls')),
|
||||||
|
|||||||
422
cli.py
Normal file
422
cli.py
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
城市手册 - 命令行接口工具
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python cli.py <命令> [参数]
|
||||||
|
|
||||||
|
示例:
|
||||||
|
python cli.py login admin Admin123!
|
||||||
|
python cli.py provinces
|
||||||
|
python cli.py article list
|
||||||
|
python cli.py article create "标题" "内容" 1
|
||||||
|
python cli.py audit article "标题" "内容"
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
# Django 设置
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.prod')
|
||||||
|
|
||||||
|
import django
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
# 配置
|
||||||
|
BASE_URL = 'http://localhost:8000'
|
||||||
|
ACCESS_TOKEN = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_token(username='admin'):
|
||||||
|
"""获取 JWT Token"""
|
||||||
|
User = get_user_model()
|
||||||
|
user = User.objects.filter(username=username).first()
|
||||||
|
if not user:
|
||||||
|
print(f'❌ 用户 {username} 不存在')
|
||||||
|
return None
|
||||||
|
|
||||||
|
refresh = RefreshToken.for_user(user)
|
||||||
|
return str(refresh.access_token)
|
||||||
|
|
||||||
|
|
||||||
|
def api_request(endpoint, method='GET', data=None, token=None):
|
||||||
|
"""发送 API 请求"""
|
||||||
|
url = f'{BASE_URL}{endpoint}'
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
if token:
|
||||||
|
headers['Authorization'] = f'Bearer {token}'
|
||||||
|
|
||||||
|
if data:
|
||||||
|
data = json.dumps(data, ensure_ascii=False).encode('utf-8')
|
||||||
|
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=10) as response:
|
||||||
|
return json.loads(response.read().decode())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
error_body = e.read().decode()
|
||||||
|
try:
|
||||||
|
error_data = json.loads(error_body)
|
||||||
|
return {'error': e.code, 'message': error_data}
|
||||||
|
except:
|
||||||
|
return {'error': e.code, 'message': error_body}
|
||||||
|
except Exception as e:
|
||||||
|
return {'error': 'network', 'message': str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_login(username, password):
|
||||||
|
"""登录获取 Token"""
|
||||||
|
result = api_request('/api/auth/login/', 'POST', {
|
||||||
|
'username': username,
|
||||||
|
'password': password
|
||||||
|
})
|
||||||
|
|
||||||
|
if 'access' in result:
|
||||||
|
print(f'✅ 登录成功')
|
||||||
|
print(f'Token: {result["access"][:50]}...')
|
||||||
|
return result['access']
|
||||||
|
else:
|
||||||
|
print(f'❌ 登录失败:{result}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_provinces():
|
||||||
|
"""获取所有省份"""
|
||||||
|
result = api_request('/api/regions/provinces/')
|
||||||
|
|
||||||
|
if isinstance(result, list):
|
||||||
|
print(f'✅ 共 {len(result)} 个省份:')
|
||||||
|
for i, p in enumerate(result, 1):
|
||||||
|
print(f' {i}. {p["name"]} (ID: {p["id"]})')
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
print(f'❌ 获取失败:{result}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_region_detail(region_id):
|
||||||
|
"""获取省份详情"""
|
||||||
|
result = api_request(f'/api/regions/{region_id}/')
|
||||||
|
|
||||||
|
if isinstance(result, dict) and 'id' in result:
|
||||||
|
print(f'✅ 省份信息:')
|
||||||
|
print(f' 名称:{result.get("name")}')
|
||||||
|
print(f' 级别:{result.get("level")}')
|
||||||
|
print(f' 状态:{result.get("status")}')
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
print(f'❌ 获取失败:{result}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_article_list(limit=10):
|
||||||
|
"""获取文章列表"""
|
||||||
|
token = get_token()
|
||||||
|
result = api_request(f'/api/articles/?limit={limit}', token=token)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and 'results' in result:
|
||||||
|
articles = result['results']
|
||||||
|
print(f'✅ 共 {result.get("count", 0)} 篇文章:')
|
||||||
|
for i, a in enumerate(articles, 1):
|
||||||
|
print(f' {i}. [{a.get("id")}] {a.get("title")} - {a.get("publish_status")}')
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
print(f'❌ 获取失败:{result}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_article_create(title, content, region_id, article_type='basic'):
|
||||||
|
"""创建文章"""
|
||||||
|
token = get_token()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = api_request('/api/articles/', 'POST', {
|
||||||
|
'title': title,
|
||||||
|
'content': content,
|
||||||
|
'region': region_id,
|
||||||
|
'article_type': article_type
|
||||||
|
}, token=token)
|
||||||
|
|
||||||
|
if 'id' in result:
|
||||||
|
print(f'✅ 文章创建成功 (ID: {result["id"]})')
|
||||||
|
print(f' 标题:{result.get("title")}')
|
||||||
|
print(f' 状态:{result.get("publish_status")}')
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
print(f'❌ 创建失败:{result}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_article_submit(article_id):
|
||||||
|
"""提交文章审核"""
|
||||||
|
token = get_token()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = api_request(f'/api/articles/{article_id}/submit/', 'POST', {}, token=token)
|
||||||
|
|
||||||
|
if 'id' in result:
|
||||||
|
print(f'✅ 文章已提交审核 (ID: {article_id})')
|
||||||
|
print(f' 版主审核状态:{result.get("moderator_status")}')
|
||||||
|
print(f' AI 审核状态:{result.get("ai_status")}')
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
print(f'❌ 提交失败:{result}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_audit_article(title, content):
|
||||||
|
"""AI 审核文章"""
|
||||||
|
token = get_token()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = api_request('/api/audit/article/', 'POST', {
|
||||||
|
'title': title,
|
||||||
|
'content': content
|
||||||
|
}, token=token)
|
||||||
|
|
||||||
|
status = '✅ 通过' if result.get('approved') else '❌ 拒绝'
|
||||||
|
print(f'AI 审核结果:{status}')
|
||||||
|
print(f' 原因:{result.get("reason")}')
|
||||||
|
if 'details' in result:
|
||||||
|
print(f' 详情:{json.dumps(result["details"], ensure_ascii=False, indent=2)}')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_audit_comment(content):
|
||||||
|
"""AI 审核评论"""
|
||||||
|
token = get_token()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = api_request('/api/audit/comment/', 'POST', {
|
||||||
|
'content': content
|
||||||
|
}, token=token)
|
||||||
|
|
||||||
|
status = '✅ 通过' if result.get('approved') else '❌ 拒绝'
|
||||||
|
print(f'AI 审核结果:{status}')
|
||||||
|
print(f' 原因:{result.get("reason")}')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_audit_service(name, description):
|
||||||
|
"""AI 审核服务"""
|
||||||
|
token = get_token()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = api_request('/api/audit/service/', 'POST', {
|
||||||
|
'name': name,
|
||||||
|
'description': description
|
||||||
|
}, token=token)
|
||||||
|
|
||||||
|
status = '✅ 通过' if result.get('approved') else '❌ 拒绝'
|
||||||
|
print(f'AI 审核结果:{status}')
|
||||||
|
print(f' 原因:{result.get("reason")}')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_audit_status():
|
||||||
|
"""AI 审核服务状态"""
|
||||||
|
token = get_token()
|
||||||
|
result = api_request('/api/audit/status/', token=token)
|
||||||
|
|
||||||
|
if 'status' in result:
|
||||||
|
print(f'✅ AI 审核服务状态:{result["status"]}')
|
||||||
|
print(f' 版本:{result.get("version")}')
|
||||||
|
print(f' 功能:{", ".join(result.get("features", []))}')
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
print(f'❌ 获取失败:{result}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_service_list(limit=10):
|
||||||
|
"""获取服务列表"""
|
||||||
|
token = get_token()
|
||||||
|
result = api_request(f'/api/services/?limit={limit}', token=token)
|
||||||
|
|
||||||
|
if isinstance(result, dict) and 'results' in result:
|
||||||
|
services = result['results']
|
||||||
|
print(f'✅ 共 {result.get("count", 0)} 个服务:')
|
||||||
|
for i, s in enumerate(services, 1):
|
||||||
|
print(f' {i}. [{s.get("id")}] {s.get("name")} - {s.get("category")}')
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
print(f'❌ 获取失败:{result}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_service_create(name, description, region_id, category='food'):
|
||||||
|
"""创建特色服务"""
|
||||||
|
token = get_token()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = api_request('/api/services/', 'POST', {
|
||||||
|
'name': name,
|
||||||
|
'description': description,
|
||||||
|
'region': region_id,
|
||||||
|
'category': category
|
||||||
|
}, token=token)
|
||||||
|
|
||||||
|
if 'id' in result:
|
||||||
|
print(f'✅ 服务创建成功 (ID: {result["id"]})')
|
||||||
|
print(f' 名称:{result.get("name")}')
|
||||||
|
print(f' 分类:{result.get("category")}')
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
print(f'❌ 创建失败:{result}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_help():
|
||||||
|
"""显示帮助信息"""
|
||||||
|
help_text = """
|
||||||
|
城市手册 - 命令行接口
|
||||||
|
|
||||||
|
用法:python cli.py <命令> [参数]
|
||||||
|
|
||||||
|
认证命令:
|
||||||
|
login <用户名> <密码> 登录获取 Token
|
||||||
|
|
||||||
|
省份命令:
|
||||||
|
provinces 获取所有省份
|
||||||
|
region <ID> 获取省份详情
|
||||||
|
|
||||||
|
文章命令:
|
||||||
|
article list [limit] 获取文章列表
|
||||||
|
article create <标题> <内容> <省份 ID> [类型]
|
||||||
|
创建文章
|
||||||
|
article submit <ID> 提交文章审核
|
||||||
|
|
||||||
|
服务命令:
|
||||||
|
service list [limit] 获取服务列表
|
||||||
|
service create <名称> <描述> <省份 ID> [分类]
|
||||||
|
创建服务
|
||||||
|
|
||||||
|
AI 审核命令:
|
||||||
|
audit article <标题> <内容> AI 审核文章
|
||||||
|
audit comment <内容> AI 审核评论
|
||||||
|
audit service <名称> <描述> AI 审核服务
|
||||||
|
audit status AI 审核服务状态
|
||||||
|
|
||||||
|
示例:
|
||||||
|
python cli.py login admin Admin123!
|
||||||
|
python cli.py provinces
|
||||||
|
python cli.py article create "北京攻略" "北京是首都..." 1
|
||||||
|
python cli.py audit article "测试" "包含暴力内容"
|
||||||
|
python cli.py audit status
|
||||||
|
"""
|
||||||
|
print(help_text)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
cmd_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
command = sys.argv[1]
|
||||||
|
|
||||||
|
# 登录命令
|
||||||
|
if command == 'login':
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
print('用法:python cli.py login <用户名> <密码>')
|
||||||
|
return
|
||||||
|
cmd_login(sys.argv[2], sys.argv[3])
|
||||||
|
|
||||||
|
# 省份命令
|
||||||
|
elif command == 'provinces':
|
||||||
|
cmd_provinces()
|
||||||
|
|
||||||
|
elif command == 'region':
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print('用法:python cli.py region <ID>')
|
||||||
|
return
|
||||||
|
cmd_region_detail(sys.argv[2])
|
||||||
|
|
||||||
|
# 文章命令
|
||||||
|
elif command == 'article':
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print('用法:python cli.py article <list|create|submit> [参数]')
|
||||||
|
return
|
||||||
|
|
||||||
|
subcommand = sys.argv[2]
|
||||||
|
if subcommand == 'list':
|
||||||
|
limit = sys.argv[3] if len(sys.argv) > 3 else 10
|
||||||
|
cmd_article_list(limit)
|
||||||
|
elif subcommand == 'create':
|
||||||
|
if len(sys.argv) < 6:
|
||||||
|
print('用法:python cli.py article create <标题> <内容> <省份 ID> [类型]')
|
||||||
|
return
|
||||||
|
cmd_article_create(sys.argv[3], sys.argv[4], sys.argv[5],
|
||||||
|
sys.argv[6] if len(sys.argv) > 6 else 'basic')
|
||||||
|
elif subcommand == 'submit':
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
print('用法:python cli.py article submit <ID>')
|
||||||
|
return
|
||||||
|
cmd_article_submit(sys.argv[3])
|
||||||
|
|
||||||
|
# 服务命令
|
||||||
|
elif command == 'service':
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print('用法:python cli.py service <list|create> [参数]')
|
||||||
|
return
|
||||||
|
|
||||||
|
subcommand = sys.argv[2]
|
||||||
|
if subcommand == 'list':
|
||||||
|
limit = sys.argv[3] if len(sys.argv) > 3 else 10
|
||||||
|
cmd_service_list(limit)
|
||||||
|
elif subcommand == 'create':
|
||||||
|
if len(sys.argv) < 6:
|
||||||
|
print('用法:python cli.py service create <名称> <描述> <省份 ID> [分类]')
|
||||||
|
return
|
||||||
|
cmd_service_create(sys.argv[3], sys.argv[4], sys.argv[5],
|
||||||
|
sys.argv[6] if len(sys.argv) > 6 else 'food')
|
||||||
|
|
||||||
|
# AI 审核命令
|
||||||
|
elif command == 'audit':
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print('用法:python cli.py audit <article|comment|service|status> [参数]')
|
||||||
|
return
|
||||||
|
|
||||||
|
subcommand = sys.argv[2]
|
||||||
|
if subcommand == 'article':
|
||||||
|
if len(sys.argv) < 5:
|
||||||
|
print('用法:python cli.py audit article <标题> <内容>')
|
||||||
|
return
|
||||||
|
cmd_audit_article(sys.argv[3], sys.argv[4])
|
||||||
|
elif subcommand == 'comment':
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
print('用法:python cli.py audit comment <内容>')
|
||||||
|
return
|
||||||
|
cmd_audit_comment(sys.argv[3])
|
||||||
|
elif subcommand == 'service':
|
||||||
|
if len(sys.argv) < 5:
|
||||||
|
print('用法:python cli.py audit service <名称> <描述>')
|
||||||
|
return
|
||||||
|
cmd_audit_service(sys.argv[3], sys.argv[4])
|
||||||
|
elif subcommand == 'status':
|
||||||
|
cmd_audit_status()
|
||||||
|
|
||||||
|
# 帮助命令
|
||||||
|
elif command == 'help' or command == '--help' or command == '-h':
|
||||||
|
cmd_help()
|
||||||
|
|
||||||
|
else:
|
||||||
|
print(f'❌ 未知命令:{command}')
|
||||||
|
print('使用 python cli.py help 查看帮助')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -1,42 +1,28 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name localhost;
|
server_name localhost;
|
||||||
|
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
location /static/ {
|
||||||
location /api {
|
alias /usr/share/nginx/html/static/;
|
||||||
|
expires 30d;
|
||||||
|
}
|
||||||
|
location /api/ {
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
}
|
}
|
||||||
|
location /graphql/ {
|
||||||
location /graphql {
|
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
}
|
}
|
||||||
|
location /media/ {
|
||||||
location /media {
|
|
||||||
proxy_pass http://backend:8000;
|
proxy_pass http://backend:8000;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
location /static {
|
|
||||||
# Try local static files first, then proxy to backend
|
|
||||||
try_files $uri $uri/ @backend_static;
|
|
||||||
}
|
|
||||||
|
|
||||||
location @backend_static {
|
|
||||||
proxy_pass http://backend:8000;
|
|
||||||
}
|
|
||||||
|
|
||||||
gzip on;
|
|
||||||
gzip_comp_level 5;
|
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,54 +1,29 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Routes, Route, useParams, useNavigate } from 'react-router-dom';
|
import { Routes, Route, useParams } from 'react-router-dom';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import styled from 'styled-components';
|
|
||||||
import { useAuthStore } from './stores/AuthStore';
|
|
||||||
import { useUserStore } from './stores/UserStore';
|
|
||||||
import { useRegionStore } from './stores/RegionStore';
|
|
||||||
import Layout from './components/common/Layout';
|
import Layout from './components/common/Layout';
|
||||||
import Loading from './components/common/Loading';
|
import HomePage from './components/home/HomePage';
|
||||||
import ChinaMap from './components/common/ChinaMap';
|
|
||||||
import CitiesPage from './components/region/CitiesPage';
|
import CitiesPage from './components/region/CitiesPage';
|
||||||
import CityDetailPage from './components/region/CityDetailPage';
|
import CityDetailPage from './components/region/CityDetailPage';
|
||||||
|
import ArticlesPage from './components/article/ArticlesPage';
|
||||||
import ArticleDetailPage from './components/article/ArticleDetailPage';
|
import ArticleDetailPage from './components/article/ArticleDetailPage';
|
||||||
|
import ServicesPage from './components/service/ServicesPage';
|
||||||
import ServiceDetailPage from './components/service/ServiceDetailPage';
|
import ServiceDetailPage from './components/service/ServiceDetailPage';
|
||||||
import LoginPage from './components/auth/LoginPage';
|
import LoginPage from './components/auth/LoginPage';
|
||||||
import RegisterPage from './components/auth/RegisterPage';
|
import RegisterPage from './components/auth/RegisterPage';
|
||||||
|
import UserProfilePage from './components/user/UserProfilePage';
|
||||||
const Container = styled.div`
|
import NotFoundPage from './components/common/NotFoundPage';
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Header = styled.header`
|
|
||||||
margin-bottom: 30px;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
border-bottom: 2px solid #eee;
|
|
||||||
`;
|
|
||||||
|
|
||||||
const Title = styled.h1`
|
|
||||||
margin: 0;
|
|
||||||
font-size: 28px;
|
|
||||||
`;
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const authStore = useAuthStore();
|
|
||||||
|
|
||||||
// Fetch current user on app load
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (authStore.isAuthenticated) {
|
|
||||||
authStore.fetchCurrentUser();
|
|
||||||
}
|
|
||||||
}, [authStore]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout title="城市手册" subtitle="地方志兼本地生活服务平台">
|
<Layout title="城市手册" subtitle="地方志兼本地生活服务平台">
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<HomePage />} />
|
<Route path="/" element={<HomePage />} />
|
||||||
<Route path="/cities" element={<CitiesPage />} />
|
<Route path="/cities" element={<CitiesPage />} />
|
||||||
<Route path="/cities/:regionId" element={<CityDetailPage />} />
|
<Route path="/cities/:regionId" element={<CityDetailPageWrapper />} />
|
||||||
|
<Route path="/articles" element={<ArticlesPage />} />
|
||||||
<Route path="/articles/:articleId" element={<ArticleDetailPageWrapper />} />
|
<Route path="/articles/:articleId" element={<ArticleDetailPageWrapper />} />
|
||||||
|
<Route path="/services" element={<ServicesPage />} />
|
||||||
<Route path="/services/:serviceId" element={<ServiceDetailPageWrapper />} />
|
<Route path="/services/:serviceId" element={<ServiceDetailPageWrapper />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/register" element={<RegisterPage />} />
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
@@ -59,6 +34,11 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CityDetailPageWrapper = observer(() => {
|
||||||
|
const { regionId } = useParams();
|
||||||
|
return <CityDetailPage regionId={regionId} />;
|
||||||
|
});
|
||||||
|
|
||||||
const ArticleDetailPageWrapper = observer(() => {
|
const ArticleDetailPageWrapper = observer(() => {
|
||||||
const { articleId } = useParams();
|
const { articleId } = useParams();
|
||||||
return <ArticleDetailPage articleId={articleId} />;
|
return <ArticleDetailPage articleId={articleId} />;
|
||||||
@@ -69,110 +49,4 @@ const ServiceDetailPageWrapper = observer(() => {
|
|||||||
return <ServiceDetailPage serviceId={serviceId} />;
|
return <ServiceDetailPage serviceId={serviceId} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
const HomePage = observer(() => {
|
export default App;
|
||||||
const navigate = useNavigate();
|
|
||||||
const regionStore = useRegionStore();
|
|
||||||
const [loading, setLoading] = React.useState(false);
|
|
||||||
|
|
||||||
const handleProvinceClick = async (geo) => {
|
|
||||||
const provinceName = geo.properties.name;
|
|
||||||
const provinceCode = geo.properties.code;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
// 先获取所有省份列表,找到对应的 region ID
|
|
||||||
await regionStore.fetchProvinces();
|
|
||||||
const province = regionStore.regions.find(
|
|
||||||
r => r.name === provinceName || r.code === provinceCode
|
|
||||||
);
|
|
||||||
|
|
||||||
if (province) {
|
|
||||||
navigate(`/cities/${province.id}`);
|
|
||||||
} else {
|
|
||||||
// 如果没有找到,跳转到城市列表页并带上省份名称
|
|
||||||
navigate(`/cities?province=${encodeURIComponent(provinceName)}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to navigate to province:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>欢迎来到城市手册</Title>
|
|
||||||
<p>探索每个城市的故事与特色</p>
|
|
||||||
</Header>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<Loading message="加载中..." />
|
|
||||||
) : (
|
|
||||||
<ChinaMap onProvinceClick={handleProvinceClick} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ marginTop: '40px' }}>
|
|
||||||
<h2>📚 最新文章</h2>
|
|
||||||
<p>即将推出...</p>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const UserProfilePage = observer(() => {
|
|
||||||
const authStore = useAuthStore();
|
|
||||||
const userStore = useUserStore();
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (authStore.isAuthenticated) {
|
|
||||||
userStore.fetchCurrentUser();
|
|
||||||
}
|
|
||||||
}, [authStore, userStore]);
|
|
||||||
|
|
||||||
if (!authStore.isAuthenticated) {
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<p>请先登录</p>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (userStore.loading) {
|
|
||||||
return <Loading message="加载用户信息..." />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>个人中心</Title>
|
|
||||||
</Header>
|
|
||||||
{userStore.user && (
|
|
||||||
<div>
|
|
||||||
<h3>用户信息</h3>
|
|
||||||
<p>用户名: {userStore.user.username}</p>
|
|
||||||
<p>邮箱: {userStore.user.email}</p>
|
|
||||||
<p>角色: {userStore.user.role_display}</p>
|
|
||||||
|
|
||||||
<h3>统计</h3>
|
|
||||||
<p>文章数: {userStore.user.articles_count}</p>
|
|
||||||
<p>服务数: {userStore.user.services_count}</p>
|
|
||||||
<p>评论数: {userStore.user.comments_count}</p>
|
|
||||||
<p>点赞数: {userStore.user.likes_count}</p>
|
|
||||||
<p>收藏数: {userStore.user.favorites_count}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
const NotFoundPage = () => (
|
|
||||||
<Container>
|
|
||||||
<Header>
|
|
||||||
<Title>404</Title>
|
|
||||||
</Header>
|
|
||||||
<p>页面未找到</p>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
|||||||
@@ -3,68 +3,179 @@ import styled from 'styled-components';
|
|||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useArticleStore } from '../../stores/ArticleStore';
|
import { useArticleStore } from '../../stores/ArticleStore';
|
||||||
import { useInteractionStore } from '../../stores/InteractionStore';
|
import { useAuthStore } from '../../stores/AuthStore';
|
||||||
import Card from '../common/Card';
|
|
||||||
import Loading from '../common/Loading';
|
import Loading from '../common/Loading';
|
||||||
import ErrorMessage from '../common/ErrorMessage';
|
import ErrorMessage from '../common/ErrorMessage';
|
||||||
|
import Card from '../common/Card';
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Header = styled.div`
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.h1`
|
||||||
|
font-size: 32px;
|
||||||
|
margin: 0 0 15px;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Meta = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MetaItem = styled.span`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
`;
|
||||||
|
|
||||||
const Content = styled.div`
|
const Content = styled.div`
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
color: #333;
|
color: #333;
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
h1, h2, h3 {
|
h1, h2, h3, h4 {
|
||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
margin-top: 30px;
|
margin: 30px 0 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
p {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin: 15px 0;
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
padding-left: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: #f5f5f5;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #2d2d2d;
|
||||||
|
color: #f8f8f2;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 20px 0;
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Actions = styled.div`
|
const Actions = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 15px;
|
||||||
margin: 20px 0;
|
margin: 30px 0;
|
||||||
|
padding: 20px 0;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Button = styled.button`
|
const ActionButton = styled.button`
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 5px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
&:hover {
|
${(props) =>
|
||||||
opacity: 0.8;
|
props.primary &&
|
||||||
}
|
`
|
||||||
|
|
||||||
${props => props.primary && `
|
|
||||||
background: #667eea;
|
background: #667eea;
|
||||||
color: white;
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
|
|
||||||
${props => props.secondary && `
|
${(props) =>
|
||||||
background: #6c757d;
|
props.secondary &&
|
||||||
color: white;
|
`
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #667eea;
|
||||||
|
border: 1px solid #667eea;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
`}
|
`}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CommentsSection = styled.div`
|
const CommentsSection = styled.div`
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const SectionTitle = styled.h2`
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
color: #333;
|
||||||
|
`;
|
||||||
|
|
||||||
const CommentForm = styled.form`
|
const CommentForm = styled.form`
|
||||||
margin-bottom: 20px;
|
margin-bottom: 30px;
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100px;
|
min-height: 100px;
|
||||||
padding: 10px;
|
padding: 15px;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 5px;
|
border-radius: 8px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -76,39 +187,70 @@ const CommentList = styled.div`
|
|||||||
|
|
||||||
const CommentItem = styled.div`
|
const CommentItem = styled.div`
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
padding: 15px;
|
padding: 20px;
|
||||||
border-radius: 5px;
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ArticleDetailPage = observer(({ articleId }) => {
|
const ArticleDetailPage = observer(({ articleId }) => {
|
||||||
const articleStore = useArticleStore();
|
|
||||||
const interactionStore = useInteractionStore();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const articleStore = useArticleStore();
|
||||||
|
const authStore = useAuthStore();
|
||||||
const [comment, setComment] = useState('');
|
const [comment, setComment] = useState('');
|
||||||
const [liked, setLiked] = useState(false);
|
const [liked, setLiked] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
articleStore.fetchArticle(articleId);
|
articleStore.fetchArticle(articleId);
|
||||||
articleStore.fetchArticleComments(articleId);
|
|
||||||
articleStore.fetchArticleStats(articleId);
|
|
||||||
}, [articleId, articleStore]);
|
}, [articleId, articleStore]);
|
||||||
|
|
||||||
const handleLike = async () => {
|
const handleLike = async () => {
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
navigate('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const result = await articleStore.likeArticle(articleId);
|
const result = await articleStore.likeArticle(articleId);
|
||||||
if (result) {
|
if (result.success) {
|
||||||
setLiked(result.liked);
|
setLiked(!liked);
|
||||||
articleStore.fetchArticleStats(articleId);
|
articleStore.fetchArticle(articleId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleComment = async (e) => {
|
const handleFavorite = async () => {
|
||||||
e.preventDefault();
|
if (!authStore.isAuthenticated) {
|
||||||
if (!comment.trim()) return;
|
navigate('/login');
|
||||||
|
return;
|
||||||
const result = await interactionStore.createComment('article', articleId, comment);
|
}
|
||||||
|
const result = await articleStore.favoriteArticle(articleId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setComment('');
|
alert('已收藏');
|
||||||
articleStore.fetchArticleComments(articleId);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: articleStore.currentArticle?.title,
|
||||||
|
url: window.location.href,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(window.location.href);
|
||||||
|
alert('链接已复制到剪贴板');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -120,7 +262,7 @@ const ArticleDetailPage = observer(({ articleId }) => {
|
|||||||
return (
|
return (
|
||||||
<ErrorMessage
|
<ErrorMessage
|
||||||
message={articleStore.error}
|
message={articleStore.error}
|
||||||
onDismiss={() => articleStore.error = null}
|
onDismiss={() => articleStore.clearError()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -132,52 +274,82 @@ const ArticleDetailPage = observer(({ articleId }) => {
|
|||||||
const article = articleStore.currentArticle;
|
const article = articleStore.currentArticle;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Container>
|
||||||
<h1>{article.title}</h1>
|
<Header>
|
||||||
<p>作者: {article.author_username} | {article.article_type_display}</p>
|
<Title>{article.title}</Title>
|
||||||
|
<Meta>
|
||||||
|
<MetaItem>
|
||||||
|
👤 {article.author_username || '匿名用户'}
|
||||||
|
</MetaItem>
|
||||||
|
<MetaItem>
|
||||||
|
📍 {article.region_name || '未知地区'}
|
||||||
|
</MetaItem>
|
||||||
|
<MetaItem>
|
||||||
|
👁 {article.views || 0} 阅读
|
||||||
|
</MetaItem>
|
||||||
|
<MetaItem>
|
||||||
|
❤️ {article.likes_count || 0} 点赞
|
||||||
|
</MetaItem>
|
||||||
|
<MetaItem>
|
||||||
|
💬 {article.comments_count || 0} 评论
|
||||||
|
</MetaItem>
|
||||||
|
</Meta>
|
||||||
|
</Header>
|
||||||
|
|
||||||
<Content dangerouslySetInnerHTML={{ __html: article.content }} />
|
<Content dangerouslySetInnerHTML={{ __html: article.content || '' }} />
|
||||||
|
|
||||||
<Actions>
|
<Actions>
|
||||||
<Button primary onClick={handleLike}>
|
<ActionButton primary onClick={handleLike}>
|
||||||
{liked ? '已点赞' : '点赞'}
|
{liked ? '❤️' : '🤍'} {liked ? '已点赞' : '点赞'}
|
||||||
</Button>
|
</ActionButton>
|
||||||
<Button secondary>
|
<ActionButton secondary onClick={handleFavorite}>
|
||||||
收藏
|
⭐ 收藏
|
||||||
</Button>
|
</ActionButton>
|
||||||
<Button secondary>
|
<ActionButton secondary onClick={handleShare}>
|
||||||
分享
|
🔗 分享
|
||||||
</Button>
|
</ActionButton>
|
||||||
</Actions>
|
</Actions>
|
||||||
|
|
||||||
<CommentsSection>
|
<CommentsSection>
|
||||||
<h2>评论 ({articleStore.currentArticle.comments_count})</h2>
|
<SectionTitle>💬 评论 ({article.comments_count || 0})</SectionTitle>
|
||||||
|
|
||||||
<CommentForm onSubmit={handleComment}>
|
{authStore.isAuthenticated ? (
|
||||||
<textarea
|
<CommentForm
|
||||||
placeholder="写下你的评论..."
|
onSubmit={async (e) => {
|
||||||
value={comment}
|
e.preventDefault();
|
||||||
onChange={(e) => setComment(e.target.value)}
|
if (!comment.trim()) return;
|
||||||
|
// TODO: 实现评论创建
|
||||||
|
setComment('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
placeholder="写下你的评论..."
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
/>
|
||||||
|
<ActionButton type="submit" primary style={{ marginTop: '10px' }}>
|
||||||
|
发表评论
|
||||||
|
</ActionButton>
|
||||||
|
</CommentForm>
|
||||||
|
) : (
|
||||||
|
<Card
|
||||||
|
title="登录后评论"
|
||||||
|
description="登录后可发表评论和参与互动"
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
style={{ cursor: 'pointer', marginBottom: '20px' }}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" primary style={{ marginTop: '10px' }}>
|
)}
|
||||||
发表评论
|
|
||||||
</Button>
|
|
||||||
</CommentForm>
|
|
||||||
|
|
||||||
<CommentList>
|
<CommentList>
|
||||||
{articleStore.currentArticle.comments.map((comment) => (
|
{/* TODO: 加载并显示评论 */}
|
||||||
<CommentItem key={comment.id}>
|
<CommentItem>
|
||||||
<p><strong>{comment.author_username}</strong></p>
|
<div className="author">暂无评论</div>
|
||||||
<p>{comment.content}</p>
|
<div className="content">成为第一个评论的人吧!</div>
|
||||||
<p style={{ color: '#999', fontSize: '14px' }}>
|
</CommentItem>
|
||||||
{comment.created_at}
|
|
||||||
</p>
|
|
||||||
</CommentItem>
|
|
||||||
))}
|
|
||||||
</CommentList>
|
</CommentList>
|
||||||
</CommentsSection>
|
</CommentsSection>
|
||||||
</div>
|
</Container>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ArticleDetailPage;
|
export default ArticleDetailPage;
|
||||||
|
|||||||
186
frontend/src/components/article/ArticlesPage.js
Normal file
186
frontend/src/components/article/ArticlesPage.js
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { useArticleStore } from '../../stores/ArticleStore';
|
||||||
|
import { useRegionStore } from '../../stores/RegionStore';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Card from '../common/Card';
|
||||||
|
import Loading from '../common/Loading';
|
||||||
|
import ErrorMessage from '../common/ErrorMessage';
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Header = styled.div`
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.h1`
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #333;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Subtitle = styled.p`
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Grid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||||
|
gap: 25px;
|
||||||
|
padding: 20px 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FilterBar = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Select = styled.select`
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SearchInput = styled.input`
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ArticlesPage = observer(() => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const articleStore = useArticleStore();
|
||||||
|
const regionStore = useRegionStore();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
articleStore.fetchArticles();
|
||||||
|
regionStore.fetchProvinces();
|
||||||
|
}, [articleStore, regionStore]);
|
||||||
|
|
||||||
|
const handleArticleClick = (articleId) => {
|
||||||
|
navigate(`/articles/${articleId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegionChange = (e) => {
|
||||||
|
const regionId = e.target.value;
|
||||||
|
if (regionId) {
|
||||||
|
setSearchParams({ region: regionId });
|
||||||
|
articleStore.fetchArticlesByRegion(regionId);
|
||||||
|
} else {
|
||||||
|
setSearchParams({});
|
||||||
|
articleStore.fetchArticles();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
articleStore.searchArticles(searchQuery);
|
||||||
|
} else {
|
||||||
|
articleStore.fetchArticles();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedRegion = searchParams.get('region');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Header>
|
||||||
|
<Title>📚 文章库</Title>
|
||||||
|
<Subtitle>探索各地的风土人情、历史文化与生活指南</Subtitle>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<FilterBar>
|
||||||
|
<Select value={selectedRegion || ''} onChange={handleRegionChange}>
|
||||||
|
<option value="">全部地区</option>
|
||||||
|
{regionStore.regions.map((region) => (
|
||||||
|
<option key={region.id} value={region.id}>
|
||||||
|
{region.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearch} style={{ display: 'flex', gap: '10px', flex: 1 }}>
|
||||||
|
<SearchInput
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索文章标题或内容..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: '#667eea',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
{articleStore.loading ? (
|
||||||
|
<Loading message="加载文章..." />
|
||||||
|
) : articleStore.error ? (
|
||||||
|
<ErrorMessage
|
||||||
|
message={articleStore.error}
|
||||||
|
onDismiss={() => (articleStore.error = null)}
|
||||||
|
/>
|
||||||
|
) : articleStore.articles && articleStore.articles.length > 0 ? (
|
||||||
|
<Grid>
|
||||||
|
{articleStore.articles.map((article) => (
|
||||||
|
<Card
|
||||||
|
key={article.id}
|
||||||
|
title={article.title}
|
||||||
|
description={article.summary || article.excerpt || article.content?.substring(0, 100) + '...'}
|
||||||
|
meta={`📍 ${article.region_name || '未知'} · 👁 ${article.views || 0} · ❤️ ${article.likes_count || 0}`}
|
||||||
|
onClick={() => handleArticleClick(article.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Card
|
||||||
|
title="暂无文章"
|
||||||
|
description="还没有文章,成为第一个发布者吧!"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ArticlesPage;
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { useAuthStore } from '../../stores/AuthStore';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
@@ -60,6 +62,20 @@ const Button = styled.button`
|
|||||||
&:hover {
|
&:hover {
|
||||||
background: #5568d3;
|
background: #5568d3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ErrorMessage = styled.div`
|
||||||
|
color: #dc3545;
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
background: #f8d7da;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Link = styled.a`
|
const Link = styled.a`
|
||||||
@@ -67,44 +83,35 @@ const Link = styled.a`
|
|||||||
color: #667eea;
|
color: #667eea;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
display: block;
|
||||||
|
margin-top: 15px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const RegisterPage = () => {
|
const RegisterPage = observer(() => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const authStore = useAuthStore();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
const [error, setError] = useState('');
|
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleSubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError('');
|
const result = await authStore.register(username, email, password, confirmPassword);
|
||||||
|
if (result.success) {
|
||||||
if (password !== confirmPassword) {
|
navigate('/');
|
||||||
setError('两次输入的密码不一致');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: 调用注册 API
|
|
||||||
console.log('Register:', { username, email, password });
|
|
||||||
|
|
||||||
navigate('/login');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Title>注册</Title>
|
<Title>注册账号</Title>
|
||||||
<Form onSubmit={handleSubmit}>
|
<Form onSubmit={handleSubmit}>
|
||||||
{error && (
|
{authStore.error && <ErrorMessage>{authStore.error}</ErrorMessage>}
|
||||||
<div style={{ color: '#dc3545', fontSize: '14px', textAlign: 'center' }}>
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<InputGroup>
|
<InputGroup>
|
||||||
<Label>用户名</Label>
|
<Label>用户名</Label>
|
||||||
@@ -114,6 +121,7 @@ const RegisterPage = () => {
|
|||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder="请输入用户名"
|
placeholder="请输入用户名"
|
||||||
required
|
required
|
||||||
|
disabled={authStore.loading}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
|
|
||||||
@@ -125,6 +133,7 @@ const RegisterPage = () => {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="请输入邮箱"
|
placeholder="请输入邮箱"
|
||||||
required
|
required
|
||||||
|
disabled={authStore.loading}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
|
|
||||||
@@ -136,6 +145,7 @@ const RegisterPage = () => {
|
|||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="请输入密码"
|
placeholder="请输入密码"
|
||||||
required
|
required
|
||||||
|
disabled={authStore.loading}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
|
|
||||||
@@ -147,15 +157,18 @@ const RegisterPage = () => {
|
|||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
placeholder="请再次输入密码"
|
placeholder="请再次输入密码"
|
||||||
required
|
required
|
||||||
|
disabled={authStore.loading}
|
||||||
/>
|
/>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
|
|
||||||
<Button type="submit">注册</Button>
|
<Button type="submit" disabled={authStore.loading}>
|
||||||
|
{authStore.loading ? '注册中...' : '注册'}
|
||||||
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
<Link href="/login">已有账号?立即登录</Link>
|
<Link href="/login">已有账号?立即登录</Link>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
export default RegisterPage;
|
export default RegisterPage;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
|
|||||||
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
|
import { ComposableMap, Geographies, Geography, ZoomableGroup } from 'react-simple-maps';
|
||||||
import { scaleQuantile } from 'd3-scale';
|
import { scaleQuantile } from 'd3-scale';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import chinaGeo from '../../data/china-provinces.geo.json';
|
import chinaGeo from '../../data/china-provinces.geo.json';
|
||||||
|
|
||||||
const MapContainer = styled.div`
|
const MapContainer = styled.div`
|
||||||
@@ -67,7 +66,6 @@ const MapWrapper = styled.div`
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const ChinaMap = ({ onProvinceClick }) => {
|
const ChinaMap = ({ onProvinceClick }) => {
|
||||||
const navigate = useNavigate();
|
|
||||||
const [tooltipContent, setTooltipContent] = useState('');
|
const [tooltipContent, setTooltipContent] = useState('');
|
||||||
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
|
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
|
||||||
const [showTooltip, setShowTooltip] = useState(false);
|
const [showTooltip, setShowTooltip] = useState(false);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../../stores/AuthStore';
|
||||||
|
import { useUserStore } from '../../stores/UserStore';
|
||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
@@ -23,19 +26,29 @@ const HeaderContent = styled.div`
|
|||||||
const Title = styled.h1`
|
const Title = styled.h1`
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Subtitle = styled.p`
|
const Subtitle = styled.p`
|
||||||
margin: 5px 0 0;
|
margin: 5px 0 0;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
|
font-size: 14px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Nav = styled.nav`
|
const Nav = styled.nav`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: white;
|
color: white;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
margin-left: 20px;
|
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
|
font-size: 15px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
@@ -43,29 +56,106 @@ const Nav = styled.nav`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const UserMenu = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const UserName = styled.span`
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.9;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const AuthButton = styled.button`
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
const Footer = styled.footer`
|
const Footer = styled.footer`
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
padding: 30px 0;
|
padding: 30px 0;
|
||||||
margin-top: 50px;
|
margin-top: 50px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #6c757d;
|
color: #6c757d;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FooterLinks = styled.div`
|
||||||
|
margin-bottom: 15px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #6c757d;
|
||||||
|
text-decoration: none;
|
||||||
|
margin: 0 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function Layout({ children, title, subtitle }) {
|
function Layout({ children, title, subtitle }) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (authStore.isAuthenticated && !userStore.user) {
|
||||||
|
userStore.fetchCurrentUser();
|
||||||
|
}
|
||||||
|
}, [authStore, userStore]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
authStore.logout();
|
||||||
|
userStore.clearUser();
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTitleClick = () => {
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Header>
|
<Header>
|
||||||
<Container>
|
<Container>
|
||||||
<HeaderContent>
|
<HeaderContent>
|
||||||
<div>
|
<div onClick={handleTitleClick} style={{ cursor: 'pointer' }}>
|
||||||
<Title>{title || '城市手册'}</Title>
|
<Title>{title || '城市手册'}</Title>
|
||||||
{subtitle && <Subtitle>{subtitle}</Subtitle>}
|
{subtitle && <Subtitle>{subtitle}</Subtitle>}
|
||||||
</div>
|
</div>
|
||||||
<Nav>
|
<Nav>
|
||||||
<a href="/">首页</a>
|
<Link to="/">首页</Link>
|
||||||
<a href="/cities">城市</a>
|
<Link to="/cities">城市</Link>
|
||||||
<a href="/services">服务</a>
|
<Link to="/articles">文章</Link>
|
||||||
<a href="/user/profile">个人中心</a>
|
<Link to="/services">服务</Link>
|
||||||
|
|
||||||
|
{authStore.isAuthenticated ? (
|
||||||
|
<UserMenu>
|
||||||
|
{userStore.user && (
|
||||||
|
<UserName>👋 {userStore.user.username}</UserName>
|
||||||
|
)}
|
||||||
|
<Link to="/user/profile">个人中心</Link>
|
||||||
|
<AuthButton onClick={handleLogout}>退出</AuthButton>
|
||||||
|
</UserMenu>
|
||||||
|
) : (
|
||||||
|
<UserMenu>
|
||||||
|
<Link to="/login">登录</Link>
|
||||||
|
<AuthButton onClick={() => navigate('/register')}>注册</AuthButton>
|
||||||
|
</UserMenu>
|
||||||
|
)}
|
||||||
</Nav>
|
</Nav>
|
||||||
</HeaderContent>
|
</HeaderContent>
|
||||||
</Container>
|
</Container>
|
||||||
@@ -77,11 +167,17 @@ function Layout({ children, title, subtitle }) {
|
|||||||
|
|
||||||
<Footer>
|
<Footer>
|
||||||
<Container>
|
<Container>
|
||||||
<p>© 2026 城市手册. All rights reserved.</p>
|
<FooterLinks>
|
||||||
|
<a href="/about">关于我们</a>
|
||||||
|
<a href="/contact">联系我们</a>
|
||||||
|
<a href="/privacy">隐私政策</a>
|
||||||
|
<a href="/terms">服务条款</a>
|
||||||
|
</FooterLinks>
|
||||||
|
<p>© 2026 城市手册。All rights reserved.</p>
|
||||||
</Container>
|
</Container>
|
||||||
</Footer>
|
</Footer>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Layout;
|
export default Layout;
|
||||||
|
|||||||
85
frontend/src/components/common/NotFoundPage.js
Normal file
85
frontend/src/components/common/NotFoundPage.js
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 60px 20px;
|
||||||
|
text-align: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ErrorCode = styled.div`
|
||||||
|
font-size: 120px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #667eea;
|
||||||
|
line-height: 1;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.h1`
|
||||||
|
font-size: 32px;
|
||||||
|
color: #333;
|
||||||
|
margin: 0 0 15px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Description = styled.p`
|
||||||
|
font-size: 16px;
|
||||||
|
color: #666;
|
||||||
|
margin: 0 0 30px;
|
||||||
|
line-height: 1.6;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Button = styled.button`
|
||||||
|
padding: 12px 30px;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
margin: 0 10px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SecondaryButton = styled.button`
|
||||||
|
padding: 12px 30px;
|
||||||
|
background: white;
|
||||||
|
color: #667eea;
|
||||||
|
border: 1px solid #667eea;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
function NotFoundPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<ErrorCode>404</ErrorCode>
|
||||||
|
<Title>页面未找到</Title>
|
||||||
|
<Description>
|
||||||
|
抱歉,您访问的页面不存在或已被移除。<br />
|
||||||
|
请检查网址是否正确,或返回首页继续浏览。
|
||||||
|
</Description>
|
||||||
|
<div>
|
||||||
|
<Button onClick={() => navigate('/')}>返回首页</Button>
|
||||||
|
<SecondaryButton onClick={() => navigate(-1)}>返回上一页</SecondaryButton>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NotFoundPage;
|
||||||
220
frontend/src/components/home/HomePage.js
Normal file
220
frontend/src/components/home/HomePage.js
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { useRegionStore } from '../../stores/RegionStore';
|
||||||
|
import { useArticleStore } from '../../stores/ArticleStore';
|
||||||
|
import { useServiceStore } from '../../stores/ServiceStore';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import ChinaMap from '../common/ChinaMap';
|
||||||
|
import Loading from '../common/Loading';
|
||||||
|
import Card from '../common/Card';
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Hero = styled.div`
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 60px 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
text-align: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeroTitle = styled.h1`
|
||||||
|
font-size: 36px;
|
||||||
|
margin: 0 0 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HeroSubtitle = styled.p`
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Section = styled.section`
|
||||||
|
margin-bottom: 50px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SectionTitle = styled.h2`
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
color: #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Grid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MapWrapper = styled.div`
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatsGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 30px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatCard = styled.div`
|
||||||
|
background: white;
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatNumber = styled.div`
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatLabel = styled.div`
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const HomePage = observer(() => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const regionStore = useRegionStore();
|
||||||
|
const articleStore = useArticleStore();
|
||||||
|
const serviceStore = useServiceStore();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 加载首页数据
|
||||||
|
articleStore.fetchLatestArticles(6);
|
||||||
|
serviceStore.fetchFeaturedServices(6);
|
||||||
|
}, [articleStore, serviceStore]);
|
||||||
|
|
||||||
|
const handleProvinceClick = async (geo) => {
|
||||||
|
const provinceName = geo.properties.name;
|
||||||
|
const provinceCode = geo.properties.code;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await regionStore.fetchProvinces();
|
||||||
|
const province = regionStore.regions.find(
|
||||||
|
r => r.name === provinceName || r.code === provinceCode
|
||||||
|
);
|
||||||
|
|
||||||
|
if (province) {
|
||||||
|
navigate(`/cities/${province.id}`);
|
||||||
|
} else {
|
||||||
|
navigate(`/cities?province=${encodeURIComponent(provinceName)}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to navigate to province:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArticleClick = (articleId) => {
|
||||||
|
navigate(`/articles/${articleId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServiceClick = (serviceId) => {
|
||||||
|
navigate(`/services/${serviceId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Hero>
|
||||||
|
<HeroTitle>🌏 探索城市的故事</HeroTitle>
|
||||||
|
<HeroSubtitle>发现每个地方的独特魅力与生活指南</HeroSubtitle>
|
||||||
|
|
||||||
|
<StatsGrid>
|
||||||
|
<StatCard>
|
||||||
|
<StatNumber>{regionStore.regions.length || 34}</StatNumber>
|
||||||
|
<StatLabel>省级行政区</StatLabel>
|
||||||
|
</StatCard>
|
||||||
|
<StatCard>
|
||||||
|
<StatNumber>{articleStore.articles?.length || 0}</StatNumber>
|
||||||
|
<StatLabel>精选文章</StatLabel>
|
||||||
|
</StatCard>
|
||||||
|
<StatCard>
|
||||||
|
<StatNumber>{serviceStore.services?.length || 0}</StatNumber>
|
||||||
|
<StatLabel>本地服务</StatLabel>
|
||||||
|
</StatCard>
|
||||||
|
<StatCard>
|
||||||
|
<StatNumber>100K+</StatNumber>
|
||||||
|
<StatLabel>注册用户</StatLabel>
|
||||||
|
</StatCard>
|
||||||
|
</StatsGrid>
|
||||||
|
</Hero>
|
||||||
|
|
||||||
|
<MapWrapper>
|
||||||
|
<SectionTitle>📍 选择省份</SectionTitle>
|
||||||
|
{loading ? (
|
||||||
|
<Loading message="加载地图..." />
|
||||||
|
) : (
|
||||||
|
<ChinaMap onProvinceClick={handleProvinceClick} />
|
||||||
|
)}
|
||||||
|
</MapWrapper>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<SectionTitle>📚 最新文章</SectionTitle>
|
||||||
|
{articleStore.loading ? (
|
||||||
|
<Loading message="加载文章..." />
|
||||||
|
) : articleStore.error ? (
|
||||||
|
<div style={{ color: '#dc3545' }}>{articleStore.error}</div>
|
||||||
|
) : articleStore.articles && articleStore.articles.length > 0 ? (
|
||||||
|
<Grid>
|
||||||
|
{articleStore.articles.map((article) => (
|
||||||
|
<Card
|
||||||
|
key={article.id}
|
||||||
|
title={article.title}
|
||||||
|
description={article.summary || article.excerpt}
|
||||||
|
meta={`📍 ${article.region_name || '未知'} · 👁 ${article.views || 0}`}
|
||||||
|
onClick={() => handleArticleClick(article.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Card title="暂无文章" description="内容即将上线,敬请期待!" />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<SectionTitle>🛠️ 精选服务</SectionTitle>
|
||||||
|
{serviceStore.loading ? (
|
||||||
|
<Loading message="加载服务..." />
|
||||||
|
) : serviceStore.error ? (
|
||||||
|
<div style={{ color: '#dc3545' }}>{serviceStore.error}</div>
|
||||||
|
) : serviceStore.services && serviceStore.services.length > 0 ? (
|
||||||
|
<Grid>
|
||||||
|
{serviceStore.services.map((service) => (
|
||||||
|
<Card
|
||||||
|
key={service.id}
|
||||||
|
title={service.name}
|
||||||
|
description={service.description}
|
||||||
|
meta={`📍 ${service.region_name || '未知'} · ⭐ ${service.rating || '新'}`}
|
||||||
|
onClick={() => handleServiceClick(service.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Card title="暂无服务" description="服务即将上线,敬请期待!" />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default HomePage;
|
||||||
@@ -1,78 +1,139 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import styled from 'styled-components';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
|
||||||
import { useRegionStore } from '../../stores/RegionStore';
|
import { useRegionStore } from '../../stores/RegionStore';
|
||||||
import { useArticleStore } from '../../stores/ArticleStore';
|
import styled from 'styled-components';
|
||||||
import { useServiceStore } from '../../stores/ServiceStore';
|
|
||||||
import Card from '../common/Card';
|
import Card from '../common/Card';
|
||||||
import Loading from '../common/Loading';
|
import Loading from '../common/Loading';
|
||||||
import ErrorMessage from '../common/ErrorMessage';
|
import ErrorMessage from '../common/ErrorMessage';
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Header = styled.div`
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.h1`
|
||||||
|
font-size: 32px;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
color: #333;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Subtitle = styled.p`
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
const InfoGrid = styled.div`
|
const InfoGrid = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const InfoItem = styled.div`
|
const InfoCard = styled.div`
|
||||||
background: #f8f9fa;
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
padding: 15px;
|
color: white;
|
||||||
border-radius: 5px;
|
padding: 20px;
|
||||||
strong {
|
border-radius: 8px;
|
||||||
display: block;
|
text-align: center;
|
||||||
margin-bottom: 5px;
|
`;
|
||||||
color: #495057;
|
|
||||||
}
|
const InfoNumber = styled.div`
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const InfoLabel = styled.div`
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.9;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Section = styled.section`
|
||||||
|
margin: 40px 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SectionTitle = styled.h2`
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
color: #333;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Grid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Tabs = styled.div`
|
const Tabs = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
border-bottom: 2px solid #dee2e6;
|
gap: 10px;
|
||||||
margin: 30px 0;
|
margin: 20px 0;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
padding-bottom: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Tab = styled.button`
|
const Tab = styled.button`
|
||||||
padding: 10px 20px;
|
padding: 12px 24px;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
color: ${props => props.active ? '#667eea' : '#6c757d'};
|
font-weight: 500;
|
||||||
border-bottom: 2px solid ${props => props.active ? '#667eea' : 'transparent'};
|
color: ${(props) => (props.active ? '#667eea' : '#6c757d')};
|
||||||
|
border-bottom: 2px solid ${(props) => (props.active ? '#667eea' : 'transparent')};
|
||||||
margin-bottom: -2px;
|
margin-bottom: -2px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: #667eea;
|
color: #667eea;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ContentGrid = styled.div`
|
const EmptyState = styled.div`
|
||||||
display: grid;
|
text-align: center;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
padding: 60px 20px;
|
||||||
gap: 20px;
|
color: #999;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CityDetailPage = observer(() => {
|
const CityDetailPage = observer(({ regionId }) => {
|
||||||
const regionStore = useRegionStore();
|
|
||||||
const articleStore = useArticleStore();
|
|
||||||
const serviceStore = useServiceStore();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [activeTab, setActiveTab] = useState('articles');
|
const regionStore = useRegionStore();
|
||||||
const { regionId } = useParams();
|
const [activeTab, setActiveTab] = useState('cities');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
regionStore.fetchRegion(regionId);
|
regionStore.fetchRegion(regionId);
|
||||||
regionStore.fetchChildren(regionId);
|
regionStore.fetchChildren(regionId);
|
||||||
regionStore.fetchRegionArticles(regionId);
|
|
||||||
regionStore.fetchRegionServices(regionId);
|
|
||||||
}, [regionId, regionStore]);
|
}, [regionId, regionStore]);
|
||||||
|
|
||||||
const handleCityClick = (cityId) => {
|
const handleCityClick = (cityId) => {
|
||||||
navigate(`/cities/${cityId}`);
|
navigate(`/cities/${cityId}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleArticleClick = (articleId) => {
|
||||||
|
navigate(`/articles/${articleId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleServiceClick = (serviceId) => {
|
||||||
|
navigate(`/services/${serviceId}`);
|
||||||
|
};
|
||||||
|
|
||||||
if (regionStore.loading) {
|
if (regionStore.loading) {
|
||||||
return <Loading message="加载城市详情..." />;
|
return <Loading message="加载城市详情..." />;
|
||||||
}
|
}
|
||||||
@@ -81,7 +142,7 @@ const CityDetailPage = observer(() => {
|
|||||||
return (
|
return (
|
||||||
<ErrorMessage
|
<ErrorMessage
|
||||||
message={regionStore.error}
|
message={regionStore.error}
|
||||||
onDismiss={() => regionStore.error = null}
|
onDismiss={() => regionStore.clearError()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -91,87 +152,128 @@ const CityDetailPage = observer(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const region = regionStore.currentRegion;
|
const region = regionStore.currentRegion;
|
||||||
|
const children = regionStore.regions || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Container>
|
||||||
<h1>{region.name}</h1>
|
<Header>
|
||||||
<p>{region.full_path}</p>
|
<Title>📍 {region.name}</Title>
|
||||||
|
<Subtitle>{region.full_path || region.description}</Subtitle>
|
||||||
|
|
||||||
<InfoGrid>
|
<InfoGrid>
|
||||||
<InfoItem>
|
<InfoCard>
|
||||||
<strong>级别</strong>
|
<InfoNumber>{children.length}</InfoNumber>
|
||||||
{region.level_display}
|
<InfoLabel>下级区域</InfoLabel>
|
||||||
</InfoItem>
|
</InfoCard>
|
||||||
<InfoItem>
|
<InfoCard>
|
||||||
<strong>子版块数量</strong>
|
<InfoNumber>{region.articles_count || 0}</InfoNumber>
|
||||||
{region.children_count}
|
<InfoLabel>文章</InfoLabel>
|
||||||
</InfoItem>
|
</InfoCard>
|
||||||
<InfoItem>
|
<InfoCard>
|
||||||
<strong>文章数量</strong>
|
<InfoNumber>{region.services_count || 0}</InfoNumber>
|
||||||
{region.articles_count}
|
<InfoLabel>服务</InfoLabel>
|
||||||
</InfoItem>
|
</InfoCard>
|
||||||
<InfoItem>
|
<InfoCard>
|
||||||
<strong>服务数量</strong>
|
<InfoNumber>{region.level_display || '-'}</InfoNumber>
|
||||||
{region.services_count}
|
<InfoLabel>行政级别</InfoLabel>
|
||||||
</InfoItem>
|
</InfoCard>
|
||||||
</InfoGrid>
|
</InfoGrid>
|
||||||
|
</Header>
|
||||||
|
|
||||||
<h2>下级城市</h2>
|
<Section>
|
||||||
<ContentGrid>
|
<SectionTitle>
|
||||||
{region.children.map((city) => (
|
{activeTab === 'cities' && '🏙️'}
|
||||||
<Card
|
{activeTab === 'articles' && '📚'}
|
||||||
key={city.id}
|
{activeTab === 'services' && '🛠️'}
|
||||||
title={city.name}
|
{activeTab === 'cities' && '下级区域'}
|
||||||
meta={city.level_display}
|
{activeTab === 'articles' && '相关文章'}
|
||||||
onClick={() => handleCityClick(city.id)}
|
{activeTab === 'services' && '本地服务'}
|
||||||
/>
|
</SectionTitle>
|
||||||
))}
|
|
||||||
</ContentGrid>
|
|
||||||
|
|
||||||
<Tabs>
|
<Tabs>
|
||||||
<Tab
|
<Tab active={activeTab === 'cities'} onClick={() => setActiveTab('cities')}>
|
||||||
active={activeTab === 'articles'}
|
下级区域 ({children.length})
|
||||||
onClick={() => setActiveTab('articles')}
|
</Tab>
|
||||||
>
|
<Tab active={activeTab === 'articles'} onClick={() => setActiveTab('articles')}>
|
||||||
文章
|
文章 ({region.articles_count || 0})
|
||||||
</Tab>
|
</Tab>
|
||||||
<Tab
|
<Tab active={activeTab === 'services'} onClick={() => setActiveTab('services')}>
|
||||||
active={activeTab === 'services'}
|
服务 ({region.services_count || 0})
|
||||||
onClick={() => setActiveTab('services')}
|
</Tab>
|
||||||
>
|
</Tabs>
|
||||||
特色服务
|
|
||||||
</Tab>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
{activeTab === 'articles' && (
|
{activeTab === 'cities' && (
|
||||||
<ContentGrid>
|
<>
|
||||||
{region.articles.map((article) => (
|
{children.length > 0 ? (
|
||||||
<Card
|
<Grid>
|
||||||
key={article.id}
|
{children.map((city) => (
|
||||||
title={article.title}
|
<Card
|
||||||
description={article.content.substring(0, 100)}
|
key={city.id}
|
||||||
meta={`作者: ${article.author_username}`}
|
title={city.name}
|
||||||
onClick={() => navigate(`/articles/${article.id}`)}
|
description={city.description || city.full_path}
|
||||||
/>
|
meta={`${city.level_display || ''} · ${city.children_count || 0} 个下级`}
|
||||||
))}
|
onClick={() => handleCityClick(city.id)}
|
||||||
</ContentGrid>
|
/>
|
||||||
)}
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<EmptyState>
|
||||||
|
<h3>暂无下级区域</h3>
|
||||||
|
<p>这是最底层的行政区域</p>
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeTab === 'services' && (
|
{activeTab === 'articles' && (
|
||||||
<ContentGrid>
|
<>
|
||||||
{region.services.map((service) => (
|
{region.articles && region.articles.length > 0 ? (
|
||||||
<Card
|
<Grid>
|
||||||
key={service.id}
|
{region.articles.map((article) => (
|
||||||
title={service.name}
|
<Card
|
||||||
description={service.description.substring(0, 100)}
|
key={article.id}
|
||||||
tags={[service.category_display]}
|
title={article.title}
|
||||||
onClick={() => navigate(`/services/${service.id}`)}
|
description={article.summary || article.excerpt || article.content?.substring(0, 100)}
|
||||||
/>
|
meta={`👁 ${article.views || 0} · ❤️ ${article.likes_count || 0}`}
|
||||||
))}
|
onClick={() => handleArticleClick(article.id)}
|
||||||
</ContentGrid>
|
/>
|
||||||
)}
|
))}
|
||||||
</div>
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<EmptyState>
|
||||||
|
<h3>暂无文章</h3>
|
||||||
|
<p>该地区还没有相关文章</p>
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'services' && (
|
||||||
|
<>
|
||||||
|
{region.services && region.services.length > 0 ? (
|
||||||
|
<Grid>
|
||||||
|
{region.services.map((service) => (
|
||||||
|
<Card
|
||||||
|
key={service.id}
|
||||||
|
title={service.name}
|
||||||
|
description={service.description}
|
||||||
|
tags={service.categories?.map((c) => c.name) || []}
|
||||||
|
meta={`⭐ ${service.rating?.toFixed(1) || '新'}`}
|
||||||
|
onClick={() => handleServiceClick(service.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<EmptyState>
|
||||||
|
<h3>暂无服务</h3>
|
||||||
|
<p>该地区还没有相关服务</p>
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</Container>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default CityDetailPage;
|
export default CityDetailPage;
|
||||||
|
|||||||
@@ -3,84 +3,269 @@ import styled from 'styled-components';
|
|||||||
import { observer } from 'mobx-react-lite';
|
import { observer } from 'mobx-react-lite';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useServiceStore } from '../../stores/ServiceStore';
|
import { useServiceStore } from '../../stores/ServiceStore';
|
||||||
import { useInteractionStore } from '../../stores/InteractionStore';
|
import { useAuthStore } from '../../stores/AuthStore';
|
||||||
import Card from '../common/Card';
|
|
||||||
import Loading from '../common/Loading';
|
import Loading from '../common/Loading';
|
||||||
import ErrorMessage from '../common/ErrorMessage';
|
import ErrorMessage from '../common/ErrorMessage';
|
||||||
|
import Card from '../common/Card';
|
||||||
|
|
||||||
const ServiceCard = styled(Card)`
|
const Container = styled.div`
|
||||||
cursor: pointer;
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Rating = styled.div`
|
const Header = styled.div`
|
||||||
|
margin-bottom: 30px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Image = styled.img`
|
||||||
|
width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.h1`
|
||||||
|
font-size: 32px;
|
||||||
|
margin: 0 0 15px;
|
||||||
|
color: #333;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Category = styled.span`
|
||||||
|
display: inline-block;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Meta = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MetaItem = styled.span`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
margin: 10px 0;
|
`;
|
||||||
|
|
||||||
span {
|
const Description = styled.div`
|
||||||
font-weight: bold;
|
line-height: 1.8;
|
||||||
|
color: #555;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const InfoSection = styled.div`
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 25px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const InfoRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const InfoLabel = styled.span`
|
||||||
|
font-weight: 600;
|
||||||
|
color: #666;
|
||||||
|
width: 100px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const InfoValue = styled.span`
|
||||||
|
color: #333;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const RatingSection = styled.div`
|
||||||
|
margin: 30px 0;
|
||||||
|
padding: 25px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: white;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const RatingTitle = styled.h3`
|
||||||
|
margin: 0 0 15px;
|
||||||
|
font-size: 18px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Stars = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
const Star = styled.span`
|
const Star = styled.span`
|
||||||
color: ${props => props.filled ? '#ffc107' : '#dee2e6'};
|
font-size: 28px;
|
||||||
font-size: 20px;
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
color: ${(props) => (props.filled ? '#ffc107' : 'rgba(255, 255, 255, 0.5)')};
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const RatingText = styled.span`
|
||||||
|
font-size: 16px;
|
||||||
|
margin-left: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Actions = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin: 30px 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ActionButton = styled.button`
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
${(props) =>
|
||||||
|
props.primary &&
|
||||||
|
`
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
|
||||||
|
${(props) =>
|
||||||
|
props.secondary &&
|
||||||
|
`
|
||||||
|
background: white;
|
||||||
|
color: #667eea;
|
||||||
|
border: 1px solid #667eea;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CommentsSection = styled.div`
|
const CommentsSection = styled.div`
|
||||||
margin-top: 40px;
|
margin-top: 40px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const SectionTitle = styled.h2`
|
||||||
|
font-size: 20px;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
color: #333;
|
||||||
|
`;
|
||||||
|
|
||||||
const CommentForm = styled.form`
|
const CommentForm = styled.form`
|
||||||
margin-bottom: 20px;
|
margin-bottom: 30px;
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 100px;
|
min-height: 100px;
|
||||||
padding: 10px;
|
padding: 15px;
|
||||||
border: 1px solid #dee2e6;
|
border: 1px solid #dee2e6;
|
||||||
border-radius: 5px;
|
border-radius: 8px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CommentList = styled.div`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const CommentItem = styled.div`
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ServiceDetailPage = observer(({ serviceId }) => {
|
const ServiceDetailPage = observer(({ serviceId }) => {
|
||||||
const serviceStore = useServiceStore();
|
|
||||||
const interactionStore = useInteractionStore();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const serviceStore = useServiceStore();
|
||||||
|
const authStore = useAuthStore();
|
||||||
const [comment, setComment] = useState('');
|
const [comment, setComment] = useState('');
|
||||||
const [liked, setLiked] = useState(false);
|
const [userRating, setUserRating] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
serviceStore.fetchService(serviceId);
|
serviceStore.fetchService(serviceId);
|
||||||
serviceStore.fetchServiceComments(serviceId);
|
|
||||||
serviceStore.fetchServiceStats(serviceId);
|
|
||||||
}, [serviceId, serviceStore]);
|
}, [serviceId, serviceStore]);
|
||||||
|
|
||||||
const handleLike = async () => {
|
|
||||||
const result = await serviceStore.likeService(serviceId);
|
|
||||||
if (result) {
|
|
||||||
setLiked(result.liked);
|
|
||||||
serviceStore.fetchServiceStats(serviceId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRate = async (score) => {
|
const handleRate = async (score) => {
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
navigate('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setUserRating(score);
|
||||||
const result = await serviceStore.rateService(serviceId, score);
|
const result = await serviceStore.rateService(serviceId, score);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
serviceStore.fetchServiceStats(serviceId);
|
serviceStore.fetchService(serviceId);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleComment = async (e) => {
|
const handleLike = async () => {
|
||||||
e.preventDefault();
|
if (!authStore.isAuthenticated) {
|
||||||
if (!comment.trim()) return;
|
navigate('/login');
|
||||||
|
return;
|
||||||
const result = await interactionStore.createComment('service', serviceId, comment);
|
}
|
||||||
|
const result = await serviceStore.likeService(serviceId);
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
setComment('');
|
serviceStore.fetchService(serviceId);
|
||||||
serviceStore.fetchServiceComments(serviceId);
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleContact = () => {
|
||||||
|
const service = serviceStore.currentService;
|
||||||
|
if (service?.contact) {
|
||||||
|
alert(`联系方式:${service.contact}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -92,7 +277,7 @@ const ServiceDetailPage = observer(({ serviceId }) => {
|
|||||||
return (
|
return (
|
||||||
<ErrorMessage
|
<ErrorMessage
|
||||||
message={serviceStore.error}
|
message={serviceStore.error}
|
||||||
onDismiss={() => serviceStore.error = null}
|
onDismiss={() => serviceStore.clearError()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -102,103 +287,121 @@ const ServiceDetailPage = observer(({ serviceId }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const service = serviceStore.currentService;
|
const service = serviceStore.currentService;
|
||||||
const stats = serviceStore.currentService.stats || {};
|
const rating = service.rating || 0;
|
||||||
|
const reviewsCount = service.reviews_count || 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Container>
|
||||||
{service.image && (
|
<Header>
|
||||||
<img
|
{service.image && <Image src={service.image} alt={service.name} />}
|
||||||
src={service.image}
|
<Category>{service.category_display || '生活服务'}</Category>
|
||||||
alt={service.name}
|
<Title>{service.name}</Title>
|
||||||
style={{
|
<Meta>
|
||||||
width: '100%',
|
<MetaItem>
|
||||||
maxHeight: '400px',
|
📍 {service.region_name || '未知地区'}
|
||||||
objectFit: 'cover',
|
</MetaItem>
|
||||||
borderRadius: '8px',
|
<MetaItem>
|
||||||
marginBottom: '20px',
|
👁 {service.views || 0} 浏览
|
||||||
}}
|
</MetaItem>
|
||||||
/>
|
<MetaItem>
|
||||||
)}
|
❤️ {service.likes_count || 0} 点赞
|
||||||
|
</MetaItem>
|
||||||
|
</Meta>
|
||||||
|
</Header>
|
||||||
|
|
||||||
<h1>{service.name}</h1>
|
<Description>
|
||||||
<p>{service.category_display}</p>
|
{service.description || '暂无详细描述'}
|
||||||
|
</Description>
|
||||||
|
|
||||||
<p>{service.description}</p>
|
<InfoSection>
|
||||||
|
<InfoRow>
|
||||||
|
<InfoLabel>地址</InfoLabel>
|
||||||
|
<InfoValue>{service.address || '未提供'}</InfoValue>
|
||||||
|
</InfoRow>
|
||||||
|
<InfoRow>
|
||||||
|
<InfoLabel>联系方式</InfoLabel>
|
||||||
|
<InfoValue>{service.contact || '未提供'}</InfoValue>
|
||||||
|
</InfoRow>
|
||||||
|
<InfoRow>
|
||||||
|
<InfoLabel>营业时间</InfoLabel>
|
||||||
|
<InfoValue>{service.business_hours || '未提供'}</InfoValue>
|
||||||
|
</InfoRow>
|
||||||
|
<InfoRow>
|
||||||
|
<InfoLabel>发布者</InfoLabel>
|
||||||
|
<InfoValue>{service.provider_name || '未知'}</InfoValue>
|
||||||
|
</InfoRow>
|
||||||
|
</InfoSection>
|
||||||
|
|
||||||
{service.address && (
|
<RatingSection>
|
||||||
<p><strong>地址:</strong> {service.address}</p>
|
<RatingTitle>给这个服务评分</RatingTitle>
|
||||||
)}
|
<Stars>
|
||||||
{service.contact && (
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
<p><strong>联系方式:</strong> {service.contact}</p>
|
<Star
|
||||||
)}
|
key={star}
|
||||||
|
filled={star <= (userRating || Math.round(rating))}
|
||||||
|
onClick={() => handleRate(star)}
|
||||||
|
>
|
||||||
|
★
|
||||||
|
</Star>
|
||||||
|
))}
|
||||||
|
<RatingText>
|
||||||
|
{rating.toFixed(1)} ({reviewsCount} 条评价)
|
||||||
|
</RatingText>
|
||||||
|
</Stars>
|
||||||
|
</RatingSection>
|
||||||
|
|
||||||
<Rating>
|
<Actions>
|
||||||
<span>评分:</span>
|
<ActionButton primary onClick={handleLike}>
|
||||||
{[1, 2, 3, 4, 5].map((star) => (
|
🤍 点赞
|
||||||
<Star
|
</ActionButton>
|
||||||
key={star}
|
<ActionButton secondary onClick={handleContact}>
|
||||||
filled={star <= Math.round(stats.avg_rating || 0)}
|
📞 联系
|
||||||
onClick={() => handleRate(star)}
|
</ActionButton>
|
||||||
style={{ cursor: 'pointer' }}
|
<ActionButton secondary>
|
||||||
>
|
🔗 分享
|
||||||
★
|
</ActionButton>
|
||||||
</Star>
|
</Actions>
|
||||||
))}
|
|
||||||
<span>
|
|
||||||
{stats.avg_rating || 0} ({stats.ratings_count || 0} 评分)
|
|
||||||
</span>
|
|
||||||
</Rating>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
<strong>点赞:</strong> {stats.likes_count || 0} |
|
|
||||||
<strong>评论:</strong> {stats.comments_count || 0}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<CommentsSection>
|
<CommentsSection>
|
||||||
<h2>评论 ({stats.comments_count || 0})</h2>
|
<SectionTitle>💬 评价 ({reviewsCount})</SectionTitle>
|
||||||
|
|
||||||
<CommentForm onSubmit={handleComment}>
|
{authStore.isAuthenticated ? (
|
||||||
<textarea
|
<CommentForm
|
||||||
placeholder="写下你的评论..."
|
onSubmit={async (e) => {
|
||||||
value={comment}
|
e.preventDefault();
|
||||||
onChange={(e) => setComment(e.target.value)}
|
if (!comment.trim()) return;
|
||||||
|
// TODO: 实现评价创建
|
||||||
|
setComment('');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
placeholder="分享你的使用体验..."
|
||||||
|
value={comment}
|
||||||
|
onChange={(e) => setComment(e.target.value)}
|
||||||
|
/>
|
||||||
|
<ActionButton type="submit" primary style={{ marginTop: '10px' }}>
|
||||||
|
发表评价
|
||||||
|
</ActionButton>
|
||||||
|
</CommentForm>
|
||||||
|
) : (
|
||||||
|
<Card
|
||||||
|
title="登录后评价"
|
||||||
|
description="登录后可发表评价和参与互动"
|
||||||
|
onClick={() => navigate('/login')}
|
||||||
|
style={{ cursor: 'pointer', marginBottom: '20px' }}
|
||||||
/>
|
/>
|
||||||
<button
|
)}
|
||||||
type="submit"
|
|
||||||
style={{
|
|
||||||
padding: '10px 20px',
|
|
||||||
background: '#667eea',
|
|
||||||
color: 'white',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '5px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
marginTop: '10px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
发表评论
|
|
||||||
</button>
|
|
||||||
</CommentForm>
|
|
||||||
|
|
||||||
{service.currentService.comments && service.currentService.comments.map((comment) => (
|
<CommentList>
|
||||||
<div
|
{/* TODO: 加载并显示评价 */}
|
||||||
key={comment.id}
|
<CommentItem>
|
||||||
style={{
|
<div className="author">暂无评价</div>
|
||||||
background: '#f8f9fa',
|
<div className="content">成为第一个评价的人吧!</div>
|
||||||
padding: '15px',
|
</CommentItem>
|
||||||
borderRadius: '5px',
|
</CommentList>
|
||||||
marginBottom: '15px',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<p><strong>{comment.author_username}</strong></p>
|
|
||||||
<p>{comment.content}</p>
|
|
||||||
<p style={{ color: '#999', fontSize: '14px' }}>
|
|
||||||
{comment.created_at}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CommentsSection>
|
</CommentsSection>
|
||||||
</div>
|
</Container>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ServiceDetailPage;
|
export default ServiceDetailPage;
|
||||||
|
|||||||
201
frontend/src/components/service/ServicesPage.js
Normal file
201
frontend/src/components/service/ServicesPage.js
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { useServiceStore } from '../../stores/ServiceStore';
|
||||||
|
import { useRegionStore } from '../../stores/RegionStore';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Card from '../common/Card';
|
||||||
|
import Loading from '../common/Loading';
|
||||||
|
import ErrorMessage from '../common/ErrorMessage';
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Header = styled.div`
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.h1`
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #333;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Subtitle = styled.p`
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 16px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Grid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||||
|
gap: 25px;
|
||||||
|
padding: 20px 0;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const FilterBar = styled.div`
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Select = styled.select`
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SearchInput = styled.input`
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Rating = styled.span`
|
||||||
|
color: #ffc107;
|
||||||
|
font-size: 14px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ServicesPage = observer(() => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const serviceStore = useServiceStore();
|
||||||
|
const regionStore = useRegionStore();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
serviceStore.fetchServices();
|
||||||
|
regionStore.fetchProvinces();
|
||||||
|
}, [serviceStore, regionStore]);
|
||||||
|
|
||||||
|
const handleServiceClick = (serviceId) => {
|
||||||
|
navigate(`/services/${serviceId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRegionChange = (e) => {
|
||||||
|
const regionId = e.target.value;
|
||||||
|
if (regionId) {
|
||||||
|
setSearchParams({ region: regionId });
|
||||||
|
serviceStore.fetchServicesByRegion(regionId);
|
||||||
|
} else {
|
||||||
|
setSearchParams({});
|
||||||
|
serviceStore.fetchServices();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSearch = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
serviceStore.searchServices(searchQuery);
|
||||||
|
} else {
|
||||||
|
serviceStore.fetchServices();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedRegion = searchParams.get('region');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Header>
|
||||||
|
<Title>🛠️ 本地服务</Title>
|
||||||
|
<Subtitle>发现身边的优质生活服务</Subtitle>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<FilterBar>
|
||||||
|
<Select value={selectedRegion || ''} onChange={handleRegionChange}>
|
||||||
|
<option value="">全部地区</option>
|
||||||
|
{regionStore.regions.map((region) => (
|
||||||
|
<option key={region.id} value={region.id}>
|
||||||
|
{region.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearch} style={{ display: 'flex', gap: '10px', flex: 1 }}>
|
||||||
|
<SearchInput
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索服务名称或描述..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
background: '#667eea',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
搜索
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</FilterBar>
|
||||||
|
|
||||||
|
{serviceStore.loading ? (
|
||||||
|
<Loading message="加载服务..." />
|
||||||
|
) : serviceStore.error ? (
|
||||||
|
<ErrorMessage
|
||||||
|
message={serviceStore.error}
|
||||||
|
onDismiss={() => (serviceStore.error = null)}
|
||||||
|
/>
|
||||||
|
) : serviceStore.services && serviceStore.services.length > 0 ? (
|
||||||
|
<Grid>
|
||||||
|
{serviceStore.services.map((service) => (
|
||||||
|
<Card
|
||||||
|
key={service.id}
|
||||||
|
title={service.name}
|
||||||
|
description={service.description}
|
||||||
|
tags={service.categories?.map((cat) => cat.name) || []}
|
||||||
|
meta={
|
||||||
|
<span>
|
||||||
|
📍 {service.region_name || '未知'} ·{' '}
|
||||||
|
<Rating>
|
||||||
|
{'⭐'.repeat(Math.floor(service.rating || 0))}{' '}
|
||||||
|
{service.rating?.toFixed(1) || '新'}
|
||||||
|
</Rating>{' '}
|
||||||
|
· 👁 {service.views || 0}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
onClick={() => handleServiceClick(service.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<Card
|
||||||
|
title="暂无服务"
|
||||||
|
description="还没有服务信息,成为第一个发布者吧!"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ServicesPage;
|
||||||
308
frontend/src/components/user/UserProfilePage.js
Normal file
308
frontend/src/components/user/UserProfilePage.js
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
import { useAuthStore } from '../../stores/AuthStore';
|
||||||
|
import { useUserStore } from '../../stores/UserStore';
|
||||||
|
import styled from 'styled-components';
|
||||||
|
import Loading from '../common/Loading';
|
||||||
|
import ErrorMessage from '../common/ErrorMessage';
|
||||||
|
|
||||||
|
const Container = styled.div`
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Header = styled.div`
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Title = styled.h1`
|
||||||
|
margin: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #333;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProfileCard = styled.div`
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const ProfileHeader = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Avatar = styled.div`
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 32px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 700;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const UserInfo = styled.div`
|
||||||
|
flex: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const UserName = styled.h2`
|
||||||
|
margin: 0 0 5px;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #333;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const UserEmail = styled.p`
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatsGrid = styled.div`
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 20px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatItem = styled.div`
|
||||||
|
text-align: center;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatNumber = styled.div`
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StatLabel = styled.div`
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Section = styled.div`
|
||||||
|
margin-bottom: 30px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SectionTitle = styled.h3`
|
||||||
|
margin: 0 0 15px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #333;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const InfoRow = styled.div`
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const InfoLabel = styled.span`
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const InfoValue = styled.span`
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const Button = styled.button`
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
margin-right: 10px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
background: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const SecondaryButton = styled.button`
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: white;
|
||||||
|
color: #667eea;
|
||||||
|
border: 1px solid #667eea;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const UserProfilePage = observer(() => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
navigate('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
userStore.fetchCurrentUser();
|
||||||
|
}, [authStore, userStore, navigate]);
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
authStore.logout();
|
||||||
|
userStore.clearUser();
|
||||||
|
navigate('/');
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!authStore.isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Loading message="验证中..." />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userStore.loading) {
|
||||||
|
return <Loading message="加载用户信息..." />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userStore.error) {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<ErrorMessage
|
||||||
|
message={userStore.error}
|
||||||
|
onDismiss={() => (userStore.error = null)}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userStore.user;
|
||||||
|
if (!user) {
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<ErrorMessage message="未找到用户信息" />
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInitials = (name) => {
|
||||||
|
return name?.charAt(0).toUpperCase() || 'U';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Header>
|
||||||
|
<Title>个人中心</Title>
|
||||||
|
</Header>
|
||||||
|
|
||||||
|
<ProfileCard>
|
||||||
|
<ProfileHeader>
|
||||||
|
<Avatar>{getInitials(user.username)}</Avatar>
|
||||||
|
<UserInfo>
|
||||||
|
<UserName>{user.username}</UserName>
|
||||||
|
<UserEmail>{user.email}</UserEmail>
|
||||||
|
</UserInfo>
|
||||||
|
</ProfileHeader>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<SectionTitle>📊 我的统计</SectionTitle>
|
||||||
|
<StatsGrid>
|
||||||
|
<StatItem>
|
||||||
|
<StatNumber>{user.articles_count || 0}</StatNumber>
|
||||||
|
<StatLabel>文章</StatLabel>
|
||||||
|
</StatItem>
|
||||||
|
<StatItem>
|
||||||
|
<StatNumber>{user.services_count || 0}</StatNumber>
|
||||||
|
<StatLabel>服务</StatLabel>
|
||||||
|
</StatItem>
|
||||||
|
<StatItem>
|
||||||
|
<StatNumber>{user.comments_count || 0}</StatNumber>
|
||||||
|
<StatLabel>评论</StatLabel>
|
||||||
|
</StatItem>
|
||||||
|
<StatItem>
|
||||||
|
<StatNumber>{user.likes_count || 0}</StatNumber>
|
||||||
|
<StatLabel>点赞</StatLabel>
|
||||||
|
</StatItem>
|
||||||
|
<StatItem>
|
||||||
|
<StatNumber>{user.favorites_count || 0}</StatNumber>
|
||||||
|
<StatLabel>收藏</StatLabel>
|
||||||
|
</StatItem>
|
||||||
|
</StatsGrid>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<SectionTitle>ℹ️ 账户信息</SectionTitle>
|
||||||
|
<InfoRow>
|
||||||
|
<InfoLabel>用户名</InfoLabel>
|
||||||
|
<InfoValue>{user.username}</InfoValue>
|
||||||
|
</InfoRow>
|
||||||
|
<InfoRow>
|
||||||
|
<InfoLabel>邮箱</InfoLabel>
|
||||||
|
<InfoValue>{user.email}</InfoValue>
|
||||||
|
</InfoRow>
|
||||||
|
<InfoRow>
|
||||||
|
<InfoLabel>角色</InfoLabel>
|
||||||
|
<InfoValue>{user.role_display || '普通用户'}</InfoValue>
|
||||||
|
</InfoRow>
|
||||||
|
<InfoRow>
|
||||||
|
<InfoLabel>注册时间</InfoLabel>
|
||||||
|
<InfoValue>
|
||||||
|
{user.date_joined ? new Date(user.date_joined).toLocaleDateString('zh-CN') : '未知'}
|
||||||
|
</InfoValue>
|
||||||
|
</InfoRow>
|
||||||
|
<InfoRow>
|
||||||
|
<InfoLabel>上次登录</InfoLabel>
|
||||||
|
<InfoValue>
|
||||||
|
{user.last_login ? new Date(user.last_login).toLocaleString('zh-CN') : '未知'}
|
||||||
|
</InfoValue>
|
||||||
|
</InfoRow>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '30px' }}>
|
||||||
|
<SecondaryButton onClick={() => setEditing(!editing)}>
|
||||||
|
{editing ? '取消编辑' : '编辑资料'}
|
||||||
|
</SecondaryButton>
|
||||||
|
<Button onClick={handleLogout} style={{ background: '#dc3545' }}>
|
||||||
|
退出登录
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ProfileCard>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default UserProfilePage;
|
||||||
@@ -7,147 +7,233 @@ class ArticleStore {
|
|||||||
currentArticle = null;
|
currentArticle = null;
|
||||||
loading = false;
|
loading = false;
|
||||||
error = null;
|
error = null;
|
||||||
|
pagination = {
|
||||||
|
count: 0,
|
||||||
|
next: null,
|
||||||
|
previous: null,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchArticles(params = {}) {
|
async fetchArticles(page = 1, pageSize = 12) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/api/articles/', { params });
|
const response = await api.get('/api/articles/', {
|
||||||
|
params: { page, page_size: pageSize },
|
||||||
|
});
|
||||||
|
this.articles = response.data.results || response.data;
|
||||||
|
this.pagination = {
|
||||||
|
count: response.data.count || this.articles.length,
|
||||||
|
next: response.data.next,
|
||||||
|
previous: response.data.previous,
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: Math.ceil((response.data.count || this.articles.length) / pageSize),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '获取文章列表失败';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchLatestArticles(limit = 6) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/articles/latest/', {
|
||||||
|
params: { limit },
|
||||||
|
});
|
||||||
|
this.articles = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '获取最新文章失败';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchArticlesByRegion(regionId, page = 1) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/regions/${regionId}/articles/`, {
|
||||||
|
params: { page },
|
||||||
|
});
|
||||||
this.articles = response.data.results || response.data;
|
this.articles = response.data.results || response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error.response?.data || 'Failed to fetch articles';
|
this.error = error.response?.data?.detail || '获取地区文章失败';
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchArticle(id) {
|
async searchArticles(query, page = 1) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/api/articles/${id}/`);
|
const response = await api.get('/api/articles/search/', {
|
||||||
|
params: { q: query, page },
|
||||||
|
});
|
||||||
|
this.articles = response.data.results || response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '搜索文章失败';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchArticle(articleId) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/articles/${articleId}/`);
|
||||||
this.currentArticle = response.data;
|
this.currentArticle = response.data;
|
||||||
} catch (error) {
|
|
||||||
this.error = error.response?.data || 'Failed to fetch article';
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createArticle(data) {
|
|
||||||
this.loading = true;
|
|
||||||
this.error = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.post('/api/articles/', data);
|
|
||||||
return { success: true, article: response.data };
|
|
||||||
} catch (error) {
|
|
||||||
this.error = error.response?.data || 'Failed to create article';
|
|
||||||
return { success: false, error: this.error };
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateArticle(id, data) {
|
|
||||||
this.loading = true;
|
|
||||||
this.error = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.put(`/api/articles/${id}/`, data);
|
|
||||||
return { success: true, article: response.data };
|
|
||||||
} catch (error) {
|
|
||||||
this.error = error.response?.data || 'Failed to update article';
|
|
||||||
return { success: false, error: this.error };
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteArticle(id) {
|
|
||||||
try {
|
|
||||||
await api.delete(`/api/articles/${id}/`);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.response?.data || 'Failed to delete article',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async submitArticle(id) {
|
|
||||||
try {
|
|
||||||
await api.post(`/api/articles/${id}/submit/`);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.response?.data || 'Failed to submit article',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async approveArticle(id, reason = '') {
|
|
||||||
try {
|
|
||||||
await api.post(`/api/articles/${id}/approve/`, { action: 'approve', reason });
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.response?.data || 'Failed to approve article',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async rejectArticle(id, reason) {
|
|
||||||
try {
|
|
||||||
await api.post(`/api/articles/${id}/reject/`, { action: 'reject', reason });
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.response?.data || 'Failed to reject article',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async likeArticle(id) {
|
|
||||||
try {
|
|
||||||
const response = await api.post(`/api/articles/${id}/like/`);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '获取文章详情失败';
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createArticle(articleData) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.post('/api/articles/', articleData);
|
||||||
|
return { success: true, article: response.data };
|
||||||
|
} catch (error) {
|
||||||
|
const errors = error.response?.data;
|
||||||
|
if (errors) {
|
||||||
|
const errorMessages = [];
|
||||||
|
Object.keys(errors).forEach((key) => {
|
||||||
|
const value = errors[key];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
errorMessages.push(`${key}: ${value.join(', ')}`);
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
errorMessages.push(`${key}: ${value}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.error = errorMessages.join('; ') || '创建文章失败';
|
||||||
|
} else {
|
||||||
|
this.error = '创建文章失败';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.error,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateArticle(articleId, articleData) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.patch(`/api/articles/${articleId}/`, articleData);
|
||||||
|
this.currentArticle = response.data;
|
||||||
|
return { success: true, article: response.data };
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '更新文章失败';
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.error,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteArticle(articleId) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/articles/${articleId}/`);
|
||||||
|
this.articles = this.articles.filter((a) => a.id !== articleId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '删除文章失败';
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.error,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async likeArticle(articleId) {
|
||||||
|
try {
|
||||||
|
const response = await api.post(`/api/articles/${articleId}/like/`);
|
||||||
|
if (this.currentArticle && this.currentArticle.id === articleId) {
|
||||||
|
this.currentArticle = { ...this.currentArticle, ...response.data };
|
||||||
|
}
|
||||||
|
this.articles = this.articles.map((a) =>
|
||||||
|
a.id === articleId ? { ...a, ...response.data } : a
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.detail || '点赞失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async favoriteArticle(articleId) {
|
||||||
|
try {
|
||||||
|
await api.post(`/api/articles/${articleId}/favorite/`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.detail || '收藏失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getArticleRating(articleId) {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/articles/${articleId}/my_rating/`);
|
||||||
|
return response.data.score;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchArticleComments(id) {
|
async rateArticle(articleId, score) {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/api/articles/${id}/comments/`);
|
await api.post(`/api/articles/${articleId}/rate/`, { score });
|
||||||
return response.data;
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return [];
|
return {
|
||||||
}
|
success: false,
|
||||||
}
|
error: error.response?.data?.detail || '评分失败',
|
||||||
|
};
|
||||||
async fetchArticleStats(id) {
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/api/articles/${id}/stats/`);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearCurrentArticle() {
|
clearCurrentArticle() {
|
||||||
this.currentArticle = null;
|
this.currentArticle = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearError() {
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ArticleStore;
|
export default ArticleStore;
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { makeAutoObservable } from 'mobx';
|
import { makeAutoObservable } from 'mobx';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
// eslint-disable-next-line no-unused-vars
|
||||||
|
import api from '../services/api';
|
||||||
|
|
||||||
class AuthStore {
|
class AuthStore {
|
||||||
token = localStorage.getItem('token') || null;
|
token = localStorage.getItem('token') || null;
|
||||||
|
refreshToken = localStorage.getItem('refresh') || null;
|
||||||
isAuthenticated = !!localStorage.getItem('token');
|
isAuthenticated = !!localStorage.getItem('token');
|
||||||
|
loading = false;
|
||||||
|
error = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async login(email, password) {
|
async login(email, password) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/auth/login/', {
|
const response = await axios.post('/api/auth/login/', {
|
||||||
email,
|
email,
|
||||||
@@ -18,28 +26,118 @@ class AuthStore {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.token = response.data.access;
|
this.token = response.data.access;
|
||||||
|
this.refreshToken = response.data.refresh;
|
||||||
this.isAuthenticated = true;
|
this.isAuthenticated = true;
|
||||||
localStorage.setItem('token', this.token);
|
localStorage.setItem('token', this.token);
|
||||||
localStorage.setItem('refresh', response.data.refresh);
|
localStorage.setItem('refresh', this.refreshToken);
|
||||||
|
|
||||||
|
// 设置全局认证头
|
||||||
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
|
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || error.response?.data?.message || '登录失败,请检查账号和密码';
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error.response?.data?.detail || 'Login failed',
|
error: this.error,
|
||||||
};
|
};
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async register(username, email, password, confirmPassword) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
this.error = '两次输入的密码不一致';
|
||||||
|
this.loading = false;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/auth/register/', {
|
||||||
|
username,
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 注册成功后自动登录
|
||||||
|
if (response.data.access) {
|
||||||
|
this.token = response.data.access;
|
||||||
|
this.refreshToken = response.data.refresh;
|
||||||
|
this.isAuthenticated = true;
|
||||||
|
localStorage.setItem('token', this.token);
|
||||||
|
localStorage.setItem('refresh', this.refreshToken);
|
||||||
|
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
// 处理字段级错误
|
||||||
|
const errors = error.response?.data;
|
||||||
|
if (errors) {
|
||||||
|
const errorMessages = [];
|
||||||
|
Object.keys(errors).forEach((key) => {
|
||||||
|
const value = errors[key];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
errorMessages.push(`${key}: ${value.join(', ')}`);
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
errorMessages.push(`${key}: ${value}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.error = errorMessages.join('; ') || '注册失败';
|
||||||
|
} else {
|
||||||
|
this.error = '注册失败,请稍后重试';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.error,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logout() {
|
logout() {
|
||||||
this.token = null;
|
this.token = null;
|
||||||
|
this.refreshToken = null;
|
||||||
this.isAuthenticated = false;
|
this.isAuthenticated = false;
|
||||||
|
this.error = null;
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('refresh');
|
localStorage.removeItem('refresh');
|
||||||
delete axios.defaults.headers.common['Authorization'];
|
delete axios.defaults.headers.common['Authorization'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refreshTokenIfNeeded() {
|
||||||
|
if (!this.refreshToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/auth/token/refresh/', {
|
||||||
|
refresh: this.refreshToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.token = response.data.access;
|
||||||
|
localStorage.setItem('token', this.token);
|
||||||
|
axios.defaults.headers.common['Authorization'] = `Bearer ${this.token}`;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// Token 刷新失败,需要重新登录
|
||||||
|
this.logout();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError() {
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AuthStore;
|
export default AuthStore;
|
||||||
|
|||||||
@@ -7,159 +7,245 @@ class ServiceStore {
|
|||||||
currentService = null;
|
currentService = null;
|
||||||
loading = false;
|
loading = false;
|
||||||
error = null;
|
error = null;
|
||||||
|
pagination = {
|
||||||
|
count: 0,
|
||||||
|
next: null,
|
||||||
|
previous: null,
|
||||||
|
currentPage: 1,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchServices(params = {}) {
|
async fetchServices(page = 1, pageSize = 12) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/api/services/', { params });
|
const response = await api.get('/api/services/', {
|
||||||
|
params: { page, page_size: pageSize },
|
||||||
|
});
|
||||||
|
this.services = response.data.results || response.data;
|
||||||
|
this.pagination = {
|
||||||
|
count: response.data.count || this.services.length,
|
||||||
|
next: response.data.next,
|
||||||
|
previous: response.data.previous,
|
||||||
|
currentPage: page,
|
||||||
|
totalPages: Math.ceil((response.data.count || this.services.length) / pageSize),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '获取服务列表失败';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchFeaturedServices(limit = 6) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get('/api/services/featured/', {
|
||||||
|
params: { limit },
|
||||||
|
});
|
||||||
|
this.services = response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '获取精选服务失败';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchServicesByRegion(regionId, page = 1) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/regions/${regionId}/services/`, {
|
||||||
|
params: { page },
|
||||||
|
});
|
||||||
this.services = response.data.results || response.data;
|
this.services = response.data.results || response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error.response?.data || 'Failed to fetch services';
|
this.error = error.response?.data?.detail || '获取地区服务失败';
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchService(id) {
|
async searchServices(query, page = 1) {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/api/services/${id}/`);
|
const response = await api.get('/api/services/search/', {
|
||||||
|
params: { q: query, page },
|
||||||
|
});
|
||||||
|
this.services = response.data.results || response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '搜索服务失败';
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchService(serviceId) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/services/${serviceId}/`);
|
||||||
this.currentService = response.data;
|
this.currentService = response.data;
|
||||||
} catch (error) {
|
|
||||||
this.error = error.response?.data || 'Failed to fetch service';
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async createService(data) {
|
|
||||||
this.loading = true;
|
|
||||||
this.error = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.post('/api/services/', data);
|
|
||||||
return { success: true, service: response.data };
|
|
||||||
} catch (error) {
|
|
||||||
this.error = error.response?.data || 'Failed to create service';
|
|
||||||
return { success: false, error: this.error };
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateService(id, data) {
|
|
||||||
this.loading = true;
|
|
||||||
this.error = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await api.put(`/api/services/${id}/`, data);
|
|
||||||
return { success: true, service: response.data };
|
|
||||||
} catch (error) {
|
|
||||||
this.error = error.response?.data || 'Failed to update service';
|
|
||||||
return { success: false, error: this.error };
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteService(id) {
|
|
||||||
try {
|
|
||||||
await api.delete(`/api/services/${id}/`);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.response?.data || 'Failed to delete service',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async submitService(id) {
|
|
||||||
try {
|
|
||||||
await api.post(`/api/services/${id}/submit/`);
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.response?.data || 'Failed to submit service',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async approveService(id, reason = '') {
|
|
||||||
try {
|
|
||||||
await api.post(`/api/services/${id}/approve/`, { action: 'approve', reason });
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.response?.data || 'Failed to approve service',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async rejectService(id, reason) {
|
|
||||||
try {
|
|
||||||
await api.post(`/api/services/${id}/reject/`, { action: 'reject', reason });
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: error.response?.data || 'Failed to reject service',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async likeService(id) {
|
|
||||||
try {
|
|
||||||
const response = await api.post(`/api/services/${id}/like/`);
|
|
||||||
return response.data;
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '获取服务详情失败';
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createService(serviceData) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.post('/api/services/', serviceData);
|
||||||
|
return { success: true, service: response.data };
|
||||||
|
} catch (error) {
|
||||||
|
const errors = error.response?.data;
|
||||||
|
if (errors) {
|
||||||
|
const errorMessages = [];
|
||||||
|
Object.keys(errors).forEach((key) => {
|
||||||
|
const value = errors[key];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
errorMessages.push(`${key}: ${value.join(', ')}`);
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
errorMessages.push(`${key}: ${value}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.error = errorMessages.join('; ') || '创建服务失败';
|
||||||
|
} else {
|
||||||
|
this.error = '创建服务失败';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.error,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateService(serviceId, serviceData) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.patch(`/api/services/${serviceId}/`, serviceData);
|
||||||
|
this.currentService = response.data;
|
||||||
|
return { success: true, service: response.data };
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '更新服务失败';
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.error,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteService(serviceId) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/api/services/${serviceId}/`);
|
||||||
|
this.services = this.services.filter((s) => s.id !== serviceId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '删除服务失败';
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.error,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async likeService(serviceId) {
|
||||||
|
try {
|
||||||
|
const response = await api.post(`/api/services/${serviceId}/like/`);
|
||||||
|
if (this.currentService && this.currentService.id === serviceId) {
|
||||||
|
this.currentService = { ...this.currentService, ...response.data };
|
||||||
|
}
|
||||||
|
this.services = this.services.map((s) =>
|
||||||
|
s.id === serviceId ? { ...s, ...response.data } : s
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.detail || '点赞失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async favoriteService(serviceId) {
|
||||||
|
try {
|
||||||
|
await api.post(`/api/services/${serviceId}/favorite/`);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.detail || '收藏失败',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServiceRating(serviceId) {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/services/${serviceId}/my_rating/`);
|
||||||
|
return response.data.score;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async rateService(id, score) {
|
async rateService(serviceId, score) {
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/services/${id}/rate/`, { score });
|
await api.post(`/api/services/${serviceId}/rate/`, { score });
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: error.response?.data || 'Failed to rate service',
|
error: error.response?.data?.detail || '评分失败',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchServiceComments(id) {
|
async bookService(serviceId, bookingData) {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/api/services/${id}/comments/`);
|
const response = await api.post(`/api/services/${serviceId}/bookings/`, bookingData);
|
||||||
return response.data;
|
return { success: true, booking: response.data };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return [];
|
return {
|
||||||
}
|
success: false,
|
||||||
}
|
error: error.response?.data?.detail || '预约失败',
|
||||||
|
};
|
||||||
async fetchServiceStats(id) {
|
|
||||||
try {
|
|
||||||
const response = await api.get(`/api/services/${id}/stats/`);
|
|
||||||
return response.data;
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
clearCurrentService() {
|
clearCurrentService() {
|
||||||
this.currentService = null;
|
this.currentService = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearError() {
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ServiceStore;
|
export default ServiceStore;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { makeAutoObservable } from 'mobx';
|
import { makeAutoObservable } from 'mobx';
|
||||||
import axios from 'axios';
|
import api from '../services/api';
|
||||||
|
|
||||||
class UserStore {
|
class UserStore {
|
||||||
user = null;
|
user = null;
|
||||||
loading = false;
|
loading = false;
|
||||||
error = null;
|
error = null;
|
||||||
|
editing = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
makeAutoObservable(this);
|
makeAutoObservable(this);
|
||||||
@@ -16,10 +17,120 @@ class UserStore {
|
|||||||
this.error = null;
|
this.error = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/users/me/');
|
const response = await api.get('/api/users/me/');
|
||||||
this.user = response.data;
|
this.user = response.data;
|
||||||
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.error = error.response?.data || 'Failed to fetch user';
|
this.error = error.response?.data?.detail || '获取用户信息失败';
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUser(userData) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.patch('/api/users/me/', userData);
|
||||||
|
this.user = response.data;
|
||||||
|
return { success: true, user: response.data };
|
||||||
|
} catch (error) {
|
||||||
|
const errors = error.response?.data;
|
||||||
|
if (errors) {
|
||||||
|
const errorMessages = [];
|
||||||
|
Object.keys(errors).forEach((key) => {
|
||||||
|
const value = errors[key];
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
errorMessages.push(`${key}: ${value.join(', ')}`);
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
errorMessages.push(`${key}: ${value}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.error = errorMessages.join('; ') || '更新失败';
|
||||||
|
} else {
|
||||||
|
this.error = '更新用户信息失败';
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.error,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async changePassword(currentPassword, newPassword, confirmPassword) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
if (newPassword !== confirmPassword) {
|
||||||
|
this.error = '两次输入的新密码不一致';
|
||||||
|
this.loading = false;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.post('/api/users/change_password/', {
|
||||||
|
current_password: currentPassword,
|
||||||
|
new_password: newPassword,
|
||||||
|
});
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '修改密码失败';
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.error,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserProfile(userId) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/users/${userId}/`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '获取用户信息失败';
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserArticles(userId) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/users/${userId}/articles/`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '获取用户文章失败';
|
||||||
|
return [];
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserServices(userId) {
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/api/users/${userId}/services/`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
this.error = error.response?.data?.detail || '获取用户服务失败';
|
||||||
|
return [];
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
@@ -27,6 +138,16 @@ class UserStore {
|
|||||||
|
|
||||||
clearUser() {
|
clearUser() {
|
||||||
this.user = null;
|
this.user = null;
|
||||||
|
this.error = null;
|
||||||
|
this.editing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearError() {
|
||||||
|
this.error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditing(value) {
|
||||||
|
this.editing = value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,18 +7,136 @@ const GlobalStyle = createGlobalStyle`
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue',
|
||||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
'Arial', 'Noto Sans', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei',
|
||||||
sans-serif;
|
sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
#root {
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
min-height: 100vh;
|
||||||
monospace;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: #5568d3;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #a8a8a8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Utility Classes */
|
||||||
|
.text-center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-1 { margin-top: 0.25rem; }
|
||||||
|
.mt-2 { margin-top: 0.5rem; }
|
||||||
|
.mt-3 { margin-top: 1rem; }
|
||||||
|
.mt-4 { margin-top: 1.5rem; }
|
||||||
|
.mt-5 { margin-top: 3rem; }
|
||||||
|
|
||||||
|
.mb-1 { margin-bottom: 0.25rem; }
|
||||||
|
.mb-2 { margin-bottom: 0.5rem; }
|
||||||
|
.mb-3 { margin-bottom: 1rem; }
|
||||||
|
.mb-4 { margin-bottom: 1.5rem; }
|
||||||
|
.mb-5 { margin-bottom: 3rem; }
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default GlobalStyle;
|
export default GlobalStyle;
|
||||||
|
|||||||
Reference in New Issue
Block a user