<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>任务管理 - 小说改写工具</title>
<script src="javascript/tailwindcss.js"></script>
<script src="javascript/vue.global.js"></script>
<style>
[v-cloak] {
display: none;
}
.modal-backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
/* 禁用数字输入框滚轮事件 */
input[type=number]::-webkit-inner-spin-button,
input[type=number]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type=number] {
-moz-appearance: textfield;
}
body,
html {
margin: 0;
padding: 0;
height: 100vh;
overflow: hidden;
}
#app {
height: 100vh;
overflow-y: auto;
}
</style>
</head>
<body class="bg-gray-50">
<div id="app" v-cloak>
<div class="w-full h-full px-6 py-6">
<!-- Project Selection -->
<div class="bg-white rounded-lg shadow p-6 mb-6">
<div class="flex items-center justify-between gap-4">
<div class="flex-1">
<label class="block text-sm font-semibold text-gray-700 mb-2">选择项目</label>
<select v-model="selectedProject" @change="onProjectChange"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-lg focus:outline-none focus:border-indigo-500 transition-colors">
<option value="">请选择项目...</option>
<option v-for="project in projects" :key="project" :value="project">{{ project }}</option>
</select>
</div>
<div class="flex-1" v-if="selectedProject">
<label class="block text-sm font-semibold text-gray-700 mb-2">使用提示词</label>
<select v-model="selectedPromptFile" @change="onPromptChange"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-lg focus:outline-none focus:border-indigo-500 transition-colors">
<option v-for="prompt in promptList" :key="prompt.name" :value="prompt.name">
{{ prompt.name }}{{ prompt.is_default ? ' (默认)' : '' }}
</option>
</select>
</div>
<button @click="loadProjects"
class="mt-7 px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
刷新
</button>
</div>
</div>
<!-- Task Statistics -->
<div v-if="selectedProject && taskStats" class="bg-white rounded-lg shadow p-6 mb-6">
<h2 class="text-2xl font-bold mb-4 text-gray-800">任务统计</h2>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<div class="bg-gradient-to-br from-gray-50 to-gray-100 p-4 rounded-lg border-2 border-gray-200">
<h3 class="text-sm text-gray-600 mb-1 font-semibold">总数</h3>
<p class="text-3xl font-bold text-gray-700">{{ taskStats.total }}</p>
</div>
<div class="bg-gradient-to-br from-green-50 to-green-100 p-4 rounded-lg border-2 border-green-200">
<h3 class="text-sm text-gray-600 mb-1 font-semibold">已完成</h3>
<p class="text-3xl font-bold text-green-600">{{ taskStats.completed }}</p>
</div>
<div class="bg-gradient-to-br from-yellow-50 to-yellow-100 p-4 rounded-lg border-2 border-yellow-200">
<h3 class="text-sm text-gray-600 mb-1 font-semibold">运行中</h3>
<p class="text-3xl font-bold text-yellow-600">{{ taskStats.running }}</p>
</div>
<div class="bg-gradient-to-br from-blue-50 to-blue-100 p-4 rounded-lg border-2 border-blue-200">
<h3 class="text-sm text-gray-600 mb-1 font-semibold">待处理</h3>
<p class="text-3xl font-bold text-blue-600">{{ taskStats.pending }}</p>
</div>
<div class="bg-gradient-to-br from-red-50 to-red-100 p-4 rounded-lg border-2 border-red-200">
<h3 class="text-sm text-gray-600 mb-1 font-semibold">失败</h3>
<p class="text-3xl font-bold text-red-600">{{ taskStats.failed }}</p>
</div>
<div class="bg-gradient-to-br from-purple-50 to-purple-100 p-4 rounded-lg border-2 border-purple-200">
<h3 class="text-sm text-gray-600 mb-1 font-semibold">已忽略</h3>
<p class="text-3xl font-bold text-purple-600">{{ taskStats.ignored || 0 }}</p>
</div>
</div>
<!-- Progress Bar -->
<div class="mt-6">
<div class="flex justify-between text-sm text-gray-600 mb-2">
<span class="font-semibold">完成进度 (不含已忽略)</span>
<span class="font-bold text-gray-800">{{ taskStats.total - (taskStats.ignored || 0) > 0 ?
Math.round(taskStats.completed / (taskStats.total - (taskStats.ignored || 0)) * 100) : 0 }}%</span>
</div>
<div class="w-full bg-gray-200 rounded-full h-4 overflow-hidden shadow-inner">
<div
class="bg-gradient-to-r from-green-500 to-green-600 h-full rounded-full transition-all duration-500 flex items-center justify-end pr-2"
:style="{ width: (taskStats.total - (taskStats.ignored || 0) > 0 ? taskStats.completed / (taskStats.total - (taskStats.ignored || 0)) * 100 : 0) + '%' }">
<span v-if="taskStats.completed > 0" class="text-xs text-white font-bold">{{ taskStats.completed }}/{{
taskStats.total - (taskStats.ignored || 0) }}</span>
</div>
</div>
</div>
</div>
<!-- Filter and Actions -->
<div v-if="selectedProject" class="bg-white rounded-lg shadow p-6 mb-6">
<div class="flex flex-col lg:flex-row items-stretch lg:items-end gap-4">
<div class="flex-grow">
<label class="block text-sm font-semibold text-gray-700 mb-2">筛选状态</label>
<select v-model="filterStatus" @change="() => { loadTasks(true); loadTaskStats(); startAutoRefresh(); }"
class="w-full px-4 py-2 border-2 border-gray-200 rounded-lg focus:outline-none focus:border-indigo-500 transition-colors">
<option value="all">📋 全部</option>
<option value="pending">⏳ 待处理</option>
<option value="running">🔄 运行中</option>
<option value="completed">✅ 已完成</option>
<option value="failed">❌ 失败</option>
<option value="ignored">🔕 已忽略</option>
</select>
</div>
<div class="flex flex-wrap gap-2">
<button @click="() => { loadTasks(true); loadTaskStats(); startAutoRefresh(); }"
class="flex-1 sm:flex-none px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors flex items-center justify-center gap-2 shadow-sm hover:shadow">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
刷新列表
</button>
<button @click="checkTimeout" :disabled="isCheckingTimeout"
class="flex-1 sm:flex-none px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors flex items-center justify-center gap-2 disabled:bg-gray-400 disabled:cursor-not-allowed shadow-sm hover:shadow">
<svg class="w-5 h-5" :class="{ 'animate-spin': isCheckingTimeout }" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span class="hidden sm:inline">{{ isCheckingTimeout ? '检查中...' : '检查超时' }}</span>
</button>
<button @click="showExecutionPrompt"
class="flex-1 sm:flex-none px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors flex items-center justify-center gap-2 shadow-sm hover:shadow">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
<span class="hidden lg:inline">获取执行提示词</span>
<span class="lg:hidden">执行提示词</span>
</button>
<button @click="exportCombined" :disabled="isExporting"
class="flex-1 sm:flex-none px-4 py-2 bg-orange-600 hover:bg-orange-700 text-white rounded-lg transition-colors flex items-center justify-center gap-2 disabled:bg-gray-400 disabled:cursor-not-allowed shadow-sm hover:shadow">
<svg class="w-5 h-5" :class="{ 'animate-spin': isExporting }" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span class="hidden sm:inline">{{ isExporting ? '导出中...' : '导出合并文件' }}</span>
</button>
</div>
</div>
<!-- Auto Refresh Status -->
<div
class="mt-4 flex items-center gap-2 text-sm text-green-600 bg-green-50 px-3 py-2 rounded-lg border border-green-200">
<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<span class="font-medium">自动刷新中 (每2秒)</span>
</div>
</div>
<!-- Task List -->
<div v-if="selectedProject && tasks.length > 0" class="bg-white rounded-lg shadow p-6">
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-gray-800">
任务列表 (显示 {{ displayedTasks.length }} / {{ tasks.length }})
</h2>
<div class="flex gap-3" v-if="displayedTasks.length < tasks.length">
<button @click="loadMore"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
加载更多 (+30)
</button>
<button @click="loadAll"
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
加载全部
</button>
</div>
</div>
<div class="space-y-3">
<div v-for="task in displayedTasks" :key="task.id"
class="border-l-4 p-4 rounded-r-lg hover:shadow-md transition-all duration-200" :class="{
'border-blue-500 bg-blue-50 hover:bg-blue-100': task.status === 'pending',
'border-yellow-500 bg-yellow-50 hover:bg-yellow-100': task.status === 'running',
'border-green-500 bg-green-50 hover:bg-green-100': task.status === 'completed',
'border-red-500 bg-red-50 hover:bg-red-100': task.status === 'failed',
'border-purple-500 bg-purple-50 hover:bg-purple-100': task.status === 'ignored'
}">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<div class="flex-1 w-full">
<div class="flex items-center gap-3 mb-2 flex-wrap">
<span class="text-lg font-bold text-gray-800">{{ task.id }}</span>
<span class="px-3 py-1 rounded-full text-sm font-semibold shadow-sm" :class="{
'bg-blue-200 text-blue-800 border border-blue-300': task.status === 'pending',
'bg-yellow-200 text-yellow-800 border border-yellow-300': task.status === 'running',
'bg-green-200 text-green-800 border border-green-300': task.status === 'completed',
'bg-red-200 text-red-800 border border-red-300': task.status === 'failed',
'bg-purple-200 text-purple-800 border border-purple-300': task.status === 'ignored'
}">
{{ getStatusText(task.status) }}
</span>
</div>
<p class="text-gray-700 mb-2 font-medium">📄 {{ task.file_name }}</p>
<div class="flex flex-wrap gap-3 text-sm text-gray-600">
<span v-if="task.word_count_original" class="bg-white px-2 py-1 rounded border border-gray-200">
📝 原文: <span class="font-semibold">{{ task.word_count_original }}</span> 字
</span>
<span v-if="task.word_count_rewritten" class="bg-white px-2 py-1 rounded border border-gray-200">
✏️ 改写: <span class="font-semibold">{{ task.word_count_rewritten }}</span> 字
</span>
<span v-if="task.word_count_original && task.word_count_rewritten"
class="bg-white px-2 py-1 rounded border border-gray-200">
📊 压缩率: <span class="font-semibold">{{ Math.round((task.word_count_original -
task.word_count_rewritten) / task.word_count_original * 100) }}%</span>
</span>
<span v-if="task.started_at"
class="bg-white px-2 py-1 rounded border border-purple-200 text-purple-700 font-semibold">
⏱️ {{ formatExecutionTime(task) }}
</span>
</div>
<p v-if="task.error_message"
class="text-red-600 text-sm mt-2 bg-red-100 px-3 py-2 rounded border border-red-200">
⚠️ 错误: {{ task.error_message }}
</p>
</div>
<div class="flex gap-2 w-full sm:w-auto">
<button @click="viewContent(task)" title="查看内容"
class="flex-1 sm:flex-none px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-lg transition-colors shadow-sm hover:shadow">
<svg class="w-5 h-5 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
</button>
<button v-if="task.status === 'running' || task.status === 'failed' || task.status === 'ignored'"
@click="resetTask(task)" title="重置为待处理"
class="flex-1 sm:flex-none px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors shadow-sm hover:shadow">
<svg class="w-5 h-5 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Empty State -->
<div v-else-if="selectedProject && tasks.length === 0" class="bg-white rounded-lg shadow p-12 text-center">
<svg class="w-20 h-20 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p class="text-xl text-gray-600">暂无任务</p>
</div>
<!-- No Project Selected -->
<div v-else-if="!selectedProject" class="bg-white rounded-lg shadow p-12 text-center">
<svg class="w-20 h-20 mx-auto mb-4 text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<p class="text-xl text-gray-600">请先选择一个项目</p>
</div>
</div>
<!-- Content Modal -->
<div v-if="showModal" @click.self="closeModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 modal-backdrop">
<div class="bg-white rounded-lg shadow-lg max-w-6xl w-full max-h-[90vh] overflow-hidden flex flex-col">
<!-- Modal Header -->
<div class="p-6 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-2xl font-bold text-gray-800">{{ currentTask?.id }} - {{ currentTask?.file_name }}</h3>
<button @click="closeModal" class="text-gray-500 hover:text-gray-700 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="flex-1 overflow-y-auto p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Source Content -->
<div>
<h4 class="text-lg font-bold text-gray-700 mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
原文 ({{ modalContent.source_content?.length || 0 }} 字)
</div>
<button @click="copyContent(modalContent.source_content, 'source')"
class="px-3 py-1 bg-gray-600 hover:bg-gray-700 text-white text-sm rounded transition-colors flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{{ sourceCopyText }}
</button>
</h4>
<div class="bg-gray-50 rounded-lg p-4 border border-gray-200 max-h-[500px] overflow-y-auto">
<p class="text-gray-700 whitespace-pre-wrap leading-relaxed">{{ modalContent.source_content || '暂无内容' }}
</p>
</div>
</div>
<!-- Rewrite Content -->
<div>
<h4 class="text-lg font-bold text-gray-700 mb-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
改写 ({{ modalContent.rewrite_content?.length || 0 }} 字)
</div>
<button @click="copyContent(modalContent.rewrite_content, 'rewrite')"
class="px-3 py-1 bg-green-600 hover:bg-green-700 text-white text-sm rounded transition-colors flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{{ rewriteCopyText }}
</button>
</h4>
<div class="bg-green-50 rounded-lg p-4 border border-green-200 max-h-[500px] overflow-y-auto">
<p class="text-gray-700 whitespace-pre-wrap leading-relaxed">{{ modalContent.rewrite_content || '暂无内容'
}}</p>
</div>
</div>
</div>
</div>
<!-- Modal Footer -->
<div class="p-6 border-t border-gray-200 flex justify-end gap-3">
<button @click="closeModal"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
关闭
</button>
</div>
</div>
</div>
<!-- Execution Prompt Modal -->
<div v-if="showPromptModal" @click.self="closePromptModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 modal-backdrop">
<div class="bg-white rounded-lg shadow-lg max-w-3xl w-full overflow-hidden flex flex-col">
<!-- Modal Header -->
<div class="p-6 border-b border-gray-200 flex items-center justify-between">
<h3 class="text-2xl font-bold text-gray-800">任务执行提示词</h3>
<button @click="closePromptModal" class="text-gray-500 hover:text-gray-700 transition-colors">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Modal Body -->
<div class="p-6">
<div class="mb-4">
<p class="text-sm text-gray-600 mb-3">
复制以下提示词发送给 AI,让它自动执行项目【{{ selectedProject }}】的任务:
</p>
<div
class="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-lg p-6 border-2 border-purple-200 relative">
<pre
class="text-gray-800 whitespace-pre-wrap leading-relaxed font-mono text-base">{{ executionPrompt }}</pre>
<button @click="copyPrompt"
class="absolute top-3 right-3 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors text-sm flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
{{ copyButtonText }}
</button>
</div>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 class="font-bold text-blue-800 mb-2 flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
使用说明
</h4>
<ul class="text-sm text-blue-800 space-y-1 ml-7">
<li>• 复制上方提示词</li>
<li>• 粘贴到 Cursor 对话框</li>
<li>• AI 将自动使用 MCP 工具循环执行任务</li>
<li>• 每个任务执行完成后会自动进行下一个</li>
</ul>
</div>
</div>
<!-- Modal Footer -->
<div class="p-6 border-t border-gray-200 flex justify-end gap-3">
<button @click="closePromptModal"
class="px-6 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg transition-colors">
关闭
</button>
</div>
</div>
</div>
</div>
<script>
const { createApp, ref, computed, onMounted } = Vue;
// 如果不在 iframe 中,提示用户访问主页面
if (window.self === window.top) {
const urlParams = new URLSearchParams(window.location.search);
const projectParam = urlParams.get('project');
const useMainPage = confirm('建议使用主页面以获得更好的体验,是否跳转?');
if (useMainPage) {
const mainUrl = projectParam
? `index.html?tab=tasks&project=${encodeURIComponent(projectParam)}`
: 'index.html?tab=tasks';
window.location.href = mainUrl;
}
}
createApp({
setup() {
// 响应式数据
const projects = ref([]);
const selectedProject = ref('');
const tasks = ref([]);
const taskStats = ref(null);
const filterStatus = ref('all');
const showModal = ref(false);
const currentTask = ref(null);
const modalContent = ref({ source_content: '', rewrite_content: '' });
const isCheckingTimeout = ref(false);
const showPromptModal = ref(false);
const executionPrompt = ref('');
const copyButtonText = ref('复制');
const displayLimit = ref(30); // 默认显示30个任务
const promptList = ref([]); // 提示词列表
const selectedPromptFile = ref(''); // 当前选择的提示词文件
const sourceCopyText = ref('复制');
const rewriteCopyText = ref('复制');
const isExporting = ref(false); // 是否正在导出
let refreshTimer = null; // 定时刷新任务
// 从 URL 参数获取项目名
const urlParams = new URLSearchParams(window.location.search);
const projectParam = urlParams.get('project');
// 计算属性 - 显示的任务列表
const displayedTasks = computed(() => {
return tasks.value.slice(0, displayLimit.value);
});
// 方法
const loadProjects = async () => {
try {
const response = await fetch('/api/projects');
const data = await response.json();
if (data.success) {
projects.value = data.projects;
// 如果有 URL 参数,优先使用 URL 参数
if (projectParam && projects.value.includes(projectParam)) {
selectedProject.value = projectParam;
await loadTasks(true);
await loadTaskStats();
}
// 否则,尝试从缓存中获取上次选择的项目
else if (projects.value.length > 0) {
const cachedProject = localStorage.getItem('lastSelectedProject');
// 如果缓存存在且项目列表中有这个项目,则选择它
if (cachedProject && projects.value.includes(cachedProject)) {
selectedProject.value = cachedProject;
} else {
// 否则选择第一个项目
selectedProject.value = projects.value[0];
}
// 自动加载任务和统计
await loadTasks(true);
await loadTaskStats();
}
}
} catch (error) {
console.error('加载项目列表失败:', error);
}
};
const loadTasks = async (resetLimit = false) => {
if (!selectedProject.value) return;
try {
const response = await fetch(`/api/tasks?project_name=${selectedProject.value}&status=${filterStatus.value}`);
const data = await response.json();
if (data.success) {
tasks.value = data.tasks;
// 只有在手动刷新时才重置显示限制
if (resetLimit) {
displayLimit.value = 30;
}
}
} catch (error) {
console.error('加载任务列表失败:', error);
}
};
const loadTaskStats = async () => {
if (!selectedProject.value) return;
try {
const response = await fetch(`/api/tasks/status?project_name=${selectedProject.value}`);
const data = await response.json();
if (data.success) {
taskStats.value = data.metadata;
// 设置当前项目的提示词配置
if (data.metadata.prompt_file) {
selectedPromptFile.value = data.metadata.prompt_file;
} else {
// 如果项目没有配置提示词,使用默认的
selectedPromptFile.value = '改写提示词.md';
}
}
} catch (error) {
console.error('加载任务统计失败:', error);
}
};
const loadPromptList = async () => {
try {
const response = await fetch('/api/prompts?include_default=true');
const data = await response.json();
if (data.success) {
promptList.value = data.files;
}
} catch (error) {
console.error('加载提示词列表失败:', error);
}
};
const onPromptChange = async () => {
if (!selectedProject.value || !selectedPromptFile.value) return;
try {
const response = await fetch('/api/projects/update_prompt', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
project_name: selectedProject.value,
prompt_file: selectedPromptFile.value
})
});
const data = await response.json();
if (data.success) {
console.log(`已将项目 ${selectedProject.value} 的提示词设置为: ${selectedPromptFile.value}`);
} else {
alert('设置提示词失败: ' + (data.error || '未知错误'));
}
} catch (error) {
console.error('设置提示词失败:', error);
alert('设置提示词失败: ' + error.message);
}
};
const onProjectChange = async () => {
await loadTasks(true);
await loadTaskStats();
// 保存到缓存
if (selectedProject.value) {
localStorage.setItem('lastSelectedProject', selectedProject.value);
}
// 更新 URL 参数
const url = new URL(window.location);
url.searchParams.set('project', selectedProject.value);
window.history.pushState({}, '', url);
// 重新启动定时刷新
startAutoRefresh();
};
const viewContent = async (task) => {
currentTask.value = task;
showModal.value = true;
try {
const response = await fetch(`/api/tasks/content?project_name=${selectedProject.value}&task_id=${task.id}`);
const data = await response.json();
if (data.success) {
modalContent.value = {
source_content: data.source_content,
rewrite_content: data.rewrite_content
};
}
} catch (error) {
console.error('加载任务内容失败:', error);
}
};
const closeModal = () => {
showModal.value = false;
currentTask.value = null;
modalContent.value = { source_content: '', rewrite_content: '' };
};
const resetTask = async (task) => {
if (!confirm(`确定要重置任务 ${task.id} 吗?`)) return;
try {
const response = await fetch('/api/tasks/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
project_name: selectedProject.value,
task_id: task.id,
status: 'pending'
})
});
const data = await response.json();
if (data.success) {
await loadTasks(false);
await loadTaskStats();
} else {
alert('重置失败: ' + (data.message || data.error));
}
} catch (error) {
console.error('重置任务失败:', error);
alert('重置失败: ' + error.message);
}
};
const checkTimeout = async () => {
if (!confirm('确定要检查所有项目的超时任务吗?')) return;
isCheckingTimeout.value = true;
try {
const response = await fetch('/api/tasks/check_timeout');
const data = await response.json();
if (data.success) {
alert(`检查完成!\n检查了 ${data.projects_count} 个项目\n发现 ${data.checked_count} 个超时任务\n完成 ${data.completed_count} 个\n重置 ${data.recovered_count} 个`);
await loadTasks(false);
await loadTaskStats();
} else {
alert('检查失败: ' + (data.error || '未知错误'));
}
} catch (error) {
console.error('检查超时任务失败:', error);
alert('检查失败: ' + error.message);
} finally {
isCheckingTimeout.value = false;
}
};
const getStatusText = (status) => {
const statusMap = {
'pending': '待处理',
'running': '运行中',
'completed': '已完成',
'failed': '失败',
'ignored': '已忽略'
};
return statusMap[status] || status;
};
const showExecutionPrompt = () => {
if (!selectedProject.value) {
alert('请先选择一个项目');
return;
}
// 生成执行提示词
executionPrompt.value = `请帮我执行项目【${selectedProject.value}】的批量改写任务。
使用 MCP 工具 run_task 循环执行以下步骤:
1. 获取下一个待处理任务
2. 按照提示词要求改写内容
3. 保存改写结果
4. 标记任务完成
5. 继续下一个任务,直到全部完成
请开始执行。`;
showPromptModal.value = true;
copyButtonText.value = '复制';
};
const copyPrompt = async () => {
try {
await navigator.clipboard.writeText(executionPrompt.value);
copyButtonText.value = '已复制!';
setTimeout(() => {
copyButtonText.value = '复制';
}, 2000);
} catch (error) {
console.error('复制失败:', error);
alert('复制失败,请手动复制');
}
};
const closePromptModal = () => {
showPromptModal.value = false;
};
// 复制内容
const copyContent = async (content, type) => {
if (!content) {
alert('暂无内容可复制');
return;
}
try {
await navigator.clipboard.writeText(content);
if (type === 'source') {
sourceCopyText.value = '已复制!';
setTimeout(() => {
sourceCopyText.value = '复制';
}, 2000);
} else if (type === 'rewrite') {
rewriteCopyText.value = '已复制!';
setTimeout(() => {
rewriteCopyText.value = '复制';
}, 2000);
}
} catch (error) {
console.error('复制失败:', error);
alert('复制失败,请手动复制');
}
};
// 计算任务运行时间(秒)
const calculateExecutionTime = (task) => {
if (!task.started_at) return null;
const startTime = new Date(task.started_at);
let endTime;
if (task.completed_at) {
// 已完成、失败的任务:使用完成时间(基于 started_at 计算,而不是 created_at)
endTime = new Date(task.completed_at);
} else if (task.status === 'running') {
// 运行中的任务:使用当前时间(基于 started_at 计算,而不是 created_at)
endTime = new Date();
} else {
// 其他状态且没有完成时间:返回null
return null;
}
return Math.floor((endTime - startTime) / 1000);
};
// 格式化执行时间
const formatExecutionTime = (task) => {
const seconds = calculateExecutionTime(task);
if (!seconds && seconds !== 0) return '';
if (seconds < 60) {
return `${seconds}秒`;
} else if (seconds < 3600) {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}分${secs}秒`;
} else {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
return `${hours}小时${minutes}分${secs}秒`;
}
};
// 加载更多任务(每次加载30个)
const loadMore = () => {
displayLimit.value += 30;
};
// 加载全部任务
const loadAll = () => {
displayLimit.value = tasks.value.length;
};
// 启动定时刷新
const startAutoRefresh = () => {
// 清除旧的定时器(如果存在)
if (refreshTimer) {
clearInterval(refreshTimer);
}
// 每2秒自动刷新一次任务列表和统计
refreshTimer = setInterval(async () => {
if (selectedProject.value) {
await loadTasks();
await loadTaskStats();
}
}, 2000);
};
// 停止定时刷新
const stopAutoRefresh = () => {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
};
// 导出合并文件
const exportCombined = async () => {
if (!selectedProject.value) {
alert('请先选择一个项目');
return;
}
if (!confirm(`确定要导出项目【${selectedProject.value}】的合并文件吗?`)) {
return;
}
isExporting.value = true;
try {
const response = await fetch(`/api/projects/export?project_name=${encodeURIComponent(selectedProject.value)}`);
if (!response.ok) {
const data = await response.json();
throw new Error(data.error || '导出失败');
}
// 获取文件名
const contentDisposition = response.headers.get('Content-Disposition');
let filename = `${selectedProject.value}_合并.txt`;
if (contentDisposition) {
const match = contentDisposition.match(/filename="(.+)"/);
if (match) {
filename = match[1];
}
}
// 下载文件
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
alert('导出成功!文件已保存到下载目录');
} catch (error) {
console.error('导出失败:', error);
alert('导出失败: ' + error.message);
} finally {
isExporting.value = false;
}
};
// 监听来自父窗口的消息
window.addEventListener('message', async (event) => {
if (event.data.action === 'setProject' && event.data.project) {
// 确保项目列表已加载
if (projects.value.length === 0) {
await loadProjects();
}
// 设置选中的项目
if (projects.value.includes(event.data.project)) {
selectedProject.value = event.data.project;
// 保存到缓存
localStorage.setItem('lastSelectedProject', event.data.project);
await loadTasks(true);
await loadTaskStats();
}
}
});
// 生命周期
onMounted(() => {
loadProjects();
loadPromptList();
// 启动定时刷新
startAutoRefresh();
});
// 组件卸载时清除定时器
window.addEventListener('beforeunload', () => {
stopAutoRefresh();
});
return {
projects,
selectedProject,
tasks,
taskStats,
filterStatus,
showModal,
currentTask,
modalContent,
isCheckingTimeout,
showPromptModal,
executionPrompt,
copyButtonText,
displayLimit,
displayedTasks,
promptList,
selectedPromptFile,
sourceCopyText,
rewriteCopyText,
isExporting,
loadProjects,
loadTasks,
loadTaskStats,
loadPromptList,
onProjectChange,
onPromptChange,
viewContent,
closeModal,
resetTask,
checkTimeout,
getStatusText,
showExecutionPrompt,
copyPrompt,
closePromptModal,
copyContent,
calculateExecutionTime,
formatExecutionTime,
loadMore,
loadAll,
startAutoRefresh,
exportCombined
};
}
}).mount('#app');
</script>
</body>
</html>