Compare commits

...

47 Commits

Author SHA1 Message Date
jeremygan2021
ec67bed6f4 finish
All checks were successful
Deploy to Server / deploy (push) Successful in 19s
2026-03-22 22:52:36 +08:00
jeremygan2021
77b8376878 upload 1
All checks were successful
Deploy to Server / deploy (push) Successful in 28s
2026-03-22 22:43:59 +08:00
jeremygan2021
bb373f3c60 upload
All checks were successful
Deploy to Server / deploy (push) Successful in 13s
2026-03-22 22:27:29 +08:00
jeremygan2021
46003a0439 upload
All checks were successful
Deploy to Server / deploy (push) Successful in 6s
2026-03-22 22:27:17 +08:00
jeremygan2021
431ddaf67c upload 1
All checks were successful
Deploy to Server / deploy (push) Successful in 6s
2026-03-22 22:23:09 +08:00
jeremygan2021
41d6991d5c upload 1
All checks were successful
Deploy to Server / deploy (push) Successful in 28s
2026-03-22 22:21:02 +08:00
jeremygan2021
94333b61b6 upload
All checks were successful
Deploy to Server / deploy (push) Successful in 14s
2026-03-22 22:10:21 +08:00
jeremygan2021
2104e7b7dc new
All checks were successful
Deploy to Server / deploy (push) Successful in 32s
2026-03-22 22:04:13 +08:00
jeremygan2021
2e05322909 pdf upload
All checks were successful
Deploy to Server / deploy (push) Successful in 32s
2026-03-22 21:15:34 +08:00
jeremygan2021
0274e59fd9 score
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-20 15:51:59 +08:00
jeremygan2021
8bc06b0423 score
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
2026-03-20 15:34:09 +08:00
jeremygan2021
98baa92e98 score
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-20 15:22:51 +08:00
jeremygan2021
06afd11f1c dimension——id
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-20 15:16:58 +08:00
jeremygan2021
4de4ff91f3 dimension——id
All checks were successful
Deploy to Server / deploy (push) Successful in 19s
2026-03-20 14:59:51 +08:00
jeremygan2021
b39e500307 new
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
2026-03-20 14:23:18 +08:00
jeremygan2021
07006d46d9 new
All checks were successful
Deploy to Server / deploy (push) Successful in 19s
2026-03-20 14:08:51 +08:00
jeremygan2021
76bb5945ac new
All checks were successful
Deploy to Server / deploy (push) Successful in 15s
2026-03-20 13:54:51 +08:00
jeremygan2021
d76b5845a1 new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-20 13:51:44 +08:00
jeremygan2021
76f7b2bcbe new
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-20 13:39:17 +08:00
jeremygan2021
c62c5b98ea new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-20 13:27:54 +08:00
jeremygan2021
0d7ba5d87c 算法
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-20 13:20:09 +08:00
jeremygan2021
98db4d6f75 算法
All checks were successful
Deploy to Server / deploy (push) Successful in 28s
2026-03-20 13:13:07 +08:00
jeremygan2021
02335d26c2 mode bug
All checks were successful
Deploy to Server / deploy (push) Successful in 15s
2026-03-18 00:38:58 +08:00
jeremygan2021
da235c3a82 mode bug
All checks were successful
Deploy to Server / deploy (push) Successful in 15s
2026-03-18 00:32:04 +08:00
jeremygan2021
f25c35af40 mode bug
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
2026-03-17 23:56:19 +08:00
jeremygan2021
465ea34dcd audo url
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-17 23:48:08 +08:00
jeremygan2021
bd102cc71f peer review
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
2026-03-17 22:32:28 +08:00
jeremygan2021
6a166c50eb new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-17 22:30:43 +08:00
jeremygan2021
75dbf22a43 new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-17 22:22:58 +08:00
jeremygan2021
7695ac3edf 逐字稿
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
2026-03-17 21:43:33 +08:00
jeremygan2021
3d94a1f0de 逐字稿
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-17 21:36:12 +08:00
jeremygan2021
f72293eb76 逐字稿
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-17 21:22:38 +08:00
jeremygan2021
35d96588f9 逐字稿
All checks were successful
Deploy to Server / deploy (push) Successful in 19s
2026-03-17 21:15:48 +08:00
jeremygan2021
afab4933b4 逐字稿
All checks were successful
Deploy to Server / deploy (push) Successful in 20s
2026-03-17 21:11:19 +08:00
jeremygan2021
4d6f98080e admin 手机和用户名
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-17 20:59:24 +08:00
jeremygan2021
de1e409447 admin phone serch
All checks were successful
Deploy to Server / deploy (push) Successful in 1m53s
2026-03-17 19:21:32 +08:00
jeremygan2021
6aaddfbe9e 打分上传后台
All checks were successful
Deploy to Server / deploy (push) Successful in 15s
2026-03-12 13:49:47 +08:00
jeremygan2021
f23e477f57 打分上传后台
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-03-12 13:47:21 +08:00
jeremygan2021
1f693e0e8a 打分上传后台
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-12 13:34:47 +08:00
jeremygan2021
6129673ddc debug
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-12 12:36:25 +08:00
jeremygan2021
8b6773bb98 commit 2026-03-11 23:07:35 +08:00
jeremygan2021
d28ecf98ea commit
All checks were successful
Deploy to Server / deploy (push) Successful in 27s
2026-03-11 23:04:37 +08:00
jeremygan2021
8b11d0aab1 commit
All checks were successful
Deploy to Server / deploy (push) Successful in 26s
2026-03-11 22:56:03 +08:00
jeremygan2021
dbd752b833 commit
All checks were successful
Deploy to Server / deploy (push) Successful in 27s
2026-03-11 22:47:35 +08:00
jeremygan2021
5a87105ec9 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-11 22:35:37 +08:00
jeremygan2021
c32522857e tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 16s
2026-03-11 22:28:19 +08:00
jeremygan2021
c1fadf1344 tingwu_new
All checks were successful
Deploy to Server / deploy (push) Successful in 17s
2026-03-11 22:25:58 +08:00
56 changed files with 4597 additions and 350 deletions

View File

