Files
yu-zhi-ran/platform/frontend/calendar.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

556 lines
36 KiB
HTML

<!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>
.content-area { padding: 32px; }
@media (max-width: 768px) {
.content-area { padding: 12px !important; padding-bottom: 80px !important; }
.card { padding: 12px !important; }
.calendar-header { flex-direction: column; align-items: flex-start; gap: 8px; }
.calendar-title { font-size: 20px; }
.calendar-nav { width: 100%; justify-content: space-between; }
.calendar-grid { gap: 2px; }
.calendar-weekday { padding: 4px; font-size: 11px; }
.calendar-day { min-height: 56px !important; padding: 2px; }
.day-number { font-size: 12px; margin-bottom: 0; }
.day-lunar { font-size: 9px; margin-top: -1px; display: none; }
.day-term { font-size: 8px; padding: 0 2px; }
.day-holiday { font-size: 8px; padding: 0 2px; }
.day-entry { font-size: 10px; padding: 2px 3px; white-space: normal; line-height: 1.2; }
.stats-bar { gap: 12px; font-size: 12px; }
.calendar-guide { flex-direction: column; padding: 12px; }
.calendar-guide-icon { font-size: 22px; }
.calendar-guide-content h3 { font-size: 15px; }
.calendar-guide-content li, .calendar-guide-content p { font-size: 12px; }
.upcoming-section h3 { font-size: 14px; }
.upcoming-item { width: 140px; padding: 10px; }
.calendar-hint { font-size: 11px; }
}
.calendar-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; flex-wrap: wrap; gap: 12px; }
.calendar-title { font-size: 28px; font-weight: 700; color: #303133; }
.calendar-nav { display: flex; align-items: center; gap: 16px; white-space: nowrap; }
.calendar-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px; }
.calendar-weekday { text-align: center; font-weight: 600; color: #606266; padding: 12px; background: #f5f7fa; border-radius: 8px; }
.calendar-day { min-height: 100px; background: #fafafa; border-radius: 8px; padding: 8px; border: 1px solid #ebeef5; cursor: pointer; transition: all 0.2s; }
.calendar-day:hover { border-color: #409EFF; box-shadow: 0 2px 8px rgba(64,158,255,0.2); }
.calendar-day.other-month { opacity: 0.4; }
.calendar-day.today { border-color: #409EFF; background: #ecf5ff; }
.day-number { font-weight: 600; font-size: 14px; margin-bottom: 8px; color: #303133; }
.day-lunar { font-size: 10px; color: #909399; line-height: 1.3; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.day-term { display: inline-block; font-size: 9px; color: #E6A23C; background: #fdf6ec; border-radius: 3px; padding: 0 4px; margin-top: 1px; font-weight: 500; white-space: nowrap; }
.day-holiday { display: inline-block; font-size: 9px; color: #F56C6C; background: #fef0f0; border-radius: 3px; padding: 0 4px; margin-top: 1px; font-weight: 500; white-space: nowrap; }
.day-entries { display: flex; flex-direction: column; gap: 4px; }
.day-entry { font-size: 11px; padding: 4px 6px; border-radius: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; cursor: pointer; display:flex; align-items:center; gap:3px; }
.day-entry.planned { background: #fdf6ec; color: #E6A23C; }
.day-entry.published { background: #f0f9eb; color: #67C23A; }
.day-entry.delayed { background: #fef0f0; color: #F56C6C; }
.day-entry.cancelled { background: #f4f4f5; color: #909399; }
.entry-topic-status { font-size:9px; padding:1px 3px; border-radius:2px; margin-left:auto; flex-shrink:0; }
.entry-topic-status.review { background:#ecf5ff; color:#409eff; }
.entry-topic-status.ready { background:#fdf6ec; color:#e6a23c; }
.entry-topic-status.published { background:#f0f9eb; color:#67c23a; }
.entry-topic-status.pending { background:#f4f4f5; color:#909399; }
.calendar-guide { background: linear-gradient(135deg, #eef2ff, #e0e7ff); border: 1px solid #c7d2fe; border-radius: 12px; padding: 16px 20px; margin-bottom: 20px; display: flex; align-items: flex-start; gap: 12px; position: relative; }
.calendar-guide-close { position: absolute; top: 8px; right: 8px; background: none; border: none; color: #6366f1; cursor: pointer; font-size: 18px; padding: 4px; border-radius: 4px; }
.calendar-guide-close:hover { background: rgba(99,102,241,0.1); }
.calendar-guide-icon { font-size: 28px; flex-shrink: 0; margin-top: 2px; }
.calendar-guide-content { flex: 1; }
.calendar-guide-content h3 { font-size: 16px; font-weight: 600; color: #4338ca; margin: 0 0 6px 0; }
.calendar-guide-content p { font-size: 13px; color: #4f46e5; margin: 0; line-height: 1.6; }
.calendar-guide-content ol { margin: 6px 0 0 0; padding-left: 20px; }
.calendar-guide-content li { font-size: 13px; color: #4f46e5; line-height: 1.8; }
.upcoming-section { margin-bottom: 20px; }
.upcoming-section h3 { font-size: 16px; font-weight: 600; color: #303133; margin: 0 0 10px 0; display: flex; align-items: center; gap: 8px; }
.upcoming-list { display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px; }
.upcoming-item { flex-shrink: 0; width: 180px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px; cursor: pointer; transition: all 0.2s; }
.upcoming-item:hover { border-color: #6366f1; box-shadow: 0 2px 8px rgba(99,102,241,0.15); }
.upcoming-date { font-size: 12px; color: #6366f1; font-weight: 600; }
.upcoming-title { font-size: 14px; color: #1f2937; font-weight: 500; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.upcoming-meta { font-size: 12px; color: #9ca3af; margin-top: 6px; display: flex; gap: 8px; }
.calendar-hint { text-align: center; font-size: 12px; color: #c0c4cc; margin-top: 8px; padding: 4px 0; }
.stats-bar { display: flex; gap: 24px; margin-bottom: 24px; flex-wrap: wrap; }
.stat-item { display: flex; align-items: center; gap: 8px; }
.stat-dot { width: 10px; height: 10px; border-radius: 50%; }
.stat-dot.planned { background: #E6A23C; }
.stat-dot.published { background: #67C23A; }
.stat-dot.delayed { background: #F56C6C; }
.stat-dot.cancelled { background: #909399; }
</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="calendar" @navigate="redirectToPage" @logout="handleLogout">
</uni-nav>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="calendar-header">
<h2 class="calendar-title"><el-icon style="vertical-align:-2px;"><IconCalendar /></el-icon> 内容日历</h2>
<div class="calendar-nav">
<el-button @click="prevMonth" size="large"><el-icon><IconArrowLeft /></el-icon> 上月</el-button>
<span style="font-size: 20px; font-weight: 600;">{{ currentYear }}年 {{ currentMonth }}月</span>
<el-button @click="nextMonth" size="large">下月 <el-icon><IconArrowRight /></el-icon></el-button>
<el-button type="primary" @click="goToday">今天</el-button>
</div>
</div>
<!-- 使用指南 -->
<div v-if="showGuide" class="calendar-guide">
<div class="calendar-guide-icon">📅</div>
<div class="calendar-guide-content">
<h3>内容日历使用说明</h3>
<p>规划和管理各平台的内容发布排期,一目了然看到每月发布计划。</p>
<ol>
<li><strong>点击日期</strong> — 查看该日所有日程,或点击"添加日程"新建</li>
<li><strong>点击日程卡片</strong> — 编辑或删除已有日程</li>
<li><strong>关联选题</strong> — 创建日程时可关联已有选题,跟踪从创作到发布的全流程</li>
</ol>
</div>
<button class="calendar-guide-close" @click="showGuide = false; localStorage.setItem('calendarGuideDismissed', 'true')" title="不再显示"></button>
</div>
<!-- 即将到来 -->
<div v-if="upcomingEntries.length > 0" class="upcoming-section">
<h3><el-icon><IconClock /></el-icon> 即将到来</h3>
<div class="upcoming-list">
<div v-for="entry in upcomingEntries" :key="entry.id" class="upcoming-item" @click="openEntryDialog(entry)">
<div class="upcoming-date">{{ formatDate(entry.planned_date) }}</div>
<div class="upcoming-title">{{ entry.title || '未命名' }}</div>
<div class="upcoming-meta">
<span>{{ platformName(entry.platform) }}</span>
<el-tag :type="statusType(entry.status)" size="small">{{ statusLabel(entry.status) }}</el-tag>
</div>
</div>
</div>
</div>
<div class="stats-bar">
<div v-if="loadingCalendar" style="color:#909399;font-size:14px;">加载中...</div>
<div v-else-if="errorMsg" class="empty-state" style="color:#F56C6C;">
<el-icon style="font-size:36px;color:#F56C6C;"><IconClose /></el-icon>
<div class="empty-text">{{ errorMsg }}</div>
</div>
<div v-else-if="!calendarDays.length" class="empty-state">
<el-icon style="font-size:48px;color:#c0c4cc;"><IconCalendar /></el-icon>
<div class="empty-text">暂无日历数据</div>
</div>
<template v-else>
<div class="stat-item"><span class="stat-dot planned"></span> 待发布: {{ stats.planned }}</div>
<div class="stat-item"><span class="stat-dot published"></span> 已发布: {{ stats.published }}</div>
<div class="stat-item"><span class="stat-dot delayed"></span> 延迟: {{ stats.delayed }}</div>
<div class="stat-item"><span class="stat-dot cancelled"></span> 取消: {{ stats.cancelled }}</div>
</template>
</div>
<div class="calendar-grid">
<div class="calendar-weekday" v-for="day in weekDays" :key="day">{{ day }}</div>
<div v-for="(day, index) in calendarDays" :key="index"
class="calendar-day"
:class="{ 'other-month': !day.isCurrentMonth, 'today': day.isToday }"
@click="openDayDialog(day)">
<div class="day-number">{{ day.day }}</div>
<div v-if="day.lunar" class="day-lunar">{{ day.lunar }}</div>
<div v-if="day.solarTerm" class="day-term">{{ day.solarTerm }}</div>
<div v-if="day.holiday" class="day-holiday">{{ day.holiday }}</div>
<div class="day-entries">
<div v-for="entry in day.entries" :key="entry.id"
class="day-entry"
:class="entry.status"
@click.stop="openEntryDialog(entry)">
<span v-if="entry.platform_icon" style="flex-shrink:0;">{{ entry.platform_icon }}</span><el-icon v-else style="flex-shrink:0;"><IconDocument /></el-icon>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">{{ entry.title || '未命名' }}</span>
<span v-if="entry.topic_status" class="entry-topic-status" :class="entry.topic_status">{{ topicStatusLabel(entry.topic_status) }}</span>
</div>
</div>
</div>
<div class="calendar-hint">💡 点击日期查看或添加日程 · 点击日程卡片编辑详情</div>
</div>
</div>
</main>
</div>
<el-dialog v-model="dayDialogVisible" :title="selectedDay ? `${selectedDay.year}年${selectedDay.month}月${selectedDay.day}日 日程` : ''" width="500px">
<div v-if="selectedDay">
<el-button type="primary" @click="openCreateDialog" style="margin-bottom: 16px;">+ 添加日程</el-button>
<div v-if="selectedDayEntries.length === 0" class="empty-state" style="padding:20px 0;">
<el-icon style="font-size:36px;color:#c0c4cc;"><IconCalendar /></el-icon>
<div class="empty-text">该日暂无日程</div>
<p style="font-size:13px;color:#909399;margin:4px 0 0 0;">点击上方"添加日程"创建发布计划</p>
</div>
<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>
</el-table-column>
<el-table-column prop="status" label="状态" width="80">
<template #default="scope"><el-tag :type="statusType(scope.row.status)" size="small">{{ statusLabel(scope.row.status) }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button v-if="scope.row._source === 'topic'" size="small" type="primary" @click="openEntryDialog(scope.row)">查看</el-button>
<el-button v-else size="small" @click="openEntryDialog(scope.row)">编辑</el-button>
</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>
<el-dialog v-model="entryDialogVisible" :title="isEdit ? '编辑日程' : '添加日程'" width="500px">
<el-form :model="entryForm" label-width="80px">
<el-form-item label="标题">
<el-input v-model="entryForm.title" placeholder="日程标题"></el-input>
</el-form-item>
<el-form-item label="日期">
<el-date-picker v-model="entryForm.planned_date" type="date" placeholder="选择日期" style="width: 100%;"></el-date-picker>
</el-form-item>
<el-form-item label="平台">
<el-select v-model="entryForm.platform" placeholder="选择平台">
<el-option label="知乎" value="zhihu"></el-option>
<el-option label="微信公众号" value="wechat"></el-option>
<el-option label="小红书" value="xiaohongshu"></el-option>
</el-select>
</el-form-item>
<el-form-item label="关联选题">
<el-select v-model="entryForm.topic_id" placeholder="选择选题" filterable clearable>
<el-option v-for="t in topics" :key="t.id" :label="t.title" :value="t.id"></el-option>
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="entryForm.status">
<el-option label="待发布" value="planned"></el-option>
<el-option label="已发布" value="published"></el-option>
<el-option label="延迟" value="delayed"></el-option>
<el-option label="取消" value="cancelled"></el-option>
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="entryForm.notes" type="textarea" rows="3" placeholder="备注信息"></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="entryDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveEntry" :loading="saving">保存</el-button>
<el-button v-if="isEdit" type="danger" @click="deleteEntry" :loading="saving">删除</el-button>
</template>
</el-dialog>
</div>
<script>
// ---- 农历/节气/节日数据 ----
const LUNAR_YEARS = [
[2024, 2,10, 0, 30,29,30,29,30,29,30,29,29,30,30,30],
[2025, 1,29, 6, 30,29,30,29,30,29,30,29,30,29,30,29,30],
[2026, 2,17, 0, 29,30,29,30,29,30,29,30,29,30,29,30],
[2027, 2, 6, 0, 30,29,30,29,30,29,30,29,30,29,30,29],
[2028, 1,26, 5, 30,29,30,29,30,29,30,30,29,30,29,30,29],
];
const LUNAR_MONTH_NAMES = ['', '正月','二月','三月','四月','五月','六月','七月','八月','九月','十月','冬月','腊月'];
const LUNAR_DAY_NAMES = ['', '初一','初二','初三','初四','初五','初六','初七','初八','初九','初十','十一','十二','十三','十四','十五','十六','十七','十八','十九','二十','廿一','廿二','廿三','廿四','廿五','廿六','廿七','廿八','廿九','三十'];
// 公历节日 (月-日)
const SOLAR_HOLIDAYS = {
'1-1': '元旦', '2-14': '情人节', '3-8': '妇女节', '3-12': '植树节',
'4-1': '愚人节', '5-1': '劳动节', '5-4': '青年节',
'6-1': '儿童节', '7-1': '建党节', '8-1': '建军节',
'9-10': '教师节', '10-1': '国庆节', '12-25': '圣诞节',
};
// 节气近似日期 (月-日)
const SOLAR_TERMS = {
'1-5':'小寒','1-20':'大寒','2-4':'立春','2-19':'雨水',
'3-6':'惊蛰','3-21':'春分','4-5':'清明','4-20':'谷雨',
'5-6':'立夏','5-21':'小满','6-6':'芒种','6-21':'夏至',
'7-7':'小暑','7-23':'大暑','8-7':'立秋','8-23':'处暑',
'9-7':'白露','9-23':'秋分','10-8':'寒露','10-23':'霜降',
'11-7':'立冬','11-22':'小雪','12-7':'大雪','12-22':'冬至',
};
function gregorianToLunar(year, month, day) {
const target = new Date(year, month - 1, day).getTime();
for (const ly of LUNAR_YEARS) {
const [lyear, sm, sd, leap, ...monthDays] = ly;
const start = new Date(lyear, sm - 1, sd);
const totalDays = monthDays.reduce((a, b) => a + b, 0);
const end = new Date(start);
end.setDate(end.getDate() + totalDays);
if (target < start.getTime() || target >= end.getTime()) continue;
const diff = Math.floor((target - start.getTime()) / 86400000);
let accum = 0;
for (let i = 0; i < monthDays.length; i++) {
if (diff < accum + monthDays[i]) {
let lunarMonth, isLeap = false;
if (leap > 0 && i === leap) {
isLeap = true;
lunarMonth = leap;
} else if (leap > 0 && i > leap) {
lunarMonth = i;
} else {
lunarMonth = i + 1;
}
const lunarDay = diff - accum + 1;
const mName = (isLeap ? '闰' : '') + (LUNAR_MONTH_NAMES[lunarMonth] || lunarMonth + '月');
const dName = LUNAR_DAY_NAMES[lunarDay] || lunarDay + '日';
let lunarStr = mName + dName;
if (lunarStr.length > 6) lunarStr = (isLeap ? '闰' : '') + lunarMonth + '月' + dName;
return lunarStr;
}
accum += monthDays[i];
}
}
return '';
}
function getDayMeta(year, month, day) {
const key = month + '-' + day;
const solarTerm = SOLAR_TERMS[key] || '';
const holiday = SOLAR_HOLIDAYS[key] || '';
const lunar = gregorianToLunar(year, month, day);
return { solarTerm, holiday, lunar };
}
</script>
<script src="vue.global.prod.js"></script>
<script src="icon-components.js"></script>
<script src="element-plus.full.js"></script>
<script>
const { ref, reactive, computed, onMounted } = Vue;
const CalendarApp = {
setup() {
const currentUser = ref({ username: '' });
const isAdmin = ref(false);
const currentYear = ref(new Date().getFullYear());
const currentMonth = ref(new Date().getMonth() + 1);
const weekDays = ref(['日', '一', '二', '三', '四', '五', '六']);
const entries = ref([]);
const topics = ref([]);
const stats = ref({ planned: 0, published: 0, delayed: 0, cancelled: 0 });
const dayDialogVisible = ref(false);
const entryDialogVisible = ref(false);
const selectedDay = ref(null);
const selectedDayEntries = ref([]);
const isEdit = ref(false);
const saving = ref(false);
const entryForm = ref({
id: null, title: '', planned_date: '', platform: 'zhihu',
topic_id: null, status: 'planned', notes: ''
});
const getToken = () => localStorage.getItem('authToken');
const api = async (path, options = {}) => {
const res = await fetch(path, { ...options, headers: { 'Authorization': 'Bearer ' + getToken(), 'Content-Type': 'application/json', ...options.headers } });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).detail || '请求失败');
return res.json();
};
const calendarDays = computed(() => {
const days = [];
const firstDay = new Date(currentYear.value, currentMonth.value - 1, 1);
const lastDay = new Date(currentYear.value, currentMonth.value, 0);
const startWeek = firstDay.getDay();
const totalDays = lastDay.getDate();
const today = new Date();
for (let i = startWeek - 1; i >= 0; i--) {
const d = new Date(currentYear.value, currentMonth.value - 1, -i);
const meta = getDayMeta(d.getFullYear(), d.getMonth() + 1, d.getDate());
days.push({ day: d.getDate(), month: d.getMonth() + 1, year: d.getFullYear(), isCurrentMonth: false, isToday: false, entries: [], ...meta });
}
for (let i = 1; i <= totalDays; i++) {
const isToday = today.getFullYear() === currentYear.value && today.getMonth() + 1 === currentMonth.value && today.getDate() === i;
const dayEntries = entries.value.filter(e => {
const pd = new Date(e.planned_date);
return pd.getFullYear() === currentYear.value && pd.getMonth() + 1 === currentMonth.value && pd.getDate() === i;
});
const meta = getDayMeta(currentYear.value, currentMonth.value, i);
days.push({ day: i, month: currentMonth.value, year: currentYear.value, isCurrentMonth: true, isToday, entries: dayEntries, ...meta });
}
const remaining = 42 - days.length;
for (let i = 1; i <= remaining; i++) {
const d = new Date(currentYear.value, currentMonth.value, i);
const meta = getDayMeta(d.getFullYear(), d.getMonth() + 1, d.getDate());
days.push({ day: d.getDate(), month: d.getMonth() + 1, year: d.getFullYear(), isCurrentMonth: false, isToday: false, entries: [], ...meta });
}
return days;
});
const loadingCalendar = ref(false);
const calendarError = ref('');
const showGuide = ref(localStorage.getItem('calendarGuideDismissed') !== 'true');
const errorMsg = ref('');
const upcomingEntries = computed(() => {
const today = new Date(); today.setHours(0, 0, 0, 0);
const end = new Date(today); end.setDate(end.getDate() + 7);
return entries.value
.filter(e => { const d = new Date(e.planned_date); return d >= today && d < end; })
.sort((a, b) => new Date(a.planned_date) - new Date(b.planned_date))
.slice(0, 10);
});
const formatDate = (dateStr) => {
if (!dateStr) return '';
const d = new Date(dateStr);
return `${d.getMonth() + 1}${d.getDate()}`;
};
const fetchEntries = async () => {
loadingCalendar.value = true;
calendarError.value = '';
errorMsg.value = '';
try {
const [calEntries, topicList] = await Promise.all([
api(`/api/calendar?year=${currentYear.value}&month=${currentMonth.value}`),
api('/api/topics?limit=200'),
]);
topics.value = topicList;
const virtualEntries = [];
(topicList || []).forEach(t => {
const s = t.status;
if ((s === 'ready' || s === '待发布') && t.ready_at) {
const d = new Date(t.ready_at);
if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) {
virtualEntries.push({ id: 'topic_ready_' + t.id, title: t.title, planned_date: t.ready_at, platform: '', status: 'planned', platform_icon: '📋', topic_status: 'ready', _source: 'topic' });
}
} else if ((s === 'published' || s === '已发布') && t.published_at) {
const d = new Date(t.published_at);
if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) {
virtualEntries.push({ id: 'topic_pub_' + t.id, title: t.title, planned_date: t.published_at, platform: '', status: 'published', platform_icon: '📄', topic_status: 'published', _source: 'topic' });
}
}
});
entries.value = [...(calEntries || []), ...virtualEntries];
} catch (e) { console.error(e); calendarError.value = e.message; errorMsg.value = '加载失败: ' + e.message; }
finally { loadingCalendar.value = false; }
};
const fetchStats = async () => {
try {
const [calStats, topicList] = await Promise.all([
api(`/api/calendar/stats?year=${currentYear.value}&month=${currentMonth.value}`),
api('/api/topics?limit=200').catch(() => []),
]);
const s = { planned: calStats.planned || 0, published: calStats.published || 0, delayed: calStats.delayed || 0, cancelled: calStats.cancelled || 0 };
(topicList || []).forEach(t => {
const st = t.status;
if ((st === 'ready' || st === '待发布') && t.ready_at) {
const d = new Date(t.ready_at);
if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) s.planned++;
} else if ((st === 'published' || st === '已发布') && t.published_at) {
const d = new Date(t.published_at);
if (d.getFullYear() === currentYear.value && d.getMonth() + 1 === currentMonth.value) s.published++;
}
});
stats.value = s;
} catch (e) { console.error(e); ElMessage.error('加载统计失败: ' + e.message); }
};
const fetchTopics = async () => {
try { const res = await api('/api/topics?limit=100'); topics.value = res; } catch (e) { console.error(e); ElMessage.error('加载选题失败: ' + e.message); }
};
const prevMonth = () => { if (currentMonth.value === 1) { currentMonth.value = 12; currentYear.value--; } else { currentMonth.value--; } fetchEntries(); fetchStats(); };
const nextMonth = () => { if (currentMonth.value === 12) { currentMonth.value = 1; currentYear.value++; } else { currentMonth.value++; } fetchEntries(); fetchStats(); };
const goToday = () => { const t = new Date(); currentYear.value = t.getFullYear(); currentMonth.value = t.getMonth() + 1; fetchEntries(); fetchStats(); };
const openDayDialog = (day) => { selectedDay.value = day; selectedDayEntries.value = entries.value.filter(e => { const pd = new Date(e.planned_date); return pd.getFullYear() === day.year && pd.getMonth() + 1 === day.month && pd.getDate() === day.day; }); dayDialogVisible.value = true; };
const openCreateDialog = () => { isEdit.value = false; entryForm.value = { id: null, title: '', planned_date: `${selectedDay.value.year}-${selectedDay.value.month}-${selectedDay.value.day}`, platform: 'zhihu', topic_id: null, status: 'planned', notes: '' }; entryDialogVisible.value = true; };
const openEntryDialog = (entry) => {
if (entry._source === 'topic') {
const topicId = entry.id.replace(/^topic_(ready|pub)_/, '');
window.location.href = '/topics.html?topic_id=' + topicId;
return;
}
isEdit.value = true; entryForm.value = { ...entry, planned_date: entry.planned_date }; entryDialogVisible.value = true;
};
const saveEntry = async () => {
saving.value = true;
try {
const data = { ...entryForm.value };
if (isEdit.value) { await api(`/api/calendar/entries/${data.id}`, { method: 'PUT', body: JSON.stringify(data) }); }
else { await api('/api/calendar/entries', { method: 'POST', body: JSON.stringify(data) }); }
entryDialogVisible.value = false;
ElMessage.success('保存成功');
await fetchEntries();
await fetchStats();
} catch (e) { ElMessage.error('保存失败: ' + e.message); }
saving.value = false;
};
const deleteEntry = async () => {
try { await ElMessageBox.confirm('确定删除?', '提示', { type: 'warning' }); } catch { return; }
saving.value = true;
try { await api(`/api/calendar/entries/${entryForm.value.id}`, { method: 'DELETE' }); entryDialogVisible.value = false; ElMessage.success('删除成功'); await fetchEntries(); await fetchStats(); } catch (e) { ElMessage.error('删除失败: ' + e.message); }
saving.value = false;
};
const platformName = (p) => ({ zhihu: '知乎', wechat: '微信公众号', xiaohongshu: '小红书' }[p] || p);
const statusLabel = (s) => ({ planned: '待发布', published: '已发布', delayed: '延迟', cancelled: '取消' }[s] || s);
const statusType = (s) => ({ planned: 'warning', published: 'success', delayed: 'danger', cancelled: 'info' }[s] || '');
const topicStatusLabel = (s) => ({ pending: '待处理', review: '待审查', ready: '待发布', published: '已发布' }[s] || s);
const checkAuth = () => {
const token = localStorage.getItem('authToken');
if (!token) {
window.location.href = '/login.html';
return;
}
fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + token } })
.then(r => r.ok ? r.json() : Promise.reject())
.then(d => {
currentUser.value = d.user;
isAdmin.value = d.user.role === 'admin';
fetchEntries();
fetchStats();
})
.catch(() => {
localStorage.removeItem('authToken');
localStorage.removeItem('userRole');
localStorage.removeItem('currentUser');
window.location.href = '/login.html';
});
};
const handleLogout = () => { localStorage.removeItem('authToken'); localStorage.removeItem('userRole'); localStorage.removeItem('currentUser'); window.location.href = '/login.html'; };
const redirectToPage = (page) => { window.location.href = page.startsWith('/') ? page : '/' + page; };
onMounted(() => {
checkAuth();
});
return { currentUser, isAdmin, currentYear, currentMonth, weekDays, calendarDays, entries, topics, stats, dayDialogVisible, entryDialogVisible, selectedDay, selectedDayEntries, isEdit, saving, entryForm, prevMonth, nextMonth, goToday, openDayDialog, openCreateDialog, openEntryDialog, saveEntry, deleteEntry, platformName, statusLabel, statusType, topicStatusLabel, handleLogout, redirectToPage, showGuide, errorMsg, upcomingEntries, formatDate };
}
};
const app = Vue.createApp(CalendarApp);
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>