1. 修复swiper箭头点击动画丢失问题 - 改用固定7天窗口,滑动到边缘时整体滚动数据 - 保持dayIndex连续,确保swiper平滑动画 2. 修复宜忌两行显示压在一起 - 设置item固定高度32rpx和行距12rpx - 超出2行显示...省略号 3. 修复月历数字竖向排列问题 - 修正wxml嵌套结构,让42个格子正确排成6行7列 - 压缩格子高度,放大数字字体 4. 节日速查功能优化 - 默认显示3条,右侧添加更多
327 lines
21 KiB
JavaScript
327 lines
21 KiB
JavaScript
// Wiki 页面组件:百科条目管理 + 历史上的今天(维基百科)同步
|
||
export default {
|
||
template: `
|
||
<div class="min-h-screen bg-gray-100">
|
||
<nav class="bg-white shadow-sm">
|
||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||
<div class="flex justify-between h-16">
|
||
<div class="flex">
|
||
<div class="flex-shrink-0 flex items-center">
|
||
<h1 class="text-xl font-bold text-red-600">祈福小助手</h1>
|
||
</div>
|
||
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||
<a href="/admin" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||
仪表盘
|
||
</a>
|
||
<a href="/admin/users" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||
用户管理
|
||
</a>
|
||
<a href="/admin/orders" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||
订单管理
|
||
</a>
|
||
<a href="/admin/wishes" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||
许愿管理
|
||
</a>
|
||
<a href="/admin/wiki" class="border-red-500 text-gray-900 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||
百科管理
|
||
</a>
|
||
<a href="/admin/settings" class="border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700 inline-flex items-center px-1 pt-1 border-b-2 text-sm font-medium">
|
||
系统设置
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</nav>
|
||
|
||
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||
<!-- 标签页切换 -->
|
||
<div class="px-4 sm:px-0 mb-4">
|
||
<div class="border-b border-gray-200">
|
||
<nav class="-mb-px flex space-x-8">
|
||
<button @click="tab = 'entries'" :class="tab === 'entries' ? 'border-red-500 text-red-600' : 'border-transparent text-gray-500 hover:text-gray-700'" class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm">百科条目</button>
|
||
<button @click="tab = 'sync'" :class="tab === 'sync' ? 'border-red-500 text-red-600' : 'border-transparent text-gray-500 hover:text-gray-700'" class="whitespace-nowrap py-3 px-1 border-b-2 font-medium text-sm">历史上的今天(维基同步)</button>
|
||
</nav>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 百科条目管理 -->
|
||
<div class="px-4 py-2 sm:px-0" v-if="tab === 'entries'">
|
||
<div class="bg-white shadow rounded-lg">
|
||
<div class="px-4 py-5 sm:p-6">
|
||
<div class="flex flex-wrap items-center justify-between gap-3 mb-4">
|
||
<h2 class="text-lg font-medium text-gray-900">百科条目(共 {{ entries.length }} 条)</h2>
|
||
<button @click="openEditor()" class="inline-flex items-center px-3 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700">
|
||
<i class="fas fa-plus mr-1"></i>新增条目
|
||
</button>
|
||
</div>
|
||
<div class="flex flex-wrap gap-3 mb-4">
|
||
<select v-model="filterCat" @change="loadEntries" class="block rounded-md border-gray-300 border px-3 py-2 text-sm">
|
||
<option value="">全部分类</option>
|
||
<option v-for="c in categories" :key="c.key" :value="c.key">{{ c.name }}</option>
|
||
</select>
|
||
<input v-model="filterKeyword" @keyup.enter="loadEntries" placeholder="搜索标题/内容..." class="block rounded-md border-gray-300 border px-3 py-2 text-sm w-64"/>
|
||
<button @click="loadEntries" class="px-3 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">搜索</button>
|
||
</div>
|
||
<div class="overflow-x-auto">
|
||
<table class="min-w-full divide-y divide-gray-200">
|
||
<thead class="bg-gray-50">
|
||
<tr>
|
||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">ID</th>
|
||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">分类</th>
|
||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">标题</th>
|
||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">简介</th>
|
||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">排序</th>
|
||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">状态</th>
|
||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="bg-white divide-y divide-gray-200">
|
||
<tr v-for="e in entries" :key="e.id">
|
||
<td class="px-4 py-3 text-sm text-gray-500">{{ e.id }}</td>
|
||
<td class="px-4 py-3 text-sm whitespace-nowrap">{{ catName(e.category) }}</td>
|
||
<td class="px-4 py-3 text-sm font-medium text-gray-900 whitespace-nowrap">{{ e.title }}</td>
|
||
<td class="px-4 py-3 text-sm text-gray-500 max-w-xs truncate">{{ e.brief }}</td>
|
||
<td class="px-4 py-3 text-sm text-gray-500">{{ e.sort }}</td>
|
||
<td class="px-4 py-3 whitespace-nowrap">
|
||
<span :class="e.status === 1 ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'" class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full">{{ e.status === 1 ? '显示' : '隐藏' }}</span>
|
||
</td>
|
||
<td class="px-4 py-3 text-sm whitespace-nowrap">
|
||
<button @click="openEditor(e)" class="text-red-600 hover:text-red-800 mr-3">编辑</button>
|
||
<button @click="removeEntry(e)" class="text-gray-400 hover:text-red-600">删除</button>
|
||
</td>
|
||
</tr>
|
||
<tr v-if="entries.length === 0">
|
||
<td colspan="7" class="px-4 py-8 text-center text-sm text-gray-400">暂无条目</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 历史上的今天:维基百科同步 -->
|
||
<div class="px-4 py-2 sm:px-0" v-if="tab === 'sync'">
|
||
<div class="bg-white shadow rounded-lg">
|
||
<div class="px-4 py-5 sm:p-6">
|
||
<h2 class="text-lg font-medium text-gray-900 mb-4">维基百科数据同步</h2>
|
||
<p class="text-sm text-gray-500 mb-4">数据来源:中文维基百科「X月X日」页面(共 366 页,含大事记/出生/逝世/节假日习俗)。服务启动时若数据缺失会自动抓取;之后按配置间隔(默认 7 天)定时增量刷新。</p>
|
||
|
||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||
<div class="bg-gray-50 rounded-lg p-4">
|
||
<div class="text-sm text-gray-500">日期覆盖</div>
|
||
<div class="text-2xl font-bold">{{ syncStatus.totalDays || 0 }} / 366 天</div>
|
||
</div>
|
||
<div class="bg-gray-50 rounded-lg p-4">
|
||
<div class="text-sm text-gray-500">条目总数</div>
|
||
<div class="text-2xl font-bold">{{ syncStatus.totalItems || 0 }}</div>
|
||
</div>
|
||
<div class="bg-gray-50 rounded-lg p-4">
|
||
<div class="text-sm text-gray-500">同步状态</div>
|
||
<div class="text-2xl font-bold">
|
||
<span v-if="syncStatus.running" class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>进行中</span>
|
||
<span v-else-if="syncStatus.lastSync && syncStatus.lastSync.status === 'success'" class="text-green-600">已完成</span>
|
||
<span v-else-if="syncStatus.lastSync && syncStatus.lastSync.status === 'partial'" class="text-yellow-600">部分失败</span>
|
||
<span v-else-if="syncStatus.lastSync && syncStatus.lastSync.status === 'failed'" class="text-red-600">失败</span>
|
||
<span v-else class="text-gray-400">未同步</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="syncStatus.lastSync" class="text-sm text-gray-600 mb-4 bg-gray-50 rounded-lg p-4">
|
||
<div>最近同步:{{ formatTime(syncStatus.lastSync.startedAt) }} ~ {{ syncStatus.lastSync.finishedAt ? formatTime(syncStatus.lastSync.finishedAt) : '进行中' }}</div>
|
||
<div class="mt-1">触发方式:{{ triggerName(syncStatus.lastSync.trigger) }} | 成功 {{ syncStatus.lastSync.success }} 页 / 失败 {{ syncStatus.lastSync.failed }} 页</div>
|
||
</div>
|
||
|
||
<button @click="triggerSync" :disabled="syncStatus.running || syncing" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-red-600 hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed">
|
||
<i class="fas fa-sync mr-2" :class="syncStatus.running ? 'fa-spin' : ''"></i>{{ syncStatus.running ? '同步进行中(约 3-5 分钟)' : '立即全量同步' }}
|
||
</button>
|
||
<button @click="loadSyncStatus" class="ml-3 px-4 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">刷新状态</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- 条目编辑弹窗 -->
|
||
<div v-if="editor.visible" class="fixed inset-0 z-10 overflow-y-auto">
|
||
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||
<div class="fixed inset-0 bg-gray-500 bg-opacity-75" @click="editor.visible = false"></div>
|
||
<div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full sm:p-6">
|
||
<h3 class="text-lg font-medium text-gray-900 mb-4">{{ editor.id ? '编辑条目' : '新增条目' }}</h3>
|
||
<div class="space-y-4">
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">分类</label>
|
||
<select v-model="editor.form.category" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
|
||
<option v-for="c in categories" :key="c.key" :value="c.key">{{ c.name }}</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">标题</label>
|
||
<input v-model="editor.form.title" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">简介(列表折叠时显示)</label>
|
||
<input v-model="editor.form.brief" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"/>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">详细内容</label>
|
||
<textarea v-model="editor.form.content" rows="6" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"></textarea>
|
||
</div>
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">排序(越小越靠前)</label>
|
||
<input type="number" v-model.number="editor.form.sort" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"/>
|
||
</div>
|
||
<div>
|
||
<label class="block text-sm font-medium text-gray-700 mb-1">状态</label>
|
||
<select v-model.number="editor.form.status" class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm">
|
||
<option :value="1">显示</option>
|
||
<option :value="0">隐藏</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="mt-6 flex justify-end space-x-3">
|
||
<button @click="editor.visible = false" class="px-4 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">取消</button>
|
||
<button @click="saveEntry" :disabled="saving" class="px-4 py-2 border border-transparent rounded-md text-sm text-white bg-red-600 hover:bg-red-700 disabled:opacity-50">{{ saving ? '保存中...' : '保存' }}</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`,
|
||
data() {
|
||
return {
|
||
tab: 'entries',
|
||
categories: [
|
||
{ key: 'term', name: '二十四节气' },
|
||
{ key: 'lunarFest', name: '农历节日' },
|
||
{ key: 'solarFest', name: '公历节日' },
|
||
{ key: 'ganzhi', name: '干支生肖' },
|
||
{ key: 'termExplain', name: '黄历术语' },
|
||
{ key: 'other', name: '其他' },
|
||
],
|
||
entries: [],
|
||
filterCat: '',
|
||
filterKeyword: '',
|
||
editor: {
|
||
visible: false,
|
||
id: null,
|
||
form: { category: 'other', title: '', brief: '', content: '', sort: 0, status: 1 },
|
||
},
|
||
saving: false,
|
||
syncing: false,
|
||
syncStatus: {},
|
||
syncTimer: null,
|
||
};
|
||
},
|
||
mounted() {
|
||
this.loadEntries();
|
||
this.loadSyncStatus();
|
||
},
|
||
beforeUnmount() {
|
||
if (this.syncTimer) clearInterval(this.syncTimer);
|
||
},
|
||
methods: {
|
||
catName(key) {
|
||
const c = this.categories.find(x => x.key === key);
|
||
return c ? c.name : key;
|
||
},
|
||
formatTime(t) {
|
||
if (!t) return '';
|
||
return new Date(t).toLocaleString('zh-CN', { hour12: false });
|
||
},
|
||
triggerName(t) {
|
||
return { startup: '启动自动', cron: '定时任务', admin: '手动触发' }[t] || t;
|
||
},
|
||
async api(url, options = {}) {
|
||
const res = await fetch(url, {
|
||
headers: { 'Content-Type': 'application/json' },
|
||
...options,
|
||
});
|
||
if (res.status === 401) {
|
||
alert('登录已过期,请重新登录');
|
||
window.location.href = '/admin';
|
||
throw new Error('未授权');
|
||
}
|
||
const data = await res.json();
|
||
if (data.code !== 0) throw new Error(data.msg || '请求失败');
|
||
return data.data;
|
||
},
|
||
async loadEntries() {
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (this.filterCat) params.set('category', this.filterCat);
|
||
if (this.filterKeyword) params.set('keyword', this.filterKeyword);
|
||
const data = await this.api('/admin/api/wiki/entries?' + params.toString());
|
||
this.entries = data.list || [];
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
},
|
||
openEditor(entry) {
|
||
this.editor.id = entry ? entry.id : null;
|
||
this.editor.form = entry
|
||
? { category: entry.category, title: entry.title, brief: entry.brief, content: entry.content, sort: entry.sort, status: entry.status }
|
||
: { category: 'other', title: '', brief: '', content: '', sort: 0, status: 1 };
|
||
this.editor.visible = true;
|
||
},
|
||
async saveEntry() {
|
||
const f = this.editor.form;
|
||
if (!f.title.trim()) { alert('标题不能为空'); return; }
|
||
this.saving = true;
|
||
try {
|
||
if (this.editor.id) {
|
||
await this.api(`/admin/api/wiki/entries/${this.editor.id}`, { method: 'PUT', body: JSON.stringify(f) });
|
||
} else {
|
||
await this.api('/admin/api/wiki/entries', { method: 'POST', body: JSON.stringify(f) });
|
||
}
|
||
this.editor.visible = false;
|
||
this.loadEntries();
|
||
} catch (e) {
|
||
alert(e.message);
|
||
} finally {
|
||
this.saving = false;
|
||
}
|
||
},
|
||
async removeEntry(entry) {
|
||
if (!confirm(`确认删除条目「${entry.title}」?`)) return;
|
||
try {
|
||
await this.api(`/admin/api/wiki/entries/${entry.id}`, { method: 'DELETE' });
|
||
this.loadEntries();
|
||
} catch (e) {
|
||
alert(e.message);
|
||
}
|
||
},
|
||
async loadSyncStatus() {
|
||
try {
|
||
this.syncStatus = await this.api('/admin/api/wiki/sync-status');
|
||
// 同步进行中时每 10 秒自动刷新
|
||
if (this.syncStatus.running && !this.syncTimer) {
|
||
this.syncTimer = setInterval(() => this.loadSyncStatus(), 10000);
|
||
} else if (!this.syncStatus.running && this.syncTimer) {
|
||
clearInterval(this.syncTimer);
|
||
this.syncTimer = null;
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
}
|
||
},
|
||
async triggerSync() {
|
||
if (!confirm('全量同步将抓取 366 个维基百科页面(约 3-5 分钟),确认执行?')) return;
|
||
this.syncing = true;
|
||
try {
|
||
await this.api('/admin/api/wiki/sync', { method: 'POST' });
|
||
this.loadSyncStatus();
|
||
} catch (e) {
|
||
alert(e.message);
|
||
} finally {
|
||
this.syncing = false;
|
||
}
|
||
},
|
||
},
|
||
}
|