@@ -10,12 +10,46 @@ Quant Speed Market 是一个基于现代技术栈构建的综合性平台,旨
## ✨ 功能特性
- **🛍️ 电商商城**:支持商品浏览、购物车、微信支付 (WeChat Pay V3)、订单管理。
- **💬 社区论坛**:支持发帖、回帖、话题分类、富文本编辑。
- **🤖 AI 服务**:集成 AI 工具,提供智能辅助服务。
- **🕶️ AR/3D 展示**:基于 Three.js 的 3D 模型预览与 AR 交互体验。
- **📱 多端适配**微信小程序原生体验Web 端响应式管理后台。
- **🔒 安全认证**微信一键登录、手机号绑定、JWT 认证。
### 🛍️ 电商商城系统
- **商品管理**ESP32硬件配置、库存管理、3D模型展示、产品特性标签
- **订单管理**:多类型订单(硬件/课程/活动)、完整状态流转、物流跟踪
- **支付系统**微信支付V3集成、多种支付方式、安全签名验证、支付回调处理
- **分销系统**二级分销体系、邀请机制、佣金计算一级10%/二级2%)、提现管理
- **课程系统**:视频课程、固定时间课程、讲师管理、课程报名与咨询
### 💬 社区论坛系统
- **活动管理**:线上线下活动、报名表单自定义、支付状态同步、审核机制
- **论坛帖子**:技术讨论、求助问答、经验分享、官方公告四大分类
- **互动功能**:点赞、置顶、嵌套回复(楼中楼)、多媒体附件支持
- **公告系统**:时效控制、跳转链接、优先级排序、置顶功能
### 🤖 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
- **Database**: PostgreSQL (psycopg2)
- **Payment**: WeChat Pay V3 (wechatpayv3)
- **AI Services**: 阿里云听悟 (语音转写)、通义千问 (AI评估)
- **Cloud Storage**: 阿里云OSS (文件存储)
- **Documentation**: drf-spectacular (OpenAPI 3.0)
- **Deployment**: Docker, Gunicorn
- **Authentication**: JWT + 微信OAuth2.0
### Frontend (Web 端)
- **Core**: React 19 + Vite 7
@@ -164,6 +201,11 @@ docker-compose up -d --build
| POST | `/api/shop/orders/` | 创建新订单 |
| POST | `/api/shop/pay/` | 发起微信支付 |
| 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 文档。
@@ -172,25 +214,46 @@ docker-compose up -d --build
```
market_page/
├── backend/ # Django 后端源码
│ ├── ai_services/ # AI服务模块 (语音转写、AI评估)
│ │ ├── models.py # 转写任务、AI评估模板模型
│ │ ├── views.py # API接口 (转写、评估、总结)
│ │ └── services.py # 阿里云听悟、通义千问服务集成
│ ├── community/ # 论坛社区模块
│ │ ├── models.py # 活动、帖子、回复、公告模型
│ │ ├── views.py # 社区API接口
│ │ └── admin_actions.py # 后台管理动作
│ ├── competition/ # 竞赛评审模块
│ │ ├── models.py # 比赛、项目、评分、维度模型
│ │ ├── judge_views.py # 评委系统接口
│ │ └── templates/ # 评委系统前端页面
│ ├── shop/ # 电商与支付模块
│ │ ├── models.py # 商品、订单、支付、用户模型
│ │ ├── services.py # 微信支付、短信服务
│ │ └── admin_actions.py # 订单管理动作
│ ├── config/ # 项目核心配置
│ │ ├── settings.py # Django配置
│ │ └── urls.py # 主路由配置
│ ├── uploads/ # 用户上传文件 (媒体资源)
│ ├── manage.py # Django 管理脚本
── requirements.txt # Python 依赖
── requirements.txt # Python 依赖
│ └── Dockerfile # 后端容器配置
├── frontend/ # React Web 端源码
│ ├── src/
│ │ ├── components/ # 公共组件 (3D模型、弹窗等)
│ │ ├── pages/ # 页面路由 (Home, Forum, Payment)
│ │ ├── hooks/ # 自定义React Hooks
│ │ └── assets/ # 静态资源
│ ├── public/ # 公共资源
│ └── vite.config.js # Vite 配置
├── miniprogram/ # Taro 小程序源码
│ ├── src/
│ │ ├── pages/ # 小程序页面
│ │ ├── subpackages/ # 分包页面 (分销、论坛详情等)
│ │ ── components/ # 小程序组件
│ │ ── components/ # 小程序组件
│ │ └── utils/ # 工具函数
│ └── project.config.json # 微信小程序配置
── docker-compose.yml # Docker 编排文件
── docker-compose.yml # Docker 编排文件
└── README.md # 项目文档
```
## 🤝 贡献规范
@@ -226,6 +289,18 @@ market_page/
- **Q: 微信支付接口调用失败?**
- A: 微信支付依赖真实商户号和证书,本地开发请使用模拟数据或沙箱环境。
- **Q: AI语音转写任务状态一直显示"处理中"**
- A: 检查阿里云听悟服务配置是否正确包括AccessKey、AppKey等参数。可通过`python manage.py check_aliyun_config`命令验证配置。
- **Q: AI评估功能无法正常使用**
- A: 确保通义千问API密钥已正确配置检查模型调用配额是否充足。评估模板中的提示词需要符合模型要求。
- **Q: 分销佣金没有正确计算?**
- A: 检查产品是否设置了独立分润比例,确认分销员状态为"正常",查看佣金日志了解具体计算过程。
- **Q: 竞赛项目无法提交?**
- A: 确认比赛状态为"作品提交中",检查是否已报名该比赛,确保每人每比赛只能提交一个项目。
## 📜 许可证
本项目采用 [MIT License](LICENSE) 许可证。

