Add task list page for Video Learning sessions

This commit adds a dedicated task list page to view and manage all video
learning sessions, solving the issue where users couldn't find their
background tasks after navigating away.

Features:
- New sessions.html page with card-based layout for all sessions
- Real-time polling for session status updates (every 3 seconds)
- Session control buttons (pause/resume/stop/delete)
- localStorage integration for session persistence across page refreshes
- Navigation links added to main page and video learning page
- Empty state UI when no sessions exist

New files:
- dashboard/static/sessions.html - Task list page
- dashboard/static/js/sessions.js - Sessions module with API calls
- dashboard/static/css/sessions.css - Styling for sessions page

Modified files:
- dashboard/api/video_learning.py - Added /sessions/list endpoint
- dashboard/static/index.html - Added "任务列表" button
- dashboard/static/video-learning.html - Added "任务列表" button and localStorage

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
let5sne.win10
2026-01-10 02:22:42 +08:00
parent a223d63088
commit a356c481ca
6 changed files with 744 additions and 0 deletions

View File

@@ -351,6 +351,29 @@ async def list_sessions() -> List[str]:
return list(_active_sessions.keys()) return list(_active_sessions.keys())
@router.get("/sessions/list")
async def list_all_sessions() -> List[Dict[str, Any]]:
"""获取所有会话的详细信息(包含进度、状态等)"""
result = []
for session_id, agent in _active_sessions.items():
try:
progress = agent.get_session_progress()
result.append({
"session_id": session_id,
"platform": progress.get("platform", ""),
"target_count": progress.get("target_count", 0),
"watched_count": progress.get("watched_count", 0),
"progress_percent": progress.get("progress_percent", 0),
"is_active": progress.get("is_active", False),
"is_paused": progress.get("is_paused", False),
"total_duration": progress.get("total_duration", 0),
})
except Exception as e:
# 跳过出错的会话
continue
return result
@router.delete("/sessions/{session_id}", response_model=Dict[str, str]) @router.delete("/sessions/{session_id}", response_model=Dict[str, str])
async def delete_session(session_id: str) -> Dict[str, str]: async def delete_session(session_id: str) -> Dict[str, str]:
"""Delete a session and clean up device mapping.""" """Delete a session and clean up device mapping."""

View File

