feat: 创作工作台统一 + AI味检测/白标 + 移动端补全 + 合规发布闭环
- 新增创作工作台 studio.html:合并选题/内容工厂/文章管理为单一 tab 入口(iframe embed 模式) - 新增 AI味检测模块(ai_slop API + 页面,合规软硬问题分级) - 新增白标品牌配置(branding API + 页面 + deploy 私有化交付包) - 发布闭环:publishing 放宽至 editor + records/mark-published 接口 - 移动端响应式补全(admin/calendar/ai-slop 表格卡片兜底) - 修复菜单幂等播种缺陷(按 path 对齐,避免功能页孤立) - 新增短视频脚本 shortvideo.py 与 2026 市场调研简报
This commit is contained in:
@@ -199,7 +199,8 @@
|
||||
<div class="empty-text">暂无平台配置</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-table v-else :data="platformConfigs" border stripe style="width:100%">
|
||||
<template v-else>
|
||||
<el-table class="data-table" :data="platformConfigs" border stripe style="width:100%">
|
||||
<el-table-column prop="platform" label="标识" width="120"></el-table-column>
|
||||
<el-table-column prop="name" label="名称" width="120"></el-table-column>
|
||||
<el-table-column prop="icon" label="图标" width="100">
|
||||
@@ -230,6 +231,22 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="pc in platformConfigs" :key="pc.platform" class="card-item">
|
||||
<div class="card-row"><span class="card-label">标识</span><span class="card-value">{{ pc.platform }}</span></div>
|
||||
<div class="card-row"><span class="card-label">名称</span><span class="card-value">{{ pc.name }}</span></div>
|
||||
<div class="card-row"><span class="card-label">图标</span><span class="card-value">{{ pc.icon || '—' }}</span></div>
|
||||
<div class="card-row"><span class="card-label">网址</span><span class="card-value" style="word-break:break-all;">{{ pc.website_url || '—' }}</span></div>
|
||||
<div class="card-row"><span class="card-label">字数</span><span class="card-value">{{ pc.min_words }} - {{ pc.max_words }}</span></div>
|
||||
<div class="card-row"><span class="card-label">配图</span><span class="card-value">{{ pc.requires_image ? pc.image_count_min + '-' + pc.image_count_max + '张' : '不需要' }}</span></div>
|
||||
<div class="card-row"><span class="card-label">激活</span><span class="card-value">{{ pc.is_active ? '是' : '否' }}</span></div>
|
||||
<div class="card-actions">
|
||||
<el-button size="small" @click="showPcDialogFn(pc)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deletePlatformConfig(pc.platform)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<el-dialog v-model="showPcDialog" :title="pcDialogTitle" width="750px" :close-on-click-modal="false">
|
||||
<el-form :model="pcForm" label-width="100px">
|
||||
<el-row :gutter="16">
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台 - AI味检测</title>
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="AI味检测" :username="currentUser.username" :is-admin="isAdmin" current-page="ai-slop" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon> AI味检测 + 净化</h2>
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<el-input v-model="topicId" placeholder="输入选题 ID,如 B02" clearable size="default" style="width:280px;" @keyup.enter="runReport">
|
||||
<template #prepend>选题</template>
|
||||
</el-input>
|
||||
<el-button type="primary" :loading="loadingReport" @click="runReport">检测</el-button>
|
||||
</div>
|
||||
<el-alert v-if="errorMsg" :title="errorMsg" type="error" show-icon :closable="false" style="margin-bottom:12px;"></el-alert>
|
||||
<div v-if="!loadingReport && report && report.platforms.length === 0" style="text-align:center;padding:40px 0;color:#909399;font-size:14px;">
|
||||
该选题暂无文章,请先创作内容。
|
||||
</div>
|
||||
<div v-for="p in (report ? report.platforms : [])" :key="p.platform" style="margin-bottom:20px;">
|
||||
<el-card shadow="hover">
|
||||
<template #header>
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
||||
<strong style="font-size:16px;">{{ platformLabel(p.platform) }}</strong>
|
||||
<el-tag :type="p.passed ? 'success' : 'danger'" size="small">{{ p.passed ? '通过' : '未通过' }}</el-tag>
|
||||
<span style="font-size:13px;color:#909399;">合规分:<b :style="{color: p.score >= 80 ? '#67c23a' : (p.score >= 60 ? '#e6a23c' : '#f56c6c')}">{{ p.score }}</b></span>
|
||||
<el-button size="small" type="warning" :loading="p.purifying" @click="purify(p)" style="margin-left:auto;">一键净化</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="p.message" style="margin-bottom:10px;color:#67c23a;font-size:13px;">{{ p.message }}</div>
|
||||
<div v-if="p.score_before !== null" style="margin-bottom:10px;font-size:13px;color:#606266;">
|
||||
净化前分:<b>{{ p.score_before }}</b> → 净化后分:<b :style="{color:'#67c23a'}">{{ p.score_after }}</b>
|
||||
<span style="margin-left:12px;">问题数:{{ p.issues_before.length }} → {{ p.issues_after.length }}</span>
|
||||
</div>
|
||||
<div v-if="p.issues.length === 0" style="color:#67c23a;font-size:14px;">✅ 未检测到明显问题</div>
|
||||
<template v-else>
|
||||
<el-table class="card-table" :data="p.issues" size="small" stripe>
|
||||
<el-table-column prop="type" label="类型" width="110">
|
||||
<template #default="s"><el-tag :type="s.row.severity === 'high' ? 'danger' : 'warning'" size="small">{{ s.row.type }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="category" label="类别" width="130" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="detail" label="详情" min-width="200" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="suggestion" label="建议" min-width="180" show-overflow-tooltip></el-table-column>
|
||||
</el-table>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="iss in p.issues" :key="iss.type + iss.detail" class="card-item">
|
||||
<div class="card-row"><span class="card-label">类型</span><span class="card-value"><el-tag :type="iss.severity === 'high' ? 'danger' : 'warning'" size="small">{{ iss.type }}</el-tag></span></div>
|
||||
<div class="card-row"><span class="card-label">类别</span><span class="card-value">{{ iss.category }}</span></div>
|
||||
<div class="card-row"><span class="card-label">详情</span><span class="card-value">{{ iss.detail }}</span></div>
|
||||
<div class="card-row"><span class="card-label">建议</span><span class="card-value">{{ iss.suggestion }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-card>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const AiSlopApp = {
|
||||
data() {
|
||||
return {
|
||||
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
|
||||
topicId: '', loadingReport: false, report: null, errorMsg: ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getToken() { return localStorage.getItem('authToken'); },
|
||||
async api(url, opts = {}) {
|
||||
const token = this.getToken();
|
||||
if (!token) { this.$message.error('请先登录'); setTimeout(() => window.location.href = '/login.html', 1500); return null; }
|
||||
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...(opts.headers || {}) }, ...opts });
|
||||
if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `请求失败: ${res.status}`); }
|
||||
return res.json();
|
||||
},
|
||||
async runReport() {
|
||||
this.errorMsg = '';
|
||||
if (!this.topicId.trim()) { this.$message.warning('请输入选题 ID'); return; }
|
||||
this.loadingReport = true;
|
||||
this.report = null;
|
||||
try {
|
||||
const data = await this.api('/api/ai-slop/report?topic_id=' + encodeURIComponent(this.topicId.trim()));
|
||||
this.report = { topic_id: data.topic_id, platforms: (data.platforms || []).map(p => ({ ...p, purifying: false, message: '', score_before: null, score_after: null, issues_before: [], issues_after: [] })) };
|
||||
} catch (error) {
|
||||
this.errorMsg = error.message;
|
||||
} finally { this.loadingReport = false; }
|
||||
},
|
||||
async purify(p) {
|
||||
p.purifying = true; p.message = '';
|
||||
try {
|
||||
const data = await this.api('/api/ai-slop/purify', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ topic_id: this.topicId.trim(), platform: p.platform })
|
||||
});
|
||||
p.score_before = data.score_before;
|
||||
p.score_after = data.score_after;
|
||||
p.issues_before = data.issues_before || [];
|
||||
p.issues_after = data.issues_after || [];
|
||||
p.issues = data.issues_after || [];
|
||||
p.score = data.score_after;
|
||||
p.passed = (data.score_after !== null && data.issues_after.length === 0) || (data.issues_after && data.issues_after.every(i => i.severity !== 'high')) || (p.score >= 80);
|
||||
p.passed = data.score_after !== null ? (data.issues_after || []).every(i => i.severity !== 'high') : p.passed;
|
||||
p.message = data.message || '净化完成';
|
||||
this.$message.success('净化完成,合规分 ' + data.score_before + ' → ' + data.score_after);
|
||||
} catch (error) {
|
||||
this.$message.error('净化失败: ' + error.message);
|
||||
} finally { p.purifying = false; }
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
platformLabel(p) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p; },
|
||||
},
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const tid = params.get('topic_id');
|
||||
if (tid) this.topicId = tid;
|
||||
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;
|
||||
if (tid) this.runReport();
|
||||
})
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(AiSlopApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+305
-193
@@ -24,86 +24,130 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="文章管理" :username="currentUser.username" :is-admin="isAdmin" current-page="articles" @navigate="redirectToPage" @logout="handleLogout">
|
||||
<uni-nav v-if="!isEmbed" title="文章管理" :username="currentUser.username" :is-admin="isAdmin" current-page="articles" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 文章管理</h2>
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="filterPlatform" placeholder="全部平台" clearable size="default" style="width:140px;" @change="fetchArticles">
|
||||
<el-option label="全部平台" value=""></el-option>
|
||||
<el-option label="知乎" value="zhihu"></el-option>
|
||||
<el-option label="微信公众号" value="wechat"></el-option>
|
||||
<el-option label="小红书" value="xiaohongshu"></el-option>
|
||||
</el-select>
|
||||
<el-select v-model="filterStatus" placeholder="全部状态" clearable size="default" style="width:140px;" @change="fetchArticles">
|
||||
<el-option label="全部状态" value=""></el-option>
|
||||
<el-option label="草稿" value="draft"></el-option>
|
||||
<el-option label="已审查" value="reviewed"></el-option>
|
||||
<el-option label="已发布" value="published"></el-option>
|
||||
</el-select>
|
||||
<el-input v-model="searchQuery" placeholder="搜索文章 ID / 选题标题" clearable size="default" class="search-bar" @input="debouncedSearch" @clear="fetchArticles">
|
||||
<template #prefix><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon></template>
|
||||
</el-input>
|
||||
</div>
|
||||
<el-table :data="filteredArticles" stripe v-loading="loadingTable">
|
||||
<el-table-column prop="id" label="文章 ID" width="160"></el-table-column>
|
||||
<el-table-column prop="topic_id" label="选题 ID" width="80"></el-table-column>
|
||||
<el-table-column prop="topic_title" label="选题标题" min-width="200" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="platform" label="平台" width="100">
|
||||
<template #default="scope">{{ platformLabel(scope.row.platform) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="scope"><span class="status-dot" :class="scope.row.status"></span> {{ statusLabel(scope.row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="compliance_score" label="合规分" width="80">
|
||||
<template #default="scope">{{ scope.row.compliance_score ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="word_count" label="字数" width="70">
|
||||
<template #default="scope">{{ scope.row.word_count ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="配图" width="60">
|
||||
<template #default="scope">
|
||||
<span v-if="scope.row.images && scope.row.images.cover" style="cursor:pointer;font-size:16px;" title="有封面图"><el-icon style="vertical-align:-2px;"><IconPicture /></el-icon></span>
|
||||
<span v-else style="color:#dcdfe6;">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="130" fixed="right">
|
||||
<template #default="scope">
|
||||
<div style="display: flex; gap: 4px;">
|
||||
<el-button size="small" type="primary" @click="previewArticle(scope.row)" style="padding:5px 8px;">预览</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteArticle(scope.row)" style="padding:5px 8px;">删除</el-button>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="文章管理" name="articles">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconDocument /></el-icon> 文章管理</h2>
|
||||
</div>
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="filterPlatform" placeholder="全部平台" clearable size="default" style="width:140px;" @change="fetchArticles">
|
||||
<el-option label="全部平台" value=""></el-option>
|
||||
<el-option label="知乎" value="zhihu"></el-option>
|
||||
<el-option label="微信公众号" value="wechat"></el-option>
|
||||
<el-option label="小红书" value="xiaohongshu"></el-option>
|
||||
</el-select>
|
||||
<el-select v-model="filterStatus" placeholder="全部状态" clearable size="default" style="width:140px;" @change="fetchArticles">
|
||||
<el-option label="全部状态" value=""></el-option>
|
||||
<el-option label="草稿" value="draft"></el-option>
|
||||
<el-option label="已审查" value="reviewed"></el-option>
|
||||
<el-option label="已发布" value="published"></el-option>
|
||||
</el-select>
|
||||
<el-input v-model="searchQuery" placeholder="搜索文章 ID / 选题标题" clearable size="default" class="search-bar" @input="debouncedSearch" @clear="fetchArticles">
|
||||
<template #prefix><el-icon style="vertical-align:-2px;"><IconSearch /></el-icon></template>
|
||||
</el-input>
|
||||
</div>
|
||||
<el-table :data="filteredArticles" stripe v-loading="loadingTable">
|
||||
<el-table-column prop="id" label="文章 ID" width="160"></el-table-column>
|
||||
<el-table-column prop="topic_id" label="选题 ID" width="80"></el-table-column>
|
||||
<el-table-column prop="topic_title" label="选题标题" min-width="200" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="platform" label="平台" width="100">
|
||||
<template #default="scope">{{ platformLabel(scope.row.platform) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="scope"><span class="status-dot" :class="scope.row.status"></span> {{ statusLabel(scope.row.status) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="compliance_score" label="合规分" width="80">
|
||||
<template #default="scope">{{ scope.row.compliance_score ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="word_count" label="字数" width="70">
|
||||
<template #default="scope">{{ scope.row.word_count ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="创建时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="210" fixed="right">
|
||||
<template #default="scope">
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
|
||||
<el-button size="small" type="primary" @click="previewArticle(scope.row)" style="padding:5px 8px;">预览</el-button>
|
||||
<el-button size="small" @click="editArticle(scope.row)" style="padding:5px 8px;">编辑</el-button>
|
||||
<el-button size="small" type="warning" @click="openSlop(scope.row)" style="padding:5px 8px;">检测</el-button>
|
||||
<el-button size="small" type="success" @click="goPublish()" style="padding:5px 8px;">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteArticle(scope.row)" style="padding:5px 8px;">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="article-card-list" v-if="filteredArticles && filteredArticles.length > 0">
|
||||
<div v-for="article in filteredArticles" :key="article.id" class="article-card">
|
||||
<div class="article-card-header">
|
||||
<div class="article-card-title">{{ article.topic_title || article.topic_id }}</div>
|
||||
<el-tag :type="statusTagType(article.status)" size="small">{{ statusLabel(article.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="article-card-meta">
|
||||
<div>ID: {{ article.id }}</div>
|
||||
<div>平台: {{ platformLabel(article.platform) }}</div>
|
||||
<div>合规: {{ article.compliance_score ?? '-' }}</div>
|
||||
<div>字数: {{ article.word_count ?? '-' }}</div>
|
||||
<div>创建: {{ formatDate(article.created_at) }}</div>
|
||||
</div>
|
||||
<div class="article-card-actions">
|
||||
<el-button size="small" type="primary" @click="previewArticle(article)">预览</el-button>
|
||||
<el-button size="small" @click="editArticle(article)">编辑</el-button>
|
||||
<el-button size="small" type="warning" @click="openSlop(article)">检测</el-button>
|
||||
<el-button size="small" type="success" @click="goPublish()">发布</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteArticle(article)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="article-card-list" v-if="filteredArticles && filteredArticles.length > 0">
|
||||
<div v-for="article in filteredArticles" :key="article.id" class="article-card">
|
||||
<div class="article-card-header">
|
||||
<div class="article-card-title">{{ article.topic_title || article.topic_id }}</div>
|
||||
<el-tag :type="statusTagType(article.status)" size="small">{{ statusLabel(article.status) }}</el-tag>
|
||||
</div>
|
||||
<div class="article-card-meta">
|
||||
<div>ID: {{ article.id }}</div>
|
||||
<div>平台: {{ platformLabel(article.platform) }}</div>
|
||||
<div>合规: {{ article.compliance_score ?? '-' }}</div>
|
||||
<div>字数: {{ article.word_count ?? '-' }}</div>
|
||||
<div>创建: {{ formatDate(article.created_at) }}</div>
|
||||
</div>
|
||||
<div class="article-card-actions">
|
||||
<el-button size="small" type="primary" @click="previewArticle(article)">预览</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteArticle(article)">删除</el-button>
|
||||
<div v-if="!loadingTable && filteredArticles.length === 0" style="text-align:center;padding:40px 0;color:#909399;font-size:14px;">
|
||||
<el-icon><IconDocument /></el-icon> 暂无文章
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!loadingTable && filteredArticles.length === 0" style="text-align:center;padding:40px 0;color:#909399;font-size:14px;">
|
||||
<el-icon><IconDocument /></el-icon> 暂无文章
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="发布记录" name="publish">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconUpload /></el-icon> 发布记录</h2>
|
||||
<el-tag type="info" size="small">AI 内容需真人校验后发布,系统不代发</el-tag>
|
||||
</div>
|
||||
<el-table :data="publishRecords" stripe v-loading="loadingRecords">
|
||||
<el-table-column prop="topic_id" label="选题 ID" width="90"></el-table-column>
|
||||
<el-table-column prop="topic_title" label="选题标题" min-width="200" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column prop="platform" label="平台" width="100">
|
||||
<template #default="scope">{{ platformLabel(scope.row.platform) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="scope"><el-tag :type="scope.row.status === 'published' ? 'success' : 'warning'" size="small">{{ scope.row.status === 'published' ? '已发布' : '待人工发布' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="operator" label="操作人" width="100"></el-table-column>
|
||||
<el-table-column prop="created_at" label="生成时间" width="140"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="scope">
|
||||
<el-button v-if="scope.row.status === 'pending_manual'" size="small" type="success" @click="markRecord(scope.row)" style="padding:5px 8px;">标记已发布</el-button>
|
||||
<span v-else style="color:#909399;font-size:12px;">—</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="card-list-mobile" v-if="publishRecords && publishRecords.length > 0">
|
||||
<div v-for="rec in publishRecords" :key="rec.id" class="card-item">
|
||||
<div class="card-row"><span class="card-label">选题</span><span class="card-value">{{ rec.topic_title || rec.topic_id }}</span></div>
|
||||
<div class="card-row"><span class="card-label">平台</span><span class="card-value">{{ platformLabel(rec.platform) }}</span></div>
|
||||
<div class="card-row"><span class="card-label">状态</span><span class="card-value"><el-tag :type="rec.status === 'published' ? 'success' : 'warning'" size="small">{{ rec.status === 'published' ? '已发布' : '待人工发布' }}</el-tag></span></div>
|
||||
<div class="card-row"><span class="card-label">操作人</span><span class="card-value">{{ rec.operator || '-' }}</span></div>
|
||||
<div class="card-actions" v-if="rec.status === 'pending_manual'">
|
||||
<el-button size="small" type="success" @click="markRecord(rec)">标记已发布</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!loadingRecords && publishRecords.length === 0" style="text-align:center;padding:40px 0;color:#909399;font-size:14px;">
|
||||
<el-icon><IconUpload /></el-icon> 暂无发布记录
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
</main>
|
||||
</div>
|
||||
<el-dialog v-model="previewVisible" title="文章预览" width="85%" :modal-props="{ closeOnClickModal: false }" :before-close="() => previewVisible = false" class="preview-dialog-custom" :fullscreen="previewFullscreen" close-on-press-escape :lock-scroll="false">
|
||||
@@ -136,137 +180,205 @@
|
||||
</div>
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="editVisible" title="编辑正文" width="85%" :close-on-click-modal="false">
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:12px;">修改后保存即覆盖该平台文章,发布前请先完成 AI 味检测与人工校验。</el-alert>
|
||||
<el-input type="textarea" :rows="20" v-model="editForm.html_content" style="font-family:monospace;"></el-input>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="savingEdit" @click="saveArticle">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const ArticlesApp = {
|
||||
data() {
|
||||
return {
|
||||
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
|
||||
loadingTable: false,
|
||||
articles: [],
|
||||
filterPlatform: '', filterStatus: '', searchQuery: '',
|
||||
previewVisible: false, previewArticleData: null, previewFullscreen: false,
|
||||
debounceTimer: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filteredArticles() {
|
||||
let list = this.articles;
|
||||
if (this.filterPlatform) list = list.filter(a => a.platform === this.filterPlatform);
|
||||
if (this.filterStatus) list = list.filter(a => a.status === this.filterStatus);
|
||||
if (this.searchQuery) {
|
||||
const q = this.searchQuery.toLowerCase();
|
||||
list = list.filter(a => (a.id && a.id.toLowerCase().includes(q)) || (a.topic_title && a.topic_title.toLowerCase().includes(q)));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getToken() { return localStorage.getItem('authToken'); },
|
||||
async api(url, opts = {}) {
|
||||
const token = this.getToken();
|
||||
if (!token) { this.$message.error('请先登录'); setTimeout(() => window.location.href = '/login.html', 1500); return null; }
|
||||
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...opts.headers }, ...opts });
|
||||
if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `请求失败: ${res.status}`); }
|
||||
return res.json();
|
||||
},
|
||||
async fetchArticles() {
|
||||
this.loadingTable = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (this.filterPlatform) params.set('platform', this.filterPlatform);
|
||||
if (this.filterStatus) params.set('status', this.filterStatus);
|
||||
const qs = params.toString();
|
||||
const data = await this.api('/api/articles/list' + (qs ? '?' + qs : ''));
|
||||
this.articles = data.articles || [];
|
||||
} catch (error) {
|
||||
console.error('获取文章列表失败:', error);
|
||||
this.$message.error('获取文章列表失败: ' + error.message);
|
||||
this.articles = [];
|
||||
} finally { this.loadingTable = false; }
|
||||
},
|
||||
debouncedSearch() {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(() => { this.fetchArticles(); }, 400);
|
||||
},
|
||||
async previewArticle(article) {
|
||||
this.previewArticleData = null;
|
||||
this.previewVisible = true;
|
||||
this.previewFullscreen = false;
|
||||
try {
|
||||
const data = await this.api('/api/articles/detail/' + article.id);
|
||||
this.previewArticleData = data;
|
||||
} catch (error) {
|
||||
this.$message.error('加载文章详情失败: ' + error.message);
|
||||
this.previewVisible = false;
|
||||
const ArticlesApp = {
|
||||
data() {
|
||||
return {
|
||||
isEmbed: false,
|
||||
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
|
||||
activeTab: 'articles',
|
||||
loadingTable: false,
|
||||
articles: [],
|
||||
filterPlatform: '', filterStatus: '', searchQuery: '',
|
||||
previewVisible: false, previewArticleData: null, previewFullscreen: false,
|
||||
editVisible: false, savingEdit: false, editForm: { topic_id: '', platform: '', html_content: '' },
|
||||
publishRecords: [], loadingRecords: false,
|
||||
debounceTimer: null
|
||||
}
|
||||
},
|
||||
togglePreviewFullscreen() {
|
||||
this.previewFullscreen = !this.previewFullscreen;
|
||||
this.$nextTick(() => {
|
||||
const overlays = document.querySelectorAll('.el-overlay');
|
||||
const overlay = overlays[overlays.length - 1];
|
||||
if (overlay) overlay.style.zIndex = this.previewFullscreen ? '100000' : '';
|
||||
const dialog = document.querySelector('.preview-dialog-custom');
|
||||
if (dialog) { dialog.style.zIndex = this.previewFullscreen ? '100001' : ''; }
|
||||
});
|
||||
computed: {
|
||||
filteredArticles() {
|
||||
let list = this.articles;
|
||||
if (this.filterPlatform) list = list.filter(a => a.platform === this.filterPlatform);
|
||||
if (this.filterStatus) list = list.filter(a => a.status === this.filterStatus);
|
||||
if (this.searchQuery) {
|
||||
const q = this.searchQuery.toLowerCase();
|
||||
list = list.filter(a => (a.id && a.id.toLowerCase().includes(q)) || (a.topic_title && a.topic_title.toLowerCase().includes(q)));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
},
|
||||
copyPreviewHtml() {
|
||||
const html = this.previewArticleData?.html_content;
|
||||
if (!html) { this.$message.warning('暂无内容可复制'); return; }
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const titleEl = doc.querySelector('h1');
|
||||
const title = titleEl ? titleEl.textContent.trim() : (this.previewArticleData.topic_title || '');
|
||||
const body = doc.body;
|
||||
if (body) body.querySelectorAll('script, style, nav, footer, .interaction, .ad, aside, .comment').forEach(el => el.remove());
|
||||
const container = doc.createElement('div');
|
||||
if (titleEl) { const h1 = doc.createElement('h1'); h1.textContent = title; container.appendChild(h1); }
|
||||
body.querySelectorAll('h2,h3,h4,p,li,blockquote,img,pre,code,table,hr').forEach(el => container.appendChild(el.cloneNode(true)));
|
||||
const cleanHtml = container.innerHTML;
|
||||
const blob = new Blob([cleanHtml], { type: 'text/html' });
|
||||
const plainText = container.textContent;
|
||||
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([plainText], { type: 'text/plain' }) });
|
||||
navigator.clipboard.write([item]).then(() => this.$message.success('✅ 已复制(含格式和配图),Ctrl+V 粘贴')).catch(() => {
|
||||
navigator.clipboard.writeText(cleanHtml).then(() => this.$message.success('✅ 已复制 HTML')).catch(() => this.$message.error('❌ 复制失败'));
|
||||
});
|
||||
watch: {
|
||||
activeTab(v) { if (v === 'publish') this.fetchRecords(); }
|
||||
},
|
||||
async deleteArticle(article) {
|
||||
try {
|
||||
await this.$confirm(`确定删除文章 [${article.id}]?`, '提示', { type: 'warning' });
|
||||
await this.api('/api/articles/' + article.id, { method: 'DELETE' });
|
||||
this.$message.success('删除成功');
|
||||
await this.fetchArticles();
|
||||
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败: ' + (e.message || '未知错误')); }
|
||||
methods: {
|
||||
getToken() { return localStorage.getItem('authToken'); },
|
||||
async api(url, opts = {}) {
|
||||
const token = this.getToken();
|
||||
if (!token) { this.$message.error('请先登录'); setTimeout(() => window.location.href = '/login.html', 1500); return null; }
|
||||
const res = await fetch(url, { headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', ...opts.headers }, ...opts });
|
||||
if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.detail || `请求失败: ${res.status}`); }
|
||||
return res.json();
|
||||
},
|
||||
async fetchArticles() {
|
||||
this.loadingTable = true;
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (this.filterPlatform) params.set('platform', this.filterPlatform);
|
||||
if (this.filterStatus) params.set('status', this.filterStatus);
|
||||
const qs = params.toString();
|
||||
const data = await this.api('/api/articles/list' + (qs ? '?' + qs : ''));
|
||||
this.articles = data.articles || [];
|
||||
} catch (error) {
|
||||
console.error('获取文章列表失败:', error);
|
||||
this.$message.error('获取文章列表失败: ' + error.message);
|
||||
this.articles = [];
|
||||
} finally { this.loadingTable = false; }
|
||||
},
|
||||
async fetchRecords() {
|
||||
this.loadingRecords = true;
|
||||
try {
|
||||
const data = await this.api('/api/publishing/records');
|
||||
this.publishRecords = data.records || [];
|
||||
} catch (error) {
|
||||
this.$message.error('加载发布记录失败: ' + error.message);
|
||||
this.publishRecords = [];
|
||||
} finally { this.loadingRecords = false; }
|
||||
},
|
||||
debouncedSearch() {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(() => { this.fetchArticles(); }, 400);
|
||||
},
|
||||
async previewArticle(article) {
|
||||
this.previewArticleData = null;
|
||||
this.previewVisible = true;
|
||||
this.previewFullscreen = false;
|
||||
try {
|
||||
const data = await this.api('/api/articles/detail/' + article.id);
|
||||
this.previewArticleData = data;
|
||||
} catch (error) {
|
||||
this.$message.error('加载文章详情失败: ' + error.message);
|
||||
this.previewVisible = false;
|
||||
}
|
||||
},
|
||||
async editArticle(article) {
|
||||
this.editForm = { topic_id: article.topic_id, platform: article.platform, html_content: '' };
|
||||
this.editVisible = true;
|
||||
this.savingEdit = false;
|
||||
try {
|
||||
const data = await this.api('/api/articles/detail/' + article.id);
|
||||
this.editForm.html_content = data.html_content || '';
|
||||
} catch (error) {
|
||||
this.$message.error('加载正文失败: ' + error.message);
|
||||
this.editVisible = false;
|
||||
}
|
||||
},
|
||||
async saveArticle() {
|
||||
this.savingEdit = true;
|
||||
try {
|
||||
await this.api('/api/articles/' + encodeURIComponent(this.editForm.topic_id) + '/content', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ platform: this.editForm.platform, html_content: this.editForm.html_content })
|
||||
});
|
||||
this.$message.success('正文已保存');
|
||||
this.editVisible = false;
|
||||
await this.fetchArticles();
|
||||
} catch (error) {
|
||||
this.$message.error('保存失败: ' + error.message);
|
||||
} finally { this.savingEdit = false; }
|
||||
},
|
||||
openSlop(article) {
|
||||
window.open('ai-slop.html?topic_id=' + encodeURIComponent(article.topic_id), '_blank');
|
||||
},
|
||||
goPublish() { this.redirectToPage('topics.html'); },
|
||||
async markRecord(rec) {
|
||||
try {
|
||||
await this.$confirm('确认已在各平台完成发布?', '标记发布', { type: 'warning' });
|
||||
await this.api('/api/publishing/' + encodeURIComponent(rec.topic_id) + '/mark-published', { method: 'POST' });
|
||||
this.$message.success('已标记为已发布');
|
||||
await this.fetchRecords();
|
||||
} catch (e) {
|
||||
if (e === 'cancel') return;
|
||||
const msg = e?.response?.data?.detail || e?.message || '未知错误';
|
||||
this.$message.error('操作失败: ' + msg);
|
||||
}
|
||||
},
|
||||
togglePreviewFullscreen() {
|
||||
this.previewFullscreen = !this.previewFullscreen;
|
||||
this.$nextTick(() => {
|
||||
const overlays = document.querySelectorAll('.el-overlay');
|
||||
const overlay = overlays[overlays.length - 1];
|
||||
if (overlay) overlay.style.zIndex = this.previewFullscreen ? '100000' : '';
|
||||
const dialog = document.querySelector('.preview-dialog-custom');
|
||||
if (dialog) { dialog.style.zIndex = this.previewFullscreen ? '100001' : ''; }
|
||||
});
|
||||
},
|
||||
copyPreviewHtml() {
|
||||
const html = this.previewArticleData?.html_content;
|
||||
if (!html) { this.$message.warning('暂无内容可复制'); return; }
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, 'text/html');
|
||||
const titleEl = doc.querySelector('h1');
|
||||
const title = titleEl ? titleEl.textContent.trim() : (this.previewArticleData.topic_title || '');
|
||||
const body = doc.body;
|
||||
if (body) body.querySelectorAll('script, style, nav, footer, .interaction, .ad, aside, .comment').forEach(el => el.remove());
|
||||
const container = doc.createElement('div');
|
||||
if (titleEl) { const h1 = doc.createElement('h1'); h1.textContent = title; container.appendChild(h1); }
|
||||
body.querySelectorAll('h2,h3,h4,p,li,blockquote,img,pre,code,table,hr').forEach(el => container.appendChild(el.cloneNode(true)));
|
||||
const cleanHtml = container.innerHTML;
|
||||
const blob = new Blob([cleanHtml], { type: 'text/html' });
|
||||
const plainText = container.textContent;
|
||||
const item = new ClipboardItem({ 'text/html': blob, 'text/plain': new Blob([plainText], { type: 'text/plain' }) });
|
||||
navigator.clipboard.write([item]).then(() => this.$message.success('✅ 已复制(含格式和配图),Ctrl+V 粘贴')).catch(() => {
|
||||
navigator.clipboard.writeText(cleanHtml).then(() => this.$message.success('✅ 已复制 HTML')).catch(() => this.$message.error('❌ 复制失败'));
|
||||
});
|
||||
},
|
||||
async deleteArticle(article) {
|
||||
try {
|
||||
await this.$confirm(`确定删除文章 [${article.id}]?`, '提示', { type: 'warning' });
|
||||
await this.api('/api/articles/' + article.id, { method: 'DELETE' });
|
||||
this.$message.success('删除成功');
|
||||
await this.fetchArticles();
|
||||
} catch (e) { if (e !== 'cancel') this.$message.error('删除失败: ' + (e.message || '未知错误')); }
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
|
||||
redirectToPage(page) { const url = page.startsWith('/') ? page : '/' + page; if (this.isEmbed) window.top.location.href = url; else window.location.href = url; },
|
||||
platformLabel(p) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p; },
|
||||
statusLabel(s) { return { draft: '草稿', reviewed: '已审查', published: '已发布' }[s] || s; },
|
||||
statusTagType(s) { return { draft: 'warning', reviewed: 'success', published: 'info' }[s] || 'primary'; },
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
|
||||
catch (e) { return dateStr; }
|
||||
}
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
platformLabel(p) { return { zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p; },
|
||||
statusLabel(s) { return { draft: '草稿', reviewed: '已审查', published: '已发布' }[s] || s; },
|
||||
statusTagType(s) { return { draft: 'warning', reviewed: 'success', published: 'info' }[s] || 'primary'; },
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
try { return new Date(dateStr.replace(' ', 'T')).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); }
|
||||
catch (e) { return dateStr; }
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
if (new URLSearchParams(window.location.search).get('embed') === '1') { this.isEmbed = true; document.body.classList.add('embed'); }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchArticles(); })
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
|
||||
.then(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(data => { this.currentUser = data.user; this.isAdmin = data.user.role === 'admin'; this.isLoggedIn = true; this.fetchArticles(); })
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(ArticlesApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
};
|
||||
const app = Vue.createApp(ArticlesApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
<script src="ai-assistant.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台 - 白标设置</title>
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="白标设置" :username="currentUser.username" :is-admin="isAdmin" current-page="branding" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconSetting /></el-icon> 白标 / 私有化品牌设置</h2>
|
||||
<el-tag type="info" size="small">仅管理员可见</el-tag>
|
||||
</div>
|
||||
<el-alert type="success" :closable="false" show-icon style="margin-bottom:16px;">
|
||||
<template #title>私有化交付说明</template>
|
||||
修改以下配置即可将本平台改名为贵司品牌(名称 / Logo / 主色 / 支持邮箱),保存后前台导航与品牌色即时生效。适合部署到客户内网或作为白标产品交付。
|
||||
</el-alert>
|
||||
|
||||
<el-form label-width="110px" style="max-width:640px;">
|
||||
<el-form-item label="平台名称">
|
||||
<el-input v-model="form.brand_name" placeholder="如:星辰内容中台" maxlength="20" show-word-limit></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="Logo 地址">
|
||||
<el-input v-model="form.brand_logo" placeholder="https 图片 URL,留空则显示文字名称"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="主色">
|
||||
<el-color-picker v-model="form.brand_primary_color"></el-color-picker>
|
||||
<el-input v-model="form.brand_primary_color" style="width:120px;margin-left:12px;" placeholder="#2563eb"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="支持邮箱">
|
||||
<el-input v-model="form.brand_support_email" placeholder="support@yourcompany.com"></el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="saving" @click="save">保存并应用</el-button>
|
||||
<el-button @click="reset">重置为当前</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-divider>实时预览</el-divider>
|
||||
<div :style="{ background: 'linear-gradient(135deg,' + (form.brand_primary_color || '#2563eb') + ',' + (form.brand_primary_color || '#2563eb') + 'cc)', color:'#fff', padding:'10px 16px', borderRadius:'8px', display:'flex', alignItems:'center', gap:'8px', maxWidth:'480px' }">
|
||||
<img v-if="form.brand_logo" :src="form.brand_logo" style="height:22px;border-radius:4px;" />
|
||||
<strong>{{ form.brand_name || '平台名称预览' }}</strong>
|
||||
<span style="margin-left:auto;font-size:12px;opacity:.85;">v{{ version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script src="element-plus.full.js"></script>
|
||||
<script>
|
||||
const BrandingApp = {
|
||||
data() {
|
||||
return {
|
||||
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
|
||||
form: { brand_name: '', brand_logo: '', brand_primary_color: '#2563eb', brand_support_email: '' },
|
||||
version: '0.1.0', saving: false,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getToken() { return localStorage.getItem('authToken'); },
|
||||
async load() {
|
||||
try {
|
||||
const data = await fetch('/api/branding').then(r => r.json());
|
||||
this.form = {
|
||||
brand_name: data.brand_name || '',
|
||||
brand_logo: data.brand_logo || '',
|
||||
brand_primary_color: data.brand_primary_color || '#2563eb',
|
||||
brand_support_email: data.brand_support_email || '',
|
||||
};
|
||||
this.version = data.version || '0.1.0';
|
||||
} catch (e) { this.$message.error('加载品牌配置失败'); }
|
||||
},
|
||||
async save() {
|
||||
this.saving = true;
|
||||
try {
|
||||
const token = this.getToken();
|
||||
const res = await fetch('/api/branding', {
|
||||
method: 'PUT',
|
||||
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(this.form)
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.json()).detail || '保存失败');
|
||||
this.$message.success('已保存,刷新其他页面即可看到新品牌');
|
||||
setTimeout(() => this.load(), 300);
|
||||
} catch (e) {
|
||||
this.$message.error('保存失败:' + e.message);
|
||||
} finally { this.saving = false; }
|
||||
},
|
||||
reset() { this.load(); },
|
||||
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
|
||||
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(r => r.ok ? r.json() : Promise.reject())
|
||||
.then(data => {
|
||||
this.currentUser = data.user; this.isAdmin = data.user.role === 'admin';
|
||||
if (!this.isAdmin) { this.$message.warning('需要管理员权限'); window.location.href = '/index.html'; return; }
|
||||
this.load();
|
||||
})
|
||||
.catch(() => { window.location.href = '/login.html'; });
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(BrandingApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -186,7 +186,8 @@
|
||||
<div class="empty-text">该日暂无日程</div>
|
||||
<p style="font-size:13px;color:#909399;margin:4px 0 0 0;">点击上方"添加日程"创建发布计划</p>
|
||||
</div>
|
||||
<el-table v-else :data="selectedDayEntries" stripe>
|
||||
<template v-else>
|
||||
<el-table class="card-table" :data="selectedDayEntries" stripe>
|
||||
<el-table-column prop="title" label="标题" min-width="120"></el-table-column>
|
||||
<el-table-column prop="platform" label="平台" width="100">
|
||||
<template #default="scope">{{ scope.row._source === 'topic' ? (scope.row.status === 'published' ? '已发布' : '选题') : platformName(scope.row.platform) }}</template>
|
||||
@@ -201,6 +202,18 @@
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div class="card-list-mobile">
|
||||
<div v-for="entry in selectedDayEntries" :key="entry.id || entry.topic_id || entry.title" class="card-item">
|
||||
<div class="card-row"><span class="card-label">标题</span><span class="card-value">{{ entry.title }}</span></div>
|
||||
<div class="card-row"><span class="card-label">平台</span><span class="card-value">{{ entry._source === 'topic' ? (entry.status === 'published' ? '已发布' : '选题') : platformName(entry.platform) }}</span></div>
|
||||
<div class="card-row"><span class="card-label">状态</span><span class="card-value"><el-tag :type="statusType(entry.status)" size="small">{{ statusLabel(entry.status) }}</el-tag></span></div>
|
||||
<div class="card-actions">
|
||||
<el-button v-if="entry._source === 'topic'" size="small" type="primary" @click="openEntryDialog(entry)">查看</el-button>
|
||||
<el-button v-else size="small" @click="openEntryDialog(entry)">编辑</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</el-dialog>
|
||||
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
/* ========== Topic Cards (待创作) ========== */
|
||||
.topic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||||
grid-template-columns: repeat(auto-fill, minmax(min(340px, 100%), 1fr));
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
.topic-card {
|
||||
@@ -565,7 +565,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="内容工厂" :username="currentUser.username" :is-admin="isAdmin" current-page="factory" @navigate="redirectToPage" @logout="handleLogout">
|
||||
<uni-nav v-if="!isEmbed" title="内容工厂" :username="currentUser.username" :is-admin="isAdmin" current-page="factory" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
|
||||
<div class="main-content">
|
||||
@@ -838,6 +838,7 @@
|
||||
const FactoryApp = {
|
||||
data() {
|
||||
return {
|
||||
isEmbed: false,
|
||||
isLoggedIn: false,
|
||||
isAdmin: false,
|
||||
currentUser: { username: '' },
|
||||
@@ -1037,7 +1038,7 @@ const FactoryApp = {
|
||||
});
|
||||
},
|
||||
goToArticles() {
|
||||
window.location.href = '/articles.html';
|
||||
this.redirectToPage('/articles.html');
|
||||
},
|
||||
handleLogout() {
|
||||
localStorage.removeItem('authToken');
|
||||
@@ -1046,7 +1047,9 @@ const FactoryApp = {
|
||||
window.location.href = '/login.html';
|
||||
},
|
||||
redirectToPage(page) {
|
||||
window.location.href = page.startsWith('/') ? page : '/' + page;
|
||||
const url = page.startsWith('/') ? page : '/' + page;
|
||||
if (this.isEmbed) window.top.location.href = url;
|
||||
else window.location.href = url;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
@@ -1055,6 +1058,7 @@ const FactoryApp = {
|
||||
window.location.href = '/login.html';
|
||||
return;
|
||||
}
|
||||
if (new URLSearchParams(window.location.search).get('embed') === '1') { this.isEmbed = true; document.body.classList.add('embed'); }
|
||||
fetch('/api/auth/me', {
|
||||
headers: { 'Authorization': 'Bearer ' + token }
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
.stat-card.danger { background: linear-gradient(135deg, #f56c6c 0%, #f78989 100%); }
|
||||
.stat-value { font-size: 32px; font-weight: 700; }
|
||||
.stat-label { font-size: 14px; opacity: 0.9; margin-top: 4px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; margin-bottom: 24px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 20px; margin-bottom: 24px; }
|
||||
.chart-container { background: white; border-radius: 12px; padding: 16px; margin-bottom: 24px; height: 320px; position: relative; }
|
||||
.platform-chart { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台 - 创作工作台</title>
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; }
|
||||
[v-cloak] { display: none; }
|
||||
#app { display: flex; flex-direction: column; height: 100vh; }
|
||||
.studio-body { flex: 1; display: flex; flex-direction: column; min-height: 0; padding-top: var(--nav-height); }
|
||||
.studio-tabs { flex: 0 0 auto; padding: 0 16px; background: var(--color-bg-raised); border-bottom: 1px solid var(--color-border); }
|
||||
.studio-tabs .el-tabs__header { margin-bottom: 0; }
|
||||
.studio-iframe-wrap { flex: 1; position: relative; min-height: 0; background: var(--color-bg); }
|
||||
.studio-iframe-wrap iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: none; }
|
||||
@media (max-width: 768px) { .studio-tabs { padding: 0 8px; } }
|
||||
</style>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="创作工作台" :username="currentUser.username" :is-admin="isAdmin" current-page="studio" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="studio-body">
|
||||
<div class="studio-tabs">
|
||||
<el-tabs v-model="activeTab" @tab-change="onTabChange">
|
||||
<el-tab-pane label="选题" name="topics"></el-tab-pane>
|
||||
<el-tab-pane label="内容生成" name="factory"></el-tab-pane>
|
||||
<el-tab-pane label="文章管理" name="articles"></el-tab-pane>
|
||||
</el-tabs>
|
||||
</div>
|
||||
<div class="studio-iframe-wrap">
|
||||
<iframe v-if="tabs.topics" src="topics.html?embed=1" frameborder="0"></iframe>
|
||||
<iframe v-if="tabs.factory" src="factory.html?embed=1" frameborder="0"></iframe>
|
||||
<iframe v-if="tabs.articles" src="articles.html?embed=1" frameborder="0"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="vue.global.prod.js"></script>
|
||||
<script src="element-plus.js"></script>
|
||||
<script src="element-plus-icons.js"></script>
|
||||
<script>
|
||||
const { createApp } = Vue;
|
||||
const StudioApp = {
|
||||
data() {
|
||||
return {
|
||||
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
|
||||
activeTab: 'topics',
|
||||
tabs: { topics: true, factory: false, articles: false },
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onTabChange(name) { this.tabs[name] = true; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
handleLogout() {
|
||||
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 = '/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; })
|
||||
.catch(() => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; });
|
||||
}
|
||||
};
|
||||
const app = Vue.createApp(StudioApp);
|
||||
app.use(ElementPlus);
|
||||
if (window.installIcons) { window.installIcons(app); }
|
||||
if (window.installUniNav) { window.installUniNav(app); }
|
||||
app.mount('#app');
|
||||
</script>
|
||||
<script src="ai-assistant.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -68,7 +68,10 @@
|
||||
--content-max-width: 1400px;
|
||||
}
|
||||
|
||||
/* ========== Dark Mode ========== */
|
||||
/* ========== Dark Mode ==========
|
||||
注意:Element Plus 组件库为亮色主题,未同步切换会导致弹窗/卡片内
|
||||
自定义文字在深色变量下不可读。当前明确采用亮色主题以保证对比度,
|
||||
待引入 EP 暗色变量(dark theme vars)后再启用完整暗色模式。
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--color-bg: #1a1a2e;
|
||||
@@ -76,12 +79,10 @@
|
||||
--color-bg-subtle: #1a1a3e;
|
||||
--color-border: #2a2a4e;
|
||||
--color-border-hover: #4a4a6e;
|
||||
|
||||
--color-text-primary: #e0e6ed;
|
||||
--color-text-regular: #a0a6b0;
|
||||
--color-text-secondary: #6b7280;
|
||||
--color-text-placeholder: #4a4a5e;
|
||||
|
||||
--shadow-sm: 0 1px 3px rgba(0,0,0,0.3);
|
||||
--shadow-md: 0 2px 12px rgba(0,0,0,0.3);
|
||||
--shadow-lg: 0 8px 28px rgba(0,0,0,0.4);
|
||||
@@ -90,6 +91,7 @@
|
||||
.card { background: var(--color-bg-raised); }
|
||||
.el-table { --el-table-tr-bg: var(--color-bg-raised); --el-table-header-bg: var(--color-bg-subtle); }
|
||||
}
|
||||
*/
|
||||
|
||||
/* ========== Reset & Base ========== */
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
@@ -119,6 +121,12 @@ body {
|
||||
margin: 0 auto;
|
||||
min-height: calc(100vh - var(--nav-height));
|
||||
}
|
||||
|
||||
/* 嵌入模式(被创作工作台 iframe 加载时):去掉顶部导航留白 */
|
||||
body.embed .main-content {
|
||||
padding-top: 0 !important;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.content-area { flex: 1; padding: var(--spacing-md); overflow-y: auto; }
|
||||
|
||||
/* ========== Card ========== */
|
||||
@@ -128,7 +136,6 @@ body {
|
||||
padding: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow-x: auto;
|
||||
transition: all 0.3s var(--ease-out);
|
||||
}
|
||||
|
||||
@@ -405,7 +412,7 @@ body:has(.preview-dialog-custom.is-fullscreen) > [class*="el-overlay"] { z-index
|
||||
}
|
||||
@media (min-width: 769px) {
|
||||
.preview-iframe { min-height: 400px; }
|
||||
.preview-dialog-custom { position: relative; left: 90px; }
|
||||
.preview-dialog-custom { position: relative; }
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="选题管理" :username="currentUser.username" :is-admin="isAdmin" current-page="topics" @navigate="redirectToPage" @logout="handleLogout">
|
||||
<uni-nav v-if="!isEmbed" title="选题管理" :username="currentUser.username" :is-admin="isAdmin" current-page="topics" @navigate="redirectToPage" @logout="handleLogout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
@@ -56,6 +56,7 @@
|
||||
<el-button size="default" :type="filterStatus === 'review' ? 'primary' : ''" @click="filterStatus = 'review'; fetchTopics()">待审查 ({{ statusStats.review }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'ready' ? 'primary' : ''" @click="filterStatus = 'ready'; fetchTopics()">待发布 ({{ statusStats.ready }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'published' ? 'primary' : ''" @click="filterStatus = 'published'; fetchTopics()">已发布 ({{ statusStats.published }})</el-button>
|
||||
<el-button size="default" :type="filterStatus === 'pending_publish' ? 'primary' : ''" @click="filterStatus = 'pending_publish'; fetchTopics()">待人工发布 ({{ statusStats.pendingPublish }})</el-button>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<span class="search-toggle" @click="showSearch = !showSearch">🔍 {{ showSearch ? '收起搜索' : '展开搜索' }}</span>
|
||||
@@ -64,30 +65,30 @@
|
||||
<div v-if="showSearch" class="search-area">
|
||||
<el-form :model="searchForm" size="small" label-width="70px" @submit.prevent>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="6"><el-form-item label="选题ID"><el-input v-model="searchForm.id" placeholder="如 A07" clearable @input="doSearch"></el-input></el-form-item></el-col>
|
||||
<el-col :span="8"><el-form-item label="标题"><el-input v-model="searchForm.title" placeholder="关键词" clearable @input="doSearch"></el-input></el-form-item></el-col>
|
||||
<el-col :span="5"><el-form-item label="领域"><el-input v-model="searchForm.field" placeholder="如 科技前沿" clearable @input="doSearch"></el-input></el-form-item></el-col>
|
||||
<el-col :span="5"><el-form-item label="栏目"><el-select v-model="searchForm.series" clearable placeholder="全部" style="width:100%;" @change="doSearch"><el-option v-for="s in seriesOptions" :key="s" :label="s" :value="s"></el-option></el-select></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6"><el-form-item label="选题ID"><el-input v-model="searchForm.id" placeholder="如 A07" clearable @input="doSearch"></el-input></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8"><el-form-item label="标题"><el-input v-model="searchForm.title" placeholder="关键词" clearable @input="doSearch"></el-input></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12" :md="5"><el-form-item label="领域"><el-input v-model="searchForm.field" placeholder="如 科技前沿" clearable @input="doSearch"></el-input></el-form-item></el-col>
|
||||
<el-col :xs="24" :sm="12" :md="5"><el-form-item label="栏目"><el-select v-model="searchForm.series" clearable placeholder="全部" style="width:100%;" @change="doSearch"><el-option v-for="s in seriesOptions" :key="s" :label="s" :value="s"></el-option></el-select></el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12"><el-form-item label="创建时间">
|
||||
<el-col :xs="24" :sm="24" :md="12"><el-form-item label="创建时间">
|
||||
<el-date-picker v-model="searchForm.createdStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
<span style="margin:0 4px;color:#909399;">~</span>
|
||||
<el-date-picker v-model="searchForm.createdEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
</el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="创作时间">
|
||||
<el-col :xs="24" :sm="24" :md="12"><el-form-item label="创作时间">
|
||||
<el-date-picker v-model="searchForm.generatedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
<span style="margin:0 4px;color:#909399;">~</span>
|
||||
<el-date-picker v-model="searchForm.generatedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
</el-form-item></el-col>
|
||||
</el-row>
|
||||
<el-row :gutter="12">
|
||||
<el-col :span="12"><el-form-item label="审查时间">
|
||||
<el-col :xs="24" :sm="24" :md="12"><el-form-item label="审查时间">
|
||||
<el-date-picker v-model="searchForm.reviewedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
<span style="margin:0 4px;color:#909399;">~</span>
|
||||
<el-date-picker v-model="searchForm.reviewedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
</el-form-item></el-col>
|
||||
<el-col :span="12"><el-form-item label="发布时间">
|
||||
<el-col :xs="24" :sm="24" :md="12"><el-form-item label="发布时间">
|
||||
<el-date-picker v-model="searchForm.publishedStart" type="date" placeholder="开始" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
<span style="margin:0 4px;color:#909399;">~</span>
|
||||
<el-date-picker v-model="searchForm.publishedEnd" type="date" placeholder="结束" style="width:130px;" value-format="YYYY-MM-DD" @change="doSearch"></el-date-picker>
|
||||
@@ -134,6 +135,8 @@
|
||||
<el-button size="small" type="success" :disabled="isStatus(scope.row, 'published')" @click="createTopic(scope.row)" style="padding:5px 8px;">创作</el-button>
|
||||
<el-button size="small" type="warning" :disabled="!isStatus(scope.row, 'review')" @click="reviewTopic(scope.row)" style="padding:5px 8px;">审查</el-button>
|
||||
<el-button v-if="isStatus(scope.row, 'ready')" size="small" type="primary" @click="openPublishDialog(scope.row)" style="padding:5px 8px;">发布</el-button>
|
||||
<el-button v-if="isStatus(scope.row, 'pending_publish')" size="small" type="success" @click="markPublished(scope.row)" style="padding:5px 8px;">标记发布</el-button>
|
||||
<el-button size="small" :disabled="(scope.row.article_count || 0) === 0" @click="openSlop(scope.row)" style="padding:5px 8px;">检测</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(scope.row.id)" style="padding:5px 8px;">删除</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -172,6 +175,8 @@
|
||||
<el-button size="small" type="success" :disabled="isStatus(topic, 'published')" @click="createTopic(topic)">创作</el-button>
|
||||
<el-button size="small" type="warning" :disabled="!isStatus(topic, 'review')" @click="reviewTopic(topic)">审查</el-button>
|
||||
<el-button v-show="isStatus(topic, 'ready')" size="small" type="primary" @click="openPublishDialog(topic)">发布</el-button>
|
||||
<el-button v-if="isStatus(topic, 'pending_publish')" size="small" type="success" @click="markPublished(topic)">标记发布</el-button>
|
||||
<el-button size="small" :disabled="(topic.article_count || 0) === 0" @click="openSlop(topic)">检测</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteTopic(topic.id)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,6 +261,10 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
<el-dialog v-model="publishDialogVisible" title="发布确认" width="420px" :close-on-click-modal="false">
|
||||
<el-alert type="warning" :closable="false" show-icon style="margin-bottom:16px;">
|
||||
<template #title>AI 内容需真人校验后发布</template>
|
||||
系统仅生成各平台发布稿并置为「待人工发布」,<b>不代发</b>。请在对应平台粘贴、核对 AI 声明与合规后手动发布,再回来点「标记已发布」。
|
||||
</el-alert>
|
||||
<div v-if="publishTopic">
|
||||
<div style="margin-bottom:16px;">
|
||||
<div style="font-size:14px;color:#606266;margin-bottom:8px;">选题:</div>
|
||||
@@ -336,6 +345,7 @@
|
||||
const TopicsApp = {
|
||||
data() {
|
||||
return {
|
||||
isEmbed: false,
|
||||
isLoggedIn: false, isAdmin: false, currentUser: { username: '' },
|
||||
loadingTable: false, selectedTopicIds: [], filterStatus: '',
|
||||
stats: { total: 0, pending: 0, review: 0, ready: 0, published: 0, today: 0 },
|
||||
@@ -371,7 +381,7 @@ const TopicsApp = {
|
||||
if (this.filterStatus === 'today') list = this.todayTopics;
|
||||
else if (!this.filterStatus) list = this.topics;
|
||||
else {
|
||||
const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] };
|
||||
const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'], 'pending_publish': ['pending_publish','待人工发布'] };
|
||||
const allowed = map[this.filterStatus] || [this.filterStatus];
|
||||
list = this.topics.filter(t => allowed.includes(t.status));
|
||||
}
|
||||
@@ -422,8 +432,8 @@ const TopicsApp = {
|
||||
return this.filteredTopics.slice(start, start + this.pageSize);
|
||||
},
|
||||
statusStats() {
|
||||
const s = { total: this.topics.length, pending: 0, review: 0, ready: 0, published: 0 };
|
||||
const aliases = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'] };
|
||||
const s = { total: this.topics.length, pending: 0, review: 0, ready: 0, published: 0, pendingPublish: 0 };
|
||||
const aliases = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'], 'pending_publish': ['pending_publish','待人工发布'] };
|
||||
this.topics.forEach(t => { for (const [k, v] of Object.entries(aliases)) { if (v.includes(t.status)) { s[k]++; break; } } });
|
||||
return s;
|
||||
},
|
||||
@@ -658,13 +668,28 @@ const TopicsApp = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ topic_id: this.publishTopic.id, platforms: selected })
|
||||
});
|
||||
this.$message.success(`发布完成: ${this.publishTopic.title}`);
|
||||
this.$message.success(data.message || `已生成发布稿,请在各平台校验后手动发布`);
|
||||
this.publishDialogVisible = false;
|
||||
await this.fetchTopics();
|
||||
await this.fetchTodayCount();
|
||||
} catch (error) { this.$message.error(`发布失败: ${error.message}`); }
|
||||
finally { this.publishing = false; }
|
||||
},
|
||||
openSlop(topic) {
|
||||
window.open('ai-slop.html?topic_id=' + encodeURIComponent(topic.id), '_blank');
|
||||
},
|
||||
async markPublished(topic) {
|
||||
try {
|
||||
await this.$confirm('确认已在各平台完成发布?此操作将把选题标记为「已发布」并关闭发布记录。', '标记发布', { type: 'warning' });
|
||||
await this.api('/api/publishing/' + encodeURIComponent(topic.id) + '/mark-published', { method: 'POST' });
|
||||
this.$message.success('已标记为已发布');
|
||||
await this.fetchTopics();
|
||||
} catch (e) {
|
||||
if (e === 'cancel') return;
|
||||
const msg = e?.response?.data?.detail || e?.message || '未知错误';
|
||||
this.$message.error('操作失败: ' + msg);
|
||||
}
|
||||
},
|
||||
async deleteTopic(id) {
|
||||
try {
|
||||
await this.$confirm('确定删除选题 ' + id + '?将同时删除相关文章、发布记录等关联数据。', '提示', { type: 'warning' });
|
||||
@@ -806,8 +831,8 @@ const TopicsApp = {
|
||||
this.currentPage = 1;
|
||||
},
|
||||
handleLogout() { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; },
|
||||
redirectToPage(page) { window.location.href = page.startsWith('/') ? page : '/' + page; },
|
||||
getStatusLabel(status) { return { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布' }[status] || status; },
|
||||
redirectToPage(page) { const url = page.startsWith('/') ? page : '/' + page; if (this.isEmbed) window.top.location.href = url; else window.location.href = url; },
|
||||
getStatusLabel(status) { return { 'pending': '待处理', 'review': '待审查', 'ready': '待发布', 'published': '已发布', 'pending_publish': '待人工发布' }[status] || status; },
|
||||
toggleCheck(id) {
|
||||
const idx = this.selectedTopicIds.indexOf(id);
|
||||
if (idx >= 0) {
|
||||
@@ -835,12 +860,13 @@ const TopicsApp = {
|
||||
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) { return { 'pending': 'warning', 'review': 'danger', 'ready': 'success', 'published': 'info' }[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; }
|
||||
getStatusType(status) { return { 'pending': 'warning', 'review': 'danger', 'ready': 'success', 'published': 'info', 'pending_publish': 'warning' }[status] || 'primary'; },
|
||||
isStatus(row, status) { const map = { 'pending': ['pending','待处理'], 'review': ['review','待审查'], 'ready': ['ready','待发布'], 'published': ['published','已发布'], 'pending_publish': ['pending_publish','待人工发布'] }; return map[status] ? map[status].includes(row.status) : row.status === status; }
|
||||
},
|
||||
mounted() {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (!token) { window.location.href = '/login.html'; return; }
|
||||
if (new URLSearchParams(window.location.search).get('embed') === '1') { this.isEmbed = true; document.body.classList.add('embed'); }
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const urlFilter = params.get('filter');
|
||||
if (urlFilter) this.filterStatus = urlFilter;
|
||||
|
||||
@@ -107,7 +107,7 @@
|
||||
.uni-nav-dropdown-divider {\
|
||||
height: 1px; background: #f0f0f0; margin: 4px 0;\
|
||||
}\
|
||||
@media (max-width: 768px) {\
|
||||
@media (max-width: 1000px) {\
|
||||
.uni-nav-items { display: none; }\
|
||||
.uni-nav-username { display: none; }\
|
||||
.uni-nav-hamburger { display: inline-flex; }\
|
||||
@@ -121,20 +121,24 @@
|
||||
|
||||
function getCurrentPage() {
|
||||
var path = window.location.pathname;
|
||||
if (path === '/index.html') return 'workspace';
|
||||
var m = path.match(/\/(\w+)\.html/);
|
||||
return m ? m[1] : 'workspace';
|
||||
if (path === '/' || path === '/index.html') return 'index.html';
|
||||
var m = path.match(/\/([\w-]+)\.html$/);
|
||||
return m ? m[1] + '.html' : 'index.html';
|
||||
}
|
||||
|
||||
function navItems(isAdmin) {
|
||||
var items = [
|
||||
{ key: 'workspace', label: '工作台', page: 'index.html' },
|
||||
{ key: 'factory', label: '内容工厂', page: 'factory.html' },
|
||||
{ key: 'insights', label: '数据洞察', page: 'insights.html' },
|
||||
{ key: 'assets', label: '资产库', page: 'assets.html' },
|
||||
{ key: 'dashboard', label: '仪表盘', page: 'index.html' },
|
||||
{ key: 'studio', label: '创作工作台', page: 'studio.html' },
|
||||
{ key: 'insights', label: '数据', page: 'metrics.html' },
|
||||
{ key: 'calendar', label: '日历', page: 'calendar.html' },
|
||||
{ key: 'assets', label: '素材', page: 'assets.html' },
|
||||
{ key: 'tasks', label: '任务', page: 'tasks.html' },
|
||||
{ key: 'campaigns', label: '外推营销', page: 'campaigns.html' },
|
||||
{ key: 'ai-slop', label: 'AI味检测', page: 'ai-slop.html' },
|
||||
];
|
||||
if (isAdmin) {
|
||||
items.push({ key: 'branding', label: '白标设置', page: 'branding.html', admin: true });
|
||||
items.push({ key: 'admin', label: '系统管理', page: 'admin.html', admin: true });
|
||||
}
|
||||
return items;
|
||||
@@ -160,6 +164,9 @@
|
||||
pwdForm: { old_password: '', new_password: '', confirm: '' },
|
||||
pwdSubmitting: false,
|
||||
serverMenus: null,
|
||||
brandName: '',
|
||||
brandLogo: '',
|
||||
primaryColor: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -167,15 +174,19 @@
|
||||
if (this.serverMenus) {
|
||||
return this.serverMenus
|
||||
.filter(function (m) { return m.is_active !== false; })
|
||||
.map(function (m) { return { key: m.name, label: m.name, page: m.path }; });
|
||||
.map(function (m) { return { key: m.name, label: m.name, page: (m.path || '').replace(/^\//, '') }; });
|
||||
}
|
||||
return navItems(this.isAdmin);
|
||||
},
|
||||
page: function () {
|
||||
return this.currentPage || getCurrentPage();
|
||||
return getCurrentPage();
|
||||
},
|
||||
showTitle: function () {
|
||||
return this.title || '宇之然';
|
||||
return this.brandName || this.title || '宇之然';
|
||||
},
|
||||
navStyle: function () {
|
||||
if (!this.primaryColor) return {};
|
||||
return { background: 'linear-gradient(135deg, ' + this.primaryColor + ', ' + this.primaryColor + 'cc)' };
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
@@ -189,6 +200,24 @@
|
||||
}.bind(this))
|
||||
.catch(function () {});
|
||||
},
|
||||
fetchBranding: function () {
|
||||
fetch('/api/branding')
|
||||
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
|
||||
.then(function (d) {
|
||||
if (d.brand_name) this.brandName = d.brand_name;
|
||||
if (d.brand_logo) this.brandLogo = d.brand_logo;
|
||||
if (d.brand_primary_color) {
|
||||
this.primaryColor = d.brand_primary_color;
|
||||
document.documentElement.style.setProperty('--color-primary', d.brand_primary_color);
|
||||
var s = document.getElementById('uni-nav-brand-style');
|
||||
if (!s) { s = document.createElement('style'); s.id = 'uni-nav-brand-style'; document.head.appendChild(s); }
|
||||
s.textContent = '.uni-nav-dropdown-item:hover,.uni-nav-dropdown-item.active{color:' + d.brand_primary_color + ';}' +
|
||||
'.uni-nav-dropdown-item.active{background:' + d.brand_primary_color + '14;}' +
|
||||
'.uni-nav-item.active{background:rgba(255,255,255,0.18);}';
|
||||
}
|
||||
}.bind(this))
|
||||
.catch(function () {});
|
||||
},
|
||||
navigate: function (page) {
|
||||
this.dropdownOpen = false; this.profileOpen = false;
|
||||
if (this.onNavigate) { this.onNavigate(page); return; }
|
||||
@@ -246,18 +275,22 @@
|
||||
mounted: function () {
|
||||
document.addEventListener('click', this.closeAll);
|
||||
this.fetchMenus();
|
||||
this.fetchBranding();
|
||||
},
|
||||
beforeUnmount: function () {
|
||||
document.removeEventListener('click', this.closeAll);
|
||||
},
|
||||
template: '\
|
||||
<nav class="uni-nav" style="position:relative;">\
|
||||
<nav class="uni-nav" :style="navStyle">\
|
||||
<div class="uni-nav-inner">\
|
||||
<div class="uni-nav-brand">{{ showTitle }}</div>\
|
||||
<div class="uni-nav-brand">\
|
||||
<img v-if="brandLogo" :src="brandLogo" style="height:24px;width:auto;margin-right:6px;vertical-align:middle;border-radius:4px;" />\
|
||||
<span>{{ showTitle }}</span>\
|
||||
</div>\
|
||||
<div class="uni-nav-items">\
|
||||
<button v-for="item in items" :key="item.key"\
|
||||
:class="[\'uni-nav-item\', { active: page === item.key }]"\
|
||||
@click="navigate(item.page)">{{ item.label }}</button>\
|
||||
<button v-for="item in items" :key="item.key"\
|
||||
:class="['uni-nav-item', { active: page === item.page }]"\
|
||||
@click="navigate(item.page)">{{ item.label }}</button>\
|
||||
</div>\
|
||||
<div class="uni-nav-right">\
|
||||
<button class="uni-nav-hamburger" @click.stop="toggleDropdown">\
|
||||
@@ -281,7 +314,7 @@
|
||||
</div>\
|
||||
<div :class="[\'uni-nav-dropdown\', { open: dropdownOpen }]" @click.stop>\
|
||||
<button v-for="item in items" :key="item.key"\
|
||||
:class="[\'uni-nav-dropdown-item\', { active: page === item.key }]"\
|
||||
:class="[\'uni-nav-dropdown-item\', { active: page === item.page }]"\
|
||||
@click="navigate(item.page)">\
|
||||
{{ item.label }}\
|
||||
</button>\
|
||||
|
||||
Reference in New Issue
Block a user