Files
yu-zhi-ran/platform/frontend/calendar.html
T
lt 71cb4c35a8 chore: 清理 Docker 相关文件并优化前端布局
- 删除 Docker 相关文件 (docker-compose, Dockerfile, nginx.conf, init.sql 等)
- 优化 platforms.html 卡片布局和响应式样式
- 优化 users.html 格式和移动端卡片设计
- 优化 admin.html 页面结构和表格布局
- 修复各页面 min-height 和溢出问题
- 更新导航组件样式
2026-05-09 19:41:59 +08:00

315 lines
20 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">
<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; }
.navbar { background: linear-gradient(135deg, #2563eb 0%, #1d4ed8 100%); color: white; padding: 16px 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.navbar-content { display: flex; justify-content: space-between; align-items: center; max-width: 1400px; margin: 0 auto; }
.navbar-title { font-size: 20px; font-weight: 600; }
.navbar-user { display: flex; align-items: center; gap: 16px; }
.avatar { width: 32px; height: 32px; border-radius: 50%; background: rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; font-size: 14px; }
.main-content { display: flex; flex: 1; max-width: 1400px; margin: 0 auto; }
.sidebar { width: 180px; background: white; padding: 12px; box-shadow: 2px 0 8px rgba(0,0,0,0.05); }
.content-area { flex: 1; padding: 32px; overflow-y: auto; }
.card { background: white; border-radius: 16px; padding: 24px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.mobile-nav { display: none; position: fixed; bottom: 0; left: 0; right: 0; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.1); padding: 8px 0; z-index: 1000; }
@media (max-width: 768px) {
.sidebar { display: none; }
.mobile-nav { display: flex; }
.content-area { padding: 12px; padding-bottom: 80px; }
}
.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; }
.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-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">
<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 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 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);
days.push({ day: d.getDate(), month: d.getMonth() + 1, year: d.getFullYear(), isCurrentMonth: false, isToday: false, entries: [] });
}
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;
});
days.push({ day: i, month: currentMonth.value, year: currentYear.value, isCurrentMonth: true, isToday, entries: dayEntries });
}
const remaining = 42 - days.length;
for (let i = 1; i <= remaining; i++) {
const d = new Date(currentYear.value, currentMonth.value, i);
days.push({ day: d.getDate(), month: d.getMonth() + 1, year: d.getFullYear(), isCurrentMonth: false, isToday: false, entries: [] });
}
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>