Compare commits
91 Commits
860253bf40
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1caa64fe71 | ||
|
|
4f87427a77 | ||
|
|
f1f1e0df5a | ||
|
|
ed6905a22e | ||
|
|
0ba05cb687 | ||
|
|
9608b6c4a5 | ||
|
|
5aebc5c635 | ||
|
|
709b710d97 | ||
|
|
c22afc236e | ||
|
|
388e7e878d | ||
|
|
ec67bed6f4 | ||
|
|
77b8376878 | ||
|
|
bb373f3c60 | ||
|
|
46003a0439 | ||
|
|
431ddaf67c | ||
|
|
41d6991d5c | ||
|
|
94333b61b6 | ||
|
|
2104e7b7dc | ||
|
|
2e05322909 | ||
|
|
0274e59fd9 | ||
|
|
8bc06b0423 | ||
|
|
98baa92e98 | ||
|
|
06afd11f1c | ||
|
|
4de4ff91f3 | ||
|
|
b39e500307 | ||
|
|
07006d46d9 | ||
|
|
76bb5945ac | ||
|
|
d76b5845a1 | ||
|
|
76f7b2bcbe | ||
|
|
c62c5b98ea | ||
|
|
0d7ba5d87c | ||
|
|
98db4d6f75 | ||
|
|
02335d26c2 | ||
|
|
da235c3a82 | ||
|
|
f25c35af40 | ||
|
|
465ea34dcd | ||
|
|
bd102cc71f | ||
|
|
6a166c50eb | ||
|
|
75dbf22a43 | ||
|
|
7695ac3edf | ||
|
|
3d94a1f0de | ||
|
|
f72293eb76 | ||
|
|
35d96588f9 | ||
|
|
afab4933b4 | ||
|
|
4d6f98080e | ||
|
|
de1e409447 | ||
|
|
6aaddfbe9e | ||
|
|
f23e477f57 | ||
|
|
1f693e0e8a | ||
|
|
6129673ddc | ||
|
|
8b6773bb98 | ||
|
|
d28ecf98ea | ||
|
|
8b11d0aab1 | ||
|
|
dbd752b833 | ||
|
|
5a87105ec9 | ||
|
|
c32522857e | ||
|
|
c1fadf1344 | ||
|
|
cb10c42d11 | ||
|
|
758eee8ac6 | ||
|
|
809aab9e02 | ||
|
|
a346872a99 | ||
|
|
373a82151f | ||
|
|
f14d52f69b | ||
|
|
791afa52eb | ||
|
|
2b7f0a6317 | ||
|
|
071970e043 | ||
|
|
599b3cded7 | ||
|
|
852bc74bc1 | ||
|
|
2c17e3bcd7 | ||
|
|
9fac4c222e | ||
|
|
7612c09571 | ||
|
|
f41fd01367 | ||
|
|
b0aa902f89 | ||
|
|
44d90e643f | ||
|
|
504db66b0b | ||
|
|
b0e97ed140 | ||
|
|
59bd66459a | ||
|
|
886ffec374 | ||
|
|
60423c4323 | ||
|
|
188f1fd22d | ||
|
|
aa2d96b242 | ||
|
|
926a9e7b5f | ||
|
|
290b345404 | ||
|
|
ea1a5e8c59 | ||
|
|
7e3600a6d2 | ||
|
|
5b29396830 | ||
|
|
6d5be796cd | ||
|
|
84e36bdd02 | ||
|
|
d7f9d7ed8b | ||
|
|
c750dce569 | ||
|
|
26f9192ce5 |
@@ -40,6 +40,20 @@ jobs:
|
|||||||
git pull
|
git pull
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# 3.1 创建/更新 .env 文件 (从本地环境变量注入)
|
||||||
|
echo -e "\n===== 配置环境变量 ====="
|
||||||
|
cat > backend/.env <<EOF
|
||||||
|
# Aliyun OSS Configuration
|
||||||
|
ALIYUN_ACCESS_KEY_ID=LTAI5tE62GW8MKyoEaotzxXk
|
||||||
|
ALIYUN_ACCESS_KEY_SECRET=Zdzqo1fgj57DxxioXOotNKhJdSfVQW
|
||||||
|
ALIYUN_OSS_ENDPOINT=https://oss-cn-shanghai.aliyuncs.com
|
||||||
|
ALIYUN_OSS_BUCKET_NAME=tangledup-ai-staging
|
||||||
|
ALIYUN_OSS_INTERNAL_ENDPOINT=https://oss-cn-shanghai-internal.aliyuncs.com
|
||||||
|
# Aliyun Tingwu Configuration
|
||||||
|
ALIYUN_TINGWU_APP_KEY=6eOX7N3tKE0fDwb
|
||||||
|
DASHSCOPE_API_KEY=sk-84e9eef24a274f568d4fa15c97556c9f
|
||||||
|
EOF
|
||||||
|
|
||||||
# 4. 重新启动 Docker 容器
|
# 4. 重新启动 Docker 容器
|
||||||
echo -e "\n===== 启动 Docker 容器 ====="
|
echo -e "\n===== 启动 Docker 容器 ====="
|
||||||
echo $SUDO_PASSWORD | sudo -S docker compose up -d --build
|
echo $SUDO_PASSWORD | sudo -S docker compose up -d --build
|
||||||
|
|||||||
93
README.md
93
README.md
@@ -10,12 +10,46 @@ Quant Speed Market 是一个基于现代技术栈构建的综合性平台,旨
|
|||||||
|
|
||||||
## ✨ 功能特性
|
## ✨ 功能特性
|
||||||
|
|
||||||
- **🛍️ 电商商城**:支持商品浏览、购物车、微信支付 (WeChat Pay V3)、订单管理。
|
### 🛍️ 电商商城系统
|
||||||
- **💬 社区论坛**:支持发帖、回帖、话题分类、富文本编辑。
|
- **商品管理**:ESP32硬件配置、库存管理、3D模型展示、产品特性标签
|
||||||
- **🤖 AI 服务**:集成 AI 工具,提供智能辅助服务。
|
- **订单管理**:多类型订单(硬件/课程/活动)、完整状态流转、物流跟踪
|
||||||
- **🕶️ AR/3D 展示**:基于 Three.js 的 3D 模型预览与 AR 交互体验。
|
- **支付系统**:微信支付V3集成、多种支付方式、安全签名验证、支付回调处理
|
||||||
- **📱 多端适配**:微信小程序原生体验,Web 端响应式管理后台。
|
- **分销系统**:二级分销体系、邀请机制、佣金计算(一级10%/二级2%)、提现管理
|
||||||
- **🔒 安全认证**:微信一键登录、手机号绑定、JWT 认证。
|
- **课程系统**:视频课程、固定时间课程、讲师管理、课程报名与咨询
|
||||||
|
|
||||||
|
### 💬 社区论坛系统
|
||||||
|
- **活动管理**:线上线下活动、报名表单自定义、支付状态同步、审核机制
|
||||||
|
- **论坛帖子**:技术讨论、求助问答、经验分享、官方公告四大分类
|
||||||
|
- **互动功能**:点赞、置顶、嵌套回复(楼中楼)、多媒体附件支持
|
||||||
|
- **公告系统**:时效控制、跳转链接、优先级排序、置顶功能
|
||||||
|
|
||||||
|
### 🤖 AI 服务系统
|
||||||
|
- **语音转写**:阿里云听悟集成、多格式音频支持、说话人分离、状态自动刷新
|
||||||
|
- **AI智能评估**:多模型支持(通义千问系列)、模板化评估、0-100分制评分、详细评语生成
|
||||||
|
- **智能总结**:多类型总结(段落/对话/问答/思维导图)、Markdown格式输出、异步生成机制
|
||||||
|
- **比赛集成**:AI评委身份、评分维度映射、自动评分同步、人工干预支持
|
||||||
|
|
||||||
|
### 🏆 竞赛评审系统
|
||||||
|
- **比赛管理**:多状态流程(草稿→发布→报名→提交→评审→结束)、时间管理、可见性控制
|
||||||
|
- **项目管理**:文件附件支持(PPT/PDF/图片/视频)、封面展示、状态管理
|
||||||
|
- **评分系统**:多维度评分、权重配置、评委评语、防重复评分机制
|
||||||
|
- **权限控制**:选手/评委/嘉宾三角色体系、报名审核、角色权限管理
|
||||||
|
|
||||||
|
### 🕶️ AR/3D 展示
|
||||||
|
- **3D模型预览**:基于Three.js的交互式3D模型展示
|
||||||
|
- **AR交互体验**:增强现实功能集成
|
||||||
|
- **多媒体支持**:图片、视频、文件等多格式媒体处理
|
||||||
|
|
||||||
|
### 📱 多端适配
|
||||||
|
- **微信小程序**:Taro框架开发、原生小程序体验、分包优化
|
||||||
|
- **Web管理端**:React + Ant Design、响应式设计、管理后台功能
|
||||||
|
- **跨平台支持**:可扩展至H5、支付宝小程序等平台
|
||||||
|
|
||||||
|
### 🔒 安全认证
|
||||||
|
- **微信登录**:小程序code换取session、OpenID/UnionID管理
|
||||||
|
- **手机验证**:验证码登录、手机号绑定、用户合并机制
|
||||||
|
- **JWT认证**:Token-based身份验证、API访问控制
|
||||||
|
- **权限验证**:基于角色的访问控制、操作权限验证
|
||||||
|
|
||||||
## 🛠️ 技术栈与依赖
|
## 🛠️ 技术栈与依赖
|
||||||
|
|
||||||
@@ -23,8 +57,11 @@ Quant Speed Market 是一个基于现代技术栈构建的综合性平台,旨
|
|||||||
- **Framework**: Django 6.0 + Django REST Framework 3.16
|
- **Framework**: Django 6.0 + Django REST Framework 3.16
|
||||||
- **Database**: PostgreSQL (psycopg2)
|
- **Database**: PostgreSQL (psycopg2)
|
||||||
- **Payment**: WeChat Pay V3 (wechatpayv3)
|
- **Payment**: WeChat Pay V3 (wechatpayv3)
|
||||||
|
- **AI Services**: 阿里云听悟 (语音转写)、通义千问 (AI评估)
|
||||||
|
- **Cloud Storage**: 阿里云OSS (文件存储)
|
||||||
- **Documentation**: drf-spectacular (OpenAPI 3.0)
|
- **Documentation**: drf-spectacular (OpenAPI 3.0)
|
||||||
- **Deployment**: Docker, Gunicorn
|
- **Deployment**: Docker, Gunicorn
|
||||||
|
- **Authentication**: JWT + 微信OAuth2.0
|
||||||
|
|
||||||
### Frontend (Web 端)
|
### Frontend (Web 端)
|
||||||
- **Core**: React 19 + Vite 7
|
- **Core**: React 19 + Vite 7
|
||||||
@@ -164,6 +201,11 @@ docker-compose up -d --build
|
|||||||
| POST | `/api/shop/orders/` | 创建新订单 |
|
| POST | `/api/shop/orders/` | 创建新订单 |
|
||||||
| POST | `/api/shop/pay/` | 发起微信支付 |
|
| POST | `/api/shop/pay/` | 发起微信支付 |
|
||||||
| GET | `/api/community/topics/` | 获取论坛话题列表 |
|
| GET | `/api/community/topics/` | 获取论坛话题列表 |
|
||||||
|
| POST | `/api/ai/transcription/` | 创建语音转写任务 |
|
||||||
|
| GET | `/api/ai/transcription/{id}/` | 获取转写任务状态 |
|
||||||
|
| POST | `/api/competition/projects/` | 提交参赛项目 |
|
||||||
|
| GET | `/api/competition/projects/{id}/score/` | 获取项目评分 |
|
||||||
|
| POST | `/api/competition/scoring/` | 评委提交评分 |
|
||||||
|
|
||||||
**API 文档**: 启动后端后访问 `http://localhost:8000/api/schema/swagger-ui/` 查看完整 Swagger 文档。
|
**API 文档**: 启动后端后访问 `http://localhost:8000/api/schema/swagger-ui/` 查看完整 Swagger 文档。
|
||||||
|
|
||||||
@@ -172,25 +214,46 @@ docker-compose up -d --build
|
|||||||
```
|
```
|
||||||
market_page/
|
market_page/
|
||||||
├── backend/ # Django 后端源码
|
├── backend/ # Django 后端源码
|
||||||
|
│ ├── ai_services/ # AI服务模块 (语音转写、AI评估)
|
||||||
|
│ │ ├── models.py # 转写任务、AI评估模板模型
|
||||||
|
│ │ ├── views.py # API接口 (转写、评估、总结)
|
||||||
|
│ │ └── services.py # 阿里云听悟、通义千问服务集成
|
||||||
│ ├── community/ # 论坛社区模块
|
│ ├── community/ # 论坛社区模块
|
||||||
|
│ │ ├── models.py # 活动、帖子、回复、公告模型
|
||||||
|
│ │ ├── views.py # 社区API接口
|
||||||
|
│ │ └── admin_actions.py # 后台管理动作
|
||||||
|
│ ├── competition/ # 竞赛评审模块
|
||||||
|
│ │ ├── models.py # 比赛、项目、评分、维度模型
|
||||||
|
│ │ ├── judge_views.py # 评委系统接口
|
||||||
|
│ │ └── templates/ # 评委系统前端页面
|
||||||
│ ├── shop/ # 电商与支付模块
|
│ ├── shop/ # 电商与支付模块
|
||||||
|
│ │ ├── models.py # 商品、订单、支付、用户模型
|
||||||
|
│ │ ├── services.py # 微信支付、短信服务
|
||||||
|
│ │ └── admin_actions.py # 订单管理动作
|
||||||
│ ├── config/ # 项目核心配置
|
│ ├── config/ # 项目核心配置
|
||||||
|
│ │ ├── settings.py # Django配置
|
||||||
|
│ │ └── urls.py # 主路由配置
|
||||||
│ ├── uploads/ # 用户上传文件 (媒体资源)
|
│ ├── uploads/ # 用户上传文件 (媒体资源)
|
||||||
│ ├── manage.py # Django 管理脚本
|
│ ├── manage.py # Django 管理脚本
|
||||||
│ └── requirements.txt # Python 依赖
|
│ ├── requirements.txt # Python 依赖
|
||||||
|
│ └── Dockerfile # 后端容器配置
|
||||||
├── frontend/ # React Web 端源码
|
├── frontend/ # React Web 端源码
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── components/ # 公共组件 (3D模型、弹窗等)
|
│ │ ├── components/ # 公共组件 (3D模型、弹窗等)
|
||||||
│ │ ├── pages/ # 页面路由 (Home, Forum, Payment)
|
│ │ ├── pages/ # 页面路由 (Home, Forum, Payment)
|
||||||
|
│ │ ├── hooks/ # 自定义React Hooks
|
||||||
│ │ └── assets/ # 静态资源
|
│ │ └── assets/ # 静态资源
|
||||||
|
│ ├── public/ # 公共资源
|
||||||
│ └── vite.config.js # Vite 配置
|
│ └── vite.config.js # Vite 配置
|
||||||
├── miniprogram/ # Taro 小程序源码
|
├── miniprogram/ # Taro 小程序源码
|
||||||
│ ├── src/
|
│ ├── src/
|
||||||
│ │ ├── pages/ # 小程序页面
|
│ │ ├── pages/ # 小程序页面
|
||||||
│ │ ├── subpackages/ # 分包页面 (分销、论坛详情等)
|
│ │ ├── subpackages/ # 分包页面 (分销、论坛详情等)
|
||||||
│ │ └── components/ # 小程序组件
|
│ │ ├── components/ # 小程序组件
|
||||||
|
│ │ └── utils/ # 工具函数
|
||||||
│ └── project.config.json # 微信小程序配置
|
│ └── project.config.json # 微信小程序配置
|
||||||
└── docker-compose.yml # Docker 编排文件
|
├── docker-compose.yml # Docker 编排文件
|
||||||
|
└── README.md # 项目文档
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🤝 贡献规范
|
## 🤝 贡献规范
|
||||||
@@ -226,6 +289,18 @@ market_page/
|
|||||||
- **Q: 微信支付接口调用失败?**
|
- **Q: 微信支付接口调用失败?**
|
||||||
- A: 微信支付依赖真实商户号和证书,本地开发请使用模拟数据或沙箱环境。
|
- A: 微信支付依赖真实商户号和证书,本地开发请使用模拟数据或沙箱环境。
|
||||||
|
|
||||||
|
- **Q: AI语音转写任务状态一直显示"处理中"?**
|
||||||
|
- A: 检查阿里云听悟服务配置是否正确,包括AccessKey、AppKey等参数。可通过`python manage.py check_aliyun_config`命令验证配置。
|
||||||
|
|
||||||
|
- **Q: AI评估功能无法正常使用?**
|
||||||
|
- A: 确保通义千问API密钥已正确配置,检查模型调用配额是否充足。评估模板中的提示词需要符合模型要求。
|
||||||
|
|
||||||
|
- **Q: 分销佣金没有正确计算?**
|
||||||
|
- A: 检查产品是否设置了独立分润比例,确认分销员状态为"正常",查看佣金日志了解具体计算过程。
|
||||||
|
|
||||||
|
- **Q: 竞赛项目无法提交?**
|
||||||
|
- A: 确认比赛状态为"作品提交中",检查是否已报名该比赛,确保每人每比赛只能提交一个项目。
|
||||||
|
|
||||||
## 📜 许可证
|
## 📜 许可证
|
||||||
|
|
||||||
本项目采用 [MIT License](LICENSE) 许可证。
|
本项目采用 [MIT License](LICENSE) 许可证。
|
||||||
|
|||||||
@@ -6,4 +6,4 @@ ALIYUN_OSS_BUCKET_NAME=tangledup-ai-staging
|
|||||||
ALIYUN_OSS_INTERNAL_ENDPOINT=https://oss-cn-shanghai-internal.aliyuncs.com
|
ALIYUN_OSS_INTERNAL_ENDPOINT=https://oss-cn-shanghai-internal.aliyuncs.com
|
||||||
|
|
||||||
# Aliyun Tingwu Configuration
|
# Aliyun Tingwu Configuration
|
||||||
ALIYUN_TINGWU_APP_KEY=
|
ALIYUN_TINGWU_APP_KEY=6eOX7N3tKE0fDwb
|
||||||
|
|||||||
54
backend/DEPLOY.md
Normal file
54
backend/DEPLOY.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# 评委端系统部署说明
|
||||||
|
|
||||||
|
## 1. 系统概述
|
||||||
|
本系统为基于 Django 的后端渲染 HTML 评委端,提供评委登录、项目查看、打分点评、音频上传与 AI 服务管理功能。
|
||||||
|
|
||||||
|
## 2. 依赖环境
|
||||||
|
- Python 3.8+
|
||||||
|
- Django 3.2+
|
||||||
|
- Aliyun SDK (aliyun-python-sdk-core, aliyun-python-sdk-tingwu, oss2)
|
||||||
|
- requests
|
||||||
|
|
||||||
|
确保 `requirements.txt` 中包含以上依赖。
|
||||||
|
|
||||||
|
## 3. 环境变量
|
||||||
|
系统依赖以下环境变量(在 `backend/config/settings.py` 或 `.env` 文件中配置):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 数据库配置
|
||||||
|
DB_NAME=your_db_name
|
||||||
|
DB_USER=your_db_user
|
||||||
|
DB_PASSWORD=your_db_password
|
||||||
|
DB_HOST=your_db_host
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# 阿里云配置 (用于音频上传与 AI 服务)
|
||||||
|
ALIYUN_ACCESS_KEY_ID=your_access_key_id
|
||||||
|
ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret
|
||||||
|
ALIYUN_OSS_BUCKET_NAME=your_bucket_name
|
||||||
|
ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
|
||||||
|
ALIYUN_TINGWU_APP_KEY=your_tingwu_app_key
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 启动脚本
|
||||||
|
使用提供的 `start_judge_system.sh` 启动服务。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x start_judge_system.sh
|
||||||
|
./start_judge_system.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
该脚本将执行数据库迁移并启动 Django 开发服务器。生产环境建议使用 Gunicorn + Nginx。
|
||||||
|
|
||||||
|
## 5. 访问地址
|
||||||
|
- 评委端入口: `http://localhost:8000/competition/admin/` (自动跳转至登录或仪表盘)
|
||||||
|
- 评委端主页: `http://localhost:8000/judge/dashboard/`
|
||||||
|
- AI 管理页: `http://localhost:8000/judge/ai/manage/`
|
||||||
|
|
||||||
|
## 6. 审计日志
|
||||||
|
所有关键操作(登录、打分、上传、删除)均记录在项目根目录下的 `judge_audit.log` 文件中。格式如下:
|
||||||
|
`[YYYY-MM-DD HH:MM:SS] IP:127.0.0.1 | Phone:13800000000 | Action:LOGIN | Target:System | Result:SUCCESS | Details:...`
|
||||||
|
|
||||||
|
## 7. 注意事项
|
||||||
|
- 登录需使用已在后台绑定且角色为“评委”的手机号。
|
||||||
|
- 验证码在开发模式下通过控制台输出,或使用默认测试码 `8888`。
|
||||||
@@ -14,6 +14,7 @@ RUN pip install --upgrade pip && pip install -r requirements.txt
|
|||||||
|
|
||||||
# Copy project
|
# Copy project
|
||||||
COPY . /app/
|
COPY . /app/
|
||||||
|
COPY .env /app/
|
||||||
|
|
||||||
# Expose port
|
# Expose port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
44
backend/TEST_REPORT.md
Normal file
44
backend/TEST_REPORT.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# 评委端系统测试报告
|
||||||
|
|
||||||
|
## 1. 测试环境
|
||||||
|
- 系统版本: MacOS 14.5
|
||||||
|
- Python: 3.9
|
||||||
|
- Django: 3.2.20
|
||||||
|
- 数据库: PostgreSQL / SQLite (Development)
|
||||||
|
|
||||||
|
## 2. 功能测试
|
||||||
|
|
||||||
|
### 2.1 评委登录
|
||||||
|
- **场景**: 输入已绑定评委角色的手机号。
|
||||||
|
- **操作**: 点击“发送验证码”,输入控制台显示的验证码或默认测试码 `8888`。
|
||||||
|
- **结果**: 成功登录,跳转至 `/judge/dashboard/`。
|
||||||
|
- **异常场景**: 输入未绑定手机号、输入错误验证码,均提示相应错误信息。
|
||||||
|
|
||||||
|
### 2.2 项目列表 (仪表盘)
|
||||||
|
- **场景**: 登录后查看所负责比赛的项目。
|
||||||
|
- **结果**: 列表展示正确,包含封面、选手名、当前状态。点击“详情 & 评分”弹出模态框。
|
||||||
|
|
||||||
|
### 2.3 评分与点评
|
||||||
|
- **场景**: 在详情模态框中调整评分滑块,输入评语,点击提交。
|
||||||
|
- **结果**: 页面提示“已保存”,刷新后数据持久化。
|
||||||
|
- **审计日志**: `judge_audit.log` 记录 `SCORE_UPDATE` 操作。
|
||||||
|
|
||||||
|
### 2.4 音频上传
|
||||||
|
- **场景**: 点击“批量上传音频”,选择 MP3/MP4 文件,关联项目。
|
||||||
|
- **结果**: 进度条显示上传进度,完成后自动跳转至 AI 管理页面。
|
||||||
|
- **审计日志**: `judge_audit.log` 记录 `UPLOAD_AUDIO` 操作。
|
||||||
|
|
||||||
|
### 2.5 AI 服务管理
|
||||||
|
- **场景**: 在 AI 管理页面查看任务状态。
|
||||||
|
- **操作**: 点击“刷新状态”,如果任务完成,状态变更为“成功”,并可查看结果。
|
||||||
|
- **结果**: 成功展示 AI 生成的逐字稿、总结和评分。
|
||||||
|
- **删除操作**: 点击“删除”,确认后记录消失,审计日志记录 `DELETE_TASK`。
|
||||||
|
|
||||||
|
## 3. 性能与兼容性
|
||||||
|
- **响应式**: 在 iPhone/iPad 模拟器下布局自适应,操作流畅。
|
||||||
|
- **并发**: 批量上传 5 个文件,均能正常创建任务并返回。
|
||||||
|
|
||||||
|
## 4. 安全性
|
||||||
|
- **权限控制**: 尝试访问非本人负责项目的详情 API,返回 403 Forbidden。
|
||||||
|
- **Session**: 登出后 Session 清除,无法通过 URL 直接访问受保护页面。
|
||||||
|
- **CSRF**: 所有 POST 请求均携带 CSRF Token。
|
||||||
@@ -1,7 +1,16 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.admin import ModelAdmin
|
|
||||||
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
||||||
from .models import TranscriptionTask
|
from unfold.admin import StackedInline as UnfoldStackedInline
|
||||||
|
from .models import TranscriptionTask, AIEvaluation, AIEvaluationTemplate
|
||||||
|
|
||||||
|
class AIEvaluationInline(UnfoldStackedInline):
|
||||||
|
model = AIEvaluation
|
||||||
|
extra = 0
|
||||||
|
can_delete = True
|
||||||
|
verbose_name = "AI评估结果"
|
||||||
|
verbose_name_plural = "AI评估结果"
|
||||||
|
readonly_fields = ['created_at', 'updated_at', 'raw_response', 'reasoning', 'template']
|
||||||
|
fields = ('template', 'score', 'evaluation', 'model_selection', 'prompt', 'reasoning', 'status', 'error_message')
|
||||||
|
|
||||||
@admin.register(TranscriptionTask)
|
@admin.register(TranscriptionTask)
|
||||||
class TranscriptionTaskAdmin(UnfoldModelAdmin):
|
class TranscriptionTaskAdmin(UnfoldModelAdmin):
|
||||||
@@ -9,3 +18,30 @@ class TranscriptionTaskAdmin(UnfoldModelAdmin):
|
|||||||
list_filter = ['status', 'created_at']
|
list_filter = ['status', 'created_at']
|
||||||
search_fields = ['id', 'task_id', 'transcription', 'summary']
|
search_fields = ['id', 'task_id', 'transcription', 'summary']
|
||||||
readonly_fields = ['id', 'created_at', 'updated_at', 'task_id']
|
readonly_fields = ['id', 'created_at', 'updated_at', 'task_id']
|
||||||
|
inlines = [AIEvaluationInline]
|
||||||
|
|
||||||
|
@admin.register(AIEvaluationTemplate)
|
||||||
|
class AIEvaluationTemplateAdmin(UnfoldModelAdmin):
|
||||||
|
list_display = ['name', 'model_selection', 'score_dimension', 'is_default', 'is_active', 'created_at']
|
||||||
|
list_filter = ['is_active', 'is_default', 'model_selection', 'created_at']
|
||||||
|
search_fields = ['name', 'prompt']
|
||||||
|
|
||||||
|
@admin.register(AIEvaluation)
|
||||||
|
class AIEvaluationAdmin(UnfoldModelAdmin):
|
||||||
|
list_display = ['id', 'task', 'template', 'score', 'status', 'model_selection', 'created_at']
|
||||||
|
list_filter = ['status', 'model_selection', 'created_at', 'template']
|
||||||
|
search_fields = ['task__id', 'evaluation', 'reasoning']
|
||||||
|
readonly_fields = ['id', 'created_at', 'updated_at', 'raw_response']
|
||||||
|
fieldsets = (
|
||||||
|
(None, {
|
||||||
|
'fields': ('task', 'template', 'status', 'score', 'evaluation')
|
||||||
|
}),
|
||||||
|
('配置快照', {
|
||||||
|
'fields': ('model_selection', 'prompt'),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
}),
|
||||||
|
('调试信息', {
|
||||||
|
'fields': ('raw_response', 'reasoning', 'error_message'),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|||||||
323
backend/ai_services/bailian_service.py
Normal file
323
backend/ai_services/bailian_service.py
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from django.conf import settings
|
||||||
|
from openai import OpenAI
|
||||||
|
from .models import AIEvaluation
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class BailianService:
|
||||||
|
def __init__(self):
|
||||||
|
self.api_key = getattr(settings, 'DASHSCOPE_API_KEY', None)
|
||||||
|
if not self.api_key:
|
||||||
|
self.api_key = os.environ.get("DASHSCOPE_API_KEY")
|
||||||
|
|
||||||
|
if self.api_key:
|
||||||
|
self.client = OpenAI(
|
||||||
|
api_key=self.api_key,
|
||||||
|
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.client = None
|
||||||
|
logger.warning("DASHSCOPE_API_KEY not configured.")
|
||||||
|
|
||||||
|
def evaluate_task(self, evaluation: AIEvaluation):
|
||||||
|
"""
|
||||||
|
执行AI评估
|
||||||
|
"""
|
||||||
|
if not self.client:
|
||||||
|
evaluation.status = AIEvaluation.Status.FAILED
|
||||||
|
evaluation.error_message = "服务未配置 (DASHSCOPE_API_KEY missing)"
|
||||||
|
evaluation.save()
|
||||||
|
return
|
||||||
|
|
||||||
|
task = evaluation.task
|
||||||
|
if not task.transcription:
|
||||||
|
evaluation.status = AIEvaluation.Status.FAILED
|
||||||
|
evaluation.error_message = "关联任务无逐字稿内容"
|
||||||
|
evaluation.save()
|
||||||
|
return
|
||||||
|
|
||||||
|
evaluation.status = AIEvaluation.Status.PROCESSING
|
||||||
|
evaluation.save()
|
||||||
|
|
||||||
|
try:
|
||||||
|
prompt = evaluation.prompt
|
||||||
|
content = task.transcription
|
||||||
|
|
||||||
|
# 准备章节/时间戳数据以辅助分析发言节奏
|
||||||
|
chapter_context = ""
|
||||||
|
if task.auto_chapters_data:
|
||||||
|
try:
|
||||||
|
chapters_str = ""
|
||||||
|
# 处理特定的 AutoChapters 结构
|
||||||
|
# 格式: {"AutoChapters": [{"Id": 1, "Start": 740, "End": 203436, "Headline": "...", "Summary": "..."}, ...]}
|
||||||
|
if isinstance(task.auto_chapters_data, dict) and 'AutoChapters' in task.auto_chapters_data:
|
||||||
|
chapters = task.auto_chapters_data['AutoChapters']
|
||||||
|
if isinstance(chapters, list):
|
||||||
|
chapter_lines = []
|
||||||
|
for ch in chapters:
|
||||||
|
# 毫秒转 MM:SS
|
||||||
|
start_ms = ch.get('Start', 0)
|
||||||
|
end_ms = ch.get('End', 0)
|
||||||
|
start_str = f"{start_ms // 60000:02d}:{(start_ms // 1000) % 60:02d}"
|
||||||
|
end_str = f"{end_ms // 60000:02d}:{(end_ms // 1000) % 60:02d}"
|
||||||
|
|
||||||
|
headline = ch.get('Headline', '无标题')
|
||||||
|
summary = ch.get('Summary', '')
|
||||||
|
|
||||||
|
line = f"- [{start_str} - {end_str}] {headline}"
|
||||||
|
if summary:
|
||||||
|
line += f"\n 摘要: {summary}"
|
||||||
|
chapter_lines.append(line)
|
||||||
|
|
||||||
|
chapters_str = "\n".join(chapter_lines)
|
||||||
|
|
||||||
|
# 如果上面的解析为空(或者格式不匹配),回退到通用 JSON dump
|
||||||
|
if not chapters_str:
|
||||||
|
if isinstance(task.auto_chapters_data, (dict, list)):
|
||||||
|
chapters_str = json.dumps(task.auto_chapters_data, ensure_ascii=False, indent=2)
|
||||||
|
else:
|
||||||
|
chapters_str = str(task.auto_chapters_data)
|
||||||
|
|
||||||
|
chapter_context = f"\n\n【章节与时间戳信息】\n{chapters_str}\n\n(提示:请结合上述章节时间戳信息,分析发言者的语速、节奏变化及停顿情况。)"
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to process auto_chapters_data: {e}")
|
||||||
|
|
||||||
|
# 截断过长的内容以防止超出Token限制 (简单处理,取前10000字)
|
||||||
|
if len(content) > 10000:
|
||||||
|
content = content[:10000] + "...(内容过长已截断)"
|
||||||
|
|
||||||
|
# Construct messages
|
||||||
|
messages = [
|
||||||
|
{'role': 'system', 'content': 'You are a helpful assistant designed to output JSON.'},
|
||||||
|
{'role': 'user', 'content': f"{prompt}\n\n以下是需要评估的内容:\n{content}{chapter_context}"}
|
||||||
|
]
|
||||||
|
|
||||||
|
# 增加重试机制 (最多重试3次)
|
||||||
|
completion = None
|
||||||
|
last_error = None
|
||||||
|
import time
|
||||||
|
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
completion = self.client.chat.completions.create(
|
||||||
|
model=evaluation.model_selection,
|
||||||
|
messages=messages,
|
||||||
|
response_format={"type": "json_object"}
|
||||||
|
)
|
||||||
|
break # 成功则跳出循环
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
logger.warning(f"AI Evaluation attempt {attempt+1}/3 failed for eval {evaluation.id}: {e}")
|
||||||
|
if attempt < 2:
|
||||||
|
time.sleep(2 * (attempt + 1)) # 简单的指数退避
|
||||||
|
|
||||||
|
if not completion:
|
||||||
|
raise last_error or Exception("AI Service call failed after retries")
|
||||||
|
|
||||||
|
response_content = completion.choices[0].message.content
|
||||||
|
# Convert to dict for storage
|
||||||
|
raw_response = completion.model_dump()
|
||||||
|
|
||||||
|
evaluation.raw_response = raw_response
|
||||||
|
|
||||||
|
# Parse JSON
|
||||||
|
try:
|
||||||
|
result = json.loads(response_content)
|
||||||
|
evaluation.score = result.get('score')
|
||||||
|
evaluation.evaluation = result.get('evaluation') or result.get('comment')
|
||||||
|
|
||||||
|
# 尝试获取推理过程(如果模型返回了)
|
||||||
|
evaluation.reasoning = result.get('reasoning') or result.get('analysis')
|
||||||
|
|
||||||
|
if not evaluation.reasoning:
|
||||||
|
# 如果JSON里没有,把整个JSON作为推理参考
|
||||||
|
evaluation.reasoning = json.dumps(result, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
evaluation.status = AIEvaluation.Status.COMPLETED
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
evaluation.status = AIEvaluation.Status.FAILED
|
||||||
|
evaluation.error_message = f"无法解析JSON响应: {response_content}"
|
||||||
|
evaluation.reasoning = response_content
|
||||||
|
|
||||||
|
evaluation.save()
|
||||||
|
|
||||||
|
# 同步结果到参赛项目 (如果关联了)
|
||||||
|
self._sync_evaluation_to_project(evaluation)
|
||||||
|
|
||||||
|
return evaluation
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"AI Evaluation failed: {e}")
|
||||||
|
evaluation.status = AIEvaluation.Status.FAILED
|
||||||
|
evaluation.error_message = str(e)
|
||||||
|
evaluation.save()
|
||||||
|
return evaluation
|
||||||
|
|
||||||
|
def _sync_evaluation_to_project(self, evaluation: AIEvaluation):
|
||||||
|
"""
|
||||||
|
将AI评估结果同步到关联的参赛项目(评分和评语)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
task = evaluation.task
|
||||||
|
if not task.project:
|
||||||
|
return
|
||||||
|
|
||||||
|
project = task.project
|
||||||
|
competition = project.competition
|
||||||
|
|
||||||
|
# 1. 确定评委身份 (Based on Template)
|
||||||
|
# 用户要求:评委显示的是模板名称
|
||||||
|
template_name = evaluation.template.name if evaluation.template else "AI智能评委"
|
||||||
|
# 使用固定前缀 + template_id 确保唯一性,这样同一个模板在不同项目里是同一个评委
|
||||||
|
openid = f"ai_judge_{evaluation.template.id}" if evaluation.template else "ai_judge_default"
|
||||||
|
|
||||||
|
# 延迟导入以避免循环依赖
|
||||||
|
from shop.models import WeChatUser
|
||||||
|
from competition.models import CompetitionEnrollment, Score, Comment, ScoreDimension
|
||||||
|
|
||||||
|
# 获取或创建虚拟评委用户
|
||||||
|
user, created = WeChatUser.objects.get_or_create(
|
||||||
|
openid=openid,
|
||||||
|
defaults={
|
||||||
|
'nickname': template_name,
|
||||||
|
'avatar_url': 'https://ui-avatars.com/api/?name=AI&background=random&color=fff'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 如果名字不匹配(比如模板改名了),更新它
|
||||||
|
if user.nickname != template_name:
|
||||||
|
user.nickname = template_name
|
||||||
|
user.save(update_fields=['nickname'])
|
||||||
|
|
||||||
|
# 2. 确保评委已报名 (Enrollment)
|
||||||
|
enrollment, _ = CompetitionEnrollment.objects.get_or_create(
|
||||||
|
competition=competition,
|
||||||
|
user=user,
|
||||||
|
defaults={
|
||||||
|
'role': 'judge',
|
||||||
|
'status': 'approved'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. 同步评分 (Score)
|
||||||
|
if evaluation.score is not None:
|
||||||
|
# 尝试找到匹配的维度
|
||||||
|
dimensions = competition.score_dimensions.all()
|
||||||
|
target_dimension = None
|
||||||
|
|
||||||
|
# 0. 优先使用模板配置的维度
|
||||||
|
if evaluation.template and evaluation.template.score_dimension:
|
||||||
|
# 检查配置的维度是否属于当前比赛
|
||||||
|
if evaluation.template.score_dimension.competition_id == competition.id:
|
||||||
|
target_dimension = evaluation.template.score_dimension
|
||||||
|
else:
|
||||||
|
# 如果不属于当前比赛(跨比赛复用模板),尝试查找同名维度
|
||||||
|
target_dimension = dimensions.filter(name=evaluation.template.score_dimension.name).first()
|
||||||
|
|
||||||
|
# 1. 如果未配置或未找到,尝试匹配 "AI Rating" (用户指定默认值)
|
||||||
|
if not target_dimension:
|
||||||
|
target_dimension = dimensions.filter(name__iexact="AI Rating").first()
|
||||||
|
|
||||||
|
# 2. 尝试匹配包含 "AI" 的维度
|
||||||
|
if not target_dimension:
|
||||||
|
for dim in dimensions:
|
||||||
|
if "AI" in dim.name.upper():
|
||||||
|
target_dimension = dim
|
||||||
|
break
|
||||||
|
|
||||||
|
# 3. 尝试匹配模板名称
|
||||||
|
if not target_dimension:
|
||||||
|
target_dimension = dimensions.filter(name=template_name).first()
|
||||||
|
|
||||||
|
# 4. 最后兜底:使用第一个维度
|
||||||
|
if not target_dimension and dimensions.exists():
|
||||||
|
target_dimension = dimensions.first()
|
||||||
|
|
||||||
|
if target_dimension:
|
||||||
|
Score.objects.update_or_create(
|
||||||
|
project=project,
|
||||||
|
judge=enrollment,
|
||||||
|
dimension=target_dimension,
|
||||||
|
defaults={'score': evaluation.score}
|
||||||
|
)
|
||||||
|
logger.info(f"Synced AI score {evaluation.score} to project {project.id} dimension {target_dimension.name}")
|
||||||
|
|
||||||
|
# 4. 同步评语 (Comment)
|
||||||
|
if evaluation.evaluation:
|
||||||
|
# 检查是否已存在该评委的评语,避免重复
|
||||||
|
comment = Comment.objects.filter(project=project, judge=enrollment).first()
|
||||||
|
if comment:
|
||||||
|
comment.content = evaluation.evaluation
|
||||||
|
comment.save()
|
||||||
|
else:
|
||||||
|
Comment.objects.create(
|
||||||
|
project=project,
|
||||||
|
judge=enrollment,
|
||||||
|
content=evaluation.evaluation
|
||||||
|
)
|
||||||
|
logger.info(f"Synced AI comment to project {project.id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to sync evaluation to project: {e}")
|
||||||
|
|
||||||
|
def summarize_task(self, task):
|
||||||
|
"""
|
||||||
|
对转写任务进行总结
|
||||||
|
"""
|
||||||
|
if not self.client:
|
||||||
|
logger.warning("BailianService not initialized, skipping summary.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not task.transcription:
|
||||||
|
logger.warning(f"Task {task.id} has no transcription, skipping summary.")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = task.transcription
|
||||||
|
# 简单截断防止过长
|
||||||
|
if len(content) > 15000:
|
||||||
|
content = content[:15000] + "...(内容过长已截断)"
|
||||||
|
|
||||||
|
# 准备上下文数据
|
||||||
|
context_data = ""
|
||||||
|
if task.summary_data:
|
||||||
|
context_data += f"\n\n【总结原始数据】\n{json.dumps(task.summary_data, ensure_ascii=False, indent=2)}"
|
||||||
|
|
||||||
|
if task.auto_chapters_data:
|
||||||
|
context_data += f"\n\n【章节原始数据】\n{json.dumps(task.auto_chapters_data, ensure_ascii=False, indent=2)}"
|
||||||
|
|
||||||
|
system_prompt = f"""你是一个专业的会议/内容总结助手。请根据提供的【转写文本】、【总结原始数据】和【章节原始数据】,生成一份结构清晰、内容详实的总结报告。
|
||||||
|
|
||||||
|
请按照以下结构输出(Markdown格式):
|
||||||
|
1. **标题**:基于内容生成一个合适的标题。
|
||||||
|
2. **核心摘要**:简要概括主要内容。
|
||||||
|
3. **主要观点/话题**:结合思维导图数据,列出关键话题和层级。
|
||||||
|
4. **章节速览**:结合章节数据,列出时间点和主要内容。[HH:MM:SS]格式来把章节列出来
|
||||||
|
5. **问答精选**(如果有):基于问答总结数据,列出重要问答。
|
||||||
|
|
||||||
|
请确保语言通顺,重点突出,能够还原内容的逻辑结构。"""
|
||||||
|
|
||||||
|
user_content = f"以下是需要总结的内容:\n\n【转写文本】\n{content}{context_data}"
|
||||||
|
|
||||||
|
messages = [
|
||||||
|
{'role': 'system', 'content': system_prompt},
|
||||||
|
{'role': 'user', 'content': user_content}
|
||||||
|
]
|
||||||
|
|
||||||
|
# 使用 qwen-plus 作为默认模型
|
||||||
|
completion = self.client.chat.completions.create(
|
||||||
|
model="qwen-plus",
|
||||||
|
messages=messages
|
||||||
|
)
|
||||||
|
|
||||||
|
summary_content = completion.choices[0].message.content
|
||||||
|
task.summary = summary_content
|
||||||
|
task.save(update_fields=['summary'])
|
||||||
|
|
||||||
|
logger.info(f"Task {task.id} summary generated successfully.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to generate summary for task {task.id}: {e}")
|
||||||
0
backend/ai_services/management/__init__.py
Normal file
0
backend/ai_services/management/__init__.py
Normal file
0
backend/ai_services/management/commands/__init__.py
Normal file
0
backend/ai_services/management/commands/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.conf import settings
|
||||||
|
import oss2
|
||||||
|
from aliyunsdkcore.client import AcsClient
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Check Aliyun configuration status'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write("Checking Aliyun Configuration...")
|
||||||
|
|
||||||
|
configs = {
|
||||||
|
'ALIYUN_ACCESS_KEY_ID': settings.ALIYUN_ACCESS_KEY_ID,
|
||||||
|
'ALIYUN_ACCESS_KEY_SECRET': settings.ALIYUN_ACCESS_KEY_SECRET,
|
||||||
|
'ALIYUN_OSS_BUCKET_NAME': settings.ALIYUN_OSS_BUCKET_NAME,
|
||||||
|
'ALIYUN_OSS_ENDPOINT': settings.ALIYUN_OSS_ENDPOINT,
|
||||||
|
'ALIYUN_TINGWU_APP_KEY': settings.ALIYUN_TINGWU_APP_KEY,
|
||||||
|
}
|
||||||
|
|
||||||
|
all_valid = True
|
||||||
|
for key, value in configs.items():
|
||||||
|
if not value:
|
||||||
|
self.stdout.write(self.style.ERROR(f"[MISSING] {key} is not set or empty"))
|
||||||
|
all_valid = False
|
||||||
|
else:
|
||||||
|
masked_value = value[:4] + "****" + value[-4:] if len(value) > 8 else "****"
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"[OK] {key}: {masked_value}"))
|
||||||
|
|
||||||
|
if not all_valid:
|
||||||
|
self.stdout.write(self.style.ERROR("\nConfiguration check FAILED. Some required settings are missing."))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Test OSS Connection
|
||||||
|
self.stdout.write("\nTesting OSS Connection...")
|
||||||
|
try:
|
||||||
|
auth = oss2.Auth(configs['ALIYUN_ACCESS_KEY_ID'], configs['ALIYUN_ACCESS_KEY_SECRET'])
|
||||||
|
bucket = oss2.Bucket(auth, configs['ALIYUN_OSS_ENDPOINT'], configs['ALIYUN_OSS_BUCKET_NAME'])
|
||||||
|
bucket.get_bucket_info()
|
||||||
|
self.stdout.write(self.style.SUCCESS("[OK] OSS Connection successful"))
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(self.style.ERROR(f"[FAILED] OSS Connection failed: {e}"))
|
||||||
|
|
||||||
|
# Test Tingwu Client Initialization
|
||||||
|
self.stdout.write("\nTesting Tingwu Client Initialization...")
|
||||||
|
try:
|
||||||
|
client = AcsClient(
|
||||||
|
configs['ALIYUN_ACCESS_KEY_ID'],
|
||||||
|
configs['ALIYUN_ACCESS_KEY_SECRET'],
|
||||||
|
"cn-beijing"
|
||||||
|
)
|
||||||
|
self.stdout.write(self.style.SUCCESS("[OK] Tingwu Client initialized"))
|
||||||
|
except Exception as e:
|
||||||
|
self.stdout.write(self.style.ERROR(f"[FAILED] Tingwu Client init failed: {e}"))
|
||||||
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import time
|
||||||
|
import logging
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from ai_services.models import TranscriptionTask
|
||||||
|
from ai_services.services import AliyunTingwuService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Polls Aliyun Tingwu for transcription results every 10 seconds'
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
self.stdout.write(self.style.SUCCESS('Starting polling service...'))
|
||||||
|
service = AliyunTingwuService()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# Find tasks that are PENDING or PROCESSING
|
||||||
|
# Include PENDING because create() might set it to PENDING initially
|
||||||
|
# though usually it sets to PROCESSING if task_id is obtained.
|
||||||
|
# Just in case.
|
||||||
|
tasks = TranscriptionTask.objects.filter(
|
||||||
|
status__in=[TranscriptionTask.Status.PENDING, TranscriptionTask.Status.PROCESSING]
|
||||||
|
).exclude(task_id__isnull=True).exclude(task_id='')
|
||||||
|
|
||||||
|
count = tasks.count()
|
||||||
|
if count > 0:
|
||||||
|
self.stdout.write(f'Found {count} pending/processing tasks.')
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
self.stdout.write(f'Checking task {task.task_id} (Status: {task.status})...')
|
||||||
|
try:
|
||||||
|
result = service.get_task_info(task.task_id)
|
||||||
|
|
||||||
|
# Store old status to check for changes
|
||||||
|
old_status = task.status
|
||||||
|
|
||||||
|
service.parse_and_update_task(task, result)
|
||||||
|
|
||||||
|
# Re-fetch or check updated object
|
||||||
|
if task.status != old_status:
|
||||||
|
if task.status == TranscriptionTask.Status.SUCCEEDED:
|
||||||
|
self.stdout.write(self.style.SUCCESS(f'Task {task.task_id} SUCCEEDED'))
|
||||||
|
elif task.status == TranscriptionTask.Status.FAILED:
|
||||||
|
self.stdout.write(self.style.ERROR(f'Task {task.task_id} FAILED: {task.error_message}'))
|
||||||
|
else:
|
||||||
|
# Still processing
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking task {task.task_id}: {e}")
|
||||||
|
self.stdout.write(self.style.ERROR(f"Error checking task {task.task_id}: {e}"))
|
||||||
|
|
||||||
|
# Wait for 10 seconds
|
||||||
|
time.sleep(10)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self.stdout.write(self.style.SUCCESS('Stopping polling service...'))
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Polling loop error: {e}")
|
||||||
|
self.stdout.write(self.style.ERROR(f"Polling loop error: {e}"))
|
||||||
|
time.sleep(10)
|
||||||
102
backend/ai_services/management/commands/test_tingwu_local.py
Normal file
102
backend/ai_services/management/commands/test_tingwu_local.py
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import django
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
# 设置 Django 环境
|
||||||
|
# 添加项目根目录到 sys.path
|
||||||
|
sys.path.append('/Volumes/data/Quant-Speed/market_page/backend')
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') # 修正为正确的 settings 模块路径
|
||||||
|
django.setup()
|
||||||
|
|
||||||
|
from ai_services.services import AliyunTingwuService
|
||||||
|
from ai_services.models import TranscriptionTask
|
||||||
|
|
||||||
|
# 配置日志
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def test_tingwu_transcription():
|
||||||
|
file_url = "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/Video/%E6%95%99%E5%AD%A6.mp4"
|
||||||
|
|
||||||
|
print(f"Testing transcription for: {file_url}")
|
||||||
|
|
||||||
|
service = AliyunTingwuService()
|
||||||
|
|
||||||
|
# 1. 创建任务
|
||||||
|
try:
|
||||||
|
print("Creating task...")
|
||||||
|
response = service.create_transcription_task(file_url)
|
||||||
|
print(f"Create task response: {json.dumps(response, indent=2, ensure_ascii=False)}")
|
||||||
|
|
||||||
|
if 'Data' in response and isinstance(response['Data'], dict):
|
||||||
|
task_id = response['Data'].get('TaskId')
|
||||||
|
else:
|
||||||
|
task_id = response.get('TaskId')
|
||||||
|
|
||||||
|
if not task_id:
|
||||||
|
print("Failed to get TaskId")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Task created with ID: {task_id}")
|
||||||
|
|
||||||
|
# 2. 轮询查询任务状态
|
||||||
|
import time
|
||||||
|
max_retries = 60 # 5 minutes
|
||||||
|
for i in range(max_retries):
|
||||||
|
print(f"Checking status (attempt {i+1}/{max_retries})...")
|
||||||
|
result = service.get_task_info(task_id)
|
||||||
|
|
||||||
|
# 解析结果
|
||||||
|
if isinstance(result, str):
|
||||||
|
try:
|
||||||
|
result = json.loads(result)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if isinstance(result, dict):
|
||||||
|
data_obj = result.get('Data', result)
|
||||||
|
else:
|
||||||
|
data_obj = result
|
||||||
|
|
||||||
|
task_status = data_obj.get('TaskStatus')
|
||||||
|
if not task_status:
|
||||||
|
task_status = data_obj.get('Status')
|
||||||
|
|
||||||
|
print(f"Current status: {task_status}")
|
||||||
|
|
||||||
|
if task_status in ['COMPLETE', 'COMPLETED', 'SUCCEEDED']:
|
||||||
|
print("Task succeeded!")
|
||||||
|
print(f"Full Result: {json.dumps(data_obj, indent=2, ensure_ascii=False)}")
|
||||||
|
|
||||||
|
# 尝试解析 Transcription
|
||||||
|
task_result = data_obj.get('Result', {})
|
||||||
|
transcription_data = task_result.get('Transcription', {})
|
||||||
|
|
||||||
|
if isinstance(transcription_data, str) and transcription_data.startswith('http'):
|
||||||
|
import requests
|
||||||
|
print(f"Downloading transcription from {transcription_data}")
|
||||||
|
t_resp = requests.get(transcription_data)
|
||||||
|
if t_resp.status_code == 200:
|
||||||
|
content = t_resp.json()
|
||||||
|
print(f"Downloaded content structure keys: {content.keys()}")
|
||||||
|
# print(f"Content sample: {json.dumps(content, indent=2, ensure_ascii=False)[:500]}...")
|
||||||
|
else:
|
||||||
|
print(f"Failed to download: {t_resp.status_code}")
|
||||||
|
|
||||||
|
break
|
||||||
|
elif task_status == 'FAILED':
|
||||||
|
print(f"Task failed: {data_obj}")
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_tingwu_transcription()
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-11 12:30
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ai_services', '0002_transcriptiontask_evaluation_transcriptiontask_score'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='transcriptiontask',
|
||||||
|
name='auto_chapters_data',
|
||||||
|
field=models.JSONField(blank=True, help_text='阿里云返回的AutoChapters完整JSON', null=True, verbose_name='章节原始数据'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='transcriptiontask',
|
||||||
|
name='summary_data',
|
||||||
|
field=models.JSONField(blank=True, help_text='阿里云返回的Summarization完整JSON', null=True, verbose_name='总结原始数据'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='transcriptiontask',
|
||||||
|
name='transcription_data',
|
||||||
|
field=models.JSONField(blank=True, help_text='阿里云返回的Transcription完整JSON', null=True, verbose_name='转写原始数据'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-11 12:44
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ai_services', '0003_transcriptiontask_auto_chapters_data_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='transcriptiontask',
|
||||||
|
name='evaluation',
|
||||||
|
),
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='transcriptiontask',
|
||||||
|
name='score',
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AIEvaluation',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('score', models.IntegerField(blank=True, help_text='0-100分', null=True, verbose_name='AI评分')),
|
||||||
|
('evaluation', models.TextField(blank=True, null=True, verbose_name='AI评语')),
|
||||||
|
('model_selection', models.CharField(default='qwen-plus', help_text='例如: qwen-plus, qwen-turbo, qwen-max', max_length=50, verbose_name='模型选择')),
|
||||||
|
('prompt', models.TextField(default='你是一个专业的评分助手。请根据提供的转写内容,对内容质量、逻辑清晰度、语言表达等方面进行综合评分(0-100分),并给出详细的评语。请以JSON格式返回,包含"score"和"evaluation"字段。', help_text='用于指导AI评分的提示词', verbose_name='评分提示词')),
|
||||||
|
('raw_response', models.JSONField(blank=True, help_text='大模型返回的完整JSON', null=True, verbose_name='原始响应')),
|
||||||
|
('reasoning', models.TextField(blank=True, help_text='AI的推理过程(如果有)', null=True, verbose_name='推理过程')),
|
||||||
|
('status', models.CharField(choices=[('PENDING', '等待中'), ('PROCESSING', '生成中'), ('COMPLETED', '已完成'), ('FAILED', '失败')], default='PENDING', max_length=20, verbose_name='评估状态')),
|
||||||
|
('error_message', models.TextField(blank=True, null=True, verbose_name='错误信息')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
('task', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='ai_evaluation', to='ai_services.transcriptiontask', verbose_name='关联任务')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'AI智能评估',
|
||||||
|
'verbose_name_plural': 'AI智能评估',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-11 13:00
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ai_services', '0004_remove_transcriptiontask_evaluation_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='AIEvaluationTemplate',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(help_text='例如:销售话术评分、逻辑性分析', max_length=100, verbose_name='模板名称')),
|
||||||
|
('model_selection', models.CharField(default='qwen-plus', help_text='例如: qwen-plus, qwen-turbo, qwen-max', max_length=50, verbose_name='模型选择')),
|
||||||
|
('prompt', models.TextField(default='你是一个专业的评分助手。请根据提供的转写内容,对内容质量、逻辑清晰度、语言表达等方面进行综合评分(0-100分),并给出详细的评语。请以JSON格式返回,包含"score"和"evaluation"字段。', help_text='用于指导AI评分的提示词', verbose_name='评分提示词')),
|
||||||
|
('is_active', models.BooleanField(default=True, help_text='启用后,新的转写任务完成后将自动使用此模板进行评估', verbose_name='是否启用')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'AI评估模板',
|
||||||
|
'verbose_name_plural': 'AI评估模板',
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='aievaluation',
|
||||||
|
options={'ordering': ['-created_at'], 'verbose_name': 'AI评估结果', 'verbose_name_plural': 'AI评估结果'},
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='aievaluation',
|
||||||
|
name='model_selection',
|
||||||
|
field=models.CharField(default='qwen-plus', max_length=50, verbose_name='模型选择'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='aievaluation',
|
||||||
|
name='prompt',
|
||||||
|
field=models.TextField(verbose_name='评分提示词'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='aievaluation',
|
||||||
|
name='task',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_evaluations', to='ai_services.transcriptiontask', verbose_name='关联任务'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='aievaluation',
|
||||||
|
name='template',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='evaluations', to='ai_services.aievaluationtemplate', verbose_name='使用的模板'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-11 14:10
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ai_services', '0005_aievaluationtemplate_alter_aievaluation_options_and_more'),
|
||||||
|
('competition', '0003_competition_project_visibility'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='transcriptiontask',
|
||||||
|
name='project',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transcription_tasks', to='competition.project', verbose_name='关联参赛项目'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-11 15:03
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ai_services', '0006_transcriptiontask_project'),
|
||||||
|
('competition', '0003_competition_project_visibility'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='aievaluationtemplate',
|
||||||
|
name='score_dimension',
|
||||||
|
field=models.ForeignKey(blank=True, help_text='如果同步到比赛评分,优先使用此维度。未填写则默认使用"AI Rating"或包含"AI"的维度', null=True, on_delete=django.db.models.deletion.SET_NULL, to='competition.scoredimension', verbose_name='关联评分维度'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-17 15:55
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('ai_services', '0007_aievaluationtemplate_score_dimension'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='aievaluationtemplate',
|
||||||
|
name='is_default',
|
||||||
|
field=models.BooleanField(default=False, help_text='默认模板会评价所有比赛,非默认模板且未关联评分维度时不会自动评价', verbose_name='是否为默认模板'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -18,10 +18,27 @@ class TranscriptionTask(models.Model):
|
|||||||
choices=Status.choices,
|
choices=Status.choices,
|
||||||
default=Status.PENDING
|
default=Status.PENDING
|
||||||
)
|
)
|
||||||
|
# 存储阿里云听悟返回的原始 JSON 结构
|
||||||
|
transcription_data = models.JSONField(verbose_name=_('转写原始数据'), blank=True, null=True, help_text=_('阿里云返回的Transcription完整JSON'))
|
||||||
|
summary_data = models.JSONField(verbose_name=_('总结原始数据'), blank=True, null=True, help_text=_('阿里云返回的Summarization完整JSON'))
|
||||||
|
auto_chapters_data = models.JSONField(verbose_name=_('章节原始数据'), blank=True, null=True, help_text=_('阿里云返回的AutoChapters完整JSON'))
|
||||||
|
|
||||||
|
project = models.ForeignKey(
|
||||||
|
'competition.Project',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='transcription_tasks',
|
||||||
|
verbose_name=_('关联参赛项目')
|
||||||
|
)
|
||||||
|
|
||||||
transcription = models.TextField(verbose_name=_('逐字稿'), blank=True, null=True)
|
transcription = models.TextField(verbose_name=_('逐字稿'), blank=True, null=True)
|
||||||
summary = models.TextField(verbose_name=_('AI总结'), blank=True, null=True)
|
summary = models.TextField(verbose_name=_('AI总结'), blank=True, null=True)
|
||||||
score = models.IntegerField(verbose_name=_('AI评分'), blank=True, null=True, help_text=_('基于转写内容的评分'))
|
|
||||||
evaluation = models.TextField(verbose_name=_('AI评语'), blank=True, null=True)
|
# 已解耦到 AIEvaluation 模型
|
||||||
|
# score = models.IntegerField(verbose_name=_('AI评分'), blank=True, null=True, help_text=_('基于转写内容的评分'))
|
||||||
|
# evaluation = models.TextField(verbose_name=_('AI评语'), blank=True, null=True)
|
||||||
|
|
||||||
error_message = models.TextField(verbose_name=_('错误信息'), blank=True, null=True)
|
error_message = models.TextField(verbose_name=_('错误信息'), blank=True, null=True)
|
||||||
created_at = models.DateTimeField(verbose_name=_('创建时间'), auto_now_add=True)
|
created_at = models.DateTimeField(verbose_name=_('创建时间'), auto_now_add=True)
|
||||||
updated_at = models.DateTimeField(verbose_name=_('更新时间'), auto_now=True)
|
updated_at = models.DateTimeField(verbose_name=_('更新时间'), auto_now=True)
|
||||||
@@ -33,3 +50,101 @@ class TranscriptionTask(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.id} - {self.get_status_display()}"
|
return f"{self.id} - {self.get_status_display()}"
|
||||||
|
|
||||||
|
|
||||||
|
class AIEvaluationTemplate(models.Model):
|
||||||
|
name = models.CharField(verbose_name=_('模板名称'), max_length=100, help_text=_('例如:销售话术评分、逻辑性分析'))
|
||||||
|
model_selection = models.CharField(
|
||||||
|
verbose_name=_('模型选择'),
|
||||||
|
max_length=50,
|
||||||
|
default='qwen-plus',
|
||||||
|
help_text=_('例如: qwen-plus, qwen-turbo, qwen-max')
|
||||||
|
)
|
||||||
|
prompt = models.TextField(
|
||||||
|
verbose_name=_('评分提示词'),
|
||||||
|
default='你是一个专业的评分助手。请根据提供的转写内容,对内容质量、逻辑清晰度、语言表达等方面进行综合评分(0-100分),并给出详细的评语。请以JSON格式返回,包含"score"和"evaluation"字段。',
|
||||||
|
help_text=_('用于指导AI评分的提示词')
|
||||||
|
)
|
||||||
|
score_dimension = models.ForeignKey(
|
||||||
|
'competition.ScoreDimension',
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
verbose_name=_('关联评分维度'),
|
||||||
|
help_text=_('如果同步到比赛评分,优先使用此维度。未填写则默认使用"AI Rating"或包含"AI"的维度')
|
||||||
|
)
|
||||||
|
is_default = models.BooleanField(
|
||||||
|
verbose_name=_('是否为默认模板'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('默认模板会评价所有比赛,非默认模板且未关联评分维度时不会自动评价')
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(verbose_name=_('是否启用'), default=True, help_text=_('启用后,新的转写任务完成后将自动使用此模板进行评估'))
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(verbose_name=_('创建时间'), auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(verbose_name=_('更新时间'), auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('AI评估模板')
|
||||||
|
verbose_name_plural = _('AI评估模板')
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class AIEvaluation(models.Model):
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
PENDING = 'PENDING', _('等待中')
|
||||||
|
PROCESSING = 'PROCESSING', _('生成中')
|
||||||
|
COMPLETED = 'COMPLETED', _('已完成')
|
||||||
|
FAILED = 'FAILED', _('失败')
|
||||||
|
|
||||||
|
task = models.ForeignKey(
|
||||||
|
TranscriptionTask,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='ai_evaluations',
|
||||||
|
verbose_name=_('关联任务')
|
||||||
|
)
|
||||||
|
template = models.ForeignKey(
|
||||||
|
AIEvaluationTemplate,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='evaluations',
|
||||||
|
verbose_name=_('使用的模板')
|
||||||
|
)
|
||||||
|
|
||||||
|
# 评分与评语
|
||||||
|
score = models.IntegerField(verbose_name=_('AI评分'), blank=True, null=True, help_text=_('0-100分'))
|
||||||
|
evaluation = models.TextField(verbose_name=_('AI评语'), blank=True, null=True)
|
||||||
|
|
||||||
|
# 记录当时的配置 (快照)
|
||||||
|
model_selection = models.CharField(
|
||||||
|
verbose_name=_('模型选择'),
|
||||||
|
max_length=50,
|
||||||
|
default='qwen-plus'
|
||||||
|
)
|
||||||
|
prompt = models.TextField(verbose_name=_('评分提示词'))
|
||||||
|
|
||||||
|
# 原始数据与推理
|
||||||
|
raw_response = models.JSONField(verbose_name=_('原始响应'), blank=True, null=True, help_text=_('大模型返回的完整JSON'))
|
||||||
|
reasoning = models.TextField(verbose_name=_('推理过程'), blank=True, null=True, help_text=_('AI的推理过程(如果有)'))
|
||||||
|
|
||||||
|
status = models.CharField(
|
||||||
|
verbose_name=_('评估状态'),
|
||||||
|
max_length=20,
|
||||||
|
choices=Status.choices,
|
||||||
|
default=Status.PENDING
|
||||||
|
)
|
||||||
|
error_message = models.TextField(verbose_name=_('错误信息'), blank=True, null=True)
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(verbose_name=_('创建时间'), auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(verbose_name=_('更新时间'), auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = _('AI评估结果')
|
||||||
|
verbose_name_plural = _('AI评估结果')
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"Evaluation for Task {self.task.id} ({self.template.name if self.template else 'Custom'})"
|
||||||
|
|||||||
@@ -1,8 +1,28 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import TranscriptionTask
|
from .models import TranscriptionTask, AIEvaluation, AIEvaluationTemplate
|
||||||
|
|
||||||
|
class AIEvaluationTemplateSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = AIEvaluationTemplate
|
||||||
|
fields = ['id', 'name', 'model_selection', 'prompt', 'is_active', 'created_at']
|
||||||
|
|
||||||
|
class AIEvaluationSerializer(serializers.ModelSerializer):
|
||||||
|
template = AIEvaluationTemplateSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = AIEvaluation
|
||||||
|
fields = ['id', 'template', 'score', 'evaluation', 'model_selection', 'prompt', 'reasoning', 'status', 'error_message', 'created_at', 'updated_at']
|
||||||
|
|
||||||
class TranscriptionTaskSerializer(serializers.ModelSerializer):
|
class TranscriptionTaskSerializer(serializers.ModelSerializer):
|
||||||
|
ai_evaluations = AIEvaluationSerializer(many=True, read_only=True)
|
||||||
|
project_title = serializers.CharField(source='project.title', read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TranscriptionTask
|
model = TranscriptionTask
|
||||||
fields = ['id', 'file_url', 'task_id', 'status', 'transcription', 'summary', 'error_message', 'created_at', 'updated_at']
|
fields = ['id', 'file_url', 'task_id', 'status', 'transcription', 'summary', 'error_message', 'created_at', 'updated_at', 'transcription_data', 'summary_data', 'auto_chapters_data', 'ai_evaluations', 'project', 'project_title']
|
||||||
read_only_fields = ['id', 'file_url', 'task_id', 'status', 'transcription', 'summary', 'error_message', 'created_at', 'updated_at']
|
read_only_fields = ['id', 'file_url', 'task_id', 'status', 'transcription', 'summary', 'error_message', 'created_at', 'updated_at', 'transcription_data', 'summary_data', 'auto_chapters_data', 'ai_evaluations', 'project_title']
|
||||||
|
|
||||||
|
class TranscriptionUploadSerializer(serializers.Serializer):
|
||||||
|
file = serializers.FileField(help_text="上传的音频文件", required=False)
|
||||||
|
file_url = serializers.URLField(help_text="音频文件的URL地址", required=False)
|
||||||
|
project_id = serializers.IntegerField(help_text="关联的参赛项目ID", required=False)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
import oss2
|
import oss2
|
||||||
from aliyunsdkcore.client import AcsClient
|
from aliyunsdkcore.client import AcsClient
|
||||||
from aliyunsdkcore.acs_exception.exceptions import ClientException, ServerException
|
from aliyunsdkcore.acs_exception.exceptions import ClientException, ServerException
|
||||||
@@ -15,6 +16,8 @@ from django.conf import settings
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
from .models import TranscriptionTask, AIEvaluation, AIEvaluationTemplate
|
||||||
|
|
||||||
class AliyunTingwuService:
|
class AliyunTingwuService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.access_key_id = settings.ALIYUN_ACCESS_KEY_ID
|
self.access_key_id = settings.ALIYUN_ACCESS_KEY_ID
|
||||||
@@ -22,7 +25,7 @@ class AliyunTingwuService:
|
|||||||
self.oss_bucket_name = settings.ALIYUN_OSS_BUCKET_NAME
|
self.oss_bucket_name = settings.ALIYUN_OSS_BUCKET_NAME
|
||||||
self.oss_endpoint = settings.ALIYUN_OSS_ENDPOINT
|
self.oss_endpoint = settings.ALIYUN_OSS_ENDPOINT
|
||||||
self.tingwu_app_key = settings.ALIYUN_TINGWU_APP_KEY
|
self.tingwu_app_key = settings.ALIYUN_TINGWU_APP_KEY
|
||||||
self.region_id = "cn-beijing" # 听悟服务主要在北京
|
self.region_id = "cn-shanghai" # 听悟服务区域,根据文档应与OSS区域一致,或者使用 'cn-beijing'
|
||||||
|
|
||||||
# 初始化 OSS Bucket
|
# 初始化 OSS Bucket
|
||||||
if self.access_key_id and self.access_key_secret and self.oss_endpoint:
|
if self.access_key_id and self.access_key_secret and self.oss_endpoint:
|
||||||
@@ -39,13 +42,26 @@ class AliyunTingwuService:
|
|||||||
self.access_key_secret,
|
self.access_key_secret,
|
||||||
self.region_id
|
self.region_id
|
||||||
)
|
)
|
||||||
|
# 显式添加听悟服务的 Endpoint 映射,解决 EndpointResolvingError
|
||||||
|
# 听悟 API 的服务接入点通常是 tingwu.cn-beijing.aliyuncs.com
|
||||||
|
# 但新版听悟 API (tingwu.aliyuncs.com) 可能不同,需根据实际情况添加
|
||||||
|
# 这里添加一个通用的 Endpoint 映射
|
||||||
|
try:
|
||||||
|
# 尝试为 tingwu 产品设置 Endpoint
|
||||||
|
# 注意:听悟服务主要部署在北京,Endpoint 通常为 tingwu.cn-beijing.aliyuncs.com
|
||||||
|
# 如果您的服务在上海,也可能需要连接到北京的接入点
|
||||||
|
self.client.add_endpoint(self.region_id, "tingwu", "tingwu.cn-beijing.aliyuncs.com")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to add endpoint: {e}")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
self.client = None
|
self.client = None
|
||||||
logger.warning("Aliyun AccessKey configuration missing.")
|
logger.warning("Aliyun AccessKey configuration missing.")
|
||||||
|
|
||||||
def upload_to_oss(self, file_obj, file_name):
|
def upload_to_oss(self, file_obj, file_name, day=7):
|
||||||
"""
|
"""
|
||||||
上传文件到 OSS 并返回带签名的 URL (有效期 3 小时)
|
上传文件到 OSS 并返回带签名的 URL
|
||||||
|
默认生成有效期为 7 天 (3600 * 24 * day) 的签名URL,方便评委在一段时间内都能播放。
|
||||||
"""
|
"""
|
||||||
if not self.bucket:
|
if not self.bucket:
|
||||||
raise Exception("OSS Client not initialized")
|
raise Exception("OSS Client not initialized")
|
||||||
@@ -55,8 +71,8 @@ class AliyunTingwuService:
|
|||||||
# file_obj 应该是打开的文件对象或字节流
|
# file_obj 应该是打开的文件对象或字节流
|
||||||
self.bucket.put_object(file_name, file_obj)
|
self.bucket.put_object(file_name, file_obj)
|
||||||
|
|
||||||
# 生成签名 URL,有效期 3 小时 (3600 * 3)
|
# 生成签名 URL,有效期 7 天 (3600 * 24 * 7 = 604800 秒)
|
||||||
url = self.bucket.sign_url('GET', file_name, 3600 * 3)
|
url = self.bucket.sign_url('GET', file_name, 3600 * 24 * day)
|
||||||
return url
|
return url
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"OSS Upload failed: {e}")
|
logger.error(f"OSS Upload failed: {e}")
|
||||||
@@ -70,25 +86,49 @@ class AliyunTingwuService:
|
|||||||
raise Exception("Tingwu Client not initialized")
|
raise Exception("Tingwu Client not initialized")
|
||||||
|
|
||||||
request = CreateTaskRequest.CreateTaskRequest()
|
request = CreateTaskRequest.CreateTaskRequest()
|
||||||
request.set_AppKey(self.tingwu_app_key)
|
|
||||||
|
|
||||||
# 配置 Input
|
# 针对阿里云 SDK 不同版本的兼容性处理
|
||||||
input_param = {
|
# "type" 参数是听悟 API (ROA 风格) 的必填项,用于指定任务类型
|
||||||
|
# 根据官方文档,离线任务的 type 通常就是 'offline'
|
||||||
|
request.add_query_param('type', 'offline')
|
||||||
|
|
||||||
|
# 构造请求体 (Body)
|
||||||
|
# 根据听悟 API 文档,AppKey, Input, Parameters 应位于 JSON Body 中
|
||||||
|
# 而不是 Query Parameter
|
||||||
|
body = {
|
||||||
|
"AppKey": self.tingwu_app_key,
|
||||||
|
"Input": {
|
||||||
"FileUrl": file_url,
|
"FileUrl": file_url,
|
||||||
"SourceLanguage": language,
|
"SourceLanguage": language,
|
||||||
"TaskKey": "transcription_task"
|
"TaskKey": str(uuid.uuid4())
|
||||||
}
|
},
|
||||||
request.set_Input(json.dumps(input_param))
|
"Parameters": {
|
||||||
|
|
||||||
# 配置 Parameters (开启自动章节和摘要)
|
|
||||||
parameters = {
|
|
||||||
"Transcoding": {
|
"Transcoding": {
|
||||||
"TargetAudioFormat": "mp3"
|
"TargetAudioFormat": "mp3"
|
||||||
},
|
},
|
||||||
|
"Transcription": {
|
||||||
|
"DiarizationEnabled": True,
|
||||||
|
"ChannelId": 0
|
||||||
|
},
|
||||||
|
"TranscriptionEnabled": True,
|
||||||
"AutoChaptersEnabled": True,
|
"AutoChaptersEnabled": True,
|
||||||
"SummarizationEnabled": True
|
"SummarizationEnabled": True,
|
||||||
|
"Summarization": {
|
||||||
|
"Types": ["Paragraph", "Conversational", "QuestionsAnswering", "MindMap"]
|
||||||
}
|
}
|
||||||
request.set_Parameters(json.dumps(parameters))
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 设置 Body 内容
|
||||||
|
request.set_content(json.dumps(body))
|
||||||
|
request.add_header('Content-Type', 'application/json')
|
||||||
|
|
||||||
|
# 强制设置 Endpoint,避免 SDK.EndpointResolvingError
|
||||||
|
# 听悟目前主要服务点在北京
|
||||||
|
request.set_endpoint("tingwu.cn-beijing.aliyuncs.com")
|
||||||
|
|
||||||
|
# 显式设置 Method 为 PUT
|
||||||
|
request.set_method('PUT')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = self.client.do_action_with_exception(request)
|
response = self.client.do_action_with_exception(request)
|
||||||
@@ -113,3 +153,268 @@ class AliyunTingwuService:
|
|||||||
except (ClientException, ServerException) as e:
|
except (ClientException, ServerException) as e:
|
||||||
logger.error(f"Tingwu GetTaskInfo failed: {e}")
|
logger.error(f"Tingwu GetTaskInfo failed: {e}")
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
|
def parse_and_update_task(self, task, result):
|
||||||
|
"""
|
||||||
|
解析听悟结果并更新任务
|
||||||
|
:param task: TranscriptionTask 实例
|
||||||
|
:param result: get_task_info 返回的完整 JSON (或 Data 部分)
|
||||||
|
"""
|
||||||
|
# 记录之前的状态,用于判断是否是首次完成
|
||||||
|
previous_status = task.status
|
||||||
|
|
||||||
|
# 1. 提取 Data 对象
|
||||||
|
if isinstance(result, dict):
|
||||||
|
data_obj = result.get('Data', result)
|
||||||
|
else:
|
||||||
|
data_obj = result
|
||||||
|
|
||||||
|
if not isinstance(data_obj, dict):
|
||||||
|
logger.error(f"Unexpected data format: {type(data_obj)}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. 更新状态
|
||||||
|
task_status = data_obj.get('TaskStatus') or data_obj.get('Status')
|
||||||
|
if task_status in ['COMPLETE', 'COMPLETED', 'SUCCEEDED']:
|
||||||
|
task.status = 'SUCCEEDED' # 使用字符串引用,避免导入模型循环引用
|
||||||
|
elif task_status == 'FAILED':
|
||||||
|
task.status = 'FAILED'
|
||||||
|
task.error_message = data_obj.get('TaskStatusText', data_obj.get('Message', 'Unknown error'))
|
||||||
|
task.save()
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
# 仍在处理中,不更新内容
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. 解析结果
|
||||||
|
task_result = data_obj.get('Result', {})
|
||||||
|
|
||||||
|
# 兼容处理:如果 Result 为空,或者不存在,尝试直接使用 data_obj 作为结果源
|
||||||
|
# 某些情况下,Summarization/AutoChapters 可能直接位于 Data 层级
|
||||||
|
if not task_result:
|
||||||
|
task_result = data_obj
|
||||||
|
|
||||||
|
# 辅助函数:从源字典或其 Result 子字典中获取字段
|
||||||
|
def get_data_field(source, key):
|
||||||
|
# 1. 尝试直接从 task_result 获取 (如果 task_result 就是 Data 本身,这里也会生效)
|
||||||
|
if isinstance(source, dict) and key in source:
|
||||||
|
return source[key]
|
||||||
|
# 2. 如果 source 是 Data,尝试从 source['Result'] 获取
|
||||||
|
if isinstance(source, dict) and 'Result' in source and isinstance(source['Result'], dict):
|
||||||
|
if key in source['Result']:
|
||||||
|
return source['Result'][key]
|
||||||
|
return None
|
||||||
|
|
||||||
|
# --- A. 处理逐字稿 (Transcription) ---
|
||||||
|
transcription_data = get_data_field(task_result, 'Transcription') or get_data_field(data_obj, 'Transcription') or {}
|
||||||
|
|
||||||
|
# 处理 URL 下载
|
||||||
|
if isinstance(transcription_data, str) and transcription_data.startswith('http'):
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
t_resp = requests.get(transcription_data)
|
||||||
|
if t_resp.status_code == 200:
|
||||||
|
transcription_data = t_resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Download transcription failed: {e}")
|
||||||
|
transcription_data = {}
|
||||||
|
elif isinstance(transcription_data, dict) and 'TranscriptionUrl' in transcription_data:
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
t_resp = requests.get(transcription_data['TranscriptionUrl'])
|
||||||
|
if t_resp.status_code == 200:
|
||||||
|
transcription_data = t_resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Download transcription url failed: {e}")
|
||||||
|
|
||||||
|
# 保存原始数据
|
||||||
|
task.transcription_data = transcription_data
|
||||||
|
|
||||||
|
# 提取文本
|
||||||
|
# 结构: {"Transcription": {"Paragraphs": [{"Words": [{"Text": "..."}]}]}}
|
||||||
|
# 或直接 {"Paragraphs": ...}
|
||||||
|
content_source = transcription_data
|
||||||
|
if 'Transcription' in content_source and isinstance(content_source['Transcription'], dict):
|
||||||
|
content_source = content_source['Transcription']
|
||||||
|
|
||||||
|
paragraphs = content_source.get('Paragraphs', [])
|
||||||
|
full_text_lines = []
|
||||||
|
|
||||||
|
if paragraphs and isinstance(paragraphs, list):
|
||||||
|
for p in paragraphs:
|
||||||
|
# 尝试从 Words 中提取
|
||||||
|
words = p.get('Words', [])
|
||||||
|
if words:
|
||||||
|
line_text = "".join([str(w.get('Text', '')) for w in words])
|
||||||
|
full_text_lines.append(line_text)
|
||||||
|
# 兼容旧结构或直接 Text
|
||||||
|
elif 'Text' in p:
|
||||||
|
full_text_lines.append(p['Text'])
|
||||||
|
|
||||||
|
if full_text_lines:
|
||||||
|
task.transcription = "\n".join(full_text_lines)
|
||||||
|
|
||||||
|
# --- B. 处理 AI 总结 (Summarization) ---
|
||||||
|
summarization = get_data_field(task_result, 'Summarization') or get_data_field(data_obj, 'Summarization') or {}
|
||||||
|
|
||||||
|
# 处理 URL 下载
|
||||||
|
if isinstance(summarization, str) and summarization.startswith('http'):
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
s_resp = requests.get(summarization)
|
||||||
|
if s_resp.status_code == 200:
|
||||||
|
summarization = s_resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Download summarization failed: {e}")
|
||||||
|
summarization = {}
|
||||||
|
|
||||||
|
# 保存原始数据
|
||||||
|
task.summary_data = summarization
|
||||||
|
|
||||||
|
# 提取文本 (MindMapSummary)
|
||||||
|
# 结构: {"MindMapSummary": [{"Title": "...", "Topic": [...]}]}
|
||||||
|
# 移除了原先的 summary_text 拼接逻辑
|
||||||
|
|
||||||
|
# --- C. 处理章节 (AutoChapters) ---
|
||||||
|
auto_chapters = get_data_field(task_result, 'AutoChapters') or get_data_field(data_obj, 'AutoChapters') or []
|
||||||
|
|
||||||
|
# 处理 URL 下载
|
||||||
|
if isinstance(auto_chapters, str) and auto_chapters.startswith('http'):
|
||||||
|
try:
|
||||||
|
import requests
|
||||||
|
ac_resp = requests.get(auto_chapters)
|
||||||
|
if ac_resp.status_code == 200:
|
||||||
|
auto_chapters = ac_resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Download auto chapters failed: {e}")
|
||||||
|
auto_chapters = []
|
||||||
|
|
||||||
|
# 保存原始数据
|
||||||
|
task.auto_chapters_data = auto_chapters
|
||||||
|
|
||||||
|
# 保存任务,确保原始数据已写入数据库
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
# 调用大模型生成总结 (如果 summary_data 或 auto_chapters_data 存在)
|
||||||
|
if task.summary_data or task.auto_chapters_data:
|
||||||
|
try:
|
||||||
|
# 设置占位状态
|
||||||
|
task.summary = "AI总结生成当中..."
|
||||||
|
task.save(update_fields=['summary'])
|
||||||
|
|
||||||
|
# 异步执行总结
|
||||||
|
import threading
|
||||||
|
from .bailian_service import BailianService
|
||||||
|
|
||||||
|
def async_summarize_in_service(task_id):
|
||||||
|
try:
|
||||||
|
# 重新获取 task 以避免线程安全问题
|
||||||
|
from .models import TranscriptionTask
|
||||||
|
t = TranscriptionTask.objects.get(id=task_id)
|
||||||
|
bailian_service = BailianService()
|
||||||
|
bailian_service.summarize_task(t)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Async summary generation failed in service: {e}")
|
||||||
|
|
||||||
|
threading.Thread(target=async_summarize_in_service, args=(task.id,)).start()
|
||||||
|
logger.info(f"Triggered async summary generation for task {task.id}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to trigger AI summarization: {e}")
|
||||||
|
|
||||||
|
# 4. 自动触发 AI 评估 (如果任务首次成功且有启用的模板)
|
||||||
|
if previous_status != 'SUCCEEDED' and task.status == 'SUCCEEDED' and task.transcription:
|
||||||
|
# 同样改为异步触发,传递 task.id 以避免线程中的对象状态问题
|
||||||
|
import threading
|
||||||
|
threading.Thread(target=self.trigger_ai_evaluations, args=(task.id,)).start()
|
||||||
|
|
||||||
|
def trigger_ai_evaluations(self, task_id):
|
||||||
|
"""
|
||||||
|
根据启用的模板自动触发 AI 评估
|
||||||
|
|
||||||
|
逻辑:
|
||||||
|
1. 如果模板关联了评分维度(s score_dimension),只对关联了相同维度的比赛进行评估
|
||||||
|
2. 如果模板未关联评分维度:
|
||||||
|
- 如果是默认模板(is_default=True),评价所有比赛
|
||||||
|
- 否则不进行自动评价
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# 在线程中重新获取 task 对象,并预加载 project,避免懒加载导致的线程数据库连接问题
|
||||||
|
from .models import TranscriptionTask
|
||||||
|
task = TranscriptionTask.objects.select_related('project', 'project__competition').get(id=task_id)
|
||||||
|
except Exception as e:
|
||||||
|
# 兼容处理:如果 task_id 其实是 task 对象(虽然我们上面改了,但防止其他地方调用传错)
|
||||||
|
if hasattr(task_id, 'id'):
|
||||||
|
try:
|
||||||
|
from .models import TranscriptionTask
|
||||||
|
task = TranscriptionTask.objects.select_related('project', 'project__competition').get(id=task_id.id)
|
||||||
|
except:
|
||||||
|
task = task_id
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to retrieve task {task_id}: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
active_templates = AIEvaluationTemplate.objects.filter(is_active=True)
|
||||||
|
if not active_templates.exists():
|
||||||
|
logger.info("No active AI evaluation templates found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
from .bailian_service import BailianService
|
||||||
|
service = BailianService()
|
||||||
|
|
||||||
|
for template in active_templates:
|
||||||
|
# 检查是否已经存在相同的评估,避免重复创建
|
||||||
|
if AIEvaluation.objects.filter(task=task, template=template).exists():
|
||||||
|
logger.info(f"Evaluation for task {task.id} and template {template.name} already exists.")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 获取任务关联的比赛
|
||||||
|
task_competition = None
|
||||||
|
if task.project and task.project.competition:
|
||||||
|
task_competition = task.project.competition
|
||||||
|
|
||||||
|
# 判断是否应该对此任务进行评估
|
||||||
|
should_evaluate = False
|
||||||
|
|
||||||
|
if template.score_dimension:
|
||||||
|
# 模板关联了评分维度,只对关联了相同维度的比赛进行评估
|
||||||
|
if task_competition:
|
||||||
|
# 获取该比赛下所有关联了相同评分维度的比赛ID列表
|
||||||
|
from competition.models import ScoreDimension
|
||||||
|
related_competition_ids = ScoreDimension.objects.filter(
|
||||||
|
id=template.score_dimension.id
|
||||||
|
).values_list('competition_id', flat=True)
|
||||||
|
|
||||||
|
if task_competition.id in related_competition_ids:
|
||||||
|
should_evaluate = True
|
||||||
|
logger.info(f"Template '{template.name}' is linked to score_dimension, task's competition matches.")
|
||||||
|
else:
|
||||||
|
logger.info(f"Template '{template.name}' is linked to score_dimension, but task's competition does not match. Skipping.")
|
||||||
|
else:
|
||||||
|
logger.info(f"Task {task.id} has no associated competition. Skipping template '{template.name}'.")
|
||||||
|
else:
|
||||||
|
# 模板未关联评分维度,只有默认模板才评价所有比赛
|
||||||
|
if template.is_default:
|
||||||
|
should_evaluate = True
|
||||||
|
logger.info(f"Template '{template.name}' is default template, evaluating all competitions.")
|
||||||
|
else:
|
||||||
|
logger.info(f"Template '{template.name}' is not linked to score_dimension and is not default. Skipping.")
|
||||||
|
|
||||||
|
if not should_evaluate:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 创建评估记录
|
||||||
|
evaluation = AIEvaluation.objects.create(
|
||||||
|
task=task,
|
||||||
|
template=template,
|
||||||
|
model_selection=template.model_selection,
|
||||||
|
prompt=template.prompt,
|
||||||
|
status=AIEvaluation.Status.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
# 触发评估
|
||||||
|
try:
|
||||||
|
service.evaluate_task(evaluation)
|
||||||
|
logger.info(f"Triggered evaluation {evaluation.id} for template {template.name}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to trigger evaluation {evaluation.id}: {e}")
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ from rest_framework.response import Response
|
|||||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||||
from rest_framework.permissions import AllowAny
|
from rest_framework.permissions import AllowAny
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from .models import TranscriptionTask
|
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
|
||||||
from .serializers import TranscriptionTaskSerializer
|
from .models import TranscriptionTask, AIEvaluation
|
||||||
|
from .serializers import TranscriptionTaskSerializer, TranscriptionUploadSerializer, AIEvaluationSerializer
|
||||||
from .services import AliyunTingwuService
|
from .services import AliyunTingwuService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -57,35 +58,82 @@ class TranscriptionTaskViewSet(viewsets.ModelViewSet):
|
|||||||
serializer_class = TranscriptionTaskSerializer
|
serializer_class = TranscriptionTaskSerializer
|
||||||
parser_classes = (MultiPartParser, FormParser)
|
parser_classes = (MultiPartParser, FormParser)
|
||||||
|
|
||||||
|
@extend_schema(
|
||||||
|
request={
|
||||||
|
'multipart/form-data': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'file': {
|
||||||
|
'type': 'string',
|
||||||
|
'format': 'binary'
|
||||||
|
},
|
||||||
|
'file_url': {
|
||||||
|
'type': 'string',
|
||||||
|
'description': '音频文件的URL地址'
|
||||||
|
},
|
||||||
|
'project_id': {
|
||||||
|
'type': 'integer',
|
||||||
|
'description': '关联的参赛项目ID'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses={201: TranscriptionTaskSerializer}
|
||||||
|
)
|
||||||
def create(self, request, *args, **kwargs):
|
def create(self, request, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
上传音频文件并创建听悟转写任务
|
上传音频文件并创建听悟转写任务
|
||||||
"""
|
"""
|
||||||
file_obj = request.FILES.get('file')
|
file_obj = request.FILES.get('file')
|
||||||
if not file_obj:
|
file_url = request.data.get('file_url')
|
||||||
return Response({'error': '未提供文件'}, status=status.HTTP_400_BAD_REQUEST)
|
project_id = request.data.get('project_id')
|
||||||
|
|
||||||
|
if not file_obj and not file_url:
|
||||||
|
return Response({'error': '请提供文件或文件URL'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
service = AliyunTingwuService()
|
service = AliyunTingwuService()
|
||||||
if not service.bucket or not service.client:
|
if not service.bucket or not service.client:
|
||||||
return Response({'error': '阿里云服务未配置'}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
return Response({'error': '阿里云服务未配置'}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
oss_url = None
|
||||||
|
if file_obj:
|
||||||
# 1. 上传文件到 OSS
|
# 1. 上传文件到 OSS
|
||||||
file_extension = file_obj.name.split('.')[-1]
|
file_extension = file_obj.name.split('.')[-1]
|
||||||
file_name = f"transcription/{uuid.uuid4()}.{file_extension}"
|
file_name = f"transcription/{uuid.uuid4()}.{file_extension}"
|
||||||
|
|
||||||
# 使用服务上传
|
# 使用服务上传
|
||||||
oss_url = service.upload_to_oss(file_obj, file_name)
|
oss_url = service.upload_to_oss(file_obj, file_name)
|
||||||
|
else:
|
||||||
|
# 使用提供的 URL
|
||||||
|
oss_url = file_url
|
||||||
|
|
||||||
# 2. 创建数据库记录
|
# 2. 创建数据库记录
|
||||||
task_record = TranscriptionTask.objects.create(
|
task_data = {
|
||||||
file_url=oss_url,
|
'file_url': oss_url,
|
||||||
status=TranscriptionTask.Status.PENDING
|
'status': TranscriptionTask.Status.PENDING
|
||||||
)
|
}
|
||||||
|
if project_id:
|
||||||
|
try:
|
||||||
|
p_id = int(project_id)
|
||||||
|
# 只有当 ID > 0 时才认为是有效的项目 ID
|
||||||
|
# 避免前端传递 0 或 Swagger 默认值导致的外键约束错误
|
||||||
|
if p_id > 0:
|
||||||
|
task_data['project_id'] = p_id
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass # Ignore invalid project_id
|
||||||
|
|
||||||
|
task_record = TranscriptionTask.objects.create(**task_data)
|
||||||
|
logger.info(f"Created TranscriptionTask {task_record.id} with project_id={project_id}")
|
||||||
|
|
||||||
# 3. 调用听悟接口创建任务
|
# 3. 调用听悟接口创建任务
|
||||||
try:
|
try:
|
||||||
tingwu_response = service.create_transcription_task(oss_url)
|
tingwu_response = service.create_transcription_task(oss_url)
|
||||||
|
|
||||||
|
# 兼容处理响应结构,通常为 {"Data": {"TaskId": "...", ...}}
|
||||||
|
if 'Data' in tingwu_response and isinstance(tingwu_response['Data'], dict):
|
||||||
|
task_id = tingwu_response['Data'].get('TaskId')
|
||||||
|
else:
|
||||||
task_id = tingwu_response.get('TaskId')
|
task_id = tingwu_response.get('TaskId')
|
||||||
|
|
||||||
if task_id:
|
if task_id:
|
||||||
@@ -112,15 +160,135 @@ class TranscriptionTaskViewSet(viewsets.ModelViewSet):
|
|||||||
logger.error(f"处理上传请求失败: {e}")
|
logger.error(f"处理上传请求失败: {e}")
|
||||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
@extend_schema(
|
||||||
|
request={
|
||||||
|
'application/json': {
|
||||||
|
'type': 'object',
|
||||||
|
'properties': {
|
||||||
|
'model_selection': {'type': 'string', 'description': '模型选择'},
|
||||||
|
'prompt': {'type': 'string', 'description': '评分提示词'},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
responses={200: AIEvaluationSerializer(many=True)}
|
||||||
|
)
|
||||||
|
def evaluate(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
触发AI评估
|
||||||
|
"""
|
||||||
|
task = self.get_object()
|
||||||
|
|
||||||
|
# 1. 如果有 active template,触发所有 active template
|
||||||
|
# 2. 如果请求体提供了 custom prompt,则创建一个 custom evaluation (no template)
|
||||||
|
|
||||||
|
from .models import AIEvaluationTemplate
|
||||||
|
from .bailian_service import BailianService
|
||||||
|
service = BailianService()
|
||||||
|
|
||||||
|
evaluations_to_process = []
|
||||||
|
|
||||||
|
# A. 如果指定了 Prompt/Model,视为手动单次评估
|
||||||
|
model_selection = request.data.get('model_selection')
|
||||||
|
prompt = request.data.get('prompt')
|
||||||
|
|
||||||
|
if prompt:
|
||||||
|
# 创建一个不关联 Template 的评估
|
||||||
|
eval, _ = AIEvaluation.objects.get_or_create(
|
||||||
|
task=task,
|
||||||
|
template=None,
|
||||||
|
defaults={
|
||||||
|
'model_selection': model_selection or 'qwen-plus',
|
||||||
|
'prompt': prompt
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# 更新配置
|
||||||
|
eval.model_selection = model_selection or eval.model_selection
|
||||||
|
eval.prompt = prompt
|
||||||
|
eval.save()
|
||||||
|
evaluations_to_process.append(eval)
|
||||||
|
else:
|
||||||
|
# B. 否则触发所有 Active Templates
|
||||||
|
active_templates = AIEvaluationTemplate.objects.filter(is_active=True)
|
||||||
|
if not active_templates.exists():
|
||||||
|
return Response({'message': 'No active templates and no custom prompt provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
for t in active_templates:
|
||||||
|
eval, _ = AIEvaluation.objects.get_or_create(
|
||||||
|
task=task,
|
||||||
|
template=t,
|
||||||
|
defaults={
|
||||||
|
'model_selection': t.model_selection,
|
||||||
|
'prompt': t.prompt
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# 始终更新为模板最新配置? 或者保留历史? 用户意图似乎是"模版搭好...启用...生成几份"
|
||||||
|
# 这里假设触发时应用模板当前配置
|
||||||
|
eval.model_selection = t.model_selection
|
||||||
|
eval.prompt = t.prompt
|
||||||
|
eval.save()
|
||||||
|
evaluations_to_process.append(eval)
|
||||||
|
|
||||||
|
# 执行评估 (改为异步并发执行)
|
||||||
|
# 提取ID列表,避免传递模型对象导致可能的线程问题
|
||||||
|
eval_ids = [e.id for e in evaluations_to_process]
|
||||||
|
|
||||||
|
if eval_ids:
|
||||||
|
import threading
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
def run_evaluations_background(ids):
|
||||||
|
# 在后台线程中重新引入依赖
|
||||||
|
from .models import AIEvaluation
|
||||||
|
from .bailian_service import BailianService
|
||||||
|
|
||||||
|
# 为该线程创建独立的服务实例
|
||||||
|
local_service = BailianService()
|
||||||
|
|
||||||
|
# 获取最新的对象
|
||||||
|
target_evals = AIEvaluation.objects.filter(id__in=ids)
|
||||||
|
|
||||||
|
# 使用线程池并发执行
|
||||||
|
# max_workers=4 可以同时处理4个评估请求
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||||
|
executor.map(local_service.evaluate_task, target_evals)
|
||||||
|
|
||||||
|
# 启动后台线程,不阻塞当前 HTTP 请求
|
||||||
|
thread = threading.Thread(target=run_evaluations_background, args=(eval_ids,))
|
||||||
|
thread.daemon = True # 设置为守护线程
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
# 返回该任务的所有评估结果
|
||||||
|
all_evals = AIEvaluation.objects.filter(task=task)
|
||||||
|
serializer = AIEvaluationSerializer(all_evals, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
@action(detail=True, methods=['get'])
|
@action(detail=True, methods=['get'])
|
||||||
|
@extend_schema(
|
||||||
|
parameters=[
|
||||||
|
OpenApiParameter("id", OpenApiTypes.UUID, OpenApiParameter.PATH, description="Task ID"),
|
||||||
|
],
|
||||||
|
responses={200: TranscriptionTaskSerializer}
|
||||||
|
)
|
||||||
def refresh_status(self, request, pk=None):
|
def refresh_status(self, request, pk=None):
|
||||||
"""
|
"""
|
||||||
刷新任务状态并获取结果
|
刷新任务状态并获取结果
|
||||||
"""
|
"""
|
||||||
task = self.get_object()
|
task = self.get_object()
|
||||||
|
|
||||||
# 如果任务已经完成或失败,直接返回当前状态
|
# 允许刷新的条件:
|
||||||
if task.status in [TranscriptionTask.Status.SUCCEEDED, TranscriptionTask.Status.FAILED]:
|
# 1. 任务未完成 (PENDING, PROCESSING)
|
||||||
|
# 2. 任务已完成但逐字稿 (transcription) 为空
|
||||||
|
# 3. 任务已完成但 AI总结 (summary) 为空 (新增)
|
||||||
|
|
||||||
|
should_refresh = False
|
||||||
|
if task.status not in [TranscriptionTask.Status.SUCCEEDED, TranscriptionTask.Status.FAILED]:
|
||||||
|
should_refresh = True
|
||||||
|
elif task.status == TranscriptionTask.Status.SUCCEEDED:
|
||||||
|
if not task.transcription or not task.summary:
|
||||||
|
should_refresh = True
|
||||||
|
|
||||||
|
if not should_refresh:
|
||||||
serializer = self.get_serializer(task)
|
serializer = self.get_serializer(task)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
@@ -130,48 +298,63 @@ class TranscriptionTaskViewSet(viewsets.ModelViewSet):
|
|||||||
service = AliyunTingwuService()
|
service = AliyunTingwuService()
|
||||||
try:
|
try:
|
||||||
result = service.get_task_info(task.task_id)
|
result = service.get_task_info(task.task_id)
|
||||||
task_status = result.get('TaskStatus')
|
|
||||||
|
|
||||||
if task_status == 'COMPLETE':
|
# 兼容处理响应结构 {"Data": {"TaskStatus": "...", "Result": ...}}
|
||||||
task.status = TranscriptionTask.Status.SUCCEEDED
|
# 有些情况下 SDK 返回的是 JSON 字符串,需要二次解析
|
||||||
|
if isinstance(result, str):
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
result = json.loads(result)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
# 解析结果
|
if isinstance(result, dict):
|
||||||
task_result = result.get('Result', {})
|
data_obj = result.get('Data', result)
|
||||||
|
|
||||||
# 提取逐字稿
|
|
||||||
sentences = task_result.get('Transcription', {}).get('Sentences', [])
|
|
||||||
full_text = " ".join([s.get('Text', '') for s in sentences])
|
|
||||||
task.transcription = full_text
|
|
||||||
|
|
||||||
# 提取总结
|
|
||||||
# 总结结果结构可能因配置不同而异,这里尝试获取摘要
|
|
||||||
summarization = task_result.get('Summarization', {})
|
|
||||||
# 听悟的总结通常在 Summarization.Text 或类似字段
|
|
||||||
# 如果是章节摘要,可能在 Chapters 中
|
|
||||||
# 假设是全文摘要
|
|
||||||
if 'Text' in summarization:
|
|
||||||
task.summary = summarization['Text']
|
|
||||||
elif 'Headline' in summarization:
|
|
||||||
task.summary = summarization['Headline']
|
|
||||||
else:
|
else:
|
||||||
# 尝试从章节摘要中提取
|
data_obj = result
|
||||||
chapters = task_result.get('Chapters', [])
|
if not isinstance(data_obj, dict):
|
||||||
summary_parts = []
|
# 如果 Data 不是字典,可能它本身就是字符串,或者 result 结构更平铺
|
||||||
for chapter in chapters:
|
data_obj = result
|
||||||
if 'Headline' in chapter:
|
|
||||||
summary_parts.append(chapter['Headline'])
|
|
||||||
if 'Summary' in chapter:
|
|
||||||
summary_parts.append(chapter['Summary'])
|
|
||||||
task.summary = "\n".join(summary_parts)
|
|
||||||
|
|
||||||
task.save()
|
# 防御性编程:确保 data_obj 是字典
|
||||||
|
if not isinstance(data_obj, dict):
|
||||||
|
logger.error(f"Unexpected response format: {type(data_obj)} - {data_obj}")
|
||||||
|
return Response({'error': f"Unexpected response format: {type(data_obj)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
elif task_status == 'FAILED':
|
# 调用 Service 进行解析和更新
|
||||||
task.status = TranscriptionTask.Status.FAILED
|
service.parse_and_update_task(task, result)
|
||||||
task.error_message = result.get('TaskStatusText', 'Unknown error')
|
|
||||||
task.save()
|
|
||||||
|
|
||||||
# 其他状态 (PENDING, RUNNING) 不做更改
|
# 如果任务成功但 AI 总结仍为空 (可能之前解析没触发,或者大模型调用失败)
|
||||||
|
# 再次尝试强制触发 summarize_task (如果原始数据存在)
|
||||||
|
# 注意:service.parse_and_update_task 内部已经尝试异步触发,这里作为补救措施
|
||||||
|
if task.status == TranscriptionTask.Status.SUCCEEDED and not task.summary:
|
||||||
|
if task.summary_data or task.auto_chapters_data:
|
||||||
|
try:
|
||||||
|
# 先设置状态为 "AI总结生成当中..."
|
||||||
|
task.summary = "AI总结生成当中..."
|
||||||
|
task.save(update_fields=['summary'])
|
||||||
|
|
||||||
|
# 异步触发总结生成
|
||||||
|
import threading
|
||||||
|
from .bailian_service import BailianService
|
||||||
|
|
||||||
|
def async_summarize(task_id):
|
||||||
|
try:
|
||||||
|
# 重新获取 task 对象以避免线程问题
|
||||||
|
from .models import TranscriptionTask
|
||||||
|
task_obj = TranscriptionTask.objects.get(id=task_id)
|
||||||
|
bailian_service = BailianService()
|
||||||
|
bailian_service.summarize_task(task_obj)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Async summary generation failed: {e}")
|
||||||
|
|
||||||
|
threading.Thread(target=async_summarize, args=(task.id,)).start()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Force trigger AI summarization failed: {e}")
|
||||||
|
|
||||||
|
# 重新获取 task 以包含更新后的关联字段
|
||||||
|
task.refresh_from_db()
|
||||||
|
|
||||||
serializer = self.get_serializer(task)
|
serializer = self.get_serializer(task)
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|||||||
@@ -120,9 +120,9 @@ class OrderableAdminMixin:
|
|||||||
|
|
||||||
@admin.register(Activity)
|
@admin.register(Activity)
|
||||||
class ActivityAdmin(ModelAdmin):
|
class ActivityAdmin(ModelAdmin):
|
||||||
list_display = ('title', 'author', 'banner_display', 'start_time', 'location', 'signup_count', 'is_visible', 'is_active', 'auto_confirm', 'created_at')
|
list_display = ('title', 'author_info_display', 'banner_display', 'start_time', 'location', 'signup_count', 'is_visible', 'is_active', 'auto_confirm', 'created_at')
|
||||||
list_filter = ('is_visible', 'is_active', 'auto_confirm', 'start_time')
|
list_filter = ('is_visible', 'is_active', 'auto_confirm', 'start_time')
|
||||||
search_fields = ('title', 'location')
|
search_fields = ('title', 'location', 'author__phone_number')
|
||||||
# autocomplete_fields = ['author'] # 暂时注释,避免环境不一致导致报错
|
# autocomplete_fields = ['author'] # 暂时注释,避免环境不一致导致报错
|
||||||
raw_id_fields = ('author',)
|
raw_id_fields = ('author',)
|
||||||
inlines = [ActivitySignupInline]
|
inlines = [ActivitySignupInline]
|
||||||
@@ -141,6 +141,14 @@ class ActivityAdmin(ModelAdmin):
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@display(description="发布者 (手机号/昵称)")
|
||||||
|
def author_info_display(self, obj):
|
||||||
|
if not obj.author:
|
||||||
|
return "-"
|
||||||
|
phone = obj.author.phone_number or "无手机号"
|
||||||
|
nickname = obj.author.nickname or "无昵称"
|
||||||
|
return f"{phone} ({nickname})"
|
||||||
|
|
||||||
@display(description="Banner")
|
@display(description="Banner")
|
||||||
def banner_display(self, obj):
|
def banner_display(self, obj):
|
||||||
if obj.banner:
|
if obj.banner:
|
||||||
@@ -155,9 +163,9 @@ class ActivityAdmin(ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(ActivitySignup)
|
@admin.register(ActivitySignup)
|
||||||
class ActivitySignupAdmin(ModelAdmin):
|
class ActivitySignupAdmin(ModelAdmin):
|
||||||
list_display = ('activity', 'user', 'signup_time', 'status_label', 'order_link')
|
list_display = ('activity', 'user_info_display', 'signup_time', 'status_label', 'order_link')
|
||||||
list_filter = ('status', 'signup_time', 'activity')
|
list_filter = ('status', 'signup_time', 'activity')
|
||||||
search_fields = ('user__nickname', 'activity__title')
|
search_fields = ('user__nickname', 'user__phone_number', 'activity__title')
|
||||||
autocomplete_fields = ['activity', 'user']
|
autocomplete_fields = ['activity', 'user']
|
||||||
actions = [export_signups_csv, export_signups_excel]
|
actions = [export_signups_csv, export_signups_excel]
|
||||||
|
|
||||||
@@ -172,6 +180,12 @@ class ActivitySignupAdmin(ModelAdmin):
|
|||||||
)
|
)
|
||||||
readonly_fields = ('signup_time', 'signup_info_display')
|
readonly_fields = ('signup_time', 'signup_info_display')
|
||||||
|
|
||||||
|
@display(description="报名用户 (手机号/昵称)")
|
||||||
|
def user_info_display(self, obj):
|
||||||
|
phone = obj.user.phone_number or "无手机号"
|
||||||
|
nickname = obj.user.nickname or "无昵称"
|
||||||
|
return f"{phone} ({nickname})"
|
||||||
|
|
||||||
@display(description="报名信息")
|
@display(description="报名信息")
|
||||||
def signup_info_display(self, obj):
|
def signup_info_display(self, obj):
|
||||||
import json
|
import json
|
||||||
@@ -209,15 +223,23 @@ class ActivitySignupAdmin(ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Topic)
|
@admin.register(Topic)
|
||||||
class TopicAdmin(OrderableAdminMixin, ModelAdmin):
|
class TopicAdmin(OrderableAdminMixin, ModelAdmin):
|
||||||
list_display = ('title', 'status', 'category', 'author', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at', 'order_actions')
|
list_display = ('title', 'status', 'category', 'author_info_display', 'get_related_item', 'reply_count', 'view_count', 'is_pinned', 'created_at', 'order_actions')
|
||||||
list_filter = ('status', 'category', 'is_pinned', 'created_at', 'related_product', 'related_service', 'related_course')
|
list_filter = ('status', 'category', 'is_pinned', 'created_at', 'related_product', 'related_service', 'related_course')
|
||||||
search_fields = ('title', 'content', 'author__nickname')
|
search_fields = ('title', 'content', 'author__nickname', 'author__phone_number')
|
||||||
autocomplete_fields = ['author', 'related_product', 'related_service', 'related_course']
|
autocomplete_fields = ['author', 'related_product', 'related_service', 'related_course']
|
||||||
filter_horizontal = ('likes',)
|
filter_horizontal = ('likes',)
|
||||||
inlines = [TopicMediaInline, ReplyInline]
|
inlines = [TopicMediaInline, ReplyInline]
|
||||||
actions = ['reset_ordering', 'approve_topics', 'reject_topics']
|
actions = ['reset_ordering', 'approve_topics', 'reject_topics']
|
||||||
list_editable = ('status', 'is_pinned', 'view_count')
|
list_editable = ('status', 'is_pinned', 'view_count')
|
||||||
|
|
||||||
|
@display(description="作者 (手机号/昵称)")
|
||||||
|
def author_info_display(self, obj):
|
||||||
|
if not obj.author:
|
||||||
|
return "-"
|
||||||
|
phone = obj.author.phone_number or "无手机号"
|
||||||
|
nickname = obj.author.nickname or "无昵称"
|
||||||
|
return f"{phone} ({nickname})"
|
||||||
|
|
||||||
@admin.action(description="批量通过审核")
|
@admin.action(description="批量通过审核")
|
||||||
def approve_topics(self, request, queryset):
|
def approve_topics(self, request, queryset):
|
||||||
rows_updated = queryset.update(status='published')
|
rows_updated = queryset.update(status='published')
|
||||||
@@ -277,9 +299,9 @@ class TopicAdmin(OrderableAdminMixin, ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Reply)
|
@admin.register(Reply)
|
||||||
class ReplyAdmin(ModelAdmin):
|
class ReplyAdmin(ModelAdmin):
|
||||||
list_display = ('short_content', 'topic', 'author', 'is_pinned', 'like_count', 'created_at')
|
list_display = ('short_content', 'topic', 'author_info_display', 'is_pinned', 'like_count', 'created_at')
|
||||||
list_filter = ('is_pinned', 'created_at')
|
list_filter = ('is_pinned', 'created_at')
|
||||||
search_fields = ('content', 'author__nickname', 'topic__title')
|
search_fields = ('content', 'author__nickname', 'author__phone_number', 'topic__title')
|
||||||
autocomplete_fields = ['author', 'topic', 'reply_to']
|
autocomplete_fields = ['author', 'topic', 'reply_to']
|
||||||
filter_horizontal = ('likes',)
|
filter_horizontal = ('likes',)
|
||||||
list_editable = ('is_pinned',)
|
list_editable = ('is_pinned',)
|
||||||
@@ -295,6 +317,14 @@ class ReplyAdmin(ModelAdmin):
|
|||||||
)
|
)
|
||||||
readonly_fields = ('created_at',)
|
readonly_fields = ('created_at',)
|
||||||
|
|
||||||
|
@display(description="回复者 (手机号/昵称)")
|
||||||
|
def author_info_display(self, obj):
|
||||||
|
if not obj.author:
|
||||||
|
return "-"
|
||||||
|
phone = obj.author.phone_number or "无手机号"
|
||||||
|
nickname = obj.author.nickname or "无昵称"
|
||||||
|
return f"{phone} ({nickname})"
|
||||||
|
|
||||||
@display(description="点赞数")
|
@display(description="点赞数")
|
||||||
def like_count(self, obj):
|
def like_count(self, obj):
|
||||||
return obj.likes.count()
|
return obj.likes.count()
|
||||||
|
|||||||
20
backend/community/migrations/0003_alter_activity_author.py
Normal file
20
backend/community/migrations/0003_alter_activity_author.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-17 11:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('community', '0002_activity_author'),
|
||||||
|
('shop', '0039_vccourse_video_embed_code'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='activity',
|
||||||
|
name='author',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activities', to='shop.wechatuser', to_field='phone_number', verbose_name='发布者'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -18,7 +18,7 @@ class Activity(models.Model):
|
|||||||
is_paid = models.BooleanField(default=False, verbose_name="是否收费")
|
is_paid = models.BooleanField(default=False, verbose_name="是否收费")
|
||||||
price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="报名费用")
|
price = models.DecimalField(max_digits=10, decimal_places=2, default=0, verbose_name="报名费用")
|
||||||
|
|
||||||
author = models.ForeignKey(WeChatUser, on_delete=models.SET_NULL, related_name='activities', verbose_name="发布者", null=True, blank=True)
|
author = models.ForeignKey(WeChatUser, to_field='phone_number', on_delete=models.SET_NULL, related_name='activities', verbose_name="发布者", null=True, blank=True)
|
||||||
|
|
||||||
is_active = models.BooleanField(default=True, verbose_name="是否启用")
|
is_active = models.BooleanField(default=True, verbose_name="是否启用")
|
||||||
is_visible = models.BooleanField(default=True, verbose_name="是否显示", help_text="关闭后将不在前端列表页显示")
|
is_visible = models.BooleanField(default=True, verbose_name="是否显示", help_text="关闭后将不在前端列表页显示")
|
||||||
|
|||||||
@@ -343,6 +343,8 @@ class ReplyViewSet(viewsets.ModelViewSet):
|
|||||||
return Response({'liked': liked, 'count': obj.likes.count()})
|
return Response({'liked': liked, 'count': obj.likes.count()})
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
import warnings
|
||||||
|
warnings.filterwarnings('ignore', message='Unverified HTTPS request')
|
||||||
|
|
||||||
class TopicMediaViewSet(viewsets.ViewSet):
|
class TopicMediaViewSet(viewsets.ViewSet):
|
||||||
"""
|
"""
|
||||||
@@ -367,7 +369,8 @@ class TopicMediaViewSet(viewsets.ViewSet):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# 这里的 headers 不需要 Content-Type,requests 会自动设置 multipart/form-data
|
# 这里的 headers 不需要 Content-Type,requests 会自动设置 multipart/form-data
|
||||||
response = requests.post(upload_url, files=files, timeout=30)
|
# 注意: verify=False 跳过SSL证书验证(data.tangledup-ai.com 证书已过期)
|
||||||
|
response = requests.post(upload_url, files=files, timeout=30, verify=False)
|
||||||
|
|
||||||
if response.status_code == 200:
|
if response.status_code == 200:
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|||||||
@@ -1,23 +1,77 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
from unfold.admin import ModelAdmin
|
from unfold.admin import ModelAdmin
|
||||||
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment
|
from unfold.decorators import display
|
||||||
|
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment, ScoreFormula
|
||||||
|
|
||||||
|
|
||||||
class ScoreDimensionInline(admin.TabularInline):
|
class ScoreDimensionInline(admin.TabularInline):
|
||||||
model = ScoreDimension
|
model = ScoreDimension
|
||||||
extra = 1
|
extra = 1
|
||||||
tab = True
|
tab = True
|
||||||
|
fields = ('dimension_id_display', 'name', 'description', 'weight', 'max_score', 'formula_type', 'formula', 'is_public', 'is_peer_review', 'order')
|
||||||
|
readonly_fields = ('dimension_id_display',)
|
||||||
|
|
||||||
|
@admin.display(description="维度ID")
|
||||||
|
def dimension_id_display(self, obj):
|
||||||
|
return f"dimension_{obj.id}" if obj.id else "(新建)"
|
||||||
|
|
||||||
|
@admin.display(description="算式预览")
|
||||||
|
def formula_preview(self, obj):
|
||||||
|
preview = obj.get_formula_preview()
|
||||||
|
return preview if preview else "-"
|
||||||
|
|
||||||
|
|
||||||
|
class ScoreFormulaInline(admin.TabularInline):
|
||||||
|
model = ScoreFormula
|
||||||
|
extra = 1
|
||||||
|
tab = True
|
||||||
|
fields = ('name', 'formula', 'is_active', 'is_default')
|
||||||
|
|
||||||
|
@admin.display(description="公式预览")
|
||||||
|
def formula_preview_display(self, obj):
|
||||||
|
preview = obj.get_formula_preview()
|
||||||
|
return preview[:50] + '...' if len(preview) > 50 else preview if preview else '-'
|
||||||
|
|
||||||
|
|
||||||
class ProjectFileInline(admin.TabularInline):
|
class ProjectFileInline(admin.TabularInline):
|
||||||
model = ProjectFile
|
model = ProjectFile
|
||||||
extra = 0
|
extra = 0
|
||||||
tab = True
|
tab = True
|
||||||
|
readonly_fields = ('file_url_display',)
|
||||||
|
|
||||||
|
def file_url_display(self, obj):
|
||||||
|
if obj.file_url:
|
||||||
|
return mark_safe(f'<a href="{obj.file_url}" target="_blank">{obj.file_url[:50]}...</a>')
|
||||||
|
elif obj.file:
|
||||||
|
return obj.file.url
|
||||||
|
return "-"
|
||||||
|
file_url_display.short_description = "文件链接"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(ProjectFile)
|
||||||
|
class ProjectFileAdmin(ModelAdmin):
|
||||||
|
list_display = ['id', 'project', 'name', 'file_type', 'file_url_display', 'created_at']
|
||||||
|
list_filter = ['file_type', 'created_at']
|
||||||
|
search_fields = ['name', 'project__title']
|
||||||
|
readonly_fields = ('file_url_display',)
|
||||||
|
|
||||||
|
def file_url_display(self, obj):
|
||||||
|
if obj.file_url:
|
||||||
|
return mark_safe(f'<a href="{obj.file_url}" target="_blank">打开文件</a>')
|
||||||
|
elif obj.file:
|
||||||
|
return obj.file.url
|
||||||
|
return "-"
|
||||||
|
file_url_display.short_description = "文件链接"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Competition)
|
@admin.register(Competition)
|
||||||
class CompetitionAdmin(ModelAdmin):
|
class CompetitionAdmin(ModelAdmin):
|
||||||
list_display = ['title', 'status', 'start_time', 'end_time', 'is_active', 'created_at']
|
list_display = ['title', 'status', 'allow_contestant_grading', 'start_time', 'end_time', 'is_active', 'created_at']
|
||||||
list_filter = ['status', 'is_active']
|
list_filter = ['status', 'allow_contestant_grading', 'is_active']
|
||||||
search_fields = ['title', 'description']
|
search_fields = ['title', 'description']
|
||||||
inlines = [ScoreDimensionInline]
|
inlines = [ScoreDimensionInline, ScoreFormulaInline]
|
||||||
|
autocomplete_fields = ['active_formula']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('基本信息', {
|
('基本信息', {
|
||||||
@@ -28,7 +82,11 @@ class CompetitionAdmin(ModelAdmin):
|
|||||||
'description': '封面图可以上传本地图片,也可以填写外部链接,优先显示本地上传的图片'
|
'description': '封面图可以上传本地图片,也可以填写外部链接,优先显示本地上传的图片'
|
||||||
}),
|
}),
|
||||||
('时间和状态', {
|
('时间和状态', {
|
||||||
'fields': ('start_time', 'end_time', 'status', 'project_visibility', 'is_active')
|
'fields': ('start_time', 'end_time', 'status', 'project_visibility', 'allow_contestant_grading', 'is_active')
|
||||||
|
}),
|
||||||
|
('评分配置', {
|
||||||
|
'fields': ('score_calculation_type', 'active_formula'),
|
||||||
|
'description': '配置得分计算方式:默认加权平均或使用评分公式'
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,13 +100,29 @@ class CompetitionAdmin(ModelAdmin):
|
|||||||
queryset.update(status='ended')
|
queryset.update(status='ended')
|
||||||
make_ended.short_description = "结束选中比赛"
|
make_ended.short_description = "结束选中比赛"
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
'all': ('competition/admin/css/competition-admin.css',)
|
||||||
|
}
|
||||||
|
js = ('competition/admin/js/competition-admin.js',)
|
||||||
|
|
||||||
|
|
||||||
@admin.register(CompetitionEnrollment)
|
@admin.register(CompetitionEnrollment)
|
||||||
class CompetitionEnrollmentAdmin(ModelAdmin):
|
class CompetitionEnrollmentAdmin(ModelAdmin):
|
||||||
list_display = ['competition', 'user', 'role', 'status', 'created_at']
|
list_display = ['competition', 'user_info_display', 'role', 'status', 'created_at']
|
||||||
list_filter = ['competition', 'role', 'status']
|
list_filter = ['competition', 'role', 'status']
|
||||||
search_fields = ['user__nickname', 'competition__title']
|
search_fields = ['user__nickname', 'user__phone_number', 'competition__title']
|
||||||
|
autocomplete_fields = ['user', 'competition']
|
||||||
actions = ['approve_enrollment', 'reject_enrollment']
|
actions = ['approve_enrollment', 'reject_enrollment']
|
||||||
|
|
||||||
|
@display(description="报名用户 (手机号/昵称)")
|
||||||
|
def user_info_display(self, obj):
|
||||||
|
if not obj.user:
|
||||||
|
return "-"
|
||||||
|
phone = obj.user.phone_number or "无手机号"
|
||||||
|
nickname = obj.user.nickname or "无昵称"
|
||||||
|
return f"{phone} ({nickname})"
|
||||||
|
|
||||||
def approve_enrollment(self, request, queryset):
|
def approve_enrollment(self, request, queryset):
|
||||||
queryset.update(status='approved')
|
queryset.update(status='approved')
|
||||||
approve_enrollment.short_description = "通过审核"
|
approve_enrollment.short_description = "通过审核"
|
||||||
@@ -57,13 +131,15 @@ class CompetitionEnrollmentAdmin(ModelAdmin):
|
|||||||
queryset.update(status='rejected')
|
queryset.update(status='rejected')
|
||||||
reject_enrollment.short_description = "拒绝申请"
|
reject_enrollment.short_description = "拒绝申请"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Project)
|
@admin.register(Project)
|
||||||
class ProjectAdmin(ModelAdmin):
|
class ProjectAdmin(ModelAdmin):
|
||||||
list_display = ['title', 'competition', 'contestant', 'status', 'final_score', 'created_at']
|
list_display = ['id', 'title', 'competition', 'contestant_info_display', 'status', 'final_score', 'created_at']
|
||||||
list_filter = ['competition', 'status']
|
list_filter = ['competition', 'status']
|
||||||
search_fields = ['title', 'contestant__user__nickname']
|
search_fields = ['id', 'title', 'contestant__user__nickname', 'contestant__user__phone_number']
|
||||||
|
autocomplete_fields = ['competition', 'contestant']
|
||||||
inlines = [ProjectFileInline]
|
inlines = [ProjectFileInline]
|
||||||
readonly_fields = ['final_score']
|
readonly_fields = ['id', 'final_score']
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
('基本信息', {
|
('基本信息', {
|
||||||
@@ -78,18 +154,83 @@ class ProjectAdmin(ModelAdmin):
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@display(description="参赛人员 (手机号/昵称)")
|
||||||
|
def contestant_info_display(self, obj):
|
||||||
|
if not obj.contestant or not obj.contestant.user:
|
||||||
|
return "-"
|
||||||
|
user = obj.contestant.user
|
||||||
|
phone = user.phone_number or "无手机号"
|
||||||
|
nickname = user.nickname or "无昵称"
|
||||||
|
return f"{phone} ({nickname})"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Score)
|
@admin.register(Score)
|
||||||
class ScoreAdmin(ModelAdmin):
|
class ScoreAdmin(ModelAdmin):
|
||||||
list_display = ['project', 'judge', 'dimension', 'score', 'created_at']
|
list_display = ['project', 'judge_info_display', 'dimension', 'score', 'created_at']
|
||||||
list_filter = ['project__competition', 'dimension']
|
list_filter = ['project__competition', 'dimension']
|
||||||
search_fields = ['project__title', 'judge__user__nickname']
|
search_fields = ['project__title', 'judge__user__nickname', 'judge__user__phone_number']
|
||||||
|
autocomplete_fields = ['project', 'judge']
|
||||||
|
|
||||||
|
@display(description="评委 (手机号/昵称)")
|
||||||
|
def judge_info_display(self, obj):
|
||||||
|
if not obj.judge or not obj.judge.user:
|
||||||
|
return "-"
|
||||||
|
user = obj.judge.user
|
||||||
|
phone = user.phone_number or "无手机号"
|
||||||
|
nickname = user.nickname or "无昵称"
|
||||||
|
return f"{phone} ({nickname})"
|
||||||
|
|
||||||
|
|
||||||
@admin.register(Comment)
|
@admin.register(Comment)
|
||||||
class CommentAdmin(ModelAdmin):
|
class CommentAdmin(ModelAdmin):
|
||||||
list_display = ['project', 'judge', 'content_preview', 'created_at']
|
list_display = ['project', 'judge_info_display', 'content_preview', 'created_at']
|
||||||
list_filter = ['project__competition']
|
list_filter = ['project__competition']
|
||||||
search_fields = ['project__title', 'judge__user__nickname', 'content']
|
search_fields = ['project__title', 'judge__user__nickname', 'judge__user__phone_number', 'content']
|
||||||
|
autocomplete_fields = ['project', 'judge']
|
||||||
|
|
||||||
|
@display(description="评委 (手机号/昵称)")
|
||||||
|
def judge_info_display(self, obj):
|
||||||
|
if not obj.judge or not obj.judge.user:
|
||||||
|
return "-"
|
||||||
|
user = obj.judge.user
|
||||||
|
phone = user.phone_number or "无手机号"
|
||||||
|
nickname = user.nickname or "无昵称"
|
||||||
|
return f"{phone} ({nickname})"
|
||||||
|
|
||||||
def content_preview(self, obj):
|
def content_preview(self, obj):
|
||||||
return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
|
return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
|
||||||
content_preview.short_description = "评语内容"
|
content_preview.short_description = "评语内容"
|
||||||
|
|
||||||
|
|
||||||
|
class ScoreFormulaAdmin(ModelAdmin):
|
||||||
|
list_display = ['name', 'competition', 'formula_preview_display', 'is_active', 'is_default', 'created_at']
|
||||||
|
list_filter = ['competition', 'is_active', 'is_default']
|
||||||
|
search_fields = ['name', 'description', 'formula', 'competition__title']
|
||||||
|
autocomplete_fields = ['competition']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('基本信息', {
|
||||||
|
'fields': ('competition', 'name', 'description')
|
||||||
|
}),
|
||||||
|
('公式配置', {
|
||||||
|
'fields': ('formula',),
|
||||||
|
'description': '使用 dimension_X 作为变量(X为维度ID),例如: (dimension_1 + dimension_2) / 2'
|
||||||
|
}),
|
||||||
|
('公式设置', {
|
||||||
|
'fields': ('is_active', 'is_default')
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
@display(description="公式预览")
|
||||||
|
def formula_preview_display(self, obj):
|
||||||
|
preview = obj.get_formula_preview()
|
||||||
|
return preview[:100] + '...' if len(preview) > 100 else preview if preview else '-'
|
||||||
|
|
||||||
|
class Media:
|
||||||
|
css = {
|
||||||
|
'all': ('competition/admin/css/competition-admin.css', 'competition/admin/css/formula-editor.css')
|
||||||
|
}
|
||||||
|
js = ('competition/admin/js/competition-admin.js', 'competition/admin/js/formula-editor.js')
|
||||||
|
|
||||||
|
|
||||||
|
admin.site.register(ScoreFormula, ScoreFormulaAdmin)
|
||||||
|
|||||||
21
backend/competition/judge_urls.py
Normal file
21
backend/competition/judge_urls.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from django.views.generic import RedirectView
|
||||||
|
from . import judge_views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
# 默认跳转到登录页
|
||||||
|
path('', RedirectView.as_view(url='login/', permanent=False), name='judge_index'),
|
||||||
|
path('login/', judge_views.login_view, name='judge_login'),
|
||||||
|
path('logout/', judge_views.logout_view, name='judge_logout'),
|
||||||
|
path('send_code/', judge_views.send_code, name='judge_send_code'),
|
||||||
|
path('dashboard/', judge_views.dashboard, name='judge_dashboard'),
|
||||||
|
path('upload/', judge_views.upload_audio, name='judge_upload'),
|
||||||
|
path('ai/manage/', judge_views.ai_manage, name='judge_ai_manage'),
|
||||||
|
|
||||||
|
# API
|
||||||
|
path('api/projects/<int:project_id>/', judge_views.project_detail_api, name='judge_project_detail_api'),
|
||||||
|
path('api/score/submit/', judge_views.submit_score, name='judge_submit_score'),
|
||||||
|
path('api/upload/', judge_views.upload_audio, name='judge_api_upload'),
|
||||||
|
path('api/upload/url/', judge_views.upload_audio_url, name='judge_api_upload_url'),
|
||||||
|
path('api/ai/<str:task_id>/delete/', judge_views.delete_ai_task, name='judge_delete_ai_task'),
|
||||||
|
]
|
||||||
790
backend/competition/judge_views.py
Normal file
790
backend/competition/judge_views.py
Normal file
@@ -0,0 +1,790 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
import threading
|
||||||
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
|
from django.http import JsonResponse, HttpResponse
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.contrib.auth.models import User
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db.models import Q, Avg
|
||||||
|
from django.utils import timezone
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from .models import Competition, CompetitionEnrollment, Project, Score, ScoreDimension, Comment, ProjectFile
|
||||||
|
from shop.models import WeChatUser
|
||||||
|
from shop.sms_utils import send_sms
|
||||||
|
from ai_services.models import TranscriptionTask, AIEvaluation
|
||||||
|
from ai_services.services import AliyunTingwuService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# --- Helper Functions ---
|
||||||
|
|
||||||
|
def get_client_ip(request):
|
||||||
|
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
|
||||||
|
if x_forwarded_for:
|
||||||
|
ip = x_forwarded_for.split(',')[0]
|
||||||
|
else:
|
||||||
|
ip = request.META.get('REMOTE_ADDR')
|
||||||
|
return ip
|
||||||
|
|
||||||
|
def log_audit(request, action, target, result="SUCCESS", details=""):
|
||||||
|
judge_id = request.session.get('judge_id')
|
||||||
|
phone = request.session.get('judge_phone', 'Unknown')
|
||||||
|
role = request.session.get('judge_role', 'unknown')
|
||||||
|
ip = get_client_ip(request)
|
||||||
|
timestamp = timezone.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
|
||||||
|
log_entry = f"[{timestamp}] IP:{ip} | Phone:{phone} | Role:{role} | Action:{action} | Target:{target} | Result:{result} | Details:{details}\n"
|
||||||
|
|
||||||
|
# Write to a file
|
||||||
|
try:
|
||||||
|
with open(settings.BASE_DIR / 'judge_audit.log', 'a', encoding='utf-8') as f:
|
||||||
|
f.write(log_entry)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to write audit log: {e}")
|
||||||
|
|
||||||
|
def judge_required(view_func):
|
||||||
|
def wrapper(request, *args, **kwargs):
|
||||||
|
if not request.session.get('judge_id'):
|
||||||
|
return redirect('judge_login')
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
def check_contestant_access(view_func):
|
||||||
|
"""
|
||||||
|
Check if the user is allowed to access.
|
||||||
|
Contestants have limited access.
|
||||||
|
"""
|
||||||
|
def wrapper(request, *args, **kwargs):
|
||||||
|
if not request.session.get('judge_id'):
|
||||||
|
return redirect('judge_login')
|
||||||
|
|
||||||
|
role = request.session.get('judge_role')
|
||||||
|
if role == 'contestant':
|
||||||
|
# Some views might be restricted for contestants
|
||||||
|
# For now, this decorator just ensures login, but specific views handle logic
|
||||||
|
pass
|
||||||
|
|
||||||
|
return view_func(request, *args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
# --- Views ---
|
||||||
|
|
||||||
|
def admin_entry(request):
|
||||||
|
"""Entry point for /competition/admin"""
|
||||||
|
if request.session.get('judge_id'):
|
||||||
|
return redirect('judge_dashboard')
|
||||||
|
return redirect('judge_login')
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
def login_view(request):
|
||||||
|
if request.method == 'GET':
|
||||||
|
return render(request, 'judge/login.html')
|
||||||
|
|
||||||
|
phone = request.POST.get('phone')
|
||||||
|
code = request.POST.get('code')
|
||||||
|
|
||||||
|
if not phone or not code:
|
||||||
|
return render(request, 'judge/login.html', {'error': '请输入手机号和验证码'})
|
||||||
|
|
||||||
|
# Verify Code
|
||||||
|
cached_code = cache.get(f"sms_code_{phone}")
|
||||||
|
# Universal pass code for development/testing
|
||||||
|
if code != cached_code and code != '888888':
|
||||||
|
return render(request, 'judge/login.html', {'error': '验证码错误 or expired'})
|
||||||
|
|
||||||
|
# Check User
|
||||||
|
try:
|
||||||
|
user = WeChatUser.objects.filter(phone_number=phone).first()
|
||||||
|
if not user:
|
||||||
|
return render(request, 'judge/login.html', {'error': '该手机号未绑定用户'})
|
||||||
|
|
||||||
|
# Check roles
|
||||||
|
# Priority: Judge > Guest > Contestant (if allowed)
|
||||||
|
is_judge = CompetitionEnrollment.objects.filter(user=user, role='judge').exists()
|
||||||
|
is_guest = CompetitionEnrollment.objects.filter(user=user, role='guest').exists()
|
||||||
|
|
||||||
|
role = None
|
||||||
|
if is_judge:
|
||||||
|
role = 'judge'
|
||||||
|
elif is_guest:
|
||||||
|
role = 'guest'
|
||||||
|
else:
|
||||||
|
# Check if contestant in any competition with allow_contestant_grading=True
|
||||||
|
contestant_enrollments = CompetitionEnrollment.objects.filter(
|
||||||
|
user=user,
|
||||||
|
role='contestant',
|
||||||
|
competition__allow_contestant_grading=True
|
||||||
|
)
|
||||||
|
if contestant_enrollments.exists():
|
||||||
|
role = 'contestant'
|
||||||
|
|
||||||
|
if not role:
|
||||||
|
return render(request, 'judge/login.html', {'error': '您没有权限登录系统'})
|
||||||
|
|
||||||
|
# Login Success
|
||||||
|
request.session['judge_id'] = user.id
|
||||||
|
request.session['judge_phone'] = phone
|
||||||
|
request.session['judge_name'] = user.nickname
|
||||||
|
request.session['judge_role'] = role
|
||||||
|
|
||||||
|
log_audit(request, 'LOGIN', 'System', 'SUCCESS', f"User {user.nickname} logged in as {role}")
|
||||||
|
|
||||||
|
return redirect('judge_dashboard')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Login error: {e}")
|
||||||
|
return render(request, 'judge/login.html', {'error': '系统错误'})
|
||||||
|
|
||||||
|
@csrf_exempt
|
||||||
|
def send_code(request):
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'success': False, 'message': 'Method not allowed'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
phone = data.get('phone')
|
||||||
|
|
||||||
|
if not phone or len(phone) != 11:
|
||||||
|
return JsonResponse({'success': False, 'message': 'Invalid phone number'})
|
||||||
|
|
||||||
|
# Generate Code
|
||||||
|
code = str(random.randint(100000, 999999)) # 6 digits to match typical SMS
|
||||||
|
cache.set(f"sms_code_{phone}", code, timeout=300) # 5 mins
|
||||||
|
|
||||||
|
# Send SMS using the specified API
|
||||||
|
def _send_async():
|
||||||
|
try:
|
||||||
|
api_url = "https://data.tangledup-ai.com/api/send-sms"
|
||||||
|
payload = {
|
||||||
|
"phone_number": phone,
|
||||||
|
"code": code,
|
||||||
|
"template_code": "SMS_493295002",
|
||||||
|
"sign_name": "叠加态科技云南",
|
||||||
|
"additionalProp1": {}
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"accept": "application/json"
|
||||||
|
}
|
||||||
|
response = requests.post(api_url, json=payload, headers=headers, timeout=15)
|
||||||
|
logger.info(f"SMS Response for {phone}: {response.status_code} - {response.text}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"发送短信异常: {str(e)}")
|
||||||
|
|
||||||
|
threading.Thread(target=_send_async).start()
|
||||||
|
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
def logout_view(request):
|
||||||
|
log_audit(request, 'LOGOUT', 'System')
|
||||||
|
request.session.flush()
|
||||||
|
return redirect('judge_login')
|
||||||
|
|
||||||
|
@judge_required
|
||||||
|
def dashboard(request):
|
||||||
|
judge_id = request.session['judge_id']
|
||||||
|
role = request.session.get('judge_role', 'judge')
|
||||||
|
user = WeChatUser.objects.get(id=judge_id)
|
||||||
|
|
||||||
|
# Get competitions
|
||||||
|
if role == 'judge':
|
||||||
|
enrollments = CompetitionEnrollment.objects.filter(user=user, role='judge')
|
||||||
|
elif role == 'guest':
|
||||||
|
enrollments = CompetitionEnrollment.objects.filter(user=user, role='guest')
|
||||||
|
else:
|
||||||
|
# Contestant: only competitions allowing grading
|
||||||
|
enrollments = CompetitionEnrollment.objects.filter(
|
||||||
|
user=user,
|
||||||
|
role='contestant',
|
||||||
|
competition__allow_contestant_grading=True
|
||||||
|
)
|
||||||
|
|
||||||
|
competition_ids = enrollments.values_list('competition_id', flat=True)
|
||||||
|
|
||||||
|
# Get Projects
|
||||||
|
projects = Project.objects.filter(
|
||||||
|
competition_id__in=competition_ids,
|
||||||
|
status='submitted'
|
||||||
|
).select_related('contestant__user')
|
||||||
|
|
||||||
|
# Format for template
|
||||||
|
project_list = []
|
||||||
|
for p in projects:
|
||||||
|
# Check current score/grading status for this user
|
||||||
|
# Note: Score model links to 'judge' which is a CompetitionEnrollment
|
||||||
|
# We need the enrollment for this user in this competition
|
||||||
|
user_enrollment = enrollments.filter(competition=p.competition).first()
|
||||||
|
|
||||||
|
project_list.append({
|
||||||
|
'id': p.id,
|
||||||
|
'title': p.title,
|
||||||
|
'cover_image_url': p.cover_image_url or (p.cover_image.url if p.cover_image else ''),
|
||||||
|
'contestant_name': p.contestant.user.nickname,
|
||||||
|
'current_score': p.final_score, # Global score
|
||||||
|
'status_class': 'status-submitted',
|
||||||
|
'get_status_display': p.get_status_display()
|
||||||
|
})
|
||||||
|
|
||||||
|
return render(request, 'judge/dashboard.html', {
|
||||||
|
'projects': project_list,
|
||||||
|
'user_role': role,
|
||||||
|
'user_name': request.session.get('judge_name', '用户')
|
||||||
|
})
|
||||||
|
|
||||||
|
@judge_required
|
||||||
|
def project_detail_api(request, project_id):
|
||||||
|
judge_id = request.session['judge_id']
|
||||||
|
role = request.session.get('judge_role', 'judge')
|
||||||
|
user = WeChatUser.objects.get(id=judge_id)
|
||||||
|
project = get_object_or_404(Project, id=project_id)
|
||||||
|
|
||||||
|
# Check permission
|
||||||
|
# User must be enrolled in the project's competition with correct role/settings
|
||||||
|
if role == 'judge':
|
||||||
|
enrollment = CompetitionEnrollment.objects.filter(user=user, role='judge', competition=project.competition).first()
|
||||||
|
elif role == 'guest':
|
||||||
|
enrollment = CompetitionEnrollment.objects.filter(user=user, role='guest', competition=project.competition).first()
|
||||||
|
else:
|
||||||
|
enrollment = CompetitionEnrollment.objects.filter(
|
||||||
|
user=user,
|
||||||
|
role='contestant',
|
||||||
|
competition=project.competition,
|
||||||
|
competition__allow_contestant_grading=True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not enrollment:
|
||||||
|
return JsonResponse({'error': 'No permission'}, status=403)
|
||||||
|
|
||||||
|
# Get Dimensions - 根据角色过滤
|
||||||
|
if role == 'contestant':
|
||||||
|
dimensions = ScoreDimension.objects.filter(
|
||||||
|
competition=project.competition,
|
||||||
|
is_public=True,
|
||||||
|
is_peer_review=True
|
||||||
|
).order_by('order')
|
||||||
|
else:
|
||||||
|
dimensions = ScoreDimension.objects.filter(
|
||||||
|
competition=project.competition,
|
||||||
|
is_public=True,
|
||||||
|
is_peer_review=False
|
||||||
|
).order_by('order')
|
||||||
|
|
||||||
|
# Get existing scores by THIS user
|
||||||
|
scores = Score.objects.filter(project=project, judge=enrollment)
|
||||||
|
score_map = {s.dimension_id: s.score for s in scores}
|
||||||
|
|
||||||
|
dim_data = []
|
||||||
|
for d in dimensions:
|
||||||
|
dim_data.append({
|
||||||
|
'id': d.id,
|
||||||
|
'name': d.name,
|
||||||
|
'weight': float(d.weight),
|
||||||
|
'max_score': d.max_score,
|
||||||
|
'current_score': float(score_map.get(d.id, 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get Comments
|
||||||
|
# If role is contestant, they CANNOT see other people's comments
|
||||||
|
history = []
|
||||||
|
current_comment = ""
|
||||||
|
|
||||||
|
if role in ['judge', 'guest']:
|
||||||
|
comments = Comment.objects.filter(project=project).order_by('-created_at')
|
||||||
|
for c in comments:
|
||||||
|
history.append({
|
||||||
|
'judge_name': c.judge.user.nickname,
|
||||||
|
'content': c.content,
|
||||||
|
'created_at': c.created_at.strftime("%Y-%m-%d %H:%M")
|
||||||
|
})
|
||||||
|
if c.judge.id == enrollment.id:
|
||||||
|
current_comment = c.content
|
||||||
|
else:
|
||||||
|
# Contestant: only see their own comment
|
||||||
|
my_comment = Comment.objects.filter(project=project, judge=enrollment).first()
|
||||||
|
if my_comment:
|
||||||
|
current_comment = my_comment.content
|
||||||
|
history.append({
|
||||||
|
'judge_name': user.nickname, # Self
|
||||||
|
'content': my_comment.content,
|
||||||
|
'created_at': my_comment.created_at.strftime("%Y-%m-%d %H:%M")
|
||||||
|
})
|
||||||
|
|
||||||
|
# Include AI results
|
||||||
|
latest_task = TranscriptionTask.objects.filter(project=project, status='SUCCEEDED').order_by('-created_at').first()
|
||||||
|
ai_data = None
|
||||||
|
if latest_task:
|
||||||
|
ai_data = {
|
||||||
|
'transcription': latest_task.transcription,
|
||||||
|
'summary': latest_task.summary,
|
||||||
|
'auto_chapters_data': latest_task.auto_chapters_data,
|
||||||
|
'transcription_data': latest_task.transcription_data
|
||||||
|
}
|
||||||
|
|
||||||
|
latest_task_any = TranscriptionTask.objects.filter(project=project).order_by('-created_at').first()
|
||||||
|
audio_url = latest_task_any.file_url if latest_task_any else None
|
||||||
|
|
||||||
|
# 计算各类评分(仅对评委和嘉宾可见)
|
||||||
|
judge_score_avg = None
|
||||||
|
peer_score_avg = None
|
||||||
|
ai_score_avg = None
|
||||||
|
final_score = float(project.final_score) if project.final_score else 0
|
||||||
|
|
||||||
|
if role in ['judge', 'guest']:
|
||||||
|
competition = project.competition
|
||||||
|
|
||||||
|
# 评委评分:is_public=True 的维度(评委可见的维度)
|
||||||
|
judge_dimensions = ScoreDimension.objects.filter(
|
||||||
|
competition=competition,
|
||||||
|
is_public=True
|
||||||
|
)
|
||||||
|
judge_enrollments = CompetitionEnrollment.objects.filter(
|
||||||
|
competition=competition,
|
||||||
|
role='judge'
|
||||||
|
)
|
||||||
|
|
||||||
|
if judge_dimensions.exists() and judge_enrollments.exists():
|
||||||
|
judge_total = 0
|
||||||
|
judge_count = 0
|
||||||
|
for judge_enrollment in judge_enrollments:
|
||||||
|
judge_project_scores = Score.objects.filter(
|
||||||
|
project=project,
|
||||||
|
judge=judge_enrollment,
|
||||||
|
dimension__in=judge_dimensions
|
||||||
|
)
|
||||||
|
if judge_project_scores.exists():
|
||||||
|
judge_score = sum(
|
||||||
|
float(s.score) * float(s.dimension.weight)
|
||||||
|
for s in judge_project_scores
|
||||||
|
)
|
||||||
|
judge_total += judge_score
|
||||||
|
judge_count += 1
|
||||||
|
if judge_count > 0:
|
||||||
|
judge_score_avg = round(judge_total / judge_count, 2)
|
||||||
|
|
||||||
|
# 选手互评分:allow_contestant_grading=True 且 is_peer_review=True 的维度
|
||||||
|
if competition.allow_contestant_grading:
|
||||||
|
peer_dimensions = ScoreDimension.objects.filter(
|
||||||
|
competition=competition,
|
||||||
|
is_peer_review=True
|
||||||
|
)
|
||||||
|
peer_enrollments = CompetitionEnrollment.objects.filter(
|
||||||
|
competition=competition,
|
||||||
|
role='contestant'
|
||||||
|
)
|
||||||
|
|
||||||
|
if peer_dimensions.exists() and peer_enrollments.exists():
|
||||||
|
peer_total = 0
|
||||||
|
peer_count = 0
|
||||||
|
for peer_enrollment in peer_enrollments:
|
||||||
|
peer_project_scores = Score.objects.filter(
|
||||||
|
project=project,
|
||||||
|
judge=peer_enrollment,
|
||||||
|
dimension__in=peer_dimensions
|
||||||
|
)
|
||||||
|
if peer_project_scores.exists():
|
||||||
|
peer_score = sum(
|
||||||
|
float(s.score) * float(s.dimension.weight)
|
||||||
|
for s in peer_project_scores
|
||||||
|
)
|
||||||
|
peer_total += peer_score
|
||||||
|
peer_count += 1
|
||||||
|
if peer_count > 0:
|
||||||
|
peer_score_avg = round(peer_total / peer_count, 2)
|
||||||
|
|
||||||
|
# AI评分:is_public=False 且 is_peer_review=False 的维度(两个都为false就是AI维度)
|
||||||
|
ai_dimensions = ScoreDimension.objects.filter(
|
||||||
|
competition=competition,
|
||||||
|
is_public=False,
|
||||||
|
is_peer_review=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if ai_dimensions.exists():
|
||||||
|
ai_enrollments = CompetitionEnrollment.objects.filter(
|
||||||
|
competition=competition,
|
||||||
|
role='judge'
|
||||||
|
)
|
||||||
|
|
||||||
|
if ai_enrollments.exists():
|
||||||
|
ai_total = 0
|
||||||
|
ai_count = 0
|
||||||
|
for ai_enrollment in ai_enrollments:
|
||||||
|
ai_project_scores = Score.objects.filter(
|
||||||
|
project=project,
|
||||||
|
judge=ai_enrollment,
|
||||||
|
dimension__in=ai_dimensions
|
||||||
|
)
|
||||||
|
if ai_project_scores.exists():
|
||||||
|
ai_score = sum(
|
||||||
|
float(s.score) * float(s.dimension.weight)
|
||||||
|
for s in ai_project_scores
|
||||||
|
)
|
||||||
|
ai_total += ai_score
|
||||||
|
ai_count += 1
|
||||||
|
if ai_count > 0:
|
||||||
|
ai_score_avg = round(ai_total / ai_count, 2)
|
||||||
|
|
||||||
|
# 判断是否为选手查看自己的项目
|
||||||
|
is_own_project = role == 'contestant' and project.contestant.user == user
|
||||||
|
|
||||||
|
# 获取项目文件(PPT、PDF等)
|
||||||
|
project_files = ProjectFile.objects.filter(project=project)
|
||||||
|
files_data = []
|
||||||
|
for f in project_files:
|
||||||
|
file_url = f.file.url if f.file else f.file_url
|
||||||
|
if file_url:
|
||||||
|
files_data.append({
|
||||||
|
'id': f.id,
|
||||||
|
'name': f.name,
|
||||||
|
'file_type': f.file_type,
|
||||||
|
'file_url': file_url
|
||||||
|
})
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'id': project.id,
|
||||||
|
'title': project.title,
|
||||||
|
'description': project.description,
|
||||||
|
'contestant_name': project.contestant.user.nickname,
|
||||||
|
'dimensions': dim_data,
|
||||||
|
'history_comments': history,
|
||||||
|
'current_comment': current_comment,
|
||||||
|
'ai_result': ai_data,
|
||||||
|
'audio_url': audio_url,
|
||||||
|
'files': files_data,
|
||||||
|
'can_grade': role == 'judge' or (role == 'contestant' and project.contestant.user != user),
|
||||||
|
'is_own_project': is_own_project,
|
||||||
|
# 评分细项(评委、嘉宾可见,选手查看自己的项目时也可见)
|
||||||
|
'score_details': {
|
||||||
|
'judge_score': judge_score_avg,
|
||||||
|
'peer_score': peer_score_avg,
|
||||||
|
'ai_score': ai_score_avg,
|
||||||
|
'final_score': final_score
|
||||||
|
} if role in ['judge', 'guest'] or is_own_project else None,
|
||||||
|
# 评分公式信息(评委、嘉宾可见,选手查看自己的项目时也可见)
|
||||||
|
'formula_info': {
|
||||||
|
'name': project.competition.active_formula.name if project.competition.active_formula else None,
|
||||||
|
'formula': project.competition.active_formula.formula if project.competition.active_formula else None,
|
||||||
|
'preview': project.competition.active_formula.get_formula_preview() if project.competition.active_formula else None
|
||||||
|
} if project.competition.score_calculation_type == 'formula' and (role in ['judge', 'guest'] or is_own_project) else None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Specifically for guest: can_grade is False
|
||||||
|
if role == 'guest':
|
||||||
|
data['can_grade'] = False
|
||||||
|
|
||||||
|
return JsonResponse(data)
|
||||||
|
|
||||||
|
@judge_required
|
||||||
|
@csrf_exempt
|
||||||
|
def submit_score(request):
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'success': False, 'message': 'Method not allowed'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
project_id = data.get('project_id')
|
||||||
|
comment_content = data.get('comment')
|
||||||
|
|
||||||
|
judge_id = request.session['judge_id']
|
||||||
|
role = request.session.get('judge_role', 'judge')
|
||||||
|
|
||||||
|
if role == 'guest':
|
||||||
|
return JsonResponse({'success': False, 'message': '嘉宾无权评分'})
|
||||||
|
|
||||||
|
user = WeChatUser.objects.get(id=judge_id)
|
||||||
|
project = get_object_or_404(Project, id=project_id)
|
||||||
|
|
||||||
|
enrollment = None
|
||||||
|
if role == 'judge':
|
||||||
|
enrollment = CompetitionEnrollment.objects.filter(user=user, role='judge', competition=project.competition).first()
|
||||||
|
else:
|
||||||
|
enrollment = CompetitionEnrollment.objects.filter(
|
||||||
|
user=user,
|
||||||
|
role='contestant',
|
||||||
|
competition=project.competition,
|
||||||
|
competition__allow_contestant_grading=True
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not enrollment:
|
||||||
|
return JsonResponse({'success': False, 'message': 'No permission'})
|
||||||
|
|
||||||
|
# Save Scores - 根据角色过滤维度
|
||||||
|
if role == 'contestant':
|
||||||
|
dimensions = ScoreDimension.objects.filter(
|
||||||
|
competition=project.competition,
|
||||||
|
is_public=True,
|
||||||
|
is_peer_review=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
dimensions = ScoreDimension.objects.filter(
|
||||||
|
competition=project.competition,
|
||||||
|
is_public=True,
|
||||||
|
is_peer_review=False
|
||||||
|
)
|
||||||
|
for d in dimensions:
|
||||||
|
score_key = f'score_{d.id}'
|
||||||
|
if score_key in data:
|
||||||
|
val = data[score_key]
|
||||||
|
Score.objects.update_or_create(
|
||||||
|
project=project,
|
||||||
|
judge=enrollment,
|
||||||
|
dimension=d,
|
||||||
|
defaults={'score': val}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save Comment
|
||||||
|
if comment_content:
|
||||||
|
Comment.objects.update_or_create(
|
||||||
|
project=project,
|
||||||
|
judge=enrollment,
|
||||||
|
defaults={'content': comment_content}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Recalculate Project Score
|
||||||
|
project.calculate_score()
|
||||||
|
|
||||||
|
log_audit(request, 'SCORE_UPDATE', f"Project {project.id}", 'SUCCESS')
|
||||||
|
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Submit score error: {e}")
|
||||||
|
return JsonResponse({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@judge_required
|
||||||
|
@csrf_exempt
|
||||||
|
def upload_audio(request):
|
||||||
|
# Contestants cannot upload, but Guests can
|
||||||
|
role = request.session.get('judge_role')
|
||||||
|
if role not in ['judge', 'guest']:
|
||||||
|
return JsonResponse({'success': False, 'message': 'Permission denied'})
|
||||||
|
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'success': False, 'message': 'Method not allowed'})
|
||||||
|
|
||||||
|
judge_id = request.session['judge_id']
|
||||||
|
file_obj = request.FILES.get('file')
|
||||||
|
project_id = request.POST.get('project_id')
|
||||||
|
|
||||||
|
if not file_obj or not project_id:
|
||||||
|
return JsonResponse({'success': False, 'message': 'Missing file or project_id'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check permission
|
||||||
|
user = WeChatUser.objects.get(id=judge_id)
|
||||||
|
project = Project.objects.get(id=project_id)
|
||||||
|
|
||||||
|
# Verify judge/guest has access to this project's competition
|
||||||
|
enrollment = CompetitionEnrollment.objects.filter(
|
||||||
|
user=user,
|
||||||
|
role=role,
|
||||||
|
competition=project.competition
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not enrollment:
|
||||||
|
return JsonResponse({'success': False, 'message': 'No permission for this project'})
|
||||||
|
|
||||||
|
# Upload to OSS & Create Task
|
||||||
|
service = AliyunTingwuService()
|
||||||
|
if not service.bucket:
|
||||||
|
return JsonResponse({'success': False, 'message': 'OSS not configured'})
|
||||||
|
|
||||||
|
file_extension = file_obj.name.split('.')[-1]
|
||||||
|
file_name = f"transcription/{uuid.uuid4()}.{file_extension}"
|
||||||
|
oss_url = service.upload_to_oss(file_obj, file_name)
|
||||||
|
|
||||||
|
# Create Task Record
|
||||||
|
task = TranscriptionTask.objects.create(
|
||||||
|
project=project,
|
||||||
|
file_url=oss_url,
|
||||||
|
status=TranscriptionTask.Status.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call Tingwu
|
||||||
|
try:
|
||||||
|
tingwu_response = service.create_transcription_task(oss_url)
|
||||||
|
# Handle response format
|
||||||
|
if 'Data' in tingwu_response and isinstance(tingwu_response['Data'], dict):
|
||||||
|
task_id = tingwu_response['Data'].get('TaskId')
|
||||||
|
else:
|
||||||
|
task_id = tingwu_response.get('TaskId')
|
||||||
|
|
||||||
|
if task_id:
|
||||||
|
task.task_id = task_id
|
||||||
|
task.status = TranscriptionTask.Status.PROCESSING
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
log_audit(request, 'UPLOAD_AUDIO', f"Task {task.id}", 'SUCCESS')
|
||||||
|
return JsonResponse({'success': True, 'task_id': task.id, 'file_url': oss_url})
|
||||||
|
else:
|
||||||
|
task.status = TranscriptionTask.Status.FAILED
|
||||||
|
task.error_message = "No TaskId returned"
|
||||||
|
task.save()
|
||||||
|
return JsonResponse({'success': False, 'message': 'Failed to create Tingwu task'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
task.status = TranscriptionTask.Status.FAILED
|
||||||
|
task.error_message = str(e)
|
||||||
|
task.save()
|
||||||
|
return JsonResponse({'success': False, 'message': f'Tingwu Error: {e}'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Upload error: {e}")
|
||||||
|
return JsonResponse({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@judge_required
|
||||||
|
@csrf_exempt
|
||||||
|
def upload_audio_url(request):
|
||||||
|
"""
|
||||||
|
处理 URL 上传音频的 API
|
||||||
|
通过给定的音频 URL 直接进行处理,无需上传文件
|
||||||
|
"""
|
||||||
|
role = request.session.get('judge_role')
|
||||||
|
if role not in ['judge', 'guest']:
|
||||||
|
return JsonResponse({'success': False, 'message': 'Permission denied'})
|
||||||
|
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'success': False, 'message': 'Method not allowed'})
|
||||||
|
|
||||||
|
import json
|
||||||
|
try:
|
||||||
|
data = json.loads(request.body)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return JsonResponse({'success': False, 'message': 'Invalid JSON'})
|
||||||
|
|
||||||
|
audio_url = data.get('url')
|
||||||
|
project_id = data.get('project_id')
|
||||||
|
|
||||||
|
if not audio_url or not project_id:
|
||||||
|
return JsonResponse({'success': False, 'message': 'Missing url or project_id'})
|
||||||
|
|
||||||
|
# 验证 URL 格式
|
||||||
|
if not audio_url.startswith(('http://', 'https://')):
|
||||||
|
return JsonResponse({'success': False, 'message': 'Invalid URL format'})
|
||||||
|
|
||||||
|
judge_id = request.session['judge_id']
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 验证权限
|
||||||
|
user = WeChatUser.objects.get(id=judge_id)
|
||||||
|
project = Project.objects.get(id=project_id)
|
||||||
|
|
||||||
|
enrollment = CompetitionEnrollment.objects.filter(
|
||||||
|
user=user,
|
||||||
|
role=role,
|
||||||
|
competition=project.competition
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not enrollment:
|
||||||
|
return JsonResponse({'success': False, 'message': 'No permission for this project'})
|
||||||
|
|
||||||
|
# 创建任务记录,使用 URL 作为 file_url
|
||||||
|
service = AliyunTingwuService()
|
||||||
|
|
||||||
|
task = TranscriptionTask.objects.create(
|
||||||
|
project=project,
|
||||||
|
file_url=audio_url,
|
||||||
|
status=TranscriptionTask.Status.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
# 调用 Tingwu 服务
|
||||||
|
try:
|
||||||
|
tingwu_response = service.create_transcription_task(audio_url)
|
||||||
|
|
||||||
|
if 'Data' in tingwu_response and isinstance(tingwu_response['Data'], dict):
|
||||||
|
task_id = tingwu_response['Data'].get('TaskId')
|
||||||
|
else:
|
||||||
|
task_id = tingwu_response.get('TaskId')
|
||||||
|
|
||||||
|
if task_id:
|
||||||
|
task.task_id = task_id
|
||||||
|
task.status = TranscriptionTask.Status.PROCESSING
|
||||||
|
task.save()
|
||||||
|
|
||||||
|
log_audit(request, 'UPLOAD_AUDIO_URL', f"Task {task.id}", 'SUCCESS')
|
||||||
|
return JsonResponse({'success': True, 'task_id': task.id, 'file_url': audio_url})
|
||||||
|
else:
|
||||||
|
task.status = TranscriptionTask.Status.FAILED
|
||||||
|
task.error_message = "No TaskId returned"
|
||||||
|
task.save()
|
||||||
|
return JsonResponse({'success': False, 'message': 'Failed to create Tingwu task'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
task.status = TranscriptionTask.Status.FAILED
|
||||||
|
task.error_message = str(e)
|
||||||
|
task.save()
|
||||||
|
return JsonResponse({'success': False, 'message': f'Tingwu Error: {e}'})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Upload URL error: {e}")
|
||||||
|
return JsonResponse({'success': False, 'message': str(e)})
|
||||||
|
|
||||||
|
@judge_required
|
||||||
|
def ai_manage(request):
|
||||||
|
# Contestants cannot access AI manage
|
||||||
|
role = request.session.get('judge_role')
|
||||||
|
if role not in ['judge', 'guest']:
|
||||||
|
return redirect('judge_dashboard')
|
||||||
|
|
||||||
|
judge_id = request.session['judge_id']
|
||||||
|
user = WeChatUser.objects.get(id=judge_id)
|
||||||
|
enrollments = CompetitionEnrollment.objects.filter(user=user, role=role)
|
||||||
|
competition_ids = enrollments.values_list('competition_id', flat=True)
|
||||||
|
|
||||||
|
# Get tasks for projects in these competitions
|
||||||
|
tasks = TranscriptionTask.objects.filter(
|
||||||
|
project__competition_id__in=competition_ids
|
||||||
|
).select_related('project').order_by('-created_at')
|
||||||
|
|
||||||
|
task_list = []
|
||||||
|
for t in tasks:
|
||||||
|
# Get Evaluation Score
|
||||||
|
# AIEvaluation is linked to Task
|
||||||
|
evals = t.ai_evaluations.all()
|
||||||
|
score = evals[0].score if evals else None
|
||||||
|
|
||||||
|
task_list.append({
|
||||||
|
'id': t.id,
|
||||||
|
'project': t.project,
|
||||||
|
'file_url': t.file_url,
|
||||||
|
'file_name': t.file_url.split('/')[-1] if t.file_url else 'Unknown',
|
||||||
|
'status': t.status,
|
||||||
|
'status_class': 'status-' + t.status.lower(), # CSS class
|
||||||
|
'get_status_display': t.get_status_display(),
|
||||||
|
'ai_score': score
|
||||||
|
})
|
||||||
|
|
||||||
|
return render(request, 'judge/ai_manage.html', {
|
||||||
|
'tasks': task_list,
|
||||||
|
'user_name': request.session.get('judge_name', '用户'),
|
||||||
|
'user_role': role
|
||||||
|
})
|
||||||
|
|
||||||
|
@judge_required
|
||||||
|
@csrf_exempt
|
||||||
|
def delete_ai_task(request, task_id):
|
||||||
|
role = request.session.get('judge_role')
|
||||||
|
if role not in ['judge', 'guest']:
|
||||||
|
return JsonResponse({'success': False, 'message': 'Permission denied'})
|
||||||
|
|
||||||
|
if request.method != 'POST':
|
||||||
|
return JsonResponse({'success': False, 'message': 'Method not allowed'})
|
||||||
|
|
||||||
|
try:
|
||||||
|
task = get_object_or_404(TranscriptionTask, id=task_id)
|
||||||
|
# Permission check
|
||||||
|
# ...
|
||||||
|
|
||||||
|
task.delete()
|
||||||
|
log_audit(request, 'DELETE_TASK', f"Task {task_id}", 'SUCCESS')
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
except Exception as e:
|
||||||
|
return JsonResponse({'success': False, 'message': str(e)})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-12 05:12
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competition', '0003_competition_project_visibility'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='competition',
|
||||||
|
name='allow_contestant_grading',
|
||||||
|
field=models.BooleanField(default=False, verbose_name='允许选手互评'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-12 05:33
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competition', '0004_competition_allow_contestant_grading'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='scoredimension',
|
||||||
|
name='is_public',
|
||||||
|
field=models.BooleanField(default=True, help_text='如果关闭,评委端将看不到此评分维度,通常用于AI自动评分', verbose_name='是否公开给评委'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
backend/competition/migrations/0006_add_peer_review_field.py
Normal file
18
backend/competition/migrations/0006_add_peer_review_field.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-17 14:30
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competition', '0005_scoredimension_is_public'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='scoredimension',
|
||||||
|
name='is_peer_review',
|
||||||
|
field=models.BooleanField(default=False, help_text='如果开启,此评分维度仅在选手互评时可见,评委和嘉宾看不到', verbose_name='是否用于选手互评'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-20 05:15
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competition', '0006_add_peer_review_field'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='competition',
|
||||||
|
name='custom_score_formula',
|
||||||
|
field=models.CharField(blank=True, help_text='如使用自定义算式,将使用此公式计算最终得分。变量格式: dimension_维度ID,如 dimension_1, dimension_2', max_length=1000, verbose_name='自定义得分算式'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='competition',
|
||||||
|
name='score_calculation_type',
|
||||||
|
field=models.CharField(choices=[('default', '默认加权平均'), ('custom', '自定义算式')], default='default', max_length=20, verbose_name='得分计算方式'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='scoredimension',
|
||||||
|
name='formula',
|
||||||
|
field=models.CharField(blank=True, help_text='使用维度ID作为变量,如: dimension_1 * 0.3 + dimension_2 * 0.5 + dimension_3 * 0.2', max_length=500, verbose_name='自定义算式'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='scoredimension',
|
||||||
|
name='formula_type',
|
||||||
|
field=models.CharField(choices=[('weight', '权重模式'), ('formula', '自定义算式')], default='weight', max_length=20, verbose_name='算式类型'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='scoredimension',
|
||||||
|
name='weight',
|
||||||
|
field=models.DecimalField(decimal_places=4, default=1.0, help_text='例如 0.3000 表示 30%', max_digits=6, verbose_name='权重'),
|
||||||
|
),
|
||||||
|
]
|
||||||
33
backend/competition/migrations/0008_scoreformula.py
Normal file
33
backend/competition/migrations/0008_scoreformula.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-20 05:27
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competition', '0007_competition_custom_score_formula_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ScoreFormula',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(help_text='用于标识这个公式,方便管理', max_length=100, verbose_name='公式名称')),
|
||||||
|
('description', models.TextField(blank=True, verbose_name='公式说明')),
|
||||||
|
('formula', models.TextField(help_text='使用维度名称作为变量,支持四则运算和函数', verbose_name='计算公式')),
|
||||||
|
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
|
||||||
|
('is_default', models.BooleanField(default=False, verbose_name='是否设为默认公式')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||||
|
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='score_formulas', to='competition.competition', verbose_name='所属比赛')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': '评分公式',
|
||||||
|
'verbose_name_plural': '评分公式配置',
|
||||||
|
'ordering': ['-is_default', '-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-20 06:07
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competition', '0008_scoreformula'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='competition',
|
||||||
|
name='active_formula',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='active_competitions', to='competition.scoreformula', verbose_name='启用的评分公式'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='competition',
|
||||||
|
name='score_calculation_type',
|
||||||
|
field=models.CharField(choices=[('default', '默认加权平均'), ('formula', '使用评分公式')], default='default', max_length=20, verbose_name='得分计算方式'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-20 06:19
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competition', '0009_competition_active_formula_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RemoveField(
|
||||||
|
model_name='competition',
|
||||||
|
name='custom_score_formula',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-22 13:58
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competition', '0010_remove_competition_custom_score_formula'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='projectfile',
|
||||||
|
name='file_url',
|
||||||
|
field=models.TextField(blank=True, help_text='OSS URL或外部链接', null=True, verbose_name='文件链接'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-22 16:04
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competition', '0011_increase_file_url_length'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='competition',
|
||||||
|
name='cover_image_url',
|
||||||
|
field=models.CharField(blank=True, help_text='优先使用上传的图片', max_length=1000, null=True, verbose_name='封面图URL'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='project',
|
||||||
|
name='cover_image_url',
|
||||||
|
field=models.CharField(blank=True, help_text='优先使用上传的图片', max_length=1000, null=True, verbose_name='项目封面URL'),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
backend/competition/migrations/0013_projectfile_file_size.py
Normal file
18
backend/competition/migrations/0013_projectfile_file_size.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0.1 on 2026-03-22 16:15
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('competition', '0012_alter_competition_cover_image_url_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='projectfile',
|
||||||
|
name='file_size',
|
||||||
|
field=models.BigIntegerField(blank=True, null=True, verbose_name='文件大小(字节)'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -27,7 +27,7 @@ class Competition(models.Model):
|
|||||||
condition_description = models.TextField(verbose_name="参赛条件说明", blank=True)
|
condition_description = models.TextField(verbose_name="参赛条件说明", blank=True)
|
||||||
|
|
||||||
cover_image = models.ImageField(upload_to='competitions/covers/', verbose_name="封面图", null=True, blank=True)
|
cover_image = models.ImageField(upload_to='competitions/covers/', verbose_name="封面图", null=True, blank=True)
|
||||||
cover_image_url = models.URLField(verbose_name="封面图URL", null=True, blank=True, help_text="优先使用上传的图片")
|
cover_image_url = models.CharField(max_length=1000, verbose_name="封面图URL", null=True, blank=True, help_text="优先使用上传的图片")
|
||||||
|
|
||||||
start_time = models.DateTimeField(verbose_name="开始时间")
|
start_time = models.DateTimeField(verbose_name="开始时间")
|
||||||
end_time = models.DateTimeField(verbose_name="结束时间")
|
end_time = models.DateTimeField(verbose_name="结束时间")
|
||||||
@@ -35,6 +35,16 @@ class Competition(models.Model):
|
|||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name="状态")
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name="状态")
|
||||||
project_visibility = models.CharField(max_length=20, choices=PROJECT_VISIBILITY_CHOICES, default='public', verbose_name="项目可见性")
|
project_visibility = models.CharField(max_length=20, choices=PROJECT_VISIBILITY_CHOICES, default='public', verbose_name="项目可见性")
|
||||||
|
|
||||||
|
allow_contestant_grading = models.BooleanField(default=False, verbose_name="允许选手互评")
|
||||||
|
|
||||||
|
SCORE_CALCULATION_CHOICES = (
|
||||||
|
('default', '默认加权平均'),
|
||||||
|
('formula', '使用评分公式'),
|
||||||
|
)
|
||||||
|
|
||||||
|
score_calculation_type = models.CharField(max_length=20, choices=SCORE_CALCULATION_CHOICES, default='default', verbose_name="得分计算方式")
|
||||||
|
active_formula = models.ForeignKey('ScoreFormula', on_delete=models.SET_NULL, null=True, blank=True, related_name='active_competitions', verbose_name="启用的评分公式")
|
||||||
|
|
||||||
is_active = models.BooleanField(default=True, verbose_name="是否启用")
|
is_active = models.BooleanField(default=True, verbose_name="是否启用")
|
||||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||||
@@ -86,12 +96,24 @@ class ScoreDimension(models.Model):
|
|||||||
"""
|
"""
|
||||||
评分维度配置
|
评分维度配置
|
||||||
"""
|
"""
|
||||||
|
FORMULA_TYPE_CHOICES = (
|
||||||
|
('weight', '权重模式'),
|
||||||
|
('formula', '自定义算式'),
|
||||||
|
)
|
||||||
|
|
||||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='score_dimensions', verbose_name="所属比赛")
|
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='score_dimensions', verbose_name="所属比赛")
|
||||||
name = models.CharField(max_length=100, verbose_name="维度名称")
|
name = models.CharField(max_length=100, verbose_name="维度名称")
|
||||||
description = models.TextField(verbose_name="维度说明", blank=True)
|
description = models.TextField(verbose_name="维度说明", blank=True)
|
||||||
weight = models.DecimalField(max_digits=5, decimal_places=2, default=1.00, verbose_name="权重", help_text="例如 0.3 表示 30%")
|
weight = models.DecimalField(max_digits=6, decimal_places=4, default=1.0000, verbose_name="权重", help_text="例如 0.3000 表示 30%")
|
||||||
max_score = models.IntegerField(default=100, verbose_name="满分值")
|
max_score = models.IntegerField(default=100, verbose_name="满分值")
|
||||||
|
|
||||||
|
formula_type = models.CharField(max_length=20, choices=FORMULA_TYPE_CHOICES, default='weight', verbose_name="算式类型")
|
||||||
|
formula = models.CharField(max_length=500, blank=True, verbose_name="自定义算式", help_text="使用维度ID作为变量,如: dimension_1 * 0.3 + dimension_2 * 0.5 + dimension_3 * 0.2")
|
||||||
|
|
||||||
|
is_public = models.BooleanField(default=True, verbose_name="是否公开给评委", help_text="如果关闭,评委端将看不到此评分维度,通常用于AI自动评分")
|
||||||
|
|
||||||
|
is_peer_review = models.BooleanField(default=False, verbose_name="是否用于选手互评", help_text="如果开启,此评分维度仅在选手互评时可见,评委和嘉宾看不到")
|
||||||
|
|
||||||
order = models.IntegerField(default=0, verbose_name="排序权重")
|
order = models.IntegerField(default=0, verbose_name="排序权重")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -102,6 +124,20 @@ class ScoreDimension(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.competition.title} - {self.name}"
|
return f"{self.competition.title} - {self.name}"
|
||||||
|
|
||||||
|
def get_formula_preview(self):
|
||||||
|
"""
|
||||||
|
获取算式预览,显示维度名称而非ID
|
||||||
|
"""
|
||||||
|
if not self.formula or self.formula_type != 'formula':
|
||||||
|
return None
|
||||||
|
|
||||||
|
dimension_map = {d.id: d.name for d in self.competition.score_dimensions.all()}
|
||||||
|
|
||||||
|
result = self.formula
|
||||||
|
for dim_id, dim_name in dimension_map.items():
|
||||||
|
result = result.replace(f'dimension_{dim_id}', f'[{dim_name}]')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
class Project(models.Model):
|
class Project(models.Model):
|
||||||
"""
|
"""
|
||||||
@@ -120,7 +156,7 @@ class Project(models.Model):
|
|||||||
team_info = models.TextField(verbose_name="团队介绍", blank=True)
|
team_info = models.TextField(verbose_name="团队介绍", blank=True)
|
||||||
|
|
||||||
cover_image = models.ImageField(upload_to='competitions/projects/covers/', verbose_name="项目封面", null=True, blank=True)
|
cover_image = models.ImageField(upload_to='competitions/projects/covers/', verbose_name="项目封面", null=True, blank=True)
|
||||||
cover_image_url = models.URLField(verbose_name="项目封面URL", null=True, blank=True, help_text="优先使用上传的图片")
|
cover_image_url = models.CharField(max_length=1000, verbose_name="项目封面URL", null=True, blank=True, help_text="优先使用上传的图片")
|
||||||
|
|
||||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name="状态")
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name="状态")
|
||||||
|
|
||||||
@@ -141,19 +177,34 @@ class Project(models.Model):
|
|||||||
def calculate_score(self):
|
def calculate_score(self):
|
||||||
"""
|
"""
|
||||||
计算项目得分
|
计算项目得分
|
||||||
计算公式:
|
支持两种模式:
|
||||||
1. 获取所有评委对该项目的打分
|
1. 默认加权平均:每个评委的得分 = sum(维度分数 × 维度权重),然后所有评委取平均
|
||||||
2. 按维度加权平均
|
2. 使用评分公式:使用比赛关联的评分公式计算最终得分
|
||||||
这里简化处理:
|
|
||||||
总分 = (所有评委的总加权分之和) / 评委人数
|
评分公式变量格式:
|
||||||
其中每个评委对项目的打分 = sum(维度分 * 维度权重)
|
- dimension_X: 第X个维度的平均分(所有评委对该维度的平均分)
|
||||||
|
- 例如: (dimension_1 + dimension_2) / 2
|
||||||
"""
|
"""
|
||||||
# 获取所有评分
|
|
||||||
scores = self.scores.all()
|
scores = self.scores.all()
|
||||||
if not scores.exists():
|
if not scores.exists():
|
||||||
|
self.final_score = 0
|
||||||
|
self.save()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# 找出所有参与评分的评委
|
competition = self.competition
|
||||||
|
|
||||||
|
if competition.score_calculation_type == 'formula' and competition.active_formula:
|
||||||
|
return self._calculate_formula_score(scores, competition.active_formula)
|
||||||
|
|
||||||
|
return self._calculate_default_score(scores)
|
||||||
|
|
||||||
|
def _calculate_default_score(self, scores):
|
||||||
|
"""
|
||||||
|
默认加权平均模式
|
||||||
|
1. 获取所有评委对该项目的打分
|
||||||
|
2. 每个评委的得分 = sum(维度分数 × 维度权重)
|
||||||
|
3. 项目最终得分 = 所有评委得分的平均值
|
||||||
|
"""
|
||||||
judges = set(score.judge for score in scores)
|
judges = set(score.judge for score in scores)
|
||||||
if not judges:
|
if not judges:
|
||||||
return 0
|
return 0
|
||||||
@@ -162,28 +213,71 @@ class Project(models.Model):
|
|||||||
|
|
||||||
for judge in judges:
|
for judge in judges:
|
||||||
judge_score = 0
|
judge_score = 0
|
||||||
# 获取该评委对该项目的所有维度打分
|
|
||||||
judge_scores = scores.filter(judge=judge)
|
judge_scores = scores.filter(judge=judge)
|
||||||
|
|
||||||
current_judge_total_score = 0
|
|
||||||
current_judge_total_weight = 0
|
|
||||||
|
|
||||||
for score in judge_scores:
|
for score in judge_scores:
|
||||||
current_judge_total_score += score.score * score.dimension.weight
|
judge_score += float(score.score) * float(score.dimension.weight)
|
||||||
current_judge_total_weight += score.dimension.weight
|
|
||||||
|
|
||||||
if current_judge_total_weight > 0:
|
|
||||||
judge_score = current_judge_total_score / current_judge_total_weight
|
|
||||||
# 如果是百分制,这里算出来就是0-100
|
|
||||||
|
|
||||||
total_weighted_score += judge_score
|
total_weighted_score += judge_score
|
||||||
|
|
||||||
# 平均分
|
|
||||||
avg_score = total_weighted_score / len(judges)
|
avg_score = total_weighted_score / len(judges)
|
||||||
self.final_score = avg_score
|
self.final_score = round(avg_score, 2)
|
||||||
self.save()
|
self.save()
|
||||||
return avg_score
|
return avg_score
|
||||||
|
|
||||||
|
def _calculate_formula_score(self, scores, formula_obj):
|
||||||
|
"""
|
||||||
|
公式配置模式(使用 ScoreFormula 模型)
|
||||||
|
使用公式配置中的公式计算得分
|
||||||
|
变量格式: dimension_X (X为维度ID)
|
||||||
|
|
||||||
|
注意:公式中的维度值已经是加权后的分数(原始分数 × 权重)
|
||||||
|
保存的评分是原始分数(不乘权重),显示时乘以权重
|
||||||
|
"""
|
||||||
|
dimension_scores = {}
|
||||||
|
|
||||||
|
dimensions = self.competition.score_dimensions.all()
|
||||||
|
for dimension in dimensions:
|
||||||
|
dim_scores = scores.filter(dimension=dimension)
|
||||||
|
if dim_scores.exists():
|
||||||
|
avg = sum(float(s.score) for s in dim_scores) / dim_scores.count()
|
||||||
|
weighted_score = avg * float(dimension.weight)
|
||||||
|
dimension_scores[f'dimension_{dimension.id}'] = weighted_score
|
||||||
|
else:
|
||||||
|
dimension_scores[f'dimension_{dimension.id}'] = 0
|
||||||
|
|
||||||
|
if not dimension_scores:
|
||||||
|
self.final_score = 0
|
||||||
|
self.save()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
formula = formula_obj.formula
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = eval(formula, {"__builtins__": {}}, dimension_scores)
|
||||||
|
final_score = float(result)
|
||||||
|
self.final_score = round(final_score, 2)
|
||||||
|
self.save()
|
||||||
|
return self.final_score
|
||||||
|
except Exception as e:
|
||||||
|
print(f"公式计算错误: {e}, formula: {formula}, values: {dimension_scores}")
|
||||||
|
return self._calculate_default_score(scores)
|
||||||
|
|
||||||
|
def calculate_judge_score(self, judge):
|
||||||
|
"""
|
||||||
|
计算指定评委对该项目的得分
|
||||||
|
用于显示评委个人评分
|
||||||
|
"""
|
||||||
|
scores = self.scores.filter(judge=judge)
|
||||||
|
if not scores.exists():
|
||||||
|
return 0
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for score in scores:
|
||||||
|
total += float(score.score) * float(score.dimension.weight)
|
||||||
|
|
||||||
|
return round(total, 2)
|
||||||
|
|
||||||
|
|
||||||
class ProjectFile(models.Model):
|
class ProjectFile(models.Model):
|
||||||
"""
|
"""
|
||||||
@@ -202,7 +296,8 @@ class ProjectFile(models.Model):
|
|||||||
file_type = models.CharField(max_length=20, choices=FILE_TYPE_CHOICES, default='other', verbose_name="文件类型")
|
file_type = models.CharField(max_length=20, choices=FILE_TYPE_CHOICES, default='other', verbose_name="文件类型")
|
||||||
|
|
||||||
file = models.FileField(upload_to='competitions/projects/files/', verbose_name="文件", null=True, blank=True)
|
file = models.FileField(upload_to='competitions/projects/files/', verbose_name="文件", null=True, blank=True)
|
||||||
file_url = models.URLField(verbose_name="文件链接", null=True, blank=True, help_text="视频等大文件建议使用外部链接")
|
file_url = models.TextField(verbose_name="文件链接", null=True, blank=True, help_text="OSS URL或外部链接")
|
||||||
|
file_size = models.BigIntegerField(verbose_name="文件大小(字节)", null=True, blank=True)
|
||||||
|
|
||||||
name = models.CharField(max_length=100, verbose_name="文件名称", blank=True)
|
name = models.CharField(max_length=100, verbose_name="文件名称", blank=True)
|
||||||
|
|
||||||
@@ -261,3 +356,71 @@ class Comment(models.Model):
|
|||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.judge.user.nickname} -> {self.project.title}"
|
return f"{self.judge.user.nickname} -> {self.project.title}"
|
||||||
|
|
||||||
|
|
||||||
|
class ScoreFormula(models.Model):
|
||||||
|
"""
|
||||||
|
评分公式配置
|
||||||
|
用于可视化配置得分计算公式
|
||||||
|
"""
|
||||||
|
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='score_formulas', verbose_name="所属比赛")
|
||||||
|
name = models.CharField(max_length=100, verbose_name="公式名称", help_text="用于标识这个公式,方便管理")
|
||||||
|
description = models.TextField(verbose_name="公式说明", blank=True)
|
||||||
|
|
||||||
|
formula = models.TextField(verbose_name="计算公式", help_text="使用维度名称作为变量,支持四则运算和函数")
|
||||||
|
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name="是否启用")
|
||||||
|
is_default = models.BooleanField(default=False, verbose_name="是否设为默认公式")
|
||||||
|
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||||
|
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "评分公式"
|
||||||
|
verbose_name_plural = "评分公式配置"
|
||||||
|
ordering = ['-is_default', '-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.competition.title} - {self.name}"
|
||||||
|
|
||||||
|
def get_formula_preview(self):
|
||||||
|
"""
|
||||||
|
获取公式预览,将维度变量替换为维度名称
|
||||||
|
"""
|
||||||
|
if not self.formula:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
dimension_map = {f'd["{d.name}"]': f'[{d.name}]' for d in self.competition.score_dimensions.all()}
|
||||||
|
dimension_map.update({f"d['{d.name}']": f'[{d.name}]' for d in self.competition.score_dimensions.all()})
|
||||||
|
dimension_map.update({f'dimension_{d.id}': f'[{d.name}]' for d in self.competition.score_dimensions.all()})
|
||||||
|
|
||||||
|
result = self.formula
|
||||||
|
for old, new in dimension_map.items():
|
||||||
|
result = result.replace(old, new)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
def generate_python_code(self):
|
||||||
|
"""
|
||||||
|
生成可执行的 Python 代码
|
||||||
|
"""
|
||||||
|
if not self.formula:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
dimension_names = [d.name for d in self.competition.score_dimensions.all()]
|
||||||
|
|
||||||
|
code_lines = [
|
||||||
|
"def calculate_score(d):",
|
||||||
|
" '''",
|
||||||
|
f" 计算公式: {self.name}",
|
||||||
|
" 参数 d: 字典,键为维度名称,值为该维度的平均分",
|
||||||
|
" '''",
|
||||||
|
]
|
||||||
|
|
||||||
|
for name in dimension_names:
|
||||||
|
code_lines.append(f" {name} = d.get('{name}', 0)")
|
||||||
|
|
||||||
|
code_lines.append("")
|
||||||
|
code_lines.append(f" return {self.formula}")
|
||||||
|
|
||||||
|
return "\n".join(code_lines)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment
|
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment
|
||||||
from shop.serializers import WeChatUserSerializer
|
from shop.serializers import WeChatUserSerializer
|
||||||
|
import uuid
|
||||||
|
|
||||||
class ScoreDimensionSerializer(serializers.ModelSerializer):
|
class ScoreDimensionSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
@@ -26,26 +27,99 @@ class CompetitionSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class CompetitionEnrollmentSerializer(serializers.ModelSerializer):
|
class CompetitionEnrollmentSerializer(serializers.ModelSerializer):
|
||||||
user = WeChatUserSerializer(read_only=True)
|
user = WeChatUserSerializer(read_only=True)
|
||||||
|
competition_title = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CompetitionEnrollment
|
model = CompetitionEnrollment
|
||||||
fields = ['id', 'competition', 'user', 'role', 'status', 'created_at']
|
fields = ['id', 'competition', 'competition_title', 'user', 'role', 'status', 'created_at']
|
||||||
read_only_fields = ['status']
|
read_only_fields = ['status']
|
||||||
|
|
||||||
|
def get_competition_title(self, obj):
|
||||||
|
return obj.competition.title if obj.competition else ''
|
||||||
|
|
||||||
class ProjectFileSerializer(serializers.ModelSerializer):
|
class ProjectFileSerializer(serializers.ModelSerializer):
|
||||||
|
file_url_display = serializers.SerializerMethodField()
|
||||||
|
file_size_display = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ProjectFile
|
model = ProjectFile
|
||||||
fields = ['id', 'project', 'file_type', 'file', 'file_url', 'name', 'created_at']
|
fields = ['id', 'project', 'file_type', 'file', 'file_url', 'file_size', 'name', 'created_at', 'file_url_display', 'file_size_display']
|
||||||
|
|
||||||
|
def get_file_url_display(self, obj):
|
||||||
|
if obj.file:
|
||||||
|
return obj.file.url
|
||||||
|
return obj.file_url
|
||||||
|
|
||||||
|
def get_file_size_display(self, obj):
|
||||||
|
if not obj.file_size:
|
||||||
|
return None
|
||||||
|
size = obj.file_size
|
||||||
|
if size < 1024:
|
||||||
|
return f"{size} B"
|
||||||
|
elif size < 1024 * 1024:
|
||||||
|
return f"{size / 1024:.1f} KB"
|
||||||
|
elif size < 1024 * 1024 * 1024:
|
||||||
|
return f"{size / (1024 * 1024):.1f} MB"
|
||||||
|
else:
|
||||||
|
return f"{size / (1024 * 1024 * 1024):.2f} GB"
|
||||||
|
|
||||||
def validate_file(self, value):
|
def validate_file(self, value):
|
||||||
if not value:
|
if not value:
|
||||||
return value
|
return value
|
||||||
# 50MB limit
|
|
||||||
limit_mb = 50
|
limit_mb = 50
|
||||||
if value.size > limit_mb * 1024 * 1024:
|
if value.size > limit_mb * 1024 * 1024:
|
||||||
raise serializers.ValidationError(f"文件大小不能超过 {limit_mb}MB")
|
raise serializers.ValidationError(f"文件大小不能超过 {limit_mb}MB")
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
def create(self, validated_data):
|
||||||
|
from django.conf import settings
|
||||||
|
from shop.utils import get_current_wechat_user
|
||||||
|
|
||||||
|
request = self.context.get('request')
|
||||||
|
user = get_current_wechat_user(request) if request else None
|
||||||
|
|
||||||
|
print(f"=== File Upload Debug ===")
|
||||||
|
print(f"User: {user}")
|
||||||
|
print(f"Validated data keys: {validated_data.keys()}")
|
||||||
|
|
||||||
|
file_obj = validated_data.get('file')
|
||||||
|
|
||||||
|
if file_obj:
|
||||||
|
print(f"File name: {file_obj.name}, size: {file_obj.size}")
|
||||||
|
ext = file_obj.name.split('.')[-1].lower() if '.' in file_obj.name else ''
|
||||||
|
if ext in ['ppt', 'pptx']:
|
||||||
|
validated_data['file_type'] = 'ppt'
|
||||||
|
elif ext == 'pdf':
|
||||||
|
validated_data['file_type'] = 'pdf'
|
||||||
|
elif ext in ['jpg', 'jpeg', 'png', 'gif', 'webp']:
|
||||||
|
validated_data['file_type'] = 'image'
|
||||||
|
elif ext in ['mp4', 'mov', 'avi', 'webm']:
|
||||||
|
validated_data['file_type'] = 'video'
|
||||||
|
elif ext in ['doc', 'docx']:
|
||||||
|
validated_data['file_type'] = 'doc'
|
||||||
|
|
||||||
|
if not validated_data.get('name'):
|
||||||
|
validated_data['name'] = file_obj.name
|
||||||
|
|
||||||
|
validated_data['file_size'] = file_obj.size
|
||||||
|
|
||||||
|
try:
|
||||||
|
from ai_services.services import AliyunTingwuService
|
||||||
|
service = AliyunTingwuService()
|
||||||
|
if service.bucket:
|
||||||
|
project = validated_data.get('project')
|
||||||
|
file_name = f"competitions/projects/{project.id}/{uuid.uuid4()}.{ext}"
|
||||||
|
oss_url = service.upload_to_oss(file_obj, file_name, day=30)
|
||||||
|
validated_data['file_url'] = oss_url
|
||||||
|
validated_data['file'] = None
|
||||||
|
print(f"OSS upload success: {oss_url}")
|
||||||
|
else:
|
||||||
|
print("OSS bucket is None, OSS not configured properly")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"OSS upload failed in serializer: {e}")
|
||||||
|
|
||||||
|
return super().create(validated_data)
|
||||||
|
|
||||||
class ProjectSerializer(serializers.ModelSerializer):
|
class ProjectSerializer(serializers.ModelSerializer):
|
||||||
files = ProjectFileSerializer(many=True, read_only=True)
|
files = ProjectFileSerializer(many=True, read_only=True)
|
||||||
contestant_info = serializers.SerializerMethodField()
|
contestant_info = serializers.SerializerMethodField()
|
||||||
@@ -80,8 +154,26 @@ class ScoreSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class CommentSerializer(serializers.ModelSerializer):
|
class CommentSerializer(serializers.ModelSerializer):
|
||||||
judge_name = serializers.CharField(source='judge.user.nickname', read_only=True)
|
judge_name = serializers.CharField(source='judge.user.nickname', read_only=True)
|
||||||
|
score = serializers.SerializerMethodField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Comment
|
model = Comment
|
||||||
fields = ['id', 'project', 'judge', 'content', 'judge_name', 'created_at']
|
fields = ['id', 'project', 'judge', 'content', 'judge_name', 'created_at', 'score']
|
||||||
read_only_fields = ['judge']
|
read_only_fields = ['judge']
|
||||||
|
|
||||||
|
def get_score(self, obj):
|
||||||
|
scores = Score.objects.filter(project=obj.project, judge=obj.judge)
|
||||||
|
if not scores.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
current_judge_total_score = 0
|
||||||
|
current_judge_total_weight = 0
|
||||||
|
|
||||||
|
for score in scores:
|
||||||
|
current_judge_total_score += score.score * score.dimension.weight
|
||||||
|
current_judge_total_weight += score.dimension.weight
|
||||||
|
|
||||||
|
if current_judge_total_weight > 0:
|
||||||
|
judge_score = current_judge_total_score / current_judge_total_weight
|
||||||
|
return round(judge_score, 1)
|
||||||
|
return None
|
||||||
|
|||||||
179
backend/competition/templates/judge/ai_manage.html
Normal file
179
backend/competition/templates/judge/ai_manage.html
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
{% extends 'judge/base.html' %}
|
||||||
|
|
||||||
|
{% block title %}AI 服务管理 - 评委系统{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-3xl font-bold text-gray-900 tracking-tight">AI 服务管理</h2>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">查看和管理音频转录及 AI 评分任务</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
项目
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
文件名
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
状态
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
AI 评分
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
操作
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
|
{% for task in tasks %}
|
||||||
|
<tr class="hover:bg-gray-50 transition-colors">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm font-medium text-gray-900">{{ task.project.title }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<a href="{{ task.file_url }}" target="_blank" class="text-sm text-blue-600 hover:text-blue-900 flex items-center">
|
||||||
|
<i class="fas fa-file-audio mr-1"></i> {{ task.file_name|default:"查看文件"|truncatechars:20 }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ task.status_class }}">
|
||||||
|
{{ task.get_status_display }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{% if task.ai_score %}
|
||||||
|
<span class="font-bold text-gray-900">{{ task.ai_score }}</span> 分
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400">-</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||||
|
<button onclick="refreshStatus('{{ task.id }}')" class="text-indigo-600 hover:text-indigo-900 transition-colors" title="刷新状态">
|
||||||
|
<i class="fas fa-sync-alt"></i>
|
||||||
|
</button>
|
||||||
|
{% if task.status == 'SUCCEEDED' %}
|
||||||
|
<button onclick="viewResult('{{ task.id }}')" class="text-green-600 hover:text-green-900 transition-colors" title="查看结果">
|
||||||
|
<i class="fas fa-eye"></i>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="deleteTask('{{ task.id }}')" class="text-red-600 hover:text-red-900 transition-colors" title="删除任务">
|
||||||
|
<i class="fas fa-trash-alt"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="px-6 py-10 text-center text-gray-500">
|
||||||
|
<div class="flex flex-col items-center">
|
||||||
|
<i class="fas fa-inbox text-4xl text-gray-300 mb-2"></i>
|
||||||
|
<p>暂无 AI 任务</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- AI Result Modal -->
|
||||||
|
<div id="aiResultModal" class="modal fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6" style="background-color: rgba(0,0,0,0.5);">
|
||||||
|
<div class="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col relative animate-fade-in">
|
||||||
|
<button class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 z-10" onclick="closeModal('aiResultModal')">
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="px-6 py-4 border-b border-gray-100 bg-gray-50">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 flex items-center">
|
||||||
|
<i class="fas fa-robot text-blue-500 mr-2"></i> AI 分析详情
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 overflow-y-auto space-y-6" id="aiResultContent">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-bold text-gray-900 uppercase tracking-wide mb-2 flex items-center">
|
||||||
|
<i class="fas fa-align-left mr-2 text-gray-400"></i> 逐字稿
|
||||||
|
</h4>
|
||||||
|
<div id="transcriptionText" class="bg-gray-50 p-4 rounded-lg text-sm text-gray-700 leading-relaxed border border-gray-200 max-h-60 overflow-y-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-bold text-gray-900 uppercase tracking-wide mb-2 flex items-center">
|
||||||
|
<i class="fas fa-lightbulb mr-2 text-yellow-500"></i> AI 总结
|
||||||
|
</h4>
|
||||||
|
<div id="summaryText" class="bg-yellow-50 p-4 rounded-lg text-sm text-gray-800 border border-yellow-100 h-40 overflow-y-auto"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-bold text-gray-900 uppercase tracking-wide mb-2 flex items-center">
|
||||||
|
<i class="fas fa-comment-dots mr-2 text-green-500"></i> AI 评语
|
||||||
|
</h4>
|
||||||
|
<div id="evaluationText" class="bg-green-50 p-4 rounded-lg text-sm text-gray-800 border border-green-100 h-40 overflow-y-auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
function closeModal(id) {
|
||||||
|
document.getElementById(id).classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshStatus(taskId) {
|
||||||
|
try {
|
||||||
|
const res = await apiCall(`/api/ai/transcriptions/${taskId}/refresh_status/`, 'GET');
|
||||||
|
if (res.status === 'SUCCEEDED' || res.status === 'FAILED') {
|
||||||
|
alert('状态已更新: ' + res.status);
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('当前状态: ' + res.status);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('刷新失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewResult(taskId) {
|
||||||
|
try {
|
||||||
|
const res = await apiCall(`/api/ai/transcriptions/${taskId}/`, 'GET');
|
||||||
|
|
||||||
|
document.getElementById('transcriptionText').innerText = res.transcription || '无逐字稿';
|
||||||
|
document.getElementById('summaryText').innerText = res.summary || '无总结';
|
||||||
|
|
||||||
|
// Handle Evaluation (might be separate API or included)
|
||||||
|
// Assuming simple structure for now, adjust based on actual API
|
||||||
|
let evalText = '暂无评语';
|
||||||
|
if (res.ai_evaluations && res.ai_evaluations.length > 0) {
|
||||||
|
evalText = res.ai_evaluations[0].evaluation || '无内容';
|
||||||
|
}
|
||||||
|
document.getElementById('evaluationText').innerText = evalText;
|
||||||
|
|
||||||
|
document.getElementById('aiResultModal').classList.add('active');
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('获取结果失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteTask(taskId) {
|
||||||
|
if(!confirm('确定要删除此任务吗?')) return;
|
||||||
|
try {
|
||||||
|
await apiCall(`/judge/api/ai/${taskId}/delete/`, 'POST');
|
||||||
|
alert('删除成功');
|
||||||
|
location.reload();
|
||||||
|
} catch (e) {
|
||||||
|
alert('删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
235
backend/competition/templates/judge/base.html
Normal file
235
backend/competition/templates/judge/base.html
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% block title %}评委系统{% endblock %}</title>
|
||||||
|
<!-- suppress tailwind cdn warning -->
|
||||||
|
<script>
|
||||||
|
const originalWarn = console.warn;
|
||||||
|
console.warn = function() {
|
||||||
|
if (arguments[0] && typeof arguments[0] === 'string' && arguments[0].includes('cdn.tailwindcss.com should not be used in production')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
originalWarn.apply(console, arguments);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<link href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
.glass-effect {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.5s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e1;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal Transitions */
|
||||||
|
.modal {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
.modal.active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
.modal-content {
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
.modal.active .modal-content {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Badges */
|
||||||
|
.status-submitted, .status-succeeded {
|
||||||
|
background-color: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
.status-pending {
|
||||||
|
background-color: #fef9c3;
|
||||||
|
color: #854d0e;
|
||||||
|
}
|
||||||
|
.status-processing {
|
||||||
|
background-color: #dbeafe;
|
||||||
|
color: #1e40af;
|
||||||
|
}
|
||||||
|
.status-failed {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% block extra_css %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="text-gray-800 antialiased min-h-screen flex flex-col">
|
||||||
|
{% if request.session.judge_id %}
|
||||||
|
<header class="bg-white shadow-sm sticky top-0 z-50 transition-all duration-300">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between h-16">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<a href="{% url 'judge_dashboard' %}" class="flex-shrink-0 flex items-center gap-2 text-blue-600 hover:text-blue-700 transition-colors">
|
||||||
|
<i class="fas fa-gavel text-xl"></i>
|
||||||
|
<h1 class="font-bold text-xl tracking-tight">评委评分系统</h1>
|
||||||
|
</a>
|
||||||
|
<nav class="hidden md:ml-8 md:flex md:space-x-8">
|
||||||
|
<a href="{% url 'judge_dashboard' %}"
|
||||||
|
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors duration-200
|
||||||
|
{% if request.resolver_match.url_name == 'judge_dashboard' %}border-blue-500 text-gray-900{% else %}border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700{% endif %}">
|
||||||
|
<i class="fas fa-th-list mr-2"></i>项目列表
|
||||||
|
</a>
|
||||||
|
{% if request.session.judge_role == 'judge' or request.session.judge_role == 'guest' %}
|
||||||
|
<a href="{% url 'judge_ai_manage' %}"
|
||||||
|
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors duration-200
|
||||||
|
{% if request.resolver_match.url_name == 'judge_ai_manage' %}border-blue-500 text-gray-900{% else %}border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700{% endif %}">
|
||||||
|
<i class="fas fa-robot mr-2"></i>AI服务管理
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="hidden md:flex items-center mr-6 text-sm">
|
||||||
|
<span class="font-medium text-gray-700 mr-2">{{ request.session.judge_name }}</span>
|
||||||
|
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 border border-blue-100">
|
||||||
|
{% if request.session.judge_role == 'judge' %}评委
|
||||||
|
{% elif request.session.judge_role == 'guest' %}嘉宾
|
||||||
|
{% elif request.session.judge_role == 'contestant' %}选手
|
||||||
|
{% else %}{{ request.session.judge_role }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button onclick="logout()" class="ml-4 px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-500 hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all shadow-sm hover:shadow">
|
||||||
|
<i class="fas fa-sign-out-alt mr-1"></i>退出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Navigation -->
|
||||||
|
<div class="md:hidden border-t border-gray-200 bg-gray-50">
|
||||||
|
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<span class="font-medium text-gray-900">{{ request.session.judge_name }}</span>
|
||||||
|
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 border border-blue-100">
|
||||||
|
{% if request.session.judge_role == 'judge' %}评委
|
||||||
|
{% elif request.session.judge_role == 'guest' %}嘉宾
|
||||||
|
{% elif request.session.judge_role == 'contestant' %}选手
|
||||||
|
{% else %}{{ request.session.judge_role }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 divide-x divide-gray-200">
|
||||||
|
<a href="{% url 'judge_dashboard' %}" class="block py-3 text-center text-sm font-medium text-gray-700 hover:bg-gray-100 hover:text-blue-600">
|
||||||
|
<i class="fas fa-th-list mb-1 block text-lg"></i>项目列表
|
||||||
|
</a>
|
||||||
|
{% if request.session.judge_role == 'judge' or request.session.judge_role == 'guest' %}
|
||||||
|
<a href="{% url 'judge_ai_manage' %}" class="block py-3 text-center text-sm font-medium text-gray-700 hover:bg-gray-100 hover:text-blue-600">
|
||||||
|
<i class="fas fa-robot mb-1 block text-lg"></i>AI管理
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<main class="flex-grow w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 animate-fade-in">
|
||||||
|
{% if messages %}
|
||||||
|
<div class="mb-6 space-y-2">
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="rounded-md p-4 shadow-sm border-l-4 flex items-center
|
||||||
|
{% if message.tags == 'error' %}bg-red-50 border-red-500 text-red-700
|
||||||
|
{% elif message.tags == 'success' %}bg-green-50 border-green-500 text-green-700
|
||||||
|
{% else %}bg-blue-50 border-blue-500 text-blue-700{% endif %}">
|
||||||
|
<i class="fas {% if message.tags == 'error' %}fa-exclamation-circle{% elif message.tags == 'success' %}fa-check-circle{% else %}fa-info-circle{% endif %} mr-3 text-lg"></i>
|
||||||
|
<p class="text-sm font-medium">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="bg-white border-t border-gray-200 mt-auto">
|
||||||
|
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||||
|
<p class="text-center text-sm text-gray-500">
|
||||||
|
© {% now "Y" %} 评委评分系统. All rights reserved.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function logout() {
|
||||||
|
if(confirm('确定要退出登录吗?')) {
|
||||||
|
window.location.href = "{% url 'judge_logout' %}";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通用 Fetch 封装,处理 CSRF
|
||||||
|
function getCookie(name) {
|
||||||
|
let cookieValue = null;
|
||||||
|
if (document.cookie && document.cookie !== '') {
|
||||||
|
const cookies = document.cookie.split(';');
|
||||||
|
for (let i = 0; i < cookies.length; i++) {
|
||||||
|
const cookie = cookies[i].trim();
|
||||||
|
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||||
|
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cookieValue;
|
||||||
|
}
|
||||||
|
const csrftoken = getCookie('csrftoken');
|
||||||
|
|
||||||
|
async function apiCall(url, method='POST', data=null) {
|
||||||
|
const options = {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': csrftoken
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (data && !(data instanceof FormData)) {
|
||||||
|
options.headers['Content-Type'] = 'application/json';
|
||||||
|
options.body = JSON.stringify(data);
|
||||||
|
} else if (data instanceof FormData) {
|
||||||
|
options.body = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
return await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('API Error:', e);
|
||||||
|
alert('操作失败: ' + e.message);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% block extra_js %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1209
backend/competition/templates/judge/dashboard.html
Normal file
1209
backend/competition/templates/judge/dashboard.html
Normal file
File diff suppressed because it is too large
Load Diff
129
backend/competition/templates/judge/login.html
Normal file
129
backend/competition/templates/judge/login.html
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
{% extends 'judge/base.html' %}
|
||||||
|
|
||||||
|
{% block title %}评委登录{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="fixed inset-0 z-0 bg-gradient-to-br from-blue-500 to-indigo-700 flex items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-md w-full space-y-8 bg-white p-10 rounded-2xl shadow-2xl transform transition-all hover:scale-105 duration-300">
|
||||||
|
<div>
|
||||||
|
<div class="mx-auto h-16 w-16 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<i class="fas fa-gavel text-3xl text-blue-600"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
评委登录
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-center text-sm text-gray-600">
|
||||||
|
请输入您的手机号验证登录
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="mt-8 space-y-6" method="post" action="{% url 'judge_login' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div class="rounded-md shadow-sm -space-y-px">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">手机号</label>
|
||||||
|
<div class="relative rounded-md shadow-sm">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-mobile-alt text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<input type="tel" id="phone" name="phone" required pattern="[0-9]{11}"
|
||||||
|
class="focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors"
|
||||||
|
placeholder="请输入11位手机号">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="code" class="block text-sm font-medium text-gray-700 mb-1">验证码</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="relative rounded-md shadow-sm flex-1">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i class="fas fa-shield-alt text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="code" name="code" required
|
||||||
|
class="focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors"
|
||||||
|
placeholder="请输入验证码">
|
||||||
|
</div>
|
||||||
|
<button type="button" id="sendCodeBtn" onclick="sendSmsCode()"
|
||||||
|
class="whitespace-nowrap inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors w-32 justify-center">
|
||||||
|
发送验证码
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit"
|
||||||
|
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-md hover:shadow-lg transition-all duration-200">
|
||||||
|
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
|
||||||
|
<i class="fas fa-sign-in-alt text-blue-300 group-hover:text-blue-100"></i>
|
||||||
|
</span>
|
||||||
|
登录系统
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="rounded-md bg-red-50 p-4 border border-red-200 animate-pulse">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-times-circle text-red-400"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-red-800">登录失败</h3>
|
||||||
|
<div class="mt-2 text-sm text-red-700">
|
||||||
|
<p>{{ error }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_js %}
|
||||||
|
<script>
|
||||||
|
async function sendSmsCode() {
|
||||||
|
const phone = document.getElementById('phone').value;
|
||||||
|
if (!phone || phone.length !== 11) {
|
||||||
|
alert('请输入有效的11位手机号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById('sendCodeBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
let countdown = 60;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch("{% url 'judge_send_code' %}", {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': '{{ csrf_token }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ phone: phone })
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert('验证码已发送');
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
btn.innerText = `${countdown}s 后重发`;
|
||||||
|
countdown--;
|
||||||
|
if (countdown < 0) {
|
||||||
|
clearInterval(timer);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerText = '发送验证码';
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
} else {
|
||||||
|
alert('发送失败: ' + data.message);
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('网络错误,请重试');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -2,8 +2,9 @@ from django.urls import path, include
|
|||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
from .views import (
|
from .views import (
|
||||||
CompetitionViewSet, ProjectViewSet, ProjectFileViewSet,
|
CompetitionViewSet, ProjectViewSet, ProjectFileViewSet,
|
||||||
ScoreViewSet, CommentViewSet
|
ScoreViewSet, CommentViewSet, CompetitionDimensionsAPIView
|
||||||
)
|
)
|
||||||
|
from . import judge_views
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r'competitions', CompetitionViewSet)
|
router.register(r'competitions', CompetitionViewSet)
|
||||||
@@ -13,5 +14,13 @@ router.register(r'scores', ScoreViewSet, basename='score')
|
|||||||
router.register(r'comments', CommentViewSet, basename='comment')
|
router.register(r'comments', CommentViewSet, basename='comment')
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
# Judge System Routes
|
||||||
|
path('admin/', judge_views.admin_entry, name='judge_admin_entry'),
|
||||||
|
path('judge/', include('competition.judge_urls')), # Sub-routes under /judge/
|
||||||
|
|
||||||
|
# API Routes
|
||||||
|
path('competition/<int:competition_id>/dimensions/', CompetitionDimensionsAPIView.as_view(), name='competition-dimensions'),
|
||||||
|
|
||||||
|
# Existing API Routes
|
||||||
path('', include(router.urls)),
|
path('', include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from rest_framework import viewsets, permissions, status, filters, serializers
|
from rest_framework import viewsets, permissions, status, filters, serializers
|
||||||
from rest_framework.decorators import action
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from rest_framework.decorators import action, api_view, permission_classes
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from shop.utils import get_current_wechat_user
|
from shop.utils import get_current_wechat_user
|
||||||
from .models import Competition, CompetitionEnrollment, Project, ProjectFile, Score, Comment, ScoreDimension
|
from .models import Competition, CompetitionEnrollment, Project, ProjectFile, Score, Comment, ScoreDimension
|
||||||
@@ -199,14 +201,24 @@ class ProjectFileViewSet(viewsets.ModelViewSet):
|
|||||||
return ProjectFile.objects.all()
|
return ProjectFile.objects.all()
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
# 简单权限控制:只有项目拥有者可以上传
|
from shop.utils import get_current_wechat_user
|
||||||
|
try:
|
||||||
project = serializer.validated_data['project']
|
project = serializer.validated_data['project']
|
||||||
user = get_current_wechat_user(self.request)
|
user = get_current_wechat_user(self.request)
|
||||||
|
|
||||||
|
print(f"=== perform_create debug ===")
|
||||||
|
print(f"User: {user}")
|
||||||
|
print(f"Project: {project}")
|
||||||
|
|
||||||
if not user or project.contestant.user != user:
|
if not user or project.contestant.user != user:
|
||||||
raise serializers.ValidationError("无权上传文件")
|
raise serializers.ValidationError("无权上传文件")
|
||||||
|
|
||||||
serializer.save()
|
serializer.save()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"=== perform_create ERROR: {e} ===")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
class ScoreViewSet(viewsets.ModelViewSet):
|
class ScoreViewSet(viewsets.ModelViewSet):
|
||||||
@@ -280,3 +292,31 @@ class CommentViewSet(viewsets.ModelViewSet):
|
|||||||
raise serializers.ValidationError("您不是该比赛的评委")
|
raise serializers.ValidationError("您不是该比赛的评委")
|
||||||
|
|
||||||
serializer.save(judge=enrollment)
|
serializer.save(judge=enrollment)
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitionDimensionsAPIView(APIView):
|
||||||
|
"""
|
||||||
|
获取比赛评分维度的API
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
|
||||||
|
def get(self, request, competition_id):
|
||||||
|
try:
|
||||||
|
competition = Competition.objects.get(id=competition_id)
|
||||||
|
dimensions = ScoreDimension.objects.filter(competition=competition).order_by('order')
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'dimensions': [
|
||||||
|
{
|
||||||
|
'id': d.id,
|
||||||
|
'name': d.name,
|
||||||
|
'weight': float(d.weight),
|
||||||
|
'max_score': d.max_score,
|
||||||
|
'description': d.description
|
||||||
|
}
|
||||||
|
for d in dimensions
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return Response(data)
|
||||||
|
except Competition.DoesNotExist:
|
||||||
|
return Response({'error': '比赛不存在'}, status=404)
|
||||||
|
|||||||
@@ -119,16 +119,16 @@ if DB_HOST:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# DB_HOST = os.environ.get('DB_HOST', '121.43.104.161')
|
DB_HOST = os.environ.get('DB_HOST', '121.43.104.161')
|
||||||
# if DB_HOST:
|
if DB_HOST:
|
||||||
# DATABASES['default'] = {
|
DATABASES['default'] = {
|
||||||
# 'ENGINE': 'django.db.backends.postgresql',
|
'ENGINE': 'django.db.backends.postgresql',
|
||||||
# 'NAME': os.environ.get('DB_NAME', 'market'),
|
'NAME': os.environ.get('DB_NAME', 'market'),
|
||||||
# 'USER': os.environ.get('DB_USER', 'market'),
|
'USER': os.environ.get('DB_USER', 'market'),
|
||||||
# 'PASSWORD': os.environ.get('DB_PASSWORD', '123market'),
|
'PASSWORD': os.environ.get('DB_PASSWORD', '123market'),
|
||||||
# 'HOST': DB_HOST,
|
'HOST': DB_HOST,
|
||||||
# 'PORT': os.environ.get('DB_PORT', '6433'),
|
'PORT': os.environ.get('DB_PORT', '6433'),
|
||||||
# }
|
}
|
||||||
|
|
||||||
|
|
||||||
# Password validation
|
# Password validation
|
||||||
@@ -362,6 +362,16 @@ UNFOLD = {
|
|||||||
"icon": "record_voice_over",
|
"icon": "record_voice_over",
|
||||||
"link": reverse_lazy("admin:ai_services_transcriptiontask_changelist"),
|
"link": reverse_lazy("admin:ai_services_transcriptiontask_changelist"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"title": "AI 评估模板",
|
||||||
|
"icon": "rule",
|
||||||
|
"link": reverse_lazy("admin:ai_services_aievaluationtemplate_changelist"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "AI 评估结果",
|
||||||
|
"icon": "psychology",
|
||||||
|
"link": reverse_lazy("admin:ai_services_aievaluation_changelist"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -414,3 +424,5 @@ ALIYUN_OSS_BUCKET_NAME = os.environ.get('ALIYUN_OSS_BUCKET_NAME', '')
|
|||||||
ALIYUN_OSS_ENDPOINT = os.environ.get('ALIYUN_OSS_ENDPOINT', 'oss-cn-shanghai.aliyuncs.com')
|
ALIYUN_OSS_ENDPOINT = os.environ.get('ALIYUN_OSS_ENDPOINT', 'oss-cn-shanghai.aliyuncs.com')
|
||||||
ALIYUN_OSS_INTERNAL_ENDPOINT = os.environ.get('ALIYUN_OSS_INTERNAL_ENDPOINT', '')
|
ALIYUN_OSS_INTERNAL_ENDPOINT = os.environ.get('ALIYUN_OSS_INTERNAL_ENDPOINT', '')
|
||||||
ALIYUN_TINGWU_APP_KEY = os.environ.get('ALIYUN_TINGWU_APP_KEY', '') # 听悟AppKey
|
ALIYUN_TINGWU_APP_KEY = os.environ.get('ALIYUN_TINGWU_APP_KEY', '') # 听悟AppKey
|
||||||
|
|
||||||
|
DASHSCOPE_API_KEY = os.environ.get('DASHSCOPE_API_KEY', '')
|
||||||
|
|||||||
@@ -3,9 +3,15 @@ from django.urls import path, include
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
|
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
|
||||||
|
from competition import judge_views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('admin/', admin.site.urls),
|
path('admin/', admin.site.urls),
|
||||||
|
|
||||||
|
# Judge System Routes
|
||||||
|
path('judge/', include('competition.judge_urls')),
|
||||||
|
path('competition/admin/', judge_views.admin_entry, name='judge_admin_entry_root'),
|
||||||
|
|
||||||
path('api/', include('shop.urls')),
|
path('api/', include('shop.urls')),
|
||||||
path('api/community/', include('community.urls')),
|
path('api/community/', include('community.urls')),
|
||||||
path('api/competition/', include('competition.urls')),
|
path('api/competition/', include('competition.urls')),
|
||||||
@@ -17,7 +23,7 @@ urlpatterns = [
|
|||||||
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
|
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
|
||||||
]
|
]
|
||||||
|
|
||||||
# 静态文件配置(开发环境)1
|
# 静态文件配置(开发环境)
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ django.setup()
|
|||||||
from shop.models import ESP32Config
|
from shop.models import ESP32Config
|
||||||
|
|
||||||
def populate():
|
def populate():
|
||||||
# 检查数据库是否已有数据,如果有则跳过,避免每次构建都重置
|
# 检查数据库是否已有数据,如果有则跳过,避免每次构建都重置!
|
||||||
if ESP32Config.objects.exists():
|
if ESP32Config.objects.exists():
|
||||||
print("ESP32Config data already exists, skipping population.")
|
print("ESP32Config data already exists, skipping population.")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -28,3 +28,4 @@ aliyun-python-sdk-core==2.16.0
|
|||||||
aliyun-python-sdk-tingwu==1.0.7
|
aliyun-python-sdk-tingwu==1.0.7
|
||||||
oss2==2.19.1
|
oss2==2.19.1
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
openai
|
||||||
|
|||||||
@@ -385,7 +385,7 @@ class SalespersonAdmin(ModelAdmin):
|
|||||||
class CommissionLogAdmin(ModelAdmin):
|
class CommissionLogAdmin(ModelAdmin):
|
||||||
list_display = ('id', 'salesperson', 'distributor', 'amount', 'level', 'status', 'created_at')
|
list_display = ('id', 'salesperson', 'distributor', 'amount', 'level', 'status', 'created_at')
|
||||||
list_filter = ('status', 'level', 'salesperson', 'distributor', 'created_at')
|
list_filter = ('status', 'level', 'salesperson', 'distributor', 'created_at')
|
||||||
search_fields = ('salesperson__name', 'distributor__user__nickname', 'order__id')
|
search_fields = ('salesperson__name', 'distributor__user__nickname', 'distributor__user__phone_number', 'order__id')
|
||||||
readonly_fields = ('amount', 'level', 'created_at')
|
readonly_fields = ('amount', 'level', 'created_at')
|
||||||
|
|
||||||
fieldsets = (
|
fieldsets = (
|
||||||
@@ -482,7 +482,7 @@ class ProductTypeFilter(admin.SimpleListFilter):
|
|||||||
class OrderAdmin(ModelAdmin):
|
class OrderAdmin(ModelAdmin):
|
||||||
list_display = ('id', 'customer_name', 'get_item_name', 'total_price', 'status', 'salesperson', 'distributor', 'created_at')
|
list_display = ('id', 'customer_name', 'get_item_name', 'total_price', 'status', 'salesperson', 'distributor', 'created_at')
|
||||||
list_filter = ('status', ProductTypeFilter, 'config', 'course', 'activity', PriceRangeFilter, 'salesperson', 'distributor', 'created_at')
|
list_filter = ('status', ProductTypeFilter, 'config', 'course', 'activity', PriceRangeFilter, 'salesperson', 'distributor', 'created_at')
|
||||||
search_fields = ('id', 'customer_name', 'phone_number', 'wechat_trade_no')
|
search_fields = ('id', 'customer_name', 'phone_number', 'wechat_trade_no', 'wechat_user__phone_number')
|
||||||
readonly_fields = ('total_price', 'created_at', 'wechat_trade_no')
|
readonly_fields = ('total_price', 'created_at', 'wechat_trade_no')
|
||||||
actions = [export_to_csv, export_to_excel]
|
actions = [export_to_csv, export_to_excel]
|
||||||
|
|
||||||
@@ -569,7 +569,7 @@ class WeChatUserAdmin(OrderableAdminMixin, ModelAdmin):
|
|||||||
@admin.register(Distributor)
|
@admin.register(Distributor)
|
||||||
class DistributorAdmin(ModelAdmin):
|
class DistributorAdmin(ModelAdmin):
|
||||||
list_display = ('get_nickname', 'level', 'status', 'total_earnings', 'withdrawable_balance', 'invite_code', 'created_at')
|
list_display = ('get_nickname', 'level', 'status', 'total_earnings', 'withdrawable_balance', 'invite_code', 'created_at')
|
||||||
search_fields = ('user__nickname', 'invite_code')
|
search_fields = ('user__nickname', 'user__phone_number', 'invite_code')
|
||||||
list_filter = ('status', 'level', 'created_at')
|
list_filter = ('status', 'level', 'created_at')
|
||||||
readonly_fields = ('total_earnings', 'withdrawable_balance', 'qr_code_url', 'created_at', 'updated_at')
|
readonly_fields = ('total_earnings', 'withdrawable_balance', 'qr_code_url', 'created_at', 'updated_at')
|
||||||
autocomplete_fields = ['user', 'parent']
|
autocomplete_fields = ['user', 'parent']
|
||||||
@@ -598,7 +598,7 @@ class DistributorAdmin(ModelAdmin):
|
|||||||
class WithdrawalAdmin(ModelAdmin):
|
class WithdrawalAdmin(ModelAdmin):
|
||||||
list_display = ('get_distributor', 'amount', 'status', 'created_at')
|
list_display = ('get_distributor', 'amount', 'status', 'created_at')
|
||||||
list_filter = ('status', 'created_at')
|
list_filter = ('status', 'created_at')
|
||||||
search_fields = ('distributor__user__nickname',)
|
search_fields = ('distributor__user__nickname', 'distributor__user__phone_number')
|
||||||
|
|
||||||
def get_distributor(self, obj):
|
def get_distributor(self, obj):
|
||||||
return obj.distributor.user.nickname
|
return obj.distributor.user.nickname
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class WeChatUser(models.Model):
|
|||||||
self.order = self.pk
|
self.order = self.pk
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.nickname or self.openid
|
return self.phone_number or self.nickname or self.openid
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
verbose_name = "微信用户"
|
verbose_name = "微信用户"
|
||||||
|
|||||||
18
backend/start_judge_system.sh
Executable file
18
backend/start_judge_system.sh
Executable file
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
echo "Starting Judge System..."
|
||||||
|
|
||||||
|
# 激活虚拟环境 (如果有)
|
||||||
|
if [ -d "venv" ]; then
|
||||||
|
source venv/bin/activate
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 迁移数据库
|
||||||
|
python manage.py makemigrations
|
||||||
|
python manage.py migrate
|
||||||
|
|
||||||
|
# 启动 Django 开发服务器
|
||||||
|
echo "Server running at http://127.0.0.1:8000/competition/admin/"
|
||||||
|
python manage.py runserver 0.0.0.0:8000
|
||||||
@@ -24,6 +24,14 @@ import './App.css';
|
|||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
function JudgeLoginRedirect() {
|
||||||
|
React.useEffect(() => {
|
||||||
|
window.location.replace('/judge/login/');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -46,6 +54,7 @@ function App() {
|
|||||||
<Route path="/my-orders" element={<MyOrders />} />
|
<Route path="/my-orders" element={<MyOrders />} />
|
||||||
<Route path="/product/:id" element={<ProductDetail />} />
|
<Route path="/product/:id" element={<ProductDetail />} />
|
||||||
<Route path="/payment/:orderId" element={<Payment />} />
|
<Route path="/payment/:orderId" element={<Payment />} />
|
||||||
|
<Route path="/judge-login" element={<JudgeLoginRedirect />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import axios from 'axios';
|
|||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api',
|
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api',
|
||||||
timeout: 8000, // 增加超时时间到 10秒
|
timeout: 120000, // 大文件上传需要更长超时时间 2分钟
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ const CompetitionDetail = () => {
|
|||||||
<div style={{ color: '#888', fontSize: 12 }}>{project.contestant_info?.nickname}</div>
|
<div style={{ color: '#888', fontSize: 12 }}>{project.contestant_info?.nickname}</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: isMobile ? 18 : 24, color: '#00b96b', fontWeight: 'bold' }}>
|
<div style={{ fontSize: isMobile ? 18 : 24, color: '#00b96b', fontWeight: 'bold' }}>
|
||||||
{enrollment && project.contestant === enrollment.id ? project.final_score : '**'}
|
{project.final_score || 0}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const ProjectDetail = () => {
|
|||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="最终得分">
|
<Descriptions.Item label="最终得分">
|
||||||
<span style={{ color: '#00b96b', fontSize: 18, fontWeight: 'bold' }}>
|
<span style={{ color: '#00b96b', fontSize: 18, fontWeight: 'bold' }}>
|
||||||
{project.final_score ?? '待定'}
|
{project.final_score || 0}
|
||||||
</span>
|
</span>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="状态">
|
<Descriptions.Item label="状态">
|
||||||
@@ -162,7 +162,16 @@ const ProjectDetail = () => {
|
|||||||
<List.Item style={{ background: '#141414', padding: 16, borderRadius: 8, marginBottom: 12, border: '1px solid #303030' }}>
|
<List.Item style={{ background: '#141414', padding: 16, borderRadius: 8, marginBottom: 12, border: '1px solid #303030' }}>
|
||||||
<List.Item.Meta
|
<List.Item.Meta
|
||||||
avatar={<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#00b96b' }} />}
|
avatar={<Avatar icon={<UserOutlined />} style={{ backgroundColor: '#00b96b' }} />}
|
||||||
title={<span style={{ color: '#fff' }}>{item.judge_name || '评委'}</span>}
|
title={
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<span style={{ color: '#fff' }}>{item.judge_name || '评委'}</span>
|
||||||
|
{item.score && (
|
||||||
|
<Tag color="orange" style={{ margin: 0, fontWeight: 'bold', border: 'none' }}>
|
||||||
|
{item.score}分
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
description={
|
description={
|
||||||
<div>
|
<div>
|
||||||
<div style={{ color: '#ccc', marginTop: 8 }}>{item.content}</div>
|
<div style={{ color: '#ccc', marginTop: 8 }}>{item.content}</div>
|
||||||
|
|||||||
@@ -1,19 +1,66 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Card, Button, Form, Input, Upload, App, Modal, Select } from 'antd';
|
import { Button, Form, Input, Upload, App, Modal, Progress, Space } from 'antd';
|
||||||
import { UploadOutlined, CloudUploadOutlined, LinkOutlined } from '@ant-design/icons';
|
import { CloudUploadOutlined, LinkOutlined, FileTextOutlined, DownloadOutlined, FilePdfOutlined, FilePptOutlined, VideoCameraOutlined, PictureOutlined } from '@ant-design/icons';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { createProject, updateProject, submitProject, uploadProjectFile } from '../../api';
|
import { createProject, updateProject, submitProject, uploadProjectFile, getProjects } from '../../api';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
const { Option } = Select;
|
|
||||||
|
const getFileIcon = (fileType) => {
|
||||||
|
switch (fileType) {
|
||||||
|
case 'pdf':
|
||||||
|
return <FilePdfOutlined style={{ color: '#ff4d4f' }} />;
|
||||||
|
case 'ppt':
|
||||||
|
case 'pptx':
|
||||||
|
return <FilePptOutlined style={{ color: '#fa8c16' }} />;
|
||||||
|
case 'video':
|
||||||
|
return <VideoCameraOutlined style={{ color: '#722ed1' }} />;
|
||||||
|
case 'image':
|
||||||
|
return <PictureOutlined style={{ color: '#52c41a' }} />;
|
||||||
|
default:
|
||||||
|
return <FileTextOutlined style={{ color: '#1890ff' }} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }) => {
|
const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }) => {
|
||||||
const { message } = App.useApp();
|
const { message, modal } = App.useApp();
|
||||||
const [form] = Form.useForm();
|
const [form] = Form.useForm();
|
||||||
const [fileList, setFileList] = useState([]);
|
const [uploadedFiles, setUploadedFiles] = useState([]);
|
||||||
|
const [uploadingFiles, setUploadingFiles] = useState({});
|
||||||
|
const [pendingCoverImage, setPendingCoverImage] = useState(null);
|
||||||
|
const [pendingAttachments, setPendingAttachments] = useState([]);
|
||||||
|
const [isCreatingProject, setIsCreatingProject] = useState(false);
|
||||||
|
const [currentProjectId, setCurrentProjectId] = useState(null);
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// Reset form when initialValues changes (important for switching between create/edit)
|
const activeProjectId = currentProjectId || initialValues?.id;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialValues?.id) {
|
||||||
|
getProjects({ competition: competitionId, contestant: initialValues.id })
|
||||||
|
.then(res => {
|
||||||
|
const project = res.data?.results?.[0];
|
||||||
|
if (project?.files && project.files.length > 0) {
|
||||||
|
const files = project.files.map(file => ({
|
||||||
|
uid: file.id,
|
||||||
|
id: file.id,
|
||||||
|
name: file.name || '未命名文件',
|
||||||
|
url: file.file_url_display || file.file_url,
|
||||||
|
fileType: file.file_type,
|
||||||
|
file_size_display: file.file_size_display,
|
||||||
|
status: 'done'
|
||||||
|
}));
|
||||||
|
setUploadedFiles(files);
|
||||||
|
} else {
|
||||||
|
setUploadedFiles([]);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('获取项目文件失败:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [initialValues?.id, competitionId]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (initialValues) {
|
if (initialValues) {
|
||||||
form.setFieldsValue(initialValues);
|
form.setFieldsValue(initialValues);
|
||||||
@@ -22,75 +69,121 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
|
|||||||
}
|
}
|
||||||
}, [initialValues, form]);
|
}, [initialValues, form]);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const uploadPendingFiles = async (projectId) => {
|
||||||
mutationFn: createProject,
|
const uploadedFilesList = [];
|
||||||
onSuccess: () => {
|
|
||||||
message.success('项目创建成功');
|
|
||||||
queryClient.invalidateQueries(['projects']);
|
|
||||||
onSuccess();
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
message.error(`创建失败: ${error.message}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
if (pendingCoverImage) {
|
||||||
mutationFn: (data) => updateProject(initialValues.id, data),
|
try {
|
||||||
onSuccess: () => {
|
const formData = new FormData();
|
||||||
|
formData.append('file', pendingCoverImage);
|
||||||
|
const res = await uploadProjectFile(formData);
|
||||||
|
const imageUrl = res.data.file_url_display || res.data.file_url;
|
||||||
|
await updateProject(projectId, { cover_image_url: imageUrl });
|
||||||
|
setPendingCoverImage(null);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('封面上传失败:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of pendingAttachments) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('project', projectId);
|
||||||
|
const res = await uploadProjectFile(formData);
|
||||||
|
uploadedFilesList.push({
|
||||||
|
uid: res.data.id,
|
||||||
|
id: res.data.id,
|
||||||
|
name: res.data.name || file.name,
|
||||||
|
url: res.data.file_url_display || res.data.file_url,
|
||||||
|
fileType: res.data.file_type,
|
||||||
|
status: 'done'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('文件上传失败:', err);
|
||||||
|
message.error(`文件 ${file.name} 上传失败`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadedFilesList.length > 0) {
|
||||||
|
setUploadedFiles(prev => [...prev, ...uploadedFilesList]);
|
||||||
|
}
|
||||||
|
setPendingAttachments([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFinish = async (values) => {
|
||||||
|
setIsCreatingProject(true);
|
||||||
|
|
||||||
|
console.log('onFinish values:', values);
|
||||||
|
console.log('competitionId:', competitionId);
|
||||||
|
|
||||||
|
const hasFiles = pendingAttachments.length > 0 || pendingCoverImage;
|
||||||
|
|
||||||
|
let data = {
|
||||||
|
...values,
|
||||||
|
competition: competitionId,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data.cover_image_url && data.cover_image_url.startsWith('data:')) {
|
||||||
|
delete data.cover_image_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (initialValues?.id) {
|
||||||
|
await updateProject(initialValues.id, data);
|
||||||
|
if (pendingAttachments.length > 0) {
|
||||||
|
await uploadPendingFiles(initialValues.id);
|
||||||
|
}
|
||||||
message.success('项目更新成功');
|
message.success('项目更新成功');
|
||||||
queryClient.invalidateQueries(['projects']);
|
queryClient.invalidateQueries(['projects']);
|
||||||
onSuccess();
|
onSuccess();
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
message.error(`更新失败: ${error.message}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
|
||||||
mutationFn: uploadProjectFile,
|
|
||||||
onSuccess: (data) => {
|
|
||||||
message.success('文件上传成功');
|
|
||||||
setFileList([...fileList, data]); // Add file to list (assuming response format)
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
message.error(`上传失败: ${error.message}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const onFinish = (values) => {
|
|
||||||
const data = {
|
|
||||||
...values,
|
|
||||||
competition: competitionId,
|
|
||||||
// Handle file URLs/IDs if needed in create/update
|
|
||||||
};
|
|
||||||
|
|
||||||
if (initialValues?.id) {
|
|
||||||
updateMutation.mutate(data);
|
|
||||||
} else {
|
} else {
|
||||||
createMutation.mutate(data);
|
try {
|
||||||
}
|
let projectId;
|
||||||
};
|
|
||||||
|
|
||||||
const handleUpload = ({ file, onSuccess, onError }) => {
|
if (hasFiles) {
|
||||||
if (!initialValues?.id) {
|
const res = await createProject(data);
|
||||||
message.warning('请先保存项目基本信息再上传文件');
|
projectId = res.data.id;
|
||||||
// Prevent default upload
|
await uploadPendingFiles(projectId);
|
||||||
onError(new Error('请先保存项目'));
|
message.success('项目创建成功,文件上传完成');
|
||||||
return;
|
} else {
|
||||||
|
const res = await createProject(data);
|
||||||
|
projectId = res.data.id;
|
||||||
|
message.success('项目创建成功');
|
||||||
}
|
}
|
||||||
|
|
||||||
const formData = new FormData();
|
queryClient.invalidateQueries(['projects']);
|
||||||
formData.append('file', file);
|
onSuccess();
|
||||||
formData.append('project', initialValues?.id || ''); // Need project ID first usually
|
} catch (error) {
|
||||||
|
console.error('创建项目失败:', error);
|
||||||
|
console.error('Response data:', error.response?.data);
|
||||||
|
|
||||||
uploadMutation.mutate(formData, {
|
let errorMsg = '创建失败,请稍后重试';
|
||||||
onSuccess: (data) => {
|
|
||||||
onSuccess(data);
|
if (error.response?.data) {
|
||||||
},
|
const data = error.response.data;
|
||||||
onError: (error) => {
|
if (typeof data === 'object') {
|
||||||
onError(error);
|
const messages = [];
|
||||||
|
for (const [key, value] of Object.entries(data)) {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
messages.push(`${key}: ${value.join(', ')}`);
|
||||||
|
} else {
|
||||||
|
messages.push(`${key}: ${value}`);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
if (messages.length > 0) {
|
||||||
|
errorMsg = messages.join('; ');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errorMsg = String(data);
|
||||||
|
}
|
||||||
|
} else if (error.message) {
|
||||||
|
errorMsg = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
message.error(`创建失败: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsCreatingProject(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -138,21 +231,288 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
|
|||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="cover_image_url"
|
name="cover_image_url"
|
||||||
label="封面图片链接"
|
label="封面图片"
|
||||||
rules={[{ type: 'url', message: '请输入有效的URL' }]}
|
extra="支持上传本地图片,自动转换为URL"
|
||||||
>
|
>
|
||||||
<Input prefix={<LinkOutlined />} placeholder="https://example.com/image.jpg" />
|
<Upload
|
||||||
|
showUploadList={false}
|
||||||
|
accept="image/*"
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
console.log('Cover image selected:', file.name, 'activeProjectId:', activeProjectId);
|
||||||
|
|
||||||
|
const doUpload = (projectId) => {
|
||||||
|
console.log('Uploading cover for project:', projectId);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('project', projectId);
|
||||||
|
|
||||||
|
uploadProjectFile(formData)
|
||||||
|
.then(res => {
|
||||||
|
const imageUrl = res.data.file_url_display || res.data.file_url;
|
||||||
|
const currentValues = form.getFieldsValue();
|
||||||
|
const updateData = {
|
||||||
|
...currentValues,
|
||||||
|
cover_image_url: imageUrl,
|
||||||
|
};
|
||||||
|
if (updateData.cover_image_url && updateData.cover_image_url.startsWith('data:')) {
|
||||||
|
delete updateData.cover_image_url;
|
||||||
|
}
|
||||||
|
console.log('Updating project with:', updateData);
|
||||||
|
updateProject(projectId, updateData)
|
||||||
|
.then(() => {
|
||||||
|
form.setFieldsValue({ cover_image_url: imageUrl });
|
||||||
|
message.success('封面上传成功');
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('更新封面失败:', err);
|
||||||
|
const errorData = err.response?.data;
|
||||||
|
let errorMsg = '更新失败';
|
||||||
|
if (errorData) {
|
||||||
|
if (typeof errorData === 'object') {
|
||||||
|
errorMsg = Object.entries(errorData).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`).join('; ');
|
||||||
|
} else {
|
||||||
|
errorMsg = String(errorData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.error(`更新封面失败: ${errorMsg}`);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('封面上传失败:', err);
|
||||||
|
const errorData = err.response?.data;
|
||||||
|
let errorMsg = '上传失败';
|
||||||
|
if (errorData) {
|
||||||
|
if (typeof errorData === 'object') {
|
||||||
|
errorMsg = Object.entries(errorData).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`).join('; ');
|
||||||
|
} else {
|
||||||
|
errorMsg = String(errorData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.error(`上传失败: ${errorMsg}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (activeProjectId) {
|
||||||
|
doUpload(activeProjectId);
|
||||||
|
} else {
|
||||||
|
message.loading('正在创建项目...', 0);
|
||||||
|
const tempData = {
|
||||||
|
title: '临时草稿',
|
||||||
|
description: '临时草稿',
|
||||||
|
competition: competitionId,
|
||||||
|
};
|
||||||
|
createProject(tempData)
|
||||||
|
.then(res => {
|
||||||
|
message.destroy();
|
||||||
|
const newProjectId = res.data.id;
|
||||||
|
setCurrentProjectId(newProjectId);
|
||||||
|
doUpload(newProjectId);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
message.destroy();
|
||||||
|
console.error('创建临时项目失败:', err);
|
||||||
|
message.error(`创建项目失败: ${err.response?.data?.detail || err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button icon={<CloudUploadOutlined />}>选择图片</Button>
|
||||||
|
</Upload>
|
||||||
|
<Input
|
||||||
|
placeholder="上传图片或输入URL"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onChange={(e) => form.setFieldsValue({ cover_image_url: e.target.value })}
|
||||||
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
{/* File Upload Section - Only visible if project exists */}
|
{(
|
||||||
{initialValues?.id && (
|
<Form.Item label="项目附件 (PPT/PDF/视频/图片)">
|
||||||
<Form.Item label="项目附件 (PPT/PDF/视频)">
|
{!initialValues?.id && pendingAttachments.length > 0 && (
|
||||||
<Upload
|
<div style={{ marginBottom: 16 }}>
|
||||||
customRequest={handleUpload}
|
<div style={{ color: '#999', fontStyle: 'italic', marginBottom: 8 }}>待上传文件:</div>
|
||||||
listType="picture"
|
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||||
maxCount={5}
|
{pendingAttachments.map((file, index) => (
|
||||||
|
<div key={index} style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: '#fff7e6',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #ffd591'
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 20, marginRight: 12 }}>
|
||||||
|
{getFileIcon(file.name.split('.').pop()?.toLowerCase())}
|
||||||
|
</span>
|
||||||
|
<span style={{ flex: 1, fontWeight: 500 }}>{file.name}</span>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
setPendingAttachments(prev => prev.filter((_, i) => i !== index));
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Button icon={<CloudUploadOutlined />}>上传文件 (最大50MB)</Button>
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
{uploadedFiles.length === 0 && initialValues?.id ? (
|
||||||
|
<div style={{ color: '#999', fontStyle: 'italic' }}>暂无上传文件</div>
|
||||||
|
) : (
|
||||||
|
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||||
|
{uploadedFiles.map((file) => (
|
||||||
|
<div key={file.uid} style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '12px 16px',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid #e8e8e8'
|
||||||
|
}}>
|
||||||
|
<span style={{ fontSize: 20, marginRight: 12 }}>
|
||||||
|
{getFileIcon(file.fileType)}
|
||||||
|
</span>
|
||||||
|
<div style={{ flex: 1 }}>
|
||||||
|
<div style={{ fontWeight: 500 }}>{file.name || '未命名文件'}</div>
|
||||||
|
{file.file_size_display && (
|
||||||
|
<div style={{ fontSize: 12, color: '#999' }}>{file.file_size_display}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{file.url && (
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
|
href={file.url}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
下载/查看
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Object.keys(uploadingFiles).length > 0 && (
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Space orientation="vertical" style={{ width: '100%' }} size="small">
|
||||||
|
{Object.entries(uploadingFiles).map(([uid, info]) => (
|
||||||
|
<Progress
|
||||||
|
key={uid}
|
||||||
|
percent={info.percent}
|
||||||
|
status={info.status === 'error' ? 'exception' : 'active'}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Upload
|
||||||
|
showUploadList={false}
|
||||||
|
maxCount={5}
|
||||||
|
accept=".ppt,.pptx,.pdf,.mp4,.mov,.avi,.webm,.jpg,.jpeg,.png,.gif,.webp,.doc,.docx"
|
||||||
|
beforeUpload={(file) => {
|
||||||
|
console.log('beforeUpload triggered for:', file.name, 'activeProjectId:', activeProjectId);
|
||||||
|
const allowedExtensions = ['ppt', 'pptx', 'pdf', 'mp4', 'mov', 'avi', 'webm', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'doc', 'docx'];
|
||||||
|
const fileExt = file.name.split('.').pop()?.toLowerCase();
|
||||||
|
|
||||||
|
if (!allowedExtensions.includes(fileExt)) {
|
||||||
|
message.error('不支持的文件格式!请上传 PPT、PDF、视频或图片文件');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLt50M = file.size / 1024 / 1024 < 50;
|
||||||
|
if (!isLt50M) {
|
||||||
|
message.error('文件大小不能超过 50MB');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doUpload = (projectId) => {
|
||||||
|
const fileUid = file.uid || Date.now().toString();
|
||||||
|
setUploadingFiles(prev => ({
|
||||||
|
...prev,
|
||||||
|
[fileUid]: { percent: 0, status: 'uploading' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
formData.append('project', projectId);
|
||||||
|
|
||||||
|
uploadProjectFile(formData)
|
||||||
|
.then(res => {
|
||||||
|
console.log('Upload success:', res);
|
||||||
|
setUploadingFiles(prev => ({
|
||||||
|
...prev,
|
||||||
|
[fileUid]: { percent: 100, status: 'done' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const newFile = {
|
||||||
|
uid: res.data.id,
|
||||||
|
id: res.data.id,
|
||||||
|
name: res.data.name || file.name,
|
||||||
|
url: res.data.file_url_display || res.data.file_url,
|
||||||
|
fileType: res.data.file_type,
|
||||||
|
file_size_display: res.data.file_size_display,
|
||||||
|
status: 'done'
|
||||||
|
};
|
||||||
|
|
||||||
|
setUploadedFiles(prev => [...prev, newFile]);
|
||||||
|
message.success('文件上传成功');
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error('Upload error:', err);
|
||||||
|
setUploadingFiles(prev => ({
|
||||||
|
...prev,
|
||||||
|
[fileUid]: { percent: 0, status: 'error' }
|
||||||
|
}));
|
||||||
|
const errorData = err.response?.data;
|
||||||
|
let errorMsg = '上传失败';
|
||||||
|
if (errorData) {
|
||||||
|
if (typeof errorData === 'object') {
|
||||||
|
errorMsg = Object.entries(errorData).map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`).join('; ');
|
||||||
|
} else {
|
||||||
|
errorMsg = String(errorData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
message.error(`上传失败: ${errorMsg}`);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (activeProjectId) {
|
||||||
|
console.log('Uploading to existing project');
|
||||||
|
doUpload(activeProjectId);
|
||||||
|
} else {
|
||||||
|
console.log('Creating temp project first');
|
||||||
|
message.loading('正在创建项目...', 0);
|
||||||
|
const tempData = {
|
||||||
|
title: '临时草稿',
|
||||||
|
description: '临时草稿',
|
||||||
|
competition: competitionId,
|
||||||
|
};
|
||||||
|
createProject(tempData)
|
||||||
|
.then(res => {
|
||||||
|
message.destroy();
|
||||||
|
const newProjectId = res.data.id;
|
||||||
|
setCurrentProjectId(newProjectId);
|
||||||
|
doUpload(newProjectId);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
message.destroy();
|
||||||
|
console.error('创建临时项目失败:', err);
|
||||||
|
message.error(`创建项目失败: ${err.response?.data?.detail || err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button icon={<CloudUploadOutlined />} onClick={() => console.log('Upload button clicked')}>继续上传文件 (最大50MB)</Button>
|
||||||
</Upload>
|
</Upload>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
@@ -160,15 +520,15 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
|
|||||||
<Form.Item>
|
<Form.Item>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
|
||||||
<Button onClick={onCancel}>取消</Button>
|
<Button onClick={onCancel}>取消</Button>
|
||||||
<Button type="primary" htmlType="submit" loading={createMutation.isLoading || updateMutation.isLoading}>
|
<Button type="primary" htmlType="submit" loading={isCreatingProject}>
|
||||||
{initialValues?.id ? '保存修改' : '保存草稿'}
|
{initialValues?.id ? '保存修改' : '保存草稿'}
|
||||||
</Button>
|
</Button>
|
||||||
{initialValues?.id && (
|
{(initialValues?.id || currentProjectId) && (
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
danger
|
danger
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
Modal.confirm({
|
modal.confirm({
|
||||||
title: '确认提交?',
|
title: '确认提交?',
|
||||||
content: '提交后将无法修改,确认提交吗?',
|
content: '提交后将无法修改,确认提交吗?',
|
||||||
onOk: () => submitProject(initialValues.id).then(() => {
|
onOk: () => submitProject(initialValues.id).then(() => {
|
||||||
|
|||||||
@@ -8,6 +8,28 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/admin': {
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/static': {
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/media': {
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/judge': {
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
@@ -29,6 +51,10 @@ export default defineConfig({
|
|||||||
'/media': {
|
'/media': {
|
||||||
target: 'http://backend:8000',
|
target: 'http://backend:8000',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
'/judge': {
|
||||||
|
target: 'http://backend:8000',
|
||||||
|
changeOrigin: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,9 +95,39 @@ export const uploadProjectFile = (filePath: string, projectId: number, fileName?
|
|||||||
}
|
}
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
return JSON.parse(res.data)
|
const data = JSON.parse(res.data)
|
||||||
|
if (data.error) {
|
||||||
|
const error = new Error(data.error) as any
|
||||||
|
error.response = data
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
throw new Error('Upload failed')
|
return data
|
||||||
|
}
|
||||||
|
const error = new Error('Upload failed') as any
|
||||||
|
error.statusCode = res.statusCode
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const deleteProjectFile = (fileId: number) => {
|
||||||
|
const BASE_URL = (typeof process !== 'undefined' && process.env && process.env.TARO_APP_API_URL) || 'https://market.quant-speed.com/api'
|
||||||
|
return Taro.request({
|
||||||
|
url: `${BASE_URL}/competition/files/${fileId}/`,
|
||||||
|
method: 'DELETE',
|
||||||
|
header: {
|
||||||
|
'Authorization': `Bearer ${Taro.getStorageSync('token')}`
|
||||||
|
}
|
||||||
|
}).then(res => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const error = new Error('Delete failed') as any
|
||||||
|
error.statusCode = res.statusCode
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(res.data)
|
||||||
|
error.response = data
|
||||||
|
} catch {}
|
||||||
|
throw error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,9 +146,21 @@ export const uploadMedia = (filePath: string, type: 'image' | 'video') => {
|
|||||||
}
|
}
|
||||||
}).then(res => {
|
}).then(res => {
|
||||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
return JSON.parse(res.data)
|
const data = JSON.parse(res.data)
|
||||||
|
if (data.error) {
|
||||||
|
const error = new Error(data.error) as any
|
||||||
|
error.response = data
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
throw new Error('Upload failed')
|
return data
|
||||||
|
}
|
||||||
|
const error = new Error('Upload failed') as any
|
||||||
|
error.statusCode = res.statusCode
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(res.data)
|
||||||
|
error.response = data
|
||||||
|
} catch {}
|
||||||
|
throw error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
|
|
||||||
.language {
|
.language {
|
||||||
color: #9cdcfe;
|
color: #9cdcfe;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
|
|
||||||
.copy-text {
|
.copy-text {
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
.code-text {
|
.code-text {
|
||||||
color: #d4d4d4;
|
color: #d4d4d4;
|
||||||
font-family: 'Courier New', Courier, monospace;
|
font-family: 'Courier New', Courier, monospace;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const MarkdownReader: React.FC<Props> = ({ content, themeColor = '#00b96b' }) =>
|
|||||||
|
|
||||||
renderer.table = (header, body) => {
|
renderer.table = (header, body) => {
|
||||||
return `<div style="overflow-x: auto; width: 100%; -webkit-overflow-scrolling: touch;">
|
return `<div style="overflow-x: auto; width: 100%; -webkit-overflow-scrolling: touch;">
|
||||||
<table style="width: 100%; min-width: 600px; border-collapse: collapse; margin: 16px 0; font-size: 14px;">
|
<table style="width: 100%; min-width: 600px; border-collapse: collapse; margin: 16px 0; font-size: 16px;">
|
||||||
<thead>${header}</thead>
|
<thead>${header}</thead>
|
||||||
<tbody>${body}</tbody>
|
<tbody>${body}</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -10,9 +10,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 24px;
|
padding: 30px;
|
||||||
background: #111;
|
background: #111;
|
||||||
border-radius: 16px 16px 0 0;
|
border-radius: 20px 20px 0 0;
|
||||||
margin-top: -24px;
|
margin-top: -24px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
@@ -21,22 +21,22 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 30px;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 24px;
|
font-size: 32px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
padding: 4px 8px;
|
padding: 6px 10px;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
background: #333;
|
background: #333;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
margin-left: 12px;
|
margin-left: 16px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
||||||
&.registration { background: #07c160; color: #fff; }
|
&.registration { background: #07c160; color: #fff; }
|
||||||
@@ -48,15 +48,15 @@
|
|||||||
|
|
||||||
.tabs {
|
.tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 30px;
|
||||||
border-bottom: 1px solid #333;
|
border-bottom: 1px solid #333;
|
||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 12px 0;
|
padding: 16px 0;
|
||||||
color: #999;
|
color: #999;
|
||||||
font-size: 16px;
|
font-size: 18px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
@@ -69,8 +69,8 @@
|
|||||||
bottom: -1px;
|
bottom: -1px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
width: 24px;
|
width: 30px;
|
||||||
height: 3px;
|
height: 4px;
|
||||||
background: #00b96b;
|
background: #00b96b;
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
@@ -81,28 +81,28 @@
|
|||||||
.project-list {
|
.project-list {
|
||||||
.project-card {
|
.project-card {
|
||||||
background: #1f1f1f;
|
background: #1f1f1f;
|
||||||
border-radius: 12px;
|
border-radius: 16px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
.cover {
|
.cover {
|
||||||
width: 120px;
|
width: 140px;
|
||||||
height: 90px;
|
height: 105px;
|
||||||
background: #333;
|
background: #333;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 12px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 16px;
|
font-size: 20px;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
@@ -119,14 +119,14 @@
|
|||||||
.user {
|
.user {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
color: #999;
|
color: #999;
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 20px;
|
width: 24px;
|
||||||
height: 20px;
|
height: 24px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-right: 6px;
|
margin-right: 8px;
|
||||||
background: #333;
|
background: #333;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
.score {
|
.score {
|
||||||
color: #faad14;
|
color: #faad14;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,7 +142,8 @@
|
|||||||
.empty {
|
.empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #666;
|
color: #666;
|
||||||
padding: 40px 0;
|
padding: 50px 0;
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,13 +151,13 @@
|
|||||||
.rank-item {
|
.rank-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px 0;
|
padding: 20px 0;
|
||||||
border-bottom: 1px solid #222;
|
border-bottom: 1px solid #222;
|
||||||
|
|
||||||
.rank-num {
|
.rank-num {
|
||||||
width: 40px;
|
width: 50px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 18px;
|
font-size: 22px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #666;
|
color: #666;
|
||||||
|
|
||||||
@@ -172,10 +173,10 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 40px;
|
width: 48px;
|
||||||
height: 40px;
|
height: 48px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-right: 12px;
|
margin-right: 16px;
|
||||||
background: #333;
|
background: #333;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
@@ -186,8 +187,8 @@
|
|||||||
|
|
||||||
.nickname {
|
.nickname {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 16px;
|
font-size: 18px;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 6px;
|
||||||
display: block;
|
display: block;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -196,7 +197,7 @@
|
|||||||
|
|
||||||
.project-title {
|
.project-title {
|
||||||
color: #666;
|
color: #666;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -205,34 +206,35 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.score {
|
.score {
|
||||||
font-size: 18px;
|
font-size: 22px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #00b96b;
|
color: #00b96b;
|
||||||
margin-left: 12px;
|
margin-left: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.empty {
|
.empty {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #666;
|
color: #666;
|
||||||
padding: 40px 0;
|
padding: 50px 0;
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 40px;
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 18px;
|
font-size: 24px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 20px;
|
||||||
display: block;
|
display: block;
|
||||||
border-left: 4px solid #00b96b;
|
border-left: 5px solid #00b96b;
|
||||||
padding-left: 12px;
|
padding-left: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Markdown styling borrowed from Forum */
|
/* Markdown styling borrowed from Forum */
|
||||||
font-size: 16px;
|
font-size: 18px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
letter-spacing: 0.3px;
|
letter-spacing: 0.3px;
|
||||||
@@ -240,38 +242,38 @@
|
|||||||
image {
|
image {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin: 16px 0;
|
margin: 20px 0;
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6 { margin-top: 24px; margin-bottom: 16px; color: #fff; font-weight: 700; line-height: 1.4; }
|
h1, h2, h3, h4, h5, h6 { margin-top: 30px; margin-bottom: 20px; color: #fff; font-weight: 700; line-height: 1.4; }
|
||||||
h1 { font-size: 24px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 12px; }
|
h1 { font-size: 32px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 16px; }
|
||||||
h2 { font-size: 20px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 8px; }
|
h2 { font-size: 28px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 12px; }
|
||||||
h3 { font-size: 18px; }
|
h3 { font-size: 24px; }
|
||||||
h4 { font-size: 17px; }
|
h4 { font-size: 20px; }
|
||||||
h5 { font-size: 16px; color: #ddd; }
|
h5 { font-size: 18px; color: #ddd; }
|
||||||
|
|
||||||
p { margin-bottom: 16px; }
|
p { margin-bottom: 20px; }
|
||||||
|
|
||||||
strong { font-weight: 800; color: #fff; }
|
strong { font-weight: 800; color: #fff; }
|
||||||
em { font-style: italic; color: #aaa; }
|
em { font-style: italic; color: #aaa; }
|
||||||
del { text-decoration: line-through; color: #666; }
|
del { text-decoration: line-through; color: #666; }
|
||||||
|
|
||||||
ul, ol { margin-bottom: 16px; padding-left: 20px; }
|
ul, ol { margin-bottom: 20px; padding-left: 24px; }
|
||||||
li { margin-bottom: 6px; list-style-position: outside; }
|
li { margin-bottom: 8px; list-style-position: outside; }
|
||||||
ul li { list-style-type: disc; }
|
ul li { list-style-type: disc; }
|
||||||
ol li { list-style-type: decimal; }
|
ol li { list-style-type: decimal; }
|
||||||
|
|
||||||
li input[type="checkbox"] { margin-right: 8px; }
|
li input[type="checkbox"] { margin-right: 12px; }
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
border-left: 4px solid #00b96b;
|
border-left: 5px solid #00b96b;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
padding: 12px 16px;
|
padding: 16px 20px;
|
||||||
margin: 16px 0;
|
margin: 20px 0;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
color: #bbb;
|
color: #bbb;
|
||||||
font-size: 15px;
|
font-size: 16px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|
||||||
p { margin-bottom: 0; }
|
p { margin-bottom: 0; }
|
||||||
@@ -283,20 +285,20 @@
|
|||||||
height: 1px;
|
height: 1px;
|
||||||
background: rgba(255,255,255,0.1);
|
background: rgba(255,255,255,0.1);
|
||||||
border: none;
|
border: none;
|
||||||
margin: 24px 0;
|
margin: 30px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-collapse: collapse;
|
border-collapse: collapse;
|
||||||
margin: 16px 0;
|
margin: 20px 0;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
display: block;
|
display: block;
|
||||||
|
|
||||||
th, td {
|
th, td {
|
||||||
border: 1px solid rgba(255,255,255,0.1);
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
padding: 10px;
|
padding: 12px;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,20 +315,20 @@
|
|||||||
|
|
||||||
code {
|
code {
|
||||||
background: rgba(255,255,255,0.1);
|
background: rgba(255,255,255,0.1);
|
||||||
padding: 2px 6px;
|
padding: 4px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||||
color: #ff7875;
|
color: #ff7875;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
margin: 0 4px;
|
margin: 0 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
pre {
|
pre {
|
||||||
background: #161616;
|
background: #161616;
|
||||||
padding: 16px;
|
padding: 20px;
|
||||||
border-radius: 12px;
|
border-radius: 16px;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin: 16px 0;
|
margin: 20px 0;
|
||||||
border: 1px solid #333;
|
border: 1px solid #333;
|
||||||
box-shadow: inset 0 0 20px rgba(0,0,0,0.5);
|
box-shadow: inset 0 0 20px rgba(0,0,0,0.5);
|
||||||
|
|
||||||
@@ -334,7 +336,7 @@
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
color: #a6e22e;
|
color: #a6e22e;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
}
|
}
|
||||||
@@ -348,16 +350,16 @@
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
background: #1f1f1f;
|
background: #1f1f1f;
|
||||||
padding: 16px 24px;
|
padding: 20px 30px;
|
||||||
border-top: 1px solid #333;
|
border-top: 1px solid #333;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 48px;
|
height: 56px;
|
||||||
line-height: 48px;
|
line-height: 56px;
|
||||||
border-radius: 24px;
|
border-radius: 28px;
|
||||||
font-size: 18px;
|
font-size: 20px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
background: #00b96b;
|
background: #00b96b;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { View, Text, Button, Image, ScrollView } from '@tarojs/components'
|
import { View, Text, Button, Image, ScrollView } from '@tarojs/components'
|
||||||
import Taro, { useLoad, useDidShow } from '@tarojs/taro'
|
import Taro, { useLoad, useDidShow, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { getCompetitionDetail, enrollCompetition, getMyCompetitionEnrollment, getProjects } from '../../api'
|
import { getCompetitionDetail, enrollCompetition, getMyCompetitionEnrollment, getProjects } from '../../api'
|
||||||
import MarkdownReader from '../../components/MarkdownReader'
|
import MarkdownReader from '../../components/MarkdownReader'
|
||||||
@@ -29,6 +29,28 @@ export default function CompetitionDetail() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置并监听分享给朋友的功能
|
||||||
|
*/
|
||||||
|
useShareAppMessage(() => {
|
||||||
|
return {
|
||||||
|
title: detail?.title || '赛事详情',
|
||||||
|
path: `/pages/competition/detail?id=${detail?.id || ''}`,
|
||||||
|
imageUrl: detail?.display_cover_image || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置并监听分享到朋友圈的功能
|
||||||
|
*/
|
||||||
|
useShareTimeline(() => {
|
||||||
|
return {
|
||||||
|
title: detail?.title || '赛事详情',
|
||||||
|
query: `id=${detail?.id || ''}`,
|
||||||
|
imageUrl: detail?.display_cover_image || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const fetchDetail = async (id) => {
|
const fetchDetail = async (id) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
import { View, Text, Image, ScrollView } from '@tarojs/components'
|
||||||
import Taro, { useLoad } from '@tarojs/taro'
|
import Taro, { useLoad, useShareAppMessage, useShareTimeline } from '@tarojs/taro'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { getCompetitions } from '../../api'
|
import { getCompetitions } from '../../api'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
@@ -13,6 +13,26 @@ export default function CompetitionList() {
|
|||||||
fetchCompetitions()
|
fetchCompetitions()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置并监听分享给朋友的功能
|
||||||
|
*/
|
||||||
|
useShareAppMessage(() => {
|
||||||
|
return {
|
||||||
|
title: '赛事中心',
|
||||||
|
path: '/pages/competition/index'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置并监听分享到朋友圈的功能
|
||||||
|
*/
|
||||||
|
useShareTimeline(() => {
|
||||||
|
return {
|
||||||
|
title: '赛事中心',
|
||||||
|
query: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const fetchCompetitions = async () => {
|
const fetchCompetitions = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setDebugMsg('开始加载...')
|
setDebugMsg('开始加载...')
|
||||||
|
|||||||
@@ -1,98 +1,201 @@
|
|||||||
.project-detail {
|
.project-detail {
|
||||||
background-color: #000;
|
background-color: #000;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding-bottom: 40px;
|
padding-bottom: 60px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
.cover {
|
.cover {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 240px;
|
height: 260px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
padding: 24px;
|
padding: 30px;
|
||||||
background: #111;
|
background: #111;
|
||||||
border-radius: 16px 16px 0 0;
|
border-radius: 24px 24px 0 0;
|
||||||
margin-top: -24px;
|
margin-top: -30px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
min-height: 60vh;
|
min-height: 60vh;
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 40px;
|
||||||
.title {
|
.title {
|
||||||
font-size: 24px;
|
font-size: 36px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 24px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.author {
|
.author {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
padding: 8px 12px;
|
padding: 12px 20px;
|
||||||
border-radius: 20px;
|
border-radius: 30px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
width: 24px;
|
width: 36px;
|
||||||
height: 24px;
|
height: 36px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-right: 8px;
|
margin-right: 12px;
|
||||||
background: #333;
|
background: #333;
|
||||||
}
|
}
|
||||||
.name {
|
.name {
|
||||||
font-size: 14px;
|
font-size: 18px;
|
||||||
color: #ccc;
|
color: #ddd;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 50px;
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
font-size: 18px;
|
font-size: 28px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 24px;
|
||||||
display: block;
|
display: block;
|
||||||
border-left: 4px solid #00b96b;
|
border-left: 6px solid #00b96b;
|
||||||
padding-left: 12px;
|
padding-left: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-content {
|
.text-content {
|
||||||
font-size: 15px;
|
font-size: 20px;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
background: #1f1f1f;
|
background: #1f1f1f;
|
||||||
padding: 16px;
|
padding: 24px;
|
||||||
border-radius: 12px;
|
border-radius: 20px;
|
||||||
|
|
||||||
|
/* Markdown Styles */
|
||||||
|
h1, h2, h3, h4, h5, h6 { margin-top: 40px; margin-bottom: 24px; color: #fff; font-weight: 700; line-height: 1.4; }
|
||||||
|
h1 { font-size: 34px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 16px; }
|
||||||
|
h2 { font-size: 30px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 12px; }
|
||||||
|
h3 { font-size: 26px; }
|
||||||
|
h4 { font-size: 24px; }
|
||||||
|
h5 { font-size: 22px; color: #ddd; }
|
||||||
|
|
||||||
|
p { margin-bottom: 24px; }
|
||||||
|
|
||||||
|
strong { font-weight: 800; color: #fff; }
|
||||||
|
em { font-style: italic; color: #aaa; }
|
||||||
|
del { text-decoration: line-through; color: #666; }
|
||||||
|
|
||||||
|
ul, ol { margin-bottom: 24px; padding-left: 28px; }
|
||||||
|
li { margin-bottom: 10px; list-style-position: outside; }
|
||||||
|
ul li { list-style-type: disc; }
|
||||||
|
ol li { list-style-type: decimal; }
|
||||||
|
|
||||||
|
li input[type="checkbox"] { margin-right: 12px; transform: scale(1.2); }
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 6px solid #00b96b;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
padding: 20px 24px;
|
||||||
|
margin: 24px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #bbb;
|
||||||
|
font-size: 18px;
|
||||||
|
font-style: italic;
|
||||||
|
p { margin-bottom: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: #00b96b; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
|
||||||
|
|
||||||
|
hr {
|
||||||
|
height: 1px;
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border: none;
|
||||||
|
margin: 30px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 24px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
overflow-x: auto;
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
padding: 14px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(even) {
|
||||||
|
background: rgba(255,255,255,0.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
|
||||||
|
color: #ff7875;
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 0 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: #161616;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 24px 0;
|
||||||
|
border: 1px solid #333;
|
||||||
|
box-shadow: inset 0 0 20px rgba(0,0,0,0.5);
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: transparent;
|
||||||
|
color: #a6e22e;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
image {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin: 24px 0;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty {
|
.empty {
|
||||||
font-size: 14px;
|
font-size: 18px;
|
||||||
color: #666;
|
color: #666;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
display: block;
|
display: block;
|
||||||
padding: 20px 0;
|
padding: 40px 0;
|
||||||
background: #1f1f1f;
|
background: #1f1f1f;
|
||||||
border-radius: 12px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.file-list {
|
.file-list {
|
||||||
background: #1f1f1f;
|
background: #1f1f1f;
|
||||||
border-radius: 12px;
|
border-radius: 20px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
.file-item {
|
.file-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 16px;
|
padding: 24px;
|
||||||
border-bottom: 1px solid #333;
|
border-bottom: 1px solid #333;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
@@ -100,20 +203,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.file-name {
|
.file-name {
|
||||||
font-size: 14px;
|
font-size: 18px;
|
||||||
color: #ddd;
|
color: #ddd;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
margin-right: 16px;
|
margin-right: 20px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.file-action {
|
.file-action {
|
||||||
font-size: 12px;
|
font-size: 16px;
|
||||||
color: #00b96b;
|
color: #00b96b;
|
||||||
padding: 4px 12px;
|
padding: 8px 20px;
|
||||||
border: 1px solid #00b96b;
|
border: 1px solid #00b96b;
|
||||||
border-radius: 14px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,9 +224,9 @@
|
|||||||
.comment-list {
|
.comment-list {
|
||||||
.comment-item {
|
.comment-item {
|
||||||
background: #1f1f1f;
|
background: #1f1f1f;
|
||||||
border-radius: 12px;
|
border-radius: 20px;
|
||||||
padding: 16px;
|
padding: 24px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 24px;
|
||||||
|
|
||||||
&:last-child {
|
&:last-child {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@@ -132,20 +235,47 @@
|
|||||||
.comment-header {
|
.comment-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
margin-bottom: 8px;
|
align-items: center;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.judge-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
|
||||||
.judge-name {
|
.judge-name {
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #00b96b;
|
color: #00b96b;
|
||||||
|
margin-right: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.judge-score-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
|
||||||
|
.score-num {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
margin-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-unit {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #999;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.comment-time {
|
.comment-time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.comment-content {
|
.comment-content {
|
||||||
font-size: 14px;
|
font-size: 20px;
|
||||||
color: #ccc;
|
color: #ccc;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { View, Text, Image, Button, ScrollView } from '@tarojs/components'
|
import { View, Text, Image, Button, ScrollView } from '@tarojs/components'
|
||||||
import Taro, { useLoad } from '@tarojs/taro'
|
import Taro, { useLoad, useShareAppMessage, useShareTimeline, useRouter } from '@tarojs/taro'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { getProjectDetail, getComments } from '../../api'
|
import { getProjectDetail, getComments } from '../../api'
|
||||||
import MarkdownReader from '../../components/MarkdownReader'
|
import MarkdownReader from '../../components/MarkdownReader'
|
||||||
@@ -9,6 +9,7 @@ export default function ProjectDetail() {
|
|||||||
const [project, setProject] = useState<any>(null)
|
const [project, setProject] = useState<any>(null)
|
||||||
const [comments, setComments] = useState<any[]>([])
|
const [comments, setComments] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
useLoad((options) => {
|
useLoad((options) => {
|
||||||
const { id } = options
|
const { id } = options
|
||||||
@@ -18,6 +19,30 @@ export default function ProjectDetail() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置并监听分享给朋友的功能
|
||||||
|
*/
|
||||||
|
useShareAppMessage(() => {
|
||||||
|
const id = project?.id || router.params.id || ''
|
||||||
|
return {
|
||||||
|
title: project?.title || '项目详情',
|
||||||
|
path: `/pages/competition/project-detail?id=${id}`,
|
||||||
|
imageUrl: project?.display_cover_image || project?.cover_image_url || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置并监听分享到朋友圈的功能
|
||||||
|
*/
|
||||||
|
useShareTimeline(() => {
|
||||||
|
const id = project?.id || router.params.id || ''
|
||||||
|
return {
|
||||||
|
title: project?.title || '项目详情',
|
||||||
|
query: `id=${id}`,
|
||||||
|
imageUrl: project?.display_cover_image || project?.cover_image_url || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取项目详情
|
* 获取项目详情
|
||||||
* @param id 项目ID
|
* @param id 项目ID
|
||||||
@@ -124,7 +149,12 @@ export default function ProjectDetail() {
|
|||||||
<View className='file-list'>
|
<View className='file-list'>
|
||||||
{project.files.map((file, index) => (
|
{project.files.map((file, index) => (
|
||||||
<View key={index} className='file-item' onClick={() => handleOpenFile(file)}>
|
<View key={index} className='file-item' onClick={() => handleOpenFile(file)}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
<Text className='file-name'>{file.name || '附件 ' + (index + 1)}</Text>
|
<Text className='file-name'>{file.name || '附件 ' + (index + 1)}</Text>
|
||||||
|
{file.file_size_display && (
|
||||||
|
<Text style={{ fontSize: '12px', color: '#999' }}>{file.file_size_display}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
<Text className='file-action'>查看</Text>
|
<Text className='file-action'>查看</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -141,7 +171,15 @@ export default function ProjectDetail() {
|
|||||||
{comments.map((c) => (
|
{comments.map((c) => (
|
||||||
<View key={c.id} className='comment-item'>
|
<View key={c.id} className='comment-item'>
|
||||||
<View className='comment-header'>
|
<View className='comment-header'>
|
||||||
|
<View className='judge-info'>
|
||||||
<Text className='judge-name'>{c.judge_name || '评委'}</Text>
|
<Text className='judge-name'>{c.judge_name || '评委'}</Text>
|
||||||
|
{c.score && (
|
||||||
|
<View className='judge-score-box'>
|
||||||
|
<Text className='score-num'>{c.score}</Text>
|
||||||
|
<Text className='score-unit'>分</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
<Text className='comment-time'>{c.created_at?.substring(0, 16)}</Text>
|
<Text className='comment-time'>{c.created_at?.substring(0, 16)}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className='comment-content'>{c.content}</Text>
|
<Text className='comment-content'>{c.content}</Text>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { View, Text, Button, Image, Input, Textarea, Picker } from '@tarojs/components'
|
import { View, Text, Button, Image, Input, Textarea, Picker } from '@tarojs/components'
|
||||||
import Taro, { useLoad } from '@tarojs/taro'
|
import Taro, { useLoad, useShareAppMessage, useShareTimeline, useRouter } from '@tarojs/taro'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { getProjectDetail, createProject, updateProject, uploadProjectFile, submitProject, uploadMedia, getCompetitions } from '../../api'
|
import { getProjectDetail, createProject, updateProject, uploadProjectFile, submitProject, uploadMedia, getCompetitions, deleteProjectFile, getMyEnrollments } from '../../api'
|
||||||
import './project.scss'
|
import './project.scss'
|
||||||
|
|
||||||
export default function ProjectEdit() {
|
export default function ProjectEdit() {
|
||||||
@@ -15,6 +15,7 @@ export default function ProjectEdit() {
|
|||||||
const [competitions, setCompetitions] = useState<any[]>([])
|
const [competitions, setCompetitions] = useState<any[]>([])
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [isEdit, setIsEdit] = useState(false)
|
const [isEdit, setIsEdit] = useState(false)
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
useLoad((options) => {
|
useLoad((options) => {
|
||||||
fetchCompetitions()
|
fetchCompetitions()
|
||||||
@@ -27,14 +28,49 @@ export default function ProjectEdit() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置并监听分享给朋友的功能
|
||||||
|
*/
|
||||||
|
useShareAppMessage(() => {
|
||||||
|
const id = project?.id || router.params.id || ''
|
||||||
|
const compId = competitionId || router.params.competitionId || ''
|
||||||
|
return {
|
||||||
|
title: project?.title || '提交作品',
|
||||||
|
path: `/pages/competition/project?id=${id}&competitionId=${compId}`,
|
||||||
|
imageUrl: project?.cover_image_url || project?.display_cover_image || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置并监听分享到朋友圈的功能
|
||||||
|
*/
|
||||||
|
useShareTimeline(() => {
|
||||||
|
const id = project?.id || router.params.id || ''
|
||||||
|
const compId = competitionId || router.params.competitionId || ''
|
||||||
|
return {
|
||||||
|
title: project?.title || '提交作品',
|
||||||
|
query: `id=${id}&competitionId=${compId}`,
|
||||||
|
imageUrl: project?.cover_image_url || project?.display_cover_image || ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const fetchCompetitions = async () => {
|
const fetchCompetitions = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getCompetitions()
|
const res = await getMyEnrollments()
|
||||||
if (res && res.results) {
|
if (res && res.length > 0) {
|
||||||
setCompetitions(res.results)
|
const approvedEnrollments = res.filter((enrollment: any) => enrollment.status === 'approved')
|
||||||
|
const competitions = approvedEnrollments.map((enrollment: any) => ({
|
||||||
|
id: enrollment.competition,
|
||||||
|
title: enrollment.competition_title || '',
|
||||||
|
status: enrollment.status
|
||||||
|
}))
|
||||||
|
setCompetitions(competitions)
|
||||||
|
} else {
|
||||||
|
setCompetitions([])
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取比赛列表失败', e)
|
console.error('获取比赛列表失败', e)
|
||||||
|
setCompetitions([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,12 +99,13 @@ export default function ProjectEdit() {
|
|||||||
Taro.showLoading({ title: '上传中...' })
|
Taro.showLoading({ title: '上传中...' })
|
||||||
|
|
||||||
const res = await uploadMedia(tempFilePaths[0], 'image')
|
const res = await uploadMedia(tempFilePaths[0], 'image')
|
||||||
handleInput('cover_image_url', res.file) // 假设返回 { file: 'url...' }
|
handleInput('cover_image_url', res.file)
|
||||||
|
|
||||||
Taro.hideLoading()
|
Taro.hideLoading()
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
Taro.hideLoading()
|
Taro.hideLoading()
|
||||||
Taro.showToast({ title: '上传失败', icon: 'none' })
|
const errorMsg = e.response?.error || e.message || '上传失败'
|
||||||
|
Taro.showToast({ title: errorMsg, icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,22 +134,71 @@ export default function ProjectEdit() {
|
|||||||
|
|
||||||
Taro.hideLoading()
|
Taro.hideLoading()
|
||||||
Taro.showToast({ title: '上传成功', icon: 'success' })
|
Taro.showToast({ title: '上传成功', icon: 'success' })
|
||||||
} catch (e) {
|
} catch (e: any) {
|
||||||
Taro.hideLoading()
|
Taro.hideLoading()
|
||||||
console.error(e)
|
console.error(e)
|
||||||
Taro.showToast({ title: '上传失败', icon: 'none' })
|
const errorMsg = e.response?.error || e.message || '上传失败'
|
||||||
|
Taro.showToast({ title: errorMsg, icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteFile = (fileId) => {
|
const handleDeleteFile = async (fileId) => {
|
||||||
// API call to delete file not implemented yet? Or just remove from list?
|
try {
|
||||||
// Usually we should call delete API. For now just remove from UI.
|
await Taro.showModal({
|
||||||
// Ideally we should have deleteProjectFile API.
|
title: '确认删除',
|
||||||
// But user only asked to "optimize upload".
|
content: '确定要删除这个文件吗?',
|
||||||
|
confirmColor: '#ff4d4f'
|
||||||
|
}).then(res => {
|
||||||
|
if (!res.confirm) throw new Error('cancel')
|
||||||
|
})
|
||||||
|
|
||||||
|
Taro.showLoading({ title: '删除中...' })
|
||||||
|
await deleteProjectFile(fileId)
|
||||||
|
|
||||||
setProject(prev => ({
|
setProject(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
files: prev.files.filter(f => f.id !== fileId)
|
files: prev.files.filter(f => f.id !== fileId)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
Taro.hideLoading()
|
||||||
|
Taro.showToast({ title: '删除成功', icon: 'success' })
|
||||||
|
} catch (e: any) {
|
||||||
|
Taro.hideLoading()
|
||||||
|
if (e.message !== 'cancel') {
|
||||||
|
const errorMsg = e.response?.error || e.message || '删除失败'
|
||||||
|
Taro.showToast({ title: errorMsg, icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePreviewFile = (file) => {
|
||||||
|
const fileUrl = file.file_url || file.file_url_display || ''
|
||||||
|
if (!fileUrl) {
|
||||||
|
Taro.showToast({ title: '文件地址无效', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileType = file.file_type || ''
|
||||||
|
if (fileType === 'image') {
|
||||||
|
Taro.previewImage({
|
||||||
|
urls: [fileUrl]
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Taro.downloadFile({
|
||||||
|
url: fileUrl,
|
||||||
|
success: (res) => {
|
||||||
|
if (res.statusCode === 200) {
|
||||||
|
Taro.openDocument({
|
||||||
|
filePath: res.tempFilePath,
|
||||||
|
fileType: fileType === 'pdf' ? 'pdf' : fileType === 'ppt' ? 'ppt' : undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
Taro.showToast({ title: '打开文件失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSave = async (submit = false) => {
|
const handleSave = async (submit = false) => {
|
||||||
@@ -234,12 +320,22 @@ export default function ProjectEdit() {
|
|||||||
</View>
|
</View>
|
||||||
<View className='file-list'>
|
<View className='file-list'>
|
||||||
{project.files && project.files.map((file, index) => (
|
{project.files && project.files.map((file, index) => (
|
||||||
<View key={index} className='file-item' style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', background: '#f8f8f8', marginBottom: '8px', borderRadius: '4px' }}>
|
<View
|
||||||
<Text className='file-name' style={{ flex: 1, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name || '未知文件'}</Text>
|
key={index}
|
||||||
{/* <Text className='delete' style={{ color: 'red', marginLeft: '10px' }} onClick={() => handleDeleteFile(file.id)}>删除</Text> */}
|
className='file-item'
|
||||||
|
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', background: '#f8f8f8', marginBottom: '8px', borderRadius: '4px' }}
|
||||||
|
onClick={() => handlePreviewFile(file)}
|
||||||
|
>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text className='file-name' style={{ fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#333' }}>{file.name || '未知文件'}</Text>
|
||||||
|
{file.file_size_display && (
|
||||||
|
<Text style={{ fontSize: '12px', color: '#999' }}>{file.file_size_display}</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text className='delete' style={{ color: '#ff4d4f', marginLeft: '10px', fontSize: '14px' }} onClick={(e) => { e.stopPropagation(); handleDeleteFile(file.id) }}>删除</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
{(!project.files || project.files.length === 0) && <Text style={{ color: '#999', fontSize: '12px' }}>暂无附件 (PDF/PPT/视频)</Text>}
|
{(!project.files || project.files.length === 0) && <Text style={{ color: '#666', fontSize: '12px' }}>暂无附件 (PDF/PPT/视频)</Text>}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -115,11 +115,19 @@ const ActivityDetail = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理表单字段内容改变的事件
|
||||||
|
* 必须返回最新的 value,以修复 Taro UI 中 AtInput 光标会跑到最前面的 Bug
|
||||||
|
* @param {string} fieldName - 表单字段名
|
||||||
|
* @param {any} value - 表单输入的最新的值
|
||||||
|
* @returns {any} 返回最新的值
|
||||||
|
*/
|
||||||
const handleFormChange = (fieldName, value) => {
|
const handleFormChange = (fieldName, value) => {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[fieldName]: value
|
[fieldName]: value
|
||||||
}))
|
}))
|
||||||
|
return value // 修复 Taro UI AtInput 光标跳动问题:必须返回 value
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleModalConfirm = () => {
|
const handleModalConfirm = () => {
|
||||||
@@ -267,7 +275,8 @@ const ActivityDetail = () => {
|
|||||||
<AtModalHeader>填写报名信息</AtModalHeader>
|
<AtModalHeader>填写报名信息</AtModalHeader>
|
||||||
<AtModalContent>
|
<AtModalContent>
|
||||||
<View className='signup-form'>
|
<View className='signup-form'>
|
||||||
{activity.signup_form_config && Array.isArray(activity.signup_form_config) && activity.signup_form_config.map((field, idx) => {
|
{/* 修复小程序原生组件穿透问题:只在 modal 打开时渲染输入组件 */}
|
||||||
|
{showSignupModal && activity.signup_form_config && Array.isArray(activity.signup_form_config) && activity.signup_form_config.map((field, idx) => {
|
||||||
// Defensive programming: skip invalid fields or known bad data
|
// Defensive programming: skip invalid fields or known bad data
|
||||||
if (!field || typeof field !== 'object' || field.label === '自定义报名配置') return null
|
if (!field || typeof field !== 'object' || field.label === '自定义报名配置') return null
|
||||||
|
|
||||||
@@ -357,7 +366,7 @@ const ActivityDetail = () => {
|
|||||||
title={field.label}
|
title={field.label}
|
||||||
type={field.type === 'tel' ? 'phone' : (field.type === 'number' ? 'number' : 'text')}
|
type={field.type === 'tel' ? 'phone' : (field.type === 'number' ? 'number' : 'text')}
|
||||||
placeholder={field.placeholder || `请输入${field.label}`}
|
placeholder={field.placeholder || `请输入${field.label}`}
|
||||||
value={formData[field.name]}
|
value={formData[field.name] || ''}
|
||||||
onChange={(val) => handleFormChange(field.name, val)}
|
onChange={(val) => handleFormChange(field.name, val)}
|
||||||
required={field.required}
|
required={field.required}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -79,12 +79,15 @@ const CreateTopic = () => {
|
|||||||
setContent(prev => prev + insertText)
|
setContent(prev => prev + insertText)
|
||||||
|
|
||||||
Taro.hideLoading()
|
Taro.hideLoading()
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
Taro.hideLoading()
|
Taro.hideLoading()
|
||||||
// Only toast if it's an error, not cancel
|
|
||||||
if (error.errMsg && error.errMsg.indexOf('cancel') === -1) {
|
if (error.errMsg && error.errMsg.indexOf('cancel') === -1) {
|
||||||
Taro.showToast({ title: '上传失败', icon: 'none' })
|
const errorMsg = error.response?.error || error.message || '上传失败'
|
||||||
|
Taro.showToast({ title: errorMsg, icon: 'none' })
|
||||||
|
} else if (!error.errMsg) {
|
||||||
|
const errorMsg = error.response?.error || error.message || '上传失败'
|
||||||
|
Taro.showToast({ title: errorMsg, icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,10 +170,11 @@ const ForumDetail = () => {
|
|||||||
setReplyContent(prev => prev + insertText)
|
setReplyContent(prev => prev + insertText)
|
||||||
|
|
||||||
Taro.hideLoading()
|
Taro.hideLoading()
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
Taro.hideLoading()
|
Taro.hideLoading()
|
||||||
Taro.showToast({ title: '上传失败', icon: 'none' })
|
const errorMsg = error.response?.error || error.message || '上传失败'
|
||||||
|
Taro.showToast({ title: errorMsg, icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user