This commit is contained in:
Kris Ka committed 2025-08-06 02:20:40 +08:00
1 parent c3c3bbf955
commit 9dd180e4ad
58 files changed
+5681 -1470

No files matched your search

Vendored
BIN
View File
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
# TODO:
- [x] add_creation_time_display: 在ProjectCard组件中添加项目创建时间显示,使用dayjs显示相对时间并设置Asia/Shanghai时区 (priority: High)
- [ ] fix_thumbnail_generation: 修复ProjectCard组件中的缩略图生成问题,改进错误处理和多路径尝试机制 (priority: High)
@@ -0,0 +1,86 @@
# AutoClip智能高光切片工具产品需求文档
## 1. 产品概述
AutoClip是一个基于AI的智能视频高光切片工具,能够自动分析视频内容,提取精彩片段并生成高质量的短视频合集。
- 解决长视频内容筛选效率低的问题,为内容创作者和视频编辑人员提供智能化的视频处理解决方案。
- 目标是成为视频内容创作领域的核心生产力工具,提升内容制作效率10倍以上。
## 2. 核心功能
### 2.1 用户角色
| 角色 | 注册方式 | 核心权限 |
|------|----------|----------|
| 普通用户 | 直接使用,无需注册 | 可上传视频、查看处理进度、下载切片结果 |
| 管理员 | 系统预设 | 可管理所有项目、查看系统状态、配置处理参数 |
### 2.2 功能模块
我们的AutoClip系统包含以下主要页面:
1. **项目管理页面**:项目列表、创建项目、项目状态监控
2. **视频上传页面**:文件上传、格式验证、预处理配置
3. **处理进度页面**:实时进度显示、任务状态跟踪、错误处理
4. **结果展示页面**:切片预览、质量评分、标题生成
5. **合集管理页面**:切片组合、合集编辑、导出设置
6. **系统设置页面**:处理参数配置、API密钥管理、系统监控
### 2.3 页面详情
| 页面名称 | 模块名称 | 功能描述 |
|----------|----------|----------|
| 项目管理页面 | 项目列表 | 显示所有项目、支持搜索筛选、状态标识、批量操作 |
| 项目管理页面 | 项目创建 | 创建新项目、设置项目名称和类型、选择处理模板 |
| 视频上传页面 | 文件上传 | 支持拖拽上传、进度显示、格式验证、大小限制 |
| 视频上传页面 | 字幕上传 | 可选SRT字幕文件上传、自动时间轴对齐 |
| 处理进度页面 | 实时进度 | WebSocket实时更新、6步处理流程显示、当前步骤详情 |
| 处理进度页面 | 任务管理 | 任务暂停/恢复、错误重试、日志查看 |
| 结果展示页面 | 切片预览 | 视频片段播放、时间轴标注、质量评分显示 |
| 结果展示页面 | 内容编辑 | 标题编辑、描述修改、标签管理 |
| 合集管理页面 | 切片选择 | 多选切片、排序调整、时长控制 |
| 合集管理页面 | 导出设置 | 分辨率选择、格式配置、水印添加 |
| 系统设置页面 | 参数配置 | 处理阈值设置、模型选择、并发控制 |
| 系统设置页面 | 系统监控 | 任务队列状态、系统资源使用、错误统计 |
## 3. 核心流程
### 普通用户流程
用户首先在项目管理页面创建新项目并选择视频类型,然后在视频上传页面上传视频文件和可选的字幕文件,系统开始6步智能处理流程(大纲提取→时间定位→内容评分→标题生成→主题聚类→视频切割),用户可在处理进度页面实时查看进度,处理完成后在结果展示页面预览所有切片,最后在合集管理页面选择优质切片组合成合集并导出。
### 管理员流程
管理员可以查看所有用户项目,在系统设置页面配置全局处理参数和监控系统状态,处理异常任务和系统维护。
```mermaid
graph TD
A[项目管理页面] --> B[视频上传页面]
B --> C[处理进度页面]
C --> D[结果展示页面]
D --> E[合集管理页面]
A --> F[系统设置页面]
C --> C1[实时进度监控]
D --> D1[切片预览播放]
E --> E1[导出下载]
```
## 4. 用户界面设计
### 4.1 设计风格
- **主色调**:深蓝色(#1890ff)作为主色,浅灰色(#f5f5f5)作为背景色
- **按钮样式**:圆角按钮,支持悬停效果和加载状态
- **字体**:系统默认字体,标题16px,正文14px,说明文字12px
- **布局风格**:卡片式布局,左侧导航栏,顶部面包屑导航
- **图标风格**:使用Ant Design图标库,简洁现代的线性图标
### 4.2 页面设计概览
| 页面名称 | 模块名称 | UI元素 |
|----------|----------|--------|
| 项目管理页面 | 项目列表 | 卡片网格布局,每个项目卡片显示缩略图、名称、状态、进度条,支持悬停效果 |
| 视频上传页面 | 文件上传区 | 大尺寸拖拽区域,虚线边框,上传图标,支持点击和拖拽两种方式 |
| 处理进度页面 | 进度指示器 | 6步骤水平进度条,当前步骤高亮显示,完成步骤显示绿色勾选 |
| 结果展示页面 | 视频播放器 | 内嵌视频播放器,时间轴标注,质量评分标签,操作按钮组 |
| 合集管理页面 | 切片选择器 | 多选列表,缩略图预览,拖拽排序,时长统计显示 |
| 系统设置页面 | 配置表单 | 分组表单布局,实时验证,保存状态提示 |
### 4.3 响应式设计
产品采用桌面优先设计,支持平板和移动端自适应,在移动端优化触摸交互体验,确保核心功能在小屏幕设备上的可用性。
@@ -0,0 +1,475 @@
# AutoClip智能高光切片工具技术架构文档
## 1. 架构设计
```mermaid
graph TD
A[用户浏览器] --> B[React前端应用]
B --> C[FastAPI后端服务]
C --> D[SQLite数据库]
C --> E[Celery任务队列]
C --> F[WebSocket管理器]
E --> G[Redis消息代理]
E --> H[AI处理Pipeline]
H --> I[LLM API服务]
C --> J[文件存储系统]
subgraph "前端层"
B
end
subgraph "后端服务层"
C
F
end
subgraph "任务处理层"
E
H
end
subgraph "数据存储层"
D
G
J
end
subgraph "外部服务"
I
end
```
## 2. 技术描述
- **前端**: React@18 + TypeScript + Ant Design + Zustand + Vite
- **后端**: FastAPI + SQLAlchemy 2.0 + Celery + WebSocket
- **数据库**: SQLite (开发) / PostgreSQL (生产)
- **消息队列**: Redis + Celery
- **AI处理**: 通义千问API + 自研Pipeline
## 3. 路由定义
| 路由 | 用途 |
|------|------|
| / | 首页,显示项目列表和快速操作入口 |
| /projects | 项目管理页面,创建和管理所有项目 |
| /projects/:id/upload | 视频上传页面,处理文件上传和预处理配置 |
| /projects/:id/progress | 处理进度页面,实时显示任务执行状态 |
| /projects/:id/results | 结果展示页面,预览和编辑生成的切片 |
| /projects/:id/collections | 合集管理页面,组合切片并导出 |
| /settings | 系统设置页面,配置处理参数和系统监控 |
| /ws | WebSocket连接端点,用于实时进度推送 |
## 4. API定义
### 4.1 核心API
**项目管理相关**
```
POST /api/v1/projects
```
请求参数:
| 参数名 | 参数类型 | 是否必需 | 描述 |
|--------|----------|----------|------|
| name | string | true | 项目名称 |
| description | string | false | 项目描述 |
| project_type | string | true | 项目类型(knowledge/business/opinion等) |
| processing_config | object | false | 处理配置参数 |
响应参数:
| 参数名 | 参数类型 | 描述 |
|--------|----------|------|
| id | string | 项目ID |
| name | string | 项目名称 |
| status | string | 项目状态 |
| created_at | string | 创建时间 |
示例:
```json
{
"name": "知识科普视频处理",
"project_type": "knowledge",
"processing_config": {
"min_score_threshold": 0.7,
"max_clips_per_collection": 5
}
}
```
**文件上传相关**
```
POST /api/v1/projects/{project_id}/upload
```
请求参数:
| 参数名 | 参数类型 | 是否必需 | 描述 |
|--------|----------|----------|------|
| video_file | file | true | 视频文件 |
| srt_file | file | false | 字幕文件 |
| file_type | string | true | 文件类型(video/srt) |
**任务处理相关**
```
POST /api/v1/projects/{project_id}/process
```
请求参数:
| 参数名 | 参数类型 | 是否必需 | 描述 |
|--------|----------|----------|------|
| processing_steps | array | false | 指定处理步骤 |
| priority | integer | false | 任务优先级 |
**WebSocket消息格式**
```
WS /api/v1/ws/{project_id}
```
消息格式:
```json
{
"type": "progress_update",
"data": {
"task_id": "task_123",
"progress": 45.5,
"current_step": "step2_timeline",
"message": "正在进行时间定位...",
"estimated_remaining": 120
}
}
```
## 5. 服务器架构图
```mermaid
graph TD
A[客户端请求] --> B[FastAPI路由层]
B --> C[服务层]
C --> D[仓储层]
D --> E[(SQLite数据库)]
C --> F[任务队列服务]
F --> G[Celery Worker]
G --> H[AI处理Pipeline]
C --> I[WebSocket通知服务]
I --> J[WebSocket管理器]
subgraph "FastAPI应用"
B
C
D
F
I
end
subgraph "异步任务处理"
G
H
end
subgraph "实时通信"
J
end
```
## 6. 数据模型
### 6.1 数据模型定义
```mermaid
erDiagram
PROJECT ||--o{ TASK : has
PROJECT ||--o{ CLIP : generates
PROJECT ||--o{ COLLECTION : creates
COLLECTION ||--o{ CLIP : contains
PROJECT {
string id PK
string name
string description
enum status
enum project_type
string video_path
integer video_duration
json processing_config
json project_metadata
datetime created_at
datetime updated_at
datetime completed_at
}
TASK {
string id PK
string name
string description
enum status
enum task_type
float progress
string current_step
integer total_steps
integer priority
datetime started_at
datetime completed_at
string error_message
string celery_task_id
json task_config
json result_data
json task_metadata
string project_id FK
}
CLIP {
string id PK
string title
string description
float start_time
float end_time
float duration
float score
string video_path
string thumbnail_path
json clip_metadata
string project_id FK
}
COLLECTION {
string id PK
string name
string description
integer total_clips
float total_duration
string export_path
json export_config
json collection_metadata
string project_id FK
}
```
### 6.2 数据定义语言
**项目表 (projects)**
```sql
-- 创建项目表
CREATE TABLE projects (
id VARCHAR(36) PRIMARY KEY DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6)))),
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'processing', 'completed', 'failed', 'cancelled')),
project_type VARCHAR(50) DEFAULT 'default' CHECK (project_type IN ('default', 'knowledge', 'business', 'opinion', 'experience', 'speech', 'content_review', 'entertainment')),
video_path VARCHAR(500),
video_duration INTEGER,
processing_config JSON,
project_metadata JSON,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
completed_at DATETIME
);
-- 创建索引
CREATE INDEX idx_projects_status ON projects(status);
CREATE INDEX idx_projects_type ON projects(project_type);
CREATE INDEX idx_projects_created_at ON projects(created_at DESC);
```
**任务表 (tasks)**
```sql
-- 创建任务表
CREATE TABLE tasks (
id VARCHAR(36) PRIMARY KEY DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6)))),
name VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'completed', 'failed', 'cancelled')),
task_type VARCHAR(50) NOT NULL CHECK (task_type IN ('video_processing', 'clip_generation', 'collection_creation', 'export', 'cleanup')),
progress REAL DEFAULT 0.0,
current_step VARCHAR(100),
total_steps INTEGER DEFAULT 1,
priority INTEGER DEFAULT 0,
started_at DATETIME,
completed_at DATETIME,
error_message TEXT,
celery_task_id VARCHAR(255),
task_config JSON,
result_data JSON,
task_metadata JSON,
project_id VARCHAR(36) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
-- 创建索引
CREATE INDEX idx_tasks_project_id ON tasks(project_id);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_celery_task_id ON tasks(celery_task_id);
CREATE INDEX idx_tasks_created_at ON tasks(created_at DESC);
```
**切片表 (clips)**
```sql
-- 创建切片表
CREATE TABLE clips (
id VARCHAR(36) PRIMARY KEY DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6)))),
title VARCHAR(255) NOT NULL,
description TEXT,
start_time REAL NOT NULL,
end_time REAL NOT NULL,
duration REAL NOT NULL,
score REAL DEFAULT 0.0,
video_path VARCHAR(500),
thumbnail_path VARCHAR(500),
clip_metadata JSON,
project_id VARCHAR(36) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
-- 创建索引
CREATE INDEX idx_clips_project_id ON clips(project_id);
CREATE INDEX idx_clips_score ON clips(score DESC);
CREATE INDEX idx_clips_duration ON clips(duration);
```
**合集表 (collections)**
```sql
-- 创建合集表
CREATE TABLE collections (
id VARCHAR(36) PRIMARY KEY DEFAULT (lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-4' || substr(lower(hex(randomblob(2))),2) || '-' || substr('89ab',abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))),2) || '-' || lower(hex(randomblob(6)))),
name VARCHAR(255) NOT NULL,
description TEXT,
total_clips INTEGER DEFAULT 0,
total_duration REAL DEFAULT 0.0,
export_path VARCHAR(500),
export_config JSON,
collection_metadata JSON,
project_id VARCHAR(36) NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
-- 创建索引
CREATE INDEX idx_collections_project_id ON collections(project_id);
CREATE INDEX idx_collections_created_at ON collections(created_at DESC);
-- 初始化数据
INSERT INTO projects (name, description, project_type, status) VALUES
('示例知识科普项目', '用于演示知识科普类视频处理流程', 'knowledge', 'pending'),
('示例商业财经项目', '用于演示商业财经类视频处理流程', 'business', 'pending');
```
## 7. 关键技术实现
### 7.1 Celery任务队列集成
**配置文件 (backend/core/celery_app.py)**
```python
from celery import Celery
from backend.core.config import settings
celery_app = Celery(
"autoclip",
broker=settings.REDIS_URL,
backend=settings.REDIS_URL,
include=["backend.tasks.processing", "backend.tasks.video"]
)
celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="UTC",
enable_utc=True,
task_track_started=True,
task_time_limit=30 * 60, # 30分钟超时
task_soft_time_limit=25 * 60, # 25分钟软超时
worker_prefetch_multiplier=1,
task_acks_late=True,
worker_max_tasks_per_child=1000
)
```
### 7.2 WebSocket实时通信
**WebSocket管理器 (backend/core/websocket_manager.py)**
```python
from fastapi import WebSocket
from typing import Dict, List
import json
class WebSocketManager:
def __init__(self):
self.active_connections: Dict[str, List[WebSocket]] = {}
async def connect(self, websocket: WebSocket, project_id: str):
await websocket.accept()
if project_id not in self.active_connections:
self.active_connections[project_id] = []
self.active_connections[project_id].append(websocket)
async def disconnect(self, websocket: WebSocket, project_id: str):
if project_id in self.active_connections:
self.active_connections[project_id].remove(websocket)
async def send_progress_update(self, project_id: str, data: dict):
if project_id in self.active_connections:
message = json.dumps({"type": "progress_update", "data": data})
for connection in self.active_connections[project_id]:
try:
await connection.send_text(message)
except:
await self.disconnect(connection, project_id)
```
### 7.3 AI处理Pipeline集成
**Pipeline适配器 (backend/services/pipeline_adapter.py)**
```python
from shared.pipeline import step1_outline, step2_timeline, step3_scoring
from backend.models import Task, Project
from backend.core.websocket_manager import websocket_manager
class PipelineAdapter:
def __init__(self):
self.steps = {
"step1_outline": step1_outline.OutlineExtractor,
"step2_timeline": step2_timeline.TimelineExtractor,
"step3_scoring": step3_scoring.ContentScorer,
# ... 其他步骤
}
async def execute_pipeline(self, project: Project, task: Task):
"""执行完整的AI处理流程"""
try:
for i, (step_name, step_class) in enumerate(self.steps.items()):
# 更新任务进度
progress = (i / len(self.steps)) * 100
await self.update_task_progress(task, progress, step_name)
# 执行处理步骤
processor = step_class()
result = await processor.process(project)
# 保存中间结果
await self.save_step_result(project, step_name, result)
# 完成任务
await self.complete_task(task)
except Exception as e:
await self.fail_task(task, str(e))
async def update_task_progress(self, task: Task, progress: float, current_step: str):
"""更新任务进度并发送WebSocket通知"""
task.progress = progress
task.current_step = current_step
# 发送WebSocket通知
await websocket_manager.send_progress_update(task.project_id, {
"task_id": task.id,
"progress": progress,
"current_step": current_step,
"message": f"正在执行{current_step}..."
})
```
View File
Whitespace-only changes.
BIN
View File
Binary file not shown.
+186 -28
View File
@@ -9,6 +9,9 @@ from sqlalchemy.orm import Session
from core.database import get_db
from services.project_service import ProjectService
from services.processing_service import ProcessingService
from services.websocket_notification_service import WebSocketNotificationService
from tasks.processing import process_video_pipeline
from core.websocket_manager import manager as websocket_manager
from schemas.project import (
ProjectCreate, ProjectUpdate, ProjectResponse, ProjectListResponse, ProjectFilter,
ProjectType, ProjectStatus
@@ -29,6 +32,11 @@ def get_processing_service(db: Session = Depends(get_db)) -> ProcessingService:
return ProcessingService(db)
def get_websocket_service(db: Session = Depends(get_db)) -> WebSocketNotificationService:
"""Dependency to get websocket notification service."""
return WebSocketNotificationService(websocket_manager)
@router.post("/upload", response_model=ProjectResponse)
async def upload_files(
video_file: UploadFile = File(...),
@@ -234,9 +242,10 @@ async def delete_project(
async def start_processing(
project_id: str,
project_service: ProjectService = Depends(get_project_service),
processing_service: ProcessingService = Depends(get_processing_service)
processing_service: ProcessingService = Depends(get_processing_service),
websocket_service: WebSocketNotificationService = Depends(get_websocket_service)
):
"""Start processing a project."""
"""Start processing a project using Celery task queue."""
try:
# 获取项目信息
project = project_service.get(project_id)
@@ -244,42 +253,152 @@ async def start_processing(
raise HTTPException(status_code=404, detail="Project not found")
# 检查项目状态
if project.status.value != "pending":
raise HTTPException(status_code=400, detail="Project is not in pending status")
if project.status.value not in ["pending", "failed"]:
raise HTTPException(status_code=400, detail="Project is not in pending or failed status")
# 获取SRT文件路径
# 获取视频和SRT文件路径
project_root = Path(__file__).parent.parent.parent / "data" / "projects" / project_id
video_path = None
srt_path = None
if project.processing_config and "srt_file" in project.processing_config:
from pathlib import Path
project_root = Path(__file__).parent.parent.parent / "data" / "projects" / project_id
srt_path = project_root / "raw" / project.processing_config["srt_file"]
if project.processing_config:
if "video_file" in project.processing_config:
video_path = project_root / "raw" / project.processing_config["video_file"]
if "srt_file" in project.processing_config:
srt_path = project_root / "raw" / project.processing_config["srt_file"]
# 验证文件存在
if not video_path or not video_path.exists():
raise HTTPException(status_code=400, detail=f"Video file not found: {video_path}")
if not srt_path or not srt_path.exists():
raise HTTPException(status_code=400, detail=f"SRT file not found: {srt_path}")
# 调用处理服务
result = processing_service.start_processing(project_id, srt_path)
# 更新项目状态为处理中
project_service.update_project_status(project_id, "processing")
# 发送WebSocket通知:处理开始
await websocket_service.send_processing_started(
project_id=int(project_id),
message="开始视频处理流程"
)
# 提交Celery任务
celery_task = process_video_pipeline.delay(
project_id=int(project_id),
input_video_path=str(video_path),
input_srt_path=str(srt_path)
)
# 创建处理任务记录
task_result = processing_service._create_processing_task(
project_id=int(project_id),
task_type="video_pipeline",
celery_task_id=celery_task.id
)
return {
"message": "Processing started successfully",
"project_id": project_id,
"task_id": result.get("task_id"),
"result": result
"task_id": task_result.id,
"celery_task_id": celery_task.id,
"status": "processing"
}
except HTTPException:
raise
except Exception as e:
# 发送错误通知
try:
await websocket_service.send_processing_error(
project_id=int(project_id),
error=str(e),
step="initialization"
)
except:
pass
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{project_id}/retry")
async def retry_processing(
project_id: str,
project_service: ProjectService = Depends(get_project_service)
project_service: ProjectService = Depends(get_project_service),
processing_service: ProcessingService = Depends(get_processing_service),
websocket_service: WebSocketNotificationService = Depends(get_websocket_service)
):
"""Retry processing a project."""
"""Retry processing a project from the beginning."""
try:
# 这里应该调用处理服务,暂时返回成功
return {"message": "Processing retry started successfully"}
# 获取项目信息
project = project_service.get(project_id)
if not project:
raise HTTPException(status_code=404, detail="Project not found")
# 检查项目状态
if project.status.value not in ["failed", "completed"]:
raise HTTPException(status_code=400, detail="Project is not in failed or completed status")
# 重置项目状态
project_service.update_project_status(project_id, "pending")
# 发送WebSocket通知
await websocket_service.send_processing_started(
project_id=int(project_id),
message="重新开始处理流程"
)
# 获取文件路径并重新提交任务
project_root = Path(__file__).parent.parent.parent / "data" / "projects" / project_id
video_path = None
srt_path = None
if project.processing_config:
if "video_file" in project.processing_config:
video_path = project_root / "raw" / project.processing_config["video_file"]
if "srt_file" in project.processing_config:
srt_path = project_root / "raw" / project.processing_config["srt_file"]
if not video_path or not video_path.exists():
raise HTTPException(status_code=400, detail=f"Video file not found: {video_path}")
if not srt_path or not srt_path.exists():
raise HTTPException(status_code=400, detail=f"SRT file not found: {srt_path}")
# 提交Celery任务
celery_task = process_video_pipeline.delay(
project_id=int(project_id),
input_video_path=str(video_path),
input_srt_path=str(srt_path)
)
# 创建新的处理任务记录
task_result = processing_service._create_processing_task(
project_id=int(project_id),
task_type="video_pipeline_retry",
celery_task_id=celery_task.id
)
return {
"message": "Processing retry started successfully",
"project_id": project_id,
"task_id": task_result.id,
"celery_task_id": celery_task.id,
"status": "processing"
}
except HTTPException:
raise
except Exception as e:
try:
await websocket_service.send_processing_error(
project_id=int(project_id),
error=str(e),
step="retry_initialization"
)
except:
pass
raise HTTPException(status_code=400, detail=str(e))
@@ -288,9 +407,10 @@ async def resume_processing(
project_id: str,
start_step: str = Form(..., description="Step to resume from (step1_outline, step2_timeline, etc.)"),
project_service: ProjectService = Depends(get_project_service),
processing_service: ProcessingService = Depends(get_processing_service)
processing_service: ProcessingService = Depends(get_processing_service),
websocket_service: WebSocketNotificationService = Depends(get_websocket_service)
):
"""Resume processing from a specific step."""
"""Resume processing from a specific step using Celery task queue."""
try:
# 获取项目信息
project = project_service.get(project_id)
@@ -301,27 +421,65 @@ async def resume_processing(
if project.status.value not in ["failed", "processing", "pending"]:
raise HTTPException(status_code=400, detail="Project is not in failed, processing, or pending status")
# 获取SRT文件路径(如果需要)
# 验证步骤名称
valid_steps = ["step1_outline", "step2_timeline", "step3_scoring", "step4_filtering", "step5_clustering", "step6_video"]
if start_step not in valid_steps:
raise HTTPException(status_code=400, detail=f"Invalid step: {start_step}. Valid steps: {valid_steps}")
# 获取文件路径
project_root = Path(__file__).parent.parent.parent / "data" / "projects" / project_id
video_path = None
srt_path = None
if start_step == "step1_outline":
if project.processing_config and "srt_file" in project.processing_config:
from pathlib import Path
project_root = Path(__file__).parent.parent.parent / "data" / "projects" / project_id
if project.processing_config:
if "video_file" in project.processing_config:
video_path = project_root / "raw" / project.processing_config["video_file"]
if "srt_file" in project.processing_config:
srt_path = project_root / "raw" / project.processing_config["srt_file"]
# 对于从第一步开始的情况,需要验证原始文件
if start_step == "step1_outline":
if not video_path or not video_path.exists():
raise HTTPException(status_code=400, detail=f"Video file not found: {video_path}")
if not srt_path or not srt_path.exists():
raise HTTPException(status_code=400, detail=f"SRT file not found: {srt_path}")
# 调用处理服务恢复执行
result = processing_service.resume_processing(project_id, start_step, srt_path)
# 更新项目状态为处理中
project_service.update_project_status(project_id, "processing")
# 发送WebSocket通知
await websocket_service.send_processing_started(
project_id=int(project_id),
message=f"从步骤 {start_step} 恢复处理流程"
)
# 使用处理服务恢复执行(这会在内部调用Pipeline适配器)
result = processing_service.resume_processing(
project_id,
start_step,
str(srt_path) if srt_path else None
)
return {
"message": f"Processing resumed from {start_step} successfully",
"project_id": project_id,
"start_step": start_step,
"result": result
"task_id": result.get("task_id"),
"status": "processing"
}
except HTTPException:
raise
except Exception as e:
try:
await websocket_service.send_processing_error(
project_id=int(project_id),
error=str(e),
step=f"resume_{start_step}"
)
except:
pass
raise HTTPException(status_code=400, detail=str(e))
+319 -262
View File
@@ -1,302 +1,359 @@
"""
任务队列API
提供任务提交、状态查询、取消等接口
"""任务API路由
处理Celery任务相关的操作
"""
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from typing import List, Optional, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Dict, Any, List, Optional
from pathlib import Path
from datetime import datetime
from celery.result import AsyncResult
from core.database import get_db
from services.task_queue_service import TaskQueueService
from schemas.task import TaskCreate, TaskResponse
from schemas.base import SuccessResponse
from services.processing_service import ProcessingService
from services.websocket_notification_service import WebSocketNotificationService
from core.websocket_manager import manager as websocket_manager
from core.celery_app import celery_app
from models.task import Task
from schemas.task import TaskResponse, TaskListResponse, TaskStatus
from schemas.base import PaginationParams
router = APIRouter(tags=["任务队列"])
router = APIRouter()
@router.post("/submit/video-processing", response_model=SuccessResponse)
async def submit_video_processing_task(
project_id: str,
srt_path: str,
def get_processing_service(db: Session = Depends(get_db)) -> ProcessingService:
"""Dependency to get processing service."""
return ProcessingService(db)
def get_websocket_service(db: Session = Depends(get_db)) -> WebSocketNotificationService:
"""Dependency to get websocket notification service."""
return WebSocketNotificationService(websocket_manager)
@router.get("/", response_model=TaskListResponse)
async def get_tasks(
page: int = Query(1, ge=1, description="Page number"),
size: int = Query(20, ge=1, le=100, description="Page size"),
project_id: Optional[int] = Query(None, description="Filter by project ID"),
status: Optional[str] = Query(None, description="Filter by task status"),
task_type: Optional[str] = Query(None, description="Filter by task type"),
db: Session = Depends(get_db)
):
"""
提交视频处理任务
Args:
project_id: 项目ID
srt_path: SRT文件路径
db: 数据库会话
Returns:
任务提交结果
"""
"""Get paginated tasks with optional filtering."""
try:
task_service = TaskQueueService(db)
result = task_service.submit_video_processing_task(project_id, srt_path)
query = db.query(Task)
return SuccessResponse(
message="视频处理任务已提交",
data=result
# 应用过滤器
if project_id:
query = query.filter(Task.project_id == project_id)
if status:
query = query.filter(Task.status == status)
if task_type:
query = query.filter(Task.task_type == task_type)
# 分页
total = query.count()
offset = (page - 1) * size
tasks = query.offset(offset).limit(size).all()
# 转换为响应格式
task_responses = []
for task in tasks:
task_responses.append(TaskResponse(
id=str(task.id),
project_id=task.project_id,
task_type=task.task_type,
status=TaskStatus(task.status),
celery_task_id=task.celery_task_id,
current_step=task.current_step,
total_steps=task.total_steps,
progress=task.progress,
error_message=task.error_message,
result=task.result,
created_at=task.created_at,
updated_at=task.updated_at,
completed_at=task.completed_at
))
return TaskListResponse(
items=task_responses,
total=total,
page=page,
size=size,
pages=(total + size - 1) // size
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"提交任务失败: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
@router.post("/submit/single-step", response_model=SuccessResponse)
async def submit_single_step_task(
project_id: str,
step_name: str,
srt_path: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
提交单个步骤处理任务
Args:
project_id: 项目ID
step_name: 步骤名称
srt_path: SRT文件路径(仅Step1需要)
db: 数据库会话
Returns:
任务提交结果
"""
try:
task_service = TaskQueueService(db)
result = task_service.submit_single_step_task(project_id, step_name, srt_path)
return SuccessResponse(
message=f"步骤 {step_name} 处理任务已提交",
data=result
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"提交任务失败: {str(e)}")
@router.post("/submit/retry", response_model=SuccessResponse)
async def submit_retry_task(
project_id: str,
@router.get("/{task_id}", response_model=TaskResponse)
async def get_task(
task_id: str,
step_name: str,
srt_path: Optional[str] = None,
db: Session = Depends(get_db)
):
"""
提交重试任务
Args:
project_id: 项目ID
task_id: 任务ID
step_name: 步骤名称
srt_path: SRT文件路径(仅Step1需要)
db: 数据库会话
Returns:
任务提交结果
"""
"""Get a task by ID."""
try:
task_service = TaskQueueService(db)
result = task_service.submit_retry_task(project_id, task_id, step_name, srt_path)
task = db.query(Task).filter(Task.id == int(task_id)).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
return SuccessResponse(
message=f"步骤 {step_name} 重试任务已提交",
data=result
return TaskResponse(
id=str(task.id),
project_id=task.project_id,
task_type=task.task_type,
status=TaskStatus(task.status),
celery_task_id=task.celery_task_id,
current_step=task.current_step,
total_steps=task.total_steps,
progress=task.progress,
error_message=task.error_message,
result=task.result,
created_at=task.created_at,
updated_at=task.updated_at,
completed_at=task.completed_at
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"提交重试任务失败: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
@router.get("/status/{task_id}", response_model=SuccessResponse)
@router.get("/{task_id}/status")
async def get_task_status(
task_id: str,
db: Session = Depends(get_db)
):
"""
获取任务状态
Args:
task_id: 任务ID
db: 数据库会话
Returns:
任务状态信息
"""
"""Get detailed task status including Celery task info."""
try:
task_service = TaskQueueService(db)
result = task_service.get_task_status(task_id)
task = db.query(Task).filter(Task.id == int(task_id)).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
if 'error' in result:
raise HTTPException(status_code=404, detail=result['error'])
# 获取Celery任务状态
celery_result = None
celery_status = "UNKNOWN"
celery_info = None
return SuccessResponse(
message="获取任务状态成功",
data=result
)
if task.celery_task_id:
try:
celery_result = AsyncResult(task.celery_task_id, app=celery_app)
celery_status = celery_result.status
celery_info = celery_result.info
except Exception as e:
celery_info = {"error": f"Failed to get Celery task info: {str(e)}"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取任务状态失败: {str(e)}")
@router.get("/project/{project_id}", response_model=SuccessResponse)
async def get_project_tasks(
project_id: str,
db: Session = Depends(get_db)
):
"""
获取项目的所有任务
Args:
project_id: 项目ID
db: 数据库会话
Returns:
任务列表
"""
try:
task_service = TaskQueueService(db)
tasks = task_service.get_project_tasks(project_id)
return SuccessResponse(
message="获取项目任务列表成功",
data={
'project_id': project_id,
'tasks': tasks,
'total': len(tasks)
}
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取项目任务失败: {str(e)}")
@router.post("/cancel/{task_id}", response_model=SuccessResponse)
async def cancel_task(
task_id: str,
db: Session = Depends(get_db)
):
"""
取消任务
Args:
task_id: 任务ID
db: 数据库会话
Returns:
取消结果
"""
try:
task_service = TaskQueueService(db)
result = task_service.cancel_task(task_id)
if 'error' in result:
raise HTTPException(status_code=404, detail=result['error'])
return SuccessResponse(
message="任务已取消",
data=result
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=f"取消任务失败: {str(e)}")
@router.post("/submit/video-clips", response_model=SuccessResponse)
async def submit_video_clips_task(
project_id: str,
clip_data: List[Dict[str, Any]],
db: Session = Depends(get_db)
):
"""
提交视频片段提取任务
Args:
project_id: 项目ID
clip_data: 片段数据列表
db: 数据库会话
Returns:
任务提交结果
"""
try:
task_service = TaskQueueService(db)
result = task_service.submit_video_clips_task(project_id, clip_data)
return SuccessResponse(
message=f"视频片段提取任务已提交,共 {len(clip_data)} 个片段",
data=result
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"提交视频片段提取任务失败: {str(e)}")
@router.post("/submit/collections", response_model=SuccessResponse)
async def submit_collection_generation_task(
project_id: str,
collection_data: List[Dict[str, Any]],
db: Session = Depends(get_db)
):
"""
提交合集生成任务
Args:
project_id: 项目ID
collection_data: 合集数据列表
db: 数据库会话
Returns:
任务提交结果
"""
try:
task_service = TaskQueueService(db)
result = task_service.submit_collection_generation_task(project_id, collection_data)
return SuccessResponse(
message=f"视频合集生成任务已提交,共 {len(collection_data)} 个合集",
data=result
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"提交合集生成任务失败: {str(e)}")
@router.get("/queue/status", response_model=SuccessResponse)
async def get_queue_status():
"""
获取队列状态
Returns:
队列状态信息
"""
try:
from core.celery_app import celery_app
# 获取活跃任务数
active_tasks = celery_app.control.inspect().active()
reserved_tasks = celery_app.control.inspect().reserved()
queue_status = {
'active_tasks': len(active_tasks.get('celery@localhost', [])) if active_tasks else 0,
'reserved_tasks': len(reserved_tasks.get('celery@localhost', [])) if reserved_tasks else 0,
'workers': len(celery_app.control.inspect().stats() or {}),
'timestamp': datetime.utcnow().isoformat()
return {
"task_id": str(task.id),
"project_id": task.project_id,
"task_type": task.task_type,
"status": task.status,
"current_step": task.current_step,
"total_steps": task.total_steps,
"progress": task.progress,
"error_message": task.error_message,
"celery_task_id": task.celery_task_id,
"celery_status": celery_status,
"celery_info": celery_info,
"created_at": task.created_at,
"updated_at": task.updated_at,
"completed_at": task.completed_at
}
return SuccessResponse(
message="获取队列状态成功",
data=queue_status
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{task_id}/cancel")
async def cancel_task(
task_id: str,
db: Session = Depends(get_db),
websocket_service: WebSocketNotificationService = Depends(get_websocket_service)
):
"""Cancel a running task."""
try:
task = db.query(Task).filter(Task.id == int(task_id)).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# 检查任务状态
if task.status in ["completed", "failed", "cancelled"]:
raise HTTPException(status_code=400, detail=f"Cannot cancel task with status: {task.status}")
# 取消Celery任务
if task.celery_task_id:
try:
celery_app.control.revoke(task.celery_task_id, terminate=True)
except Exception as e:
# 即使Celery取消失败,我们也要更新数据库状态
pass
# 更新任务状态
task.status = "cancelled"
task.error_message = "Task cancelled by user"
db.commit()
# 发送WebSocket通知
await websocket_service.send_task_update(
task_id=int(task_id),
project_id=task.project_id,
status="cancelled",
progress=task.progress,
message="任务已取消"
)
return {
"message": "Task cancelled successfully",
"task_id": task_id,
"status": "cancelled"
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
@router.post("/{task_id}/retry")
async def retry_task(
task_id: str,
db: Session = Depends(get_db),
processing_service: ProcessingService = Depends(get_processing_service),
websocket_service: WebSocketNotificationService = Depends(get_websocket_service)
):
"""Retry a failed task."""
try:
task = db.query(Task).filter(Task.id == int(task_id)).first()
if not task:
raise HTTPException(status_code=404, detail="Task not found")
# 检查任务状态
if task.status not in ["failed", "cancelled"]:
raise HTTPException(status_code=400, detail=f"Cannot retry task with status: {task.status}")
# 重置任务状态
task.status = "pending"
task.current_step = 0
task.progress = 0
task.error_message = None
task.celery_task_id = None
db.commit()
# 发送WebSocket通知
await websocket_service.send_task_update(
task_id=int(task_id),
project_id=task.project_id,
status="pending",
progress=0,
message="任务重试中"
)
# 根据任务类型重新提交任务
if task.task_type == "video_pipeline":
# 这里需要重新获取项目信息并提交Celery任务
# 为了简化,我们返回成功,实际实现中需要调用相应的处理逻辑
pass
return {
"message": "Task retry initiated successfully",
"task_id": task_id,
"status": "pending"
}
except HTTPException:
raise
except Exception as e:
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
@router.get("/project/{project_id}", response_model=TaskListResponse)
async def get_project_tasks(
project_id: int,
page: int = Query(1, ge=1, description="Page number"),
size: int = Query(20, ge=1, le=100, description="Page size"),
status: Optional[str] = Query(None, description="Filter by task status"),
db: Session = Depends(get_db)
):
"""Get tasks for a specific project."""
try:
query = db.query(Task).filter(Task.project_id == project_id)
if status:
query = query.filter(Task.status == status)
# 按创建时间倒序排列
query = query.order_by(Task.created_at.desc())
# 分页
total = query.count()
offset = (page - 1) * size
tasks = query.offset(offset).limit(size).all()
# 转换为响应格式
task_responses = []
for task in tasks:
task_responses.append(TaskResponse(
id=str(task.id),
project_id=task.project_id,
task_type=task.task_type,
status=TaskStatus(task.status),
celery_task_id=task.celery_task_id,
current_step=task.current_step,
total_steps=task.total_steps,
progress=task.progress,
error_message=task.error_message,
result=task.result,
created_at=task.created_at,
updated_at=task.updated_at,
completed_at=task.completed_at
))
return TaskListResponse(
items=task_responses,
total=total,
page=page,
size=size,
pages=(total + size - 1) // size
)
except Exception as e:
raise HTTPException(status_code=500, detail=f"获取队列状态失败: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/cleanup")
async def cleanup_old_tasks(
days: int = Query(7, ge=1, le=365, description="Delete tasks older than this many days"),
status: Optional[str] = Query(None, description="Only delete tasks with this status"),
db: Session = Depends(get_db)
):
"""Clean up old tasks."""
try:
from datetime import datetime, timedelta
cutoff_date = datetime.utcnow() - timedelta(days=days)
query = db.query(Task).filter(Task.created_at < cutoff_date)
if status:
query = query.filter(Task.status == status)
# 获取要删除的任务数量
count = query.count()
# 删除任务
query.delete()
db.commit()
return {
"message": f"Cleaned up {count} old tasks",
"deleted_count": count,
"cutoff_date": cutoff_date.isoformat()
}
except Exception as e:
db.rollback()
raise HTTPException(status_code=400, detail=str(e))
+151
View File
@@ -0,0 +1,151 @@
"""WebSocket API路由
处理WebSocket连接和实时通信
"""
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPException
from typing import Optional
from core.websocket_manager import manager
from core.database import get_db
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/ws", tags=["websocket"])
@router.websocket("/{user_id}")
async def websocket_endpoint(websocket: WebSocket, user_id: str):
"""WebSocket连接端点
Args:
websocket: WebSocket连接对象
user_id: 用户ID
"""
await manager.connect(websocket, user_id)
try:
while True:
# 接收客户端消息
data = await websocket.receive_json()
# 处理不同类型的消息
message_type = data.get("type")
if message_type == "subscribe":
# 订阅主题
topic = data.get("topic")
if topic:
manager.subscribe_to_topic(user_id, topic)
await websocket.send_json({
"type": "subscription_confirmed",
"topic": topic,
"message": f"已订阅主题: {topic}"
})
elif message_type == "unsubscribe":
# 取消订阅主题
topic = data.get("topic")
if topic:
manager.unsubscribe_from_topic(user_id, topic)
await websocket.send_json({
"type": "unsubscription_confirmed",
"topic": topic,
"message": f"已取消订阅主题: {topic}"
})
elif message_type == "ping":
# 心跳检测
await websocket.send_json({
"type": "pong",
"timestamp": data.get("timestamp")
})
elif message_type == "get_status":
# 获取连接状态
await websocket.send_json({
"type": "status",
"user_id": user_id,
"connection_count": manager.get_connection_count(),
"subscribed_topics": list(manager.user_subscriptions.get(user_id, set()))
})
else:
logger.warning(f"未知消息类型: {message_type}")
await websocket.send_json({
"type": "error",
"message": f"未知消息类型: {message_type}"
})
except WebSocketDisconnect:
logger.info(f"用户 {user_id} 断开WebSocket连接")
manager.disconnect(user_id)
except Exception as e:
logger.error(f"WebSocket连接错误: {str(e)}")
manager.disconnect(user_id)
@router.get("/status")
async def get_websocket_status():
"""获取WebSocket服务状态"""
return {
"active_connections": manager.get_connection_count(),
"topics": {
topic: manager.get_topic_subscriber_count(topic)
for topic in manager.topic_subscribers.keys()
}
}
@router.post("/broadcast")
async def broadcast_message(message: dict):
"""广播消息到所有连接
Args:
message: 要广播的消息
"""
try:
await manager.broadcast(message)
return {
"success": True,
"message": "消息已广播",
"recipients": manager.get_connection_count()
}
except Exception as e:
logger.error(f"广播消息失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"广播失败: {str(e)}")
@router.post("/broadcast/{topic}")
async def broadcast_to_topic(topic: str, message: dict):
"""广播消息到特定主题
Args:
topic: 主题名称
message: 要广播的消息
"""
try:
await manager.broadcast_to_topic(message, topic)
return {
"success": True,
"message": f"消息已广播到主题: {topic}",
"recipients": manager.get_topic_subscriber_count(topic)
}
except Exception as e:
logger.error(f"广播消息到主题失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"广播失败: {str(e)}")
@router.post("/send/{user_id}")
async def send_personal_message(user_id: str, message: dict):
"""发送个人消息
Args:
user_id: 目标用户ID
message: 要发送的消息
"""
try:
await manager.send_personal_message(message, user_id)
return {
"success": True,
"message": f"消息已发送给用户: {user_id}"
}
except Exception as e:
logger.error(f"发送个人消息失败: {str(e)}")
raise HTTPException(status_code=500, detail=f"发送失败: {str(e)}")
+46 -32
View File
@@ -20,7 +20,9 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
# 使用绝对导入
from api.v1 import health, projects, clips, collections, tasks, settings
from api.v1 import health, projects, clips, collections, tasks as task_routes, settings
from api.v1.tasks import router as tasks_router
from api.v1 import websocket
from core.database import engine
from models.base import Base
@@ -67,11 +69,9 @@ app.include_router(health.router, prefix="/api/v1/health", tags=["health"])
app.include_router(projects.router, prefix="/api/v1/projects", tags=["projects"])
app.include_router(clips.router, prefix="/api/v1/clips", tags=["clips"])
app.include_router(collections.router, prefix="/api/v1/collections", tags=["collections"])
app.include_router(tasks.router, prefix="/api/v1/tasks", tags=["tasks"])
app.include_router(task_routes.router, prefix="/api/v1/tasks", tags=["tasks"])
app.include_router(tasks_router, prefix="/api/v1/tasks", tags=["tasks"])
app.include_router(settings.router, prefix="/api/v1/settings", tags=["settings"])
# Include WebSocket routes
from api.v1 import websocket
app.include_router(websocket.router, prefix="/api/v1", tags=["websocket"])
# 添加独立的video-categories端点
@@ -80,50 +80,64 @@ async def get_video_categories():
"""获取视频分类配置."""
return {
"categories": [
{
"value": "default",
"name": "默认",
"description": "通用视频内容处理",
"icon": "🎬",
"color": "#4facfe"
},
{
"value": "knowledge",
"name": "知识科普",
"description": "科学、技术、历史、文化等知识类内容",
"icon": "book",
"color": "#1890ff"
},
{
"value": "entertainment",
"name": "娱乐休闲",
"description": "游戏、音乐、电影、综艺等娱乐内容",
"icon": "play-circle",
"icon": "📚",
"color": "#52c41a"
},
{
"value": "experience",
"name": "生活经验",
"description": "生活技巧、美食、旅行、手工等实用内容",
"icon": "heart",
"color": "#fa8c16"
},
{
"value": "opinion",
"name": "观点评论",
"description": "时事评论、观点分享、社会话题等",
"icon": "message",
"color": "#722ed1"
},
{
"value": "business",
"name": "商业财经",
"description": "商业分析、财经资讯、投资理财等",
"icon": "dollar",
"icon": "💼",
"color": "#faad14"
},
{
"value": "opinion",
"name": "观点评论",
"description": "时事评论、观点分享、社会话题等",
"icon": "💭",
"color": "#722ed1"
},
{
"value": "experience",
"name": "经验分享",
"description": "生活技巧、经验分享、实用内容等",
"icon": "🌟",
"color": "#13c2c2"
},
{
"value": "speech",
"name": "演讲访谈",
"description": "演讲、访谈、对话等口语化内容",
"icon": "sound",
"name": "演讲脱口秀",
"description": "演讲、访谈、脱口秀等口语化内容",
"icon": "🎤",
"color": "#eb2f96"
},
{
"value": "content_review",
"name": "内容解说",
"description": "影视解说、内容点评等",
"icon": "🎭",
"color": "#f5222d"
},
{
"value": "entertainment",
"name": "娱乐内容",
"description": "游戏、音乐、娱乐等轻松内容",
"icon": "🎪",
"color": "#fa8c16"
}
],
"default_category": "knowledge"
"default_category": "default"
}
# Root endpoint
Binary file not shown.
+20
View File
@@ -2107,3 +2107,23 @@
2025-08-03 00:42:23,266 - services.processing_orchestrator - INFO - 流水线模块导入成功
2025-08-03 00:44:52,612 - services.processing_orchestrator - INFO - 流水线模块导入成功
2025-08-03 00:45:00,752 - services.processing_orchestrator - INFO - 流水线模块导入成功
2025-08-06 01:57:45,784 - services.processing_orchestrator - INFO - 流水线模块导入成功
2025-08-06 01:57:56,276 - services.processing_orchestrator - INFO - 流水线模块导入成功
2025-08-06 01:58:17,520 - services.processing_orchestrator - INFO - 流水线模块导入成功
2025-08-06 01:58:17,561 - fastapi - ERROR - Form data requires "python-multipart" to be installed.
You can install "python-multipart" with:
pip install python-multipart
2025-08-06 01:58:34,095 - services.processing_orchestrator - INFO - 流水线模块导入成功
2025-08-06 01:58:34,168 - app.main - INFO - 启动AutoClip API服务...
2025-08-06 01:58:34,171 - app.main - INFO - 数据库表创建完成
2025-08-06 01:58:34,172 - app.main - INFO - API密钥已加载到环境变量
2025-08-06 02:03:30,178 - app.main - ERROR - 全局异常处理: WebSocketNotificationService() takes no arguments
2025-08-06 02:07:15,951 - services.processing_orchestrator - INFO - 流水线模块导入成功
2025-08-06 02:07:16,022 - app.main - INFO - 启动AutoClip API服务...
2025-08-06 02:07:16,023 - app.main - INFO - 数据库表创建完成
2025-08-06 02:07:16,023 - app.main - INFO - API密钥已加载到环境变量
2025-08-06 02:10:49,379 - app.main - INFO - 访问根端点
2025-08-06 02:11:25,987 - app.main - ERROR - 全局异常处理: WebSocketNotificationService() takes no arguments
2025-08-06 02:11:50,877 - app.main - ERROR - 全局异常处理: WebSocketNotificationService() takes no arguments
+1 -1
View File
@@ -8,7 +8,7 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.pool import StaticPool
from typing import Generator
from backend.models.base import Base
from models.base import Base
# 数据库配置
DATABASE_URL = os.getenv(
+386
View File
@@ -0,0 +1,386 @@
"""进度管理器
统一处理任务进度更新和状态同步
"""
import logging
import asyncio
from typing import Dict, Any, Optional, Callable
from datetime import datetime
from sqlalchemy.orm import Session
from models.task import Task
from models.project import Project
from services.websocket_notification_service import WebSocketNotificationService
from core.websocket_manager import manager as websocket_manager
logger = logging.getLogger(__name__)
class ProgressManager:
"""进度管理器,负责任务进度更新和状态同步"""
def __init__(self, db: Session):
self.db = db
self.websocket_service = WebSocketNotificationService(websocket_manager)
self._callbacks: Dict[str, Callable] = {}
def register_callback(self, callback_id: str, callback: Callable):
"""注册进度回调函数
Args:
callback_id: 回调ID
callback: 回调函数
"""
self._callbacks[callback_id] = callback
logger.debug(f"注册进度回调: {callback_id}")
def unregister_callback(self, callback_id: str):
"""注销进度回调函数
Args:
callback_id: 回调ID
"""
if callback_id in self._callbacks:
del self._callbacks[callback_id]
logger.debug(f"注销进度回调: {callback_id}")
async def update_task_progress(
self,
task_id: int,
current_step: int,
total_steps: int,
step_name: str,
progress: float,
message: Optional[str] = None,
step_result: Optional[Dict[str, Any]] = None
):
"""更新任务进度
Args:
task_id: 任务ID
current_step: 当前步骤
total_steps: 总步骤数
step_name: 步骤名称
progress: 进度百分比 (0-100)
message: 进度消息
step_result: 步骤结果数据
"""
try:
# 更新数据库中的任务状态
task = self.db.query(Task).filter(Task.id == task_id).first()
if not task:
logger.error(f"任务 {task_id} 不存在")
return
# 更新任务字段
task.current_step = current_step
task.total_steps = total_steps
task.progress = progress
task.updated_at = datetime.utcnow()
# 如果有步骤结果,更新到任务结果中
if step_result:
if not task.result:
task.result = {}
task.result[f"step_{current_step}"] = step_result
# 如果进度达到100%,标记为完成
if progress >= 100:
task.status = "completed"
task.completed_at = datetime.utcnow()
# 同时更新项目状态
project = self.db.query(Project).filter(Project.id == task.project_id).first()
if project:
project.status = "completed"
project.completed_at = datetime.utcnow()
self.db.commit()
# 发送WebSocket通知
await self.websocket_service.send_processing_progress(
project_id=task.project_id,
current_step=current_step,
total_steps=total_steps,
step_name=step_name,
progress=progress,
message=message or f"正在执行步骤 {current_step}/{total_steps}: {step_name}"
)
# 发送任务更新通知
await self.websocket_service.send_task_update(
task_id=task_id,
project_id=task.project_id,
status=task.status,
progress=progress,
message=message or f"步骤 {current_step}: {step_name}"
)
# 调用注册的回调函数
for callback_id, callback in self._callbacks.items():
try:
if asyncio.iscoroutinefunction(callback):
await callback({
'task_id': task_id,
'project_id': task.project_id,
'current_step': current_step,
'total_steps': total_steps,
'step_name': step_name,
'progress': progress,
'message': message,
'step_result': step_result
})
else:
callback({
'task_id': task_id,
'project_id': task.project_id,
'current_step': current_step,
'total_steps': total_steps,
'step_name': step_name,
'progress': progress,
'message': message,
'step_result': step_result
})
except Exception as e:
logger.error(f"执行进度回调 {callback_id} 失败: {str(e)}")
logger.info(f"任务 {task_id} 进度更新: {current_step}/{total_steps} ({progress}%) - {step_name}")
except Exception as e:
logger.error(f"更新任务进度失败: {str(e)}")
self.db.rollback()
raise
async def update_task_error(
self,
task_id: int,
error_message: str,
current_step: Optional[int] = None,
step_name: Optional[str] = None
):
"""更新任务错误状态
Args:
task_id: 任务ID
error_message: 错误消息
current_step: 出错的步骤
step_name: 步骤名称
"""
try:
# 更新数据库中的任务状态
task = self.db.query(Task).filter(Task.id == task_id).first()
if not task:
logger.error(f"任务 {task_id} 不存在")
return
# 更新任务状态为失败
task.status = "failed"
task.error_message = error_message
task.updated_at = datetime.utcnow()
if current_step is not None:
task.current_step = current_step
# 同时更新项目状态
project = self.db.query(Project).filter(Project.id == task.project_id).first()
if project:
project.status = "failed"
self.db.commit()
# 发送WebSocket错误通知
await self.websocket_service.send_processing_error(
project_id=task.project_id,
error=error_message,
step=step_name or f"step_{current_step}" if current_step else "unknown"
)
# 发送任务更新通知
await self.websocket_service.send_task_update(
task_id=task_id,
project_id=task.project_id,
status="failed",
progress=task.progress,
message=f"任务失败: {error_message}"
)
logger.error(f"任务 {task_id} 执行失败: {error_message}")
except Exception as e:
logger.error(f"更新任务错误状态失败: {str(e)}")
self.db.rollback()
raise
async def update_project_status(
self,
project_id: int,
status: str,
message: Optional[str] = None
):
"""更新项目状态
Args:
project_id: 项目ID
status: 项目状态
message: 状态消息
"""
try:
project = self.db.query(Project).filter(Project.id == project_id).first()
if not project:
logger.error(f"项目 {project_id} 不存在")
return
old_status = project.status
project.status = status
project.updated_at = datetime.utcnow()
if status == "completed":
project.completed_at = datetime.utcnow()
self.db.commit()
# 发送WebSocket项目更新通知
await self.websocket_service.send_project_update(
project_id=project_id,
status=status,
message=message or f"项目状态从 {old_status} 更新为 {status}"
)
logger.info(f"项目 {project_id} 状态更新: {old_status} -> {status}")
except Exception as e:
logger.error(f"更新项目状态失败: {str(e)}")
self.db.rollback()
raise
def create_progress_callback(self, task_id: int) -> Callable:
"""创建进度回调函数
Args:
task_id: 任务ID
Returns:
进度回调函数
"""
async def progress_callback(
current_step: int,
total_steps: int,
step_name: str,
progress: float,
message: Optional[str] = None,
step_result: Optional[Dict[str, Any]] = None
):
await self.update_task_progress(
task_id=task_id,
current_step=current_step,
total_steps=total_steps,
step_name=step_name,
progress=progress,
message=message,
step_result=step_result
)
return progress_callback
def create_error_callback(self, task_id: int) -> Callable:
"""创建错误回调函数
Args:
task_id: 任务ID
Returns:
错误回调函数
"""
async def error_callback(
error_message: str,
current_step: Optional[int] = None,
step_name: Optional[str] = None
):
await self.update_task_error(
task_id=task_id,
error_message=error_message,
current_step=current_step,
step_name=step_name
)
return error_callback
async def sync_task_status(self, task_id: int):
"""同步任务状态
Args:
task_id: 任务ID
"""
try:
task = self.db.query(Task).filter(Task.id == task_id).first()
if not task:
logger.error(f"任务 {task_id} 不存在")
return
# 检查Celery任务状态
if task.celery_task_id:
from celery.result import AsyncResult
from core.celery_app import celery_app
try:
celery_result = AsyncResult(task.celery_task_id, app=celery_app)
celery_status = celery_result.status
# 根据Celery状态更新任务状态
if celery_status == "SUCCESS" and task.status != "completed":
task.status = "completed"
task.completed_at = datetime.utcnow()
task.progress = 100
elif celery_status == "FAILURE" and task.status != "failed":
task.status = "failed"
task.error_message = str(celery_result.info) if celery_result.info else "Unknown error"
elif celery_status == "REVOKED" and task.status != "cancelled":
task.status = "cancelled"
task.error_message = "Task was cancelled"
self.db.commit()
# 发送状态同步通知
await self.websocket_service.send_task_update(
task_id=task_id,
project_id=task.project_id,
status=task.status,
progress=task.progress,
message=f"任务状态已同步: {task.status}"
)
except Exception as e:
logger.error(f"检查Celery任务状态失败: {str(e)}")
except Exception as e:
logger.error(f"同步任务状态失败: {str(e)}")
self.db.rollback()
# 全局进度管理器实例
_progress_managers: Dict[str, ProgressManager] = {}
def get_progress_manager(db: Session) -> ProgressManager:
"""获取进度管理器实例
Args:
db: 数据库会话
Returns:
进度管理器实例
"""
# 使用数据库会话的ID作为键,确保每个会话有独立的管理器
session_id = str(id(db))
if session_id not in _progress_managers:
_progress_managers[session_id] = ProgressManager(db)
return _progress_managers[session_id]
def cleanup_progress_managers():
"""清理不再使用的进度管理器"""
global _progress_managers
_progress_managers.clear()
logger.info("已清理所有进度管理器实例")
@@ -0,0 +1,703 @@
1
00:00:00,210 --> 00:00:01,932
你们都是什么区块链
2
00:00:03,200 --> 00:00:04,500
我说一句话
3
00:00:04,500 --> 00:00:07,888
比你外面你自己或你自己瞎操作十年二十年都有用
4
00:00:08,780 --> 00:00:09,548
但是你听不懂
5
00:00:10,530 --> 00:00:11,280
配三个固态
6
00:00:11,280 --> 00:00:12,320
那哥们儿那你出来
7
00:00:12,500 --> 00:00:13,508
这不是你他妈又割了
8
00:00:14,050 --> 00:00:15,818
割了说因为Z哥说要控制风险
9
00:00:15,830 --> 00:00:16,565
所以我他妈割了
10
00:00:16,680 --> 00:00:18,090
所以我说傻逼叫你炒股
11
00:00:18,090 --> 00:00:19,034
你赚钱跟我没关系
12
00:00:19,040 --> 00:00:20,480
你他妈亏钱就是他让我买的
13
00:00:20,700 --> 00:00:21,663
我他妈从来没让你买
14
00:00:21,920 --> 00:00:22,390
对吗
15
00:00:22,870 --> 00:00:23,686
配置还是配置
16
00:00:23,770 --> 00:00:26,058
你他妈一个仓配配了三个固态
17
00:00:26,060 --> 00:00:27,360
你那赖谁
18
00:00:28,610 --> 00:00:29,780
所以这钱你认知不够
19
00:00:29,780 --> 00:00:30,650
你就赚不了
20
00:00:30,970 --> 00:00:31,579
很正常
21
00:00:31,580 --> 00:00:32,300
你就赚不了
22
00:00:34,760 --> 00:00:35,390
配三个固态
23
00:00:35,390 --> 00:00:37,040
哥们儿前天还发视频总结自己成绩
24
00:00:37,160 --> 00:00:37,680
是吗
25
00:00:37,680 --> 00:00:38,728
他觉得他行了是吗
26
00:00:39,110 --> 00:00:39,990
他不骂我了
27
00:00:40,240 --> 00:00:41,640
他他他他不卖门
28
00:00:41,640 --> 00:00:43,134
他妈的诋毁我了是吗
29
00:00:44,230 --> 00:00:45,210
人性人性
30
00:00:45,350 --> 00:00:47,950
我操这人人性人性
31
00:00:48,080 --> 00:00:50,790
我操没办法
32
00:00:50,790 --> 00:00:54,320
这他妈人性是不是人性
33
00:00:54,940 --> 00:00:58,056
我周三我说我说没准你下礼拜就ZGNB了
34
00:00:58,380 --> 00:00:58,880
对吗
35
00:01:01,000 --> 00:01:03,272
所以我说这个市场永远是有经验人赚
36
00:01:03,280 --> 00:01:05,247
没经验钱的地方
37
00:01:06,390 --> 00:01:07,720
就是说什么意思
38
00:01:07,720 --> 00:01:08,746
我不是吹牛逼
39
00:01:08,750 --> 00:01:11,846
咱上礼拜三我给你们举的那五个还是四个
40
00:01:11,860 --> 00:01:13,520
四个还是五个例子来着
41
00:01:13,690 --> 00:01:17,630
我忘了就那那哥们儿那个短线到后面周四周五接着涨
42
00:01:18,430 --> 00:01:21,334
康康又登了10% 10几
43
00:01:21,570 --> 00:01:24,882
一根涨停加一个6%还是7
44
00:01:25,140 --> 00:01:25,690
是吧
45
00:01:27,390 --> 00:01:30,030
某康俩涨停
46
00:01:31,160 --> 00:01:37,348
然后那个国某矿直接把周五登进来是吧
47
00:01:37,350 --> 00:01:38,294
然后还有什么来着
48
00:01:38,530 --> 00:01:39,610
我忘了记不清了
49
00:01:40,570 --> 00:01:43,658
然后那陈他没买买进去的又一根长阳
50
00:01:43,670 --> 00:01:44,230
对吧
51
00:01:44,890 --> 00:01:45,940
这他妈就是水平
52
00:01:46,080 --> 00:01:47,438
你别你还别不服
53
00:01:47,440 --> 00:01:53,936
我操就有那那你那黑子说听周三的跟大直播没还没比大直播要无聊
54
00:01:53,950 --> 00:01:58,010
他说那所以可能他妈是可能玩鞋或者喝喝酒的
55
00:01:58,010 --> 00:01:58,370
是​‌​‌​​‌‌​​‌‌​​‌‌​‌​‌​​‌‌‌​‌‌‌‌​‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌‌‌‌​‌‌​‌‌​​‌‌‌‌‌‌​​‌‌​‌​‌‌‌​‌​​‌‌‌‌​‌​‌‌​​​‌‌‌‌‌‌​​‌‌‌‌​‌‌‌​‌​‌​‌​​‌‌‌​‌‌​​‌‌​​​‌​​‌‌​​‌‌‌‌​‌​‌​‌​​‌‌​​‌‌‌‌​‌​‌‌‌‌‌​‌​‌‌‌‌‌​‌​‌​‌​​‌‌‌‌‌​​​‌‌‌​​‌‌‌​‌‌‌‌​​​‌‌‌​‌‌​​‌‌‌​‌‌‌‌​‌​​‌‌​​‌‌‌‌‌​​​‌‌​‌‌​​​‌‌​​‌‌​​‌‌​​‌‌‌‌​‌‌‌​‌​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​‌​​‌‌​‌​‌​​‌‌​‌​‌​​‌‌‌​‌‌​​‌‌‌​‌‌‌‌​‌‌‌‌‌​​‌‌‌‌​‌​​‌‌‌​​‌‌‌​‌​‌‌‌​​‌‌‌​‌‌​​‌‌​‌​‌​​‌‌​‌‌‌‌‌​‌‌​​‌​​‌‌​​​‌​​‌‌​‌‌‌‌‌​‌​​‌‌‌‌​‌​‌‌‌​​‌‌‌‌‌​​​‌‌​‌‌‌‌‌​‌‌‌​‌​​‌‌‌‌​‌‌‌​‌‌​‌‌‌‌​‌​‌​‌‌‌​‌​​‌‌​​‌‌​‌​‌​​‌‌‌​‌‌‌‌​‌‌‌​‌​​‌‌​​‌‌‌‌​‌​‌​‌‌‌​‌‌‌​‌‌‌​‌​‌‌​​​‌‌‌​‌‌‌‌​‌‌​​‌​​‌‌​‌​‌‌‌​‌​‌​‌‌‌​‌​‌‌‌‌‌​‌‌‌‌‌​​‌‌​‌​‌​​‌‌​‌​‌‌‌​‌‌​​‌‌‌​‌‌‌​‌‌‌​‌‌‌​‌​​‌‌‌‌​‌​​‌‌‌​​‌‌‌​‌​​‌‌​​‌‌‌​​‌​​‌‌‌‌​‌​​‌‌​‌‌​​​‌‌‌‌‌‌​​‌‌​‌‌‌‌‌​‌‌‌‌​​​‌‌​​​‌​​‌‌​‌‌‌‌‌​‌‌‌​‌​​‌‌‌‌​‌‌‌​‌‌‌​‌‌‌​‌‌​‌‌​​‌‌​​‌‌​​‌‌​‌‌‌​​‌‌​​‌‌‌‌​‌‌‌​‌‌‌​‌​‌​‌‌‌​‌‌​‌‌‌‌​‌​​‌‌‌‌​‌​​​‌​​‌‌​​‌‌​​‌‌‌​‌‌​​‌‌‌‌​‌​​‌‌​​‌‌​​‌‌‌‌​‌​​‌‌‌‌‌​​​‌‌‌​‌‌‌‌​‌‌‌​‌‌‌​‌‌‌​‌‌‌​‌‌​​‌​​‌‌​‌​‌‌‌​‌​‌‌‌​​‌‌​‌‌​​​‌‌‌‌​‌‌‌​‌​‌‌‌​​‌‌‌‌‌​​​‌‌​‌​‌​​‌‌‌‌​‌​​‌‌‌​‌‌‌‌​‌‌​‌‌‌‌​‌‌​​‌‌‌​‌​‌‌‌‌‌​‌‌​‌‌‌‌​‌‌​‌‌‌‌​‌‌‌‌‌​​‌‌‌‌‌‌​​‌‌​‌‌‌​​‌‌‌​‌‌‌‌​‌‌​‌‌‌‌​‌‌​​‌​​‌‌​​‌‌​​‌‌‌​​‌​​‌‌‌​​‌​​‌‌​‌​‌‌‌​‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌Line truncated
56
00:01:58,550 --> 00:01:59,378
就是你听不懂
57
00:02:00,230 --> 00:02:00,760
所以听不懂
58
00:02:00,760 --> 00:02:01,388
认知不够
59
00:02:01,390 --> 00:02:02,086
那么就没办法
60
00:02:02,090 --> 00:02:03,962
无知者无畏才会说出这种话
61
00:02:05,360 --> 00:02:07,592
踏踏实实看两个70%到是吧
62
00:02:07,600 --> 00:02:09,283
你这他妈实打实的吗
63
00:02:09,290 --> 00:02:10,958
傻逼续费对吗
64
00:02:11,440 --> 00:02:14,116
说如果说这哥们儿包括续费
65
00:02:14,120 --> 00:02:17,054
现在周三两万多同屏
66
00:02:18,040 --> 00:02:19,370
我诚惶诚恐
67
00:02:19,660 --> 00:02:20,050
真的
68
00:02:20,050 --> 00:02:22,294
咱家当时3000到5000特别自由
69
00:02:23,810 --> 00:02:25,079
现在充电视频两万多
70
00:02:25,080 --> 00:02:25,905
我诚惶诚恐
71
00:02:25,910 --> 00:02:27,080
说实话真的
72
00:02:30,080 --> 00:02:32,852
所以我还是这块儿我想告诉大家
73
00:02:33,430 --> 00:02:33,758
什么意思
74
00:02:33,760 --> 00:02:39,280
就是说咱们一保护咱们直播间不容易
75
00:02:39,590 --> 00:02:42,837
在B站能够有这样一个非常开心的地方
76
00:02:42,850 --> 00:02:43,898
我觉得非常不容易
77
00:02:44,200 --> 00:02:49,348
另外第二就是说也请你们珍惜
78
00:02:49,360 --> 00:02:55,022
我还愿意说真的虽然那几个杂毛不超过五个
79
00:02:55,430 --> 00:02:58,269
咱家他妈每每礼拜大大直播的十几万人
80
00:02:58,870 --> 00:02:59,927
咱们不超过五个
81
00:03:01,490 --> 00:03:06,950
但是人性我经历过人性你能明白吗
82
00:03:07,510 --> 00:03:11,386
就比如说咱们上礼拜大直播
83
00:03:12,500 --> 00:03:14,580
我说反内卷
84
00:03:14,720 --> 00:03:17,814
我专门讲了一晚上反内卷对吗
85
00:03:19,620 --> 00:03:20,868
周五是不是反内卷
86
00:03:22,800 --> 00:03:25,124
然后我之前说我上周说的是反对
87
00:03:25,130 --> 00:03:25,778
眼都瞪起来了
88
00:03:25,780 --> 00:03:26,770
别着急我等回来
89
00:03:26,770 --> 00:03:27,340
等下来
90
00:03:27,720 --> 00:03:28,300
等他下来
91
00:03:28,300 --> 00:03:28,789
等下来
92
00:03:29,410 --> 00:03:30,280
周五是什么
93
00:03:30,400 --> 00:03:32,688
煤炭对吧
94
00:03:33,100 --> 00:03:33,410
稀土
95
00:03:36,600 --> 00:03:39,336
然后水利就乱七八糟
96
00:03:39,340 --> 00:03:41,836
这些你们平时就咱们看不到
97
00:03:41,840 --> 00:03:43,465
我说我跟他讲的都是机构思维
98
00:03:44,470 --> 00:03:46,630
机构思维不是你那种抱着说
99
00:03:47,060 --> 00:03:48,660
说完以后马上就涨起来
100
00:03:48,820 --> 00:03:49,288
不一定
101
00:03:50,530 --> 00:03:54,273
但是概率足够大比你下芝麻轮那些要大很多
102
00:03:55,310 --> 00:03:56,949
就是我微信打开我那些群
103
00:03:57,850 --> 00:03:59,390
都是什么类的群
104
00:03:59,670 --> 00:04:01,778
跟你打开那些他妈小姐姐小妹妹的裙子
105
00:04:01,790 --> 00:04:02,665
他妈什么不一样
106
00:04:02,670 --> 00:04:06,058
哥们踏踏实实就是很简单
107
00:04:06,060 --> 00:04:08,418
你行你上对吗
108
00:04:08,610 --> 00:04:10,338
你你你行你上
109
00:04:11,580 --> 00:04:14,375
包括下礼拜上周反内卷没涨完
110
00:04:14,670 --> 00:04:16,713
没没没休息一礼拜了
111
00:04:16,920 --> 00:04:17,508
接着轮
112
00:04:18,230 --> 00:04:19,693
就是他妈的没准接着作业
113
00:04:19,700 --> 00:04:22,534
接着我就那些还没有轮动到的
114
00:04:22,540 --> 00:04:25,580
别着急踏踏实实那踏踏实实
115
00:04:35,320 --> 00:04:38,656
然后还有我我我我想到哪儿
116
00:04:38,660 --> 00:04:39,050
我说到哪儿
117
00:04:39,170 --> 00:04:41,686
因为咱们直播主要就是一个蛋逼直播间
118
00:04:43,750 --> 00:04:48,235
有有一些朋友大概我看了有七八个
119
00:04:48,590 --> 00:04:49,910
后台给我截图
120
00:04:50,280 --> 00:04:54,620
其中还有一个还还有一些是跟的时间比较长的
121
00:04:55,260 --> 00:04:59,385
我不知道周三录播你们都看没看啊
122
00:04:59,790 --> 00:05:06,612
就是这个我后来后面专门给你们讲的案例
123
00:05:06,780 --> 00:05:09,104
我讲第一选出来
124
00:05:09,110 --> 00:05:10,250
当天选完之后
125
00:05:10,250 --> 00:05:13,488
第二天比如那天比例选了217个
126
00:05:14,050 --> 00:05:16,258
第一第二天涨停的只有一个
127
00:05:16,580 --> 00:05:18,637
相当于是2百17分之1
128
00:05:19,130 --> 00:05:22,570
但是我给你们往上翻三个交易日前的
129
00:05:23,950 --> 00:05:25,358
我怀孕了什么意思
130
00:05:26,120 --> 00:05:27,896
哥们儿你怀孕了跟我说干嘛
131
00:05:28,310 --> 00:05:29,798
他老师该该找谁去
132
00:05:31,860 --> 00:05:32,540
说哪儿了
133
00:05:32,540 --> 00:05:35,428
对你往三天前一番
134
00:05:36,000 --> 00:05:41,814
然后周三的时候一看是单3到5个涨停
135
00:05:42,190 --> 00:05:42,568
是不是
136
00:05:42,880 --> 00:05:45,270
3到5个涨停什么意思
137
00:05:45,940 --> 00:05:46,930
再强调一遍
138
00:05:48,130 --> 00:05:52,882
第一叫潜伏好不好
139
00:05:53,930 --> 00:05:57,787
B一潜伏不是说B一一进去马上坑就掉下来
140
00:05:58,530 --> 00:06:01,346
那概率就是200分之1
141
00:06:03,020 --> 00:06:04,571
还是在市场不暴跌的时候
142
00:06:05,100 --> 00:06:08,976
B一进去绝大部分时间你要等2到3天
143
00:06:10,060 --> 00:06:12,293
甚至个别板块等3到5天
144
00:06:14,130 --> 00:06:15,618
这句话能不能明白
145
00:06:16,330 --> 00:06:21,270
而B2进去才是已经确认了B一牛逼的情况下
146
00:06:21,410 --> 00:06:22,450
B一成立的情况下
147
00:06:22,450 --> 00:06:23,647
他可能会快速蹬几根
148
00:06:24,630 --> 00:06:25,894
所以先不要管B2
149
00:06:25,900 --> 00:06:27,180
先说B1
150
00:06:27,410 --> 00:06:29,060
所以说你们第一轮进去的
151
00:06:29,140 --> 00:06:30,860
不要抱着不切实际幻想
152
00:06:30,860 --> 00:06:32,948
说我今天第一轮完明天就涨
153
00:06:33,300 --> 00:06:34,972
说有没有可能有2 1%
154
00:06:34,980 --> 00:06:36,079
你就记住这概率
155
00:06:36,080 --> 00:06:37,536
市场未来好多1%
156
00:06:38,530 --> 00:06:40,194
你100个票第一
157
00:06:40,460 --> 00:06:42,847
你虾芝麻轮忙忙忙选进去
158
00:06:43,060 --> 00:06:47,146
第二天也是1%的2 1%的涨停的概率
159
00:06:47,950 --> 00:06:51,910
5%以上可能是说50分之1或者80分之1就这样了
160
00:06:53,500 --> 00:06:55,130
但是你他妈三天后再看
161
00:06:55,630 --> 00:06:57,205
或者2到3天后再看
162
00:07:00,030 --> 00:07:01,260
所以这块能不能听明白
163
00:07:02,410 --> 00:07:04,160
第一叫潜伏
164
00:07:06,070 --> 00:07:09,271
B2才是买完之后必须的
165
00:07:10,370 --> 00:07:13,695
即使B2也有大概率是那天给你们看的案例
166
00:07:13,710 --> 00:07:15,208
一个是国某矿业
167
00:07:15,650 --> 00:07:17,762
我调了三天周五才定下来
168
00:07:18,010 --> 00:07:18,540
对吗
169
00:07:18,710 --> 00:07:20,355
不破止损就拿着
170
00:07:20,360 --> 00:07:21,018
但对于很多人讲
171
00:07:21,020 --> 00:07:23,561
B2那1根启动的太长了
172
00:07:23,790 --> 00:07:24,498
拿不了
173
00:07:24,500 --> 00:07:25,178
拿不住
174
00:07:25,470 --> 00:07:26,366
B一还没整明白
175
00:07:26,500 --> 00:07:28,489
所以先踏踏实实把B一整明白
176
00:07:29,330 --> 00:07:30,930
把B一的止损整明白
@@ -0,0 +1,703 @@
1
00:00:00,210 --> 00:00:01,932
你们都是什么区块链
2
00:00:03,200 --> 00:00:04,500
我说一句话
3
00:00:04,500 --> 00:00:07,888
比你外面你自己或你自己瞎操作十年二十年都有用
4
00:00:08,780 --> 00:00:09,548
但是你听不懂
5
00:00:10,530 --> 00:00:11,280
配三个固态
6
00:00:11,280 --> 00:00:12,320
那哥们儿那你出来
7
00:00:12,500 --> 00:00:13,508
这不是你他妈又割了
8
00:00:14,050 --> 00:00:15,818
割了说因为Z哥说要控制风险
9
00:00:15,830 --> 00:00:16,565
所以我他妈割了
10
00:00:16,680 --> 00:00:18,090
所以我说傻逼叫你炒股
11
00:00:18,090 --> 00:00:19,034
你赚钱跟我没关系
12
00:00:19,040 --> 00:00:20,480
你他妈亏钱就是他让我买的
13
00:00:20,700 --> 00:00:21,663
我他妈从来没让你买
14
00:00:21,920 --> 00:00:22,390
对吗
15
00:00:22,870 --> 00:00:23,686
配置还是配置
16
00:00:23,770 --> 00:00:26,058
你他妈一个仓配配了三个固态
17
00:00:26,060 --> 00:00:27,360
你那赖谁
18
00:00:28,610 --> 00:00:29,780
所以这钱你认知不够
19
00:00:29,780 --> 00:00:30,650
你就赚不了
20
00:00:30,970 --> 00:00:31,579
很正常
21
00:00:31,580 --> 00:00:32,300
你就赚不了
22
00:00:34,760 --> 00:00:35,390
配三个固态
23
00:00:35,390 --> 00:00:37,040
哥们儿前天还发视频总结自己成绩
24
00:00:37,160 --> 00:00:37,680
是吗
25
00:00:37,680 --> 00:00:38,728
他觉得他行了是吗
26
00:00:39,110 --> 00:00:39,990
他不骂我了
27
00:00:40,240 --> 00:00:41,640
他他他他不卖门
28
00:00:41,640 --> 00:00:43,134
他妈的诋毁我了是吗
29
00:00:44,230 --> 00:00:45,210
人性人性
30
00:00:45,350 --> 00:00:47,950
我操这人人性人性
31
00:00:48,080 --> 00:00:50,790
我操没办法
32
00:00:50,790 --> 00:00:54,320
这他妈人性是不是人性
33
00:00:54,940 --> 00:00:58,056
我周三我说我说没准你下礼拜就ZGNB了
34
00:00:58,380 --> 00:00:58,880
对吗
35
00:01:01,000 --> 00:01:03,272
所以我说这个市场永远是有经验人赚
36
00:01:03,280 --> 00:01:05,247
没经验钱的地方
37
00:01:06,390 --> 00:01:07,720
就是说什么意思
38
00:01:07,720 --> 00:01:08,746
我不是吹牛逼
39
00:01:08,750 --> 00:01:11,846
咱上礼拜三我给你们举的那五个还是四个
40
00:01:11,860 --> 00:01:13,520
四个还是五个例子来着
41
00:01:13,690 --> 00:01:17,630
我忘了就那那哥们儿那个短线到后面周四周五接着涨
42
00:01:18,430 --> 00:01:21,334
康康又登了10% 10几
43
00:01:21,570 --> 00:01:24,882
一根涨停加一个6%还是7
44
00:01:25,140 --> 00:01:25,690
是吧
45
00:01:27,390 --> 00:01:30,030
某康俩涨停
46
00:01:31,160 --> 00:01:37,348
然后那个国某矿直接把周五登进来是吧
47
00:01:37,350 --> 00:01:38,294
然后还有什么来着
48
00:01:38,530 --> 00:01:39,610
我忘了记不清了
49
00:01:40,570 --> 00:01:43,658
然后那陈他没买买进去的又一根长阳
50
00:01:43,670 --> 00:01:44,230
对吧
51
00:01:44,890 --> 00:01:45,940
这他妈就是水平
52
00:01:46,080 --> 00:01:47,438
你别你还别不服
53
00:01:47,440 --> 00:01:53,936
我操就有那那你那黑子说听周三的跟大直播没还没比大直播要无聊
54
00:01:53,950 --> 00:01:58,010
他说那所以可能他妈是可能玩鞋或者喝喝酒的
55
00:01:58,010 --> 00:01:58,370
是​‌​‌​​‌‌​​‌‌​​‌‌​‌​‌​​‌‌‌​‌‌‌‌​‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌‌‌‌​‌‌​‌‌​​‌‌‌‌‌‌​​‌‌​‌​‌‌‌​‌​​‌‌‌‌​‌​‌‌​​​‌‌‌‌‌‌​​‌‌‌‌​‌‌‌​‌​‌​‌​​‌‌‌​‌‌​​‌‌​​​‌​​‌‌​​‌‌‌‌​‌​‌​‌​​‌‌​​‌‌‌‌​‌​‌‌‌‌‌​‌​‌‌‌‌‌​‌​‌​‌​​‌‌‌‌‌​​​‌‌‌​​‌‌‌​‌‌‌‌​​​‌‌‌​‌‌​​‌‌‌​‌‌‌‌​‌​​‌‌​​‌‌‌‌‌​​​‌‌​‌‌​​​‌‌​​‌‌​​‌‌​​‌‌‌‌​‌‌‌​‌​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​‌​​‌‌​‌​‌​​‌‌​‌​‌​​‌‌‌​‌‌​​‌‌‌​‌‌‌‌​‌‌‌‌‌​​‌‌‌‌​‌​​‌‌‌​​‌‌‌​‌​‌‌‌​​‌‌‌​‌‌​​‌‌​‌​‌​​‌‌​‌‌‌‌‌​‌‌​​‌​​‌‌​​​‌​​‌‌​‌‌‌‌‌​‌​​‌‌‌‌​‌​‌‌‌​​‌‌‌‌‌​​​‌‌​‌‌‌‌‌​‌‌‌​‌​​‌‌‌‌​‌‌‌​‌‌​‌‌‌‌​‌​‌​‌‌‌​‌​​‌‌​​‌‌​‌​‌​​‌‌‌​‌‌‌‌​‌‌‌​‌​​‌‌​​‌‌‌‌​‌​‌​‌‌‌​‌‌‌​‌‌‌​‌​‌‌​​​‌‌‌​‌‌‌‌​‌‌​​‌​​‌‌​‌​‌‌‌​‌​‌​‌‌‌​‌​‌‌‌‌‌​‌‌‌‌‌​​‌‌​‌​‌​​‌‌​‌​‌‌‌​‌‌​​‌‌‌​‌‌‌​‌‌‌​‌‌‌​‌​​‌‌‌‌​‌​​‌‌‌​​‌‌‌​‌​​‌‌​​‌‌‌​​‌​​‌‌‌‌​‌​​‌‌​‌‌​​​‌‌‌‌‌‌​​‌‌​‌‌‌‌‌​‌‌‌‌​​​‌‌​​​‌​​‌‌​‌‌‌‌‌​‌‌‌​‌​​‌‌‌‌​‌‌‌​‌‌‌​‌‌‌​‌‌​‌‌​​‌‌​​‌‌​​‌‌​‌‌‌​​‌‌​​‌‌‌‌​‌‌‌​‌‌‌​‌​‌​‌‌‌​‌‌​‌‌‌‌​‌​​‌‌‌‌​‌​​​‌​​‌‌​​‌‌​​‌‌‌​‌‌​​‌‌‌‌​‌​​‌‌​​‌‌​​‌‌‌‌​‌​​‌‌‌‌‌​​​‌‌‌​‌‌‌‌​‌‌‌​‌‌‌​‌‌‌​‌‌‌​‌‌​​‌​​‌‌​‌​‌‌‌​‌​‌‌‌​​‌‌​‌‌​​​‌‌‌‌​‌‌‌​‌​‌‌‌​​‌‌‌‌‌​​​‌‌​‌​‌​​‌‌‌‌​‌​​‌‌‌​‌‌‌‌​‌‌​‌‌‌‌​‌‌​​‌‌‌​‌​‌‌‌‌‌​‌‌​‌‌‌‌​‌‌​‌‌‌‌​‌‌‌‌‌​​‌‌‌‌‌‌​​‌‌​‌‌‌​​‌‌‌​‌‌‌‌​‌‌​‌‌‌‌​‌‌​​‌​​‌‌​​‌‌​​‌‌‌​​‌​​‌‌‌​​‌​​‌‌​‌​‌‌‌​‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌Line truncated
56
00:01:58,550 --> 00:01:59,378
就是你听不懂
57
00:02:00,230 --> 00:02:00,760
所以听不懂
58
00:02:00,760 --> 00:02:01,388
认知不够
59
00:02:01,390 --> 00:02:02,086
那么就没办法
60
00:02:02,090 --> 00:02:03,962
无知者无畏才会说出这种话
61
00:02:05,360 --> 00:02:07,592
踏踏实实看两个70%到是吧
62
00:02:07,600 --> 00:02:09,283
你这他妈实打实的吗
63
00:02:09,290 --> 00:02:10,958
傻逼续费对吗
64
00:02:11,440 --> 00:02:14,116
说如果说这哥们儿包括续费
65
00:02:14,120 --> 00:02:17,054
现在周三两万多同屏
66
00:02:18,040 --> 00:02:19,370
我诚惶诚恐
67
00:02:19,660 --> 00:02:20,050
真的
68
00:02:20,050 --> 00:02:22,294
咱家当时3000到5000特别自由
69
00:02:23,810 --> 00:02:25,079
现在充电视频两万多
70
00:02:25,080 --> 00:02:25,905
我诚惶诚恐
71
00:02:25,910 --> 00:02:27,080
说实话真的
72
00:02:30,080 --> 00:02:32,852
所以我还是这块儿我想告诉大家
73
00:02:33,430 --> 00:02:33,758
什么意思
74
00:02:33,760 --> 00:02:39,280
就是说咱们一保护咱们直播间不容易
75
00:02:39,590 --> 00:02:42,837
在B站能够有这样一个非常开心的地方
76
00:02:42,850 --> 00:02:43,898
我觉得非常不容易
77
00:02:44,200 --> 00:02:49,348
另外第二就是说也请你们珍惜
78
00:02:49,360 --> 00:02:55,022
我还愿意说真的虽然那几个杂毛不超过五个
79
00:02:55,430 --> 00:02:58,269
咱家他妈每每礼拜大大直播的十几万人
80
00:02:58,870 --> 00:02:59,927
咱们不超过五个
81
00:03:01,490 --> 00:03:06,950
但是人性我经历过人性你能明白吗
82
00:03:07,510 --> 00:03:11,386
就比如说咱们上礼拜大直播
83
00:03:12,500 --> 00:03:14,580
我说反内卷
84
00:03:14,720 --> 00:03:17,814
我专门讲了一晚上反内卷对吗
85
00:03:19,620 --> 00:03:20,868
周五是不是反内卷
86
00:03:22,800 --> 00:03:25,124
然后我之前说我上周说的是反对
87
00:03:25,130 --> 00:03:25,778
眼都瞪起来了
88
00:03:25,780 --> 00:03:26,770
别着急我等回来
89
00:03:26,770 --> 00:03:27,340
等下来
90
00:03:27,720 --> 00:03:28,300
等他下来
91
00:03:28,300 --> 00:03:28,789
等下来
92
00:03:29,410 --> 00:03:30,280
周五是什么
93
00:03:30,400 --> 00:03:32,688
煤炭对吧
94
00:03:33,100 --> 00:03:33,410
稀土
95
00:03:36,600 --> 00:03:39,336
然后水利就乱七八糟
96
00:03:39,340 --> 00:03:41,836
这些你们平时就咱们看不到
97
00:03:41,840 --> 00:03:43,465
我说我跟他讲的都是机构思维
98
00:03:44,470 --> 00:03:46,630
机构思维不是你那种抱着说
99
00:03:47,060 --> 00:03:48,660
说完以后马上就涨起来
100
00:03:48,820 --> 00:03:49,288
不一定
101
00:03:50,530 --> 00:03:54,273
但是概率足够大比你下芝麻轮那些要大很多
102
00:03:55,310 --> 00:03:56,949
就是我微信打开我那些群
103
00:03:57,850 --> 00:03:59,390
都是什么类的群
104
00:03:59,670 --> 00:04:01,778
跟你打开那些他妈小姐姐小妹妹的裙子
105
00:04:01,790 --> 00:04:02,665
他妈什么不一样
106
00:04:02,670 --> 00:04:06,058
哥们踏踏实实就是很简单
107
00:04:06,060 --> 00:04:08,418
你行你上对吗
108
00:04:08,610 --> 00:04:10,338
你你你行你上
109
00:04:11,580 --> 00:04:14,375
包括下礼拜上周反内卷没涨完
110
00:04:14,670 --> 00:04:16,713
没没没休息一礼拜了
111
00:04:16,920 --> 00:04:17,508
接着轮
112
00:04:18,230 --> 00:04:19,693
就是他妈的没准接着作业
113
00:04:19,700 --> 00:04:22,534
接着我就那些还没有轮动到的
114
00:04:22,540 --> 00:04:25,580
别着急踏踏实实那踏踏实实
115
00:04:35,320 --> 00:04:38,656
然后还有我我我我想到哪儿
116
00:04:38,660 --> 00:04:39,050
我说到哪儿
117
00:04:39,170 --> 00:04:41,686
因为咱们直播主要就是一个蛋逼直播间
118
00:04:43,750 --> 00:04:48,235
有有一些朋友大概我看了有七八个
119
00:04:48,590 --> 00:04:49,910
后台给我截图
120
00:04:50,280 --> 00:04:54,620
其中还有一个还还有一些是跟的时间比较长的
121
00:04:55,260 --> 00:04:59,385
我不知道周三录播你们都看没看啊
122
00:04:59,790 --> 00:05:06,612
就是这个我后来后面专门给你们讲的案例
123
00:05:06,780 --> 00:05:09,104
我讲第一选出来
124
00:05:09,110 --> 00:05:10,250
当天选完之后
125
00:05:10,250 --> 00:05:13,488
第二天比如那天比例选了217个
126
00:05:14,050 --> 00:05:16,258
第一第二天涨停的只有一个
127
00:05:16,580 --> 00:05:18,637
相当于是2百17分之1
128
00:05:19,130 --> 00:05:22,570
但是我给你们往上翻三个交易日前的
129
00:05:23,950 --> 00:05:25,358
我怀孕了什么意思
130
00:05:26,120 --> 00:05:27,896
哥们儿你怀孕了跟我说干嘛
131
00:05:28,310 --> 00:05:29,798
他老师该该找谁去
132
00:05:31,860 --> 00:05:32,540
说哪儿了
133
00:05:32,540 --> 00:05:35,428
对你往三天前一番
134
00:05:36,000 --> 00:05:41,814
然后周三的时候一看是单3到5个涨停
135
00:05:42,190 --> 00:05:42,568
是不是
136
00:05:42,880 --> 00:05:45,270
3到5个涨停什么意思
137
00:05:45,940 --> 00:05:46,930
再强调一遍
138
00:05:48,130 --> 00:05:52,882
第一叫潜伏好不好
139
00:05:53,930 --> 00:05:57,787
B一潜伏不是说B一一进去马上坑就掉下来
140
00:05:58,530 --> 00:06:01,346
那概率就是200分之1
141
00:06:03,020 --> 00:06:04,571
还是在市场不暴跌的时候
142
00:06:05,100 --> 00:06:08,976
B一进去绝大部分时间你要等2到3天
143
00:06:10,060 --> 00:06:12,293
甚至个别板块等3到5天
144
00:06:14,130 --> 00:06:15,618
这句话能不能明白
145
00:06:16,330 --> 00:06:21,270
而B2进去才是已经确认了B一牛逼的情况下
146
00:06:21,410 --> 00:06:22,450
B一成立的情况下
147
00:06:22,450 --> 00:06:23,647
他可能会快速蹬几根
148
00:06:24,630 --> 00:06:25,894
所以先不要管B2
149
00:06:25,900 --> 00:06:27,180
先说B1
150
00:06:27,410 --> 00:06:29,060
所以说你们第一轮进去的
151
00:06:29,140 --> 00:06:30,860
不要抱着不切实际幻想
152
00:06:30,860 --> 00:06:32,948
说我今天第一轮完明天就涨
153
00:06:33,300 --> 00:06:34,972
说有没有可能有2 1%
154
00:06:34,980 --> 00:06:36,079
你就记住这概率
155
00:06:36,080 --> 00:06:37,536
市场未来好多1%
156
00:06:38,530 --> 00:06:40,194
你100个票第一
157
00:06:40,460 --> 00:06:42,847
你虾芝麻轮忙忙忙选进去
158
00:06:43,060 --> 00:06:47,146
第二天也是1%的2 1%的涨停的概率
159
00:06:47,950 --> 00:06:51,910
5%以上可能是说50分之1或者80分之1就这样了
160
00:06:53,500 --> 00:06:55,130
但是你他妈三天后再看
161
00:06:55,630 --> 00:06:57,205
或者2到3天后再看
162
00:07:00,030 --> 00:07:01,260
所以这块能不能听明白
163
00:07:02,410 --> 00:07:04,160
第一叫潜伏
164
00:07:06,070 --> 00:07:09,271
B2才是买完之后必须的
165
00:07:10,370 --> 00:07:13,695
即使B2也有大概率是那天给你们看的案例
166
00:07:13,710 --> 00:07:15,208
一个是国某矿业
167
00:07:15,650 --> 00:07:17,762
我调了三天周五才定下来
168
00:07:18,010 --> 00:07:18,540
对吗
169
00:07:18,710 --> 00:07:20,355
不破止损就拿着
170
00:07:20,360 --> 00:07:21,018
但对于很多人讲
171
00:07:21,020 --> 00:07:23,561
B2那1根启动的太长了
172
00:07:23,790 --> 00:07:24,498
拿不了
173
00:07:24,500 --> 00:07:25,178
拿不住
174
00:07:25,470 --> 00:07:26,366
B一还没整明白
175
00:07:26,500 --> 00:07:28,489
所以先踏踏实实把B一整明白
176
00:07:29,330 --> 00:07:30,930
把B一的止损整明白
@@ -0,0 +1,703 @@
1
00:00:00,210 --> 00:00:01,932
你们都是什么区块链
2
00:00:03,200 --> 00:00:04,500
我说一句话
3
00:00:04,500 --> 00:00:07,888
比你外面你自己或你自己瞎操作十年二十年都有用
4
00:00:08,780 --> 00:00:09,548
但是你听不懂
5
00:00:10,530 --> 00:00:11,280
配三个固态
6
00:00:11,280 --> 00:00:12,320
那哥们儿那你出来
7
00:00:12,500 --> 00:00:13,508
这不是你他妈又割了
8
00:00:14,050 --> 00:00:15,818
割了说因为Z哥说要控制风险
9
00:00:15,830 --> 00:00:16,565
所以我他妈割了
10
00:00:16,680 --> 00:00:18,090
所以我说傻逼叫你炒股
11
00:00:18,090 --> 00:00:19,034
你赚钱跟我没关系
12
00:00:19,040 --> 00:00:20,480
你他妈亏钱就是他让我买的
13
00:00:20,700 --> 00:00:21,663
我他妈从来没让你买
14
00:00:21,920 --> 00:00:22,390
对吗
15
00:00:22,870 --> 00:00:23,686
配置还是配置
16
00:00:23,770 --> 00:00:26,058
你他妈一个仓配配了三个固态
17
00:00:26,060 --> 00:00:27,360
你那赖谁
18
00:00:28,610 --> 00:00:29,780
所以这钱你认知不够
19
00:00:29,780 --> 00:00:30,650
你就赚不了
20
00:00:30,970 --> 00:00:31,579
很正常
21
00:00:31,580 --> 00:00:32,300
你就赚不了
22
00:00:34,760 --> 00:00:35,390
配三个固态
23
00:00:35,390 --> 00:00:37,040
哥们儿前天还发视频总结自己成绩
24
00:00:37,160 --> 00:00:37,680
是吗
25
00:00:37,680 --> 00:00:38,728
他觉得他行了是吗
26
00:00:39,110 --> 00:00:39,990
他不骂我了
27
00:00:40,240 --> 00:00:41,640
他他他他不卖门
28
00:00:41,640 --> 00:00:43,134
他妈的诋毁我了是吗
29
00:00:44,230 --> 00:00:45,210
人性人性
30
00:00:45,350 --> 00:00:47,950
我操这人人性人性
31
00:00:48,080 --> 00:00:50,790
我操没办法
32
00:00:50,790 --> 00:00:54,320
这他妈人性是不是人性
33
00:00:54,940 --> 00:00:58,056
我周三我说我说没准你下礼拜就ZGNB了
34
00:00:58,380 --> 00:00:58,880
对吗
35
00:01:01,000 --> 00:01:03,272
所以我说这个市场永远是有经验人赚
36
00:01:03,280 --> 00:01:05,247
没经验钱的地方
37
00:01:06,390 --> 00:01:07,720
就是说什么意思
38
00:01:07,720 --> 00:01:08,746
我不是吹牛逼
39
00:01:08,750 --> 00:01:11,846
咱上礼拜三我给你们举的那五个还是四个
40
00:01:11,860 --> 00:01:13,520
四个还是五个例子来着
41
00:01:13,690 --> 00:01:17,630
我忘了就那那哥们儿那个短线到后面周四周五接着涨
42
00:01:18,430 --> 00:01:21,334
康康又登了10% 10几
43
00:01:21,570 --> 00:01:24,882
一根涨停加一个6%还是7
44
00:01:25,140 --> 00:01:25,690
是吧
45
00:01:27,390 --> 00:01:30,030
某康俩涨停
46
00:01:31,160 --> 00:01:37,348
然后那个国某矿直接把周五登进来是吧
47
00:01:37,350 --> 00:01:38,294
然后还有什么来着
48
00:01:38,530 --> 00:01:39,610
我忘了记不清了
49
00:01:40,570 --> 00:01:43,658
然后那陈他没买买进去的又一根长阳
50
00:01:43,670 --> 00:01:44,230
对吧
51
00:01:44,890 --> 00:01:45,940
这他妈就是水平
52
00:01:46,080 --> 00:01:47,438
你别你还别不服
53
00:01:47,440 --> 00:01:53,936
我操就有那那你那黑子说听周三的跟大直播没还没比大直播要无聊
54
00:01:53,950 --> 00:01:58,010
他说那所以可能他妈是可能玩鞋或者喝喝酒的
55
00:01:58,010 --> 00:01:58,370
是​‌​‌​​‌‌​​‌‌​​‌‌​‌​‌​​‌‌‌​‌‌‌‌​‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌‌‌‌​‌‌​‌‌​​‌‌‌‌‌‌​​‌‌​‌​‌‌‌​‌​​‌‌‌‌​‌​‌‌​​​‌‌‌‌‌‌​​‌‌‌‌​‌‌‌​‌​‌​‌​​‌‌‌​‌‌​​‌‌​​​‌​​‌‌​​‌‌‌‌​‌​‌​‌​​‌‌​​‌‌‌‌​‌​‌‌‌‌‌​‌​‌‌‌‌‌​‌​‌​‌​​‌‌‌‌‌​​​‌‌‌​​‌‌‌​‌‌‌‌​​​‌‌‌​‌‌​​‌‌‌​‌‌‌‌​‌​​‌‌​​‌‌‌‌‌​​​‌‌​‌‌​​​‌‌​​‌‌​​‌‌​​‌‌‌‌​‌‌‌​‌​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​‌​​‌‌​‌​‌​​‌‌​‌​‌​​‌‌‌​‌‌​​‌‌‌​‌‌‌‌​‌‌‌‌‌​​‌‌‌‌​‌​​‌‌‌​​‌‌‌​‌​‌‌‌​​‌‌‌​‌‌​​‌‌​‌​‌​​‌‌​‌‌‌‌‌​‌‌​​‌​​‌‌​​​‌​​‌‌​‌‌‌‌‌​‌​​‌‌‌‌​‌​‌‌‌​​‌‌‌‌‌​​​‌‌​‌‌‌‌‌​‌‌‌​‌​​‌‌‌‌​‌‌‌​‌‌​‌‌‌‌​‌​‌​‌‌‌​‌​​‌‌​​‌‌​‌​‌​​‌‌‌​‌‌‌‌​‌‌‌​‌​​‌‌​​‌‌‌‌​‌​‌​‌‌‌​‌‌‌​‌‌‌​‌​‌‌​​​‌‌‌​‌‌‌‌​‌‌​​‌​​‌‌​‌​‌‌‌​‌​‌​‌‌‌​‌​‌‌‌‌‌​‌‌‌‌‌​​‌‌​‌​‌​​‌‌​‌​‌‌‌​‌‌​​‌‌‌​‌‌‌​‌‌‌​‌‌‌​‌​​‌‌‌‌​‌​​‌‌‌​​‌‌‌​‌​​‌‌​​‌‌‌​​‌​​‌‌‌‌​‌​​‌‌​‌‌​​​‌‌‌‌‌‌​​‌‌​‌‌‌‌‌​‌‌‌‌​​​‌‌​​​‌​​‌‌​‌‌‌‌‌​‌‌‌​‌​​‌‌‌‌​‌‌‌​‌‌‌​‌‌‌​‌‌​‌‌​​‌‌​​‌‌​​‌‌​‌‌‌​​‌‌​​‌‌‌‌​‌‌‌​‌‌‌​‌​‌​‌‌‌​‌‌​‌‌‌‌​‌​​‌‌‌‌​‌​​​‌​​‌‌​​‌‌​​‌‌‌​‌‌​​‌‌‌‌​‌​​‌‌​​‌‌​​‌‌‌‌​‌​​‌‌‌‌‌​​​‌‌‌​‌‌‌‌​‌‌‌​‌‌‌​‌‌‌​‌‌‌​‌‌​​‌​​‌‌​‌​‌‌‌​‌​‌‌‌​​‌‌​‌‌​​​‌‌‌‌​‌‌‌​‌​‌‌‌​​‌‌‌‌‌​​​‌‌​‌​‌​​‌‌‌‌​‌​​‌‌‌​‌‌‌‌​‌‌​‌‌‌‌​‌‌​​‌‌‌​‌​‌‌‌‌‌​‌‌​‌‌‌‌​‌‌​‌‌‌‌​‌‌‌‌‌​​‌‌‌‌‌‌​​‌‌​‌‌‌​​‌‌‌​‌‌‌‌​‌‌​‌‌‌‌​‌‌​​‌​​‌‌​​‌‌​​‌‌‌​​‌​​‌‌‌​​‌​​‌‌​‌​‌‌‌​‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌‌Line truncated
56
00:01:58,550 --> 00:01:59,378
就是你听不懂
57
00:02:00,230 --> 00:02:00,760
所以听不懂
58
00:02:00,760 --> 00:02:01,388
认知不够
59
00:02:01,390 --> 00:02:02,086
那么就没办法
60
00:02:02,090 --> 00:02:03,962
无知者无畏才会说出这种话
61
00:02:05,360 --> 00:02:07,592
踏踏实实看两个70%到是吧
62
00:02:07,600 --> 00:02:09,283
你这他妈实打实的吗
63
00:02:09,290 --> 00:02:10,958
傻逼续费对吗
64
00:02:11,440 --> 00:02:14,116
说如果说这哥们儿包括续费
65
00:02:14,120 --> 00:02:17,054
现在周三两万多同屏
66
00:02:18,040 --> 00:02:19,370
我诚惶诚恐
67
00:02:19,660 --> 00:02:20,050
真的
68
00:02:20,050 --> 00:02:22,294
咱家当时3000到5000特别自由
69
00:02:23,810 --> 00:02:25,079
现在充电视频两万多
70
00:02:25,080 --> 00:02:25,905
我诚惶诚恐
71
00:02:25,910 --> 00:02:27,080
说实话真的
72
00:02:30,080 --> 00:02:32,852
所以我还是这块儿我想告诉大家
73
00:02:33,430 --> 00:02:33,758
什么意思
74
00:02:33,760 --> 00:02:39,280
就是说咱们一保护咱们直播间不容易
75
00:02:39,590 --> 00:02:42,837
在B站能够有这样一个非常开心的地方
76
00:02:42,850 --> 00:02:43,898
我觉得非常不容易
77
00:02:44,200 --> 00:02:49,348
另外第二就是说也请你们珍惜
78
00:02:49,360 --> 00:02:55,022
我还愿意说真的虽然那几个杂毛不超过五个
79
00:02:55,430 --> 00:02:58,269
咱家他妈每每礼拜大大直播的十几万人
80
00:02:58,870 --> 00:02:59,927
咱们不超过五个
81
00:03:01,490 --> 00:03:06,950
但是人性我经历过人性你能明白吗
82
00:03:07,510 --> 00:03:11,386
就比如说咱们上礼拜大直播
83
00:03:12,500 --> 00:03:14,580
我说反内卷
84
00:03:14,720 --> 00:03:17,814
我专门讲了一晚上反内卷对吗
85
00:03:19,620 --> 00:03:20,868
周五是不是反内卷
86
00:03:22,800 --> 00:03:25,124
然后我之前说我上周说的是反对
87
00:03:25,130 --> 00:03:25,778
眼都瞪起来了
88
00:03:25,780 --> 00:03:26,770
别着急我等回来
89
00:03:26,770 --> 00:03:27,340
等下来
90
00:03:27,720 --> 00:03:28,300
等他下来
91
00:03:28,300 --> 00:03:28,789
等下来
92
00:03:29,410 --> 00:03:30,280
周五是什么
93
00:03:30,400 --> 00:03:32,688
煤炭对吧
94
00:03:33,100 --> 00:03:33,410
稀土
95
00:03:36,600 --> 00:03:39,336
然后水利就乱七八糟
96
00:03:39,340 --> 00:03:41,836
这些你们平时就咱们看不到
97
00:03:41,840 --> 00:03:43,465
我说我跟他讲的都是机构思维
98
00:03:44,470 --> 00:03:46,630
机构思维不是你那种抱着说
99
00:03:47,060 --> 00:03:48,660
说完以后马上就涨起来
100
00:03:48,820 --> 00:03:49,288
不一定
101
00:03:50,530 --> 00:03:54,273
但是概率足够大比你下芝麻轮那些要大很多
102
00:03:55,310 --> 00:03:56,949
就是我微信打开我那些群
103
00:03:57,850 --> 00:03:59,390
都是什么类的群
104
00:03:59,670 --> 00:04:01,778
跟你打开那些他妈小姐姐小妹妹的裙子
105
00:04:01,790 --> 00:04:02,665
他妈什么不一样
106
00:04:02,670 --> 00:04:06,058
哥们踏踏实实就是很简单
107
00:04:06,060 --> 00:04:08,418
你行你上对吗
108
00:04:08,610 --> 00:04:10,338
你你你行你上
109
00:04:11,580 --> 00:04:14,375
包括下礼拜上周反内卷没涨完
110
00:04:14,670 --> 00:04:16,713
没没没休息一礼拜了
111
00:04:16,920 --> 00:04:17,508
接着轮
112
00:04:18,230 --> 00:04:19,693
就是他妈的没准接着作业
113
00:04:19,700 --> 00:04:22,534
接着我就那些还没有轮动到的
114
00:04:22,540 --> 00:04:25,580
别着急踏踏实实那踏踏实实
115
00:04:35,320 --> 00:04:38,656
然后还有我我我我想到哪儿
116
00:04:38,660 --> 00:04:39,050
我说到哪儿
117
00:04:39,170 --> 00:04:41,686
因为咱们直播主要就是一个蛋逼直播间
118
00:04:43,750 --> 00:04:48,235
有有一些朋友大概我看了有七八个
119
00:04:48,590 --> 00:04:49,910
后台给我截图
120
00:04:50,280 --> 00:04:54,620
其中还有一个还还有一些是跟的时间比较长的
121
00:04:55,260 --> 00:04:59,385
我不知道周三录播你们都看没看啊
122
00:04:59,790 --> 00:05:06,612
就是这个我后来后面专门给你们讲的案例
123
00:05:06,780 --> 00:05:09,104
我讲第一选出来
124
00:05:09,110 --> 00:05:10,250
当天选完之后
125
00:05:10,250 --> 00:05:13,488
第二天比如那天比例选了217个
126
00:05:14,050 --> 00:05:16,258
第一第二天涨停的只有一个
127
00:05:16,580 --> 00:05:18,637
相当于是2百17分之1
128
00:05:19,130 --> 00:05:22,570
但是我给你们往上翻三个交易日前的
129
00:05:23,950 --> 00:05:25,358
我怀孕了什么意思
130
00:05:26,120 --> 00:05:27,896
哥们儿你怀孕了跟我说干嘛
131
00:05:28,310 --> 00:05:29,798
他老师该该找谁去
132
00:05:31,860 --> 00:05:32,540
说哪儿了
133
00:05:32,540 --> 00:05:35,428
对你往三天前一番
134
00:05:36,000 --> 00:05:41,814
然后周三的时候一看是单3到5个涨停
135
00:05:42,190 --> 00:05:42,568
是不是
136
00:05:42,880 --> 00:05:45,270
3到5个涨停什么意思
137
00:05:45,940 --> 00:05:46,930
再强调一遍
138
00:05:48,130 --> 00:05:52,882
第一叫潜伏好不好
139
00:05:53,930 --> 00:05:57,787
B一潜伏不是说B一一进去马上坑就掉下来
140
00:05:58,530 --> 00:06:01,346
那概率就是200分之1
141
00:06:03,020 --> 00:06:04,571
还是在市场不暴跌的时候
142
00:06:05,100 --> 00:06:08,976
B一进去绝大部分时间你要等2到3天
143
00:06:10,060 --> 00:06:12,293
甚至个别板块等3到5天
144
00:06:14,130 --> 00:06:15,618
这句话能不能明白
145
00:06:16,330 --> 00:06:21,270
而B2进去才是已经确认了B一牛逼的情况下
146
00:06:21,410 --> 00:06:22,450
B一成立的情况下
147
00:06:22,450 --> 00:06:23,647
他可能会快速蹬几根
148
00:06:24,630 --> 00:06:25,894
所以先不要管B2
149
00:06:25,900 --> 00:06:27,180
先说B1
150
00:06:27,410 --> 00:06:29,060
所以说你们第一轮进去的
151
00:06:29,140 --> 00:06:30,860
不要抱着不切实际幻想
152
00:06:30,860 --> 00:06:32,948
说我今天第一轮完明天就涨
153
00:06:33,300 --> 00:06:34,972
说有没有可能有2 1%
154
00:06:34,980 --> 00:06:36,079
你就记住这概率
155
00:06:36,080 --> 00:06:37,536
市场未来好多1%
156
00:06:38,530 --> 00:06:40,194
你100个票第一
157
00:06:40,460 --> 00:06:42,847
你虾芝麻轮忙忙忙选进去
158
00:06:43,060 --> 00:06:47,146
第二天也是1%的2 1%的涨停的概率
159
00:06:47,950 --> 00:06:51,910
5%以上可能是说50分之1或者80分之1就这样了
160
00:06:53,500 --> 00:06:55,130
但是你他妈三天后再看
161
00:06:55,630 --> 00:06:57,205
或者2到3天后再看
162
00:07:00,030 --> 00:07:01,260
所以这块能不能听明白
163
00:07:02,410 --> 00:07:04,160
第一叫潜伏
164
00:07:06,070 --> 00:07:09,271
B2才是买完之后必须的
165
00:07:10,370 --> 00:07:13,695
即使B2也有大概率是那天给你们看的案例
166
00:07:13,710 --> 00:07:15,208
一个是国某矿业
167
00:07:15,650 --> 00:07:17,762
我调了三天周五才定下来
168
00:07:18,010 --> 00:07:18,540
对吗
169
00:07:18,710 --> 00:07:20,355
不破止损就拿着
170
00:07:20,360 --> 00:07:21,018
但对于很多人讲
171
00:07:21,020 --> 00:07:23,561
B2那1根启动的太长了
172
00:07:23,790 --> 00:07:24,498
拿不了
173
00:07:24,500 --> 00:07:25,178
拿不住
174
00:07:25,470 --> 00:07:26,366
B一还没整明白
175
00:07:26,500 --> 00:07:28,489
所以先踏踏实实把B一整明白
176
00:07:29,330 --> 00:07:30,930
把B一的止损整明白
+1 -1
View File
@@ -6,7 +6,7 @@
from typing import TypeVar, Generic, Optional, List, Dict, Any, Type
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from backend.models.base import BaseModel
from models.base import BaseModel
# 定义泛型类型
ModelType = TypeVar("ModelType", bound=BaseModel)
+1 -1
View File
@@ -7,7 +7,7 @@ from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import desc, asc, func
from .base import BaseRepository
from backend.models.clip import Clip, ClipStatus
from models.clip import Clip, ClipStatus
class ClipRepository(BaseRepository[Clip]):
"""切片Repository类"""
@@ -7,7 +7,7 @@ from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import desc, asc, func
from .base import BaseRepository
from backend.models.collection import Collection, CollectionStatus
from models.collection import Collection, CollectionStatus
class CollectionRepository(BaseRepository[Collection]):
"""合集Repository类"""
+1 -1
View File
@@ -7,7 +7,7 @@ from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import desc, asc
from .base import BaseRepository
from backend.models.project import Project, ProjectStatus, ProjectType
from models.project import Project, ProjectStatus, ProjectType
class ProjectRepository(BaseRepository[Project]):
"""项目Repository类"""
+1 -1
View File
@@ -7,7 +7,7 @@ from typing import List, Optional
from sqlalchemy.orm import Session
from sqlalchemy import desc, asc, func
from .base import BaseRepository
from backend.models.task import Task, TaskStatus, TaskType
from models.task import Task, TaskStatus, TaskType
class TaskRepository(BaseRepository[Task]):
"""任务Repository类"""
+2 -2
View File
@@ -6,8 +6,8 @@
from typing import Generic, TypeVar, Type, Optional, List, Dict, Any
from sqlalchemy.orm import Session
from sqlalchemy import and_, or_
from backend.repositories.base import BaseRepository, ModelType as RepoModelType
from backend.schemas.base import BaseSchema, PaginationParams, PaginationResponse
from repositories.base import BaseRepository, ModelType as RepoModelType
from schemas.base import BaseSchema, PaginationParams, PaginationResponse
CreateSchemaType = TypeVar("CreateSchemaType")
+238 -5
View File
@@ -1,19 +1,25 @@
"""
合集服务
提供合集相关的业务逻辑操作
"""合集服务
提供合集相关的业务逻辑操作,集成Pipeline处理结果
"""
import logging
from typing import List, Optional, Dict, Any
from sqlalchemy.orm import Session
from pathlib import Path
from services.base import BaseService
from repositories.collection_repository import CollectionRepository
from models.collection import Collection
from schemas.collection import CollectionCreate, CollectionUpdate, CollectionResponse, CollectionListResponse, CollectionFilter
from schemas.base import PaginationParams, PaginationResponse
from models.project import Project
from models.clip import Clip
logger = logging.getLogger(__name__)
class CollectionService(BaseService[Collection, CollectionCreate, CollectionUpdate, CollectionResponse]):
"""Collection service with business logic."""
"""Collection service with business logic and Pipeline integration."""
def __init__(self, db: Session):
repository = CollectionRepository(db)
@@ -35,4 +41,231 @@ class CollectionService(BaseService[Collection, CollectionCreate, CollectionUpda
def get_collections_by_project(self, project_id: str, skip: int = 0, limit: int = 100) -> List[Collection]:
"""Get collections by project ID."""
return self.repository.find_by(project_id=project_id)
return self.repository.find_by(project_id=project_id)
def create_collections_from_pipeline_result(self, project_id: int, clustering_result: Dict[str, Any]) -> List[Collection]:
"""从Pipeline聚类结果创建合集
Args:
project_id: 项目ID
clustering_result: Pipeline步骤5的聚类结果
Returns:
创建的合集列表
"""
logger.info(f"从Pipeline结果为项目 {project_id} 创建合集")
try:
collections = []
# 获取项目信息
project = self.db.query(Project).filter(Project.id == project_id).first()
if not project:
raise ValueError(f"项目 {project_id} 不存在")
# 解析聚类结果
if not clustering_result.get('success'):
raise ValueError(f"聚类结果无效: {clustering_result.get('error')}")
clusters_data = clustering_result.get('data', {})
clusters = clusters_data.get('clusters', [])
for i, cluster in enumerate(clusters):
# 创建合集
collection_data = {
'title': cluster.get('title', f'合集 {i+1}'),
'description': cluster.get('description', ''),
'project_id': project_id,
'theme': cluster.get('theme', ''),
'clip_count': len(cluster.get('clips', [])),
'total_duration': sum(clip.get('duration', 0) for clip in cluster.get('clips', [])),
'metadata': {
'cluster_id': cluster.get('cluster_id'),
'confidence_score': cluster.get('confidence_score', 0.0),
'keywords': cluster.get('keywords', []),
'created_from_pipeline': True
}
}
collection = Collection(**collection_data)
self.db.add(collection)
self.db.flush() # 获取ID但不提交
# 关联切片到合集
self._associate_clips_to_collection(collection.id, cluster.get('clips', []))
collections.append(collection)
logger.info(f"创建合集: {collection.title} (包含 {collection.clip_count} 个切片)")
self.db.commit()
logger.info(f"成功为项目 {project_id} 创建了 {len(collections)} 个合集")
return collections
except Exception as e:
self.db.rollback()
logger.error(f"从Pipeline结果创建合集失败: {str(e)}")
raise
def _associate_clips_to_collection(self, collection_id: int, clips_data: List[Dict[str, Any]]):
"""将切片关联到合集
Args:
collection_id: 合集ID
clips_data: 切片数据列表
"""
for clip_data in clips_data:
# 查找或创建切片
clip = self._find_or_create_clip(clip_data)
if clip:
# 更新切片的合集关联
clip.collection_id = collection_id
self.db.add(clip)
def _find_or_create_clip(self, clip_data: Dict[str, Any]) -> Optional[Clip]:
"""查找或创建切片
Args:
clip_data: 切片数据
Returns:
切片对象
"""
try:
# 根据时间范围查找现有切片
start_time = clip_data.get('start_time')
end_time = clip_data.get('end_time')
project_id = clip_data.get('project_id')
if start_time is not None and end_time is not None and project_id:
existing_clip = self.db.query(Clip).filter(
Clip.project_id == project_id,
Clip.start_time == start_time,
Clip.end_time == end_time
).first()
if existing_clip:
return existing_clip
# 创建新切片
clip_data_clean = {
'title': clip_data.get('title', ''),
'description': clip_data.get('description', ''),
'start_time': start_time,
'end_time': end_time,
'duration': clip_data.get('duration', 0),
'project_id': project_id,
'score': clip_data.get('score', 0.0),
'metadata': {
'created_from_pipeline': True,
'original_text': clip_data.get('text', ''),
'keywords': clip_data.get('keywords', []),
'confidence': clip_data.get('confidence', 0.0)
}
}
clip = Clip(**clip_data_clean)
self.db.add(clip)
return clip
except Exception as e:
logger.error(f"创建切片失败: {str(e)}")
return None
def generate_collection_video(self, collection_id: int, output_path: Optional[Path] = None) -> Dict[str, Any]:
"""生成合集视频
Args:
collection_id: 合集ID
output_path: 输出路径
Returns:
生成结果
"""
logger.info(f"生成合集 {collection_id} 的视频")
try:
collection = self.get(collection_id)
if not collection:
raise ValueError(f"合集 {collection_id} 不存在")
# 获取合集中的所有切片
clips = self.db.query(Clip).filter(Clip.collection_id == collection_id).all()
if not clips:
raise ValueError(f"合集 {collection_id} 中没有切片")
# 使用Pipeline的视频生成功能
from services.pipeline_adapter import create_pipeline_adapter
pipeline_adapter = create_pipeline_adapter(self.db)
# 准备切片数据
clips_data = []
for clip in clips:
clips_data.append({
'start_time': clip.start_time,
'end_time': clip.end_time,
'title': clip.title,
'score': clip.score
})
# 生成合集视频
result = pipeline_adapter._step6_video_for_collection(
collection.project_id,
clips_data,
collection.title,
output_path
)
# 更新合集的视频路径
if result.get('success') and result.get('data', {}).get('video_path'):
collection.video_path = result['data']['video_path']
collection.status = 'completed'
self.db.commit()
return result
except Exception as e:
logger.error(f"生成合集视频失败: {str(e)}")
return {
'success': False,
'error': str(e)
}
def get_collection_statistics(self, project_id: int) -> Dict[str, Any]:
"""获取项目的合集统计信息
Args:
project_id: 项目ID
Returns:
统计信息
"""
collections = self.get_collections_by_project(str(project_id))
total_collections = len(collections)
total_clips = sum(collection.clip_count for collection in collections)
total_duration = sum(collection.total_duration for collection in collections)
# 按主题分组统计
theme_stats = {}
for collection in collections:
theme = collection.theme or '未分类'
if theme not in theme_stats:
theme_stats[theme] = {
'count': 0,
'clips': 0,
'duration': 0
}
theme_stats[theme]['count'] += 1
theme_stats[theme]['clips'] += collection.clip_count
theme_stats[theme]['duration'] += collection.total_duration
return {
'total_collections': total_collections,
'total_clips': total_clips,
'total_duration': total_duration,
'average_clips_per_collection': total_clips / total_collections if total_collections > 0 else 0,
'average_duration_per_collection': total_duration / total_collections if total_collections > 0 else 0,
'theme_statistics': theme_stats
}
+2 -2
View File
@@ -12,8 +12,8 @@ from typing import Dict, Any, Optional
from dataclasses import dataclass
from enum import Enum
from backend.models.project import ProjectType
from backend.core.database import get_db
from models.project import ProjectType
from core.database import get_db
logger = logging.getLogger(__name__)
+312 -337
View File
@@ -1,360 +1,335 @@
"""
流水线适配器
负责文件路径映射和流水线接口适配,让现有流水线逻辑保持原状
"""
"""Pipeline适配器服务 - 将shared/pipeline中的处理逻辑集成到新架构"""
import json
import logging
import asyncio
from typing import List, Dict, Any, Optional, Callable
from pathlib import Path
from typing import Dict, Any, List, Optional
from shutil import copy2
from datetime import datetime
from .config_manager import ProjectConfigManager
from .path_manager import PathManager
# 导入shared/pipeline中的处理步骤
import sys
shared_path = Path(__file__).parent.parent.parent / "shared"
if str(shared_path) not in sys.path:
sys.path.insert(0, str(shared_path))
from pipeline.step1_outline import OutlineExtractor
from pipeline.step2_timeline import TimelineExtractor
from pipeline.step3_scoring import ClipScorer
from pipeline.step4_title import TitleGenerator
from pipeline.step5_clustering import ClusteringEngine
from pipeline.step6_video import VideoGenerator
from utils.project_manager import ProjectManager
from config import METADATA_DIR, PROMPT_FILES
# 导入新架构的模型和服务
from models.project import Project, ProjectStatus
from models.task import Task, TaskStatus
from core.database import get_db
from core.progress_manager import get_progress_manager
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
class PipelineAdapter:
"""流水线适配器,负责路径映射和接口适配"""
"""Pipeline适配器 - 桥接旧Pipeline和新架构"""
def __init__(self, project_id: str, debug_mode: bool = False):
self.project_id = project_id
self.debug_mode = debug_mode
self.config_manager = ProjectConfigManager(project_id)
self.path_manager = PathManager(project_id)
self.project_paths = self.path_manager.get_project_paths()
def __init__(self, db: Session, task_id: Optional[int] = None, progress_callback: Optional[Callable[[int, int, str], Any]] = None):
self.db = db
self.task_id = task_id
self.progress_callback = progress_callback
self.progress_manager = get_progress_manager(db) if task_id else None
# 确保项目目录结构存在
self.path_manager.ensure_directories()
def get_step_input_path(self, step_name: str) -> Path:
"""获取步骤输入文件路径"""
return self.path_manager.get_step_input_path(step_name)
def get_step_output_path(self, step_name: str) -> Path:
"""获取步骤输出文件路径"""
return self.path_manager.get_step_output_path(step_name)
def get_step_intermediate_dir(self, step_name: str) -> Path:
"""获取步骤中间文件目录"""
return self.path_manager.get_step_intermediate_dir(step_name)
def prepare_step_environment(self, step_name: str, input_data: Any = None):
"""准备步骤执行环境"""
# 使用PathManager创建步骤目录
self.path_manager.create_step_directories(step_name)
# 初始化各个步骤的处理器
self.outline_extractor = None
self.timeline_generator = None
self.clip_scorer = None
self.title_generator = None
self.clustering_engine = None
self.video_generator = None
# 如果有输入数据,保存到输入文件
if input_data is not None:
input_path = self.get_step_input_path(step_name)
self._save_json(input_path, input_data)
def should_skip_step(self, step_name: str) -> bool:
"""
判断是否应该跳过步骤(debug模式下如果输出已存在则跳过)
# 步骤配置
self.steps = [
{"name": "outline", "description": "提取视频大纲", "weight": 15},
{"name": "timeline", "description": "生成时间线", "weight": 20},
{"name": "scoring", "description": "内容评分", "weight": 20},
{"name": "title", "description": "生成标题", "weight": 15},
{"name": "clustering", "description": "主题聚类", "weight": 15},
{"name": "video", "description": "生成视频", "weight": 15}
]
Args:
step_name: 步骤名称
async def process_project(self, project_id: int, input_video_path: str, input_srt_path: str) -> Dict[str, Any]:
"""处理整个项目的Pipeline流程"""
try:
# 获取项目信息
project = self.db.query(Project).filter(Project.id == project_id).first()
if not project:
raise ValueError(f"项目 {project_id} 不存在")
Returns:
是否应该跳过
"""
if not self.debug_mode:
return False
output_path = self.get_step_output_path(step_name)
if output_path.exists():
logger.info(f"Debug模式:跳过步骤 {step_name},输出文件已存在: {output_path}")
return True
return False
def get_step_cache_info(self, step_name: str) -> Dict[str, Any]:
"""
获取步骤缓存信息
Args:
step_name: 步骤名称
# 更新项目状态
project.status = ProjectStatus.PROCESSING
project.started_at = datetime.utcnow()
self.db.commit()
Returns:
缓存信息
"""
output_path = self.get_step_output_path(step_name)
input_path = self.get_step_input_path(step_name)
# 创建项目工作目录
project_dir = Path(METADATA_DIR) / f"project_{project_id}"
project_dir.mkdir(parents=True, exist_ok=True)
# 初始化处理器
self._initialize_processors(project_dir)
# 执行6个步骤
results = {}
total_progress = 0
for i, step in enumerate(self.steps):
step_name = step["name"]
step_description = step["description"]
step_weight = step["weight"]
logger.info(f"开始执行步骤 {i+1}/6: {step_description}")
# 更新进度
await self._update_progress(project_id, total_progress, f"正在{step_description}...")
try:
# 执行具体步骤
if step_name == "outline":
results[step_name] = await self._step1_outline(input_srt_path, project_dir)
elif step_name == "timeline":
results[step_name] = await self._step2_timeline(results["outline"], project_dir)
elif step_name == "scoring":
results[step_name] = await self._step3_scoring(results["timeline"], project_dir)
elif step_name == "title":
results[step_name] = await self._step4_title(results["scoring"], project_dir)
elif step_name == "clustering":
results[step_name] = await self._step5_clustering(results["title"], project_dir)
elif step_name == "video":
results[step_name] = await self._step6_video(results["clustering"], input_video_path, project_dir)
total_progress += step_weight
await self._update_progress(project_id, total_progress, f"{step_description}完成", results[step_name])
except Exception as e:
logger.error(f"步骤 {step_name} 执行失败: {str(e)}")
await self._update_progress(project_id, total_progress, f"{step_description}失败: {str(e)}")
raise
# 更新项目状态为完成
project.status = ProjectStatus.COMPLETED
project.completed_at = datetime.utcnow()
project.result_data = results
self.db.commit()
await self._update_progress(project_id, 100, "项目处理完成")
logger.info(f"项目 {project_id} 处理完成")
return results
except Exception as e:
# 更新项目状态为失败
project = self.db.query(Project).filter(Project.id == project_id).first()
if project:
project.status = ProjectStatus.FAILED
project.error_message = str(e)
self.db.commit()
await self._update_progress(project_id, -1, f"处理失败: {str(e)}")
logger.error(f"项目 {project_id} 处理失败: {str(e)}")
raise
def _initialize_processors(self, project_dir: Path):
"""初始化各个处理器"""
self.outline_extractor = OutlineExtractor(metadata_dir=project_dir, prompt_files=PROMPT_FILES)
self.timeline_extractor = TimelineExtractor(metadata_dir=project_dir, prompt_files=PROMPT_FILES)
self.clip_scorer = ClipScorer(prompt_files=PROMPT_FILES)
self.title_generator = TitleGenerator(prompt_files=PROMPT_FILES)
self.clustering_engine = ClusteringEngine(prompt_files=PROMPT_FILES)
self.video_generator = VideoGenerator(metadata_dir=str(project_dir))
async def _step1_outline(self, srt_path: str, project_dir: Path) -> List[Dict]:
"""步骤1: 提取视频大纲"""
srt_file = Path(srt_path)
if not srt_file.exists():
raise FileNotFoundError(f"SRT文件不存在: {srt_path}")
cache_info = {
"step_name": step_name,
"output_exists": output_path.exists(),
"input_exists": input_path.exists(),
"output_path": str(output_path),
"input_path": str(input_path),
"output_size": output_path.stat().st_size if output_path.exists() else 0,
"input_size": input_path.stat().st_size if input_path.exists() else 0,
"output_modified": output_path.stat().st_mtime if output_path.exists() else 0,
"input_modified": input_path.stat().st_mtime if input_path.exists() else 0
# 在线程池中执行CPU密集型任务
loop = asyncio.get_event_loop()
outlines = await loop.run_in_executor(
None,
self.outline_extractor.extract_outline,
srt_file
)
# 保存结果
output_path = project_dir / "step1_outline.json"
self.outline_extractor.save_outline(outlines, output_path)
return outlines
async def _step2_timeline(self, outlines: List[Dict], project_dir: Path) -> List[Dict]:
"""步骤2: 生成时间线"""
# 加载SRT块数据
srt_chunks_dir = project_dir / "step1_srt_chunks"
if not srt_chunks_dir.exists():
raise FileNotFoundError(f"SRT块目录不存在: {srt_chunks_dir}")
loop = asyncio.get_event_loop()
timeline_data = await loop.run_in_executor(
None,
self.timeline_extractor.extract_timeline,
outlines
)
# 保存结果
output_path = project_dir / "step2_timeline.json"
self.timeline_extractor.save_timeline(timeline_data, output_path)
return timeline_data
async def _step3_scoring(self, timeline_data: List[Dict], project_dir: Path) -> List[Dict]:
"""步骤3: 内容评分"""
loop = asyncio.get_event_loop()
scored_clips = await loop.run_in_executor(
None,
self.clip_scorer.score_clips,
timeline_data
)
# 保存结果
output_path = project_dir / "step3_scored_clips.json"
self.clip_scorer.save_scores(scored_clips, output_path)
return scored_clips
async def _step4_title(self, scored_clips: List[Dict], project_dir: Path) -> List[Dict]:
"""步骤4: 生成标题"""
loop = asyncio.get_event_loop()
clips_with_titles = await loop.run_in_executor(
None,
self.title_generator.generate_titles,
scored_clips
)
# 保存结果
output_path = project_dir / "step4_titles.json"
self.title_generator.save_titles(clips_with_titles, output_path)
return clips_with_titles
async def _step5_clustering(self, clips_with_titles: List[Dict], project_dir: Path) -> List[Dict]:
"""步骤5: 主题聚类"""
loop = asyncio.get_event_loop()
collections = await loop.run_in_executor(
None,
self.clustering_engine.create_collections,
clips_with_titles
)
# 保存结果
output_path = project_dir / "step5_collections.json"
self.clustering_engine.save_collections(collections, output_path)
return collections
async def _step6_video(self, collections: List[Dict], input_video_path: str, project_dir: Path) -> Dict:
"""步骤6: 生成视频"""
# 从collections中提取clips_with_titles
clips_with_titles = []
for collection in collections:
clips_with_titles.extend(collection.get('clips', []))
input_video = Path(input_video_path)
if not input_video.exists():
raise FileNotFoundError(f"输入视频不存在: {input_video_path}")
loop = asyncio.get_event_loop()
# 生成切片视频
successful_clips = await loop.run_in_executor(
None,
self.video_generator.generate_clips,
clips_with_titles,
input_video
)
# 生成合集视频
successful_collections = await loop.run_in_executor(
None,
self.video_generator.generate_collections,
collections
)
# 保存元数据
self.video_generator.save_clip_metadata(clips_with_titles)
self.video_generator.save_collection_metadata(collections)
result = {
'clips_generated': len(successful_clips),
'collections_generated': len(successful_collections),
'clip_paths': [str(path) for path in successful_clips],
'collection_paths': [str(path) for path in successful_collections]
}
return cache_info
def _save_json(self, path: Path, data: Any):
"""保存JSON数据到文件"""
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, 'w', encoding='utf-8') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
logger.info(f"数据已保存到: {path}")
def _load_json(self, path: Path) -> Any:
"""从文件加载JSON数据"""
if not path.exists():
return None
# 保存结果
output_path = project_dir / "step6_video_result.json"
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2)
with open(path, 'r', encoding='utf-8') as f:
return json.load(f)
def adapt_step(self, step_name: str, **kwargs) -> Dict[str, Any]:
"""
统一的步骤适配方法
Args:
step_name: 步骤名称
**kwargs: 步骤特定参数
Returns:
适配后的参数
"""
logger.info(f"开始适配步骤: {step_name}")
logger.debug(f"步骤 {step_name} 输入参数: {list(kwargs.keys())}")
step_config = {
"step1_outline": {
"required_inputs": ["srt_path"],
"input_mapping": {"srt_path": "srt_path"},
"output_path": "step1_outline",
"special_handling": "copy_srt_file"
},
"step2_timeline": {
"required_inputs": ["step1_outline"],
"input_mapping": {"outline_path": "step1_outline"},
"output_path": "step2_timeline"
},
"step3_scoring": {
"required_inputs": ["step2_timeline"],
"input_mapping": {"timeline_path": "step2_timeline"},
"output_path": "step3_scoring"
},
"step4_title": {
"required_inputs": ["step3_scoring"],
"input_mapping": {"high_score_clips_path": "step3_scoring"},
"output_path": "step4_title",
"metadata_dir_type": "str"
},
"step5_clustering": {
"required_inputs": ["step4_title"],
"input_mapping": {"clips_with_titles_path": "step4_title"},
"output_path": "step5_clustering",
"metadata_dir_type": "str"
},
"step6_video": {
"required_inputs": ["step4_title", "step5_clustering"],
"input_mapping": {
"clips_with_titles_path": "step4_title",
"collections_path": "step5_clustering",
"input_video": "input_video"
},
"output_path": "step6_video",
"metadata_dir_type": "str",
"output_param_name": "output_dir",
"special_handling": "add_input_video"
}
}
if step_name not in step_config:
logger.error(f"未知的步骤: {step_name}")
raise ValueError(f"未知的步骤: {step_name}")
config = step_config[step_name]
logger.debug(f"步骤 {step_name} 配置: {config}")
# 处理特殊输入(如SRT文件复制)
if "special_handling" in config:
if config["special_handling"] == "copy_srt_file":
srt_path = kwargs.get("srt_path")
if not srt_path or not srt_path.exists():
logger.error(f"SRT文件不存在: {srt_path}")
raise FileNotFoundError(f"SRT文件不存在: {srt_path}")
# 复制SRT文件到项目目录
project_srt_path = self.path_manager.get_srt_path()
# 检查源路径和目标路径是否相同
if srt_path.resolve() == project_srt_path.resolve():
logger.info(f"SRT文件已在目标位置,跳过复制: {srt_path}")
else:
logger.info(f"复制SRT文件: {srt_path} -> {project_srt_path}")
copy2(srt_path, project_srt_path)
kwargs["srt_path"] = project_srt_path
elif config["special_handling"] == "add_input_video":
# 为step6_video添加输入视频路径
input_video_path = self.path_manager.get_video_path()
if not input_video_path.exists():
logger.error(f"输入视频文件不存在: {input_video_path}")
raise FileNotFoundError(f"输入视频文件不存在: {input_video_path}")
kwargs["input_video"] = input_video_path
logger.info(f"添加输入视频路径: {input_video_path}")
# 验证输入文件
for input_name in config["required_inputs"]:
if input_name not in kwargs:
# 尝试从前一步的输出获取
prev_step_output = self.get_step_output_path(input_name)
logger.debug(f"检查输入文件: {input_name} -> {prev_step_output}")
if not prev_step_output.exists():
logger.error(f"输入文件不存在: {input_name} -> {prev_step_output}")
raise FileNotFoundError(f"输入文件不存在: {input_name} -> {prev_step_output}")
kwargs[input_name] = prev_step_output
logger.debug(f"使用前一步输出作为输入: {input_name}")
# 构建输出路径
output_path = self.get_step_output_path(config["output_path"])
logger.debug(f"步骤 {step_name} 输出路径: {output_path}")
# 构建基础参数
output_param_name = config.get("output_param_name", "output_path")
params = {
output_param_name: output_path
}
# 只有非step6的步骤才添加prompt_files
if step_name != "step6_video":
params["prompt_files"] = self.config_manager.get_prompt_files()
# 处理特殊输出映射(如step6的output_dir)
if "special_output_mapping" in config:
for param_name, output_param in config["special_output_mapping"].items():
params[param_name] = params[output_param]
logger.debug(f"特殊输出映射: {param_name} <- {output_param}")
# 添加输入映射
for param_name, input_name in config["input_mapping"].items():
if input_name in kwargs:
params[param_name] = kwargs[input_name]
logger.debug(f"映射参数: {param_name} <- {input_name}")
# 添加metadata_dir
metadata_dir = self.project_paths["metadata_dir"]
if config.get("metadata_dir_type") == "str":
params["metadata_dir"] = str(metadata_dir)
logger.debug(f"metadata_dir类型: str")
else:
params["metadata_dir"] = metadata_dir
logger.debug(f"metadata_dir类型: Path")
logger.info(f"步骤 {step_name} 适配完成,参数: {list(params.keys())}")
return params
# 保持向后兼容的方法
def adapt_step1_outline(self, srt_path: Path) -> Dict[str, Any]:
"""适配Step1: 大纲提取"""
return self.adapt_step("step1_outline", srt_path=srt_path)
def adapt_step2_timeline(self) -> Dict[str, Any]:
"""适配Step2: 时间线提取"""
return self.adapt_step("step2_timeline")
def adapt_step3_scoring(self) -> Dict[str, Any]:
"""适配Step3: 评分"""
return self.adapt_step("step3_scoring")
def adapt_step4_title(self) -> Dict[str, Any]:
"""适配Step4: 标题生成"""
return self.adapt_step("step4_title")
def adapt_step5_clustering(self) -> Dict[str, Any]:
"""适配Step5: 聚类"""
return self.adapt_step("step5_clustering")
def adapt_step6_video(self) -> Dict[str, Any]:
"""适配Step6: 视频生成"""
return self.adapt_step("step6_video")
def get_step_result(self, step_name: str) -> Any:
"""获取步骤执行结果"""
output_path = self.get_step_output_path(step_name)
result = self._load_json(output_path)
logger.debug(f"获取步骤 {step_name} 结果: {'成功' if result else '失败'}")
return result
def get_pipeline_cache_info(self) -> Dict[str, Any]:
"""获取流水线缓存信息"""
steps = [
"step1_outline", "step2_timeline", "step3_scoring",
"step4_title", "step5_clustering", "step6_video"
]
cache_info = {}
for step in steps:
cache_info[step] = self.get_step_cache_info(step)
return cache_info
def get_project_size_info(self) -> Dict[str, Any]:
"""获取项目大小信息"""
return self.path_manager.get_project_size_info()
def cleanup_all_intermediate_files(self):
"""清理所有步骤的中间文件"""
steps = [
"step1_outline", "step2_timeline", "step3_scoring",
"step4_title", "step5_clustering", "step6_video"
]
for step in steps:
self.cleanup_intermediate_files(step)
logger.info("已清理所有步骤的中间文件")
def cleanup_intermediate_files(self, step_name: str):
"""清理步骤的中间文件"""
self.path_manager.cleanup_step_files(step_name, keep_output=True)
def get_pipeline_status(self) -> Dict[str, Any]:
"""获取流水线执行状态"""
status = {}
steps = [
"step1_outline", "step2_timeline", "step3_scoring",
"step4_title", "step5_clustering", "step6_video"
]
for step in steps:
output_path = self.get_step_output_path(step)
status[step] = {
"completed": output_path.exists(),
"output_path": str(output_path),
"file_size": output_path.stat().st_size if output_path.exists() else 0
}
return status
def validate_pipeline_prerequisites(self) -> List[str]:
"""验证流水线前置条件"""
errors = []
# 检查路径有效性
path_errors = self.path_manager.validate_paths()
errors.extend(path_errors)
# 检查SRT文件
srt_path = self.path_manager.get_srt_path()
if not srt_path.exists():
errors.append(f"SRT文件不存在: {srt_path}")
# 检查LLM配置
async def _update_progress(self, project_id: int, progress: int, message: str, step_result: Optional[Dict[str, Any]] = None):
"""更新项目进度"""
try:
self.config_manager.get_llm_config()
except ValueError as e:
errors.append(str(e))
# 更新数据库中的项目进度
project = self.db.query(Project).filter(Project.id == project_id).first()
if project:
project.progress = progress
project.current_step = message
self.db.commit()
# 使用进度管理器更新进度
if self.progress_manager and self.task_id:
await self.progress_manager.update_task_progress(
task_id=self.task_id,
current_step=progress // 17 + 1, # 估算当前步骤
total_steps=6,
step_name=message,
progress=progress,
message=message,
step_result=step_result
)
# 调用进度回调函数(用于WebSocket推送)
if self.progress_callback:
await self.progress_callback(project_id, progress, message)
except Exception as e:
logger.error(f"更新进度失败: {str(e)}")
def get_project_status(self, project_id: int) -> Dict[str, Any]:
"""获取项目状态"""
project = self.db.query(Project).filter(Project.id == project_id).first()
if not project:
return {"error": "项目不存在"}
return errors
return {
"id": project.id,
"name": project.name,
"status": project.status.value,
"progress": project.progress or 0,
"current_step": project.current_step or "",
"started_at": project.started_at.isoformat() if project.started_at else None,
"completed_at": project.completed_at.isoformat() if project.completed_at else None,
"error_message": project.error_message
}
# 工厂函数
def create_pipeline_adapter(db: Session, task_id: Optional[int] = None, progress_callback: Optional[Callable[[int, int, str], Any]] = None) -> PipelineAdapter:
"""创建Pipeline适配器实例"""
return PipelineAdapter(db, task_id, progress_callback)
# 同步版本的工厂函数,用于在非异步环境中使用
def create_pipeline_adapter_sync(db: Session, task_id: Optional[int] = None) -> PipelineAdapter:
"""创建同步版本的Pipeline适配器实例"""
return PipelineAdapter(db, task_id, None)
+4 -4
View File
@@ -9,10 +9,10 @@ from typing import Dict, Any, List, Optional, Callable
from pathlib import Path
from sqlalchemy.orm import Session
from backend.models.task import Task, TaskStatus, TaskType
from backend.repositories.task_repository import TaskRepository
from backend.services.config_manager import ProjectConfigManager, ProcessingStep
from backend.services.pipeline_adapter import PipelineAdapter
from models.task import Task, TaskStatus, TaskType
from repositories.task_repository import TaskRepository
from services.config_manager import ProjectConfigManager, ProcessingStep
from services.pipeline_adapter import PipelineAdapter
logger = logging.getLogger(__name__)
+10 -10
View File
@@ -8,14 +8,14 @@ from pathlib import Path
from typing import Dict, Any, Optional
from sqlalchemy.orm import Session
from backend.models.task import Task, TaskStatus, TaskType
from backend.repositories.task_repository import TaskRepository
from backend.services.config_manager import ProjectConfigManager, ProcessingStep
from backend.services.pipeline_adapter import PipelineAdapter
from backend.services.processing_orchestrator import ProcessingOrchestrator
from backend.services.processing_context import ProcessingContext
from backend.services.exceptions import ServiceError, ProcessingError, TaskError, ProjectError, handle_service_error
from backend.services.concurrency_manager import with_concurrency_control
from models.task import Task, TaskStatus, TaskType
from repositories.task_repository import TaskRepository
from services.config_manager import ProjectConfigManager, ProcessingStep
from services.pipeline_adapter import PipelineAdapter
from services.processing_orchestrator import ProcessingOrchestrator
from services.processing_context import ProcessingContext
from services.exceptions import ServiceError, ProcessingError, TaskError, ProjectError, handle_service_error
from services.concurrency_manager import with_concurrency_control
logger = logging.getLogger(__name__)
@@ -61,7 +61,7 @@ class ProcessingService:
# 更新项目状态为已完成
try:
from backend.models.project import Project, ProjectStatus
from models.project import Project, ProjectStatus
project = self.db.query(Project).filter(Project.id == project_id).first()
if project:
project.status = ProjectStatus.COMPLETED
@@ -233,7 +233,7 @@ class ProcessingService:
# 更新项目状态为已完成
try:
from backend.models.project import Project, ProjectStatus
from models.project import Project, ProjectStatus
project = self.db.query(Project).filter(Project.id == project_id).first()
if project:
project.status = ProjectStatus.COMPLETED
@@ -8,7 +8,7 @@ import asyncio
from typing import Dict, Any, Optional
from datetime import datetime
from backend.core.websocket_manager import manager, WebSocketMessage
from core.websocket_manager import manager, WebSocketMessage
logger = logging.getLogger(__name__)
+6 -6
View File
@@ -10,12 +10,12 @@ from pathlib import Path
from typing import Dict, Any, List
from celery import current_task
from backend.core.celery_app import celery_app
from backend.core.database import SessionLocal
from backend.models.task import Task, TaskStatus
from backend.models.project import Project, ProjectStatus
from backend.repositories.task_repository import TaskRepository
from backend.repositories.project_repository import ProjectRepository
from core.celery_app import celery_app
from core.database import SessionLocal
from models.task import Task, TaskStatus
from models.project import Project, ProjectStatus
from repositories.task_repository import TaskRepository
from repositories.project_repository import ProjectRepository
logger = logging.getLogger(__name__)
+4 -4
View File
@@ -8,10 +8,10 @@ from typing import Dict, Any, Optional
from celery import current_task
from datetime import datetime
from backend.core.celery_app import celery_app
from backend.core.database import SessionLocal
from backend.models.task import Task, TaskStatus
from backend.repositories.task_repository import TaskRepository
from core.celery_app import celery_app
from core.database import SessionLocal
from models.task import Task, TaskStatus
from repositories.task_repository import TaskRepository
logger = logging.getLogger(__name__)
+63 -74
View File
@@ -1,6 +1,5 @@
"""
视频处理Celery任务
包含WebSocket实时通知
"""视频处理Celery任务
包含WebSocket实时通知和Pipeline适配器集成
"""
import os
@@ -8,11 +7,16 @@ import logging
import asyncio
from typing import Dict, Any, Optional
from celery import current_task
from pathlib import Path
from backend.core.celery_app import celery_app
from backend.services.websocket_notification_service import notification_service
from backend.services.processing_service import ProcessingService
from backend.core.database import SessionLocal
from core.celery_app import celery_app
from services.websocket_notification_service import notification_service
from services.processing_service import ProcessingService
from services.pipeline_adapter import create_pipeline_adapter_sync
from core.database import SessionLocal
from core.progress_manager import get_progress_manager
from models.project import Project, ProjectStatus
from models.task import Task, TaskStatus
logger = logging.getLogger(__name__)
@@ -27,13 +31,14 @@ def run_async_notification(coro):
return loop.run_until_complete(coro)
@celery_app.task(bind=True, name='backend.tasks.processing.process_video_pipeline')
def process_video_pipeline(self, project_id: str, config: Dict[str, Any]) -> Dict[str, Any]:
def process_video_pipeline(self, project_id: int, input_video_path: str, input_srt_path: str) -> Dict[str, Any]:
"""
处理视频流水线任务
处理视频流水线任务 - 使用Pipeline适配器
Args:
project_id: 项目ID
config: 处理配置
input_video_path: 输入视频路径
input_srt_path: 输入SRT路径
Returns:
处理结果
@@ -42,80 +47,46 @@ def process_video_pipeline(self, project_id: str, config: Dict[str, Any]) -> Dic
logger.info(f"开始处理视频流水线: {project_id}, 任务ID: {task_id}")
try:
# 发送开始通知
run_async_notification(
notification_service.send_processing_start(project_id, task_id)
)
# 创建数据库会话
db = SessionLocal()
try:
# 创建处理服务
processing_service = ProcessingService(db)
# 创建任务记录
task = Task(
name=f"视频处理流水线",
description=f"处理项目 {project_id} 的完整视频流水线",
type="pipeline",
project_id=project_id,
celery_task_id=task_id,
status=TaskStatus.RUNNING,
progress=0,
current_step="初始化",
total_steps=6
)
db.add(task)
db.commit()
db.refresh(task) # 确保获取到task.id
# 步骤1: 大纲生成 (20%)
logger.info("步骤1: 生成大纲")
# 发送开始通知
run_async_notification(
notification_service.send_processing_progress(project_id, task_id, 20, "生成大纲")
notification_service.send_processing_start(project_id, task_id)
)
outline_result = processing_service.generate_outline(project_id, config)
if not outline_result.get("success"):
raise Exception(f"大纲生成失败: {outline_result.get('error')}")
# 获取进度管理器
progress_manager = get_progress_manager(db)
# 步骤2: 时间轴提取 (40%)
logger.info("步骤2: 提取时间轴")
run_async_notification(
notification_service.send_processing_progress(project_id, task_id, 40, "提取时间轴")
)
# 创建Pipeline适配器,传入task.id以启用进度管理
pipeline_adapter = create_pipeline_adapter_sync(db, task.id)
timeline_result = processing_service.extract_timeline(project_id, config)
if not timeline_result.get("success"):
raise Exception(f"时间轴提取失败: {timeline_result.get('error')}")
# 执行Pipeline处理
result = pipeline_adapter.process_project_sync(project_id, input_video_path, input_srt_path)
# 步骤3: 标题生成 (60%)
logger.info("步骤3: 生成标题")
run_async_notification(
notification_service.send_processing_progress(project_id, task_id, 60, "生成标题")
)
title_result = processing_service.generate_titles(project_id, config)
if not title_result.get("success"):
raise Exception(f"标题生成失败: {title_result.get('error')}")
# 步骤4: 视频切片 (80%)
logger.info("步骤4: 视频切片")
run_async_notification(
notification_service.send_processing_progress(project_id, task_id, 80, "视频切片")
)
clip_result = processing_service.extract_clips(project_id, config)
if not clip_result.get("success"):
raise Exception(f"视频切片失败: {clip_result.get('error')}")
# 步骤5: 合集生成 (100%)
logger.info("步骤5: 生成合集")
run_async_notification(
notification_service.send_processing_progress(project_id, task_id, 100, "生成合集")
)
collection_result = processing_service.generate_collections(project_id, config)
if not collection_result.get("success"):
raise Exception(f"合集生成失败: {collection_result.get('error')}")
# 处理完成
result = {
"success": True,
"project_id": project_id,
"task_id": task_id,
"outline": outline_result,
"timeline": timeline_result,
"titles": title_result,
"clips": clip_result,
"collections": collection_result,
"message": "视频处理流水线完成"
}
# 更新任务状态为完成
task.status = TaskStatus.COMPLETED
task.progress = 100
task.current_step = "处理完成"
task.result = result
db.commit()
# 发送完成通知
run_async_notification(
@@ -123,7 +94,13 @@ def process_video_pipeline(self, project_id: str, config: Dict[str, Any]) -> Dic
)
logger.info(f"视频流水线处理完成: {project_id}")
return result
return {
"success": True,
"project_id": project_id,
"task_id": task_id,
"result": result,
"message": "视频处理流水线完成"
}
finally:
db.close()
@@ -132,6 +109,18 @@ def process_video_pipeline(self, project_id: str, config: Dict[str, Any]) -> Dic
error_msg = f"视频流水线处理失败: {str(e)}"
logger.error(error_msg)
# 更新任务状态为失败
try:
db = SessionLocal()
task = db.query(Task).filter(Task.celery_task_id == task_id).first()
if task:
task.status = TaskStatus.FAILED
task.error_message = error_msg
db.commit()
db.close()
except Exception as db_error:
logger.error(f"更新任务状态失败: {str(db_error)}")
# 发送错误通知
run_async_notification(
notification_service.send_processing_error(project_id, task_id, error_msg)
+6 -6
View File
@@ -8,12 +8,12 @@ from pathlib import Path
from typing import Dict, Any, List
from celery import current_task
from backend.core.celery_app import celery_app
from backend.core.database import SessionLocal
from backend.models.clip import Clip, ClipStatus
from backend.models.collection import Collection, CollectionStatus
from backend.repositories.clip_repository import ClipRepository
from backend.repositories.collection_repository import CollectionRepository
from core.celery_app import celery_app
from core.database import SessionLocal
from models.clip import Clip, ClipStatus
from models.collection import Collection, CollectionStatus
from repositories.clip_repository import ClipRepository
from repositories.collection_repository import CollectionRepository
logger = logging.getLogger(__name__)
-15
View File
@@ -1,15 +0,0 @@
1
00:00:01,000 --> 00:00:05,000
这是一个测试字幕文件
2
00:00:05,000 --> 00:00:10,000
用于测试视频处理功能
3
00:00:10,000 --> 00:00:15,000
包含多个字幕片段
4
00:00:15,000 --> 00:00:20,000
用于验证处理流程
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env python3
"""
Celery任务测试脚本
"""
import os
import sys
from pathlib import Path
# 添加项目根目录到Python路径
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from core.celery_app import celery_app
from tasks.processing import process_video_pipeline, process_single_step
from tasks.maintenance import health_check
def test_celery_tasks():
"""测试Celery任务"""
print("🧪 开始测试Celery任务...")
# 设置测试环境变量
os.environ['DASHSCOPE_API_KEY'] = 'test_api_key'
try:
# 测试健康检查任务
print("1. 测试健康检查任务...")
result = health_check.delay()
print(f" 结果: {result.get()}")
# 测试视频处理任务(使用测试SRT文件)
print("2. 测试视频处理任务...")
srt_path = Path(__file__).parent / "test.srt"
result = process_video_pipeline.delay("test_project", str(srt_path))
print(f" 结果: {result.get()}")
# 测试单个步骤任务
print("3. 测试单个步骤任务...")
result = process_single_step.delay("test_project", "step1_outline", str(srt_path))
print(f" 结果: {result.get()}")
print("✅ 所有Celery任务测试通过!")
except Exception as e:
print(f"❌ Celery任务测试失败: {e}")
raise
if __name__ == "__main__":
test_celery_tasks()
-93
View File
@@ -1,93 +0,0 @@
#!/usr/bin/env python3
"""
前后端联调测试脚本
测试WebSocket连接和消息发送
"""
import os
import sys
import asyncio
import json
import time
from pathlib import Path
# 添加项目根目录到Python路径
project_root = Path(__file__).parent.parent
sys.path.insert(0, str(project_root))
from services.websocket_notification_service import notification_service
async def test_frontend_integration():
"""测试前后端联调"""
print("🎯 前后端联调测试")
print("=" * 50)
try:
# 1. 发送连接成功通知
print("1. 📢 发送连接成功通知...")
await notification_service.send_system_notification(
"frontend_connected",
"前端连接成功",
"前端已成功连接到WebSocket服务",
"success"
)
print(" ✅ 连接通知已发送")
# 2. 模拟任务开始
print("\n2. 📋 模拟任务开始...")
await notification_service.send_task_update(
task_id="test-task-001",
status="started",
progress=0,
message="测试任务已开始"
)
print(" ✅ 任务开始通知已发送")
# 3. 模拟任务进度
print("\n3. 📈 模拟任务进度...")
for i in range(1, 6):
progress = i * 20
await notification_service.send_task_update(
task_id="test-task-001",
status="processing",
progress=progress,
message=f"测试任务进度: {progress}%"
)
print(f" 📊 进度 {progress}% 已发送")
await asyncio.sleep(2) # 等待2秒
# 4. 模拟任务完成
print("\n4. ✅ 模拟任务完成...")
await notification_service.send_task_update(
task_id="test-task-001",
status="completed",
progress=100,
message="测试任务已完成"
)
print(" ✅ 任务完成通知已发送")
# 5. 发送系统通知
print("\n5. 📢 发送系统通知...")
await notification_service.send_system_notification(
"test_complete",
"测试完成",
"前后端联调测试已完成",
"success"
)
print(" ✅ 系统通知已发送")
print("\n🎉 前后端联调测试完成!")
print("💡 提示:")
print(" - 前端应该能看到实时通知")
print(" - 任务进度应该实时更新")
print(" - WebSocket连接状态应该正常")
return True
except Exception as e:
print(f"❌ 测试失败: {e}")
return False
if __name__ == "__main__":
success = asyncio.run(test_frontend_integration())
exit(0 if success else 1)
-97
View File
@@ -1,97 +0,0 @@
#!/usr/bin/env python3
"""
测试WebSocket通知的防重复和节流功能
"""
import asyncio
import json
import websockets
import logging
from datetime import datetime
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
async def test_notification_throttling():
"""测试通知防重复和节流功能"""
uri = "ws://localhost:8000/api/v1/ws/test-user"
try:
async with websockets.connect(uri) as websocket:
logger.info("✅ 已连接到WebSocket服务器")
# 1. 发送连接成功消息
welcome_message = {
"type": "system_notification",
"notification_type": "connection",
"title": "连接成功",
"message": "测试用户已连接",
"level": "success",
"timestamp": datetime.utcnow().isoformat()
}
await websocket.send(json.dumps(welcome_message))
logger.info("📢 发送连接成功通知")
# 2. 快速发送多个相同的任务进度消息(应该被节流)
for i in range(5):
progress_message = {
"type": "task_update",
"task_id": "test-task-001",
"status": "processing",
"progress": 20,
"message": "正在处理...",
"timestamp": datetime.utcnow().isoformat()
}
await websocket.send(json.dumps(progress_message))
logger.info(f"📊 发送任务进度消息 {i+1}/5")
await asyncio.sleep(0.5) # 快速发送
# 3. 发送任务完成消息(应该显示通知)
complete_message = {
"type": "task_update",
"task_id": "test-task-001",
"status": "completed",
"progress": 100,
"message": "任务已完成",
"timestamp": datetime.utcnow().isoformat()
}
await websocket.send(json.dumps(complete_message))
logger.info("✅ 发送任务完成消息")
# 4. 再次发送相同的完成消息(应该被防重复)
await websocket.send(json.dumps(complete_message))
logger.info("🔄 重复发送任务完成消息(应该被忽略)")
# 5. 等待一段时间后发送另一个完成消息(应该被节流)
await asyncio.sleep(1)
another_complete = {
"type": "task_update",
"task_id": "test-task-002",
"status": "completed",
"progress": 100,
"message": "另一个任务已完成",
"timestamp": datetime.utcnow().isoformat()
}
await websocket.send(json.dumps(another_complete))
logger.info("✅ 发送另一个任务完成消息")
# 6. 发送错误消息
error_message = {
"type": "error_notification",
"error_type": "test_error",
"error_message": "这是一个测试错误",
"details": {"test": True},
"timestamp": datetime.utcnow().isoformat()
}
await websocket.send(json.dumps(error_message))
logger.info("❌ 发送错误消息")
logger.info("🎉 测试完成!")
logger.info("💡 前端应该只显示少量重要通知,而不是疯狂弹窗")
except Exception as e:
logger.error(f"测试失败: {e}")
if __name__ == "__main__":
asyncio.run(test_notification_throttling())
-244
View File
@@ -1,244 +0,0 @@
#!/usr/bin/env python3
"""
WebSocket客户端测试脚本
用于测试WebSocket连接和消息接收
"""
import asyncio
import websockets
import json
import logging
from datetime import datetime
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class WebSocketClient:
"""WebSocket客户端"""
def __init__(self, user_id: str, uri: str = "ws://localhost:8000/api/v1/ws/"):
self.user_id = user_id
self.uri = f"{uri}{user_id}"
self.websocket = None
self.connected = False
async def connect(self):
"""连接到WebSocket服务器"""
try:
self.websocket = await websockets.connect(self.uri)
self.connected = True
logger.info(f"用户 {self.user_id} 已连接到WebSocket服务器")
return True
except Exception as e:
logger.error(f"连接失败: {e}")
return False
async def disconnect(self):
"""断开连接"""
if self.websocket:
await self.websocket.close()
self.connected = False
logger.info(f"用户 {self.user_id} 已断开连接")
async def send_message(self, message: dict):
"""发送消息"""
if not self.connected:
logger.error("未连接到服务器")
return False
try:
await self.websocket.send(json.dumps(message))
logger.info(f"发送消息: {message}")
return True
except Exception as e:
logger.error(f"发送消息失败: {e}")
return False
async def subscribe_to_topic(self, topic: str):
"""订阅主题"""
message = {
"type": "subscribe",
"topic": topic
}
return await self.send_message(message)
async def unsubscribe_from_topic(self, topic: str):
"""取消订阅主题"""
message = {
"type": "unsubscribe",
"topic": topic
}
return await self.send_message(message)
async def ping(self):
"""发送心跳"""
message = {
"type": "ping"
}
return await self.send_message(message)
async def get_status(self):
"""获取连接状态"""
message = {
"type": "get_status"
}
return await self.send_message(message)
async def listen_for_messages(self, duration: int = 30):
"""监听消息"""
if not self.connected:
logger.error("未连接到服务器")
return
logger.info(f"开始监听消息,持续 {duration} 秒...")
start_time = datetime.now()
try:
while self.connected:
try:
# 设置超时
message = await asyncio.wait_for(
self.websocket.recv(),
timeout=1.0
)
# 解析消息
data = json.loads(message)
await self.handle_message(data)
# 检查是否超时
if (datetime.now() - start_time).seconds >= duration:
logger.info("监听时间已到,停止监听")
break
except asyncio.TimeoutError:
# 超时继续监听
continue
except Exception as e:
logger.error(f"接收消息失败: {e}")
break
except Exception as e:
logger.error(f"监听消息时出错: {e}")
async def handle_message(self, data: dict):
"""处理接收到的消息"""
message_type = data.get("type", "unknown")
timestamp = data.get("timestamp", "")
print(f"\n📨 收到消息 [{timestamp}]:")
print(f" 类型: {message_type}")
if message_type == "task_update":
task_id = data.get("task_id", "")
status = data.get("status", "")
progress = data.get("progress", 0)
message = data.get("message", "")
print(f" 📋 任务: {task_id}")
print(f" 📈 状态: {status}")
print(f" 📊 进度: {progress}%")
print(f" 💬 消息: {message}")
elif message_type == "project_update":
project_id = data.get("project_id", "")
status = data.get("status", "")
progress = data.get("progress", 0)
message = data.get("message", "")
print(f" 📁 项目: {project_id}")
print(f" 📈 状态: {status}")
print(f" 📊 进度: {progress}%")
print(f" 💬 消息: {message}")
elif message_type == "system_notification":
title = data.get("title", "")
message = data.get("message", "")
level = data.get("level", "info")
print(f" 📢 标题: {title}")
print(f" 💬 消息: {message}")
print(f" 🎯 级别: {level}")
elif message_type == "error_notification":
error_type = data.get("error_type", "")
error_message = data.get("error_message", "")
print(f" ⚠️ 错误类型: {error_type}")
print(f" 💬 错误消息: {error_message}")
elif message_type == "pong":
print(f" 🏓 心跳响应")
elif message_type == "status":
connected = data.get("connected", False)
subscriptions = data.get("subscriptions", [])
total_connections = data.get("total_connections", 0)
print(f" 🔗 连接状态: {'已连接' if connected else '未连接'}")
print(f" 📋 订阅主题: {subscriptions}")
print(f" 👥 总连接数: {total_connections}")
else:
print(f" 📄 原始数据: {data}")
async def demo_websocket_client():
"""演示WebSocket客户端功能"""
print("🎯 WebSocket客户端演示")
print("=" * 50)
# 创建客户端
client = WebSocketClient("demo-user-001")
try:
# 1. 连接到服务器
print("1. 🔗 连接到WebSocket服务器...")
if not await client.connect():
print(" ❌ 连接失败")
return False
print(" ✅ 连接成功")
# 2. 获取连接状态
print("\n2. 📊 获取连接状态...")
await client.get_status()
await asyncio.sleep(1)
# 3. 订阅主题
print("\n3. 📋 订阅主题...")
topics = ["task_demo-task-001", "project_demo-project-001", "system"]
for topic in topics:
await client.subscribe_to_topic(topic)
await asyncio.sleep(0.5)
# 4. 发送心跳
print("\n4. 🏓 发送心跳...")
await client.ping()
await asyncio.sleep(1)
# 5. 监听消息
print("\n5. 📨 开始监听消息...")
print(" 💡 提示:现在可以运行 demo_websocket.py 来发送测试消息")
print(" ⏰ 监听将持续30秒...")
# 创建监听任务
listen_task = asyncio.create_task(client.listen_for_messages(30))
# 等待监听完成
await listen_task
# 6. 取消订阅
print("\n6. 📋 取消订阅...")
for topic in topics:
await client.unsubscribe_from_topic(topic)
await asyncio.sleep(0.5)
# 7. 断开连接
print("\n7. 🔌 断开连接...")
await client.disconnect()
print("\n🎉 WebSocket客户端演示完成!")
return True
except Exception as e:
print(f"❌ 演示失败: {e}")
return False
if __name__ == "__main__":
success = asyncio.run(demo_websocket_client())
exit(0 if success else 1)
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""
修复backend目录下的导入路径
"""
import os
import re
def fix_imports_in_file(file_path):
"""修复单个文件中的导入路径"""
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
# 替换 from backend. 为相对导入
original_content = content
content = re.sub(r'from backend\.', 'from ', content)
if content != original_content:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(content)
print(f"Fixed: {file_path}")
return True
return False
def main():
"""主函数"""
backend_dir = '/Users/zhoukk/autoclip/backend'
fixed_count = 0
for root, dirs, files in os.walk(backend_dir):
for file in files:
if file.endswith('.py'):
file_path = os.path.join(root, file)
if fix_imports_in_file(file_path):
fixed_count += 1
print(f"\nTotal files fixed: {fixed_count}")
if __name__ == '__main__':
main()
+2 -2
View File
@@ -835,8 +835,8 @@ import {
create,
getTwoToneColor,
setTwoToneColor
} from "./chunk-HWALLW6A.js";
import "./chunk-4JNVF2QO.js";
} from "./chunk-X7BPSMZA.js";
import "./chunk-66ZF2VOI.js";
import "./chunk-QJTFJ6OV.js";
import "./chunk-V4OQ3NZ2.js";
export {
+47 -35
View File
@@ -2,112 +2,133 @@
"hash": "9d37e763",
"configHash": "8de57516",
"lockfileHash": "0fe605fb",
"browserHash": "dc774958",
"browserHash": "1cf0d51b",
"optimized": {
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "e29de45d",
"fileHash": "f7ce770a",
"needsInterop": true
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "df25dae6",
"fileHash": "f949a0db",
"needsInterop": true
},
"react/jsx-dev-runtime": {
"src": "../../react/jsx-dev-runtime.js",
"file": "react_jsx-dev-runtime.js",
"fileHash": "1b7b46ff",
"fileHash": "c56bee2a",
"needsInterop": true
},
"react/jsx-runtime": {
"src": "../../react/jsx-runtime.js",
"file": "react_jsx-runtime.js",
"fileHash": "00d6a67c",
"fileHash": "4a65b026",
"needsInterop": true
},
"@ant-design/icons": {
"src": "../../@ant-design/icons/es/index.js",
"file": "@ant-design_icons.js",
"fileHash": "81655c32",
"fileHash": "b3cb3c25",
"needsInterop": false
},
"antd": {
"src": "../../antd/es/index.js",
"file": "antd.js",
"fileHash": "93b83763",
"fileHash": "df84fd8a",
"needsInterop": false
},
"antd/locale/zh_CN": {
"src": "../../antd/locale/zh_CN.js",
"file": "antd_locale_zh_CN.js",
"fileHash": "b904130e",
"fileHash": "418c17f2",
"needsInterop": true
},
"axios": {
"src": "../../axios/index.js",
"file": "axios.js",
"fileHash": "5d2b9067",
"fileHash": "31b533d9",
"needsInterop": false
},
"dayjs": {
"src": "../../dayjs/dayjs.min.js",
"file": "dayjs.js",
"fileHash": "77fdcba2",
"fileHash": "9b4d2933",
"needsInterop": true
},
"dayjs/locale/zh-cn": {
"src": "../../dayjs/locale/zh-cn.js",
"file": "dayjs_locale_zh-cn.js",
"fileHash": "3cdee437",
"fileHash": "a0518c78",
"needsInterop": true
},
"dayjs/plugin/relativeTime": {
"src": "../../dayjs/plugin/relativeTime.js",
"file": "dayjs_plugin_relativeTime.js",
"fileHash": "e9462706",
"fileHash": "0b5805c9",
"needsInterop": true
},
"react-beautiful-dnd": {
"src": "../../react-beautiful-dnd/dist/react-beautiful-dnd.esm.js",
"file": "react-beautiful-dnd.js",
"fileHash": "b2d5abf1",
"fileHash": "9d54abab",
"needsInterop": false
},
"react-dom/client": {
"src": "../../react-dom/client.js",
"file": "react-dom_client.js",
"fileHash": "09ddd4c1",
"fileHash": "7a4b6fa8",
"needsInterop": true
},
"react-dropzone": {
"src": "../../react-dropzone/dist/es/index.js",
"file": "react-dropzone.js",
"fileHash": "cea2e1ae",
"fileHash": "2873c972",
"needsInterop": false
},
"react-player": {
"src": "../../react-player/lib/index.js",
"file": "react-player.js",
"fileHash": "1a73b58d",
"fileHash": "e7b23ed2",
"needsInterop": true
},
"react-router-dom": {
"src": "../../react-router-dom/dist/index.js",
"file": "react-router-dom.js",
"fileHash": "71572fe7",
"fileHash": "eca96e1d",
"needsInterop": false
},
"zustand": {
"src": "../../zustand/esm/index.mjs",
"file": "zustand.js",
"fileHash": "a982e874",
"fileHash": "e892f14b",
"needsInterop": false
},
"dayjs/plugin/timezone": {
"src": "../../dayjs/plugin/timezone.js",
"file": "dayjs_plugin_timezone.js",
"fileHash": "c573eedd",
"needsInterop": true
},
"dayjs/plugin/utc": {
"src": "../../dayjs/plugin/utc.js",
"file": "dayjs_plugin_utc.js",
"fileHash": "1de96265",
"needsInterop": true
}
},
"chunks": {
"Preview-TKE3YBNZ": {
"file": "Preview-TKE3YBNZ.js"
},
"Streamable-S7PKL4XG": {
"file": "Streamable-S7PKL4XG.js"
},
"Wistia-Q2EBW457": {
"file": "Wistia-Q2EBW457.js"
},
"Twitch-26ES2YRZ": {
"file": "Twitch-26ES2YRZ.js"
},
@@ -126,9 +147,6 @@
"FilePlayer-MNM5SGEG": {
"file": "FilePlayer-MNM5SGEG.js"
},
"Preview-TKE3YBNZ": {
"file": "Preview-TKE3YBNZ.js"
},
"YouTube-SBPGO6SV": {
"file": "YouTube-SBPGO6SV.js"
},
@@ -144,11 +162,11 @@
"Facebook-2SVAHVW5": {
"file": "Facebook-2SVAHVW5.js"
},
"Streamable-S7PKL4XG": {
"file": "Streamable-S7PKL4XG.js"
"chunk-WT22ZCGG": {
"file": "chunk-WT22ZCGG.js"
},
"Wistia-Q2EBW457": {
"file": "Wistia-Q2EBW457.js"
"chunk-4FTWOKSW": {
"file": "chunk-4FTWOKSW.js"
},
"chunk-XQD3CSF6": {
"file": "chunk-XQD3CSF6.js"
@@ -159,20 +177,14 @@
"chunk-7TYQWO6A": {
"file": "chunk-7TYQWO6A.js"
},
"chunk-4FTWOKSW": {
"file": "chunk-4FTWOKSW.js"
},
"chunk-GKJBSOWT": {
"file": "chunk-GKJBSOWT.js"
},
"chunk-WT22ZCGG": {
"file": "chunk-WT22ZCGG.js"
"chunk-X7BPSMZA": {
"file": "chunk-X7BPSMZA.js"
},
"chunk-HWALLW6A": {
"file": "chunk-HWALLW6A.js"
},
"chunk-4JNVF2QO": {
"file": "chunk-4JNVF2QO.js"
"chunk-66ZF2VOI": {
"file": "chunk-66ZF2VOI.js"
},
"chunk-QJTFJ6OV": {
"file": "chunk-QJTFJ6OV.js"
+5 -5
View File
@@ -1,13 +1,13 @@
"use client";
import {
require_dayjs_min
} from "./chunk-WT22ZCGG.js";
import {
_setPrototypeOf
} from "./chunk-4FTWOKSW.js";
import {
require_react_dom
} from "./chunk-GKJBSOWT.js";
import {
require_dayjs_min
} from "./chunk-WT22ZCGG.js";
import {
BarsOutlined_default,
CalendarOutlined_default,
@@ -90,14 +90,14 @@ import {
useMemo,
warning,
warning_default
} from "./chunk-HWALLW6A.js";
} from "./chunk-X7BPSMZA.js";
import {
_defineProperty,
_extends,
_objectSpread2,
_typeof,
toPropertyKey
} from "./chunk-4JNVF2QO.js";
} from "./chunk-66ZF2VOI.js";
import {
require_react
} from "./chunk-QJTFJ6OV.js";
@@ -47,6 +47,17 @@ function _defineProperty(e, r, t) {
}) : e[r] = t, e;
}
// node_modules/@babel/runtime/helpers/esm/objectWithoutPropertiesLoose.js
function _objectWithoutPropertiesLoose(r, e) {
if (null == r) return {};
var t = {};
for (var n in r) if ({}.hasOwnProperty.call(r, n)) {
if (-1 !== e.indexOf(n)) continue;
t[n] = r[n];
}
return t;
}
// node_modules/@babel/runtime/helpers/esm/objectSpread2.js
function ownKeys(e, r) {
var t = Object.keys(e);
@@ -70,23 +81,12 @@ function _objectSpread2(e) {
return e;
}
// node_modules/@babel/runtime/helpers/esm/objectWithoutPropertiesLoose.js
function _objectWithoutPropertiesLoose(r, e) {
if (null == r) return {};
var t = {};
for (var n in r) if ({}.hasOwnProperty.call(r, n)) {
if (-1 !== e.indexOf(n)) continue;
t[n] = r[n];
}
return t;
}
export {
_extends,
_typeof,
toPropertyKey,
_defineProperty,
_objectSpread2,
_objectWithoutPropertiesLoose
_objectWithoutPropertiesLoose,
_objectSpread2
};
//# sourceMappingURL=chunk-4JNVF2QO.js.map
//# sourceMappingURL=chunk-66ZF2VOI.js.map
@@ -1,7 +1,7 @@
{
"version": 3,
"sources": ["../../@babel/runtime/helpers/esm/extends.js", "../../@babel/runtime/helpers/esm/typeof.js", "../../@babel/runtime/helpers/esm/toPrimitive.js", "../../@babel/runtime/helpers/esm/toPropertyKey.js", "../../@babel/runtime/helpers/esm/defineProperty.js", "../../@babel/runtime/helpers/esm/objectSpread2.js", "../../@babel/runtime/helpers/esm/objectWithoutPropertiesLoose.js"],
"sourcesContent": ["function _extends() {\n return _extends = Object.assign ? Object.assign.bind() : function (n) {\n for (var e = 1; e < arguments.length; e++) {\n var t = arguments[e];\n for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]);\n }\n return n;\n }, _extends.apply(null, arguments);\n}\nexport { _extends as default };", "function _typeof(o) {\n \"@babel/helpers - typeof\";\n\n return _typeof = \"function\" == typeof Symbol && \"symbol\" == typeof Symbol.iterator ? function (o) {\n return typeof o;\n } : function (o) {\n return o && \"function\" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? \"symbol\" : typeof o;\n }, _typeof(o);\n}\nexport { _typeof as default };", "import _typeof from \"./typeof.js\";\nfunction toPrimitive(t, r) {\n if (\"object\" != _typeof(t) || !t) return t;\n var e = t[Symbol.toPrimitive];\n if (void 0 !== e) {\n var i = e.call(t, r || \"default\");\n if (\"object\" != _typeof(i)) return i;\n throw new TypeError(\"@@toPrimitive must return a primitive value.\");\n }\n return (\"string\" === r ? String : Number)(t);\n}\nexport { toPrimitive as default };", "import _typeof from \"./typeof.js\";\nimport toPrimitive from \"./toPrimitive.js\";\nfunction toPropertyKey(t) {\n var i = toPrimitive(t, \"string\");\n return \"symbol\" == _typeof(i) ? i : i + \"\";\n}\nexport { toPropertyKey as default };", "import toPropertyKey from \"./toPropertyKey.js\";\nfunction _defineProperty(e, r, t) {\n return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {\n value: t,\n enumerable: !0,\n configurable: !0,\n writable: !0\n }) : e[r] = t, e;\n}\nexport { _defineProperty as default };", "import defineProperty from \"./defineProperty.js\";\nfunction ownKeys(e, r) {\n var t = Object.keys(e);\n if (Object.getOwnPropertySymbols) {\n var o = Object.getOwnPropertySymbols(e);\n r && (o = o.filter(function (r) {\n return Object.getOwnPropertyDescriptor(e, r).enumerable;\n })), t.push.apply(t, o);\n }\n return t;\n}\nfunction _objectSpread2(e) {\n for (var r = 1; r < arguments.length; r++) {\n var t = null != arguments[r] ? arguments[r] : {};\n r % 2 ? ownKeys(Object(t), !0).forEach(function (r) {\n defineProperty(e, r, t[r]);\n }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) {\n Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r));\n });\n }\n return e;\n}\nexport { _objectSpread2 as default };", "function _objectWithoutPropertiesLoose(r, e) {\n if (null == r) return {};\n var t = {};\n for (var n in r) if ({}.hasOwnProperty.call(r, n)) {\n if (-1 !== e.indexOf(n)) continue;\n t[n] = r[n];\n }\n return t;\n}\nexport { _objectWithoutPropertiesLoose as default };"],
"mappings": "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",
"sources": ["../../@babel/runtime/helpers/esm/extends.js", "../../@babel/runtime/helpers/esm/typeof.js", "../../@babel/runtime/helpers/esm/toPrimitive.js", "../../@babel/runtime/helpers/esm/toPropertyKey.js", "../../@babel/runtime/helpers/esm/defineProperty.js", "../../@babel/runtime/helpers/esm/objectWithoutPropertiesLoose.js", "../../@babel/runtime/helpers/esm/objectSpread2.js"],
"sourcesContent": ["function _extends() {\n return _extends = Object.assign ? Object.assign.bind() : function (n) {\n for (var e = 1; e < arguments.length; e++) {\n var t = arguments[e];\n for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]);\n }\n return n;\n }, _extends.apply(null, arguments);\n}\nexport { _extends as default };", "function _typeof(o) {\n \"@babel/helpers - typeof\";\n\n return _typeof = \"function\" == typeof Symbol && \"symbol\" == typeof Symbol.iterator ? function (o) {\n return typeof o;\n } : function (o) {\n return o && \"function\" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? \"symbol\" : typeof o;\n }, _typeof(o);\n}\nexport { _typeof as default };", "import _typeof from \"./typeof.js\";\nfunction toPrimitive(t, r) {\n if (\"object\" != _typeof(t) || !t) return t;\n var e = t[Symbol.toPrimitive];\n if (void 0 !== e) {\n var i = e.call(t, r || \"default\");\n if (\"object\" != _typeof(i)) return i;\n throw new TypeError(\"@@toPrimitive must return a primitive value.\");\n }\n return (\"string\" === r ? String : Number)(t);\n}\nexport { toPrimitive as default };", "import _typeof from \"./typeof.js\";\nimport toPrimitive from \"./toPrimitive.js\";\nfunction toPropertyKey(t) {\n var i = toPrimitive(t, \"string\");\n return \"symbol\" == _typeof(i) ? i : i + \"\";\n}\nexport { toPropertyKey as default };", "import toPropertyKey from \"./toPropertyKey.js\";\nfunction _defineProperty(e, r, t) {\n return (r = toPropertyKey(r)) in e ? Object.defineProperty(e, r, {\n value: t,\n enumerable: !0,\n configurable: !0,\n writable: !0\n }) : e[r] = t, e;\n}\nexport { _defineProperty as default };", "function _objectWithoutPropertiesLoose(r, e) {\n if (null == r) return {};\n var t = {};\n for (var n in r) if ({}.hasOwnProperty.call(r, n)) {\n if (-1 !== e.indexOf(n)) continue;\n t[n] = r[n];\n }\n return t;\n}\nexport { _objectWithoutPropertiesLoose as default };", "import defineProperty from \"./defineProperty.js\";\nfunction ownKeys(e, r) {\n var t = Object.keys(e);\n if (Object.getOwnPropertySymbols) {\n var o = Object.getOwnPropertySymbols(e);\n r && (o = o.filter(function (r) {\n return Object.getOwnPropertyDescriptor(e, r).enumerable;\n })), t.push.apply(t, o);\n }\n return t;\n}\nfunction _objectSpread2(e) {\n for (var r = 1; r < arguments.length; r++) {\n var t = null != arguments[r] ? arguments[r] : {};\n r % 2 ? ownKeys(Object(t), !0).forEach(function (r) {\n defineProperty(e, r, t[r]);\n }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) {\n Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r));\n });\n }\n return e;\n}\nexport { _objectSpread2 as default };"],
"mappings": "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",
"names": ["o", "r"]
}
@@ -4,7 +4,7 @@ import {
_objectSpread2,
_objectWithoutPropertiesLoose,
_typeof
} from "./chunk-4JNVF2QO.js";
} from "./chunk-66ZF2VOI.js";
import {
require_react
} from "./chunk-QJTFJ6OV.js";
@@ -18535,68 +18535,30 @@ function create() {
var IconProvider = Context_default.Provider;
export {
require_classnames,
isFragment,
warning,
noteOnce,
warning_default,
useMemo,
fillRef,
composeRef,
useComposeRef,
supportRef,
supportNodeRef,
getNodeRef,
Context_default,
_arrayWithHoles,
_arrayLikeToArray,
_unsupportedIterableToArray,
_arrayWithHoles,
_nonIterableRest,
_slicedToArray,
canUseDom,
contains,
removeCSS,
updateCSS,
_objectWithoutProperties,
Context_default,
require_classnames,
FastColor,
generate,
presetPrimaryColors,
gold,
blue,
presetPalettes,
canUseDom,
contains,
removeCSS,
updateCSS,
getShadowRoot,
warning,
noteOnce,
warning_default,
setTwoToneColor,
getTwoToneColor,
CheckCircleFilled_default2 as CheckCircleFilled_default,
CloseCircleFilled_default2 as CloseCircleFilled_default,
CloseOutlined_default2 as CloseOutlined_default,
ExclamationCircleFilled_default2 as ExclamationCircleFilled_default,
InfoCircleFilled_default2 as InfoCircleFilled_default,
LoadingOutlined_default2 as LoadingOutlined_default,
RightOutlined_default2 as RightOutlined_default,
CheckOutlined_default2 as CheckOutlined_default,
DownOutlined_default2 as DownOutlined_default,
SearchOutlined_default2 as SearchOutlined_default,
VerticalAlignTopOutlined_default2 as VerticalAlignTopOutlined_default,
LeftOutlined_default2 as LeftOutlined_default,
BarsOutlined_default2 as BarsOutlined_default,
EllipsisOutlined_default2 as EllipsisOutlined_default,
PlusOutlined_default2 as PlusOutlined_default,
UpOutlined_default2 as UpOutlined_default,
CalendarOutlined_default2 as CalendarOutlined_default,
ClockCircleOutlined_default2 as ClockCircleOutlined_default,
SwapRightOutlined_default2 as SwapRightOutlined_default,
FileTextOutlined_default2 as FileTextOutlined_default,
QuestionCircleOutlined_default2 as QuestionCircleOutlined_default,
EyeOutlined_default2 as EyeOutlined_default,
RotateLeftOutlined_default2 as RotateLeftOutlined_default,
RotateRightOutlined_default2 as RotateRightOutlined_default,
SwapOutlined_default2 as SwapOutlined_default,
ZoomInOutlined_default2 as ZoomInOutlined_default,
ZoomOutOutlined_default2 as ZoomOutOutlined_default,
EyeInvisibleOutlined_default2 as EyeInvisibleOutlined_default,
DoubleLeftOutlined_default2 as DoubleLeftOutlined_default,
DoubleRightOutlined_default2 as DoubleRightOutlined_default,
AccountBookFilled_default2 as AccountBookFilled_default,
AccountBookOutlined_default2 as AccountBookOutlined_default,
AccountBookTwoTone_default2 as AccountBookTwoTone_default,
@@ -18651,6 +18613,7 @@ export {
BankTwoTone_default2 as BankTwoTone_default,
BarChartOutlined_default2 as BarChartOutlined_default,
BarcodeOutlined_default2 as BarcodeOutlined_default,
BarsOutlined_default2 as BarsOutlined_default,
BehanceCircleFilled_default2 as BehanceCircleFilled_default,
BehanceOutlined_default2 as BehanceOutlined_default,
BehanceSquareFilled_default2 as BehanceSquareFilled_default,
@@ -18693,6 +18656,7 @@ export {
CalculatorOutlined_default2 as CalculatorOutlined_default,
CalculatorTwoTone_default2 as CalculatorTwoTone_default,
CalendarFilled_default2 as CalendarFilled_default,
CalendarOutlined_default2 as CalendarOutlined_default,
CalendarTwoTone_default2 as CalendarTwoTone_default,
CameraFilled_default2 as CameraFilled_default,
CameraOutlined_default2 as CameraOutlined_default,
@@ -18711,8 +18675,10 @@ export {
CarryOutFilled_default2 as CarryOutFilled_default,
CarryOutOutlined_default2 as CarryOutOutlined_default,
CarryOutTwoTone_default2 as CarryOutTwoTone_default,
CheckCircleFilled_default2 as CheckCircleFilled_default,
CheckCircleOutlined_default2 as CheckCircleOutlined_default,
CheckCircleTwoTone_default2 as CheckCircleTwoTone_default,
CheckOutlined_default2 as CheckOutlined_default,
CheckSquareFilled_default2 as CheckSquareFilled_default,
CheckSquareOutlined_default2 as CheckSquareOutlined_default,
CheckSquareTwoTone_default2 as CheckSquareTwoTone_default,
@@ -18725,9 +18691,12 @@ export {
CiTwoTone_default2 as CiTwoTone_default,
ClearOutlined_default2 as ClearOutlined_default,
ClockCircleFilled_default2 as ClockCircleFilled_default,
ClockCircleOutlined_default2 as ClockCircleOutlined_default,
ClockCircleTwoTone_default2 as ClockCircleTwoTone_default,
CloseCircleFilled_default2 as CloseCircleFilled_default,
CloseCircleOutlined_default2 as CloseCircleOutlined_default,
CloseCircleTwoTone_default2 as CloseCircleTwoTone_default,
CloseOutlined_default2 as CloseOutlined_default,
CloseSquareFilled_default2 as CloseSquareFilled_default,
CloseSquareOutlined_default2 as CloseSquareOutlined_default,
CloseSquareTwoTone_default2 as CloseSquareTwoTone_default,
@@ -18820,9 +18789,12 @@ export {
DollarTwoTone_default2 as DollarTwoTone_default,
DotChartOutlined_default2 as DotChartOutlined_default,
DotNetOutlined_default2 as DotNetOutlined_default,
DoubleLeftOutlined_default2 as DoubleLeftOutlined_default,
DoubleRightOutlined_default2 as DoubleRightOutlined_default,
DownCircleFilled_default2 as DownCircleFilled_default,
DownCircleOutlined_default2 as DownCircleOutlined_default,
DownCircleTwoTone_default2 as DownCircleTwoTone_default,
DownOutlined_default2 as DownOutlined_default,
DownSquareFilled_default2 as DownSquareFilled_default,
DownSquareOutlined_default2 as DownSquareOutlined_default,
DownSquareTwoTone_default2 as DownSquareTwoTone_default,
@@ -18838,6 +18810,7 @@ export {
EditFilled_default2 as EditFilled_default,
EditOutlined_default2 as EditOutlined_default,
EditTwoTone_default2 as EditTwoTone_default,
EllipsisOutlined_default2 as EllipsisOutlined_default,
EnterOutlined_default2 as EnterOutlined_default,
EnvironmentFilled_default2 as EnvironmentFilled_default,
EnvironmentOutlined_default2 as EnvironmentOutlined_default,
@@ -18848,6 +18821,7 @@ export {
EuroOutlined_default2 as EuroOutlined_default,
EuroTwoTone_default2 as EuroTwoTone_default,
ExceptionOutlined_default2 as ExceptionOutlined_default,
ExclamationCircleFilled_default2 as ExclamationCircleFilled_default,
ExclamationCircleOutlined_default2 as ExclamationCircleOutlined_default,
ExclamationCircleTwoTone_default2 as ExclamationCircleTwoTone_default,
ExclamationOutlined_default2 as ExclamationOutlined_default,
@@ -18859,7 +18833,9 @@ export {
ExportOutlined_default2 as ExportOutlined_default,
EyeFilled_default2 as EyeFilled_default,
EyeInvisibleFilled_default2 as EyeInvisibleFilled_default,
EyeInvisibleOutlined_default2 as EyeInvisibleOutlined_default,
EyeInvisibleTwoTone_default2 as EyeInvisibleTwoTone_default,
EyeOutlined_default2 as EyeOutlined_default,
EyeTwoTone_default2 as EyeTwoTone_default,
FacebookFilled_default2 as FacebookFilled_default,
FacebookOutlined_default2 as FacebookOutlined_default,
@@ -18902,6 +18878,7 @@ export {
FileSearchOutlined_default2 as FileSearchOutlined_default,
FileSyncOutlined_default2 as FileSyncOutlined_default,
FileTextFilled_default2 as FileTextFilled_default,
FileTextOutlined_default2 as FileTextOutlined_default,
FileTextTwoTone_default2 as FileTextTwoTone_default,
FileTwoTone_default2 as FileTwoTone_default,
FileUnknownFilled_default2 as FileUnknownFilled_default,
@@ -19005,6 +18982,7 @@ export {
IeSquareFilled_default2 as IeSquareFilled_default,
ImportOutlined_default2 as ImportOutlined_default,
InboxOutlined_default2 as InboxOutlined_default,
InfoCircleFilled_default2 as InfoCircleFilled_default,
InfoCircleOutlined_default2 as InfoCircleOutlined_default,
InfoCircleTwoTone_default2 as InfoCircleTwoTone_default,
InfoOutlined_default2 as InfoOutlined_default,
@@ -19033,6 +19011,7 @@ export {
LeftCircleFilled_default2 as LeftCircleFilled_default,
LeftCircleOutlined_default2 as LeftCircleOutlined_default,
LeftCircleTwoTone_default2 as LeftCircleTwoTone_default,
LeftOutlined_default2 as LeftOutlined_default,
LeftSquareFilled_default2 as LeftSquareFilled_default,
LeftSquareOutlined_default2 as LeftSquareOutlined_default,
LeftSquareTwoTone_default2 as LeftSquareTwoTone_default,
@@ -19047,6 +19026,7 @@ export {
LinkedinOutlined_default2 as LinkedinOutlined_default,
LinuxOutlined_default2 as LinuxOutlined_default,
Loading3QuartersOutlined_default2 as Loading3QuartersOutlined_default,
LoadingOutlined_default2 as LoadingOutlined_default,
LockFilled_default2 as LockFilled_default,
LockOutlined_default2 as LockOutlined_default,
LockTwoTone_default2 as LockTwoTone_default,
@@ -19139,6 +19119,7 @@ export {
PlusCircleFilled_default2 as PlusCircleFilled_default,
PlusCircleOutlined_default2 as PlusCircleOutlined_default,
PlusCircleTwoTone_default2 as PlusCircleTwoTone_default,
PlusOutlined_default2 as PlusOutlined_default,
PlusSquareFilled_default2 as PlusSquareFilled_default,
PlusSquareOutlined_default2 as PlusSquareOutlined_default,
PlusSquareTwoTone_default2 as PlusSquareTwoTone_default,
@@ -19171,6 +19152,7 @@ export {
QqSquareFilled_default2 as QqSquareFilled_default,
QrcodeOutlined_default2 as QrcodeOutlined_default,
QuestionCircleFilled_default2 as QuestionCircleFilled_default,
QuestionCircleOutlined_default2 as QuestionCircleOutlined_default,
QuestionCircleTwoTone_default2 as QuestionCircleTwoTone_default,
QuestionOutlined_default2 as QuestionOutlined_default,
RadarChartOutlined_default2 as RadarChartOutlined_default,
@@ -19199,6 +19181,7 @@ export {
RightCircleFilled_default2 as RightCircleFilled_default,
RightCircleOutlined_default2 as RightCircleOutlined_default,
RightCircleTwoTone_default2 as RightCircleTwoTone_default,
RightOutlined_default2 as RightOutlined_default,
RightSquareFilled_default2 as RightSquareFilled_default,
RightSquareOutlined_default2 as RightSquareOutlined_default,
RightSquareTwoTone_default2 as RightSquareTwoTone_default,
@@ -19209,6 +19192,8 @@ export {
RocketOutlined_default2 as RocketOutlined_default,
RocketTwoTone_default2 as RocketTwoTone_default,
RollbackOutlined_default2 as RollbackOutlined_default,
RotateLeftOutlined_default2 as RotateLeftOutlined_default,
RotateRightOutlined_default2 as RotateRightOutlined_default,
RubyOutlined_default2 as RubyOutlined_default,
SafetyCertificateFilled_default2 as SafetyCertificateFilled_default,
SafetyCertificateOutlined_default2 as SafetyCertificateOutlined_default,
@@ -19222,6 +19207,7 @@ export {
ScheduleOutlined_default2 as ScheduleOutlined_default,
ScheduleTwoTone_default2 as ScheduleTwoTone_default,
ScissorOutlined_default2 as ScissorOutlined_default,
SearchOutlined_default2 as SearchOutlined_default,
SecurityScanFilled_default2 as SecurityScanFilled_default,
SecurityScanOutlined_default2 as SecurityScanOutlined_default,
SecurityScanTwoTone_default2 as SecurityScanTwoTone_default,
@@ -19291,6 +19277,8 @@ export {
SunFilled_default2 as SunFilled_default,
SunOutlined_default2 as SunOutlined_default,
SwapLeftOutlined_default2 as SwapLeftOutlined_default,
SwapOutlined_default2 as SwapOutlined_default,
SwapRightOutlined_default2 as SwapRightOutlined_default,
SwitcherFilled_default2 as SwitcherFilled_default,
SwitcherOutlined_default2 as SwitcherOutlined_default,
SwitcherTwoTone_default2 as SwitcherTwoTone_default,
@@ -19345,6 +19333,7 @@ export {
UpCircleFilled_default2 as UpCircleFilled_default,
UpCircleOutlined_default2 as UpCircleOutlined_default,
UpCircleTwoTone_default2 as UpCircleTwoTone_default,
UpOutlined_default2 as UpOutlined_default,
UpSquareFilled_default2 as UpSquareFilled_default,
UpSquareOutlined_default2 as UpSquareOutlined_default,
UpSquareTwoTone_default2 as UpSquareTwoTone_default,
@@ -19361,6 +19350,7 @@ export {
VerifiedOutlined_default2 as VerifiedOutlined_default,
VerticalAlignBottomOutlined_default2 as VerticalAlignBottomOutlined_default,
VerticalAlignMiddleOutlined_default2 as VerticalAlignMiddleOutlined_default,
VerticalAlignTopOutlined_default2 as VerticalAlignTopOutlined_default,
VerticalLeftOutlined_default2 as VerticalLeftOutlined_default,
VerticalRightOutlined_default2 as VerticalRightOutlined_default,
VideoCameraAddOutlined_default2 as VideoCameraAddOutlined_default,
@@ -19398,6 +19388,16 @@ export {
ZhihuCircleFilled_default2 as ZhihuCircleFilled_default,
ZhihuOutlined_default2 as ZhihuOutlined_default,
ZhihuSquareFilled_default2 as ZhihuSquareFilled_default,
ZoomInOutlined_default2 as ZoomInOutlined_default,
ZoomOutOutlined_default2 as ZoomOutOutlined_default,
useMemo,
isFragment,
fillRef,
composeRef,
useComposeRef,
supportRef,
supportNodeRef,
getNodeRef,
Icon_default,
create,
IconProvider
@@ -19422,4 +19422,4 @@ react-is/cjs/react-is.development.js:
* LICENSE file in the root directory of this source tree.
*)
*/
//# sourceMappingURL=chunk-HWALLW6A.js.map
//# sourceMappingURL=chunk-X7BPSMZA.js.map
+70
View File
@@ -0,0 +1,70 @@
import {
__commonJS
} from "./chunk-V4OQ3NZ2.js";
// node_modules/dayjs/plugin/timezone.js
var require_timezone = __commonJS({
"node_modules/dayjs/plugin/timezone.js"(exports, module) {
!function(t, e) {
"object" == typeof exports && "undefined" != typeof module ? module.exports = e() : "function" == typeof define && define.amd ? define(e) : (t = "undefined" != typeof globalThis ? globalThis : t || self).dayjs_plugin_timezone = e();
}(exports, function() {
"use strict";
var t = { year: 0, month: 1, day: 2, hour: 3, minute: 4, second: 5 }, e = {};
return function(n, i, o) {
var r, a = function(t2, n2, i2) {
void 0 === i2 && (i2 = {});
var o2 = new Date(t2), r2 = function(t3, n3) {
void 0 === n3 && (n3 = {});
var i3 = n3.timeZoneName || "short", o3 = t3 + "|" + i3, r3 = e[o3];
return r3 || (r3 = new Intl.DateTimeFormat("en-US", { hour12: false, timeZone: t3, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", timeZoneName: i3 }), e[o3] = r3), r3;
}(n2, i2);
return r2.formatToParts(o2);
}, u = function(e2, n2) {
for (var i2 = a(e2, n2), r2 = [], u2 = 0; u2 < i2.length; u2 += 1) {
var f2 = i2[u2], s2 = f2.type, m = f2.value, c = t[s2];
c >= 0 && (r2[c] = parseInt(m, 10));
}
var d = r2[3], l = 24 === d ? 0 : d, h = r2[0] + "-" + r2[1] + "-" + r2[2] + " " + l + ":" + r2[4] + ":" + r2[5] + ":000", v = +e2;
return (o.utc(h).valueOf() - (v -= v % 1e3)) / 6e4;
}, f = i.prototype;
f.tz = function(t2, e2) {
void 0 === t2 && (t2 = r);
var n2, i2 = this.utcOffset(), a2 = this.toDate(), u2 = a2.toLocaleString("en-US", { timeZone: t2 }), f2 = Math.round((a2 - new Date(u2)) / 1e3 / 60), s2 = 15 * -Math.round(a2.getTimezoneOffset() / 15) - f2;
if (!Number(s2)) n2 = this.utcOffset(0, e2);
else if (n2 = o(u2, { locale: this.$L }).$set("millisecond", this.$ms).utcOffset(s2, true), e2) {
var m = n2.utcOffset();
n2 = n2.add(i2 - m, "minute");
}
return n2.$x.$timezone = t2, n2;
}, f.offsetName = function(t2) {
var e2 = this.$x.$timezone || o.tz.guess(), n2 = a(this.valueOf(), e2, { timeZoneName: t2 }).find(function(t3) {
return "timezonename" === t3.type.toLowerCase();
});
return n2 && n2.value;
};
var s = f.startOf;
f.startOf = function(t2, e2) {
if (!this.$x || !this.$x.$timezone) return s.call(this, t2, e2);
var n2 = o(this.format("YYYY-MM-DD HH:mm:ss:SSS"), { locale: this.$L });
return s.call(n2, t2, e2).tz(this.$x.$timezone, true);
}, o.tz = function(t2, e2, n2) {
var i2 = n2 && e2, a2 = n2 || e2 || r, f2 = u(+o(), a2);
if ("string" != typeof t2) return o(t2).tz(a2);
var s2 = function(t3, e3, n3) {
var i3 = t3 - 60 * e3 * 1e3, o2 = u(i3, n3);
if (e3 === o2) return [i3, e3];
var r2 = u(i3 -= 60 * (o2 - e3) * 1e3, n3);
return o2 === r2 ? [i3, o2] : [t3 - 60 * Math.min(o2, r2) * 1e3, Math.max(o2, r2)];
}(o.utc(t2, i2).valueOf(), f2, a2), m = s2[0], c = s2[1], d = o(m).utcOffset(c);
return d.$x.$timezone = a2, d;
}, o.tz.guess = function() {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
}, o.tz.setDefault = function(t2) {
r = t2;
};
};
});
}
});
export default require_timezone();
//# sourceMappingURL=dayjs_plugin_timezone.js.map
+7
View File
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../dayjs/plugin/timezone.js"],
"sourcesContent": ["!function(t,e){\"object\"==typeof exports&&\"undefined\"!=typeof module?module.exports=e():\"function\"==typeof define&&define.amd?define(e):(t=\"undefined\"!=typeof globalThis?globalThis:t||self).dayjs_plugin_timezone=e()}(this,(function(){\"use strict\";var t={year:0,month:1,day:2,hour:3,minute:4,second:5},e={};return function(n,i,o){var r,a=function(t,n,i){void 0===i&&(i={});var o=new Date(t),r=function(t,n){void 0===n&&(n={});var i=n.timeZoneName||\"short\",o=t+\"|\"+i,r=e[o];return r||(r=new Intl.DateTimeFormat(\"en-US\",{hour12:!1,timeZone:t,year:\"numeric\",month:\"2-digit\",day:\"2-digit\",hour:\"2-digit\",minute:\"2-digit\",second:\"2-digit\",timeZoneName:i}),e[o]=r),r}(n,i);return r.formatToParts(o)},u=function(e,n){for(var i=a(e,n),r=[],u=0;u<i.length;u+=1){var f=i[u],s=f.type,m=f.value,c=t[s];c>=0&&(r[c]=parseInt(m,10))}var d=r[3],l=24===d?0:d,h=r[0]+\"-\"+r[1]+\"-\"+r[2]+\" \"+l+\":\"+r[4]+\":\"+r[5]+\":000\",v=+e;return(o.utc(h).valueOf()-(v-=v%1e3))/6e4},f=i.prototype;f.tz=function(t,e){void 0===t&&(t=r);var n,i=this.utcOffset(),a=this.toDate(),u=a.toLocaleString(\"en-US\",{timeZone:t}),f=Math.round((a-new Date(u))/1e3/60),s=15*-Math.round(a.getTimezoneOffset()/15)-f;if(!Number(s))n=this.utcOffset(0,e);else if(n=o(u,{locale:this.$L}).$set(\"millisecond\",this.$ms).utcOffset(s,!0),e){var m=n.utcOffset();n=n.add(i-m,\"minute\")}return n.$x.$timezone=t,n},f.offsetName=function(t){var e=this.$x.$timezone||o.tz.guess(),n=a(this.valueOf(),e,{timeZoneName:t}).find((function(t){return\"timezonename\"===t.type.toLowerCase()}));return n&&n.value};var s=f.startOf;f.startOf=function(t,e){if(!this.$x||!this.$x.$timezone)return s.call(this,t,e);var n=o(this.format(\"YYYY-MM-DD HH:mm:ss:SSS\"),{locale:this.$L});return s.call(n,t,e).tz(this.$x.$timezone,!0)},o.tz=function(t,e,n){var i=n&&e,a=n||e||r,f=u(+o(),a);if(\"string\"!=typeof t)return o(t).tz(a);var s=function(t,e,n){var i=t-60*e*1e3,o=u(i,n);if(e===o)return[i,e];var r=u(i-=60*(o-e)*1e3,n);return o===r?[i,o]:[t-60*Math.min(o,r)*1e3,Math.max(o,r)]}(o.utc(t,i).valueOf(),f,a),m=s[0],c=s[1],d=o(m).utcOffset(c);return d.$x.$timezone=a,d},o.tz.guess=function(){return Intl.DateTimeFormat().resolvedOptions().timeZone},o.tz.setDefault=function(t){r=t}}}));"],
"mappings": "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",
"names": ["t", "n", "i", "o", "r", "e", "u", "f", "s", "a"]
}
+83
View File
@@ -0,0 +1,83 @@
import {
__commonJS
} from "./chunk-V4OQ3NZ2.js";
// node_modules/dayjs/plugin/utc.js
var require_utc = __commonJS({
"node_modules/dayjs/plugin/utc.js"(exports, module) {
!function(t, i) {
"object" == typeof exports && "undefined" != typeof module ? module.exports = i() : "function" == typeof define && define.amd ? define(i) : (t = "undefined" != typeof globalThis ? globalThis : t || self).dayjs_plugin_utc = i();
}(exports, function() {
"use strict";
var t = "minute", i = /[+-]\d\d(?::?\d\d)?/g, e = /([+-]|\d\d)/g;
return function(s, f, n) {
var u = f.prototype;
n.utc = function(t2) {
var i2 = { date: t2, utc: true, args: arguments };
return new f(i2);
}, u.utc = function(i2) {
var e2 = n(this.toDate(), { locale: this.$L, utc: true });
return i2 ? e2.add(this.utcOffset(), t) : e2;
}, u.local = function() {
return n(this.toDate(), { locale: this.$L, utc: false });
};
var o = u.parse;
u.parse = function(t2) {
t2.utc && (this.$u = true), this.$utils().u(t2.$offset) || (this.$offset = t2.$offset), o.call(this, t2);
};
var r = u.init;
u.init = function() {
if (this.$u) {
var t2 = this.$d;
this.$y = t2.getUTCFullYear(), this.$M = t2.getUTCMonth(), this.$D = t2.getUTCDate(), this.$W = t2.getUTCDay(), this.$H = t2.getUTCHours(), this.$m = t2.getUTCMinutes(), this.$s = t2.getUTCSeconds(), this.$ms = t2.getUTCMilliseconds();
} else r.call(this);
};
var a = u.utcOffset;
u.utcOffset = function(s2, f2) {
var n2 = this.$utils().u;
if (n2(s2)) return this.$u ? 0 : n2(this.$offset) ? a.call(this) : this.$offset;
if ("string" == typeof s2 && (s2 = function(t2) {
void 0 === t2 && (t2 = "");
var s3 = t2.match(i);
if (!s3) return null;
var f3 = ("" + s3[0]).match(e) || ["-", 0, 0], n3 = f3[0], u3 = 60 * +f3[1] + +f3[2];
return 0 === u3 ? 0 : "+" === n3 ? u3 : -u3;
}(s2), null === s2)) return this;
var u2 = Math.abs(s2) <= 16 ? 60 * s2 : s2, o2 = this;
if (f2) return o2.$offset = u2, o2.$u = 0 === s2, o2;
if (0 !== s2) {
var r2 = this.$u ? this.toDate().getTimezoneOffset() : -1 * this.utcOffset();
(o2 = this.local().add(u2 + r2, t)).$offset = u2, o2.$x.$localOffset = r2;
} else o2 = this.utc();
return o2;
};
var h = u.format;
u.format = function(t2) {
var i2 = t2 || (this.$u ? "YYYY-MM-DDTHH:mm:ss[Z]" : "");
return h.call(this, i2);
}, u.valueOf = function() {
var t2 = this.$utils().u(this.$offset) ? 0 : this.$offset + (this.$x.$localOffset || this.$d.getTimezoneOffset());
return this.$d.valueOf() - 6e4 * t2;
}, u.isUTC = function() {
return !!this.$u;
}, u.toISOString = function() {
return this.toDate().toISOString();
}, u.toString = function() {
return this.toDate().toUTCString();
};
var l = u.toDate;
u.toDate = function(t2) {
return "s" === t2 && this.$offset ? n(this.format("YYYY-MM-DD HH:mm:ss:SSS")).toDate() : l.call(this);
};
var c = u.diff;
u.diff = function(t2, i2, e2) {
if (t2 && this.$u === t2.$u) return c.call(this, t2, i2, e2);
var s2 = this.local(), f2 = n(t2).local();
return c.call(s2, f2, i2, e2);
};
};
});
}
});
export default require_utc();
//# sourceMappingURL=dayjs_plugin_utc.js.map
+7
View File
@@ -0,0 +1,7 @@
{
"version": 3,
"sources": ["../../dayjs/plugin/utc.js"],
"sourcesContent": ["!function(t,i){\"object\"==typeof exports&&\"undefined\"!=typeof module?module.exports=i():\"function\"==typeof define&&define.amd?define(i):(t=\"undefined\"!=typeof globalThis?globalThis:t||self).dayjs_plugin_utc=i()}(this,(function(){\"use strict\";var t=\"minute\",i=/[+-]\\d\\d(?::?\\d\\d)?/g,e=/([+-]|\\d\\d)/g;return function(s,f,n){var u=f.prototype;n.utc=function(t){var i={date:t,utc:!0,args:arguments};return new f(i)},u.utc=function(i){var e=n(this.toDate(),{locale:this.$L,utc:!0});return i?e.add(this.utcOffset(),t):e},u.local=function(){return n(this.toDate(),{locale:this.$L,utc:!1})};var o=u.parse;u.parse=function(t){t.utc&&(this.$u=!0),this.$utils().u(t.$offset)||(this.$offset=t.$offset),o.call(this,t)};var r=u.init;u.init=function(){if(this.$u){var t=this.$d;this.$y=t.getUTCFullYear(),this.$M=t.getUTCMonth(),this.$D=t.getUTCDate(),this.$W=t.getUTCDay(),this.$H=t.getUTCHours(),this.$m=t.getUTCMinutes(),this.$s=t.getUTCSeconds(),this.$ms=t.getUTCMilliseconds()}else r.call(this)};var a=u.utcOffset;u.utcOffset=function(s,f){var n=this.$utils().u;if(n(s))return this.$u?0:n(this.$offset)?a.call(this):this.$offset;if(\"string\"==typeof s&&(s=function(t){void 0===t&&(t=\"\");var s=t.match(i);if(!s)return null;var f=(\"\"+s[0]).match(e)||[\"-\",0,0],n=f[0],u=60*+f[1]+ +f[2];return 0===u?0:\"+\"===n?u:-u}(s),null===s))return this;var u=Math.abs(s)<=16?60*s:s,o=this;if(f)return o.$offset=u,o.$u=0===s,o;if(0!==s){var r=this.$u?this.toDate().getTimezoneOffset():-1*this.utcOffset();(o=this.local().add(u+r,t)).$offset=u,o.$x.$localOffset=r}else o=this.utc();return o};var h=u.format;u.format=function(t){var i=t||(this.$u?\"YYYY-MM-DDTHH:mm:ss[Z]\":\"\");return h.call(this,i)},u.valueOf=function(){var t=this.$utils().u(this.$offset)?0:this.$offset+(this.$x.$localOffset||this.$d.getTimezoneOffset());return this.$d.valueOf()-6e4*t},u.isUTC=function(){return!!this.$u},u.toISOString=function(){return this.toDate().toISOString()},u.toString=function(){return this.toDate().toUTCString()};var l=u.toDate;u.toDate=function(t){return\"s\"===t&&this.$offset?n(this.format(\"YYYY-MM-DD HH:mm:ss:SSS\")).toDate():l.call(this)};var c=u.diff;u.diff=function(t,i,e){if(t&&this.$u===t.$u)return c.call(this,t,i,e);var s=this.local(),f=n(t).local();return c.call(s,f,i,e)}}}));"],
"mappings": "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",
"names": ["t", "i", "e", "s", "f", "n", "u", "o", "r"]
}
+4 -4
View File
@@ -1,3 +1,6 @@
import {
_setPrototypeOf
} from "./chunk-4FTWOKSW.js";
import {
init_memoize_one_esm,
memoize_one_esm_default
@@ -5,9 +8,6 @@ import {
import {
require_prop_types
} from "./chunk-7TYQWO6A.js";
import {
_setPrototypeOf
} from "./chunk-4FTWOKSW.js";
import {
require_react_dom
} from "./chunk-GKJBSOWT.js";
@@ -15,7 +15,7 @@ import {
_extends,
_objectSpread2,
_objectWithoutPropertiesLoose
} from "./chunk-4JNVF2QO.js";
} from "./chunk-66ZF2VOI.js";
import {
require_react
} from "./chunk-QJTFJ6OV.js";
+6 -30
View File
@@ -1,12 +1,10 @@
import React from 'react'
import { Routes, Route } from 'react-router-dom'
import { Layout, Tabs } from 'antd'
import { Layout } from 'antd'
import HomePage from './pages/HomePage'
import ProjectDetailPage from './pages/ProjectDetailPage'
import SettingsPage from './pages/SettingsPage'
import Header from './components/Header'
import { RealTimeStatus } from './components/RealTimeStatus'
import { SimpleTest } from './components/SimpleTest'
const { Content } = Layout
@@ -17,33 +15,11 @@ function App() {
<Layout>
<Header />
<Content>
<Tabs
defaultActiveKey="main"
onChange={(key) => console.log('🔄 切换到标签页:', key)}
items={[
{
key: 'main',
label: '主界面',
children: (
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/project/:id" element={<ProjectDetailPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
)
},
{
key: 'realtime',
label: '实时状态',
children: <RealTimeStatus userId="frontend-user" />
},
{
key: 'test',
label: '简单测试',
children: <SimpleTest />
}
]}
/>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/project/:id" element={<ProjectDetailPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Content>
</Layout>
)
+106 -49
View File
@@ -6,9 +6,14 @@ import { Project } from '../store/useProjectStore'
import { projectApi } from '../services/api'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import 'dayjs/locale/zh-cn'
dayjs.extend(relativeTime)
dayjs.extend(timezone)
dayjs.extend(utc)
dayjs.locale('zh-cn')
const { Text, Title } = Typography
const { Meta } = Card
@@ -35,6 +40,13 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ project, onDelete, onRetry, o
const [logs, setLogs] = useState<LogEntry[]>([])
const [currentLogIndex, setCurrentLogIndex] = useState(0)
// 格式化创建时间,确保正确的时区处理
const formatCreatedTime = (createdAt: string) => {
const now = dayjs().tz('Asia/Shanghai')
const created = dayjs(createdAt).tz('Asia/Shanghai')
return created.from(now)
}
// 获取分类信息
const getCategoryInfo = (category?: string) => {
const categoryMap: Record<string, { name: string; icon: string; color: string }> = {
@@ -56,7 +68,10 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ project, onDelete, onRetry, o
// 生成项目视频缩略图(带缓存)
useEffect(() => {
const generateThumbnail = async () => {
if (!project.video_path) return
if (!project.video_path) {
console.log('项目没有视频路径:', project.id)
return
}
// 检查缓存
const cachedThumbnail = localStorage.getItem(thumbnailCacheKey)
@@ -71,66 +86,108 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ project, onDelete, onRetry, o
const video = document.createElement('video')
video.crossOrigin = 'anonymous'
video.muted = true
video.preload = 'metadata'
const videoUrl = projectApi.getProjectFileUrl(project.id, 'input/input.mp4')
// 尝试多个可能的视频文件路径
const possiblePaths = [
'input/input.mp4',
'input.mp4',
project.video_path,
`${project.video_path}/input.mp4`
].filter(Boolean)
video.onloadedmetadata = () => {
video.currentTime = Math.min(5, video.duration / 4) // 取视频1/4处或5秒处的帧
}
let videoLoaded = false
video.onseeked = () => {
for (const path of possiblePaths) {
if (videoLoaded) break
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) return
const videoUrl = projectApi.getProjectFileUrl(project.id, path)
console.log('尝试加载视频:', videoUrl)
// 设置合适的缩略图尺寸
const maxWidth = 320
const maxHeight = 180
const aspectRatio = video.videoWidth / video.videoHeight
let width = maxWidth
let height = maxHeight
if (aspectRatio > maxWidth / maxHeight) {
height = maxWidth / aspectRatio
} else {
width = maxHeight * aspectRatio
}
canvas.width = width
canvas.height = height
ctx.drawImage(video, 0, 0, width, height)
const thumbnail = canvas.toDataURL('image/jpeg', 0.7)
setVideoThumbnail(thumbnail)
// 缓存缩略图
try {
localStorage.setItem(thumbnailCacheKey, thumbnail)
} catch (e) {
// 如果localStorage空间不足,清理旧缓存
const keys = Object.keys(localStorage).filter(key => key.startsWith('thumbnail_'))
if (keys.length > 50) { // 保留最多50个缩略图缓存
keys.slice(0, 10).forEach(key => localStorage.removeItem(key))
localStorage.setItem(thumbnailCacheKey, thumbnail)
await new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error('视频加载超时'))
}, 10000) // 10秒超时
video.onloadedmetadata = () => {
clearTimeout(timeoutId)
console.log('视频元数据加载成功:', videoUrl)
video.currentTime = Math.min(5, video.duration / 4) // 取视频1/4处或5秒处的帧
}
}
video.onseeked = () => {
clearTimeout(timeoutId)
try {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
if (!ctx) {
reject(new Error('无法获取canvas上下文'))
return
}
// 设置合适的缩略图尺寸
const maxWidth = 320
const maxHeight = 180
const aspectRatio = video.videoWidth / video.videoHeight
let width = maxWidth
let height = maxHeight
if (aspectRatio > maxWidth / maxHeight) {
height = maxWidth / aspectRatio
} else {
width = maxHeight * aspectRatio
}
canvas.width = width
canvas.height = height
ctx.drawImage(video, 0, 0, width, height)
const thumbnail = canvas.toDataURL('image/jpeg', 0.7)
setVideoThumbnail(thumbnail)
// 缓存缩略图
try {
localStorage.setItem(thumbnailCacheKey, thumbnail)
} catch (e) {
// 如果localStorage空间不足,清理旧缓存
const keys = Object.keys(localStorage).filter(key => key.startsWith('thumbnail_'))
if (keys.length > 50) { // 保留最多50个缩略图缓存
keys.slice(0, 10).forEach(key => localStorage.removeItem(key))
localStorage.setItem(thumbnailCacheKey, thumbnail)
}
}
videoLoaded = true
resolve(thumbnail)
} catch (error) {
reject(error)
}
}
video.onerror = (error) => {
clearTimeout(timeoutId)
console.error('视频加载失败:', videoUrl, error)
reject(error)
}
video.src = videoUrl
})
break // 如果成功加载,跳出循环
} catch (error) {
console.error('生成缩略图失败:', error)
} finally {
setThumbnailLoading(false)
console.warn(`路径 ${path} 加载失败:`, error)
continue // 尝试下一个路径
}
}
video.onerror = (error) => {
console.error('视频加载失败:', error)
setThumbnailLoading(false)
if (!videoLoaded) {
console.error('所有视频路径都加载失败')
}
video.src = videoUrl
} catch (error) {
console.error('生成缩略图时发生错误:', error)
} finally {
setThumbnailLoading(false)
}
}
@@ -366,7 +423,7 @@ const ProjectCard: React.FC<ProjectCardProps> = ({ project, onDelete, onRetry, o
height: '28px'
}}>
<Text style={{ fontSize: '12px', color: 'rgba(255, 255, 255, 0.8)' }}>
{dayjs(project.updated_at).fromNow()}
{formatCreatedTime(project.created_at)}
</Text>
{/* 操作按钮 */}
+10 -1
View File
@@ -5,11 +5,20 @@ import { ConfigProvider } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import dayjs from 'dayjs'
import 'dayjs/locale/zh-cn'
import relativeTime from 'dayjs/plugin/relativeTime'
import timezone from 'dayjs/plugin/timezone'
import utc from 'dayjs/plugin/utc'
import App from './App.tsx'
import './index.css'
// 设置dayjs中文
// 配置dayjs插件
dayjs.extend(relativeTime)
dayjs.extend(timezone)
dayjs.extend(utc)
// 设置dayjs中文和时区
dayjs.locale('zh-cn')
dayjs.tz.setDefault('Asia/Shanghai')
ReactDOM.createRoot(document.getElementById('root')!).render(
<ConfigProvider locale={zhCN}>
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+405
View File
@@ -0,0 +1,405 @@
/**
* WebSocket连接提供者组件
* 管理WebSocket连接生命周期和自动重连
*/
import React, { useEffect, useRef } from 'react'
import { useWebSocketStore, TaskProgressMessage } from '../stores/websocketStore'
import { toast } from 'sonner'
import { CheckCircle, AlertCircle, Activity } from 'lucide-react'
interface WebSocketProviderProps {
children: React.ReactNode
userId?: string
autoConnect?: boolean
}
export const WebSocketProvider: React.FC<WebSocketProviderProps> = ({
children,
userId = 'user', // 默认用户ID,实际应用中应该从认证状态获取
autoConnect = true
}) => {
const {
connectionStatus,
connect,
disconnect,
notifications,
removeNotification
} = useWebSocketStore()
const hasConnected = useRef(false)
const notificationTimeouts = useRef<Map<number, NodeJS.Timeout>>(new Map())
// 自动连接
useEffect(() => {
if (autoConnect && !hasConnected.current && connectionStatus === 'disconnected') {
connect(userId)
hasConnected.current = true
}
// 组件卸载时断开连接
return () => {
if (hasConnected.current) {
disconnect()
hasConnected.current = false
}
}
}, [autoConnect, userId, connectionStatus, connect, disconnect])
// 显示连接状态变化的提示
useEffect(() => {
switch (connectionStatus) {
case 'connected':
toast.success('WebSocket连接已建立')
break
case 'disconnected':
if (hasConnected.current) {
toast.error('WebSocket连接已断开')
}
break
case 'reconnecting':
toast.loading('正在重新连接...')
break
case 'error':
toast.error('WebSocket连接失败')
break
}
}, [connectionStatus])
// 处理WebSocket消息并显示通知
useEffect(() => {
const handleMessage = (message: any) => {
// 根据消息类型显示不同的通知
switch (message.type) {
case 'task_update':
if (message.status === 'completed') {
toast.success(`任务完成: ${message.message || '处理完成'}`, {
icon: <CheckCircle className="w-4 h-4" />
})
} else if (message.status === 'failed') {
toast.error(`任务失败: ${message.error || '处理失败'}`, {
icon: <AlertCircle className="w-4 h-4" />
})
} else if (message.status === 'started') {
toast.info(`任务开始: ${message.message || '开始处理'}`, {
icon: <Activity className="w-4 h-4" />
})
}
break
case 'task_progress':
// 对于进度更新,只在特定里程碑显示通知
const progressMsg = message as TaskProgressMessage
if (progressMsg.current_step === 1 || progressMsg.progress >= 100 || progressMsg.progress % 25 === 0) {
toast.info(`${progressMsg.step_name}: ${Math.round(progressMsg.progress)}%`, {
icon: <Activity className="w-4 h-4" />,
description: `步骤 ${progressMsg.current_step}/${progressMsg.total_steps}`
})
}
break
case 'project_update':
if (message.status === 'completed') {
toast.success(`项目完成: ${message.message || '项目处理完成'}`, {
icon: <CheckCircle className="w-4 h-4" />
})
} else if (message.status === 'failed') {
toast.error(`项目失败: ${message.message || '项目处理失败'}`, {
icon: <AlertCircle className="w-4 h-4" />
})
}
break
case 'system_notification':
const level = message.level || 'info'
const toastFn = level === 'error' ? toast.error :
level === 'warning' ? toast.warning :
level === 'success' ? toast.success : toast.info
toastFn(message.message, {
description: message.title
})
break
}
}
// 监听新消息
const unsubscribe = useWebSocketStore.subscribe(
(state) => state.messages,
(messages) => {
if (messages.length > 0) {
const latestMessage = messages[messages.length - 1]
handleMessage(latestMessage)
}
}
)
return unsubscribe
}, [])
// 显示系统通知
useEffect(() => {
notifications.forEach((notification, index) => {
// 避免重复显示同一个通知
if (!notificationTimeouts.current.has(index)) {
const { title, message, level } = notification
// 根据级别显示不同类型的toast
switch (level) {
case 'success':
toast.success(title, { description: message })
break
case 'error':
toast.error(title, { description: message })
break
case 'warning':
toast.warning(title, { description: message })
break
default:
toast.info(title, { description: message })
}
// 设置自动移除通知的定时器
const timeout = setTimeout(() => {
removeNotification(index)
notificationTimeouts.current.delete(index)
}, 5000) // 5秒后自动移除
notificationTimeouts.current.set(index, timeout)
}
})
// 清理过期的定时器
return () => {
notificationTimeouts.current.forEach(timeout => clearTimeout(timeout))
notificationTimeouts.current.clear()
}
}, [notifications, removeNotification])
return <>{children}</>
}
/**
* WebSocket连接状态指示器组件
*/
export const WebSocketStatusIndicator: React.FC = () => {
const { connectionStatus, reconnect } = useWebSocketStore()
const getStatusColor = () => {
switch (connectionStatus) {
case 'connected':
return 'text-green-500'
case 'connecting':
case 'reconnecting':
return 'text-yellow-500'
case 'disconnected':
case 'error':
return 'text-red-500'
default:
return 'text-gray-500'
}
}
const getStatusText = () => {
switch (connectionStatus) {
case 'connected':
return '已连接'
case 'connecting':
return '连接中'
case 'reconnecting':
return '重连中'
case 'disconnected':
return '已断开'
case 'error':
return '连接错误'
default:
return '未知状态'
}
}
return (
<div className="flex items-center space-x-2 text-sm">
<div className={`w-2 h-2 rounded-full ${getStatusColor().replace('text-', 'bg-')}`} />
<span className={getStatusColor()}>{getStatusText()}</span>
{(connectionStatus === 'disconnected' || connectionStatus === 'error') && (
<button
onClick={reconnect}
className="text-blue-500 hover:text-blue-700 underline"
>
重连
</button>
)}
</div>
)
}
/**
* 任务进度监控组件
*/
interface TaskProgressMonitorProps {
taskId: string
onComplete?: (result: any) => void
onError?: (error: string) => void
}
export const TaskProgressMonitor: React.FC<TaskProgressMonitorProps> = ({
taskId,
onComplete,
onError
}) => {
const { subscribeToTopic, unsubscribeFromTopic, getTaskStatus } = useWebSocketStore()
const taskStatus = getTaskStatus(taskId)
useEffect(() => {
// 订阅任务主题
const topic = `task_${taskId}`
subscribeToTopic(topic)
return () => {
unsubscribeFromTopic(topic)
}
}, [taskId, subscribeToTopic, unsubscribeFromTopic])
useEffect(() => {
if (taskStatus) {
if (taskStatus.status === 'completed' && onComplete) {
onComplete(taskStatus)
} else if (taskStatus.status === 'failed' && onError) {
onError(taskStatus.error || '任务执行失败')
}
}
}, [taskStatus, onComplete, onError])
if (!taskStatus) {
return (
<div className="flex items-center space-x-2">
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
<span className="text-gray-500">等待任务状态...</span>
</div>
)
}
const extendedTask = taskStatus as any
return (
<div className="space-y-2">
<div className="flex items-center space-x-2 text-sm">
{taskStatus.status === 'processing' ? (
<Activity className="w-4 h-4 animate-spin text-blue-500" />
) : taskStatus.status === 'completed' ? (
<CheckCircle className="w-4 h-4 text-green-500" />
) : taskStatus.status === 'failed' ? (
<AlertCircle className="w-4 h-4 text-red-500" />
) : (
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
)}
<span className="font-medium">
{taskStatus.message || '处理中...'}
</span>
{taskStatus.progress !== undefined && (
<span className="text-blue-600 font-medium">
{Math.round(taskStatus.progress)}%
</span>
)}
</div>
{/* 显示步骤信息 */}
{extendedTask.current_step && extendedTask.total_steps && (
<div className="text-xs text-gray-500">
步骤 {extendedTask.current_step}/{extendedTask.total_steps}: {extendedTask.step_name}
</div>
)}
{/* 进度条 */}
{taskStatus.progress !== undefined && (
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${Math.min(100, Math.max(0, taskStatus.progress))}%` }}
/>
</div>
)}
{taskStatus.status === 'failed' && taskStatus.error && (
<div className="text-red-500 text-sm">
错误: {taskStatus.error}
</div>
)}
</div>
)
}
/**
* 项目进度监控组件
*/
interface ProjectProgressMonitorProps {
projectId: string
onComplete?: (result: any) => void
onError?: (error: string) => void
}
export const ProjectProgressMonitor: React.FC<ProjectProgressMonitorProps> = ({
projectId,
onComplete,
onError
}) => {
const { subscribeToTopic, unsubscribeFromTopic, getProjectStatus } = useWebSocketStore()
const projectStatus = getProjectStatus(projectId)
useEffect(() => {
// 订阅项目主题
const topic = `project_${projectId}`
subscribeToTopic(topic)
return () => {
unsubscribeFromTopic(topic)
}
}, [projectId, subscribeToTopic, unsubscribeFromTopic])
useEffect(() => {
if (projectStatus) {
if (projectStatus.status === 'completed' && onComplete) {
onComplete(projectStatus)
} else if (projectStatus.status === 'failed' && onError) {
onError(projectStatus.message || '项目处理失败')
}
}
}, [projectStatus, onComplete, onError])
if (!projectStatus) {
return (
<div className="flex items-center space-x-2">
<div className="w-4 h-4 border-2 border-gray-300 border-t-blue-500 rounded-full animate-spin" />
<span className="text-gray-500">等待项目状态...</span>
</div>
)
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{projectStatus.message || '处理中...'}
</span>
<span className="text-sm text-gray-500">
{projectStatus.progress || 0}%
</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-green-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${projectStatus.progress || 0}%` }}
/>
</div>
{projectStatus.status === 'failed' && (
<div className="text-red-500 text-sm">
错误: {projectStatus.message}
</div>
)}
</div>
)
}
+386
View File
@@ -0,0 +1,386 @@
/**
* WebSocket状态管理Store
* 使用Zustand管理WebSocket连接、消息和状态
*/
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
// WebSocket消息类型定义
export interface WebSocketMessage {
type: 'task_update' | 'task_progress' | 'project_update' | 'system_notification' | 'error_notification'
timestamp: string
[key: string]: any
}
// 任务更新消息
export interface TaskUpdateMessage extends WebSocketMessage {
type: 'task_update'
task_id: string
status: 'started' | 'processing' | 'completed' | 'failed'
progress?: number
message?: string
error?: string
}
// 任务进度消息
export interface TaskProgressMessage extends WebSocketMessage {
type: 'task_progress'
task_id: string
current_step: number
total_steps: number
step_name: string
progress: number
message?: string
step_result?: Record<string, any>
}
// 项目更新消息
export interface ProjectUpdateMessage extends WebSocketMessage {
type: 'project_update'
project_id: string
status: 'processing' | 'completed' | 'failed'
progress?: number
message?: string
}
// 系统通知消息
export interface SystemNotificationMessage extends WebSocketMessage {
type: 'system_notification'
notification_type: string
title: string
message: string
level: 'info' | 'success' | 'warning' | 'error'
}
// 错误通知消息
export interface ErrorNotificationMessage extends WebSocketMessage {
type: 'error_notification'
error_type: string
error_message: string
details?: Record<string, any>
}
// WebSocket连接状态
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'reconnecting' | 'error'
// Store状态接口
interface WebSocketState {
// 连接状态
connectionStatus: ConnectionStatus
socket: WebSocket | null
reconnectAttempts: number
maxReconnectAttempts: number
reconnectInterval: number
// 消息管理
messages: WebSocketMessage[]
unreadCount: number
// 任务状态
tasks: Record<string, TaskUpdateMessage>
// 项目状态
projects: Record<string, ProjectUpdateMessage>
// 系统通知
notifications: SystemNotificationMessage[]
// 订阅主题
subscribedTopics: Set<string>
// Actions
connect: (userId: string) => void
disconnect: () => void
reconnect: () => void
sendMessage: (message: any) => void
subscribeToTopic: (topic: string) => void
unsubscribeFromTopic: (topic: string) => void
clearMessages: () => void
markAsRead: () => void
removeNotification: (index: number) => void
getTaskStatus: (taskId: string) => TaskUpdateMessage | null
getProjectStatus: (projectId: string) => ProjectUpdateMessage | null
}
// WebSocket URL配置
const getWebSocketUrl = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
return `${protocol}//${host}/api/ws`
}
export const useWebSocketStore = create<WebSocketState>()(devtools(
(set, get) => ({
// 初始状态
connectionStatus: 'disconnected',
socket: null,
reconnectAttempts: 0,
maxReconnectAttempts: 5,
reconnectInterval: 3000,
messages: [],
unreadCount: 0,
tasks: {},
projects: {},
notifications: [],
subscribedTopics: new Set(),
// 连接WebSocket
connect: (userId: string) => {
const state = get()
if (state.socket && state.connectionStatus === 'connected') {
console.log('WebSocket已连接,跳过重复连接')
return
}
set({ connectionStatus: 'connecting' })
try {
const wsUrl = `${getWebSocketUrl()}/${userId}`
const socket = new WebSocket(wsUrl)
socket.onopen = () => {
console.log('WebSocket连接已建立')
set({
connectionStatus: 'connected',
socket,
reconnectAttempts: 0
})
}
socket.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data)
set((state) => {
const newState = {
messages: [...state.messages, message],
unreadCount: state.unreadCount + 1
}
// 根据消息类型更新相应状态
switch (message.type) {
case 'task_update':
const taskMsg = message as TaskUpdateMessage
newState.tasks = {
...state.tasks,
[taskMsg.task_id]: taskMsg
}
break
case 'task_progress':
const progressMsg = message as TaskProgressMessage
// 更新任务进度信息
const existingTask = state.tasks[progressMsg.task_id]
newState.tasks = {
...state.tasks,
[progressMsg.task_id]: {
...existingTask,
type: 'task_update',
task_id: progressMsg.task_id,
status: 'processing',
progress: progressMsg.progress,
message: progressMsg.message || progressMsg.step_name,
timestamp: progressMsg.timestamp,
current_step: progressMsg.current_step,
total_steps: progressMsg.total_steps,
step_name: progressMsg.step_name,
step_result: progressMsg.step_result
} as TaskUpdateMessage & {
current_step: number
total_steps: number
step_name: string
step_result?: Record<string, any>
}
}
break
case 'project_update':
const projectMsg = message as ProjectUpdateMessage
newState.projects = {
...state.projects,
[projectMsg.project_id]: projectMsg
}
break
case 'system_notification':
const notificationMsg = message as SystemNotificationMessage
newState.notifications = [...state.notifications, notificationMsg]
break
case 'error_notification':
const errorMsg = message as ErrorNotificationMessage
newState.notifications = [
...state.notifications,
{
type: 'system_notification',
notification_type: 'error',
title: '错误',
message: errorMsg.error_message,
level: 'error',
timestamp: errorMsg.timestamp
} as SystemNotificationMessage
]
break
}
return newState
})
} catch (error) {
console.error('解析WebSocket消息失败:', error)
}
}
socket.onclose = (event) => {
console.log('WebSocket连接已关闭:', event.code, event.reason)
set({ connectionStatus: 'disconnected', socket: null })
// 自动重连
const state = get()
if (state.reconnectAttempts < state.maxReconnectAttempts) {
setTimeout(() => {
set((state) => ({
connectionStatus: 'reconnecting',
reconnectAttempts: state.reconnectAttempts + 1
}))
get().connect(userId)
}, state.reconnectInterval)
} else {
set({ connectionStatus: 'error' })
}
}
socket.onerror = (error) => {
console.error('WebSocket错误:', error)
set({ connectionStatus: 'error' })
}
} catch (error) {
console.error('创建WebSocket连接失败:', error)
set({ connectionStatus: 'error' })
}
},
// 断开连接
disconnect: () => {
const state = get()
if (state.socket) {
state.socket.close()
}
set({
connectionStatus: 'disconnected',
socket: null,
reconnectAttempts: 0
})
},
// 重新连接
reconnect: () => {
const state = get()
state.disconnect()
// 这里需要用户ID,实际使用时应该从认证状态获取
setTimeout(() => state.connect('user'), 1000)
},
// 发送消息
sendMessage: (message: any) => {
const state = get()
if (state.socket && state.connectionStatus === 'connected') {
state.socket.send(JSON.stringify(message))
} else {
console.warn('WebSocket未连接,无法发送消息')
}
},
// 订阅主题
subscribeToTopic: (topic: string) => {
const state = get()
state.sendMessage({
type: 'subscribe',
topic
})
set((state) => ({
subscribedTopics: new Set([...state.subscribedTopics, topic])
}))
},
// 取消订阅主题
unsubscribeFromTopic: (topic: string) => {
const state = get()
state.sendMessage({
type: 'unsubscribe',
topic
})
set((state) => {
const newTopics = new Set(state.subscribedTopics)
newTopics.delete(topic)
return { subscribedTopics: newTopics }
})
},
// 清空消息
clearMessages: () => {
set({ messages: [], unreadCount: 0 })
},
// 标记为已读
markAsRead: () => {
set({ unreadCount: 0 })
},
// 移除通知
removeNotification: (index: number) => {
set((state) => ({
notifications: state.notifications.filter((_, i) => i !== index)
}))
},
// 获取任务状态
getTaskStatus: (taskId: string) => {
return get().tasks[taskId] || null
},
// 获取项目状态
getProjectStatus: (projectId: string) => {
return get().projects[projectId] || null
}
}),
{
name: 'websocket-store',
partialize: (state) => ({
// 只持久化部分状态,不包括连接相关的状态
notifications: state.notifications,
subscribedTopics: Array.from(state.subscribedTopics)
})
}
))
// 导出便捷的hooks
export const useWebSocketConnection = () => {
const { connectionStatus, connect, disconnect, reconnect } = useWebSocketStore()
return { connectionStatus, connect, disconnect, reconnect }
}
export const useWebSocketMessages = () => {
const { messages, unreadCount, clearMessages, markAsRead } = useWebSocketStore()
return { messages, unreadCount, clearMessages, markAsRead }
}
export const useTaskStatus = (taskId: string) => {
return useWebSocketStore((state) => state.getTaskStatus(taskId))
}
export const useProjectStatus = (projectId: string) => {
return useWebSocketStore((state) => state.getProjectStatus(projectId))
}
export const useNotifications = () => {
const { notifications, removeNotification } = useWebSocketStore()
return { notifications, removeNotification }
}