Files
gouki fec383f257
Publish Mini Program Dev Version / publish (push) Successful in 1m12s
Build and Publish Server / build (push) Successful in 8m24s
feat(wiki): 二十四节气详情抓取百度百科并图文展示
数据源:百度百科开放接口 BaikeLemmaCardApi(无反爬,返回结构化 JSON)
与「历史上的今天」(维基)完全解耦,维基代码原样保留可随时恢复:

服务端:
- WikiEntry 增加 image(配图)和 extra(结构化字段 JSON)字段
- 新建 BaikeTermSyncLog 独立日志表(不污染维基 WikiSyncLog)
- 新建 BaikeTermSyncService 独立同步服务(24 节气,间隔 1.2s)
- 启动时缺详情自动补齐,之后按配置间隔定时增量刷新
- 管理 API:GET/POST /admin/api/wiki/term-sync*

管理后台:
- 百科条目 Tab 顶部加节气同步卡片(X/24 + 状态 + 同步按钮)
- 条目编辑器加配图 URL(含预览)和节气扩展字段编辑

小程序:
- 百科页展开详情图文展示:配图 + 简介 + 物候/习俗/养生等分块

本地验证:24/24 节气抓取成功,image/extra 全部填充,维基数据不受影响
2026-08-13 00:31:43 +00:00

426 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 mb-4">
<div class="px-4 py-4 sm:px-6 flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-4">
<div>
<div class="text-sm font-medium text-gray-900">节气详情(百度百科)</div>
<div class="text-xs text-gray-500 mt-0.5">
已含详情 {{ termSync.detailedTerms || 0 }} / {{ termSync.totalTerms || 24 }}
<span v-if="termSync.lastSync && termSync.lastSync.startedAt"> 最近同步:{{ formatTime(termSync.lastSync.startedAt) }}</span>
<span v-if="termSync.lastSync && termSync.lastSync.status" :class="termSync.lastSync.status === 'success' ? 'text-green-600' : 'text-yellow-600'"> {{ termSync.lastSync.status === 'success' ? '成功' : termSync.lastSync.status === 'partial' ? '部分失败' : '失败' }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button @click="loadTermSyncStatus" class="px-3 py-2 border border-gray-300 rounded-md text-sm hover:bg-gray-50">刷新</button>
<button @click="triggerTermSync" :disabled="termSync.running || termSyncing" 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 disabled:opacity-50 disabled:cursor-not-allowed">
<i class="fas fa-sync mr-1" :class="termSync.running ? 'fa-spin' : ''"></i>{{ termSync.running ? '同步中...' : '同步节气详情' }}
</button>
</div>
</div>
</div>
<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>
<label class="block text-sm font-medium text-gray-700 mb-1">配图 URL</label>
<input v-model="editor.form.image" placeholder="https://..." class="block w-full rounded-md border border-gray-300 px-3 py-2 text-sm"/>
<div v-if="editor.form.image" class="mt-2"><img :src="editor.form.image" class="h-20 rounded border" alt="配图预览"/></div>
</div>
<!-- 节气扩展字段(仅节气分类显示) -->
<div v-if="editor.form.category === 'term'">
<label class="block text-sm font-medium text-gray-700 mb-2">节气扩展信息</label>
<div class="grid grid-cols-2 gap-3">
<div v-for="f in termExtraFields" :key="f.key">
<label class="block text-xs text-gray-500 mb-1">{{ f.name }}</label>
<input v-model="editor.extra[f.key]" class="block w-full rounded-md border border-gray-300 px-2 py-1.5 text-sm"/>
</div>
</div>
</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: '', image: '', sort: 0, status: 1 },
extra: {},
},
termExtraFields: [
{ key: 'alias', name: '别名' },
{ key: 'english', name: '外文名' },
{ key: 'meaning', name: '涵义' },
{ key: 'solarDate', name: '公历时间' },
{ key: 'ecliptic', name: '黄道位置' },
{ key: 'climate', name: '气候特点' },
{ key: 'phenology', name: '物候现象' },
{ key: 'farming', name: '农事活动' },
{ key: 'customs', name: '传统习俗' },
{ key: 'health', name: '起居养生' },
{ key: 'flower', name: '花信' },
],
saving: false,
syncing: false,
syncStatus: {},
syncTimer: null,
termSync: {},
termSyncing: false,
termSyncTimer: null,
};
},
mounted() {
this.loadEntries();
this.loadSyncStatus();
this.loadTermSyncStatus();
},
beforeUnmount() {
if (this.syncTimer) clearInterval(this.syncTimer);
if (this.termSyncTimer) clearInterval(this.termSyncTimer);
},
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, image: entry.image || '', sort: entry.sort, status: entry.status }
: { category: 'other', title: '', brief: '', content: '', image: '', sort: 0, status: 1 };
let extra = {};
if (entry && entry.extra) {
try { extra = JSON.parse(entry.extra); } catch (e) { extra = {}; }
}
this.editor.extra = extra;
this.editor.visible = true;
},
async saveEntry() {
const f = this.editor.form;
if (!f.title.trim()) { alert('标题不能为空'); return; }
// 节气分类:把 extra 对象序列化为 JSON 字符串
const payload = { ...f };
if (f.category === 'term') {
const extra = {};
this.termExtraFields.forEach(fd => {
const v = (this.editor.extra[fd.key] || '').trim();
if (v) extra[fd.key] = v;
});
payload.extra = JSON.stringify(extra);
} else {
payload.extra = '';
}
this.saving = true;
try {
if (this.editor.id) {
await this.api(`/admin/api/wiki/entries/${this.editor.id}`, { method: 'PUT', body: JSON.stringify(payload) });
} else {
await this.api('/admin/api/wiki/entries', { method: 'POST', body: JSON.stringify(payload) });
}
this.editor.visible = false;
this.loadEntries();
this.loadTermSyncStatus();
} 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;
}
},
async loadTermSyncStatus() {
try {
this.termSync = await this.api('/admin/api/wiki/term-sync-status');
if (this.termSync.running && !this.termSyncTimer) {
this.termSyncTimer = setInterval(() => this.loadTermSyncStatus(), 5000);
} else if (!this.termSync.running && this.termSyncTimer) {
clearInterval(this.termSyncTimer);
this.termSyncTimer = null;
}
} catch (e) {
console.error(e);
}
},
async triggerTermSync() {
if (!confirm('将从百度百科抓取 24 节气的详细图文(约 30 秒),确认执行?')) return;
this.termSyncing = true;
try {
await this.api('/admin/api/wiki/term-sync', { method: 'POST' });
this.loadTermSyncStatus();
setTimeout(() => this.loadEntries(), 3000);
} catch (e) {
alert(e.message);
} finally {
this.termSyncing = false;
}
},
},
}