chore: 清理 Docker 相关文件并优化前端布局
- 删除 Docker 相关文件 (docker-compose, Dockerfile, nginx.conf, init.sql 等) - 优化 platforms.html 卡片布局和响应式样式 - 优化 users.html 格式和移动端卡片设计 - 优化 admin.html 页面结构和表格布局 - 修复各页面 min-height 和溢出问题 - 更新导航组件样式
This commit is contained in:
@@ -1,37 +0,0 @@
|
||||
# 宇之然内容创作平台 - 前端Docker镜像
|
||||
|
||||
FROM nginx:alpine as builder
|
||||
|
||||
# 安装构建工具(用于优化HTML)
|
||||
RUN apk add --no-cache python3 py3-pip
|
||||
COPY index.html /tmp/index.html
|
||||
COPY login.html /tmp/login.html
|
||||
|
||||
# 简单压缩HTML(实际生产应使用Webpack等构建工具)
|
||||
RUN cat /tmp/index.html | tr -d '\n' > /tmp/index.min.html && \
|
||||
mv /tmp/index.min.html /tmp/index.html
|
||||
|
||||
WORKDIR /usr/share/nginx/html
|
||||
|
||||
# 复制静态资源
|
||||
COPY . .
|
||||
|
||||
# 生产阶段 - 直接使用Nginx
|
||||
FROM nginx:alpine
|
||||
|
||||
# 复制优化后的前端文件
|
||||
COPY --from=builder /usr/share/nginx/html /usr/share/nginx/html
|
||||
|
||||
# 配置Nginx
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost/ || exit 1
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
# 标签信息
|
||||
LABEL maintainer="宇之然团队"
|
||||
LABEL version="1.0.0"
|
||||
LABEL description="企业级内容创作管理系统前端"
|
||||
+323
-362
@@ -3,415 +3,376 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台 - 用户管理</title>
|
||||
<title>宇之然内容创作平台 - 系统管理</title>
|
||||
<link rel="stylesheet" href="element-plus.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; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
|
||||
.navbar-title { font-size: 20px; font-weight: 600; }
|
||||
.navbar-user { display: flex; align-items: center; gap: 16px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
.sidebar { width: 180px; background: white; padding: 16px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
|
||||
|
||||
|
||||
|
||||
.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: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow-x: auto; }
|
||||
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); padding: 8px 0; z-index: 1000; }
|
||||
|
||||
|
||||
.mobile-nav-btn { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 2px; background: none; border: none; font-size: 10px; color: #909399; cursor: pointer; padding: 4px; }
|
||||
.mobile-nav-btn.active { color: #409eff; }
|
||||
.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; }
|
||||
|
||||
.mobile-card-list { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { display: none; }
|
||||
.mobile-nav { display: flex; }
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
}
|
||||
|
||||
.user-card-list { display: none; }
|
||||
/* users.html 移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.user-table { display: none; }
|
||||
.user-card-list { display: block; margin: 0 -16px; }
|
||||
.user-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.user-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.user-card-name { font-size: 16px; font-weight: 600; }
|
||||
.user-card-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.user-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
.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; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="navigation-component.js"></script>
|
||||
|
||||
<script src="navbar-component.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navbar-component
|
||||
title="系统管理"
|
||||
:username="currentUser.username"
|
||||
:is-admin="isAdmin"
|
||||
@logout="handleLogout"
|
||||
></navbar-component>
|
||||
|
||||
<navigation-component
|
||||
current-page="admin"
|
||||
:is-admin="isAdmin"
|
||||
@navigate="redirectToPage"
|
||||
></navigation-component>
|
||||
<div class="main-content"><main class="content-area">
|
||||
<el-tabs v-model="activeTab" type="border-card">
|
||||
<el-tab-pane label="案例管理" name="cases"></el-tab-pane>
|
||||
<el-tab-pane label="任务日志" name="tasklogs"></el-tab-pane>
|
||||
<el-tab-pane label="LLM配置" name="llmconfigs"></el-tab-pane>
|
||||
<el-tab-pane label="系统配置" name="systemconfigs"></el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- Cases Tab -->
|
||||
<div v-show="activeTab === 'cases'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="showCaseDialog()">新增案例</el-button>
|
||||
<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>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="案例管理" name="cases">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="showCaseDialog()">新增案例</el-button>
|
||||
</div>
|
||||
<el-table :data="cases" border stripe class="data-table">
|
||||
<el-table-column prop="id" label="ID" width="70"/>
|
||||
<el-table-column prop="title" label="标题" min-width="150"/>
|
||||
<el-table-column prop="field" label="领域" width="100"/>
|
||||
<el-table-column prop="summary" label="概述" min-width="200" :show-overflow-tooltip="true"/>
|
||||
<el-table-column prop="source" label="来源" width="100"/>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showCaseDialog(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCase(scope.row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<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">
|
||||
<el-button size="small" @click="showCaseDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCase(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="任务日志" name="tasklogs">
|
||||
<div class="toolbar">
|
||||
<el-button @click="loadTaskLogs()">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="taskLogs" border stripe class="data-table">
|
||||
<el-table-column prop="id" label="ID" width="70"/>
|
||||
<el-table-column prop="task_name" label="任务名称" min-width="120"/>
|
||||
<el-table-column prop="topic_id" label="选题" width="100"/>
|
||||
<el-table-column prop="status" label="状态" width="80"/>
|
||||
<el-table-column prop="message" label="消息" min-width="150" :show-overflow-tooltip="true"/>
|
||||
<el-table-column prop="started_at" label="开始" width="150"/>
|
||||
<el-table-column prop="finished_at" label="结束" width="150"/>
|
||||
<el-table-column prop="duration" label="耗时" width="80"/>
|
||||
</el-table>
|
||||
<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>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="LLM配置" name="llmconfigs">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="showLLMConfigDialog()">新增配置</el-button>
|
||||
</div>
|
||||
<el-table :data="llmConfigs" border stripe class="data-table">
|
||||
<el-table-column prop="id" label="ID" width="70"/>
|
||||
<el-table-column prop="name" label="名称" min-width="120"/>
|
||||
<el-table-column prop="model" label="模型" min-width="180"/>
|
||||
<el-table-column prop="temperature" label="温度" width="80"/>
|
||||
<el-table-column prop="max_tokens" label="最大Token" width="110"/>
|
||||
<el-table-column prop="is_active" label="激活" width="70">
|
||||
<template #default="scope">{{ scope.row.is_active ? '是' : '否' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showLLMConfigDialog(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteLLMConfig(scope.row.id)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<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">
|
||||
<el-button size="small" @click="showLLMConfigDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteLLMConfig(item.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="系统配置" name="systemconfigs">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="showSystemConfigDialog()">新增配置</el-button>
|
||||
</div>
|
||||
<el-table :data="systemConfigs" border stripe class="data-table">
|
||||
<el-table-column prop="key" label="键" min-width="180"/>
|
||||
<el-table-column prop="value" label="值" min-width="250">
|
||||
<template #default="scope">
|
||||
{{ typeof scope.row.value === 'object' ? JSON.stringify(scope.row.value) : scope.row.value }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述" min-width="200"/>
|
||||
<el-table-column label="操作" width="140" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showSystemConfigDialog(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSystemConfig(scope.row.key)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<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">
|
||||
<el-button size="small" @click="showSystemConfigDialog(item)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSystemConfig(item.key)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<el-table :data="cases" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="80"/>
|
||||
<el-table-column prop="title" label="标题"/>
|
||||
<el-table-column prop="field" label="领域"/>
|
||||
<el-table-column prop="summary" label="概述" :show-overflow-tooltip="true"/>
|
||||
<el-table-column prop="source" label="来源"/>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showCaseDialog(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCase(scope.row.id)">删除</el-button>
|
||||
|
||||
<el-dialog v-model="caseDialogVisible" :title="caseDialogTitle" width="600px">
|
||||
<el-form :model="caseForm" label-width="80px">
|
||||
<el-form-item label="标题"><el-input v-model="caseForm.title"/></el-form-item>
|
||||
<el-form-item label="领域"><el-input v-model="caseForm.field"/></el-form-item>
|
||||
<el-form-item label="概述"><el-input type="textarea" v-model="caseForm.summary"/></el-form-item>
|
||||
<el-form-item label="关键指标"><el-input v-model="caseForm.key_metrics"/></el-form-item>
|
||||
<el-form-item label="日期"><el-input v-model="caseForm.date"/></el-form-item>
|
||||
<el-form-item label="来源"><el-input v-model="caseForm.source"/></el-form-item>
|
||||
<el-form-item label="来源URL"><el-input v-model="caseForm.source_url"/></el-form-item>
|
||||
<el-form-item label="可信度"><el-input v-model="caseForm.credibility_rating"/></el-form-item>
|
||||
<el-form-item label="国内适用性"><el-input v-model="caseForm.china_applicability"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="caseDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveCase">确定</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- TaskLogs Tab -->
|
||||
<div v-show="activeTab === 'tasklogs'">
|
||||
<div class="toolbar">
|
||||
<el-button @click="loadTaskLogs()">刷新</el-button>
|
||||
</div>
|
||||
<el-table :data="taskLogs" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="80"/>
|
||||
<el-table-column prop="task_name" label="任务名称"/>
|
||||
<el-table-column prop="topic_id" label="选题ID"/>
|
||||
<el-table-column prop="status" label="状态"/>
|
||||
<el-table-column prop="message" label="消息" :show-overflow-tooltip="true"/>
|
||||
<el-table-column prop="started_at" label="开始时间"/>
|
||||
<el-table-column prop="finished_at" label="结束时间"/>
|
||||
<el-table-column prop="duration_seconds" label="耗时(秒)"/>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- LLMConfigs Tab -->
|
||||
<div v-show="activeTab === 'llmconfigs'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="showLLMConfigDialog()">新增配置</el-button>
|
||||
</div>
|
||||
<el-table :data="llmConfigs" border stripe>
|
||||
<el-table-column prop="id" label="ID" width="80"/>
|
||||
<el-table-column prop="name" label="名称"/>
|
||||
<el-table-column prop="model" label="模型"/>
|
||||
<el-table-column prop="temperature" label="温度"/>
|
||||
<el-table-column prop="max_tokens" label="最大Token"/>
|
||||
<el-table-column prop="is_active" label="激活">
|
||||
<template #default="scope">{ scope.row.is_active ? '是' : '否' }</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showLLMConfigDialog(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteLLMConfig(scope.row.id)">删除</el-button>
|
||||
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="600px">
|
||||
<el-form :model="llmConfigForm" label-width="120px">
|
||||
<el-form-item label="名称"><el-input v-model="llmConfigForm.name"/></el-form-item>
|
||||
<el-form-item label="系统提示词"><el-input type="textarea" v-model="llmConfigForm.system_prompt"/></el-form-item>
|
||||
<el-form-item label="用户提示模板"><el-input type="textarea" v-model="llmConfigForm.user_prompt_template"/></el-form-item>
|
||||
<el-form-item label="温度"><el-input-number v-model="llmConfigForm.temperature" :min="0" :max="2" :step="0.1"/></el-form-item>
|
||||
<el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="10000"/></el-form-item>
|
||||
<el-form-item label="模型"><el-input v-model="llmConfigForm.model"/></el-form-item>
|
||||
<el-form-item label="激活">
|
||||
<el-switch v-model="llmConfigForm.is_active"/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="llmConfigDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveLLMConfig">确定</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
<!-- SystemConfigs Tab -->
|
||||
<div v-show="activeTab === 'systemconfigs'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" @click="showSystemConfigDialog()">新增配置</el-button>
|
||||
</div>
|
||||
<el-table :data="systemConfigs" border stripe>
|
||||
<el-table-column prop="key" label="键"/>
|
||||
<el-table-column prop="value" label="值">
|
||||
<template #default="scope">
|
||||
{ typeof scope.row.value === 'object' ? JSON.stringify(scope.row.value) : scope.row.value }
|
||||
<el-dialog v-model="systemConfigDialogVisible" :title="systemConfigDialogTitle" width="500px">
|
||||
<el-form :model="systemConfigForm" label-width="100px">
|
||||
<el-form-item label="键"><el-input v-model="systemConfigForm.key" :disabled="!!editingSystemConfigKey"/></el-form-item>
|
||||
<el-form-item label="值"><el-input v-model="systemConfigForm.value" type="textarea"/></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="systemConfigForm.description"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="systemConfigDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveSystemConfig">确定</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="描述"/>
|
||||
<el-table-column label="操作" width="150">
|
||||
<template #default="scope">
|
||||
<el-button size="small" @click="showSystemConfigDialog(scope.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteSystemConfig(scope.row.key)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<!-- Cases Dialog -->
|
||||
<el-dialog v-model="caseDialogVisible" :title="caseDialogTitle" width="600px">
|
||||
<el-form :model="caseForm" label-width="80px">
|
||||
<el-form-item label="标题"><el-input v-model="caseForm.title"/></el-form-item>
|
||||
<el-form-item label="领域"><el-input v-model="caseForm.field"/></el-form-item>
|
||||
<el-form-item label="概述"><el-input type="textarea" v-model="caseForm.summary"/></el-form-item>
|
||||
<el-form-item label="关键指标"><el-input v-model="caseForm.key_metrics"/></el-form-item>
|
||||
<el-form-item label="日期"><el-input v-model="caseForm.date"/></el-form-item>
|
||||
<el-form-item label="来源"><el-input v-model="caseForm.source"/></el-form-item>
|
||||
<el-form-item label="来源URL"><el-input v-model="caseForm.source_url"/></el-form-item>
|
||||
<el-form-item label="可信度"><el-input v-model="caseForm.credibility_rating"/></el-form-item>
|
||||
<el-form-item label="国内适用性"><el-input v-model="caseForm.china_applicability"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="caseDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveCase">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- LLMConfig Dialog -->
|
||||
<el-dialog v-model="llmConfigDialogVisible" :title="llmConfigDialogTitle" width="600px">
|
||||
<el-form :model="llmConfigForm" label-width="120px">
|
||||
<el-form-item label="名称"><el-input v-model="llmConfigForm.name"/></el-form-item>
|
||||
<el-form-item label="系统提示词"><el-input type="textarea" v-model="llmConfigForm.system_prompt"/></el-form-item>
|
||||
<el-form-item label="用户提示模板"><el-input type="textarea" v-model="llmConfigForm.user_prompt_template"/></el-form-item>
|
||||
<el-form-item label="温度"><el-input-number v-model="llmConfigForm.temperature" :min="0" :max="2" :step="0.1"/></el-form-item>
|
||||
<el-form-item label="最大Token"><el-input-number v-model="llmConfigForm.max_tokens" :min="1" :max="10000"/></el-form-item>
|
||||
<el-form-item label="模型"><el-input v-model="llmConfigForm.model"/></el-form-item>
|
||||
<el-form-item label="激活">
|
||||
<el-switch v-model="llmConfigForm.is_active"/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="llmConfigDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveLLMConfig">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- SystemConfig Dialog -->
|
||||
<el-dialog v-model="systemConfigDialogVisible" :title="systemConfigDialogTitle" width="500px">
|
||||
<el-form :model="systemConfigForm" label-width="100px">
|
||||
<el-form-item label="键"><el-input v-model="systemConfigForm.key" :disabled="!!editingSystemConfigKey"/></el-form-item>
|
||||
<el-form-item label="值"><el-input v-model="systemConfigForm.value" type="textarea"/></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="systemConfigForm.description"/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="systemConfigDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="saveSystemConfig">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
</main></div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const { createApp, ref, reactive, onMounted, watch } = Vue;
|
||||
const { ElMessage, ElMessageBox } = ElementPlus;
|
||||
|
||||
const app = createApp({
|
||||
setup() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
window.location.href = 'login.html';
|
||||
return {};
|
||||
}
|
||||
const api = {
|
||||
get: (url) => fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
|
||||
post: (url, body) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(body) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
|
||||
put: (url, body) => fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(body) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
|
||||
delete: (url) => fetch(url, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
|
||||
};
|
||||
const app = createApp({
|
||||
setup() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = 'login.html'; return {}; }
|
||||
|
||||
const activeTab = ref('cases');
|
||||
const currentUser = ref({ username: '' });
|
||||
const redirectToPage = (page) => { window.location.href = '/' + page; }; const currentUser = ref({ username: '' });
|
||||
const isAdmin = ref(false);
|
||||
const isLoggedIn = ref(false);
|
||||
const api = {
|
||||
get: (url) => fetch(url, { headers: { 'Authorization': `Bearer ${token}` } }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
|
||||
post: (url, body) => fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(body) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
|
||||
put: (url, body) => fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, body: JSON.stringify(body) }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
|
||||
delete: (url) => fetch(url, { method: 'DELETE', headers: { 'Authorization': `Bearer ${token}` } }).then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }),
|
||||
};
|
||||
|
||||
// 获取当前用户信息
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => {
|
||||
const user = data.user || { username: '', role: 'user' };
|
||||
currentUser.value = user;
|
||||
isAdmin.value = user.role === 'admin';
|
||||
isLoggedIn.value = true;
|
||||
if (!isAdmin.value) {
|
||||
ElMessage.warning('需要管理员权限');
|
||||
window.location.href = '/';
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('authToken');
|
||||
window.location.href = 'login.html';
|
||||
});
|
||||
const activeTab = ref('cases');
|
||||
const currentUser = ref({ username: '' });
|
||||
const isAdmin = ref(false);
|
||||
const isLoggedIn = ref(false);
|
||||
|
||||
// 页面跳转
|
||||
const redirectToPage = (page) => {
|
||||
window.location.href = '/' + page;
|
||||
};
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => {
|
||||
const user = data.user || { username: '', role: 'user' };
|
||||
currentUser.value = user;
|
||||
isAdmin.value = user.role === 'admin';
|
||||
isLoggedIn.value = true;
|
||||
if (!isAdmin.value) { ElMessage.warning('需要管理员权限'); window.location.href = '/login.html'; }
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
|
||||
// Cases
|
||||
const cases = ref([]);
|
||||
const caseDialogVisible = ref(false);
|
||||
const caseDialogTitle = ref('新增案例');
|
||||
const caseForm = reactive({ id: null, title: '', field: '', summary: '', key_metrics: '', date: '', source: '', source_url: '', credibility_rating: '', china_applicability: '' });
|
||||
const editingCaseId = ref(null);
|
||||
const redirectToPage = (page) => { window.location.href = '/' + page; };
|
||||
|
||||
const loadCases = async () => {
|
||||
try { cases.value = await api.get('/api/admin/cases'); } catch (e) { ElMessage.error('加载案例失败: ' + e.message); }
|
||||
};
|
||||
const showCaseDialog = (row = null) => {
|
||||
if (row) { caseDialogTitle.value = '编辑案例'; editingCaseId.value = row.id; Object.assign(caseForm, row); }
|
||||
else { caseDialogTitle.value = '新增案例'; editingCaseId.value = null; Object.keys(caseForm).forEach(k => { if (k === 'id') caseForm.id = null; else caseForm[k] = ''; }); }
|
||||
caseDialogVisible.value = true;
|
||||
};
|
||||
const saveCase = async () => {
|
||||
try {
|
||||
if (editingCaseId.value) { await api.put(`/api/admin/cases/${editingCaseId.value}`, caseForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/cases', caseForm); ElMessage.success('创建成功'); }
|
||||
caseDialogVisible.value = false; await loadCases();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
const deleteCase = async (id) => {
|
||||
try { await ElMessageBox.confirm('确定删除该案例吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/cases/${id}`); ElMessage.success('删除成功'); await loadCases(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
const cases = ref([]);
|
||||
const caseDialogVisible = ref(false);
|
||||
const caseDialogTitle = ref('新增案例');
|
||||
const caseForm = reactive({ id: null, title: '', field: '', summary: '', key_metrics: '', date: '', source: '', source_url: '', credibility_rating: '', china_applicability: '' });
|
||||
const editingCaseId = ref(null);
|
||||
|
||||
// TaskLogs
|
||||
const taskLogs = ref([]);
|
||||
const loadTaskLogs = async () => {
|
||||
try { taskLogs.value = await api.get('/api/admin/tasklogs'); } catch (e) { ElMessage.error('加载任务日志失败: ' + e.message); }
|
||||
};
|
||||
const loadCases = async () => { try { cases.value = await api.get('/api/admin/cases'); } catch (e) { ElMessage.error('加载案例失败: ' + e.message); } };
|
||||
const showCaseDialog = (row = null) => {
|
||||
if (row) { caseDialogTitle.value = '编辑案例'; editingCaseId.value = row.id; Object.assign(caseForm, row); }
|
||||
else { caseDialogTitle.value = '新增案例'; editingCaseId.value = null; Object.keys(caseForm).forEach(k => { if (k === 'id') caseForm.id = null; else caseForm[k] = ''; }); }
|
||||
caseDialogVisible.value = true;
|
||||
};
|
||||
const saveCase = async () => {
|
||||
try {
|
||||
if (editingCaseId.value) { await api.put(`/api/admin/cases/${editingCaseId.value}`, caseForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/cases', caseForm); ElMessage.success('创建成功'); }
|
||||
caseDialogVisible.value = false; await loadCases();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
const deleteCase = async (id) => {
|
||||
try { await ElMessageBox.confirm('确定删除该案例吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/cases/${id}`); ElMessage.success('删除成功'); await loadCases(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
|
||||
// LLMConfigs
|
||||
const llmConfigs = ref([]);
|
||||
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 editingLLMConfigId = ref(null);
|
||||
const taskLogs = ref([]);
|
||||
const loadTaskLogs = async () => { try { taskLogs.value = await api.get('/api/admin/tasklogs'); } catch (e) { ElMessage.error('加载任务日志失败: ' + e.message); } };
|
||||
|
||||
const loadLLMConfigs = async () => {
|
||||
try { llmConfigs.value = await api.get('/api/admin/llmconfigs'); } catch (e) { ElMessage.error('加载LLM配置失败: ' + e.message); }
|
||||
};
|
||||
const showLLMConfigDialog = (row = null) => {
|
||||
if (row) { llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id; Object.assign(llmConfigForm, row); }
|
||||
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] = ''; }); }
|
||||
llmConfigDialogVisible.value = true;
|
||||
};
|
||||
const saveLLMConfig = async () => {
|
||||
try {
|
||||
if (editingLLMConfigId.value) { await api.put(`/api/admin/llmconfigs/${editingLLMConfigId.value}`, llmConfigForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/llmconfigs', llmConfigForm); ElMessage.success('创建成功'); }
|
||||
llmConfigDialogVisible.value = false; await loadLLMConfigs();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
const deleteLLMConfig = async (id) => {
|
||||
try { await ElMessageBox.confirm('确定删除该配置吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/llmconfigs/${id}`); ElMessage.success('删除成功'); await loadLLMConfigs(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
const llmConfigs = ref([]);
|
||||
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 editingLLMConfigId = ref(null);
|
||||
|
||||
// SystemConfigs
|
||||
const systemConfigs = ref([]);
|
||||
const systemConfigDialogVisible = ref(false);
|
||||
const systemConfigDialogTitle = ref('新增配置');
|
||||
const systemConfigForm = reactive({ key: '', value: '', description: '' });
|
||||
const editingSystemConfigKey = ref(null);
|
||||
const loadLLMConfigs = async () => { try { llmConfigs.value = await api.get('/api/admin/llmconfigs'); } catch (e) { ElMessage.error('加载LLM配置失败: ' + e.message); } };
|
||||
const showLLMConfigDialog = (row = null) => {
|
||||
if (row) { llmConfigDialogTitle.value = '编辑配置'; editingLLMConfigId.value = row.id; Object.assign(llmConfigForm, row); }
|
||||
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] = ''; });
|
||||
}
|
||||
llmConfigDialogVisible.value = true;
|
||||
};
|
||||
const saveLLMConfig = async () => {
|
||||
try {
|
||||
if (editingLLMConfigId.value) { await api.put(`/api/admin/llmconfigs/${editingLLMConfigId.value}`, llmConfigForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/llmconfigs', llmConfigForm); ElMessage.success('创建成功'); }
|
||||
llmConfigDialogVisible.value = false; await loadLLMConfigs();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
const deleteLLMConfig = async (id) => {
|
||||
try { await ElMessageBox.confirm('确定删除该配置吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/llmconfigs/${id}`); ElMessage.success('删除成功'); await loadLLMConfigs(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
|
||||
const loadSystemConfigs = async () => {
|
||||
try { systemConfigs.value = await api.get('/api/admin/systemconfigs'); } catch (e) { ElMessage.error('加载系统配置失败: ' + e.message); }
|
||||
};
|
||||
const showSystemConfigDialog = (row = null) => {
|
||||
if (row) { systemConfigDialogTitle.value = '编辑配置'; editingSystemConfigKey.value = row.key; systemConfigForm.key = row.key; systemConfigForm.value = (typeof row.value === 'object') ? JSON.stringify(row.value) : (row.value || ''); systemConfigForm.description = row.description || ''; }
|
||||
else { systemConfigDialogTitle.value = '新增配置'; editingSystemConfigKey.value = null; systemConfigForm.key = ''; systemConfigForm.value = ''; systemConfigForm.description = ''; }
|
||||
systemConfigDialogVisible.value = true;
|
||||
};
|
||||
const saveSystemConfig = async () => {
|
||||
try {
|
||||
if (editingSystemConfigKey.value) { await api.put(`/api/admin/systemconfigs/${editingSystemConfigKey.value}`, systemConfigForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/systemconfigs', systemConfigForm); ElMessage.success('创建成功'); }
|
||||
systemConfigDialogVisible.value = false; await loadSystemConfigs();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
const deleteSystemConfig = async (key) => {
|
||||
try { await ElMessageBox.confirm('确定删除该配置吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/systemconfigs/${key}`); ElMessage.success('删除成功'); await loadSystemConfigs(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
const systemConfigs = ref([]);
|
||||
const systemConfigDialogVisible = ref(false);
|
||||
const systemConfigDialogTitle = ref('新增配置');
|
||||
const systemConfigForm = reactive({ key: '', value: '', description: '' });
|
||||
const editingSystemConfigKey = ref(null);
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('authToken');
|
||||
window.location.href = 'login.html';
|
||||
};
|
||||
const loadSystemConfigs = async () => { try { systemConfigs.value = await api.get('/api/admin/systemconfigs'); } catch (e) { ElMessage.error('加载系统配置失败: ' + e.message); } };
|
||||
const showSystemConfigDialog = (row = null) => {
|
||||
if (row) { systemConfigDialogTitle.value = '编辑配置'; editingSystemConfigKey.value = row.key; systemConfigForm.key = row.key; systemConfigForm.value = (typeof row.value === 'object') ? JSON.stringify(row.value) : (row.value || ''); systemConfigForm.description = row.description || ''; }
|
||||
else { systemConfigDialogTitle.value = '新增配置'; editingSystemConfigKey.value = null; systemConfigForm.key = ''; systemConfigForm.value = ''; systemConfigForm.description = ''; }
|
||||
systemConfigDialogVisible.value = true;
|
||||
};
|
||||
const saveSystemConfig = async () => {
|
||||
try {
|
||||
if (editingSystemConfigKey.value) { await api.put(`/api/admin/systemconfigs/${editingSystemConfigKey.value}`, systemConfigForm); ElMessage.success('更新成功'); }
|
||||
else { await api.post('/api/admin/systemconfigs', systemConfigForm); ElMessage.success('创建成功'); }
|
||||
systemConfigDialogVisible.value = false; await loadSystemConfigs();
|
||||
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
|
||||
};
|
||||
const deleteSystemConfig = async (key) => {
|
||||
try { await ElMessageBox.confirm('确定删除该配置吗?', '提示', { type: 'warning' }); await api.delete(`/api/admin/systemconfigs/${key}`); ElMessage.success('删除成功'); await loadSystemConfigs(); }
|
||||
catch (e) { if (e !== 'cancel') ElMessage.error('删除失败: ' + e.message); }
|
||||
};
|
||||
|
||||
watch(activeTab, (t) => {
|
||||
if (t === 'cases' && !cases.value.length) loadCases();
|
||||
if (t === 'tasklogs' && !taskLogs.value.length) loadTaskLogs();
|
||||
if (t === 'llmconfigs' && !llmConfigs.value.length) loadLLMConfigs();
|
||||
if (t === 'systemconfigs' && !systemConfigs.value.length) loadSystemConfigs();
|
||||
});
|
||||
const logout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
|
||||
|
||||
onMounted(() => {
|
||||
if (activeTab.value === 'cases') loadCases();
|
||||
else if (activeTab.value === 'tasklogs') loadTaskLogs();
|
||||
else if (activeTab.value === 'llmconfigs') loadLLMConfigs();
|
||||
else if (activeTab.value === 'systemconfigs') loadSystemConfigs();
|
||||
});
|
||||
watch(activeTab, (t) => {
|
||||
if (t === 'cases' && !cases.value.length) loadCases();
|
||||
if (t === 'tasklogs' && !taskLogs.value.length) loadTaskLogs();
|
||||
if (t === 'llmconfigs' && !llmConfigs.value.length) loadLLMConfigs();
|
||||
if (t === 'systemconfigs' && !systemConfigs.value.length) loadSystemConfigs();
|
||||
});
|
||||
|
||||
return {
|
||||
activeTab, cases, caseDialogVisible, caseForm, caseDialogTitle, showCaseDialog, saveCase, deleteCase,
|
||||
taskLogs, loadTaskLogs,
|
||||
llmConfigs, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
|
||||
systemConfigs, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
|
||||
logout,
|
||||
currentUser, isAdmin, isLoggedIn, redirectToPage
|
||||
};
|
||||
}
|
||||
});
|
||||
onMounted(() => {
|
||||
if (activeTab.value === 'cases') loadCases();
|
||||
else if (activeTab.value === 'tasklogs') loadTaskLogs();
|
||||
else if (activeTab.value === 'llmconfigs') loadLLMConfigs();
|
||||
else if (activeTab.value === 'systemconfigs') loadSystemConfigs();
|
||||
});
|
||||
|
||||
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); }
|
||||
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);
|
||||
return {
|
||||
activeTab, cases, caseDialogVisible, caseForm, caseDialogTitle, showCaseDialog, saveCase, deleteCase,
|
||||
taskLogs, loadTaskLogs,
|
||||
llmConfigs, llmConfigDialogVisible, llmConfigForm, llmConfigDialogTitle, showLLMConfigDialog, saveLLMConfig, deleteLLMConfig,
|
||||
systemConfigs, systemConfigDialogVisible, systemConfigForm, systemConfigDialogTitle, showSystemConfigDialog, saveSystemConfig, deleteSystemConfig,
|
||||
logout, currentUser, isAdmin, isLoggedIn, 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); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
.navbar-user { display: flex; align-items: center; gap: 16px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
|
||||
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
@@ -153,8 +153,23 @@ const AssetsApp = {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
|
||||
redirectToPage(page) { window.location.href = page; },
|
||||
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() {
|
||||
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.loadAssets();
|
||||
this.loadTags();
|
||||
this.loadStats();
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
},
|
||||
formatSize(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
@@ -236,19 +251,7 @@ const AssetsApp = {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/'; 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.loadAssets();
|
||||
this.loadTags();
|
||||
this.loadStats();
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
|
||||
this.checkAuth();
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(AssetsApp);
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
Redirecting to /axios@1.15.2/dist/axios.min.js
|
||||
@@ -13,7 +13,7 @@
|
||||
.navbar-title { font-size: 20px; font-weight: 600; }
|
||||
.navbar-user { display: flex; align-items: center; gap: 16px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
|
||||
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
|
||||
.content-area { flex: 1; padding: 32px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
@@ -170,6 +170,7 @@
|
||||
<script src="vue.global.prod.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() {
|
||||
@@ -270,19 +271,37 @@
|
||||
const statusLabel = (s) => ({ planned: '待发布', published: '已发布', delayed: '延迟', cancelled: '取消' }[s] || s);
|
||||
const statusType = (s) => ({ planned: 'warning', published: 'success', delayed: 'danger', cancelled: 'info' }[s] || '');
|
||||
|
||||
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 };
|
||||
},
|
||||
methods: {
|
||||
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
|
||||
redirectToPage(page) { window.location.href = page + '.html'; }
|
||||
},
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/'; return; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(d => { this.currentUser = d.user; this.isAdmin = d.user.role === 'admin'; this.fetchEntries(); this.fetchStats(); this.fetchTopics(); })
|
||||
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
|
||||
const checkAuth = () => {
|
||||
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(d => {
|
||||
currentUser.value = d.user;
|
||||
isAdmin.value = d.user.role === 'admin';
|
||||
fetchEntries();
|
||||
fetchStats();
|
||||
fetchTopics();
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('userRole');
|
||||
localStorage.removeItem('currentUser');
|
||||
window.location.href = '/login.html';
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
|
||||
const redirectToPage = (page) => { window.location.href = page.startsWith('/') ? page : '/' + page; };
|
||||
|
||||
onMounted(() => {
|
||||
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 };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>导航组件调试</title>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<navigation-component current-page="dashboard" :is-admin="true" @navigate="()=>{}"></navigation-component>
|
||||
</div>
|
||||
|
||||
<script src="navigation-component.js"></script>
|
||||
<script>
|
||||
// 手动检查
|
||||
console.log('NavigationComponent:', window.NavigationComponent);
|
||||
console.log('installNavigation:', window.installNavigation);
|
||||
|
||||
const App = { data() { return { isAdmin: true } } };
|
||||
const app = Vue.createApp(App);
|
||||
|
||||
if (window.installNavigation) {
|
||||
window.installNavigation(app);
|
||||
console.log('通过 installNavigation 注册');
|
||||
} else if (window.NavigationComponent) {
|
||||
app.component('navigation-component', window.NavigationComponent);
|
||||
console.log('直接注册组件');
|
||||
// 手动注入样式
|
||||
const style = document.createElement('style');
|
||||
style.textContent = '.navigation-wrapper .sidebar { position: fixed; top: 0; left: 0; bottom: 0; width: 180px; background: #f0f0f0; z-index: 9999; }';
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
app.use(ElementPlus);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 67 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">
|
||||
<rect width="192" height="192" fill="#409EFF" rx="24"/>
|
||||
<text x="96" y="120" font-family="Arial, sans-serif" font-size="80" font-weight="bold" fill="white" text-anchor="middle">宇</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 287 B |
Binary file not shown.
|
After Width: | Height: | Size: 67 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" fill="#409EFF" rx="48"/>
|
||||
<text x="256" y="320" font-family="Arial, sans-serif" font-size="200" font-weight="bold" fill="white" text-anchor="middle">宇</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 289 B |
@@ -8,13 +8,7 @@
|
||||
<style>
|
||||
/* 深色渐变背景主题 */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
/* 深色渐变: #1a1a2e → #16213e → #0f3460 */
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
min-height: 100vh;
|
||||
color: #e0e6ed;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; min-height: 100vh; }
|
||||
|
||||
/* 导航栏 */
|
||||
.navbar {
|
||||
@@ -453,9 +447,12 @@
|
||||
},
|
||||
handleLogout() {
|
||||
localStorage.removeItem('authToken');
|
||||
localStorage.removeItem('userRole');
|
||||
localStorage.removeItem('currentUser');
|
||||
this.isLoggedIn = false;
|
||||
this.currentUser = { username: '' };
|
||||
this.isAdmin = false;
|
||||
window.location.href = '/login.html';
|
||||
},
|
||||
async fetchStats() {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
<!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">
|
||||
<style>
|
||||
/* 深色渐变背景主题 */
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
/* 深色渐变: #1a1a2e → #16213e → #0f3460 */
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||
min-height: 100vh;
|
||||
color: #e0e6ed;
|
||||
}
|
||||
|
||||
/* 导航栏 */
|
||||
.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;
|
||||
}
|
||||
.sidebar-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
color: #a0aec0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar-btn:hover {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
color: #667eea;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
.sidebar-btn.active {
|
||||
background: linear-gradient(90deg, rgba(102, 126, 234, 0.2), rgba(118, 75, 162, 0.2));
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
.sidebar-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: linear-gradient(180deg, #667eea, #764ba2);
|
||||
border-radius: 0 4px 4px 0;
|
||||
}
|
||||
|
||||
/* 内容区域 */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 32px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 页面切换 */
|
||||
.page { display: none; animation: fadeIn 0.5s ease-out; }
|
||||
.page.active { display: block; }
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(20px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 统计卡片网格 */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
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);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
cursor: pointer;
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
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);
|
||||
}
|
||||
.stat-title {
|
||||
font-size: 13px;
|
||||
color: #a0aec0;
|
||||
margin-bottom: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 36px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stat-card.primary .stat-value { background: linear-gradient(135deg, #667eea, #764ba2); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.stat-card.success .stat-value { background: linear-gradient(135deg, #67c23a, #85e61d); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.stat-card.warning .stat-value { background: linear-gradient(135deg, #e6a23c, #f5c543); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.stat-card.danger .stat-value { background: linear-gradient(135deg, #f56c6c, #f79296); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
.stat-card.info .stat-value { background: linear-gradient(135deg, #409eff, #5cd0f3); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
|
||||
|
||||
/* 模块卡片 */
|
||||
.module-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
.module-card {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(102, 126, 234, 0.1);
|
||||
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);
|
||||
}
|
||||
.module-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.module-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #e0e6ed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.module-status {
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: rgba(103, 194, 58, 0.2);
|
||||
color: #67c23a;
|
||||
border: 1px solid rgba(103, 194, 58, 0.3);
|
||||
}
|
||||
.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); }
|
||||
}
|
||||
.module-content {
|
||||
font-size: 14px;
|
||||
color: #a0aec0;
|
||||
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);
|
||||
}
|
||||
.module-content div:last-child { border-bottom: none; }
|
||||
|
||||
/* 移动端导航 */
|
||||
.mobile-nav {
|
||||
display: none;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(26, 26, 46, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
border-top: 1px solid rgba(102, 126, 234, 0.2);
|
||||
padding: 8px 0;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
.mobile-nav-btn {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: #a0aec0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.mobile-nav-btn.active {
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
.mobile-nav-btn.active::before {
|
||||
content: '';
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { display: none; }
|
||||
.mobile-nav { display: flex; }
|
||||
.content-area {
|
||||
padding: 16px;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
.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; }
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
body { background: #f5f7fa !important; color: #303133 !important; }
|
||||
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%) !important; border-bottom: none !important; backdrop-filter: none !important; }
|
||||
.navbar-title { background: none !important; -webkit-text-fill-color: white !important; color: white !important; }
|
||||
.user-info { color: white !important; }
|
||||
.avatar { background: rgba(255,255,255,0.2) !important; color: white !important; }
|
||||
.sidebar { background: white !important; border-right: 1px solid #ebeef5 !important; backdrop-filter: none !important; }
|
||||
.sidebar-btn { color: #606266 !important; }
|
||||
.sidebar-btn:hover, .sidebar-btn.active { background: #ecf5ff !important; color: #409eff !important; }
|
||||
.mobile-nav { background: white !important; border-top: 1px solid #ebeef5 !important; backdrop-filter: none !important; box-shadow: 0 -2px 8px rgba(0,0,0,0.1) !important; }
|
||||
.mobile-nav-btn { color: #606266 !important; }
|
||||
.mobile-nav-btn.active { color: #409eff !important; }
|
||||
.mobile-nav-btn.active::before { content: none !important; display: none !important; }
|
||||
#page-overview h2, #page-overview h3, #page-overview .module-title { color: #303133 !important; }
|
||||
#page-overview .module-content { color: #303133 !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav class="navbar" v-if="isLoggedIn">
|
||||
<div class="navbar-content">
|
||||
<h1 class="navbar-title">宇之然内容创作平台</h1>
|
||||
<div class="navbar-user">
|
||||
<div class="user-info">
|
||||
<div class="avatar">{{ currentUser.username ? currentUser.username.charAt(0).toUpperCase() : '?' }}</div>
|
||||
<span>{{ currentUser.username }}</span>
|
||||
<el-tag v-if="isAdmin" size="small" type="danger" style="border: none;">管理员</el-tag>
|
||||
</div>
|
||||
<el-button size="small" type="danger" plain @click="handleLogout">退出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="main-content" v-if="isLoggedIn">
|
||||
<aside class="sidebar">
|
||||
<button class="sidebar-btn" :class="{ active: currentPage === 'overview' }" @click="redirectToPage('/')">
|
||||
📊 系统概览
|
||||
</button>
|
||||
<button class="sidebar-btn" :class="{ active: currentPage === 'topics' }" @click="redirectToPage('topics.html')">
|
||||
📋 选题管理
|
||||
</button>
|
||||
<button class="sidebar-btn" :class="{ active: currentPage === 'logs' }" @click="redirectToPage('logs.html')">
|
||||
📄 系统日志
|
||||
</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn" :class="{ active: currentPage === 'users' }" @click="redirectToPage('users.html')">
|
||||
👥 用户管理
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<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;">
|
||||
📊 系统概览
|
||||
</h2>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card primary" @click="goToTopics('')">
|
||||
<div class="stat-title">选题总数</div>
|
||||
<div class="stat-value">{{ stats.total }}</div>
|
||||
</div>
|
||||
<div class="stat-card warning" @click="goToTopics('pending')">
|
||||
<div class="stat-title">待处理</div>
|
||||
<div class="stat-value">{{ stats.pending }}</div>
|
||||
</div>
|
||||
<div class="stat-card danger" @click="goToTopics('review')">
|
||||
<div class="stat-title">待审查</div>
|
||||
<div class="stat-value">{{ stats.review }}</div>
|
||||
</div>
|
||||
<div class="stat-card success" @click="goToTopics('ready')">
|
||||
<div class="stat-title">待发布</div>
|
||||
<div class="stat-value">{{ stats.ready }}</div>
|
||||
</div>
|
||||
<div class="stat-card info" @click="goToTopics('published')">
|
||||
<div class="stat-title">已发布</div>
|
||||
<div class="stat-value">{{ stats.published }}</div>
|
||||
</div>
|
||||
<div class="stat-card primary" @click="goToTopics('')">
|
||||
<div class="stat-title">今日新增</div>
|
||||
<div class="stat-value">{{ stats.today }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模块状态 -->
|
||||
<h3 style="font-size: 20px; font-weight: 600; margin-bottom: 24px; color: #e0e6ed;">
|
||||
🔧 模块状态
|
||||
</h3>
|
||||
<div class="module-grid">
|
||||
<div class="module-card">
|
||||
<div class="module-header">
|
||||
<span class="module-title">🤖 内容创作引擎</span>
|
||||
<span class="module-status running">运行中</span>
|
||||
</div>
|
||||
<div class="module-content">
|
||||
<div><span>最后运行</span><span>2026-04-27 14:30</span></div>
|
||||
<div><span>今日任务</span><span>12 个</span></div>
|
||||
<div><span>成功率</span><span>95%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="module-card">
|
||||
<div class="module-header">
|
||||
<span class="module-title">🔍 内容优化器</span>
|
||||
<span class="module-status running">运行中</span>
|
||||
</div>
|
||||
<div class="module-content">
|
||||
<div><span>最后运行</span><span>2026-04-27 14:45</span></div>
|
||||
<div><span>今日优化</span><span>8 个</span></div>
|
||||
<div><span>平均提升</span><span>+12 分</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="module-card">
|
||||
<div class="module-header">
|
||||
<span class="module-title">📡 内容收集器</span>
|
||||
<span class="module-status running">运行中</span>
|
||||
</div>
|
||||
<div class="module-content">
|
||||
<div><span>最后运行</span><span>2026-04-27 14:00</span></div>
|
||||
<div><span>今日收集</span><span>24 个</span></div>
|
||||
<div><span>来源平台</span><span>8 个</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="module-card">
|
||||
<div class="module-header">
|
||||
<span class="module-title">📤 发布管理器</span>
|
||||
<span class="module-status running">运行中</span>
|
||||
</div>
|
||||
<div class="module-content">
|
||||
<div><span>最后运行</span><span>2026-04-27 13:30</span></div>
|
||||
<div><span>今日发布</span><span>5 个</span></div>
|
||||
<div><span>成功率</span><span>100%</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- 移动端导航 -->
|
||||
<nav class="mobile-nav" v-if="isLoggedIn">
|
||||
<button class="mobile-nav-btn" :class="{ active: currentPage === 'overview' }" @click="redirectToPage('/')">
|
||||
📊 概览
|
||||
</button>
|
||||
<button class="mobile-nav-btn" :class="{ active: currentPage === 'topics' }" @click="redirectToPage('topics.html')">
|
||||
📋 选题
|
||||
</button>
|
||||
<button class="mobile-nav-btn" :class="{ active: currentPage === 'logs' }" @click="redirectToPage('logs.html')">
|
||||
📄 日志
|
||||
</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn" :class="{ active: currentPage === 'users' }" @click="redirectToPage('users.html')">
|
||||
👥 用户
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const App = {
|
||||
data() {
|
||||
return {
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
currentUser: { username: '' },
|
||||
currentPage: 'overview',
|
||||
stats: {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
review: 0,
|
||||
ready: 0,
|
||||
published: 0,
|
||||
today: 0
|
||||
}
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async handleLogin() {
|
||||
this.loginLoading = true;
|
||||
this.loginError = '';
|
||||
try {
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.loginForm)
|
||||
});
|
||||
if (!response.ok) throw new Error('登录失败');
|
||||
const data = await response.json();
|
||||
localStorage.setItem('authToken', data.token);
|
||||
this.currentUser = data.user;
|
||||
this.isAdmin = data.user.role === 'admin';
|
||||
this.isLoggedIn = true;
|
||||
this.currentPage = 'overview';
|
||||
this.fetchStats();
|
||||
} catch (error) {
|
||||
this.loginError = '用户名或密码错误';
|
||||
} finally {
|
||||
this.loginLoading = false;
|
||||
}
|
||||
},
|
||||
handleLogout() {
|
||||
localStorage.removeItem('authToken');
|
||||
this.isLoggedIn = false;
|
||||
this.currentUser = { username: '' };
|
||||
this.isAdmin = false;
|
||||
},
|
||||
async fetchStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
window.location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/status', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
// API返回格式: { stats: { total, pending, review, ready, published, today } }
|
||||
this.stats = {
|
||||
total: data.stats?.total || 0,
|
||||
pending: data.stats?.pending || 0,
|
||||
review: data.stats?.review || 0,
|
||||
ready: data.stats?.ready || 0,
|
||||
published: data.stats?.published || 0,
|
||||
today: data.stats?.today || 0
|
||||
};
|
||||
} else if (response.status === 401) {
|
||||
// Token无效,清除并跳转登录
|
||||
localStorage.removeItem('authToken');
|
||||
window.location.href = '/login.html';
|
||||
} else {
|
||||
console.error('获取统计信息失败:', response.status, response.statusText);
|
||||
this.stats = { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取统计信息失败:', error);
|
||||
// 失败时设置为0,避免页面空白
|
||||
this.stats = { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 };
|
||||
}
|
||||
},
|
||||
goToTopics(filter) {
|
||||
const url = filter ? '/topics.html?filter=' + encodeURIComponent(filter) : '/topics.html';
|
||||
window.location.href = url;
|
||||
},
|
||||
redirectToPage(page) {
|
||||
window.location.href = page.startsWith('/') ? page : '/' + page;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
window.location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
})
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => {
|
||||
this.currentUser = data.user;
|
||||
this.isAdmin = data.user.role === 'admin';
|
||||
this.isLoggedIn = true;
|
||||
this.currentPage = 'overview';
|
||||
this.fetchStats();
|
||||
})
|
||||
.catch(() => {
|
||||
localStorage.removeItem('authToken');
|
||||
window.location.href = '/login.html';
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const app = Vue.createApp(App);
|
||||
app.use(ElementPlus);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -14,7 +14,7 @@
|
||||
.navbar-user { display: flex; align-items: center; gap: 16px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
|
||||
.sidebar { width: 180px; background: white; padding: 16px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
|
||||
|
||||
|
||||
@@ -132,16 +132,19 @@
|
||||
this.loadingLogs = false;
|
||||
}
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
|
||||
redirectToPage(page) { window.location.href = page; }
|
||||
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() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; })
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/'; return; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; })
|
||||
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
|
||||
this.checkAuth();
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(LogsApp);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
.navbar-user { display: flex; align-items: center; gap: 16px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
|
||||
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
@@ -162,8 +162,24 @@ const MetricsApp = {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
|
||||
redirectToPage(page) { window.location.href = page; },
|
||||
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() {
|
||||
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.fetchDashboard();
|
||||
this.fetchTrend();
|
||||
this.fetchPlatformData();
|
||||
this.fetchRecommendations();
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
},
|
||||
getStatusLabel(status) {
|
||||
const map = { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布' };
|
||||
return map[status] || status;
|
||||
@@ -205,20 +221,7 @@ const MetricsApp = {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/'; 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.fetchDashboard();
|
||||
this.fetchTrend();
|
||||
this.fetchPlatformData();
|
||||
this.fetchRecommendations();
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
|
||||
this.checkAuth();
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(MetricsApp);
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
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 2px 8px rgba(0,0,0,0.1); z-index: 10000; }
|
||||
.navbar-component .navbar-title { font-size: 18px; font-weight: 600; }
|
||||
.navbar-component .navbar-title { font-size: 18px; font-weight: 600; margin: 0; }
|
||||
.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: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.navbar-component .logout-btn { background: rgba(255,255,255,0.2); border: none; color: white; padding: 6px 12px; border-radius: 6px; cursor: pointer; margin-left: 12px; }
|
||||
.navbar-component .logout-btn:hover { background: rgba(255,255,255,0.3); }
|
||||
.navbar-component .admin-badge { margin-left: 8px; font-size: 12px; background: rgba(255,255,255,0.3); padding: 2px 8px; border-radius: 10px; }
|
||||
@media (max-width: 768px) {
|
||||
.navbar-component { padding: 0 16px; }
|
||||
.navbar-component .navbar-title { font-size: 16px; }
|
||||
@@ -22,26 +25,9 @@
|
||||
console.log('[Navbar] 样式已注入');
|
||||
}
|
||||
|
||||
function getHFromApp(app) {
|
||||
try {
|
||||
if (app._context && app._context.h) return app._context.h;
|
||||
if (app._instance && app._instance.proxy && app._instance.proxy._cf) return app._instance.proxy._cf;
|
||||
} catch (e) { console.warn('[Navbar] 获取 h 失败', e); }
|
||||
return null;
|
||||
}
|
||||
|
||||
const installNavbar = (app) => {
|
||||
console.log('[Navbar] installNavbar called');
|
||||
injectStyles();
|
||||
const h = getHFromApp(app) || (window.Vue && Vue.h) || function(tag, data, children) {
|
||||
const el = document.createElement(tag);
|
||||
if (data && data.class) el.className = data.class;
|
||||
if (children) {
|
||||
if (Array.isArray(children)) children.forEach(c => { if (typeof c === 'string') el.appendChild(document.createTextNode(c)); else if (c && c.nodeType) el.appendChild(c); });
|
||||
else if (typeof children === 'string') el.textContent = children;
|
||||
}
|
||||
if (data && data.on) Object.keys(data.on).forEach(ev => el.addEventListener(ev, data.on[ev]));
|
||||
};
|
||||
|
||||
const NavbarComponent = {
|
||||
name: 'NavbarComponent',
|
||||
@@ -51,25 +37,32 @@
|
||||
isAdmin: { type: Boolean, default: false },
|
||||
onLogout: { type: Function, default: null }
|
||||
},
|
||||
render(){
|
||||
const createElement = arguments[0] || h;
|
||||
return createElement('nav', { class: 'navbar-component' }, [
|
||||
createElement('div', { class: 'navbar-content', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' } }, [
|
||||
createElement('h1', { class: 'navbar-title' }, this.title),
|
||||
createElement('div', { class: 'navbar-user' }, [
|
||||
createElement('div', { class: 'user-info' }, [
|
||||
createElement('div', { class: 'avatar' }, this.username ? this.username.charAt(0).toUpperCase() : '?'),
|
||||
this.username ? createElement('span', this.username) : null
|
||||
]),
|
||||
this.isAdmin ? createElement('span', { style: { marginLeft: '8px', fontSize: '12px', background: 'rgba(255,255,255,0.3)', padding: '2px 8px', borderRadius: '10px' } }, '管理员') : null,
|
||||
createElement('button', {
|
||||
class: 'logout-btn',
|
||||
style: { background: 'rgba(255,255,255,0.2)', border: 'none', color: 'white', padding: '6px 12px', borderRadius: '6px', cursor: 'pointer', marginLeft: '12px' },
|
||||
on: { click: () => { if (this.onLogout) this.onLogout(); else window.location.href = '/login.html'; } }
|
||||
}, '退出')
|
||||
])
|
||||
])
|
||||
]);
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -80,4 +73,4 @@
|
||||
|
||||
window.installNavbar = installNavbar;
|
||||
console.log('[Navbar] 脚本已加载');
|
||||
})();
|
||||
})();
|
||||
@@ -5,17 +5,20 @@
|
||||
function injectStyles() {
|
||||
if (document.getElementById('navigation-styles')) return;
|
||||
const styles = `
|
||||
.navigation-wrapper .sidebar { position: fixed; top: 60px; left: 0; bottom: 0; width: 180px; background: #fff; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.1); overflow-y: auto; z-index: 99999; border-right: 1px solid #ebeef5; }
|
||||
.navigation-wrapper .sidebar-header { padding: 12px 8px 16px; border-bottom: 1px solid #ebeef5; margin-bottom: 12px; }
|
||||
.navigation-wrapper .sidebar-header h3 { margin: 0; font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.navigation-wrapper .sidebar-btn { width: 100%; text-align: left; padding: 10px 12px; border: none; background: transparent; border-radius: 8px; margin-bottom: 6px; cursor: pointer; transition: all 0.3s; color: #606266; font-size: 14px; display: flex; align-items: center; gap: 6px; }
|
||||
.navigation-wrapper .sidebar-btn:hover { background: #f5f7fa; color: #409eff; }
|
||||
.navigation-wrapper .sidebar-btn.active { background: #ecf5ff; color: #409eff; font-weight: 600 !important; }
|
||||
.navigation-wrapper .mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: #1890ff; box-shadow: 0 -2px 8px rgba(0,0,0,0.2); padding: 8px 0; z-index: 99999; justify-content: space-around; }
|
||||
.navigation-wrapper .mobile-nav-btn { flex: 1; border: none; background: transparent; padding: 10px 4px; text-align: center; font-size: 12px; color: white !important; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.navigation-wrapper .mobile-nav-btn:hover { background: rgba(255,255,255,0.2); }
|
||||
.navigation-wrapper .mobile-nav-btn.active { background: rgba(255,255,255,0.3); font-weight: 600 !important; }
|
||||
@media (max-width: 768px) { .navigation-wrapper .sidebar { display: none !important; } .navigation-wrapper .mobile-nav { display: flex !important; } }
|
||||
.nav-wrapper, .navigation-wrapper { position: fixed; top: 60px; left: 0; bottom: 0; z-index: 9999; }
|
||||
.nav-wrapper .sidebar, .navigation-wrapper .sidebar { position: fixed; top: 60px; left: 0; bottom: 0; width: 180px; background: #fff; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.1); overflow-y: auto; z-index: 99999; border-right: 1px solid #ebeef5; }
|
||||
.nav-wrapper .sidebar-header, .navigation-wrapper .sidebar-header { padding: 12px 8px 16px; border-bottom: 1px solid #ebeef5; margin-bottom: 12px; }
|
||||
.nav-wrapper .sidebar-header h3, .navigation-wrapper .sidebar-header h3 { margin: 0; font-size: 16px; font-weight: 600; color: #303133; }
|
||||
.nav-wrapper .sidebar-btn, .navigation-wrapper .sidebar-btn { width: 100%; text-align: left; padding: 10px 12px; border: none; background: transparent; border-radius: 8px; margin-bottom: 6px; cursor: pointer; transition: all 0.3s; color: #606266; font-size: 14px; display: flex; align-items: center; gap: 6px; }
|
||||
.nav-wrapper .sidebar-btn:hover, .navigation-wrapper .sidebar-btn:hover { background: #f5f7fa; color: #409eff; }
|
||||
.nav-wrapper .sidebar-btn.active, .navigation-wrapper .sidebar-btn.active { background: #ecf5ff; color: #409eff; font-weight: 600 !important; }
|
||||
.nav-wrapper .mobile-nav, .navigation-wrapper .mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: #1890ff; box-shadow: 0 -2px 8px rgba(0,0,0,0.2); padding: 8px 0; z-index: 99999; justify-content: space-around; }
|
||||
.nav-wrapper .mobile-nav-btn, .navigation-wrapper .mobile-nav-btn { flex: 1; border: none; background: transparent; padding: 8px 4px; text-align: center; font-size: 11px; color: white !important; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.nav-wrapper .mobile-nav-btn .nav-icon, .navigation-wrapper .mobile-nav-btn .nav-icon { font-size: 18px; line-height: 1; }
|
||||
.nav-wrapper .mobile-nav-btn .nav-text, .navigation-wrapper .mobile-nav-btn .nav-text { font-size: 10px; margin-top: 2px; }
|
||||
.nav-wrapper .mobile-nav-btn:hover, .navigation-wrapper .mobile-nav-btn:hover { background: rgba(255,255,255,0.2); }
|
||||
.nav-wrapper .mobile-nav-btn.active, .navigation-wrapper .mobile-nav-btn.active { background: rgba(255,255,255,0.3); font-weight: 600 !important; }
|
||||
@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 { margin-left: 180px !important; padding-top: 60px !important; }
|
||||
@media (max-width: 768px) { body > #app > .main-content { margin-left: 0 !important; padding-bottom: 60px !important; } }
|
||||
`;
|
||||
@@ -50,11 +53,11 @@
|
||||
const mobileNav = document.createElement('nav');
|
||||
mobileNav.className = 'mobile-nav';
|
||||
mobileNav.innerHTML = `
|
||||
<button class="mobile-nav-btn ${currentPage==='dashboard'?'active':''}" data-page="/">📊</button>
|
||||
<button class="mobile-nav-btn ${currentPage==='topics'?'active':''}" data-page="topics.html">📋</button>
|
||||
<button class="mobile-nav-btn ${currentPage==='metrics'?'active':''}" data-page="metrics.html">📊</button>
|
||||
<button class="mobile-nav-btn ${currentPage==='assets'?'active':''}" data-page="assets.html">🖼️</button>
|
||||
<button class="mobile-nav-btn ${currentPage==='tasks'?'active':''}" data-page="tasks.html">🚀</button>
|
||||
<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==='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 ${currentPage==='tasks'?'active':''}" data-page="tasks.html"><span class="nav-icon">🚀</span><span class="nav-text">任务</span></button>
|
||||
`;
|
||||
|
||||
wrapper.appendChild(sidebar);
|
||||
@@ -62,10 +65,24 @@
|
||||
|
||||
// 绑定点击事件
|
||||
wrapper.querySelectorAll('button').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const page = btn.getAttribute('data-page');
|
||||
console.log('[Nav] 导航到:', page);
|
||||
if (onNavigate) onNavigate(page);
|
||||
console.log('[Nav] 点击导航:', page);
|
||||
if (onNavigate) {
|
||||
onNavigate(page);
|
||||
} else {
|
||||
// 直接跳转 - 修复路径处理
|
||||
let target;
|
||||
if (page === '/' || page === '') {
|
||||
target = '/';
|
||||
} else if (page.startsWith('/')) {
|
||||
target = page;
|
||||
} else {
|
||||
target = '/' + page;
|
||||
}
|
||||
window.location.href = target;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,36 +103,210 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// 尝试从 Vue 实例获取 isAdmin 和 currentPage
|
||||
let isAdmin = false;
|
||||
// 获取当前页面名称(从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;
|
||||
|
||||
// 尝试从 Vue 实例提取数据
|
||||
if (app && app._instance && app._instance.proxy) {
|
||||
const proxy = app._instance.proxy;
|
||||
if (proxy.isAdmin !== undefined) isAdmin = proxy.isAdmin;
|
||||
if (proxy.currentPage !== undefined) currentPage = proxy.currentPage;
|
||||
if (proxy.redirectToPage) redirectToPage = proxy.redirectToPage;
|
||||
} else if (window.Vue && Vue.app && Vue.app._instance && Vue.app._instance.proxy) {
|
||||
const proxy = Vue.app._instance.proxy;
|
||||
if (proxy.isAdmin !== undefined) isAdmin = proxy.isAdmin;
|
||||
if (proxy.currentPage !== undefined) currentPage = proxy.currentPage;
|
||||
if (proxy.redirectToPage) redirectToPage = proxy.redirectToPage;
|
||||
// 首先检查 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 || ((page) => { window.location.href = page; }));
|
||||
const nav = createNavigation(currentPage, isAdmin, redirectToPage || defaultNavigate);
|
||||
container.insertBefore(nav, container.firstChild);
|
||||
console.log('[Nav] 导航已插入');
|
||||
};
|
||||
|
||||
init();
|
||||
// 延迟执行,确保 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: 'calendar', label: '日历', icon: '📅', page: 'calendar.html' },
|
||||
{ key: 'assets', label: '素材', icon: '🖼️', page: 'assets.html' },
|
||||
{ key: 'tasks', label: '任务', icon: '🚀', page: 'tasks.html' }
|
||||
]
|
||||
};
|
||||
},
|
||||
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>
|
||||
</nav>
|
||||
</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);
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
# 宇之然内容创作平台 - Nginx配置
|
||||
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
# 基本设置
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# 日志格式
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
|
||||
# Gzip压缩
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
|
||||
gzip_comp_level 6;
|
||||
|
||||
# 安全头
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: blob: 'unsafe-inline'" always;
|
||||
|
||||
# 代理缓存
|
||||
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=STATIC:10m inactive=7d use_temp_path=off;
|
||||
|
||||
# 上游服务器
|
||||
upstream backend {
|
||||
server app:8001;
|
||||
keepalive 32;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
client_max_body_size 100M;
|
||||
|
||||
# SSL配置(生产环境)
|
||||
# listen 443 ssl http2;
|
||||
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
|
||||
# 静态文件直接服务
|
||||
location /static/ {
|
||||
alias /root/openclaw-workspace/projects/yu-zhi-ran/platform/frontend/static/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
access_log off;
|
||||
}
|
||||
|
||||
location / {
|
||||
# 前端静态资源缓存
|
||||
proxy_cache STATIC;
|
||||
proxy_cache_valid 200 302 7d;
|
||||
proxy_cache_valid 404 1m;
|
||||
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
|
||||
|
||||
# 反向代理到后端API
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_redirect off;
|
||||
|
||||
# WebSocket支持
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# 超时设置
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
}
|
||||
|
||||
# 健康检查
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# API文档(可选)
|
||||
location /docs {
|
||||
proxy_pass http://backend/docs;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location /redoc {
|
||||
proxy_pass http://backend/redoc;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
}
|
||||
|
||||
# 静态文件服务(如果需要)
|
||||
server {
|
||||
listen 8000;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html login.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,31 +7,57 @@
|
||||
<link rel="stylesheet" href="element-plus.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; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
|
||||
.navbar-title { font-size: 20px; font-weight: 600; }
|
||||
.navbar-user { display: flex; align-items: center; gap: 16px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow-x: auto; }
|
||||
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); padding: 8px 0; z-index: 1000; }
|
||||
.platform-card { border: 1px solid #ebeef5; border-radius: 12px; padding: 20px; margin-bottom: 16px; transition: all 0.3s; }
|
||||
.platform-card:hover { border-color: #409eff; box-shadow: 0 2px 12px rgba(64,158,255,0.15); }
|
||||
.platform-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
|
||||
.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; }
|
||||
.platform-name { font-size: 18px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||
.platform-badge { padding: 4px 12px; border-radius: 16px; font-size: 12px; }
|
||||
.active-badge { background: #f0f9eb; color: #67c23a; }
|
||||
.inactive-badge { background: #f4f4f5; color: #909399; }
|
||||
.platform-info { font-size: 14px; color: #606266; margin-bottom: 12px; }
|
||||
.rule-item { padding: 8px 12px; background: #f5f7fa; border-radius: 4px; margin-bottom: 8px; font-size: 13px; }
|
||||
.platform-actions { display: flex; gap: 8px; align-items: center; flex-shrink: 0; }
|
||||
.platform-badge { padding: 4px 12px; border-radius: 16px; font-size: 12px; font-weight: 500; }
|
||||
.badge-active { background: #f0f9eb; color: #67c23a; }
|
||||
.badge-inactive { background: #f4f4f5; color: #909399; }
|
||||
.platform-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: 14px; color: #606266; margin-bottom: 16px; background: #fafbfc; border-radius: 8px; padding: 12px 16px; }
|
||||
.platform-meta-item { display: flex; align-items: center; gap: 6px; }
|
||||
.platform-meta-label { color: #909399; }
|
||||
.platform-meta-value { color: #303133; font-weight: 500; }
|
||||
|
||||
.rules-section { margin-top: 12px; }
|
||||
.rules-title { font-weight: 600; margin-bottom: 10px; color: #606266; font-size: 14px; display: flex; align-items: center; gap: 6px; }
|
||||
.rule-item { padding: 10px 14px; background: #f5f7fa; border-radius: 8px; margin-bottom: 8px; font-size: 13px; line-height: 1.6; border-left: 3px solid #409eff; }
|
||||
.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) {
|
||||
.sidebar { display: none; }
|
||||
.mobile-nav { display: flex; }
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
.card { padding: 16px; }
|
||||
.platform-card { padding: 16px; }
|
||||
.platform-header { flex-direction: column; }
|
||||
.platform-actions { align-self: flex-end; }
|
||||
.platform-meta { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
<script src="navigation-component.js"></script>
|
||||
@@ -43,19 +69,21 @@
|
||||
<navigation-component current-page="platforms" :is-admin="isAdmin" @navigate="redirectToPage"></navigation-component>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<h2 style="font-size: 24px; font-weight: 700; margin-bottom: 24px; color: #303133;">🌐 平台配置</h2>
|
||||
<div class="card">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; flex-wrap: wrap; gap: 12px;">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title">🌐 平台配置</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>
|
||||
</div>
|
||||
<div v-if="loading" style="text-align: center; padding: 40px;">加载中...</div>
|
||||
<div v-else-if="platforms.length === 0" style="text-align: center; padding: 40px; color: #909399;">
|
||||
<div style="font-size: 48px; margin-bottom: 16px;">🌐</div>
|
||||
<div>暂无平台配置</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<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>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="p in platforms" :key="p.platform" class="platform-card">
|
||||
@@ -64,25 +92,35 @@
|
||||
<span>{{ getPlatformIcon(p.platform) }}</span>
|
||||
{{ getPlatformName(p.platform) }}
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
<span class="platform-badge" :class="p.is_active ? 'active-badge' : 'inactive-badge'">
|
||||
<div class="platform-actions">
|
||||
<span class="platform-badge" :class="p.is_active ? 'badge-active' : 'badge-inactive'">
|
||||
{{ p.is_active ? '✓ 启用' : '○ 停用' }}
|
||||
</span>
|
||||
<el-button size="small" type="primary" @click="editPlatform(p)">编辑</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="platform-info">
|
||||
<div style="margin-bottom: 4px;">平台标识: {{ p.platform }}</div>
|
||||
<div v-if="p.platform_name">平台名称: {{ p.platform_name }}</div>
|
||||
<div class="platform-meta">
|
||||
<div class="platform-meta-item">
|
||||
<span class="platform-meta-label">平台标识</span>
|
||||
<span class="platform-meta-value">{{ p.platform }}</span>
|
||||
</div>
|
||||
<div v-if="p.platform_name" class="platform-meta-item">
|
||||
<span class="platform-meta-label">平台名称</span>
|
||||
<span class="platform-meta-value">{{ p.platform_name }}</span>
|
||||
</div>
|
||||
<div v-if="p.min_words || p.max_words" class="platform-meta-item">
|
||||
<span class="platform-meta-label">字数限制</span>
|
||||
<span class="platform-meta-value">{{ p.min_words || 0 }} - {{ p.max_words || '不限' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="p.format_rules && Object.keys(p.format_rules).length > 0">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">格式规则:</div>
|
||||
<div v-if="p.format_rules && Object.keys(p.format_rules).length > 0" class="rules-section">
|
||||
<div class="rules-title">📋 格式规则</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">
|
||||
<div style="font-weight: 600; margin-bottom: 8px;">合规规则:</div>
|
||||
<div v-if="p.compliance_rules && p.compliance_rules.length > 0" class="rules-section">
|
||||
<div class="rules-title">⚖️ 合规规则</div>
|
||||
<div v-for="(rule, idx) in p.compliance_rules" :key="idx" class="rule-item">{{ rule }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,8 +176,21 @@ const PlatformsApp = {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
|
||||
redirectToPage(page) { window.location.href = page; },
|
||||
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() {
|
||||
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.loadPlatforms();
|
||||
})
|
||||
.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] || '🌐';
|
||||
@@ -184,17 +235,7 @@ const PlatformsApp = {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/'; 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.loadPlatforms();
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
|
||||
this.checkAuth();
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(PlatformsApp);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
.navbar-user { display: flex; align-items: center; gap: 16px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
|
||||
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
@@ -146,8 +146,21 @@ const TasksApp = {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
|
||||
redirectToPage(page) { window.location.href = page; },
|
||||
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() {
|
||||
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.loadTasks();
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
},
|
||||
getStatusLabel(status) {
|
||||
const map = { 'pending': '等待中', 'running': '进行中', 'completed': '已完成', 'failed': '失败', 'cancelled': '已取消' };
|
||||
return map[status] || status;
|
||||
@@ -185,17 +198,7 @@ const TasksApp = {
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/'; 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.loadTasks();
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
|
||||
this.checkAuth();
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(TasksApp);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="navigation-component.js"></script>
|
||||
<style>
|
||||
body { margin: 0; padding: 0; }
|
||||
.navbar { height: 60px; background: #333; color: white; display: flex; align-items: center; padding: 0 20px; }
|
||||
.main-content { padding: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<div class="navbar">Test Navbar</div>
|
||||
<navigation-component current-page="dashboard" :is-admin="true" @navigate="()=>{}"></navigation-component>
|
||||
<div class="main-content">
|
||||
<h1>内容区域</h1>
|
||||
<p>侧边栏应该在左侧显示。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const app = Vue.createApp({
|
||||
data() { return { isAdmin: true } }
|
||||
});
|
||||
if (window.installNavigation) {
|
||||
window.installNavigation(app);
|
||||
console.log('已调用 installNavigation');
|
||||
} else {
|
||||
console.error('installNavigation 未定义');
|
||||
}
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>导航组件测试</title>
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script src="navigation-component.js"></script>
|
||||
<style>
|
||||
body { margin: 0; font-family: sans-serif; }
|
||||
.navbar { background: #2563eb; color: white; padding: 16px; }
|
||||
.main-content { padding: 24px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav class="navbar">
|
||||
<span>测试页面</span>
|
||||
</nav>
|
||||
|
||||
<navigation-component
|
||||
current-page="topics"
|
||||
:is-admin="true"
|
||||
@navigate="redirectToPage"
|
||||
></navigation-component>
|
||||
|
||||
<div class="main-content">
|
||||
<h1>内容区域</h1>
|
||||
<p>如果看到左边栏,说明组件工作正常。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const App = {
|
||||
data() {
|
||||
return { isAdmin: true };
|
||||
},
|
||||
methods: {
|
||||
redirectToPage(page) {
|
||||
console.log('导航到:', page);
|
||||
}
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(App);
|
||||
if (window.installNavigation) { window.installNavigation(app); }
|
||||
app.use(ElementPlus);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -16,7 +16,7 @@
|
||||
.navbar-user { display: flex; align-items: center; gap: 16px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
|
||||
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
|
||||
|
||||
|
||||
@@ -600,8 +600,8 @@ const TopicsApp = {
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
|
||||
redirectToPage(page) { window.location.href = page; },
|
||||
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) {
|
||||
const statusMap = {
|
||||
'pending': '待处理',
|
||||
@@ -647,7 +647,7 @@ const TopicsApp = {
|
||||
console.log('[DEBUG] TopicsApp mounted');
|
||||
const token = localStorage.getItem('authToken');
|
||||
console.log('[DEBUG] Token exists:', !!token);
|
||||
if (!token) { window.location.href = '/'; return; }
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
// 解析 URL filter 参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const filter = urlParams.get('filter');
|
||||
@@ -662,7 +662,7 @@ const TopicsApp = {
|
||||
this.isLoggedIn = true;
|
||||
this.fetchTopics();
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(TopicsApp);
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
<!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">
|
||||
<style>
|
||||
.preview-iframe { box-sizing: border-box; }
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
|
||||
.navbar-title { font-size: 20px; font-weight: 600; }
|
||||
.navbar-user { display: flex; align-items: center; gap: 16px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
|
||||
.sidebar-btn { width: 100%; text-align: left; padding: 8px 12px; border: none; background: transparent; border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: all 0.3s; color: #606266; font-size: 14px; }
|
||||
.sidebar-btn:hover { background: #f5f7fa; color: #409eff; }
|
||||
.sidebar-btn.active { background: #ecf5ff; color: #409eff; font-weight: 600; }
|
||||
.content-area { flex: 1; padding: 32px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.status-badge { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.status-dot { width: 6px; height: 6px; border-radius: 50%; }
|
||||
.status-dot.pending { background: #E6A23C; }
|
||||
.status-dot.review { background: #F56C6C; }
|
||||
.status-dot.ready { background: #67C23A; }
|
||||
.status-dot.published { background: #409EFF; }
|
||||
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); padding: 8px 0; z-index: 1000; }
|
||||
.mobile-nav-btn { flex: 1; border: none; background: transparent; padding: 12px; text-align: center; font-size: 12px; color: #606266; cursor: pointer; }
|
||||
.mobile-nav-btn.active { color: #409eff; font-weight: 600; }
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { display: none; }
|
||||
.mobile-nav { display: flex; }
|
||||
.content-area { padding: 12px; padding-bottom: 80px; }
|
||||
}
|
||||
|
||||
.topic-card-list { display: none; }
|
||||
/* 移动端卡片布局 */
|
||||
@media (max-width: 768px) {
|
||||
.el-table { font-size: 12px; display: none; }
|
||||
.el-table .el-button { padding: 4px 8px; font-size: 11px; min-height: auto; }
|
||||
.el-table .cell { padding: 0 4px; }
|
||||
.el-table .el-table__cell { padding: 6px 0; }
|
||||
.topic-card-list { display: block; margin: 0 -16px; }
|
||||
.topic-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.topic-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.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; }
|
||||
.mobile-nav { display: flex; }
|
||||
}
|
||||
|
||||
.preview-iframe { box-sizing: border-box; }
|
||||
/* 预览弹窗响应式高度 */
|
||||
@media (min-width: 769px) {
|
||||
.preview-iframe { max-height: calc(100vh - 100px) !important; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.preview-iframe { max-height: calc(100vh - 250px) !important; }
|
||||
}
|
||||
|
||||
/* 预览弹窗自定义高度(非全屏时) */
|
||||
.preview-dialog-custom.el-dialog {
|
||||
max-height: calc(100vh - 90px) !important;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__header {
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__body {
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__footer {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav class="navbar">
|
||||
<div class="navbar-content">
|
||||
<h1 class="navbar-title">宇之然内容创作平台 - 选题管理</h1>
|
||||
<div class="navbar-user">
|
||||
<div class="user-info"><div class="avatar">{{ currentUser.username ? currentUser.username.charAt(0).toUpperCase() : '?' }}</div><span>{{ currentUser.username }}</span></div>
|
||||
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="main-content">
|
||||
<aside class="sidebar">
|
||||
<button class="sidebar-btn" @click="redirectToPage('/')">📊 系统概览</button>
|
||||
<button class="sidebar-btn active">📋 选题管理</button>
|
||||
<button class="sidebar-btn" @click="redirectToPage('logs.html')">📄 系统日志</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('users.html')">👥 用户管理</button>
|
||||
<button v-if="isAdmin" class="sidebar-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
|
||||
</aside>
|
||||
<main class="content-area">
|
||||
<div class="card">
|
||||
<h2 style="font-size: 28px; font-weight: 700; margin-bottom: 32px; color: #303133;">📋 选题管理</h2>
|
||||
<div class="card" style="display: inline-block; min-width: fit-content; padding: 12px; margin-bottom: 24px;">
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<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="triggerOptimizeSelected" :disabled="selectedTopicIds.length === 0">🔍 批量优化</el-button>
|
||||
<span class="ml-auto text-sm text-gray-500" v-if="selectedTopicIds.length > 0" style="color: #909399; font-size: 14px; margin-left: auto;">已选 {{ selectedTopicIds.length }} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap;">
|
||||
<el-button size="large" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''">全部 ({{ topics.length }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'">待处理 ({{ statusStats.pending }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'">待审查 ({{ statusStats.review }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'">待发布 ({{ statusStats.ready }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'">已发布 ({{ statusStats.published }})</el-button>
|
||||
</div>
|
||||
<div class="card" style="width: 100%; overflow-x: auto; padding: 12px;">
|
||||
<el-table :data="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event">
|
||||
<el-table-column type="selection" width="55"></el-table-column>
|
||||
<el-table-column prop="id" label="ID" width="70" fixed></el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="200"></el-table-column>
|
||||
<el-table-column prop="field" label="领域" width="100"></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ getStatusLabel(scope.row.status) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="compliance_score" label="合规分" width="90">
|
||||
<template #default="scope"><el-progress :percentage="scope.row.compliance_score || 0" :format="() => scope.row.compliance_score || '-'" :stroke-width="15"></el-progress></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 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="180" 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="optimizeTopic(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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="topic-card-list" v-if="filteredTopics && filteredTopics.length > 0">
|
||||
<div v-for="(topic, index) in filteredTopics" :key="topic.id" class="topic-card">
|
||||
<div class="topic-card-header">
|
||||
<div class="topic-card-title">{{ topic.id }}. {{ topic.title }}</div>
|
||||
<el-tag :type="getStatusType(topic.status)" size="small">{{ getStatusLabel(topic.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="topic-card-tags">
|
||||
<el-tag size="small" type="info">{{ topic.field }}</el-tag>
|
||||
<el-tag size="small" type="warning">合规{{ topic.compliance_score }}</el-tag>
|
||||
</div>
|
||||
<div class="topic-card-meta">
|
||||
<div>创建: {{ formatDate(topic.created_at) }}</div>
|
||||
<div>创作: {{ topic.generated_at ? formatDate(topic.generated_at) : '-' }}</div>
|
||||
<div>发布: {{ topic.published_at ? formatDate(topic.published_at) : '-' }}</div>
|
||||
</div>
|
||||
<div class="topic-card-actions">
|
||||
<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="optimizeTopic(topic)">审查</el-button>
|
||||
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="handlePublish(topic)">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<nav class="mobile-nav">
|
||||
<button class="mobile-nav-btn" @click="redirectToPage('/')">📊 概览</button>
|
||||
<button class="mobile-nav-btn active">📋 选题</button>
|
||||
<button class="mobile-nav-btn" @click="redirectToPage('logs.html')">📄 日志</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('users.html')">👥 用户</button>
|
||||
<button v-if="isAdmin" class="mobile-nav-btn" @click="redirectToPage('admin.html')">⚙️ 系统</button>
|
||||
</nav>
|
||||
<!-- 预览弹窗 -->
|
||||
<el-dialog v-model="previewVisible" title="选题预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen">
|
||||
<div v-if="previewTopic">
|
||||
<!-- 平台切换按钮 -->
|
||||
<div style="margin-bottom: 16px; display: flex; justify-content: flex-end; gap: 8px;">
|
||||
<el-button-group>
|
||||
<el-button :type="previewPlatform === 'zhihu' ? 'primary' : 'default'" @click="previewPlatform = 'zhihu'">知乎</el-button>
|
||||
<el-button :type="previewPlatform === 'wechat' ? 'primary' : 'default'" @click="previewPlatform = 'wechat'">微信公众号</el-button>
|
||||
<el-button :type="previewPlatform === 'xiaohongshu' ? 'primary' : 'default'" @click="previewPlatform = 'xiaohongshu'">小红书</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
|
||||
<h2 style="margin:0; font-size:16px;">{{ previewTopic.title }}</h2>
|
||||
<div style="display:flex; align-items:center; gap:12px; font-size:13px; color:#909399;">
|
||||
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
||||
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
|
||||
<span v-if="previewTopic.generated_at">创作:{{ formatDate(previewTopic.generated_at) }}</span>
|
||||
<span v-if="previewTopic.published_at">发布:{{ formatDate(previewTopic.published_at) }}</span>
|
||||
<el-button size="small" @click="togglePreviewFullscreen">
|
||||
{{ previewFullscreen ? '退出全屏' : '全屏' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览内容使用 iframe 隔离样式 -->
|
||||
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
|
||||
<iframe :srcdoc="currentPreviewHtml"
|
||||
class="preview-iframe"
|
||||
style="flex: 1; min-height: 500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding: 0 0; width: 100%;"
|
||||
sandbox>
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<template #footer>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:14px; color:#909399;">
|
||||
<div class="preview-info">
|
||||
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
||||
<span style="margin: 0 8px;">|</span>
|
||||
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
|
||||
<span v-if="previewTopic.generated_at" style="margin-left:8px;">
|
||||
创作:{{ formatDate(previewTopic.generated_at) }}
|
||||
</span>
|
||||
<span v-if="previewTopic.published_at" style="margin-left:8px;">
|
||||
发布:{{ formatDate(previewTopic.published_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="copyContent(previewPlatform)">复制并发布到{{ platformName(previewPlatform) }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
|
||||
<script>
|
||||
const TopicsApp = {
|
||||
data() {
|
||||
return {
|
||||
currentPage: 'topics',
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
currentUser: { username: '' },
|
||||
loadingTable: false,
|
||||
selectedTopicIds: [],
|
||||
filterStatus: '',
|
||||
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
|
||||
topics: [],
|
||||
previewVisible: false,
|
||||
previewTopic: null,
|
||||
previewFullscreen: false,
|
||||
previewPlatform: 'zhihu',
|
||||
platformContents: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredTopics() {
|
||||
if (!this.topics || !this.topics.length) { return []; }
|
||||
if (!this.filterStatus) { return this.topics; }
|
||||
const statusMap = {
|
||||
'pending': ['pending', '待处理'],
|
||||
'review': ['review', '待审查'],
|
||||
'ready': ['ready', '待发布'],
|
||||
'published': ['published', '已发布']
|
||||
};
|
||||
const allowed = statusMap[this.filterStatus] || [this.filterStatus];
|
||||
return this.topics.filter(t => allowed.includes(t.status));
|
||||
},
|
||||
statusStats() {
|
||||
const pending = ['pending', '待处理'];
|
||||
const review = ['review', '待审查'];
|
||||
const ready = ['ready', '待发布'];
|
||||
const published = ['published', '已发布'];
|
||||
return {
|
||||
total: this.topics.length,
|
||||
pending: this.topics.filter(t => pending.includes(t.status)).length,
|
||||
review: this.topics.filter(t => review.includes(t.status)).length,
|
||||
ready: this.topics.filter(t => ready.includes(t.status)).length,
|
||||
published: this.topics.filter(t => published.includes(t.status)).length
|
||||
};
|
||||
},
|
||||
currentPreviewHtml() {
|
||||
const html = this.platformContents[this.previewPlatform];
|
||||
if (!html) return '';
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const body = doc.body;
|
||||
if (!body) return html;
|
||||
|
||||
body.querySelectorAll('script, nav, .header, footer, .interaction').forEach(el => el.remove());
|
||||
|
||||
const head = doc.querySelector('head');
|
||||
const headHtml = head ? head.innerHTML : '';
|
||||
const bodyHtml = body.innerHTML;
|
||||
const ending = '<p style="margin-top:24px;padding-top:16px;border-top:1px solid #eee;color:#666;font-size:14px;">感兴趣可以收藏关注我们,欢迎在评论区分享你的实践经验和改进建议!</p>';
|
||||
|
||||
return `<!DOCTYPE html><html><head>${headHtml}</head><body style="margin:0;padding:0;">${bodyHtml}${ending}</body></html>`;
|
||||
} catch (e) {
|
||||
console.error('生成预览 HTML 失败:', e);
|
||||
return html;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchTopics() {
|
||||
this.loadingTable = true;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
setTimeout(() => window.location.href = '/', 1500);
|
||||
this.loadingTable = false;
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/topics', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.topics = data || [];
|
||||
this.$message.success('选题加载成功');
|
||||
} catch (error) {
|
||||
console.error('获取选题失败:', error);
|
||||
this.$message.error(`获取选题失败: ${error.message}`);
|
||||
this.topics = [];
|
||||
} finally {
|
||||
this.loadingTable = false;
|
||||
}
|
||||
},
|
||||
refreshAll() { this.$message.info('执行批量刷新'); },
|
||||
async triggerGenerateSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
this.$message.success('批量创作已启动');
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
// 取第一个选题(当前简单实现)
|
||||
const topicId = this.selectedTopicIds[0];
|
||||
const response = await fetch('/api/system/generate/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_id: topicId })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
await response.json();
|
||||
this.$message.success('批量创作已启动');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('批量创作失败:', error);
|
||||
this.$message.error(`批量创作失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async triggerOptimizeSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/optimize/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: this.selectedTopicIds })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success('批量优化完成');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('批量优化失败:', error);
|
||||
this.$message.error(`批量优化失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async openPreview(topic) {
|
||||
this.previewTopic = topic;
|
||||
this.previewPlatform = 'zhihu';
|
||||
this.previewVisible = true;
|
||||
this.platformContents = {};
|
||||
// 并行加载所有平台内容
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.warning('请先登录');
|
||||
return;
|
||||
}
|
||||
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
|
||||
const promises = 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))
|
||||
);
|
||||
await Promise.all(promises);
|
||||
},
|
||||
togglePreviewFullscreen() {
|
||||
this.previewFullscreen = !this.previewFullscreen;
|
||||
},
|
||||
platformName(platform) {
|
||||
const names = {
|
||||
zhihu: '知乎',
|
||||
wechat: '微信公众号',
|
||||
xiaohongshu: '小红书'
|
||||
};
|
||||
return names[platform] || platform;
|
||||
},
|
||||
// 计算属性:当前平台预览的完整 HTML(响应式更新)
|
||||
copyContent(platform) {
|
||||
if (!this.previewTopic || !this.previewTopic.content) {
|
||||
this.$message.warning('暂无内容可复制');
|
||||
return;
|
||||
}
|
||||
const text = `标题:${this.previewTopic.title}\n\n内容:\n${this.previewTopic.content}`;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this.$message.success(`已复制内容,请前往${platform}粘贴发布`);
|
||||
}).catch(err => {
|
||||
console.error('复制失败', err);
|
||||
this.$message.error('复制失败,请手动复制');
|
||||
});
|
||||
},
|
||||
async createTopic(topic) {
|
||||
console.log('createTopic clicked, topic:', topic);
|
||||
if (this.isStatus(topic, 'published')) {
|
||||
this.$message.info('已发布选题不可创作');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/generate/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_id: topic.id })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`创作完成: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('创作失败:', error);
|
||||
this.$message.error(`创作失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async optimizeTopic(topic) {
|
||||
if (!this.isStatus(topic, 'review')) {
|
||||
this.$message.info('仅待审查选题可优化');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/optimize/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: [topic.id] })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`优化完成: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('优化失败:', error);
|
||||
this.$message.error(`优化失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async handlePublish(topic) {
|
||||
if (!this.isStatus(topic, 'ready')) {
|
||||
this.$message.info('仅待发布选题可发布');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/publishing/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: [topic.id] })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`发布成功: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('发布失败:', error);
|
||||
this.$message.error(`发布失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async deleteTopic(id) {
|
||||
this.$confirm('确定删除?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/topics/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
this.$message.success('删除成功');
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
this.$message.error(`删除失败: ${error.message}`);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
getStatusLabel(status) {
|
||||
const statusMap = {
|
||||
'pending': '待处理',
|
||||
'review': '待审查',
|
||||
'ready': '待发布',
|
||||
'published': '已发布'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
},
|
||||
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; }
|
||||
},
|
||||
getStatusType(status) {
|
||||
const map = {
|
||||
'pending': 'warning',
|
||||
'review': 'danger',
|
||||
'ready': 'success',
|
||||
'published': 'info',
|
||||
'待处理': 'warning',
|
||||
'待审查': 'danger',
|
||||
'待发布': 'success',
|
||||
'已发布': 'info'
|
||||
};
|
||||
return map[status] || 'primary';
|
||||
},
|
||||
isStatus(row, status) {
|
||||
const map = {
|
||||
'pending': ['pending', '待处理'],
|
||||
'review': ['review', '待审查'],
|
||||
'ready': ['ready', '待发布'],
|
||||
'published': ['published', '已发布']
|
||||
};
|
||||
return map[status] ? map[status].includes(row.status) : row.status === status;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('[DEBUG] TopicsApp mounted');
|
||||
const token = localStorage.getItem('authToken');
|
||||
console.log('[DEBUG] Token exists:', !!token);
|
||||
if (!token) { window.location.href = '/'; return; }
|
||||
// 解析 URL filter 参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const filter = urlParams.get('filter');
|
||||
console.log('[DEBUG] URL filter:', filter);
|
||||
if (filter) { this.filterStatus = filter; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => {
|
||||
console.log('[DEBUG] Auth success, user:', data.user);
|
||||
this.currentUser = data.user;
|
||||
this.isAdmin = data.user.role === 'admin';
|
||||
this.isLoggedIn = true;
|
||||
this.fetchTopics();
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(TopicsApp);
|
||||
app.use(ElementPlus);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,649 @@
|
||||
<!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">
|
||||
<style>
|
||||
.preview-iframe { box-sizing: border-box; }
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
|
||||
.navbar-title { font-size: 20px; font-weight: 600; }
|
||||
.navbar-user { display: flex; align-items: center; gap: 16px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
|
||||
/* 移动端卡片布局 */
|
||||
@media (max-width: 768px) {
|
||||
.el-table .el-button { padding: 4px 8px; font-size: 11px; min-height: auto; }
|
||||
.el-table .cell { padding: 0 4px; }
|
||||
.el-table .el-table__cell { padding: 6px 0; }
|
||||
.topic-card-list { display: block; margin: 0 -16px; }
|
||||
.topic-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.topic-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.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 { box-sizing: border-box; }
|
||||
/* 预览弹窗响应式高度 */
|
||||
@media (min-width: 769px) {
|
||||
.preview-iframe { max-height: calc(100vh - 100px) !important; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.preview-iframe { max-height: calc(100vh - 250px) !important; }
|
||||
}
|
||||
|
||||
/* 预览弹窗自定义高度(非全屏时) */
|
||||
.preview-dialog-custom.el-dialog {
|
||||
max-height: calc(100vh - 90px) !important;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__header {
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__body {
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__footer {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="navigation-component.js"></script></head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav class="navbar">
|
||||
<div class="navbar-content">
|
||||
<h1 class="navbar-title">宇之然内容创作平台 - 选题管理</h1>
|
||||
<div class="navbar-user">
|
||||
<div class="user-info"><div class="avatar">{{ currentUser.username ? currentUser.username.charAt(0).toUpperCase() : '?' }}</div><span>{{ currentUser.username }}</span></div>
|
||||
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="main-content">
|
||||
|
||||
<main class="content-area">
|
||||
<div class="card">
|
||||
<h2 style="font-size: 28px; font-weight: 700; margin-bottom: 32px; color: #303133;">📋 选题管理</h2>
|
||||
<div class="card" style="display: inline-block; min-width: fit-content; padding: 12px; margin-bottom: 24px;">
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<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="triggerOptimizeSelected" :disabled="selectedTopicIds.length === 0">🔍 批量优化</el-button>
|
||||
<span class="ml-auto text-sm text-gray-500" v-if="selectedTopicIds.length > 0" style="color: #909399; font-size: 14px; margin-left: auto;">已选 {{ selectedTopicIds.length }} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap;">
|
||||
<el-button size="large" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''">全部 ({{ topics.length }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'">待处理 ({{ statusStats.pending }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'">待审查 ({{ statusStats.review }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'">待发布 ({{ statusStats.ready }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'">已发布 ({{ statusStats.published }})</el-button>
|
||||
</div>
|
||||
<div class="card" style="width: 100%; overflow-x: auto; padding: 12px;">
|
||||
<el-table :data="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event">
|
||||
<el-table-column type="selection" width="55"></el-table-column>
|
||||
<el-table-column prop="id" label="ID" width="70" fixed></el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="200"></el-table-column>
|
||||
<el-table-column prop="field" label="领域" width="100"></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ getStatusLabel(scope.row.status) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="compliance_score" label="合规分" width="90">
|
||||
<template #default="scope"><el-progress :percentage="scope.row.compliance_score || 0" :format="() => scope.row.compliance_score || '-'" :stroke-width="15"></el-progress></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 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="180" 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="optimizeTopic(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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="topic-card-list" v-if="filteredTopics && filteredTopics.length > 0">
|
||||
<div v-for="(topic, index) in filteredTopics" :key="topic.id" class="topic-card">
|
||||
<div class="topic-card-header">
|
||||
<div class="topic-card-title">{{ topic.id }}. {{ topic.title }}</div>
|
||||
<el-tag :type="getStatusType(topic.status)" size="small">{{ getStatusLabel(topic.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="topic-card-tags">
|
||||
<el-tag size="small" type="info">{{ topic.field }}</el-tag>
|
||||
<el-tag size="small" type="warning">合规{{ topic.compliance_score }}</el-tag>
|
||||
</div>
|
||||
<div class="topic-card-meta">
|
||||
<div>创建: {{ formatDate(topic.created_at) }}</div>
|
||||
<div>创作: {{ topic.generated_at ? formatDate(topic.generated_at) : '-' }}</div>
|
||||
<div>发布: {{ topic.published_at ? formatDate(topic.published_at) : '-' }}</div>
|
||||
</div>
|
||||
<div class="topic-card-actions">
|
||||
<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="optimizeTopic(topic)">审查</el-button>
|
||||
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="handlePublish(topic)">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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">
|
||||
<div v-if="previewTopic">
|
||||
<!-- 平台切换按钮 -->
|
||||
<div style="margin-bottom: 16px; display: flex; justify-content: flex-end; gap: 8px;">
|
||||
<el-button-group>
|
||||
<el-button :type="previewPlatform === 'zhihu' ? 'primary' : 'default'" @click="previewPlatform = 'zhihu'">知乎</el-button>
|
||||
<el-button :type="previewPlatform === 'wechat' ? 'primary' : 'default'" @click="previewPlatform = 'wechat'">微信公众号</el-button>
|
||||
<el-button :type="previewPlatform === 'xiaohongshu' ? 'primary' : 'default'" @click="previewPlatform = 'xiaohongshu'">小红书</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
|
||||
<h2 style="margin:0; font-size:16px;">{{ previewTopic.title }}</h2>
|
||||
<div style="display:flex; align-items:center; gap:12px; font-size:13px; color:#909399;">
|
||||
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
||||
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
|
||||
<span v-if="previewTopic.generated_at">创作:{{ formatDate(previewTopic.generated_at) }}</span>
|
||||
<span v-if="previewTopic.published_at">发布:{{ formatDate(previewTopic.published_at) }}</span>
|
||||
<el-button size="small" @click="togglePreviewFullscreen">
|
||||
{{ previewFullscreen ? '退出全屏' : '全屏' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览内容使用 iframe 隔离样式 -->
|
||||
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
|
||||
<iframe :srcdoc="currentPreviewHtml"
|
||||
class="preview-iframe"
|
||||
style="flex: 1; min-height: 500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding: 0 0; width: 100%;"
|
||||
sandbox>
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<template #footer>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:14px; color:#909399;">
|
||||
<div class="preview-info">
|
||||
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
||||
<span style="margin: 0 8px;">|</span>
|
||||
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
|
||||
<span v-if="previewTopic.generated_at" style="margin-left:8px;">
|
||||
创作:{{ formatDate(previewTopic.generated_at) }}
|
||||
</span>
|
||||
<span v-if="previewTopic.published_at" style="margin-left:8px;">
|
||||
发布:{{ formatDate(previewTopic.published_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="copyContent(previewPlatform)">复制并发布到{{ platformName(previewPlatform) }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
|
||||
<script>
|
||||
const TopicsApp = {
|
||||
data() {
|
||||
return {
|
||||
currentPage: 'topics',
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
currentUser: { username: '' },
|
||||
loadingTable: false,
|
||||
selectedTopicIds: [],
|
||||
filterStatus: '',
|
||||
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
|
||||
topics: [],
|
||||
previewVisible: false,
|
||||
previewTopic: null,
|
||||
previewFullscreen: false,
|
||||
previewPlatform: 'zhihu',
|
||||
platformContents: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredTopics() {
|
||||
if (!this.topics || !this.topics.length) { return []; }
|
||||
if (!this.filterStatus) { return this.topics; }
|
||||
const statusMap = {
|
||||
'pending': ['pending', '待处理'],
|
||||
'review': ['review', '待审查'],
|
||||
'ready': ['ready', '待发布'],
|
||||
'published': ['published', '已发布']
|
||||
};
|
||||
const allowed = statusMap[this.filterStatus] || [this.filterStatus];
|
||||
return this.topics.filter(t => allowed.includes(t.status));
|
||||
},
|
||||
statusStats() {
|
||||
const pending = ['pending', '待处理'];
|
||||
const review = ['review', '待审查'];
|
||||
const ready = ['ready', '待发布'];
|
||||
const published = ['published', '已发布'];
|
||||
return {
|
||||
total: this.topics.length,
|
||||
pending: this.topics.filter(t => pending.includes(t.status)).length,
|
||||
review: this.topics.filter(t => review.includes(t.status)).length,
|
||||
ready: this.topics.filter(t => ready.includes(t.status)).length,
|
||||
published: this.topics.filter(t => published.includes(t.status)).length
|
||||
};
|
||||
},
|
||||
currentPreviewHtml() {
|
||||
const html = this.platformContents[this.previewPlatform];
|
||||
if (!html) return '';
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const body = doc.body;
|
||||
if (!body) return html;
|
||||
|
||||
body.querySelectorAll('script, nav, .header, footer, .interaction').forEach(el => el.remove());
|
||||
|
||||
const head = doc.querySelector('head');
|
||||
const headHtml = head ? head.innerHTML : '';
|
||||
const bodyHtml = body.innerHTML;
|
||||
const ending = '<p style="margin-top:24px;padding-top:16px;border-top:1px solid #eee;color:#666;font-size:14px;">感兴趣可以收藏关注我们,欢迎在评论区分享你的实践经验和改进建议!</p>';
|
||||
|
||||
return `<!DOCTYPE html><html><head>${headHtml}</head><body style="margin:0;padding:0;">${bodyHtml}${ending}</body></html>`;
|
||||
} catch (e) {
|
||||
console.error('生成预览 HTML 失败:', e);
|
||||
return html;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchTopics() {
|
||||
this.loadingTable = true;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
setTimeout(() => window.location.href = '/', 1500);
|
||||
this.loadingTable = false;
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/topics', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.topics = data || [];
|
||||
this.$message.success('选题加载成功');
|
||||
} catch (error) {
|
||||
console.error('获取选题失败:', error);
|
||||
this.$message.error(`获取选题失败: ${error.message}`);
|
||||
this.topics = [];
|
||||
} finally {
|
||||
this.loadingTable = false;
|
||||
}
|
||||
},
|
||||
refreshAll() { this.$message.info('执行批量刷新'); },
|
||||
async triggerGenerateSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
this.$message.success('批量创作已启动');
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
// 取第一个选题(当前简单实现)
|
||||
const topicId = this.selectedTopicIds[0];
|
||||
const response = await fetch('/api/system/generate/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_id: topicId })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
await response.json();
|
||||
this.$message.success('批量创作已启动');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('批量创作失败:', error);
|
||||
this.$message.error(`批量创作失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async triggerOptimizeSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/optimize/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: this.selectedTopicIds })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success('批量优化完成');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('批量优化失败:', error);
|
||||
this.$message.error(`批量优化失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async openPreview(topic) {
|
||||
this.previewTopic = topic;
|
||||
this.previewPlatform = 'zhihu';
|
||||
this.previewVisible = true;
|
||||
this.platformContents = {};
|
||||
// 并行加载所有平台内容
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.warning('请先登录');
|
||||
return;
|
||||
}
|
||||
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
|
||||
const promises = 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))
|
||||
);
|
||||
await Promise.all(promises);
|
||||
},
|
||||
togglePreviewFullscreen() {
|
||||
this.previewFullscreen = !this.previewFullscreen;
|
||||
},
|
||||
platformName(platform) {
|
||||
const names = {
|
||||
zhihu: '知乎',
|
||||
wechat: '微信公众号',
|
||||
xiaohongshu: '小红书'
|
||||
};
|
||||
return names[platform] || platform;
|
||||
},
|
||||
// 计算属性:当前平台预览的完整 HTML(响应式更新)
|
||||
copyContent(platform) {
|
||||
if (!this.previewTopic || !this.previewTopic.content) {
|
||||
this.$message.warning('暂无内容可复制');
|
||||
return;
|
||||
}
|
||||
const text = `标题:${this.previewTopic.title}\n\n内容:\n${this.previewTopic.content}`;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this.$message.success(`已复制内容,请前往${platform}粘贴发布`);
|
||||
}).catch(err => {
|
||||
console.error('复制失败', err);
|
||||
this.$message.error('复制失败,请手动复制');
|
||||
});
|
||||
},
|
||||
async createTopic(topic) {
|
||||
console.log('createTopic clicked, topic:', topic);
|
||||
if (this.isStatus(topic, 'published')) {
|
||||
this.$message.info('已发布选题不可创作');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/generate/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_id: topic.id })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`创作完成: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('创作失败:', error);
|
||||
this.$message.error(`创作失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async optimizeTopic(topic) {
|
||||
if (!this.isStatus(topic, 'review')) {
|
||||
this.$message.info('仅待审查选题可优化');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/optimize/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: [topic.id] })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`优化完成: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('优化失败:', error);
|
||||
this.$message.error(`优化失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async handlePublish(topic) {
|
||||
if (!this.isStatus(topic, 'ready')) {
|
||||
this.$message.info('仅待发布选题可发布');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/publishing/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: [topic.id] })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`发布成功: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('发布失败:', error);
|
||||
this.$message.error(`发布失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async deleteTopic(id) {
|
||||
this.$confirm('确定删除?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/topics/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
this.$message.success('删除成功');
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
this.$message.error(`删除失败: ${error.message}`);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
getStatusLabel(status) {
|
||||
const statusMap = {
|
||||
'pending': '待处理',
|
||||
'review': '待审查',
|
||||
'ready': '待发布',
|
||||
'published': '已发布'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
},
|
||||
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; }
|
||||
},
|
||||
getStatusType(status) {
|
||||
const map = {
|
||||
'pending': 'warning',
|
||||
'review': 'danger',
|
||||
'ready': 'success',
|
||||
'published': 'info',
|
||||
'待处理': 'warning',
|
||||
'待审查': 'danger',
|
||||
'待发布': 'success',
|
||||
'已发布': 'info'
|
||||
};
|
||||
return map[status] || 'primary';
|
||||
},
|
||||
isStatus(row, status) {
|
||||
const map = {
|
||||
'pending': ['pending', '待处理'],
|
||||
'review': ['review', '待审查'],
|
||||
'ready': ['ready', '待发布'],
|
||||
'published': ['published', '已发布']
|
||||
};
|
||||
return map[status] ? map[status].includes(row.status) : row.status === status;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('[DEBUG] TopicsApp mounted');
|
||||
const token = localStorage.getItem('authToken');
|
||||
console.log('[DEBUG] Token exists:', !!token);
|
||||
if (!token) { window.location.href = '/'; return; }
|
||||
// 解析 URL filter 参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const filter = urlParams.get('filter');
|
||||
console.log('[DEBUG] URL filter:', filter);
|
||||
if (filter) { this.filterStatus = filter; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => {
|
||||
console.log('[DEBUG] Auth success, user:', data.user);
|
||||
this.currentUser = data.user;
|
||||
this.isAdmin = data.user.role === 'admin';
|
||||
this.isLoggedIn = true;
|
||||
this.fetchTopics();
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(TopicsApp);
|
||||
app.use(ElementPlus);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,654 @@
|
||||
<!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">
|
||||
<style>
|
||||
.preview-iframe { box-sizing: border-box; }
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
|
||||
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
|
||||
.navbar-title { font-size: 20px; font-weight: 600; }
|
||||
.navbar-user { display: flex; align-items: center; gap: 16px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
|
||||
/* 移动端卡片布局 */
|
||||
@media (max-width: 768px) {
|
||||
.el-table .el-button { padding: 4px 8px; font-size: 11px; min-height: auto; }
|
||||
.el-table .cell { padding: 0 4px; }
|
||||
.el-table .el-table__cell { padding: 6px 0; }
|
||||
.topic-card-list { display: block; margin: 0 -16px; }
|
||||
.topic-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
.topic-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.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 { box-sizing: border-box; }
|
||||
/* 预览弹窗响应式高度 */
|
||||
@media (min-width: 769px) {
|
||||
.preview-iframe { max-height: calc(100vh - 100px) !important; }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.preview-iframe { max-height: calc(100vh - 250px) !important; }
|
||||
}
|
||||
|
||||
/* 预览弹窗自定义高度(非全屏时) */
|
||||
.preview-dialog-custom.el-dialog {
|
||||
max-height: calc(100vh - 90px) !important;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__header {
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__body {
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.preview-dialog-custom.el-dialog .el-dialog__footer {
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="navigation-component.js"></script></head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<nav class="navbar">
|
||||
<div class="navbar-content">
|
||||
<h1 class="navbar-title">宇之然内容创作平台 - 选题管理</h1>
|
||||
<div class="navbar-user">
|
||||
<div class="user-info"><div class="avatar">{{ currentUser.username ? currentUser.username.charAt(0).toUpperCase() : '?' }}</div><span>{{ currentUser.username }}</span></div>
|
||||
<el-button type="danger" size="small" @click="handleLogout">退出</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<navigation-component
|
||||
current-page="topics"
|
||||
:is-admin="isAdmin"
|
||||
@navigate="redirectToPage"
|
||||
></navigation-component>
|
||||
<div class="main-content">
|
||||
|
||||
<main class="content-area">
|
||||
<div class="card">
|
||||
<h2 style="font-size: 28px; font-weight: 700; margin-bottom: 32px; color: #303133;">📋 选题管理</h2>
|
||||
<div class="card" style="display: inline-block; min-width: fit-content; padding: 12px; margin-bottom: 24px;">
|
||||
<div style="display: flex; gap: 8px; flex-wrap: wrap;">
|
||||
<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="triggerOptimizeSelected" :disabled="selectedTopicIds.length === 0">🔍 批量优化</el-button>
|
||||
<span class="ml-auto text-sm text-gray-500" v-if="selectedTopicIds.length > 0" style="color: #909399; font-size: 14px; margin-left: auto;">已选 {{ selectedTopicIds.length }} 项</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; margin-bottom: 24px; flex-wrap: wrap;">
|
||||
<el-button size="large" :type="filterStatus === '' ? 'primary' : ''" @click="filterStatus = ''">全部 ({{ topics.length }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'pending' ? 'primary' : ''" @click="filterStatus = 'pending'">待处理 ({{ statusStats.pending }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'">待审查 ({{ statusStats.review }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'">待发布 ({{ statusStats.ready }})</el-button>
|
||||
<el-button size="large" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'">已发布 ({{ statusStats.published }})</el-button>
|
||||
</div>
|
||||
<div class="card" style="width: 100%; overflow-x: auto; padding: 12px;">
|
||||
<el-table :data="filteredTopics" stripe v-loading="loadingTable" @selection-change="selectedTopicIds = $event">
|
||||
<el-table-column type="selection" width="55"></el-table-column>
|
||||
<el-table-column prop="id" label="ID" width="70" fixed></el-table-column>
|
||||
<el-table-column prop="title" label="标题" min-width="200"></el-table-column>
|
||||
<el-table-column prop="field" label="领域" width="100"></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="scope"><span class="status-badge"><span class="status-dot" :class="scope.row.status"></span>{{ getStatusLabel(scope.row.status) }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="compliance_score" label="合规分" width="90">
|
||||
<template #default="scope"><el-progress :percentage="scope.row.compliance_score || 0" :format="() => scope.row.compliance_score || '-'" :stroke-width="15"></el-progress></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 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="180" 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="optimizeTopic(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>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<!-- 移动端卡片列表 -->
|
||||
<div class="topic-card-list" v-if="filteredTopics && filteredTopics.length > 0">
|
||||
<div v-for="(topic, index) in filteredTopics" :key="topic.id" class="topic-card">
|
||||
<div class="topic-card-header">
|
||||
<div class="topic-card-title">{{ topic.id }}. {{ topic.title }}</div>
|
||||
<el-tag :type="getStatusType(topic.status)" size="small">{{ getStatusLabel(topic.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="topic-card-tags">
|
||||
<el-tag size="small" type="info">{{ topic.field }}</el-tag>
|
||||
<el-tag size="small" type="warning">合规{{ topic.compliance_score }}</el-tag>
|
||||
</div>
|
||||
<div class="topic-card-meta">
|
||||
<div>创建: {{ formatDate(topic.created_at) }}</div>
|
||||
<div>创作: {{ topic.generated_at ? formatDate(topic.generated_at) : '-' }}</div>
|
||||
<div>发布: {{ topic.published_at ? formatDate(topic.published_at) : '-' }}</div>
|
||||
</div>
|
||||
<div class="topic-card-actions">
|
||||
<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="optimizeTopic(topic)">审查</el-button>
|
||||
<el-button v-if="isStatus(topic, 'ready')" size="small" type="primary" @click="handlePublish(topic)">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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">
|
||||
<div v-if="previewTopic">
|
||||
<!-- 平台切换按钮 -->
|
||||
<div style="margin-bottom: 16px; display: flex; justify-content: flex-end; gap: 8px;">
|
||||
<el-button-group>
|
||||
<el-button :type="previewPlatform === 'zhihu' ? 'primary' : 'default'" @click="previewPlatform = 'zhihu'">知乎</el-button>
|
||||
<el-button :type="previewPlatform === 'wechat' ? 'primary' : 'default'" @click="previewPlatform = 'wechat'">微信公众号</el-button>
|
||||
<el-button :type="previewPlatform === 'xiaohongshu' ? 'primary' : 'default'" @click="previewPlatform = 'xiaohongshu'">小红书</el-button>
|
||||
</el-button-group>
|
||||
</div>
|
||||
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px;">
|
||||
<h2 style="margin:0; font-size:16px;">{{ previewTopic.title }}</h2>
|
||||
<div style="display:flex; align-items:center; gap:12px; font-size:13px; color:#909399;">
|
||||
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
||||
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
|
||||
<span v-if="previewTopic.generated_at">创作:{{ formatDate(previewTopic.generated_at) }}</span>
|
||||
<span v-if="previewTopic.published_at">发布:{{ formatDate(previewTopic.published_at) }}</span>
|
||||
<el-button size="small" @click="togglePreviewFullscreen">
|
||||
{{ previewFullscreen ? '退出全屏' : '全屏' }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览内容使用 iframe 隔离样式 -->
|
||||
<div style="max-width: 1000px; margin: 0 auto; width: 100%; height: 100%; display: flex; flex-direction: column;">
|
||||
<iframe :srcdoc="currentPreviewHtml"
|
||||
class="preview-iframe"
|
||||
style="flex: 1; min-height: 500px; border:1px solid #ebeef5; border-radius:8px; background:#fff; overflow:auto; padding: 0 0; width: 100%;"
|
||||
sandbox>
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<template #footer>
|
||||
<div style="display:flex; justify-content:space-between; align-items:center; width:100%; font-size:14px; color:#909399;">
|
||||
<div class="preview-info">
|
||||
<span>创建:{{ formatDate(previewTopic.created_at) }}</span>
|
||||
<span style="margin: 0 8px;">|</span>
|
||||
<span>状态:{{ getStatusLabel(previewTopic.status) }}</span>
|
||||
<span v-if="previewTopic.generated_at" style="margin-left:8px;">
|
||||
创作:{{ formatDate(previewTopic.generated_at) }}
|
||||
</span>
|
||||
<span v-if="previewTopic.published_at" style="margin-left:8px;">
|
||||
发布:{{ formatDate(previewTopic.published_at) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<el-button @click="previewVisible = false">关闭</el-button>
|
||||
<el-button type="primary" @click="copyContent(previewPlatform)">复制并发布到{{ platformName(previewPlatform) }}</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
|
||||
<script>
|
||||
const TopicsApp = {
|
||||
data() {
|
||||
return {
|
||||
currentPage: 'topics',
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
currentUser: { username: '' },
|
||||
loadingTable: false,
|
||||
selectedTopicIds: [],
|
||||
filterStatus: '',
|
||||
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
|
||||
topics: [],
|
||||
previewVisible: false,
|
||||
previewTopic: null,
|
||||
previewFullscreen: false,
|
||||
previewPlatform: 'zhihu',
|
||||
platformContents: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredTopics() {
|
||||
if (!this.topics || !this.topics.length) { return []; }
|
||||
if (!this.filterStatus) { return this.topics; }
|
||||
const statusMap = {
|
||||
'pending': ['pending', '待处理'],
|
||||
'review': ['review', '待审查'],
|
||||
'ready': ['ready', '待发布'],
|
||||
'published': ['published', '已发布']
|
||||
};
|
||||
const allowed = statusMap[this.filterStatus] || [this.filterStatus];
|
||||
return this.topics.filter(t => allowed.includes(t.status));
|
||||
},
|
||||
statusStats() {
|
||||
const pending = ['pending', '待处理'];
|
||||
const review = ['review', '待审查'];
|
||||
const ready = ['ready', '待发布'];
|
||||
const published = ['published', '已发布'];
|
||||
return {
|
||||
total: this.topics.length,
|
||||
pending: this.topics.filter(t => pending.includes(t.status)).length,
|
||||
review: this.topics.filter(t => review.includes(t.status)).length,
|
||||
ready: this.topics.filter(t => ready.includes(t.status)).length,
|
||||
published: this.topics.filter(t => published.includes(t.status)).length
|
||||
};
|
||||
},
|
||||
currentPreviewHtml() {
|
||||
const html = this.platformContents[this.previewPlatform];
|
||||
if (!html) return '';
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const body = doc.body;
|
||||
if (!body) return html;
|
||||
|
||||
body.querySelectorAll('script, nav, .header, footer, .interaction').forEach(el => el.remove());
|
||||
|
||||
const head = doc.querySelector('head');
|
||||
const headHtml = head ? head.innerHTML : '';
|
||||
const bodyHtml = body.innerHTML;
|
||||
const ending = '<p style="margin-top:24px;padding-top:16px;border-top:1px solid #eee;color:#666;font-size:14px;">感兴趣可以收藏关注我们,欢迎在评论区分享你的实践经验和改进建议!</p>';
|
||||
|
||||
return `<!DOCTYPE html><html><head>${headHtml}</head><body style="margin:0;padding:0;">${bodyHtml}${ending}</body></html>`;
|
||||
} catch (e) {
|
||||
console.error('生成预览 HTML 失败:', e);
|
||||
return html;
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async fetchTopics() {
|
||||
this.loadingTable = true;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
setTimeout(() => window.location.href = '/', 1500);
|
||||
this.loadingTable = false;
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/topics', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.topics = data || [];
|
||||
this.$message.success('选题加载成功');
|
||||
} catch (error) {
|
||||
console.error('获取选题失败:', error);
|
||||
this.$message.error(`获取选题失败: ${error.message}`);
|
||||
this.topics = [];
|
||||
} finally {
|
||||
this.loadingTable = false;
|
||||
}
|
||||
},
|
||||
refreshAll() { this.$message.info('执行批量刷新'); },
|
||||
async triggerGenerateSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
this.$message.success('批量创作已启动');
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
// 取第一个选题(当前简单实现)
|
||||
const topicId = this.selectedTopicIds[0];
|
||||
const response = await fetch('/api/system/generate/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_id: topicId })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
await response.json();
|
||||
this.$message.success('批量创作已启动');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('批量创作失败:', error);
|
||||
this.$message.error(`批量创作失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async triggerOptimizeSelected() {
|
||||
if (!this.selectedTopicIds.length) return;
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/optimize/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: this.selectedTopicIds })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success('批量优化完成');
|
||||
this.selectedTopicIds = [];
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('批量优化失败:', error);
|
||||
this.$message.error(`批量优化失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async openPreview(topic) {
|
||||
this.previewTopic = topic;
|
||||
this.previewPlatform = 'zhihu';
|
||||
this.previewVisible = true;
|
||||
this.platformContents = {};
|
||||
// 并行加载所有平台内容
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.warning('请先登录');
|
||||
return;
|
||||
}
|
||||
const platforms = ['zhihu', 'wechat', 'xiaohongshu'];
|
||||
const promises = 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))
|
||||
);
|
||||
await Promise.all(promises);
|
||||
},
|
||||
togglePreviewFullscreen() {
|
||||
this.previewFullscreen = !this.previewFullscreen;
|
||||
},
|
||||
platformName(platform) {
|
||||
const names = {
|
||||
zhihu: '知乎',
|
||||
wechat: '微信公众号',
|
||||
xiaohongshu: '小红书'
|
||||
};
|
||||
return names[platform] || platform;
|
||||
},
|
||||
// 计算属性:当前平台预览的完整 HTML(响应式更新)
|
||||
copyContent(platform) {
|
||||
if (!this.previewTopic || !this.previewTopic.content) {
|
||||
this.$message.warning('暂无内容可复制');
|
||||
return;
|
||||
}
|
||||
const text = `标题:${this.previewTopic.title}\n\n内容:\n${this.previewTopic.content}`;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
this.$message.success(`已复制内容,请前往${platform}粘贴发布`);
|
||||
}).catch(err => {
|
||||
console.error('复制失败', err);
|
||||
this.$message.error('复制失败,请手动复制');
|
||||
});
|
||||
},
|
||||
async createTopic(topic) {
|
||||
console.log('createTopic clicked, topic:', topic);
|
||||
if (this.isStatus(topic, 'published')) {
|
||||
this.$message.info('已发布选题不可创作');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/generate/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_id: topic.id })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`创作完成: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('创作失败:', error);
|
||||
this.$message.error(`创作失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async optimizeTopic(topic) {
|
||||
if (!this.isStatus(topic, 'review')) {
|
||||
this.$message.info('仅待审查选题可优化');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/system/optimize/run', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: [topic.id] })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`优化完成: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('优化失败:', error);
|
||||
this.$message.error(`优化失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async handlePublish(topic) {
|
||||
if (!this.isStatus(topic, 'ready')) {
|
||||
this.$message.info('仅待发布选题可发布');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/publishing/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ topic_ids: [topic.id] })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
this.$message.success(`发布成功: ${topic.title}`);
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('发布失败:', error);
|
||||
this.$message.error(`发布失败: ${error.message}`);
|
||||
}
|
||||
},
|
||||
async deleteTopic(id) {
|
||||
this.$confirm('确定删除?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/topics/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
this.$message.success('删除成功');
|
||||
await this.fetchTopics();
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
this.$message.error(`删除失败: ${error.message}`);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
getStatusLabel(status) {
|
||||
const statusMap = {
|
||||
'pending': '待处理',
|
||||
'review': '待审查',
|
||||
'ready': '待发布',
|
||||
'published': '已发布'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
},
|
||||
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; }
|
||||
},
|
||||
getStatusType(status) {
|
||||
const map = {
|
||||
'pending': 'warning',
|
||||
'review': 'danger',
|
||||
'ready': 'success',
|
||||
'published': 'info',
|
||||
'待处理': 'warning',
|
||||
'待审查': 'danger',
|
||||
'待发布': 'success',
|
||||
'已发布': 'info'
|
||||
};
|
||||
return map[status] || 'primary';
|
||||
},
|
||||
isStatus(row, status) {
|
||||
const map = {
|
||||
'pending': ['pending', '待处理'],
|
||||
'review': ['review', '待审查'],
|
||||
'ready': ['ready', '待发布'],
|
||||
'published': ['published', '已发布']
|
||||
};
|
||||
return map[status] ? map[status].includes(row.status) : row.status === status;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
console.log('[DEBUG] TopicsApp mounted');
|
||||
const token = localStorage.getItem('authToken');
|
||||
console.log('[DEBUG] Token exists:', !!token);
|
||||
if (!token) { window.location.href = '/'; return; }
|
||||
// 解析 URL filter 参数
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const filter = urlParams.get('filter');
|
||||
console.log('[DEBUG] URL filter:', filter);
|
||||
if (filter) { this.filterStatus = filter; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => {
|
||||
console.log('[DEBUG] Auth success, user:', data.user);
|
||||
this.currentUser = data.user;
|
||||
this.isAdmin = data.user.role === 'admin';
|
||||
this.isLoggedIn = true;
|
||||
this.fetchTopics();
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(TopicsApp);
|
||||
app.use(ElementPlus);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+96
-142
@@ -7,110 +7,119 @@
|
||||
<link rel="stylesheet" href="element-plus.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; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; color: #303133; }
|
||||
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
|
||||
.navbar-title { font-size: 20px; font-weight: 600; }
|
||||
.navbar-user { display: flex; align-items: center; gap: 16px; }
|
||||
.user-info { display: flex; align-items: center; gap: 8px; }
|
||||
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; width: 100%; }
|
||||
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; min-height: calc(100vh - 60px); }
|
||||
.sidebar { width: 180px; background: white; padding: 16px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
|
||||
|
||||
|
||||
|
||||
.content-area { flex: 1; padding: 24px; overflow-y: auto; }
|
||||
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.card { background: white; border-radius: 12px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow-x: auto; }
|
||||
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); padding: 8px 0; z-index: 1000; }
|
||||
|
||||
|
||||
|
||||
.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) {
|
||||
.sidebar { display: none; }
|
||||
.mobile-nav { display: flex; }
|
||||
.content-area { padding: 16px; padding-bottom: 80px; }
|
||||
}
|
||||
|
||||
.user-card-list { display: none; }
|
||||
/* users.html 移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.card { padding: 16px; }
|
||||
.user-table { display: none; }
|
||||
.user-card-list { display: block; margin: 0 -16px; }
|
||||
.user-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
.user-card {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.user-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
.user-card:active { transform: scale(0.99); }
|
||||
.user-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.user-card-name { font-size: 16px; font-weight: 600; }
|
||||
.user-card-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
margin-bottom: 12px;
|
||||
.user-card-name { font-size: 16px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||
.user-card-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.user-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
.user-card-meta-item { display: flex; flex-direction: column; gap: 2px; }
|
||||
.user-card-meta-label { font-size: 11px; color: #909399; }
|
||||
.user-card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="navigation-component.js"></script>
|
||||
|
||||
<script src="navbar-component.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>
|
||||
<div class="main-content">
|
||||
|
||||
<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>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card">
|
||||
<h2 style="font-size: 24px; font-weight: 600; margin-bottom: 24px;">👥 用户管理</h2>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px;">
|
||||
<h3 style="font-size: 18px; font-weight: 600;">用户列表</h3>
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">👥 用户管理</h2>
|
||||
<el-button type="primary" @click="addUser">+ 新建用户</el-button>
|
||||
</div>
|
||||
<el-table :data="users" stripe :cell-class-name="getMobileUserCellClass" class="user-table">
|
||||
<el-table :data="users" stripe class="user-table">
|
||||
<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="created_at" label="创建时间" width="180"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="150">
|
||||
<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">
|
||||
<el-button size="small" type="danger" @click="deleteUser(scope.row.id)" :disabled="scope.row.role === 'admin'">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="user-card-list">
|
||||
<div v-for="u in users" :key="u.id" class="user-card">
|
||||
<div class="user-card-header">
|
||||
<div class="user-card-name">
|
||||
{{ u.username }}
|
||||
<el-tag size="small" :type="u.role === 'admin' ? 'danger' : 'info'">{{ u.role === 'admin' ? '管理员' : '编辑' }}</el-tag>
|
||||
</div>
|
||||
<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>{{ formatDate(u.created_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-card-actions">
|
||||
<el-button size="small" type="danger" plain @click="deleteUser(u.id)" :disabled="u.role === 'admin'">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
@@ -121,17 +130,9 @@
|
||||
async fetchUsers() {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/admin/users', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
if (!token) { this.$message.error('请先登录'); return; }
|
||||
const response = await fetch('/api/admin/users', { headers: { 'Authorization': 'Bearer ' + token } });
|
||||
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('用户列表加载成功');
|
||||
@@ -144,24 +145,14 @@
|
||||
async addUser() {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
return;
|
||||
}
|
||||
// 使用默认用户名,添加时间戳避免重复
|
||||
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'
|
||||
},
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username: username, role: 'editor' })
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
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('添加用户成功');
|
||||
@@ -171,76 +162,39 @@
|
||||
}
|
||||
},
|
||||
async deleteUser(id) {
|
||||
if (id === 'admin') {
|
||||
this.$message.warning('不能删除管理员用户');
|
||||
return;
|
||||
}
|
||||
if (id === 'admin') { this.$message.warning('不能删除管理员用户'); return; }
|
||||
this.$confirm('确定删除该用户?', '提示', { confirmButtonText: '确定', cancelButtonText: '取消', type: 'warning' })
|
||||
.then(async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) {
|
||||
this.$message.error('请先登录');
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
const response = await fetch(`/api/admin/users/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.detail || `请求失败: ${response.status}`);
|
||||
}
|
||||
if (!token) { this.$message.error('请先登录'); window.location.href = '/'; return; }
|
||||
const response = await fetch(`/api/admin/users/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { 'Authorization': 'Bearer ' + token } });
|
||||
if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.detail || `请求失败: ${response.status}`); }
|
||||
this.users = this.users.filter(u => u.id !== id);
|
||||
this.$message.success('删除用户成功');
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error);
|
||||
this.$message.error(`删除用户失败: ${error.message}`);
|
||||
}
|
||||
} catch (error) { console.error('删除用户失败:', error); this.$message.error(`删除用户失败: ${error.message}`); }
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); window.location.href = '/'; },
|
||||
redirectToPage(page) {
|
||||
if (!page) return;
|
||||
// 处理绝对URL或相对路径
|
||||
if (page.startsWith('/') || page.startsWith('http://') || page.startsWith('https://')) {
|
||||
window.location.href = page;
|
||||
} else {
|
||||
window.location.href = '/' + page;
|
||||
}
|
||||
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() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; if (!this.isAdmin) { this.$message.warning('需要管理员权限'); window.location.href = '/login.html'; } else { this.fetchUsers(); } })
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
},
|
||||
formatDate(dateStr) { if (!dateStr) return '-'; return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
|
||||
},
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/'; return; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(response => response.ok ? response.json() : Promise.reject())
|
||||
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; if (!this.isAdmin) { this.$message.warning('需要管理员权限'); window.location.href = '/'; } else { this.fetchUsers(); } })
|
||||
.catch(() => { localStorage.removeItem('authToken'); window.location.href = '/'; });
|
||||
}
|
||||
mounted() { this.checkAuth(); }
|
||||
};
|
||||
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); }
|
||||
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);
|
||||
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); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user