54
backend/DEPLOY.md Normal file
View 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`

44
backend/TEST_REPORT.md Normal file
View 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。

View File

@@ -22,8 +22,8 @@ class TranscriptionTaskAdmin(UnfoldModelAdmin):
@admin.register(AIEvaluationTemplate)
class AIEvaluationTemplateAdmin(UnfoldModelAdmin):
list_display = ['name', 'model_selection', 'is_active', 'created_at']
list_filter = ['is_active', 'model_selection', 'created_at']
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)

View File

@@ -95,11 +95,27 @@ class BailianService:
{'role': 'user', 'content': f"{prompt}\n\n以下是需要评估的内容:\n{content}{chapter_context}"}
]
completion = self.client.chat.completions.create(
model=evaluation.model_selection,
messages=messages,
response_format={"type": "json_object"}
)
# 增加重试机制 (最多重试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
@@ -189,24 +205,34 @@ class BailianService:
# 3. 同步评分 (Score)
if evaluation.score is not None:
# 尝试找到匹配的维度
# 优先级:完全匹配模板名称 > 包含"AI"的维度 > 第一个维度
dimensions = competition.score_dimensions.all()
target_dimension = None
for dim in dimensions:
if dim.name == template_name:
target_dimension = dim
break
# 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
# 如果还是没找到,尝试创建一个默认的 "AI评分" 维度?
# 或者使用第一个维度。考虑到用户说"对应的AI评分",如果没有对应的,可能需要创建一个?
# 为了安全起见如果找不到明确的AI维度且存在维度就用第一个否则不评分。
# 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()

View File

@@ -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='关联评分维度'),
),
]

View File

@@ -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='是否为默认模板'),
),
]

View File

@@ -65,6 +65,19 @@ class AIEvaluationTemplate(models.Model):
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)

View File

@@ -58,9 +58,10 @@ class AliyunTingwuService:
self.client = None
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:
raise Exception("OSS Client not initialized")
@@ -70,8 +71,8 @@ class AliyunTingwuService:
# file_obj 应该是打开的文件对象或字节流
self.bucket.put_object(file_name, file_obj)
# 生成签名 URL有效期 3 小时 (3600 * 3)
url = self.bucket.sign_url('GET', file_name, 3600 * 3)
# 生成签名 URL有效期 7 天 (3600 * 24 * 7 = 604800 秒)
url = self.bucket.sign_url('GET', file_name, 3600 * 24 * day)
return url
except Exception as e:
logger.error(f"OSS Upload failed: {e}")
@@ -323,14 +324,36 @@ class AliyunTingwuService:
# 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,)).start()
threading.Thread(target=self.trigger_ai_evaluations, args=(task.id,)).start()
def trigger_ai_evaluations(self, task):
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.")
@@ -340,6 +363,46 @@ class AliyunTingwuService:
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,
@@ -349,8 +412,7 @@ class AliyunTingwuService:
status=AIEvaluation.Status.PENDING
)
# 触发评估 (同步执行,或者放入 Celery 任务)
# 这里为了简单直接调用,生产环境建议使用 Celery
# 触发评估
try:
service.evaluate_task(evaluation)
logger.info(f"Triggered evaluation {evaluation.id} for template {template.name}")

View File

@@ -70,6 +70,10 @@ class TranscriptionTaskViewSet(viewsets.ModelViewSet):
'file_url': {
'type': 'string',
'description': '音频文件的URL地址'
},
'project_id': {
'type': 'integer',
'description': '关联的参赛项目ID'
}
}
}
@@ -111,11 +115,16 @@ class TranscriptionTaskViewSet(viewsets.ModelViewSet):
}
if project_id:
try:
task_data['project_id'] = int(project_id)
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. 调用听悟接口创建任务
try:
@@ -220,9 +229,34 @@ class TranscriptionTaskViewSet(viewsets.ModelViewSet):
eval.save()
evaluations_to_process.append(eval)
# 执行评估
for eval_obj in evaluations_to_process:
service.evaluate_task(eval_obj)
# 执行评估 (改为异步并发执行)
# 提取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)

View File

@@ -120,9 +120,9 @@ class OrderableAdminMixin:
@admin.register(Activity)
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')
search_fields = ('title', 'location')
search_fields = ('title', 'location', 'author__phone_number')
# autocomplete_fields = ['author'] # 暂时注释,避免环境不一致导致报错
raw_id_fields = ('author',)
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")
def banner_display(self, obj):
if obj.banner:
@@ -155,9 +163,9 @@ class ActivityAdmin(ModelAdmin):
@admin.register(ActivitySignup)
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')
search_fields = ('user__nickname', 'activity__title')
search_fields = ('user__nickname', 'user__phone_number', 'activity__title')
autocomplete_fields = ['activity', 'user']
actions = [export_signups_csv, export_signups_excel]
@@ -172,6 +180,12 @@ class ActivitySignupAdmin(ModelAdmin):
)
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="报名信息")
def signup_info_display(self, obj):
import json
@@ -209,15 +223,23 @@ class ActivitySignupAdmin(ModelAdmin):
@admin.register(Topic)
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')
search_fields = ('title', 'content', 'author__nickname')
search_fields = ('title', 'content', 'author__nickname', 'author__phone_number')
autocomplete_fields = ['author', 'related_product', 'related_service', 'related_course']
filter_horizontal = ('likes',)
inlines = [TopicMediaInline, ReplyInline]
actions = ['reset_ordering', 'approve_topics', 'reject_topics']
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="批量通过审核")
def approve_topics(self, request, queryset):
rows_updated = queryset.update(status='published')
@@ -277,9 +299,9 @@ class TopicAdmin(OrderableAdminMixin, ModelAdmin):
@admin.register(Reply)
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')
search_fields = ('content', 'author__nickname', 'topic__title')
search_fields = ('content', 'author__nickname', 'author__phone_number', 'topic__title')
autocomplete_fields = ['author', 'topic', 'reply_to']
filter_horizontal = ('likes',)
list_editable = ('is_pinned',)
@@ -295,6 +317,14 @@ class ReplyAdmin(ModelAdmin):
)
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="点赞数")
def like_count(self, obj):
return obj.likes.count()

View 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='发布者'),
),
]

View File

@@ -18,7 +18,7 @@ class Activity(models.Model):
is_paid = models.BooleanField(default=False, 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_visible = models.BooleanField(default=True, verbose_name="是否显示", help_text="关闭后将不在前端列表页显示")

View File

@@ -343,6 +343,8 @@ class ReplyViewSet(viewsets.ModelViewSet):
return Response({'liked': liked, 'count': obj.likes.count()})
import requests
import warnings
warnings.filterwarnings('ignore', message='Unverified HTTPS request')
class TopicMediaViewSet(viewsets.ViewSet):
"""
@@ -367,7 +369,8 @@ class TopicMediaViewSet(viewsets.ViewSet):
try:
# 这里的 headers 不需要 Content-Typerequests 会自动设置 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:
data = response.json()

View File

@@ -1,23 +1,77 @@
from django.contrib import admin
from django.utils.safestring import mark_safe
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):
model = ScoreDimension
extra = 1
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):
model = ProjectFile
extra = 0
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)
class CompetitionAdmin(ModelAdmin):
list_display = ['title', 'status', 'start_time', 'end_time', 'is_active', 'created_at']
list_filter = ['status', 'is_active']
list_display = ['title', 'status', 'allow_contestant_grading', 'start_time', 'end_time', 'is_active', 'created_at']
list_filter = ['status', 'allow_contestant_grading', 'is_active']
search_fields = ['title', 'description']
inlines = [ScoreDimensionInline]
inlines = [ScoreDimensionInline, ScoreFormulaInline]
autocomplete_fields = ['active_formula']
fieldsets = (
('基本信息', {
@@ -28,7 +82,11 @@ class CompetitionAdmin(ModelAdmin):
'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')
make_ended.short_description = "结束选中比赛"
class Media:
css = {
'all': ('competition/admin/css/competition-admin.css',)
}
js = ('competition/admin/js/competition-admin.js',)
@admin.register(CompetitionEnrollment)
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']
search_fields = ['user__nickname', 'competition__title']
search_fields = ['user__nickname', 'user__phone_number', 'competition__title']
autocomplete_fields = ['user', 'competition']
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):
queryset.update(status='approved')
approve_enrollment.short_description = "通过审核"
@@ -57,11 +131,13 @@ class CompetitionEnrollmentAdmin(ModelAdmin):
queryset.update(status='rejected')
reject_enrollment.short_description = "拒绝申请"
@admin.register(Project)
class ProjectAdmin(ModelAdmin):
list_display = ['id', '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']
search_fields = ['id', 'title', 'contestant__user__nickname']
search_fields = ['id', 'title', 'contestant__user__nickname', 'contestant__user__phone_number']
autocomplete_fields = ['competition', 'contestant']
inlines = [ProjectFileInline]
readonly_fields = ['id', 'final_score']
@@ -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)
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']
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)
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']
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):
return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
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)

View 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'),
]

View 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)})

View File

@@ -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='允许选手互评'),
),
]

View File

@@ -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='是否公开给评委'),
),
]

View 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='是否用于选手互评'),
),
]

View File

@@ -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='权重'),
),
]

View 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'],
},
),
]

View File

@@ -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='得分计算方式'),
),
]

View File

@@ -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',
),
]

View File

@@ -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='文件链接'),
),
]

View File

@@ -35,6 +35,16 @@ class Competition(models.Model):
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="项目可见性")
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="是否启用")
created_at = models.DateTimeField(auto_now_add=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="所属比赛")
name = models.CharField(max_length=100, verbose_name="维度名称")
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="满分值")
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="排序权重")
class Meta:
@@ -102,6 +124,20 @@ class ScoreDimension(models.Model):
def __str__(self):
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):
"""
@@ -141,19 +177,34 @@ class Project(models.Model):
def calculate_score(self):
"""
计算项目得分
计算公式:
1. 获取所有评委对该项目的打分
2. 按维度加权平均
这里简化处理:
总分 = (所有评委的总加权分之和) / 评委人数
其中每个评委对项目的打分 = sum(维度分 * 维度权重)
支持两种模式:
1. 默认加权平均:每个评委的得分 = sum(维度分数 × 维度权重),然后所有评委取平均
2. 使用评分公式:使用比赛关联的评分公式计算最终得分
评分公式变量格式:
- dimension_X: 第X个维度的平均分所有评委对该维度的平均分
- 例如: (dimension_1 + dimension_2) / 2
"""
# 获取所有评分
scores = self.scores.all()
if not scores.exists():
self.final_score = 0
self.save()
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)
if not judges:
return 0
@@ -162,28 +213,71 @@ class Project(models.Model):
for judge in judges:
judge_score = 0
# 获取该评委对该项目的所有维度打分
judge_scores = scores.filter(judge=judge)
current_judge_total_score = 0
current_judge_total_weight = 0
for score in judge_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
# 如果是百分制这里算出来就是0-100
judge_score += float(score.score) * float(score.dimension.weight)
total_weighted_score += judge_score
# 平均分
avg_score = total_weighted_score / len(judges)
self.final_score = avg_score
self.final_score = round(avg_score, 2)
self.save()
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):
"""
@@ -202,7 +296,7 @@ class ProjectFile(models.Model):
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_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或外部链接")
name = models.CharField(max_length=100, verbose_name="文件名称", blank=True)
@@ -261,3 +355,71 @@ class Comment(models.Model):
def __str__(self):
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)

View File

@@ -1,6 +1,7 @@
from rest_framework import serializers
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment
from shop.serializers import WeChatUserSerializer
import uuid
class ScoreDimensionSerializer(serializers.ModelSerializer):
class Meta:
@@ -26,26 +27,83 @@ class CompetitionSerializer(serializers.ModelSerializer):
class CompetitionEnrollmentSerializer(serializers.ModelSerializer):
user = WeChatUserSerializer(read_only=True)
competition_title = serializers.SerializerMethodField()
class Meta:
model = CompetitionEnrollment
fields = ['id', 'competition', 'user', 'role', 'status', 'created_at']
fields = ['id', 'competition', 'competition_title', 'user', 'role', 'status', 'created_at']
read_only_fields = ['status']
def get_competition_title(self, obj):
return obj.competition.title if obj.competition else ''
class ProjectFileSerializer(serializers.ModelSerializer):
file_url_display = serializers.SerializerMethodField()
class Meta:
model = ProjectFile
fields = ['id', 'project', 'file_type', 'file', 'file_url', 'name', 'created_at']
fields = ['id', 'project', 'file_type', 'file', 'file_url', 'name', 'created_at', 'file_url_display']
def get_file_url_display(self, obj):
if obj.file:
return obj.file.url
return obj.file_url
def validate_file(self, value):
if not value:
return value
# 50MB limit
limit_mb = 50
if value.size > limit_mb * 1024 * 1024:
raise serializers.ValidationError(f"文件大小不能超过 {limit_mb}MB")
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
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):
files = ProjectFileSerializer(many=True, read_only=True)
contestant_info = serializers.SerializerMethodField()
@@ -80,8 +138,26 @@ class ScoreSerializer(serializers.ModelSerializer):
class CommentSerializer(serializers.ModelSerializer):
judge_name = serializers.CharField(source='judge.user.nickname', read_only=True)
score = serializers.SerializerMethodField()
class Meta:
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']
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

View 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 %}

View 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">
&copy; {% 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>

File diff suppressed because it is too large Load Diff

View 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 %}

View File

@@ -2,8 +2,9 @@ from django.urls import path, include
from rest_framework.routers import DefaultRouter
from .views import (
CompetitionViewSet, ProjectViewSet, ProjectFileViewSet,
ScoreViewSet, CommentViewSet
ScoreViewSet, CommentViewSet, CompetitionDimensionsAPIView
)
from . import judge_views
router = DefaultRouter()
router.register(r'competitions', CompetitionViewSet)
@@ -13,5 +14,13 @@ router.register(r'scores', ScoreViewSet, basename='score')
router.register(r'comments', CommentViewSet, basename='comment')
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)),
]

View File

@@ -1,6 +1,8 @@
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.views import APIView
from django.db.models import Q
from shop.utils import get_current_wechat_user
from .models import Competition, CompetitionEnrollment, Project, ProjectFile, Score, Comment, ScoreDimension
@@ -199,14 +201,24 @@ class ProjectFileViewSet(viewsets.ModelViewSet):
return ProjectFile.objects.all()
def perform_create(self, serializer):
# 简单权限控制:只有项目拥有者可以上传
project = serializer.validated_data['project']
user = get_current_wechat_user(self.request)
from shop.utils import get_current_wechat_user
try:
project = serializer.validated_data['project']
user = get_current_wechat_user(self.request)
if not user or project.contestant.user != user:
raise serializers.ValidationError("无权上传文件")
print(f"=== perform_create debug ===")
print(f"User: {user}")
print(f"Project: {project}")
serializer.save()
if not user or project.contestant.user != user:
raise serializers.ValidationError("无权上传文件")
serializer.save()
except Exception as e:
print(f"=== perform_create ERROR: {e} ===")
import traceback
traceback.print_exc()
raise
class ScoreViewSet(viewsets.ModelViewSet):
@@ -280,3 +292,31 @@ class CommentViewSet(viewsets.ModelViewSet):
raise serializers.ValidationError("您不是该比赛的评委")
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)

View File

@@ -3,9 +3,15 @@ from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
from competition import judge_views
urlpatterns = [
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/community/', include('community.urls')),
path('api/competition/', include('competition.urls')),
@@ -17,7 +23,7 @@ urlpatterns = [
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
]
# 静态文件配置(开发环境)1
# 静态文件配置(开发环境)
if settings.DEBUG:
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -7,7 +7,7 @@ django.setup()
from shop.models import ESP32Config
def populate():
# 检查数据库是否已有数据,如果有则跳过,避免每次构建都重置
# 检查数据库是否已有数据,如果有则跳过,避免每次构建都重置
if ESP32Config.objects.exists():
print("ESP32Config data already exists, skipping population.")
return

View File

@@ -385,7 +385,7 @@ class SalespersonAdmin(ModelAdmin):
class CommissionLogAdmin(ModelAdmin):
list_display = ('id', 'salesperson', 'distributor', 'amount', 'level', 'status', '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')
fieldsets = (
@@ -482,7 +482,7 @@ class ProductTypeFilter(admin.SimpleListFilter):
class OrderAdmin(ModelAdmin):
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')
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')
actions = [export_to_csv, export_to_excel]
@@ -569,7 +569,7 @@ class WeChatUserAdmin(OrderableAdminMixin, ModelAdmin):
@admin.register(Distributor)
class DistributorAdmin(ModelAdmin):
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')
readonly_fields = ('total_earnings', 'withdrawable_balance', 'qr_code_url', 'created_at', 'updated_at')
autocomplete_fields = ['user', 'parent']
@@ -598,7 +598,7 @@ class DistributorAdmin(ModelAdmin):
class WithdrawalAdmin(ModelAdmin):
list_display = ('get_distributor', 'amount', '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):
return obj.distributor.user.nickname

View File

@@ -41,7 +41,7 @@ class WeChatUser(models.Model):
self.order = self.pk
def __str__(self):
return self.nickname or self.openid
return self.phone_number or self.nickname or self.openid
class Meta:
verbose_name = "微信用户"

18
backend/start_judge_system.sh Executable file
View 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

View File

@@ -24,6 +24,14 @@ import './App.css';
const queryClient = new QueryClient();
function JudgeLoginRedirect() {
React.useEffect(() => {
window.location.replace('/judge/login/');
}, []);
return null;
}
function App() {
return (
<QueryClientProvider client={queryClient}>
@@ -46,6 +54,7 @@ function App() {
<Route path="/my-orders" element={<MyOrders />} />
<Route path="/product/:id" element={<ProductDetail />} />
<Route path="/payment/:orderId" element={<Payment />} />
<Route path="/judge-login" element={<JudgeLoginRedirect />} />
</Routes>
</Layout>
</BrowserRouter>

View File

@@ -2,7 +2,7 @@ import axios from 'axios';
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8000/api',
timeout: 8000, // 增加超时时间到 10秒
timeout: 120000, // 大文件上传需要更长超时时间 2分钟
headers: {
'Content-Type': 'application/json',
}

View File

@@ -315,7 +315,7 @@ const CompetitionDetail = () => {
<div style={{ color: '#888', fontSize: 12 }}>{project.contestant_info?.nickname}</div>
</div>
<div style={{ fontSize: isMobile ? 18 : 24, color: '#00b96b', fontWeight: 'bold' }}>
{enrollment && project.contestant === enrollment.id ? project.final_score : '**'}
{project.final_score || 0}
</div>
</div>
))}

View File

@@ -91,7 +91,7 @@ const ProjectDetail = () => {
</Descriptions.Item>
<Descriptions.Item label="最终得分">
<span style={{ color: '#00b96b', fontSize: 18, fontWeight: 'bold' }}>
{project.final_score ?? '待定'}
{project.final_score || 0}
</span>
</Descriptions.Item>
<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.Meta
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={
<div>
<div style={{ color: '#ccc', marginTop: 8 }}>{item.content}</div>

View File

@@ -1,19 +1,66 @@
import React, { useState } from 'react';
import { Card, Button, Form, Input, Upload, App, Modal, Select } from 'antd';
import { UploadOutlined, CloudUploadOutlined, LinkOutlined } from '@ant-design/icons';
import React, { useState, useEffect } from 'react';
import { Button, Form, Input, Upload, App, Modal, Progress, Space } from 'antd';
import { CloudUploadOutlined, LinkOutlined, FileTextOutlined, DownloadOutlined, FilePdfOutlined, FilePptOutlined, VideoCameraOutlined, PictureOutlined } from '@ant-design/icons';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createProject, updateProject, submitProject, uploadProjectFile } from '../../api';
import { createProject, updateProject, submitProject, uploadProjectFile, getProjects } from '../../api';
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 getFileUrl = (file) => {
return file.file_url_display || file.file_url || (file.file ? URL.createObjectURL(file.file) : null);
};
const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }) => {
const { message } = App.useApp();
const { message, modal } = App.useApp();
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 queryClient = useQueryClient();
// Reset form when initialValues changes (important for switching between create/edit)
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,
status: 'done'
}));
setUploadedFiles(files);
} else {
setUploadedFiles([]);
}
})
.catch(err => {
console.error('获取项目文件失败:', err);
});
}
}, [initialValues?.id, competitionId]);
React.useEffect(() => {
if (initialValues) {
form.setFieldsValue(initialValues);
@@ -22,75 +69,134 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
}
}, [initialValues, form]);
const createMutation = useMutation({
mutationFn: createProject,
onSuccess: () => {
message.success('项目创建成功');
queryClient.invalidateQueries(['projects']);
onSuccess();
},
onError: (error) => {
message.error(`创建失败: ${error.message}`);
}
});
const updateMutation = useMutation({
mutationFn: (data) => updateProject(initialValues.id, data),
onSuccess: () => {
message.success('项目更新成功');
queryClient.invalidateQueries(['projects']);
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 {
createMutation.mutate(data);
}
};
const handleUpload = ({ file, onSuccess, onError }) => {
console.log('handleUpload called', file.name);
if (!initialValues?.id) {
message.warning('请先保存项目基本信息再上传文件');
// Prevent default upload
onError(new Error('请先保存项目'));
return;
message.warning('请先保存项目基本信息再上传文件');
onError(new Error('请先保存项目'));
return;
}
const fileUid = file.uid || Date.now().toString();
console.log('fileUid:', fileUid);
setUploadingFiles(prev => ({
...prev,
[fileUid]: { percent: 0, status: 'uploading' }
}));
const formData = new FormData();
formData.append('file', file);
formData.append('project', initialValues?.id || ''); // Need project ID first usually
formData.append('project', initialValues.id);
console.log('Sending upload request for project:', initialValues.id);
uploadMutation.mutate(formData, {
onSuccess: (data) => {
onSuccess(data);
},
onError: (error) => {
onError(error);
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,
status: 'done'
};
setUploadedFiles(prev => [...prev, newFile]);
message.success('文件上传成功');
onSuccess(res.data);
})
.catch(err => {
console.error('Upload error:', err);
setUploadingFiles(prev => ({
...prev,
[fileUid]: { percent: 0, status: 'error' }
}));
message.error(`上传失败: ${err.response?.data?.detail || err.message}`);
onError(err);
});
};
const uploadPendingFiles = async (projectId) => {
const uploadedFilesList = [];
if (pendingCoverImage) {
try {
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);
const data = {
...values,
competition: competitionId,
};
if (initialValues?.id) {
await updateProject(initialValues.id, data);
if (pendingAttachments.length > 0) {
await uploadPendingFiles(initialValues.id);
}
message.success('项目更新成功');
queryClient.invalidateQueries(['projects']);
onSuccess();
} else {
try {
const res = await createProject(data);
const projectId = res.data.id;
if (pendingAttachments.length > 0 || pendingCoverImage) {
await uploadPendingFiles(projectId);
}
});
message.success('项目创建成功');
queryClient.invalidateQueries(['projects']);
onSuccess();
} catch (error) {
message.error(`创建失败: ${error.response?.data?.detail || error.message}`);
}
}
setIsCreatingProject(false);
};
return (
@@ -138,29 +244,175 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
<Form.Item
name="cover_image_url"
label="封面图片链接"
rules={[{ type: 'url', message: '请输入有效的URL' }]}
label="封面图片"
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);
if (initialValues?.id) {
const formData = new FormData();
formData.append('file', file);
uploadProjectFile(formData)
.then(res => {
const imageUrl = res.data.file_url_display || res.data.file_url;
form.setFieldsValue({ cover_image_url: imageUrl });
message.success('封面上传成功');
})
.catch(err => {
message.error(`上传失败: ${err.response?.data?.detail || err.message}`);
});
} else {
setPendingCoverImage(file);
const reader = new FileReader();
reader.onload = () => {
form.setFieldsValue({ cover_image_url: reader.result });
};
reader.readAsDataURL(file);
message.info('封面已选择,提交时将自动上传');
}
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>
{/* File Upload Section - Only visible if project exists */}
{initialValues?.id && (
<Form.Item label="项目附件 (PPT/PDF/视频)">
<Upload
customRequest={handleUpload}
listType="picture"
maxCount={5}
>
<Button icon={<CloudUploadOutlined />}>上传文件 (最大50MB)</Button>
</Upload>
</Form.Item>
{(
<Form.Item label="项目附件 (PPT/PDF/视频/图片)">
{!initialValues?.id && pendingAttachments.length > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ color: '#999', fontStyle: 'italic', marginBottom: 8 }}>待上传文件</div>
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
{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>
</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>
<span style={{ flex: 1, fontWeight: 500 }}>{file.name}</span>
{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);
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;
}
if (initialValues?.id) {
console.log('beforeUpload passed, manually calling handleUpload');
handleUpload({ file, onSuccess: () => {}, onError: () => {} });
} else {
if (pendingAttachments.length >= 5) {
message.warning('最多只能上传5个文件');
return Upload.LIST_IGNORE;
}
setPendingAttachments(prev => [...prev, file]);
message.info('文件已添加到待上传列表,提交时将自动上传');
}
return Upload.LIST_IGNORE;
}}
>
<Button icon={<CloudUploadOutlined />} onClick={() => console.log('Upload button clicked')}>继续上传文件 (最大50MB)</Button>
</Upload>
</Form.Item>
)}
<Form.Item>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 10 }}>
<Button onClick={onCancel}>取消</Button>
<Button type="primary" htmlType="submit" loading={createMutation.isLoading || updateMutation.isLoading}>
<Button type="primary" htmlType="submit" loading={isCreatingProject}>
{initialValues?.id ? '保存修改' : '保存草稿'}
</Button>
{initialValues?.id && (
@@ -168,7 +420,7 @@ const ProjectSubmission = ({ competitionId, initialValues, onCancel, onSuccess }
type="primary"
danger
onClick={() => {
Modal.confirm({
modal.confirm({
title: '确认提交?',
content: '提交后将无法修改,确认提交吗?',
onOk: () => submitProject(initialValues.id).then(() => {

View File

@@ -8,6 +8,28 @@ export default defineConfig({
server: {
host: '0.0.0.0',
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: {
host: '0.0.0.0',
@@ -29,6 +51,10 @@ export default defineConfig({
'/media': {
target: 'http://backend:8000',
changeOrigin: true,
},
'/judge': {
target: 'http://backend:8000',
changeOrigin: true,
}
}
}

View File

@@ -95,9 +95,39 @@ export const uploadProjectFile = (filePath: string, projectId: number, fileName?
}
}).then(res => {
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
}
return data
}
throw new Error('Upload failed')
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 => {
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
}
return data
}
throw new Error('Upload failed')
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
})
}

View File

@@ -55,7 +55,7 @@
.language {
color: #9cdcfe;
font-size: 12px;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
}
@@ -75,7 +75,7 @@
.copy-text {
color: #ccc;
font-size: 12px;
font-size: 14px;
margin-left: 4px;
}
}
@@ -90,7 +90,7 @@
.code-text {
color: #d4d4d4;
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
font-size: 16px;
line-height: 1.5;
white-space: pre;
display: block;

View File

@@ -22,7 +22,7 @@ const MarkdownReader: React.FC<Props> = ({ content, themeColor = '#00b96b' }) =>
renderer.table = (header, body) => {
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>
<tbody>${body}</tbody>
</table>

View File

@@ -10,9 +10,9 @@
}
.content {
padding: 24px;
padding: 30px;
background: #111;
border-radius: 16px 16px 0 0;
border-radius: 20px 20px 0 0;
margin-top: -24px;
position: relative;
z-index: 10;
@@ -21,22 +21,22 @@
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 24px;
margin-bottom: 30px;
.title {
font-size: 24px;
font-size: 32px;
font-weight: bold;
color: #fff;
line-height: 1.4;
}
.status {
font-size: 14px;
padding: 4px 8px;
border-radius: 4px;
font-size: 16px;
padding: 6px 10px;
border-radius: 6px;
background: #333;
color: #ccc;
margin-left: 12px;
margin-left: 16px;
white-space: nowrap;
&.registration { background: #07c160; color: #fff; }
@@ -48,15 +48,15 @@
.tabs {
display: flex;
margin-bottom: 24px;
margin-bottom: 30px;
border-bottom: 1px solid #333;
.tab-item {
flex: 1;
text-align: center;
padding: 12px 0;
padding: 16px 0;
color: #999;
font-size: 16px;
font-size: 18px;
position: relative;
&.active {
@@ -69,8 +69,8 @@
bottom: -1px;
left: 50%;
transform: translateX(-50%);
width: 24px;
height: 3px;
width: 30px;
height: 4px;
background: #00b96b;
border-radius: 2px;
}
@@ -81,28 +81,28 @@
.project-list {
.project-card {
background: #1f1f1f;
border-radius: 12px;
border-radius: 16px;
overflow: hidden;
margin-bottom: 16px;
margin-bottom: 20px;
display: flex;
.cover {
width: 120px;
height: 90px;
width: 140px;
height: 105px;
background: #333;
flex-shrink: 0;
}
.info {
flex: 1;
padding: 12px;
padding: 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
overflow: hidden;
.title {
font-size: 16px;
font-size: 20px;
color: #fff;
font-weight: 500;
display: -webkit-box;
@@ -119,14 +119,14 @@
.user {
display: flex;
align-items: center;
font-size: 12px;
font-size: 14px;
color: #999;
.avatar {
width: 20px;
height: 20px;
width: 24px;
height: 24px;
border-radius: 50%;
margin-right: 6px;
margin-right: 8px;
background: #333;
}
}
@@ -134,7 +134,7 @@
.score {
color: #faad14;
font-weight: bold;
font-size: 14px;
font-size: 16px;
}
}
}
@@ -142,7 +142,8 @@
.empty {
text-align: center;
color: #666;
padding: 40px 0;
padding: 50px 0;
font-size: 16px;
}
}
@@ -150,13 +151,13 @@
.rank-item {
display: flex;
align-items: center;
padding: 16px 0;
padding: 20px 0;
border-bottom: 1px solid #222;
.rank-num {
width: 40px;
width: 50px;
text-align: center;
font-size: 18px;
font-size: 22px;
font-weight: bold;
color: #666;
@@ -172,10 +173,10 @@
overflow: hidden;
.avatar {
width: 40px;
height: 40px;
width: 48px;
height: 48px;
border-radius: 50%;
margin-right: 12px;
margin-right: 16px;
background: #333;
flex-shrink: 0;
}
@@ -186,8 +187,8 @@
.nickname {
color: #fff;
font-size: 16px;
margin-bottom: 4px;
font-size: 18px;
margin-bottom: 6px;
display: block;
white-space: nowrap;
overflow: hidden;
@@ -196,7 +197,7 @@
.project-title {
color: #666;
font-size: 12px;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -205,34 +206,35 @@
}
.score {
font-size: 18px;
font-size: 22px;
font-weight: bold;
color: #00b96b;
margin-left: 12px;
margin-left: 16px;
}
}
.empty {
text-align: center;
color: #666;
padding: 40px 0;
padding: 50px 0;
font-size: 16px;
}
}
.section {
margin-bottom: 32px;
margin-bottom: 40px;
.section-title {
font-size: 18px;
font-size: 24px;
font-weight: bold;
color: #fff;
margin-bottom: 12px;
margin-bottom: 20px;
display: block;
border-left: 4px solid #00b96b;
padding-left: 12px;
border-left: 5px solid #00b96b;
padding-left: 16px;
}
/* Markdown styling borrowed from Forum */
font-size: 16px;
font-size: 18px;
line-height: 1.8;
color: #e0e0e0;
letter-spacing: 0.3px;
@@ -240,38 +242,38 @@
image {
max-width: 100%;
border-radius: 12px;
margin: 16px 0;
margin: 20px 0;
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 { font-size: 24px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 12px; }
h2 { font-size: 20px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 8px; }
h3 { font-size: 18px; }
h4 { font-size: 17px; }
h5 { font-size: 16px; color: #ddd; }
h1, h2, h3, h4, h5, h6 { margin-top: 30px; margin-bottom: 20px; color: #fff; font-weight: 700; line-height: 1.4; }
h1 { font-size: 32px; border-bottom: 1px solid rgba(255,255,255,0.1); padding-bottom: 16px; }
h2 { font-size: 28px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 12px; }
h3 { font-size: 24px; }
h4 { font-size: 20px; }
h5 { font-size: 18px; color: #ddd; }
p { margin-bottom: 16px; }
p { margin-bottom: 20px; }
strong { font-weight: 800; color: #fff; }
em { font-style: italic; color: #aaa; }
del { text-decoration: line-through; color: #666; }
ul, ol { margin-bottom: 16px; padding-left: 20px; }
li { margin-bottom: 6px; list-style-position: outside; }
ul, ol { margin-bottom: 20px; padding-left: 24px; }
li { margin-bottom: 8px; list-style-position: outside; }
ul li { list-style-type: disc; }
ol li { list-style-type: decimal; }
li input[type="checkbox"] { margin-right: 8px; }
li input[type="checkbox"] { margin-right: 12px; }
blockquote {
border-left: 4px solid #00b96b;
border-left: 5px solid #00b96b;
background: rgba(255, 255, 255, 0.05);
padding: 12px 16px;
margin: 16px 0;
border-radius: 4px;
padding: 16px 20px;
margin: 20px 0;
border-radius: 6px;
color: #bbb;
font-size: 15px;
font-size: 16px;
font-style: italic;
p { margin-bottom: 0; }
@@ -283,20 +285,20 @@
height: 1px;
background: rgba(255,255,255,0.1);
border: none;
margin: 24px 0;
margin: 30px 0;
}
table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
font-size: 14px;
margin: 20px 0;
font-size: 16px;
overflow-x: auto;
display: block;
th, td {
border: 1px solid rgba(255,255,255,0.1);
padding: 10px;
padding: 12px;
text-align: left;
}
@@ -313,20 +315,20 @@
code {
background: rgba(255,255,255,0.1);
padding: 2px 6px;
border-radius: 4px;
padding: 4px 8px;
border-radius: 6px;
font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace;
color: #ff7875;
font-size: 14px;
margin: 0 4px;
font-size: 16px;
margin: 0 6px;
}
pre {
background: #161616;
padding: 16px;
border-radius: 12px;
padding: 20px;
border-radius: 16px;
overflow-x: auto;
margin: 16px 0;
margin: 20px 0;
border: 1px solid #333;
box-shadow: inset 0 0 20px rgba(0,0,0,0.5);
@@ -334,7 +336,7 @@
background: transparent;
color: #a6e22e;
padding: 0;
font-size: 13px;
font-size: 14px;
margin: 0;
white-space: pre;
}
@@ -348,16 +350,16 @@
left: 0;
right: 0;
background: #1f1f1f;
padding: 16px 24px;
padding: 20px 30px;
border-top: 1px solid #333;
z-index: 100;
.btn {
width: 100%;
height: 48px;
line-height: 48px;
border-radius: 24px;
font-size: 18px;
height: 56px;
line-height: 56px;
border-radius: 28px;
font-size: 20px;
font-weight: bold;
color: #fff;
background: #00b96b;

View File

@@ -1,5 +1,5 @@
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 { getCompetitionDetail, enrollCompetition, getMyCompetitionEnrollment, getProjects } from '../../api'
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) => {
setLoading(true)
try {

View File

@@ -1,5 +1,5 @@
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 { getCompetitions } from '../../api'
import './index.scss'
@@ -13,6 +13,26 @@ export default function CompetitionList() {
fetchCompetitions()
})
/**
* 配置并监听分享给朋友的功能
*/
useShareAppMessage(() => {
return {
title: '赛事中心',
path: '/pages/competition/index'
}
})
/**
* 配置并监听分享到朋友圈的功能
*/
useShareTimeline(() => {
return {
title: '赛事中心',
query: ''
}
})
const fetchCompetitions = async () => {
setLoading(true)
setDebugMsg('开始加载...')

View File

@@ -1,98 +1,201 @@
.project-detail {
background-color: #000;
min-height: 100vh;
padding-bottom: 40px;
padding-bottom: 60px;
box-sizing: border-box;
.cover {
width: 100%;
height: 240px;
height: 260px;
display: block;
}
.content {
padding: 24px;
padding: 30px;
background: #111;
border-radius: 16px 16px 0 0;
margin-top: -24px;
border-radius: 24px 24px 0 0;
margin-top: -30px;
position: relative;
z-index: 10;
min-height: 60vh;
.header {
margin-bottom: 32px;
margin-bottom: 40px;
.title {
font-size: 24px;
font-size: 36px;
font-weight: bold;
color: #fff;
margin-bottom: 16px;
margin-bottom: 24px;
line-height: 1.4;
display: block;
}
.author {
display: flex;
align-items: center;
background: rgba(255, 255, 255, 0.05);
padding: 8px 12px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.08);
padding: 12px 20px;
border-radius: 30px;
display: inline-flex;
.avatar {
width: 24px;
height: 24px;
width: 36px;
height: 36px;
border-radius: 50%;
margin-right: 8px;
margin-right: 12px;
background: #333;
}
.name {
font-size: 14px;
color: #ccc;
font-size: 18px;
color: #ddd;
}
}
}
.section {
margin-bottom: 32px;
margin-bottom: 50px;
.section-title {
font-size: 18px;
font-size: 28px;
font-weight: bold;
color: #fff;
margin-bottom: 16px;
margin-bottom: 24px;
display: block;
border-left: 4px solid #00b96b;
padding-left: 12px;
border-left: 6px solid #00b96b;
padding-left: 18px;
}
.text-content {
font-size: 15px;
font-size: 20px;
color: #ccc;
line-height: 1.8;
background: #1f1f1f;
padding: 16px;
border-radius: 12px;
padding: 24px;
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 {
font-size: 14px;
font-size: 18px;
color: #666;
text-align: center;
display: block;
padding: 20px 0;
padding: 40px 0;
background: #1f1f1f;
border-radius: 12px;
border-radius: 16px;
}
.file-list {
background: #1f1f1f;
border-radius: 12px;
border-radius: 20px;
overflow: hidden;
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
padding: 24px;
border-bottom: 1px solid #333;
&:last-child {
@@ -100,20 +203,20 @@
}
.file-name {
font-size: 14px;
font-size: 18px;
color: #ddd;
flex: 1;
margin-right: 16px;
margin-right: 20px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-action {
font-size: 12px;
font-size: 16px;
color: #00b96b;
padding: 4px 12px;
padding: 8px 20px;
border: 1px solid #00b96b;
border-radius: 14px;
border-radius: 20px;
}
}
}
@@ -121,9 +224,9 @@
.comment-list {
.comment-item {
background: #1f1f1f;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
border-radius: 20px;
padding: 24px;
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
@@ -132,20 +235,47 @@
.comment-header {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
align-items: center;
margin-bottom: 12px;
.judge-name {
font-size: 14px;
font-weight: bold;
color: #00b96b;
.judge-info {
display: flex;
align-items: baseline;
.judge-name {
font-size: 16px;
font-weight: bold;
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 {
font-size: 12px;
color: #666;
}
}
.comment-content {
font-size: 14px;
font-size: 20px;
color: #ccc;
line-height: 1.6;
display: block;

View File

@@ -1,5 +1,5 @@
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 { getProjectDetail, getComments } from '../../api'
import MarkdownReader from '../../components/MarkdownReader'
@@ -9,6 +9,7 @@ export default function ProjectDetail() {
const [project, setProject] = useState<any>(null)
const [comments, setComments] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const router = useRouter()
useLoad((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
@@ -141,7 +166,15 @@ export default function ProjectDetail() {
{comments.map((c) => (
<View key={c.id} className='comment-item'>
<View className='comment-header'>
<Text className='judge-name'>{c.judge_name || '评委'}</Text>
<View className='judge-info'>
<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>
</View>
<Text className='comment-content'>{c.content}</Text>

View File

@@ -1,7 +1,7 @@
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 { getProjectDetail, createProject, updateProject, uploadProjectFile, submitProject, uploadMedia, getCompetitions } from '../../api'
import { getProjectDetail, createProject, updateProject, uploadProjectFile, submitProject, uploadMedia, getCompetitions, deleteProjectFile, getMyEnrollments } from '../../api'
import './project.scss'
export default function ProjectEdit() {
@@ -15,6 +15,7 @@ export default function ProjectEdit() {
const [competitions, setCompetitions] = useState<any[]>([])
const [loading, setLoading] = useState(false)
const [isEdit, setIsEdit] = useState(false)
const router = useRouter()
useLoad((options) => {
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 () => {
try {
const res = await getCompetitions()
if (res && res.results) {
setCompetitions(res.results)
const res = await getMyEnrollments()
if (res && res.length > 0) {
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) {
console.error('获取比赛列表失败', e)
setCompetitions([])
}
}
@@ -63,12 +99,13 @@ export default function ProjectEdit() {
Taro.showLoading({ title: '上传中...' })
const res = await uploadMedia(tempFilePaths[0], 'image')
handleInput('cover_image_url', res.file) // 假设返回 { file: 'url...' }
handleInput('cover_image_url', res.file)
Taro.hideLoading()
} catch (e) {
} catch (e: any) {
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.showToast({ title: '上传成功', icon: 'success' })
} catch (e) {
} catch (e: any) {
Taro.hideLoading()
console.error(e)
Taro.showToast({ title: '上传失败', icon: 'none' })
const errorMsg = e.response?.error || e.message || '上传失败'
Taro.showToast({ title: errorMsg, icon: 'none' })
}
}
const handleDeleteFile = (fileId) => {
// API call to delete file not implemented yet? Or just remove from list?
// Usually we should call delete API. For now just remove from UI.
// Ideally we should have deleteProjectFile API.
// But user only asked to "optimize upload".
setProject(prev => ({
...prev,
files: prev.files.filter(f => f.id !== fileId)
}))
const handleDeleteFile = async (fileId) => {
try {
await Taro.showModal({
title: '确认删除',
content: '确定要删除这个文件吗?',
confirmColor: '#ff4d4f'
}).then(res => {
if (!res.confirm) throw new Error('cancel')
})
Taro.showLoading({ title: '删除中...' })
await deleteProjectFile(fileId)
setProject(prev => ({
...prev,
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) => {
@@ -234,12 +320,17 @@ export default function ProjectEdit() {
</View>
<View className='file-list'>
{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' }}>
<Text className='file-name' style={{ flex: 1, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{file.name || '未知文件'}</Text>
{/* <Text className='delete' style={{ color: 'red', marginLeft: '10px' }} onClick={() => handleDeleteFile(file.id)}>删除</Text> */}
<View
key={index}
className='file-item'
style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', background: '#f8f8f8', marginBottom: '8px', borderRadius: '4px' }}
onClick={() => handlePreviewFile(file)}
>
<Text className='file-name' style={{ flex: 1, fontSize: '14px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#333' }}>{file.name || '未知文件'}</Text>
<Text className='delete' style={{ color: '#ff4d4f', marginLeft: '10px', fontSize: '14px' }} onClick={(e) => { e.stopPropagation(); handleDeleteFile(file.id) }}></Text>
</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 File

@@ -115,11 +115,19 @@ const ActivityDetail = () => {
}
}
/**
* 处理表单字段内容改变的事件
* 必须返回最新的 value以修复 Taro UI 中 AtInput 光标会跑到最前面的 Bug
* @param {string} fieldName - 表单字段名
* @param {any} value - 表单输入的最新的值
* @returns {any} 返回最新的值
*/
const handleFormChange = (fieldName, value) => {
setFormData(prev => ({
...prev,
[fieldName]: value
}))
return value // 修复 Taro UI AtInput 光标跳动问题:必须返回 value
}
const handleModalConfirm = () => {
@@ -267,7 +275,8 @@ const ActivityDetail = () => {
<AtModalHeader></AtModalHeader>
<AtModalContent>
<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
if (!field || typeof field !== 'object' || field.label === '自定义报名配置') return null
@@ -357,7 +366,7 @@ const ActivityDetail = () => {
title={field.label}
type={field.type === 'tel' ? 'phone' : (field.type === 'number' ? 'number' : 'text')}
placeholder={field.placeholder || `请输入${field.label}`}
value={formData[field.name]}
value={formData[field.name] || ''}
onChange={(val) => handleFormChange(field.name, val)}
required={field.required}
/>

View File

@@ -79,12 +79,15 @@ const CreateTopic = () => {
setContent(prev => prev + insertText)
Taro.hideLoading()
} catch (error) {
} catch (error: any) {
console.error(error)
Taro.hideLoading()
// Only toast if it's an error, not cancel
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' })
}
}
}

View File

@@ -170,10 +170,11 @@ const ForumDetail = () => {
setReplyContent(prev => prev + insertText)
Taro.hideLoading()
} catch (error) {
} catch (error: any) {
console.error(error)
Taro.hideLoading()
Taro.showToast({ title: '上传失败', icon: 'none' })
const errorMsg = error.response?.error || error.message || '上传失败'
Taro.showToast({ title: errorMsg, icon: 'none' })
}
}