feat: 管理员页面整合与侧边栏导航优化
- 完成系统管理页面与导航整合 - 在 users.html/topics.html/logs.html 侧边栏与移动导航添加系统管理入口 - 创建 admin.html 管理页面(案例/任务日志/LLM配置/系统配置) - 新增核心模块:collector.py(数据采集器)、scheduler.py(任务调度器) - 新增脚本:collector_db_integration.py(采集器数据库整合) - 更新项目文档并验证路由注册
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
选题收集模块
|
||||||
|
调用 scripts/collector.py 脚本,从外部来源收集/生成新选题并写入 JSON
|
||||||
|
"""
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 计算项目根目录
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[4]
|
||||||
|
if os.getenv('PROJECT_ROOT'):
|
||||||
|
PROJECT_ROOT = Path(os.getenv('PROJECT_ROOT'))
|
||||||
|
|
||||||
|
def run_collector():
|
||||||
|
"""运行选题收集脚本"""
|
||||||
|
script_path = PROJECT_ROOT / "scripts" / "collector.py"
|
||||||
|
if not script_path.exists():
|
||||||
|
raise FileNotFoundError(f"Collector script not found: {script_path}")
|
||||||
|
result = subprocess.run(
|
||||||
|
["python", str(script_path)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
cwd=PROJECT_ROOT,
|
||||||
|
timeout=300
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise RuntimeError(f"Collector failed: {result.stderr}")
|
||||||
|
return {"ok": True, "output": result.stdout}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""
|
||||||
|
定时任务调度器
|
||||||
|
基于 APScheduler,支持在 FastAPI 生命周期内运行定时任务
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from apscheduler.schedulers.background import BackgroundScheduler
|
||||||
|
from apscheduler.triggers.cron import CronTrigger
|
||||||
|
from .generator import run_creator
|
||||||
|
from .optimizer import run_optimizer
|
||||||
|
from .sync import sync_all_topics
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class TaskScheduler:
|
||||||
|
def __init__(self):
|
||||||
|
self.scheduler = BackgroundScheduler()
|
||||||
|
self._started = False
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self._started:
|
||||||
|
logger.warning("Scheduler already started")
|
||||||
|
return
|
||||||
|
# 使用 CronTrigger 设置每日固定时间点
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._run_sync,
|
||||||
|
CronTrigger(hour=2, minute=30),
|
||||||
|
id='scheduled_sync',
|
||||||
|
replace_existing=True,
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True
|
||||||
|
)
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._run_generate,
|
||||||
|
CronTrigger(hour=3, minute=30),
|
||||||
|
id='scheduled_generate',
|
||||||
|
replace_existing=True,
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True
|
||||||
|
)
|
||||||
|
self.scheduler.add_job(
|
||||||
|
self._run_optimize,
|
||||||
|
CronTrigger(hour=4, minute=30),
|
||||||
|
id='scheduled_optimize',
|
||||||
|
replace_existing=True,
|
||||||
|
max_instances=1,
|
||||||
|
coalesce=True
|
||||||
|
)
|
||||||
|
self.scheduler.start()
|
||||||
|
self._started = True
|
||||||
|
logger.info("Scheduler started with daily cron triggers (02:30, 03:30, 04:30)")
|
||||||
|
def shutdown(self):
|
||||||
|
if self.scheduler.running:
|
||||||
|
self.scheduler.shutdown()
|
||||||
|
logger.info("Scheduler shut down")
|
||||||
|
|
||||||
|
def _run_generate(self):
|
||||||
|
try:
|
||||||
|
logger.info("[Scheduled] Starting content generation...")
|
||||||
|
result = run_creator()
|
||||||
|
logger.info("[Scheduled] Generation completed: %s", result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Scheduled] Generation failed: %s", e)
|
||||||
|
|
||||||
|
def _run_optimize(self):
|
||||||
|
try:
|
||||||
|
logger.info("[Scheduled] Starting compliance optimization...")
|
||||||
|
result = run_optimizer()
|
||||||
|
logger.info("[Scheduled] Optimization completed: %s", result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Scheduled] Optimization failed: %s", e)
|
||||||
|
|
||||||
|
def _run_sync(self):
|
||||||
|
try:
|
||||||
|
logger.info("[Scheduled] Starting data sync...")
|
||||||
|
sync_all_topics()
|
||||||
|
logger.info("[Scheduled] Sync completed")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("[Scheduled] Sync failed: %s", e)
|
||||||
|
|
||||||
|
def get_jobs(self):
|
||||||
|
"""返回当前所有定时任务的状态"""
|
||||||
|
jobs = []
|
||||||
|
for job in self.scheduler.get_jobs():
|
||||||
|
jobs.append({
|
||||||
|
"id": job.id,
|
||||||
|
"next_run_time": job.next_run_time.isoformat() if job.next_run_time else None,
|
||||||
|
"trigger": str(job.trigger),
|
||||||
|
})
|
||||||
|
return jobs
|
||||||
|
|
||||||
|
# 全局单例
|
||||||
|
scheduler = TaskScheduler()
|
||||||
@@ -255,6 +255,7 @@ const app = createApp({
|
|||||||
|
|
||||||
const activeTab = ref('cases');
|
const activeTab = ref('cases');
|
||||||
const currentUser = ref({ username: '' });
|
const currentUser = ref({ username: '' });
|
||||||
|
const redirectToPage = (page) => { window.location.href = '/' + page; }; const currentUser = ref({ username: '' });
|
||||||
const isAdmin = ref(false);
|
const isAdmin = ref(false);
|
||||||
const isLoggedIn = ref(false);
|
const isLoggedIn = ref(false);
|
||||||
|
|
||||||
@@ -262,8 +263,9 @@ const app = createApp({
|
|||||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||||
.then(response => response.ok ? response.json() : Promise.reject())
|
.then(response => response.ok ? response.json() : Promise.reject())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
currentUser.value = data.user;
|
const user = data.user || { username: '', role: 'user' };
|
||||||
isAdmin.value = data.user.role === 'admin';
|
currentUser.value = user;
|
||||||
|
isAdmin.value = user.role === 'admin';
|
||||||
isLoggedIn.value = true;
|
isLoggedIn.value = true;
|
||||||
if (!isAdmin.value) {
|
if (!isAdmin.value) {
|
||||||
ElMessage.warning('需要管理员权限');
|
ElMessage.warning('需要管理员权限');
|
||||||
@@ -275,6 +277,11 @@ const app = createApp({
|
|||||||
window.location.href = 'login.html';
|
window.location.href = 'login.html';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 页面跳转
|
||||||
|
const redirectToPage = (page) => {
|
||||||
|
window.location.href = '/' + page;
|
||||||
|
};
|
||||||
|
|
||||||
// Cases
|
// Cases
|
||||||
const cases = ref([]);
|
const cases = ref([]);
|
||||||
const caseDialogVisible = ref(false);
|
const caseDialogVisible = ref(false);
|
||||||
|
|||||||
@@ -71,7 +71,7 @@
|
|||||||
<el-date-picker v-model="logDate" type="date" placeholder="选择日期" format="YYYY-MM-DD" value-format="YYYY-MM-DD" size="default"></el-date-picker>
|
<el-date-picker v-model="logDate" type="date" placeholder="选择日期" format="YYYY-MM-DD" value-format="YYYY-MM-DD" size="default"></el-date-picker>
|
||||||
<el-button type="primary" @click="fetchLogs" :loading="loadingLogs">加载日志</el-button>
|
<el-button type="primary" @click="fetchLogs" :loading="loadingLogs">加载日志</el-button>
|
||||||
</div>
|
</div>
|
||||||
<el-card v-if="logContent" class="font-mono text-sm bg-gray-50" style="max-height: 600px; overflow-y: auto; background: #f9fafb; border: 1px solid #e5e7eb;"><pre style="margin: 0; white-space: pre-wrap; word-wrap: break-word;">{{ logContent }}</pre></el-card>
|
<el-card v-if="logContent" ref="logContainer" class="font-mono text-sm bg-gray-50" style="max-height: 600px; overflow-y: auto; background: #f9fafb; border: 1px solid #e5e7eb;"><pre style="margin: 0; white-space: pre-wrap; word-wrap: break-word;">{{ logContent }}</pre></el-card>
|
||||||
<el-empty v-else description="请先选择类型和日期,然后点击加载"></el-empty>
|
<el-empty v-else description="请先选择类型和日期,然后点击加载"></el-empty>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
@@ -88,7 +88,17 @@
|
|||||||
<script src="element-plus.full.js"></script>
|
<script src="element-plus.full.js"></script>
|
||||||
<script>
|
<script>
|
||||||
const LogsApp = {
|
const LogsApp = {
|
||||||
data() { return { isLoggedIn: false, isAdmin: false, currentUser: { username: '' }, logType: 'creator', logDate: '', logContent: '', loadingLogs: false } },
|
data() {
|
||||||
|
const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||||
|
return {
|
||||||
|
isLoggedIn: false,
|
||||||
|
isAdmin: false,
|
||||||
|
currentUser: { username: '' },
|
||||||
|
logType: 'creator',
|
||||||
|
logDate: today,
|
||||||
|
logContent: '',
|
||||||
|
loadingLogs: false
|
||||||
|
} },
|
||||||
methods: {
|
methods: {
|
||||||
async fetchLogs() {
|
async fetchLogs() {
|
||||||
// 验证输入
|
// 验证输入
|
||||||
@@ -113,6 +123,12 @@
|
|||||||
// 后端返回: { type, date, content }
|
// 后端返回: { type, date, content }
|
||||||
this.logContent = `日志类型:${data.type}\n日期:${data.date}\n\n${data.content || '(日志文件为空)'}`;
|
this.logContent = `日志类型:${data.type}\n日期:${data.date}\n\n${data.content || '(日志文件为空)'}`;
|
||||||
this.$message.success('日志加载成功');
|
this.$message.success('日志加载成功');
|
||||||
|
this.$nextTick(() => {
|
||||||
|
const container = this.$refs.logContainer;
|
||||||
|
if (container && container.$el) {
|
||||||
|
container.$el.scrollTop = container.$el.scrollHeight;
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取日志失败:', error);
|
console.error('获取日志失败:', error);
|
||||||
this.$message.error(`获取日志失败: ${error.message}`);
|
this.$message.error(`获取日志失败: ${error.message}`);
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<title>前端测试</title>
|
<title>前端测试</title>
|
||||||
<script src="/static/vue.global.prod.js"></script>
|
<script src="vue.global.prod.js"></script>
|
||||||
<link rel="stylesheet" href="/static/element-plus.css" />
|
<link rel="stylesheet" href="element-plus.css" />
|
||||||
|
|
||||||
|
|
||||||
<!-- Tailwind CSS -->
|
<!-- Tailwind CSS -->
|
||||||
@@ -66,9 +66,9 @@
|
|||||||
|
|
||||||
|
|
||||||
<!-- 本地静态文件 -->
|
<!-- 本地静态文件 -->
|
||||||
<script src="./static/vue.global.prod.js?v=20260427"></script>
|
<script src=".vue.global.prod.js?v=20260427"></script>
|
||||||
<link rel="stylesheet" href="./static/element-plus.css?v=20260427">
|
<link rel="stylesheet" href=".element-plus.css?v=20260427">
|
||||||
<script src="./static/element-plus.full.js?v=20260427"></script>
|
<script src="element-plus.full.js?v=20260427"></script>
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
+103
-21
@@ -6,6 +6,8 @@
|
|||||||
<title>宇之然内容创作平台 - 选题管理</title>
|
<title>宇之然内容创作平台 - 选题管理</title>
|
||||||
<link rel="stylesheet" href="element-plus.css">
|
<link rel="stylesheet" href="element-plus.css">
|
||||||
<style>
|
<style>
|
||||||
|
.preview-iframe { box-sizing: border-box; }
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||||
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||||
@@ -15,8 +17,8 @@
|
|||||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||||
.sidebar { width: 180px; background: white; padding: 16px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
|
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
|
||||||
.sidebar-btn { width: 100%; text-align: left; padding: 12px 16px; border: none; background: transparent; border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: all 0.3s; color: #606266; font-size: 14px; }
|
.sidebar-btn { width: 100%; text-align: left; padding: 8px 12px; border: none; background: transparent; border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: all 0.3s; color: #606266; font-size: 14px; }
|
||||||
.sidebar-btn:hover { background: #f5f7fa; color: #409eff; }
|
.sidebar-btn:hover { background: #f5f7fa; color: #409eff; }
|
||||||
.sidebar-btn.active { background: #ecf5ff; color: #409eff; font-weight: 600; }
|
.sidebar-btn.active { background: #ecf5ff; color: #409eff; font-weight: 600; }
|
||||||
.content-area { flex: 1; padding: 32px; overflow-y: auto; }
|
.content-area { flex: 1; padding: 32px; overflow-y: auto; }
|
||||||
@@ -33,7 +35,7 @@
|
|||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar { display: none; }
|
.sidebar { display: none; }
|
||||||
.mobile-nav { display: flex; }
|
.mobile-nav { display: flex; }
|
||||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
.content-area { padding: 12px; padding-bottom: 80px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.topic-card-list { display: none; }
|
.topic-card-list { display: none; }
|
||||||
@@ -47,7 +49,7 @@
|
|||||||
.topic-card {
|
.topic-card {
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 16px;
|
padding: 12px;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||||
}
|
}
|
||||||
@@ -78,7 +80,41 @@
|
|||||||
.topic-card-actions .el-button { flex: 1; min-width: 60px; }
|
.topic-card-actions .el-button { flex: 1; min-width: 60px; }
|
||||||
.mobile-nav { display: flex; }
|
.mobile-nav { display: flex; }
|
||||||
}
|
}
|
||||||
</style>
|
|
||||||
|
.preview-iframe { box-sizing: border-box; }
|
||||||
|
/* 预览弹窗响应式高度 */
|
||||||
|
@media (min-width: 769px) {
|
||||||
|
.preview-iframe { max-height: calc(100vh - 100px) !important; }
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.preview-iframe { max-height: calc(100vh - 250px) !important; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 预览弹窗自定义高度(非全屏时) */
|
||||||
|
.preview-dialog-custom.el-dialog {
|
||||||
|
max-height: calc(100vh - 90px) !important;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 0 !important;
|
||||||
|
}
|
||||||
|
.preview-dialog-custom.el-dialog .el-dialog__header {
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.preview-dialog-custom.el-dialog .el-dialog__body {
|
||||||
|
padding: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.preview-dialog-custom.el-dialog .el-dialog__footer {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app">
|
<div id="app">
|
||||||
@@ -102,7 +138,7 @@
|
|||||||
<main class="content-area">
|
<main class="content-area">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 style="font-size: 28px; font-weight: 700; margin-bottom: 32px; color: #303133;">📋 选题管理</h2>
|
<h2 style="font-size: 28px; font-weight: 700; margin-bottom: 32px; color: #303133;">📋 选题管理</h2>
|
||||||
<div class="card" style="display: inline-block; min-width: fit-content; padding: 16px; margin-bottom: 24px;">
|
<div class="card" style="display: inline-block; min-width: fit-content; padding: 12px; margin-bottom: 24px;">
|
||||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||||
<el-button type="primary" size="small" @click="refreshAll">🔄 批量刷新</el-button>
|
<el-button type="primary" size="small" @click="refreshAll">🔄 批量刷新</el-button>
|
||||||
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0">▶ 批量创作</el-button>
|
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0">▶ 批量创作</el-button>
|
||||||
@@ -117,7 +153,7 @@
|
|||||||
<el-button size="large" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'">待发布 ({{ statusStats.ready }})</el-button>
|
<el-button size="large" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'">待发布 ({{ statusStats.ready }})</el-button>
|
||||||
<el-button size="large" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'">已发布 ({{ statusStats.published }})</el-button>
|
<el-button size="large" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'">已发布 ({{ statusStats.published }})</el-button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" style="width: 100%; overflow-x: auto; padding: 16px;">
|
<div class="card" style="width: 100%; overflow-x: auto; padding: 12px;">
|
||||||
<el-table :data="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event">
|
<el-table :data="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event">
|
||||||
<el-table-column type="selection" width="55"></el-table-column>
|
<el-table-column type="selection" width="55"></el-table-column>
|
||||||
<el-table-column prop="id" label="ID" width="70" fixed></el-table-column>
|
<el-table-column prop="id" label="ID" width="70" fixed></el-table-column>
|
||||||
@@ -182,7 +218,7 @@
|
|||||||
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
|
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
|
||||||
</nav>
|
</nav>
|
||||||
<!-- 预览弹窗 -->
|
<!-- 预览弹窗 -->
|
||||||
<el-dialog v-model="previewVisible" title="选题预览" width="80%" :before-close="() => previewVisible = false">
|
<el-dialog v-model="previewVisible" title="选题预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen">
|
||||||
<div v-if="previewTopic">
|
<div v-if="previewTopic">
|
||||||
<!-- 平台切换按钮 -->
|
<!-- 平台切换按钮 -->
|
||||||
<div style="margin-bottom: 16px; display: flex; justify-content: flex-end; gap: 8px;">
|
<div style="margin-bottom: 16px; display: flex; justify-content: flex-end; gap: 8px;">
|
||||||
@@ -193,25 +229,47 @@
|
|||||||
</el-button-group>
|
</el-button-group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2 style="margin-top: 0;">{{ previewTopic.title }}</h2>
|
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
|
||||||
|
<h2 style="margin:0; font-size:16px;">{{ previewTopic.title }}</h2>
|
||||||
<!-- 富文本内容预览(v-html 渲染) -->
|
<div style="display:flex; align-items:center; gap:12px; font-size:13px; color:#909399;">
|
||||||
<div class="preview-content" v-html="getPreviewHtml(previewPlatform)"
|
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
||||||
style="max-height: 60vh; overflow-y: auto; margin: 16px 0; padding: 16px; border: 1px solid #ebeef5; border-radius: 8px; background: #fafafa;">
|
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
|
||||||
|
<span v-if="previewTopic.generated_at">创作:{{ formatDate(previewTopic.generated_at) }}</span>
|
||||||
|
<span v-if="previewTopic.published_at">发布:{{ formatDate(previewTopic.published_at) }}</span>
|
||||||
|
<el-button size="small" @click="togglePreviewFullscreen">
|
||||||
|
{{ previewFullscreen ? '退出全屏' : '全屏' }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="preview-footer" style="font-size: 14px; color: #909399;">
|
<!-- 预览内容使用 iframe 隔离样式 -->
|
||||||
<div>创建时间:{{ formatDate(previewTopic.created_at) }}</div>
|
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
|
||||||
<div>状态:{{ getStatusLabel(previewTopic.status) }}</div>
|
<iframe :srcdoc="currentPreviewHtml"
|
||||||
<div v-if="previewTopic.generated_at">创作时间:{{ formatDate(previewTopic.generated_at) }}</div>
|
class="preview-iframe"
|
||||||
<div v-if="previewTopic.published_at">发布时间:{{ formatDate(previewTopic.published_at) }}</div>
|
style="flex: 1; min-height: 500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding: 0 0; width: 100%;"
|
||||||
|
sandbox>
|
||||||
|
</iframe>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:14px; color:#909399;">
|
||||||
|
<div class="preview-info">
|
||||||
|
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
||||||
|
<span style="margin: 0 8px;">|</span>
|
||||||
|
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
|
||||||
|
<span v-if="previewTopic.generated_at" style="margin-left:8px;">
|
||||||
|
创作:{{ formatDate(previewTopic.generated_at) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="previewTopic.published_at" style="margin-left:8px;">
|
||||||
|
发布:{{ formatDate(previewTopic.published_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
<el-button @click="previewVisible = false">关闭</el-button>
|
<el-button @click="previewVisible = false">关闭</el-button>
|
||||||
<el-button type="primary" @click="copyContent(previewPlatform)">复制并发布到{{ platformName(previewPlatform) }}</el-button>
|
<el-button type="primary" @click="copyContent(previewPlatform)">复制并发布到{{ platformName(previewPlatform) }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,6 +291,7 @@ const TopicsApp = {
|
|||||||
topics: [],
|
topics: [],
|
||||||
previewVisible: false,
|
previewVisible: false,
|
||||||
previewTopic: null,
|
previewTopic: null,
|
||||||
|
previewFullscreen: false,
|
||||||
previewPlatform: 'zhihu',
|
previewPlatform: 'zhihu',
|
||||||
platformContents: {}
|
platformContents: {}
|
||||||
}
|
}
|
||||||
@@ -262,6 +321,28 @@ const TopicsApp = {
|
|||||||
ready: this.topics.filter(t => ready.includes(t.status)).length,
|
ready: this.topics.filter(t => ready.includes(t.status)).length,
|
||||||
published: this.topics.filter(t => published.includes(t.status)).length
|
published: this.topics.filter(t => published.includes(t.status)).length
|
||||||
};
|
};
|
||||||
|
},
|
||||||
|
currentPreviewHtml() {
|
||||||
|
const html = this.platformContents[this.previewPlatform];
|
||||||
|
if (!html) return '';
|
||||||
|
try {
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const doc = parser.parseFromString(html, 'text/html');
|
||||||
|
const body = doc.body;
|
||||||
|
if (!body) return html;
|
||||||
|
|
||||||
|
body.querySelectorAll('script, nav, .header, footer, .interaction').forEach(el => el.remove());
|
||||||
|
|
||||||
|
const head = doc.querySelector('head');
|
||||||
|
const headHtml = head ? head.innerHTML : '';
|
||||||
|
const bodyHtml = body.innerHTML;
|
||||||
|
const ending = '<p style="margin-top:24px;padding-top:16px;border-top:1px solid #eee;color:#666;font-size:14px;">感兴趣可以收藏关注我们,欢迎在评论区分享你的实践经验和改进建议!</p>';
|
||||||
|
|
||||||
|
return `<!DOCTYPE html><html><head>${headHtml}</head><body style="margin:0;padding:0;">${bodyHtml}${ending}</body></html>`;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('生成预览 HTML 失败:', e);
|
||||||
|
return html;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -381,8 +462,8 @@ const TopicsApp = {
|
|||||||
);
|
);
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
},
|
},
|
||||||
getPreviewHtml(platform) {
|
togglePreviewFullscreen() {
|
||||||
return this.platformContents[platform] || '暂无内容';
|
this.previewFullscreen = !this.previewFullscreen;
|
||||||
},
|
},
|
||||||
platformName(platform) {
|
platformName(platform) {
|
||||||
const names = {
|
const names = {
|
||||||
@@ -392,6 +473,7 @@ const TopicsApp = {
|
|||||||
};
|
};
|
||||||
return names[platform] || platform;
|
return names[platform] || platform;
|
||||||
},
|
},
|
||||||
|
// 计算属性:当前平台预览的完整 HTML(响应式更新)
|
||||||
copyContent(platform) {
|
copyContent(platform) {
|
||||||
if (!this.previewTopic || !this.previewTopic.content) {
|
if (!this.previewTopic || !this.previewTopic.content) {
|
||||||
this.$message.warning('暂无内容可复制');
|
this.$message.warning('暂无内容可复制');
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
collector 数据库集成模块
|
||||||
|
将采集到的选题写入数据库
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import Topic
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def save_topics_to_db(topics_data: list):
|
||||||
|
"""将选题列表保存到数据库(插入或更新)"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
for t in topics_data:
|
||||||
|
# 检查是否存在
|
||||||
|
existing = db.query(Topic).filter(Topic.id == t['id']).first()
|
||||||
|
if existing:
|
||||||
|
# 更新字段
|
||||||
|
existing.title = t['title']
|
||||||
|
existing.field = t.get('field', existing.field)
|
||||||
|
existing.format = t.get('format', existing.format)
|
||||||
|
existing.core_concept = t.get('core_concept', existing.core_concept)
|
||||||
|
existing.audience_pain = t.get('audience_pain', existing.audience_pain)
|
||||||
|
existing.unique_angle = t.get('unique_angle', existing.unique_angle)
|
||||||
|
existing.priority = t.get('priority', existing.priority)
|
||||||
|
existing.priority_score = t.get('priority_score', existing.priority_score)
|
||||||
|
existing.total_score = t.get('total_score', existing.total_score)
|
||||||
|
existing.status = t.get('status', existing.status)
|
||||||
|
existing.cases = t.get('cases', existing.cases)
|
||||||
|
existing.source_file = t.get('source_file', existing.source_file)
|
||||||
|
existing.ready_at = datetime.strptime(t['ready_at'], '%Y-%m-%d').date() if t.get('ready_at') else existing.ready_at
|
||||||
|
existing.published_at = datetime.strptime(t['published_at'], '%Y-%m-%d').date() if t.get('published_at') else existing.published_at
|
||||||
|
existing.compliance_score = t.get('compliance_score', existing.compliance_score)
|
||||||
|
existing.platform_urls = t.get('platform_urls', existing.platform_urls)
|
||||||
|
existing.updated_at = datetime.now()
|
||||||
|
else:
|
||||||
|
# 新增
|
||||||
|
topic = Topic(
|
||||||
|
id=t['id'],
|
||||||
|
title=t['title'],
|
||||||
|
field=t.get('field', '可持续生活系统'),
|
||||||
|
format=t.get('format'),
|
||||||
|
core_concept=t.get('core_concept'),
|
||||||
|
audience_pain=t.get('audience_pain'),
|
||||||
|
unique_angle=t.get('unique_angle'),
|
||||||
|
priority=t.get('priority', '中'),
|
||||||
|
priority_score=t.get('priority_score', 0),
|
||||||
|
total_score=t.get('total_score'),
|
||||||
|
status=t.get('status', 'pending'),
|
||||||
|
cases=t.get('cases', []),
|
||||||
|
source_file=t.get('source_file'),
|
||||||
|
ready_at=datetime.strptime(t['ready_at'], '%Y-%m-%d').date() if t.get('ready_at') else None,
|
||||||
|
published_at=datetime.strptime(t['published_at'], '%Y-%m-%d').date() if t.get('published_at') else None,
|
||||||
|
compliance_score=t.get('compliance_score', 100),
|
||||||
|
platform_urls=t.get('platform_urls', {}),
|
||||||
|
created_at=datetime.now(),
|
||||||
|
updated_at=datetime.now()
|
||||||
|
)
|
||||||
|
db.add(topic)
|
||||||
|
db.commit()
|
||||||
|
print(f"✅ 保存/更新了 {len(topics_data)} 个选题到数据库")
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise e
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# 测试示例
|
||||||
|
print("collector_db_integration module - for import only")
|
||||||
+1
-1
@@ -46,4 +46,4 @@ echo "========================================"
|
|||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
cd backend
|
cd backend
|
||||||
exec $PYTHON_CMD -m uvicorn app.main:app --host 0.0.0.0 --port $PORT --reload
|
exec $PYTHON_CMD -m uvicorn app.main:app --host 0.0.0.0 --port $PORT
|
||||||
|
|||||||
Reference in New Issue
Block a user