创赢未来评分系统 - 初始化提交(移除大文件)
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
All checks were successful
Deploy to Server / deploy (push) Successful in 18s
This commit is contained in:
9
backend/.env.example
Normal file
9
backend/.env.example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Aliyun OSS Configuration
|
||||
ALIYUN_ACCESS_KEY_ID=LTAI5tE62GW8MKyoEaotzxXk
|
||||
ALIYUN_ACCESS_KEY_SECRET=Zdzqo1fgj57DxxioXOotNKhJdSfVQW
|
||||
ALIYUN_OSS_ENDPOINT=https://oss-cn-shanghai.aliyuncs.com
|
||||
ALIYUN_OSS_BUCKET_NAME=tangledup-ai-staging
|
||||
ALIYUN_OSS_INTERNAL_ENDPOINT=https://oss-cn-shanghai-internal.aliyuncs.com
|
||||
|
||||
# Aliyun Tingwu Configuration
|
||||
ALIYUN_TINGWU_APP_KEY=6eOX7N3tKE0fDwb
|
||||
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`。
|
||||
26
backend/Dockerfile
Normal file
26
backend/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# Use an official Python runtime as a parent image
|
||||
FROM python:3.13-slim
|
||||
|
||||
# Set environment variables
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Set work directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install python dependencies
|
||||
COPY requirements.txt /app/
|
||||
RUN pip install --upgrade pip && pip install -r requirements.txt
|
||||
|
||||
# Copy project
|
||||
COPY . /app/
|
||||
COPY .env /app/
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Volume for media files
|
||||
VOLUME ["/app/media"]
|
||||
|
||||
# Run the application with gunicorn
|
||||
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "config.wsgi:application"]
|
||||
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。
|
||||
0
backend/ai_services/__init__.py
Normal file
0
backend/ai_services/__init__.py
Normal file
47
backend/ai_services/admin.py
Normal file
47
backend/ai_services/admin.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from django.contrib import admin
|
||||
from unfold.admin import ModelAdmin as UnfoldModelAdmin
|
||||
from unfold.admin import StackedInline as UnfoldStackedInline
|
||||
from .models import TranscriptionTask, AIEvaluation, AIEvaluationTemplate
|
||||
|
||||
class AIEvaluationInline(UnfoldStackedInline):
|
||||
model = AIEvaluation
|
||||
extra = 0
|
||||
can_delete = True
|
||||
verbose_name = "AI评估结果"
|
||||
verbose_name_plural = "AI评估结果"
|
||||
readonly_fields = ['created_at', 'updated_at', 'raw_response', 'reasoning', 'template']
|
||||
fields = ('template', 'score', 'evaluation', 'model_selection', 'prompt', 'reasoning', 'status', 'error_message')
|
||||
|
||||
@admin.register(TranscriptionTask)
|
||||
class TranscriptionTaskAdmin(UnfoldModelAdmin):
|
||||
list_display = ['id', 'status', 'task_id', 'created_at']
|
||||
list_filter = ['status', 'created_at']
|
||||
search_fields = ['id', 'task_id', 'transcription', 'summary']
|
||||
readonly_fields = ['id', 'created_at', 'updated_at', 'task_id']
|
||||
inlines = [AIEvaluationInline]
|
||||
|
||||
@admin.register(AIEvaluationTemplate)
|
||||
class AIEvaluationTemplateAdmin(UnfoldModelAdmin):
|
||||
list_display = ['name', 'model_selection', 'score_dimension', 'is_default', 'is_active', 'created_at']
|
||||
list_filter = ['is_active', 'is_default', 'model_selection', 'created_at']
|
||||
search_fields = ['name', 'prompt']
|
||||
|
||||
@admin.register(AIEvaluation)
|
||||
class AIEvaluationAdmin(UnfoldModelAdmin):
|
||||
list_display = ['id', 'task', 'template', 'score', 'status', 'model_selection', 'created_at']
|
||||
list_filter = ['status', 'model_selection', 'created_at', 'template']
|
||||
search_fields = ['task__id', 'evaluation', 'reasoning']
|
||||
readonly_fields = ['id', 'created_at', 'updated_at', 'raw_response']
|
||||
fieldsets = (
|
||||
(None, {
|
||||
'fields': ('task', 'template', 'status', 'score', 'evaluation')
|
||||
}),
|
||||
('配置快照', {
|
||||
'fields': ('model_selection', 'prompt'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
('调试信息', {
|
||||
'fields': ('raw_response', 'reasoning', 'error_message'),
|
||||
'classes': ('collapse',),
|
||||
}),
|
||||
)
|
||||
5
backend/ai_services/apps.py
Normal file
5
backend/ai_services/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AiServicesConfig(AppConfig):
|
||||
name = 'ai_services'
|
||||
323
backend/ai_services/bailian_service.py
Normal file
323
backend/ai_services/bailian_service.py
Normal file
@@ -0,0 +1,323 @@
|
||||
import logging
|
||||
import json
|
||||
import os
|
||||
from django.conf import settings
|
||||
from openai import OpenAI
|
||||
from .models import AIEvaluation
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class BailianService:
|
||||
def __init__(self):
|
||||
self.api_key = getattr(settings, 'DASHSCOPE_API_KEY', None)
|
||||
if not self.api_key:
|
||||
self.api_key = os.environ.get("DASHSCOPE_API_KEY")
|
||||
|
||||
if self.api_key:
|
||||
self.client = OpenAI(
|
||||
api_key=self.api_key,
|
||||
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
)
|
||||
else:
|
||||
self.client = None
|
||||
logger.warning("DASHSCOPE_API_KEY not configured.")
|
||||
|
||||
def evaluate_task(self, evaluation: AIEvaluation):
|
||||
"""
|
||||
执行AI评估
|
||||
"""
|
||||
if not self.client:
|
||||
evaluation.status = AIEvaluation.Status.FAILED
|
||||
evaluation.error_message = "服务未配置 (DASHSCOPE_API_KEY missing)"
|
||||
evaluation.save()
|
||||
return
|
||||
|
||||
task = evaluation.task
|
||||
if not task.transcription:
|
||||
evaluation.status = AIEvaluation.Status.FAILED
|
||||
evaluation.error_message = "关联任务无逐字稿内容"
|
||||
evaluation.save()
|
||||
return
|
||||
|
||||
evaluation.status = AIEvaluation.Status.PROCESSING
|
||||
evaluation.save()
|
||||
|
||||
try:
|
||||
prompt = evaluation.prompt
|
||||
content = task.transcription
|
||||
|
||||
# 准备章节/时间戳数据以辅助分析发言节奏
|
||||
chapter_context = ""
|
||||
if task.auto_chapters_data:
|
||||
try:
|
||||
chapters_str = ""
|
||||
# 处理特定的 AutoChapters 结构
|
||||
# 格式: {"AutoChapters": [{"Id": 1, "Start": 740, "End": 203436, "Headline": "...", "Summary": "..."}, ...]}
|
||||
if isinstance(task.auto_chapters_data, dict) and 'AutoChapters' in task.auto_chapters_data:
|
||||
chapters = task.auto_chapters_data['AutoChapters']
|
||||
if isinstance(chapters, list):
|
||||
chapter_lines = []
|
||||
for ch in chapters:
|
||||
# 毫秒转 MM:SS
|
||||
start_ms = ch.get('Start', 0)
|
||||
end_ms = ch.get('End', 0)
|
||||
start_str = f"{start_ms // 60000:02d}:{(start_ms // 1000) % 60:02d}"
|
||||
end_str = f"{end_ms // 60000:02d}:{(end_ms // 1000) % 60:02d}"
|
||||
|
||||
headline = ch.get('Headline', '无标题')
|
||||
summary = ch.get('Summary', '')
|
||||
|
||||
line = f"- [{start_str} - {end_str}] {headline}"
|
||||
if summary:
|
||||
line += f"\n 摘要: {summary}"
|
||||
chapter_lines.append(line)
|
||||
|
||||
chapters_str = "\n".join(chapter_lines)
|
||||
|
||||
# 如果上面的解析为空(或者格式不匹配),回退到通用 JSON dump
|
||||
if not chapters_str:
|
||||
if isinstance(task.auto_chapters_data, (dict, list)):
|
||||
chapters_str = json.dumps(task.auto_chapters_data, ensure_ascii=False, indent=2)
|
||||
else:
|
||||
chapters_str = str(task.auto_chapters_data)
|
||||
|
||||
chapter_context = f"\n\n【章节与时间戳信息】\n{chapters_str}\n\n(提示:请结合上述章节时间戳信息,分析发言者的语速、节奏变化及停顿情况。)"
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to process auto_chapters_data: {e}")
|
||||
|
||||
# 截断过长的内容以防止超出Token限制 (简单处理,取前10000字)
|
||||
if len(content) > 10000:
|
||||
content = content[:10000] + "...(内容过长已截断)"
|
||||
|
||||
# Construct messages
|
||||
messages = [
|
||||
{'role': 'system', 'content': 'You are a helpful assistant designed to output JSON.'},
|
||||
{'role': 'user', 'content': f"{prompt}\n\n以下是需要评估的内容:\n{content}{chapter_context}"}
|
||||
]
|
||||
|
||||
# 增加重试机制 (最多重试3次)
|
||||
completion = None
|
||||
last_error = None
|
||||
import time
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
completion = self.client.chat.completions.create(
|
||||
model=evaluation.model_selection,
|
||||
messages=messages,
|
||||
response_format={"type": "json_object"}
|
||||
)
|
||||
break # 成功则跳出循环
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(f"AI Evaluation attempt {attempt+1}/3 failed for eval {evaluation.id}: {e}")
|
||||
if attempt < 2:
|
||||
time.sleep(2 * (attempt + 1)) # 简单的指数退避
|
||||
|
||||
if not completion:
|
||||
raise last_error or Exception("AI Service call failed after retries")
|
||||
|
||||
response_content = completion.choices[0].message.content
|
||||
# Convert to dict for storage
|
||||
raw_response = completion.model_dump()
|
||||
|
||||
evaluation.raw_response = raw_response
|
||||
|
||||
# Parse JSON
|
||||
try:
|
||||
result = json.loads(response_content)
|
||||
evaluation.score = result.get('score')
|
||||
evaluation.evaluation = result.get('evaluation') or result.get('comment')
|
||||
|
||||
# 尝试获取推理过程(如果模型返回了)
|
||||
evaluation.reasoning = result.get('reasoning') or result.get('analysis')
|
||||
|
||||
if not evaluation.reasoning:
|
||||
# 如果JSON里没有,把整个JSON作为推理参考
|
||||
evaluation.reasoning = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
|
||||
evaluation.status = AIEvaluation.Status.COMPLETED
|
||||
except json.JSONDecodeError:
|
||||
evaluation.status = AIEvaluation.Status.FAILED
|
||||
evaluation.error_message = f"无法解析JSON响应: {response_content}"
|
||||
evaluation.reasoning = response_content
|
||||
|
||||
evaluation.save()
|
||||
|
||||
# 同步结果到参赛项目 (如果关联了)
|
||||
self._sync_evaluation_to_project(evaluation)
|
||||
|
||||
return evaluation
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AI Evaluation failed: {e}")
|
||||
evaluation.status = AIEvaluation.Status.FAILED
|
||||
evaluation.error_message = str(e)
|
||||
evaluation.save()
|
||||
return evaluation
|
||||
|
||||
def _sync_evaluation_to_project(self, evaluation: AIEvaluation):
|
||||
"""
|
||||
将AI评估结果同步到关联的参赛项目(评分和评语)
|
||||
"""
|
||||
try:
|
||||
task = evaluation.task
|
||||
if not task.project:
|
||||
return
|
||||
|
||||
project = task.project
|
||||
competition = project.competition
|
||||
|
||||
# 1. 确定评委身份 (Based on Template)
|
||||
# 用户要求:评委显示的是模板名称
|
||||
template_name = evaluation.template.name if evaluation.template else "AI智能评委"
|
||||
# 使用固定前缀 + template_id 确保唯一性,这样同一个模板在不同项目里是同一个评委
|
||||
openid = f"ai_judge_{evaluation.template.id}" if evaluation.template else "ai_judge_default"
|
||||
|
||||
# 延迟导入以避免循环依赖
|
||||
from shop.models import WeChatUser
|
||||
from competition.models import CompetitionEnrollment, Score, Comment, ScoreDimension
|
||||
|
||||
# 获取或创建虚拟评委用户
|
||||
user, created = WeChatUser.objects.get_or_create(
|
||||
openid=openid,
|
||||
defaults={
|
||||
'nickname': template_name,
|
||||
'avatar_url': 'https://ui-avatars.com/api/?name=AI&background=random&color=fff'
|
||||
}
|
||||
)
|
||||
|
||||
# 如果名字不匹配(比如模板改名了),更新它
|
||||
if user.nickname != template_name:
|
||||
user.nickname = template_name
|
||||
user.save(update_fields=['nickname'])
|
||||
|
||||
# 2. 确保评委已报名 (Enrollment)
|
||||
enrollment, _ = CompetitionEnrollment.objects.get_or_create(
|
||||
competition=competition,
|
||||
user=user,
|
||||
defaults={
|
||||
'role': 'judge',
|
||||
'status': 'approved'
|
||||
}
|
||||
)
|
||||
|
||||
# 3. 同步评分 (Score)
|
||||
if evaluation.score is not None:
|
||||
# 尝试找到匹配的维度
|
||||
dimensions = competition.score_dimensions.all()
|
||||
target_dimension = None
|
||||
|
||||
# 0. 优先使用模板配置的维度
|
||||
if evaluation.template and evaluation.template.score_dimension:
|
||||
# 检查配置的维度是否属于当前比赛
|
||||
if evaluation.template.score_dimension.competition_id == competition.id:
|
||||
target_dimension = evaluation.template.score_dimension
|
||||
else:
|
||||
# 如果不属于当前比赛(跨比赛复用模板),尝试查找同名维度
|
||||
target_dimension = dimensions.filter(name=evaluation.template.score_dimension.name).first()
|
||||
|
||||
# 1. 如果未配置或未找到,尝试匹配 "AI Rating" (用户指定默认值)
|
||||
if not target_dimension:
|
||||
target_dimension = dimensions.filter(name__iexact="AI Rating").first()
|
||||
|
||||
# 2. 尝试匹配包含 "AI" 的维度
|
||||
if not target_dimension:
|
||||
for dim in dimensions:
|
||||
if "AI" in dim.name.upper():
|
||||
target_dimension = dim
|
||||
break
|
||||
|
||||
# 3. 尝试匹配模板名称
|
||||
if not target_dimension:
|
||||
target_dimension = dimensions.filter(name=template_name).first()
|
||||
|
||||
# 4. 最后兜底:使用第一个维度
|
||||
if not target_dimension and dimensions.exists():
|
||||
target_dimension = dimensions.first()
|
||||
|
||||
if target_dimension:
|
||||
Score.objects.update_or_create(
|
||||
project=project,
|
||||
judge=enrollment,
|
||||
dimension=target_dimension,
|
||||
defaults={'score': evaluation.score}
|
||||
)
|
||||
logger.info(f"Synced AI score {evaluation.score} to project {project.id} dimension {target_dimension.name}")
|
||||
|
||||
# 4. 同步评语 (Comment)
|
||||
if evaluation.evaluation:
|
||||
# 检查是否已存在该评委的评语,避免重复
|
||||
comment = Comment.objects.filter(project=project, judge=enrollment).first()
|
||||
if comment:
|
||||
comment.content = evaluation.evaluation
|
||||
comment.save()
|
||||
else:
|
||||
Comment.objects.create(
|
||||
project=project,
|
||||
judge=enrollment,
|
||||
content=evaluation.evaluation
|
||||
)
|
||||
logger.info(f"Synced AI comment to project {project.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to sync evaluation to project: {e}")
|
||||
|
||||
def summarize_task(self, task):
|
||||
"""
|
||||
对转写任务进行总结
|
||||
"""
|
||||
if not self.client:
|
||||
logger.warning("BailianService not initialized, skipping summary.")
|
||||
return
|
||||
|
||||
if not task.transcription:
|
||||
logger.warning(f"Task {task.id} has no transcription, skipping summary.")
|
||||
return
|
||||
|
||||
try:
|
||||
content = task.transcription
|
||||
# 简单截断防止过长
|
||||
if len(content) > 15000:
|
||||
content = content[:15000] + "...(内容过长已截断)"
|
||||
|
||||
# 准备上下文数据
|
||||
context_data = ""
|
||||
if task.summary_data:
|
||||
context_data += f"\n\n【总结原始数据】\n{json.dumps(task.summary_data, ensure_ascii=False, indent=2)}"
|
||||
|
||||
if task.auto_chapters_data:
|
||||
context_data += f"\n\n【章节原始数据】\n{json.dumps(task.auto_chapters_data, ensure_ascii=False, indent=2)}"
|
||||
|
||||
system_prompt = f"""你是一个专业的会议/内容总结助手。请根据提供的【转写文本】、【总结原始数据】和【章节原始数据】,生成一份结构清晰、内容详实的总结报告。
|
||||
|
||||
请按照以下结构输出(Markdown格式):
|
||||
1. **标题**:基于内容生成一个合适的标题。
|
||||
2. **核心摘要**:简要概括主要内容。
|
||||
3. **主要观点/话题**:结合思维导图数据,列出关键话题和层级。
|
||||
4. **章节速览**:结合章节数据,列出时间点和主要内容。[HH:MM:SS]格式来把章节列出来
|
||||
5. **问答精选**(如果有):基于问答总结数据,列出重要问答。
|
||||
|
||||
请确保语言通顺,重点突出,能够还原内容的逻辑结构。"""
|
||||
|
||||
user_content = f"以下是需要总结的内容:\n\n【转写文本】\n{content}{context_data}"
|
||||
|
||||
messages = [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': user_content}
|
||||
]
|
||||
|
||||
# 使用 qwen-plus 作为默认模型
|
||||
completion = self.client.chat.completions.create(
|
||||
model="qwen-plus",
|
||||
messages=messages
|
||||
)
|
||||
|
||||
summary_content = completion.choices[0].message.content
|
||||
task.summary = summary_content
|
||||
task.save(update_fields=['summary'])
|
||||
|
||||
logger.info(f"Task {task.id} summary generated successfully.")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate summary for task {task.id}: {e}")
|
||||
0
backend/ai_services/management/__init__.py
Normal file
0
backend/ai_services/management/__init__.py
Normal file
0
backend/ai_services/management/commands/__init__.py
Normal file
0
backend/ai_services/management/commands/__init__.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.conf import settings
|
||||
import oss2
|
||||
from aliyunsdkcore.client import AcsClient
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Check Aliyun configuration status'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write("Checking Aliyun Configuration...")
|
||||
|
||||
configs = {
|
||||
'ALIYUN_ACCESS_KEY_ID': settings.ALIYUN_ACCESS_KEY_ID,
|
||||
'ALIYUN_ACCESS_KEY_SECRET': settings.ALIYUN_ACCESS_KEY_SECRET,
|
||||
'ALIYUN_OSS_BUCKET_NAME': settings.ALIYUN_OSS_BUCKET_NAME,
|
||||
'ALIYUN_OSS_ENDPOINT': settings.ALIYUN_OSS_ENDPOINT,
|
||||
'ALIYUN_TINGWU_APP_KEY': settings.ALIYUN_TINGWU_APP_KEY,
|
||||
}
|
||||
|
||||
all_valid = True
|
||||
for key, value in configs.items():
|
||||
if not value:
|
||||
self.stdout.write(self.style.ERROR(f"[MISSING] {key} is not set or empty"))
|
||||
all_valid = False
|
||||
else:
|
||||
masked_value = value[:4] + "****" + value[-4:] if len(value) > 8 else "****"
|
||||
self.stdout.write(self.style.SUCCESS(f"[OK] {key}: {masked_value}"))
|
||||
|
||||
if not all_valid:
|
||||
self.stdout.write(self.style.ERROR("\nConfiguration check FAILED. Some required settings are missing."))
|
||||
return
|
||||
|
||||
# Test OSS Connection
|
||||
self.stdout.write("\nTesting OSS Connection...")
|
||||
try:
|
||||
auth = oss2.Auth(configs['ALIYUN_ACCESS_KEY_ID'], configs['ALIYUN_ACCESS_KEY_SECRET'])
|
||||
bucket = oss2.Bucket(auth, configs['ALIYUN_OSS_ENDPOINT'], configs['ALIYUN_OSS_BUCKET_NAME'])
|
||||
bucket.get_bucket_info()
|
||||
self.stdout.write(self.style.SUCCESS("[OK] OSS Connection successful"))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"[FAILED] OSS Connection failed: {e}"))
|
||||
|
||||
# Test Tingwu Client Initialization
|
||||
self.stdout.write("\nTesting Tingwu Client Initialization...")
|
||||
try:
|
||||
client = AcsClient(
|
||||
configs['ALIYUN_ACCESS_KEY_ID'],
|
||||
configs['ALIYUN_ACCESS_KEY_SECRET'],
|
||||
"cn-beijing"
|
||||
)
|
||||
self.stdout.write(self.style.SUCCESS("[OK] Tingwu Client initialized"))
|
||||
except Exception as e:
|
||||
self.stdout.write(self.style.ERROR(f"[FAILED] Tingwu Client init failed: {e}"))
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import time
|
||||
import logging
|
||||
from django.core.management.base import BaseCommand
|
||||
from ai_services.models import TranscriptionTask
|
||||
from ai_services.services import AliyunTingwuService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Polls Aliyun Tingwu for transcription results every 10 seconds'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
self.stdout.write(self.style.SUCCESS('Starting polling service...'))
|
||||
service = AliyunTingwuService()
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Find tasks that are PENDING or PROCESSING
|
||||
# Include PENDING because create() might set it to PENDING initially
|
||||
# though usually it sets to PROCESSING if task_id is obtained.
|
||||
# Just in case.
|
||||
tasks = TranscriptionTask.objects.filter(
|
||||
status__in=[TranscriptionTask.Status.PENDING, TranscriptionTask.Status.PROCESSING]
|
||||
).exclude(task_id__isnull=True).exclude(task_id='')
|
||||
|
||||
count = tasks.count()
|
||||
if count > 0:
|
||||
self.stdout.write(f'Found {count} pending/processing tasks.')
|
||||
|
||||
for task in tasks:
|
||||
self.stdout.write(f'Checking task {task.task_id} (Status: {task.status})...')
|
||||
try:
|
||||
result = service.get_task_info(task.task_id)
|
||||
|
||||
# Store old status to check for changes
|
||||
old_status = task.status
|
||||
|
||||
service.parse_and_update_task(task, result)
|
||||
|
||||
# Re-fetch or check updated object
|
||||
if task.status != old_status:
|
||||
if task.status == TranscriptionTask.Status.SUCCEEDED:
|
||||
self.stdout.write(self.style.SUCCESS(f'Task {task.task_id} SUCCEEDED'))
|
||||
elif task.status == TranscriptionTask.Status.FAILED:
|
||||
self.stdout.write(self.style.ERROR(f'Task {task.task_id} FAILED: {task.error_message}'))
|
||||
else:
|
||||
# Still processing
|
||||
pass
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking task {task.task_id}: {e}")
|
||||
self.stdout.write(self.style.ERROR(f"Error checking task {task.task_id}: {e}"))
|
||||
|
||||
# Wait for 10 seconds
|
||||
time.sleep(10)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
self.stdout.write(self.style.SUCCESS('Stopping polling service...'))
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Polling loop error: {e}")
|
||||
self.stdout.write(self.style.ERROR(f"Polling loop error: {e}"))
|
||||
time.sleep(10)
|
||||
102
backend/ai_services/management/commands/test_tingwu_local.py
Normal file
102
backend/ai_services/management/commands/test_tingwu_local.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import os
|
||||
import sys
|
||||
import django
|
||||
import json
|
||||
import logging
|
||||
from django.conf import settings
|
||||
|
||||
# 设置 Django 环境
|
||||
# 添加项目根目录到 sys.path
|
||||
sys.path.append('/Volumes/data/Quant-Speed/market_page/backend')
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') # 修正为正确的 settings 模块路径
|
||||
django.setup()
|
||||
|
||||
from ai_services.services import AliyunTingwuService
|
||||
from ai_services.models import TranscriptionTask
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def test_tingwu_transcription():
|
||||
file_url = "https://tangledup-ai-staging.oss-cn-shanghai.aliyuncs.com/Video/%E6%95%99%E5%AD%A6.mp4"
|
||||
|
||||
print(f"Testing transcription for: {file_url}")
|
||||
|
||||
service = AliyunTingwuService()
|
||||
|
||||
# 1. 创建任务
|
||||
try:
|
||||
print("Creating task...")
|
||||
response = service.create_transcription_task(file_url)
|
||||
print(f"Create task response: {json.dumps(response, indent=2, ensure_ascii=False)}")
|
||||
|
||||
if 'Data' in response and isinstance(response['Data'], dict):
|
||||
task_id = response['Data'].get('TaskId')
|
||||
else:
|
||||
task_id = response.get('TaskId')
|
||||
|
||||
if not task_id:
|
||||
print("Failed to get TaskId")
|
||||
return
|
||||
|
||||
print(f"Task created with ID: {task_id}")
|
||||
|
||||
# 2. 轮询查询任务状态
|
||||
import time
|
||||
max_retries = 60 # 5 minutes
|
||||
for i in range(max_retries):
|
||||
print(f"Checking status (attempt {i+1}/{max_retries})...")
|
||||
result = service.get_task_info(task_id)
|
||||
|
||||
# 解析结果
|
||||
if isinstance(result, str):
|
||||
try:
|
||||
result = json.loads(result)
|
||||
except:
|
||||
pass
|
||||
|
||||
if isinstance(result, dict):
|
||||
data_obj = result.get('Data', result)
|
||||
else:
|
||||
data_obj = result
|
||||
|
||||
task_status = data_obj.get('TaskStatus')
|
||||
if not task_status:
|
||||
task_status = data_obj.get('Status')
|
||||
|
||||
print(f"Current status: {task_status}")
|
||||
|
||||
if task_status in ['COMPLETE', 'COMPLETED', 'SUCCEEDED']:
|
||||
print("Task succeeded!")
|
||||
print(f"Full Result: {json.dumps(data_obj, indent=2, ensure_ascii=False)}")
|
||||
|
||||
# 尝试解析 Transcription
|
||||
task_result = data_obj.get('Result', {})
|
||||
transcription_data = task_result.get('Transcription', {})
|
||||
|
||||
if isinstance(transcription_data, str) and transcription_data.startswith('http'):
|
||||
import requests
|
||||
print(f"Downloading transcription from {transcription_data}")
|
||||
t_resp = requests.get(transcription_data)
|
||||
if t_resp.status_code == 200:
|
||||
content = t_resp.json()
|
||||
print(f"Downloaded content structure keys: {content.keys()}")
|
||||
# print(f"Content sample: {json.dumps(content, indent=2, ensure_ascii=False)[:500]}...")
|
||||
else:
|
||||
print(f"Failed to download: {t_resp.status_code}")
|
||||
|
||||
break
|
||||
elif task_status == 'FAILED':
|
||||
print(f"Task failed: {data_obj}")
|
||||
break
|
||||
|
||||
time.sleep(5)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_tingwu_transcription()
|
||||
34
backend/ai_services/migrations/0001_initial.py
Normal file
34
backend/ai_services/migrations/0001_initial.py
Normal file
@@ -0,0 +1,34 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-11 05:11
|
||||
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TranscriptionTask',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||
('file_url', models.URLField(max_length=1024, verbose_name='文件链接')),
|
||||
('task_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='听悟任务ID')),
|
||||
('status', models.CharField(choices=[('PENDING', '等待中'), ('PROCESSING', '处理中'), ('SUCCEEDED', '成功'), ('FAILED', '失败')], default='PENDING', max_length=20, verbose_name='状态')),
|
||||
('transcription', models.TextField(blank=True, null=True, verbose_name='逐字稿')),
|
||||
('summary', models.TextField(blank=True, null=True, verbose_name='AI总结')),
|
||||
('error_message', models.TextField(blank=True, null=True, verbose_name='错误信息')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '转写任务',
|
||||
'verbose_name_plural': '转写任务',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-11 05:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai_services', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transcriptiontask',
|
||||
name='evaluation',
|
||||
field=models.TextField(blank=True, null=True, verbose_name='AI评语'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transcriptiontask',
|
||||
name='score',
|
||||
field=models.IntegerField(blank=True, help_text='基于转写内容的评分', null=True, verbose_name='AI评分'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-11 12:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai_services', '0002_transcriptiontask_evaluation_transcriptiontask_score'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transcriptiontask',
|
||||
name='auto_chapters_data',
|
||||
field=models.JSONField(blank=True, help_text='阿里云返回的AutoChapters完整JSON', null=True, verbose_name='章节原始数据'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transcriptiontask',
|
||||
name='summary_data',
|
||||
field=models.JSONField(blank=True, help_text='阿里云返回的Summarization完整JSON', null=True, verbose_name='总结原始数据'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='transcriptiontask',
|
||||
name='transcription_data',
|
||||
field=models.JSONField(blank=True, help_text='阿里云返回的Transcription完整JSON', null=True, verbose_name='转写原始数据'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-11 12:44
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai_services', '0003_transcriptiontask_auto_chapters_data_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='transcriptiontask',
|
||||
name='evaluation',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='transcriptiontask',
|
||||
name='score',
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='AIEvaluation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('score', models.IntegerField(blank=True, help_text='0-100分', null=True, verbose_name='AI评分')),
|
||||
('evaluation', models.TextField(blank=True, null=True, verbose_name='AI评语')),
|
||||
('model_selection', models.CharField(default='qwen-plus', help_text='例如: qwen-plus, qwen-turbo, qwen-max', max_length=50, verbose_name='模型选择')),
|
||||
('prompt', models.TextField(default='你是一个专业的评分助手。请根据提供的转写内容,对内容质量、逻辑清晰度、语言表达等方面进行综合评分(0-100分),并给出详细的评语。请以JSON格式返回,包含"score"和"evaluation"字段。', help_text='用于指导AI评分的提示词', verbose_name='评分提示词')),
|
||||
('raw_response', models.JSONField(blank=True, help_text='大模型返回的完整JSON', null=True, verbose_name='原始响应')),
|
||||
('reasoning', models.TextField(blank=True, help_text='AI的推理过程(如果有)', null=True, verbose_name='推理过程')),
|
||||
('status', models.CharField(choices=[('PENDING', '等待中'), ('PROCESSING', '生成中'), ('COMPLETED', '已完成'), ('FAILED', '失败')], default='PENDING', max_length=20, verbose_name='评估状态')),
|
||||
('error_message', models.TextField(blank=True, null=True, verbose_name='错误信息')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('task', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='ai_evaluation', to='ai_services.transcriptiontask', verbose_name='关联任务')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'AI智能评估',
|
||||
'verbose_name_plural': 'AI智能评估',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-11 13:00
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai_services', '0004_remove_transcriptiontask_evaluation_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AIEvaluationTemplate',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(help_text='例如:销售话术评分、逻辑性分析', max_length=100, verbose_name='模板名称')),
|
||||
('model_selection', models.CharField(default='qwen-plus', help_text='例如: qwen-plus, qwen-turbo, qwen-max', max_length=50, verbose_name='模型选择')),
|
||||
('prompt', models.TextField(default='你是一个专业的评分助手。请根据提供的转写内容,对内容质量、逻辑清晰度、语言表达等方面进行综合评分(0-100分),并给出详细的评语。请以JSON格式返回,包含"score"和"evaluation"字段。', help_text='用于指导AI评分的提示词', verbose_name='评分提示词')),
|
||||
('is_active', models.BooleanField(default=True, help_text='启用后,新的转写任务完成后将自动使用此模板进行评估', verbose_name='是否启用')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'AI评估模板',
|
||||
'verbose_name_plural': 'AI评估模板',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='aievaluation',
|
||||
options={'ordering': ['-created_at'], 'verbose_name': 'AI评估结果', 'verbose_name_plural': 'AI评估结果'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aievaluation',
|
||||
name='model_selection',
|
||||
field=models.CharField(default='qwen-plus', max_length=50, verbose_name='模型选择'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aievaluation',
|
||||
name='prompt',
|
||||
field=models.TextField(verbose_name='评分提示词'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aievaluation',
|
||||
name='task',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_evaluations', to='ai_services.transcriptiontask', verbose_name='关联任务'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='aievaluation',
|
||||
name='template',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='evaluations', to='ai_services.aievaluationtemplate', verbose_name='使用的模板'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-11 14:10
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai_services', '0005_aievaluationtemplate_alter_aievaluation_options_and_more'),
|
||||
('competition', '0003_competition_project_visibility'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='transcriptiontask',
|
||||
name='project',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='transcription_tasks', to='competition.project', verbose_name='关联参赛项目'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-11 15:03
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai_services', '0006_transcriptiontask_project'),
|
||||
('competition', '0003_competition_project_visibility'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='aievaluationtemplate',
|
||||
name='score_dimension',
|
||||
field=models.ForeignKey(blank=True, help_text='如果同步到比赛评分,优先使用此维度。未填写则默认使用"AI Rating"或包含"AI"的维度', null=True, on_delete=django.db.models.deletion.SET_NULL, to='competition.scoredimension', verbose_name='关联评分维度'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-17 15:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai_services', '0007_aievaluationtemplate_score_dimension'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='aievaluationtemplate',
|
||||
name='is_default',
|
||||
field=models.BooleanField(default=False, help_text='默认模板会评价所有比赛,非默认模板且未关联评分维度时不会自动评价', verbose_name='是否为默认模板'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-18 12:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ai_services', '0008_add_is_default_to_template'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='aievaluation',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='aievaluationtemplate',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
]
|
||||
0
backend/ai_services/migrations/__init__.py
Normal file
0
backend/ai_services/migrations/__init__.py
Normal file
150
backend/ai_services/models.py
Normal file
150
backend/ai_services/models.py
Normal file
@@ -0,0 +1,150 @@
|
||||
import uuid
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
class TranscriptionTask(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
PENDING = 'PENDING', _('等待中')
|
||||
PROCESSING = 'PROCESSING', _('处理中')
|
||||
SUCCEEDED = 'SUCCEEDED', _('成功')
|
||||
FAILED = 'FAILED', _('失败')
|
||||
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
file_url = models.URLField(verbose_name=_('文件链接'), max_length=1024)
|
||||
task_id = models.CharField(verbose_name=_('听悟任务ID'), max_length=100, blank=True, null=True)
|
||||
status = models.CharField(
|
||||
verbose_name=_('状态'),
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.PENDING
|
||||
)
|
||||
# 存储阿里云听悟返回的原始 JSON 结构
|
||||
transcription_data = models.JSONField(verbose_name=_('转写原始数据'), blank=True, null=True, help_text=_('阿里云返回的Transcription完整JSON'))
|
||||
summary_data = models.JSONField(verbose_name=_('总结原始数据'), blank=True, null=True, help_text=_('阿里云返回的Summarization完整JSON'))
|
||||
auto_chapters_data = models.JSONField(verbose_name=_('章节原始数据'), blank=True, null=True, help_text=_('阿里云返回的AutoChapters完整JSON'))
|
||||
|
||||
project = models.ForeignKey(
|
||||
'competition.Project',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='transcription_tasks',
|
||||
verbose_name=_('关联参赛项目')
|
||||
)
|
||||
|
||||
transcription = models.TextField(verbose_name=_('逐字稿'), blank=True, null=True)
|
||||
summary = models.TextField(verbose_name=_('AI总结'), blank=True, null=True)
|
||||
|
||||
# 已解耦到 AIEvaluation 模型
|
||||
# score = models.IntegerField(verbose_name=_('AI评分'), blank=True, null=True, help_text=_('基于转写内容的评分'))
|
||||
# evaluation = models.TextField(verbose_name=_('AI评语'), blank=True, null=True)
|
||||
|
||||
error_message = models.TextField(verbose_name=_('错误信息'), blank=True, null=True)
|
||||
created_at = models.DateTimeField(verbose_name=_('创建时间'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(verbose_name=_('更新时间'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('转写任务')
|
||||
verbose_name_plural = _('转写任务')
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.id} - {self.get_status_display()}"
|
||||
|
||||
|
||||
class AIEvaluationTemplate(models.Model):
|
||||
name = models.CharField(verbose_name=_('模板名称'), max_length=100, help_text=_('例如:销售话术评分、逻辑性分析'))
|
||||
model_selection = models.CharField(
|
||||
verbose_name=_('模型选择'),
|
||||
max_length=50,
|
||||
default='qwen-plus',
|
||||
help_text=_('例如: qwen-plus, qwen-turbo, qwen-max')
|
||||
)
|
||||
prompt = models.TextField(
|
||||
verbose_name=_('评分提示词'),
|
||||
default='你是一个专业的评分助手。请根据提供的转写内容,对内容质量、逻辑清晰度、语言表达等方面进行综合评分(0-100分),并给出详细的评语。请以JSON格式返回,包含"score"和"evaluation"字段。',
|
||||
help_text=_('用于指导AI评分的提示词')
|
||||
)
|
||||
score_dimension = models.ForeignKey(
|
||||
'competition.ScoreDimension',
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_('关联评分维度'),
|
||||
help_text=_('如果同步到比赛评分,优先使用此维度。未填写则默认使用"AI Rating"或包含"AI"的维度')
|
||||
)
|
||||
is_default = models.BooleanField(
|
||||
verbose_name=_('是否为默认模板'),
|
||||
default=False,
|
||||
help_text=_('默认模板会评价所有比赛,非默认模板且未关联评分维度时不会自动评价')
|
||||
)
|
||||
is_active = models.BooleanField(verbose_name=_('是否启用'), default=True, help_text=_('启用后,新的转写任务完成后将自动使用此模板进行评估'))
|
||||
|
||||
created_at = models.DateTimeField(verbose_name=_('创建时间'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(verbose_name=_('更新时间'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('AI评估模板')
|
||||
verbose_name_plural = _('AI评估模板')
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class AIEvaluation(models.Model):
|
||||
class Status(models.TextChoices):
|
||||
PENDING = 'PENDING', _('等待中')
|
||||
PROCESSING = 'PROCESSING', _('生成中')
|
||||
COMPLETED = 'COMPLETED', _('已完成')
|
||||
FAILED = 'FAILED', _('失败')
|
||||
|
||||
task = models.ForeignKey(
|
||||
TranscriptionTask,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='ai_evaluations',
|
||||
verbose_name=_('关联任务')
|
||||
)
|
||||
template = models.ForeignKey(
|
||||
AIEvaluationTemplate,
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='evaluations',
|
||||
verbose_name=_('使用的模板')
|
||||
)
|
||||
|
||||
# 评分与评语
|
||||
score = models.IntegerField(verbose_name=_('AI评分'), blank=True, null=True, help_text=_('0-100分'))
|
||||
evaluation = models.TextField(verbose_name=_('AI评语'), blank=True, null=True)
|
||||
|
||||
# 记录当时的配置 (快照)
|
||||
model_selection = models.CharField(
|
||||
verbose_name=_('模型选择'),
|
||||
max_length=50,
|
||||
default='qwen-plus'
|
||||
)
|
||||
prompt = models.TextField(verbose_name=_('评分提示词'))
|
||||
|
||||
# 原始数据与推理
|
||||
raw_response = models.JSONField(verbose_name=_('原始响应'), blank=True, null=True, help_text=_('大模型返回的完整JSON'))
|
||||
reasoning = models.TextField(verbose_name=_('推理过程'), blank=True, null=True, help_text=_('AI的推理过程(如果有)'))
|
||||
|
||||
status = models.CharField(
|
||||
verbose_name=_('评估状态'),
|
||||
max_length=20,
|
||||
choices=Status.choices,
|
||||
default=Status.PENDING
|
||||
)
|
||||
error_message = models.TextField(verbose_name=_('错误信息'), blank=True, null=True)
|
||||
|
||||
created_at = models.DateTimeField(verbose_name=_('创建时间'), auto_now_add=True)
|
||||
updated_at = models.DateTimeField(verbose_name=_('更新时间'), auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('AI评估结果')
|
||||
verbose_name_plural = _('AI评估结果')
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return f"Evaluation for Task {self.task.id} ({self.template.name if self.template else 'Custom'})"
|
||||
28
backend/ai_services/serializers.py
Normal file
28
backend/ai_services/serializers.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from rest_framework import serializers
|
||||
from .models import TranscriptionTask, AIEvaluation, AIEvaluationTemplate
|
||||
|
||||
class AIEvaluationTemplateSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = AIEvaluationTemplate
|
||||
fields = ['id', 'name', 'model_selection', 'prompt', 'is_active', 'created_at']
|
||||
|
||||
class AIEvaluationSerializer(serializers.ModelSerializer):
|
||||
template = AIEvaluationTemplateSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = AIEvaluation
|
||||
fields = ['id', 'template', 'score', 'evaluation', 'model_selection', 'prompt', 'reasoning', 'status', 'error_message', 'created_at', 'updated_at']
|
||||
|
||||
class TranscriptionTaskSerializer(serializers.ModelSerializer):
|
||||
ai_evaluations = AIEvaluationSerializer(many=True, read_only=True)
|
||||
project_title = serializers.CharField(source='project.title', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = TranscriptionTask
|
||||
fields = ['id', 'file_url', 'task_id', 'status', 'transcription', 'summary', 'error_message', 'created_at', 'updated_at', 'transcription_data', 'summary_data', 'auto_chapters_data', 'ai_evaluations', 'project', 'project_title']
|
||||
read_only_fields = ['id', 'file_url', 'task_id', 'status', 'transcription', 'summary', 'error_message', 'created_at', 'updated_at', 'transcription_data', 'summary_data', 'auto_chapters_data', 'ai_evaluations', 'project_title']
|
||||
|
||||
class TranscriptionUploadSerializer(serializers.Serializer):
|
||||
file = serializers.FileField(help_text="上传的音频文件", required=False)
|
||||
file_url = serializers.URLField(help_text="音频文件的URL地址", required=False)
|
||||
project_id = serializers.IntegerField(help_text="关联的参赛项目ID", required=False)
|
||||
420
backend/ai_services/services.py
Normal file
420
backend/ai_services/services.py
Normal file
@@ -0,0 +1,420 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
import oss2
|
||||
from aliyunsdkcore.client import AcsClient
|
||||
from aliyunsdkcore.acs_exception.exceptions import ClientException, ServerException
|
||||
# 尝试导入最新的 API 版本,如果有问题可能需要调整
|
||||
try:
|
||||
from aliyunsdktingwu.request.v20230930 import CreateTaskRequest, GetTaskInfoRequest
|
||||
except ImportError:
|
||||
# Fallback or error handling if version differs
|
||||
pass
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .models import TranscriptionTask, AIEvaluation, AIEvaluationTemplate
|
||||
|
||||
class AliyunTingwuService:
|
||||
def __init__(self):
|
||||
self.access_key_id = settings.ALIYUN_ACCESS_KEY_ID
|
||||
self.access_key_secret = settings.ALIYUN_ACCESS_KEY_SECRET
|
||||
self.oss_bucket_name = settings.ALIYUN_OSS_BUCKET_NAME
|
||||
self.oss_endpoint = settings.ALIYUN_OSS_ENDPOINT
|
||||
self.tingwu_app_key = settings.ALIYUN_TINGWU_APP_KEY
|
||||
self.region_id = "cn-shanghai" # 听悟服务区域,根据文档应与OSS区域一致,或者使用 'cn-beijing'
|
||||
|
||||
# 初始化 OSS Bucket
|
||||
if self.access_key_id and self.access_key_secret and self.oss_endpoint:
|
||||
auth = oss2.Auth(self.access_key_id, self.access_key_secret)
|
||||
self.bucket = oss2.Bucket(auth, self.oss_endpoint, self.oss_bucket_name)
|
||||
else:
|
||||
self.bucket = None
|
||||
logger.warning("Aliyun OSS configuration missing.")
|
||||
|
||||
# 初始化听悟 Client
|
||||
if self.access_key_id and self.access_key_secret:
|
||||
self.client = AcsClient(
|
||||
self.access_key_id,
|
||||
self.access_key_secret,
|
||||
self.region_id
|
||||
)
|
||||
# 显式添加听悟服务的 Endpoint 映射,解决 EndpointResolvingError
|
||||
# 听悟 API 的服务接入点通常是 tingwu.cn-beijing.aliyuncs.com
|
||||
# 但新版听悟 API (tingwu.aliyuncs.com) 可能不同,需根据实际情况添加
|
||||
# 这里添加一个通用的 Endpoint 映射
|
||||
try:
|
||||
# 尝试为 tingwu 产品设置 Endpoint
|
||||
# 注意:听悟服务主要部署在北京,Endpoint 通常为 tingwu.cn-beijing.aliyuncs.com
|
||||
# 如果您的服务在上海,也可能需要连接到北京的接入点
|
||||
self.client.add_endpoint(self.region_id, "tingwu", "tingwu.cn-beijing.aliyuncs.com")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to add endpoint: {e}")
|
||||
|
||||
else:
|
||||
self.client = None
|
||||
logger.warning("Aliyun AccessKey configuration missing.")
|
||||
|
||||
def upload_to_oss(self, file_obj, file_name, day=7):
|
||||
"""
|
||||
上传文件到 OSS 并返回带签名的 URL
|
||||
默认生成有效期为 7 天 (3600 * 24 * day) 的签名URL,方便评委在一段时间内都能播放。
|
||||
"""
|
||||
if not self.bucket:
|
||||
raise Exception("OSS Client not initialized")
|
||||
|
||||
try:
|
||||
# 上传文件
|
||||
# file_obj 应该是打开的文件对象或字节流
|
||||
self.bucket.put_object(file_name, file_obj)
|
||||
|
||||
# 生成签名 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}")
|
||||
raise e
|
||||
|
||||
def create_transcription_task(self, file_url, language="cn"):
|
||||
"""
|
||||
创建听悟转写任务
|
||||
"""
|
||||
if not self.client:
|
||||
raise Exception("Tingwu Client not initialized")
|
||||
|
||||
request = CreateTaskRequest.CreateTaskRequest()
|
||||
|
||||
# 针对阿里云 SDK 不同版本的兼容性处理
|
||||
# "type" 参数是听悟 API (ROA 风格) 的必填项,用于指定任务类型
|
||||
# 根据官方文档,离线任务的 type 通常就是 'offline'
|
||||
request.add_query_param('type', 'offline')
|
||||
|
||||
# 构造请求体 (Body)
|
||||
# 根据听悟 API 文档,AppKey, Input, Parameters 应位于 JSON Body 中
|
||||
# 而不是 Query Parameter
|
||||
body = {
|
||||
"AppKey": self.tingwu_app_key,
|
||||
"Input": {
|
||||
"FileUrl": file_url,
|
||||
"SourceLanguage": language,
|
||||
"TaskKey": str(uuid.uuid4())
|
||||
},
|
||||
"Parameters": {
|
||||
"Transcoding": {
|
||||
"TargetAudioFormat": "mp3"
|
||||
},
|
||||
"Transcription": {
|
||||
"DiarizationEnabled": True,
|
||||
"ChannelId": 0
|
||||
},
|
||||
"TranscriptionEnabled": True,
|
||||
"AutoChaptersEnabled": True,
|
||||
"SummarizationEnabled": True,
|
||||
"Summarization": {
|
||||
"Types": ["Paragraph", "Conversational", "QuestionsAnswering", "MindMap"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# 设置 Body 内容
|
||||
request.set_content(json.dumps(body))
|
||||
request.add_header('Content-Type', 'application/json')
|
||||
|
||||
# 强制设置 Endpoint,避免 SDK.EndpointResolvingError
|
||||
# 听悟目前主要服务点在北京
|
||||
request.set_endpoint("tingwu.cn-beijing.aliyuncs.com")
|
||||
|
||||
# 显式设置 Method 为 PUT
|
||||
request.set_method('PUT')
|
||||
|
||||
try:
|
||||
response = self.client.do_action_with_exception(request)
|
||||
return json.loads(response)
|
||||
except (ClientException, ServerException) as e:
|
||||
logger.error(f"Tingwu CreateTask failed: {e}")
|
||||
raise e
|
||||
|
||||
def get_task_info(self, task_id):
|
||||
"""
|
||||
查询任务状态和结果
|
||||
"""
|
||||
if not self.client:
|
||||
raise Exception("Tingwu Client not initialized")
|
||||
|
||||
request = GetTaskInfoRequest.GetTaskInfoRequest()
|
||||
request.set_TaskId(task_id)
|
||||
|
||||
try:
|
||||
response = self.client.do_action_with_exception(request)
|
||||
return json.loads(response)
|
||||
except (ClientException, ServerException) as e:
|
||||
logger.error(f"Tingwu GetTaskInfo failed: {e}")
|
||||
raise e
|
||||
|
||||
def parse_and_update_task(self, task, result):
|
||||
"""
|
||||
解析听悟结果并更新任务
|
||||
:param task: TranscriptionTask 实例
|
||||
:param result: get_task_info 返回的完整 JSON (或 Data 部分)
|
||||
"""
|
||||
# 记录之前的状态,用于判断是否是首次完成
|
||||
previous_status = task.status
|
||||
|
||||
# 1. 提取 Data 对象
|
||||
if isinstance(result, dict):
|
||||
data_obj = result.get('Data', result)
|
||||
else:
|
||||
data_obj = result
|
||||
|
||||
if not isinstance(data_obj, dict):
|
||||
logger.error(f"Unexpected data format: {type(data_obj)}")
|
||||
return
|
||||
|
||||
# 2. 更新状态
|
||||
task_status = data_obj.get('TaskStatus') or data_obj.get('Status')
|
||||
if task_status in ['COMPLETE', 'COMPLETED', 'SUCCEEDED']:
|
||||
task.status = 'SUCCEEDED' # 使用字符串引用,避免导入模型循环引用
|
||||
elif task_status == 'FAILED':
|
||||
task.status = 'FAILED'
|
||||
task.error_message = data_obj.get('TaskStatusText', data_obj.get('Message', 'Unknown error'))
|
||||
task.save()
|
||||
return
|
||||
else:
|
||||
# 仍在处理中,不更新内容
|
||||
return
|
||||
|
||||
# 3. 解析结果
|
||||
task_result = data_obj.get('Result', {})
|
||||
|
||||
# 兼容处理:如果 Result 为空,或者不存在,尝试直接使用 data_obj 作为结果源
|
||||
# 某些情况下,Summarization/AutoChapters 可能直接位于 Data 层级
|
||||
if not task_result:
|
||||
task_result = data_obj
|
||||
|
||||
# 辅助函数:从源字典或其 Result 子字典中获取字段
|
||||
def get_data_field(source, key):
|
||||
# 1. 尝试直接从 task_result 获取 (如果 task_result 就是 Data 本身,这里也会生效)
|
||||
if isinstance(source, dict) and key in source:
|
||||
return source[key]
|
||||
# 2. 如果 source 是 Data,尝试从 source['Result'] 获取
|
||||
if isinstance(source, dict) and 'Result' in source and isinstance(source['Result'], dict):
|
||||
if key in source['Result']:
|
||||
return source['Result'][key]
|
||||
return None
|
||||
|
||||
# --- A. 处理逐字稿 (Transcription) ---
|
||||
transcription_data = get_data_field(task_result, 'Transcription') or get_data_field(data_obj, 'Transcription') or {}
|
||||
|
||||
# 处理 URL 下载
|
||||
if isinstance(transcription_data, str) and transcription_data.startswith('http'):
|
||||
try:
|
||||
import requests
|
||||
t_resp = requests.get(transcription_data)
|
||||
if t_resp.status_code == 200:
|
||||
transcription_data = t_resp.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Download transcription failed: {e}")
|
||||
transcription_data = {}
|
||||
elif isinstance(transcription_data, dict) and 'TranscriptionUrl' in transcription_data:
|
||||
try:
|
||||
import requests
|
||||
t_resp = requests.get(transcription_data['TranscriptionUrl'])
|
||||
if t_resp.status_code == 200:
|
||||
transcription_data = t_resp.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Download transcription url failed: {e}")
|
||||
|
||||
# 保存原始数据
|
||||
task.transcription_data = transcription_data
|
||||
|
||||
# 提取文本
|
||||
# 结构: {"Transcription": {"Paragraphs": [{"Words": [{"Text": "..."}]}]}}
|
||||
# 或直接 {"Paragraphs": ...}
|
||||
content_source = transcription_data
|
||||
if 'Transcription' in content_source and isinstance(content_source['Transcription'], dict):
|
||||
content_source = content_source['Transcription']
|
||||
|
||||
paragraphs = content_source.get('Paragraphs', [])
|
||||
full_text_lines = []
|
||||
|
||||
if paragraphs and isinstance(paragraphs, list):
|
||||
for p in paragraphs:
|
||||
# 尝试从 Words 中提取
|
||||
words = p.get('Words', [])
|
||||
if words:
|
||||
line_text = "".join([str(w.get('Text', '')) for w in words])
|
||||
full_text_lines.append(line_text)
|
||||
# 兼容旧结构或直接 Text
|
||||
elif 'Text' in p:
|
||||
full_text_lines.append(p['Text'])
|
||||
|
||||
if full_text_lines:
|
||||
task.transcription = "\n".join(full_text_lines)
|
||||
|
||||
# --- B. 处理 AI 总结 (Summarization) ---
|
||||
summarization = get_data_field(task_result, 'Summarization') or get_data_field(data_obj, 'Summarization') or {}
|
||||
|
||||
# 处理 URL 下载
|
||||
if isinstance(summarization, str) and summarization.startswith('http'):
|
||||
try:
|
||||
import requests
|
||||
s_resp = requests.get(summarization)
|
||||
if s_resp.status_code == 200:
|
||||
summarization = s_resp.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Download summarization failed: {e}")
|
||||
summarization = {}
|
||||
|
||||
# 保存原始数据
|
||||
task.summary_data = summarization
|
||||
|
||||
# 提取文本 (MindMapSummary)
|
||||
# 结构: {"MindMapSummary": [{"Title": "...", "Topic": [...]}]}
|
||||
# 移除了原先的 summary_text 拼接逻辑
|
||||
|
||||
# --- C. 处理章节 (AutoChapters) ---
|
||||
auto_chapters = get_data_field(task_result, 'AutoChapters') or get_data_field(data_obj, 'AutoChapters') or []
|
||||
|
||||
# 处理 URL 下载
|
||||
if isinstance(auto_chapters, str) and auto_chapters.startswith('http'):
|
||||
try:
|
||||
import requests
|
||||
ac_resp = requests.get(auto_chapters)
|
||||
if ac_resp.status_code == 200:
|
||||
auto_chapters = ac_resp.json()
|
||||
except Exception as e:
|
||||
logger.error(f"Download auto chapters failed: {e}")
|
||||
auto_chapters = []
|
||||
|
||||
# 保存原始数据
|
||||
task.auto_chapters_data = auto_chapters
|
||||
|
||||
# 保存任务,确保原始数据已写入数据库
|
||||
task.save()
|
||||
|
||||
# 调用大模型生成总结 (如果 summary_data 或 auto_chapters_data 存在)
|
||||
if task.summary_data or task.auto_chapters_data:
|
||||
try:
|
||||
# 设置占位状态
|
||||
task.summary = "AI总结生成当中..."
|
||||
task.save(update_fields=['summary'])
|
||||
|
||||
# 异步执行总结
|
||||
import threading
|
||||
from .bailian_service import BailianService
|
||||
|
||||
def async_summarize_in_service(task_id):
|
||||
try:
|
||||
# 重新获取 task 以避免线程安全问题
|
||||
from .models import TranscriptionTask
|
||||
t = TranscriptionTask.objects.get(id=task_id)
|
||||
bailian_service = BailianService()
|
||||
bailian_service.summarize_task(t)
|
||||
except Exception as e:
|
||||
logger.error(f"Async summary generation failed in service: {e}")
|
||||
|
||||
threading.Thread(target=async_summarize_in_service, args=(task.id,)).start()
|
||||
logger.info(f"Triggered async summary generation for task {task.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to trigger AI summarization: {e}")
|
||||
|
||||
# 4. 自动触发 AI 评估 (如果任务首次成功且有启用的模板)
|
||||
if previous_status != 'SUCCEEDED' and task.status == 'SUCCEEDED' and task.transcription:
|
||||
# 同样改为异步触发,传递 task.id 以避免线程中的对象状态问题
|
||||
import threading
|
||||
threading.Thread(target=self.trigger_ai_evaluations, args=(task.id,)).start()
|
||||
|
||||
def trigger_ai_evaluations(self, task_id):
|
||||
"""
|
||||
根据启用的模板自动触发 AI 评估
|
||||
|
||||
逻辑:
|
||||
1. 如果模板关联了评分维度(s score_dimension),只对关联了相同维度的比赛进行评估
|
||||
2. 如果模板未关联评分维度:
|
||||
- 如果是默认模板(is_default=True),评价所有比赛
|
||||
- 否则不进行自动评价
|
||||
"""
|
||||
try:
|
||||
# 在线程中重新获取 task 对象,并预加载 project,避免懒加载导致的线程数据库连接问题
|
||||
from .models import TranscriptionTask
|
||||
task = TranscriptionTask.objects.select_related('project', 'project__competition').get(id=task_id)
|
||||
except Exception as e:
|
||||
# 兼容处理:如果 task_id 其实是 task 对象(虽然我们上面改了,但防止其他地方调用传错)
|
||||
if hasattr(task_id, 'id'):
|
||||
try:
|
||||
from .models import TranscriptionTask
|
||||
task = TranscriptionTask.objects.select_related('project', 'project__competition').get(id=task_id.id)
|
||||
except:
|
||||
task = task_id
|
||||
else:
|
||||
logger.error(f"Failed to retrieve task {task_id}: {e}")
|
||||
return
|
||||
|
||||
active_templates = AIEvaluationTemplate.objects.filter(is_active=True)
|
||||
if not active_templates.exists():
|
||||
logger.info("No active AI evaluation templates found.")
|
||||
return
|
||||
|
||||
from .bailian_service import BailianService
|
||||
service = BailianService()
|
||||
|
||||
for template in active_templates:
|
||||
# 检查是否已经存在相同的评估,避免重复创建
|
||||
if AIEvaluation.objects.filter(task=task, template=template).exists():
|
||||
logger.info(f"Evaluation for task {task.id} and template {template.name} already exists.")
|
||||
continue
|
||||
|
||||
# 获取任务关联的比赛
|
||||
task_competition = None
|
||||
if task.project and task.project.competition:
|
||||
task_competition = task.project.competition
|
||||
|
||||
# 判断是否应该对此任务进行评估
|
||||
should_evaluate = False
|
||||
|
||||
if template.score_dimension:
|
||||
# 模板关联了评分维度,只对关联了相同维度的比赛进行评估
|
||||
if task_competition:
|
||||
# 获取该比赛下所有关联了相同评分维度的比赛ID列表
|
||||
from competition.models import ScoreDimension
|
||||
related_competition_ids = ScoreDimension.objects.filter(
|
||||
id=template.score_dimension.id
|
||||
).values_list('competition_id', flat=True)
|
||||
|
||||
if task_competition.id in related_competition_ids:
|
||||
should_evaluate = True
|
||||
logger.info(f"Template '{template.name}' is linked to score_dimension, task's competition matches.")
|
||||
else:
|
||||
logger.info(f"Template '{template.name}' is linked to score_dimension, but task's competition does not match. Skipping.")
|
||||
else:
|
||||
logger.info(f"Task {task.id} has no associated competition. Skipping template '{template.name}'.")
|
||||
else:
|
||||
# 模板未关联评分维度,只有默认模板才评价所有比赛
|
||||
if template.is_default:
|
||||
should_evaluate = True
|
||||
logger.info(f"Template '{template.name}' is default template, evaluating all competitions.")
|
||||
else:
|
||||
logger.info(f"Template '{template.name}' is not linked to score_dimension and is not default. Skipping.")
|
||||
|
||||
if not should_evaluate:
|
||||
continue
|
||||
|
||||
# 创建评估记录
|
||||
evaluation = AIEvaluation.objects.create(
|
||||
task=task,
|
||||
template=template,
|
||||
model_selection=template.model_selection,
|
||||
prompt=template.prompt,
|
||||
status=AIEvaluation.Status.PENDING
|
||||
)
|
||||
|
||||
# 触发评估
|
||||
try:
|
||||
service.evaluate_task(evaluation)
|
||||
logger.info(f"Triggered evaluation {evaluation.id} for template {template.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to trigger evaluation {evaluation.id}: {e}")
|
||||
3
backend/ai_services/tests.py
Normal file
3
backend/ai_services/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
11
backend/ai_services/urls.py
Normal file
11
backend/ai_services/urls.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import TranscriptionTaskViewSet, tingwu_callback
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'transcriptions', TranscriptionTaskViewSet)
|
||||
|
||||
urlpatterns = [
|
||||
path('callback/', tingwu_callback, name='tingwu-callback'),
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
364
backend/ai_services/views.py
Normal file
364
backend/ai_services/views.py
Normal file
@@ -0,0 +1,364 @@
|
||||
import logging
|
||||
import uuid
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.decorators import action, api_view, permission_classes, parser_classes
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.parsers import MultiPartParser, FormParser, JSONParser
|
||||
from rest_framework.permissions import AllowAny
|
||||
from django.conf import settings
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
|
||||
from .models import TranscriptionTask, AIEvaluation
|
||||
from .serializers import TranscriptionTaskSerializer, TranscriptionUploadSerializer, AIEvaluationSerializer
|
||||
from .services import AliyunTingwuService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@api_view(['POST'])
|
||||
@permission_classes([AllowAny])
|
||||
def tingwu_callback(request):
|
||||
"""
|
||||
处理阿里云听悟的回调消息
|
||||
"""
|
||||
data = request.data
|
||||
logger.info(f"收到听悟回调: {data}")
|
||||
|
||||
# 1. 处理连通性测试消息
|
||||
# 格式: {"Code": "0", "Data": {"Test": "..."}, "Message": "success", "RequestId": "..."}
|
||||
if isinstance(data, dict) and 'Data' in data and 'Test' in data['Data']:
|
||||
logger.info("收到听悟连通性测试请求")
|
||||
return Response({'message': 'success'}, status=status.HTTP_200_OK)
|
||||
|
||||
# 2. 处理任务完成消息 (根据实际文档或后续调试完善)
|
||||
# 通常会包含 TaskId 和 Status
|
||||
# 注意:阿里云听悟回调的结构可能在 Header 或 Body 中不同,需根据实际情况调整
|
||||
# 这里是一个通用的处理逻辑
|
||||
task_id = data.get('TaskId')
|
||||
task_status = data.get('Status')
|
||||
|
||||
if task_id:
|
||||
try:
|
||||
task = TranscriptionTask.objects.filter(task_id=task_id).first()
|
||||
if task:
|
||||
if task_status == 'COMPLETE':
|
||||
logger.info(f"任务 {task_id} 完成,等待下一次查询刷新")
|
||||
# 可以在这里直接调用 get_task_info 刷新数据,但要注意超时
|
||||
elif task_status == 'FAILED':
|
||||
task.status = TranscriptionTask.Status.FAILED
|
||||
task.error_message = data.get('StatusText', 'Callback reported failure')
|
||||
task.save()
|
||||
else:
|
||||
logger.warning(f"回调收到未知任务ID: {task_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"处理回调异常: {e}")
|
||||
|
||||
return Response({'message': 'success'}, status=status.HTTP_200_OK)
|
||||
|
||||
class TranscriptionTaskViewSet(viewsets.ModelViewSet):
|
||||
queryset = TranscriptionTask.objects.all()
|
||||
serializer_class = TranscriptionTaskSerializer
|
||||
parser_classes = (MultiPartParser, FormParser)
|
||||
|
||||
@extend_schema(
|
||||
request={
|
||||
'multipart/form-data': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'file': {
|
||||
'type': 'string',
|
||||
'format': 'binary'
|
||||
},
|
||||
'file_url': {
|
||||
'type': 'string',
|
||||
'description': '音频文件的URL地址'
|
||||
},
|
||||
'project_id': {
|
||||
'type': 'integer',
|
||||
'description': '关联的参赛项目ID'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses={201: TranscriptionTaskSerializer}
|
||||
)
|
||||
def create(self, request, *args, **kwargs):
|
||||
"""
|
||||
上传音频文件并创建听悟转写任务
|
||||
"""
|
||||
file_obj = request.FILES.get('file')
|
||||
file_url = request.data.get('file_url')
|
||||
project_id = request.data.get('project_id')
|
||||
|
||||
if not file_obj and not file_url:
|
||||
return Response({'error': '请提供文件或文件URL'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
service = AliyunTingwuService()
|
||||
if not service.bucket or not service.client:
|
||||
return Response({'error': '阿里云服务未配置'}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
|
||||
try:
|
||||
oss_url = None
|
||||
if file_obj:
|
||||
# 1. 上传文件到 OSS
|
||||
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)
|
||||
else:
|
||||
# 使用提供的 URL
|
||||
oss_url = file_url
|
||||
|
||||
# 2. 创建数据库记录
|
||||
task_data = {
|
||||
'file_url': oss_url,
|
||||
'status': TranscriptionTask.Status.PENDING
|
||||
}
|
||||
if project_id:
|
||||
try:
|
||||
p_id = int(project_id)
|
||||
# 只有当 ID > 0 时才认为是有效的项目 ID
|
||||
# 避免前端传递 0 或 Swagger 默认值导致的外键约束错误
|
||||
if p_id > 0:
|
||||
task_data['project_id'] = p_id
|
||||
except (ValueError, TypeError):
|
||||
pass # Ignore invalid project_id
|
||||
|
||||
task_record = TranscriptionTask.objects.create(**task_data)
|
||||
logger.info(f"Created TranscriptionTask {task_record.id} with project_id={project_id}")
|
||||
|
||||
# 3. 调用听悟接口创建任务
|
||||
try:
|
||||
tingwu_response = service.create_transcription_task(oss_url)
|
||||
|
||||
# 兼容处理响应结构,通常为 {"Data": {"TaskId": "...", ...}}
|
||||
if 'Data' in tingwu_response and isinstance(tingwu_response['Data'], dict):
|
||||
task_id = tingwu_response['Data'].get('TaskId')
|
||||
else:
|
||||
task_id = tingwu_response.get('TaskId')
|
||||
|
||||
if task_id:
|
||||
task_record.task_id = task_id
|
||||
task_record.status = TranscriptionTask.Status.PROCESSING
|
||||
task_record.save()
|
||||
else:
|
||||
task_record.status = TranscriptionTask.Status.FAILED
|
||||
task_record.error_message = "未能获取 TaskId"
|
||||
task_record.save()
|
||||
return Response({'error': '未能获取 TaskId'}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
except Exception as e:
|
||||
task_record.status = TranscriptionTask.Status.FAILED
|
||||
task_record.error_message = str(e)
|
||||
task_record.save()
|
||||
logger.error(f"创建听悟任务失败: {e}")
|
||||
return Response({'error': f"创建听悟任务失败: {str(e)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
serializer = self.get_serializer(task_record)
|
||||
return Response(serializer.data, status=status.HTTP_201_CREATED)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"处理上传请求失败: {e}")
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
@extend_schema(
|
||||
request={
|
||||
'application/json': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'model_selection': {'type': 'string', 'description': '模型选择'},
|
||||
'prompt': {'type': 'string', 'description': '评分提示词'},
|
||||
}
|
||||
}
|
||||
},
|
||||
responses={200: AIEvaluationSerializer(many=True)}
|
||||
)
|
||||
def evaluate(self, request, pk=None):
|
||||
"""
|
||||
触发AI评估
|
||||
"""
|
||||
task = self.get_object()
|
||||
|
||||
# 1. 如果有 active template,触发所有 active template
|
||||
# 2. 如果请求体提供了 custom prompt,则创建一个 custom evaluation (no template)
|
||||
|
||||
from .models import AIEvaluationTemplate
|
||||
from .bailian_service import BailianService
|
||||
service = BailianService()
|
||||
|
||||
evaluations_to_process = []
|
||||
|
||||
# A. 如果指定了 Prompt/Model,视为手动单次评估
|
||||
model_selection = request.data.get('model_selection')
|
||||
prompt = request.data.get('prompt')
|
||||
|
||||
if prompt:
|
||||
# 创建一个不关联 Template 的评估
|
||||
eval, _ = AIEvaluation.objects.get_or_create(
|
||||
task=task,
|
||||
template=None,
|
||||
defaults={
|
||||
'model_selection': model_selection or 'qwen-plus',
|
||||
'prompt': prompt
|
||||
}
|
||||
)
|
||||
# 更新配置
|
||||
eval.model_selection = model_selection or eval.model_selection
|
||||
eval.prompt = prompt
|
||||
eval.save()
|
||||
evaluations_to_process.append(eval)
|
||||
else:
|
||||
# B. 否则触发所有 Active Templates
|
||||
active_templates = AIEvaluationTemplate.objects.filter(is_active=True)
|
||||
if not active_templates.exists():
|
||||
return Response({'message': 'No active templates and no custom prompt provided'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
for t in active_templates:
|
||||
eval, _ = AIEvaluation.objects.get_or_create(
|
||||
task=task,
|
||||
template=t,
|
||||
defaults={
|
||||
'model_selection': t.model_selection,
|
||||
'prompt': t.prompt
|
||||
}
|
||||
)
|
||||
# 始终更新为模板最新配置? 或者保留历史? 用户意图似乎是"模版搭好...启用...生成几份"
|
||||
# 这里假设触发时应用模板当前配置
|
||||
eval.model_selection = t.model_selection
|
||||
eval.prompt = t.prompt
|
||||
eval.save()
|
||||
evaluations_to_process.append(eval)
|
||||
|
||||
# 执行评估 (改为异步并发执行)
|
||||
# 提取ID列表,避免传递模型对象导致可能的线程问题
|
||||
eval_ids = [e.id for e in evaluations_to_process]
|
||||
|
||||
if eval_ids:
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
def run_evaluations_background(ids):
|
||||
# 在后台线程中重新引入依赖
|
||||
from .models import AIEvaluation
|
||||
from .bailian_service import BailianService
|
||||
|
||||
# 为该线程创建独立的服务实例
|
||||
local_service = BailianService()
|
||||
|
||||
# 获取最新的对象
|
||||
target_evals = AIEvaluation.objects.filter(id__in=ids)
|
||||
|
||||
# 使用线程池并发执行
|
||||
# max_workers=4 可以同时处理4个评估请求
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
executor.map(local_service.evaluate_task, target_evals)
|
||||
|
||||
# 启动后台线程,不阻塞当前 HTTP 请求
|
||||
thread = threading.Thread(target=run_evaluations_background, args=(eval_ids,))
|
||||
thread.daemon = True # 设置为守护线程
|
||||
thread.start()
|
||||
|
||||
# 返回该任务的所有评估结果
|
||||
all_evals = AIEvaluation.objects.filter(task=task)
|
||||
serializer = AIEvaluationSerializer(all_evals, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter("id", OpenApiTypes.UUID, OpenApiParameter.PATH, description="Task ID"),
|
||||
],
|
||||
responses={200: TranscriptionTaskSerializer}
|
||||
)
|
||||
def refresh_status(self, request, pk=None):
|
||||
"""
|
||||
刷新任务状态并获取结果
|
||||
"""
|
||||
task = self.get_object()
|
||||
|
||||
# 允许刷新的条件:
|
||||
# 1. 任务未完成 (PENDING, PROCESSING)
|
||||
# 2. 任务已完成但逐字稿 (transcription) 为空
|
||||
# 3. 任务已完成但 AI总结 (summary) 为空 (新增)
|
||||
|
||||
should_refresh = False
|
||||
if task.status not in [TranscriptionTask.Status.SUCCEEDED, TranscriptionTask.Status.FAILED]:
|
||||
should_refresh = True
|
||||
elif task.status == TranscriptionTask.Status.SUCCEEDED:
|
||||
if not task.transcription or not task.summary:
|
||||
should_refresh = True
|
||||
|
||||
if not should_refresh:
|
||||
serializer = self.get_serializer(task)
|
||||
return Response(serializer.data)
|
||||
|
||||
if not task.task_id:
|
||||
return Response({'error': '任务ID不存在'}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
service = AliyunTingwuService()
|
||||
try:
|
||||
result = service.get_task_info(task.task_id)
|
||||
|
||||
# 兼容处理响应结构 {"Data": {"TaskStatus": "...", "Result": ...}}
|
||||
# 有些情况下 SDK 返回的是 JSON 字符串,需要二次解析
|
||||
if isinstance(result, str):
|
||||
import json
|
||||
try:
|
||||
result = json.loads(result)
|
||||
except:
|
||||
pass
|
||||
|
||||
if isinstance(result, dict):
|
||||
data_obj = result.get('Data', result)
|
||||
else:
|
||||
data_obj = result
|
||||
if not isinstance(data_obj, dict):
|
||||
# 如果 Data 不是字典,可能它本身就是字符串,或者 result 结构更平铺
|
||||
data_obj = result
|
||||
|
||||
# 防御性编程:确保 data_obj 是字典
|
||||
if not isinstance(data_obj, dict):
|
||||
logger.error(f"Unexpected response format: {type(data_obj)} - {data_obj}")
|
||||
return Response({'error': f"Unexpected response format: {type(data_obj)}"}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
# 调用 Service 进行解析和更新
|
||||
service.parse_and_update_task(task, result)
|
||||
|
||||
# 如果任务成功但 AI 总结仍为空 (可能之前解析没触发,或者大模型调用失败)
|
||||
# 再次尝试强制触发 summarize_task (如果原始数据存在)
|
||||
# 注意:service.parse_and_update_task 内部已经尝试异步触发,这里作为补救措施
|
||||
if task.status == TranscriptionTask.Status.SUCCEEDED and not task.summary:
|
||||
if task.summary_data or task.auto_chapters_data:
|
||||
try:
|
||||
# 先设置状态为 "AI总结生成当中..."
|
||||
task.summary = "AI总结生成当中..."
|
||||
task.save(update_fields=['summary'])
|
||||
|
||||
# 异步触发总结生成
|
||||
import threading
|
||||
from .bailian_service import BailianService
|
||||
|
||||
def async_summarize(task_id):
|
||||
try:
|
||||
# 重新获取 task 对象以避免线程问题
|
||||
from .models import TranscriptionTask
|
||||
task_obj = TranscriptionTask.objects.get(id=task_id)
|
||||
bailian_service = BailianService()
|
||||
bailian_service.summarize_task(task_obj)
|
||||
except Exception as e:
|
||||
logger.error(f"Async summary generation failed: {e}")
|
||||
|
||||
threading.Thread(target=async_summarize, args=(task.id,)).start()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Force trigger AI summarization failed: {e}")
|
||||
|
||||
# 重新获取 task 以包含更新后的关联字段
|
||||
task.refresh_from_db()
|
||||
|
||||
serializer = self.get_serializer(task)
|
||||
return Response(serializer.data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"刷新任务状态失败: {e}")
|
||||
return Response({'error': str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
30
backend/check_urls.py
Normal file
30
backend/check_urls.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import os
|
||||
import django
|
||||
from django.urls import reverse
|
||||
from django.conf import settings
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
django.setup()
|
||||
|
||||
links = [
|
||||
"admin:shop_wechatuser_changelist",
|
||||
"admin:shop_salesperson_changelist",
|
||||
"admin:shop_distributor_changelist",
|
||||
"admin:shop_esp32config_changelist",
|
||||
"admin:shop_service_changelist",
|
||||
"admin:shop_VBcourse_changelist",
|
||||
"admin:shop_order_changelist",
|
||||
"admin:shop_serviceorder_changelist",
|
||||
"admin:shop_withdrawal_changelist",
|
||||
"admin:shop_commissionlog_changelist",
|
||||
"admin:shop_wechatpayconfig_changelist",
|
||||
"admin:auth_user_changelist",
|
||||
]
|
||||
|
||||
print("Checking URL patterns...")
|
||||
for link in links:
|
||||
try:
|
||||
url = reverse(link)
|
||||
print(f"[OK] {link} -> {url}")
|
||||
except Exception as e:
|
||||
print(f"[ERROR] {link}: {e}")
|
||||
0
backend/community/__init__.py
Normal file
0
backend/community/__init__.py
Normal file
404
backend/community/admin.py
Normal file
404
backend/community/admin.py
Normal file
@@ -0,0 +1,404 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.urls import path, reverse
|
||||
from django.shortcuts import redirect
|
||||
from unfold.admin import ModelAdmin, TabularInline
|
||||
from unfold.decorators import display
|
||||
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
|
||||
from .admin_actions import export_signups_csv, export_signups_excel
|
||||
|
||||
class ActivitySignupInline(TabularInline):
|
||||
model = ActivitySignup
|
||||
extra = 0
|
||||
readonly_fields = ('signup_time',)
|
||||
fields = ('user', 'status', 'signup_time')
|
||||
autocomplete_fields = ['user']
|
||||
can_delete = True
|
||||
show_change_link = True
|
||||
|
||||
class ReplyInline(TabularInline):
|
||||
model = Reply
|
||||
extra = 0
|
||||
readonly_fields = ('created_at',)
|
||||
fields = ('content', 'author', 'created_at')
|
||||
can_delete = True
|
||||
show_change_link = True
|
||||
|
||||
class TopicMediaInline(TabularInline):
|
||||
model = TopicMedia
|
||||
extra = 0
|
||||
fields = ('file', 'file_url', 'media_type', 'created_at')
|
||||
readonly_fields = ('created_at',)
|
||||
can_delete = True
|
||||
|
||||
class OrderableAdminMixin:
|
||||
"""
|
||||
为 Admin 添加排序功能的 Mixin
|
||||
提供上移、下移按钮,直接交换 order 值
|
||||
"""
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('<path:object_id>/move-up/', self.admin_site.admin_view(self.move_up_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_up'),
|
||||
path('<path:object_id>/move-down/', self.admin_site.admin_view(self.move_down_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_down'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def move_up_view(self, request, object_id):
|
||||
obj = self.get_object(request, object_id)
|
||||
if obj:
|
||||
qs = self.model.objects.all()
|
||||
# 如果模型有 is_pinned 字段,则只在相同置顶状态的记录中交换
|
||||
if hasattr(obj, 'is_pinned'):
|
||||
qs = qs.filter(is_pinned=obj.is_pinned)
|
||||
|
||||
# 找到排在它前面的一个 (order 小于它的最大值)
|
||||
prev_obj = qs.filter(order__lt=obj.order).order_by('-order').first()
|
||||
if prev_obj:
|
||||
# 交换
|
||||
obj.order, prev_obj.order = prev_obj.order, obj.order
|
||||
obj.save()
|
||||
prev_obj.save()
|
||||
self.message_user(request, f"成功将 {obj} 上移")
|
||||
else:
|
||||
# 已经是第一个,或者前面没有更小的 order
|
||||
pass
|
||||
return redirect(request.META.get('HTTP_REFERER', '..'))
|
||||
|
||||
def move_down_view(self, request, object_id):
|
||||
obj = self.get_object(request, object_id)
|
||||
if obj:
|
||||
qs = self.model.objects.all()
|
||||
# 如果模型有 is_pinned 字段,则只在相同置顶状态的记录中交换
|
||||
if hasattr(obj, 'is_pinned'):
|
||||
qs = qs.filter(is_pinned=obj.is_pinned)
|
||||
|
||||
# 找到排在它后面的一个 (order 大于它的最小值)
|
||||
next_obj = qs.filter(order__gt=obj.order).order_by('order').first()
|
||||
if next_obj:
|
||||
# 交换
|
||||
obj.order, next_obj.order = next_obj.order, obj.order
|
||||
obj.save()
|
||||
next_obj.save()
|
||||
self.message_user(request, f"成功将 {obj} 下移")
|
||||
return redirect(request.META.get('HTTP_REFERER', '..'))
|
||||
|
||||
def order_actions(self, obj):
|
||||
# 只有专家用户才显示排序按钮
|
||||
if not getattr(obj, 'is_star', True): # 默认为True是为了兼容其他模型,WeChatUser有is_star字段
|
||||
return "默认排序"
|
||||
|
||||
# 使用 inline style 实现基本样式
|
||||
btn_style = (
|
||||
"display: inline-flex; align-items: center; justify-content: center; "
|
||||
"width: 26px; height: 26px; border-radius: 6px; "
|
||||
"background-color: #f3f4f6; color: #4b5563; text-decoration: none; "
|
||||
"border: 1px solid #e5e7eb; transition: all 0.2s;"
|
||||
)
|
||||
# onmouseover js
|
||||
hover_js = "this.style.backgroundColor='#dbeafe'; this.style.color='#2563eb'; this.style.borderColor='#bfdbfe';"
|
||||
out_js = "this.style.backgroundColor='#f3f4f6'; this.style.color='#4b5563'; this.style.borderColor='#e5e7eb';"
|
||||
|
||||
return format_html(
|
||||
'<div style="display: flex; align-items: center; gap: 6px;">'
|
||||
'<a href="{}" title="上移" style="{}" onmouseover="{}" onmouseout="{}">'
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 15l-6-6-6 6"/></svg>'
|
||||
'</a>'
|
||||
'<span style="font-weight: 700; font-family: system-ui, -apple-system, sans-serif; min-width: 20px; text-align: center; color: #374151; font-size: 13px;">{}</span>'
|
||||
'<a href="{}" title="下移" style="{}" onmouseover="{}" onmouseout="{}">'
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>'
|
||||
'</a>'
|
||||
'</div>',
|
||||
reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_up', args=[obj.pk]),
|
||||
btn_style, hover_js, out_js,
|
||||
obj.order,
|
||||
reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_down', args=[obj.pk]),
|
||||
btn_style, hover_js, out_js,
|
||||
)
|
||||
order_actions.short_description = "排序调节"
|
||||
order_actions.allow_tags = True
|
||||
|
||||
@admin.register(Activity)
|
||||
class ActivityAdmin(ModelAdmin):
|
||||
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', 'author__phone_number')
|
||||
# autocomplete_fields = ['author'] # 暂时注释,避免环境不一致导致报错
|
||||
raw_id_fields = ('author',)
|
||||
inlines = [ActivitySignupInline]
|
||||
|
||||
fieldsets = (
|
||||
('基本信息', {
|
||||
'fields': ('title', 'author', 'description', 'banner', 'banner_url', 'is_visible', 'is_active', 'auto_confirm')
|
||||
}),
|
||||
('费用与时间', {
|
||||
'fields': ('is_paid', 'price', 'start_time', 'end_time', 'location'),
|
||||
'classes': ('tab',)
|
||||
}),
|
||||
('报名设置', {
|
||||
'fields': ('max_participants', 'ask_name', 'ask_phone', 'ask_wechat', 'ask_company', 'signup_form_config'),
|
||||
'description': '勾选需要收集的信息,或者在下方“自定义报名配置”中填写高级JSON配置'
|
||||
}),
|
||||
)
|
||||
|
||||
@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:
|
||||
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', obj.banner.url)
|
||||
elif obj.banner_url:
|
||||
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', obj.banner_url)
|
||||
return "暂无"
|
||||
|
||||
@display(description="报名人数")
|
||||
def signup_count(self, obj):
|
||||
return obj.signups.count()
|
||||
|
||||
@admin.register(ActivitySignup)
|
||||
class ActivitySignupAdmin(ModelAdmin):
|
||||
list_display = ('activity', 'user_info_display', 'signup_time', 'status_label', 'order_link')
|
||||
list_filter = ('status', 'signup_time', 'activity')
|
||||
search_fields = ('user__nickname', 'user__phone_number', 'activity__title')
|
||||
autocomplete_fields = ['activity', 'user']
|
||||
actions = [export_signups_csv, export_signups_excel]
|
||||
|
||||
fieldsets = (
|
||||
('报名详情', {
|
||||
'fields': ('activity', 'user', 'status', 'order', 'signup_info_display')
|
||||
}),
|
||||
('时间信息', {
|
||||
'fields': ('signup_time',),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
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
|
||||
if not obj.signup_info:
|
||||
return "无"
|
||||
|
||||
try:
|
||||
# Format JSON nicely
|
||||
formatted_json = json.dumps(obj.signup_info, indent=2, ensure_ascii=False)
|
||||
return format_html('<pre style="white-space: pre-wrap; word-break: break-all;">{}</pre>', formatted_json)
|
||||
except:
|
||||
return str(obj.signup_info)
|
||||
|
||||
@display(
|
||||
description="状态",
|
||||
label={
|
||||
"pending": "warning",
|
||||
"confirmed": "success",
|
||||
"cancelled": "danger",
|
||||
"unpaid": "secondary",
|
||||
}
|
||||
)
|
||||
def status_label(self, obj):
|
||||
# Auto sync with order status on display
|
||||
if obj.check_payment_status():
|
||||
# If status changed, return new status
|
||||
return obj.status
|
||||
return obj.status
|
||||
|
||||
@display(description="关联订单")
|
||||
def order_link(self, obj):
|
||||
if obj.order:
|
||||
return format_html('<a href="/admin/shop/order/{}/change/">Order #{}</a>', obj.order.id, obj.order.id)
|
||||
return "-"
|
||||
|
||||
@admin.register(Topic)
|
||||
class TopicAdmin(OrderableAdminMixin, ModelAdmin):
|
||||
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', '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')
|
||||
self.message_user(request, f"{rows_updated} 个帖子已通过审核")
|
||||
|
||||
@admin.action(description="批量拒绝")
|
||||
def reject_topics(self, request, queryset):
|
||||
rows_updated = queryset.update(status='rejected')
|
||||
self.message_user(request, f"{rows_updated} 个帖子已拒绝")
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
# 当帖子被置顶时(新建或修改状态),默认将排序值设为0
|
||||
if obj.is_pinned and (not change or 'is_pinned' in form.changed_data):
|
||||
obj.order = 0
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
@admin.action(description="重置排序 (0,1,2... 新帖子在前)")
|
||||
def reset_ordering(self, request, queryset):
|
||||
"""
|
||||
将所有帖子按时间倒序重新分配order值 (0, 1, 2, ...)
|
||||
"""
|
||||
all_objects = Topic.objects.all().order_by('-created_at')
|
||||
for index, obj in enumerate(all_objects):
|
||||
if obj.order != index:
|
||||
obj.order = index
|
||||
obj.save(update_fields=['order'])
|
||||
self.message_user(request, f"成功重置了 {all_objects.count()} 个帖子的排序权重(从0开始)。")
|
||||
|
||||
fieldsets = (
|
||||
('帖子内容', {
|
||||
'fields': ('title', 'status', 'category', 'content', 'is_pinned', 'likes')
|
||||
}),
|
||||
('关联信息', {
|
||||
'fields': ('author', 'related_product', 'related_service', 'related_course'),
|
||||
'description': '可关联 硬件、服务 或 课程,用于技术求助或讨论'
|
||||
}),
|
||||
('统计数据', {
|
||||
'fields': ('view_count', 'order', 'created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
readonly_fields = ('created_at', 'updated_at')
|
||||
|
||||
@display(description="关联项目")
|
||||
def get_related_item(self, obj):
|
||||
if obj.related_product:
|
||||
return f"[硬件] {obj.related_product.name}"
|
||||
if obj.related_service:
|
||||
return f"[服务] {obj.related_service.title}"
|
||||
if obj.related_course:
|
||||
return f"[课程] {obj.related_course.title}"
|
||||
return "-"
|
||||
|
||||
@display(description="回复数")
|
||||
def reply_count(self, obj):
|
||||
return obj.replies.count()
|
||||
|
||||
@admin.register(Reply)
|
||||
class ReplyAdmin(ModelAdmin):
|
||||
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', 'author__phone_number', 'topic__title')
|
||||
autocomplete_fields = ['author', 'topic', 'reply_to']
|
||||
filter_horizontal = ('likes',)
|
||||
list_editable = ('is_pinned',)
|
||||
inlines = [TopicMediaInline]
|
||||
|
||||
fieldsets = (
|
||||
('回复内容', {
|
||||
'fields': ('topic', 'reply_to', 'content', 'likes')
|
||||
}),
|
||||
('发布信息', {
|
||||
'fields': ('author', 'is_pinned', 'created_at')
|
||||
}),
|
||||
)
|
||||
readonly_fields = ('created_at',)
|
||||
|
||||
@display(description="回复者 (手机号/昵称)")
|
||||
def author_info_display(self, obj):
|
||||
if not obj.author:
|
||||
return "-"
|
||||
phone = obj.author.phone_number or "无手机号"
|
||||
nickname = obj.author.nickname or "无昵称"
|
||||
return f"{phone} ({nickname})"
|
||||
|
||||
@display(description="点赞数")
|
||||
def like_count(self, obj):
|
||||
return obj.likes.count()
|
||||
|
||||
@display(description="内容摘要")
|
||||
def short_content(self, obj):
|
||||
return obj.content[:30] + '...' if len(obj.content) > 30 else obj.content
|
||||
|
||||
@admin.register(TopicMedia)
|
||||
class TopicMediaAdmin(ModelAdmin):
|
||||
list_display = ('id', 'media_type', 'file_preview', 'topic', 'reply', 'created_at')
|
||||
list_filter = ('media_type', 'created_at')
|
||||
search_fields = ('file', 'topic__title')
|
||||
autocomplete_fields = ['topic', 'reply']
|
||||
|
||||
@display(description="预览")
|
||||
def file_preview(self, obj):
|
||||
url = ""
|
||||
if obj.file:
|
||||
url = obj.file.url
|
||||
elif obj.file_url:
|
||||
url = obj.file_url
|
||||
|
||||
if obj.media_type == 'image' and url:
|
||||
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', url)
|
||||
return obj.file.name or "外部文件"
|
||||
|
||||
@admin.register(Announcement)
|
||||
class AnnouncementAdmin(ModelAdmin):
|
||||
list_display = ('title', 'image_preview', 'active_label', 'pinned_label', 'priority', 'start_time', 'end_time', 'created_at')
|
||||
list_filter = ('is_active', 'is_pinned', 'created_at')
|
||||
search_fields = ('title', 'content')
|
||||
|
||||
fieldsets = (
|
||||
('公告信息', {
|
||||
'fields': ('title', 'content', 'link_url')
|
||||
}),
|
||||
('图片设置', {
|
||||
'fields': ('image', 'image_url'),
|
||||
'description': '上传图片或填写图片链接,优先显示上传的图片'
|
||||
}),
|
||||
('显示设置', {
|
||||
'fields': ('is_active', 'is_pinned', 'priority'),
|
||||
'classes': ('tab',)
|
||||
}),
|
||||
('排期设置', {
|
||||
'fields': ('start_time', 'end_time'),
|
||||
'classes': ('tab',)
|
||||
}),
|
||||
)
|
||||
|
||||
@display(description="图片预览")
|
||||
def image_preview(self, obj):
|
||||
url = obj.display_image_url
|
||||
if url:
|
||||
return format_html('<img src="{}" height="50" style="border-radius: 4px;" />', url)
|
||||
return "无图片"
|
||||
|
||||
@display(
|
||||
description="状态",
|
||||
label={
|
||||
True: "success",
|
||||
False: "danger",
|
||||
}
|
||||
)
|
||||
def active_label(self, obj):
|
||||
return obj.is_active
|
||||
|
||||
@display(
|
||||
description="置顶",
|
||||
label={
|
||||
True: "warning",
|
||||
False: "default",
|
||||
}
|
||||
)
|
||||
def pinned_label(self, obj):
|
||||
return obj.is_pinned
|
||||
149
backend/community/admin_actions.py
Normal file
149
backend/community/admin_actions.py
Normal file
@@ -0,0 +1,149 @@
|
||||
import csv
|
||||
import json
|
||||
import datetime
|
||||
from django.http import HttpResponse
|
||||
from django.utils.encoding import escape_uri_path
|
||||
|
||||
def flatten_json(y):
|
||||
"""
|
||||
Flatten a nested json object
|
||||
"""
|
||||
out = {}
|
||||
|
||||
def flatten(x, name=''):
|
||||
if type(x) is dict:
|
||||
for a in x:
|
||||
flatten(x[a], name + a + '_')
|
||||
elif type(x) is list:
|
||||
i = 0
|
||||
for a in x:
|
||||
flatten(a, name + str(i) + '_')
|
||||
i += 1
|
||||
else:
|
||||
out[name[:-1]] = x
|
||||
|
||||
flatten(y)
|
||||
return out
|
||||
|
||||
def get_signup_info_keys(queryset):
|
||||
"""
|
||||
Collect all unique keys from the signup_info JSON across the queryset
|
||||
"""
|
||||
keys = set()
|
||||
for obj in queryset:
|
||||
if obj.signup_info and isinstance(obj.signup_info, dict):
|
||||
# Flatten the dictionary first to get all nested keys
|
||||
flat_info = flatten_json(obj.signup_info)
|
||||
keys.update(flat_info.keys())
|
||||
return sorted(list(keys))
|
||||
|
||||
def export_signups_csv(modeladmin, request, queryset):
|
||||
"""
|
||||
Export selected signups to CSV, including flattened JSON fields
|
||||
"""
|
||||
opts = modeladmin.model._meta
|
||||
filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
response = HttpResponse(content_type='text/csv; charset=utf-8-sig')
|
||||
response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}'
|
||||
|
||||
writer = csv.writer(response)
|
||||
|
||||
# Base fields to export
|
||||
base_headers = ['ID', '活动标题', '用户昵称', '用户ID', '报名时间', '状态', '关联订单ID']
|
||||
|
||||
# Get dynamic JSON keys
|
||||
json_keys = get_signup_info_keys(queryset)
|
||||
|
||||
# Write header
|
||||
writer.writerow(base_headers + json_keys)
|
||||
|
||||
# Write data
|
||||
for obj in queryset:
|
||||
row = [
|
||||
str(obj.id),
|
||||
obj.activity.title,
|
||||
obj.user.nickname if obj.user else 'Unknown',
|
||||
str(obj.user.id) if obj.user else '',
|
||||
obj.signup_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
obj.get_status_display(),
|
||||
str(obj.order.id) if obj.order else ''
|
||||
]
|
||||
|
||||
# Add JSON data
|
||||
flat_info = {}
|
||||
if obj.signup_info and isinstance(obj.signup_info, dict):
|
||||
flat_info = flatten_json(obj.signup_info)
|
||||
|
||||
for key in json_keys:
|
||||
val = flat_info.get(key, '')
|
||||
if val is None:
|
||||
val = ''
|
||||
row.append(str(val))
|
||||
|
||||
writer.writerow(row)
|
||||
|
||||
return response
|
||||
|
||||
export_signups_csv.short_description = "导出选中报名记录为 CSV (含详细信息)"
|
||||
|
||||
def export_signups_excel(modeladmin, request, queryset):
|
||||
"""
|
||||
Export selected signups to Excel, including flattened JSON fields
|
||||
"""
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
except ImportError:
|
||||
modeladmin.message_user(request, "请先安装 openpyxl 库以使用 Excel 导出功能", level='error')
|
||||
return
|
||||
|
||||
opts = modeladmin.model._meta
|
||||
filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
|
||||
response = HttpResponse(
|
||||
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}'
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = str(opts.verbose_name)[:31] # Sheet name limit is 31 chars
|
||||
|
||||
# Base fields to export
|
||||
base_headers = ['ID', '活动标题', '用户昵称', '用户ID', '报名时间', '状态', '关联订单ID']
|
||||
|
||||
# Get dynamic JSON keys
|
||||
json_keys = get_signup_info_keys(queryset)
|
||||
|
||||
# Write header
|
||||
ws.append(base_headers + json_keys)
|
||||
|
||||
# Write data
|
||||
for obj in queryset:
|
||||
row = [
|
||||
obj.id,
|
||||
obj.activity.title,
|
||||
obj.user.nickname if obj.user else 'Unknown',
|
||||
obj.user.id if obj.user else '',
|
||||
obj.signup_time.replace(tzinfo=None) if obj.signup_time else '', # Remove tz for Excel
|
||||
obj.get_status_display(),
|
||||
obj.order.id if obj.order else ''
|
||||
]
|
||||
|
||||
# Add JSON data
|
||||
flat_info = {}
|
||||
if obj.signup_info and isinstance(obj.signup_info, dict):
|
||||
flat_info = flatten_json(obj.signup_info)
|
||||
|
||||
for key in json_keys:
|
||||
val = flat_info.get(key, '')
|
||||
if val is None:
|
||||
val = ''
|
||||
row.append(str(val)) # Ensure string for simplicity, or handle types
|
||||
|
||||
ws.append(row)
|
||||
|
||||
wb.save(response)
|
||||
return response
|
||||
|
||||
export_signups_excel.short_description = "导出选中报名记录为 Excel (含详细信息)"
|
||||
5
backend/community/apps.py
Normal file
5
backend/community/apps.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommunityConfig(AppConfig):
|
||||
name = 'community'
|
||||
144
backend/community/migrations/0001_initial.py
Normal file
144
backend/community/migrations/0001_initial.py
Normal file
@@ -0,0 +1,144 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-04 04:57
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('shop', '__first__'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Announcement',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=100, verbose_name='公告标题')),
|
||||
('content', models.TextField(verbose_name='公告内容')),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='announcements/', verbose_name='公告图片')),
|
||||
('image_url', models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='图片链接')),
|
||||
('link_url', models.URLField(blank=True, null=True, verbose_name='跳转链接')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
|
||||
('is_pinned', models.BooleanField(default=False, verbose_name='是否置顶')),
|
||||
('priority', models.IntegerField(default=0, help_text='数字越大越靠前', verbose_name='排序权重')),
|
||||
('start_time', models.DateTimeField(blank=True, null=True, verbose_name='开始展示时间')),
|
||||
('end_time', models.DateTimeField(blank=True, null=True, verbose_name='结束展示时间')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '社区公告',
|
||||
'verbose_name_plural': '社区公告管理',
|
||||
'ordering': ['-is_pinned', '-priority', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Activity',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=100, verbose_name='活动标题')),
|
||||
('description', models.TextField(verbose_name='活动详情')),
|
||||
('banner', models.ImageField(blank=True, null=True, upload_to='activities/banners/', verbose_name='活动Banner图')),
|
||||
('banner_url', models.URLField(blank=True, help_text='可直接填写图片链接,若同时上传图片,将优先显示上传的图片', null=True, verbose_name='活动Banner链接')),
|
||||
('start_time', models.DateTimeField(verbose_name='开始时间')),
|
||||
('end_time', models.DateTimeField(verbose_name='结束时间')),
|
||||
('location', models.CharField(max_length=100, verbose_name='活动地点')),
|
||||
('max_participants', models.IntegerField(default=50, verbose_name='最大报名人数')),
|
||||
('is_paid', models.BooleanField(default=False, verbose_name='是否收费')),
|
||||
('price', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='报名费用')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
|
||||
('is_visible', models.BooleanField(default=True, help_text='关闭后将不在前端列表页显示', verbose_name='是否显示')),
|
||||
('auto_confirm', models.BooleanField(default=False, help_text='开启后,付费活动支付成功或免费活动报名后直接显示报名成功,不需要审核', verbose_name='无需审核')),
|
||||
('ask_name', models.BooleanField(default=False, verbose_name='收集姓名')),
|
||||
('ask_phone', models.BooleanField(default=False, verbose_name='收集手机号')),
|
||||
('ask_wechat', models.BooleanField(default=False, verbose_name='收集微信号')),
|
||||
('ask_company', models.BooleanField(default=False, verbose_name='收集公司/机构')),
|
||||
('signup_form_config', models.JSONField(blank=True, default=list, help_text='JSON格式的高级配置,若填写则优先于上方开关。例如:[{"name": "job", "label": "职位", "type": "text", "required": true}]', verbose_name='自定义报名配置')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '社区活动',
|
||||
'verbose_name_plural': '社区活动管理',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Topic',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200, verbose_name='标题')),
|
||||
('category', models.CharField(choices=[('discussion', '技术讨论'), ('help', '求助问答'), ('share', '经验分享'), ('notice', '官方公告')], default='discussion', max_length=20, verbose_name='分类')),
|
||||
('status', models.CharField(choices=[('pending', '待审核'), ('published', '已发布'), ('rejected', '已拒绝')], default='published', max_length=20, verbose_name='状态')),
|
||||
('content', models.TextField(help_text='支持Markdown格式,支持插入图片', verbose_name='内容')),
|
||||
('view_count', models.IntegerField(default=0, verbose_name='浏览量')),
|
||||
('is_pinned', 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='更新时间')),
|
||||
('order', models.IntegerField(default=0, verbose_name='排序')),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='topics', to='shop.wechatuser', verbose_name='作者')),
|
||||
('likes', models.ManyToManyField(blank=True, related_name='liked_topics', to='shop.wechatuser', verbose_name='点赞用户')),
|
||||
('related_course', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.vccourse', verbose_name='关联课程')),
|
||||
('related_product', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.esp32config', verbose_name='关联硬件')),
|
||||
('related_service', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.service', verbose_name='关联服务')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '论坛帖子',
|
||||
'verbose_name_plural': '论坛帖子管理',
|
||||
'ordering': ['order', '-is_pinned', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Reply',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('content', models.TextField(help_text='支持Markdown格式', verbose_name='回复内容')),
|
||||
('is_pinned', models.BooleanField(default=False, verbose_name='置顶')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='回复时间')),
|
||||
('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='shop.wechatuser', verbose_name='回复者')),
|
||||
('likes', models.ManyToManyField(blank=True, related_name='liked_replies', to='shop.wechatuser', verbose_name='点赞用户')),
|
||||
('reply_to', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='community.reply', verbose_name='回复楼层')),
|
||||
('topic', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='community.topic', verbose_name='所属帖子')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '帖子回复',
|
||||
'verbose_name_plural': '帖子回复管理',
|
||||
'ordering': ['-is_pinned', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TopicMedia',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file', models.FileField(blank=True, null=True, upload_to='community/media/', verbose_name='文件')),
|
||||
('file_url', models.URLField(blank=True, max_length=500, null=True, verbose_name='文件链接')),
|
||||
('media_type', models.CharField(choices=[('image', '图片'), ('video', '视频'), ('file', '文件')], default='image', max_length=10, verbose_name='媒体类型')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='上传时间')),
|
||||
('reply', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='media', to='community.reply', verbose_name='所属回复')),
|
||||
('topic', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='media', to='community.topic', verbose_name='所属帖子')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '论坛媒体资源',
|
||||
'verbose_name_plural': '论坛媒体资源管理',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ActivitySignup',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('signup_time', models.DateTimeField(auto_now_add=True, verbose_name='报名时间')),
|
||||
('signup_info', models.JSONField(blank=True, default=dict, verbose_name='报名信息')),
|
||||
('status', models.CharField(choices=[('unpaid', '待支付'), ('pending', '审核中'), ('confirmed', '报名成功'), ('cancelled', '已取消')], default='confirmed', max_length=20, verbose_name='状态')),
|
||||
('activity', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='signups', to='community.activity', verbose_name='活动')),
|
||||
('order', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='activity_signups', to='shop.order', verbose_name='关联订单')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='activity_signups', to='shop.wechatuser', verbose_name='报名用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '活动报名',
|
||||
'verbose_name_plural': '活动报名管理',
|
||||
'unique_together': {('activity', 'user')},
|
||||
},
|
||||
),
|
||||
]
|
||||
20
backend/community/migrations/0002_activity_author.py
Normal file
20
backend/community/migrations/0002_activity_author.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-04 04:58
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('community', '0001_initial'),
|
||||
('shop', '__first__'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
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', verbose_name='发布者'),
|
||||
),
|
||||
]
|
||||
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='发布者'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-18 12:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('community', '0003_alter_activity_author'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='activity',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='activitysignup',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='announcement',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='reply',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='topic',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='topicmedia',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
]
|
||||
0
backend/community/migrations/__init__.py
Normal file
0
backend/community/migrations/__init__.py
Normal file
285
backend/community/models.py
Normal file
285
backend/community/models.py
Normal file
@@ -0,0 +1,285 @@
|
||||
from django.db import models
|
||||
from shop.models import WeChatUser, ESP32Config, Order, Service, VCCourse, ServiceOrder
|
||||
|
||||
class Activity(models.Model):
|
||||
"""
|
||||
社区活动模型
|
||||
"""
|
||||
title = models.CharField(max_length=100, verbose_name="活动标题")
|
||||
description = models.TextField(verbose_name="活动详情")
|
||||
banner = models.ImageField(upload_to='activities/banners/', verbose_name="活动Banner图", null=True, blank=True)
|
||||
banner_url = models.URLField(verbose_name="活动Banner链接", null=True, blank=True, help_text="可直接填写图片链接,若同时上传图片,将优先显示上传的图片")
|
||||
start_time = models.DateTimeField(verbose_name="开始时间")
|
||||
end_time = models.DateTimeField(verbose_name="结束时间")
|
||||
location = models.CharField(max_length=100, verbose_name="活动地点")
|
||||
max_participants = models.IntegerField(default=50, verbose_name="最大报名人数")
|
||||
|
||||
# 费用设置
|
||||
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, 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="关闭后将不在前端列表页显示")
|
||||
auto_confirm = models.BooleanField(default=False, verbose_name="无需审核", help_text="开启后,付费活动支付成功或免费活动报名后直接显示报名成功,不需要审核")
|
||||
|
||||
# 常用报名信息开关
|
||||
ask_name = models.BooleanField(default=False, verbose_name="收集姓名")
|
||||
ask_phone = models.BooleanField(default=False, verbose_name="收集手机号")
|
||||
ask_wechat = models.BooleanField(default=False, verbose_name="收集微信号")
|
||||
ask_company = models.BooleanField(default=False, verbose_name="收集公司/机构")
|
||||
|
||||
signup_form_config = models.JSONField(
|
||||
default=list,
|
||||
verbose_name="自定义报名配置",
|
||||
blank=True,
|
||||
help_text='JSON格式的高级配置,若填写则优先于上方开关。例如:[{"name": "job", "label": "职位", "type": "text", "required": true}]'
|
||||
)
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||
|
||||
def clean(self):
|
||||
from django.core.exceptions import ValidationError
|
||||
if not self.banner and not self.banner_url:
|
||||
raise ValidationError("Banner图片和Banner链接必须至少填写一项")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def display_banner_url(self):
|
||||
"""
|
||||
获取Banner显示的URL,优先使用上传的图片
|
||||
"""
|
||||
if self.banner:
|
||||
return self.banner.url
|
||||
return self.banner_url
|
||||
|
||||
@property
|
||||
def current_signups(self):
|
||||
"""
|
||||
当前有效报名人数(仅统计已确认/已支付的报名)
|
||||
"""
|
||||
return self.signups.filter(status='confirmed').count()
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Meta:
|
||||
verbose_name = "社区活动"
|
||||
verbose_name_plural = "社区活动管理"
|
||||
|
||||
|
||||
class ActivitySignup(models.Model):
|
||||
"""
|
||||
活动报名记录
|
||||
"""
|
||||
STATUS_CHOICES = (
|
||||
('unpaid', '待支付'),
|
||||
('pending', '审核中'),
|
||||
('confirmed', '报名成功'),
|
||||
('cancelled', '已取消'),
|
||||
)
|
||||
|
||||
activity = models.ForeignKey(Activity, on_delete=models.CASCADE, related_name='signups', verbose_name="活动")
|
||||
user = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='activity_signups', verbose_name="报名用户")
|
||||
signup_time = models.DateTimeField(auto_now_add=True, verbose_name="报名时间")
|
||||
signup_info = models.JSONField(
|
||||
default=dict,
|
||||
verbose_name="报名信息",
|
||||
blank=True
|
||||
)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='confirmed', verbose_name="状态")
|
||||
|
||||
# 关联订单(针对付费活动)
|
||||
order = models.ForeignKey('shop.Order', on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联订单", related_name='activity_signups')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.nickname} - {self.activity.title}"
|
||||
|
||||
def check_payment_status(self):
|
||||
"""
|
||||
检查并同步关联订单的支付状态
|
||||
"""
|
||||
if self.status == 'unpaid' and self.order:
|
||||
if self.order.status == 'paid':
|
||||
self.status = 'confirmed' if self.activity.auto_confirm else 'pending'
|
||||
self.save()
|
||||
return True
|
||||
return False
|
||||
|
||||
class Meta:
|
||||
verbose_name = "活动报名"
|
||||
verbose_name_plural = "活动报名管理"
|
||||
unique_together = ('activity', 'user')
|
||||
|
||||
|
||||
class Topic(models.Model):
|
||||
"""
|
||||
论坛帖子/主题
|
||||
"""
|
||||
title = models.CharField(max_length=200, verbose_name="标题")
|
||||
|
||||
CATEGORY_CHOICES = (
|
||||
('discussion', '技术讨论'),
|
||||
('help', '求助问答'),
|
||||
('share', '经验分享'),
|
||||
('notice', '官方公告'),
|
||||
)
|
||||
category = models.CharField(max_length=20, choices=CATEGORY_CHOICES, default='discussion', verbose_name="分类")
|
||||
|
||||
STATUS_CHOICES = (
|
||||
('pending', '待审核'),
|
||||
('published', '已发布'),
|
||||
('rejected', '已拒绝'),
|
||||
)
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='published', verbose_name="状态")
|
||||
|
||||
content = models.TextField(verbose_name="内容", help_text="支持Markdown格式,支持插入图片")
|
||||
author = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='topics', verbose_name="作者")
|
||||
|
||||
# 关联对象:硬件、服务、课程
|
||||
related_product = models.ForeignKey(ESP32Config, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联硬件")
|
||||
related_service = models.ForeignKey(Service, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联服务")
|
||||
related_course = models.ForeignKey(VCCourse, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="关联课程")
|
||||
|
||||
view_count = models.IntegerField(default=0, verbose_name="浏览量")
|
||||
likes = models.ManyToManyField(WeChatUser, related_name='liked_topics', blank=True, verbose_name="点赞用户")
|
||||
is_pinned = 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="更新时间")
|
||||
order = models.IntegerField(default=0, verbose_name="排序")
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# 记录是否为新对象,因为super().save后pk就有了
|
||||
is_new = self.pk is None
|
||||
|
||||
# 第一次保存,先入库
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# 如果是新创建,且 order 默认为 0(未指定)
|
||||
if is_new and getattr(self, 'order', 0) == 0:
|
||||
# 将所有其他帖子的 order + 1,腾出 0 的位置
|
||||
Topic.objects.exclude(pk=self.pk).filter(order__gte=0).update(order=models.F('order') + 1)
|
||||
# 确保自己是 0
|
||||
Topic.objects.filter(pk=self.pk).update(order=0)
|
||||
self.order = 0
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def is_verified_owner(self):
|
||||
"""
|
||||
判断作者是否为关联项目(硬件/服务/课程)的已购用户(Verified Owner)
|
||||
"""
|
||||
# 1. 验证硬件
|
||||
if self.related_product:
|
||||
if Order.objects.filter(
|
||||
wechat_user=self.author,
|
||||
config=self.related_product,
|
||||
status__in=['paid', 'shipped']
|
||||
).exists():
|
||||
return True
|
||||
|
||||
# 2. 验证课程
|
||||
if self.related_course:
|
||||
if Order.objects.filter(
|
||||
wechat_user=self.author,
|
||||
course=self.related_course,
|
||||
status__in=['paid', 'shipped']
|
||||
).exists():
|
||||
return True
|
||||
|
||||
# 3. 验证服务
|
||||
if self.related_service:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
class Meta:
|
||||
verbose_name = "论坛帖子"
|
||||
verbose_name_plural = "论坛帖子管理"
|
||||
ordering = ['order', '-is_pinned', '-created_at']
|
||||
|
||||
|
||||
class Reply(models.Model):
|
||||
"""
|
||||
帖子回复
|
||||
"""
|
||||
topic = models.ForeignKey(Topic, on_delete=models.CASCADE, related_name='replies', verbose_name="所属帖子")
|
||||
content = models.TextField(verbose_name="回复内容", help_text="支持Markdown格式")
|
||||
author = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='replies', verbose_name="回复者")
|
||||
reply_to = models.ForeignKey('self', on_delete=models.SET_NULL, null=True, blank=True, related_name='children', verbose_name="回复楼层")
|
||||
likes = models.ManyToManyField(WeChatUser, related_name='liked_replies', blank=True, verbose_name="点赞用户")
|
||||
is_pinned = models.BooleanField(default=False, verbose_name="置顶")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="回复时间")
|
||||
|
||||
def __str__(self):
|
||||
return f"回复: {self.topic.title}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "帖子回复"
|
||||
verbose_name_plural = "帖子回复管理"
|
||||
ordering = ['-is_pinned', '-created_at']
|
||||
|
||||
|
||||
class TopicMedia(models.Model):
|
||||
"""
|
||||
论坛多媒体资源(图片/视频/文件)
|
||||
"""
|
||||
MEDIA_TYPE_CHOICES = (
|
||||
('image', '图片'),
|
||||
('video', '视频'),
|
||||
('file', '文件'),
|
||||
)
|
||||
|
||||
topic = models.ForeignKey(Topic, on_delete=models.CASCADE, related_name='media', verbose_name="所属帖子", null=True, blank=True)
|
||||
reply = models.ForeignKey(Reply, on_delete=models.CASCADE, related_name='media', verbose_name="所属回复", null=True, blank=True)
|
||||
file = models.FileField(upload_to='community/media/', verbose_name="文件", null=True, blank=True)
|
||||
file_url = models.URLField(max_length=500, verbose_name="文件链接", null=True, blank=True)
|
||||
media_type = models.CharField(max_length=10, choices=MEDIA_TYPE_CHOICES, default='image', verbose_name="媒体类型")
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.media_type} - {self.file.name}"
|
||||
|
||||
class Meta:
|
||||
verbose_name = "论坛媒体资源"
|
||||
verbose_name_plural = "论坛媒体资源管理"
|
||||
|
||||
|
||||
class Announcement(models.Model):
|
||||
"""
|
||||
社区公告模型
|
||||
"""
|
||||
title = models.CharField(max_length=100, verbose_name="公告标题")
|
||||
content = models.TextField(verbose_name="公告内容")
|
||||
image = models.ImageField(upload_to='announcements/', verbose_name="公告图片", null=True, blank=True)
|
||||
image_url = models.URLField(verbose_name="图片链接", null=True, blank=True, help_text="优先使用上传的图片")
|
||||
link_url = models.URLField(verbose_name="跳转链接", null=True, blank=True)
|
||||
|
||||
is_active = models.BooleanField(default=True, verbose_name="是否启用")
|
||||
is_pinned = models.BooleanField(default=False, verbose_name="是否置顶")
|
||||
priority = models.IntegerField(default=0, verbose_name="排序权重", help_text="数字越大越靠前")
|
||||
|
||||
start_time = models.DateTimeField(verbose_name="开始展示时间", null=True, blank=True)
|
||||
end_time = models.DateTimeField(verbose_name="结束展示时间", null=True, blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="创建时间")
|
||||
updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间")
|
||||
|
||||
@property
|
||||
def display_image_url(self):
|
||||
if self.image:
|
||||
return self.image.url
|
||||
return self.image_url
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Meta:
|
||||
verbose_name = "社区公告"
|
||||
verbose_name_plural = "社区公告管理"
|
||||
ordering = ['-is_pinned', '-priority', '-created_at']
|
||||
23
backend/community/permissions.py
Normal file
23
backend/community/permissions.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from rest_framework import permissions
|
||||
from .utils import get_current_wechat_user
|
||||
|
||||
class IsAuthorOrReadOnly(permissions.BasePermission):
|
||||
"""
|
||||
Object-level permission to only allow authors of an object to edit it.
|
||||
Assumes the model instance has an `author` attribute.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
# Read permissions are allowed to any request,
|
||||
# so we'll always allow GET, HEAD or OPTIONS requests.
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
# Write permissions are only allowed to the author of the object.
|
||||
# We need to manually get the user because we are using custom auth logic (get_current_wechat_user)
|
||||
# instead of request.user for some reason (or in addition to).
|
||||
# However, DRF's request.user might not be set if we don't use a standard authentication class.
|
||||
# Based on views.py, it uses `get_current_wechat_user(request)`.
|
||||
|
||||
current_user = get_current_wechat_user(request)
|
||||
return current_user and obj.author == current_user
|
||||
190
backend/community/serializers.py
Normal file
190
backend/community/serializers.py
Normal file
@@ -0,0 +1,190 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
|
||||
from shop.serializers import WeChatUserSerializer, ESP32ConfigSerializer, ServiceSerializer, VCCourseSerializer
|
||||
from .utils import get_current_wechat_user
|
||||
|
||||
class ActivitySerializer(serializers.ModelSerializer):
|
||||
display_banner_url = serializers.ReadOnlyField()
|
||||
signup_form_config = serializers.SerializerMethodField()
|
||||
current_signups = serializers.IntegerField(read_only=True)
|
||||
has_signed_up = serializers.SerializerMethodField()
|
||||
is_signed_up = serializers.SerializerMethodField()
|
||||
my_signup_status = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Activity
|
||||
fields = '__all__'
|
||||
|
||||
def get_has_signed_up(self, obj):
|
||||
return self.get_is_signed_up(obj)
|
||||
|
||||
def get_my_signup_status(self, obj):
|
||||
request = self.context.get('request')
|
||||
if not request:
|
||||
return None
|
||||
user = get_current_wechat_user(request)
|
||||
if user:
|
||||
# Return the status of the non-cancelled signup
|
||||
signup = obj.signups.filter(user=user).exclude(status='cancelled').first()
|
||||
return signup.status if signup else None
|
||||
return None
|
||||
|
||||
def get_is_signed_up(self, obj):
|
||||
request = self.context.get('request')
|
||||
if not request:
|
||||
return False
|
||||
user = get_current_wechat_user(request)
|
||||
if user:
|
||||
# Check if there is a valid signup (only confirmed counts)
|
||||
return obj.signups.filter(user=user, status='confirmed').exists()
|
||||
return False
|
||||
|
||||
def get_signup_form_config(self, obj):
|
||||
# 1. 优先使用 JSON 配置
|
||||
if obj.signup_form_config:
|
||||
return obj.signup_form_config
|
||||
|
||||
# 2. 否则根据开关生成默认配置
|
||||
config = []
|
||||
if obj.ask_name:
|
||||
config.append({"name": "name", "label": "姓名", "type": "text", "required": True})
|
||||
if obj.ask_phone:
|
||||
config.append({"name": "phone", "label": "手机号", "type": "number", "required": True})
|
||||
if obj.ask_wechat:
|
||||
config.append({"name": "wechat", "label": "微信号", "type": "text", "required": True})
|
||||
if obj.ask_company:
|
||||
config.append({"name": "company", "label": "公司/机构", "type": "text", "required": False})
|
||||
|
||||
return config
|
||||
|
||||
class ActivitySignupSerializer(serializers.ModelSerializer):
|
||||
activity_info = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = ActivitySignup
|
||||
fields = ['id', 'activity', 'activity_info', 'user', 'signup_time', 'status', 'signup_info']
|
||||
read_only_fields = ['signup_time', 'status', 'user']
|
||||
|
||||
def get_activity_info(self, obj):
|
||||
return ActivitySerializer(obj.activity).data
|
||||
|
||||
class TopicMediaSerializer(serializers.ModelSerializer):
|
||||
url = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = TopicMedia
|
||||
fields = ['id', 'file', 'file_url', 'url', 'media_type', 'created_at']
|
||||
|
||||
def get_url(self, obj):
|
||||
if obj.file:
|
||||
return obj.file.url
|
||||
return obj.file_url
|
||||
|
||||
class ReplySerializer(serializers.ModelSerializer):
|
||||
author_info = WeChatUserSerializer(source='author', read_only=True)
|
||||
media = TopicMediaSerializer(many=True, read_only=True)
|
||||
media_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
write_only=True,
|
||||
required=False
|
||||
)
|
||||
like_count = serializers.IntegerField(source='likes.count', read_only=True)
|
||||
is_liked = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Reply
|
||||
fields = ['id', 'topic', 'content', 'author', 'author_info', 'reply_to', 'media', 'created_at', 'media_ids', 'is_pinned', 'like_count', 'is_liked']
|
||||
read_only_fields = ['author', 'created_at']
|
||||
|
||||
def get_is_liked(self, obj):
|
||||
request = self.context.get('request')
|
||||
if not request:
|
||||
return False
|
||||
user = get_current_wechat_user(request)
|
||||
if user:
|
||||
return obj.likes.filter(id=user.id).exists()
|
||||
return False
|
||||
|
||||
def create(self, validated_data):
|
||||
media_ids = validated_data.pop('media_ids', [])
|
||||
reply = super().create(validated_data)
|
||||
if media_ids:
|
||||
TopicMedia.objects.filter(id__in=media_ids).update(reply=reply)
|
||||
return reply
|
||||
|
||||
class TopicSerializer(serializers.ModelSerializer):
|
||||
author_info = WeChatUserSerializer(source='author', read_only=True)
|
||||
replies = ReplySerializer(many=True, read_only=True)
|
||||
media = TopicMediaSerializer(many=True, read_only=True)
|
||||
is_verified_owner = serializers.BooleanField(read_only=True)
|
||||
like_count = serializers.IntegerField(source='likes.count', read_only=True)
|
||||
is_liked = serializers.SerializerMethodField()
|
||||
|
||||
product_info = ESP32ConfigSerializer(source='related_product', read_only=True)
|
||||
service_info = ServiceSerializer(source='related_service', read_only=True)
|
||||
course_info = VCCourseSerializer(source='related_course', read_only=True)
|
||||
|
||||
media_ids = serializers.ListField(
|
||||
child=serializers.IntegerField(),
|
||||
write_only=True,
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Topic
|
||||
fields = [
|
||||
'id', 'title', 'category', 'status', 'content', 'author', 'author_info',
|
||||
'related_product', 'product_info',
|
||||
'related_service', 'service_info',
|
||||
'related_course', 'course_info',
|
||||
'view_count', 'is_pinned', 'created_at', 'updated_at',
|
||||
'is_verified_owner', 'replies', 'media', 'media_ids',
|
||||
'like_count', 'is_liked'
|
||||
]
|
||||
read_only_fields = ['author', 'view_count', 'created_at', 'updated_at', 'is_verified_owner', 'status']
|
||||
|
||||
def get_is_liked(self, obj):
|
||||
request = self.context.get('request')
|
||||
if not request:
|
||||
return False
|
||||
user = get_current_wechat_user(request)
|
||||
if user:
|
||||
return obj.likes.filter(id=user.id).exists()
|
||||
return False
|
||||
|
||||
def create(self, validated_data):
|
||||
media_ids = validated_data.pop('media_ids', [])
|
||||
topic = super().create(validated_data)
|
||||
if media_ids:
|
||||
TopicMedia.objects.filter(id__in=media_ids).update(topic=topic)
|
||||
return topic
|
||||
|
||||
class AnnouncementSerializer(serializers.ModelSerializer):
|
||||
display_image_url = serializers.ReadOnlyField()
|
||||
|
||||
class Meta:
|
||||
model = Announcement
|
||||
fields = '__all__'
|
||||
|
||||
class AdminActivitySerializer(serializers.ModelSerializer):
|
||||
signup_form_config = serializers.JSONField(required=False)
|
||||
description = serializers.CharField(
|
||||
style={'base_template': 'textarea.html'},
|
||||
help_text="活动详情内容,支持 Markdown 格式。如果需要插入图片,请先调用媒体上传接口获取 URL。"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Activity
|
||||
fields = '__all__'
|
||||
read_only_fields = ['author', 'created_at', 'current_signups']
|
||||
|
||||
class AdminTopicSerializer(serializers.ModelSerializer):
|
||||
content = serializers.CharField(
|
||||
style={'base_template': 'textarea.html'},
|
||||
help_text="帖子内容,支持 Markdown 格式。如果需要插入图片,请先调用媒体上传接口获取 URL。"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Topic
|
||||
fields = '__all__'
|
||||
read_only_fields = ['author', 'created_at', 'updated_at', 'view_count', 'is_verified_owner']
|
||||
3
backend/community/tests.py
Normal file
3
backend/community/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
15
backend/community/urls.py
Normal file
15
backend/community/urls.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import ActivityViewSet, TopicViewSet, ReplyViewSet, TopicMediaViewSet, AnnouncementViewSet, AdminPublishViewSet
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'activities', ActivityViewSet)
|
||||
router.register(r'topics', TopicViewSet)
|
||||
router.register(r'replies', ReplyViewSet)
|
||||
router.register(r'media', TopicMediaViewSet, basename='media')
|
||||
router.register(r'announcements', AnnouncementViewSet)
|
||||
router.register(r'admin-publish', AdminPublishViewSet, basename='admin-publish')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
55
backend/community/utils.py
Normal file
55
backend/community/utils.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
|
||||
from shop.models import WeChatUser
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_current_wechat_user(request):
|
||||
"""
|
||||
根据 Authorization 头获取当前微信用户
|
||||
增强逻辑:如果 Token 解析出的 OpenID 对应的用户不存在(可能已被合并删除),
|
||||
但该 OpenID 是 Web 虚拟 ID (web_phone),尝试通过手机号查找现有的主账号。
|
||||
"""
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if not auth_header or not auth_header.startswith('Bearer '):
|
||||
logger.warning(f"Authentication failed: Missing or invalid Authorization header. Header: {auth_header}")
|
||||
return None
|
||||
token = auth_header.split(' ')[1]
|
||||
signer = TimestampSigner()
|
||||
try:
|
||||
# 签名包含 openid
|
||||
openid = signer.unsign(token, max_age=86400 * 30) # 30天有效
|
||||
user = WeChatUser.objects.filter(openid=openid).first()
|
||||
|
||||
if user:
|
||||
return user
|
||||
|
||||
# 如果没找到用户,检查是否是 Web 虚拟 OpenID
|
||||
# 场景:Web 用户已被合并到小程序账号,旧 Web Token 依然有效,指向合并后的账号
|
||||
logger.info(f"User not found for openid: {openid}, checking for merged account...")
|
||||
if openid.startswith('web_'):
|
||||
try:
|
||||
# 格式: web_13800138000
|
||||
parts = openid.split('_', 1)
|
||||
if len(parts) == 2:
|
||||
phone = parts[1]
|
||||
# 尝试通过手机号查找(查找合并后的主账号)
|
||||
user = WeChatUser.objects.filter(phone_number=phone).first()
|
||||
if user:
|
||||
logger.info(f"Found merged user {user.id} for phone {phone}")
|
||||
return user
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking merged account: {e}")
|
||||
pass
|
||||
|
||||
logger.warning(f"Authentication failed: User not found for openid {openid}")
|
||||
return None
|
||||
except SignatureExpired:
|
||||
logger.warning("Authentication failed: Signature expired")
|
||||
return None
|
||||
except BadSignature:
|
||||
logger.warning("Authentication failed: Bad signature")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Authentication unexpected error: {e}")
|
||||
return None
|
||||
516
backend/community/views.py
Normal file
516
backend/community/views.py
Normal file
@@ -0,0 +1,516 @@
|
||||
from rest_framework import viewsets, status, mixins, parsers, filters
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import serializers, permissions
|
||||
from django.core.signing import TimestampSigner, BadSignature, SignatureExpired
|
||||
from django.utils import timezone
|
||||
from django.db import models
|
||||
from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiTypes
|
||||
|
||||
from shop.models import WeChatUser, Order
|
||||
from shop.views import get_wechat_pay_client
|
||||
from .models import Activity, ActivitySignup, Topic, Reply, TopicMedia, Announcement
|
||||
from .serializers import ActivitySerializer, ActivitySignupSerializer, TopicSerializer, ReplySerializer, TopicMediaSerializer, AnnouncementSerializer, AdminActivitySerializer, AdminTopicSerializer
|
||||
from .utils import get_current_wechat_user
|
||||
from .permissions import IsAuthorOrReadOnly
|
||||
|
||||
class ActivityViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
社区活动接口
|
||||
"""
|
||||
queryset = Activity.objects.filter(is_active=True).order_by('-created_at')
|
||||
serializer_class = ActivitySerializer
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
# list 接口过滤 is_visible=True
|
||||
if self.action == 'list':
|
||||
qs = qs.filter(is_visible=True)
|
||||
return qs
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
|
||||
# Sync status for current user
|
||||
user = get_current_wechat_user(request)
|
||||
if user:
|
||||
# Use filter to avoid exception if multiple exist (though unique_together constraint exists)
|
||||
signup = instance.signups.filter(user=user).exclude(status='cancelled').first()
|
||||
if signup:
|
||||
has_changed = signup.check_payment_status()
|
||||
if has_changed:
|
||||
print(f"DEBUG: Synced signup status for user {user.id} activity {instance.id}")
|
||||
|
||||
serializer = self.get_serializer(instance)
|
||||
# Debug print to verify data
|
||||
print(f"DEBUG: Activity {instance.title} current_signups: {instance.current_signups}")
|
||||
return Response(serializer.data)
|
||||
|
||||
@extend_schema(summary="报名活动")
|
||||
@action(detail=True, methods=['post'])
|
||||
def signup(self, request, pk=None):
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({'error': '请先登录'}, status=401)
|
||||
|
||||
activity = self.get_object()
|
||||
|
||||
# 1. Check confirmed signup
|
||||
if ActivitySignup.objects.filter(activity=activity, user=user, status='confirmed').exists():
|
||||
return Response({'error': '您已报名该活动'}, status=400)
|
||||
|
||||
# 2. Get pending signup (for retry)
|
||||
pending_signup = ActivitySignup.objects.filter(activity=activity, user=user, status='pending').first()
|
||||
|
||||
# 3. Check limit (exclude cancelled, exclude current pending)
|
||||
query = activity.signups.exclude(status='cancelled')
|
||||
if pending_signup:
|
||||
query = query.exclude(id=pending_signup.id)
|
||||
|
||||
if query.count() >= activity.max_participants:
|
||||
return Response({'error': '活动名额已满'}, status=400)
|
||||
|
||||
# Get signup info
|
||||
signup_info = request.data.get('signup_info', {})
|
||||
|
||||
# Validate signup info
|
||||
effective_config = activity.signup_form_config
|
||||
if not effective_config:
|
||||
effective_config = []
|
||||
if activity.ask_name:
|
||||
effective_config.append({"name": "name", "label": "姓名", "type": "text", "required": True})
|
||||
if activity.ask_phone:
|
||||
effective_config.append({"name": "phone", "label": "手机号", "type": "number", "required": True})
|
||||
if activity.ask_wechat:
|
||||
effective_config.append({"name": "wechat", "label": "微信号", "type": "text", "required": True})
|
||||
if activity.ask_company:
|
||||
effective_config.append({"name": "company", "label": "公司/机构", "type": "text", "required": False})
|
||||
|
||||
if effective_config:
|
||||
required_fields = [f['name'] for f in effective_config if f.get('required')]
|
||||
for field in required_fields:
|
||||
val = signup_info.get(field)
|
||||
if val is None or (isinstance(val, str) and not val.strip()):
|
||||
label = next((f['label'] for f in effective_config if f['name'] == field), field)
|
||||
return Response({'error': f'请填写: {label}'}, status=400)
|
||||
|
||||
# Handle Payment Logic
|
||||
if activity.is_paid and activity.price > 0:
|
||||
import time
|
||||
from wechatpayv3 import WeChatPayType
|
||||
|
||||
# Create or Get Order
|
||||
order = None
|
||||
if pending_signup and pending_signup.order:
|
||||
# Reuse existing order if it's pending
|
||||
if pending_signup.order.status == 'pending':
|
||||
order = pending_signup.order
|
||||
# Update contact info if needed
|
||||
contact_name = signup_info.get('name') or signup_info.get('participant_name') or user.nickname or 'Activity User'
|
||||
contact_phone = signup_info.get('phone') or user.phone_number or ''
|
||||
if contact_name: order.customer_name = contact_name
|
||||
if contact_phone: order.phone_number = contact_phone
|
||||
|
||||
# Ensure activity is linked
|
||||
if not order.activity:
|
||||
order.activity = activity
|
||||
|
||||
order.save()
|
||||
|
||||
if not order:
|
||||
# Check independent pending order
|
||||
pending_order = Order.objects.filter(
|
||||
wechat_user=user,
|
||||
activity=activity,
|
||||
status='pending'
|
||||
).first()
|
||||
|
||||
if pending_order:
|
||||
order = pending_order
|
||||
# Ensure shipping address is up-to-date
|
||||
order.shipping_address = activity.location or '线下活动'
|
||||
order.save()
|
||||
else:
|
||||
contact_name = signup_info.get('name') or signup_info.get('participant_name') or user.nickname or 'Activity User'
|
||||
contact_phone = signup_info.get('phone') or user.phone_number or ''
|
||||
|
||||
order = Order.objects.create(
|
||||
wechat_user=user,
|
||||
activity=activity,
|
||||
total_price=activity.price,
|
||||
status='pending',
|
||||
quantity=1,
|
||||
customer_name=contact_name,
|
||||
phone_number=contact_phone,
|
||||
shipping_address=activity.location or '线下活动',
|
||||
)
|
||||
|
||||
# Generate Pay Code
|
||||
out_trade_no = f"ACT{activity.id}U{user.id}T{int(time.time())}"
|
||||
order.out_trade_no = out_trade_no
|
||||
order.save()
|
||||
|
||||
wxpay, error_msg = get_wechat_pay_client(pay_type=WeChatPayType.NATIVE)
|
||||
if not wxpay:
|
||||
return Response({'error': f'支付配置错误: {error_msg}'}, status=500)
|
||||
|
||||
code, message = wxpay.pay(
|
||||
description=f"报名活动: {activity.title}",
|
||||
out_trade_no=out_trade_no,
|
||||
amount={
|
||||
'total': int(activity.price * 100),
|
||||
'currency': 'CNY'
|
||||
},
|
||||
notify_url=wxpay._notify_url,
|
||||
attach=f'{{"type":"activity","activity_id":{activity.id}}}'
|
||||
)
|
||||
|
||||
import json
|
||||
result = json.loads(message)
|
||||
if code in range(200, 300):
|
||||
code_url = result.get('code_url')
|
||||
|
||||
if pending_signup:
|
||||
pending_signup.signup_info = signup_info
|
||||
pending_signup.order = order
|
||||
pending_signup.status = 'unpaid' # Explicitly set to unpaid
|
||||
pending_signup.save()
|
||||
else:
|
||||
ActivitySignup.objects.create(
|
||||
activity=activity,
|
||||
user=user,
|
||||
signup_info=signup_info,
|
||||
status='unpaid',
|
||||
order=order
|
||||
)
|
||||
|
||||
return Response({
|
||||
'payment_required': True,
|
||||
'code_url': code_url,
|
||||
'order_id': order.id,
|
||||
'price': activity.price,
|
||||
'message': '请完成支付'
|
||||
}, status=200)
|
||||
else:
|
||||
return Response({'error': '支付接口调用失败', 'detail': result}, status=500)
|
||||
|
||||
# Free Activity Signup
|
||||
# Check auto_confirm
|
||||
status_val = 'confirmed' if activity.auto_confirm else 'pending'
|
||||
|
||||
signup = ActivitySignup.objects.create(
|
||||
activity=activity,
|
||||
user=user,
|
||||
signup_info=signup_info,
|
||||
status=status_val
|
||||
)
|
||||
|
||||
# Send SMS for free activity signup (if confirmed)
|
||||
if status_val == 'confirmed':
|
||||
try:
|
||||
from shop.sms_utils import notify_user_activity_signup_success
|
||||
|
||||
# Mock an order object for the SMS template
|
||||
# The template expects: customer_name, wechat_user, phone_number
|
||||
class MockOrder:
|
||||
def __init__(self, user, signup_info):
|
||||
# Ensure we get the name and phone from signup_info first
|
||||
# signup_info keys might vary, let's try common ones
|
||||
self.customer_name = signup_info.get('name') or signup_info.get('username') or user.nickname or "用户"
|
||||
self.wechat_user = user
|
||||
self.phone_number = signup_info.get('phone') or signup_info.get('mobile') or user.phone_number or ""
|
||||
|
||||
mock_order = MockOrder(user, signup_info)
|
||||
|
||||
# Check if we have a valid phone number before sending
|
||||
if mock_order.phone_number:
|
||||
notify_user_activity_signup_success(mock_order, signup)
|
||||
else:
|
||||
print(f"Skipping SMS for signup {signup.id}: No phone number found")
|
||||
except Exception as e:
|
||||
print(f"发送免费活动报名短信失败: {str(e)}")
|
||||
|
||||
serializer = ActivitySignupSerializer(signup)
|
||||
return Response(serializer.data, status=201)
|
||||
|
||||
@extend_schema(summary="我的报名记录")
|
||||
@action(detail=False, methods=['get'])
|
||||
def my_signups(self, request):
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({'error': '请先登录'}, status=401)
|
||||
signups = ActivitySignup.objects.filter(user=user).order_by('-signup_time')
|
||||
|
||||
# Sync payment status
|
||||
for signup in signups:
|
||||
signup.check_payment_status()
|
||||
|
||||
serializer = ActivitySignupSerializer(signups, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
class TopicViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
技术论坛帖子接口
|
||||
"""
|
||||
queryset = Topic.objects.all()
|
||||
serializer_class = TopicSerializer
|
||||
permission_classes = [IsAuthorOrReadOnly]
|
||||
filter_backends = [filters.SearchFilter, filters.OrderingFilter, DjangoFilterBackend]
|
||||
search_fields = ['title', 'content']
|
||||
filterset_fields = ['category', 'is_pinned']
|
||||
ordering_fields = ['created_at', 'view_count', 'order']
|
||||
ordering = ['-is_pinned', 'order', '-created_at']
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
# 列表接口仅显示已发布的帖子
|
||||
if self.action == 'list':
|
||||
qs = qs.filter(status='published')
|
||||
return qs
|
||||
|
||||
def perform_create(self, serializer):
|
||||
user = get_current_wechat_user(self.request)
|
||||
# Auth check is done in create or permission, but here we need user for save
|
||||
if user:
|
||||
# 如果关联了系统用户(user字段不为空),则是管理员/内部人员,直接发布
|
||||
# 否则进入审核流程
|
||||
status = 'published' if user.user else 'pending'
|
||||
serializer.save(author=user, status=status)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({'error': '请先登录'}, status=401)
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def like(self, request, pk=None):
|
||||
obj = self.get_object()
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({'error': '请先登录'}, status=401)
|
||||
|
||||
if obj.likes.filter(id=user.id).exists():
|
||||
obj.likes.remove(user)
|
||||
liked = False
|
||||
else:
|
||||
obj.likes.add(user)
|
||||
liked = True
|
||||
|
||||
return Response({'liked': liked, 'count': obj.likes.count()})
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
instance = self.get_object()
|
||||
instance.view_count += 1
|
||||
instance.save(update_fields=['view_count'])
|
||||
serializer = self.get_serializer(instance)
|
||||
return Response(serializer.data)
|
||||
|
||||
class ReplyViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
帖子回复接口
|
||||
"""
|
||||
queryset = Reply.objects.all()
|
||||
serializer_class = ReplySerializer
|
||||
permission_classes = [IsAuthorOrReadOnly]
|
||||
|
||||
def perform_create(self, serializer):
|
||||
user = get_current_wechat_user(self.request)
|
||||
if user:
|
||||
serializer.save(author=user)
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({'error': '请先登录'}, status=401)
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def like(self, request, pk=None):
|
||||
obj = self.get_object()
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({'error': '请先登录'}, status=401)
|
||||
|
||||
if obj.likes.filter(id=user.id).exists():
|
||||
obj.likes.remove(user)
|
||||
liked = False
|
||||
else:
|
||||
obj.likes.add(user)
|
||||
liked = True
|
||||
|
||||
return Response({'liked': liked, 'count': obj.likes.count()})
|
||||
|
||||
import requests
|
||||
|
||||
class TopicMediaViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
论坛多媒体资源上传接口 (代理到外部OSS服务)
|
||||
"""
|
||||
permission_classes = [] # 内部鉴权
|
||||
parser_classes = [parsers.MultiPartParser, parsers.FormParser]
|
||||
|
||||
@extend_schema(summary="上传媒体文件 (返回URL用于Markdown)")
|
||||
def create(self, request, *args, **kwargs):
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({'error': '请先登录'}, status=401)
|
||||
|
||||
file_obj = request.FILES.get('file')
|
||||
if not file_obj:
|
||||
return Response({'error': '未提供文件'}, status=400)
|
||||
|
||||
# 转发到外部 OSS 上传服务
|
||||
upload_url = "https://data.tangledup-ai.com/upload?folder=uploads%2Fmarket%2Fforum_image"
|
||||
files = {'file': (file_obj.name, file_obj, file_obj.content_type)}
|
||||
|
||||
try:
|
||||
# 这里的 headers 不需要 Content-Type,requests 会自动设置 multipart/form-data
|
||||
response = requests.post(upload_url, files=files, timeout=30)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
if data.get('success'):
|
||||
# Create TopicMedia record
|
||||
media_type = 'image' if 'image' in file_obj.content_type else 'video'
|
||||
media_obj = TopicMedia.objects.create(
|
||||
file_url=data.get('file_url'),
|
||||
media_type=media_type,
|
||||
# topic will be associated later
|
||||
)
|
||||
|
||||
# 返回符合前端预期的格式
|
||||
return Response({
|
||||
'id': media_obj.id, # Return real DB ID
|
||||
'file': media_obj.file_url,
|
||||
'media_type': media_obj.media_type,
|
||||
'created_at': media_obj.created_at
|
||||
})
|
||||
else:
|
||||
return Response({'error': '外部服务上传失败', 'detail': data}, status=400)
|
||||
else:
|
||||
return Response({'error': f'上传服务响应错误: {response.status_code}', 'detail': response.text}, status=502)
|
||||
|
||||
except Exception as e:
|
||||
return Response({'error': str(e)}, status=500)
|
||||
|
||||
class AnnouncementViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
社区公告接口
|
||||
"""
|
||||
queryset = Announcement.objects.all()
|
||||
serializer_class = AnnouncementSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get_queryset(self):
|
||||
now = timezone.now()
|
||||
qs = Announcement.objects.filter(is_active=True)
|
||||
# Filter by start_time (if set, must be <= now)
|
||||
qs = qs.filter(models.Q(start_time__isnull=True) | models.Q(start_time__lte=now))
|
||||
# Filter by end_time (if set, must be >= now)
|
||||
qs = qs.filter(models.Q(end_time__isnull=True) | models.Q(end_time__gte=now))
|
||||
return qs.order_by('-is_pinned', '-priority', '-created_at')
|
||||
|
||||
class AdminPublishViewSet(viewsets.ViewSet):
|
||||
"""
|
||||
管理员/API发布接口
|
||||
"""
|
||||
permission_classes = []
|
||||
authentication_classes = []
|
||||
|
||||
def check_api_key(self, request):
|
||||
key = request.headers.get('X-API-KEY') or request.query_params.get('apikey')
|
||||
if key != '123quant-speed':
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_admin_user_by_phone(self, phone):
|
||||
if not phone:
|
||||
return None
|
||||
# Find WeChatUser by phone
|
||||
user = WeChatUser.objects.filter(phone_number=phone).first()
|
||||
if not user:
|
||||
return None
|
||||
|
||||
# Check if linked to a system user and has admin privileges (is_staff)
|
||||
if user.user and user.user.is_staff:
|
||||
return user
|
||||
|
||||
return None
|
||||
|
||||
@extend_schema(
|
||||
summary="API发布活动",
|
||||
request=AdminActivitySerializer,
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='apikey',
|
||||
description='API访问密钥',
|
||||
required=True,
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY
|
||||
),
|
||||
OpenApiParameter(
|
||||
name='phone_number',
|
||||
description='管理员手机号 (用于关联发布者)',
|
||||
required=True,
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY
|
||||
)
|
||||
]
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def publish_activity(self, request):
|
||||
if not self.check_api_key(request):
|
||||
return Response({'error': 'Invalid API Key'}, status=403)
|
||||
|
||||
phone = request.data.get('phone_number') or request.query_params.get('phone_number')
|
||||
user = self.get_admin_user_by_phone(phone)
|
||||
if not user:
|
||||
return Response({'error': 'Admin user not found with this phone number'}, status=404)
|
||||
|
||||
data = request.data.copy()
|
||||
serializer = AdminActivitySerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
activity = serializer.save(author=user)
|
||||
return Response(serializer.data, status=201)
|
||||
return Response(serializer.errors, status=400)
|
||||
|
||||
@extend_schema(
|
||||
summary="API发布帖子",
|
||||
request=AdminTopicSerializer,
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name='apikey',
|
||||
description='API访问密钥',
|
||||
required=True,
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY
|
||||
),
|
||||
OpenApiParameter(
|
||||
name='phone_number',
|
||||
description='管理员手机号 (用于关联发布者)',
|
||||
required=True,
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.QUERY
|
||||
)
|
||||
]
|
||||
)
|
||||
@action(detail=False, methods=['post'])
|
||||
def publish_topic(self, request):
|
||||
if not self.check_api_key(request):
|
||||
return Response({'error': 'Invalid API Key'}, status=403)
|
||||
|
||||
phone = request.data.get('phone_number') or request.query_params.get('phone_number')
|
||||
user = self.get_admin_user_by_phone(phone)
|
||||
if not user:
|
||||
return Response({'error': 'Admin user not found with this phone number'}, status=404)
|
||||
|
||||
data = request.data.copy()
|
||||
serializer = AdminTopicSerializer(data=data)
|
||||
if serializer.is_valid():
|
||||
# Only set status to published if not provided, otherwise respect the input
|
||||
status = data.get('status', 'published')
|
||||
topic = serializer.save(author=user, status=status)
|
||||
return Response(serializer.data, status=201)
|
||||
return Response(serializer.errors, status=400)
|
||||
0
backend/competition/__init__.py
Normal file
0
backend/competition/__init__.py
Normal file
198
backend/competition/admin.py
Normal file
198
backend/competition/admin.py
Normal file
@@ -0,0 +1,198 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from unfold.admin import ModelAdmin
|
||||
from unfold.decorators import display
|
||||
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment, HomePageConfig, CarouselItem
|
||||
|
||||
|
||||
class CarouselItemInline(admin.TabularInline):
|
||||
model = CarouselItem
|
||||
extra = 1
|
||||
tab = True
|
||||
fields = ('carousel_type', 'image', 'image_url', 'title', 'subtitle', 'status', 'status_color', 'date', 'location', 'order', 'is_active')
|
||||
autocomplete_fields = []
|
||||
|
||||
|
||||
@admin.register(HomePageConfig)
|
||||
class HomePageConfigAdmin(ModelAdmin):
|
||||
list_display = ['id', 'main_title', 'carousel1_title', 'carousel2_title', 'organizer', 'undertaker', 'is_active']
|
||||
list_editable = ['main_title', 'carousel1_title', 'carousel2_title', 'organizer', 'undertaker', 'is_active']
|
||||
fieldsets = (
|
||||
('首页Banner', {
|
||||
'fields': ('banner_image', 'banner_image_url'),
|
||||
'description': '首页顶部Banner图片,可以上传本地图片或填写URL'
|
||||
}),
|
||||
('标题设置', {
|
||||
'fields': ('main_title', 'carousel1_title', 'carousel2_title')
|
||||
}),
|
||||
('主办单位', {
|
||||
'fields': ('organizer', 'undertaker')
|
||||
}),
|
||||
('状态', {
|
||||
'fields': ('is_active',)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@admin.register(CarouselItem)
|
||||
class CarouselItemAdmin(ModelAdmin):
|
||||
list_display = ['title', 'carousel_type', 'status', 'location', 'order', 'is_active', 'created_at']
|
||||
list_filter = ['carousel_type', 'status', 'is_active']
|
||||
search_fields = ['title', 'subtitle', 'location']
|
||||
readonly_fields = ['image_preview']
|
||||
fieldsets = (
|
||||
('轮播图类型', {
|
||||
'fields': ('carousel_type',)
|
||||
}),
|
||||
('图片设置', {
|
||||
'fields': ('image', 'image_preview', 'image_url'),
|
||||
'description': '优先使用本地上传的图片,上传后可预览'
|
||||
}),
|
||||
('内容设置', {
|
||||
'fields': ('title', 'subtitle', 'status', 'status_color', 'date', 'location')
|
||||
}),
|
||||
('显示设置', {
|
||||
'fields': ('order', 'is_active')
|
||||
}),
|
||||
)
|
||||
|
||||
@display(description='图片预览')
|
||||
def image_preview(self, obj):
|
||||
if obj.image:
|
||||
return format_html('<img src="{}" style="max-width: 400px; max-height: 200px; border-radius: 8px;" />', obj.image.url)
|
||||
elif obj.image_url:
|
||||
return format_html('<img src="{}" style="max-width: 400px; max-height: 200px; border-radius: 8px;" />', obj.image_url)
|
||||
return "暂无图片"
|
||||
|
||||
|
||||
class ScoreDimensionInline(admin.TabularInline):
|
||||
model = ScoreDimension
|
||||
extra = 1
|
||||
tab = True
|
||||
fields = ('name', 'description', 'weight', 'max_score', 'is_public', 'is_peer_review', 'order')
|
||||
|
||||
class ProjectFileInline(admin.TabularInline):
|
||||
model = ProjectFile
|
||||
extra = 0
|
||||
tab = True
|
||||
|
||||
@admin.register(Competition)
|
||||
class CompetitionAdmin(ModelAdmin):
|
||||
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]
|
||||
|
||||
fieldsets = (
|
||||
('基本信息', {
|
||||
'fields': ('title', 'description', 'rule_description', 'condition_description')
|
||||
}),
|
||||
('封面设置', {
|
||||
'fields': ('cover_image', 'cover_image_url'),
|
||||
'description': '封面图可以上传本地图片,也可以填写外部链接,优先显示本地上传的图片'
|
||||
}),
|
||||
('时间和状态', {
|
||||
'fields': ('start_time', 'end_time', 'status', 'project_visibility', 'allow_contestant_grading', 'is_active')
|
||||
}),
|
||||
)
|
||||
|
||||
actions = ['make_published', 'make_ended']
|
||||
|
||||
def make_published(self, request, queryset):
|
||||
queryset.update(status='published')
|
||||
make_published.short_description = "发布选中比赛"
|
||||
|
||||
def make_ended(self, request, queryset):
|
||||
queryset.update(status='ended')
|
||||
make_ended.short_description = "结束选中比赛"
|
||||
|
||||
@admin.register(CompetitionEnrollment)
|
||||
class CompetitionEnrollmentAdmin(ModelAdmin):
|
||||
list_display = ['competition', 'user_info_display', 'role', 'status', 'created_at']
|
||||
list_filter = ['competition', 'role', 'status']
|
||||
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 = "通过审核"
|
||||
|
||||
def reject_enrollment(self, request, queryset):
|
||||
queryset.update(status='rejected')
|
||||
reject_enrollment.short_description = "拒绝申请"
|
||||
|
||||
@admin.register(Project)
|
||||
class ProjectAdmin(ModelAdmin):
|
||||
list_display = ['id', 'title', 'competition', 'contestant_info_display', 'status', 'final_score', 'created_at']
|
||||
list_filter = ['competition', 'status']
|
||||
search_fields = ['id', 'title', 'contestant__user__nickname', 'contestant__user__phone_number']
|
||||
autocomplete_fields = ['competition', 'contestant']
|
||||
inlines = [ProjectFileInline]
|
||||
readonly_fields = ['id', 'final_score']
|
||||
|
||||
fieldsets = (
|
||||
('基本信息', {
|
||||
'fields': ('competition', 'contestant', 'title', 'description', 'team_info')
|
||||
}),
|
||||
('封面设置', {
|
||||
'fields': ('cover_image', 'cover_image_url'),
|
||||
'description': '封面图可以上传本地图片,也可以填写外部链接,优先显示本地上传的图片'
|
||||
}),
|
||||
('状态和得分', {
|
||||
'fields': ('status', 'final_score')
|
||||
}),
|
||||
)
|
||||
|
||||
@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_info_display', 'dimension', 'score', 'created_at']
|
||||
list_filter = ['project__competition', 'dimension']
|
||||
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_info_display', 'content_preview', 'created_at']
|
||||
list_filter = ['project__competition']
|
||||
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 = "评语内容"
|
||||
6
backend/competition/apps.py
Normal file
6
backend/competition/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CompetitionConfig(AppConfig):
|
||||
name = 'competition'
|
||||
verbose_name = '首页管理'
|
||||
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'),
|
||||
]
|
||||
659
backend/competition/judge_views.py
Normal file
659
backend/competition/judge_views.py
Normal file
@@ -0,0 +1,659 @@
|
||||
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
|
||||
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
|
||||
|
||||
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) # Contestant can grade others if allowed
|
||||
}
|
||||
|
||||
# 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)})
|
||||
141
backend/competition/migrations/0001_initial.py
Normal file
141
backend/competition/migrations/0001_initial.py
Normal file
@@ -0,0 +1,141 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-10 02:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('shop', '0039_vccourse_video_embed_code'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Competition',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200, verbose_name='比赛名称')),
|
||||
('description', models.TextField(verbose_name='比赛简介')),
|
||||
('rule_description', models.TextField(verbose_name='规则说明')),
|
||||
('condition_description', models.TextField(blank=True, verbose_name='参赛条件说明')),
|
||||
('cover_image', models.ImageField(blank=True, null=True, upload_to='competitions/covers/', verbose_name='封面图')),
|
||||
('start_time', models.DateTimeField(verbose_name='开始时间')),
|
||||
('end_time', models.DateTimeField(verbose_name='结束时间')),
|
||||
('status', models.CharField(choices=[('draft', '草稿'), ('published', '已发布'), ('registration', '报名中'), ('submission', '作品提交中'), ('judging', '评审中'), ('ended', '已结束')], default='draft', max_length=20, 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='更新时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '比赛',
|
||||
'verbose_name_plural': '比赛管理',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CompetitionEnrollment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(choices=[('contestant', '选手'), ('judge', '评委'), ('guest', '嘉宾')], default='contestant', max_length=20, verbose_name='角色')),
|
||||
('status', models.CharField(choices=[('pending', '待审核'), ('approved', '已通过'), ('rejected', '已拒绝')], default='pending', max_length=20, 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='enrollments', to='competition.competition', verbose_name='所属比赛')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='competitions', to='shop.wechatuser', verbose_name='用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '比赛人员',
|
||||
'verbose_name_plural': '人员管理',
|
||||
'unique_together': {('competition', 'user')},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Project',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=200, verbose_name='项目名称')),
|
||||
('description', models.TextField(verbose_name='项目介绍')),
|
||||
('team_info', models.TextField(blank=True, verbose_name='团队介绍')),
|
||||
('cover_image', models.ImageField(blank=True, null=True, upload_to='competitions/projects/covers/', verbose_name='项目封面')),
|
||||
('status', models.CharField(choices=[('draft', '草稿'), ('submitted', '已提交')], default='draft', max_length=20, verbose_name='状态')),
|
||||
('final_score', models.DecimalField(decimal_places=2, default=0.0, max_digits=10, 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='projects', to='competition.competition', verbose_name='所属比赛')),
|
||||
('contestant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='projects', to='competition.competitionenrollment', verbose_name='参赛选手')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '参赛项目',
|
||||
'verbose_name_plural': '项目管理',
|
||||
'ordering': ['-final_score', '-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Comment',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('content', models.TextField(verbose_name='评语内容')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='评论时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('judge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='given_comments', to='competition.competitionenrollment', verbose_name='评委')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='competition.project', verbose_name='所属项目')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '评委评语',
|
||||
'verbose_name_plural': '评语管理',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ProjectFile',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('file_type', models.CharField(choices=[('ppt', 'PPT演示文稿'), ('pdf', 'PDF文档'), ('image', '图片'), ('video', '视频'), ('doc', '文档'), ('other', '其他')], default='other', max_length=20, verbose_name='文件类型')),
|
||||
('file', models.FileField(blank=True, null=True, upload_to='competitions/projects/files/', verbose_name='文件')),
|
||||
('file_url', models.URLField(blank=True, help_text='视频等大文件建议使用外部链接', null=True, verbose_name='文件链接')),
|
||||
('name', models.CharField(blank=True, max_length=100, verbose_name='文件名称')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='上传时间')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='competition.project', verbose_name='所属项目')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '项目附件',
|
||||
'verbose_name_plural': '附件管理',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ScoreDimension',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='维度名称')),
|
||||
('description', models.TextField(blank=True, verbose_name='维度说明')),
|
||||
('weight', models.DecimalField(decimal_places=2, default=1.0, help_text='例如 0.3 表示 30%', max_digits=5, verbose_name='权重')),
|
||||
('max_score', models.IntegerField(default=100, verbose_name='满分值')),
|
||||
('order', models.IntegerField(default=0, verbose_name='排序权重')),
|
||||
('competition', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='score_dimensions', to='competition.competition', verbose_name='所属比赛')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '评分维度',
|
||||
'verbose_name_plural': '评分维度配置',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Score',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('score', models.DecimalField(decimal_places=1, max_digits=5, verbose_name='得分')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='打分时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('judge', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='given_scores', to='competition.competitionenrollment', verbose_name='评委')),
|
||||
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scores', to='competition.project', verbose_name='所属项目')),
|
||||
('dimension', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='competition.scoredimension', verbose_name='评分维度')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '评分记录',
|
||||
'verbose_name_plural': '评分记录',
|
||||
'unique_together': {('project', 'judge', 'dimension')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-10 02:41
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='competition',
|
||||
name='cover_image_url',
|
||||
field=models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='封面图URL'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='project',
|
||||
name='cover_image_url',
|
||||
field=models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='项目封面URL'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-03-10 06:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0002_competition_cover_image_url_project_cover_image_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='competition',
|
||||
name='project_visibility',
|
||||
field=models.CharField(choices=[('public', '公开可见'), ('contestant', '选手及以上可见'), ('guest', '嘉宾及评委可见'), ('judge', '仅评委可见')], default='public', max_length=20, verbose_name='项目可见性'),
|
||||
),
|
||||
]
|
||||
@@ -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,97 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-18 09:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0006_add_peer_review_field'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='CarouselItem',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('carousel_type', models.CharField(choices=[('carousel1', '创业大赛轮播图'), ('carousel2', '创业活动轮播图')], default='carousel1', max_length=20, verbose_name='轮播图类型')),
|
||||
('image', models.ImageField(blank=True, null=True, upload_to='homepage/carousel/', verbose_name='轮播图片')),
|
||||
('image_url', models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='图片URL')),
|
||||
('title', models.CharField(max_length=100, verbose_name='标题')),
|
||||
('subtitle', models.CharField(max_length=200, verbose_name='副标题')),
|
||||
('status', models.CharField(choices=[('报名中', '报名中'), ('即将开始', '即将开始'), ('敬请期待', '敬请期待'), ('进行中', '进行中')], default='报名中', max_length=20, verbose_name='状态')),
|
||||
('status_color', models.CharField(default='#52c41a', max_length=20, verbose_name='状态颜色')),
|
||||
('date', models.CharField(max_length=100, verbose_name='日期')),
|
||||
('location', models.CharField(max_length=100, verbose_name='地点')),
|
||||
('order', models.IntegerField(default=0, 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='更新时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '轮播图项目',
|
||||
'verbose_name_plural': '轮播图管理',
|
||||
'ordering': ['order', 'id'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HomePageConfig',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('banner_image', models.ImageField(blank=True, null=True, upload_to='homepage/', verbose_name='首页Banner图片')),
|
||||
('banner_image_url', models.URLField(blank=True, help_text='优先使用上传的图片', null=True, verbose_name='Banner图片URL')),
|
||||
('main_title', models.CharField(default='"创赢未来"云南2026创业大赛', max_length=200, verbose_name='主标题')),
|
||||
('carousel1_title', models.CharField(default='"创赢未来"云南2026创业大赛', max_length=200, verbose_name='轮播图1标题')),
|
||||
('carousel2_title', models.CharField(default='"七彩云南创业福地"创业主题系列活动', max_length=200, verbose_name='轮播图2标题')),
|
||||
('organizer', models.CharField(default='云南省人力资源和社会保障厅', max_length=200, verbose_name='主办单位')),
|
||||
('undertaker', models.CharField(default='云南省就业局', max_length=200, 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='更新时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '首页配置',
|
||||
'verbose_name_plural': '首页配置',
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='comment',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competition',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='competitionenrollment',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='project',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='projectfile',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='score',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='scoredimension',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
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='权重'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.29 on 2026-03-18 12:35
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('competition', '0007_carouselitem_homepageconfig_alter_comment_id_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='carouselitem',
|
||||
name='image_url',
|
||||
field=models.CharField(blank=True, help_text='可填写本地路径如 /carousel1.png 或完整URL,优先使用上方上传的图片', max_length=500, null=True, verbose_name='图片URL'),
|
||||
),
|
||||
]
|
||||
0
backend/competition/migrations/__init__.py
Normal file
0
backend/competition/migrations/__init__.py
Normal file
337
backend/competition/models.py
Normal file
337
backend/competition/models.py
Normal file
@@ -0,0 +1,337 @@
|
||||
from django.db import models
|
||||
from shop.models import WeChatUser
|
||||
|
||||
|
||||
class HomePageConfig(models.Model):
|
||||
"""首页配置"""
|
||||
banner_image = models.ImageField(upload_to='homepage/', verbose_name="首页Banner图片", null=True, blank=True)
|
||||
banner_image_url = models.URLField(verbose_name="Banner图片URL", null=True, blank=True, help_text="优先使用上传的图片")
|
||||
|
||||
main_title = models.CharField(max_length=200, default='"创赢未来"云南2026创业大赛', verbose_name="主标题")
|
||||
|
||||
carousel1_title = models.CharField(max_length=200, default='"创赢未来"云南2026创业大赛', verbose_name="轮播图1标题")
|
||||
carousel2_title = models.CharField(max_length=200, default='"七彩云南创业福地"创业主题系列活动', verbose_name="轮播图2标题")
|
||||
|
||||
organizer = models.CharField(max_length=200, default='云南省人力资源和社会保障厅', verbose_name="主办单位")
|
||||
undertaker = models.CharField(max_length=200, default='云南省就业局', 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="更新时间")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "首页配置"
|
||||
verbose_name_plural = "首页配置"
|
||||
|
||||
def __str__(self):
|
||||
return "首页配置"
|
||||
|
||||
def get_banner_url(self):
|
||||
if self.banner_image:
|
||||
return self.banner_image.url
|
||||
return self.banner_image_url
|
||||
|
||||
|
||||
class CarouselItem(models.Model):
|
||||
"""轮播图项目"""
|
||||
CAROUSEL_TYPE_CHOICES = (
|
||||
('carousel1', '创业大赛轮播图'),
|
||||
('carousel2', '创业活动轮播图'),
|
||||
)
|
||||
|
||||
STATUS_CHOICES = (
|
||||
('报名中', '报名中'),
|
||||
('即将开始', '即将开始'),
|
||||
('敬请期待', '敬请期待'),
|
||||
('进行中', '进行中'),
|
||||
)
|
||||
|
||||
carousel_type = models.CharField(max_length=20, choices=CAROUSEL_TYPE_CHOICES, default='carousel1', verbose_name="轮播图类型")
|
||||
|
||||
image = models.ImageField(upload_to='homepage/carousel/', verbose_name="轮播图片", null=True, blank=True)
|
||||
image_url = models.CharField(max_length=500, verbose_name="图片URL", null=True, blank=True, help_text="可填写本地路径如 /carousel1.png 或完整URL,优先使用上方上传的图片")
|
||||
|
||||
title = models.CharField(max_length=100, verbose_name="标题")
|
||||
subtitle = models.CharField(max_length=200, verbose_name="副标题")
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='报名中', verbose_name="状态")
|
||||
status_color = models.CharField(max_length=20, default='#52c41a', verbose_name="状态颜色")
|
||||
|
||||
date = models.CharField(max_length=100, verbose_name="日期")
|
||||
location = models.CharField(max_length=100, verbose_name="地点")
|
||||
|
||||
order = models.IntegerField(default=0, 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="更新时间")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "轮播图项目"
|
||||
verbose_name_plural = "轮播图管理"
|
||||
ordering = ['order', 'id']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.get_carousel_type_display()} - {self.title}"
|
||||
|
||||
def get_image_url(self):
|
||||
if self.image:
|
||||
return self.image.url
|
||||
return self.image_url
|
||||
|
||||
|
||||
class Competition(models.Model):
|
||||
"""
|
||||
比赛管理模型
|
||||
"""
|
||||
STATUS_CHOICES = (
|
||||
('draft', '草稿'),
|
||||
('published', '已发布'),
|
||||
('registration', '报名中'),
|
||||
('submission', '作品提交中'),
|
||||
('judging', '评审中'),
|
||||
('ended', '已结束'),
|
||||
)
|
||||
|
||||
PROJECT_VISIBILITY_CHOICES = (
|
||||
('public', '公开可见'),
|
||||
('contestant', '选手及以上可见'),
|
||||
('guest', '嘉宾及评委可见'),
|
||||
('judge', '仅评委可见'),
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=200, verbose_name="比赛名称")
|
||||
description = models.TextField(verbose_name="比赛简介")
|
||||
rule_description = models.TextField(verbose_name="规则说明")
|
||||
condition_description = models.TextField(verbose_name="参赛条件说明", blank=True)
|
||||
|
||||
cover_image = models.ImageField(upload_to='competitions/covers/', verbose_name="封面图", null=True, blank=True)
|
||||
cover_image_url = models.URLField(verbose_name="封面图URL", null=True, blank=True, help_text="优先使用上传的图片")
|
||||
|
||||
start_time = models.DateTimeField(verbose_name="开始时间")
|
||||
end_time = models.DateTimeField(verbose_name="结束时间")
|
||||
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name="状态")
|
||||
project_visibility = models.CharField(max_length=20, choices=PROJECT_VISIBILITY_CHOICES, default='public', verbose_name="项目可见性")
|
||||
|
||||
allow_contestant_grading = models.BooleanField(default=False, 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="更新时间")
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
class Meta:
|
||||
verbose_name = "比赛"
|
||||
verbose_name_plural = "比赛管理"
|
||||
ordering = ['-created_at']
|
||||
|
||||
|
||||
class CompetitionEnrollment(models.Model):
|
||||
"""
|
||||
比赛人员报名/角色分配
|
||||
"""
|
||||
ROLE_CHOICES = (
|
||||
('contestant', '选手'),
|
||||
('judge', '评委'),
|
||||
('guest', '嘉宾'),
|
||||
)
|
||||
|
||||
STATUS_CHOICES = (
|
||||
('pending', '待审核'),
|
||||
('approved', '已通过'),
|
||||
('rejected', '已拒绝'),
|
||||
)
|
||||
|
||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='enrollments', verbose_name="所属比赛")
|
||||
user = models.ForeignKey(WeChatUser, on_delete=models.CASCADE, related_name='competitions', verbose_name="用户")
|
||||
|
||||
role = models.CharField(max_length=20, choices=ROLE_CHOICES, default='contestant', verbose_name="角色")
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending', 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 = "人员管理"
|
||||
unique_together = ('competition', 'user')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.competition.title} - {self.user.nickname} ({self.get_role_display()})"
|
||||
|
||||
|
||||
class ScoreDimension(models.Model):
|
||||
"""
|
||||
评分维度配置
|
||||
"""
|
||||
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=6, decimal_places=4, default=1.0000, verbose_name="权重", help_text="例如 0.3000 表示 30%")
|
||||
max_score = models.IntegerField(default=100, verbose_name="满分值")
|
||||
|
||||
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:
|
||||
verbose_name = "评分维度"
|
||||
verbose_name_plural = "评分维度配置"
|
||||
ordering = ['order']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.competition.title} - {self.name}"
|
||||
|
||||
|
||||
class Project(models.Model):
|
||||
"""
|
||||
参赛项目/作品
|
||||
"""
|
||||
STATUS_CHOICES = (
|
||||
('draft', '草稿'),
|
||||
('submitted', '已提交'),
|
||||
)
|
||||
|
||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, related_name='projects', verbose_name="所属比赛")
|
||||
contestant = models.ForeignKey(CompetitionEnrollment, on_delete=models.CASCADE, related_name='projects', verbose_name="参赛选手")
|
||||
|
||||
title = models.CharField(max_length=200, verbose_name="项目名称")
|
||||
description = models.TextField(verbose_name="项目介绍")
|
||||
team_info = models.TextField(verbose_name="团队介绍", blank=True)
|
||||
|
||||
cover_image = models.ImageField(upload_to='competitions/projects/covers/', verbose_name="项目封面", null=True, blank=True)
|
||||
cover_image_url = models.URLField(verbose_name="项目封面URL", null=True, blank=True, help_text="优先使用上传的图片")
|
||||
|
||||
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='draft', verbose_name="状态")
|
||||
|
||||
# 最终得分缓存,避免每次实时计算
|
||||
final_score = models.DecimalField(max_digits=10, decimal_places=2, default=0.00, 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 = ['-final_score', '-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def calculate_score(self):
|
||||
"""
|
||||
计算项目得分
|
||||
计算公式:
|
||||
1. 获取所有评委对该项目的打分
|
||||
2. 每个评委的得分 = sum(维度分数 × 维度权重)
|
||||
3. 项目最终得分 = 所有评委得分的平均值
|
||||
"""
|
||||
# 获取所有评分
|
||||
scores = self.scores.all()
|
||||
if not scores.exists():
|
||||
return 0
|
||||
|
||||
# 找出所有参与评分的评委
|
||||
judges = set(score.judge for score in scores)
|
||||
if not judges:
|
||||
return 0
|
||||
|
||||
total_weighted_score = 0
|
||||
|
||||
for judge in judges:
|
||||
judge_score = 0
|
||||
# 获取该评委对该项目的所有维度打分
|
||||
judge_scores = scores.filter(judge=judge)
|
||||
|
||||
for score in judge_scores:
|
||||
# 直接用原始分数乘以权重相加
|
||||
judge_score += score.score * score.dimension.weight
|
||||
|
||||
total_weighted_score += judge_score
|
||||
|
||||
# 平均分
|
||||
avg_score = total_weighted_score / len(judges)
|
||||
self.final_score = avg_score
|
||||
self.save()
|
||||
return avg_score
|
||||
|
||||
|
||||
class ProjectFile(models.Model):
|
||||
"""
|
||||
项目附件
|
||||
"""
|
||||
FILE_TYPE_CHOICES = (
|
||||
('ppt', 'PPT演示文稿'),
|
||||
('pdf', 'PDF文档'),
|
||||
('image', '图片'),
|
||||
('video', '视频'),
|
||||
('doc', '文档'),
|
||||
('other', '其他'),
|
||||
)
|
||||
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='files', verbose_name="所属项目")
|
||||
file_type = models.CharField(max_length=20, choices=FILE_TYPE_CHOICES, default='other', verbose_name="文件类型")
|
||||
|
||||
file = models.FileField(upload_to='competitions/projects/files/', verbose_name="文件", null=True, blank=True)
|
||||
file_url = models.URLField(verbose_name="文件链接", null=True, blank=True, help_text="视频等大文件建议使用外部链接")
|
||||
|
||||
name = models.CharField(max_length=100, verbose_name="文件名称", blank=True)
|
||||
|
||||
created_at = models.DateTimeField(auto_now_add=True, verbose_name="上传时间")
|
||||
|
||||
class Meta:
|
||||
verbose_name = "项目附件"
|
||||
verbose_name_plural = "附件管理"
|
||||
|
||||
def __str__(self):
|
||||
return self.name or f"{self.get_file_type_display()}"
|
||||
|
||||
|
||||
class Score(models.Model):
|
||||
"""
|
||||
评委打分
|
||||
"""
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='scores', verbose_name="所属项目")
|
||||
judge = models.ForeignKey(CompetitionEnrollment, on_delete=models.CASCADE, related_name='given_scores', verbose_name="评委")
|
||||
dimension = models.ForeignKey(ScoreDimension, on_delete=models.CASCADE, verbose_name="评分维度")
|
||||
|
||||
score = models.DecimalField(max_digits=5, decimal_places=1, 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 = "评分记录"
|
||||
unique_together = ('project', 'judge', 'dimension')
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.judge.user.nickname} -> {self.project.title}: {self.score}"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs)
|
||||
# 触发重新计算分数
|
||||
self.project.calculate_score()
|
||||
|
||||
|
||||
class Comment(models.Model):
|
||||
"""
|
||||
评委评语
|
||||
"""
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='comments', verbose_name="所属项目")
|
||||
judge = models.ForeignKey(CompetitionEnrollment, on_delete=models.CASCADE, related_name='given_comments', verbose_name="评委")
|
||||
|
||||
content = models.TextField(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 = "评语管理"
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.judge.user.nickname} -> {self.project.title}"
|
||||
155
backend/competition/serializers.py
Normal file
155
backend/competition/serializers.py
Normal file
@@ -0,0 +1,155 @@
|
||||
from rest_framework import serializers
|
||||
from .models import Competition, CompetitionEnrollment, ScoreDimension, Project, ProjectFile, Score, Comment, HomePageConfig, CarouselItem
|
||||
from shop.serializers import WeChatUserSerializer
|
||||
|
||||
|
||||
class CarouselItemSerializer(serializers.ModelSerializer):
|
||||
display_image = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = CarouselItem
|
||||
fields = ['id', 'carousel_type', 'image', 'image_url', 'display_image',
|
||||
'title', 'subtitle', 'status', 'status_color', 'date', 'location',
|
||||
'order', 'is_active']
|
||||
|
||||
def get_display_image(self, obj):
|
||||
request = self.context.get('request')
|
||||
if obj.image:
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.image.url)
|
||||
return obj.image.url
|
||||
return obj.image_url
|
||||
|
||||
|
||||
class HomePageConfigSerializer(serializers.ModelSerializer):
|
||||
display_banner = serializers.SerializerMethodField()
|
||||
carousel1_items = serializers.SerializerMethodField()
|
||||
carousel2_items = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = HomePageConfig
|
||||
fields = ['id', 'banner_image', 'banner_image_url', 'display_banner',
|
||||
'main_title', 'carousel1_title', 'carousel2_title',
|
||||
'organizer', 'undertaker', 'carousel1_items', 'carousel2_items']
|
||||
|
||||
def get_display_banner(self, obj):
|
||||
request = self.context.get('request')
|
||||
if obj.banner_image:
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.banner_image.url)
|
||||
return obj.banner_image.url
|
||||
return obj.banner_image_url
|
||||
|
||||
def get_carousel1_items(self, obj):
|
||||
items = CarouselItem.objects.filter(carousel_type='carousel1', is_active=True)
|
||||
return CarouselItemSerializer(items, many=True, context=self.context).data
|
||||
|
||||
def get_carousel2_items(self, obj):
|
||||
items = CarouselItem.objects.filter(carousel_type='carousel2', is_active=True)
|
||||
return CarouselItemSerializer(items, many=True, context=self.context).data
|
||||
|
||||
|
||||
class ScoreDimensionSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ScoreDimension
|
||||
fields = ['id', 'name', 'description', 'weight', 'max_score', 'order']
|
||||
|
||||
class CompetitionSerializer(serializers.ModelSerializer):
|
||||
score_dimensions = ScoreDimensionSerializer(many=True, read_only=True)
|
||||
display_cover_image = serializers.SerializerMethodField()
|
||||
status_display = serializers.CharField(source='get_status_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Competition
|
||||
fields = ['id', 'title', 'description', 'rule_description', 'condition_description',
|
||||
'cover_image', 'cover_image_url', 'display_cover_image',
|
||||
'start_time', 'end_time', 'status', 'project_visibility', 'status_display', 'is_active',
|
||||
'score_dimensions', 'created_at']
|
||||
|
||||
def get_display_cover_image(self, obj):
|
||||
request = self.context.get('request')
|
||||
if obj.cover_image:
|
||||
if request:
|
||||
return request.build_absolute_uri(obj.cover_image.url)
|
||||
return obj.cover_image.url
|
||||
return obj.cover_image_url
|
||||
|
||||
class CompetitionEnrollmentSerializer(serializers.ModelSerializer):
|
||||
user = WeChatUserSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = CompetitionEnrollment
|
||||
fields = ['id', 'competition', 'user', 'role', 'status', 'created_at']
|
||||
read_only_fields = ['status']
|
||||
|
||||
class ProjectFileSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ProjectFile
|
||||
fields = ['id', 'project', 'file_type', 'file', 'file_url', 'name', 'created_at']
|
||||
|
||||
def validate_file(self, value):
|
||||
if not value:
|
||||
return value
|
||||
# 50MB limit
|
||||
limit_mb = 50
|
||||
if value.size > limit_mb * 1024 * 1024:
|
||||
raise serializers.ValidationError(f"文件大小不能超过 {limit_mb}MB")
|
||||
return value
|
||||
|
||||
class ProjectSerializer(serializers.ModelSerializer):
|
||||
files = ProjectFileSerializer(many=True, read_only=True)
|
||||
contestant_info = serializers.SerializerMethodField()
|
||||
display_cover_image = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = ['id', 'competition', 'contestant', 'title', 'description', 'team_info',
|
||||
'cover_image', 'cover_image_url', 'display_cover_image',
|
||||
'status', 'final_score', 'files', 'contestant_info', 'created_at']
|
||||
read_only_fields = ['final_score', 'contestant']
|
||||
|
||||
def get_contestant_info(self, obj):
|
||||
return {
|
||||
"nickname": obj.contestant.user.nickname,
|
||||
"avatar_url": obj.contestant.user.avatar_url
|
||||
}
|
||||
|
||||
def get_display_cover_image(self, obj):
|
||||
if obj.cover_image:
|
||||
return obj.cover_image.url
|
||||
return obj.cover_image_url
|
||||
|
||||
class ScoreSerializer(serializers.ModelSerializer):
|
||||
judge_name = serializers.CharField(source='judge.user.nickname', read_only=True)
|
||||
dimension_name = serializers.CharField(source='dimension.name', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Score
|
||||
fields = ['id', 'project', 'judge', 'dimension', 'score', 'judge_name', 'dimension_name', 'created_at']
|
||||
read_only_fields = ['judge']
|
||||
|
||||
class CommentSerializer(serializers.ModelSerializer):
|
||||
judge_name = serializers.CharField(source='judge.user.nickname', read_only=True)
|
||||
score = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Comment
|
||||
fields = ['id', 'project', 'judge', 'content', 'judge_name', 'created_at', 'score']
|
||||
read_only_fields = ['judge']
|
||||
|
||||
def get_score(self, obj):
|
||||
scores = Score.objects.filter(project=obj.project, judge=obj.judge)
|
||||
if not scores.exists():
|
||||
return None
|
||||
|
||||
current_judge_total_score = 0
|
||||
current_judge_total_weight = 0
|
||||
|
||||
for score in scores:
|
||||
current_judge_total_score += score.score * score.dimension.weight
|
||||
current_judge_total_weight += score.dimension.weight
|
||||
|
||||
if current_judge_total_weight > 0:
|
||||
judge_score = current_judge_total_score / current_judge_total_weight
|
||||
return round(judge_score, 1)
|
||||
return None
|
||||
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>
|
||||
823
backend/competition/templates/judge/dashboard.html
Normal file
823
backend/competition/templates/judge/dashboard.html
Normal file
@@ -0,0 +1,823 @@
|
||||
{% 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>
|
||||
|
||||
<!-- 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;
|
||||
|
||||
// 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 %}
|
||||
3
backend/competition/tests.py
Normal file
3
backend/competition/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
27
backend/competition/urls.py
Normal file
27
backend/competition/urls.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from django.urls import path, include
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from .views import (
|
||||
CompetitionViewSet, ProjectViewSet, ProjectFileViewSet,
|
||||
ScoreViewSet, CommentViewSet, CarouselItemViewSet, get_homepage_config
|
||||
)
|
||||
from . import judge_views
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'competitions', CompetitionViewSet)
|
||||
router.register(r'projects', ProjectViewSet, basename='project')
|
||||
router.register(r'files', ProjectFileViewSet, basename='projectfile')
|
||||
router.register(r'scores', ScoreViewSet, basename='score')
|
||||
router.register(r'comments', CommentViewSet, basename='comment')
|
||||
router.register(r'carousel-items', CarouselItemViewSet, basename='carouselitem')
|
||||
|
||||
urlpatterns = [
|
||||
# 首页配置
|
||||
path('homepage-config/', get_homepage_config, name='homepage-config'),
|
||||
|
||||
# Judge System Routes
|
||||
path('admin/', judge_views.admin_entry, name='judge_admin_entry'),
|
||||
path('judge/', include('competition.judge_urls')), # Sub-routes under /judge/
|
||||
|
||||
# Existing API Routes
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
319
backend/competition/views.py
Normal file
319
backend/competition/views.py
Normal file
@@ -0,0 +1,319 @@
|
||||
from rest_framework import viewsets, permissions, status, filters, serializers
|
||||
from rest_framework.decorators import action, api_view, permission_classes
|
||||
from rest_framework.response import Response
|
||||
from django.db.models import Q
|
||||
from shop.utils import get_current_wechat_user
|
||||
from .models import Competition, CompetitionEnrollment, Project, ProjectFile, Score, Comment, ScoreDimension, HomePageConfig, CarouselItem
|
||||
from .serializers import (
|
||||
CompetitionSerializer, CompetitionEnrollmentSerializer,
|
||||
ProjectSerializer, ProjectFileSerializer,
|
||||
ScoreSerializer, CommentSerializer, ScoreDimensionSerializer,
|
||||
HomePageConfigSerializer, CarouselItemSerializer
|
||||
)
|
||||
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
|
||||
|
||||
@api_view(['GET'])
|
||||
@permission_classes([permissions.AllowAny])
|
||||
def get_homepage_config(request):
|
||||
"""获取首页配置"""
|
||||
try:
|
||||
config = HomePageConfig.objects.filter(is_active=True).first()
|
||||
if not config:
|
||||
config = HomePageConfig.objects.create()
|
||||
serializer = HomePageConfigSerializer(config, context={'request': request})
|
||||
return Response(serializer.data)
|
||||
except Exception as e:
|
||||
return Response({"error": str(e)}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||
|
||||
|
||||
class CarouselItemViewSet(viewsets.ModelViewSet):
|
||||
"""轮播图项目管理"""
|
||||
queryset = CarouselItem.objects.all()
|
||||
serializer_class = CarouselItemSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
filter_backends = [filters.SearchFilter]
|
||||
search_fields = ['title']
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = CarouselItem.objects.all()
|
||||
carousel_type = self.request.query_params.get('carousel_type')
|
||||
if carousel_type:
|
||||
queryset = queryset.filter(carousel_type=carousel_type)
|
||||
return queryset
|
||||
|
||||
|
||||
class StandardResultsSetPagination(PageNumberPagination):
|
||||
page_size = 10
|
||||
page_size_query_param = 'page_size'
|
||||
max_page_size = 100
|
||||
|
||||
class CompetitionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
比赛视图集
|
||||
"""
|
||||
queryset = Competition.objects.filter(is_active=True).order_by('created_at')
|
||||
serializer_class = CompetitionSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
pagination_class = StandardResultsSetPagination
|
||||
filter_backends = [filters.SearchFilter]
|
||||
search_fields = ['title', 'description']
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
context['request'] = self.request
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
获取比赛查询集,支持根据查询参数进行动态过滤
|
||||
"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# 状态过滤
|
||||
status_param = self.request.query_params.get('status')
|
||||
if status_param and status_param != 'all':
|
||||
queryset = queryset.filter(status=status_param)
|
||||
|
||||
return queryset
|
||||
|
||||
@action(detail=True, methods=['post'], permission_classes=[permissions.AllowAny])
|
||||
def enroll(self, request, pk=None):
|
||||
"""
|
||||
报名参加比赛
|
||||
"""
|
||||
competition = self.get_object()
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({"detail": "未登录"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
role = request.data.get('role', 'contestant')
|
||||
|
||||
# 检查是否已报名
|
||||
if CompetitionEnrollment.objects.filter(competition=competition, user=user).exists():
|
||||
return Response({"detail": "您已报名该比赛"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
enrollment = CompetitionEnrollment.objects.create(
|
||||
competition=competition,
|
||||
user=user,
|
||||
role=role,
|
||||
status='pending' # 默认待审核
|
||||
)
|
||||
|
||||
return Response(CompetitionEnrollmentSerializer(enrollment).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@action(detail=True, methods=['get'])
|
||||
def my_enrollment(self, request, pk=None):
|
||||
"""
|
||||
获取我的报名信息
|
||||
"""
|
||||
competition = self.get_object()
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response({"detail": "未登录"}, status=status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
try:
|
||||
enrollment = CompetitionEnrollment.objects.get(competition=competition, user=user)
|
||||
return Response(CompetitionEnrollmentSerializer(enrollment).data)
|
||||
except CompetitionEnrollment.DoesNotExist:
|
||||
return Response({"detail": "未报名"}, status=status.HTTP_404_NOT_FOUND)
|
||||
|
||||
@action(detail=False, methods=['get'])
|
||||
def my_enrollments(self, request):
|
||||
"""
|
||||
获取我的所有报名信息
|
||||
"""
|
||||
user = get_current_wechat_user(request)
|
||||
if not user:
|
||||
return Response([])
|
||||
enrollments = CompetitionEnrollment.objects.filter(user=user)
|
||||
return Response(CompetitionEnrollmentSerializer(enrollments, many=True).data)
|
||||
|
||||
|
||||
class ProjectViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
参赛项目视图集
|
||||
"""
|
||||
serializer_class = ProjectSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
pagination_class = StandardResultsSetPagination
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = Project.objects.all()
|
||||
competition_id = self.request.query_params.get('competition')
|
||||
if competition_id:
|
||||
queryset = queryset.filter(competition_id=competition_id)
|
||||
|
||||
contestant_id = self.request.query_params.get('contestant')
|
||||
if contestant_id:
|
||||
queryset = queryset.filter(contestant_id=contestant_id)
|
||||
|
||||
user = get_current_wechat_user(self.request)
|
||||
|
||||
# 1. 基础条件:公开可见且已提交的项目
|
||||
q = Q(competition__project_visibility='public', status='submitted')
|
||||
|
||||
if user:
|
||||
# 2. 用户自己的项目(始终可见,包括草稿)
|
||||
q |= Q(contestant__user=user)
|
||||
|
||||
# 3. 基于角色的可见性
|
||||
# 获取用户已通过审核的报名信息
|
||||
enrollments = CompetitionEnrollment.objects.filter(user=user, status='approved')
|
||||
|
||||
# 获取各角色的比赛ID集合
|
||||
judge_comp_ids = set(enrollments.filter(role='judge').values_list('competition_id', flat=True))
|
||||
guest_comp_ids = set(enrollments.filter(role='guest').values_list('competition_id', flat=True))
|
||||
contestant_comp_ids = set(enrollments.filter(role='contestant').values_list('competition_id', flat=True))
|
||||
|
||||
# 'judge' 可见性:仅评委可见
|
||||
if judge_comp_ids:
|
||||
q |= Q(competition__project_visibility='judge', competition__in=judge_comp_ids, status='submitted')
|
||||
|
||||
# 'guest' 可见性:嘉宾及评委可见
|
||||
guest_access_ids = judge_comp_ids | guest_comp_ids
|
||||
if guest_access_ids:
|
||||
q |= Q(competition__project_visibility='guest', competition__in=guest_access_ids, status='submitted')
|
||||
|
||||
# 'contestant' 可见性:选手及以上可见(包括评委、嘉宾)
|
||||
contestant_access_ids = judge_comp_ids | guest_comp_ids | contestant_comp_ids
|
||||
if contestant_access_ids:
|
||||
q |= Q(competition__project_visibility='contestant', competition__in=contestant_access_ids, status='submitted')
|
||||
|
||||
queryset = queryset.filter(q)
|
||||
|
||||
return queryset.order_by('-final_score', '-created_at')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
user = get_current_wechat_user(self.request)
|
||||
if not user:
|
||||
raise serializers.ValidationError("请先登录")
|
||||
|
||||
competition = serializer.validated_data['competition']
|
||||
|
||||
# 检查是否有参赛资格
|
||||
try:
|
||||
enrollment = CompetitionEnrollment.objects.get(
|
||||
competition=competition,
|
||||
user=user,
|
||||
role='contestant',
|
||||
status='approved'
|
||||
)
|
||||
except CompetitionEnrollment.DoesNotExist:
|
||||
raise serializers.ValidationError("您没有参赛资格或审核未通过")
|
||||
|
||||
# 检查是否已提交过项目
|
||||
if Project.objects.filter(competition=competition, contestant=enrollment).exists():
|
||||
raise serializers.ValidationError("您已提交过该比赛的项目,请勿重复提交")
|
||||
|
||||
serializer.save(contestant=enrollment)
|
||||
|
||||
@action(detail=True, methods=['post'])
|
||||
def submit(self, request, pk=None):
|
||||
"""
|
||||
提交项目(从草稿转为已提交)
|
||||
"""
|
||||
project = self.get_object()
|
||||
user = get_current_wechat_user(request)
|
||||
|
||||
if project.contestant.user != user:
|
||||
return Response({"detail": "无权操作"}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
project.status = 'submitted'
|
||||
project.save()
|
||||
return Response({"status": "submitted"})
|
||||
|
||||
|
||||
class ProjectFileViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
项目附件管理
|
||||
"""
|
||||
serializer_class = ProjectFileSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get_queryset(self):
|
||||
return ProjectFile.objects.all()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# 简单权限控制:只有项目拥有者可以上传
|
||||
project = serializer.validated_data['project']
|
||||
user = get_current_wechat_user(self.request)
|
||||
|
||||
if not user or project.contestant.user != user:
|
||||
raise serializers.ValidationError("无权上传文件")
|
||||
|
||||
serializer.save()
|
||||
|
||||
|
||||
class ScoreViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
评分管理
|
||||
"""
|
||||
serializer_class = ScoreSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get_queryset(self):
|
||||
project_id = self.request.query_params.get('project')
|
||||
if project_id:
|
||||
return Score.objects.filter(project_id=project_id)
|
||||
return Score.objects.all()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
user = get_current_wechat_user(self.request)
|
||||
if not user:
|
||||
raise serializers.ValidationError("请先登录")
|
||||
|
||||
project = serializer.validated_data['project']
|
||||
|
||||
# 检查是否是评委
|
||||
try:
|
||||
enrollment = CompetitionEnrollment.objects.get(
|
||||
competition=project.competition,
|
||||
user=user,
|
||||
role='judge',
|
||||
status='approved'
|
||||
)
|
||||
except CompetitionEnrollment.DoesNotExist:
|
||||
raise serializers.ValidationError("您不是该比赛的评委")
|
||||
|
||||
# 检查是否重复打分
|
||||
dimension = serializer.validated_data['dimension']
|
||||
if Score.objects.filter(project=project, judge=enrollment, dimension=dimension).exists():
|
||||
raise serializers.ValidationError("您已对该维度打分")
|
||||
|
||||
serializer.save(judge=enrollment)
|
||||
|
||||
|
||||
class CommentViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
评语管理
|
||||
"""
|
||||
serializer_class = CommentSerializer
|
||||
permission_classes = [permissions.AllowAny]
|
||||
|
||||
def get_queryset(self):
|
||||
project_id = self.request.query_params.get('project')
|
||||
if project_id:
|
||||
return Comment.objects.filter(project_id=project_id)
|
||||
return Comment.objects.all()
|
||||
|
||||
def perform_create(self, serializer):
|
||||
user = get_current_wechat_user(self.request)
|
||||
if not user:
|
||||
raise serializers.ValidationError("请先登录")
|
||||
|
||||
project = serializer.validated_data['project']
|
||||
|
||||
# 检查是否是评委
|
||||
try:
|
||||
enrollment = CompetitionEnrollment.objects.get(
|
||||
competition=project.competition,
|
||||
user=user,
|
||||
role='judge',
|
||||
status='approved'
|
||||
)
|
||||
except CompetitionEnrollment.DoesNotExist:
|
||||
raise serializers.ValidationError("您不是该比赛的评委")
|
||||
|
||||
serializer.save(judge=enrollment)
|
||||
0
backend/config/__init__.py
Normal file
0
backend/config/__init__.py
Normal file
16
backend/config/asgi.py
Normal file
16
backend/config/asgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
ASGI config for config project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
404
backend/config/settings.py
Normal file
404
backend/config/settings.py
Normal file
@@ -0,0 +1,404 @@
|
||||
"""
|
||||
Django settings for config project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 6.0.1.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/6.0/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Load .env file
|
||||
load_dotenv(BASE_DIR / '.env')
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = 'django-insecure-9hwh_v44(3n)61g)tiwkvm1k0h&5c+u=68&z*!$e0ujpd-6^1o'
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'unfold', # django-unfold必须在admin之前
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'rest_framework',
|
||||
'corsheaders',
|
||||
'django_filters',
|
||||
'drf_spectacular', # Swagger文档生成
|
||||
'drf_spectacular_sidecar',
|
||||
# 'adminsortable2', # 暂时禁用,改用手动设置
|
||||
'shop',
|
||||
'community',
|
||||
'competition',
|
||||
'ai_services',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
CORS_ALLOW_ALL_ORIGINS = True
|
||||
|
||||
CSRF_TRUSTED_ORIGINS = [
|
||||
"https://market.quant-speed.com",
|
||||
"http://market.quant-speed.com",
|
||||
"http://localhost:8000",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'config.urls'
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'config.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
|
||||
|
||||
# 数据库配置:默认使用 SQLite,如果有环境变量配置则使用 PostgreSQL
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
#从环境变量获取数据库配置 (Docker 环境会自动注入这些变量。
|
||||
# 只有当 DB_HOST 被明确设置且不为空时才使用 PostgreSQL
|
||||
DB_HOST = os.environ.get('DB_HOST', '')
|
||||
if DB_HOST and DB_HOST != '6.6.6.66':
|
||||
DATABASES['default'] = {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': os.environ.get('DB_NAME', 'market'),
|
||||
'USER': os.environ.get('DB_USER', 'market'),
|
||||
'PASSWORD': os.environ.get('DB_PASSWORD', '123market'),
|
||||
'HOST': DB_HOST,
|
||||
'PORT': os.environ.get('DB_PORT', '5432'),
|
||||
}
|
||||
|
||||
|
||||
# DB_HOST = os.environ.get('DB_HOST', '121.43.104.161')
|
||||
# if DB_HOST:
|
||||
# DATABASES['default'] = {
|
||||
# 'ENGINE': 'django.db.backends.postgresql',
|
||||
# 'NAME': os.environ.get('DB_NAME', 'market'),
|
||||
# 'USER': os.environ.get('DB_USER', 'market'),
|
||||
# 'PASSWORD': os.environ.get('DB_PASSWORD', '123market'),
|
||||
# 'HOST': DB_HOST,
|
||||
# 'PORT': os.environ.get('DB_PORT', '6433'),
|
||||
# }
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/6.0/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'zh-hans'
|
||||
|
||||
TIME_ZONE = 'Asia/Shanghai'
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/6.0/howto/static-files/
|
||||
|
||||
STATIC_URL = 'static/'
|
||||
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||
|
||||
# 静态文件配置
|
||||
STATICFILES_DIRS = [
|
||||
BASE_DIR / 'static',
|
||||
]
|
||||
|
||||
# 媒体文件配置
|
||||
MEDIA_URL = '/media/'
|
||||
MEDIA_ROOT = BASE_DIR / 'media'
|
||||
|
||||
# Django REST Framework配置
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [],
|
||||
'DEFAULT_PERMISSION_CLASSES': [],
|
||||
}
|
||||
|
||||
# drf-spectacular配置
|
||||
SPECTACULAR_SETTINGS = {
|
||||
'TITLE': '科技公司产品购买API',
|
||||
'DESCRIPTION': '科技公司产品购买官网的API文档',
|
||||
'VERSION': '1.0.0',
|
||||
'SERVE_INCLUDE_SCHEMA': True,
|
||||
'SERVE_PERMISSIONS': ['rest_framework.permissions.AllowAny'],
|
||||
'COMPONENT_SPLIT_REQUEST': True,
|
||||
'SWAGGER_UI_DIST': 'SIDECAR',
|
||||
'SWAGGER_UI_FAVICON_HREF': 'SIDECAR',
|
||||
'REDOC_DIST': 'SIDECAR',
|
||||
}
|
||||
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
# django-unfold配置
|
||||
UNFOLD = {
|
||||
"SITE_TITLE": "创赢未来",
|
||||
"SITE_HEADER": "创赢未来评分管理后台",
|
||||
"SITE_URL": "/",
|
||||
"COLORS": {
|
||||
"primary": {
|
||||
"50": "rgb(236 254 255)",
|
||||
"100": "rgb(207 250 254)",
|
||||
"200": "rgb(165 243 252)",
|
||||
"300": "rgb(103 232 249)",
|
||||
"400": "rgb(34 211 238)",
|
||||
"500": "rgb(6 182 212)",
|
||||
"600": "rgb(8 145 178)",
|
||||
"700": "rgb(14 116 144)",
|
||||
"800": "rgb(21 94 117)",
|
||||
"900": "rgb(22 78 99)",
|
||||
"950": "rgb(8 51 68)",
|
||||
},
|
||||
},
|
||||
"SIDEBAR": {
|
||||
"show_search": True,
|
||||
"show_all_applications": False,
|
||||
"navigation": [
|
||||
{
|
||||
"title": "用户管理",
|
||||
"separator": True,
|
||||
"items": [
|
||||
{
|
||||
"title": "微信用户",
|
||||
"icon": "people",
|
||||
"link": reverse_lazy("admin:shop_wechatuser_changelist"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "首页管理",
|
||||
"separator": True,
|
||||
"items": [
|
||||
{
|
||||
"title": "首页配置",
|
||||
"icon": "home",
|
||||
"link": reverse_lazy("admin:competition_homepageconfig_changelist"),
|
||||
},
|
||||
{
|
||||
"title": "轮播图管理",
|
||||
"icon": "image",
|
||||
"link": reverse_lazy("admin:competition_carouselitem_changelist"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "比赛管理",
|
||||
"separator": True,
|
||||
"items": [
|
||||
{
|
||||
"title": "比赛列表",
|
||||
"icon": "emoji_events",
|
||||
"link": reverse_lazy("admin:competition_competition_changelist"),
|
||||
},
|
||||
{
|
||||
"title": "比赛人员/报名",
|
||||
"icon": "group_add",
|
||||
"link": reverse_lazy("admin:competition_competitionenrollment_changelist"),
|
||||
},
|
||||
{
|
||||
"title": "参赛项目",
|
||||
"icon": "lightbulb",
|
||||
"link": reverse_lazy("admin:competition_project_changelist"),
|
||||
},
|
||||
{
|
||||
"title": "评分记录",
|
||||
"icon": "score",
|
||||
"link": reverse_lazy("admin:competition_score_changelist"),
|
||||
},
|
||||
{
|
||||
"title": "评委评语",
|
||||
"icon": "rate_review",
|
||||
"link": reverse_lazy("admin:competition_comment_changelist"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "系列活动",
|
||||
"separator": True,
|
||||
"items": [
|
||||
{
|
||||
"title": "活动管理",
|
||||
"icon": "calendar_today",
|
||||
"link": reverse_lazy("admin:community_activity_changelist"),
|
||||
},
|
||||
{
|
||||
"title": "活动报名",
|
||||
"icon": "how_to_reg",
|
||||
"link": reverse_lazy("admin:community_activitysignup_changelist"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "课程培训",
|
||||
"separator": True,
|
||||
"items": [
|
||||
{
|
||||
"title": "课程管理",
|
||||
"icon": "school",
|
||||
"link": reverse_lazy("admin:shop_vccourse_changelist"),
|
||||
},
|
||||
{
|
||||
"title": "课程报名",
|
||||
"icon": "menu_book",
|
||||
"link": reverse_lazy("admin:shop_courseenrollment_changelist"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "身份标签",
|
||||
"separator": True,
|
||||
"items": [
|
||||
{
|
||||
"title": "标签管理",
|
||||
"icon": "label",
|
||||
"link": reverse_lazy("admin:shop_identitytag_changelist"),
|
||||
},
|
||||
{
|
||||
"title": "用户身份",
|
||||
"icon": "person_pin",
|
||||
"link": reverse_lazy("admin:shop_useridentity_changelist"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "AI 听悟",
|
||||
"separator": True,
|
||||
"items": [
|
||||
{
|
||||
"title": "转写与总结任务",
|
||||
"icon": "record_voice_over",
|
||||
"link": reverse_lazy("admin:ai_services_transcriptiontask_changelist"),
|
||||
},
|
||||
{
|
||||
"title": "AI 评估模板",
|
||||
"icon": "rule",
|
||||
"link": reverse_lazy("admin:ai_services_aievaluationtemplate_changelist"),
|
||||
},
|
||||
{
|
||||
"title": "AI 评估结果",
|
||||
"icon": "psychology",
|
||||
"link": reverse_lazy("admin:ai_services_aievaluation_changelist"),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "系统配置",
|
||||
"separator": True,
|
||||
"items": [
|
||||
{
|
||||
"title": "微信支付配置",
|
||||
"icon": "payment",
|
||||
"link": reverse_lazy("admin:shop_wechatpayconfig_changelist"),
|
||||
},
|
||||
{
|
||||
"title": "管理员通知手机号",
|
||||
"icon": "contact_phone",
|
||||
"link": reverse_lazy("admin:shop_adminphonenumber_changelist"),
|
||||
},
|
||||
{
|
||||
"title": "用户认证",
|
||||
"icon": "security",
|
||||
"link": reverse_lazy("admin:auth_user_changelist"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# 重新启用自动补齐斜杠,方便 Admin 使用
|
||||
# 微信支付回调接口已在 urls.py 中配置 re_path 兼容无斜杠的情况
|
||||
APPEND_SLASH = True
|
||||
|
||||
LOGGING = {
|
||||
'version': 1,
|
||||
'disable_existing_loggers': False,
|
||||
'handlers': {
|
||||
'console': {
|
||||
'class': 'logging.StreamHandler',
|
||||
},
|
||||
},
|
||||
'root': {
|
||||
'handlers': ['console'],
|
||||
'level': 'INFO',
|
||||
},
|
||||
}
|
||||
|
||||
# 阿里云配置
|
||||
ALIYUN_ACCESS_KEY_ID = os.environ.get('ALIYUN_ACCESS_KEY_ID', '')
|
||||
ALIYUN_ACCESS_KEY_SECRET = os.environ.get('ALIYUN_ACCESS_KEY_SECRET', '')
|
||||
ALIYUN_OSS_BUCKET_NAME = os.environ.get('ALIYUN_OSS_BUCKET_NAME', '')
|
||||
ALIYUN_OSS_ENDPOINT = os.environ.get('ALIYUN_OSS_ENDPOINT', 'oss-cn-shanghai.aliyuncs.com')
|
||||
ALIYUN_OSS_INTERNAL_ENDPOINT = os.environ.get('ALIYUN_OSS_INTERNAL_ENDPOINT', '')
|
||||
ALIYUN_TINGWU_APP_KEY = os.environ.get('ALIYUN_TINGWU_APP_KEY', '') # 听悟AppKey
|
||||
|
||||
DASHSCOPE_API_KEY = os.environ.get('DASHSCOPE_API_KEY', '')
|
||||
29
backend/config/urls.py
Normal file
29
backend/config/urls.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from django.contrib import admin
|
||||
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')),
|
||||
path('api/ai/', include('ai_services.urls')),
|
||||
|
||||
# Swagger文档路由
|
||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
||||
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
|
||||
]
|
||||
|
||||
# 静态文件配置(开发环境)
|
||||
if settings.DEBUG:
|
||||
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
16
backend/config/wsgi.py
Normal file
16
backend/config/wsgi.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
WSGI config for config project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
22
backend/manage.py
Normal file
22
backend/manage.py
Normal file
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
57
backend/populate_db.py
Normal file
57
backend/populate_db.py
Normal file
@@ -0,0 +1,57 @@
|
||||
import os
|
||||
import django
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
|
||||
django.setup()
|
||||
|
||||
from shop.models import ESP32Config
|
||||
|
||||
def populate():
|
||||
# 检查数据库是否已有数据,如果有则跳过,避免每次构建都重置!
|
||||
if ESP32Config.objects.exists():
|
||||
print("ESP32Config data already exists, skipping population.")
|
||||
return
|
||||
|
||||
# 清除旧数据,避免重复累积
|
||||
# 注意:在生产环境中慎用 delete
|
||||
# ESP32Config.objects.all().delete()
|
||||
|
||||
configs = [
|
||||
{
|
||||
"name": "AI小智 Mini款",
|
||||
"chip_type": "ESP32-C3",
|
||||
"flash_size": 4,
|
||||
"ram_size": 1,
|
||||
"has_camera": False,
|
||||
"has_microphone": True,
|
||||
"price": 150.00,
|
||||
"description": "高性价比入门款,支持语音交互,小巧便携。"
|
||||
},
|
||||
{
|
||||
"name": "AI小智 V2款 (舵机版)",
|
||||
"chip_type": "ESP32-S3",
|
||||
"flash_size": 8,
|
||||
"ram_size": 2,
|
||||
"has_camera": False,
|
||||
"has_microphone": True,
|
||||
"price": 188.00,
|
||||
"description": "升级版性能,支持驱动舵机,适合机器人控制与运动交互。"
|
||||
},
|
||||
{
|
||||
"name": "AI小智 V3款 (视觉版)",
|
||||
"chip_type": "ESP32-S3",
|
||||
"flash_size": 16,
|
||||
"ram_size": 8,
|
||||
"has_camera": True,
|
||||
"has_microphone": True,
|
||||
"price": 250.00,
|
||||
"description": "旗舰视觉版,配备摄像头与高性能计算单元,支持视觉识别与复杂AI任务。"
|
||||
}
|
||||
]
|
||||
|
||||
for data in configs:
|
||||
config = ESP32Config.objects.create(**data)
|
||||
print(f"Created: {config.name} - ¥{config.price}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
populate()
|
||||
31
backend/requirements.txt
Normal file
31
backend/requirements.txt
Normal file
@@ -0,0 +1,31 @@
|
||||
asgiref==3.11.0
|
||||
attrs==25.4.0
|
||||
Django==6.0.1
|
||||
django-cors-headers==4.9.0
|
||||
django-unfold==0.77.1
|
||||
djangorestframework==3.16.1
|
||||
drf-spectacular==0.29.0
|
||||
inflection==0.5.1
|
||||
jsonschema==4.26.0
|
||||
jsonschema-specifications==2025.9.1
|
||||
pillow==12.1.0
|
||||
psycopg2-binary==2.9.11
|
||||
PyYAML==6.0.3
|
||||
qrcode==8.2
|
||||
referencing==0.37.0
|
||||
rpds-py==0.30.0
|
||||
sqlparse==0.5.5
|
||||
uritemplate==4.2.0
|
||||
wechatpayv3==2.0.1
|
||||
drf-spectacular-sidecar==2026.1.1
|
||||
gunicorn==21.2.0
|
||||
requests
|
||||
django-filter
|
||||
django-admin-sortable2
|
||||
openpyxl
|
||||
|
||||
aliyun-python-sdk-core==2.16.0
|
||||
aliyun-python-sdk-tingwu==1.0.7
|
||||
oss2==2.19.1
|
||||
python-dotenv
|
||||
openai
|
||||
0
backend/shop/__init__.py
Normal file
0
backend/shop/__init__.py
Normal file
548
backend/shop/admin.py
Normal file
548
backend/shop/admin.py
Normal file
@@ -0,0 +1,548 @@
|
||||
from django.contrib import admin
|
||||
from django.utils.html import format_html
|
||||
from django.db.models import Sum
|
||||
from django import forms
|
||||
from django.urls import path, reverse
|
||||
from django.shortcuts import redirect
|
||||
from unfold.admin import ModelAdmin, TabularInline
|
||||
from unfold.decorators import display
|
||||
from .models import ESP32Config, Order, Salesperson, WeChatPayConfig, Service, VCCourse, ProductFeature, CommissionLog, WeChatUser, Distributor, Withdrawal, ServiceOrder, CourseEnrollment, AdminPhoneNumber, IdentityTag, UserIdentity
|
||||
from .admin_actions import export_to_csv, export_to_excel
|
||||
import qrcode
|
||||
from io import BytesIO
|
||||
import base64
|
||||
|
||||
# 自定义后台标题
|
||||
admin.site.site_header = "创赢未来评分系统"
|
||||
admin.site.site_title = "创赢未来"
|
||||
admin.site.index_title = "欢迎使用创赢未来评分系统"
|
||||
|
||||
class OrderableAdminMixin:
|
||||
"""
|
||||
为 Admin 添加排序功能的 Mixin
|
||||
提供上移、下移按钮,直接交换 order 值
|
||||
"""
|
||||
def get_urls(self):
|
||||
urls = super().get_urls()
|
||||
custom_urls = [
|
||||
path('<path:object_id>/move-up/', self.admin_site.admin_view(self.move_up_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_up'),
|
||||
path('<path:object_id>/move-down/', self.admin_site.admin_view(self.move_down_view), name=f'{self.model._meta.app_label}_{self.model._meta.model_name}_move_down'),
|
||||
]
|
||||
return custom_urls + urls
|
||||
|
||||
def move_up_view(self, request, object_id):
|
||||
obj = self.get_object(request, object_id)
|
||||
if obj:
|
||||
# 找到排在它前面的一个 (order 小于它的最大值)
|
||||
prev_obj = self.model.objects.filter(order__lt=obj.order).order_by('-order').first()
|
||||
if prev_obj:
|
||||
# 交换
|
||||
obj.order, prev_obj.order = prev_obj.order, obj.order
|
||||
obj.save()
|
||||
prev_obj.save()
|
||||
self.message_user(request, f"成功将 {obj} 上移")
|
||||
else:
|
||||
# 已经是第一个,或者前面没有更小的 order
|
||||
# 尝试查找 order 等于它的其他对象(理论上不应发生,但为了稳健)
|
||||
pass
|
||||
return redirect(request.META.get('HTTP_REFERER', '..'))
|
||||
|
||||
def move_down_view(self, request, object_id):
|
||||
obj = self.get_object(request, object_id)
|
||||
if obj:
|
||||
# 找到排在它后面的一个 (order 大于它的最小值)
|
||||
next_obj = self.model.objects.filter(order__gt=obj.order).order_by('order').first()
|
||||
if next_obj:
|
||||
# 交换
|
||||
obj.order, next_obj.order = next_obj.order, obj.order
|
||||
obj.save()
|
||||
next_obj.save()
|
||||
self.message_user(request, f"成功将 {obj} 下移")
|
||||
return redirect(request.META.get('HTTP_REFERER', '..'))
|
||||
|
||||
def order_actions(self, obj):
|
||||
# 只有专家用户才显示排序按钮
|
||||
if not getattr(obj, 'is_star', True): # 默认为True是为了兼容其他模型,WeChatUser有is_star字段
|
||||
return "默认排序"
|
||||
|
||||
# 使用 inline style 实现基本样式,hover 效果如果不能用 CSS 文件,就只能妥协或者用 onmouseover
|
||||
btn_style = (
|
||||
"display: inline-flex; align-items: center; justify-content: center; "
|
||||
"width: 26px; height: 26px; border-radius: 6px; "
|
||||
"background-color: #f3f4f6; color: #4b5563; text-decoration: none; "
|
||||
"border: 1px solid #e5e7eb; transition: all 0.2s;"
|
||||
)
|
||||
# onmouseover js
|
||||
hover_js = "this.style.backgroundColor='#dbeafe'; this.style.color='#2563eb'; this.style.borderColor='#bfdbfe';"
|
||||
out_js = "this.style.backgroundColor='#f3f4f6'; this.style.color='#4b5563'; this.style.borderColor='#e5e7eb';"
|
||||
|
||||
return format_html(
|
||||
'<div style="display: flex; align-items: center; gap: 6px;">'
|
||||
'<a href="{}" title="上移" style="{}" onmouseover="{}" onmouseout="{}">'
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M18 15l-6-6-6 6"/></svg>'
|
||||
'</a>'
|
||||
'<span style="font-weight: 700; font-family: system-ui, -apple-system, sans-serif; min-width: 20px; text-align: center; color: #374151; font-size: 13px;">{}</span>'
|
||||
'<a href="{}" title="下移" style="{}" onmouseover="{}" onmouseout="{}">'
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 9l6 6 6-6"/></svg>'
|
||||
'</a>'
|
||||
'</div>',
|
||||
reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_up', args=[obj.pk]),
|
||||
btn_style, hover_js, out_js,
|
||||
obj.order,
|
||||
reverse(f'admin:{self.model._meta.app_label}_{self.model._meta.model_name}_move_down', args=[obj.pk]),
|
||||
btn_style, hover_js, out_js,
|
||||
)
|
||||
order_actions.short_description = "排序调节"
|
||||
order_actions.allow_tags = True
|
||||
|
||||
|
||||
class ExternalUploadWidget(forms.URLInput):
|
||||
def __init__(self, upload_url, accept='*', *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.upload_url = upload_url
|
||||
self.attrs.update({
|
||||
'class': 'upload-url-input vTextField',
|
||||
'data-upload-url': upload_url,
|
||||
'data-accept': accept,
|
||||
'placeholder': '上传文件后自动生成URL',
|
||||
'style': 'width: 100%;'
|
||||
})
|
||||
|
||||
class Media:
|
||||
js = ('shop/js/admin_upload.js',)
|
||||
css = {
|
||||
'all': ('shop/css/admin_upload.css',)
|
||||
}
|
||||
|
||||
class ESP32ConfigAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = ESP32Config
|
||||
fields = '__all__'
|
||||
widgets = {
|
||||
'static_image_url': ExternalUploadWidget(
|
||||
upload_url='https://data.tangledup-ai.com/upload?folder=market_page%2Fhardware_xiaozhi%2Fproduct_static_image',
|
||||
accept='image/*'
|
||||
),
|
||||
'model_3d_url': ExternalUploadWidget(
|
||||
upload_url='https://data.tangledup-ai.com/upload?folder=market_page%2Fhardware_xiaozhi%2Fproduct_3D_image',
|
||||
accept='.zip'
|
||||
),
|
||||
}
|
||||
|
||||
class ProductFeatureInline(TabularInline):
|
||||
model = ProductFeature
|
||||
extra = 1
|
||||
fields = ('title', 'description', 'icon_name', 'icon_image', 'icon_url', 'order')
|
||||
|
||||
@admin.register(WeChatPayConfig)
|
||||
class WeChatPayConfigAdmin(ModelAdmin):
|
||||
list_display = ('app_id', 'mch_id', 'is_active', 'notify_url', 'updated_at_display')
|
||||
list_filter = ('is_active',)
|
||||
search_fields = ('app_id', 'mch_id')
|
||||
|
||||
def updated_at_display(self, obj):
|
||||
# 假设模型没有 updated_at,如果有可以显示,这里仅作占位或移除
|
||||
return "N/A"
|
||||
updated_at_display.short_description = "更新时间"
|
||||
|
||||
fieldsets = (
|
||||
('核心配置 (登录与支付)', {
|
||||
'fields': ('app_id', 'app_secret', 'mch_id', 'is_active'),
|
||||
'description': 'AppID 和 AppSecret 是小程序登录和支付的基础凭证。请确保 AppID 与小程序后台一致 (项目中优先使用 wxdf2ca73e6c0929f0)。'
|
||||
}),
|
||||
('微信支付 V3 安全配置 (推荐)', {
|
||||
'fields': ('apiv3_key', 'mch_cert_serial_no', 'mch_private_key'),
|
||||
'description': '使用 Native 支付必须配置这些项。私钥可以粘贴在这里,或者放在 backend/certs/apiclient_key.pem 文件中。'
|
||||
}),
|
||||
('微信支付 V2 安全配置 (旧版)', {
|
||||
'fields': ('api_key',),
|
||||
'classes': ('collapse',),
|
||||
'description': '仅旧版支付接口需要 API Key (V2)。'
|
||||
}),
|
||||
('回调配置', {
|
||||
'fields': ('notify_url',)
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(ESP32Config)
|
||||
class ESP32ConfigAdmin(OrderableAdminMixin, ModelAdmin):
|
||||
form = ESP32ConfigAdminForm
|
||||
list_display = ('name', 'chip_type', 'price', 'stock', 'has_camera', 'has_microphone', 'order_actions')
|
||||
list_filter = ('chip_type', 'has_camera')
|
||||
search_fields = ('name', 'description')
|
||||
inlines = [ProductFeatureInline]
|
||||
fieldsets = (
|
||||
('基本信息', {
|
||||
'fields': ('name', 'price', 'stock', 'commission_rate', 'description')
|
||||
}),
|
||||
('硬件参数', {
|
||||
'fields': ('chip_type', 'flash_size', 'ram_size', 'has_camera', 'has_microphone')
|
||||
}),
|
||||
('详情页图片', {
|
||||
'fields': ('detail_image', 'detail_image_url'),
|
||||
'description': '图片上传和URL二选一,优先使用URL'
|
||||
}),
|
||||
('多媒体资源', {
|
||||
'fields': ('static_image_url', 'model_3d_url'),
|
||||
'description': '产品静态图和3D模型的外部链接'
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(Service)
|
||||
class ServiceAdmin(OrderableAdminMixin, ModelAdmin):
|
||||
list_display = ('title', 'created_at', 'order_actions')
|
||||
search_fields = ('title', 'description')
|
||||
fieldsets = (
|
||||
('基本信息', {
|
||||
'fields': ('title', 'description', 'color')
|
||||
}),
|
||||
('价格与交付', {
|
||||
'fields': ('price', 'unit', 'delivery_time', 'delivery_content')
|
||||
}),
|
||||
('图标', {
|
||||
'fields': ('icon', 'icon_url'),
|
||||
'description': '图标上传和URL二选一,优先使用URL'
|
||||
}),
|
||||
('详情页图片', {
|
||||
'fields': ('detail_image', 'detail_image_url'),
|
||||
'description': '图片上传和URL二选一,优先使用URL'
|
||||
}),
|
||||
('详细内容', {
|
||||
'fields': ('features',)
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(ServiceOrder)
|
||||
class ServiceOrderAdmin(ModelAdmin):
|
||||
list_display = ('id', 'customer_name', 'service', 'total_price', 'status', 'salesperson', 'created_at')
|
||||
list_filter = ('status', 'service', 'salesperson', 'created_at')
|
||||
search_fields = ('id', 'customer_name', 'phone_number', 'email')
|
||||
readonly_fields = ('total_price', 'created_at', 'updated_at')
|
||||
|
||||
fieldsets = (
|
||||
('订单信息', {
|
||||
'fields': ('service', 'status', 'total_price', 'created_at')
|
||||
}),
|
||||
('客户信息', {
|
||||
'fields': ('customer_name', 'company_name', 'phone_number', 'email', 'requirements')
|
||||
}),
|
||||
('销售归属', {
|
||||
'fields': ('salesperson',)
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(VCCourse)
|
||||
class VCCourseAdmin(OrderableAdminMixin, ModelAdmin):
|
||||
list_display = ('title', 'course_type', 'is_video_course', 'price', 'tag', 'instructor', 'lesson_count', 'duration', 'created_at', 'order_actions')
|
||||
search_fields = ('title', 'description', 'instructor', 'tag')
|
||||
list_filter = ('course_type', 'is_video_course', 'instructor', 'tag')
|
||||
actions = ['reset_ordering']
|
||||
|
||||
@admin.action(description="重置排序 (按ID顺序)")
|
||||
def reset_ordering(self, request, queryset):
|
||||
"""
|
||||
将选中的课程(或全部)按ID顺序重新分配order值
|
||||
"""
|
||||
# 如果没有选中任何项,默认处理所有(Django Admin默认行为是选中了才会触发Action,但为了稳健)
|
||||
# 这里既然是Action,用户必须选中。建议用户选中所有。
|
||||
# 为了方便,如果用户只选了一个,我们可以提示他选更多,或者我们其实可以忽略queryset,直接重置所有?
|
||||
# 通常Action是针对queryset的。
|
||||
# 更好的做法:对选中的queryset按ID排序,然后更新order。
|
||||
|
||||
# 这种实现方式:只重置选中的部分,可能会导致order冲突。
|
||||
# 稳妥方式:重置整个表的排序。
|
||||
|
||||
all_objects = VCCourse.objects.all().order_by('id')
|
||||
for index, obj in enumerate(all_objects, start=1):
|
||||
obj.order = index
|
||||
obj.save(update_fields=['order'])
|
||||
|
||||
self.message_user(request, f"成功重置了 {all_objects.count()} 个课程的排序权重。")
|
||||
|
||||
fieldsets = (
|
||||
('基本信息', {
|
||||
'fields': ('title', 'description', 'course_type', 'tag', 'price')
|
||||
}),
|
||||
('视频设置', {
|
||||
'fields': ('is_video_course', 'video_url', 'video_embed_code'),
|
||||
'description': '设置是否为视频课程及视频链接'
|
||||
}),
|
||||
('课程安排', {
|
||||
'fields': ('is_fixed_schedule', 'start_time', 'end_time'),
|
||||
'description': '勾选“是否固定时间课程”后,请设置开始和结束时间'
|
||||
}),
|
||||
('讲师信息', {
|
||||
'fields': ('instructor', 'instructor_title', 'instructor_desc', 'instructor_avatar', 'instructor_avatar_url'),
|
||||
'description': '讲师头像上传和URL二选一,优先使用URL'
|
||||
}),
|
||||
('课程详情', {
|
||||
'fields': ('duration', 'lesson_count', 'content')
|
||||
}),
|
||||
('封面', {
|
||||
'fields': ('cover_image', 'cover_image_url'),
|
||||
'description': '图片上传和URL二选一,优先使用URL'
|
||||
}),
|
||||
('详情页长图', {
|
||||
'fields': ('detail_image', 'detail_image_url'),
|
||||
'description': '图片上传和URL二选一,优先使用URL'
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(CourseEnrollment)
|
||||
class CourseEnrollmentAdmin(ModelAdmin):
|
||||
list_display = ('customer_name', 'course', 'phone_number', 'status', 'created_at')
|
||||
list_filter = ('status', 'course', 'created_at')
|
||||
search_fields = ('customer_name', 'phone_number', 'wechat_id')
|
||||
|
||||
fieldsets = (
|
||||
('报名信息', {
|
||||
'fields': ('course', 'status', 'created_at')
|
||||
}),
|
||||
('客户资料', {
|
||||
'fields': ('customer_name', 'phone_number', 'wechat_id', 'email', 'message')
|
||||
}),
|
||||
('销售归属', {
|
||||
'fields': ('salesperson', 'distributor')
|
||||
}),
|
||||
)
|
||||
|
||||
# 分销员管理已隐藏 - 取消注册
|
||||
# @admin.register(Salesperson)
|
||||
class SalespersonAdmin(ModelAdmin):
|
||||
pass
|
||||
|
||||
# 分销佣金记录已隐藏
|
||||
# @admin.register(CommissionLog)
|
||||
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', 'distributor__user__phone_number', 'order__id')
|
||||
readonly_fields = ('amount', 'level', 'created_at')
|
||||
|
||||
fieldsets = (
|
||||
('基本信息', {
|
||||
'fields': ('salesperson', 'distributor', 'order', 'amount', 'level')
|
||||
}),
|
||||
('状态管理', {
|
||||
'fields': ('status', 'created_at')
|
||||
}),
|
||||
)
|
||||
|
||||
class GenderFilter(admin.SimpleListFilter):
|
||||
title = '性别'
|
||||
parameter_name = 'gender'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
(1, '男'),
|
||||
(2, '女'),
|
||||
(0, '未知'),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value():
|
||||
return queryset.filter(gender=self.value())
|
||||
return queryset
|
||||
|
||||
class UserSourceFilter(admin.SimpleListFilter):
|
||||
title = '用户来源'
|
||||
parameter_name = 'user_source'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('miniprogram', '仅小程序用户'),
|
||||
('both', '网页小程序都已注册'),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
if self.value() == 'miniprogram':
|
||||
return queryset.filter(user__isnull=True)
|
||||
if self.value() == 'both':
|
||||
return queryset.filter(user__isnull=False)
|
||||
return queryset
|
||||
|
||||
class PriceRangeFilter(admin.SimpleListFilter):
|
||||
title = '价格区间'
|
||||
parameter_name = 'price_range'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('0-50', '¥0 - ¥50'),
|
||||
('50-100', '¥50 - ¥100'),
|
||||
('100-500', '¥100 - ¥500'),
|
||||
('500-1000', '¥500 - ¥1000'),
|
||||
('1000+', '¥1000以上'),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
value = self.value()
|
||||
if value == '0-50':
|
||||
return queryset.filter(total_price__gte=0, total_price__lte=50)
|
||||
elif value == '50-100':
|
||||
return queryset.filter(total_price__gt=50, total_price__lte=100)
|
||||
elif value == '100-500':
|
||||
return queryset.filter(total_price__gt=100, total_price__lte=500)
|
||||
elif value == '500-1000':
|
||||
return queryset.filter(total_price__gt=500, total_price__lte=1000)
|
||||
elif value == '1000+':
|
||||
return queryset.filter(total_price__gt=1000)
|
||||
return queryset
|
||||
|
||||
class ProductTypeFilter(admin.SimpleListFilter):
|
||||
title = '商品类型'
|
||||
parameter_name = 'product_type'
|
||||
|
||||
def lookups(self, request, model_admin):
|
||||
return (
|
||||
('hardware', '硬件产品'),
|
||||
('course', '课程'),
|
||||
('activity', '活动'),
|
||||
)
|
||||
|
||||
def queryset(self, request, queryset):
|
||||
value = self.value()
|
||||
if value == 'hardware':
|
||||
return queryset.filter(config__isnull=False)
|
||||
elif value == 'course':
|
||||
return queryset.filter(course__isnull=False)
|
||||
elif value == 'activity':
|
||||
return queryset.filter(activity__isnull=False)
|
||||
return queryset
|
||||
|
||||
@admin.register(Order)
|
||||
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', 'wechat_user__phone_number')
|
||||
readonly_fields = ('total_price', 'created_at', 'wechat_trade_no')
|
||||
actions = [export_to_csv, export_to_excel]
|
||||
|
||||
def get_item_name(self, obj):
|
||||
if obj.config:
|
||||
return f"[硬件] {obj.config.name}"
|
||||
if obj.course:
|
||||
return f"[课程] {obj.course.title}"
|
||||
if obj.activity:
|
||||
return f"[活动] {obj.activity.title}"
|
||||
return "未知商品"
|
||||
get_item_name.short_description = "购买商品"
|
||||
|
||||
fieldsets = (
|
||||
('订单信息', {
|
||||
'fields': ('config', 'course', 'activity', 'quantity', 'total_price', 'status', 'created_at')
|
||||
}),
|
||||
('客户信息', {
|
||||
'fields': ('customer_name', 'phone_number', 'shipping_address', 'wechat_user')
|
||||
}),
|
||||
('物流信息', {
|
||||
'fields': ('courier_name', 'tracking_number')
|
||||
}),
|
||||
('销售归属', {
|
||||
'fields': ('salesperson', 'distributor')
|
||||
}),
|
||||
('支付信息', {
|
||||
'fields': ('wechat_trade_no',)
|
||||
}),
|
||||
)
|
||||
|
||||
@admin.register(WeChatUser)
|
||||
class WeChatUserAdmin(OrderableAdminMixin, ModelAdmin):
|
||||
list_display = ('nickname', 'phone_number', 'is_star', 'title', 'avatar_display', 'gender_display', 'province', 'city', 'created_at', 'order_actions')
|
||||
search_fields = ('nickname', 'openid', 'phone_number')
|
||||
list_filter = ('is_star', GenderFilter, UserSourceFilter, 'province', 'city', 'created_at')
|
||||
readonly_fields = ('openid', 'unionid', 'session_key', 'created_at', 'updated_at')
|
||||
actions = [export_to_csv, export_to_excel]
|
||||
|
||||
def avatar_display(self, obj):
|
||||
if obj.avatar_url:
|
||||
return format_html('<img src="{}" width="50" height="50" style="border-radius: 50%;" />', obj.avatar_url)
|
||||
return "暂无"
|
||||
avatar_display.short_description = "头像"
|
||||
|
||||
def gender_display(self, obj):
|
||||
choices = {0: '未知', 1: '男', 2: '女'}
|
||||
return choices.get(obj.gender, '未知')
|
||||
gender_display.short_description = "性别"
|
||||
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
fieldsets = [
|
||||
('基本信息', {
|
||||
'fields': ('user', 'nickname', 'phone_number', 'avatar_url', 'gender')
|
||||
}),
|
||||
]
|
||||
|
||||
if obj and obj.is_star:
|
||||
fieldsets.append(('专家认证', {
|
||||
'fields': ('is_star', 'title', 'skills', 'order'),
|
||||
'description': '标记为明星技术用户/专家,将在社区中展示'
|
||||
}))
|
||||
else:
|
||||
fieldsets.append(('专家认证', {
|
||||
'fields': ('is_star',),
|
||||
'description': '标记为明星技术用户/专家,将在社区中展示。保存后若为专家用户,可进一步编辑专家信息。'
|
||||
}))
|
||||
|
||||
fieldsets.append(('位置信息', {
|
||||
'fields': ('country', 'province', 'city')
|
||||
}))
|
||||
|
||||
fieldsets.append(('认证信息', {
|
||||
'fields': ('openid', 'unionid', 'session_key'),
|
||||
'classes': ('collapse',)
|
||||
}))
|
||||
|
||||
fieldsets.append(('时间信息', {
|
||||
'fields': ('created_at', 'updated_at')
|
||||
}))
|
||||
|
||||
return fieldsets
|
||||
|
||||
# 小程序分销员已隐藏 - 取消注册
|
||||
# @admin.register(Distributor)
|
||||
class DistributorAdmin(ModelAdmin):
|
||||
pass
|
||||
|
||||
# 提现管理已隐藏
|
||||
# @admin.register(Withdrawal)
|
||||
class WithdrawalAdmin(ModelAdmin):
|
||||
pass
|
||||
|
||||
@admin.register(AdminPhoneNumber)
|
||||
class AdminPhoneNumberAdmin(ModelAdmin):
|
||||
list_display = ('name', 'phone_number', 'is_active', 'created_at')
|
||||
list_filter = ('is_active',)
|
||||
search_fields = ('name', 'phone_number')
|
||||
|
||||
|
||||
class UserIdentityInline(TabularInline):
|
||||
model = UserIdentity
|
||||
extra = 1
|
||||
autocomplete_fields = ['tag']
|
||||
|
||||
|
||||
@admin.register(IdentityTag)
|
||||
class IdentityTagAdmin(ModelAdmin):
|
||||
list_display = ('name', 'color_preview', 'icon', 'sort_order', 'is_active', 'created_at')
|
||||
list_editable = ['sort_order', 'is_active']
|
||||
list_filter = ('is_active', 'created_at')
|
||||
search_fields = ('name', 'description')
|
||||
|
||||
@display(description='颜色预览')
|
||||
def color_preview(self, obj):
|
||||
return format_html(
|
||||
'<span style="display: inline-block; width: 20px; height: 20px; background-color: {}; border-radius: 4px; border: 1px solid #ddd;"></span> {}',
|
||||
obj.color, obj.color
|
||||
)
|
||||
|
||||
|
||||
@admin.register(UserIdentity)
|
||||
class UserIdentityAdmin(ModelAdmin):
|
||||
list_display = ('user_info', 'tag', 'assigned_at', 'assigned_by')
|
||||
list_filter = ('tag', 'assigned_at')
|
||||
search_fields = ('user__nickname', 'user__phone_number', 'user__openid', 'tag__name')
|
||||
autocomplete_fields = ['user', 'tag']
|
||||
date_hierarchy = 'assigned_at'
|
||||
|
||||
@display(description='用户信息')
|
||||
def user_info(self, obj):
|
||||
return f"{obj.user.nickname or ''} {obj.user.phone_number or ''}".strip() or obj.user.openid[:20]
|
||||
110
backend/shop/admin_actions.py
Normal file
110
backend/shop/admin_actions.py
Normal file
@@ -0,0 +1,110 @@
|
||||
import csv
|
||||
import datetime
|
||||
from django.http import HttpResponse
|
||||
from django.utils.encoding import escape_uri_path
|
||||
|
||||
def export_to_csv(modeladmin, request, queryset):
|
||||
"""
|
||||
通用导出 CSV 的 Admin Action
|
||||
支持中文编码(UTF-8 BOM),可直接用 Excel 打开
|
||||
"""
|
||||
opts = modeladmin.model._meta
|
||||
# 设置文件名,使用模型的 verbose_name
|
||||
filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
|
||||
response = HttpResponse(content_type='text/csv; charset=utf-8-sig')
|
||||
response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}'
|
||||
|
||||
writer = csv.writer(response)
|
||||
|
||||
# 获取所有非多对多字段和非反向关联字段
|
||||
fields = [field for field in opts.get_fields() if not field.many_to_many and not field.one_to_many]
|
||||
|
||||
# 写入表头 (使用字段的 verbose_name)
|
||||
writer.writerow([field.verbose_name for field in fields])
|
||||
|
||||
# 写入数据
|
||||
for obj in queryset:
|
||||
data_row = []
|
||||
for field in fields:
|
||||
value = getattr(obj, field.name)
|
||||
|
||||
# 处理 Choice 字段,显示可读的标签
|
||||
if hasattr(obj, f'get_{field.name}_display'):
|
||||
value = getattr(obj, f'get_{field.name}_display')()
|
||||
|
||||
# 处理关联对象(ForeignKey)
|
||||
if field.is_relation and value:
|
||||
value = str(value)
|
||||
|
||||
# 处理日期时间
|
||||
if isinstance(value, datetime.datetime):
|
||||
value = value.strftime('%Y-%m-%d %H:%M:%S')
|
||||
elif isinstance(value, datetime.date):
|
||||
value = value.strftime('%Y-%m-%d')
|
||||
|
||||
# 处理 None
|
||||
if value is None:
|
||||
value = ""
|
||||
|
||||
data_row.append(str(value))
|
||||
writer.writerow(data_row)
|
||||
|
||||
return response
|
||||
|
||||
export_to_csv.short_description = "导出选中项为 CSV"
|
||||
|
||||
def export_to_excel(modeladmin, request, queryset):
|
||||
"""
|
||||
导出为 Excel (需要安装 openpyxl)
|
||||
"""
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
except ImportError:
|
||||
modeladmin.message_user(request, "请先安装 openpyxl 库以使用 Excel 导出功能: pip install openpyxl", level='error')
|
||||
return
|
||||
|
||||
opts = modeladmin.model._meta
|
||||
filename = f"{opts.verbose_name}_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
|
||||
response = HttpResponse(
|
||||
content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
)
|
||||
response['Content-Disposition'] = f'attachment; filename={escape_uri_path(filename)}'
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
# Sheet name limit is 31 chars
|
||||
ws.title = str(opts.verbose_name)[:31]
|
||||
|
||||
fields = [field for field in opts.get_fields() if not field.many_to_many and not field.one_to_many]
|
||||
|
||||
# 写入表头
|
||||
ws.append([str(field.verbose_name) for field in fields])
|
||||
|
||||
# 写入数据
|
||||
for obj in queryset:
|
||||
row = []
|
||||
for field in fields:
|
||||
value = getattr(obj, field.name)
|
||||
|
||||
if hasattr(obj, f'get_{field.name}_display'):
|
||||
value = getattr(obj, f'get_{field.name}_display')()
|
||||
|
||||
# 处理关联对象(ForeignKey)
|
||||
if field.is_relation and value:
|
||||
value = str(value)
|
||||
|
||||
if isinstance(value, (datetime.datetime, datetime.date)):
|
||||
# openpyxl 可以直接处理 datetime 格式,Excel 会自动识别
|
||||
# 但为了避免时区问题,通常转为无时区时间或字符串
|
||||
if isinstance(value, datetime.datetime):
|
||||
value = value.replace(tzinfo=None)
|
||||
|
||||
row.append(value)
|
||||
ws.append(row)
|
||||
|
||||
wb.save(response)
|
||||
return response
|
||||
|
||||
export_to_excel.short_description = "导出选中项为 Excel"
|
||||
9
backend/shop/apps.py
Normal file
9
backend/shop/apps.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ShopConfig(AppConfig):
|
||||
name = 'shop'
|
||||
verbose_name = "课程培训"
|
||||
|
||||
def ready(self):
|
||||
import shop.signals
|
||||
50
backend/shop/migrations/0001_initial.py
Normal file
50
backend/shop/migrations/0001_initial.py
Normal file
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 04:06
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ESP32Config',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='配置名称')),
|
||||
('chip_type', models.CharField(help_text='例如: ESP32-S3, ESP32-C3', max_length=50, verbose_name='芯片型号')),
|
||||
('flash_size', models.IntegerField(default=4, verbose_name='Flash大小(MB)')),
|
||||
('ram_size', models.IntegerField(default=2, verbose_name='PSRAM大小(MB)')),
|
||||
('has_camera', models.BooleanField(default=False, verbose_name='是否包含摄像头')),
|
||||
('has_microphone', models.BooleanField(default=False, verbose_name='是否包含麦克风')),
|
||||
('price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='价格')),
|
||||
('description', models.TextField(blank=True, verbose_name='描述')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '硬件配置',
|
||||
'verbose_name_plural': '硬件配置列表',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Order',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('quantity', models.IntegerField(default=1, verbose_name='数量')),
|
||||
('total_price', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='总价')),
|
||||
('status', models.CharField(choices=[('pending', '待支付'), ('paid', '已支付'), ('shipped', '已发货'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='订单状态')),
|
||||
('wechat_trade_no', models.CharField(blank=True, max_length=100, null=True, verbose_name='微信支付单号')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('config', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.esp32config', verbose_name='所选配置')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '订单',
|
||||
'verbose_name_plural': '订单列表',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 04:10
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='customer_name',
|
||||
field=models.CharField(default='', max_length=100, verbose_name='收货人姓名'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='phone_number',
|
||||
field=models.CharField(default='', max_length=20, verbose_name='联系电话'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='shipping_address',
|
||||
field=models.TextField(default='', verbose_name='发货地址'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 04:14
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0002_order_customer_name_order_phone_number_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Salesperson',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=50, verbose_name='销售员姓名')),
|
||||
('code', models.CharField(help_text='唯一的推广标识码,如: zhangsan01', max_length=20, unique=True, verbose_name='推广码')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '销售员',
|
||||
'verbose_name_plural': '销售员管理',
|
||||
},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='esp32config',
|
||||
options={'verbose_name': '硬件配置 (小智参数)', 'verbose_name_plural': '硬件配置 (小智参数)'},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='salesperson',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.salesperson', verbose_name='所属销售员'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,44 @@
|
||||
# Generated by Django 5.2.10 on 2026-02-02 04:56
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0003_salesperson_alter_esp32config_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WeChatPayConfig',
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('app_id', models.CharField(max_length=50, verbose_name='AppID')),
|
||||
('mch_id', models.CharField(max_length=50, verbose_name='商户号(MchID)')),
|
||||
('api_key', models.CharField(max_length=100, verbose_name='API密钥(Key)')),
|
||||
('app_secret', models.CharField(blank=True, max_length=100, null=True, verbose_name='AppSecret')),
|
||||
('notify_url', models.URLField(verbose_name='回调通知地址')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='是否启用')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '微信支付配置',
|
||||
'verbose_name_plural': '微信支付配置',
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='esp32config',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesperson',
|
||||
name='id',
|
||||
field=models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 05:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0004_wechatpayconfig_alter_esp32config_id_alter_order_id_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Service',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=100, verbose_name='服务名称')),
|
||||
('icon', models.ImageField(upload_to='services/icons/', verbose_name='图标')),
|
||||
('description', models.TextField(verbose_name='简介')),
|
||||
('features', models.TextField(help_text='每行一个特性', verbose_name='特性列表')),
|
||||
('color', models.CharField(default='#00f0ff', max_length=20, verbose_name='主题色')),
|
||||
('detail_image', models.ImageField(blank=True, null=True, upload_to='services/details/', verbose_name='详情页长图')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'AI服务',
|
||||
'verbose_name_plural': 'AI服务管理',
|
||||
},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='esp32config',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='order',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='salesperson',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wechatpayconfig',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,58 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 05:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0005_service_alter_esp32config_id_alter_order_id_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ARService',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=100, verbose_name='体验名称')),
|
||||
('description', models.TextField(verbose_name='简介')),
|
||||
('cover_image', models.ImageField(blank=True, null=True, upload_to='ar/covers/', verbose_name='封面/长图 (上传)')),
|
||||
('cover_image_url', models.URLField(blank=True, null=True, verbose_name='封面/长图 (URL)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'AR体验',
|
||||
'verbose_name_plural': 'AR体验管理',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='esp32config',
|
||||
name='detail_image',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='products/details/', verbose_name='详情页长图 (上传)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='esp32config',
|
||||
name='detail_image_url',
|
||||
field=models.URLField(blank=True, help_text='如果填写了URL,将优先使用URL', null=True, verbose_name='详情页长图 (URL)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='detail_image_url',
|
||||
field=models.URLField(blank=True, null=True, verbose_name='详情页长图 (URL)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='icon_url',
|
||||
field=models.URLField(blank=True, null=True, verbose_name='图标 (URL)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='service',
|
||||
name='detail_image',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='services/details/', verbose_name='详情页长图 (上传)'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='service',
|
||||
name='icon',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='services/icons/', verbose_name='图标 (上传)'),
|
||||
),
|
||||
]
|
||||
32
backend/shop/migrations/0007_productfeature.py
Normal file
32
backend/shop/migrations/0007_productfeature.py
Normal file
@@ -0,0 +1,32 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 06:04
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0006_arservice_esp32config_detail_image_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ProductFeature',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=50, verbose_name='特性标题')),
|
||||
('description', models.TextField(verbose_name='特性描述')),
|
||||
('icon_name', models.CharField(blank=True, help_text='例如: SafetyCertificate, Eye, Thunderbolt', max_length=50, null=True, verbose_name='Antd图标名称')),
|
||||
('icon_image', models.ImageField(blank=True, null=True, upload_to='products/features/', verbose_name='特性图标 (上传)')),
|
||||
('icon_url', models.URLField(blank=True, null=True, verbose_name='特性图标 (URL)')),
|
||||
('order', models.IntegerField(default=0, help_text='数字越小越靠前', verbose_name='排序权重')),
|
||||
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='features', to='shop.esp32config', verbose_name='所属产品')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '产品特性',
|
||||
'verbose_name_plural': '产品特性',
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 06:16
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0007_productfeature'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='delivery_content',
|
||||
field=models.TextField(blank=True, help_text='描述将交付给客户的具体成果', verbose_name='交付内容'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='delivery_time',
|
||||
field=models.CharField(blank=True, help_text='例如:3-5个工作日', max_length=50, verbose_name='预计交付周期'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='price',
|
||||
field=models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='起步价格'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='service',
|
||||
name='unit',
|
||||
field=models.CharField(default='次', help_text='例如:次、小时、月、个', max_length=20, verbose_name='计费单位'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ServiceOrder',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('customer_name', models.CharField(max_length=100, verbose_name='客户姓名')),
|
||||
('company_name', models.CharField(blank=True, max_length=100, verbose_name='公司名称')),
|
||||
('phone_number', models.CharField(max_length=20, verbose_name='联系电话')),
|
||||
('email', models.EmailField(blank=True, max_length=254, verbose_name='电子邮箱')),
|
||||
('requirements', models.TextField(blank=True, verbose_name='具体需求描述')),
|
||||
('total_price', models.DecimalField(decimal_places=2, default=0, max_digits=10, verbose_name='预估总价')),
|
||||
('status', models.CharField(choices=[('pending', '待沟通/待支付'), ('processing', '服务进行中'), ('completed', '已完成'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='订单状态')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('salesperson', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='shop.salesperson', verbose_name='所属销售员')),
|
||||
('service', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='shop.service', verbose_name='所选服务')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '服务订单',
|
||||
'verbose_name_plural': '服务订单列表',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 11:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0008_service_delivery_content_service_delivery_time_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='esp32config',
|
||||
name='model_3d_url',
|
||||
field=models.URLField(blank=True, null=True, verbose_name='产品3D模型 (URL)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='esp32config',
|
||||
name='static_image_url',
|
||||
field=models.URLField(blank=True, null=True, verbose_name='产品静态图 (URL)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 12:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0009_esp32config_model_3d_url_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='esp32config',
|
||||
name='model_3d_url',
|
||||
field=models.URLField(blank=True, help_text='请上传包含 .obj 模型文件和 .mtl 材质文件的 .zip 压缩包', null=True, verbose_name='产品3D模型 (URL)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-02 14:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0010_alter_esp32config_model_3d_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='esp32config',
|
||||
name='model_3d_url',
|
||||
field=models.URLField(blank=True, null=True, verbose_name='产品3D模型 (URL)'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-06 13:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0011_alter_esp32config_model_3d_url'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='wechatpayconfig',
|
||||
name='apiv3_key',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='API V3密钥'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='wechatpayconfig',
|
||||
name='mch_cert_serial_no',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='商户证书序列号'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='wechatpayconfig',
|
||||
name='mch_private_key',
|
||||
field=models.TextField(blank=True, help_text='apiclient_key.pem 的内容', null=True, verbose_name='商户私钥内容'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='wechatpayconfig',
|
||||
name='api_key',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='API密钥(V2 Key)'),
|
||||
),
|
||||
]
|
||||
18
backend/shop/migrations/0013_order_out_trade_no.py
Normal file
18
backend/shop/migrations/0013_order_out_trade_no.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-07 09:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0012_wechatpayconfig_apiv3_key_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='out_trade_no',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='商户订单号'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 15:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0013_order_out_trade_no'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='esp32config',
|
||||
name='stock',
|
||||
field=models.IntegerField(default=0, verbose_name='库存数量'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='courier_name',
|
||||
field=models.CharField(blank=True, max_length=50, null=True, verbose_name='快递公司'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='tracking_number',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='快递单号'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,50 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 15:48
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0014_esp32config_stock_order_courier_name_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='esp32config',
|
||||
name='commission_rate',
|
||||
field=models.DecimalField(decimal_places=4, default=0.0, help_text='例如 0.10 表示 10%,优先级高于销售员默认比例', max_digits=5, verbose_name='产品分润比例'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesperson',
|
||||
name='commission_rate',
|
||||
field=models.DecimalField(decimal_places=4, default=0.1, help_text='例如 0.10 表示 10%', max_digits=5, verbose_name='默认分润比例'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesperson',
|
||||
name='parent',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='shop.salesperson', verbose_name='上级分销员'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='salesperson',
|
||||
name='second_level_rate',
|
||||
field=models.DecimalField(decimal_places=4, default=0.02, help_text='作为上级时可获得的分润比例,例如 0.02 表示 2%', max_digits=5, verbose_name='二级分销比例'),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='CommissionLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='佣金金额')),
|
||||
('level', models.IntegerField(default=1, help_text='1: 直接销售, 2: 二级分销', verbose_name='分销层级')),
|
||||
('status', models.CharField(choices=[('pending', '待结算'), ('settled', '已结算'), ('cancelled', '已取消')], default='pending', max_length=20, verbose_name='状态')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('order', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='commissions', to='shop.order', verbose_name='关联订单')),
|
||||
('salesperson', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='commissions', to='shop.salesperson', verbose_name='获佣销售员')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '佣金记录',
|
||||
'verbose_name_plural': '佣金结算',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,64 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 16:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0015_esp32config_commission_rate_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='WeChatUser',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('openid', models.CharField(max_length=64, unique=True, verbose_name='OpenID')),
|
||||
('unionid', models.CharField(blank=True, db_index=True, max_length=64, null=True, verbose_name='UnionID')),
|
||||
('session_key', models.CharField(blank=True, max_length=64, verbose_name='SessionKey')),
|
||||
('nickname', models.CharField(blank=True, max_length=64, verbose_name='昵称')),
|
||||
('avatar_url', models.URLField(blank=True, verbose_name='头像URL')),
|
||||
('gender', models.IntegerField(default=0, help_text='0:未知, 1:男, 2:女', verbose_name='性别')),
|
||||
('country', models.CharField(blank=True, max_length=64, verbose_name='国家')),
|
||||
('province', models.CharField(blank=True, max_length=64, verbose_name='省份')),
|
||||
('city', models.CharField(blank=True, max_length=64, verbose_name='城市')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('user', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='wechat_profile', to=settings.AUTH_USER_MODEL, verbose_name='关联系统用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '微信用户',
|
||||
'verbose_name_plural': '微信用户管理',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Distributor',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('level', models.IntegerField(default=1, verbose_name='分销等级')),
|
||||
('commission_rate', models.DecimalField(decimal_places=4, default=0.1, help_text='例如 0.10 表示 10%', max_digits=5, verbose_name='分佣比例')),
|
||||
('total_earnings', models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='累计收益')),
|
||||
('withdrawable_balance', models.DecimalField(decimal_places=2, default=0.0, max_digits=12, verbose_name='可提现余额')),
|
||||
('status', models.CharField(choices=[('pending', '审核中'), ('active', '正常'), ('disabled', '已禁用')], default='pending', max_length=20, verbose_name='状态')),
|
||||
('invite_code', models.CharField(blank=True, max_length=20, unique=True, verbose_name='邀请码')),
|
||||
('qr_code_url', models.URLField(blank=True, verbose_name='推广二维码URL')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='申请时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('parent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='shop.distributor', verbose_name='上级分销员')),
|
||||
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='distributor', to='shop.wechatuser', verbose_name='关联微信用户')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '分销员',
|
||||
'verbose_name_plural': '分销员管理',
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='order',
|
||||
name='wechat_user',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='orders', to='shop.wechatuser', verbose_name='下单微信用户'),
|
||||
),
|
||||
]
|
||||
30
backend/shop/migrations/0017_withdrawal.py
Normal file
30
backend/shop/migrations/0017_withdrawal.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 16:35
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0016_wechatuser_distributor_order_wechat_user'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Withdrawal',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='提现金额')),
|
||||
('status', models.CharField(choices=[('pending', '审核中'), ('approved', '已打款'), ('rejected', '已拒绝')], default='pending', max_length=20, verbose_name='状态')),
|
||||
('remark', models.TextField(blank=True, verbose_name='备注')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='申请时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('distributor', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='withdrawals', to='shop.distributor', verbose_name='分销员')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '提现记录',
|
||||
'verbose_name_plural': '提现管理',
|
||||
},
|
||||
),
|
||||
]
|
||||
35
backend/shop/migrations/0018_vbcourse_delete_arservice.py
Normal file
35
backend/shop/migrations/0018_vbcourse_delete_arservice.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 18:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0017_withdrawal'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='VBCourse',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=100, verbose_name='课程名称')),
|
||||
('description', models.TextField(verbose_name='课程简介')),
|
||||
('course_type', models.CharField(choices=[('software', '软件课程'), ('hardware', '硬件课程')], default='software', max_length=20, verbose_name='课程类型')),
|
||||
('duration', models.CharField(default='30分钟', help_text='例如: 30分钟', max_length=50, verbose_name='课程时长')),
|
||||
('lesson_count', models.IntegerField(default=1, verbose_name='课时数量')),
|
||||
('instructor', models.CharField(default='VB讲师', max_length=50, verbose_name='讲师')),
|
||||
('cover_image', models.ImageField(blank=True, null=True, upload_to='courses/covers/', verbose_name='封面图 (上传)')),
|
||||
('cover_image_url', models.URLField(blank=True, null=True, verbose_name='封面图 (URL)')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'VB课程',
|
||||
'verbose_name_plural': 'VB课程管理',
|
||||
},
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='ARService',
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0.1 on 2026-02-10 18:55
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('shop', '0018_vbcourse_delete_arservice'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='vbcourse',
|
||||
name='detail_image',
|
||||
field=models.ImageField(blank=True, null=True, upload_to='courses/details/', verbose_name='详情页长图 (上传)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vbcourse',
|
||||
name='detail_image_url',
|
||||
field=models.URLField(blank=True, help_text='如果填写了URL,将优先使用URL', null=True, verbose_name='详情页长图 (URL)'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='vbcourse',
|
||||
name='tag',
|
||||
field=models.CharField(blank=True, help_text='例如: 热门, 推荐, 进阶', max_length=20, verbose_name='标签'),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user