Files
lunar-mini/server/web/static/js/pages/Wiki.js
T
gouki deb32f8fab
Build and Publish Server / build (push) Successful in 4m30s
feat(server): 商业化支付与订单系统 + Harness 改进
- 新增商业化模块:Stripe/Binance Pay 支付、邮箱登录、订单管理
- 新增数据模型:ReportProduct, ReportEntitlement, ReportSnapshot, PaymentEvent, EmailLoginCode
- 新增 API:/api/commerce/checkout, /api/commerce/webhooks/*, /api/commerce/orders/*
- 新增管理后台:订单列表、统计图表、统一 AdminHeader 组件
- 新增中间件测试:RateLimit, Auth, CORS 共 7 个测试用例
- 新增 AGENTS.md 项目代理指令文件
- 新增 REMINDER.md 商业化功能开发规范
- 修复 JWT 解析:添加 WithValidMethods 限制 HS256
- 修复 GetPayStatus:增加用户权限校验
- 更新 .gitignore 和 README.md
2026-09-19 18:57:23 +00:00

398 lines
25 KiB
JavaScript
Raw 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.
import { adminHeader } from './AdminHeader.js';
// Wiki 页面组件:百科条目管理 + 历史上的今天(维基百科)同步
export default {
template: `
<div class="min-h-screen bg-gray-100">
${adminHeader('wiki')}
<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;
}
},
},
}