feat: 完成布局优化 - 操作列固定、批量按钮自适应、分类标签带数量

优化内容:
1. 表格布局:
   - 使用 calc(100vw - 160px) 确保表格不超出视口
   - 操作列 fixed='right' 固定在右侧,宽度 300px
   - 按钮 3 个后自动换行 (max-width: 200px)
   - 恢复合理列宽,不再过度压缩

2. 批量操作区域:
   - 容器改为 inline-block,宽度自适应按钮内容
   - 背景宽度与按钮总宽度匹配

3. 分类标签:
   - 显示数量 (如 '待处理 (20)')
   - 点击切换筛选,去掉误导的 'X' 图标

4. 删除功能:
   - 操作列增加删除按钮
   - 删除前弹出确认对话框

5. 系统日志:
   - 修复后端日志路径 (parents[4])
   - 404 时显示友好提示

6. 其他:
   - 左侧菜单宽度 160px
   - 所有功能保留 (登录、用户管理、批量操作等)
This commit is contained in:
lt
2026-04-27 11:32:17 +08:00
parent 59d2a76df4
commit 277b13eaae
137 changed files with 8615 additions and 1213 deletions
+37
View File
@@ -0,0 +1,37 @@
# 宇之然内容创作平台 - 前端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="企业级内容创作管理系统前端"
+7
View File
@@ -0,0 +1,7 @@
(function() {
var d = document.createElement('div');
d.style.cssText = 'position:fixed;top:0;left:0;background:rgba(0,0,0,0.9);color:#fff;padding:8px;font-size:12px;z-index:999999;max-width:90vw;overflow:auto;';
d.innerHTML = 'Vue: ' + typeof Vue + '<br>ElementPlus: ' + typeof ElementPlus + '<br>Time: ' + new Date().toLocaleTimeString();
document.body.appendChild(d);
console.log('Debug panel injected', d.innerHTML);
})();
-76
View File
@@ -1,76 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然 - 简单版</title>
<script src="/static/vue.global.prod.js"></script>
<link rel="stylesheet" href="/static/element-plus.css" />
<script src="/static/element-plus.full.js"></script>
<style>
body { margin: 20px; font-family: sans-serif; }
.card { border: 1px solid #ddd; padding: 20px; margin: 10px 0; border-radius: 8px; }
.stat-value { font-size: 2rem; color: #409EFF; }
</style>
</head>
<body>
<div id="app">
<h1>宇之然内容创作平台</h1>
<div class="card">
<h2>系统概览</h2>
<div v-if="status">
<p>选题总数: {{ status.total_topics }}</p>
<p>待发布: {{ status.topics_by_status?.['待发布'] || 0 }}</p>
<p>待处理: {{ status.topics_by_status?.['待处理'] || 0 }}</p>
</div>
<div v-else>加载中...</div>
<button @click="refresh">刷新</button>
</div>
<div class="card">
<h2>选题列表</h2>
<div v-if="topics.length">
<ul>
<li v-for="t in topics" :key="t.id">
{{ t.id }} - {{ t.title }} - {{ t.status }}
</li>
</ul>
</div>
<div v-else-if="topics">无选题</div>
<div v-else>加载中...</div>
</div>
</div>
<script>
const { createApp, ref, onMounted } = Vue;
createApp({
setup() {
const API_BASE = '';
const status = ref(null);
const topics = ref([]);
const refresh = async () => {
try {
const [s, t] = await Promise.all([
fetch(API_BASE + '/api/system/status').then(r => r.json()),
fetch(API_BASE + '/api/topics').then(r => r.json())
]);
status.value = s;
topics.value = t;
console.log('数据加载成功', s, t);
} catch (e) {
console.error('刷新失败:', e);
alert('加载失败: ' + e);
}
};
onMounted(() => {
console.log('应用启动');
refresh();
});
return { status, topics, refresh };
}
}).use(ElementPlus).mount('#app');
</script>
</body>
</html>
+181
View File
@@ -0,0 +1,181 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>宇之然内容创作平台 - 登录</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="/static/vue.global.prod.js?v=20260421-0830"></script>
<link rel="stylesheet" href="/static/element-plus.css?v=20260421-0830" />
<script src="/static/element-plus.full.js?v=20260421-0830"></script>
<style>
.login-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 100%;
max-width: 400px;
padding: 32px;
background: white;
border-radius: 16px;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.login-title {
text-align: center;
font-size: 28px;
font-weight: bold;
color: #1f2937;
margin-bottom: 32px;
}
.login-input {
width: 100%;
padding: 12px 16px;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 16px;
margin-bottom: 16px;
outline: none;
transition: border-color 0.2s;
}
.login-input:focus {
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.login-button {
width: 100%;
padding: 12px;
background: #3b82f6;
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.login-button:hover {
background: #2563eb;
}
.login-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-footer {
text-align: center;
margin-top: 24px;
color: #6b7280;
font-size: 14px;
}
</style>
</head>
<body>
<div class="login-container">
<div class="login-card">
<h1 class="login-title">宇之然内容创作平台</h1>
<form @submit.prevent="handleLogin">
<input
v-model="username"
class="login-input"
type="text"
placeholder="请输入用户名"
required
autocomplete="username"
/>
<input
v-model="password"
class="login-input"
type="password"
placeholder="请输入密码"
required
autocomplete="current-password"
/>
<button
class="login-button"
type="submit"
:disabled="loading"
:class="{'opacity-60 cursor-not-allowed': loading}"
>
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
<p class="login-footer">
只有管理员用户可登录访问系统
</p>
</div>
</div>
<script>
const { ref } = Vue;
const { ElMessage } = ElementPlus;
const app = Vue.createApp({
name: 'LoginPage',
setup() {
const username = ref('');
const password = ref('');
const loading = ref(false);
const handleLogin = async () => {
if (!username.value.trim() || !password.value) {
ElMessage.warning('请输入用户名和密码');
return;
}
loading.value = true;
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username: username.value.trim(),
password: password.value
})
});
const data = await response.json();
if (response.ok && data.token) {
// 保存认证信息
localStorage.setItem('auth_token', data.token);
localStorage.setItem('user_role', data.role || 'admin');
ElMessage.success('登录成功!正在跳转...');
// 延迟跳转,让用户看到成功消息
setTimeout(() => {
window.location.href = '/';
}, 1000);
} else {
ElMessage.error(data.message || data.error || '登录失败,请检查用户名和密码');
}
} catch (error) {
console.error('登录请求失败:', error);
ElMessage.error('网络连接失败,请检查服务是否正常运行');
} finally {
loading.value = false;
}
};
return {
username,
password,
loading,
handleLogin
};
}
});
app.use(ElementPlus);
app.mount('#app');
</script>
</body>
</html>
+36
View File
@@ -0,0 +1,36 @@
{
"name": "宇之然内容创作平台",
"short_name": "宇之然",
"description": "可持续性内容创作与管理系统",
"start_url": "/",
"display": "standalone",
"background_color": "#f5f7fa",
"theme_color": "#409EFF",
"orientation": "portrait-primary",
"icons": [
{
"src": "/static/icon-192.svg",
"sizes": "192x192",
"type": "image/svg+xml"
},
{
"src": "/static/icon-512.svg",
"sizes": "512x512",
"type": "image/svg+xml"
}
],
"screenshots": [
{
"src": "/static/screenshot-desktop.png",
"sizes": "1280x720",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/static/screenshot-mobile.png",
"sizes": "750x1334",
"type": "image/png",
"form_factor": "narrow"
}
]
}
+128
View File
@@ -0,0 +1,128 @@
# 宇之然内容创作平台 - 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 / {
# 前端静态资源缓存
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";
}
}
}
+57
View File
@@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>离线 - 宇之然平台</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
padding: 20px;
text-align: center;
}
.container {
max-width: 400px;
}
.icon {
font-size: 80px;
margin-bottom: 20px;
}
h1 {
font-size: 24px;
margin: 0 0 12px;
}
p {
font-size: 16px;
line-height: 1.6;
opacity: 0.9;
}
.btn {
display: inline-block;
margin-top: 20px;
padding: 12px 24px;
background: white;
color: #667eea;
text-decoration: none;
border-radius: 8px;
font-weight: bold;
}
</style>
</head>
<body>
<div class="container">
<div class="icon">📴</div>
<h1>当前处于离线状态</h1>
<p>您似乎已断开网络连接,但可以查看已缓存的内容。</p>
<p>请检查网络后刷新页面以获取最新数据。</p>
<a href="/" class="btn">重试</a>
</div>
</body>
</html>
+476
View File
@@ -0,0 +1,476 @@
<script>
const { ref, reactive, computed, onMounted, watch } = Vue;
const { ElMessage, ElNotification, ElMessageBox } = ElementPlus;
// 图标组件
const CopyDocument = Vue.h('el-icon', { name: 'CopyDocument' });
const FullScreen = Vue.h('el-icon', { name: 'FullScreen' });
const Document = Vue.h('el-icon', { name: 'Document' });
const Upload = Vue.h('el-icon', { name: 'Upload' });
const Promotion = Vue.h('el-icon', { name: 'Promotion' });
const app = Vue.createApp({
name: 'YuZhiRanPlatform',
setup() {
// ========== 变量声明区 ==========
const API_BASE = window.location.origin;
// 状态
const isLoggedIn = ref(false);
const isAdmin = ref(false);
const loginForm = reactive({ username: '', password: '' });
const loginError = ref('');
const status = ref({});
const topics = ref([]);
const selectedTopicIds = ref([]); // 批量操作选中
const filterStatus = ref('');
const generating = ref(false);
const optimizing = ref(false);
const loadingAll = ref(false);
const loadingTable = ref(false);
const loadingLogs = ref(false);
const loadingOverlay = ref(false);
const loadingText = ref('');
const pipeline = ref({ status_distribution: {} });
const pipelineLoading = ref(false);
const pipelineModules = ref([]);
const previewVisible = ref(false);
const previewTopic = ref({ title: '' });
const previewPlatform = ref('zhihu');
const previewHtml = ref('');
const fullScreenPreview = ref(false);
const showLogs = ref(false);
const logType = ref('creator');
const logDate = ref(new Date().toISOString().split('T')[0]);
const logContent = ref('');
// 计算属性
const filteredTopics = computed(() => {
if (!filterStatus.value) return topics.value || [];
return (topics.value || []).filter(t => t && t.status === filterStatus.value);
});
// ========== 工具函数 ==========
const formatDate = (val) => {
if (!val) return '-';
const d = new Date(val);
if (isNaN(d.getTime())) return val;
return d.toLocaleString('zh-CN', { hour12: false });
};
const formatRelativeTime = (val) => {
if (!val) return '-';
const d = new Date(val);
if (isNaN(d.getTime())) return '-';
const now = new Date();
const diff = now - d;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}小时前`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}天前`;
return formatDate(val);
};
// ========== 业务方法 ==========
const countByStatus = (status) => {
return (topics.value || []).filter(t => t.status === status).length;
};
const getPriorityType = (score) => {
if (!score) return '';
if (score >= 20) return 'danger';
if (score >= 15) return 'warning';
return 'success';
};
const getStatusClass = (status) => {
const map = {
'待处理': 'pending',
'待审查': 'review',
'待发布': 'ready',
'已发布': 'published'
};
return map[status] || '';
};
const refresh = async () => {
try {
const [s, t] = await Promise.all([
fetch(API_BASE + '/api/system/status').then(r => r.json()),
fetch(API_BASE + '/api/topics').then(r => r.json())
]);
status.value = s;
topics.value = t;
} catch (e) {
ElMessage.error('刷新失败:' + e.message);
}
};
const refreshPipeline = async () => {
pipelineLoading.value = true;
try {
const res = await fetch(API_BASE + '/api/system/pipeline/status');
if (res.ok) {
const data = await res.json();
pipeline.value = data;
pipelineModules.value = Object.entries(data.pipeline_modules || {}).map(([name, info]) => ({
module: name,
last_run: info.last_run || '未运行',
status_ok: !info.has_error && info.exists,
status_text: info.exists && !info.has_error ? '正常' : info.exists ? '有错误' : '缺失',
error: info.has_error ? '检测到错误' : ''
}));
}
} catch (e) {
ElMessage.error('获取流水线状态失败');
} finally {
pipelineLoading.value = false;
}
};
const refreshAll = async () => {
loadingAll.value = true;
try {
await Promise.all([refresh(), refreshPipeline()]);
ElMessage.success('刷新成功');
} catch (e) {
ElMessage.error('刷新失败');
} finally {
loadingAll.value = false;
}
};
const triggerGenerate = async () => {
generating.value = true;
try {
const res = await fetch(API_BASE + '/api/system/generate/run', { method: 'POST' });
const data = await res.json();
if (data.result && data.result.ok) {
ElMessage.success('创作任务已启动');
setTimeout(refresh, 3000);
} else {
ElMessage.error('启动失败:' + (data.error || '未知错误'));
}
} catch (e) {
ElMessage.error('请求失败:' + e.message);
} finally {
generating.value = false;
}
};
const triggerOptimize = async () => {
optimizing.value = true;
try {
const res = await fetch(API_BASE + '/api/system/optimize/run', { method: 'POST' });
const data = await res.json();
if (data.summary) {
ElNotification({
title: '优化完成',
message: `自动通过 ${data.summary.passed_auto || 0} 篇,需人工 ${data.summary.need_manual || 0}`,
type: 'success'
});
await refresh();
} else {
ElMessage.success('优化完成');
}
} catch (e) {
ElMessage.error('优化失败:' + e.message);
} finally {
optimizing.value = false;
}
};
const openPreview = async (topic) => {
previewTopic.value = { id: topic.id, title: topic.title };
previewPlatform.value = 'zhihu';
previewVisible.value = true;
await loadPreview();
};
const loadPreview = async () => {
previewHtml.value = '';
console.log('[Preview] Loading topic:', previewTopic.value.id, 'platform:', previewPlatform.value);
try {
const res = await fetch(`${API_BASE}/api/articles/${previewTopic.value.id}/preview?platform=${previewPlatform.value}`);
console.log('[Preview] Response status:', res.status);
if (res.ok) {
const data = await res.json();
console.log('[Preview] Got HTML, length:', data.html?.length);
const parser = new DOMParser();
const doc = parser.parseFromString(data.html, 'text/html');
const contentDiv = doc.querySelector('.content');
console.log('[Preview] Found .content:', !!contentDiv);
if (contentDiv) {
previewHtml.value = contentDiv.innerHTML;
console.log('[Preview] Set previewHtml from .content');
} else {
const header = doc.querySelector('.header');
const footer = doc.querySelector('footer');
const tags = doc.querySelector('.tags');
const interaction = doc.querySelector('.interaction');
if (header) header.remove();
if (footer) footer.remove();
if (tags) tags.remove();
if (interaction) interaction.remove();
previewHtml.value = doc.body.innerHTML;
console.log('[Preview] Set previewHtml from body.innerHTML');
}
} else if (res.status === 404) {
previewHtml.value = '<div class="text-center py-12 text-gray-500"><p>暂未创作文章,请先点击创作按钮生成</p></div>';
} else {
ElMessage.error('加载预览失败:' + res.status);
}
} catch (e) {
previewHtml.value = '<div class="text-center py-12 text-gray-500"><p>请求失败,请检查后端服务是否运行</p></div>';
console.error('Preview error:', e);
}
};
const copyPreviewHtml = async () => {
if (!previewHtml.value) return;
try {
const parser = new DOMParser();
const doc = parser.parseFromString(previewHtml.value, 'text/html');
const header = doc.querySelector('.header');
if (header) header.remove();
const footer = doc.querySelector('footer');
if (footer) footer.remove();
const tagsDiv = doc.querySelector('.tags');
if (tagsDiv) tagsDiv.remove();
const interaction = doc.querySelector('.interaction');
if (interaction) interaction.remove();
const contentDiv = doc.querySelector('.content');
let text = '';
if (contentDiv) {
text = contentDiv.innerText.trim();
} else {
text = doc.body.innerText.trim();
}
if (!text) {
ElMessage.warning('未提取到正文内容');
return;
}
await navigator.clipboard.writeText(text);
ElMessage.success('正文已复制到剪贴板');
} catch (e) {
console.error('Copy error:', e);
ElMessage.error('复制失败');
}
};
const expandPreview = () => {
fullScreenPreview.value = true;
};
const handleShowLogs = () => {
showLogs.value = true;
};
const fetchLogs = async () => {
loadingLogs.value = true;
try {
const res = await fetch(`${API_BASE}/api/system/logs/${logDate.value}?log_type=${logType.value}`);
if (res.ok) {
const data = await res.json();
logContent.value = data.content ? data.content.join('\n') : '无内容';
} else {
ElMessage.error('加载日志失败');
}
} catch (e) {
ElMessage.error('请求失败');
} finally {
loadingLogs.value = false;
}
};
const createTopic = async (topic) => {
if (topic.published_urls && Object.keys(topic.published_urls).length > 0) {
try {
await ElMessageBox.alert(
'本文已发布过,重新创作将覆盖原有内容。是否继续?',
'重新创作确认',
{
confirmButtonText: '继续',
cancelButtonText: '取消',
type: 'warning',
}
);
} catch (e) {
return;
}
}
try {
const res = await fetch(`${API_BASE}/api/system/generate/run?topic_id=${topic.id}`, { method: 'POST' });
const data = await res.json();
if (data.result && data.result.ok) {
ElMessage.success(`选题 ${topic.id} 创作任务已启动`);
setTimeout(refresh, 3000);
} else {
ElMessage.error('创作失败:' + (data.error || '未知错误'));
}
} catch (e) {
ElMessage.error('请求失败:' + e.message);
}
};
const optimizeTopic = async (topic) => {
try {
const res = await fetch(API_BASE + '/api/system/optimize/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic_ids: [topic.id] })
});
const data = await res.json();
if (data.summary) {
ElMessage.success(`选题 ${topic.id} 优化完成`);
setTimeout(refresh, 2000);
} else {
ElMessage.success('优化完成');
}
} catch (e) {
ElMessage.error('优化失败:' + e.message);
}
};
// 批量操作
const triggerGenerateSelected = async () => {
if (selectedTopicIds.value.length === 0) {
ElMessage.warning('请先选择要创作的选题');
return;
}
generating.value = true;
try {
const res = await fetch(API_BASE + '/api/system/generate/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic_ids: selectedTopicIds.value })
});
const data = await res.json();
if (data.result && data.result.ok) {
ElMessage.success(`已启动 ${selectedTopicIds.value.length} 个选题的创作任务`);
selectedTopicIds.value = [];
setTimeout(refresh, 3000);
} else {
ElMessage.error('批量创作失败:' + (data.error || '未知错误'));
}
} catch (e) {
ElMessage.error('请求失败:' + e.message);
} finally {
generating.value = false;
}
};
const triggerOptimizeSelected = async () => {
if (selectedTopicIds.value.length === 0) {
ElMessage.warning('请先选择要优化的选题');
return;
}
optimizing.value = true;
try {
const res = await fetch(API_BASE + '/api/system/optimize/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic_ids: selectedTopicIds.value })
});
const data = await res.json();
if (data.summary) {
ElNotification({
title: '批量优化完成',
message: `自动通过 ${data.summary.passed_auto || 0} 篇,需人工 ${data.summary.need_manual || 0}`,
type: 'success'
});
selectedTopicIds.value = [];
await refresh();
} else {
ElMessage.success('批量优化完成');
}
} catch (e) {
ElMessage.error('批量优化失败:' + e.message);
} finally {
optimizing.value = false;
}
};
const handlePublish = async (topic) => {
try {
ElMessage.info(`正在发布选题 ${topic.id}...`);
const res = await fetch(API_BASE + '/api/publishing/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic_id: topic.id })
});
if (!res.ok) throw new Error('发布失败');
const data = await res.json();
ElMessage.success(`选题 ${topic.id} 已发布`);
await refresh();
} catch (e) {
ElMessage.error('发布失败:' + e.message);
}
};
const openCreateTopic = () => {
ElMessage.info('新建选题功能待实现');
};
// 页面路由
const currentPage = ref('overview');
const switchPage = (page) => {
currentPage.value = page;
};
const goToTopicsWithFilter = (status) => {
currentPage.value = 'topics';
filterStatus.value = status;
};
// 生命周期
watch(previewPlatform, loadPreview);
onMounted(() => {
const authToken = localStorage.getItem('auth_token');
const role = localStorage.getItem('user_role');
if (authToken) {
isLoggedIn.value = true;
if (role === 'admin') isAdmin.value = true;
}
refresh();
refreshPipeline();
});
// 返回给模板
return {
// 状态
status, topics, filterStatus, filteredTopics,
generating, optimizing, loadingAll, loadingTable, loadingLogs, loadingOverlay, loadingText,
pipeline, pipelineLoading, pipelineModules,
previewVisible, previewTopic, previewPlatform, previewHtml, fullScreenPreview,
showLogs, logType, logDate, logContent,
// 页面路由
currentPage,
// 方法
countByStatus, getPriorityType, getStatusClass,
refresh, refreshPipeline, refreshAll,
triggerGenerate, triggerOptimize,
openPreview, loadPreview, copyPreviewHtml, expandPreview,
fetchLogs,
createTopic, optimizeTopic, handlePublish,
openCreateTopic,
// 工具函数
formatDate, formatRelativeTime,
switchPage, goToTopicsWithFilter,
// 认证(未完整)
isLoggedIn, isAdmin, loginForm, loginError,
// 图标
Document, Upload, CopyDocument, FullScreen, Promotion
};
}
});
app.use(ElementPlus);
app.mount('#app');
</script>
+398
View File
@@ -0,0 +1,398 @@
// 修复后的 Vue 3 setup 函数体
// 所有变量和方法必须在 return 之前定义
const API_BASE = window.location.origin;
// 1. 状态变量
const isLoggedIn = ref(false);
const isAdmin = ref(false);
const loginForm = reactive({ username: '', password: '' });
const loginError = ref('');
const status = ref({});
const topics = ref([]);
const filterStatus = ref('');
const generating = ref(false);
const optimizing = ref(false);
const loadingAll = ref(false);
const loadingTable = ref(false);
const loadingLogs = ref(false);
const loadingOverlay = ref(false);
const loadingText = ref('');
const pipeline = ref({ status_distribution: {} });
const pipelineLoading = ref(false);
const pipelineModules = ref([]);
const previewVisible = ref(false);
const previewTopic = ref({ title: '' });
const previewPlatform = ref('zhihu');
const previewHtml = ref('');
const fullScreenPreview = ref(false);
const showLogs = ref(false);
const logType = ref('creator');
const logDate = ref(new Date().toISOString().split('T')[0]);
const logContent = ref('');
// 2. 计算属性
const filteredTopics = computed(() => {
if (!filterStatus.value) return topics.value || [];
return (topics.value || []).filter(t => t && t.status === filterStatus.value);
});
// 3. 工具函数
const formatDate = (val) => {
if (!val) return '-';
const d = new Date(val);
if (isNaN(d.getTime())) return val;
return d.toLocaleString('zh-CN', { hour12: false });
};
const formatRelativeTime = (val) => {
if (!val) return '-';
const d = new Date(val);
if (isNaN(d.getTime())) return '-';
const now = new Date();
const diff = now - d;
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return '刚刚';
if (minutes < 60) return `${minutes}分钟前`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}小时前`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days}天前`;
return formatDate(val);
};
// 4. 业务方法
const countByStatus = (status) => {
return (topics.value || []).filter(t => t.status === status).length;
};
const getPriorityType = (score) => {
if (!score) return '';
if (score >= 20) return 'danger';
if (score >= 15) return 'warning';
return 'success';
};
const getStatusClass = (status) => {
const map = {
'待处理': 'pending',
'待审查': 'review',
'待发布': 'ready',
'已发布': 'published'
};
return map[status] || '';
};
const refresh = async () => {
try {
const [s, t] = await Promise.all([
fetch(API_BASE + '/api/system/status').then(r => r.json()),
fetch(API_BASE + '/api/topics').then(r => r.json())
]);
status.value = s;
topics.value = t;
} catch (e) {
ElMessage.error('刷新失败:' + e.message);
}
};
const refreshPipeline = async () => {
pipelineLoading.value = true;
try {
const res = await fetch(API_BASE + '/api/system/pipeline/status');
if (res.ok) {
const data = await res.json();
pipeline.value = data;
pipelineModules.value = Object.entries(data.pipeline_modules || {}).map(([name, info]) => ({
module: name,
last_run: info.last_run || '未运行',
status_ok: !info.has_error && info.exists,
status_text: info.exists && !info.has_error ? '正常' : info.exists ? '有错误' : '缺失',
error: info.has_error ? '检测到错误' : ''
}));
}
} catch (e) {
ElMessage.error('获取流水线状态失败');
} finally {
pipelineLoading.value = false;
}
};
const refreshAll = async () => {
loadingAll.value = true;
try {
await Promise.all([refresh(), refreshPipeline()]);
ElMessage.success('刷新成功');
} catch (e) {
ElMessage.error('刷新失败');
} finally {
loadingAll.value = false;
}
};
const triggerGenerate = async () => {
generating.value = true;
try {
const res = await fetch(API_BASE + '/api/system/generate/run', { method: 'POST' });
const data = await res.json();
if (data.result && data.result.ok) {
ElMessage.success('创作任务已启动');
setTimeout(refresh, 3000);
} else {
ElMessage.error('启动失败:' + (data.error || '未知错误'));
}
} catch (e) {
ElMessage.error('请求失败:' + e.message);
} finally {
generating.value = false;
}
};
const triggerOptimize = async () => {
optimizing.value = true;
try {
const res = await fetch(API_BASE + '/api/system/optimize/run', { method: 'POST' });
const data = await res.json();
if (data.summary) {
ElNotification({
title: '优化完成',
message: `自动通过 ${data.summary.passed_auto || 0} 篇,需人工 ${data.summary.need_manual || 0}`,
type: 'success'
});
await refresh();
} else {
ElMessage.success('优化完成');
}
} catch (e) {
ElMessage.error('优化失败:' + e.message);
} finally {
optimizing.value = false;
}
};
const openPreview = async (topic) => {
previewTopic.value = { id: topic.id, title: topic.title };
previewPlatform.value = 'zhihu';
previewVisible.value = true;
await loadPreview();
};
const loadPreview = async () => {
previewHtml.value = '';
console.log('[Preview] Loading topic:', previewTopic.value.id, 'platform:', previewPlatform.value);
try {
const res = await fetch(`${API_BASE}/api/articles/${previewTopic.value.id}/preview?platform=${previewPlatform.value}`);
console.log('[Preview] Response status:', res.status);
if (res.ok) {
const data = await res.json();
console.log('[Preview] Got HTML, length:', data.html?.length);
const parser = new DOMParser();
const doc = parser.parseFromString(data.html, 'text/html');
const contentDiv = doc.querySelector('.content');
console.log('[Preview] Found .content:', !!contentDiv);
if (contentDiv) {
previewHtml.value = contentDiv.innerHTML;
console.log('[Preview] Set previewHtml from .content');
} else {
const header = doc.querySelector('.header');
const footer = doc.querySelector('footer');
const tags = doc.querySelector('.tags');
const interaction = doc.querySelector('.interaction');
if (header) header.remove();
if (footer) footer.remove();
if (tags) tags.remove();
if (interaction) interaction.remove();
previewHtml.value = doc.body.innerHTML;
console.log('[Preview] Set previewHtml from body.innerHTML');
}
} else if (res.status === 404) {
previewHtml.value = '<div class="text-center py-12 text-gray-500"><p>暂未创作文章,请先点击创作按钮生成</p></div>';
} else {
ElMessage.error('加载预览失败:' + res.status);
}
} catch (e) {
previewHtml.value = '<div class="text-center py-12 text-gray-500"><p>请求失败,请检查后端服务是否运行</p></div>';
console.error('Preview error:', e);
}
};
const copyPreviewHtml = async () => {
if (!previewHtml.value) return;
try {
const parser = new DOMParser();
const doc = parser.parseFromString(previewHtml.value, 'text/html');
const header = doc.querySelector('.header');
if (header) header.remove();
const footer = doc.querySelector('footer');
if (footer) footer.remove();
const tagsDiv = doc.querySelector('.tags');
if (tagsDiv) tagsDiv.remove();
const interaction = doc.querySelector('.interaction');
if (interaction) interaction.remove();
const contentDiv = doc.querySelector('.content');
let text = '';
if (contentDiv) {
text = contentDiv.innerText.trim();
} else {
text = doc.body.innerText.trim();
}
if (!text) {
ElMessage.warning('未提取到正文内容');
return;
}
await navigator.clipboard.writeText(text);
ElMessage.success('正文已复制到剪贴板');
} catch (e) {
console.error('Copy error:', e);
ElMessage.error('复制失败');
}
};
const expandPreview = () => {
fullScreenPreview.value = true;
};
const handleShowLogs = () => {
showLogs.value = true;
};
const fetchLogs = async () => {
loadingLogs.value = true;
try {
const res = await fetch(`${API_BASE}/api/system/logs/${logDate.value}?log_type=${logType.value}`);
if (res.ok) {
const data = await res.json();
logContent.value = data.content ? data.content.join('\n') : '无内容';
} else {
ElMessage.error('加载日志失败');
}
} catch (e) {
ElMessage.error('请求失败');
} finally {
loadingLogs.value = false;
}
};
const createTopic = async (topic) => {
if (topic.published_urls && Object.keys(topic.published_urls).length > 0) {
try {
await ElMessageBox.alert(
'本文已发布过,重新创作将覆盖原有内容。是否继续?',
'重新创作确认',
{
confirmButtonText: '继续',
cancelButtonText: '取消',
type: 'warning',
}
);
} catch (e) {
return;
}
}
try {
const res = await fetch(`${API_BASE}/api/system/generate/run?topic_id=${topic.id}`, { method: 'POST' });
const data = await res.json();
if (data.result && data.result.ok) {
ElMessage.success(`选题 ${topic.id} 创作任务已启动`);
setTimeout(refresh, 3000);
} else {
ElMessage.error('创作失败:' + (data.error || '未知错误'));
}
} catch (e) {
ElMessage.error('请求失败:' + e.message);
}
};
const optimizeTopic = async (topic) => {
try {
const res = await fetch(API_BASE + '/api/system/optimize/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic_ids: [topic.id] })
});
const data = await res.json();
if (data.summary) {
ElMessage.success(`选题 ${topic.id} 优化完成`);
setTimeout(refresh, 2000);
} else {
ElMessage.success('优化完成');
}
} catch (e) {
ElMessage.error('优化失败:' + e.message);
}
};
const handlePublish = async (topic) => {
try {
ElMessage.info(`正在发布选题 ${topic.id}...`);
const res = await fetch(API_BASE + '/api/publishing/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ topic_id: topic.id })
});
if (!res.ok) throw new Error('发布失败');
const data = await res.json();
ElMessage.success(`选题 ${topic.id} 已发布`);
await refresh();
} catch (e) {
ElMessage.error('发布失败:' + e.message);
}
};
const openCreateTopic = () => {
ElMessage.info('新建选题功能待实现');
};
// 5. 页面路由
const currentPage = ref('overview');
const switchPage = (page) => {
currentPage.value = page;
};
const goToTopicsWithFilter = (status) => {
currentPage.value = 'topics';
filterStatus.value = status;
};
// 6. 生命周期(必须在 return 之前)
watch(previewPlatform, loadPreview);
onMounted(() => {
const authToken = localStorage.getItem('auth_token');
const role = localStorage.getItem('user_role');
if (authToken) {
isLoggedIn.value = true;
if (role === 'admin') isAdmin.value = true;
}
refresh();
refreshPipeline();
});
// 7. 返回给模板
return {
// 状态
status, topics, filterStatus, filteredTopics,
generating, optimizing, loadingAll, loadingTable, loadingLogs, loadingOverlay, loadingText,
pipeline, pipelineLoading, pipelineModules,
previewVisible, previewTopic, previewPlatform, previewHtml, fullScreenPreview,
showLogs, logType, logDate, logContent,
// 页面路由
currentPage,
// 方法
countByStatus, getPriorityType, getStatusClass,
refresh, refreshPipeline, refreshAll,
triggerGenerate, triggerOptimize,
openPreview, loadPreview, copyPreviewHtml, expandPreview,
fetchLogs,
createTopic, optimizeTopic, handlePublish,
openCreateTopic,
// 工具函数
formatDate, formatRelativeTime,
switchPage, goToTopicsWithFilter,
// 认证(未完整)
isLoggedIn, isAdmin, loginForm, loginError,
// 图标
Document, Upload, CopyDocument, FullScreen, Promotion
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

+4
View File
@@ -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

+4
View File
@@ -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

File diff suppressed because one or more lines are too long
+116
View File
@@ -0,0 +1,116 @@
// Service Worker for 宇之然内容创作平台
const CACHE_NAME = 'yuzhiran-v1';
const CACHE_URLS = [
'/',
'/index.html',
'/offline.html',
'/static/vue.global.prod.js',
'/static/element-plus.css',
'/static/element-plus.full.js',
'/manifest.json'
];
// 安装事件:预缓存核心资源
self.addEventListener('install', (event) => {
console.log('[SW] Installing...');
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
console.log('[SW] Pre-caching core assets');
return cache.addAll(CACHE_URLS.map(url => {
// 忽略同源请求404错误(静态资源可能不存在)
return new Promise((resolve, reject) => {
fetch(url).then(response => {
if (response.ok) {
resolve(url);
} else {
reject(new Error(`Failed to fetch ${url}: ${response.status}`));
}
}).catch(() => {
// 静默失败,不阻止安装
resolve(url);
});
});
}));
}).catch(err => {
console.error('[SW] Install failed:', err);
})
);
self.skipWaiting();
});
// 激活事件:清理旧缓存
self.addEventListener('activate', (event) => {
console.log('[SW] Activating...');
event.waitUntil(
caches.keys().then((cacheNames) => {
return Promise.all(
cacheNames.map((cache) => {
if (cache !== CACHE_NAME) {
console.log('[SW] Deleting old cache:', cache);
return caches.delete(cache);
}
})
);
})
);
self.clients.claim();
});
// 网络请求拦截:Cache First + Network Fallback
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
// 只处理同源请求
if (url.origin !== location.origin) {
return;
}
// API 请求:Network Only(不走缓存)
if (url.pathname.startsWith('/api/')) {
event.respondWith(fetch(request));
return;
}
// 静态资源:Cache First
event.respondWith(
caches.match(request).then((cached) => {
if (cached) {
// 返回缓存,并在后台更新
fetch(request).then(response => {
if (response.ok) {
caches.open(CACHE_NAME).then(cache => cache.put(request, response));
}
});
return cached;
}
// 无缓存,发起网络请求
return fetch(request).then(response => {
// 成功且为有效响应,加入缓存
if (response.ok && response.status === 200) {
const responseClone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(request, responseClone));
}
return response;
}).catch(() => {
// 网络失败,尝试返回离线页面(如果是文档请求)
if (request.destination === 'document') {
return caches.match('/offline.html');
}
});
})
);
});
// 后台同步(可选:在网络恢复后发送错误日志)
self.addEventListener('sync', (event) => {
if (event.tag === 'sync-logs') {
event.waitUntil(syncLogs());
}
});
async function syncLogs() {
// TODO: 实现日志同步
console.log('[SW] Syncing logs...');
}