feat: Phase 4 多租户隔离 + 四阶段升级测试 + CSS 统一化

Phase 4: org_id 注入 JWT/API 过滤/组织管理 CRUD/前端组织列
测试: tests/test_phase_upgrades.py 97项全覆盖
CSS: theme-modern.css 共享 mobile-card-list/status-dot/search-bar 等模式
修复: initial_data.py LLM配置 NOT NULL 约束, TopicResponse 含 org_id
This commit is contained in:
Yuzhiran Dev
2026-05-17 06:56:53 +08:00
parent 301dc3e438
commit 9c37c9a574
45 changed files with 3707 additions and 1366 deletions
+370 -98
View File
@@ -7,69 +7,28 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.card-loading { display: flex; justify-content: center; align-items: center; min-height: 200px; color: #909399; font-size: 14px; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: #909399; }
.empty-state .empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.4; }
.empty-state .empty-text { font-size: 14px; margin-bottom: 16px; }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.toolbar { margin-bottom: 16px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.el-table { width: 100%; }
.el-table .el-table__cell { word-break: break-word; }
.stat-summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
.stat-item { background: #f0f5ff; border-radius: 8px; padding: 12px 20px; display: flex; flex-direction: column; align-items: center; min-width: 100px; }
.stat-item .num { font-size: 24px; font-weight: 600; color: #409eff; }
.stat-item .label { font-size: 12px; color: #909399; margin-top: 4px; }
.mobile-card-list { display: none; }
@media (max-width: 768px) {
.content-area { padding: 16px; padding-bottom: 80px; }
.card { padding: 16px; }
.data-table { display: none; }
.mobile-card-list { display: block; }
.mobile-card {
background: #fafbfc;
border-radius: 10px;
padding: 14px;
margin-bottom: 10px;
border: 1px solid #ebeef5;
transition: all 0.2s ease;
}
.mobile-card:active { transform: scale(0.99); }
.mobile-card-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; border-bottom: 1px dashed #f0f0f0; }
.mobile-card-row:last-child { border-bottom: none; }
.mobile-card-label { color: #909399; flex-shrink: 0; margin-right: 8px; }
.mobile-card-value { color: #303133; text-align: right; word-break: break-word; }
.mobile-card-actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 10px; margin-top: 6px; border-top: 1px solid #ebeef5; }
.stat-summary { gap: 8px; }
.stat-item { min-width: 70px; padding: 8px 12px; }
.stat-item .num { font-size: 18px; }
}
.data-table { display: block; }
@media (max-width: 768px) { .data-table { display: none; } }
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="系统管理" :username="currentUser.username" :is-admin="isAdmin" @logout="logout"></navbar-component>
<navigation-component current-page="admin" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="系统管理" :username="currentUser.username" :is-admin="isAdmin" current-page="admin" @navigate="redirectToPage" @logout="logout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title">⚙️ 系统管理</h2>
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 系统管理</h2>
<div class="filter-bar">
<el-button size="default" :type="activeTab === 'cases' ? 'primary' : ''" @click="switchTab('cases')">案例管理</el-button>
<el-button size="default" :type="activeTab === 'tasklogs' ? 'primary' : ''" @click="switchTab('tasklogs')">任务日志</el-button>
<el-button size="default" :type="activeTab === 'llmconfigs' ? 'primary' : ''" @click="switchTab('llmconfigs')">LLM配置</el-button>
<el-button size="default" :type="activeTab === 'systemconfigs' ? 'primary' : ''" @click="switchTab('systemconfigs')">系统配置</el-button>
<el-button size="default" :type="activeTab === 'categories' ? 'primary' : ''" @click="switchTab('categories')">采集类别</el-button>
<el-button size="default" :type="activeTab === 'sources' ? 'primary' : ''" @click="switchTab('sources')">信息源</el-button>
<el-button size="default" :type="activeTab === 'orgs' ? 'primary' : ''" @click="switchTab('orgs')">组织管理</el-button>
</div>
</div>
@@ -80,7 +39,7 @@
<div v-if="casesLoading" class="card-loading">加载中...</div>
<template v-else-if="cases.length === 0">
<div class="empty-state">
<div class="empty-icon">📋</div>
<el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon>
<div class="empty-text">暂无案例数据</div>
<el-button type="primary" size="small" @click="showCaseDialog()">新增第一个案例</el-button>
</div>
@@ -103,12 +62,12 @@
</el-table-column>
</el-table>
</template>
<div class="mobile-card-list">
<div v-for="item in cases" :key="item.id" class="mobile-card">
<div class="mobile-card-row"><span class="mobile-card-label">标题</span><span class="mobile-card-value">{{ item.title }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">领域</span><span class="mobile-card-value">{{ item.field }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">来源</span><span class="mobile-card-value">{{ item.source }}</span></div>
<div class="mobile-card-actions">
<div class="card-list-mobile">
<div v-for="item in cases" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">标题</span><span class="card-value">{{ item.title }}</span></div>
<div class="card-row"><span class="card-label">领域</span><span class="card-value">{{ item.field }}</span></div>
<div class="card-row"><span class="card-label">来源</span><span class="card-value">{{ item.source }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showCaseDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCase(item.id)">删除</el-button>
</div>
@@ -123,7 +82,7 @@
<div v-if="taskLogsLoading" class="card-loading">加载中...</div>
<template v-else-if="taskLogs.length === 0">
<div class="empty-state">
<div class="empty-icon">📝</div>
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
<div class="empty-text">暂无任务日志</div>
</div>
</template>
@@ -139,13 +98,13 @@
<el-table-column prop="duration" label="耗时" width="80"></el-table-column>
</el-table>
</template>
<div class="mobile-card-list">
<div v-for="item in taskLogs" :key="item.id" class="mobile-card">
<div class="mobile-card-row"><span class="mobile-card-label">任务</span><span class="mobile-card-value">{{ item.task_name }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">选题</span><span class="mobile-card-value">{{ item.topic_id }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">状态</span><span class="mobile-card-value">{{ item.status }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">消息</span><span class="mobile-card-value">{{ item.message }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">耗时</span><span class="mobile-card-value">{{ item.duration }}s</span></div>
<div class="card-list-mobile">
<div v-for="item in taskLogs" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">任务</span><span class="card-value">{{ item.task_name }}</span></div>
<div class="card-row"><span class="card-label">选题</span><span class="card-value">{{ item.topic_id }}</span></div>
<div class="card-row"><span class="card-label">状态</span><span class="card-value">{{ item.status }}</span></div>
<div class="card-row"><span class="card-label">消息</span><span class="card-value">{{ item.message }}</span></div>
<div class="card-row"><span class="card-label">耗时</span><span class="card-value">{{ item.duration }}s</span></div>
</div>
</div>
</div>
@@ -157,7 +116,7 @@
<div v-if="llmConfigsLoading" class="card-loading">加载中...</div>
<template v-else-if="llmConfigs.length === 0">
<div class="empty-state">
<div class="empty-icon">🤖</div>
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDocument /></el-icon>
<div class="empty-text">暂无 LLM 配置</div>
<el-button type="primary" size="small" @click="showLLMConfigDialog()">新增配置</el-button>
</div>
@@ -170,7 +129,10 @@
<el-table-column prop="temperature" label="温度" width="80"></el-table-column>
<el-table-column prop="max_tokens" label="最大Token" width="110"></el-table-column>
<el-table-column prop="is_active" label="激活" width="70">
<template #default="scope">{{ scope.row.is_active ? '是' : '否' }}</template>
<template #default="scope">
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
@@ -180,13 +142,13 @@
</el-table-column>
</el-table>
</template>
<div class="mobile-card-list">
<div v-for="item in llmConfigs" :key="item.id" class="mobile-card">
<div class="mobile-card-row"><span class="mobile-card-label">名称</span><span class="mobile-card-value">{{ item.name }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">模型</span><span class="mobile-card-value">{{ item.model }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">温度</span><span class="mobile-card-value">{{ item.temperature }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">激活</span><span class="mobile-card-value">{{ item.is_active ? '是' : '否' }}</span></div>
<div class="mobile-card-actions">
<div class="card-list-mobile">
<div v-for="item in llmConfigs" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">模型</span><span class="card-value">{{ item.model }}</span></div>
<div class="card-row"><span class="card-label">温度</span><span class="card-value">{{ item.temperature }}</span></div>
<div class="card-row"><span class="card-label">激活</span><span class="card-value">{{ item.is_active ? '是' : '否' }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showLLMConfigDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteLLMConfig(item.id)">删除</el-button>
</div>
@@ -201,7 +163,7 @@
<div v-if="systemConfigsLoading" class="card-loading">加载中...</div>
<template v-else-if="systemConfigs.length === 0">
<div class="empty-state">
<div class="empty-icon">⚙️</div>
<el-icon style="font-size:48px;color:#c0c4cc;"><IconSetting /></el-icon>
<div class="empty-text">暂无系统配置</div>
<el-button type="primary" size="small" @click="showSystemConfigDialog()">新增配置</el-button>
</div>
@@ -223,18 +185,162 @@
</el-table-column>
</el-table>
</template>
<div class="mobile-card-list">
<div v-for="item in systemConfigs" :key="item.key" class="mobile-card">
<div class="mobile-card-row"><span class="mobile-card-label"></span><span class="mobile-card-value">{{ item.key }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label"></span><span class="mobile-card-value">{{ typeof item.value === 'object' ? JSON.stringify(item.value) : item.value }}</span></div>
<div class="mobile-card-row"><span class="mobile-card-label">描述</span><span class="mobile-card-value">{{ item.description }}</span></div>
<div class="mobile-card-actions">
<div class="card-list-mobile">
<div v-for="item in systemConfigs" :key="item.key" class="card-item">
<div class="card-row"><span class="card-label"></span><span class="card-value">{{ item.key }}</span></div>
<div class="card-row"><span class="card-label"></span><span class="card-value">{{ typeof item.value === 'object' ? JSON.stringify(item.value) : item.value }}</span></div>
<div class="card-row"><span class="card-label">描述</span><span class="card-value">{{ item.description }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showSystemConfigDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSystemConfig(item.key)">删除</el-button>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'categories'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
<el-button size="small" @click="loadCategories">刷新</el-button>
</div>
<div v-if="categoriesLoading" class="card-loading">加载中...</div>
<template v-else-if="categories.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconPicture /></el-icon>
<div class="empty-text">暂无采集类别</div>
<el-button type="primary" size="small" @click="showCategoryDialog()">新增类别</el-button>
</div>
</template>
<template v-else>
<el-table :data="categories" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="60"></el-table-column>
<el-table-column prop="name" label="名称" min-width="120"></el-table-column>
<el-table-column prop="search_query" label="搜索词" min-width="200"></el-table-column>
<el-table-column prop="source_count" label="信息源数" width="100"></el-table-column>
<el-table-column prop="is_active" label="激活" width="70">
<template #default="scope">
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
<el-button size="small" @click="showCategoryDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCategory(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<div class="card-list-mobile" v-if="categories.length > 0">
<div v-for="item in categories" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">搜索词</span><span class="card-value">{{ item.search_query }}</span></div>
<div class="card-row"><span class="card-label">源数</span><span class="card-value">{{ item.source_count }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showCategoryDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCategory(item.id)">删除</el-button>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'orgs'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showOrgDialog()">新增组织</el-button>
<el-button size="small" @click="loadOrgs">刷新</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ orgs.length }} 个</span>
</div>
<div v-if="orgsLoading" class="card-loading">加载中...</div>
<template v-else-if="orgs.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconSetting /></el-icon>
<div class="empty-text">暂无组织</div>
<el-button type="primary" size="small" @click="showOrgDialog()">新增组织</el-button>
</div>
</template>
<template v-else>
<el-table :data="orgs" border stripe class="data-table" style="width:100%">
<el-table-column prop="org_id" label="组织ID" min-width="120"></el-table-column>
<el-table-column prop="name" label="名称" min-width="150"></el-table-column>
<el-table-column prop="description" label="描述" min-width="200" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="user_count" label="用户数" width="80"></el-table-column>
<el-table-column prop="topic_count" label="选题数" width="80"></el-table-column>
<el-table-column label="默认" width="70">
<template #default="scope">
<el-icon v-if="scope.row.is_default" style="color:#67C23A;"><IconCheck /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="scope">
<el-button size="small" @click="showOrgDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteOrg(scope.row.org_id)" :disabled="scope.row.is_default">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<div class="card-list-mobile" v-if="orgs.length > 0">
<div v-for="item in orgs" :key="item.org_id" class="card-item">
<div class="card-row"><span class="card-label">ID</span><span class="card-value">{{ item.org_id }}</span></div>
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">用户</span><span class="card-value">{{ item.user_count }}</span></div>
<div class="card-row"><span class="card-label">选题</span><span class="card-value">{{ item.topic_count }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showOrgDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteOrg(item.org_id)" :disabled="item.is_default">删除</el-button>
</div>
</div>
</div>
</div>
<div v-if="activeTab === 'sources'">
<div class="toolbar">
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
<el-button size="small" @click="loadSources">刷新</el-button>
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ sources.length }} 个</span>
</div>
<div v-if="sourcesLoading" class="card-loading">加载中...</div>
<template v-else-if="sources.length === 0">
<div class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconGlobe /></el-icon>
<div class="empty-text">暂无信息源</div>
<el-button type="primary" size="small" @click="showSourceDialog()">新增信息源</el-button>
</div>
</template>
<template v-else>
<el-table :data="sources" border stripe class="data-table" style="width:100%">
<el-table-column prop="id" label="ID" width="50"></el-table-column>
<el-table-column prop="name" label="名称" min-width="130"></el-table-column>
<el-table-column prop="source_type" label="类型" width="90">
<template #default="scope">{{ {rss:'RSS',web_search:'搜索',local:'本地'}[scope.row.source_type] || scope.row.source_type }}</template>
</el-table-column>
<el-table-column prop="query" label="查询词/URL" min-width="250" :show-overflow-tooltip="true"></el-table-column>
<el-table-column prop="focus" label="聚焦" min-width="120"></el-table-column>
<el-table-column prop="is_active" label="激活" width="60">
<template #default="scope">
<el-icon v-if="scope.row.is_active" style="color:#67C23A;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;"><IconClose /></el-icon>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button size="small" @click="showSourceDialog(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSource(scope.row.id)">删除</el-button>
</template>
</el-table-column>
</el-table>
</template>
<div class="card-list-mobile" v-if="sources.length > 0">
<div v-for="item in sources" :key="item.id" class="card-item">
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ item.name }}</span></div>
<div class="card-row"><span class="card-label">类型</span><span class="card-value">{{ item.source_type }}</span></div>
<div class="card-row"><span class="card-label">查询</span><span class="card-value">{{ item.query || item.url }}</span></div>
<div class="card-actions">
<el-button size="small" @click="showSourceDialog(item)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteSource(item.id)">删除</el-button>
</div>
</div>
</div>
</div>
</div>
</main>
</div>
@@ -267,25 +373,35 @@
</template>
</el-dialog>
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="700px" :close-on-click-modal="false">
<el-form :model="llmConfigForm" label-width="100px">
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="750px" :close-on-click-modal="false">
<el-form :model="llmConfigForm" label-width="110px">
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="名称"><el-input v-model="llmConfigForm.name"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="模型"><el-input v-model="llmConfigForm.model"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="名称"><el-input v-model="llmConfigForm.name" placeholder="如:default_expand"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="供应商">
<el-select v-model="llmConfigForm.provider" style="width:100%">
<el-option label="opencode-go (deepseek-v4-pro)" value="opencode-go"></el-option>
<el-option label="nvidia (gemma-3n)" value="nvidia"></el-option>
</el-select>
</el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="温度"><el-input-number v-model="llmConfigForm.temperature" :min="0" :max="2" :step="0.1"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="10000"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="模型"><el-input v-model="llmConfigForm.model" placeholder="deepseek-v4-pro"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="接口地址"><el-input v-model="llmConfigForm.base_url" placeholder="https://opencode.ai/zen/go/v1"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="系统提示词"><el-input type="textarea" v-model="llmConfigForm.system_prompt" :rows="4"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="API Key"><el-input v-model="llmConfigForm.api_key" type="password" show-password placeholder="sk-..."/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="温度"><el-input-number v-model="llmConfigForm.temperature" :min="0" :max="2" :step="0.1" style="width:100%"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12"><el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="10000" style="width:100%"/></el-form-item></el-col>
<el-col :span="12"><el-form-item label="激活"><el-switch v-model="llmConfigForm.is_active"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="系统提示词"><el-input type="textarea" v-model="llmConfigForm.system_prompt" :rows="3" placeholder="你是一个专业的内容创作助手。"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="提示模板"><el-input type="textarea" v-model="llmConfigForm.user_prompt_template" :rows="4"/></el-form-item></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="24"><el-form-item label="激活"><el-switch v-model="llmConfigForm.is_active"/></el-form-item></el-col>
</el-row>
</el-form>
<template #footer>
<el-button @click="llmConfigDialogVisible=false">取消</el-button>
@@ -308,8 +424,63 @@
<el-button type="primary" @click="saveSystemConfig">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="categoryDialogVisible" :title="categoryDialogTitle" width="500px" :close-on-click-modal="false">
<el-form :model="categoryForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="categoryForm.name" placeholder="如:循环消费"/></el-form-item>
<el-form-item label="搜索词"><el-input v-model="categoryForm.search_query" placeholder="如:以旧换新 二手交易 闲置 2026"/></el-form-item>
<el-form-item label="描述"><el-input type="textarea" v-model="categoryForm.description" :rows="3"/></el-form-item>
<el-form-item label="排序"><el-input-number v-model="categoryForm.sort_order" :min="0"/></el-form-item>
<el-form-item label="激活"><el-switch v-model="categoryForm.is_active"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="categoryDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveCategory">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="sourceDialogVisible" :title="sourceDialogTitle" width="550px" :close-on-click-modal="false">
<el-form :model="sourceForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="sourceForm.name" placeholder="如:循环消费搜索"/></el-form-item>
<el-form-item label="类型">
<el-select v-model="sourceForm.source_type" style="width:100%">
<el-option label="RSS订阅" value="rss"></el-option>
<el-option label="搜索引擎" value="web_search"></el-option>
<el-option label="本地文件" value="local"></el-option>
</el-select>
</el-form-item>
<el-form-item label="查询词/URL"><el-input v-model="sourceForm.query" :placeholder="sourceForm.source_type==='web_search'?'搜索关键词':'RSS URL或本地路径'"/></el-form-item>
<el-form-item label="聚焦领域"><el-input v-model="sourceForm.focus"/></el-form-item>
<el-form-item label="可信度">
<el-select v-model="sourceForm.credibility" style="width:100%">
<el-option label="高" value="high"></el-option>
<el-option label="中" value="medium"></el-option>
<el-option label="低" value="low"></el-option>
</el-select>
</el-form-item>
<el-form-item label="排序"><el-input-number v-model="sourceForm.sort_order" :min="0"/></el-form-item>
<el-form-item label="激活"><el-switch v-model="sourceForm.is_active"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="sourceDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveSource">确定</el-button>
</template>
</el-dialog>
<el-dialog v-model="orgDialogVisible" :title="orgDialogTitle" width="500px" :close-on-click-modal="false">
<el-form :model="orgForm" label-width="80px">
<el-form-item label="组织ID"><el-input v-model="orgForm.org_id" :disabled="!!editingOrgId" placeholder="唯一标识,如:org_a"/></el-form-item>
<el-form-item label="名称"><el-input v-model="orgForm.name" placeholder="组织名称"/></el-form-item>
<el-form-item label="描述"><el-input type="textarea" v-model="orgForm.description" :rows="3"/></el-form-item>
</el-form>
<template #footer>
<el-button @click="orgDialogVisible=false">取消</el-button>
<el-button type="primary" @click="saveOrg">确定</el-button>
</template>
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const { createApp, ref, reactive, onMounted } = Vue;
@@ -385,7 +556,7 @@
const llmConfigsLoading = ref(false);
const llmConfigDialogVisible = ref(false);
const llmConfigDialogTitle = ref('新增配置');
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 2000, model: '', is_active: true });
const llmConfigForm = reactive({ id: null, name: '', system_prompt: '', user_prompt_template: '', temperature: 0.7, max_tokens: 2000, model: '', provider: 'opencode-go', base_url: '', api_key: '', is_active: true });
const editingLLMConfigId = ref(null);
const loadLLMConfigs = async () => {
@@ -394,10 +565,18 @@
finally { llmConfigsLoading.value = false; }
};
const showLLMConfigDialog = (row = null) => {
if (row) { llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id; Object.assign(llmConfigForm, row); }
else {
if (row) {
llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id;
llmConfigForm.id = row.id; llmConfigForm.name = row.name; llmConfigForm.provider = row.provider || 'opencode-go';
llmConfigForm.model = row.model || ''; llmConfigForm.base_url = row.base_url || ''; llmConfigForm.api_key = '';
llmConfigForm.temperature = row.temperature ?? 0.7; llmConfigForm.max_tokens = row.max_tokens ?? 2000;
llmConfigForm.system_prompt = row.system_prompt || ''; llmConfigForm.user_prompt_template = row.user_prompt_template || '';
llmConfigForm.is_active = row.is_active ?? true;
} else {
llmConfigDialogTitle.value = '新增配置'; editingLLMConfigId.value = null;
Object.keys(llmConfigForm).forEach(k => { if (k === 'id') llmConfigForm.id = null; else if (k === 'temperature') llmConfigForm.temperature = 0.7; else if (k === 'max_tokens') llmConfigForm.max_tokens = 2000; else if (k === 'is_active') llmConfigForm.is_active = true; else llmConfigForm[k] = ''; });
llmConfigForm.id = null; llmConfigForm.name = ''; llmConfigForm.provider = 'opencode-go'; llmConfigForm.model = 'deepseek-v4-pro';
llmConfigForm.base_url = 'https://opencode.ai/zen/go/v1'; llmConfigForm.api_key = ''; llmConfigForm.temperature = 0.7;
llmConfigForm.max_tokens = 2000; llmConfigForm.system_prompt = ''; llmConfigForm.user_prompt_template = ''; llmConfigForm.is_active = true;
}
llmConfigDialogVisible.value = true;
};
@@ -420,6 +599,50 @@
const systemConfigForm = reactive({ key: '', value: '', description: '' });
const editingSystemConfigKey = ref(null);
const categories = ref([]);
const categoriesLoading = ref(false);
const categoryDialogVisible = ref(false);
const categoryDialogTitle = ref('新增类别');
const categoryForm = reactive({ name: '', search_query: '', description: '', sort_order: 0, is_active: true });
const editingCategoryId = ref(null);
const sources = ref([]);
const sourcesLoading = ref(false);
const sourceDialogVisible = ref(false);
const sourceDialogTitle = ref('新增信息源');
const sourceForm = reactive({ name: '', source_type: 'web_search', query: '', credibility: 'medium', focus: '', sort_order: 0, is_active: true });
const editingSourceId = ref(null);
const orgs = ref([]);
const orgsLoading = ref(false);
const orgDialogVisible = ref(false);
const orgDialogTitle = ref('新增组织');
const orgForm = reactive({ org_id: '', name: '', description: '' });
const editingOrgId = ref(null);
const loadOrgs = async () => {
orgsLoading.value = true;
try { orgs.value = await api.get('/api/admin/orgs'); } catch (e) { ElMessage.error('加载组织失败: ' + e.message); }
finally { orgsLoading.value = false; }
};
const showOrgDialog = (row = null) => {
if (row) { orgDialogTitle.value = '编辑组织'; editingOrgId.value = row.org_id; orgForm.org_id = row.org_id; orgForm.name = row.name; orgForm.description = row.description || ''; }
else { orgDialogTitle.value = '新增组织'; editingOrgId.value = null; orgForm.org_id = ''; orgForm.name = ''; orgForm.description = ''; }
orgDialogVisible.value = true;
};
const saveOrg = async () => {
if (!orgForm.org_id || !orgForm.name) { ElMessage.warning('请填写组织ID和名称'); return; }
try {
if (editingOrgId.value) { await api.put(`/api/admin/orgs/${editingOrgId.value}`, orgForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/orgs', orgForm); ElMessage.success('创建成功'); }
orgDialogVisible.value = false; await loadOrgs();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const deleteOrg = async (orgId) => {
try { await ElMessageBox.confirm('确定删除该组织吗?关联的用户和选题不会被删除。', '提示', { type: 'warning' }); await api.delete(`/api/admin/orgs/${orgId}`); ElMessage.success('删除成功'); await loadOrgs(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const loadSystemConfigs = async () => {
systemConfigsLoading.value = true;
try { systemConfigs.value = await api.get('/api/admin/systemconfigs'); } catch (e) { ElMessage.error('加载系统配置失败: ' + e.message); }
@@ -442,13 +665,59 @@
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const loadCategories = async () => {
categoriesLoading.value = true;
try { categories.value = await api.get('/api/admin/collector/categories'); } catch (e) { ElMessage.error('加载类别失败: ' + e.message); }
finally { categoriesLoading.value = false; }
};
const showCategoryDialog = (row = null) => {
if (row) { categoryDialogTitle.value = '编辑类别'; editingCategoryId.value = row.id; Object.assign(categoryForm, { name: row.name, search_query: row.search_query || '', description: row.description || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
else { categoryDialogTitle.value = '新增类别'; editingCategoryId.value = null; categoryForm.name = ''; categoryForm.search_query = ''; categoryForm.description = ''; categoryForm.sort_order = 0; categoryForm.is_active = true; }
categoryDialogVisible.value = true;
};
const saveCategory = async () => {
try {
if (editingCategoryId.value) { await api.put(`/api/admin/collector/categories/${editingCategoryId.value}`, categoryForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/collector/categories', categoryForm); ElMessage.success('创建成功'); }
categoryDialogVisible.value = false; await loadCategories();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const deleteCategory = async (id) => {
try { await ElMessageBox.confirm('确定删除该类别及其关联的信息源吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/collector/categories/${id}`); ElMessage.success('删除成功'); await loadCategories(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const loadSources = async () => {
sourcesLoading.value = true;
try { sources.value = await api.get('/api/admin/collector/sources'); } catch (e) { ElMessage.error('加载信息源失败: ' + e.message); }
finally { sourcesLoading.value = false; }
};
const showSourceDialog = (row = null) => {
if (row) { sourceDialogTitle.value = '编辑信息源'; editingSourceId.value = row.id; Object.assign(sourceForm, { name: row.name, source_type: row.source_type, query: row.query || '', credibility: row.credibility || 'medium', focus: row.focus || '', sort_order: row.sort_order || 0, is_active: row.is_active }); }
else { sourceDialogTitle.value = '新增信息源'; editingSourceId.value = null; sourceForm.name = ''; sourceForm.source_type = 'web_search'; sourceForm.query = ''; sourceForm.credibility = 'medium'; sourceForm.focus = ''; sourceForm.sort_order = 0; sourceForm.is_active = true; }
sourceDialogVisible.value = true;
};
const saveSource = async () => {
try {
if (editingSourceId.value) { await api.put(`/api/admin/collector/sources/${editingSourceId.value}`, sourceForm); ElMessage.success('更新成功'); }
else { await api.post('/api/admin/collector/sources', sourceForm); ElMessage.success('创建成功'); }
sourceDialogVisible.value = false; await loadSources();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
};
const deleteSource = async (id) => {
try { await ElMessageBox.confirm('确定删除该信息源吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/collector/sources/${id}`); ElMessage.success('删除成功'); await loadSources(); }
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
};
const logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
const tabLoaders = {
cases: loadCases, tasklogs: loadTaskLogs,
llmconfigs: loadLLMConfigs, systemconfigs: loadSystemConfigs,
categories: loadCategories, sources: loadSources,
orgs: loadOrgs,
};
const loadedTabs = new Set();
const loadedTabs = new Set(['cases']);
const switchTab = (name) => {
activeTab.value = name;
@@ -469,14 +738,17 @@
taskLogs, taskLogsLoading, loadTaskLogs,
llmConfigs, llmConfigsLoading, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
systemConfigs, systemConfigsLoading, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
categories, categoriesLoading, categoryDialogVisible, categoryForm, categoryDialogTitle, showCategoryDialog, saveCategory, deleteCategory,
sources, sourcesLoading, sourceDialogVisible, sourceForm, sourceDialogTitle, showSourceDialog, saveSource, deleteSource,
orgs, orgsLoading, orgDialogVisible, orgForm, orgDialogTitle, showOrgDialog, saveOrg, deleteOrg,
logout, currentUser, isAdmin, redirectToPage
};
}
});
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
+275
View File
@@ -0,0 +1,275 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 文章管理</title>
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
.search-bar { margin-left: auto; width: 240px; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
.status-dot.draft { background: #E6A23C; }
.status-dot.reviewed { background: #67C23A; }
.status-dot.published { background: #409EFF; }
.article-card-list { display: none; }
@media (max-width: 768px) {
.el-table { display: none; }
.article-card-list { display: block; }
.article-card {
background: white;
border-radius: 12px;
padding: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
.article-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.article-card-title { font-size: 14px; font-weight: 600; color: #303133; flex: 1; margin-right: 8px; }
.article-card-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 12px; color: #606266; margin-bottom: 12px; }
.article-card-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #ebeef5; }
.article-card-actions .el-button { margin: 0; width: 100%; justify-content: center; }
.search-bar { width: 100%; margin-left: 0; }
}
</style>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<uni-nav title="文章管理" :username="currentUser.username" :is-admin="isAdmin" current-page="articles" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 文章管理</h2>
</div>
<div class="filter-bar">
<el-select v-model="filterPlatform" placeholder="全部平台" clearable size="default" style="width:140px;" @change="fetchArticles">
<el-option label="全部平台" value=""></el-option>
<el-option label="知乎" value="zhihu"></el-option>
<el-option label="微信公众号" value="wechat"></el-option>
<el-option label="小红书" value="xiaohongshu"></el-option>
</el-select>
<el-select v-model="filterStatus" placeholder="全部状态" clearable size="default" style="width:140px;" @change="fetchArticles">
<el-option label="全部状态" value=""></el-option>
<el-option label="草稿" value="draft"></el-option>
<el-option label="已审查" value="reviewed"></el-option>
<el-option label="已发布" value="published"></el-option>
</el-select>
<el-input v-model="searchQuery" placeholder="搜索文章 ID / 选题标题" clearable size="default" class="search-bar" @input="debouncedSearch" @clear="fetchArticles">
<template #prefix><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon></template>
</el-input>
</div>
<el-table :data="filteredArticles" stripe v-loading="loadingTable">
<el-table-column prop="id" label="文章 ID" width="160"></el-table-column>
<el-table-column prop="topic_id" label="选题 ID" width="80"></el-table-column>
<el-table-column prop="topic_title" label="选题标题" min-width="200" show-overflow-tooltip></el-table-column>
<el-table-column prop="platform" label="平台" width="100">
<template #default="scope">{{ platformLabel(scope.row.platform) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="90">
<template #default="scope"><span class="status-dot" :class="scope.row.status"></span> {{ statusLabel(scope.row.status) }}</template>
</el-table-column>
<el-table-column prop="compliance_score" label="合规分" width="80">
<template #default="scope">{{ scope.row.compliance_score ?? '-' }}</template>
</el-table-column>
<el-table-column prop="word_count" label="字数" width="70">
<template #default="scope">{{ scope.row.word_count ?? '-' }}</template>
</el-table-column>
<el-table-column label="配图" width="60">
<template #default="scope">
<span v-if="scope.row.images && scope.row.images.cover" style="cursor:pointer;font-size:16px;" title="有封面图"><el-icon style="vertical-align:-2px;"><IconPicture /></el-icon></span>
<span v-else style="color:#dcdfe6;"></span>
</template>
</el-table-column>
<el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<template #default="scope">
<div style="display: flex; gap: 4px;">
<el-button size="small" type="primary" @click="previewArticle(scope.row)" style="padding:5px 8px;">预览</el-button>
<el-button size="small" type="danger" @click="deleteArticle(scope.row)" style="padding:5px 8px;">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div class="article-card-list" v-if="filteredArticles && filteredArticles.length > 0">
<div v-for="article in filteredArticles" :key="article.id" class="article-card">
<div class="article-card-header">
<div class="article-card-title">{{ article.topic_title || article.topic_id }}</div>
<el-tag :type="statusTagType(article.status)" size="small">{{ statusLabel(article.status) }}</el-tag>
</div>
<div class="article-card-meta">
<div>ID: {{ article.id }}</div>
<div>平台: {{ platformLabel(article.platform) }}</div>
<div>合规: {{ article.compliance_score ?? '-' }}</div>
<div>字数: {{ article.word_count ?? '-' }}</div>
<div>创建: {{ formatDate(article.created_at) }}</div>
</div>
<div class="article-card-actions">
<el-button size="small" type="primary" @click="previewArticle(article)">预览</el-button>
<el-button size="small" type="danger" @click="deleteArticle(article)">删除</el-button>
</div>
</div>
</div>
<div v-if="!loadingTable && filteredArticles.length === 0" style="text-align:center;padding:40px 0;color:#909399;font-size:14px;">
<el-icon><IconDocument /></el-icon> 暂无文章
</div>
</div>
</main>
</div>
<el-dialog v-model="previewVisible" title="文章预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape>
<div v-if="previewArticleData">
<div style="display:flex; gap:16px; margin-bottom:12px; flex-wrap:wrap;">
<div style="flex:1; min-width:200px;">
<h3 style="margin:0; font-size:15px;">{{ previewArticleData.topic_title || previewArticleData.topic_id }}</h3>
<div style="font-size:12px; color:#909399; margin-top:4px;">
{{ platformLabel(previewArticleData.platform) }} · {{ statusLabel(previewArticleData.status) }} · {{ previewArticleData.word_count ?? '-' }}字
</div>
</div>
<div v-if="previewArticleData.images && previewArticleData.images.cover" style="flex-shrink:0;">
<img :src="previewArticleData.images.cover" style="height:80px; border-radius:8px; border:1px solid #ebeef5; object-fit:cover;" alt="封面">
</div>
</div>
<div style="display:flex; gap:8px;">
<el-button size="small" @click="togglePreviewFullscreen">{{ previewFullscreen ? '退出全屏' : '全屏' }}</el-button>
<el-button v-if="previewFullscreen" size="small" type="danger" @click="previewVisible = false">关闭</el-button>
</div>
</div>
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
<iframe v-if="previewArticleData.html_content" :srcdoc="previewArticleData.html_content" class="preview-iframe" style="flex:1; min-height:500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding:0; width:100%;" sandbox></iframe>
<div v-else style="padding:60px 20px; text-align:center; color:#909399; font-size:14px;">该文章暂无 HTML 内容</div>
</div>
</div>
<template #footer>
<div style="display:flex; justify-content:flex-end; gap:8px; width:100%;">
<el-button @click="previewVisible = false">关闭</el-button>
<el-button v-if="previewArticleData && previewArticleData.html_content" type="primary" @click="copyPreviewHtml">复制 HTML</el-button>
</div>
</template>
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const ArticlesApp = {
data() {
return {
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
loadingTable: false,
articles: [],
filterPlatform: '', filterStatus: '', searchQuery: '',
previewVisible: false, previewArticleData: null, previewFullscreen: false,
debounceTimer: null
}
},
computed: {
filteredArticles() {
let list = this.articles;
if (this.filterPlatform) list = list.filter(a => a.platform === this.filterPlatform);
if (this.filterStatus) list = list.filter(a => a.status === this.filterStatus);
if (this.searchQuery) {
const q = this.searchQuery.toLowerCase();
list = list.filter(a => (a.id && a.id.toLowerCase().includes(q)) || (a.topic_title && a.topic_title.toLowerCase().includes(q)));
}
return list;
}
},
methods: {
getToken() { return localStorage.getItem('authToken'); },
async api(url, opts = {}) {
const token = this.getToken();
if (!token) { this.$message.error('请先登录'); setTimeout(() => window.location.href = '/', 1500); return null; }
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...opts.headers }, ...opts });
if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `请求失败: ${res.status}`); }
return res.json();
},
async fetchArticles() {
this.loadingTable = true;
try {
const params = new URLSearchParams();
if (this.filterPlatform) params.set('platform', this.filterPlatform);
if (this.filterStatus) params.set('status', this.filterStatus);
const qs = params.toString();
const data = await this.api('/api/articles/list' + (qs ? '?' + qs : ''));
this.articles = data.articles || [];
} catch (error) {
console.error('获取文章列表失败:', error);
this.$message.error('获取文章列表失败: ' + error.message);
this.articles = [];
} finally { this.loadingTable = false; }
},
debouncedSearch() {
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => { this.fetchArticles(); }, 400);
},
async previewArticle(article) {
this.previewArticleData = null;
this.previewVisible = true;
this.previewFullscreen = false;
try {
const data = await this.api('/api/articles/detail/' + article.id);
this.previewArticleData = data;
} catch (error) {
this.$message.error('加载文章详情失败: ' + error.message);
this.previewVisible = false;
}
},
togglePreviewFullscreen() {
this.previewFullscreen = !this.previewFullscreen;
this.$nextTick(() => {
const overlays = document.querySelectorAll('.el-overlay');
const overlay = overlays[overlays.length - 1];
if (overlay) overlay.style.zIndex = this.previewFullscreen ? '100000' : '';
const dialog = document.querySelector('.preview-dialog-custom');
if (dialog) { dialog.style.zIndex = this.previewFullscreen ? '100001' : ''; }
});
},
copyPreviewHtml() {
if (!this.previewArticleData || !this.previewArticleData.html_content) {
this.$message.warning('暂无 HTML 内容可复制');
return;
}
navigator.clipboard.writeText(this.previewArticleData.html_content)
.then(() => this.$message.success('HTML 已复制'))
.catch(() => this.$message.error('复制失败'));
},
async deleteArticle(article) {
try {
await this.$confirm(`确定删除文章 [${article.id}]`, '提示', { type: 'warning' });
await this.api('/api/articles/' + article.id, { method: 'DELETE' });
this.$message.success('删除成功');
await this.fetchArticles();
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败: ' + (e.message || '未知错误')); }
},
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
platformLabel(p) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p; },
statusLabel(s) { return { draft: '草稿', reviewed: '已审查', published: '已发布' }[s] || s; },
statusTagType(s) { return { draft: 'warning', reviewed: 'success', published: 'info' }[s] || 'primary'; },
formatDate(dateStr) {
if (!dateStr) return '-';
try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
catch (e) { return dateStr; }
}
},
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchArticles(); })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
}
};
const app = Vue.createApp(ArticlesApp);
app.use(ElementPlus);
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
</html>
+26 -31
View File
@@ -7,14 +7,6 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
@media (max-width: 768px) {
.content-area { padding: 16px; padding-bottom: 80px; }
}
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; }
.asset-item { border: 1px solid #ebeef5; border-radius: 8px; padding: 12px; transition: all 0.3s; cursor: pointer; }
.asset-item:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.2); }
@@ -25,34 +17,35 @@
.upload-area { border: 2px dashed #dcdfe6; border-radius: 12px; padding: 40px; text-align: center; cursor: pointer; transition: all 0.3s; }
.upload-area:hover { border-color: #409eff; background: #f0f9eb; }
.upload-icon { font-size: 48px; color: #c0c4cc; }
.filter-bar { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 16px; }
@media (max-width: 768px) {
.asset-grid { grid-template-columns: repeat(2, 1fr) !important; gap: 10px; }
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="素材库" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="assets" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="素材库" :username="currentUser.username" :is-admin="isAdmin" current-page="assets" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;">🖼️ 素材库</h2>
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;"><el-icon style="vertical-align:-2px;"><IconPicture /></el-icon> 素材库</h2>
<div class="card page-fade">
<div class="filter-bar">
<el-input v-model="searchKeyword" placeholder="搜索素材..." style="width: 200px;" clearable @clear="loadAssets" @keyup.enter="loadAssets">
<template #prefix><span>🔍</span></template>
<el-input v-model="searchKeyword" placeholder="搜索素材..." clearable @clear="loadAssets" @keyup.enter="loadAssets" style="flex:1;min-width:140px;">
<template #prefix><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon></template>
</el-input>
<el-select v-model="filterType" placeholder="文件类型" style="width: 120px;" clearable @change="loadAssets">
<el-select v-model="filterType" placeholder="文件类型" clearable @change="loadAssets" style="width:120px;">
<el-option label="全部" value=""></el-option>
<el-option label="图片" value="image"></el-option>
<el-option label="视频" value="video"></el-option>
<el-option label="文档" value="document"></el-option>
</el-select>
<el-select v-model="filterTag" placeholder="标签筛选" style="width: 150px;" clearable @change="loadAssets">
<el-select v-model="filterTag" placeholder="标签筛选" clearable @change="loadAssets" style="width:130px;">
<el-option v-for="tag in allTags" :key="tag" :label="tag" :value="tag"></el-option>
</el-select>
<el-button @click="loadAssets">🔄 刷新</el-button>
<el-button type="primary" @click="showUploadDialog = true">📤 上传素材</el-button>
<el-button @click="loadAssets"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
<el-button type="primary" @click="showUploadDialog = true"><el-icon style="vertical-align:-2px;"><IconUpload /></el-icon> 上传素材</el-button>
</div>
<div style="margin-bottom: 16px; display: flex; gap: 20px; font-size: 14px; color: #606266;">
<span>总计: {{ assetStats.total }} 个</span>
@@ -60,15 +53,15 @@
</div>
<div v-if="loading" style="text-align: center; padding: 40px;">加载中...</div>
<div v-else-if="assets.length === 0" style="text-align: center; padding: 40px; color: #909399;">
<div style="font-size: 48px; margin-bottom: 16px;">📂</div>
<el-icon style="font-size:48px;margin-bottom:16px;color:#c0c4cc;"><IconPicture /></el-icon>
<div>暂无素材,点击上方按钮上传</div>
</div>
<div v-else class="asset-grid">
<div v-for="asset in assets" :key="asset.id" class="asset-item" @click="previewAsset(asset)">
<div class="asset-thumb">
<template v-if="asset.file_type === 'image'">🖼️</template>
<template v-if="asset.file_type === 'image'"><el-icon style="font-size:32px;"><IconPicture /></el-icon></template>
<template v-else-if="asset.file_type === 'video'">🎬</template>
<template v-else>📄</template>
<template v-else><el-icon style="font-size:32px;"><IconDocument /></el-icon></template>
</div>
<div class="asset-name">{{ asset.filename }}</div>
<div class="asset-meta">{{ formatSize(asset.size) }} | {{ formatDate(asset.created_at) }}</div>
@@ -87,7 +80,7 @@
<el-dialog v-model="showUploadDialog" title="上传素材" width="500px">
<el-upload ref="uploadRef" drag :auto-upload="false" :limit="10" :on-change="handleFileChange" multiple accept="image/*,.pdf,.doc,.docx,.ppt,.pptx,.mp4,.mov,.avi">
<div class="upload-area">
<div class="upload-icon">📤</div>
<el-icon style="font-size:48px;color:#c0c4cc;"><IconUpload /></el-icon>
<div style="margin-top: 12px; color: #606266;">将文件拖到此处,或<span style="color: #409eff;">点击上传</span></div>
<div style="font-size: 12px; color: #909399; margin-top: 8px;">支持: JPG, PNG, GIF, WebP, PDF, Word, PPT, MP4</div>
</div>
@@ -104,9 +97,9 @@
<el-dialog v-model="showPreviewDialog" title="素材预览" width="800px">
<div v-if="previewAssetData" style="text-align: center;">
<div style="font-size: 64px; margin-bottom: 16px;">
<template v-if="previewAssetData.file_type === 'image'">🖼️</template>
<template v-if="previewAssetData.file_type === 'image'"><el-icon style="font-size:64px;"><IconPicture /></el-icon></template>
<template v-else-if="previewAssetData.file_type === 'video'">🎬</template>
<template v-else>📄</template>
<template v-else><el-icon style="font-size:64px;"><IconDocument /></el-icon></template>
</div>
<div style="font-size: 18px; font-weight: 600; margin-bottom: 8px;">{{ previewAssetData.filename }}</div>
<div style="color: #909399; font-size: 14px;">{{ formatSize(previewAssetData.size) }} | {{ formatDate(previewAssetData.created_at) }}</div>
@@ -119,6 +112,7 @@
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const AssetsApp = {
@@ -186,7 +180,8 @@ const AssetsApp = {
if (this.searchKeyword) url += '&search=' + encodeURIComponent(this.searchKeyword);
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.assets = await res.json();
} catch (e) { console.error(e); }
else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
} catch (e) { console.error(e); this.$message.error('加载素材失败: ' + e.message); }
finally { this.loading = false; }
},
async loadTags() {
@@ -194,14 +189,14 @@ const AssetsApp = {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/assets/tags', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.allTags = await res.json();
} catch (e) { console.error(e); }
} catch (e) { console.error(e); this.$message.error('加载标签失败: ' + e.message); }
},
async loadStats() {
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/assets/counts', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.assetStats = await res.json();
} catch (e) { console.error(e); }
} catch (e) { console.error(e); this.$message.error('加载统计失败: ' + e.message); }
},
handleFileChange(file, fileList) { this.uploadFiles = fileList; },
async uploadFiles() {
@@ -247,8 +242,8 @@ const AssetsApp = {
};
const app = Vue.createApp(AssetsApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
+60 -46
View File
@@ -7,22 +7,23 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
.content-area { flex: 1; padding: 32px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.content-area { padding: 32px; }
@media (max-width: 768px) {
.content-area { padding: 12px; padding-bottom: 80px; }
.calendar-day { min-height: 56px !important; padding: 2px; }
.calendar-weekday { padding: 6px; font-size: 12px; }
.day-entry { font-size: 10px; padding: 2px 4px; white-space: normal; }
.content-area { padding: 12px !important; padding-bottom: 80px !important; }
.card { padding: 12px !important; }
.calendar-header { flex-direction: column; align-items: flex-start; gap: 8px; }
.calendar-title { font-size: 20px; }
.calendar-nav { width: 100%; justify-content: space-between; }
.calendar-grid { gap: 2px; }
.calendar-weekday { padding: 4px; font-size: 11px; }
.calendar-day { min-height: 56px !important; padding: 2px; }
.day-number { font-size: 12px; margin-bottom: 0; }
.day-lunar { font-size: 9px; margin-top: -1px; }
.day-term { font-size: 8px; padding: 0 3px; }
.day-holiday { font-size: 8px; padding: 0 3px; }
.day-lunar { font-size: 9px; margin-top: -1px; display: none; }
.day-term { font-size: 8px; padding: 0 2px; }
.day-holiday { font-size: 8px; padding: 0 2px; }
.day-entry { font-size: 10px; padding: 2px 3px; white-space: normal; line-height: 1.2; }
.stats-bar { gap: 12px; font-size: 12px; }
}
.calendar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
@@ -39,11 +40,16 @@
.day-term { display: inline-block; font-size: 9px; color: #E6A23C; background: #fdf6ec; border-radius: 3px; padding: 0 4px; margin-top: 1px; font-weight: 500; white-space: nowrap; }
.day-holiday { display: inline-block; font-size: 9px; color: #F56C6C; background: #fef0f0; border-radius: 3px; padding: 0 4px; margin-top: 1px; font-weight: 500; white-space: nowrap; }
.day-entries { display: flex; flex-direction: column; gap: 4px; }
.day-entry { font-size: 11px; padding: 4px 6px; border-radius: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; }
.day-entry { font-size: 11px; padding: 4px 6px; border-radius: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; display:flex; align-items:center; gap:3px; }
.day-entry.planned { background: #fdf6ec; color: #E6A23C; }
.day-entry.published { background: #f0f9eb; color: #67C23A; }
.day-entry.delayed { background: #fef0f0; color: #F56C6C; }
.day-entry.cancelled { background: #f4f4f5; color: #909399; }
.entry-topic-status { font-size:9px; padding:1px 3px; border-radius:2px; margin-left:auto; flex-shrink:0; }
.entry-topic-status.review { background:#ecf5ff; color:#409eff; }
.entry-topic-status.ready { background:#fdf6ec; color:#e6a23c; }
.entry-topic-status.published { background:#f0f9eb; color:#67c23a; }
.entry-topic-status.pending { background:#f4f4f5; color:#909399; }
.stats-bar { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }
.stat-item { display: flex; align-items: center; gap: 8px; }
@@ -53,40 +59,37 @@
.stat-dot.delayed { background: #F56C6C; }
.stat-dot.cancelled { background: #909399; }
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component
title="内容日历"
:username="currentUser.username"
:is-admin="isAdmin"
@logout="handleLogout"
></navbar-component>
<navigation-component
current-page="calendar"
:is-admin="isAdmin"
@navigate="redirectToPage"
></navigation-component>
<uni-nav title="内容日历" :username="currentUser.username" :is-admin="isAdmin" current-page="calendar" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="calendar-header">
<h2 class="calendar-title">📅 内容日历</h2>
<h2 class="calendar-title"><el-icon style="vertical-align:-2px;"><IconCalendar /></el-icon> 内容日历</h2>
<div class="calendar-nav">
<el-button @click="prevMonth" size="large"></el-button>
<el-button @click="prevMonth" size="large"><el-icon><IconArrowLeft /></el-icon></el-button>
<span style="font-size: 20px; font-weight: 600;">{{ currentYear }}年 {{ currentMonth }}月</span>
<el-button @click="nextMonth" size="large"></el-button>
<el-button @click="nextMonth" size="large"><el-icon><IconArrowRight /></el-icon></el-button>
<el-button type="primary" @click="goToday">今天</el-button>
</div>
</div>
<div class="stats-bar">
<div class="stat-item"><span class="stat-dot planned"></span> 待发布: {{ stats.planned }}</div>
<div class="stat-item"><span class="stat-dot published"></span> 已发布: {{ stats.published }}</div>
<div class="stat-item"><span class="stat-dot delayed"></span> 延迟: {{ stats.delayed }}</div>
<div class="stat-item"><span class="stat-dot cancelled"></span> 取消: {{ stats.cancelled }}</div>
<div v-if="loadingCalendar" style="color:#909399;font-size:14px;">加载中...</div>
<div v-else-if="!calendarDays.length" class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconCalendar /></el-icon>
<div class="empty-text">暂无日历数据</div>
</div>
<template v-else>
<div class="stat-item"><span class="stat-dot planned"></span> 待发布: {{ stats.planned }}</div>
<div class="stat-item"><span class="stat-dot published"></span> 已发布: {{ stats.published }}</div>
<div class="stat-item"><span class="stat-dot delayed"></span> 延迟: {{ stats.delayed }}</div>
<div class="stat-item"><span class="stat-dot cancelled"></span> 取消: {{ stats.cancelled }}</div>
</template>
</div>
<div class="calendar-grid">
@@ -104,7 +107,9 @@
class="day-entry"
:class="entry.status"
@click.stop="openEntryDialog(entry)">
{{ entry.platform_icon }} {{ entry.title || '未命名' }}
<span v-if="entry.platform_icon" style="flex-shrink:0;">{{ entry.platform_icon }}</span><el-icon v-else style="flex-shrink:0;"><IconDocument /></el-icon>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ entry.title || '未命名' }}</span>
<span v-if="entry.topic_status" class="entry-topic-status" :class="entry.topic_status">{{ topicStatusLabel(entry.topic_status) }}</span>
</div>
</div>
</div>
@@ -122,7 +127,7 @@
<template #default="scope">{{ platformName(scope.row.platform) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="scope"><el-tag :type="statusType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</template>
<template #default="scope"><el-tag :type="statusType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
@@ -248,11 +253,12 @@ function getDayMeta(year, month, day) {
}
</script>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const { ref, reactive, computed, onMounted } = Vue;
const CalendarApp = {
components: { 'navbar-component': window.NavbarComponent, 'navigation-component': window.NavigationComponent },
setup() {
const currentUser = ref({ username: '' });
const isAdmin = ref(false);
@@ -313,14 +319,20 @@ function getDayMeta(year, month, day) {
return days;
});
const loadingCalendar = ref(false);
const calendarError = ref('');
const fetchEntries = async () => {
try { entries.value = await api(`/api/calendar?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); }
loadingCalendar.value = true;
calendarError.value = '';
try { entries.value = await api(`/api/calendar?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); calendarError.value = e.message; ElMessage.error('加载日历失败: ' + e.message); }
finally { loadingCalendar.value = false; }
};
const fetchStats = async () => {
try { stats.value = await api(`/api/calendar/stats?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); }
try { stats.value = await api(`/api/calendar/stats?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); ElMessage.error('加载统计失败: ' + e.message); }
};
const fetchTopics = async () => {
try { const res = await api('/api/topics?limit=100'); topics.value = res; } catch (e) { console.error(e); }
try { const res = await api('/api/topics?limit=100'); topics.value = res; } catch (e) { console.error(e); ElMessage.error('加载选题失败: ' + e.message); }
};
const prevMonth = () => { if (currentMonth.value === 1) { currentMonth.value = 12; currentYear.value--; } else { currentMonth.value--; } fetchEntries(); fetchStats(); };
@@ -338,21 +350,23 @@ function getDayMeta(year, month, day) {
if (isEdit.value) { await api(`/api/calendar/entries/${data.id}`, { method: 'PUT', body: JSON.stringify(data) }); }
else { await api('/api/calendar/entries', { method: 'POST', body: JSON.stringify(data) }); }
entryDialogVisible.value = false;
ElMessage.success('保存成功');
await fetchEntries();
await fetchStats();
} catch (e) { alert('保存失败: ' + e.message); }
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
saving.value = false;
};
const deleteEntry = async () => {
if (!confirm('确定删除?')) return;
try { await ElMessageBox.confirm('确定删除?', '提示', { type: 'warning' }); } catch { return; }
saving.value = true;
try { await api(`/api/calendar/entries/${entryForm.value.id}`, { method: 'DELETE' }); entryDialogVisible.value = false; await fetchEntries(); await fetchStats(); } catch (e) { alert('删除失败: ' + e.message); }
try { await api(`/api/calendar/entries/${entryForm.value.id}`, { method: 'DELETE' }); entryDialogVisible.value = false; ElMessage.success('删除成功'); await fetchEntries(); await fetchStats(); } catch (e) { ElMessage.error('删除失败: ' + e.message); }
saving.value = false;
};
const platformName = (p) => ({ zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p);
const statusLabel = (s) => ({ planned: '待发布', published: '已发布', delayed: '延迟', cancelled: '取消' }[s] || s);
const statusType = (s) => ({ planned: 'warning', published: 'success', delayed: 'danger', cancelled: 'info' }[s] || '');
const topicStatusLabel = (s) => ({ pending: '待处理', review: '待审查', ready: '待发布', published: '已发布' }[s] || s);
const checkAuth = () => {
const token = localStorage.getItem('authToken');
@@ -384,15 +398,15 @@ function getDayMeta(year, month, day) {
checkAuth();
});
return { currentUser, isAdmin, currentYear, currentMonth, weekDays, calendarDays, entries, topics, stats, dayDialogVisible, entryDialogVisible, selectedDay, selectedDayEntries, isEdit, saving, entryForm, prevMonth, nextMonth, goToday, openDayDialog, openCreateDialog, openEntryDialog, saveEntry, deleteEntry, platformName, statusLabel, statusType, handleLogout, redirectToPage };
return { currentUser, isAdmin, currentYear, currentMonth, weekDays, calendarDays, entries, topics, stats, dayDialogVisible, entryDialogVisible, selectedDay, selectedDayEntries, isEdit, saving, entryForm, prevMonth, nextMonth, goToday, openDayDialog, openCreateDialog, openEntryDialog, saveEntry, deleteEntry, platformName, statusLabel, statusType, topicStatusLabel, handleLogout, redirectToPage };
}
};
const app = Vue.createApp(CalendarApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
</html>
</html>
File diff suppressed because one or more lines are too long
+40
View File
@@ -0,0 +1,40 @@
(function() {
function ic(path, vb) {
return { render() { return Vue.h('svg', { xmlns:'http://www.w3.org/2000/svg', viewBox: vb || '0 0 1024 1024' }, Vue.h('path', { fill:'currentColor', d: path })) } };
}
const icons = {
IconSearch: ic('m795.904 750.72 124.992 124.928a32 32 0 0 1-45.248 45.248L750.656 795.904a416 416 0 1 1 45.248-45.248zM480 832a352 352 0 1 0 0-704 352 352 0 0 0 0 704'),
IconDocument: ic('M832 384H576V128H192v768h640zm-26.496-64L640 154.496V320zM160 64h480l256 256v608a32 32 0 0 1-32 32H160a32 32 0 0 1-32-32V96a32 32 0 0 1 32-32'),
IconPlus: ic('M480 480V128a32 32 0 0 1 64 0v352h352a32 32 0 1 1 0 64H544v352a32 32 0 1 1-64 0V544H128a32 32 0 1 1 0-64h352z'),
IconClock: ic('M512 896a384 384 0 1 0 0-768 384 384 0 0 0 0 768m0 64a448 448 0 1 1 0-896 448 448 0 0 1 0 896m-32-832a32 32 0 0 1 32 32v256a32 32 0 0 1-64 0V160a32 32 0 0 1 32-32'),
IconDelete: ic('M160 256H96a32 32 0 0 1 0-64h256V95.936a32 32 0 0 1 32-32h256a32 32 0 0 1 32 32V192h256a32 32 0 1 1 0 64h-64v672a32 32 0 0 1-32 32H192a32 32 0 0 1-32-32V256zm448 0H256v640h352V256z'),
IconClose: ic('M764.288 214.592 512 466.88 259.712 214.592a31.936 31.936 0 0 0-45.12 45.12L466.88 512 214.592 764.288a31.936 31.936 0 1 0 45.12 45.12L512 557.12l252.288 252.288a31.936 31.936 0 0 0 45.12-45.12L557.12 512l252.288-252.288a31.936 31.936 0 1 0-45.12-45.12z'),
IconCheck: ic('M406.656 706.944 195.84 496.256a32 32 0 1 0-45.248 45.248l256 256 512-512a32 32 0 0 0-45.248-45.248L406.656 706.944z'),
IconStar: ic('m512 747.84 228.16 119.936a6.4 6.4 0 0 0 9.28-6.72l-43.52-254.08 184.512-179.904a6.4 6.4 0 0 0-3.52-10.88l-255.104-37.12L517.76 147.84a6.4 6.4 0 0 0-11.52 0L389.12 379.136l-255.104 37.12a6.4 6.4 0 0 0-3.52 10.88L314.88 606.976 271.36 861.056a6.4 6.4 0 0 0 9.28 6.72L512 747.84z'),
IconArrowDown: ic('M831.872 340.864 512 652.672 192.128 340.864a30.59 30.59 0 0 0-42.752 0 29.12 29.12 0 0 0 0 41.6L489.664 714.24a32 32 0 0 0 44.672 0l340.288-331.712a29.12 29.12 0 0 0 0-41.728 30.59 30.59 0 0 0-42.752 0z'),
IconArrowRight: ic('M340.864 149.312a30.59 30.59 0 0 0 0 42.752L652.736 512 340.864 831.872a30.59 30.59 0 0 0 0 42.752 29.12 29.12 0 0 0 41.728 0L714.24 534.336a32 32 0 0 0 0-44.672L382.592 149.312a29.12 29.12 0 0 0-41.728 0z'),
IconArrowLeft: ic('M609.408 149.376 277.76 489.6a32 32 0 0 0 0 44.672l331.648 340.352a29.12 29.12 0 0 0 41.728 0 30.59 30.59 0 0 0 0-42.752L338.688 512l312.448-340.352a30.59 30.59 0 0 0 0-42.752 29.12 29.12 0 0 0-41.728 0z'),
IconRefresh: ic('M784.512 230.272v-50.56a32 32 0 1 1 64 0v149.056a32 32 0 0 1-32 32H667.52a32 32 0 1 1 0-64h92.992A288 288 0 0 0 224 512a32 32 0 1 1-64 0 352 352 0 0 1 624.512-224z m-545.024 563.456v50.56a32 32 0 1 1-64 0V694.912a32 32 0 0 1 32-32h149.056a32 32 0 1 1 0 64H263.552A288 288 0 0 0 800 512a32 32 0 1 1 64 0 352 352 0 0 1-624.512 224z'),
IconPicture: ic('M96 896a32 32 0 0 1-32-32V160a32 32 0 0 1 32-32h832a32 32 0 0 1 32 32v704a32 32 0 0 1-32 32H96zm32-64h768V192H128v640zm319.808-457.152L320 512l128 170.496-63.744 47.808L256 563.2 128 736h768L574.72 321.6a32 32 0 0 0-50.432 0zM448 384a64 64 0 1 0-128 0 64 64 0 0 0 128 0z'),
IconInfo: ic('M512 64a448 448 0 1 1 0 896.064A448 448 0 0 1 512 64m67.2 275.072c33.28 0 60.288-23.104 60.288-57.6s-27.072-57.6-60.288-57.6c-34.176 0-60.288 23.104-60.288 57.6s26.88 57.6 60.288 57.6m-109.44 355.008c0 17.088 12.864 29.76 35.328 29.76 29.312 0 52.416-16.832 52.416-44.928v-200.32c0-18.688-7.04-76.8-48.896-76.8-22.4 0-35.2 17.408-35.2 34.688 0 6.848 3.2 19.456 3.2 24.96 0 0-55.872 143.488-55.872 180.48 0 17.28 13.792 51.84 48.128 51.84z'),
IconWarning: ic('M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m0 192a58.43 58.43 0 0 0-58.24 63.744l23.36 256.512a35.52 35.52 0 0 0 69.76 0l23.2-256.512A58.43 58.43 0 0 0 512 256m0 448a64 64 0 1 0 0 128 64 64 0 0 0 0-128'),
IconSuccess: ic('M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m-55.808 536.384-99.52-99.584a38.4 38.4 0 1 0-54.336 54.336l126.72 126.72a38.27 38.27 0 0 0 54.336 0l262.4-262.464a38.4 38.4 0 1 0-54.272-54.336L456.192 600.384z'),
IconLoading: ic('M512 64a32 32 0 0 1 32 32v192a32 32 0 0 1-64 0V96a32 32 0 0 1 32-32m0 640a32 32 0 0 1 32 32v192a32 32 0 0 1-64 0V736a32 32 0 0 1 32-32m259.712-412.288a32 32 0 0 1 45.248 45.248l-135.808 135.808a32 32 0 0 1-45.248-45.248l135.808-135.808zM381.568 590.432a32 32 0 0 1 45.248 45.248l-135.808 135.808a32 32 0 0 1-45.248-45.248l135.808-135.808zM832 480a32 32 0 0 1 32 32h-192a32 32 0 0 1 0-64h160a32 32 0 0 1 32 32zM192 480a32 32 0 0 1 32 32H64a32 32 0 0 1 0-64h128a32 32 0 0 1 32 32zm596.288 339.712a32 32 0 0 1 0 45.248l-135.808-135.808a32 32 0 0 1 45.248-45.248l90.56 90.56v.248zM313.248 389.568a32 32 0 0 1-45.248 45.248l-90.56-90.56a32 32 0 0 1 45.248-45.248l90.56 90.56z'),
IconMore: ic('M176 416a112 112 0 1 0 0 224 112 112 0 0 0 0-224m336 0a112 112 0 1 0 0 224 112 112 0 0 0 0-224m336 0a112 112 0 1 0 0 224 112 112 0 0 0 0-224'),
IconFullScreen: ic('m160 96.064 192 .192a32 32 0 0 1 0 64l-192-.192V352a32 32 0 0 1-64 0V96h64zm0 832v-192a32 32 0 0 1 64 0v192l192-.192a32 32 0 0 1 0 64l-192-.192H160zM864 96.064l-192 .192a32 32 0 0 0 0 64l192-.192V352a32 32 0 0 0 64 0V96h-64zm0 736V640a32 32 0 0 0-64 0v192l-192-.192a32 32 0 0 0 0 64l192 .192H864z'),
IconMenu: ic('M160 224a32 32 0 0 1 32-32h640a32 32 0 1 1 0 64H192a32 32 0 0 1-32-32m0 288a32 32 0 0 1 32-32h640a32 32 0 1 1 0 64H192a32 32 0 0 1-32-32m0 288a32 32 0 0 1 32-32h640a32 32 0 1 1 0 64H192a32 32 0 0 1-32-32'),
IconUser: ic('M659.2 659.2a192 192 0 1 0-294.4 0 448 448 0 0 0-294.4 307.2 32 32 0 1 0 62.08 15.36 384 384 0 0 1 724.48 0 32 32 0 0 0 62.08-15.36 448 448 0 0 0-259.84-307.2zM512 640a128 128 0 1 1 0-256 128 128 0 0 1 0 256'),
IconSetting: ic('M640 512a128 128 0 1 0-256 0 128 128 0 0 0 256 0m64 0a192 192 0 1 1-384 0 192 192 0 0 1 384 0m-64-256a32 32 0 0 0 0-64H384a32 32 0 0 0 0 64h256m-192 0h128a32 32 0 0 0 0-64H448a32 32 0 0 0 0 64m-64 480a32 32 0 0 0 0 64h256a32 32 0 0 0 0-64H384'),
IconUpload: ic('M544 864a32 32 0 1 1-64 0V380.8L337.6 523.2a32 32 0 0 1-45.248-45.248l192-192a32 32 0 0 1 45.248 0l192 192a32 32 0 1 1-45.248 45.248L544 380.8V864zM192 256a32 32 0 1 1 0-64h640a32 32 0 1 1 0 64H192z'),
IconCalendar: ic('M768 128a32 32 0 0 1 32 32v96h96a32 32 0 0 1 32 32v576a32 32 0 0 1-32 32H128a32 32 0 0 1-32-32V288a32 32 0 0 1 32-32h96v-96a32 32 0 1 1 64 0v96h512v-96a32 32 0 0 1 32-32zm32 224H224v480h576V352zM384 448a32 32 0 0 1 32 32v128a32 32 0 0 1-64 0V480a32 32 0 0 1 32-32m256 0a32 32 0 0 1 32 32v128a32 32 0 1 1-64 0V480a32 32 0 0 1 32-32'),
IconDashboard: ic('M480 64a32 32 0 0 1 64 0v192a32 32 0 1 1-64 0V64zM239.648 182.816a32 32 0 0 1 45.248 0l135.808 135.872a32 32 0 0 1-45.248 45.248L239.648 228.064a32 32 0 0 1 0-45.248m544.704 0a32 32 0 0 1 0 45.248L648.544 363.968a32 32 0 0 1-45.248-45.248l135.808-135.872a32 32 0 0 1 45.248 0zM64 480a32 32 0 0 1 32-32h192a32 32 0 1 1 0 64H96a32 32 0 0 1-32-32m672 0a32 32 0 0 1 32-32h192a32 32 0 1 1 0 64H768a32 32 0 0 1-32-32m-544 288a32 32 0 0 1 32-32h576a32 32 0 1 1 0 64H224a32 32 0 0 1-32-32'),
IconTopic: ic('M832 896H192a32 32 0 0 1-32-32V160a32 32 0 0 1 32-32h448l256 256v480a32 32 0 0 1-32 32zm-32-64V416H576V192H224v640h576zM480 256H320a32 32 0 1 0 0 64h160a32 32 0 1 0 0-64m0 192H320a32 32 0 1 0 0 64h160a32 32 0 1 0 0-64m-160 192h320a32 32 0 1 0 0-64H320a32 32 0 0 0 0 64'),
IconGlobe: ic('M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896m32 63.744V320h128a32 32 0 0 1 0 64h-22.912c13.12 47.232 19.904 97.28 20.288 148.48L704 532.48V512a32 32 0 1 1 64 0v88.064a448.256 448.256 0 0 1-128 165.12l-.064-101.12a32 32 0 1 0-64 0v138.88c-43.968 17.472-91.36 26.944-141.248 27.136L416 829.44V640a32 32 0 0 0-64 0v138.88a320 320 0 0 1-96-116.544V544h128a32 32 0 1 0 0-64H256v-64h128a32 32 0 1 0 0-64H273.792a320.256 320.256 0 0 1 49.152-128H448a32 32 0 0 0 0-64H377.024A320.128 320.128 0 0 1 544 127.744z'),
};
window.__iconComponents = icons;
window.installIcons = function(app) {
for (const [name, comp] of Object.entries(icons)) {
app.component(name, comp);
}
};
})();
+51 -175
View File
@@ -5,96 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台</title>
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
/* 深色渐变背景主题 */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; min-height: 100vh; }
/* 导航栏 */
.navbar {
background: rgba(102, 126, 234, 0.15);
backdrop-filter: blur(20px);
border-bottom: 1px solid rgba(102, 126, 234, 0.2);
padding: 16px 24px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
position: sticky;
top: 0;
z-index: 100;
}
.navbar-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1400px;
margin: 0 auto;
}
.navbar-title {
font-size: 20px;
font-weight: 700;
background: linear-gradient(90deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.5px;
}
.navbar-user {
display: flex;
align-items: center;
gap: 16px;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
color: #a0aec0;
font-size: 14px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: 700;
color: white;
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.4);
}
/* 主内容区 */
.main-content {
display: flex;
max-width: 1400px;
margin: 0 auto;
min-height: calc(100vh - 64px);
}
/* 侧边栏 */
.sidebar {
width: 200px;
background: rgba(26, 26, 46, 0.8);
backdrop-filter: blur(20px);
padding: 16px 12px;
border-right: 1px solid rgba(102, 126, 234, 0.1);
display: flex;
flex-direction: column;
gap: 4px;
}
/* 内容区域 */
.content-area {
flex: 1;
padding: 32px;
overflow-y: auto;
}
/* 页面切换 */
/* 仪表盘统计卡片 */
.page { display: none; animation: fadeIn 0.5s ease-out; }
.page.active { display: block; }
@keyframes fadeIn {
@@ -102,7 +15,6 @@
to { opacity: 1; transform: translateY(0); }
}
/* 统计卡片网格 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
@@ -110,9 +22,8 @@
margin-bottom: 40px;
}
.stat-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(102, 126, 234, 0.1);
background: white;
border: 1px solid #ebeef5;
border-radius: 16px;
padding: 24px;
cursor: pointer;
@@ -120,27 +31,14 @@
position: relative;
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(102, 126, 234, 0.1), transparent);
transition: left 0.6s;
}
.stat-card:hover::before {
left: 100%;
}
.stat-card:hover {
transform: translateY(-8px) scale(1.02);
border-color: rgba(102, 126, 234, 0.4);
box-shadow: 0 12px 32px rgba(102, 126, 234, 0.2);
border-color: #c6d8ff;
box-shadow: 0 12px 32px rgba(102, 126, 234, 0.15);
}
.stat-title {
font-size: 13px;
color: #a0aec0;
color: #909399;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
@@ -168,18 +66,16 @@
gap: 20px;
}
.module-card {
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
border: 1px solid rgba(102, 126, 234, 0.1);
background: white;
border: 1px solid #ebeef5;
border-radius: 16px;
padding: 24px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.module-card:hover {
transform: translateY(-6px);
border-color: rgba(102, 126, 234, 0.3);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
border-color: #c6d8ff;
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
}
.module-header {
display: flex;
@@ -190,7 +86,7 @@
.module-title {
font-size: 16px;
font-weight: 600;
color: #e0e6ed;
color: #303133;
display: flex;
align-items: center;
gap: 8px;
@@ -200,40 +96,28 @@
border-radius: 20px;
font-size: 12px;
font-weight: 600;
background: rgba(103, 194, 58, 0.2);
background: rgba(103, 194, 58, 0.12);
color: #67c23a;
border: 1px solid rgba(103, 194, 58, 0.3);
border: 1px solid rgba(103, 194, 58, 0.25);
}
.module-status.running {
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(103, 194, 58, 0.4); }
50% { box-shadow: 0 0 0 8px rgba(103, 194, 58, 0); }
animation: pulse-glow 2s infinite;
}
.module-content {
font-size: 14px;
color: #a0aec0;
color: #606266;
line-height: 1.8;
}
.module-content div {
display: flex;
justify-content: space-between;
padding: 4px 0;
border-bottom: 1px dashed rgba(255, 255, 255, 0.05);
border-bottom: 1px dashed #f0f0f0;
}
.module-content div:last-child { border-bottom: none; }
/* 响应式 */
@media (max-width: 768px) {
.content-area {
padding: 16px;
padding-bottom: 80px;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
.stats-grid { grid-template-columns: repeat(2, 1fr); gap: 12px; }
.stat-card { padding: 16px; }
.stat-value { font-size: 24px; }
.module-grid { grid-template-columns: 1fr; }
@@ -253,34 +137,32 @@
#page-overview .module-content { color: #303133 !important; }
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component
title="宇之然内容创作平台"
:username="currentUser.username"
:is-admin="isAdmin"
@logout="handleLogout"
></navbar-component>
<navigation-component
current-page="dashboard"
:is-admin="isAdmin"
@navigate="redirectToPage"
></navigation-component>
<uni-nav title="仪表盘" :username="currentUser.username" :is-admin="isAdmin" current-page="dashboard" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content" v-if="isLoggedIn">
<main class="content-area">
<!-- 系统概览页面 -->
<div id="page-overview" class="page" :class="{ active: currentPage === 'overview' }">
<h2 style="font-size: 28px; font-weight: 700; margin-bottom: 32px; color: #e0e6ed;">
📊 系统概览
<el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 系统概览
</h2>
<!-- 加载状态 -->
<div v-if="loadingStats" style="text-align:center;padding:40px 0;color:#909399;">
<el-icon style="font-size:32px;margin-bottom:12px;" class="is-loading"><IconLoading /></el-icon>
<div>加载中...</div>
</div>
<!-- 空状态 -->
<div v-else-if="!stats.total && !loadingStats" class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconDashboard /></el-icon>
<div class="empty-text">暂无数据</div>
</div>
<!-- 统计卡片 -->
<div class="stats-grid">
<div v-else class="stats-grid">
<div class="stat-card primary" @click="goToTopics('')">
<div class="stat-title">选题总数</div>
<div class="stat-value">{{ stats.total }}</div>
@@ -301,7 +183,7 @@
<div class="stat-title">已发布</div>
<div class="stat-value">{{ stats.published }}</div>
</div>
<div class="stat-card primary" @click="goToTopics('')">
<div class="stat-card primary" @click="goToTopics('today')">
<div class="stat-title">今日新增</div>
<div class="stat-value">{{ stats.today }}</div>
</div>
@@ -309,9 +191,12 @@
<!-- 模块状态 -->
<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 24px; color: #e0e6ed;">
🔧 模块状态
<el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 模块状态
<span style="font-size: 13px; font-weight: 400; color: #909399; margin-left: 12px;">
定时任务: {{ schedulerStatus }}
定时任务:
<el-icon v-if="schedulerRunning" style="color:#67C23A;vertical-align:-2px;"><IconCheck /></el-icon>
<el-icon v-else style="color:#F56C6C;vertical-align:-2px;"><IconClose /></el-icon>
{{ schedulerStatus }}
</span>
</h3>
<div class="module-grid">
@@ -332,10 +217,10 @@
</div>
<!-- 移动端导航 -->
<!-- 移动端导航由 navigation-component.js 注入 -->
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const App = {
@@ -345,6 +230,7 @@
isAdmin: false,
currentUser: { username: '' },
currentPage: 'overview',
loadingStats: false,
stats: {
total: 0,
pending: 0,
@@ -354,7 +240,8 @@
today: 0
},
modules: [],
schedulerStatus: ''
schedulerStatus: '',
schedulerRunning: false
};
},
methods: {
@@ -391,6 +278,7 @@
window.location.href = '/login.html';
},
async fetchStats() {
this.loadingStats = true;
try {
const token = localStorage.getItem('authToken');
if (!token) {
@@ -421,9 +309,9 @@
}
} catch (error) {
console.error('获取统计信息失败:', error);
// 失败时设置为0,避免页面空白
this.$message.error('获取统计信息失败: ' + error.message);
this.stats = { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 };
}
} finally { this.loadingStats = false; }
},
async fetchModules() {
try {
@@ -435,13 +323,15 @@
const data = await resp.json();
this.modules = data.modules || [];
const s = data.scheduler || {};
this.schedulerStatus = s.running ? '🟢 运行中' : '🔴 未启动';
this.schedulerRunning = s.running;
this.schedulerStatus = (s.running ? '运行中' : '未启动');
if (s.jobs && s.jobs.length) {
this.schedulerStatus += ' · ' + s.jobs.length + ' 个任务';
}
}
} catch (e) {
console.error('获取模块状态失败:', e);
this.$message.error('获取模块状态失败: ' + e.message);
}
},
goToTopics(filter) {
@@ -479,23 +369,9 @@
const app = Vue.createApp(App);
app.use(ElementPlus);
// 安装导航组件
if (window.installNavbar) { window.installNavbar(app); }
// 安装导航组件
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
// 调试代码:检查导航组件状态
setTimeout(() => {
const hasNav = !!document.querySelector('.navigation-wrapper');
const hasSidebar = !!document.querySelector('.navigation-wrapper .sidebar');
console.log('[调试] 导航wrapper:', hasNav);
console.log('[调试] 侧边栏:', hasSidebar);
if (!hasNav) {
console.error('[调试] 导航组件未渲染!window.NavigationComponent=', !!window.NavigationComponent);
console.error('[调试] app实例是否存在组件注册?', Vue && Vue.app && Vue.app._context.components['navigation-component']);
}
}, 100);
</script>
</body>
</html>
+9 -20
View File
@@ -7,41 +7,29 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
.controls { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; align-items: center; }
.log-container { max-height: 600px; overflow-y: auto; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; }
.log-container pre { margin: 0; padding: 16px; white-space: pre-wrap; word-wrap: break-word; font-size: 13px; line-height: 1.6; color: #303133; }
@media (max-width: 768px) {
.content-area { padding: 16px; padding-bottom: 80px; }
.card { padding: 16px; }
.controls { flex-direction: column; align-items: stretch; }
.controls .el-select, .controls .el-date-picker { width: 100% !important; }
.log-container { max-height: calc(100vh - 250px); }
.controls .el-button { width: 100%; }
.log-container { max-height: calc(100vh - 280px); }
.log-container pre { font-size: 11px; padding: 12px; }
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="系统日志" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="logs" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="系统日志" :username="currentUser.username" :is-admin="isAdmin" current-page="logs" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title">📄 系统日志</h2>
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 系统日志</h2>
</div>
<div class="controls">
<el-select v-model="logType" placeholder="日志类型" style="width: 200px;">
@@ -67,6 +55,7 @@
</div>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const LogsApp = {
@@ -112,8 +101,8 @@
};
const app = Vue.createApp(LogsApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
+246 -80
View File
@@ -7,40 +7,51 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.stat-card { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; padding: 20px; color: white; text-align: center; }
.stat-card.success { background: linear-gradient(135deg, #67c23a 0%, #85ce61 100%); }
.stat-card.warning { background: linear-gradient(135deg, #e6a23c 0%, #f5c543 100%); }
.stat-card.danger { background: linear-gradient(135deg, #f56c6c 0%, #f78989 100%); }
.stat-value { font-size: 32px; font-weight: 700; }
.stat-label { font-size: 14px; opacity: 0.9; margin-top: 4px; }
@media (max-width: 768px) {
.content-area { padding: 16px; padding-bottom: 80px; }
.stats-grid { grid-template-columns: repeat(2, 1fr) !important; gap: 12px !important; }
.chart-container { height: 250px !important; }
}
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 24px; }
.chart-container { background: white; border-radius: 12px; padding: 20px; margin-bottom: 24px; height: 350px; }
.chart-container { background: white; border-radius: 12px; padding: 16px; margin-bottom: 24px; height: 320px; position: relative; }
.platform-chart { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr) !important; gap: 10px !important; }
.stat-card { padding: 14px; }
.stat-value { font-size: 24px; }
.stat-label { font-size: 12px; }
.chart-container { height: 220px !important; padding: 12px; }
.platform-chart { grid-template-columns: 1fr; }
.metrics-table { display: none; }
.metrics-card-list { display: block; }
}
.metrics-table { display: block; }
.metrics-card-list { display: none; }
.metrics-card { background: white; border-radius: 10px; padding: 14px; margin-bottom: 10px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.metrics-card:active { transform: scale(0.99); }
.metrics-card-row { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; border-bottom: 1px dashed #f0f0f0; font-size: 13px; }
.metrics-card-row:last-child { border-bottom: none; }
.metrics-card-label { color: #909399; }
.metrics-card-value { color: #303133; font-weight: 500; }
.metrics-card-title { font-size: 15px; font-weight: 600; color: #303133; margin-bottom: 10px; }
.metrics-card-reason { font-size: 12px; color: #606266; margin-top: 6px; line-height: 1.5; }
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
<script src="chart.umd.min.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="数据分析" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="metrics" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="数据分析" :username="currentUser.username" :is-admin="isAdmin" current-page="metrics" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;">📊 数据分析</h2>
<div class="stats-grid">
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;"><el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 数据分析</h2>
<div v-if="loadingDashboard" style="text-align:center;padding:40px 0;color:#909399;">
<el-icon style="font-size:32px;margin-bottom:12px;" class="is-loading"><IconLoading /></el-icon>
<div>加载中...</div>
</div>
<div v-else class="stats-grid">
<div class="stat-card">
<div class="stat-value">{{ dashboard.total_topics }}</div>
<div class="stat-label">选题总数</div>
@@ -59,29 +70,47 @@
</div>
</div>
<div class="card page-fade">
<h3 style="font-size: 18px; margin-bottom: 16px;">📈 选题状态分布</h3>
<div style="display: flex; gap: 20px; flex-wrap: wrap;">
<div v-for="(count, status) in dashboard.topics_by_status" :key="status" style="text-align: center;">
<div style="font-size: 28px; font-weight: 700; color: #409eff;">{{ count }}</div>
<div style="font-size: 14px; color: #909399;">{{ getStatusLabel(status) }}</div>
<h3 style="font-size: 18px; margin-bottom: 16px;"><el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 选题状态分布</h3>
<div style="display: flex; gap: 20px; flex-wrap: wrap; align-items:center;">
<div style="flex:1;min-width:200px;">
<div v-for="(count, status) in dashboard.topics_by_status" :key="status" style="display:flex;align-items:center;margin-bottom:12px;">
<div :style="{width:14+'px',height:14+'px',borderRadius:'50%',marginRight:10+'px',background:statusColors(status)}"></div>
<div style="flex:1;font-size:14px;color:#606266;">{{ getStatusLabel(status) }}</div>
<div style="font-size:16px;font-weight:600;color:#303133;">{{ count }}</div>
</div>
</div>
<div style="width:200px;height:180px;">
<canvas ref="statusChartCanvas" style="width:100%;height:100%;"></canvas>
</div>
</div>
</div>
<div class="card">
<h3 style="font-size: 18px; margin-bottom: 16px;">🔥 热门选题 TOP10</h3>
<el-table :data="dashboard.top_topics" stripe size="small">
<el-table-column prop="topic_id" label="ID" width="80"></el-table-column>
<el-table-column prop="title" label="标题"></el-table-column>
<el-table-column prop="total_views" label="阅读" width="100">
<template #default="scope">{{ scope.row.total_views || 0 }}</template>
</el-table-column>
<el-table-column prop="total_likes" label="点赞" width="100">
<template #default="scope">{{ scope.row.total_likes || 0 }}</template>
</el-table-column>
</el-table>
<h3 style="font-size: 18px; margin-bottom: 16px;"><el-icon style="vertical-align:-2px;"><IconStar /></el-icon> 热门选题 TOP10</h3>
<div v-if="dashboard.top_topics && dashboard.top_topics.length > 0">
<div class="metrics-table">
<el-table :data="dashboard.top_topics" stripe size="small">
<el-table-column prop="topic_id" label="ID" width="80"></el-table-column>
<el-table-column prop="title" label="标题"></el-table-column>
<el-table-column prop="total_views" label="阅读" width="100">
<template #default="scope">{{ scope.row.total_views || 0 }}</template>
</el-table-column>
<el-table-column prop="total_likes" label="点赞" width="100">
<template #default="scope">{{ scope.row.total_likes || 0 }}</template>
</el-table-column>
</el-table>
</div>
<div class="metrics-card-list">
<div v-for="item in dashboard.top_topics" :key="item.topic_id" class="metrics-card">
<div class="metrics-card-title">{{ item.topic_id }}. {{ item.title }}</div>
<div class="metrics-card-row"><span class="metrics-card-label">阅读</span><span class="metrics-card-value">{{ item.total_views || 0 }}</span></div>
<div class="metrics-card-row"><span class="metrics-card-label">点赞</span><span class="metrics-card-value">{{ item.total_likes || 0 }}</span></div>
</div>
</div>
</div>
<div v-else class="empty-state"><el-icon style="font-size:48px;color:#c0c4cc;"><IconDashboard /></el-icon><div class="empty-text">暂无热门选题数据</div></div>
</div>
<div class="card">
<h3 style="font-size: 18px; margin-bottom: 16px;">📉 数据趋势</h3>
<h3 style="font-size: 18px; margin-bottom: 16px;"><el-icon style="vertical-align:-2px;"><IconDashboard /></el-icon> 数据趋势</h3>
<div style="display: flex; gap: 12px; margin-bottom: 16px; flex-wrap: wrap;">
<el-button-group>
<el-button :type="trendDays === 7 ? 'primary' : ''" @click="trendDays = 7; fetchTrend()">7天</el-button>
@@ -89,53 +118,80 @@
<el-button :type="trendDays === 90 ? 'primary' : ''" @click="trendDays = 90; fetchTrend()">90天</el-button>
</el-button-group>
</div>
<div class="chart-container" style="overflow-x: auto;">
<div style="min-width: 600px;">
<div v-for="(item, idx) in trendData" :key="idx" style="display: flex; align-items: center; margin-bottom: 12px; gap: 16px;">
<div style="width: 100px; font-size: 13px; color: #606266;">{{ item.period }}</div>
<div style="flex: 1; background: #f0f9eb; border-radius: 4px; height: 24px; position: relative;">
<div :style="{ width: (item.views / maxViews * 100) + '%', background: '#67c23a', height: '100%', borderRadius: '4px', transition: 'width 0.3s' }"></div>
</div>
<div style="width: 80px; font-size: 13px; text-align: right;">{{ item.views || 0 }} 阅读</div>
<div class="chart-container">
<canvas ref="trendChartCanvas" style="width:100%;height:280px;"></canvas>
</div>
<div v-if="trendData.length === 0" style="text-align: center; color: #909399; padding: 20px;">暂无数据</div>
</div>
<div class="card">
<h3 style="font-size: 18px; margin-bottom: 16px;"><el-icon style="vertical-align:-2px;"><IconStar /></el-icon> 平台对比</h3>
<div v-if="platformData && platformData.length > 0">
<div style="display:flex;flex-wrap:wrap;gap:20px;margin-bottom:16px;">
<div style="flex:1;min-width:250px;height:200px;">
<canvas ref="platformChartCanvas" style="width:100%;height:100%;"></canvas>
</div>
</div>
<div class="metrics-table">
<el-table :data="platformData" stripe size="small">
<el-table-column prop="platform" label="平台" width="120">
<template #default="scope">{{ getPlatformName(scope.row.platform) }}</template>
</el-table-column>
<el-table-column prop="count" label="文章数" width="100"></el-table-column>
<el-table-column prop="total_views" label="总阅读" width="120"></el-table-column>
<el-table-column prop="avg_views" label="平均阅读" width="120">
<template #default="scope">{{ Math.round(scope.row.avg_views || 0) }}</template>
</el-table-column>
<el-table-column prop="total_likes" label="总点赞" width="120"></el-table-column>
<el-table-column prop="avg_likes" label="平均点赞" width="120">
<template #default="scope">{{ Math.round(scope.row.avg_likes || 0) }}</template>
</el-table-column>
</el-table>
</div>
<div class="metrics-card-list">
<div v-for="item in platformData" :key="item.platform" class="metrics-card">
<div class="metrics-card-title">{{ getPlatformName(item.platform) }}</div>
<div class="metrics-card-row"><span class="metrics-card-label">文章数</span><span class="metrics-card-value">{{ item.count }}</span></div>
<div class="metrics-card-row"><span class="metrics-card-label">总阅读</span><span class="metrics-card-value">{{ item.total_views }}</span></div>
<div class="metrics-card-row"><span class="metrics-card-label">平均阅读</span><span class="metrics-card-value">{{ Math.round(item.avg_views || 0) }}</span></div>
<div class="metrics-card-row"><span class="metrics-card-label">总点赞</span><span class="metrics-card-value">{{ item.total_likes }}</span></div>
<div class="metrics-card-row"><span class="metrics-card-label">平均点赞</span><span class="metrics-card-value">{{ Math.round(item.avg_likes || 0) }}</span></div>
</div>
<div v-if="trendData.length === 0" style="text-align: center; color: #909399; padding: 40px;">暂无数据</div>
</div>
</div>
<div v-else class="empty-state"><el-icon style="font-size:48px;color:#c0c4cc;"><IconGlobe /></el-icon><div class="empty-text">暂无平台对比数据</div></div>
</div>
<div class="card">
<h3 style="font-size: 18px; margin-bottom: 16px;">🏆 平台对比</h3>
<el-table :data="platformData" stripe size="small">
<el-table-column prop="platform" label="平台" width="120">
<template #default="scope">{{ getPlatformName(scope.row.platform) }}</template>
</el-table-column>
<el-table-column prop="count" label="文章数" width="100"></el-table-column>
<el-table-column prop="total_views" label="总阅读" width="120"></el-table-column>
<el-table-column prop="avg_views" label="平均阅读" width="120">
<template #default="scope">{{ Math.round(scope.row.avg_views || 0) }}</template>
</el-table-column>
<el-table-column prop="total_likes" label="总点赞" width="120"></el-table-column>
<el-table-column prop="avg_likes" label="平均点赞" width="120">
<template #default="scope">{{ Math.round(scope.row.avg_likes || 0) }}</template>
</el-table-column>
</el-table>
</div>
<div class="card">
<h3 style="font-size: 18px; margin-bottom: 16px;">💡 选题推荐</h3>
<el-table :data="recommendations" stripe size="small">
<el-table-column prop="topic_id" label="ID" width="80"></el-table-column>
<el-table-column prop="title" label="推荐选题"></el-table-column>
<el-table-column prop="field" label="领域" width="120"></el-table-column>
<el-table-column prop="avg_engagement" label="互动率" width="100">
<template #default="scope">{{ scope.row.avg_engagement }}%</template>
</el-table-column>
<el-table-column prop="max_views" label="最高阅读" width="120"></el-table-column>
<el-table-column prop="reason" label="推荐理由"></el-table-column>
</el-table>
<h3 style="font-size: 18px; margin-bottom: 16px;"><el-icon style="vertical-align:-2px;"><IconInfo /></el-icon> 选题推荐</h3>
<div v-if="recommendations && recommendations.length > 0">
<div class="metrics-table">
<el-table :data="recommendations" stripe size="small">
<el-table-column prop="topic_id" label="ID" width="80"></el-table-column>
<el-table-column prop="title" label="推荐选题"></el-table-column>
<el-table-column prop="field" label="领域" width="120"></el-table-column>
<el-table-column prop="avg_engagement" label="互动率" width="100">
<template #default="scope">{{ scope.row.avg_engagement }}%</template>
</el-table-column>
<el-table-column prop="max_views" label="最高阅读" width="120"></el-table-column>
<el-table-column prop="reason" label="推荐理由"></el-table-column>
</el-table>
</div>
<div class="metrics-card-list">
<div v-for="item in recommendations" :key="item.topic_id" class="metrics-card">
<div class="metrics-card-title">{{ item.topic_id }}. {{ item.title }}</div>
<div class="metrics-card-row"><span class="metrics-card-label">领域</span><span class="metrics-card-value">{{ item.field }}</span></div>
<div class="metrics-card-row"><span class="metrics-card-label">互动率</span><span class="metrics-card-value">{{ item.avg_engagement }}%</span></div>
<div class="metrics-card-row"><span class="metrics-card-label">最高阅读</span><span class="metrics-card-value">{{ item.max_views }}</span></div>
<div class="metrics-card-reason">{{ item.reason }}</div>
</div>
</div>
</div>
<div v-else class="empty-state"><el-icon style="font-size:48px;color:#c0c4cc;"><IconInfo /></el-icon><div class="empty-text">暂无选题推荐</div></div>
</div>
</main>
</div>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const MetricsApp = {
@@ -144,15 +200,117 @@ const MetricsApp = {
currentUser: { username: '' },
isAdmin: false,
isLoggedIn: false,
loadingDashboard: false,
dashboard: { total_topics: 0, topics_by_status: {}, total_published: 0, total_views: 0, total_likes: 0, avg_engagement_rate: 0, top_topics: [], recent_metrics: [] },
trendDays: 30,
trendData: [],
maxViews: 1,
platformData: [],
recommendations: []
recommendations: [],
chartInstances: {}
}
},
methods: {
statusColors(status) {
return { pending: '#909399', review: '#409eff', ready: '#e6a23c', published: '#67c23a' }[status] || '#909399';
},
destroyCharts() {
Object.values(this.chartInstances).forEach(c => { if (c) c.destroy(); });
this.chartInstances = {};
},
renderCharts() {
this.$nextTick(() => {
this.renderTrendChart();
this.renderStatusChart();
this.renderPlatformChart();
});
},
renderTrendChart() {
const canvas = this.$refs.trendChartCanvas;
if (!canvas) return;
if (this.chartInstances.trend) this.chartInstances.trend.destroy();
const data = this.trendData || [];
if (data.length === 0) return;
const ctx = canvas.getContext('2d');
this.chartInstances.trend = new Chart(ctx, {
type: 'line',
data: {
labels: data.map(d => d.period),
datasets: [{
label: '阅读量',
data: data.map(d => d.views || 0),
borderColor: '#409eff',
backgroundColor: 'rgba(64,158,255,0.1)',
fill: true,
tension: 0.4,
pointRadius: 4,
pointHoverRadius: 6,
}, {
label: '点赞',
data: data.map(d => d.likes || 0),
borderColor: '#67c23a',
backgroundColor: 'rgba(103,194,58,0.1)',
fill: true,
tension: 0.4,
pointRadius: 4,
pointHoverRadius: 6,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'top' } },
scales: { y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' } }, x: { grid: { display: false } } }
}
});
},
renderPlatformChart() {
const canvas = this.$refs.platformChartCanvas;
if (!canvas) return;
if (this.chartInstances.platform) this.chartInstances.platform.destroy();
const data = this.platformData || [];
if (data.length === 0) return;
const ctx = canvas.getContext('2d');
const names = data.map(d => this.getPlatformName(d.platform));
this.chartInstances.platform = new Chart(ctx, {
type: 'bar',
data: {
labels: names,
datasets: [
{ label: '总阅读', data: data.map(d => d.total_views || 0), backgroundColor: 'rgba(64,158,255,0.7)', borderRadius: 4 },
{ label: '平均阅读', data: data.map(d => Math.round(d.avg_views || 0)), backgroundColor: 'rgba(103,194,58,0.7)', borderRadius: 4 },
]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { position: 'top' } },
scales: { y: { beginAtZero: true, grid: { color: 'rgba(0,0,0,0.05)' } }, x: { grid: { display: false } } }
}
});
},
renderStatusChart() {
const canvas = this.$refs.statusChartCanvas;
if (!canvas) return;
if (this.chartInstances.status) this.chartInstances.status.destroy();
const byStatus = this.dashboard.topics_by_status || {};
const labels = [], data = [], colors = [];
for (const [status, count] of Object.entries(byStatus)) {
if (count > 0) { labels.push(this.getStatusLabel(status)); data.push(count); colors.push(this.statusColors(status)); }
}
if (data.length === 0) return;
const ctx = canvas.getContext('2d');
this.chartInstances.status = new Chart(ctx, {
type: 'doughnut',
data: { labels, datasets: [{ data, backgroundColor: colors, borderWidth: 2, borderColor: '#fff' }] },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: { legend: { display: false } },
cutout: '60%'
}
});
},
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
checkAuth() {
@@ -180,11 +338,14 @@ const MetricsApp = {
return map[platform] || platform;
},
async fetchDashboard() {
this.loadingDashboard = true;
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/dashboard?days=' + this.trendDays, { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.dashboard = await res.json();
} catch (e) { console.error(e); }
else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
} catch (e) { console.error(e); this.$message.error('加载概览失败: ' + e.message); }
finally { this.loadingDashboard = false; this.renderCharts(); }
},
async fetchTrend() {
try {
@@ -192,33 +353,38 @@ const MetricsApp = {
const res = await fetch('/api/metrics/trend?days=' + this.trendDays, { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) {
this.trendData = await res.json();
this.maxViews = Math.max(...this.trendData.map(t => t.views || 0), 1);
}
} catch (e) { console.error(e); }
} else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
} catch (e) { console.error(e); this.$message.error('加载趋势失败: ' + e.message); }
this.renderCharts();
},
async fetchPlatformData() {
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/by-platform', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.platformData = await res.json();
} catch (e) { console.error(e); }
else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
} catch (e) { console.error(e); this.$message.error('加载平台数据失败: ' + e.message); }
},
async fetchRecommendations() {
try {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/metrics/recommend-topics?limit=10', { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.recommendations = await res.json();
} catch (e) { console.error(e); }
else { const d = await res.json().catch(() => ({})); throw new Error(d.detail || '加载失败'); }
} catch (e) { console.error(e); this.$message.error('加载推荐失败: ' + e.message); }
}
},
mounted() {
this.checkAuth();
},
beforeUnmount() {
this.destroyCharts();
}
};
const app = Vue.createApp(MetricsApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
-79
View File
@@ -1,79 +0,0 @@
// 公共页眉组件 - Vue 3
(function() {
console.log('[Navbar] 脚本加载');
function injectStyles() {
if (document.getElementById('navbar-styles')) return;
const styles = `
.navbar-component { position: fixed; top: 0; left: 0; right: 0; height: 60px; background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; display: flex; align-items: center; justify-content: space-between; padding: 0 24px; box-shadow: 0 4px 20px rgba(37, 99, 235, 0.3); z-index: 10000; }
.navbar-component .navbar-title { font-size: 20px; font-weight: 700; margin: 0; letter-spacing: -0.3px; }
.navbar-component .navbar-user { display: flex; align-items: center; gap: 12px; }
.navbar-component .user-info { display: flex; align-items: center; gap: 8px; }
.navbar-component .avatar { width: 34px; height: 34px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 600; box-shadow: 0 2px 8px rgba(0,0,0,0.15); }
.navbar-component .logout-btn { background: rgba(255,255,255,0.15); border: 1px solid rgba(255,255,255,0.2); color: white; padding: 6px 14px; border-radius: 8px; cursor: pointer; margin-left: 12px; font-size: 13px; transition: all 0.2s; }
.navbar-component .logout-btn:hover { background: rgba(255,255,255,0.25); border-color: rgba(255,255,255,0.3); transform: translateY(-1px); }
.navbar-component .admin-badge { margin-left: 8px; font-size: 11px; background: rgba(255,255,255,0.2); padding: 2px 10px; border-radius: 12px; font-weight: 500; letter-spacing: 0.3px; }
@media (min-width: 769px) {
.navbar-component { padding: 0 24px 0 180px; }
}
@media (max-width: 768px) {
.navbar-component { padding: 0 16px; }
.navbar-component .navbar-title { font-size: 16px; }
}
`;
const styleEl = document.createElement('style');
styleEl.id = 'navbar-styles';
styleEl.textContent = styles;
document.head.appendChild(styleEl);
console.log('[Navbar] 样式已注入');
}
const installNavbar = (app) => {
console.log('[Navbar] installNavbar called');
injectStyles();
const NavbarComponent = {
name: 'NavbarComponent',
props: {
title: { type: String, default: '宇之然内容创作平台' },
username: { type: String, default: '' },
isAdmin: { type: Boolean, default: false },
onLogout: { type: Function, default: null }
},
template: `
<nav class="navbar-component">
<div class="navbar-content" style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<h1 class="navbar-title">{{ title }}</h1>
<div class="navbar-user">
<div class="user-info">
<div class="avatar">{{ username ? username.charAt(0).toUpperCase() : '?' }}</div>
<span v-if="username">{{ username }}</span>
</div>
<span v-if="isAdmin" class="admin-badge">管理员</span>
<button class="logout-btn" @click="handleLogout">退出</button>
</div>
</div>
</nav>
`,
methods: {
handleLogout() {
if (this.onLogout) {
this.onLogout();
} else {
localStorage.removeItem('authToken');
localStorage.removeItem('userRole');
localStorage.removeItem('currentUser');
window.location.href = '/login.html';
}
}
}
};
app.component('navbar-component', NavbarComponent);
console.log('[Navbar] 组件已注册');
return app;
};
window.installNavbar = installNavbar;
console.log('[Navbar] 脚本已加载');
})();
-383
View File
@@ -1,383 +0,0 @@
// 纯DOM导航 - 通过 installNavigation 直接插入
(function() {
console.log('[Nav] 脚本加载');
function injectStyles() {
if (document.getElementById('navigation-styles')) return;
const styles = `
.nav-wrapper, .navigation-wrapper { position: fixed; top: 60px; left: 0; bottom: 0; width: 180px; z-index: 9999; pointer-events: none; }
.nav-wrapper .sidebar, .navigation-wrapper .sidebar { position: fixed; top: 60px; left: 0; bottom: 0; width: 180px; background: #fff; padding: 12px 8px; box-shadow: 2px 0 12px rgba(0,0,0,0.06); overflow-y: auto; z-index: 10000; border-right: 1px solid #ebeef5; pointer-events: auto; }
.nav-wrapper .sidebar-header, .navigation-wrapper .sidebar-header { padding: 12px 12px 16px; border-bottom: 1px solid #f0f2f5; margin-bottom: 12px; }
.nav-wrapper .sidebar-header h3, .navigation-wrapper .sidebar-header h3 { margin: 0; font-size: 15px; font-weight: 700; background: linear-gradient(135deg, #2563eb, #7c3aed); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; letter-spacing: -0.3px; }
.nav-wrapper .sidebar-btn, .navigation-wrapper .sidebar-btn { width: 100%; text-align: left; padding: 10px 12px; border: none; background: transparent; border-radius: 10px; margin-bottom: 4px; cursor: pointer; transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1); color: #606266; font-size: 13px; display: flex; align-items: center; gap: 8px; position: relative; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nav-wrapper .sidebar-btn:hover, .navigation-wrapper .sidebar-btn:hover { background: #f0f4ff; color: #2563eb; transform: translateX(2px); }
.nav-wrapper .sidebar-btn.active, .navigation-wrapper .sidebar-btn.active { background: linear-gradient(135deg, #eef2ff, #e0e7ff); color: #2563eb; font-weight: 600 !important; box-shadow: inset 3px 0 0 #2563eb; }
.nav-wrapper .mobile-nav, .navigation-wrapper .mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: linear-gradient(135deg, #2563eb, #1d4ed8); box-shadow: 0 -4px 20px rgba(37, 99, 235, 0.25); padding: 4px 0; z-index: 99999; justify-content: space-around; pointer-events: auto; }
.nav-wrapper .mobile-nav-btn, .navigation-wrapper .mobile-nav-btn { flex: 1; border: none; background: transparent; padding: 4px 2px; text-align: center; font-size: 10px; color: rgba(255,255,255,0.8) !important; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 0; transition: all 0.2s; border-radius: 8px; margin: 0 1px; min-width: 0; }
.nav-wrapper .mobile-nav-btn .nav-icon, .navigation-wrapper .mobile-nav-btn .nav-icon { font-size: 16px; line-height: 1.2; }
.nav-wrapper .mobile-nav-btn .nav-text, .navigation-wrapper .mobile-nav-btn .nav-text { font-size: 9px; margin-top: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.nav-wrapper .mobile-nav-btn:hover, .navigation-wrapper .mobile-nav-btn:hover { background: rgba(255,255,255,0.15); color: white !important; }
.nav-wrapper .mobile-nav-btn.active, .navigation-wrapper .mobile-nav-btn.active { background: rgba(255,255,255,0.2); color: white !important; font-weight: 600 !important; }
.mobile-more-backdrop { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.3); z-index: 100000; animation: fadeIn 0.2s ease-out; }
.mobile-more-sheet { position: fixed; bottom: 0; left: 0; right: 0; background: white; border-radius: 16px 16px 0 0; box-shadow: 0 -8px 32px rgba(0,0,0,0.15); z-index: 100001; padding: 20px 16px calc(env(safe-area-inset-bottom) + 60px); animation: slideUp 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); max-height: 70vh; overflow-y: auto; pointer-events: auto; }
.mobile-more-sheet .sheet-handle { width: 36px; height: 4px; background: #e0e0e0; border-radius: 2px; margin: 0 auto 16px; }
.mobile-more-sheet .sheet-title { font-size: 16px; font-weight: 700; color: #303133; margin-bottom: 16px; text-align: center; }
.mobile-more-sheet .sheet-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
.mobile-more-sheet .sheet-item { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px 4px; border: none; background: transparent; border-radius: 12px; cursor: pointer; transition: all 0.2s; color: #606266; font-size: 12px; white-space: nowrap; }
.mobile-more-sheet .sheet-item:hover { background: #f0f4ff; color: #2563eb; }
.mobile-more-sheet .sheet-item.active { background: #eef2ff; color: #2563eb; font-weight: 600; }
.mobile-more-sheet .sheet-item .item-icon { font-size: 24px; line-height: 1; }
@keyframes slideUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@media (max-width: 768px) { .nav-wrapper .sidebar, .navigation-wrapper .sidebar { display: none !important; } .nav-wrapper .mobile-nav, .navigation-wrapper .mobile-nav { display: flex !important; } body > #app > .main-content { padding-bottom: 56px !important; } }
body > #app > .main-content { margin-left: 180px !important; padding-top: 60px !important; }
@media (max-width: 768px) { body > #app > .main-content { margin-left: 0 !important; padding-bottom: 60px !important; } }
`;
document.head.appendChild(Object.assign(document.createElement('style'), { id: 'navigation-styles', textContent: styles }));
console.log('[Nav] 样式已注入');
}
function createNavigation(currentPage, isAdmin, onNavigate) {
const wrapper = document.createElement('div');
wrapper.className = 'navigation-wrapper';
// 侧边栏
const sidebar = document.createElement('aside');
sidebar.className = 'sidebar';
sidebar.innerHTML = `
<div class="sidebar-header"><h3>宇之然平台</h3></div>
<nav class="sidebar-nav">
<button class="sidebar-btn ${currentPage==='dashboard'?'active':''}" data-page="/">📊 系统概览</button>
<button class="sidebar-btn ${currentPage==='topics'?'active':''}" data-page="topics.html">📋 选题管理</button>
<button class="sidebar-btn ${currentPage==='metrics'?'active':''}" data-page="metrics.html">📊 数据分析</button>
<button class="sidebar-btn ${currentPage==='calendar'?'active':''}" data-page="calendar.html">📅 内容日历</button>
<button class="sidebar-btn ${currentPage==='assets'?'active':''}" data-page="assets.html">🖼️ 素材库</button>
<button class="sidebar-btn ${currentPage==='tasks'?'active':''}" data-page="tasks.html">🚀 创作任务</button>
<button class="sidebar-btn ${currentPage==='platforms'?'active':''}" data-page="platforms.html">🌐 平台配置</button>
<button class="sidebar-btn ${currentPage==='logs'?'active':''}" data-page="logs.html">📄 系统日志</button>
${isAdmin ? `<button class="sidebar-btn ${currentPage==='users'?'active':''}" data-page="users.html">👥 用户管理</button>` : ''}
${isAdmin ? `<button class="sidebar-btn ${currentPage==='admin'?'active':''}" data-page="admin.html">⚙️ 系统管理</button>` : ''}
</nav>
`;
// 移动端底部导航
const mobileNav = document.createElement('nav');
mobileNav.className = 'mobile-nav';
mobileNav.innerHTML = `
<button class="mobile-nav-btn ${currentPage==='dashboard'?'active':''}" data-page="/"><span class="nav-icon">📊</span><span class="nav-text">首页</span></button>
<button class="mobile-nav-btn ${currentPage==='topics'?'active':''}" data-page="topics.html"><span class="nav-icon">📋</span><span class="nav-text">选题</span></button>
<button class="mobile-nav-btn ${currentPage==='tasks'?'active':''}" data-page="tasks.html"><span class="nav-icon">🚀</span><span class="nav-text">任务</span></button>
<button class="mobile-nav-btn ${currentPage==='calendar'?'active':''}" data-page="calendar.html"><span class="nav-icon">📅</span><span class="nav-text">日历</span></button>
<button class="mobile-nav-btn ${currentPage==='assets'?'active':''}" data-page="assets.html"><span class="nav-icon">🖼️</span><span class="nav-text">素材</span></button>
<button class="mobile-nav-btn" id="mobile-more-btn"><span class="nav-icon">⬆</span><span class="nav-text">更多</span></button>
`;
// "更多"弹出菜单
const sheetItems = [
{ key: 'metrics', label: '数据分析', icon: '📊', page: 'metrics.html' },
{ key: 'platforms', label: '平台配置', icon: '🌐', page: 'platforms.html' },
{ key: 'logs', label: '系统日志', icon: '📄', page: 'logs.html' },
{ key: 'users', label: '用户管理', icon: '👥', page: 'users.html', admin: true },
{ key: 'admin', label: '系统管理', icon: '⚙️', page: 'admin.html', admin: true },
];
const sheetItemsHtml = sheetItems.map(item =>
`<button class="sheet-item ${currentPage===item.key?'active':''}" data-page="${item.page}"${item.admin ? ' data-admin="1"' : ''} style="${item.admin && !isAdmin ? 'display:none' : ''}">
<span class="item-icon">${item.icon}</span><span>${item.label}</span>
</button>`
).join('');
const backdrop = document.createElement('div');
backdrop.className = 'mobile-more-backdrop';
backdrop.id = 'mobile-more-backdrop';
backdrop.style.display = 'none';
const sheet = document.createElement('div');
sheet.className = 'mobile-more-sheet';
sheet.id = 'mobile-more-sheet';
sheet.style.display = 'none';
sheet.innerHTML = `<div class="sheet-handle"></div><div class="sheet-title">所有菜单</div><div class="sheet-grid">${sheetItemsHtml}</div>`;
const showMore = () => {
backdrop.style.display = ''; sheet.style.display = '';
};
const hideMore = () => {
backdrop.style.display = 'none'; sheet.style.display = 'none';
};
// 绑定导航跳转
const handleNavClick = (btn) => {
const page = btn.getAttribute('data-page');
if (!page) return;
console.log('[Nav] 点击导航:', page);
hideMore();
if (onNavigate) { onNavigate(page); return; }
const target = page === '/' ? '/' : (page.startsWith('/') ? page : '/' + page);
window.location.href = target;
};
wrapper.appendChild(sidebar);
wrapper.appendChild(mobileNav);
wrapper.appendChild(backdrop);
wrapper.appendChild(sheet);
// 事件绑定
wrapper.querySelectorAll('.sidebar-btn').forEach(btn => {
btn.addEventListener('click', (e) => { e.preventDefault(); handleNavClick(btn); });
});
wrapper.querySelectorAll('.mobile-nav-btn').forEach(btn => {
if (btn.id === 'mobile-more-btn') {
btn.addEventListener('click', (e) => { e.preventDefault(); showMore(); });
} else {
btn.addEventListener('click', (e) => { e.preventDefault(); handleNavClick(btn); });
}
});
wrapper.querySelectorAll('.sheet-item').forEach(btn => {
btn.addEventListener('click', (e) => { e.preventDefault(); handleNavClick(btn); });
});
backdrop.addEventListener('click', hideMore);
return wrapper;
}
// 新的 installNavigation 接口:接收 app 对象(为了兼容),但实际不注册组件
window.installNavigation = function(app, options = {}) {
console.log('[Nav] installNavigation called (DOM mode)');
injectStyles();
// 延迟执行,确保DOM就绪
const init = () => {
const container = document.getElementById('app');
if (!container) {
console.warn('[Nav] #app 未找到,等待');
setTimeout(init, 100);
return;
}
// 获取当前页面名称(从URL或body类名推断)
let currentPage = 'dashboard';
const path = window.location.pathname;
if (path.includes('topics')) currentPage = 'topics';
else if (path.includes('calendar')) currentPage = 'calendar';
else if (path.includes('metrics')) currentPage = 'metrics';
else if (path.includes('assets')) currentPage = 'assets';
else if (path.includes('tasks')) currentPage = 'tasks';
else if (path.includes('platforms')) currentPage = 'platforms';
else if (path.includes('logs')) currentPage = 'logs';
else if (path.includes('users')) currentPage = 'users';
else if (path.includes('admin')) currentPage = 'admin';
// 尝试从 Vue 实例获取 isAdmin
let isAdmin = false;
let redirectToPage = null;
// 首先检查 localStorage,如果用户已登录且是管理员
const userRole = localStorage.getItem('userRole');
if (userRole === 'admin') {
isAdmin = true;
console.log('[Nav] 从 localStorage 获取到 admin 角色');
}
// 尝试从 Vue 实例提取数据
const getVueData = () => {
if (app && app._instance && app._instance.proxy) {
return app._instance.proxy;
} else if (window.Vue && window.Vue.app && window.Vue.app._instance && window.Vue.app._instance.proxy) {
return window.Vue.app._instance.proxy;
}
return null;
};
const updateNavFromVue = () => {
const proxy = getVueData();
if (!proxy) return false;
const newIsAdmin = proxy.isAdmin === true;
if (proxy.isAdmin !== undefined) isAdmin = proxy.isAdmin;
if (proxy.redirectToPage) redirectToPage = proxy.redirectToPage;
// 如果侧边栏已存在,更新管理菜单显示状态
const sidebar = document.querySelector('.sidebar-nav');
if (sidebar) {
const adminBtns = sidebar.querySelectorAll('.sidebar-btn[data-page="users.html"], .sidebar-btn[data-page="admin.html"]');
adminBtns.forEach(btn => {
btn.style.display = isAdmin ? '' : 'none';
});
}
return true;
};
// 延迟获取 isAdmin,确保 Vue mounted 已执行
setTimeout(() => {
updateNavFromVue();
console.log('[Nav] 延迟获取后 isAdmin:', isAdmin);
// 如果侧边栏已存在,再次更新管理菜单显示状态
const sidebarEl = container.querySelector('.sidebar-nav');
if (sidebarEl) {
const adminBtns = sidebarEl.querySelectorAll('.sidebar-btn[data-page="users.html"], .sidebar-btn[data-page="admin.html"]');
adminBtns.forEach(btn => {
btn.style.display = isAdmin ? '' : 'none';
});
}
}, 500);
// 持续监听 Vue 数据变化
const stopWatch = setInterval(() => {
const updated = updateNavFromVue();
if (updated && isAdmin) {
clearInterval(stopWatch);
console.log('[Nav] 已获取到 isAdmin:', isAdmin);
}
}, 200);
// 5秒后停止监听
setTimeout(() => clearInterval(stopWatch), 5000);
console.log('[Nav] currentPage:', currentPage, '初始 isAdmin:', isAdmin);
// 默认跳转函数
const defaultNavigate = (page) => {
const target = page === '/' ? '/index.html' : (page.startsWith('/') ? page : '/' + page);
window.location.href = target;
};
// 移除旧的导航容器(如果有)
const oldNav = container.querySelector('.navigation-wrapper');
if (oldNav) oldNav.remove();
const nav = createNavigation(currentPage, isAdmin, redirectToPage || defaultNavigate);
container.insertBefore(nav, container.firstChild);
console.log('[Nav] 导航已插入');
};
// 延迟执行,确保 Vue 实例挂载完成
setTimeout(init, 100);
return app;
};
console.log('[Nav] 脚本已加载(纯DOM版)');
// 注册 Vue 组件 (备用方案)
const createNavComponent = () => ({
props: {
currentPage: { type: String, default: 'dashboard' },
isAdmin: { type: Boolean, default: false },
onNavigate: { type: Function, default: null }
},
data() {
return {
menuItems: [
{ key: 'dashboard', label: '系统概览', icon: '📊', page: '/' },
{ key: 'topics', label: '选题管理', icon: '📋', page: 'topics.html' },
{ key: 'metrics', label: '数据分析', icon: '📊', page: 'metrics.html' },
{ key: 'calendar', label: '内容日历', icon: '📅', page: 'calendar.html' },
{ key: 'assets', label: '素材库', icon: '🖼️', page: 'assets.html' },
{ key: 'tasks', label: '创作任务', icon: '🚀', page: 'tasks.html' },
{ key: 'platforms', label: '平台配置', icon: '🌐', page: 'platforms.html' },
{ key: 'logs', label: '系统日志', icon: '📄', page: 'logs.html' }
],
adminItems: [
{ key: 'users', label: '用户管理', icon: '👥', page: 'users.html' },
{ key: 'admin', label: '系统管理', icon: '⚙️', page: 'admin.html' }
],
mobileItems: [
{ key: 'dashboard', label: '首页', icon: '📊', page: '/' },
{ key: 'topics', label: '选题', icon: '📋', page: 'topics.html' },
{ key: 'tasks', label: '任务', icon: '🚀', page: 'tasks.html' },
{ key: 'calendar', label: '日历', icon: '📅', page: 'calendar.html' },
{ key: 'assets', label: '素材', icon: '🖼️', page: 'assets.html' }
],
sheetItems: [
{ key: 'metrics', label: '数据分析', icon: '📊', page: 'metrics.html' },
{ key: 'platforms', label: '平台配置', icon: '🌐', page: 'platforms.html' },
{ key: 'logs', label: '系统日志', icon: '📄', page: 'logs.html' },
{ key: 'users', label: '用户管理', icon: '👥', page: 'users.html', admin: true },
{ key: 'admin', label: '系统管理', icon: '⚙️', page: 'admin.html', admin: true }
],
showMoreSheet: false
};
},
template: `
<div class="nav-wrapper">
<aside class="sidebar">
<div class="sidebar-header"><h3>宇之然平台</h3></div>
<nav class="sidebar-nav">
<button v-for="item in menuItems" :key="item.key" :class="['sidebar-btn', { active: currentPage === item.key }]" @click="navigate(item.page)">
{{ item.icon }} {{ item.label }}
</button>
<template v-if="isAdmin">
<button v-for="item in adminItems" :key="item.key" :class="['sidebar-btn', { active: currentPage === item.key }]" @click="navigate(item.page)">
{{ item.icon }} {{ item.label }}
</button>
</template>
</nav>
</aside>
<nav class="mobile-nav">
<button v-for="item in mobileItems" :key="item.key" :class="['mobile-nav-btn', { active: currentPage === item.key }]" @click="navigate(item.page)">
<span class="nav-icon">{{ item.icon }}</span>
<span class="nav-text">{{ item.label }}</span>
</button>
<button class="mobile-nav-btn" @click="showMoreSheet = !showMoreSheet"><span class="nav-icon">⬆</span><span class="nav-text">更多</span></button>
</nav>
<div v-if="showMoreSheet" class="mobile-more-backdrop" @click="showMoreSheet = false"></div>
<div v-if="showMoreSheet" class="mobile-more-sheet">
<div class="sheet-handle"></div>
<div class="sheet-title">所有菜单</div>
<div class="sheet-grid">
<button v-for="item in sheetItems" :key="item.key" v-show="!item.admin || isAdmin" :class="['sheet-item', { active: currentPage === item.key }]" @click="navigate(item.page)">
<span class="item-icon">{{ item.icon }}</span>
<span>{{ item.label }}</span>
</button>
</div>
</div>
</div>
`,
methods: {
navigate(page) {
console.log('[Nav Vue] 点击导航:', page);
if (this.onNavigate) {
this.onNavigate(page);
} else {
// 修复路径处理
let target;
if (page === '/' || page === '') {
target = '/';
} else if (page.startsWith('/')) {
target = page;
} else {
target = '/' + page;
}
window.location.href = target;
}
}
},
mounted() {
injectStyles();
}
});
// 注册全局组件
window.NavigationComponent = createNavComponent();
// 自动注入 DOM 导航 (优先使用)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
setTimeout(() => {
if (window.Vue && window.Vue.app) {
window.Vue.app._instance.proxy.$nextTick(() => {
const app = window.Vue.app;
if (app && app._instance && app._instance.proxy) {
const proxy = app._instance.proxy;
const nav = createNavigation(proxy.currentPage || 'dashboard', proxy.isAdmin || false, proxy.redirectToPage || null);
const container = document.getElementById('app');
if (container && !container.querySelector('.navigation-wrapper')) {
container.insertBefore(nav, container.firstChild);
}
}
});
}
}, 500);
});
}
})();
+20 -38
View File
@@ -7,19 +7,6 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.platform-card { border: 1px solid #ebeef5; border-radius: 12px; padding: 20px; margin-bottom: 16px; transition: all 0.3s ease; word-break: break-word; background: #fff; }
.platform-card:hover { border-color: #409eff; box-shadow: 0 4px 20px rgba(64,158,255,0.12); transform: translateY(-1px); }
.platform-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 16px; gap: 12px; }
@@ -39,51 +26,44 @@
.rule-item strong { color: #303133; }
.rule-item:last-child { margin-bottom: 0; }
.empty-state { text-align: center; padding: 60px 20px; color: #909399; }
.empty-state-icon { font-size: 56px; margin-bottom: 16px; }
.empty-state-text { font-size: 16px; }
.loading-state { text-align: center; padding: 60px 20px; color: #909399; font-size: 16px; }
@media (max-width: 768px) {
.content-area { padding: 16px; padding-bottom: 80px; }
.card { padding: 16px; }
.toolbar { width: 100%; }
.toolbar .el-button { flex: 1; justify-content: center; }
.platform-card { padding: 16px; }
.platform-header { flex-direction: column; }
.platform-actions { align-self: flex-end; }
.platform-actions { align-self: flex-end; width: 100%; display: flex; justify-content: flex-end; }
.platform-meta { grid-template-columns: 1fr; }
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="平台配置" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="platforms" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="平台配置" :username="currentUser.username" :is-admin="isAdmin" current-page="platforms" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="page-header">
<h2 class="page-title">🌐 平台配置</h2>
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconGlobe /></el-icon> 平台配置</h2>
<div class="toolbar">
<el-button-group>
<el-button :type="showActiveOnly ? 'primary' : ''" @click="showActiveOnly = true; loadPlatforms()">启用中</el-button>
<el-button :type="!showActiveOnly ? 'primary' : ''" @click="showActiveOnly = false; loadPlatforms()">全部</el-button>
</el-button-group>
<el-button @click="loadPlatforms">🔄 刷新</el-button>
<el-button @click="loadPlatforms"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
</div>
</div>
<div class="card page-fade">
<div v-if="loading" class="loading-state">加载中...</div>
<div v-else-if="platforms.length === 0" class="empty-state">
<div class="empty-state-icon">🌐</div>
<div class="empty-state-text">暂无平台配置</div>
<el-icon style="font-size:48px;color:#c0c4cc;"><IconGlobe /></el-icon>
<div class="empty-text">暂无平台配置</div>
</div>
<div v-else>
<div v-for="p in platforms" :key="p.platform" class="platform-card">
<div class="platform-header">
<div class="platform-name">
<span>{{ getPlatformIcon(p.platform) }}</span>
<el-icon><component :is="getPlatformIcon(p.platform)" /></el-icon>
{{ getPlatformName(p.platform) }}
</div>
<div class="platform-actions">
@@ -108,13 +88,13 @@
</div>
</div>
<div v-if="p.format_rules && Object.keys(p.format_rules).length > 0" class="rules-section">
<div class="rules-title">📋 格式规则</div>
<div class="rules-title"><el-icon style="vertical-align:-2px;"><IconTopic /></el-icon> 格式规则</div>
<div v-for="(rule, key) in p.format_rules" :key="key" class="rule-item">
<strong>{{ key }}:</strong> {{ typeof rule === 'object' ? JSON.stringify(rule) : rule }}
</div>
</div>
<div v-if="p.compliance_rules && p.compliance_rules.length > 0" class="rules-section">
<div class="rules-title">⚖️ 合规规则</div>
<div class="rules-title"><el-icon style="vertical-align:-2px;"><IconWarning /></el-icon> 合规规则</div>
<div v-for="(rule, idx) in p.compliance_rules" :key="idx" class="rule-item">{{ rule }}</div>
</div>
</div>
@@ -152,6 +132,7 @@
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const PlatformsApp = {
@@ -186,8 +167,8 @@ const PlatformsApp = {
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
},
getPlatformIcon(platform) {
const map = { 'zhihu': '💬', 'wechat': '💌', 'xiaohongshu': '📕', 'weibo': '🌐' };
return map[platform] || '🌐';
const map = { 'zhihu': 'IconTopic', 'wechat': 'IconDocument', 'xiaohongshu': 'IconPicture', 'weibo': 'IconGlobe' };
return map[platform] || 'IconGlobe';
},
getPlatformName(platform) {
const map = { 'zhihu': '知乎', 'wechat': '微信公众号', 'xiaohongshu': '小红书', 'weibo': '微博' };
@@ -199,7 +180,8 @@ const PlatformsApp = {
const token = localStorage.getItem('authToken');
const res = await fetch('/api/platform-config?active_only=' + this.showActiveOnly, { headers: { 'Authorization': 'Bearer ' + token } });
if (res.ok) this.platforms = await res.json();
} catch (e) { console.error(e); }
else { const d = await res.json(); throw new Error(d.detail || '加载失败'); }
} catch (e) { console.error(e); this.$message.error('加载平台配置失败: ' + e.message); }
finally { this.loading = false; }
},
editPlatform(p) {
@@ -234,8 +216,8 @@ const PlatformsApp = {
};
const app = Vue.createApp(PlatformsApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
+20 -32
View File
@@ -7,17 +7,7 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
.toolbar { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
.toolbar { gap: 12px; }
.task-card { border: 1px solid #ebeef5; border-radius: 12px; padding: 16px; margin-bottom: 12px; transition: all 0.3s; background: #fff; }
.task-card:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.12); transform: translateY(-1px); }
@@ -56,38 +46,35 @@
.detail-value { flex: 1; font-size: 13px; color: #303133; word-break: break-all; }
@media (max-width: 768px) {
.content-area { padding: 16px; padding-bottom: 80px; }
.card { padding: 16px; }
.task-table { display: none; }
.task-card-list-mobile { display: block; }
.schedule-row { padding: 10px 12px; gap: 10px; flex-wrap: wrap; }
.schedule-next { width: 100%; margin-left: 46px; }
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="创作任务" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="tasks" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="创作任务" :username="currentUser.username" :is-admin="isAdmin" current-page="tasks" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title">🚀 定时任务</h2>
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 定时任务</h2>
<div class="toolbar">
<el-button @click="loadSchedulerStatus">🔄 刷新</el-button>
<el-button @click="loadSchedulerStatus"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
</div>
</div>
<div v-if="schedulerLoading" style="text-align: center; padding: 20px; color: #909399;">加载中...</div>
<div v-else>
<div v-if="schedulerJobs.length === 0" style="text-align: center; padding: 30px 20px; color: #909399; font-size: 14px;">暂无定时任务</div>
<div v-for="job in schedulerJobs" :key="job.id" class="schedule-row">
<div class="schedule-icon">{{ job.icon }}</div>
<div class="schedule-icon"><el-icon><component :is="job.icon" /></el-icon></div>
<div class="schedule-info">
<div class="schedule-name">{{ job.name }}</div>
<div class="schedule-time"> 每日 {{ job.time }}</div>
<div class="schedule-time"><el-icon style="vertical-align:-2px;"><IconClock /></el-icon> 每日 {{ job.time }}</div>
</div>
<div class="schedule-next">
<span class="schedule-badge" :class="job.active ? 'active' : 'inactive'"></span>
@@ -100,7 +87,7 @@
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title">🚀 创作任务</h2>
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 创作任务</h2>
<div class="toolbar">
<el-button-group>
<el-button :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''; loadTasks()">全部</el-button>
@@ -109,12 +96,12 @@
<el-button :type="filterStatus === 'completed' ? 'primary' : ''" @click="filterStatus = 'completed'; loadTasks()">已完成</el-button>
<el-button :type="filterStatus === 'failed' ? 'primary' : ''" @click="filterStatus = 'failed'; loadTasks()">失败</el-button>
</el-button-group>
<el-button @click="loadTasks">🔄 刷新</el-button>
<el-button @click="loadTasks"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 刷新</el-button>
</div>
</div>
<div v-if="loading" style="text-align: center; padding: 40px; color: #909399;">加载中...</div>
<div v-else-if="tasks.length === 0" style="text-align: center; padding: 60px 20px; color: #909399;">
<div style="font-size: 56px; margin-bottom: 16px;">📋</div>
<el-icon style="font-size:56px;margin-bottom:16px;color:#c0c4cc;"><IconTopic /></el-icon>
<div style="font-size: 16px;">暂无任务</div>
<div style="margin-top: 12px; font-size: 14px;">前往 <a href="/topics.html" style="color: #409eff;">选题管理</a> 创建新任务</div>
</div>
@@ -173,14 +160,15 @@
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const TasksApp = {
data() {
const SCHEDULER_JOBS = {
'scheduled_sync': { icon: '🔄', name: '数据同步', defaultTime: '02:30' },
'scheduled_generate': { icon: '🤖', name: '内容创作', defaultTime: '03:30' },
'scheduled_optimize': { icon: '🔍', name: '合规审查', defaultTime: '04:30' },
'scheduled_sync': { icon: 'IconRefresh', name: '数据同步', defaultTime: '02:30' },
'scheduled_generate': { icon: 'IconDocument', name: '内容创作', defaultTime: '03:30' },
'scheduled_optimize': { icon: 'IconSearch', name: '合规审查', defaultTime: '04:30' },
};
return {
currentUser: { username: '' }, isAdmin: false, isLoggedIn: false,
@@ -215,7 +203,7 @@ const TasksApp = {
const data = await this.api('/api/system/scheduler/status');
if (!data) return;
this.schedulerJobs = (data.jobs || []).map(job => {
const info = this.SCHEDULER_JOBS[job.id] || { icon: '', name: job.id, defaultTime: '' };
const info = this.SCHEDULER_JOBS[job.id] || { icon: 'IconClock', name: job.id, defaultTime: '' };
// Parse cron trigger for time display
let time = info.defaultTime;
const m = job.trigger && job.trigger.match(/hour='?(\d+)'?,\s*minute='?(\d+)'?/);
@@ -227,7 +215,7 @@ const TasksApp = {
}
return { id: job.id, icon: info.icon, name: info.name, time, active: data.running, next_run };
});
} catch (e) { console.error(e); }
} catch (e) { console.error(e); this.$message.error('加载调度状态失败: ' + e.message); }
finally { this.schedulerLoading = false; }
},
getStatusLabel(status) { return { 'pending': '等待中', 'running': '进行中', 'completed': '已完成', 'failed': '失败', 'cancelled': '已取消' }[status] || status; },
@@ -242,7 +230,7 @@ const TasksApp = {
let url = '/api/tasks?limit=50';
if (this.filterStatus) url += '&status=' + this.filterStatus;
this.tasks = await this.api(url) || [];
} catch (e) { console.error(e); }
} catch (e) { console.error(e); this.$message.error('加载任务列表失败: ' + e.message); }
finally { this.loading = false; }
},
viewTaskDetail(task) { this.detailTask = task; this.showDetailDialog = true; },
@@ -267,8 +255,8 @@ const TasksApp = {
};
const app = Vue.createApp(TasksApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
+118 -4
View File
@@ -36,12 +36,33 @@
.status-badge-modern { display: inline-flex; align-items: center; gap: 6px; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 600; }
.status-badge-modern.running { background: rgba(103, 194, 58, 0.12); color: #67c23a; border: 1px solid rgba(103, 194, 58, 0.25); animation: pulse-glow 2s infinite; }
/* Modern el-table refinements */
.el-table { border-radius: 12px; overflow: hidden; }
/* el-table refinements */
.el-table { border-radius: 12px; overflow: hidden; width: 100%; }
.el-table .el-table__cell { word-break: break-word; }
/* Smooth page transitions */
.page-enter-active { animation: fadeIn 0.4s ease-out; }
/* ========== Reset & Base ========== */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; min-height: 100vh; }
/* ========== 统一布局 ========== */
/* 固定顶部导航占位,所有页面自动继承 */
.main-content { padding-top: 60px !important; display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
/* ========== 共享卡片 ========== */
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
/* ========== 页面标题 ========== */
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
/* ========== 筛选/工具栏 ========== */
.filter-bar { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
/* ========== H5响应式 ========== */
@media (max-width: 768px) {
/* 对话框自适应宽度 */
@@ -51,14 +72,107 @@
/* 表单网格在手机上改为单列 */
.el-row .el-col-12 { width: 100% !important; }
/* 手机端表格卡片 */
/* 手机端表格卡片 */
.mobile-card { position: relative; }
.mobile-card:active { transform: scale(0.98); }
/* 共享 content-area + card 响应式 */
.content-area { padding: 16px !important; padding-bottom: 80px; }
.card { padding: 16px !important; }
/* 页面标题在手机上紧凑 */
.page-header { flex-direction: column; align-items: flex-start !important; gap: 8px !important; }
}
/* 触摸优化:增大按钮点击区域 */
/* ========== 预览对话框(选题/文章共用) ========== */
.preview-iframe { box-sizing: border-box; }
.preview-dialog-custom.el-dialog { max-height: calc(100vh - 90px); 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; }
.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; }
.preview-dialog-custom.is-fullscreen { z-index: 100001 !important; }
body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index: 100000 !important; }
@media (max-width: 768px) { .preview-iframe { max-height: calc(100vh - 250px) !important; } }
@media (min-width: 769px) { .preview-iframe { max-height: calc(100vh - 100px) !important; } .preview-dialog-custom { position: relative; left: 90px; } }
/* ========== 共享卡片无数据状态 ========== */
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 20px; color: #909399; }
.empty-state .empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.4; }
.empty-state .empty-text { font-size: 14px; margin-bottom: 16px; }
.loading-state { text-align: center; padding: 60px 20px; color: #909399; font-size: 16px; }
/* ========== 通用移动端卡片列表(替代表格) ========== */
.card-table { display: block; }
.card-list-mobile { display: none; }
@media (max-width: 768px) {
.card-table { display: none; }
.card-list-mobile { display: block; }
}
.card-list-mobile .card-item {
background: #fafbfc; border-radius: 10px; padding: 14px; margin-bottom: 10px;
border: 1px solid #ebeef5; transition: all 0.2s ease;
}
.card-list-mobile .card-item:active { transform: scale(0.99); }
.card-list-mobile .card-row {
display: flex; justify-content: space-between; padding: 6px 0;
font-size: 13px; border-bottom: 1px dashed #f0f0f0;
}
.card-list-mobile .card-row:last-child { border-bottom: none; }
.card-list-mobile .card-label { color: #909399; flex-shrink: 0; margin-right: 8px; }
.card-list-mobile .card-value { color: #303133; text-align: right; word-break: break-word; }
.card-list-mobile .card-actions {
display: flex; gap: 8px; justify-content: flex-end;
padding-top: 10px; margin-top: 6px; border-top: 1px solid #ebeef5;
}
/* ========== 统计摘要条 ========== */
.stat-summary { display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 16px; }
.stat-summary .stat-item {
background: #f0f5ff; border-radius: 8px; padding: 12px 20px;
display: flex; flex-direction: column; align-items: center; min-width: 100px;
}
.stat-summary .stat-item .num { font-size: 24px; font-weight: 600; color: #409eff; }
.stat-summary .stat-item .label { font-size: 12px; color: #909399; margin-top: 4px; }
@media (max-width: 768px) {
.stat-summary { gap: 8px; }
.stat-summary .stat-item { min-width: 70px; padding: 8px 12px; }
.stat-summary .stat-item .num { font-size: 18px; }
}
/* ========== 加载中状态 ========== */
.card-loading { display: flex; justify-content: center; align-items: center; min-height: 200px; color: #909399; font-size: 14px; }
/* ========== 状态圆点 ========== */
.status-dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 4px; }
.status-dot.pending { background: #E6A23C; }
.status-dot.review { background: #F56C6C; }
.status-dot.ready { background: #67C23A; }
.status-dot.published { background: #409EFF; }
.status-dot.draft { background: #909399; }
/* ========== 搜索栏 ========== */
.search-bar { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 16px; }
@media (max-width: 768px) {
.search-bar { flex-direction: column; align-items: stretch; }
.search-bar .el-input, .search-bar .el-select { width: 100% !important; }
}
/* ========== 触摸优化 ========== */
@media (max-width: 768px) {
.el-button { min-height: 36px; }
.el-button--small { min-height: 36px; padding: 8px 14px !important; }
.el-button--default { padding: 10px 16px !important; }
.el-input__inner { min-height: 38px; }
.el-table__cell .el-button { padding: 8px 12px !important; }
.btn-group-mobile { display: flex; gap: 6px; flex-wrap: wrap; }
.btn-group-mobile .el-button { flex: 1; min-width: 0; justify-content: center; }
.action-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
.action-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
.filter-bar-mobile { flex-direction: column; align-items: stretch !important; }
.filter-bar-mobile .el-select,
.filter-bar-mobile .el-input { width: 100% !important; }
.page-header-mobile { flex-direction: column; align-items: flex-start !important; gap: 8px !important; }
}
+123 -74
View File
@@ -7,16 +7,6 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.page-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.filter-bar { display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; }
.selected-count { color: #909399; font-size: 14px; margin-left: auto; }
.status-badge { display: inline-flex; align-items: center; gap: 4px; }
@@ -28,17 +18,7 @@
.topic-card-list { display: none; }
.preview-iframe { box-sizing: border-box; }
.preview-dialog-custom.el-dialog { max-height: calc(100vh - 90px); 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; }
.preview-dialog-custom.is-fullscreen { z-index: 100001 !important; }
body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index: 100000 !important; }
@media (max-width: 768px) {
.content-area { padding: 12px; padding-bottom: 80px; }
.card { padding: 16px; }
.el-table { display: none; }
.topic-card-list { display: block; }
.topic-card {
@@ -52,41 +32,38 @@
.topic-card-title { font-size: 16px; font-weight: 600; color: #303133; flex: 1; margin-right: 8px; }
.topic-card-tags { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
.topic-card-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 12px; color: #606266; margin-bottom: 12px; }
.topic-card-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; padding-top: 12px; border-top: 1px solid #ebeef5; }
.topic-card-actions .el-button { flex: 1; min-width: 60px; }
.preview-iframe { max-height: calc(100vh - 250px) !important; }
}
@media (min-width: 769px) {
.preview-iframe { max-height: calc(100vh - 100px) !important; }
.preview-dialog-custom { position: relative; left: 90px; }
.topic-card-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 12px; padding-top: 12px; border-top: 1px solid #ebeef5; }
.topic-card-actions .el-button { margin: 0; width: 100%; justify-content: center; }
.topic-card-actions .el-button:last-child:nth-child(odd) { grid-column: 1 / -1; }
.topic-card.is-checked { border: 2px solid #409eff; box-shadow: 0 0 0 1px rgba(64,158,255,0.2); }
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="选题管理" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="topics" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="选题管理" :username="currentUser.username" :is-admin="isAdmin" current-page="topics" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="page-header">
<h2 class="page-title">📋 选题管理</h2>
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconTopic /></el-icon> 选题管理</h2>
<div class="toolbar">
<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="warning" size="small" @click="triggerReviewSelected" :disabled="selectedTopicIds.length === 0">🔍 批量审查</el-button>
<el-button type="primary" size="small" @click="refreshAll"><el-icon style="vertical-align:-2px;"><IconRefresh /></el-icon> 批量刷新</el-button>
<el-button size="small" @click="toggleSelectAll">{{ selectAllLabel }}</el-button>
<el-button type="success" size="small" @click="triggerGenerateSelected" :disabled="selectedTopicIds.length === 0"><el-icon style="vertical-align:-2px;"><IconPlus /></el-icon> 批量创作</el-button>
<el-button type="warning" size="small" @click="triggerReviewSelected" :disabled="selectedTopicIds.length === 0"><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon> 批量审查</el-button>
<span v-if="selectedTopicIds.length > 0" class="selected-count">已选 {{ selectedTopicIds.length }} 项</span>
</div>
</div>
<div class="filter-bar">
<el-button size="default" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''">全部 ({{ topics.length }})</el-button>
<el-button size="default" :type="filterStatus === 'today' ? 'primary' : ''" @click="filterStatus = 'today'">今日新增 ({{ todayCount }})</el-button>
<el-button size="default" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'">待处理 ({{ statusStats.pending }})</el-button>
<el-button size="default" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'">待审查 ({{ statusStats.review }})</el-button>
<el-button size="default" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'">待发布 ({{ statusStats.ready }})</el-button>
<el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'">已发布 ({{ statusStats.published }})</el-button>
<el-button size="default" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''; fetchTopics()">全部 ({{ topics.length }})</el-button>
<el-button size="default" :type="filterStatus === 'today' ? 'primary' : ''" @click="filterStatus = 'today'; fetchTopics()">今日新增 ({{ todayCount }})</el-button>
<el-button size="default" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'; fetchTopics()">待处理 ({{ statusStats.pending }})</el-button>
<el-button size="default" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'; fetchTopics()">待审查 ({{ statusStats.review }})</el-button>
<el-button size="default" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'; fetchTopics()">待发布 ({{ statusStats.ready }})</el-button>
<el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'; fetchTopics()">已发布 ({{ statusStats.published }})</el-button>
</div>
<el-table :data="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event.map(item => item.id)">
<el-table-column type="selection" width="55"></el-table-column>
@@ -102,22 +79,26 @@
<el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column prop="generated_at" label="创作时间" width="140"><template #default="scope">{{ scope.row.generated_at ? formatDate(scope.row.generated_at) : '-' }}</template></el-table-column>
<el-table-column prop="published_at" label="发布时间" width="140"><template #default="scope">{{ scope.row.published_at ? formatDate(scope.row.published_at) : '-' }}</template></el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<el-table-column label="操作" width="230" fixed="right">
<template #default="scope">
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
<el-button size="small" @click="openPreview(scope.row)" type="primary">预览</el-button>
<el-button size="small" type="success" :disabled="isStatus(scope.row, 'published')" @click="createTopic(scope.row)">创作</el-button>
<el-button size="small" type="warning" :disabled="!isStatus(scope.row, 'review')" @click="reviewTopic(scope.row)">审查</el-button>
<el-button v-if="isStatus(scope.row, 'ready')" size="small" type="primary" @click="handlePublish(scope.row)">发布</el-button>
<el-button size="small" type="danger" @click="deleteTopic(scope.row.id)">删除</el-button>
<div style="display: flex; gap: 4px; white-space: nowrap;">
<el-button size="small" @click="openPreview(scope.row)" type="primary" style="padding:5px 8px;">预览</el-button>
<el-button size="small" type="success" :disabled="isStatus(scope.row, 'published')" @click="createTopic(scope.row)" style="padding:5px 8px;">创作</el-button>
<el-button size="small" type="warning" :disabled="!isStatus(scope.row, 'review')" @click="reviewTopic(scope.row)" style="padding:5px 8px;">审查</el-button>
<el-button v-if="isStatus(scope.row, 'ready')" size="small" type="primary" @click="openPublishDialog(scope.row)" style="padding:5px 8px;">发布</el-button>
<el-button size="small" type="danger" @click="deleteTopic(scope.row.id)" style="padding:5px 8px;">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
<div v-if="!loadingTable && filteredTopics.length === 0" class="empty-state" style="margin-top:20px;"><el-icon style="font-size:48px;color:#c0c4cc;"><IconTopic /></el-icon><div class="empty-text">暂无选题数据</div></div>
<div class="topic-card-list" v-if="filteredTopics && filteredTopics.length > 0">
<div v-for="topic in filteredTopics" :key="topic.id" class="topic-card">
<div v-for="topic in filteredTopics" :key="topic.id" class="topic-card" :class="{ 'is-checked': selectedTopicIds.includes(topic.id) }">
<div class="topic-card-header">
<div class="topic-card-title">{{ topic.id }}. {{ topic.title }}</div>
<div class="topic-card-title">
<el-checkbox :checked="selectedTopicIds.includes(topic.id)" @change="toggleCheck(topic.id)" style="margin-right:6px;"></el-checkbox>
{{ topic.id }}. {{ topic.title }}
</div>
<el-tag :type="getStatusType(topic.status)" size="small">{{ getStatusLabel(topic.status) }}</el-tag>
</div>
<div class="topic-card-tags">
@@ -133,7 +114,7 @@
<el-button size="small" @click="openPreview(topic)" type="primary">预览</el-button>
<el-button size="small" type="success" :disabled="isStatus(topic, 'published')" @click="createTopic(topic)">创作</el-button>
<el-button size="small" type="warning" :disabled="!isStatus(topic, 'review')" @click="reviewTopic(topic)">审查</el-button>
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="handlePublish(topic)">发布</el-button>
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="openPublishDialog(topic)">发布</el-button>
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
</div>
</div>
@@ -141,6 +122,30 @@
</div>
</main>
</div>
<el-dialog v-model="publishDialogVisible" title="发布确认" width="420px" :close-on-click-modal="false">
<div v-if="publishTopic">
<div style="margin-bottom:16px;">
<div style="font-size:14px;color:#606266;margin-bottom:8px;">选题:</div>
<div style="font-size:15px;font-weight:600;color:#303133;">{{ publishTopic.id }}. {{ publishTopic.title }}</div>
</div>
<div style="margin-bottom:16px;">
<div style="font-size:14px;color:#606266;margin-bottom:8px;">选择发布平台:</div>
<el-checkbox v-model="publishPlatforms.zhihu" label="zhihu" style="display:block;margin-bottom:8px;">知乎</el-checkbox>
<el-checkbox v-model="publishPlatforms.wechat" label="wechat" style="display:block;margin-bottom:8px;">微信公众号</el-checkbox>
<el-checkbox v-model="publishPlatforms.xiaohongshu" label="xiaohongshu" style="display:block;">小红书</el-checkbox>
</div>
<div v-if="publishing" style="text-align:center;padding:12px;color:#909399;">
<el-icon class="is-loading" style="margin-right:4px;">
<svg viewBox="0 0 1024 1024" width="16" height="16"><path d="M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896z" fill="none" stroke="currentColor" stroke-width="64"/></svg>
</el-icon>
正在发布...
</div>
</div>
<template #footer>
<el-button @click="publishDialogVisible = false" :disabled="publishing">取消</el-button>
<el-button type="primary" @click="confirmPublish" :loading="publishing" :disabled="!publishPlatforms.zhihu && !publishPlatforms.wechat && !publishPlatforms.xiaohongshu">确认发布</el-button>
</template>
</el-dialog>
<el-dialog v-model="previewVisible" title="选题预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape>
<div v-if="previewTopic">
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
@@ -181,6 +186,7 @@
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const TopicsApp = {
@@ -191,22 +197,15 @@ const TopicsApp = {
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
topics: [], todayCount: 0,
previewVisible: false, previewTopic: null, previewFullscreen: false,
previewPlatform: 'zhihu', platformContents: {}
previewPlatform: 'zhihu', platformContents: {},
publishDialogVisible: false, publishTopic: null,
publishPlatforms: { zhihu: true, wechat: true, xiaohongshu: true },
publishing: false
}
},
computed: {
filteredTopics() {
if (!this.topics || !this.topics.length) return [];
if (!this.filterStatus) return this.topics;
if (this.filterStatus === 'today') {
const today = new Date();
const todayStr = today.toISOString().slice(0, 10);
return this.topics.filter(t => {
const d = t.created_at;
if (!d) return false;
return d.slice(0, 10) === todayStr;
});
}
const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] };
const allowed = map[this.filterStatus] || [this.filterStatus];
return this.topics.filter(t => allowed.includes(t.status));
@@ -231,6 +230,12 @@ const TopicsApp = {
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; }
},
selectAllLabel() {
const visible = this.filteredTopics.map(t => t.id);
if (visible.length === 0) return '全选';
const allChecked = visible.every(id => this.selectedTopicIds.includes(id));
return allChecked ? '取消全选' : `全选 (${visible.length})`;
}
},
methods: {
@@ -245,7 +250,8 @@ const TopicsApp = {
async fetchTopics() {
this.loadingTable = true;
try {
const data = await this.api('/api/topics');
const params = this.filterStatus === 'today' ? '?today=true' : '';
const data = await this.api('/api/topics' + params);
this.topics = data || [];
this.$message.success('选题加载成功');
} catch (error) {
@@ -258,7 +264,7 @@ const TopicsApp = {
try {
const stats = await this.api('/api/topics/stats');
if (stats) this.todayCount = stats.today_created || 0;
} catch (e) { console.error('获取统计失败:', e); }
} catch (e) { console.error('获取统计失败:', e); this.$message.error('获取统计失败: ' + e.message); }
},
refreshAll() { this.fetchTopics(); this.fetchTodayCount(); this.$message.success('已刷新'); },
async triggerGenerateSelected() {
@@ -272,7 +278,7 @@ const TopicsApp = {
}
this.$message.success(`已提交 ${count}/${this.selectedTopicIds.length} 个创作任务,可在「创作任务」页面查看进度`);
this.selectedTopicIds = [];
setTimeout(() => this.fetchTopics(), 2000);
setTimeout(() => { this.fetchTopics(); this.fetchTodayCount(); }, 2000);
},
async triggerReviewSelected() {
if (!this.selectedTopicIds.length) return;
@@ -281,6 +287,7 @@ const TopicsApp = {
this.$message.success('批量审查完成');
this.selectedTopicIds = [];
await this.fetchTopics();
await this.fetchTodayCount();
} catch (error) { this.$message.error(`批量审查失败: ${error.message}`); }
},
async openPreview(topic) {
@@ -288,9 +295,10 @@ const TopicsApp = {
const token = this.getToken();
if (!token) return;
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
const names = { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' };
await Promise.all(platforms.map(p =>
fetch(`/api/articles/${topic.id}/preview?platform=${p}`, { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : null).then(d => { if (d && d.html) this.platformContents[p] = d.html; }).catch(e => console.error(`加载${p}预览失败:`, e))
.then(r => r.ok ? r.json() : null).then(d => { if (d && d.html) this.platformContents[p] = d.html; }).catch(e => { console.error(`加载${p}预览失败:`, e); this.$message.error(`加载${names[p]}预览失败`); })
));
},
togglePreviewFullscreen() {
@@ -321,7 +329,7 @@ const TopicsApp = {
try {
const data = await this.api('/api/tasks/run-creator?topic_id=' + topic.id, { method: 'POST' });
this.$message.success(`创作任务已启动: ${topic.title},可在「创作任务」页面查看进度`);
setTimeout(() => this.fetchTopics(), 2000);
setTimeout(() => { this.fetchTopics(); this.fetchTodayCount(); }, 2000);
} catch (error) { this.$message.error(`创作失败: ${error.message}`); }
},
async reviewTopic(topic) {
@@ -330,15 +338,31 @@ const TopicsApp = {
const data = await this.api('/api/system/review/run', { method: 'POST', body: JSON.stringify({ topic_ids: [topic.id] }) });
this.$message.success(`审查完成: ${topic.title}`);
await this.fetchTopics();
await this.fetchTodayCount();
} catch (error) { this.$message.error(`审查失败: ${error.message}`); }
},
async handlePublish(topic) {
openPublishDialog(topic) {
if (!this.isStatus(topic, 'ready')) { this.$message.info('仅待发布选题可发布'); return; }
this.publishTopic = topic;
this.publishPlatforms = { zhihu: true, wechat: true, xiaohongshu: true };
this.publishing = false;
this.publishDialogVisible = true;
},
async confirmPublish() {
const selected = Object.entries(this.publishPlatforms).filter(([, v]) => v).map(([k]) => k);
if (selected.length === 0) { this.$message.warning('请至少选择一个平台'); return; }
this.publishing = true;
try {
const data = await this.api('/api/publishing/create', { method: 'POST', body: JSON.stringify({ topic_id: topic.id }) });
this.$message.success(`发布成功: ${topic.title}`);
const data = await this.api('/api/publishing/create', {
method: 'POST',
body: JSON.stringify({ topic_id: this.publishTopic.id, platforms: selected })
});
this.$message.success(`发布完成: ${this.publishTopic.title}`);
this.publishDialogVisible = false;
await this.fetchTopics();
await this.fetchTodayCount();
} catch (error) { this.$message.error(`发布失败: ${error.message}`); }
finally { this.publishing = false; }
},
async deleteTopic(id) {
try {
@@ -346,11 +370,30 @@ const TopicsApp = {
await this.api('/api/topics/' + id, { method: 'DELETE' });
this.$message.success('删除成功');
await this.fetchTopics();
await this.fetchTodayCount();
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败'); }
},
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
getStatusLabel(status) { return { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布' }[status] || status; },
toggleCheck(id) {
const idx = this.selectedTopicIds.indexOf(id);
if (idx >= 0) {
this.selectedTopicIds.splice(idx, 1);
} else {
this.selectedTopicIds.push(id);
}
},
toggleSelectAll() {
const visible = this.filteredTopics.map(t => t.id);
const allChecked = visible.every(id => this.selectedTopicIds.includes(id));
if (allChecked) {
this.selectedTopicIds = this.selectedTopicIds.filter(id => !visible.includes(id));
} else {
const existing = new Set(this.selectedTopicIds);
for (const id of visible) { if (!existing.has(id)) { this.selectedTopicIds.push(id); } }
}
},
formatDate(dateStr) {
if (!dateStr) return '-';
try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
@@ -362,18 +405,24 @@ const TopicsApp = {
mounted() {
const token = localStorage.getItem('authToken');
if (!token) { window.location.href = '/login.html'; return; }
const filter = new URLSearchParams(window.location.search).get('filter');
if (filter) this.filterStatus = filter;
const urlFilter = new URLSearchParams(window.location.search).get('filter');
if (urlFilter) this.filterStatus = urlFilter;
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchTopics(); this.fetchTodayCount(); })
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
},
watch: {
topics() {
const urlFilter = new URLSearchParams(window.location.search).get('filter');
if (urlFilter) this.filterStatus = urlFilter;
}
}
};
const app = Vue.createApp(TopicsApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>
+230
View File
@@ -0,0 +1,230 @@
(function () {
if (document.getElementById('uni-nav-styles')) return;
var style = document.createElement('style');
style.id = 'uni-nav-styles';
style.textContent = '\
.uni-nav {\
position: fixed; top: 0; left: 0; right: 0; height: 60px;\
z-index: 10000;\
display: flex; align-items: center;\
padding: 0 16px;\
background: linear-gradient(135deg, #2563eb, #1d4ed8);\
color: #fff;\
box-shadow: 0 2px 12px rgba(37,99,235,0.25);\
}\
.uni-nav-inner {\
display: flex; align-items: center;\
width: 100%; max-width: 1400px; margin: 0 auto;\
gap: 8px;\
}\
.uni-nav-brand {\
font-size: 18px; font-weight: 700;\
white-space: nowrap;\
margin-right: 8px;\
letter-spacing: -0.3px;\
}\
.uni-nav-items {\
display: flex; align-items: center; gap: 2px;\
flex: 1; overflow: hidden;\
}\
.uni-nav-item {\
display: inline-flex; align-items: center; gap: 4px;\
padding: 6px 12px;\
border: none; background: transparent;\
color: rgba(255,255,255,0.8);\
font-size: 13px;\
border-radius: 8px;\
cursor: pointer;\
white-space: nowrap;\
transition: all 0.2s;\
}\
.uni-nav-item:hover { background: rgba(255,255,255,0.12); color: #fff; }\
.uni-nav-item.active {\
background: rgba(255,255,255,0.18); color: #fff; font-weight: 600;\
}\
.uni-nav-right {\
display: flex; align-items: center; gap: 8px;\
flex-shrink: 0;\
}\
.uni-nav-avatar {\
width: 30px; height: 30px;\
border-radius: 50%;\
background: rgba(255,255,255,0.2);\
display: flex; align-items: center; justify-content: center;\
font-size: 13px; font-weight: 600;\
}\
.uni-nav-username { font-size: 13px; color: rgba(255,255,255,0.9); }\
.uni-nav-badge {\
font-size: 10px;\
background: rgba(255,255,255,0.15);\
padding: 1px 8px; border-radius: 10px;\
margin-left: 4px;\
}\
.uni-nav-logout {\
background: rgba(255,255,255,0.1);\
border: 1px solid rgba(255,255,255,0.15);\
color: rgba(255,255,255,0.9);\
padding: 4px 12px; border-radius: 6px;\
cursor: pointer; font-size: 12px;\
transition: all 0.2s;\
}\
.uni-nav-logout:hover { background: rgba(255,255,255,0.2); }\
.uni-nav-hamburger {\
display: none;\
background: rgba(255,255,255,0.1);\
border: none; color: #fff;\
width: 36px; height: 36px;\
border-radius: 8px;\
cursor: pointer; font-size: 20px;\
align-items: center; justify-content: center;\
transition: all 0.2s;\
}\
.uni-nav-hamburger:hover { background: rgba(255,255,255,0.18); }\
.uni-nav-dropdown {\
display: none;\
position: fixed; top: 60px; left: 0; right: 0;\
background: #fff;\
box-shadow: 0 8px 24px rgba(0,0,0,0.12);\
z-index: 9999;\
padding: 8px;\
max-height: calc(100vh - 60px);\
overflow-y: auto;\
animation: uniNavSlideDown 0.2s ease-out;\
}\
.uni-nav-dropdown.open { display: block; }\
.uni-nav-dropdown-item {\
display: flex; align-items: center; gap: 8px;\
width: 100%;\
padding: 12px 16px;\
border: none; background: transparent;\
color: #303133; font-size: 14px;\
border-radius: 8px;\
cursor: pointer;\
transition: all 0.15s;\
}\
.uni-nav-dropdown-item:hover { background: #f0f4ff; color: #2563eb; }\
.uni-nav-dropdown-item.active { background: #eef2ff; color: #2563eb; font-weight: 600; }\
.uni-nav-dropdown-divider {\
height: 1px; background: #f0f0f0; margin: 4px 0;\
}\
@media (max-width: 768px) {\
.uni-nav-items { display: none; }\
.uni-nav-username { display: none; }\
.uni-nav-hamburger { display: inline-flex; }\
}\
@keyframes uniNavSlideDown {\
from { opacity: 0; transform: translateY(-8px); }\
to { opacity: 1; transform: translateY(0); }\
}\
';
document.head.appendChild(style);
function getCurrentPage() {
var path = window.location.pathname;
if (path === '/' || path === '/index.html') return 'dashboard';
var m = path.match(/\/(\w+)\.html/);
return m ? m[1] : 'dashboard';
}
function navItems(isAdmin) {
var items = [
{ key: 'dashboard', label: '仪表盘', page: '/' },
{ key: 'topics', label: '选题', page: 'topics.html' },
{ key: 'metrics', label: '数据', page: 'metrics.html' },
{ key: 'calendar', label: '日历', page: 'calendar.html' },
{ key: 'assets', label: '素材', page: 'assets.html' },
{ key: 'tasks', label: '任务', page: 'tasks.html' },
{ key: 'platforms', label: '平台', page: 'platforms.html' },
{ key: 'logs', label: '日志', page: 'logs.html' },
];
if (isAdmin) {
items.push({ key: 'users', label: '用户', page: 'users.html', admin: true });
items.push({ key: 'admin', label: '系统', page: 'admin.html', admin: true });
}
return items;
}
var UniNav = {
name: 'UniNav',
props: {
title: { type: String, default: '' },
username: { type: String, default: '' },
isAdmin: { type: Boolean, default: false },
currentPage: { type: String, default: null },
onNavigate: { type: Function, default: null },
},
emits: ['logout'],
data: function () {
return { dropdownOpen: false };
},
computed: {
items: function () {
return navItems(this.isAdmin);
},
page: function () {
return this.currentPage || getCurrentPage();
},
showTitle: function () {
return this.title || '宇之然';
},
},
methods: {
navigate: function (page) {
this.dropdownOpen = false;
if (this.onNavigate) { this.onNavigate(page); return; }
window.location.href = page === '/' ? '/' : '/' + page;
},
logout: function () {
this.$emit('logout');
},
toggleDropdown: function () {
this.dropdownOpen = !this.dropdownOpen;
},
closeDropdown: function (e) {
if (this.dropdownOpen && !this.$el.contains(e.target)) {
this.dropdownOpen = false;
}
},
},
mounted: function () {
document.addEventListener('click', this.closeDropdown);
},
beforeUnmount: function () {
document.removeEventListener('click', this.closeDropdown);
},
template: '\
<nav class="uni-nav">\
<div class="uni-nav-inner">\
<div class="uni-nav-brand">{{ showTitle }}</div>\
<div class="uni-nav-items">\
<button v-for="item in items" :key="item.key"\
:class="[\'uni-nav-item\', { active: page === item.key }]"\
@click="navigate(item.page)">{{ item.label }}</button>\
</div>\
<div class="uni-nav-right">\
<button class="uni-nav-hamburger" @click.stop="toggleDropdown">\
{{ dropdownOpen ? "✕" : "☰" }}\
</button>\
<div class="uni-nav-avatar">{{ username ? username.charAt(0).toUpperCase() : "?" }}</div>\
<span v-if="username" class="uni-nav-username">{{ username }}</span>\
<span v-if="isAdmin" class="uni-nav-badge">管理员</span>\
<button class="uni-nav-logout" @click="logout">退出</button>\
</div>\
</div>\
<div :class="[\'uni-nav-dropdown\', { open: dropdownOpen }]" @click.stop>\
<button v-for="item in items" :key="item.key"\
:class="[\'uni-nav-dropdown-item\', { active: page === item.key }]"\
@click="navigate(item.page)">\
{{ item.label }}\
</button>\
</div>\
</nav>',
};
window.UniNav = UniNav;
window.installUniNav = function (app) {
app.component('uni-nav', UniNav);
return app;
};
})();
+58 -28
View File
@@ -7,26 +7,12 @@
<link rel="stylesheet" href="element-plus.css">
<link rel="stylesheet" href="theme-modern.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
.card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.card-title { font-size: 24px; font-weight: 700; color: #303133; display: flex; align-items: center; gap: 8px; }
.user-table { width: 100%; }
.user-table .el-table__cell { word-break: break-word; }
.user-card-list { display: none; }
@media (max-width: 768px) {
.content-area { padding: 16px; padding-bottom: 80px; }
.card { padding: 16px; }
.user-table { display: none; }
.user-card-list { display: block; margin: 0 -16px; }
.user-card {
@@ -64,26 +50,26 @@
}
}
</style>
<script src="navigation-component.js"></script>
<script src="navbar-component.js"></script>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app">
<navbar-component title="用户管理" :username="currentUser.username" :is-admin="isAdmin" @logout="handleLogout"></navbar-component>
<navigation-component current-page="users" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
<uni-nav title="用户管理" :username="currentUser.username" :is-admin="isAdmin" current-page="users" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="card-header">
<h2 class="card-title">👥 用户管理</h2>
<h2 class="card-title"><el-icon style="vertical-align:-2px;"><IconUser /></el-icon> 用户管理</h2>
<el-button type="primary" @click="addUser">+ 新建用户</el-button>
</div>
<el-table :data="users" stripe class="user-table">
<el-table :data="users" stripe class="user-table" v-loading="loading">
<el-table-column prop="id" label="ID" width="80"></el-table-column>
<el-table-column prop="username" label="用户名"></el-table-column>
<el-table-column prop="role" label="角色" width="100">
<template #default="scope"><el-tag :type="scope.row.role === 'admin' ? 'danger' : 'info'">{{ scope.row.role === 'admin' ? '管理员' : '编辑' }}</el-tag></template>
</el-table-column>
<el-table-column prop="org_id" label="组织" width="100"></el-table-column>
<el-table-column prop="created_at" label="创建时间"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
@@ -91,6 +77,10 @@
</template>
</el-table-column>
</el-table>
<div v-if="!loading && users.length === 0" style="text-align:center;padding:40px 0;color:#909399;">
<el-icon style="font-size:48px;margin-bottom:12px;color:#c0c4cc;"><IconUser /></el-icon>
<div>暂无用户</div>
</div>
<div class="user-card-list">
<div v-for="u in users" :key="u.id" class="user-card">
<div class="user-card-header">
@@ -101,6 +91,10 @@
<span style="font-size: 12px; color: #909399;">#{{ u.id }}</span>
</div>
<div class="user-card-meta">
<div class="user-card-meta-item">
<span class="user-card-meta-label">组织</span>
<span>{{ u.org_id || '-' }}</span>
</div>
<div class="user-card-meta-item">
<span class="user-card-meta-label">创建时间</span>
<span>{{ formatDate(u.created_at) }}</span>
@@ -114,14 +108,42 @@
</div>
</main>
</div>
<el-dialog v-model="dialogVisible" title="新建用户" width="400px" :close-on-click-modal="false">
<el-form :model="form" label-width="80px" @submit.prevent="submitUser">
<el-form-item label="用户名" required>
<el-input v-model="form.username" placeholder="2-20个字符" maxlength="20" clearable></el-input>
</el-form-item>
<el-form-item label="密码" required>
<el-input v-model="form.password" type="password" placeholder="至少6位" show-password></el-input>
</el-form-item>
<el-form-item label="角色">
<el-select v-model="form.role" style="width:100%">
<el-option label="编辑" value="editor"></el-option>
<el-option label="管理员" value="admin"></el-option>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitUser" :loading="submitting">创建</el-button>
</template>
</el-dialog>
</div>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const UsersApp = {
data() { return { isLoggedIn: false, isAdmin: false, currentUser: { username: '' }, users: [] } },
data() {
return {
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
users: [], dialogVisible: false, submitting: false, loading: false,
form: { username: '', password: '', role: 'editor' }
}
},
methods: {
async fetchUsers() {
this.loading = true;
try {
const token = localStorage.getItem('authToken');
if (!token) { this.$message.error('请先登录'); return; }
@@ -129,30 +151,38 @@
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.detail || `请求失败: ${response.status}`); }
const data = await response.json();
this.users = data || [];
this.$message.success('用户列表加载成功');
} catch (error) {
console.error('获取用户失败:', error);
this.$message.error(`获取用户失败: ${error.message}`);
this.users = [];
}
} finally { this.loading = false; }
},
async addUser() {
addUser() {
this.form = { username: '', password: '', role: 'editor' };
this.dialogVisible = true;
},
async submitUser() {
if (!this.form.username || this.form.username.length < 2) { this.$message.warning('用户名至少2个字符'); return; }
if (!this.form.password || this.form.password.length < 6) { this.$message.warning('密码至少6位'); return; }
this.submitting = true;
try {
const token = localStorage.getItem('authToken');
if (!token) { this.$message.error('请先登录'); return; }
const username = '新用户' + Date.now().toString().slice(-4);
const response = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username, role: 'editor' })
body: JSON.stringify(this.form)
});
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.detail || `请求失败: ${response.status}`); }
const newUser = await response.json();
this.users.push(newUser);
this.$message.success('添加用户成功');
this.dialogVisible = false;
} catch (error) {
console.error('添加用户失败:', error);
this.$message.error(`添加用户失败: ${error.message}`);
} finally {
this.submitting = false;
}
},
async deleteUser(id) {
@@ -186,8 +216,8 @@
};
const app = Vue.createApp(UsersApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
if (window.installIcons) { window.installIcons(app); }
if (window.installUniNav) { window.installUniNav(app); }
app.mount('#app');
</script>
</body>