diff --git a/server/app.mjs b/server/app.mjs index 81a3244..490f570 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -108,6 +108,7 @@ export function createAdminApp(services) { skillRuntimeConfigService, billingConfigService, wechatScheduleLlmConfigService, + wechatNewsMorningDraftService, wechatIntentRouterConfigService, wechatCursorExecutorPolicyService, adminSystemTestService, @@ -420,6 +421,68 @@ export function createAdminApp(services) { res.json(result); }); + adminApi.get('/wechat/news-morning-draft/config', requireAdmin, async (_req, res) => { + if (!wechatNewsMorningDraftService) { + return res.status(503).json({ message: '新闻早报草稿推送未启用' }); + } + const [config, template] = await Promise.all([ + wechatNewsMorningDraftService.getConfig(), + Promise.resolve(wechatNewsMorningDraftService.getTemplate()), + ]); + return res.json({ config, template }); + }); + + adminApi.patch('/wechat/news-morning-draft/config', requireAdmin, async (req, res) => { + if (!wechatNewsMorningDraftService) { + return res.status(503).json({ message: '新闻早报草稿推送未启用' }); + } + const config = await wechatNewsMorningDraftService.updateConfig(req.body ?? {}, { + updatedBy: req.currentUser.id, + }); + return res.json({ config }); + }); + + adminApi.get('/wechat/news-morning-draft/preview', requireAdmin, async (_req, res) => { + if (!wechatNewsMorningDraftService) { + return res.status(503).json({ message: '新闻早报草稿推送未启用' }); + } + try { + return res.json(await wechatNewsMorningDraftService.preview()); + } catch (error) { + return res.status(400).json({ + message: error instanceof Error ? error.message : '预览失败', + }); + } + }); + + adminApi.post('/wechat/news-morning-draft/push', requireAdmin, async (req, res) => { + if (!wechatNewsMorningDraftService) { + return res.status(503).json({ message: '新闻早报草稿推送未启用' }); + } + try { + const result = await wechatNewsMorningDraftService.pushDraft({ + triggeredBy: req.currentUser.id, + dryRun: req.body?.dryRun === true, + }); + return res.json(result); + } catch (error) { + return res.status(400).json({ + message: error instanceof Error ? error.message : '推送失败', + run: error?.run ?? null, + }); + } + }); + + adminApi.get('/wechat/news-morning-draft/runs', requireAdmin, async (req, res) => { + if (!wechatNewsMorningDraftService) { + return res.status(503).json({ message: '新闻早报草稿推送未启用' }); + } + const runs = await wechatNewsMorningDraftService.listRuns({ + limit: req.query.limit, + }); + return res.json({ runs }); + }); + adminApi.get('/wechat/intent-router/config', requireAdmin, async (_req, res) => { if (!wechatIntentRouterConfigService?.getConfig) { return res.status(503).json({ message: '微信意图路由配置未启用' }); diff --git a/server/bootstrap.mjs b/server/bootstrap.mjs index d4021ac..8c03555 100644 --- a/server/bootstrap.mjs +++ b/server/bootstrap.mjs @@ -43,6 +43,7 @@ export async function bootstrapAdminServices() { const { createAssetGatewayConfigService } = await importMemind('asset-gateway.mjs'); const { ensureAssetGatewaySchema } = await importMemind('db.mjs'); const { createWechatScheduleLlmConfigService } = await importMemind('wechat-schedule-llm-config.mjs'); + const { createWechatNewsMorningDraftService } = await importMemind('wechat-news-morning-draft.mjs'); const { createWechatIntentRouterConfigService } = await importMemind('wechat-intent-router-config.mjs'); const { createWechatCursorExecutorAdminConfigService } = await importMemind( 'wechat-cursor-executor-admin-config.mjs', @@ -138,6 +139,12 @@ export async function bootstrapAdminServices() { portalBaseUrl: `http://127.0.0.1:${process.env.H5_PORT ?? 8081}`, }); const wechatMpConfig = loadWechatMpConfig(); + const wechatNewsMorningDraftService = createWechatNewsMorningDraftService(pool, { + mpConfig: wechatMpConfig, + h5Root, + memindLibRoot: resolveMemindLib(), + env: process.env, + }); const wechatMpService = createWechatMpService({ config: wechatMpConfig, userAuth, @@ -204,6 +211,7 @@ export async function bootstrapAdminServices() { skillRuntimeConfigService, billingConfigService, wechatScheduleLlmConfigService, + wechatNewsMorningDraftService, wechatIntentRouterConfigService, wechatCursorExecutorPolicyService, adminSystemTestService, diff --git a/server/index.mjs b/server/index.mjs index 60e6391..28ce275 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -112,6 +112,7 @@ ready skillRuntimeConfigService, billingConfigService, wechatScheduleLlmConfigService, + wechatNewsMorningDraftService, wechatIntentRouterConfigService, wechatCursorExecutorPolicyService, adminSystemTestService, @@ -144,6 +145,7 @@ ready skillRuntimeConfigService, billingConfigService, wechatScheduleLlmConfigService, + wechatNewsMorningDraftService, wechatIntentRouterConfigService, wechatCursorExecutorPolicyService, adminSystemTestService, diff --git a/src/admin/pages/WechatPage.tsx b/src/admin/pages/WechatPage.tsx index 57aa8d9..afdba0a 100644 --- a/src/admin/pages/WechatPage.tsx +++ b/src/admin/pages/WechatPage.tsx @@ -7,14 +7,19 @@ import { getWechatAdminSummary, getWechatIntentRouterConfig, getWechatIntentRouterRuntime, + getWechatNewsMorningDraftConfig, listAdminUsers, listLlmProviderKeys, listWechatBindings, listWechatDeliveries, listWechatDigests, listWechatMessages, + listWechatNewsMorningDraftRuns, listWechatWebNotifications, patchWechatIntentRouterConfig, + patchWechatNewsMorningDraftConfig, + previewWechatNewsMorningDraft, + pushWechatNewsMorningDraft, resumeWechatDigest, updateWechatScheduleLlmConfig, } from '../../api/client'; @@ -28,6 +33,9 @@ import type { WechatIntentRouterAdminConfig, WechatIntentRouterRuntimeState, WechatMessage, + WechatNewsMorningDraftPreview, + WechatNewsMorningDraftRun, + WechatNewsMorningDraftTemplate, WechatWebNotification, } from '../../types'; import { formatTime } from '../utils/format'; @@ -88,6 +96,35 @@ type IntentRouterForm = { canaryOpenids: string; }; +type NewsMorningDraftForm = { + enabled: boolean; + autoPushEnabled: boolean; + pushHour: string; + pushMinute: string; + timezone: string; + sourceUserId: string; + pageSlugPattern: string; + author: string; + templateVersion: string; +}; + +function newsMorningDraftToForm( + config: Partial | undefined, + template: WechatNewsMorningDraftTemplate | undefined, +): NewsMorningDraftForm { + return { + enabled: Boolean(config?.enabled), + autoPushEnabled: Boolean(config?.autoPushEnabled), + pushHour: String(config?.pushHour ?? 6), + pushMinute: String(config?.pushMinute ?? 0), + timezone: String(config?.timezone ?? 'Asia/Shanghai'), + sourceUserId: String(config?.sourceUserId ?? ''), + pageSlugPattern: String(config?.pageSlugPattern ?? 'daily-news-*'), + author: String(config?.author ?? 'TKMind'), + templateVersion: String(config?.templateVersion ?? template?.version ?? '2026-09-10'), + }; +} + function intentRouterToForm( config: WechatIntentRouterAdminConfig | undefined, keys: LlmProviderKeyRow[], @@ -147,6 +184,13 @@ export function WechatPage() { const [savedIntentForm, setSavedIntentForm] = useState(null); const [intentRuntime, setIntentRuntime] = useState(null); const [intentSaving, setIntentSaving] = useState(false); + const [newsDraftForm, setNewsDraftForm] = useState(null); + const [savedNewsDraftForm, setSavedNewsDraftForm] = useState(null); + const [newsDraftTemplate, setNewsDraftTemplate] = useState(null); + const [newsDraftPreview, setNewsDraftPreview] = useState(null); + const [newsDraftRuns, setNewsDraftRuns] = useState([]); + const [newsDraftSaving, setNewsDraftSaving] = useState(false); + const [newsDraftWorking, setNewsDraftWorking] = useState(false); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -158,6 +202,11 @@ export function WechatPage() { return JSON.stringify(intentForm) !== JSON.stringify(savedIntentForm); }, [intentForm, savedIntentForm]); + const newsDraftDirty = useMemo(() => { + if (!newsDraftForm || !savedNewsDraftForm) return false; + return JSON.stringify(newsDraftForm) !== JSON.stringify(savedNewsDraftForm); + }, [newsDraftForm, savedNewsDraftForm]); + const selectedIntentKey = useMemo( () => llmKeys.find((item) => item.id === intentForm?.modelProviderKeyId) ?? null, [llmKeys, intentForm?.modelProviderKeyId], @@ -178,6 +227,8 @@ export function WechatPage() { nextLlmKeys, nextIntentConfig, nextIntentRuntime, + nextNewsDraftConfig, + nextNewsDraftRuns, ] = await Promise.all([ getWechatAdminSummary(), @@ -190,6 +241,8 @@ export function WechatPage() { listLlmProviderKeys().catch(() => [] as LlmProviderKeyRow[]), getWechatIntentRouterConfig().catch(() => null), getWechatIntentRouterRuntime().catch(() => null), + getWechatNewsMorningDraftConfig().catch(() => null), + listWechatNewsMorningDraftRuns(10).catch(() => [] as WechatNewsMorningDraftRun[]), ]); setSummary(nextSummary); setScheduleLlmEnabled(nextSummary.config.scheduleLlmEnabled ?? false); @@ -207,6 +260,13 @@ export function WechatPage() { setSavedIntentForm(nextForm); } setIntentRuntime(nextIntentRuntime); + if (nextNewsDraftConfig) { + const nextForm = newsMorningDraftToForm(nextNewsDraftConfig.config, nextNewsDraftConfig.template); + setNewsDraftForm(nextForm); + setSavedNewsDraftForm(nextForm); + setNewsDraftTemplate(nextNewsDraftConfig.template); + } + setNewsDraftRuns(nextNewsDraftRuns); } catch (err) { setError(err instanceof Error ? err.message : '加载服务号管理失败'); } finally { @@ -314,6 +374,62 @@ export function WechatPage() { }); }; + const handleSaveNewsMorningDraft = async () => { + if (!newsDraftForm) return; + setNewsDraftSaving(true); + setError(null); + setNotice(null); + try { + const result = await patchWechatNewsMorningDraftConfig({ + enabled: newsDraftForm.enabled, + autoPushEnabled: newsDraftForm.autoPushEnabled, + pushHour: Number(newsDraftForm.pushHour) || 6, + pushMinute: Number(newsDraftForm.pushMinute) || 0, + timezone: newsDraftForm.timezone.trim() || 'Asia/Shanghai', + sourceUserId: newsDraftForm.sourceUserId.trim() || null, + pageSlugPattern: newsDraftForm.pageSlugPattern.trim() || 'daily-news-*', + author: newsDraftForm.author.trim() || 'TKMind', + }); + const nextForm = newsMorningDraftToForm(result, newsDraftTemplate ?? undefined); + setNewsDraftForm(nextForm); + setSavedNewsDraftForm(nextForm); + setNotice('新闻早报草稿推送配置已保存。'); + } catch (err) { + setError(err instanceof Error ? err.message : '保存失败'); + } finally { + setNewsDraftSaving(false); + } + }; + + const handlePreviewNewsMorningDraft = async () => { + setNewsDraftWorking(true); + setError(null); + try { + setNewsDraftPreview(await previewWechatNewsMorningDraft()); + } catch (err) { + setError(err instanceof Error ? err.message : '预览失败'); + } finally { + setNewsDraftWorking(false); + } + }; + + const handlePushNewsMorningDraft = async () => { + if (!window.confirm('确认将今日新闻早报推送到微信服务号草稿箱?')) return; + setNewsDraftWorking(true); + setError(null); + setNotice(null); + try { + const result = await pushWechatNewsMorningDraft(); + setNotice(`草稿已写入微信后台,media_id:${result.draftMediaId ?? '—'}`); + setNewsDraftPreview(result.preview ?? null); + setNewsDraftRuns(await listWechatNewsMorningDraftRuns(10)); + } catch (err) { + setError(err instanceof Error ? err.message : '推送失败'); + } finally { + setNewsDraftWorking(false); + } + }; + const handleSaveIntentRouter = async () => { if (!intentForm) return; setIntentSaving(true); @@ -425,6 +541,208 @@ export function WechatPage() { )} + {newsDraftForm ? ( +
+
+
+

新闻早报 · 微信草稿箱

+

+ 将 MindSpace 生成的新闻早报 HTML 转为服务号图文草稿。模板版本 {newsDraftForm.templateVersion}(0910 版式)。 +

+
+ +
+
+ + + + + + +
+ {newsDraftTemplate ? ( +
+ 0910 模板说明(taskSpec 参考) +
+                {newsDraftTemplate.spec}
+              
+
+ ) : null} +
+ + +
+ {newsDraftPreview ? ( +
+

预览

+
+ +
+
标题
+
{newsDraftPreview.article.title}
+
+
+
摘要
+
{newsDraftPreview.article.digest}
+
+
+
正文模式
+
+ {'contentMode' in newsDraftPreview.article && newsDraftPreview.article.contentMode === 'inline_html' + ? `内联 HTML(公众号排版,非长图${newsDraftPreview.article.trimmed ? ',已自动裁剪' : ''})` + : 'HTML 正文'} +
+
+ {'contentLength' in newsDraftPreview.article && newsDraftPreview.article.contentLength ? ( +
+
正文字符数
+
{newsDraftPreview.article.contentLength.toLocaleString()} / 20,000
+
+ ) : null} + {'cardCount' in newsDraftPreview.article ? ( +
+
卡片数
+
{newsDraftPreview.article.cardCount}
+
+ ) : null} +
+
+ ) : null} + {newsDraftRuns.length > 0 ? ( +
+

最近推送记录

+ + + + + + + + + + + + {newsDraftRuns.map((run) => ( + + + + + + + + ))} + +
时间状态页面draft media_id错误
{dateLabel(run.createdAt)}{statusLabel(run.status)}{run.pageSlug ?? '—'}{run.draftMediaId ?? '—'}{run.errorMessage ?? '—'}
+
+ ) : null} +
+ ) : null} + {intentForm ? (
diff --git a/src/api/client.ts b/src/api/client.ts index 28a0dfc..063634e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -55,6 +55,10 @@ import type { WechatDeliveryLog, WechatDigestSubscription, WechatScheduleLlmConfig, + WechatNewsMorningDraftConfig, + WechatNewsMorningDraftPreview, + WechatNewsMorningDraftRun, + WechatNewsMorningDraftTemplate, WechatIntentRouterAdminConfig, WechatIntentRouterConfigState, WechatIntentRouterRuntimeState, @@ -458,6 +462,50 @@ export async function updateWechatScheduleLlmConfig( }); } +export async function getWechatNewsMorningDraftConfig(): Promise<{ + config: WechatNewsMorningDraftConfig; + template: WechatNewsMorningDraftTemplate; +}> { + return portalFetch('/admin-api/wechat/news-morning-draft/config'); +} + +export async function patchWechatNewsMorningDraftConfig( + patch: Partial>, +): Promise { + const result = await portalFetch<{ config: WechatNewsMorningDraftConfig }>( + '/admin-api/wechat/news-morning-draft/config', + { + method: 'PATCH', + body: JSON.stringify(patch), + }, + ); + return result.config; +} + +export async function previewWechatNewsMorningDraft(): Promise { + return portalFetch('/admin-api/wechat/news-morning-draft/preview'); +} + +export async function pushWechatNewsMorningDraft(options?: { dryRun?: boolean }): Promise<{ + ok?: boolean; + dryRun?: boolean; + draftMediaId?: string; + preview?: WechatNewsMorningDraftPreview; + run?: WechatNewsMorningDraftRun; +}> { + return portalFetch('/admin-api/wechat/news-morning-draft/push', { + method: 'POST', + body: JSON.stringify(options ?? {}), + }); +} + +export async function listWechatNewsMorningDraftRuns(limit = 20): Promise { + const result = await portalFetch<{ runs: WechatNewsMorningDraftRun[] }>( + `/admin-api/wechat/news-morning-draft/runs?limit=${encodeURIComponent(String(limit))}`, + ); + return result.runs ?? []; +} + export async function getWechatIntentRouterConfig(): Promise { const result = await portalFetch('/admin-api/wechat/intent-router/config'); return result.config; diff --git a/src/types.ts b/src/types.ts index 1f3ba71..f1779e2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -330,6 +330,60 @@ export type WechatScheduleLlmConfig = { updatedBy: string | null; }; +export type WechatNewsMorningDraftConfig = { + enabled: boolean; + autoPushEnabled: boolean; + pushHour: number; + pushMinute: number; + timezone: string; + sourceUserId: string | null; + pageSlugPattern: string; + author: string; + templateVersion: string; + updatedAt: number | null; + updatedBy: string | null; +}; + +export type WechatNewsMorningDraftTemplate = { + version: string; + spec: string; +}; + +export type WechatNewsMorningDraftPreview = { + config: WechatNewsMorningDraftConfig; + page: { + slug: string; + relativePath: string; + publicUrl: string; + modifiedAt: number; + hasThumb: boolean; + }; + article: { + title: string; + digest: string; + content: string; + contentSourceUrl?: string; + contentMode?: 'inline_html' | 'html' | string; + contentLength?: number; + cardCount?: number; + statsCount?: number; + trimmed?: boolean; + trimLevel?: number; + }; + template: WechatNewsMorningDraftTemplate; +}; + +export type WechatNewsMorningDraftRun = { + id: string; + status: string; + pageSlug: string | null; + pageUrl: string | null; + draftMediaId: string | null; + errorMessage: string | null; + triggeredBy: string | null; + createdAt: number; +}; + export type WechatIntentRouterAdminConfig = { enabled: boolean; shadowMode: boolean;