新增用户管理/角色管理/菜单管理功能,修复创作流水线研究脚本

- 用户管理:新增编辑弹窗(修改用户名/角色/密码),增加组织/创建时间/最后登录列
- 角色管理:新增 Role 模型 + CRUD API,admin.html 新增角色管理 tab
- 菜单管理:新增 Menu 模型 + CRUD API,导航栏从 API 动态加载菜单项
- 个人中心:右上角下拉菜单(个人信息/修改密码/退出),新增修改密码 API
- 种子数据:initial_data.py 自动创建默认角色(admin/editor)和默认菜单(7项)
- 修复 research.py 缺少 enrich_topic_research 函数导致导入失败
- 修复 db_helper.py 中 generated_at 条件导致重创作不更新时间戳
- admin.html 操作列加宽防止按钮换行,平台配置增加删除按钮
- articles.html 预览弹窗加 lock-scroll=false 防止页面尺寸跳动
This commit is contained in:
Yuzhiran Dev
2026-05-22 18:34:27 +08:00
parent 1855f190f5
commit b2d043b231
14 changed files with 811 additions and 139 deletions
+113 -9
View File
@@ -19,7 +19,8 @@
.module-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--spacing-md); }
.module-title { font-size: var(--font-size-h3); font-weight: 600; color: var(--color-text-primary); display: flex; align-items: center; gap: var(--spacing-sm); }
.module-status { padding: 6px 12px; border-radius: var(--radius-xl); font-size: var(--font-size-caption); font-weight: 600; background: rgba(103,194,58,0.12); color: #67c23a; border: 1px solid rgba(103,194,58,0.25); }
.module-status.running { animation: pulse-glow 2s infinite; }
.module-status.inactive { background: rgba(144,147,153,0.12); color: #909399; border: 1px solid rgba(144,147,153,0.25); }
.module-drawer .el-drawer__body { padding: 20px; overflow-y: auto; }
.module-content { font-size: var(--font-size-body); color: var(--color-text-regular); line-height: 1.8; }
.module-content div { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px dashed var(--color-border); }
.module-content div:last-child { border-bottom: none; }
@@ -95,18 +96,31 @@
</span>
</h3>
<div class="module-grid">
<div v-for="mod in modules" :key="mod.id" class="module-card">
<div v-for="mod in modules" :key="mod.module_id" class="module-card">
<div class="module-header">
<span class="module-title">{{ mod.title }}</span>
<span :class="['module-status', mod.status === 'running' ? 'running' : '']">{{ mod.status === 'running' ? '运行中' : '已停止' }}</span>
<span :class="['module-status', mod.enabled ? '' : 'inactive']">
<el-icon v-if="mod.enabled" style="vertical-align:-2px;"><IconCheck /></el-icon>
<el-icon v-else style="vertical-align:-2px;"><IconClose /></el-icon>
{{ mod.enabled ? '已启用' : '已禁用' }}
</span>
</div>
<div class="module-content">
<div><span>最后运行</span><span>{{ mod.last_run }}</span></div>
<div><span>下次运行</span><span>{{ mod.next_run }}</span></div>
<div><span>今日任务</span><span>{{ mod.task_count }} </span></div>
<div><span>成功率</span><span>{{ mod.success_rate > 0 ? mod.success_rate + '%' : '暂无' }}</span></div>
<div><span>执行时间</span><span>{{ mod.schedule || mod.cron_default }}</span></div>
<div><span>成功/失败</span><span style="color:#67c23a;">{{ mod.success_runs }}</span> / <span style="color:#f56c6c;">{{ mod.failed_runs }}</span></div>
<div><span>今日任务</span><span>{{ mod.total_runs }} </span></div>
<div><span>下次运行</span><span style="color:#409eff;">{{ mod.next_run || '—' }}</span></div>
<div v-if="mod.last_result && mod.last_result.topics_count" style="border-bottom:none;">
<span>最新产出</span>
<span style="color:#409eff;font-weight:600;">{{ mod.last_result.topics_count }} 个选题</span>
</div>
<div v-else-if="mod.last_result && mod.last_result.articles_synced" style="border-bottom:none;">
<span>最新产出</span>
<span style="color:#409eff;font-weight:600;">{{ mod.last_result.articles_synced }} 篇</span>
</div>
<div style="margin-top:10px; border-bottom:none;">
<el-button size="small" type="primary" @click="triggerModule(mod.id)" :loading="runningModule === mod.id">立即运行</el-button>
<el-button v-if="isAdmin" size="small" type="primary" @click="openModuleDetail(mod)">详情</el-button>
<el-button size="small" @click="triggerModule(mod.module_id || mod.id)" :loading="runningModule === (mod.module_id || mod.id)" :disabled="!mod.enabled">立即运行</el-button>
</div>
</div>
</div>
@@ -132,6 +146,56 @@
</div>
</div>
</div>
<!-- 模块详情抽屉 -->
<el-drawer v-model="showModuleDrawer" :title="(moduleDetail ? moduleDetail.title : '') + ' 详情'" size="50%" direction="rtl" class="module-drawer">
<div v-if="moduleDetailLoading" style="text-align:center;padding:40px;color:#909399;">加载中...</div>
<div v-else-if="moduleDetail" style="padding:0 20px;">
<div style="margin-bottom:16px;display:flex;gap:16px;align-items:center;">
<el-tag :type="moduleDetail.enabled ? 'success' : 'info'" size="small">{{ moduleDetail.enabled ? '已启用' : '已禁用' }}</el-tag>
<span style="font-size:13px;color:#909399;">执行时间: {{ moduleDetail.schedule || moduleDetail.cron_default }}</span>
</div>
<el-tabs style="height:calc(100vh - 200px);">
<el-tab-pane label="📋 运行记录" name="history" style="overflow:auto;">
<div v-if="moduleDetailHistory && moduleDetailHistory.length > 0">
<el-timeline>
<el-timeline-item v-for="(h, i) in moduleDetailHistory" :key="h.id || i"
:timestamp="h.started_at ? new Date(h.started_at).toLocaleString('zh-CN', {timeZone:'Asia/Shanghai'}) : '—'"
:color="h.status === 'success' ? '#67c23a' : h.status === 'running' ? '#e6a23c' : '#f56c6c'">
<div style="font-size:13px;">
<el-tag :type="h.status === 'success' ? 'success' : h.status === 'running' ? 'warning' : 'danger'" size="small" style="margin-right:6px;">{{ h.status }}</el-tag>
<span v-if="h.message">{{ h.message }}</span>
<span v-else style="color:#909399;"></span>
</div>
<div v-if="h.error_trace" style="margin-top:4px;padding:6px 8px;background:#fef0f0;border-radius:4px;font-size:12px;color:#f56c6c;max-height:60px;overflow:auto;">{{ h.error_trace }}</div>
<div style="font-size:12px;color:#c0c4cc;margin-top:2px;">耗时 {{ h.duration ? h.duration + 's' : '—' }} · {{ h.triggered_by }}</div>
</el-timeline-item>
</el-timeline>
</div>
<div v-else style="color:#909399;padding:20px;text-align:center;">暂无运行记录</div>
</el-tab-pane>
<el-tab-pane label="🤖 提示词" name="prompts" style="overflow:auto;">
<div v-if="moduleDetailPrompts && moduleDetailPrompts.length > 0">
<div v-for="p in moduleDetailPrompts" :key="p.id" style="margin-bottom:12px;border:1px solid #ebeef5;border-radius:10px;padding:12px;">
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;">
<div>
<el-tag size="small" type="info" style="margin-right:6px;">{{ p.category }}</el-tag>
<span style="font-weight:600;font-size:13px;">{{ p.key }}</span>
<div style="font-size:11px;color:#909399;margin-top:2px;">{{ p.description }}</div>
</div>
<el-switch v-model="p.enabled" @change="savePrompt(p)" size="small" :disabled="promptSaving === p.id"></el-switch>
</div>
<el-input type="textarea" v-model="p.content" :rows="5" style="font-family:monospace;font-size:12px;" @blur="savePrompt(p)"></el-input>
</div>
</div>
<div v-else style="color:#909399;padding:20px;text-align:center;">此模块暂无提示词配置</div>
</el-tab-pane>
</el-tabs>
<div style="padding-top:16px;border-top:1px solid #ebeef5;text-align:center;">
<el-button type="primary" @click="triggerModule(moduleDetail.module_id)" :loading="runningModule === moduleDetail.module_id" :disabled="!moduleDetail.enabled">立即运行此任务</el-button>
</div>
</div>
</el-drawer>
</main>
</div>
@@ -163,7 +227,13 @@
schedulerRunning: false,
upcomingEntries: [],
loadingPlan: false,
runningModule: null
runningModule: null,
showModuleDrawer: false,
moduleDetail: null,
moduleDetailLoading: false,
moduleDetailHistory: [],
moduleDetailPrompts: [],
promptSaving: null
};
},
methods: {
@@ -311,6 +381,40 @@
},
redirectToPage(page) {
window.location.href = page.startsWith('/') ? page : '/' + page;
},
async openModuleDetail(mod) {
this.showModuleDrawer = true;
this.moduleDetail = mod;
this.moduleDetailLoading = true;
this.moduleDetailHistory = [];
this.moduleDetailPrompts = [];
const token = localStorage.getItem('authToken');
try {
const [history, prompts] = await Promise.all([
fetch('/api/admin/task-configs/history/' + mod.module_id + '?limit=20', {
headers: { 'Authorization': 'Bearer ' + token }
}).then(r => r.ok ? r.json() : []),
fetch('/api/admin/prompt-configs?module_id=' + mod.module_id, {
headers: { 'Authorization': 'Bearer ' + token }
}).then(r => r.ok ? r.json() : [])
]);
this.moduleDetailHistory = history || [];
this.moduleDetailPrompts = prompts || [];
} catch (e) { this.$message.error('加载详情失败: ' + e.message); }
finally { this.moduleDetailLoading = false; }
},
async savePrompt(p) {
this.promptSaving = p.id;
const token = localStorage.getItem('authToken');
try {
await fetch('/api/admin/prompt-configs/' + p.id, {
method: 'PUT',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ content: p.content, enabled: p.enabled, description: p.description, temperature: p.temperature, max_tokens: p.max_tokens })
});
this.$message.success('已保存');
} catch (e) { this.$message.error('保存失败: ' + e.message); }
finally { this.promptSaving = null; }
}
},
mounted() {