feat(mindspace): add WeChat MP config and one-click draft push on publish.

Let users bind their own official account credentials in M 配置 and push MindSpace pages to the WeChat draft box during or after publish, reusing the existing draft conversion pipeline.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-09-10 16:43:25 +08:00
parent 3bc2442769
commit 78b7d546c2
19 changed files with 1635 additions and 16 deletions
+301
View File
@@ -0,0 +1,301 @@
import { fetch as undiciFetch } from 'undici';
import { decryptSecret, encryptSecret, maskApiKey } from './llm-providers.mjs';
const TABLE = 'h5_user_wechat_mp_configs';
const DEFAULT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
const ACCOUNT_TYPES = new Set(['service', 'subscription']);
function normalizeAccountType(value, fallback = 'service') {
const normalized = String(value ?? '').trim().toLowerCase();
return ACCOUNT_TYPES.has(normalized) ? normalized : fallback;
}
function resolveEncryptionKey(env = process.env) {
return (
env.H5_SETTINGS_ENCRYPTION_KEY
?? env.TKMIND_SERVER__SECRET_KEY
?? 'local-dev-secret'
);
}
async function readJsonResponse(response) {
const text = await response.text().catch(() => '');
if (!response.ok) {
throw new Error(text || `upstream ${response.status}`);
}
return text ? JSON.parse(text) : null;
}
async function ensureTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${TABLE} (
user_id CHAR(36) PRIMARY KEY,
app_id VARCHAR(64) NOT NULL,
account_type ENUM('service', 'subscription') NOT NULL DEFAULT 'service',
label VARCHAR(64) NULL,
author VARCHAR(16) NULL,
secret_ciphertext TEXT NOT NULL,
secret_iv VARCHAR(32) NOT NULL,
secret_tag VARCHAR(32) NOT NULL,
verified_at BIGINT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
KEY idx_wechat_mp_config_app (app_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
function mapRow(row) {
if (!row) return null;
return {
userId: row.user_id,
appId: row.app_id,
accountType: row.account_type,
label: row.label ?? '',
author: row.author ?? '',
secretCiphertext: row.secret_ciphertext,
secretIv: row.secret_iv,
secretTag: row.secret_tag,
verifiedAt: row.verified_at ? Number(row.verified_at) : null,
createdAt: Number(row.created_at ?? 0),
updatedAt: Number(row.updated_at ?? 0),
};
}
function toPublicConfig(row, env = process.env) {
if (!row) {
return {
configured: false,
appId: '',
accountType: 'service',
label: '',
author: '',
appSecretMasked: '',
verifiedAt: null,
updatedAt: null,
};
}
let appSecretMasked = '';
try {
const secret = decryptSecret(
{
ciphertext: row.secretCiphertext,
iv: row.secretIv,
tag: row.secretTag,
},
resolveEncryptionKey(env),
);
appSecretMasked = maskApiKey(secret);
} catch {
appSecretMasked = '********';
}
return {
configured: true,
appId: row.appId,
accountType: row.accountType,
label: row.label,
author: row.author,
appSecretMasked,
verifiedAt: row.verifiedAt,
updatedAt: row.updatedAt,
};
}
export async function fetchWechatMpAccessToken(
{ appId, appSecret },
{ tokenUrl = DEFAULT_TOKEN_URL, wechatFetch = undiciFetch } = {},
) {
const payload = await readJsonResponse(
await wechatFetch(tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credential',
appid: appId,
secret: appSecret,
force_refresh: false,
}),
}),
);
if (!payload?.access_token) {
throw new Error(payload?.errmsg || '获取微信 access_token 失败');
}
return {
accessToken: String(payload.access_token),
expiresIn: Number(payload.expires_in ?? 7200),
};
}
export function createMindSpaceWechatMpConfigService(
pool,
{
env = process.env,
tokenUrl = env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_TOKEN_URL,
wechatFetch = undiciFetch,
} = {},
) {
let ensurePromise = null;
async function ensureReady() {
if (!ensurePromise) {
ensurePromise = ensureTable(pool);
}
await ensurePromise;
}
async function readRow(userId) {
await ensureReady();
const [rows] = await pool.query(
`SELECT user_id, app_id, account_type, label, author,
secret_ciphertext, secret_iv, secret_tag,
verified_at, created_at, updated_at
FROM ${TABLE}
WHERE user_id = ?
LIMIT 1`,
[userId],
);
return mapRow(rows[0] ?? null);
}
function readCredentials(row) {
if (!row) return null;
const appSecret = decryptSecret(
{
ciphertext: row.secretCiphertext,
iv: row.secretIv,
tag: row.secretTag,
},
resolveEncryptionKey(env),
);
if (!appSecret) {
throw new Error('公众号密钥解密失败,请重新保存配置');
}
return {
appId: row.appId,
appSecret,
accountType: row.accountType,
author: row.author,
label: row.label,
};
}
return {
async getConfig(userId) {
return toPublicConfig(await readRow(userId), env);
},
async getCredentials(userId) {
return readCredentials(await readRow(userId));
},
async upsertConfig(
userId,
{
appId,
appSecret,
accountType,
label,
author,
verify = true,
} = {},
) {
await ensureReady();
const normalizedAppId = String(appId ?? '').trim();
if (!normalizedAppId) {
throw Object.assign(new Error('请填写 AppID'), { code: 'invalid_wechat_mp_config' });
}
const existing = await readRow(userId);
const nextSecret = String(appSecret ?? '').trim();
let encrypted = null;
if (nextSecret) {
encrypted = encryptSecret(nextSecret, resolveEncryptionKey(env));
} else if (existing) {
encrypted = {
ciphertext: existing.secretCiphertext,
iv: existing.secretIv,
tag: existing.secretTag,
};
} else {
throw Object.assign(new Error('请填写 AppSecret'), { code: 'invalid_wechat_mp_config' });
}
const credentials = {
appId: normalizedAppId,
appSecret: nextSecret || decryptSecret(
{
ciphertext: encrypted.ciphertext,
iv: encrypted.iv,
tag: encrypted.tag,
},
resolveEncryptionKey(env),
),
};
let verifiedAt = existing?.verifiedAt ?? null;
if (verify) {
await fetchWechatMpAccessToken(credentials, { tokenUrl, wechatFetch });
verifiedAt = Date.now();
}
const now = Date.now();
await pool.query(
`INSERT INTO ${TABLE}
(user_id, app_id, account_type, label, author,
secret_ciphertext, secret_iv, secret_tag,
verified_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
app_id = VALUES(app_id),
account_type = VALUES(account_type),
label = VALUES(label),
author = VALUES(author),
secret_ciphertext = VALUES(secret_ciphertext),
secret_iv = VALUES(secret_iv),
secret_tag = VALUES(secret_tag),
verified_at = VALUES(verified_at),
updated_at = VALUES(updated_at)`,
[
userId,
normalizedAppId,
normalizeAccountType(accountType, existing?.accountType ?? 'service'),
String(label ?? existing?.label ?? '').trim().slice(0, 64) || null,
String(author ?? existing?.author ?? '').trim().slice(0, 8) || null,
encrypted.ciphertext,
encrypted.iv,
encrypted.tag,
verifiedAt,
existing?.createdAt ?? now,
now,
],
);
return this.getConfig(userId);
},
async verifyConfig(userId) {
const row = await readRow(userId);
if (!row) {
throw Object.assign(new Error('尚未配置公众号凭证'), { code: 'wechat_mp_not_configured' });
}
const credentials = readCredentials(row);
await fetchWechatMpAccessToken(credentials, { tokenUrl, wechatFetch });
const now = Date.now();
await pool.query(
`UPDATE ${TABLE} SET verified_at = ?, updated_at = ? WHERE user_id = ?`,
[now, now, userId],
);
return this.getConfig(userId);
},
async deleteConfig(userId) {
await ensureReady();
await pool.query(`DELETE FROM ${TABLE} WHERE user_id = ?`, [userId]);
return { ok: true };
},
};
}
export const mindspaceWechatMpConfigInternals = {
normalizeAccountType,
toPublicConfig,
};
+111
View File
@@ -0,0 +1,111 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createMindSpaceWechatMpConfigService,
fetchWechatMpAccessToken,
mindspaceWechatMpConfigInternals,
} from './mindspace-wechat-mp-config.mjs';
function createPool() {
const rows = new Map();
return {
rows,
async query(sql, params = []) {
if (sql.includes('CREATE TABLE')) return [[]];
if (sql.includes('INSERT INTO h5_user_wechat_mp_configs')) {
rows.set(params[0], {
user_id: params[0],
app_id: params[1],
account_type: params[2],
label: params[3],
author: params[4],
secret_ciphertext: params[5],
secret_iv: params[6],
secret_tag: params[7],
verified_at: params[8],
created_at: params[9],
updated_at: params[10],
});
return [{ affectedRows: 1 }];
}
if (sql.includes('FROM h5_user_wechat_mp_configs')) {
const row = rows.get(params[0]);
return [[row ?? null].filter(Boolean)];
}
if (sql.startsWith('UPDATE h5_user_wechat_mp_configs')) {
const row = rows.get(params[2]);
if (row) {
row.verified_at = params[0];
row.updated_at = params[1];
}
return [{ affectedRows: 1 }];
}
if (sql.startsWith('DELETE FROM h5_user_wechat_mp_configs')) {
rows.delete(params[0]);
return [{ affectedRows: 1 }];
}
throw new Error(`unexpected sql: ${sql}`);
},
};
}
test('upsertConfig stores encrypted secret and returns masked config', async () => {
const pool = createPool();
const service = createMindSpaceWechatMpConfigService(pool, {
env: { H5_SETTINGS_ENCRYPTION_KEY: 'test-key' },
wechatFetch: async () => ({
ok: true,
text: async () => JSON.stringify({ access_token: 'token-1', expires_in: 7200 }),
}),
});
const config = await service.upsertConfig('user-1', {
appId: 'wx1234567890',
appSecret: 'secret-value-1234567890',
accountType: 'service',
label: '我的服务号',
author: 'TKMind',
});
assert.equal(config.configured, true);
assert.equal(config.appId, 'wx1234567890');
assert.ok(config.appSecretMasked.includes('*'));
assert.ok(config.appSecretMasked.endsWith('7890'));
assert.equal(config.label, '我的服务号');
assert.ok(config.verifiedAt);
});
test('verifyConfig refreshes verifiedAt', async () => {
const pool = createPool();
const service = createMindSpaceWechatMpConfigService(pool, {
env: { H5_SETTINGS_ENCRYPTION_KEY: 'test-key' },
wechatFetch: async () => ({
ok: true,
text: async () => JSON.stringify({ access_token: 'token-2', expires_in: 7200 }),
}),
});
await service.upsertConfig('user-1', {
appId: 'wx1234567890',
appSecret: 'secret-value-1234567890',
});
const verified = await service.verifyConfig('user-1');
assert.ok(verified.verifiedAt);
});
test('fetchWechatMpAccessToken surfaces upstream error', async () => {
await assert.rejects(
() => fetchWechatMpAccessToken(
{ appId: 'bad', appSecret: 'bad' },
{
wechatFetch: async () => ({
ok: true,
text: async () => JSON.stringify({ errcode: 40013, errmsg: 'invalid appid' }),
}),
},
),
/invalid appid/,
);
});
test('normalizeAccountType defaults to service', () => {
assert.equal(mindspaceWechatMpConfigInternals.normalizeAccountType('subscription'), 'subscription');
assert.equal(mindspaceWechatMpConfigInternals.normalizeAccountType('unknown'), 'service');
});
+360
View File
@@ -0,0 +1,360 @@
import fs from 'node:fs';
import path from 'node:path';
import { fetch as undiciFetch } from 'undici';
import sharp from 'sharp';
import {
buildPublicUrl,
PUBLISH_ROOT_DIR,
PUBLIC_ZONE_DIR,
resolvePublicBaseUrl,
} from './user-publish.mjs';
import { extractPageTitle } from './wechat/verify/share-preview-repair.mjs';
import { readWorkspacePublishHtml } from './mindspace-workspace-path.mjs';
import { resolvePageWorkspaceRelativePath } from './mindspace-workspace-relative-path.mjs';
import { fetchWechatMpAccessToken } from './mindspace-wechat-mp-config.mjs';
import {
addWechatDraftArticle,
buildDailyNewsWechatDraftArticleForPush,
convertNewsPageHtmlToWechatArticle,
isDailyNewsFormat,
uploadWechatPermanentThumb,
} from './wechat-news-morning-draft.mjs';
const RUNS_TABLE = 'h5_user_wechat_page_draft_runs';
const DEFAULT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/stable_token';
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function extractMetaDescription(html) {
const match = String(html ?? '').match(
/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i,
);
return match?.[1]?.trim() ?? '';
}
function stripHtml(value) {
return String(value ?? '')
.replace(/<br\s*\/?>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim();
}
export function convertGenericPageHtmlToWechatArticle(
html,
{ publicUrl = '', pageTitle = '', pageSummary = '' } = {},
) {
const title = (extractPageTitle(html) || pageTitle || 'TKMind 页面').slice(0, 64);
const digest = (extractMetaDescription(html) || pageSummary || title).slice(0, 120);
const bodyMatch = String(html ?? '').match(/<body[^>]*>([\s\S]*?)<\/body>/i);
let body = bodyMatch ? bodyMatch[1] : String(html ?? '');
body = body
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<link[^>]*>/gi, '')
.trim();
const sections = [];
const summary = stripHtml(pageSummary);
if (summary) {
sections.push(
`<p style="margin:0 0 16px;font-size:14px;color:#666;line-height:1.8;">${escapeHtml(summary)}</p>`,
);
}
if (body) {
sections.push(`<div style="font-size:15px;line-height:1.8;color:#333;">${body}</div>`);
} else {
sections.push(
`<p style="margin:0;font-size:15px;line-height:1.8;color:#333;">${escapeHtml(title)}</p>`,
);
}
if (publicUrl) {
sections.push(
`<p style="margin:16px 0 0;font-size:13px;color:#888;">阅读原文:<a href="${publicUrl}">${escapeHtml(publicUrl)}</a></p>`,
);
}
return {
title,
digest,
content: sections.join('\n').slice(0, 20000),
contentSourceUrl: publicUrl || undefined,
};
}
export function convertPageHtmlToWechatArticle(
html,
{ publicUrl = '', pageTitle = '', pageSummary = '' } = {},
) {
if (isDailyNewsFormat(html)) {
return convertNewsPageHtmlToWechatArticle(html, { publicUrl });
}
const structured = convertNewsPageHtmlToWechatArticle(html, { publicUrl });
if ((structured.cardCount ?? 0) > 0) {
return structured;
}
return convertGenericPageHtmlToWechatArticle(html, { publicUrl, pageTitle, pageSummary });
}
async function ensureRunsTable(pool) {
await pool.query(`
CREATE TABLE IF NOT EXISTS ${RUNS_TABLE} (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
page_id CHAR(36) NOT NULL,
status VARCHAR(16) NOT NULL,
page_title VARCHAR(255) NULL,
page_url VARCHAR(512) NULL,
draft_media_id VARCHAR(128) NULL,
error_message TEXT NULL,
triggered_by CHAR(36) NULL,
created_at BIGINT NOT NULL,
KEY idx_wechat_page_draft_user (user_id, created_at),
KEY idx_wechat_page_draft_page (page_id, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
function cryptoRandomId() {
return globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function resolveThumbPath(h5Root, userId, workspaceRelativePath) {
if (!h5Root || !userId || !workspaceRelativePath) return null;
const htmlPath = path.join(
h5Root,
PUBLISH_ROOT_DIR,
String(userId),
PUBLIC_ZONE_DIR,
...workspaceRelativePath.split('/'),
);
const pngPath = htmlPath.replace(/\.html$/i, '.thumbnail.png');
if (fs.existsSync(pngPath)) return pngPath;
const svgPath = htmlPath.replace(/\.html$/i, '.thumbnail.svg');
if (fs.existsSync(svgPath)) return svgPath;
return null;
}
async function loadThumbBuffer(thumbPath) {
if (!thumbPath) {
return Buffer.from(
'<svg xmlns="http://www.w3.org/2000/svg" width="900" height="900"><rect width="900" height="900" fill="#1a1a2e"/><text x="50%" y="50%" fill="#fff" font-size="42" text-anchor="middle" dominant-baseline="middle">TKMind</text></svg>',
);
}
const raw = fs.readFileSync(thumbPath);
if (/\.svg$/i.test(thumbPath)) {
return sharp(raw).png().toBuffer();
}
return raw;
}
export function createMindSpaceWechatPageDraftService(
pool,
{
getMindSpacePages = () => null,
getWechatMpConfig = () => null,
h5Root = process.cwd(),
memindLibRoot = h5Root,
env = process.env,
tokenUrl = env.H5_WECHAT_MP_TOKEN_URL?.trim() || DEFAULT_TOKEN_URL,
wechatFetch = undiciFetch,
} = {},
) {
let ensurePromise = null;
const accessTokenCache = new Map();
async function ensureReady() {
if (!ensurePromise) {
ensurePromise = ensureRunsTable(pool);
}
await ensurePromise;
}
async function getAccessToken(userId) {
const configService = getWechatMpConfig?.();
if (!configService) throw new Error('公众号配置服务未启用');
const credentials = await configService.getCredentials(userId);
if (!credentials?.appId || !credentials?.appSecret) {
throw Object.assign(new Error('请先在 M 配置中填写公众号 AppID 和 AppSecret'), {
code: 'wechat_mp_not_configured',
});
}
const cacheKey = `${userId}:${credentials.appId}`;
const cached = accessTokenCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now() + 60_000) {
return { accessToken: cached.token, credentials };
}
const token = await fetchWechatMpAccessToken(credentials, { tokenUrl, wechatFetch });
accessTokenCache.set(cacheKey, {
token: token.accessToken,
expiresAt: Date.now() + token.expiresIn * 1000,
});
return { accessToken: token.accessToken, credentials };
}
async function recordRun(payload) {
await ensureReady();
const id = cryptoRandomId();
const now = Date.now();
await pool.query(
`INSERT INTO ${RUNS_TABLE}
(id, user_id, page_id, status, page_title, page_url, draft_media_id, error_message, triggered_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id,
payload.userId,
payload.pageId,
payload.status,
payload.pageTitle ?? null,
payload.pageUrl ?? null,
payload.draftMediaId ?? null,
payload.errorMessage ?? null,
payload.triggeredBy ?? null,
now,
],
);
return {
id,
status: payload.status,
pageId: payload.pageId,
pageTitle: payload.pageTitle ?? null,
pageUrl: payload.pageUrl ?? null,
draftMediaId: payload.draftMediaId ?? null,
errorMessage: payload.errorMessage ?? null,
triggeredBy: payload.triggeredBy ?? null,
createdAt: now,
};
}
async function resolvePageHtml(userId, pageId) {
const pages = getMindSpacePages?.();
if (!pages?.renderPreview || !pages?.getPage) {
throw new Error('MindSpace 页面服务未启用');
}
const page = await pages.getPage(userId, pageId);
const { html } = await pages.renderPreview(userId, pageId);
const workspaceRelativePath = resolvePageWorkspaceRelativePath(page);
const publicBaseUrl = resolvePublicBaseUrl(env);
const publicUrl = page.publication?.publicUrl
?? (workspaceRelativePath
? buildPublicUrl(publicBaseUrl, userId, workspaceRelativePath)
: '');
let sourceHtml = html;
if (workspaceRelativePath) {
const workspaceHtml = await readWorkspacePublishHtml(h5Root, userId, workspaceRelativePath);
if (workspaceHtml?.trim()) {
sourceHtml = workspaceHtml;
}
}
return {
page,
html: sourceHtml,
publicUrl,
workspaceRelativePath,
};
}
return {
async pushPageDraft(userId, pageId, { triggeredBy = null } = {}) {
let preview = null;
try {
preview = await resolvePageHtml(userId, pageId);
const { accessToken, credentials } = await getAccessToken(userId);
const article = isDailyNewsFormat(preview.html)
? await buildDailyNewsWechatDraftArticleForPush({
html: preview.html,
publicUrl: preview.publicUrl,
accessToken,
wechatFetch,
memindLibRoot,
})
: convertPageHtmlToWechatArticle(preview.html, {
publicUrl: preview.publicUrl,
pageTitle: preview.page.title,
pageSummary: preview.page.summary,
});
const thumbPath = resolveThumbPath(
h5Root,
userId,
preview.workspaceRelativePath,
);
const thumbBuffer = await loadThumbBuffer(thumbPath);
const thumbMediaId = await uploadWechatPermanentThumb(accessToken, thumbBuffer, { wechatFetch });
const draft = await addWechatDraftArticle(
accessToken,
{
title: article.title,
author: credentials.author || 'TKMind',
digest: article.digest,
content: article.content,
content_source_url: article.contentSourceUrl,
thumb_media_id: thumbMediaId,
need_open_comment: 0,
only_fans_can_comment: 0,
},
{ wechatFetch },
);
const run = await recordRun({
userId,
pageId,
status: 'success',
pageTitle: preview.page.title,
pageUrl: preview.publicUrl,
draftMediaId: draft.draftMediaId,
triggeredBy,
});
return {
ok: true,
draftMediaId: draft.draftMediaId,
pageTitle: preview.page.title,
pageUrl: preview.publicUrl,
run,
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const run = await recordRun({
userId,
pageId,
status: 'failed',
pageTitle: preview?.page?.title ?? null,
pageUrl: preview?.publicUrl ?? null,
errorMessage: message,
triggeredBy,
});
throw Object.assign(new Error(message), { code: error?.code, run });
}
},
async listRuns(userId, { pageId = null, limit = 20 } = {}) {
await ensureReady();
const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 100);
const params = [userId];
let sql = `
SELECT id, page_id AS pageId, status, page_title AS pageTitle, page_url AS pageUrl,
draft_media_id AS draftMediaId, error_message AS errorMessage,
triggered_by AS triggeredBy, created_at AS createdAt
FROM ${RUNS_TABLE}
WHERE user_id = ?`;
if (pageId) {
sql += ' AND page_id = ?';
params.push(pageId);
}
sql += ' ORDER BY created_at DESC LIMIT ?';
params.push(safeLimit);
const [rows] = await pool.query(sql, params);
return rows.map((row) => ({
...row,
createdAt: Number(row.createdAt ?? 0),
}));
},
};
}
export const mindspaceWechatPageDraftInternals = {
convertGenericPageHtmlToWechatArticle,
convertPageHtmlToWechatArticle,
};
+43
View File
@@ -0,0 +1,43 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
convertGenericPageHtmlToWechatArticle,
convertPageHtmlToWechatArticle,
mindspaceWechatPageDraftInternals,
} from './mindspace-wechat-page-draft.mjs';
const SAMPLE_HTML = `<!DOCTYPE html>
<html><head><title>测试页面</title>
<meta name="description" content="页面摘要"></head>
<body><h1>标题</h1><p>正文内容</p></body></html>`;
test('convertGenericPageHtmlToWechatArticle extracts title and body', () => {
const article = convertGenericPageHtmlToWechatArticle(SAMPLE_HTML, {
publicUrl: 'https://m.tkmind.cn/u/demo/pages/test.html',
pageTitle: '测试页面',
pageSummary: '页面摘要',
});
assert.equal(article.title, '测试页面');
assert.equal(article.digest, '页面摘要');
assert.match(article.content, /正文内容/);
assert.match(article.content, /阅读原文/);
});
test('convertPageHtmlToWechatArticle falls back to generic converter', () => {
const article = convertPageHtmlToWechatArticle(SAMPLE_HTML, {
publicUrl: 'https://example.com/page.html',
pageTitle: '测试页面',
pageSummary: '页面摘要',
});
assert.equal(article.title, '测试页面');
assert.match(article.content, /正文内容/);
});
test('convertGenericPageHtmlToWechatArticle truncates long content', () => {
const longBody = `<p>${'很长'.repeat(12000)}</p>`;
const article = mindspaceWechatPageDraftInternals.convertGenericPageHtmlToWechatArticle(
`<html><body>${longBody}</body></html>`,
{ pageTitle: '长文' },
);
assert.ok(article.content.length <= 20000);
});
+2 -1
View File
File diff suppressed because one or more lines are too long
+5
View File
@@ -33,6 +33,11 @@ const MENU = {
name: '空间首页',
url: 'https://m.tkmind.cn/space',
},
{
type: 'view',
name: 'M配置',
url: 'https://m.tkmind.cn/space/wechat-config',
},
{
type: 'view',
name: 'M成果',
+22
View File
@@ -53,7 +53,10 @@ import { attachPortalMindSpaceChatSaveRoutes } from './server/portal-mindspace-c
import { attachPortalMindSpaceChatShareRoutes } from './server/portal-mindspace-chat-share-routes.mjs';
import { attachPortalMindSpacePageCoreRoutes } from './server/portal-mindspace-page-core-routes.mjs';
import { attachPortalMindSpacePagePublishRoutes } from './server/portal-mindspace-page-publish-routes.mjs';
import { attachPortalMindSpaceWechatRoutes } from './server/portal-mindspace-wechat-routes.mjs';
import { attachPortalMindSpaceSpaceRoutes } from './server/portal-mindspace-space-routes.mjs';
import { createMindSpaceWechatMpConfigService } from './mindspace-wechat-mp-config.mjs';
import { createMindSpaceWechatPageDraftService } from './mindspace-wechat-page-draft.mjs';
import { attachPortalTemplateCatalogRoutes } from './server/portal-template-catalog-routes.mjs';
import { attachPortalPlazaDiscoveryRoutes } from './server/portal-plaza-discovery-routes.mjs';
import { attachPortalPlazaRoutes } from './server/portal-plaza-routes.mjs';
@@ -320,6 +323,8 @@ let mindSpacePageLiveEdit = null;
let mindSpaceAssetAgent = null;
let mindSpacePageEditSession = null;
let mindSpacePublications = null;
let mindSpaceWechatMpConfig = null;
let mindSpaceWechatPageDraft = null;
let workspacePageDeliver = null;
let plazaPosts = null;
let plazaEvents = null;
@@ -445,6 +450,16 @@ async function bootstrapUserAuth() {
plazaPosts = domainServices.plazaPosts;
plazaOps = domainServices.plazaOps;
mindSpaceCleanup = domainServices.mindSpaceCleanup;
mindSpaceWechatMpConfig = createMindSpaceWechatMpConfigService(pool, {
env: process.env,
});
mindSpaceWechatPageDraft = createMindSpaceWechatPageDraftService(pool, {
getMindSpacePages: () => mindSpacePages,
getWechatMpConfig: () => mindSpaceWechatMpConfig,
h5Root: H5_ROOT,
memindLibRoot: __dirname,
env: process.env,
});
const authServices =
await bootstrapPortalAuthServices({
pool,
@@ -1457,6 +1472,13 @@ attachPortalMindSpacePagePublishRoutes(api, {
handleMindSpaceError: mindSpaceError,
});
attachPortalMindSpaceWechatRoutes(api, {
getWechatMpConfig: () => mindSpaceWechatMpConfig,
getWechatPageDraft: () => mindSpaceWechatPageDraft,
sendData,
handleMindSpaceError: mindSpaceError,
});
attachPortalPlazaDiscoveryRoutes(api, {
getPlazaPosts: () => plazaPosts,
getPlazaSeo: () => plazaSeo,
+126
View File
@@ -0,0 +1,126 @@
function assertRouter(api) {
if (
!api ||
typeof api.get !== 'function' ||
typeof api.post !== 'function' ||
typeof api.put !== 'function' ||
typeof api.delete !== 'function'
) {
throw new Error(
'attachPortalMindSpaceWechatRoutes requires an Express-compatible router',
);
}
}
export function attachPortalMindSpaceWechatRoutes(
api,
{
getWechatMpConfig = () => null,
getWechatPageDraft = () => null,
sendData,
handleMindSpaceError,
} = {},
) {
assertRouter(api);
if (typeof sendData !== 'function' || typeof handleMindSpaceError !== 'function') {
throw new Error(
'attachPortalMindSpaceWechatRoutes requires response dependencies',
);
}
api.get('/mindspace/v1/wechat-mp/config', async (req, res) => {
const service = getWechatMpConfig();
if (!service) {
return res.status(503).json({ message: '公众号配置未启用' });
}
try {
return sendData(res, req, await service.getConfig(req.currentUser.id));
} catch (error) {
return handleMindSpaceError(res, req, error);
}
});
api.put('/mindspace/v1/wechat-mp/config', async (req, res) => {
const service = getWechatMpConfig();
if (!service) {
return res.status(503).json({ message: '公众号配置未启用' });
}
try {
return sendData(
res,
req,
await service.upsertConfig(req.currentUser.id, {
appId: req.body?.app_id ?? req.body?.appId,
appSecret: req.body?.app_secret ?? req.body?.appSecret,
accountType: req.body?.account_type ?? req.body?.accountType,
label: req.body?.label,
author: req.body?.author,
verify: req.body?.verify !== false,
}),
);
} catch (error) {
return handleMindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/wechat-mp/config/verify', async (req, res) => {
const service = getWechatMpConfig();
if (!service) {
return res.status(503).json({ message: '公众号配置未启用' });
}
try {
return sendData(res, req, await service.verifyConfig(req.currentUser.id));
} catch (error) {
return handleMindSpaceError(res, req, error);
}
});
api.delete('/mindspace/v1/wechat-mp/config', async (req, res) => {
const service = getWechatMpConfig();
if (!service) {
return res.status(503).json({ message: '公众号配置未启用' });
}
try {
return sendData(res, req, await service.deleteConfig(req.currentUser.id));
} catch (error) {
return handleMindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/:pageId/wechat-draft', async (req, res) => {
const service = getWechatPageDraft();
if (!service) {
return res.status(503).json({ message: '公众号草稿推送未启用' });
}
try {
return sendData(
res,
req,
await service.pushPageDraft(req.currentUser.id, req.params.pageId, {
triggeredBy: req.currentUser.id,
}),
);
} catch (error) {
return handleMindSpaceError(res, req, error);
}
});
api.get('/mindspace/v1/pages/:pageId/wechat-draft/runs', async (req, res) => {
const service = getWechatPageDraft();
if (!service) {
return res.status(503).json({ message: '公众号草稿推送未启用' });
}
try {
return sendData(
res,
req,
await service.listRuns(req.currentUser.id, {
pageId: req.params.pageId,
limit: req.query?.limit,
}),
);
} catch (error) {
return handleMindSpaceError(res, req, error);
}
});
}
@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { attachPortalMindSpaceWechatRoutes } from './portal-mindspace-wechat-routes.mjs';
function createRouterRecorder() {
const routes = new Map();
return {
routes,
get(path, handler) {
routes.set(`GET ${path}`, handler);
},
post(path, handler) {
routes.set(`POST ${path}`, handler);
},
put(path, handler) {
routes.set(`PUT ${path}`, handler);
},
delete(path, handler) {
routes.set(`DELETE ${path}`, handler);
},
};
}
function createResponseRecorder() {
return {
statusCode: 200,
body: undefined,
status(code) {
this.statusCode = code;
return this;
},
json(body) {
this.body = body;
return this;
},
};
}
function createRequest(overrides = {}) {
return {
body: {},
params: { pageId: 'page-1' },
query: {},
currentUser: { id: 'user-1' },
requestId: 'request-1',
...overrides,
};
}
test('wechat config routes delegate to services', async () => {
const calls = [];
const api = createRouterRecorder();
attachPortalMindSpaceWechatRoutes(api, {
getWechatMpConfig: () => ({
async getConfig(userId) {
calls.push(['getConfig', userId]);
return { configured: true, appId: 'wx-test' };
},
async upsertConfig(userId, payload) {
calls.push(['upsertConfig', userId, payload]);
return { configured: true, appId: payload.appId };
},
async verifyConfig(userId) {
calls.push(['verifyConfig', userId]);
return { configured: true, verifiedAt: 1 };
},
async deleteConfig(userId) {
calls.push(['deleteConfig', userId]);
return { ok: true };
},
}),
getWechatPageDraft: () => ({
async pushPageDraft(userId, pageId) {
calls.push(['pushPageDraft', userId, pageId]);
return { ok: true, draftMediaId: 'draft-1' };
},
async listRuns(userId, options) {
calls.push(['listRuns', userId, options]);
return [];
},
}),
sendData(res, req, data) {
return res.status(200).json({ data });
},
handleMindSpaceError(res, req, error) {
return res.status(400).json({ message: error.message });
},
});
const getRes = createResponseRecorder();
await api.routes.get('GET /mindspace/v1/wechat-mp/config')(createRequest(), getRes);
assert.deepEqual(calls[0], ['getConfig', 'user-1']);
const putRes = createResponseRecorder();
await api.routes.get('PUT /mindspace/v1/wechat-mp/config')(
createRequest({ body: { app_id: 'wx-new', app_secret: 'secret' } }),
putRes,
);
assert.equal(calls.at(-1)[0], 'upsertConfig');
const pushRes = createResponseRecorder();
await api.routes.get('POST /mindspace/v1/pages/:pageId/wechat-draft')(createRequest(), pushRes);
assert.deepEqual(calls.at(-1), ['pushPageDraft', 'user-1', 'page-1']);
});
+1
View File
@@ -55,6 +55,7 @@ export function resolveProductRouteName(pathname: string) {
if (pathname === '/') return 'chat';
if (pathname === '/space') return 'mindspace_home';
if (pathname === '/space/achievements') return 'mindspace_achievements';
if (pathname === '/space/wechat-config') return 'mindspace_wechat_config';
if (pathname.startsWith('/space/page/')) return 'mindspace_page';
if (pathname.startsWith('/feedback/')) return 'feedback_detail';
if (pathname === '/feedback') return 'feedback';
+8
View File
@@ -110,6 +110,14 @@ export {
redactMindSpacePage,
updatePublicationStatus,
} from './mindspace-publications';
export {
deleteMindSpaceWechatMpConfig,
getMindSpaceWechatMpConfig,
listMindSpaceWechatPageDraftRuns,
pushMindSpacePageWechatDraft,
saveMindSpaceWechatMpConfig,
verifyMindSpaceWechatMpConfig,
} from './mindspace-wechat-mp';
export {
applyPageDataPublishPolicy,
buildPageDataExportUrl,
+94
View File
@@ -0,0 +1,94 @@
import { apiFetch } from './core';
export type MindSpaceWechatMpAccountType = 'service' | 'subscription';
export type MindSpaceWechatMpConfig = {
configured: boolean;
appId: string;
accountType: MindSpaceWechatMpAccountType;
label: string;
author: string;
appSecretMasked: string;
verifiedAt: number | null;
updatedAt: number | null;
};
export type MindSpaceWechatPageDraftRun = {
id: string;
pageId: string;
status: 'success' | 'failed' | string;
pageTitle: string | null;
pageUrl: string | null;
draftMediaId: string | null;
errorMessage: string | null;
triggeredBy: string | null;
createdAt: number;
};
export type MindSpaceWechatPageDraftResult = {
ok: boolean;
draftMediaId: string;
pageTitle: string;
pageUrl: string;
run: MindSpaceWechatPageDraftRun;
};
export async function getMindSpaceWechatMpConfig(): Promise<MindSpaceWechatMpConfig> {
const result = await apiFetch<{ data: MindSpaceWechatMpConfig }>('/mindspace/v1/wechat-mp/config');
return result.data;
}
export async function saveMindSpaceWechatMpConfig(input: {
appId: string;
appSecret?: string;
accountType?: MindSpaceWechatMpAccountType;
label?: string;
author?: string;
verify?: boolean;
}): Promise<MindSpaceWechatMpConfig> {
const result = await apiFetch<{ data: MindSpaceWechatMpConfig }>('/mindspace/v1/wechat-mp/config', {
method: 'PUT',
body: JSON.stringify({
app_id: input.appId,
app_secret: input.appSecret,
account_type: input.accountType,
label: input.label,
author: input.author,
verify: input.verify,
}),
});
return result.data;
}
export async function verifyMindSpaceWechatMpConfig(): Promise<MindSpaceWechatMpConfig> {
const result = await apiFetch<{ data: MindSpaceWechatMpConfig }>(
'/mindspace/v1/wechat-mp/config/verify',
{ method: 'POST' },
);
return result.data;
}
export async function deleteMindSpaceWechatMpConfig(): Promise<{ ok: boolean }> {
const result = await apiFetch<{ data: { ok: boolean } }>('/mindspace/v1/wechat-mp/config', {
method: 'DELETE',
});
return result.data;
}
export async function pushMindSpacePageWechatDraft(pageId: string): Promise<MindSpaceWechatPageDraftResult> {
const result = await apiFetch<{ data: MindSpaceWechatPageDraftResult }>(
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/wechat-draft`,
{ method: 'POST' },
);
return result.data;
}
export async function listMindSpaceWechatPageDraftRuns(
pageId: string,
limit = 10,
): Promise<MindSpaceWechatPageDraftRun[]> {
const result = await apiFetch<{ data: MindSpaceWechatPageDraftRun[] }>(
`/mindspace/v1/pages/${encodeURIComponent(pageId)}/wechat-draft/runs?limit=${limit}`,
);
return result.data;
}
+128 -5
View File
@@ -18,6 +18,10 @@ import {
updateMindSpacePage,
updatePublicationStatus,
} from '../api/client';
import {
getMindSpaceWechatMpConfig,
pushMindSpacePageWechatDraft,
} from '../api/mindspace-wechat-mp';
import type {
MindSpacePage,
MindSpacePageDeletePreview,
@@ -319,16 +323,52 @@ export function MindSpacePageDetail({
const [plazaSubmitting, setPlazaSubmitting] = useState(false);
const [plazaPost, setPlazaPost] = useState<PlazaPostBrief | null>(null);
const [pushToPlaza, setPushToPlaza] = useState(false);
const [pushToWechatDraft, setPushToWechatDraft] = useState(false);
const [wechatMpConfigured, setWechatMpConfigured] = useState(false);
const [wechatDraftPushing, setWechatDraftPushing] = useState(false);
const [wechatDraftNotice, setWechatDraftNotice] = useState<string | null>(null);
const [fixNotice, setFixNotice] = useState<MindSpaceRedactionChange[] | null>(null);
const [publishSuccess, setPublishSuccess] = useState<{
publication: MindSpacePublication;
plazaPost: PlazaPostBrief | null;
pageTitle: string;
pushedToPlaza: boolean;
pushedToWechatDraft: boolean;
wechatDraftMediaId?: string | null;
wechatDraftError?: string | null;
} | null>(null);
const [confirmPublicationStatusOpen, setConfirmPublicationStatusOpen] = useState(false);
const [statusConfirming, setStatusConfirming] = useState(false);
useEffect(() => {
let cancelled = false;
void getMindSpaceWechatMpConfig()
.then((config) => {
if (!cancelled) setWechatMpConfigured(Boolean(config.configured));
})
.catch(() => {
if (!cancelled) setWechatMpConfigured(false);
});
return () => {
cancelled = true;
};
}, [pageId]);
useEffect(() => {
if (!publishOpen) return;
let cancelled = false;
void getMindSpaceWechatMpConfig()
.then((config) => {
if (!cancelled) setWechatMpConfigured(Boolean(config.configured));
})
.catch(() => {
if (!cancelled) setWechatMpConfigured(false);
});
return () => {
cancelled = true;
};
}, [publishOpen]);
useEffect(() => {
if (!publishOpen) return;
let cancelled = false;
@@ -559,6 +599,16 @@ export function MindSpacePageDetail({
if (pushToPlaza) {
nextPlazaPost = await publishToPlazaWithDefaults(publication.id);
}
let wechatDraftMediaId: string | null = null;
let wechatDraftError: string | null = null;
if (pushToWechatDraft) {
try {
const draftResult = await pushMindSpacePageWechatDraft(page.id);
wechatDraftMediaId = draftResult.draftMediaId;
} catch (err) {
wechatDraftError = err instanceof Error ? err.message : '推送到公众号草稿箱失败';
}
}
setPublishOpen(false);
setFullscreenPreviewOpen(false);
onFullscreenPreviewChange?.(false);
@@ -567,10 +617,19 @@ export function MindSpacePageDetail({
plazaPost: nextPlazaPost,
pageTitle: title || page.title,
pushedToPlaza: pushToPlaza,
pushedToWechatDraft: pushToWechatDraft,
wechatDraftMediaId,
wechatDraftError,
});
await onSaved();
} catch (err) {
setError(err instanceof Error ? err.message : pushToPlaza ? '发布或推送到 Plaza 失败' : '发布失败');
setError(
err instanceof Error
? err.message
: pushToPlaza || pushToWechatDraft
? '发布或后续推送失败'
: '发布失败',
);
} finally {
setPublishing(false);
}
@@ -645,6 +704,25 @@ export function MindSpacePageDetail({
}
};
const pushWechatDraft = async () => {
if (!page) return;
if (!wechatMpConfigured) {
setError('请先在 M 配置中绑定公众号 AppID 和 AppSecret。');
return;
}
setWechatDraftPushing(true);
setError(null);
setWechatDraftNotice(null);
try {
const result = await pushMindSpacePageWechatDraft(page.id);
setWechatDraftNotice(`已推送到公众号草稿箱。草稿 media_id:${result.draftMediaId}`);
} catch (err) {
setError(err instanceof Error ? err.message : '推送到公众号草稿箱失败');
} finally {
setWechatDraftPushing(false);
}
};
const submitPlaza = async () => {
if (!page?.publication || !plazaCategoryId) return;
setPlazaSubmitting(true);
@@ -986,6 +1064,9 @@ export function MindSpacePageDetail({
publication={publishSuccess.publication}
plazaPost={publishSuccess.plazaPost}
pushedToPlaza={publishSuccess.pushedToPlaza}
pushedToWechatDraft={publishSuccess.pushedToWechatDraft}
wechatDraftMediaId={publishSuccess.wechatDraftMediaId}
wechatDraftError={publishSuccess.wechatDraftError}
offlineBusy={publishing}
error={error}
onOffline={offlineFromSuccess}
@@ -1104,6 +1185,15 @@ export function MindSpacePageDetail({
)}
</MindSpaceModal>
{wechatDraftNotice ? (
<div className="mindspace-publication-live">
<div>
<span>稿</span>
<small>{wechatDraftNotice}</small>
</div>
</div>
) : null}
{page.publication && (
<div className="mindspace-publication-live">
<div>
@@ -1139,6 +1229,16 @@ export function MindSpacePageDetail({
广
</button>
) : null}
{page.publication.status === 'online' ? (
<button
type="button"
onClick={() => void pushWechatDraft()}
disabled={publishing || wechatDraftPushing || !wechatMpConfigured}
title={wechatMpConfigured ? undefined : '请先在 M 配置中绑定公众号凭证'}
>
{wechatDraftPushing ? '推送草稿箱中…' : '推送到公众号草稿箱'}
</button>
) : null}
<button
type="button"
onClick={() => {
@@ -1490,9 +1590,13 @@ export function MindSpacePageDetail({
)}
<div className="mindspace-page-editor-actions">
<span>
{pushToPlaza
? '确认发布后会同步推送到 Plaza 发现广场。'
: '发布会创建独立快照,后续修改草稿不会影响线上页面。'}
{pushToPlaza && pushToWechatDraft
? '确认发布后会同步推送到 Plaza,并写入你的公众号草稿箱。'
: pushToPlaza
? '确认发布后会同步推送到 Plaza 发现广场。'
: pushToWechatDraft
? '确认发布后会写入你的公众号草稿箱(需先在 M 配置中绑定凭证)。'
: '发布会创建独立快照,后续修改草稿不会影响线上页面。'}
</span>
<div className="mindspace-publish-action-group">
<button
@@ -1528,13 +1632,32 @@ export function MindSpacePageDetail({
/>
Plaza
</label>
<label className="mindspace-publish-plaza-toggle">
<input
type="checkbox"
checked={pushToWechatDraft}
disabled={publishing || !wechatMpConfigured}
onChange={(event) => setPushToWechatDraft(event.target.checked)}
/>
稿
</label>
{!wechatMpConfigured ? (
<p className="mindspace-page-data-publish-hint">
<a href="/space/wechat-config"> M </a>
</p>
) : null}
<button
type="button"
className="mindspace-primary"
onClick={() => void publish()}
disabled={publishing || !publishCheck?.allowed || publishPageDataBlocked}
>
{publishing ? (pushToPlaza ? '发布并推送…' : '发布中…') : '确认发布'}
{publishing
? pushToPlaza || pushToWechatDraft
? '发布并推送…'
: '发布中…'
: '确认发布'}
</button>
</div>
</div>
@@ -10,6 +10,9 @@ export function MindSpacePublishSuccess({
publication,
plazaPost,
pushedToPlaza = false,
pushedToWechatDraft = false,
wechatDraftMediaId = null,
wechatDraftError = null,
offlineBusy = false,
error,
onOffline,
@@ -19,6 +22,9 @@ export function MindSpacePublishSuccess({
publication: MindSpacePublication;
plazaPost?: PlazaPostBrief | null;
pushedToPlaza?: boolean;
pushedToWechatDraft?: boolean;
wechatDraftMediaId?: string | null;
wechatDraftError?: string | null;
offlineBusy?: boolean;
error?: string | null;
onOffline: () => void | Promise<void>;
@@ -50,6 +56,16 @@ export function MindSpacePublishSuccess({
Plaza广广
</p>
)}
{pushedToWechatDraft && !wechatDraftError ? (
<p className="mindspace-publish-success-note">
稿{wechatDraftMediaId ? `media_id${wechatDraftMediaId}` : ''}
</p>
) : null}
{wechatDraftError ? (
<div className="mindspace-security-note">
稿{wechatDraftError}
</div>
) : null}
{error ? <div className="mindspace-security-note">{error}</div> : null}
<div className="mindspace-publish-success-actions">
<a
+61 -9
View File
@@ -58,6 +58,7 @@ import {
import { PREVIEW_ASSETS, PREVIEW_PAGES, PREVIEW_SPACE } from '../dev/mindspacePreviewData';
import { MindSpaceFeedCard } from './MindSpaceFeedCard';
import { MindSpaceAchievementsPanel } from './MindSpaceAchievementsPanel';
import { MindSpaceWechatMpConfigPanel } from './MindSpaceWechatMpConfigPanel';
import { MindSpacePageDetail } from './MindSpacePageDetail';
import { MindSpaceModal } from './MindSpaceModal';
import { HealthMindSpacePanel } from './HealthMindSpacePanel';
@@ -508,6 +509,7 @@ function canGenerateWithAgent(asset: MindSpaceAsset) {
type MindSpaceRouteSync = {
pushHome: () => void;
pushAchievements: () => void;
pushWechatConfig: () => void;
pushCategory: (code: MindSpaceSaveCategory | MindSpaceCategory['code']) => void;
pushPage: (pageId: string) => void;
};
@@ -539,6 +541,7 @@ export function MindSpaceView({
initialPageId,
initialCategoryCode,
initialAchievementsOpen = false,
initialWechatConfigOpen = false,
onBack,
onLogout,
onOpenFeedback,
@@ -550,6 +553,7 @@ export function MindSpaceView({
initialPageId?: string | null;
initialCategoryCode?: MindSpaceSaveCategory | 'health' | null;
initialAchievementsOpen?: boolean;
initialWechatConfigOpen?: boolean;
onBack: () => void;
onLogout: () => void;
onOpenFeedback?: () => void;
@@ -603,6 +607,7 @@ export function MindSpaceView({
const [allPagesPageIndex, setAllPagesPageIndex] = useState(0);
const [allPagesLoading, setAllPagesLoading] = useState(false);
const [achievementsOpen, setAchievementsOpen] = useState(initialAchievementsOpen);
const [wechatConfigOpen, setWechatConfigOpen] = useState(initialWechatConfigOpen);
const [achievementsItems, setAchievementsItems] = useState<MindSpacePage[]>([]);
const [achievementsTotal, setAchievementsTotal] = useState(0);
const [achievementsPageIndex, setAchievementsPageIndex] = useState(0);
@@ -669,11 +674,11 @@ export function MindSpaceView({
}, [selectedPageId]);
useEffect(() => {
if (!allPagesOpen && !achievementsOpen && !selectedPageId) return;
if (!allPagesOpen && !achievementsOpen && !wechatConfigOpen && !selectedPageId) return;
window.requestAnimationFrame(() => {
window.scrollTo({ top: 0, left: 0 });
});
}, [allPagesOpen, achievementsOpen, selectedPageId]);
}, [allPagesOpen, achievementsOpen, wechatConfigOpen, selectedPageId]);
useEffect(() => {
if (!selectedPageId || !session?.id || previewMode || pageFullscreenPreviewOpen) return;
@@ -1002,6 +1007,7 @@ export function MindSpaceView({
setNewPageOpen(false);
setAgentJobsPanelOpen(false);
setAchievementsOpen(false);
setWechatConfigOpen(false);
setAllPagesOpen(true);
setSelectedAllPageIds([]);
routeSync?.pushHome();
@@ -1041,6 +1047,7 @@ export function MindSpaceView({
setNewPageOpen(false);
setAgentJobsPanelOpen(false);
setAllPagesOpen(false);
setWechatConfigOpen(false);
setAchievementsOpen(true);
setSelectedAchievementPageIds([]);
routeSync?.pushAchievements();
@@ -1053,6 +1060,22 @@ export function MindSpaceView({
routeSync?.pushHome();
};
const openWechatConfigPanel = () => {
setSelectedCategory(null);
setSelectedPageId(null);
setNewPageOpen(false);
setAgentJobsPanelOpen(false);
setAllPagesOpen(false);
setAchievementsOpen(false);
setWechatConfigOpen(true);
routeSync?.pushWechatConfig();
};
const closeWechatConfigPanel = () => {
setWechatConfigOpen(false);
routeSync?.pushHome();
};
const refreshAgentJobsSummary = async () => {
if (previewMode) return;
const { page } = await listMindSpaceAgentJobs({ limit: 1, offset: 0 });
@@ -1088,6 +1111,7 @@ export function MindSpaceView({
setNewPageOpen(false);
setAllPagesOpen(false);
setAchievementsOpen(false);
setWechatConfigOpen(false);
setAgentJobsPanelOpen(true);
routeSync?.pushHome();
void loadAgentJobsPage(0);
@@ -1206,7 +1230,7 @@ export function MindSpaceView({
useEffect(() => {
if (previewMode) return;
const refreshSchedule = () => {
if (!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && !achievementsOpen) {
if (!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && !achievementsOpen && !wechatConfigOpen) {
void refreshSpaceQuietly();
}
};
@@ -1223,6 +1247,7 @@ export function MindSpaceView({
agentJobsPanelOpen,
allPagesOpen,
achievementsOpen,
wechatConfigOpen,
]);
useEffect(() => {
@@ -1230,11 +1255,11 @@ export function MindSpaceView({
}, [initialPageId]);
useEffect(() => {
if (!space || !initialCategoryCode || initialCategoryCode === 'draft' || initialAchievementsOpen) return;
if (!space || !initialCategoryCode || initialCategoryCode === 'draft' || initialAchievementsOpen || initialWechatConfigOpen) return;
if (initialCategoryCode === 'health' && !healthEnabled) return;
const category = space.categories.find((item) => item.code === initialCategoryCode);
if (category) void openCategory(category);
}, [space, initialCategoryCode, initialAchievementsOpen, healthEnabled]);
}, [space, initialCategoryCode, initialAchievementsOpen, initialWechatConfigOpen, healthEnabled]);
useEffect(() => {
if (!initialAchievementsOpen || previewMode) return;
@@ -1243,10 +1268,22 @@ export function MindSpaceView({
setNewPageOpen(false);
setAgentJobsPanelOpen(false);
setAllPagesOpen(false);
setWechatConfigOpen(false);
setAchievementsOpen(true);
void loadAchievementsPage(0);
}, [initialAchievementsOpen, previewMode]);
useEffect(() => {
if (!initialWechatConfigOpen || previewMode) return;
setSelectedCategory(null);
setSelectedPageId(null);
setNewPageOpen(false);
setAgentJobsPanelOpen(false);
setAllPagesOpen(false);
setAchievementsOpen(false);
setWechatConfigOpen(true);
}, [initialWechatConfigOpen, previewMode]);
useEffect(() => {
if (previewMode || !agentJob || !['queued', 'running'].includes(agentJob.status)) return;
const timer = window.setInterval(() => {
@@ -2107,7 +2144,8 @@ export function MindSpaceView({
!newPageOpen &&
!agentJobsPanelOpen &&
!allPagesOpen &&
!achievementsOpen;
!achievementsOpen &&
!wechatConfigOpen;
const rawUpcomingReminders = previewMode ? [] : space?.schedule?.upcomingReminders ?? [];
const scheduleReminders = previewMode
? PREVIEW_SCHEDULE_REMINDERS.map((item) => ({ ...item }))
@@ -2247,7 +2285,7 @@ export function MindSpaceView({
</header>
<main className="mindspace-content">
{!allPagesOpen && !achievementsOpen && (
{!allPagesOpen && !achievementsOpen && !wechatConfigOpen && (
<section className="mindspace-hero">
<div className="mindspace-hero-left">
<p className="mindspace-eyebrow">MINDSPACE</p>
@@ -2270,6 +2308,7 @@ export function MindSpaceView({
setSelectedPageId(null);
setAllPagesOpen(false);
setAchievementsOpen(false);
setWechatConfigOpen(false);
setNewPageOpen(true);
}}
>
@@ -2544,7 +2583,7 @@ export function MindSpaceView({
<section className="mindspace-page-reader">
<header className="mindspace-page-reader-bar">
<button type="button" className="mindspace-inline-back" onClick={closePage}>
{allPagesOpen ? '返回全部页面' : achievementsOpen ? '返回成果展示' : '返回空间首页'}
{allPagesOpen ? '返回全部页面' : achievementsOpen ? '返回成果展示' : wechatConfigOpen ? '返回 M 配置' : '返回空间首页'}
</button>
<span></span>
</header>
@@ -2626,6 +2665,8 @@ export function MindSpaceView({
</button>
</div>
</section>
) : wechatConfigOpen ? (
<MindSpaceWechatMpConfigPanel onBack={closeWechatConfigPanel} />
) : achievementsOpen ? (
<MindSpaceAchievementsPanel
items={achievementsItems}
@@ -3233,6 +3274,17 @@ export function MindSpaceView({
))}
</div>
<div className="mindspace-grid-secondary">
<article className="mindspace-card mindspace-card-compact mindspace-card-wechat-config">
<div className="mindspace-card-top">
<span className="mindspace-card-code">WECHAT</span>
<span>稿</span>
</div>
<h3>M </h3>
<p> AppID / AppSecret稿</p>
<button type="button" onClick={openWechatConfigPanel}>
</button>
</article>
<article className="mindspace-card mindspace-card-compact mindspace-card-achievements">
<div className="mindspace-card-top">
<span className="mindspace-card-code">SHOWCASE</span>
@@ -3269,7 +3321,7 @@ export function MindSpaceView({
</section>
)}
{!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && !achievementsOpen && (
{!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && !achievementsOpen && !wechatConfigOpen && (
<section className="mindspace-section mindspace-recent-work">
<div className="mindspace-section-heading">
<div>
@@ -0,0 +1,230 @@
import { useEffect, useState } from 'react';
import {
deleteMindSpaceWechatMpConfig,
getMindSpaceWechatMpConfig,
saveMindSpaceWechatMpConfig,
verifyMindSpaceWechatMpConfig,
type MindSpaceWechatMpAccountType,
type MindSpaceWechatMpConfig,
} from '../api/mindspace-wechat-mp';
function formatTime(value: number | null | undefined) {
if (!value) return '—';
return new Date(value).toLocaleString('zh-CN', { hour12: false });
}
type MindSpaceWechatMpConfigPanelProps = {
onBack: () => void;
};
export function MindSpaceWechatMpConfigPanel({ onBack }: MindSpaceWechatMpConfigPanelProps) {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [verifying, setVerifying] = useState(false);
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [config, setConfig] = useState<MindSpaceWechatMpConfig | null>(null);
const [appId, setAppId] = useState('');
const [appSecret, setAppSecret] = useState('');
const [accountType, setAccountType] = useState<MindSpaceWechatMpAccountType>('service');
const [label, setLabel] = useState('');
const [author, setAuthor] = useState('');
const load = async () => {
setLoading(true);
setError(null);
try {
const next = await getMindSpaceWechatMpConfig();
setConfig(next);
setAppId(next.appId ?? '');
setAccountType(next.accountType ?? 'service');
setLabel(next.label ?? '');
setAuthor(next.author ?? '');
setAppSecret('');
} catch (err) {
setError(err instanceof Error ? err.message : '加载公众号配置失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
}, []);
const save = async () => {
setSaving(true);
setError(null);
setNotice(null);
try {
const next = await saveMindSpaceWechatMpConfig({
appId: appId.trim(),
appSecret: appSecret.trim() || undefined,
accountType,
label: label.trim(),
author: author.trim(),
});
setConfig(next);
setAppSecret('');
setNotice('配置已保存并通过连通性校验。');
} catch (err) {
setError(err instanceof Error ? err.message : '保存公众号配置失败');
} finally {
setSaving(false);
}
};
const verify = async () => {
setVerifying(true);
setError(null);
setNotice(null);
try {
const next = await verifyMindSpaceWechatMpConfig();
setConfig(next);
setNotice('凭证校验成功。');
} catch (err) {
setError(err instanceof Error ? err.message : '凭证校验失败');
} finally {
setVerifying(false);
}
};
const remove = async () => {
if (!window.confirm('确定删除公众号配置?删除后无法一键推送到草稿箱。')) return;
setDeleting(true);
setError(null);
setNotice(null);
try {
await deleteMindSpaceWechatMpConfig();
setConfig({
configured: false,
appId: '',
accountType: 'service',
label: '',
author: '',
appSecretMasked: '',
verifiedAt: null,
updatedAt: null,
});
setAppId('');
setAppSecret('');
setLabel('');
setAuthor('');
setNotice('已删除公众号配置。');
} catch (err) {
setError(err instanceof Error ? err.message : '删除公众号配置失败');
} finally {
setDeleting(false);
}
};
return (
<section className="mindspace-independent-page mindspace-wechat-config">
<div className="mindspace-section-heading">
<div>
<button type="button" className="mindspace-inline-back" onClick={onBack}>
</button>
<p className="mindspace-eyebrow">WECHAT MP</p>
<h2>M </h2>
</div>
</div>
<p className="mindspace-wechat-config-intro">
AppID AppSecret稿
IP access_token
</p>
{loading ? (
<div className="mindspace-state"></div>
) : (
<section className="mindspace-publish-panel mindspace-wechat-config-form">
<label>
<select
value={accountType}
onChange={(event) => setAccountType(event.target.value as MindSpaceWechatMpAccountType)}
>
<option value="service"></option>
<option value="subscription"></option>
</select>
</label>
<label>
<input
value={label}
maxLength={64}
onChange={(event) => setLabel(event.target.value)}
placeholder="例如:公司服务号"
/>
</label>
<label>
AppID
<input
value={appId}
maxLength={64}
onChange={(event) => setAppId(event.target.value)}
placeholder="wxXXXXXXXXXXXXXXXX"
autoComplete="off"
/>
</label>
<label>
AppSecret
<input
value={appSecret}
type="password"
maxLength={128}
onChange={(event) => setAppSecret(event.target.value)}
placeholder={config?.configured ? `已保存:${config.appSecretMasked}` : '请输入 AppSecret'}
autoComplete="new-password"
/>
</label>
<label>
稿 8
<input
value={author}
maxLength={8}
onChange={(event) => setAuthor(event.target.value)}
placeholder="TKMind"
/>
</label>
{config?.configured ? (
<div className="mindspace-wechat-config-meta">
<span>{formatTime(config.verifiedAt)}</span>
<span>{formatTime(config.updatedAt)}</span>
</div>
) : null}
{error ? <div className="mindspace-security-note">{error}</div> : null}
{notice ? <div className="mindspace-publish-success-note">{notice}</div> : null}
<div className="mindspace-page-editor-actions">
<span>Secret </span>
<div className="mindspace-publish-action-group">
{config?.configured ? (
<>
<button type="button" onClick={() => void verify()} disabled={verifying || saving || deleting}>
{verifying ? '校验中…' : '重新校验'}
</button>
<button type="button" onClick={() => void remove()} disabled={deleting || saving || verifying}>
{deleting ? '删除中…' : '删除配置'}
</button>
</>
) : null}
<button
type="button"
className="mindspace-primary"
onClick={() => void save()}
disabled={saving || verifying || deleting || !appId.trim() || (!config?.configured && !appSecret.trim())}
>
{saving ? '保存中…' : '保存配置'}
</button>
</div>
</div>
</section>
)}
</section>
);
}
+19
View File
@@ -8296,6 +8296,25 @@ body,
line-height: 1.6;
}
.mindspace-wechat-config-intro {
margin: 0 0 18px;
color: rgba(24, 33, 29, 0.72);
font-size: 14px;
line-height: 1.6;
}
.mindspace-wechat-config-form {
max-width: 720px;
}
.mindspace-wechat-config-meta {
display: flex;
flex-wrap: wrap;
gap: 12px 18px;
color: rgba(24, 33, 29, 0.62);
font-size: 13px;
}
.mindspace-achievements-bulkbar {
margin-bottom: 14px;
}
+3
View File
@@ -27,6 +27,7 @@ export function MindSpaceRoute({
const [searchParams] = useSearchParams();
const pageMatch = matchPath('/space/page/:pageId', location.pathname);
const achievementsMatch = matchPath('/space/achievements', location.pathname);
const wechatConfigMatch = matchPath('/space/wechat-config', location.pathname);
const pageId = pageMatch?.params.pageId ?? null;
const categoryCode = parseCategory(searchParams.get('category'), healthEnabled);
@@ -37,12 +38,14 @@ export function MindSpaceRoute({
initialPageId={pageId}
initialCategoryCode={categoryCode}
initialAchievementsOpen={Boolean(achievementsMatch)}
initialWechatConfigOpen={Boolean(wechatConfigMatch)}
onBack={() => navigate('/')}
onLogout={onLogout}
onOpenFeedback={() => navigate('/feedback')}
routeSync={{
pushHome: () => navigate('/space'),
pushAchievements: () => navigate('/space/achievements'),
pushWechatConfig: () => navigate('/space/wechat-config'),
pushCategory: (code) => navigate(`/space?category=${code}`),
pushPage: (id) => navigate(`/space/page/${id}`),
}}
+1 -1
View File
@@ -91,7 +91,7 @@ function formatShanghaiDateParts(date = new Date()) {
};
}
function isDailyNewsFormat(html) {
export function isDailyNewsFormat(html) {
return /daily-news|每日新闻早报/u.test(String(html ?? ''))
|| /class="date-badge"/u.test(String(html ?? ''));
}