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:
yuzhiran
2026-07-11 12:36:01 +08:00
parent 3d596275d5
commit 7e953afbd2
39 changed files with 2418 additions and 311 deletions
+43 -17
View File
@@ -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;