@@ -0,0 +1,232 @@
/**
* Video Learning Sessions Page Styles
*/
/* Sessions Grid */
.sessions-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
gap: 20px;
padding: 20px;
}
/* Session Card */
.session-card {
background: white;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.session-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.session-card.completed {
opacity: 0.8;
}
/* Session Header */
.session-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.session-platform {
font-weight: 600;
font-size: 16px;
display: flex;
align-items: center;
gap: 8px;
}
.session-platform::before {
content: '';
width: 24px;
height: 24px;
background-size: contain;
background-repeat: no-repeat;
}
/* Session Status */
.session-status {
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
}
.session-status.status-active {
background: #10b981;
color: white;
}
.session-status.status-paused {
background: #f59e0b;
color: white;
}
.session-status.status-completed {
background: #6b7280;
color: white;
}
/* Session ID */
.session-id {
margin-bottom: 12px;
padding: 8px 12px;
background: #f3f4f6;
border-radius: 4px;
}
.session-id small {
display: block;
font-size: 11px;
color: #6b7280;
margin-bottom: 4px;
}
.session-id code {
font-family: 'Monaco', 'Consolas', monospace;
font-size: 12px;
color: #374151;
}
/* Session Progress */
.session-progress {
margin-bottom: 12px;
}
.progress-info {
display: flex;
justify-content: space-between;
font-size: 14px;
color: #374151;
margin-bottom: 6px;
}
.progress-bar {
height: 8px;
background: #e5e7eb;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
transition: width 0.3s ease;
}
/* Session Duration */
.session-duration {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 16px;
font-size: 14px;
color: #6b7280;
}
/* Session Actions */
.session-actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.session-actions .btn {
flex: 1;
min-width: 80px;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 80px 20px;
color: #6b7280;
}
.empty-state svg {
margin-bottom: 20px;
color: #d1d5db;
}
.empty-state h2 {
font-size: 24px;
margin-bottom: 8px;
color: #374151;
}
.empty-state p {
font-size: 16px;
margin-bottom: 24px;
}
/* Button Styles */
.btn-sm {
padding: 6px 12px;
font-size: 13px;
}
/* Toast Notifications */
.toast-container {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
}
.toast {
padding: 12px 20px;
border-radius: 6px;
margin-bottom: 10px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: slideIn 0.3s ease;
}
.toast.info {
background: #3b82f6;
color: white;
}
.toast.success {
background: #10b981;
color: white;
}
.toast.error {
background: #ef4444;
color: white;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Responsive Design */
@media (max-width: 768px) {
.sessions-grid {
grid-template-columns: 1fr;
}
.session-actions {
flex-direction: column;
}
.session-actions .btn {
width: 100%;
}
}

View File

@@ -48,6 +48,15 @@
</svg> </svg>
Video Learning Video Learning
</a> </a>
<a href="/static/sessions.html" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
任务列表
</a>
<button @click="refreshDevices" class="btn btn-secondary" :disabled="refreshing"> <button @click="refreshDevices" class="btn btn-secondary" :disabled="refreshing">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ spinning: refreshing }"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" :class="{ spinning: refreshing }">
<polyline points="23 4 23 10 17 10"></polyline> <polyline points="23 4 23 10 17 10"></polyline>

View File

@@ -0,0 +1,120 @@
/**
* Video Learning Sessions Module for AutoGLM Dashboard
*
* This module provides functionality for managing video learning sessions,
* including listing, controlling, and monitoring sessions.
*/
const SessionsModule = {
// Current session state
sessions: [],
isLoading: false,
/**
* Load all sessions
*/
async loadSessions() {
try {
const response = await axios.get('/api/video-learning/sessions/list');
this.sessions = response.data;
return this.sessions;
} catch (error) {
console.error('Error loading sessions:', error);
throw error;
}
},
/**
* Get session status
*/
async getSessionStatus(sessionId) {
try {
const response = await axios.get(`/api/video-learning/sessions/${sessionId}/status`);
return response.data;
} catch (error) {
console.error('Error getting session status:', error);
throw error;
}
},
/**
* Get session videos
*/
async getSessionVideos(sessionId) {
try {
const response = await axios.get(`/api/video-learning/sessions/${sessionId}/videos`);
return response.data;
} catch (error) {
console.error('Error getting session videos:', error);
throw error;
}
},
/**
* Control session (pause/resume/stop)
*/
async controlSession(sessionId, action) {
try {
const response = await axios.post(`/api/video-learning/sessions/${sessionId}/control`, {
action: action
});
return response.data;
} catch (error) {
console.error('Error controlling session:', error);
throw error;
}
},
/**
* Delete session
*/
async deleteSession(sessionId) {
try {
const response = await axios.delete(`/api/video-learning/sessions/${sessionId}`);
return response.data;
} catch (error) {
console.error('Error deleting session:', error);
throw error;
}
},
/**
* Format platform name
*/
getPlatformName(platform) {
const names = {
'douyin': '抖音',
'kuaishou': '快手',
'tiktok': 'TikTok'
};
return names[platform] || platform;
},
/**
* Get status text
*/
getStatusText(session) {
if (!session.is_active && !session.is_paused) return '已完成';
if (session.is_paused) return '已暂停';
if (session.is_active) return '进行中';
return '未知';
},
/**
* Format duration
*/
formatDuration(seconds) {
if (!seconds) return '0s';
if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds.toFixed(1)}s`;
}
};
// Export for use in other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = SessionsModule;
}

View File

@@ -0,0 +1,330 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>任务列表 - AutoGLM Dashboard</title>
<!-- Vue.js 3 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<!-- Axios for API requests -->
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<!-- CSS -->
<link rel="stylesheet" href="/static/css/dashboard.css">
<link rel="stylesheet" href="/static/css/sessions.css">
</head>
<body>
<div id="app">
<!-- Header -->
<header class="header">
<div class="header-content">
<h1>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
Video Learning 任务列表
</h1>
<div class="stats">
<span class="stat" v-if="sessions.length > 0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
{{ sessions.length }} 个会话
</span>
<span class="stat" v-if="activeCount > 0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
{{ activeCount }} 活跃中
</span>
</div>
</div>
<div class="header-actions">
<button @click="refreshSessions" class="btn btn-secondary" :disabled="loading">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 4v6h-6"></path>
<path d="M1 20v-6h6"></path>
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path>
</svg>
刷新
</button>
<a href="/" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="19" y1="12" x2="5" y2="12"></line>
<polyline points="12 19 5 12 12 5"></polyline>
</svg>
返回主页
</a>
<a href="/static/video-learning.html" class="btn btn-primary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
新建任务
</a>
</div>
</header>
<!-- Main Content -->
<main class="main-content">
<!-- Empty State -->
<div class="empty-state" v-if="sessions.length === 0 && !loading">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
<line x1="9" y1="3" x2="9" y2="21"></line>
<line x1="15" y1="3" x2="15" y2="21"></line>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="3" y1="15" x2="21" y2="15"></line>
</svg>
<h2>暂无任务</h2>
<p>还没有创建任何视频学习任务</p>
<a href="/static/video-learning.html" class="btn btn-primary">创建第一个任务</a>
</div>
<!-- Sessions Grid -->
<div class="sessions-grid" v-if="sessions.length > 0">
<div v-for="session in sessions" :key="session.session_id"
class="session-card"
:class="{ 'completed': !session.is_active && !session.is_paused }">
<!-- Card Header -->
<div class="session-header">
<div class="session-platform">
{{ getPlatformName(session.platform) }}
</div>
<div class="session-status" :class="getStatusClass(session)">
{{ getStatusText(session) }}
</div>
</div>
<!-- Session ID -->
<div class="session-id">
<small>会话 ID</small>
<code>{{ formatSessionId(session.session_id) }}</code>
</div>
<!-- Progress -->
<div class="session-progress">
<div class="progress-info">
<span>进度: {{ session.watched_count }} / {{ session.target_count }}</span>
<span>{{ Math.round(session.progress_percent) }}%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" :style="{ width: session.progress_percent + '%' }"></div>
</div>
</div>
<!-- Duration -->
<div class="session-duration" v-if="session.total_duration > 0">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
{{ formatDuration(session.total_duration) }}
</div>
<!-- Actions -->
<div class="session-actions">
<button @click="viewDetails(session)" class="btn btn-sm btn-secondary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-11 11-11-11-11z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
详情
</button>
<button v-if="session.is_paused" @click="controlSession(session.session_id, 'resume')"
class="btn btn-sm btn-primary">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polygon points="5 3 19 12 5 21 5 3"></polygon>
</svg>
恢复
</button>
<button v-else-if="session.is_active" @click="controlSession(session.session_id, 'pause')"
class="btn btn-sm btn-warning">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="6" y="4" width="4" height="16"></rect>
<rect x="14" y="4" width="4" height="16"></rect>
</svg>
暂停
</button>
<button v-if="session.is_active || session.is_paused"
@click="controlSession(session.session_id, 'stop')"
class="btn btn-sm btn-danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="6" y="6" width="12" height="12"></rect>
</svg>
停止
</button>
<button v-if="!session.is_active && !session.is_paused"
@click="deleteSession(session.session_id)"
class="btn btn-sm btn-danger">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"></polyline>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
</svg>
删除
</button>
</div>
</div>
</div>
</main>
<!-- Toast notifications -->
<div class="toast-container">
<div v-for="toast in toasts" :key="toast.id" class="toast" :class="toast.type">
{{ toast.message }}
</div>
</div>
</div>
<script src="/static/js/sessions.js"></script>
<script>
const { createApp } = Vue;
createApp({
data() {
return {
sessions: [],
loading: false,
toasts: [],
toastIdCounter: 0,
pollInterval: null,
};
},
mounted() {
this.loadSessions();
this.startPolling();
},
beforeUnmount() {
this.stopPolling();
},
methods: {
async loadSessions() {
this.loading = true;
try {
const response = await axios.get('/api/video-learning/sessions/list');
this.sessions = response.data;
} catch (error) {
this.showToast('加载失败: ' + error.message, 'error');
} finally {
this.loading = false;
}
},
async refreshSessions() {
await this.loadSessions();
this.showToast('已刷新', 'info');
},
async viewDetails(session) {
// 保存 session_id 到 localStorage
localStorage.setItem('current_session_id', session.session_id);
// 跳转到 Video Learning 详情页
window.location.href = '/static/video-learning.html?session=' + session.session_id;
},
async controlSession(sessionId, action) {
try {
await axios.post(`/api/video-learning/sessions/${sessionId}/control`, { action });
this.showToast(`会话已${action === 'pause' ? '暂停' : action === 'resume' ? '恢复' : '停止'}`, 'success');
await this.loadSessions();
} catch (error) {
this.showToast('操作失败: ' + error.message, 'error');
}
},
async deleteSession(sessionId) {
if (!confirm('确定要删除这个会话吗?')) return;
try {
await axios.delete(`/api/video-learning/sessions/${sessionId}`);
this.showToast('会话已删除', 'success');
await this.loadSessions();
} catch (error) {
this.showToast('删除失败: ' + error.message, 'error');
}
},
startPolling() {
this.pollInterval = setInterval(() => {
this.loadSessions();
}, 3000); // 每 3 秒刷新一次
},
stopPolling() {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
},
getPlatformName(platform) {
const names = {
'douyin': '抖音',
'kuaishou': '快手',
'tiktok': 'TikTok'
};
return names[platform] || platform;
},
getStatusText(session) {
if (!session.is_active && !session.is_paused) return '已完成';
if (session.is_paused) return '已暂停';
if (session.is_active) return '进行中';
return '未知';
},
getStatusClass(session) {
if (!session.is_active && !session.is_paused) return 'status-completed';
if (session.is_paused) return 'status-paused';
if (session.is_active) return 'status-active';
return '';
},
formatSessionId(sessionId) {
// 只显示前 8 位和后 4 位
if (sessionId.length > 16) {
return sessionId.substring(0, 8) + '...' + sessionId.substring(sessionId.length - 4);
}
return sessionId;
},
formatDuration(seconds) {
if (seconds < 60) {
return `${seconds.toFixed(1)}s`;
}
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds.toFixed(1)}s`;
},
showToast(message, type = 'info') {
const id = this.toastIdCounter++;
this.toasts.push({ id, message, type });
setTimeout(() => {
this.toasts = this.toasts.filter(t => t.id !== id);
}, 3000);
},
},
computed: {
activeCount() {
return this.sessions.filter(s => s.is_active).length;
}
}
}).mount('#app');
</script>
</body>
</html>

