Files
yu-zhi-ran/platform/frontend/articles.html
T
yuzhiran 7e953afbd2 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 市场调研简报
2026-07-11 12:36:01 +08:00

386 lines
27 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
.article-card-list { display: none; }
@media (max-width: 768px) {
.el-table { display: none; }
.article-card-list { display: block; }
.article-card { background: var(--color-bg-raised); border-radius: var(--radius-md); padding: 12px; margin-bottom: 12px; box-shadow: var(--shadow-sm); }
.article-card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.article-card-title { font-size: var(--font-size-body); font-weight: 600; color: var(--color-text-primary); flex: 1; margin-right: 8px; }
.article-card-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; font-size: var(--font-size-caption); color: var(--color-text-regular); margin-bottom: 12px; }
.article-card-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--color-border); }
.article-card-actions .el-button { margin: 0; width: 100%; justify-content: center; }
.search-bar { width: 100%; margin-left: 0; }
}
</style>
<script src="uni-nav.js"></script>
</head>
<body>
<div id="app" v-cloak>
<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">
<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>
</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">
<div v-if="previewArticleData">
<div style="display:flex; gap:16px; margin-bottom:12px; flex-wrap:wrap;">
<div style="flex:1; min-width:200px;">
<h3 style="margin:0; font-size:15px;">{{ previewArticleData.topic_title || previewArticleData.topic_id }}</h3>
<div style="font-size:12px; color:#909399; margin-top:4px;">
{{ platformLabel(previewArticleData.platform) }} · {{ statusLabel(previewArticleData.status) }} · {{ previewArticleData.word_count ?? '-' }}字
</div>
</div>
<div v-if="previewArticleData.images && previewArticleData.images.cover" style="flex-shrink:0;">
<img :src="previewArticleData.images.cover" style="height:80px; border-radius:8px; border:1px solid #ebeef5; object-fit:cover;" alt="封面">
</div>
</div>
<div style="display:flex; gap:8px;">
<el-button size="small" @click="togglePreviewFullscreen">{{ previewFullscreen ? '退出全屏' : '全屏' }}</el-button>
<el-button v-if="previewFullscreen" size="small" type="danger" @click="previewVisible = false">关闭</el-button>
</div>
</div>
<div style="max-width: 1000px; margin: 0 auto; width: 100%; flex: 1; min-height: 0; display: flex; flex-direction: column;">
<iframe v-if="previewArticleData.html_content" :srcdoc="previewArticleData.html_content" class="preview-iframe" style="flex:1; border:1px solid #ebeef5; border-radius:8px; background:#fff; padding:0;" sandbox></iframe>
<div v-else style="padding:60px 20px; text-align:center; color:#909399; font-size:14px;">该文章暂无 HTML 内容</div>
</div>
</div>
<template #footer>
<div style="display:flex; justify-content:flex-end; gap:8px; width:100%;">
<el-button @click="previewVisible = false">关闭</el-button>
<el-button v-if="previewArticleData && previewArticleData.html_content" type="primary" @click="copyPreviewHtml">复制正文</el-button>
</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 {
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
}
},
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;
}
},
watch: {
activeTab(v) { if (v === 'publish') this.fetchRecords(); }
},
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; }
}
},
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'; });
}
};
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>
</html>