Compare commits
28 Commits
6129673ddc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98baa92e98 | ||
|
|
06afd11f1c | ||
|
|
4de4ff91f3 | ||
|
|
b39e500307 | ||
|
|
07006d46d9 | ||
|
|
76bb5945ac | ||
|
|
d76b5845a1 | ||
|
|
76f7b2bcbe | ||
|
|
c62c5b98ea | ||
|
|
0d7ba5d87c | ||
|
|
98db4d6f75 | ||
|
|
02335d26c2 | ||
|
|
da235c3a82 | ||
|
|
f25c35af40 | ||
|
|
465ea34dcd | ||
|
|
bd102cc71f | ||
|
|
6a166c50eb | ||
|
|
75dbf22a43 | ||
|
|
7695ac3edf | ||
|
|
3d94a1f0de | ||
|
|
f72293eb76 | ||
|
|
35d96588f9 | ||
|
|
afab4933b4 | ||
|
|
4d6f98080e | ||
|
|
de1e409447 | ||
|
|
6aaddfbe9e | ||
|
|
f23e477f57 | ||
|
|
1f693e0e8a |
93
README.md
93
README.md
@@ -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
54
backend/DEPLOY.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 评委端系统部署说明
|
||||
|
||||
## 1. 系统概述
|
||||
本系统为基于 Django 的后端渲染 HTML 评委端,提供评委登录、项目查看、打分点评、音频上传与 AI 服务管理功能。
|
||||
|
||||
## 2. 依赖环境
|
||||
- Python 3.8+
|
||||
- Django 3.2+
|
||||
- Aliyun SDK (aliyun-python-sdk-core, aliyun-python-sdk-tingwu, oss2)
|
||||
- requests
|
||||
|
||||
确保 `requirements.txt` 中包含以上依赖。
|
||||
|
||||
## 3. 环境变量
|
||||
系统依赖以下环境变量(在 `backend/config/settings.py` 或 `.env` 文件中配置):
|
||||
|
||||
```bash
|
||||
# 数据库配置
|
||||
DB_NAME=your_db_name
|
||||
DB_USER=your_db_user
|
||||
DB_PASSWORD=your_db_password
|
||||
DB_HOST=your_db_host
|
||||
DB_PORT=5432
|
||||
|
||||
# 阿里云配置 (用于音频上传与 AI 服务)
|
||||
ALIYUN_ACCESS_KEY_ID=your_access_key_id
|
||||
ALIYUN_ACCESS_KEY_SECRET=your_access_key_secret
|
||||
ALIYUN_OSS_BUCKET_NAME=your_bucket_name
|
||||
ALIYUN_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
|
||||
ALIYUN_TINGWU_APP_KEY=your_tingwu_app_key
|
||||
```
|
||||
|
||||
## 4. 启动脚本
|
||||
使用提供的 `start_judge_system.sh` 启动服务。
|
||||
|
||||
```bash
|
||||
chmod +x start_judge_system.sh
|
||||
./start_judge_system.sh
|
||||
```
|
||||
|
||||
该脚本将执行数据库迁移并启动 Django 开发服务器。生产环境建议使用 Gunicorn + Nginx。
|
||||
|
||||
## 5. 访问地址
|
||||
- 评委端入口: `http://localhost:8000/competition/admin/` (自动跳转至登录或仪表盘)
|
||||
- 评委端主页: `http://localhost:8000/judge/dashboard/`
|
||||
- AI 管理页: `http://localhost:8000/judge/ai/manage/`
|
||||
|
||||
## 6. 审计日志
|
||||
所有关键操作(登录、打分、上传、删除)均记录在项目根目录下的 `judge_audit.log` 文件中。格式如下:
|
||||
`[YYYY-MM-DD HH:MM:SS] IP:127.0.0.1 | Phone:13800000000 | Action:LOGIN | Target:System | Result:SUCCESS | Details:...`
|
||||
|
||||
## 7. 注意事项
|
||||
- 登录需使用已在后台绑定且角色为“评委”的手机号。
|
||||
- 验证码在开发模式下通过控制台输出,或使用默认测试码 `8888`。
|
||||
44
backend/TEST_REPORT.md
Normal file
44
backend/TEST_REPORT.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 评委端系统测试报告
|
||||
|
||||
## 1. 测试环境
|
||||
- 系统版本: MacOS 14.5
|
||||
- Python: 3.9
|
||||
- Django: 3.2.20
|
||||
- 数据库: PostgreSQL / SQLite (Development)
|
||||
|
||||
## 2. 功能测试
|
||||
|
||||
### 2.1 评委登录
|
||||
- **场景**: 输入已绑定评委角色的手机号。
|
||||
- **操作**: 点击“发送验证码”,输入控制台显示的验证码或默认测试码 `8888`。
|
||||
- **结果**: 成功登录,跳转至 `/judge/dashboard/`。
|
||||
- **异常场景**: 输入未绑定手机号、输入错误验证码,均提示相应错误信息。
|
||||
|
||||
### 2.2 项目列表 (仪表盘)
|
||||
- **场景**: 登录后查看所负责比赛的项目。
|
||||
- **结果**: 列表展示正确,包含封面、选手名、当前状态。点击“详情 & 评分”弹出模态框。
|
||||
|
||||
### 2.3 评分与点评
|
||||
- **场景**: 在详情模态框中调整评分滑块,输入评语,点击提交。
|
||||
- **结果**: 页面提示“已保存”,刷新后数据持久化。
|
||||
- **审计日志**: `judge_audit.log` 记录 `SCORE_UPDATE` 操作。
|
||||
|
||||
### 2.4 音频上传
|
||||
- **场景**: 点击“批量上传音频”,选择 MP3/MP4 文件,关联项目。
|
||||
- **结果**: 进度条显示上传进度,完成后自动跳转至 AI 管理页面。
|
||||
- **审计日志**: `judge_audit.log` 记录 `UPLOAD_AUDIO` 操作。
|
||||
|
||||
### 2.5 AI 服务管理
|
||||
- **场景**: 在 AI 管理页面查看任务状态。
|
||||
- **操作**: 点击“刷新状态”,如果任务完成,状态变更为“成功”,并可查看结果。
|
||||
- **结果**: 成功展示 AI 生成的逐字稿、总结和评分。
|
||||
- **删除操作**: 点击“删除”,确认后记录消失,审计日志记录 `DELETE_TASK`。
|
||||
|
||||
## 3. 性能与兼容性
|
||||
- **响应式**: 在 iPhone/iPad 模拟器下布局自适应,操作流畅。
|
||||
- **并发**: 批量上传 5 个文件,均能正常创建任务并返回。
|
||||
|
||||
## 4. 安全性
|
||||
- **权限控制**: 尝试访问非本人负责项目的详情 API,返回 403 Forbidden。
|
||||
- **Session**: 登出后 Session 清除,无法通过 URL 直接访问受保护页面。
|
||||
- **CSRF**: 所有 POST 请求均携带 CSRF Token。
|
||||
@@ -22,8 +22,8 @@ class TranscriptionTaskAdmin(UnfoldModelAdmin):
|
||||
|
||||
@admin.register(AIEvaluationTemplate)
|
||||
class AIEvaluationTemplateAdmin(UnfoldModelAdmin):
|
||||
list_display = ['name', 'model_selection', 'score_dimension', '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)
|
||||
|
||||
@@ -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='是否为默认模板'),
|
||||
),
|
||||
]
|
||||
@@ -73,6 +73,11 @@ class AIEvaluationTemplate(models.Model):
|
||||
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)
|
||||
|
||||
@@ -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}")
|
||||
@@ -330,17 +331,23 @@ class AliyunTingwuService:
|
||||
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').get(id=task_id)
|
||||
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').get(id=task_id.id)
|
||||
task = TranscriptionTask.objects.select_related('project', 'project__competition').get(id=task_id.id)
|
||||
except:
|
||||
task = task_id
|
||||
else:
|
||||
@@ -361,6 +368,41 @@ class AliyunTingwuService:
|
||||
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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
20
backend/community/migrations/0003_alter_activity_author.py
Normal file
20
backend/community/migrations/0003_alter_activity_author.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-17 11:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('community', '0002_activity_author'),
|
||||
('shop', '0039_vccourse_video_embed_code'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='author',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activities', to='shop.wechatuser', to_field='phone_number', verbose_name='发布者'),
|
||||
),
|
||||
]
|
||||
@@ -18,7 +18,7 @@ class Activity(models.Model):
|
||||
is_paid = models.BooleanField(default=False, verbose_name="是否收费")
|
||||
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="关闭后将不在前端列表页显示")
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
from django.contrib import admin
|
||||
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
|
||||
|
||||
|
||||
@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 +56,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 +74,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 +105,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 +128,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)
|
||||
|
||||
21
backend/competition/judge_urls.py
Normal file
21
backend/competition/judge_urls.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from django.urls import path
|
||||
from django.views.generic import RedirectView
|
||||
from . import judge_views
|
||||
|
||||
urlpatterns = [
|
||||
# 默认跳转到登录页
|
||||
path('', RedirectView.as_view(url='login/', permanent=False), name='judge_index'),
|
||||
path('login/', judge_views.login_view, name='judge_login'),
|
||||
path('logout/', judge_views.logout_view, name='judge_logout'),
|
||||
path('send_code/', judge_views.send_code, name='judge_send_code'),
|
||||
path('dashboard/', judge_views.dashboard, name='judge_dashboard'),
|
||||
path('upload/', judge_views.upload_audio, name='judge_upload'),
|
||||
path('ai/manage/', judge_views.ai_manage, name='judge_ai_manage'),
|
||||
|
||||
# API
|
||||
path('api/projects/<int:project_id>/', judge_views.project_detail_api, name='judge_project_detail_api'),
|
||||
path('api/score/submit/', judge_views.submit_score, name='judge_submit_score'),
|
||||
path('api/upload/', judge_views.upload_audio, name='judge_api_upload'),
|
||||
path('api/upload/url/', judge_views.upload_audio_url, name='judge_api_upload_url'),
|
||||
path('api/ai/<str:task_id>/delete/', judge_views.delete_ai_task, name='judge_delete_ai_task'),
|
||||
]
|
||||
766
backend/competition/judge_views.py
Normal file
766
backend/competition/judge_views.py
Normal file
@@ -0,0 +1,766 @@
|
||||
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)
|
||||
|
||||
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,
|
||||
'can_grade': role == 'judge' or (role == 'contestant' and project.contestant.user != user),
|
||||
# 评分细项(仅评委和嘉宾可见)
|
||||
'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'] else None
|
||||
}
|
||||
|
||||
# Specifically for guest: can_grade is False
|
||||
if role == 'guest':
|
||||
data['can_grade'] = False
|
||||
|
||||
return JsonResponse(data)
|
||||
|
||||
@judge_required
|
||||
@csrf_exempt
|
||||
def submit_score(request):
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'message': 'Method not allowed'})
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
project_id = data.get('project_id')
|
||||
comment_content = data.get('comment')
|
||||
|
||||
judge_id = request.session['judge_id']
|
||||
role = request.session.get('judge_role', 'judge')
|
||||
|
||||
if role == 'guest':
|
||||
return JsonResponse({'success': False, 'message': '嘉宾无权评分'})
|
||||
|
||||
user = WeChatUser.objects.get(id=judge_id)
|
||||
project = get_object_or_404(Project, id=project_id)
|
||||
|
||||
enrollment = None
|
||||
if role == 'judge':
|
||||
enrollment = CompetitionEnrollment.objects.filter(user=user, role='judge', competition=project.competition).first()
|
||||
else:
|
||||
enrollment = CompetitionEnrollment.objects.filter(
|
||||
user=user,
|
||||
role='contestant',
|
||||
competition=project.competition,
|
||||
competition__allow_contestant_grading=True
|
||||
).first()
|
||||
|
||||
if not enrollment:
|
||||
return JsonResponse({'success': False, 'message': 'No permission'})
|
||||
|
||||
# Save Scores - 根据角色过滤维度
|
||||
if role == 'contestant':
|
||||
dimensions = ScoreDimension.objects.filter(
|
||||
competition=project.competition,
|
||||
is_public=True,
|
||||
is_peer_review=True
|
||||
)
|
||||
else:
|
||||
dimensions = ScoreDimension.objects.filter(
|
||||
competition=project.competition,
|
||||
is_public=True,
|
||||
is_peer_review=False
|
||||
)
|
||||
for d in dimensions:
|
||||
score_key = f'score_{d.id}'
|
||||
if score_key in data:
|
||||
val = data[score_key]
|
||||
Score.objects.update_or_create(
|
||||
project=project,
|
||||
judge=enrollment,
|
||||
dimension=d,
|
||||
defaults={'score': val}
|
||||
)
|
||||
|
||||
# Save Comment
|
||||
if comment_content:
|
||||
Comment.objects.update_or_create(
|
||||
project=project,
|
||||
judge=enrollment,
|
||||
defaults={'content': comment_content}
|
||||
)
|
||||
|
||||
# Recalculate Project Score
|
||||
project.calculate_score()
|
||||
|
||||
log_audit(request, 'SCORE_UPDATE', f"Project {project.id}", 'SUCCESS')
|
||||
|
||||
return JsonResponse({'success': True})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Submit score error: {e}")
|
||||
return JsonResponse({'success': False, 'message': str(e)})
|
||||
|
||||
@judge_required
|
||||
@csrf_exempt
|
||||
def upload_audio(request):
|
||||
# Contestants cannot upload, but Guests can
|
||||
role = request.session.get('judge_role')
|
||||
if role not in ['judge', 'guest']:
|
||||
return JsonResponse({'success': False, 'message': 'Permission denied'})
|
||||
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'message': 'Method not allowed'})
|
||||
|
||||
judge_id = request.session['judge_id']
|
||||
file_obj = request.FILES.get('file')
|
||||
project_id = request.POST.get('project_id')
|
||||
|
||||
if not file_obj or not project_id:
|
||||
return JsonResponse({'success': False, 'message': 'Missing file or project_id'})
|
||||
|
||||
try:
|
||||
# Check permission
|
||||
user = WeChatUser.objects.get(id=judge_id)
|
||||
project = Project.objects.get(id=project_id)
|
||||
|
||||
# Verify judge/guest has access to this project's competition
|
||||
enrollment = CompetitionEnrollment.objects.filter(
|
||||
user=user,
|
||||
role=role,
|
||||
competition=project.competition
|
||||
).first()
|
||||
|
||||
if not enrollment:
|
||||
return JsonResponse({'success': False, 'message': 'No permission for this project'})
|
||||
|
||||
# Upload to OSS & Create Task
|
||||
service = AliyunTingwuService()
|
||||
if not service.bucket:
|
||||
return JsonResponse({'success': False, 'message': 'OSS not configured'})
|
||||
|
||||
file_extension = file_obj.name.split('.')[-1]
|
||||
file_name = f"transcription/{uuid.uuid4()}.{file_extension}"
|
||||
oss_url = service.upload_to_oss(file_obj, file_name)
|
||||
|
||||
# Create Task Record
|
||||
task = TranscriptionTask.objects.create(
|
||||
project=project,
|
||||
file_url=oss_url,
|
||||
status=TranscriptionTask.Status.PENDING
|
||||
)
|
||||
|
||||
# Call Tingwu
|
||||
try:
|
||||
tingwu_response = service.create_transcription_task(oss_url)
|
||||
# Handle response format
|
||||
if 'Data' in tingwu_response and isinstance(tingwu_response['Data'], dict):
|
||||
task_id = tingwu_response['Data'].get('TaskId')
|
||||
else:
|
||||
task_id = tingwu_response.get('TaskId')
|
||||
|
||||
if task_id:
|
||||
task.task_id = task_id
|
||||
task.status = TranscriptionTask.Status.PROCESSING
|
||||
task.save()
|
||||
|
||||
log_audit(request, 'UPLOAD_AUDIO', f"Task {task.id}", 'SUCCESS')
|
||||
return JsonResponse({'success': True, 'task_id': task.id, 'file_url': oss_url})
|
||||
else:
|
||||
task.status = TranscriptionTask.Status.FAILED
|
||||
task.error_message = "No TaskId returned"
|
||||
task.save()
|
||||
return JsonResponse({'success': False, 'message': 'Failed to create Tingwu task'})
|
||||
|
||||
except Exception as e:
|
||||
task.status = TranscriptionTask.Status.FAILED
|
||||
task.error_message = str(e)
|
||||
task.save()
|
||||
return JsonResponse({'success': False, 'message': f'Tingwu Error: {e}'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Upload error: {e}")
|
||||
return JsonResponse({'success': False, 'message': str(e)})
|
||||
|
||||
@judge_required
|
||||
@csrf_exempt
|
||||
def upload_audio_url(request):
|
||||
"""
|
||||
处理 URL 上传音频的 API
|
||||
通过给定的音频 URL 直接进行处理,无需上传文件
|
||||
"""
|
||||
role = request.session.get('judge_role')
|
||||
if role not in ['judge', 'guest']:
|
||||
return JsonResponse({'success': False, 'message': 'Permission denied'})
|
||||
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'message': 'Method not allowed'})
|
||||
|
||||
import json
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({'success': False, 'message': 'Invalid JSON'})
|
||||
|
||||
audio_url = data.get('url')
|
||||
project_id = data.get('project_id')
|
||||
|
||||
if not audio_url or not project_id:
|
||||
return JsonResponse({'success': False, 'message': 'Missing url or project_id'})
|
||||
|
||||
# 验证 URL 格式
|
||||
if not audio_url.startswith(('http://', 'https://')):
|
||||
return JsonResponse({'success': False, 'message': 'Invalid URL format'})
|
||||
|
||||
judge_id = request.session['judge_id']
|
||||
|
||||
try:
|
||||
# 验证权限
|
||||
user = WeChatUser.objects.get(id=judge_id)
|
||||
project = Project.objects.get(id=project_id)
|
||||
|
||||
enrollment = CompetitionEnrollment.objects.filter(
|
||||
user=user,
|
||||
role=role,
|
||||
competition=project.competition
|
||||
).first()
|
||||
|
||||
if not enrollment:
|
||||
return JsonResponse({'success': False, 'message': 'No permission for this project'})
|
||||
|
||||
# 创建任务记录,使用 URL 作为 file_url
|
||||
service = AliyunTingwuService()
|
||||
|
||||
task = TranscriptionTask.objects.create(
|
||||
project=project,
|
||||
file_url=audio_url,
|
||||
status=TranscriptionTask.Status.PENDING
|
||||
)
|
||||
|
||||
# 调用 Tingwu 服务
|
||||
try:
|
||||
tingwu_response = service.create_transcription_task(audio_url)
|
||||
|
||||
if 'Data' in tingwu_response and isinstance(tingwu_response['Data'], dict):
|
||||
task_id = tingwu_response['Data'].get('TaskId')
|
||||
else:
|
||||
task_id = tingwu_response.get('TaskId')
|
||||
|
||||
if task_id:
|
||||
task.task_id = task_id
|
||||
task.status = TranscriptionTask.Status.PROCESSING
|
||||
task.save()
|
||||
|
||||
log_audit(request, 'UPLOAD_AUDIO_URL', f"Task {task.id}", 'SUCCESS')
|
||||
return JsonResponse({'success': True, 'task_id': task.id, 'file_url': audio_url})
|
||||
else:
|
||||
task.status = TranscriptionTask.Status.FAILED
|
||||
task.error_message = "No TaskId returned"
|
||||
task.save()
|
||||
return JsonResponse({'success': False, 'message': 'Failed to create Tingwu task'})
|
||||
|
||||
except Exception as e:
|
||||
task.status = TranscriptionTask.Status.FAILED
|
||||
task.error_message = str(e)
|
||||
task.save()
|
||||
return JsonResponse({'success': False, 'message': f'Tingwu Error: {e}'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Upload URL error: {e}")
|
||||
return JsonResponse({'success': False, 'message': str(e)})
|
||||
|
||||
@judge_required
|
||||
def ai_manage(request):
|
||||
# Contestants cannot access AI manage
|
||||
role = request.session.get('judge_role')
|
||||
if role not in ['judge', 'guest']:
|
||||
return redirect('judge_dashboard')
|
||||
|
||||
judge_id = request.session['judge_id']
|
||||
user = WeChatUser.objects.get(id=judge_id)
|
||||
enrollments = CompetitionEnrollment.objects.filter(user=user, role=role)
|
||||
competition_ids = enrollments.values_list('competition_id', flat=True)
|
||||
|
||||
# Get tasks for projects in these competitions
|
||||
tasks = TranscriptionTask.objects.filter(
|
||||
project__competition_id__in=competition_ids
|
||||
).select_related('project').order_by('-created_at')
|
||||
|
||||
task_list = []
|
||||
for t in tasks:
|
||||
# Get Evaluation Score
|
||||
# AIEvaluation is linked to Task
|
||||
evals = t.ai_evaluations.all()
|
||||
score = evals[0].score if evals else None
|
||||
|
||||
task_list.append({
|
||||
'id': t.id,
|
||||
'project': t.project,
|
||||
'file_url': t.file_url,
|
||||
'file_name': t.file_url.split('/')[-1] if t.file_url else 'Unknown',
|
||||
'status': t.status,
|
||||
'status_class': 'status-' + t.status.lower(), # CSS class
|
||||
'get_status_display': t.get_status_display(),
|
||||
'ai_score': score
|
||||
})
|
||||
|
||||
return render(request, 'judge/ai_manage.html', {
|
||||
'tasks': task_list,
|
||||
'user_name': request.session.get('judge_name', '用户'),
|
||||
'user_role': role
|
||||
})
|
||||
|
||||
@judge_required
|
||||
@csrf_exempt
|
||||
def delete_ai_task(request, task_id):
|
||||
role = request.session.get('judge_role')
|
||||
if role not in ['judge', 'guest']:
|
||||
return JsonResponse({'success': False, 'message': 'Permission denied'})
|
||||
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({'success': False, 'message': 'Method not allowed'})
|
||||
|
||||
try:
|
||||
task = get_object_or_404(TranscriptionTask, id=task_id)
|
||||
# Permission check
|
||||
# ...
|
||||
|
||||
task.delete()
|
||||
log_audit(request, 'DELETE_TASK', f"Task {task_id}", 'SUCCESS')
|
||||
return JsonResponse({'success': True})
|
||||
except Exception as e:
|
||||
return JsonResponse({'success': False, 'message': str(e)})
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-12 05:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0003_competition_project_visibility'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='competition',
|
||||
name='allow_contestant_grading',
|
||||
field=models.BooleanField(default=False, verbose_name='允许选手互评'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-12 05:33
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0004_competition_allow_contestant_grading'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='scoredimension',
|
||||
name='is_public',
|
||||
field=models.BooleanField(default=True, help_text='如果关闭,评委端将看不到此评分维度,通常用于AI自动评分', verbose_name='是否公开给评委'),
|
||||
),
|
||||
]
|
||||
18
backend/competition/migrations/0006_add_peer_review_field.py
Normal file
18
backend/competition/migrations/0006_add_peer_review_field.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-17 14:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0005_scoredimension_is_public'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='scoredimension',
|
||||
name='is_peer_review',
|
||||
field=models.BooleanField(default=False, help_text='如果开启,此评分维度仅在选手互评时可见,评委和嘉宾看不到', verbose_name='是否用于选手互评'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-20 05:15
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0006_add_peer_review_field'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='competition',
|
||||
name='custom_score_formula',
|
||||
field=models.CharField(blank=True, help_text='如使用自定义算式,将使用此公式计算最终得分。变量格式: dimension_维度ID,如 dimension_1, dimension_2', max_length=1000, verbose_name='自定义得分算式'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='competition',
|
||||
name='score_calculation_type',
|
||||
field=models.CharField(choices=[('default', '默认加权平均'), ('custom', '自定义算式')], default='default', max_length=20, verbose_name='得分计算方式'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='scoredimension',
|
||||
name='formula',
|
||||
field=models.CharField(blank=True, help_text='使用维度ID作为变量,如: dimension_1 * 0.3 + dimension_2 * 0.5 + dimension_3 * 0.2', max_length=500, verbose_name='自定义算式'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='scoredimension',
|
||||
name='formula_type',
|
||||
field=models.CharField(choices=[('weight', '权重模式'), ('formula', '自定义算式')], default='weight', max_length=20, verbose_name='算式类型'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='scoredimension',
|
||||
name='weight',
|
||||
field=models.DecimalField(decimal_places=4, default=1.0, help_text='例如 0.3000 表示 30%', max_digits=6, verbose_name='权重'),
|
||||
),
|
||||
]
|
||||
33
backend/competition/migrations/0008_scoreformula.py
Normal file
33
backend/competition/migrations/0008_scoreformula.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-20 05:27
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0007_competition_custom_score_formula_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ScoreFormula',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='用于标识这个公式,方便管理', max_length=100, verbose_name='公式名称')),
|
||||
('description', models.TextField(blank=True, verbose_name='公式说明')),
|
||||
('formula', models.TextField(help_text='使用维度名称作为变量,支持四则运算和函数', verbose_name='计算公式')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
|
||||
('is_default', models.BooleanField(default=False, verbose_name='是否设为默认公式')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='score_formulas', to='competition.competition', verbose_name='所属比赛')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '评分公式',
|
||||
'verbose_name_plural': '评分公式配置',
|
||||
'ordering': ['-is_default', '-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-20 06:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0008_scoreformula'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='competition',
|
||||
name='active_formula',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='active_competitions', to='competition.scoreformula', verbose_name='启用的评分公式'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competition',
|
||||
name='score_calculation_type',
|
||||
field=models.CharField(choices=[('default', '默认加权平均'), ('formula', '使用评分公式')], default='default', max_length=20, verbose_name='得分计算方式'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-20 06:19
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0009_competition_active_formula_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='competition',
|
||||
name='custom_score_formula',
|
||||
),
|
||||
]
|
||||
@@ -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):
|
||||
"""
|
||||
@@ -261,3 +355,70 @@ 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()})
|
||||
|
||||
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)
|
||||
|
||||
179
backend/competition/templates/judge/ai_manage.html
Normal file
179
backend/competition/templates/judge/ai_manage.html
Normal file
@@ -0,0 +1,179 @@
|
||||
{% extends 'judge/base.html' %}
|
||||
|
||||
{% block title %}AI 服务管理 - 评委系统{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-3xl font-bold text-gray-900 tracking-tight">AI 服务管理</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">查看和管理音频转录及 AI 评分任务</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-lg border border-gray-200">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
项目
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
文件名
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
状态
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
AI 评分
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{% for task in tasks %}
|
||||
<tr class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900">{{ task.project.title }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<a href="{{ task.file_url }}" target="_blank" class="text-sm text-blue-600 hover:text-blue-900 flex items-center">
|
||||
<i class="fas fa-file-audio mr-1"></i> {{ task.file_name|default:"查看文件"|truncatechars:20 }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full {{ task.status_class }}">
|
||||
{{ task.get_status_display }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{% if task.ai_score %}
|
||||
<span class="font-bold text-gray-900">{{ task.ai_score }}</span> 分
|
||||
{% else %}
|
||||
<span class="text-gray-400">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium space-x-2">
|
||||
<button onclick="refreshStatus('{{ task.id }}')" class="text-indigo-600 hover:text-indigo-900 transition-colors" title="刷新状态">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
</button>
|
||||
{% if task.status == 'SUCCEEDED' %}
|
||||
<button onclick="viewResult('{{ task.id }}')" class="text-green-600 hover:text-green-900 transition-colors" title="查看结果">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button onclick="deleteTask('{{ task.id }}')" class="text-red-600 hover:text-red-900 transition-colors" title="删除任务">
|
||||
<i class="fas fa-trash-alt"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% empty %}
|
||||
<tr>
|
||||
<td colspan="5" class="px-6 py-10 text-center text-gray-500">
|
||||
<div class="flex flex-col items-center">
|
||||
<i class="fas fa-inbox text-4xl text-gray-300 mb-2"></i>
|
||||
<p>暂无 AI 任务</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI Result Modal -->
|
||||
<div id="aiResultModal" class="modal fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col relative animate-fade-in">
|
||||
<button class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 z-10" onclick="closeModal('aiResultModal')">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
|
||||
<div class="px-6 py-4 border-b border-gray-100 bg-gray-50">
|
||||
<h3 class="text-lg font-bold text-gray-900 flex items-center">
|
||||
<i class="fas fa-robot text-blue-500 mr-2"></i> AI 分析详情
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="p-6 overflow-y-auto space-y-6" id="aiResultContent">
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-gray-900 uppercase tracking-wide mb-2 flex items-center">
|
||||
<i class="fas fa-align-left mr-2 text-gray-400"></i> 逐字稿
|
||||
</h4>
|
||||
<div id="transcriptionText" class="bg-gray-50 p-4 rounded-lg text-sm text-gray-700 leading-relaxed border border-gray-200 max-h-60 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-gray-900 uppercase tracking-wide mb-2 flex items-center">
|
||||
<i class="fas fa-lightbulb mr-2 text-yellow-500"></i> AI 总结
|
||||
</h4>
|
||||
<div id="summaryText" class="bg-yellow-50 p-4 rounded-lg text-sm text-gray-800 border border-yellow-100 h-40 overflow-y-auto"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-bold text-gray-900 uppercase tracking-wide mb-2 flex items-center">
|
||||
<i class="fas fa-comment-dots mr-2 text-green-500"></i> AI 评语
|
||||
</h4>
|
||||
<div id="evaluationText" class="bg-green-50 p-4 rounded-lg text-sm text-gray-800 border border-green-100 h-40 overflow-y-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.remove('active');
|
||||
}
|
||||
|
||||
async function refreshStatus(taskId) {
|
||||
try {
|
||||
const res = await apiCall(`/api/ai/transcriptions/${taskId}/refresh_status/`, 'GET');
|
||||
if (res.status === 'SUCCEEDED' || res.status === 'FAILED') {
|
||||
alert('状态已更新: ' + res.status);
|
||||
location.reload();
|
||||
} else {
|
||||
alert('当前状态: ' + res.status);
|
||||
}
|
||||
} catch (e) {
|
||||
alert('刷新失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function viewResult(taskId) {
|
||||
try {
|
||||
const res = await apiCall(`/api/ai/transcriptions/${taskId}/`, 'GET');
|
||||
|
||||
document.getElementById('transcriptionText').innerText = res.transcription || '无逐字稿';
|
||||
document.getElementById('summaryText').innerText = res.summary || '无总结';
|
||||
|
||||
// Handle Evaluation (might be separate API or included)
|
||||
// Assuming simple structure for now, adjust based on actual API
|
||||
let evalText = '暂无评语';
|
||||
if (res.ai_evaluations && res.ai_evaluations.length > 0) {
|
||||
evalText = res.ai_evaluations[0].evaluation || '无内容';
|
||||
}
|
||||
document.getElementById('evaluationText').innerText = evalText;
|
||||
|
||||
document.getElementById('aiResultModal').classList.add('active');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('获取结果失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(taskId) {
|
||||
if(!confirm('确定要删除此任务吗?')) return;
|
||||
try {
|
||||
await apiCall(`/judge/api/ai/${taskId}/delete/`, 'POST');
|
||||
alert('删除成功');
|
||||
location.reload();
|
||||
} catch (e) {
|
||||
alert('删除失败');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
235
backend/competition/templates/judge/base.html
Normal file
235
backend/competition/templates/judge/base.html
Normal file
@@ -0,0 +1,235 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}评委系统{% endblock %}</title>
|
||||
<!-- suppress tailwind cdn warning -->
|
||||
<script>
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function() {
|
||||
if (arguments[0] && typeof arguments[0] === 'string' && arguments[0].includes('cdn.tailwindcss.com should not be used in production')) {
|
||||
return;
|
||||
}
|
||||
originalWarn.apply(console, arguments);
|
||||
};
|
||||
</script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.bootcdn.net/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
/* Custom Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* Modal Transitions */
|
||||
.modal {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
.modal.active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
.modal-content {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.3s ease-in-out;
|
||||
}
|
||||
.modal.active .modal-content {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.status-submitted, .status-succeeded {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
.status-pending {
|
||||
background-color: #fef9c3;
|
||||
color: #854d0e;
|
||||
}
|
||||
.status-processing {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.status-failed {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
</style>
|
||||
{% block extra_css %}{% endblock %}
|
||||
</head>
|
||||
<body class="text-gray-800 antialiased min-h-screen flex flex-col">
|
||||
{% if request.session.judge_id %}
|
||||
<header class="bg-white shadow-sm sticky top-0 z-50 transition-all duration-300">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<a href="{% url 'judge_dashboard' %}" class="flex-shrink-0 flex items-center gap-2 text-blue-600 hover:text-blue-700 transition-colors">
|
||||
<i class="fas fa-gavel text-xl"></i>
|
||||
<h1 class="font-bold text-xl tracking-tight">评委评分系统</h1>
|
||||
</a>
|
||||
<nav class="hidden md:ml-8 md:flex md:space-x-8">
|
||||
<a href="{% url 'judge_dashboard' %}"
|
||||
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors duration-200
|
||||
{% if request.resolver_match.url_name == 'judge_dashboard' %}border-blue-500 text-gray-900{% else %}border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700{% endif %}">
|
||||
<i class="fas fa-th-list mr-2"></i>项目列表
|
||||
</a>
|
||||
{% if request.session.judge_role == 'judge' or request.session.judge_role == 'guest' %}
|
||||
<a href="{% url 'judge_ai_manage' %}"
|
||||
class="inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium transition-colors duration-200
|
||||
{% if request.resolver_match.url_name == 'judge_ai_manage' %}border-blue-500 text-gray-900{% else %}border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700{% endif %}">
|
||||
<i class="fas fa-robot mr-2"></i>AI服务管理
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="hidden md:flex items-center mr-6 text-sm">
|
||||
<span class="font-medium text-gray-700 mr-2">{{ request.session.judge_name }}</span>
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 border border-blue-100">
|
||||
{% if request.session.judge_role == 'judge' %}评委
|
||||
{% elif request.session.judge_role == 'guest' %}嘉宾
|
||||
{% elif request.session.judge_role == 'contestant' %}选手
|
||||
{% else %}{{ request.session.judge_role }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<button onclick="logout()" class="ml-4 px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-500 hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all shadow-sm hover:shadow">
|
||||
<i class="fas fa-sign-out-alt mr-1"></i>退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation -->
|
||||
<div class="md:hidden border-t border-gray-200 bg-gray-50">
|
||||
<div class="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<span class="font-medium text-gray-900">{{ request.session.judge_name }}</span>
|
||||
<span class="px-2 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700 border border-blue-100">
|
||||
{% if request.session.judge_role == 'judge' %}评委
|
||||
{% elif request.session.judge_role == 'guest' %}嘉宾
|
||||
{% elif request.session.judge_role == 'contestant' %}选手
|
||||
{% else %}{{ request.session.judge_role }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 divide-x divide-gray-200">
|
||||
<a href="{% url 'judge_dashboard' %}" class="block py-3 text-center text-sm font-medium text-gray-700 hover:bg-gray-100 hover:text-blue-600">
|
||||
<i class="fas fa-th-list mb-1 block text-lg"></i>项目列表
|
||||
</a>
|
||||
{% if request.session.judge_role == 'judge' or request.session.judge_role == 'guest' %}
|
||||
<a href="{% url 'judge_ai_manage' %}" class="block py-3 text-center text-sm font-medium text-gray-700 hover:bg-gray-100 hover:text-blue-600">
|
||||
<i class="fas fa-robot mb-1 block text-lg"></i>AI管理
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{% endif %}
|
||||
|
||||
<main class="flex-grow w-full max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 animate-fade-in">
|
||||
{% if messages %}
|
||||
<div class="mb-6 space-y-2">
|
||||
{% for message in messages %}
|
||||
<div class="rounded-md p-4 shadow-sm border-l-4 flex items-center
|
||||
{% if message.tags == 'error' %}bg-red-50 border-red-500 text-red-700
|
||||
{% elif message.tags == 'success' %}bg-green-50 border-green-500 text-green-700
|
||||
{% else %}bg-blue-50 border-blue-500 text-blue-700{% endif %}">
|
||||
<i class="fas {% if message.tags == 'error' %}fa-exclamation-circle{% elif message.tags == 'success' %}fa-check-circle{% else %}fa-info-circle{% endif %} mr-3 text-lg"></i>
|
||||
<p class="text-sm font-medium">{{ message }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="bg-white border-t border-gray-200 mt-auto">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
|
||||
<p class="text-center text-sm text-gray-500">
|
||||
© {% now "Y" %} 评委评分系统. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
function logout() {
|
||||
if(confirm('确定要退出登录吗?')) {
|
||||
window.location.href = "{% url 'judge_logout' %}";
|
||||
}
|
||||
}
|
||||
|
||||
// 通用 Fetch 封装,处理 CSRF
|
||||
function getCookie(name) {
|
||||
let cookieValue = null;
|
||||
if (document.cookie && document.cookie !== '') {
|
||||
const cookies = document.cookie.split(';');
|
||||
for (let i = 0; i < cookies.length; i++) {
|
||||
const cookie = cookies[i].trim();
|
||||
if (cookie.substring(0, name.length + 1) === (name + '=')) {
|
||||
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return cookieValue;
|
||||
}
|
||||
const csrftoken = getCookie('csrftoken');
|
||||
|
||||
async function apiCall(url, method='POST', data=null) {
|
||||
const options = {
|
||||
method: method,
|
||||
headers: {
|
||||
'X-CSRFToken': csrftoken
|
||||
}
|
||||
};
|
||||
if (data && !(data instanceof FormData)) {
|
||||
options.headers['Content-Type'] = 'application/json';
|
||||
options.body = JSON.stringify(data);
|
||||
} else if (data instanceof FormData) {
|
||||
options.body = data;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (e) {
|
||||
console.error('API Error:', e);
|
||||
alert('操作失败: ' + e.message);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% block extra_js %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
870
backend/competition/templates/judge/dashboard.html
Normal file
870
backend/competition/templates/judge/dashboard.html
Normal file
@@ -0,0 +1,870 @@
|
||||
{% extends 'judge/base.html' %}
|
||||
|
||||
{% block title %}项目列表 - 评委系统{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.markdown-body p { margin-bottom: 0.5em; }
|
||||
.markdown-body ul { list-style-type: disc; padding-left: 1.5em; margin-bottom: 0.5em; }
|
||||
.markdown-body ol { list-style-type: decimal; padding-left: 1.5em; margin-bottom: 0.5em; }
|
||||
.markdown-body strong { font-weight: 600; }
|
||||
.markdown-body h1, .markdown-body h2, .markdown-body h3, .markdown-body h4 { font-weight: 600; margin-top: 1em; margin-bottom: 0.5em; }
|
||||
.markdown-body { overflow: hidden; }
|
||||
.line-clamp-5 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 5;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center mb-8 gap-4">
|
||||
<div>
|
||||
<h2 class="text-3xl font-bold text-gray-900 tracking-tight">参赛项目列表</h2>
|
||||
<p class="mt-1 text-sm text-gray-500">请对以下分配给您的项目进行评审</p>
|
||||
</div>
|
||||
{% if request.session.judge_role == 'judge' or request.session.judge_role == 'guest' %}
|
||||
<button class="inline-flex items-center px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-all transform hover:scale-105" onclick="openUploadModal()">
|
||||
<i class="fas fa-cloud-upload-alt mr-2"></i>批量上传音频
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{% for project in projects %}
|
||||
<div class="bg-white rounded-xl shadow-md hover:shadow-xl transition-all duration-300 overflow-hidden group flex flex-col h-full border border-gray-100" data-id="{{ project.id }}">
|
||||
<div class="relative overflow-hidden h-48">
|
||||
{% if project.cover_image_url %}
|
||||
<img src="{{ project.cover_image_url }}" alt="{{ project.title }}" class="w-full h-full object-cover transform group-hover:scale-110 transition-transform duration-500">
|
||||
{% else %}
|
||||
<div class="w-full h-full bg-gray-100 flex flex-col items-center justify-center text-gray-400">
|
||||
<i class="fas fa-image text-4xl mb-2"></i>
|
||||
<span class="text-sm">暂无封面</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="absolute top-2 right-2">
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ project.status_class }} shadow-sm bg-opacity-90 backdrop-filter backdrop-blur-sm">
|
||||
{{ project.get_status_display }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 flex-1 flex flex-col">
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2 line-clamp-1" title="{{ project.title }}">{{ project.title }}</h3>
|
||||
<div class="flex items-center text-sm text-gray-500 mb-4">
|
||||
<i class="fas fa-user-circle mr-2 text-gray-400"></i>
|
||||
<span>{{ project.contestant_name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-auto pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-xs text-gray-400 uppercase tracking-wider font-semibold">当前得分</span>
|
||||
<span class="text-lg font-bold text-blue-600 score-display">{{ project.current_score|default:"--" }}</span>
|
||||
</div>
|
||||
<button class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors" onclick="viewProject({{ project.id }})">
|
||||
详情 & 评分 <i class="fas fa-arrow-right ml-2 text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-span-full">
|
||||
<div class="text-center py-16 bg-white rounded-xl shadow-sm border border-gray-100">
|
||||
<div class="mx-auto h-24 w-24 text-gray-200">
|
||||
<i class="fas fa-folder-open text-6xl"></i>
|
||||
</div>
|
||||
<h3 class="mt-4 text-lg font-medium text-gray-900">暂无项目</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">当前没有分配给您的参赛项目。</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Project Detail & Grading Modal -->
|
||||
<div id="projectModal" 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-2xl shadow-2xl w-full max-w-5xl 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 p-2 rounded-full hover:bg-gray-100 transition-colors" onclick="closeModal('projectModal')">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
|
||||
<div class="px-8 py-6 border-b border-gray-100 bg-gray-50">
|
||||
<h2 class="text-2xl font-bold text-gray-900" id="modalTitle">项目标题</h2>
|
||||
<div class="flex items-center gap-4 mt-2 text-sm text-gray-500">
|
||||
<span class="flex items-center"><i class="fas fa-hashtag mr-1"></i> <span id="modalId"></span></span>
|
||||
<span class="flex items-center"><i class="fas fa-user mr-1"></i> <span id="modalContestant"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-8">
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
<!-- Left Column: Info -->
|
||||
<div class="flex-1 space-y-6">
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-gray-900 mb-3 flex items-center"><i class="fas fa-info-circle mr-2 text-blue-500"></i>项目简介</h4>
|
||||
<div id="modalDesc" class="bg-gray-50 p-4 rounded-lg text-gray-700 text-sm leading-relaxed border border-gray-100 max-h-48 overflow-y-auto"></div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-gray-900 mb-3 flex items-center"><i class="fas fa-headphones mr-2 text-blue-500"></i>项目录音</h4>
|
||||
<div id="modalAudioSection" class="bg-gray-50 p-4 rounded-t-lg border border-gray-100 flex items-center justify-center min-h-[80px]">
|
||||
<!-- Audio player or "No audio" message will be injected here -->
|
||||
</div>
|
||||
<div id="subtitleContainer" class="bg-black text-white p-3 rounded-b-lg text-center min-h-[48px] flex items-center justify-center text-lg font-medium" style="display: none;">
|
||||
<span id="subtitleText"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="aiResultSection" style="display:none;" class="border border-indigo-100 rounded-xl overflow-hidden">
|
||||
<div class="bg-indigo-50 px-4 py-3 border-b border-indigo-100 flex items-center">
|
||||
<i class="fas fa-robot text-indigo-600 mr-2"></i>
|
||||
<h4 class="text-sm font-bold text-indigo-900 uppercase tracking-wide">AI 智能分析</h4>
|
||||
</div>
|
||||
<div class="p-4 bg-white space-y-4">
|
||||
<div>
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase mb-1">AI 总结</p>
|
||||
<div class="relative">
|
||||
<div class="text-sm text-gray-800 markdown-body line-clamp-5 transition-all duration-300" id="modalAiSummary"></div>
|
||||
<button id="toggleAiSummaryBtn" type="button" onclick="toggleAiSummary()" class="text-xs text-blue-600 hover:text-blue-800 focus:outline-none flex items-center mt-2 hidden">
|
||||
<i class="fas fa-chevron-down mr-1" id="toggleAiSummaryIcon"></i> <span id="toggleAiSummaryText">点击完整显示</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-100 pt-3 relative">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<p class="text-xs font-semibold text-gray-500 uppercase">逐字稿片段</p>
|
||||
<button type="button" onclick="openFullTranscriptionModal()" class="text-xs text-blue-600 hover:text-blue-800 focus:outline-none flex items-center">
|
||||
<i class="fas fa-expand-arrows-alt mr-1"></i> 查看完整逐字稿与章节
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 italic" id="modalAiTrans"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 class="text-lg font-semibold text-gray-900 mb-3 flex items-center"><i class="fas fa-history mr-2 text-blue-500"></i>历史评语</h4>
|
||||
<div id="modalHistoryComments" class="space-y-3 max-h-60 overflow-y-auto pr-2">
|
||||
<!-- Loaded via JS -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 评分细项(仅评委和嘉宾可见) -->
|
||||
<div id="scoreDetailsSection" style="display: none;" class="bg-gradient-to-br from-gray-50 to-blue-50 rounded-xl p-5 border border-gray-200 shadow-sm">
|
||||
<h4 class="text-lg font-bold text-gray-900 mb-4 flex items-center"><i class="fas fa-chart-bar mr-2 text-indigo-500"></i>评分明细</h4>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="bg-white rounded-lg p-3 border border-gray-100 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">评委评分</span>
|
||||
<i class="fas fa-user-tie text-blue-400 text-sm"></i>
|
||||
</div>
|
||||
<span id="judgeScoreValue" class="text-2xl font-bold text-blue-600">--</span>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg p-3 border border-gray-100 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">选手互评分</span>
|
||||
<i class="fas fa-users text-green-400 text-sm"></i>
|
||||
</div>
|
||||
<span id="peerScoreValue" class="text-2xl font-bold text-green-600">--</span>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg p-3 border border-gray-100 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">AI评分</span>
|
||||
<i class="fas fa-robot text-purple-400 text-sm"></i>
|
||||
</div>
|
||||
<span id="aiScoreValue" class="text-2xl font-bold text-purple-600">--</span>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg p-3 border border-gray-100 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="text-xs font-semibold text-gray-500 uppercase">最终平均分</span>
|
||||
<i class="fas fa-star text-yellow-400 text-sm"></i>
|
||||
</div>
|
||||
<span id="finalScoreValue" class="text-2xl font-bold text-yellow-600">--</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Grading -->
|
||||
<div class="lg:w-1/3 bg-gray-50 p-6 rounded-xl border border-gray-200 h-fit sticky top-0">
|
||||
<h4 class="text-lg font-bold text-gray-900 mb-4 flex items-center"><i class="fas fa-star mr-2 text-yellow-500"></i>打分 & 评语</h4>
|
||||
<form id="gradingForm" onsubmit="submitScore(event)" class="space-y-6">
|
||||
<input type="hidden" id="projectId" name="project_id">
|
||||
|
||||
<div id="scoreDimensions" class="space-y-4">
|
||||
<!-- Dimensions loaded via JS -->
|
||||
</div>
|
||||
|
||||
<div id="totalScoreDisplay" class="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm font-medium text-blue-800">综合得分</span>
|
||||
<span id="totalScoreValue" class="text-2xl font-bold text-blue-600">0</span>
|
||||
</div>
|
||||
<p class="text-xs text-blue-500 mt-1">各维度分数×权重相加,提交后计算所有评委平均值</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label for="comment" class="block text-sm font-medium text-gray-700">评语建议</label>
|
||||
<textarea id="comment" name="comment" rows="4"
|
||||
class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-3"
|
||||
placeholder="请输入您的专业点评..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-gray-200 flex items-center justify-between">
|
||||
<span id="saveStatus" class="text-green-600 text-sm font-medium opacity-0 transition-opacity duration-300 flex items-center">
|
||||
<i class="fas fa-check mr-1"></i> 已保存
|
||||
</span>
|
||||
<button type="submit" class="inline-flex justify-center py-2 px-6 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
|
||||
提交评分
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upload Modal -->
|
||||
<div id="uploadModal" class="modal fixed inset-0 z-50 flex items-center justify-center p-4" style="background-color: rgba(0,0,0,0.5);">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md overflow-hidden animate-fade-in relative">
|
||||
<button class="absolute top-3 right-3 text-gray-400 hover:text-gray-600" onclick="closeModal('uploadModal')">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<div class="px-6 py-4 bg-gray-50 border-b border-gray-100">
|
||||
<h2 class="text-lg font-bold text-gray-900">上传项目音频</h2>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<form id="uploadForm" onsubmit="uploadFiles(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">选择项目</label>
|
||||
<select id="uploadProjectSelect" required class="mt-1 block w-full pl-3 pr-10 py-2 text-base border-gray-300 focus:outline-none focus:ring-blue-500 focus:border-blue-500 sm:text-sm rounded-md">
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}">{{ project.title }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">选择上传方式</label>
|
||||
<div class="flex space-x-4 mb-3">
|
||||
<label class="inline-flex items-center">
|
||||
<input type="radio" name="uploadType" value="file" checked class="form-radio text-blue-600" onchange="toggleUploadType()">
|
||||
<span class="ml-2 text-sm text-gray-700">文件上传</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center">
|
||||
<input type="radio" name="uploadType" value="url" class="form-radio text-blue-600" onchange="toggleUploadType()">
|
||||
<span class="ml-2 text-sm text-gray-700">URL 上传</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传 -->
|
||||
<div id="fileUploadSection">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">选择文件 (支持mp3/mp4, ≤50MB)</label>
|
||||
<div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-blue-400 transition-colors cursor-pointer" onclick="document.getElementById('fileInput').click()">
|
||||
<div class="space-y-1 text-center">
|
||||
<i class="fas fa-cloud-upload-alt text-gray-400 text-3xl mb-2"></i>
|
||||
<div class="flex text-sm text-gray-600">
|
||||
<label for="fileInput" class="relative cursor-pointer bg-white rounded-md font-medium text-blue-600 hover:text-blue-500 focus-within:outline-none">
|
||||
<span>点击上传</span>
|
||||
<input id="fileInput" name="fileInput" type="file" class="sr-only" multiple accept="audio/mpeg,audio/mp4,audio/*,.mp3,.mp4" onchange="updateFileName(this)">
|
||||
</label>
|
||||
<p class="pl-1">或拖拽文件到这里</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500">MP3, MP4 up to 50MB</p>
|
||||
<p id="fileNameDisplay" class="text-xs text-blue-600 mt-2 font-medium"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL 上传 -->
|
||||
<div id="urlUploadSection" style="display: none;">
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">音频 URL 地址</label>
|
||||
<input type="url" id="audioUrlInput" class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 rounded-md p-3" placeholder="https://example.com/audio.mp3">
|
||||
<p class="text-xs text-gray-500 mt-1">支持 MP3、MP4 等音频/视频格式的直链</p>
|
||||
</div>
|
||||
|
||||
<div id="uploadProgressContainer" style="display: none;" class="bg-gray-50 p-3 rounded-md">
|
||||
<div class="flex justify-between text-xs text-gray-600 mb-1">
|
||||
<span id="uploadStatusText">准备上传...</span>
|
||||
<span id="uploadPercent">0%</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div id="uploadProgressBar" class="bg-blue-600 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
|
||||
开始上传
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full Transcription Modal -->
|
||||
<div id="fullTranscriptionModal" class="modal fixed inset-0 z-[60] flex items-center justify-center p-4 sm:p-6" style="background-color: rgba(0,0,0,0.6);">
|
||||
<div class="bg-white rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col relative animate-fade-in">
|
||||
<button class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 z-10 p-2 rounded-full hover:bg-gray-100 transition-colors" onclick="closeModal('fullTranscriptionModal')">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
|
||||
<div class="px-8 py-5 border-b border-gray-100 bg-gray-50 rounded-t-2xl flex justify-between items-center">
|
||||
<h2 class="text-xl font-bold text-gray-900 flex items-center"><i class="fas fa-file-alt text-blue-500 mr-2"></i>完整逐字稿与章节</h2>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-8">
|
||||
<div class="space-y-8">
|
||||
<!-- Chapters Section -->
|
||||
<div id="chaptersSection" style="display:none;">
|
||||
<h3 class="text-lg font-bold text-gray-800 mb-4 flex items-center border-b pb-2"><i class="fas fa-list-ul mr-2 text-indigo-500"></i>章节内容</h3>
|
||||
<div id="modalChaptersContent" class="space-y-4">
|
||||
<!-- Chapters rendered here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Full Transcription Section -->
|
||||
<div>
|
||||
<h3 class="text-lg font-bold text-gray-800 mb-4 flex items-center border-b pb-2"><i class="fas fa-align-left mr-2 text-green-500"></i>完整逐字稿</h3>
|
||||
<div id="modalFullTrans" class="text-gray-700 text-sm leading-relaxed bg-gray-50 p-6 rounded-xl border border-gray-100 whitespace-pre-wrap"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script src="https://cdn.staticfile.net/marked/11.1.1/marked.min.js"></script>
|
||||
<script>
|
||||
/**
|
||||
* 更新单个维度分数显示并计算总分
|
||||
*/
|
||||
function updateDimensionScore(dimensionId, maxScore, weight, value) {
|
||||
// 更新分数显示
|
||||
document.getElementById('score_val_' + dimensionId).innerText = value;
|
||||
// 重新计算总分
|
||||
calculateTotalScore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算评委的综合得分
|
||||
* 公式:直接用原始分数乘以权重相加 (与后端逻辑一致)
|
||||
*/
|
||||
function calculateTotalScore() {
|
||||
const dimensionsContainer = document.getElementById('scoreDimensions');
|
||||
const dimensionDivs = dimensionsContainer.querySelectorAll('[data-dimension-id]');
|
||||
|
||||
let totalScore = 0;
|
||||
|
||||
dimensionDivs.forEach(div => {
|
||||
const weight = parseFloat(div.dataset.weight);
|
||||
const dimensionId = div.dataset.dimensionId;
|
||||
|
||||
// 获取当前分数
|
||||
const scoreInput = document.querySelector('input[name="score_' + dimensionId + '"]');
|
||||
if (scoreInput) {
|
||||
const score = parseFloat(scoreInput.value) || 0;
|
||||
// 直接用原始分数乘以权重相加
|
||||
totalScore += score * weight;
|
||||
}
|
||||
});
|
||||
|
||||
// 更新显示
|
||||
const totalScoreElement = document.getElementById('totalScoreValue');
|
||||
if (totalScoreElement) {
|
||||
totalScoreElement.innerText = totalScore.toFixed(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换 AI 总结内容的显示状态(折叠/展开)
|
||||
* 通过添加或移除 line-clamp-5 类来实现截断或完整显示,并更新按钮的文字和图标。
|
||||
*/
|
||||
function toggleAiSummary() {
|
||||
const summaryDiv = document.getElementById('modalAiSummary');
|
||||
const toggleText = document.getElementById('toggleAiSummaryText');
|
||||
const toggleIcon = document.getElementById('toggleAiSummaryIcon');
|
||||
|
||||
if (summaryDiv.classList.contains('line-clamp-5')) {
|
||||
summaryDiv.classList.remove('line-clamp-5');
|
||||
toggleText.innerText = '收起内容';
|
||||
toggleIcon.className = 'fas fa-chevron-up mr-1';
|
||||
} else {
|
||||
summaryDiv.classList.add('line-clamp-5');
|
||||
toggleText.innerText = '点击完整显示';
|
||||
toggleIcon.className = 'fas fa-chevron-down mr-1';
|
||||
}
|
||||
}
|
||||
|
||||
function updateFileName(input) {
|
||||
const display = document.getElementById('fileNameDisplay');
|
||||
if (input.files.length > 0) {
|
||||
display.innerText = `已选: ${input.files.length} 个文件`;
|
||||
} else {
|
||||
display.innerText = '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换文件上传和URL上传的显示
|
||||
* 根据用户选择显示对应的输入区域
|
||||
*/
|
||||
function toggleUploadType() {
|
||||
const uploadType = document.querySelector('input[name="uploadType"]:checked').value;
|
||||
const fileUploadSection = document.getElementById('fileUploadSection');
|
||||
const urlUploadSection = document.getElementById('urlUploadSection');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const urlInput = document.getElementById('audioUrlInput');
|
||||
|
||||
if (uploadType === 'file') {
|
||||
fileUploadSection.style.display = 'block';
|
||||
urlUploadSection.style.display = 'none';
|
||||
fileInput.required = true;
|
||||
urlInput.required = false;
|
||||
} else {
|
||||
fileUploadSection.style.display = 'none';
|
||||
urlUploadSection.style.display = 'block';
|
||||
fileInput.required = false;
|
||||
urlInput.required = true;
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
const modal = document.getElementById(id);
|
||||
modal.classList.remove('active');
|
||||
|
||||
// Stop audio if it's playing when modal is closed
|
||||
const audios = modal.querySelectorAll('audio');
|
||||
audios.forEach(audio => {
|
||||
audio.pause();
|
||||
});
|
||||
}
|
||||
|
||||
function openUploadModal() {
|
||||
document.getElementById('uploadModal').classList.add('active');
|
||||
}
|
||||
|
||||
function openFullTranscriptionModal() {
|
||||
if (!window.currentAiData) return;
|
||||
|
||||
document.getElementById('modalFullTrans').innerText = window.currentAiData.transcription || '暂无完整逐字稿';
|
||||
|
||||
let chaptersData = window.currentAiData.auto_chapters_data;
|
||||
const chaptersSection = document.getElementById('chaptersSection');
|
||||
const chaptersContent = document.getElementById('modalChaptersContent');
|
||||
|
||||
// Check if chaptersData is a string and parse it if necessary
|
||||
if (typeof chaptersData === 'string') {
|
||||
try {
|
||||
chaptersData = JSON.parse(chaptersData);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse auto_chapters_data:', e);
|
||||
chaptersData = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (chaptersData && chaptersData.AutoChapters && chaptersData.AutoChapters.length > 0) {
|
||||
chaptersSection.style.display = 'block';
|
||||
chaptersContent.innerHTML = chaptersData.AutoChapters.map(chapter => {
|
||||
// Convert ms to mm:ss format
|
||||
const formatTime = ms => {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const start = formatTime(chapter.Start);
|
||||
const end = formatTime(chapter.End);
|
||||
|
||||
return `
|
||||
<div class="bg-white p-4 rounded-lg border border-gray-100 shadow-sm">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="font-bold text-gray-800 text-sm">${chapter.Headline || '未命名章节'}</h4>
|
||||
<span class="text-xs font-mono text-indigo-600 bg-indigo-50 px-2 py-1 rounded">${start} - ${end}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">${chapter.Summary || '无摘要'}</p>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
chaptersSection.style.display = 'none';
|
||||
}
|
||||
|
||||
document.getElementById('fullTranscriptionModal').classList.add('active');
|
||||
}
|
||||
|
||||
async function viewProject(id) {
|
||||
try {
|
||||
// Show loading state or skeleton if possible, for now just fetch
|
||||
const data = await fetch(`/judge/api/projects/${id}/`).then(res => res.json());
|
||||
|
||||
document.getElementById('projectId').value = id;
|
||||
document.getElementById('modalTitle').innerText = data.title;
|
||||
document.getElementById('modalId').innerText = data.id;
|
||||
document.getElementById('modalContestant').innerText = data.contestant_name;
|
||||
document.getElementById('modalDesc').innerHTML = data.description || '<span class="text-gray-400 italic">暂无简介</span>';
|
||||
|
||||
// Render Audio Player
|
||||
const audioSection = document.getElementById('modalAudioSection');
|
||||
const subtitleContainer = document.getElementById('subtitleContainer');
|
||||
const subtitleText = document.getElementById('subtitleText');
|
||||
|
||||
if (subtitleContainer && subtitleText) {
|
||||
subtitleText.innerText = '';
|
||||
subtitleContainer.style.display = 'none';
|
||||
}
|
||||
|
||||
if (data.audio_url) {
|
||||
audioSection.innerHTML = `
|
||||
<audio id="projectAudio" controls class="w-full">
|
||||
<source src="${data.audio_url}" type="audio/mpeg">
|
||||
<source src="${data.audio_url}" type="audio/mp4">
|
||||
您的浏览器不支持音频播放。
|
||||
</audio>
|
||||
`;
|
||||
audioSection.classList.remove('justify-center');
|
||||
} else {
|
||||
audioSection.innerHTML = `
|
||||
<div class="text-center text-gray-400 py-2">
|
||||
<i class="fas fa-microphone-slash text-2xl mb-2 block"></i>
|
||||
<span class="text-sm">暂未上传录音</span>
|
||||
</div>
|
||||
`;
|
||||
audioSection.classList.add('justify-center');
|
||||
}
|
||||
|
||||
// AI Result
|
||||
const aiSection = document.getElementById('aiResultSection');
|
||||
if (data.ai_result) {
|
||||
aiSection.style.display = 'block';
|
||||
|
||||
// Subtitle integration
|
||||
if (data.audio_url && data.ai_result.transcription_data && subtitleContainer && subtitleText) {
|
||||
let transData = data.ai_result.transcription_data;
|
||||
if (typeof transData === 'string') {
|
||||
try { transData = JSON.parse(transData); } catch(e) { console.error('Error parsing transcription_data', e); }
|
||||
}
|
||||
|
||||
if (transData && transData.Transcription && transData.Transcription.Paragraphs) {
|
||||
subtitleContainer.style.display = 'flex';
|
||||
|
||||
const sentences = [];
|
||||
transData.Transcription.Paragraphs.forEach(p => {
|
||||
if (p.Words && p.Words.length > 0) {
|
||||
let currentSentenceId = null;
|
||||
let currentSentence = { text: '', start: 0, end: 0 };
|
||||
|
||||
p.Words.forEach(w => {
|
||||
if (w.SentenceId !== currentSentenceId) {
|
||||
if (currentSentenceId !== null) {
|
||||
sentences.push({...currentSentence});
|
||||
}
|
||||
currentSentenceId = w.SentenceId;
|
||||
currentSentence = { text: w.Text, start: w.Start, end: w.End };
|
||||
} else {
|
||||
currentSentence.text += w.Text;
|
||||
currentSentence.end = w.End;
|
||||
}
|
||||
});
|
||||
if (currentSentenceId !== null) {
|
||||
sentences.push({...currentSentence});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const audio = document.getElementById('projectAudio');
|
||||
if (audio) {
|
||||
audio.addEventListener('timeupdate', () => {
|
||||
const currentTimeMs = audio.currentTime * 1000;
|
||||
// Add a small buffer (e.g. 200ms) to make subtitle display smoother
|
||||
const activeSentence = sentences.find(s => currentTimeMs >= (s.start - 200) && currentTimeMs <= (s.end + 200));
|
||||
if (activeSentence) {
|
||||
subtitleText.innerText = activeSentence.text;
|
||||
} else {
|
||||
subtitleText.innerText = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summaryDiv = document.getElementById('modalAiSummary');
|
||||
const toggleBtn = document.getElementById('toggleAiSummaryBtn');
|
||||
const toggleText = document.getElementById('toggleAiSummaryText');
|
||||
const toggleIcon = document.getElementById('toggleAiSummaryIcon');
|
||||
|
||||
const summaryText = data.ai_result.summary || '暂无总结';
|
||||
summaryDiv.innerHTML = marked.parse(summaryText);
|
||||
|
||||
// Reset to collapsed state
|
||||
summaryDiv.classList.add('line-clamp-5');
|
||||
toggleText.innerText = '点击完整显示';
|
||||
toggleIcon.className = 'fas fa-chevron-down mr-1';
|
||||
|
||||
// Show/hide toggle button based on content height
|
||||
setTimeout(() => {
|
||||
if (summaryDiv.scrollHeight > summaryDiv.clientHeight) {
|
||||
toggleBtn.classList.remove('hidden');
|
||||
} else {
|
||||
toggleBtn.classList.add('hidden');
|
||||
}
|
||||
}, 50);
|
||||
|
||||
document.getElementById('modalAiTrans').innerText = (data.ai_result.transcription || '暂无内容').substring(0, 150) + '...';
|
||||
|
||||
// Store full data for full transcription modal
|
||||
window.currentAiData = data.ai_result;
|
||||
} else {
|
||||
aiSection.style.display = 'none';
|
||||
window.currentAiData = null;
|
||||
}
|
||||
|
||||
// Render History Comments
|
||||
const historyHtml = data.history_comments.length > 0 ? data.history_comments.map(c =>
|
||||
`<div class="bg-white p-3 rounded border border-gray-100 shadow-sm">
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<span class="font-bold text-sm text-gray-800">${c.judge_name}</span>
|
||||
<span class="text-xs text-gray-400">${c.created_at}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">${c.content}</p>
|
||||
</div>`
|
||||
).join('') : '<div class="text-center text-gray-400 py-4 text-sm">暂无历史评语</div>';
|
||||
document.getElementById('modalHistoryComments').innerHTML = historyHtml;
|
||||
|
||||
// 渲染评分细项(仅评委和嘉宾可见)
|
||||
const scoreDetailsSection = document.getElementById('scoreDetailsSection');
|
||||
if (data.score_details) {
|
||||
scoreDetailsSection.style.display = 'block';
|
||||
document.getElementById('judgeScoreValue').innerText = data.score_details.judge_score !== null ? data.score_details.judge_score : '--';
|
||||
document.getElementById('peerScoreValue').innerText = data.score_details.peer_score !== null ? data.score_details.peer_score : '--';
|
||||
document.getElementById('aiScoreValue').innerText = data.score_details.ai_score !== null ? data.score_details.ai_score : '--';
|
||||
document.getElementById('finalScoreValue').innerText = data.score_details.final_score !== null ? data.score_details.final_score : '--';
|
||||
} else {
|
||||
scoreDetailsSection.style.display = 'none';
|
||||
}
|
||||
|
||||
// Render Score Inputs
|
||||
const dimensionsHtml = data.dimensions.map(d => `
|
||||
<div class="bg-white p-3 rounded-lg border border-gray-200" data-dimension-id="${d.id}" data-max-score="${d.max_score}" data-weight="${d.weight}">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<label class="text-sm font-medium text-gray-700">${d.name}</label>
|
||||
<span class="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded">满分: ${d.max_score} | 权重: ${d.weight}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<input type="range" min="0" max="${d.max_score}" step="1" value="${d.current_score || 0}"
|
||||
oninput="updateDimensionScore('${d.id}', '${d.max_score}', '${d.weight}', this.value)"
|
||||
name="score_${d.id}" class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-600">
|
||||
<div class="w-12 text-right">
|
||||
<span id="score_val_${d.id}" class="text-lg font-bold text-blue-600">${d.current_score || 0}</span>
|
||||
<span class="text-xs text-gray-400">/${d.max_score}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('scoreDimensions').innerHTML = dimensionsHtml;
|
||||
|
||||
// 计算初始总分
|
||||
calculateTotalScore();
|
||||
|
||||
document.getElementById('comment').value = data.current_comment || '';
|
||||
|
||||
// Handle Grading Permission
|
||||
const gradingForm = document.getElementById('gradingForm');
|
||||
const gradingContainer = gradingForm.parentElement;
|
||||
let readOnlyMsg = document.getElementById('readOnlyMsg');
|
||||
|
||||
if (!readOnlyMsg) {
|
||||
readOnlyMsg = document.createElement('div');
|
||||
readOnlyMsg.id = 'readOnlyMsg';
|
||||
readOnlyMsg.className = 'text-center py-10 bg-white rounded-lg border border-dashed border-gray-300 mt-4';
|
||||
readOnlyMsg.innerHTML = `
|
||||
<div class="mx-auto h-12 w-12 bg-gray-50 rounded-full flex items-center justify-center mb-3">
|
||||
<i class="fas fa-eye text-2xl text-gray-400"></i>
|
||||
</div>
|
||||
<h3 class="text-base font-medium text-gray-900">仅浏览模式</h3>
|
||||
<p class="mt-1 text-sm text-gray-500">当前身份无法进行评分</p>
|
||||
`;
|
||||
gradingContainer.appendChild(readOnlyMsg);
|
||||
}
|
||||
|
||||
if (data.can_grade) {
|
||||
gradingForm.style.display = 'block';
|
||||
readOnlyMsg.style.display = 'none';
|
||||
} else {
|
||||
gradingForm.style.display = 'none';
|
||||
readOnlyMsg.style.display = 'block';
|
||||
}
|
||||
|
||||
document.getElementById('projectModal').classList.add('active');
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
alert('加载项目详情失败');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitScore(e) {
|
||||
e.preventDefault();
|
||||
if(!confirm('确认提交评分吗?')) return;
|
||||
|
||||
const form = e.target;
|
||||
const formData = new FormData(form);
|
||||
const data = Object.fromEntries(formData.entries());
|
||||
|
||||
try {
|
||||
const res = await fetch('/judge/api/score/submit/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
const result = await res.json();
|
||||
if(result.success) {
|
||||
const status = document.getElementById('saveStatus');
|
||||
status.style.opacity = '1';
|
||||
setTimeout(() => status.style.opacity = '0', 2000);
|
||||
|
||||
// Optional: Update card score in background
|
||||
const cardScore = document.querySelector(`.project-card[data-id="${data.project_id}"] .score-display`);
|
||||
if(cardScore) cardScore.innerText = '已评分'; // Or fetch new score
|
||||
|
||||
} else {
|
||||
alert('提交失败: ' + result.message);
|
||||
}
|
||||
} catch(e) {
|
||||
alert('提交出错');
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadFiles(e) {
|
||||
e.preventDefault();
|
||||
const projectSelect = document.getElementById('uploadProjectSelect');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const audioUrlInput = document.getElementById('audioUrlInput');
|
||||
const projectId = projectSelect.value;
|
||||
const files = fileInput.files;
|
||||
const uploadType = document.querySelector('input[name="uploadType"]:checked').value;
|
||||
|
||||
const progressBar = document.getElementById('uploadProgressBar');
|
||||
const statusText = document.getElementById('uploadStatusText');
|
||||
const percentText = document.getElementById('uploadPercent');
|
||||
const container = document.getElementById('uploadProgressContainer');
|
||||
|
||||
container.style.display = 'block';
|
||||
|
||||
if (uploadType === 'url') {
|
||||
// URL 上传模式
|
||||
const audioUrl = audioUrlInput.value.trim();
|
||||
if (!audioUrl) {
|
||||
alert('请输入音频 URL');
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
statusText.innerText = '正在处理 URL...';
|
||||
progressBar.style.width = '30%';
|
||||
percentText.innerText = '30%';
|
||||
|
||||
try {
|
||||
const res = await fetch('/judge/api/upload/url/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: audioUrl,
|
||||
project_id: projectId
|
||||
})
|
||||
});
|
||||
const result = await res.json();
|
||||
|
||||
if (result.success) {
|
||||
progressBar.style.width = '100%';
|
||||
percentText.innerText = '100%';
|
||||
statusText.innerText = '上传成功!';
|
||||
|
||||
setTimeout(() => {
|
||||
closeModal('uploadModal');
|
||||
container.style.display = 'none';
|
||||
window.location.href = "{% url 'judge_ai_manage' %}";
|
||||
}, 1000);
|
||||
} else {
|
||||
alert('上传失败: ' + result.message);
|
||||
container.style.display = 'none';
|
||||
}
|
||||
} catch (err) {
|
||||
alert('上传出错: ' + err);
|
||||
container.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 文件上传模式 (原有用 XMLHttpRequest)
|
||||
if (files.length === 0) return;
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i];
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
alert(`文件 ${file.name} 超过 50MB,跳过`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('project_id', projectId);
|
||||
|
||||
statusText.innerText = `正在上传 ${file.name} (${i+1}/${files.length})...`;
|
||||
progressBar.style.width = '0%';
|
||||
percentText.innerText = '0%';
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '/judge/api/upload/', true);
|
||||
xhr.setRequestHeader('X-CSRFToken', '{{ csrf_token }}');
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const percentComplete = Math.round((e.loaded / e.total) * 100);
|
||||
progressBar.style.width = percentComplete + '%';
|
||||
percentText.innerText = percentComplete + '%';
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status === 200) {
|
||||
const res = JSON.parse(xhr.responseText);
|
||||
if(res.success) resolve();
|
||||
else reject(res.message);
|
||||
} else {
|
||||
reject(xhr.statusText);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onerror = () => reject('Network Error');
|
||||
xhr.send(formData);
|
||||
});
|
||||
} catch (err) {
|
||||
alert(`上传 ${file.name} 失败: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
statusText.innerText = '所有任务完成!';
|
||||
progressBar.style.width = '100%';
|
||||
percentText.innerText = '100%';
|
||||
|
||||
setTimeout(() => {
|
||||
closeModal('uploadModal');
|
||||
container.style.display = 'none';
|
||||
window.location.href = "{% url 'judge_ai_manage' %}";
|
||||
}, 1000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
129
backend/competition/templates/judge/login.html
Normal file
129
backend/competition/templates/judge/login.html
Normal file
@@ -0,0 +1,129 @@
|
||||
{% extends 'judge/base.html' %}
|
||||
|
||||
{% block title %}评委登录{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="fixed inset-0 z-0 bg-gradient-to-br from-blue-500 to-indigo-700 flex items-center justify-center px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8 bg-white p-10 rounded-2xl shadow-2xl transform transition-all hover:scale-105 duration-300">
|
||||
<div>
|
||||
<div class="mx-auto h-16 w-16 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<i class="fas fa-gavel text-3xl text-blue-600"></i>
|
||||
</div>
|
||||
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
评委登录
|
||||
</h2>
|
||||
<p class="mt-2 text-center text-sm text-gray-600">
|
||||
请输入您的手机号验证登录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form class="mt-8 space-y-6" method="post" action="{% url 'judge_login' %}">
|
||||
{% csrf_token %}
|
||||
<div class="rounded-md shadow-sm -space-y-px">
|
||||
<div class="mb-4">
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 mb-1">手机号</label>
|
||||
<div class="relative rounded-md shadow-sm">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-mobile-alt text-gray-400"></i>
|
||||
</div>
|
||||
<input type="tel" id="phone" name="phone" required pattern="[0-9]{11}"
|
||||
class="focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors"
|
||||
placeholder="请输入11位手机号">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="code" class="block text-sm font-medium text-gray-700 mb-1">验证码</label>
|
||||
<div class="flex gap-2">
|
||||
<div class="relative rounded-md shadow-sm flex-1">
|
||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<i class="fas fa-shield-alt text-gray-400"></i>
|
||||
</div>
|
||||
<input type="text" id="code" name="code" required
|
||||
class="focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 sm:text-sm border-gray-300 rounded-lg py-3 transition-colors"
|
||||
placeholder="请输入验证码">
|
||||
</div>
|
||||
<button type="button" id="sendCodeBtn" onclick="sendSmsCode()"
|
||||
class="whitespace-nowrap inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-lg text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors w-32 justify-center">
|
||||
发送验证码
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit"
|
||||
class="group relative w-full flex justify-center py-3 px-4 border border-transparent text-sm font-medium rounded-lg text-white bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 shadow-md hover:shadow-lg transition-all duration-200">
|
||||
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
|
||||
<i class="fas fa-sign-in-alt text-blue-300 group-hover:text-blue-100"></i>
|
||||
</span>
|
||||
登录系统
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if error %}
|
||||
<div class="rounded-md bg-red-50 p-4 border border-red-200 animate-pulse">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0">
|
||||
<i class="fas fa-times-circle text-red-400"></i>
|
||||
</div>
|
||||
<div class="ml-3">
|
||||
<h3 class="text-sm font-medium text-red-800">登录失败</h3>
|
||||
<div class="mt-2 text-sm text-red-700">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
async function sendSmsCode() {
|
||||
const phone = document.getElementById('phone').value;
|
||||
if (!phone || phone.length !== 11) {
|
||||
alert('请输入有效的11位手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('sendCodeBtn');
|
||||
btn.disabled = true;
|
||||
let countdown = 60;
|
||||
|
||||
try {
|
||||
const response = await fetch("{% url 'judge_send_code' %}", {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token }}'
|
||||
},
|
||||
body: JSON.stringify({ phone: phone })
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
alert('验证码已发送');
|
||||
const timer = setInterval(() => {
|
||||
btn.innerText = `${countdown}s 后重发`;
|
||||
countdown--;
|
||||
if (countdown < 0) {
|
||||
clearInterval(timer);
|
||||
btn.disabled = false;
|
||||
btn.innerText = '发送验证码';
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
alert('发送失败: ' + data.message);
|
||||
btn.disabled = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('网络错误,请重试');
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -2,8 +2,9 @@ from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .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)),
|
||||
]
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from rest_framework import viewsets, permissions, status, filters, serializers
|
||||
from rest_framework.decorators import action
|
||||
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
|
||||
@@ -280,3 +281,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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
18
backend/start_judge_system.sh
Executable file
@@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
echo "Starting Judge System..."
|
||||
|
||||
# 激活虚拟环境 (如果有)
|
||||
if [ -d "venv" ]; then
|
||||
source venv/bin/activate
|
||||
fi
|
||||
|
||||
# 安装依赖
|
||||
pip install -r requirements.txt
|
||||
|
||||
# 迁移数据库
|
||||
python manage.py makemigrations
|
||||
python manage.py migrate
|
||||
|
||||
# 启动 Django 开发服务器
|
||||
echo "Server running at http://127.0.0.1:8000/competition/admin/"
|
||||
python manage.py runserver 0.0.0.0:8000
|
||||
@@ -24,6 +24,14 @@ import './App.css';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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('开始加载...')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 './project.scss'
|
||||
@@ -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,6 +28,32 @@ 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()
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user