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:
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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
@@ -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,
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -110,6 +110,14 @@ export {
|
||||
redactMindSpacePage,
|
||||
updatePublicationStatus,
|
||||
} from './mindspace-publications';
|
||||
export {
|
||||
deleteMindSpaceWechatMpConfig,
|
||||
getMindSpaceWechatMpConfig,
|
||||
listMindSpaceWechatPageDraftRuns,
|
||||
pushMindSpacePageWechatDraft,
|
||||
saveMindSpaceWechatMpConfig,
|
||||
verifyMindSpaceWechatMpConfig,
|
||||
} from './mindspace-wechat-mp';
|
||||
export {
|
||||
applyPageDataPublishPolicy,
|
||||
buildPageDataExportUrl,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`),
|
||||
}}
|
||||
|
||||
@@ -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 ?? ''));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user