Files
yu-zhi-ran/platform/frontend/calendar.html
T
Yuzhiran Dev 233e23016c feat: 内容数据迁移至数据库,合规审查全链路打通
- 文章 HTML 存储从文件系统迁移至 articles 表,删除 releases 目录
- 合规审查从 DB 读取 HTML,审查结果写回 DB,通过后自动推进至待发布
- 新增 todayCount 筛选按钮,与系统概览统计数据一致
- 全屏预览修复:提升 z-index 超过侧边栏,添加退出全屏/关闭按钮
- 统一 '优化' → '审查' 命名,消除前后端术语不一致
- 调度器创作完成后自动触发审查(生成 → 审查 → 待发布)
- 清理旧备份/调试文件、过期大纲和研究笔记
2026-05-13 17:33:56 +08:00

398 lines
24 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>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; background: #f5f7fa; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
.content-area { flex: 1; padding: 32px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 12px rgba(0,0,0,0.06); overflow-x: auto; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); }
@media (max-width: 768px) {
.content-area { padding: 12px; padding-bottom: 80px; }
.calendar-day { min-height: 56px !important; padding: 2px; }
.calendar-weekday { padding: 6px; font-size: 12px; }
.day-entry { font-size: 10px; padding: 2px 4px; white-space: normal; }
.calendar-grid { gap: 2px; }
.day-number { font-size: 12px; margin-bottom: 0; }
.day-lunar { font-size: 9px; margin-top: -1px; }
.day-term { font-size: 8px; padding: 0 3px; }
.day-holiday { font-size: 8px; padding: 0 3px; }
}
.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; }
.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; }
.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="navigation-component.js"></script>
<script src="navbar-component.js"></script>
</head>
<body>
<div id="app">
<navbar-component
title="内容日历"
:username="currentUser.username"
:is-admin="isAdmin"
@logout="handleLogout"
></navbar-component>
<navigation-component
current-page="calendar"
:is-admin="isAdmin"
@navigate="redirectToPage"
></navigation-component>
<div class="main-content">
<main class="content-area">
<div class="card page-fade">
<div class="calendar-header">
<h2 class="calendar-title">📅 内容日历</h2>
<div class="calendar-nav">
<el-button @click="prevMonth" size="large"></el-button>
<span style="font-size: 20px; font-weight: 600;">{{ currentYear }}年 {{ currentMonth }}月</span>
<el-button @click="nextMonth" size="large"></el-button>
<el-button type="primary" @click="goToday">今天</el-button>
</div>
</div>
<div class="stats-bar">
<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>
</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)">
{{ entry.platform_icon }} {{ entry.title || '未命名' }}
</div>
</div>
</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>
<el-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">{{ 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) }}</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="scope">
<el-button size="small" @click="openEntryDialog(scope.row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</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="element-plus.full.js"></script>
<script>
const { ref, reactive, computed, onMounted } = Vue;
const CalendarApp = {
components: { 'navbar-component': window.NavbarComponent, 'navigation-component': window.NavigationComponent },
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 fetchEntries = async () => {
try { entries.value = await api(`/api/calendar?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); }
};
const fetchStats = async () => {
try { stats.value = await api(`/api/calendar/stats?year=${currentYear.value}&month=${currentMonth.value}`); } catch (e) { console.error(e); }
};
const fetchTopics = async () => {
try { const res = await api('/api/topics?limit=100'); topics.value = res; } catch (e) { console.error(e); }
};
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) => { 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;
await fetchEntries();
await fetchStats();
} catch (e) { alert('保存失败: ' + e.message); }
saving.value = false;
};
const deleteEntry = async () => {
if (!confirm('确定删除?')) return;
saving.value = true;
try { await api(`/api/calendar/entries/${entryForm.value.id}`, { method: 'DELETE' }); entryDialogVisible.value = false; await fetchEntries(); await fetchStats(); } catch (e) { alert('删除失败: ' + 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 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();
fetchTopics();
})
.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, handleLogout, redirectToPage };
}
};
const app = Vue.createApp(CalendarApp);
app.use(ElementPlus);
if (window.installNavbar) { window.installNavbar(app); }
if (window.installNavigation) { window.installNavigation(app); } else if (window.NavigationComponent) { app.component("navigation-component", window.NavigationComponent); }
app.mount('#app');
</script>
</body>
</html>