View File

@@ -42,6 +42,15 @@
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<a href="/static/sessions.html" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"></rect>
<rect x="14" y="3" width="7" height="7"></rect>
<rect x="14" y="14" width="7" height="7"></rect>
<rect x="3" y="14" width="7" height="7"></rect>
</svg>
任务列表
</a>
<button @click="goBack" class="btn btn-secondary"> <button @click="goBack" class="btn btn-secondary">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="19" y1="12" x2="5" y2="12"></line> <line x1="19" y1="12" x2="5" y2="12"></line>
@@ -285,6 +294,23 @@
mounted() { mounted() {
this.loadDevices(); this.loadDevices();
this.setupVideoLearningEvents(); this.setupVideoLearningEvents();
// 检查 URL 是否有 session 参数
const urlParams = new URLSearchParams(window.location.search);
const sessionParam = urlParams.get('session');
if (sessionParam) {
this.currentSessionId = sessionParam;
this.updateSessionStatus();
}
// 检查 localStorage 中是否有保存的 session_id
if (!this.currentSessionId) {
const savedSessionId = localStorage.getItem('current_session_id');
if (savedSessionId) {
this.currentSessionId = savedSessionId;
this.updateSessionStatus();
}
}
}, },
methods: { methods: {
@@ -318,6 +344,10 @@
); );
this.currentSessionId = createResult.session_id; this.currentSessionId = createResult.session_id;
// 保存到 localStorage
localStorage.setItem('current_session_id', this.currentSessionId);
this.showToast('Session created! Starting...', 'success'); this.showToast('Session created! Starting...', 'success');
// Start session // Start session