Add Video Learning Agent for short video platforms
Features: - VideoLearningAgent for automated video watching on Douyin/Kuaishou/TikTok - Web dashboard UI for video learning sessions - Real-time progress tracking with screenshot capture - App detection using get_current_app() for accurate recording - Session management with pause/resume/stop controls Technical improvements: - Simplified video detection logic using direct app detection - Full base64 hash for sensitive screenshot change detection - Immediate stop when target video count is reached - Fixed circular import issues with ModelConfig Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
283
dashboard/static/css/video-learning.css
Normal file
283
dashboard/static/css/video-learning.css
Normal file
@@ -0,0 +1,283 @@
|
||||
/* Video Learning Module Styles */
|
||||
|
||||
/* Header modifications */
|
||||
.header h1 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Configuration Section */
|
||||
.config-section {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.config-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.form-group select,
|
||||
.form-group input {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.form-group select:focus,
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.form-group select:disabled,
|
||||
.form-group input:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Session Section */
|
||||
.session-section {
|
||||
background-color: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.session-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.session-header h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.session-controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Progress Section */
|
||||
.progress-section {
|
||||
background-color: var(--bg-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.progress-bar-large {
|
||||
height: 8px;
|
||||
background-color: rgba(99, 102, 241, 0.2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-stats {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Current Video */
|
||||
.current-video {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.current-video h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Video Cards */
|
||||
.video-card {
|
||||
background-color: var(--bg-color);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.video-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.video-screenshot {
|
||||
width: 100%;
|
||||
aspect-ratio: 9/16;
|
||||
background-color: #000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-screenshot img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.video-placeholder {
|
||||
width: 100%;
|
||||
aspect-ratio: 9/16;
|
||||
background-color: var(--bg-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.video-info {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.video-id {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.video-description {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.video-stats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.video-stats span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.video-stats svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Session Complete */
|
||||
.session-complete {
|
||||
text-align: center;
|
||||
padding: 3rem 2rem;
|
||||
}
|
||||
|
||||
.complete-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.session-complete h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.session-complete p {
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Video Grid */
|
||||
.video-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.video-grid .video-card {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.video-grid .video-screenshot,
|
||||
.video-grid .video-placeholder {
|
||||
aspect-ratio: 9/16;
|
||||
}
|
||||
|
||||
/* History Section */
|
||||
.history-section {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.history-section h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.session-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.video-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,13 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<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">
|
||||
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||
</svg>
|
||||
Video Learning
|
||||
</a>
|
||||
<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 }">
|
||||
<polyline points="23 4 23 10 17 10"></polyline>
|
||||
|
||||
200
dashboard/static/js/video-learning.js
Normal file
200
dashboard/static/js/video-learning.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Video Learning Module for AutoGLM Dashboard
|
||||
*
|
||||
* This module provides UI and functionality for the Video Learning Agent,
|
||||
* allowing users to watch and learn from short video platforms.
|
||||
*/
|
||||
|
||||
const VideoLearningModule = {
|
||||
// Current session state
|
||||
currentSessionId: null,
|
||||
currentSessionStatus: null,
|
||||
videos: [],
|
||||
isPolling: false,
|
||||
|
||||
// Create a new learning session
|
||||
async createSession(deviceId, options = {}) {
|
||||
const {
|
||||
platform = 'douyin',
|
||||
targetCount = 10,
|
||||
category = null,
|
||||
watchDuration = 3.0,
|
||||
} = options;
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/video-learning/sessions', {
|
||||
device_id: deviceId,
|
||||
platform: platform,
|
||||
target_count: targetCount,
|
||||
category: category,
|
||||
watch_duration: watchDuration,
|
||||
});
|
||||
|
||||
this.currentSessionId = response.data.session_id;
|
||||
this.startPolling();
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error creating session:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Start a session
|
||||
async startSession(sessionId) {
|
||||
try {
|
||||
const response = await axios.post(`/api/video-learning/sessions/${sessionId}/start`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error starting session:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Control a 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;
|
||||
}
|
||||
},
|
||||
|
||||
// Get session status
|
||||
async getSessionStatus(sessionId) {
|
||||
try {
|
||||
const response = await axios.get(`/api/video-learning/sessions/${sessionId}/status`);
|
||||
this.currentSessionStatus = response.data;
|
||||
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`);
|
||||
this.videos = response.data;
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error getting session videos:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// List all active sessions
|
||||
async listSessions() {
|
||||
try {
|
||||
const response = await axios.get('/api/video-learning/sessions');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error listing sessions:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Delete a session
|
||||
async deleteSession(sessionId) {
|
||||
try {
|
||||
const response = await axios.delete(`/api/video-learning/sessions/${sessionId}`);
|
||||
if (this.currentSessionId === sessionId) {
|
||||
this.currentSessionId = null;
|
||||
this.currentSessionStatus = null;
|
||||
this.stopPolling();
|
||||
}
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error deleting session:', error);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// Start polling for session updates
|
||||
startPolling(intervalMs = 1000) {
|
||||
if (this.isPolling) return;
|
||||
|
||||
this.isPolling = true;
|
||||
this.pollInterval = setInterval(async () => {
|
||||
if (this.currentSessionId) {
|
||||
try {
|
||||
await this.getSessionStatus(this.currentSessionId);
|
||||
await this.getSessionVideos(this.currentSessionId);
|
||||
|
||||
// Trigger custom event for UI updates
|
||||
window.dispatchEvent(new CustomEvent('videoLearningUpdate', {
|
||||
detail: {
|
||||
sessionId: this.currentSessionId,
|
||||
status: this.currentSessionStatus,
|
||||
videos: this.videos,
|
||||
}
|
||||
}));
|
||||
|
||||
// Stop polling if session is complete, but do one final update
|
||||
if (this.currentSessionStatus && !this.currentSessionStatus.is_active) {
|
||||
console.log('[VideoLearning] Session completed, doing final update...');
|
||||
// Do one final update to ensure we have the latest data
|
||||
await this.getSessionStatus(this.currentSessionId);
|
||||
await this.getSessionVideos(this.currentSessionId);
|
||||
|
||||
window.dispatchEvent(new CustomEvent('videoLearningUpdate', {
|
||||
detail: {
|
||||
sessionId: this.currentSessionId,
|
||||
status: this.currentSessionStatus,
|
||||
videos: this.videos,
|
||||
}
|
||||
}));
|
||||
|
||||
console.log('[VideoLearning] Final update complete, stopping poll');
|
||||
this.stopPolling();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error polling session status:', error);
|
||||
// Don't stop polling on error, just log it
|
||||
}
|
||||
}
|
||||
}, intervalMs);
|
||||
console.log(`[VideoLearning] Started polling with ${intervalMs}ms interval`);
|
||||
},
|
||||
|
||||
// Stop polling
|
||||
stopPolling() {
|
||||
if (this.pollInterval) {
|
||||
clearInterval(this.pollInterval);
|
||||
this.pollInterval = null;
|
||||
console.log('[VideoLearning] Stopped polling');
|
||||
}
|
||||
this.isPolling = false;
|
||||
},
|
||||
|
||||
// Format duration
|
||||
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`;
|
||||
},
|
||||
|
||||
// Format number with K/M suffix
|
||||
formatNumber(num) {
|
||||
if (num === null || num === undefined) return 'N/A';
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
} else if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toString();
|
||||
},
|
||||
};
|
||||
|
||||
// Export for use in other modules
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = VideoLearningModule;
|
||||
}
|
||||
412
dashboard/static/video-learning.html
Normal file
412
dashboard/static/video-learning.html
Normal file
@@ -0,0 +1,412 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Video Learning - 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/video-learning.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">
|
||||
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||
</svg>
|
||||
Video Learning Agent
|
||||
</h1>
|
||||
<div class="stats">
|
||||
<span class="stat" title="Session Status">
|
||||
<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>
|
||||
{{ sessionStatus ? sessionStatus.status : 'No Session' }}
|
||||
</span>
|
||||
<span class="stat" v-if="sessionStatus" title="Progress">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
{{ sessionStatus.watched_count }} / {{ sessionStatus.target_count }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button @click="goBack" 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>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Configuration Section -->
|
||||
<section class="config-section" v-if="!currentSessionId">
|
||||
<h2>Create Learning Session</h2>
|
||||
<div class="config-form">
|
||||
<div class="form-group">
|
||||
<label>Device</label>
|
||||
<select v-model="config.deviceId" :disabled="loading">
|
||||
<option value="">Select a device...</option>
|
||||
<option v-for="device in devices" :key="device.device_id" :value="device.device_id"
|
||||
:disabled="!device.is_connected || device.status === 'busy'">
|
||||
{{ device.device_id }}
|
||||
{{ !device.is_connected ? '(Disconnected)' : '' }}
|
||||
{{ device.status === 'busy' ? '(Busy)' : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Platform</label>
|
||||
<select v-model="config.platform" :disabled="loading">
|
||||
<option value="douyin">Douyin (抖音)</option>
|
||||
<option value="kuaishou">Kuaishou (快手)</option>
|
||||
<option value="tiktok">TikTok</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Target Videos</label>
|
||||
<input type="number" v-model.number="config.targetCount" min="1" max="100" :disabled="loading">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Watch Duration (s)</label>
|
||||
<input type="number" v-model.number="config.watchDuration" min="1" max="30" step="0.5" :disabled="loading">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Category (Optional)</label>
|
||||
<input type="text" v-model="config.category" placeholder="e.g., 美食, 旅行, 搞笑" :disabled="loading">
|
||||
<small>Leave empty to watch recommended videos</small>
|
||||
</div>
|
||||
|
||||
<button @click="createAndStartSession" class="btn btn-primary" :disabled="loading || !config.deviceId">
|
||||
<svg v-if="loading" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spinning">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56"></path>
|
||||
</svg>
|
||||
{{ loading ? 'Creating...' : 'Start Learning' }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Session Control Section -->
|
||||
<section class="session-section" v-if="currentSessionId && sessionStatus">
|
||||
<div class="session-header">
|
||||
<h2>Session: {{ currentSessionId }}</h2>
|
||||
<div class="session-controls">
|
||||
<button v-if="sessionStatus.is_paused" @click="resumeSession" class="btn btn-primary btn-sm">
|
||||
<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>
|
||||
Resume
|
||||
</button>
|
||||
<button v-else-if="sessionStatus.is_active" @click="pauseSession" class="btn btn-secondary btn-sm">
|
||||
<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>
|
||||
Pause
|
||||
</button>
|
||||
<button v-if="sessionStatus.is_active" @click="stopSession" class="btn btn-danger btn-sm">
|
||||
<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>
|
||||
Stop
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Progress Bar -->
|
||||
<div class="progress-section" v-if="sessionStatus.is_active || sessionStatus.is_paused">
|
||||
<div class="progress-info">
|
||||
<span>Progress: {{ sessionStatus.watched_count }} / {{ sessionStatus.target_count }}</span>
|
||||
<span>{{ Math.round(sessionStatus.progress_percent) }}%</span>
|
||||
</div>
|
||||
<div class="progress-bar-large">
|
||||
<div class="progress-fill" :style="{ width: sessionStatus.progress_percent + '%' }"></div>
|
||||
</div>
|
||||
<div class="progress-stats">
|
||||
<span>Total Duration: {{ formatDuration(sessionStatus.total_duration) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current Video -->
|
||||
<div class="current-video" v-if="sessionStatus.current_video">
|
||||
<h3>Current Video</h3>
|
||||
<div class="video-card">
|
||||
<div class="video-screenshot" v-if="sessionStatus.current_video.screenshot_path">
|
||||
<img :src="sessionStatus.current_video.screenshot_path" alt="Current video">
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<div class="video-id">#{{ sessionStatus.current_video.sequence_id }}</div>
|
||||
<div class="video-description" v-if="sessionStatus.current_video.description">
|
||||
{{ sessionStatus.current_video.description }}
|
||||
</div>
|
||||
<div class="video-stats">
|
||||
<span v-if="sessionStatus.current_video.likes">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
|
||||
</svg>
|
||||
{{ formatNumber(sessionStatus.current_video.likes) }}
|
||||
</span>
|
||||
<span v-if="sessionStatus.current_video.comments">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
|
||||
</svg>
|
||||
{{ formatNumber(sessionStatus.current_video.comments) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Session Complete -->
|
||||
<div class="session-complete" v-if="!sessionStatus.is_active && currentSessionId">
|
||||
<div class="complete-icon">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
</div>
|
||||
<h3>Session Complete!</h3>
|
||||
<p>Watched {{ sessionStatus.watched_count }} videos in {{ formatDuration(sessionStatus.total_duration) }}</p>
|
||||
<button @click="resetSession" class="btn btn-primary">Start New Session</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Video History -->
|
||||
<section class="history-section" v-if="videos.length > 0">
|
||||
<h2>Watched Videos</h2>
|
||||
<div class="video-grid">
|
||||
<div v-for="video in videos" :key="video.sequence_id" class="video-card">
|
||||
<div class="video-screenshot" v-if="video.screenshot_path">
|
||||
<img :src="video.screenshot_path" :alt="'Video ' + video.sequence_id">
|
||||
</div>
|
||||
<div class="video-placeholder" v-else>
|
||||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="2" y="2" width="20" height="20" rx="2.18" ry="2.18"></rect>
|
||||
<line x1="7" y1="2" x2="7" y2="22"></line>
|
||||
<line x1="17" y1="2" x2="17" y2="22"></line>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<line x1="2" y1="7" x2="7" y2="7"></line>
|
||||
<line x1="2" y1="17" x2="7" y2="17"></line>
|
||||
<line x1="17" y1="17" x2="22" y2="17"></line>
|
||||
<line x1="17" y1="7" x2="22" y2="7"></line>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="video-info">
|
||||
<div class="video-id">#{{ video.sequence_id }}</div>
|
||||
<div class="video-description" v-if="video.description">{{ video.description }}</div>
|
||||
<div class="video-stats">
|
||||
<span v-if="video.likes">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"></path>
|
||||
</svg>
|
||||
{{ formatNumber(video.likes) }}
|
||||
</span>
|
||||
<span v-if="video.comments">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"></path>
|
||||
</svg>
|
||||
{{ formatNumber(video.comments) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</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/video-learning.js"></script>
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
|
||||
createApp({
|
||||
data() {
|
||||
return {
|
||||
devices: [],
|
||||
currentSessionId: null,
|
||||
sessionStatus: null,
|
||||
videos: [],
|
||||
loading: false,
|
||||
toasts: [],
|
||||
toastIdCounter: 0,
|
||||
|
||||
config: {
|
||||
deviceId: '',
|
||||
platform: 'douyin',
|
||||
targetCount: 10,
|
||||
category: '',
|
||||
watchDuration: 3.0,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.loadDevices();
|
||||
this.setupVideoLearningEvents();
|
||||
},
|
||||
|
||||
methods: {
|
||||
async loadDevices() {
|
||||
try {
|
||||
const response = await axios.get('/api/devices');
|
||||
this.devices = response.data;
|
||||
} catch (error) {
|
||||
this.showToast('Failed to load devices', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async createAndStartSession() {
|
||||
if (!this.config.deviceId) {
|
||||
this.showToast('Please select a device', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
// Create session
|
||||
const createResult = await VideoLearningModule.createSession(
|
||||
this.config.deviceId,
|
||||
{
|
||||
platform: this.config.platform,
|
||||
targetCount: this.config.targetCount,
|
||||
category: this.config.category || null,
|
||||
watchDuration: this.config.watchDuration,
|
||||
}
|
||||
);
|
||||
|
||||
this.currentSessionId = createResult.session_id;
|
||||
this.showToast('Session created! Starting...', 'success');
|
||||
|
||||
// Start session
|
||||
await VideoLearningModule.startSession(this.currentSessionId);
|
||||
this.showToast('Learning session started!', 'success');
|
||||
|
||||
// Initial status update
|
||||
await this.updateSessionStatus();
|
||||
} catch (error) {
|
||||
this.showToast('Failed to create session: ' + error.message, 'error');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async pauseSession() {
|
||||
if (!this.currentSessionId) return;
|
||||
|
||||
try {
|
||||
await VideoLearningModule.controlSession(this.currentSessionId, 'pause');
|
||||
await this.updateSessionStatus();
|
||||
this.showToast('Session paused', 'info');
|
||||
} catch (error) {
|
||||
this.showToast('Failed to pause session', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async resumeSession() {
|
||||
if (!this.currentSessionId) return;
|
||||
|
||||
try {
|
||||
await VideoLearningModule.controlSession(this.currentSessionId, 'resume');
|
||||
await this.updateSessionStatus();
|
||||
this.showToast('Session resumed', 'info');
|
||||
} catch (error) {
|
||||
this.showToast('Failed to resume session', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async stopSession() {
|
||||
if (!this.currentSessionId) return;
|
||||
|
||||
if (!confirm('Are you sure you want to stop this session?')) return;
|
||||
|
||||
try {
|
||||
await VideoLearningModule.controlSession(this.currentSessionId, 'stop');
|
||||
await this.updateSessionStatus();
|
||||
this.showToast('Session stopped', 'info');
|
||||
} catch (error) {
|
||||
this.showToast('Failed to stop session', 'error');
|
||||
}
|
||||
},
|
||||
|
||||
async updateSessionStatus() {
|
||||
if (!this.currentSessionId) return;
|
||||
|
||||
try {
|
||||
this.sessionStatus = await VideoLearningModule.getSessionStatus(this.currentSessionId);
|
||||
this.videos = await VideoLearningModule.getSessionVideos(this.currentSessionId);
|
||||
} catch (error) {
|
||||
console.error('Error updating session status:', error);
|
||||
}
|
||||
},
|
||||
|
||||
setupVideoLearningEvents() {
|
||||
window.addEventListener('videoLearningUpdate', (event) => {
|
||||
const { status, videos } = event.detail;
|
||||
this.sessionStatus = status;
|
||||
this.videos = videos;
|
||||
});
|
||||
},
|
||||
|
||||
resetSession() {
|
||||
this.currentSessionId = null;
|
||||
this.sessionStatus = null;
|
||||
this.videos = [];
|
||||
VideoLearningModule.stopPolling();
|
||||
},
|
||||
|
||||
goBack() {
|
||||
window.location.href = '/';
|
||||
},
|
||||
|
||||
formatDuration(seconds) {
|
||||
return VideoLearningModule.formatDuration(seconds);
|
||||
},
|
||||
|
||||
formatNumber(num) {
|
||||
return VideoLearningModule.formatNumber(num);
|
||||
},
|
||||
|
||||
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);
|
||||
},
|
||||
},
|
||||
|
||||
beforeUnmount() {
|
||||
VideoLearningModule.stopPolling();
|
||||
},
|
||||
}).mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user