diff --git a/.env.example b/.env.example
index 6e30f03..d2c98cb 100644
--- a/.env.example
+++ b/.env.example
@@ -49,12 +49,12 @@ H5_ADMIN_PASSWORD=change-me-admin
H5_ACCESS_PASSWORD=change-me
# 计费(Phase 2,金额单位均为人民币分)
-# 默认:DeepSeek V4 Flash 公开价 × 3(随 H5_USD_CNY_RATE 换算);可用下方变量覆盖
+# 默认:DeepSeek V4 Flash 公开价 × 1(随 H5_USD_CNY_RATE 换算);可用下方变量覆盖
# H5_USE_BACKEND_COST=0
# H5_USD_CNY_RATE=7.2
-# 汇率 7.2 时默认约:输入 0.302 分/1k(¥0.00302/1k)、输出 0.605 分/1k(¥0.00605/1k)
-# H5_BILL_INPUT_CENTS_PER_1K=0.302
-# H5_BILL_OUTPUT_CENTS_PER_1K=0.605
+# 汇率 7.2 时默认约:输入 0.101 分/1k(¥0.00101/1k)、输出 0.202 分/1k(¥0.00202/1k)
+# H5_BILL_INPUT_CENTS_PER_1K=0.101
+# H5_BILL_OUTPUT_CENTS_PER_1K=0.202
# H5_MIN_BILL_CENTS=1
# 仅调试:H5_USE_BACKEND_COST=1 + H5_USD_CNY_RATE=7.2(上游 USD × 汇率 → 人民币分)
@@ -112,6 +112,17 @@ H5_ACCESS_PASSWORD=change-me
# H5_ASR_MAX_BYTES=5242880
# H5_ASR_TIMEOUT_MS=45000
+# AI 图片生成(image-designer 技能;OpenAI 兼容 /v1/images/generations)
+# H5_IMAGE_GEN_ENABLED=1
+# H5_IMAGE_GEN_API_URL=https://api.openai.com/v1/images/generations
+# H5_IMAGE_GEN_API_KEY=sk-...
+# H5_IMAGE_GEN_MODEL=dall-e-3
+# H5_IMAGE_GEN_DEFAULT_SIZE=1024x1024
+# H5_IMAGE_GEN_DEFAULT_STYLE=vivid
+# H5_IMAGE_GEN_DEFAULT_QUALITY=standard
+# H5_IMAGE_GEN_BILL_CENTS=30
+# 未配置 H5_IMAGE_GEN_API_* 时,回退使用管理后台已选 LLM 的 API Key(需支持 images/generations)
+
# 前端构建时注入(Vite,需 VITE_ 前缀)
# 工作目录:新建会话时使用,必填
VITE_TKMIND_WORKING_DIR=/Users/john/PycharmProjects/tkmind
diff --git a/billing.mjs b/billing.mjs
index 6763734..375b764 100644
--- a/billing.mjs
+++ b/billing.mjs
@@ -1,7 +1,7 @@
// DeepSeek V4 Flash 公开价($/1M tokens,cache-miss 输入);见 api-docs.deepseek.com
export const DEEPSEEK_FLASH_INPUT_USD_PER_1M = 0.14;
export const DEEPSEEK_FLASH_OUTPUT_USD_PER_1M = 0.28;
-export const DEEPSEEK_BILLING_MARKUP = 3;
+export const DEEPSEEK_BILLING_MARKUP = 1;
function deepSeekFlashCentsPer1k(usdPer1M, usdCnyRate) {
return (usdPer1M / 1000) * usdCnyRate * 100 * DEEPSEEK_BILLING_MARKUP;
diff --git a/billing.test.mjs b/billing.test.mjs
index f3d65bc..2206a54 100644
--- a/billing.test.mjs
+++ b/billing.test.mjs
@@ -14,7 +14,7 @@ const config = {
minBillCents: 1,
};
-test('loadBillingConfig defaults to DeepSeek Flash × 3 at USD/CNY 7.2', () => {
+test('loadBillingConfig defaults to DeepSeek Flash × 1 at USD/CNY 7.2', () => {
const savedInput = process.env.H5_BILL_INPUT_CENTS_PER_1K;
const savedOutput = process.env.H5_BILL_OUTPUT_CENTS_PER_1K;
const savedRate = process.env.H5_USD_CNY_RATE;
@@ -23,8 +23,8 @@ test('loadBillingConfig defaults to DeepSeek Flash × 3 at USD/CNY 7.2', () => {
process.env.H5_USD_CNY_RATE = '7.2';
try {
const billing = loadBillingConfig();
- assert.ok(Math.abs(billing.inputCentsPer1k - 0.3024) < 1e-9);
- assert.ok(Math.abs(billing.outputCentsPer1k - 0.6048) < 1e-9);
+ assert.ok(Math.abs(billing.inputCentsPer1k - 0.1008) < 1e-9);
+ assert.ok(Math.abs(billing.outputCentsPer1k - 0.2016) < 1e-9);
} finally {
if (savedInput == null) delete process.env.H5_BILL_INPUT_CENTS_PER_1K;
else process.env.H5_BILL_INPUT_CENTS_PER_1K = savedInput;
@@ -35,12 +35,12 @@ test('loadBillingConfig defaults to DeepSeek Flash × 3 at USD/CNY 7.2', () => {
}
});
-test('computeDeltaCostCents bills DeepSeek Flash × 3 defaults with min charge', () => {
+test('computeDeltaCostCents bills DeepSeek Flash × 1 defaults with min charge', () => {
const billing = {
useBackendCost: false,
usdCnyRate: 7.2,
- inputCentsPer1k: 0.3024,
- outputCentsPer1k: 0.6048,
+ inputCentsPer1k: 0.1008,
+ outputCentsPer1k: 0.2016,
minBillCents: 1,
};
const previous = { lastInputTokens: 0, lastOutputTokens: 0 };
diff --git a/chat-skills.mjs b/chat-skills.mjs
index d1eb240..ed55612 100644
--- a/chat-skills.mjs
+++ b/chat-skills.mjs
@@ -60,6 +60,14 @@ export const CHAT_SKILL_DEFINITIONS = [
requiresPublish: true,
promptKey: 'generate-page',
},
+ {
+ id: 'generate-image',
+ label: '生成图片',
+ icon: 'image',
+ skillName: 'image-designer',
+ prefillOnly: true,
+ promptKey: 'generate-image',
+ },
];
export function buildChatSkillPrompt(promptKey, skillName) {
@@ -78,6 +86,8 @@ export function buildChatSkillPrompt(promptKey, skillName) {
return '请深入分析以下内容,给出结构化解读:背景、关键发现、风险或机会、以及建议下一步:';
case 'generate-page':
return `请使用 ${skillName ?? PUBLISH_SKILL_NAME} 技能:在我的专属 MindSpace 发布目录生成静态 HTML 页面,并给出可公网访问的完整链接。`;
+ case 'generate-image':
+ return `请使用 ${skillName ?? 'image-designer'} 技能:以高级视觉设计师身份,根据我的需求生成高质量图片,保存到工作区并给出可点击预览链接。我的需求是:`;
default:
return '';
}
diff --git a/chat-skills.test.mjs b/chat-skills.test.mjs
index ba399af..aeb133f 100644
--- a/chat-skills.test.mjs
+++ b/chat-skills.test.mjs
@@ -31,6 +31,7 @@ test('filterChatSkills shows generate-page when publish is allowed', () => {
test('buildChatSkillPrompt includes skill name for platform skills', () => {
assert.match(buildChatSkillPrompt('web', 'web'), /请使用 web 技能/);
assert.match(buildChatSkillPrompt('generate-page'), /static-page-publish/);
+ assert.match(buildChatSkillPrompt('generate-image', 'image-designer'), /image-designer/);
});
test('prefillOnly is set for open-ended chat skills', () => {
diff --git a/image-generation.mjs b/image-generation.mjs
new file mode 100644
index 0000000..0803eea
--- /dev/null
+++ b/image-generation.mjs
@@ -0,0 +1,340 @@
+import fs from 'node:fs/promises';
+import path from 'node:path';
+import { Agent, fetch as undiciFetch } from 'undici';
+import {
+ decryptSecret,
+ normalizeApiUrl,
+ resolveChatCompletionsUrl,
+} from './llm-providers.mjs';
+import { buildPublicUrl, resolvePublishKey } from './user-publish.mjs';
+import { isPathInsideUserWorkspace, resolveUserWorkspaceRoot } from './user-space.mjs';
+
+const insecureDispatcher = new Agent({
+ connect: { rejectUnauthorized: false },
+});
+
+const ALLOWED_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
+const ALLOWED_SIZES = new Set(['1024x1024', '1024x1792', '1792x1024', '512x512', '768x768']);
+const ALLOWED_STYLES = new Set(['vivid', 'natural']);
+const ALLOWED_QUALITIES = new Set(['standard', 'hd']);
+
+export function loadImageGenConfig(env = process.env) {
+ return {
+ enabled: env.H5_IMAGE_GEN_ENABLED !== '0',
+ apiUrl: String(env.H5_IMAGE_GEN_API_URL ?? '').trim(),
+ apiKey: String(env.H5_IMAGE_GEN_API_KEY ?? '').trim(),
+ model: String(env.H5_IMAGE_GEN_MODEL ?? 'dall-e-3').trim() || 'dall-e-3',
+ defaultSize: String(env.H5_IMAGE_GEN_DEFAULT_SIZE ?? '1024x1024').trim() || '1024x1024',
+ defaultStyle: String(env.H5_IMAGE_GEN_DEFAULT_STYLE ?? 'vivid').trim() || 'vivid',
+ defaultQuality: String(env.H5_IMAGE_GEN_DEFAULT_QUALITY ?? 'standard').trim() || 'standard',
+ billCents: Number(env.H5_IMAGE_GEN_BILL_CENTS ?? 30),
+ };
+}
+
+export function resolveImagesGenerationsUrl(apiUrl) {
+ const configured = normalizeApiUrl(apiUrl);
+ if (!configured) return '';
+ if (configured.endsWith('/images/generations')) return configured;
+ if (configured.endsWith('/chat/completions')) {
+ return configured.replace(/\/chat\/completions$/, '/images/generations');
+ }
+ if (configured.endsWith('/v1')) return `${configured}/images/generations`;
+ return `${configured}/v1/images/generations`;
+}
+
+function resolveEncryptionKey(explicitKey) {
+ return (
+ explicitKey ??
+ process.env.H5_SETTINGS_ENCRYPTION_KEY ??
+ process.env.TKMIND_SERVER__SECRET_KEY ??
+ 'local-dev-secret'
+ );
+}
+
+function slugifyFilename(value) {
+ return String(value ?? '')
+ .normalize('NFKC')
+ .trim()
+ .toLowerCase()
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 48);
+}
+
+export function normalizeImageOutputPath(rawPath, prompt) {
+ const cleaned = String(rawPath ?? '')
+ .normalize('NFKC')
+ .trim()
+ .replace(/^\.?\/+/, '')
+ .replace(/\\/g, '/');
+ const fallback = `assets/${slugifyFilename(prompt) || 'generated-image'}.png`;
+ const relative = cleaned || fallback;
+ if (
+ !relative ||
+ relative.includes('..') ||
+ relative.startsWith('/') ||
+ relative.includes('\0')
+ ) {
+ throw Object.assign(new Error('输出路径无效'), { code: 'invalid_output_path' });
+ }
+ const ext = path.extname(relative).toLowerCase();
+ if (!ALLOWED_EXTENSIONS.has(ext)) {
+ throw Object.assign(new Error('仅支持 png / jpg / webp 输出'), { code: 'invalid_output_path' });
+ }
+ return relative;
+}
+
+export function resolveWorkspaceOutputPath(workspaceRoot, relativePath) {
+ const absolute = path.resolve(workspaceRoot, relativePath);
+ if (!isPathInsideUserWorkspace(workspaceRoot, absolute)) {
+ throw Object.assign(new Error('输出路径必须位于当前用户工作区内'), { code: 'invalid_output_path' });
+ }
+ return absolute;
+}
+
+export function composeDesignerPrompt({ prompt, styleNotes, aspectRatio, brandColors, subject }) {
+ const parts = [
+ 'Professional commercial visual design, high-end art direction, polished composition, clean lighting, production-ready quality.',
+ ];
+ if (subject) parts.push(`Subject: ${subject}.`);
+ if (styleNotes) parts.push(`Style direction: ${styleNotes}.`);
+ if (aspectRatio) parts.push(`Aspect ratio intent: ${aspectRatio}.`);
+ if (brandColors) parts.push(`Brand palette: ${brandColors}.`);
+ parts.push(`Creative brief: ${String(prompt ?? '').trim()}`);
+ parts.push('Avoid text overlays, watermarks, logos, distorted anatomy, blurry details, or cluttered backgrounds unless explicitly requested.');
+ return parts.join(' ');
+}
+
+function parseImagePayload(data) {
+ const item = data?.data?.[0];
+ if (!item) return null;
+ if (typeof item.b64_json === 'string' && item.b64_json.trim()) {
+ return { kind: 'base64', value: item.b64_json.trim(), revisedPrompt: item.revised_prompt ?? null };
+ }
+ if (typeof item.url === 'string' && item.url.trim()) {
+ return { kind: 'url', value: item.url.trim(), revisedPrompt: item.revised_prompt ?? null };
+ }
+ return null;
+}
+
+async function loadProviderCredentials(pool, encryptionKey) {
+ const [rows] = await pool.query(
+ `SELECT api_url, api_key_ciphertext, api_key_iv, api_key_tag, relay_provider
+ FROM h5_llm_provider_keys
+ WHERE is_selected = 1 AND status = 'active'
+ LIMIT 1`,
+ );
+ const row = rows[0];
+ if (!row) {
+ throw Object.assign(new Error('请先在管理后台配置并启用 LLM'), { code: 'image_gen_not_configured' });
+ }
+ let apiKey;
+ try {
+ apiKey = decryptSecret(
+ {
+ ciphertext: row.api_key_ciphertext,
+ iv: row.api_key_iv,
+ tag: row.api_key_tag,
+ },
+ resolveEncryptionKey(encryptionKey),
+ );
+ } catch {
+ throw Object.assign(new Error('LLM 密钥解密失败,请重新配置'), { code: 'image_gen_not_configured' });
+ }
+ return {
+ apiUrl: normalizeApiUrl(row.api_url),
+ apiKey,
+ relayProvider: row.relay_provider ?? null,
+ };
+}
+
+async function resolveProvider(pool, encryptionKey) {
+ const config = loadImageGenConfig();
+ if (config.apiUrl && config.apiKey) {
+ return {
+ url: resolveImagesGenerationsUrl(config.apiUrl),
+ apiKey: config.apiKey,
+ model: config.model,
+ relayProvider: null,
+ };
+ }
+ const provider = await loadProviderCredentials(pool, encryptionKey);
+ const chatUrl = resolveChatCompletionsUrl(provider.apiUrl);
+ return {
+ url: resolveImagesGenerationsUrl(chatUrl || provider.apiUrl),
+ apiKey: provider.apiKey,
+ model: config.model,
+ relayProvider: provider.relayProvider,
+ };
+}
+
+async function downloadImageBuffer(payload) {
+ if (payload.kind === 'base64') {
+ return Buffer.from(payload.value, 'base64');
+ }
+ const response = await undiciFetch(payload.value, {
+ dispatcher: payload.value.startsWith('https://') ? insecureDispatcher : undefined,
+ });
+ if (!response.ok) {
+ throw Object.assign(new Error(`下载生成图片失败 (${response.status})`), { code: 'image_gen_failed' });
+ }
+ const arrayBuffer = await response.arrayBuffer();
+ return Buffer.from(arrayBuffer);
+}
+
+function mimeTypeForPath(relativePath) {
+ switch (path.extname(relativePath).toLowerCase()) {
+ case '.jpg':
+ case '.jpeg':
+ return 'image/jpeg';
+ case '.webp':
+ return 'image/webp';
+ default:
+ return 'image/png';
+ }
+}
+
+export function buildImageGenerateCurlExample({ h5ApiBase, sessionId = '<当前会话ID>' } = {}) {
+ const base = String(h5ApiBase ?? 'http://127.0.0.1:8081').replace(/\/$/, '');
+ return [
+ '```bash',
+ `curl -sS -X POST '${base}/api/agent/generate_image' \\`,
+ " -H 'Content-Type: application/json' \\",
+ ` -d '{"session_id":"${sessionId}","prompt":"A premium travel poster for Malaysia, cinematic sunset, editorial photography","output_path":"assets/malaysia-hero.png","style_notes":"modern editorial, warm palette","aspect_ratio":"3:4 portrait"}'`,
+ '```',
+ ].join('\n');
+}
+
+export function createImageGenerationService({
+ pool,
+ h5Root,
+ encryptionKey,
+ resolveUserIdForAgentSession,
+ publicBaseUrl,
+}) {
+ async function generateForAgentSession(input = {}) {
+ const config = loadImageGenConfig();
+ if (!config.enabled) {
+ throw Object.assign(new Error('图片生成未启用'), { code: 'image_gen_unavailable' });
+ }
+
+ const sessionId = String(input.sessionId ?? input.session_id ?? '').trim();
+ const prompt = String(input.prompt ?? '').trim();
+ if (!sessionId) {
+ throw Object.assign(new Error('缺少 session_id'), { code: 'invalid_request' });
+ }
+ if (!prompt) {
+ throw Object.assign(new Error('缺少 prompt'), { code: 'invalid_request' });
+ }
+ if (prompt.length > 4000) {
+ throw Object.assign(new Error('prompt 过长'), { code: 'invalid_request' });
+ }
+
+ const userId = await resolveUserIdForAgentSession(sessionId);
+ if (!userId) {
+ throw Object.assign(new Error('无效的 Agent 会话'), { code: 'forbidden' });
+ }
+
+ const user = { id: userId };
+
+ const relativePath = normalizeImageOutputPath(input.outputPath ?? input.output_path, prompt);
+ const workspaceRoot = resolveUserWorkspaceRoot(h5Root, user);
+ const absolutePath = resolveWorkspaceOutputPath(workspaceRoot, relativePath);
+
+ const size = ALLOWED_SIZES.has(String(input.size ?? '').trim())
+ ? String(input.size).trim()
+ : config.defaultSize;
+ const style = ALLOWED_STYLES.has(String(input.style ?? '').trim())
+ ? String(input.style).trim()
+ : config.defaultStyle;
+ const quality = ALLOWED_QUALITIES.has(String(input.quality ?? '').trim())
+ ? String(input.quality).trim()
+ : config.defaultQuality;
+
+ const designerPrompt = composeDesignerPrompt({
+ prompt,
+ styleNotes: input.styleNotes ?? input.style_notes,
+ aspectRatio: input.aspectRatio ?? input.aspect_ratio,
+ brandColors: input.brandColors ?? input.brand_colors,
+ subject: input.subject,
+ });
+
+ const provider = await resolveProvider(pool, encryptionKey);
+ if (!provider.url) {
+ throw Object.assign(new Error('图片生成 API 地址无效'), { code: 'image_gen_not_configured' });
+ }
+
+ let upstream;
+ try {
+ upstream = await undiciFetch(provider.url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${provider.apiKey}`,
+ },
+ body: JSON.stringify({
+ model: provider.model,
+ prompt: designerPrompt,
+ n: 1,
+ size,
+ style,
+ quality,
+ response_format: 'b64_json',
+ ...(provider.relayProvider ? { provider: provider.relayProvider } : {}),
+ }),
+ dispatcher: provider.url.startsWith('https://') ? insecureDispatcher : undefined,
+ });
+ } catch (error) {
+ throw Object.assign(
+ new Error(`图片生成失败:${error instanceof Error ? error.message : '网络错误'}`),
+ { code: 'image_gen_failed' },
+ );
+ }
+
+ const text = await upstream.text().catch(() => '');
+ if (!upstream.ok) {
+ throw Object.assign(new Error(`图片生成失败 (${upstream.status})`), {
+ code: 'image_gen_failed',
+ details: text.slice(0, 500),
+ });
+ }
+
+ let data;
+ try {
+ data = JSON.parse(text);
+ } catch {
+ throw Object.assign(new Error('图片生成响应不是 JSON'), { code: 'image_gen_failed' });
+ }
+
+ const payload = parseImagePayload(data);
+ if (!payload) {
+ throw Object.assign(new Error('图片生成 API 未返回图片'), { code: 'image_gen_failed' });
+ }
+
+ const buffer = await downloadImageBuffer(payload);
+ if (buffer.length === 0) {
+ throw Object.assign(new Error('图片内容为空'), { code: 'image_gen_failed' });
+ }
+
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true });
+ await fs.writeFile(absolutePath, buffer);
+
+ const publishKey = resolvePublishKey(user);
+ const publicUrl = buildPublicUrl(publicBaseUrl, publishKey, relativePath);
+
+ return {
+ relativePath,
+ absolutePath,
+ publicUrl,
+ mimeType: mimeTypeForPath(relativePath),
+ size,
+ style,
+ quality,
+ revisedPrompt: payload.revisedPrompt,
+ prompt,
+ designerPrompt,
+ };
+ }
+
+ return { generateForAgentSession };
+}
diff --git a/image-generation.test.mjs b/image-generation.test.mjs
new file mode 100644
index 0000000..6c2df4f
--- /dev/null
+++ b/image-generation.test.mjs
@@ -0,0 +1,68 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {
+ buildImageGenerateCurlExample,
+ composeDesignerPrompt,
+ loadImageGenConfig,
+ normalizeImageOutputPath,
+ resolveImagesGenerationsUrl,
+} from './image-generation.mjs';
+
+test('resolveImagesGenerationsUrl derives from chat completions base', () => {
+ assert.equal(
+ resolveImagesGenerationsUrl('https://api.openai.com/v1/chat/completions'),
+ 'https://api.openai.com/v1/images/generations',
+ );
+ assert.equal(
+ resolveImagesGenerationsUrl('https://api.openai.com/v1/images/generations'),
+ 'https://api.openai.com/v1/images/generations',
+ );
+});
+
+test('normalizeImageOutputPath defaults to assets slug png', () => {
+ assert.equal(
+ normalizeImageOutputPath('', '马来西亚旅行海报'),
+ 'assets/马来西亚旅行海报.png',
+ );
+ assert.equal(normalizeImageOutputPath('public/hero.webp', 'x'), 'public/hero.webp');
+});
+
+test('normalizeImageOutputPath rejects traversal and unsupported extensions', () => {
+ assert.throws(
+ () => normalizeImageOutputPath('../secret.png', 'x'),
+ (error) => error.code === 'invalid_output_path',
+ );
+ assert.throws(
+ () => normalizeImageOutputPath('assets/hero.svg', 'x'),
+ (error) => error.code === 'invalid_output_path',
+ );
+});
+
+test('composeDesignerPrompt merges creative brief and guardrails', () => {
+ const prompt = composeDesignerPrompt({
+ prompt: 'premium coffee brand poster',
+ styleNotes: 'minimal, warm beige palette',
+ aspectRatio: '3:4',
+ brandColors: '#c8a27d',
+ subject: 'latte art',
+ });
+ assert.match(prompt, /premium coffee brand poster/);
+ assert.match(prompt, /minimal, warm beige palette/);
+ assert.match(prompt, /Avoid text overlays/);
+});
+
+test('buildImageGenerateCurlExample includes agent endpoint', () => {
+ const example = buildImageGenerateCurlExample({
+ h5ApiBase: 'http://127.0.0.1:8081',
+ sessionId: 'sess-1',
+ });
+ assert.match(example, /\/api\/agent\/generate_image/);
+ assert.match(example, /sess-1/);
+});
+
+test('loadImageGenConfig defaults', () => {
+ const config = loadImageGenConfig({});
+ assert.equal(config.enabled, true);
+ assert.equal(config.model, 'dall-e-3');
+ assert.equal(config.defaultSize, '1024x1024');
+});
diff --git a/mindspace-chat-save.mjs b/mindspace-chat-save.mjs
index 46e8618..88f1dcf 100644
--- a/mindspace-chat-save.mjs
+++ b/mindspace-chat-save.mjs
@@ -1,6 +1,11 @@
import fs from 'node:fs/promises';
import path from 'node:path';
-import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID } from './user-publish.mjs';
+import {
+ migrateUserPublishDir,
+ PUBLISH_ROOT_DIR,
+ PUBLISH_KEY_UUID,
+ resolveLegacyPublishDir,
+} from './user-publish.mjs';
import { workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
const URL_PATTERN =
@@ -23,11 +28,10 @@ export function extractStaticPageLinks(content, { userId, username } = {}) {
for (const match of text.matchAll(URL_PATTERN)) {
const owner = decodePathSegment(match[1]).toLowerCase();
const relativePath = decodePathSegment(match[2]);
- if (normalizedUserId) {
- if (owner !== normalizedUserId) continue;
- } else if (normalizedUsername && owner !== normalizedUsername) {
- continue;
- }
+ const ownedByUser =
+ (normalizedUserId && owner === normalizedUserId) ||
+ (normalizedUsername && owner === normalizedUsername);
+ if ((normalizedUserId || normalizedUsername) && !ownedByUser) continue;
const key = `${owner}/${relativePath}`;
if (seen.has(key)) continue;
seen.add(key);
@@ -114,7 +118,7 @@ async function walkPublishHtmlByBasename(publishRoot, basename, maxDepth = 6, de
return null;
}
-export async function findPublishHtml(h5Root, userId, relativePath) {
+async function findPublishHtmlInRoot(publishRoot, relativePath, { readFile = fs.readFile } = {}) {
const normalized = String(relativePath ?? '').replace(/^\/+/, '');
const basename = path.basename(normalized);
const candidates = [
@@ -124,22 +128,59 @@ export async function findPublishHtml(h5Root, userId, relativePath) {
].filter((value, index, list) => value && list.indexOf(value) === index);
for (const candidate of candidates) {
+ const absolute = path.resolve(publishRoot, candidate);
+ if (absolute !== publishRoot && !absolute.startsWith(`${publishRoot}${path.sep}`)) continue;
+ if (!absolute.toLowerCase().endsWith('.html')) continue;
try {
- return await readPublishHtml(h5Root, userId, candidate);
+ const content = await readFile(absolute, 'utf8');
+ if (!content.trim()) continue;
+ const resolvedRelativePath = path.relative(publishRoot, absolute).split(path.sep).join('/');
+ return {
+ absolute,
+ content,
+ relativePath: resolvedRelativePath,
+ filename: path.basename(resolvedRelativePath),
+ };
} catch {
// try next candidate
}
}
- const publishRoot = path.resolve(
- h5Root,
- PUBLISH_ROOT_DIR,
- String(userId ?? '').trim().toLowerCase(),
- );
const absolute = await walkPublishHtmlByBasename(publishRoot, basename);
if (absolute) {
const resolvedRelativePath = path.relative(publishRoot, absolute).split(path.sep).join('/');
- return readPublishHtml(h5Root, userId, resolvedRelativePath);
+ const content = await readFile(absolute, 'utf8');
+ if (!content.trim()) {
+ throw Object.assign(new Error('页面内容为空'), { code: 'empty_page_content' });
+ }
+ return {
+ absolute,
+ content,
+ relativePath: resolvedRelativePath,
+ filename: path.basename(resolvedRelativePath),
+ };
+ }
+
+ return null;
+}
+
+export async function findPublishHtml(h5Root, userId, relativePath, { username } = {}) {
+ const key = String(userId ?? '').trim().toLowerCase();
+ if (username && key) {
+ migrateUserPublishDir(h5Root, { id: key, username });
+ }
+
+ const publishRoot = path.resolve(h5Root, PUBLISH_ROOT_DIR, key);
+ const primary = await findPublishHtmlInRoot(publishRoot, relativePath);
+ if (primary) return primary;
+
+ const legacyRoot = username ? resolveLegacyPublishDir(h5Root, { username }) : null;
+ if (legacyRoot && path.resolve(legacyRoot) !== publishRoot) {
+ const legacy = await findPublishHtmlInRoot(legacyRoot, relativePath);
+ if (legacy) {
+ migrateUserPublishDir(h5Root, { id: key, username });
+ return readPublishHtml(h5Root, userId, legacy.relativePath);
+ }
}
throw Object.assign(new Error('无法读取链接页面内容'), { code: 'static_page_not_found' });
@@ -215,8 +256,9 @@ function dedupeDuplicatedFilename(filename) {
if (half >= 2 && base.slice(0, half) === base.slice(half)) {
base = base.slice(0, half);
}
- base = base.replace(/(.+?)\1+/g, '$1');
+ base = base.replace(/(.{4,}?)\1+/g, '$1');
base = base.replace(/-v-v(\d+)/g, '-v$1');
+ base = base.replace(/-v(\d)\1+$/g, '-v$1');
return `${base}.html`;
}
@@ -288,23 +330,123 @@ export function analyzeChatMessageForSave({
};
}
-export async function resolveStaticHtmlContent(analysis) {
+function uniqueRelativePaths(relativePath) {
+ const basename = path.basename(String(relativePath ?? ''));
+ return [
+ String(relativePath ?? '').replace(/^\/+/, ''),
+ String(relativePath ?? '')
+ .replace(/^\/+/, '')
+ .replace(/^public\//, ''),
+ `public/${basename}`,
+ basename,
+ ].filter((value, index, list) => value && list.indexOf(value) === index);
+}
+
+function buildPublishHtmlFetchUrls({ userId, link, publicBaseUrl }) {
+ const urls = new Set();
+ const base = String(publicBaseUrl ?? '').replace(/\/$/, '');
+ if (base && link) {
+ for (const rel of uniqueRelativePaths(link.relativePath)) {
+ const asset = buildWorkspaceAssetUrl(userId, rel);
+ if (asset) urls.add(`${base}${asset}`);
+ }
+ }
+ if (link?.publicUrl) urls.add(link.publicUrl);
+ return [...urls];
+}
+
+async function mirrorFetchedPublishHtml(h5Root, userId, relativePath, content, { username } = {}) {
+ try {
+ if (username) {
+ migrateUserPublishDir(h5Root, { id: userId, username });
+ }
+ const absolute = resolvePublishHtmlAbsolutePath(h5Root, userId, relativePath);
+ await fs.mkdir(path.dirname(absolute), { recursive: true });
+ await fs.writeFile(absolute, content, 'utf8');
+ return absolute;
+ } catch {
+ return null;
+ }
+}
+
+export async function fetchPublishHtmlFallback(
+ analysis,
+ { publicBaseUrl, fetchImpl = fetch } = {},
+) {
+ if (analysis.contentMode !== 'static_html' || !analysis.selectedLink) return null;
+ const candidatePaths = uniqueRelativePaths(analysis.selectedLink.relativePath);
+ const urls = buildPublishHtmlFetchUrls({
+ userId: analysis.userId,
+ link: analysis.selectedLink,
+ publicBaseUrl,
+ });
+ for (const url of urls) {
+ try {
+ const response = await fetchImpl(url, { redirect: 'follow' });
+ if (!response.ok) continue;
+ const contentType = response.headers.get('content-type') ?? '';
+ if (contentType.includes('json')) continue;
+ const content = await response.text();
+ if (!content.trim() || !/ {
+ const content = 'https://goo.tkmind.cn/MindSpace/john/report.html';
+ const links = extractStaticPageLinks(content, { userId: USER_ID, username: 'john' });
+ assert.equal(links.length, 1);
+ assert.equal(links[0].relativePath, 'report.html');
+});
+
test('analyzeChatMessageForSave prefers static html mode', () => {
const analysis = analyzeChatMessageForSave({
content: `链接 https://g2.tkmind.cn/MindSpace/${USER_ID}/report.html`,
@@ -96,6 +105,42 @@ test('buildChatSavePreviewUrls use local api routes', () => {
);
});
+test('findPublishHtml resolves html stored only in legacy username directory', async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-legacy-'));
+ const legacyDir = path.join(root, 'MindSpace', 'john', 'public');
+ await fs.mkdir(legacyDir, { recursive: true });
+ await fs.writeFile(
+ path.join(legacyDir, 'legacy-only.html'),
+ '
Legacylegacy',
+ 'utf8',
+ );
+ const loaded = await findPublishHtml(root, USER_ID, 'public/legacy-only.html', { username: 'john' });
+ assert.equal(loaded.relativePath, 'public/legacy-only.html');
+ assert.match(loaded.content, /Legacy/);
+ const migrated = path.join(root, 'MindSpace', USER_ID, 'public', 'legacy-only.html');
+ assert.ok(await fs.stat(migrated).then(() => true).catch(() => false));
+});
+
+test('resolveChatSaveAnalysis resolves uuid link when html exists only in legacy username dir', async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-uuid-link-'));
+ const legacyDir = path.join(root, 'MindSpace', 'john', 'public');
+ await fs.mkdir(legacyDir, { recursive: true });
+ await fs.writeFile(
+ path.join(legacyDir, 'report.html'),
+ 'Reportok',
+ 'utf8',
+ );
+ const content = `链接 https://g2.tkmind.cn/MindSpace/${USER_ID}/public/report.html`;
+ const result = await resolveChatSaveAnalysis({
+ content,
+ userId: USER_ID,
+ username: 'john',
+ h5Root: root,
+ });
+ assert.equal(result.analysis.contentMode, 'static_html');
+ assert.equal(result.resolvedHtml?.relativePath, 'public/report.html');
+});
+
test('findPublishHtml resolves nested public html by basename', async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'memind-chat-save-'));
const publishDir = path.join(root, 'MindSpace', USER_ID, 'public');
@@ -116,6 +161,11 @@ test('injectHtmlBaseHref adds base tag for relative assets', () => {
assert.match(next, /=6.9.0"
}
},
+ "node_modules/@epic-web/invariant": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
+ "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
+ "license": "MIT"
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -802,6 +810,80 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@phc/format": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
+ "integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@redis/bloom": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-1.2.0.tgz",
+ "integrity": "sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/client": {
+ "version": "1.6.1",
+ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.6.1.tgz",
+ "integrity": "sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==",
+ "license": "MIT",
+ "dependencies": {
+ "cluster-key-slot": "1.1.2",
+ "generic-pool": "3.9.0",
+ "yallist": "4.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@redis/client/node_modules/yallist": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
+ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
+ "license": "ISC"
+ },
+ "node_modules/@redis/graph": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/@redis/graph/-/graph-1.1.1.tgz",
+ "integrity": "sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/json": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/@redis/json/-/json-1.0.7.tgz",
+ "integrity": "sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/search": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@redis/search/-/search-1.2.0.tgz",
+ "integrity": "sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
+ "node_modules/@redis/time-series": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-1.1.0.tgz",
+ "integrity": "sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==",
+ "license": "MIT",
+ "peerDependencies": {
+ "@redis/client": "^1.0.0"
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -901,9 +983,6 @@
"arm"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -918,9 +997,6 @@
"arm"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -935,9 +1011,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -952,9 +1025,6 @@
"arm64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -969,9 +1039,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -986,9 +1053,6 @@
"loong64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1003,9 +1067,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1020,9 +1081,6 @@
"ppc64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1037,9 +1095,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1054,9 +1109,6 @@
"riscv64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1071,9 +1123,6 @@
"s390x"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1088,9 +1137,6 @@
"x64"
],
"dev": true,
- "libc": [
- "glibc"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1105,9 +1151,6 @@
"x64"
],
"dev": true,
- "libc": [
- "musl"
- ],
"license": "MIT",
"optional": true,
"os": [
@@ -1363,6 +1406,22 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
+ "node_modules/argon2": {
+ "version": "0.44.0",
+ "resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz",
+ "integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "@phc/format": "^1.0.0",
+ "cross-env": "^10.0.0",
+ "node-addon-api": "^8.5.0",
+ "node-gyp-build": "^4.8.4"
+ },
+ "engines": {
+ "node": ">=16.17.0"
+ }
+ },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -1568,6 +1627,15 @@
"wrap-ansi": "^6.2.0"
}
},
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1629,6 +1697,37 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT"
},
+ "node_modules/cross-env": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
+ "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
+ "license": "MIT",
+ "dependencies": {
+ "@epic-web/invariant": "^1.0.0",
+ "cross-spawn": "^7.0.6"
+ },
+ "bin": {
+ "cross-env": "dist/bin/cross-env.js",
+ "cross-env-shell": "dist/bin/cross-env-shell.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -2044,6 +2143,15 @@
"is-property": "^1.0.2"
}
},
+ "node_modules/generic-pool": {
+ "version": "3.9.0",
+ "resolved": "https://registry.npmjs.org/generic-pool/-/generic-pool-3.9.0.tgz",
+ "integrity": "sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -2287,6 +2395,12 @@
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "license": "ISC"
+ },
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -2538,6 +2652,26 @@
"node": ">= 0.6"
}
},
+ "node_modules/node-addon-api": {
+ "version": "8.8.0",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz",
+ "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18 || ^20 || >= 21"
+ }
+ },
+ "node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "license": "MIT",
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
"node_modules/node-releases": {
"version": "2.0.47",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz",
@@ -2626,6 +2760,15 @@
"node": ">=8"
}
},
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
@@ -2840,6 +2983,23 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/redis": {
+ "version": "4.7.1",
+ "resolved": "https://registry.npmjs.org/redis/-/redis-4.7.1.tgz",
+ "integrity": "sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==",
+ "license": "MIT",
+ "workspaces": [
+ "./packages/*"
+ ],
+ "dependencies": {
+ "@redis/bloom": "1.2.0",
+ "@redis/client": "1.6.1",
+ "@redis/graph": "1.1.1",
+ "@redis/json": "1.0.7",
+ "@redis/search": "1.2.0",
+ "@redis/time-series": "1.1.0"
+ }
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -3020,6 +3180,27 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/side-channel": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
@@ -3437,6 +3618,21 @@
"node": ">=12"
}
},
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
diff --git a/package.json b/package.json
index fe045d9..2f25274 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"check:plaza": "node scripts/check-plaza-local.mjs",
"setup:plaza-local": "node scripts/setup-plaza-local.mjs",
"setup:plaza-tunnel": "bash scripts/install-plaza-tunnel.sh",
+ "setup:prod-services": "bash scripts/install-prod-services.sh",
"setup:plaza-tls": "node scripts/plaza-local-tls.mjs",
"dev:vite": "vite",
"dev:server": "node server.mjs",
@@ -25,7 +26,7 @@
"seed:plaza-covers": "node scripts/seed-plaza-covers.mjs",
"enrich:plaza": "node scripts/enrich-plaza-posts.mjs",
"build": "vite build",
- "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs",
+ "test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs image-generation.test.mjs",
"test:mindspace-e2e": "node scripts/mindspace-e2e.mjs",
"test:mindspace-pages-e2e": "node scripts/mindspace-pages-e2e.mjs",
"test:mindspace-publications-e2e": "node scripts/mindspace-publications-e2e.mjs",
@@ -41,6 +42,7 @@
"deploy:plaza-105": "bash scripts/deploy-plaza-105.sh"
},
"dependencies": {
+ "argon2": "^0.44.0",
"debug": "^4.4.3",
"express": "^4.21.2",
"http-proxy-middleware": "^3.0.3",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a43a717..2cd1321 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
+ argon2:
+ specifier: ^0.44.0
+ version: 0.44.0
debug:
specifier: ^4.4.3
version: 4.4.3
@@ -146,6 +149,9 @@ packages:
resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==}
engines: {node: '>=6.9.0'}
+ '@epic-web/invariant@1.0.0':
+ resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==}
+
'@esbuild/aix-ppc64@0.25.12':
resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==}
engines: {node: '>=18'}
@@ -318,6 +324,10 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@phc/format@1.0.0':
+ resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
+ engines: {node: '>=10'}
+
'@redis/bloom@1.2.0':
resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==}
peerDependencies:
@@ -541,6 +551,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
+ argon2@0.44.0:
+ resolution: {integrity: sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==}
+ engines: {node: '>=16.17.0'}
+
array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
@@ -625,6 +639,15 @@ packages:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
+ cross-env@10.1.0:
+ resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==}
+ engines: {node: '>=20'}
+ hasBin: true
+
+ cross-spawn@7.0.6:
+ resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
+ engines: {node: '>= 8'}
+
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@@ -855,6 +878,9 @@ packages:
is-property@1.0.2:
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
+ isexe@2.0.0:
+ resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -943,6 +969,14 @@ packages:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
+ node-addon-api@8.8.0:
+ resolution: {integrity: sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==}
+ engines: {node: ^18 || ^20 || >= 21}
+
+ node-gyp-build@4.8.4:
+ resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
+ hasBin: true
+
node-releases@2.0.47:
resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==}
engines: {node: '>=18'}
@@ -975,6 +1009,10 @@ packages:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
+ path-key@3.1.1:
+ resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
+ engines: {node: '>=8'}
+
path-to-regexp@0.1.13:
resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==}
@@ -1096,6 +1134,14 @@ packages:
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+ shebang-command@2.0.0:
+ resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
+ engines: {node: '>=8'}
+
+ shebang-regex@3.0.0:
+ resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
+ engines: {node: '>=8'}
+
side-channel-list@1.0.1:
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
engines: {node: '>= 0.4'}
@@ -1225,6 +1271,11 @@ packages:
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
+ which@2.0.2:
+ resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
+ engines: {node: '>= 8'}
+ hasBin: true
+
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
@@ -1372,6 +1423,8 @@ snapshots:
'@babel/helper-string-parser': 7.29.7
'@babel/helper-validator-identifier': 7.29.7
+ '@epic-web/invariant@1.0.0': {}
+
'@esbuild/aix-ppc64@0.25.12':
optional: true
@@ -1469,6 +1522,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@phc/format@1.0.0': {}
+
'@redis/bloom@1.2.0(@redis/client@1.6.1)':
dependencies:
'@redis/client': 1.6.1
@@ -1640,6 +1695,13 @@ snapshots:
dependencies:
color-convert: 2.0.1
+ argon2@0.44.0:
+ dependencies:
+ '@phc/format': 1.0.0
+ cross-env: 10.1.0
+ node-addon-api: 8.8.0
+ node-gyp-build: 4.8.4
+
array-flatten@1.1.1: {}
aws-ssl-profiles@1.1.2: {}
@@ -1723,6 +1785,17 @@ snapshots:
cookie@1.1.1: {}
+ cross-env@10.1.0:
+ dependencies:
+ '@epic-web/invariant': 1.0.0
+ cross-spawn: 7.0.6
+
+ cross-spawn@7.0.6:
+ dependencies:
+ path-key: 3.1.1
+ shebang-command: 2.0.0
+ which: 2.0.2
+
csstype@3.2.3: {}
debug@2.6.9:
@@ -1980,6 +2053,8 @@ snapshots:
is-property@1.0.2: {}
+ isexe@2.0.0: {}
+
js-tokens@4.0.0: {}
jsesc@3.1.0: {}
@@ -2045,6 +2120,10 @@ snapshots:
negotiator@0.6.3: {}
+ node-addon-api@8.8.0: {}
+
+ node-gyp-build@4.8.4: {}
+
node-releases@2.0.47: {}
object-inspect@1.13.4: {}
@@ -2067,6 +2146,8 @@ snapshots:
path-exists@4.0.0: {}
+ path-key@3.1.1: {}
+
path-to-regexp@0.1.13: {}
picocolors@1.1.1: {}
@@ -2217,6 +2298,12 @@ snapshots:
setprototypeof@1.2.0: {}
+ shebang-command@2.0.0:
+ dependencies:
+ shebang-regex: 3.0.0
+
+ shebang-regex@3.0.0: {}
+
side-channel-list@1.0.1:
dependencies:
es-errors: 1.3.0
@@ -2311,6 +2398,10 @@ snapshots:
which-module@2.0.1: {}
+ which@2.0.2:
+ dependencies:
+ isexe: 2.0.0
+
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..03d61c0
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,2 @@
+allowBuilds:
+ argon2: set this to true or false
diff --git a/public/plaza-covers/business-1bt5ajr.jpg b/public/plaza-covers/business-1bt5ajr.jpg
new file mode 100644
index 0000000..80de088
Binary files /dev/null and b/public/plaza-covers/business-1bt5ajr.jpg differ
diff --git a/public/plaza-covers/business-1dpghh8.jpg b/public/plaza-covers/business-1dpghh8.jpg
new file mode 100644
index 0000000..76d994a
Binary files /dev/null and b/public/plaza-covers/business-1dpghh8.jpg differ
diff --git a/public/plaza-covers/business-30-84q88u.jpg b/public/plaza-covers/business-30-84q88u.jpg
new file mode 100644
index 0000000..a22930d
Binary files /dev/null and b/public/plaza-covers/business-30-84q88u.jpg differ
diff --git a/public/plaza-covers/business-e09bef.jpg b/public/plaza-covers/business-e09bef.jpg
new file mode 100644
index 0000000..80de088
Binary files /dev/null and b/public/plaza-covers/business-e09bef.jpg differ
diff --git a/public/plaza-covers/business-nfc9ph.jpg b/public/plaza-covers/business-nfc9ph.jpg
new file mode 100644
index 0000000..27b08b0
Binary files /dev/null and b/public/plaza-covers/business-nfc9ph.jpg differ
diff --git a/public/plaza-covers/business-sop-33nw2t.jpg b/public/plaza-covers/business-sop-33nw2t.jpg
new file mode 100644
index 0000000..27b08b0
Binary files /dev/null and b/public/plaza-covers/business-sop-33nw2t.jpg differ
diff --git a/public/plaza-covers/business-zfht2z.jpg b/public/plaza-covers/business-zfht2z.jpg
new file mode 100644
index 0000000..2da05d3
Binary files /dev/null and b/public/plaza-covers/business-zfht2z.jpg differ
diff --git a/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg b/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg
new file mode 100644
index 0000000..27b08b0
Binary files /dev/null and b/public/plaza-covers/business-宠物洗护上门服务-sop-手册.jpg differ
diff --git a/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg b/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg
new file mode 100644
index 0000000..80de088
Binary files /dev/null and b/public/plaza-covers/business-日式美甲沙龙会员体系设计.jpg differ
diff --git a/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg b/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg
new file mode 100644
index 0000000..2da05d3
Binary files /dev/null and b/public/plaza-covers/business-民宿旺季定价与房源包装指南.jpg differ
diff --git a/public/plaza-covers/business-独立书店文创周边选品策略.jpg b/public/plaza-covers/business-独立书店文创周边选品策略.jpg
new file mode 100644
index 0000000..76d994a
Binary files /dev/null and b/public/plaza-covers/business-独立书店文创周边选品策略.jpg differ
diff --git a/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg b/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg
new file mode 100644
index 0000000..27b08b0
Binary files /dev/null and b/public/plaza-covers/business-社区健身房私教转化漏斗优化.jpg differ
diff --git a/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg b/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg
new file mode 100644
index 0000000..a22930d
Binary files /dev/null and b/public/plaza-covers/business-精酿啤酒馆开业-30-天数据复盘.jpg differ
diff --git a/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg b/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg
new file mode 100644
index 0000000..80de088
Binary files /dev/null and b/public/plaza-covers/business-轻食沙拉店午餐高峰运营方案.jpg differ
diff --git a/public/plaza-covers/creative-17bb2q5.jpg b/public/plaza-covers/creative-17bb2q5.jpg
new file mode 100644
index 0000000..3330d02
Binary files /dev/null and b/public/plaza-covers/creative-17bb2q5.jpg differ
diff --git a/public/plaza-covers/creative-1a69un3.jpg b/public/plaza-covers/creative-1a69un3.jpg
new file mode 100644
index 0000000..dbf1769
Binary files /dev/null and b/public/plaza-covers/creative-1a69un3.jpg differ
diff --git a/public/plaza-covers/creative-1wuxajv.jpg b/public/plaza-covers/creative-1wuxajv.jpg
new file mode 100644
index 0000000..1e93c59
Binary files /dev/null and b/public/plaza-covers/creative-1wuxajv.jpg differ
diff --git a/public/plaza-covers/creative-ai-1m59cjr.jpg b/public/plaza-covers/creative-ai-1m59cjr.jpg
new file mode 100644
index 0000000..dbf1769
Binary files /dev/null and b/public/plaza-covers/creative-ai-1m59cjr.jpg differ
diff --git a/public/plaza-covers/creative-ceramics-1o5zmj5.jpg b/public/plaza-covers/creative-ceramics-1o5zmj5.jpg
new file mode 100644
index 0000000..495339d
Binary files /dev/null and b/public/plaza-covers/creative-ceramics-1o5zmj5.jpg differ
diff --git a/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg b/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg
new file mode 100644
index 0000000..3330d02
Binary files /dev/null and b/public/plaza-covers/creative-ui-200-icons-12lpy7p.jpg differ
diff --git a/public/plaza-covers/creative-vis-tkrnir.jpg b/public/plaza-covers/creative-vis-tkrnir.jpg
new file mode 100644
index 0000000..1e93c59
Binary files /dev/null and b/public/plaza-covers/creative-vis-tkrnir.jpg differ
diff --git a/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg b/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg
new file mode 100644
index 0000000..1e93c59
Binary files /dev/null and b/public/plaza-covers/creative-国潮茶饮品牌-vis-视觉系统.jpg differ
diff --git a/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg b/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg
new file mode 100644
index 0000000..3330d02
Binary files /dev/null and b/public/plaza-covers/creative-城市夜景长曝光摄影系列.jpg differ
diff --git a/public/plaza-covers/creative-复古胶片人像调色预设包.jpg b/public/plaza-covers/creative-复古胶片人像调色预设包.jpg
new file mode 100644
index 0000000..dbf1769
Binary files /dev/null and b/public/plaza-covers/creative-复古胶片人像调色预设包.jpg differ
diff --git a/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg b/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg
new file mode 100644
index 0000000..495339d
Binary files /dev/null and b/public/plaza-covers/creative-手作-ceramics-工作室品牌摄影.jpg differ
diff --git a/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg b/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg
new file mode 100644
index 0000000..3330d02
Binary files /dev/null and b/public/plaza-covers/creative-极简-ui-图标集-200-icons.jpg differ
diff --git a/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg b/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg
new file mode 100644
index 0000000..dbf1769
Binary files /dev/null and b/public/plaza-covers/creative-水墨风-ai-插画实验合集.jpg differ
diff --git a/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg b/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg
new file mode 100644
index 0000000..1e93c59
Binary files /dev/null and b/public/plaza-covers/creative-科幻短片-归途-分镜脚本.jpg differ
diff --git a/public/plaza-covers/data-analysis-1293jxp.jpg b/public/plaza-covers/data-analysis-1293jxp.jpg
new file mode 100644
index 0000000..1b25426
Binary files /dev/null and b/public/plaza-covers/data-analysis-1293jxp.jpg differ
diff --git a/public/plaza-covers/data-analysis-a-b-1slrtg.jpg b/public/plaza-covers/data-analysis-a-b-1slrtg.jpg
new file mode 100644
index 0000000..0969588
Binary files /dev/null and b/public/plaza-covers/data-analysis-a-b-1slrtg.jpg differ
diff --git a/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg b/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg
new file mode 100644
index 0000000..0969588
Binary files /dev/null and b/public/plaza-covers/data-analysis-a-b-测试显著性检验指南.jpg differ
diff --git a/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg b/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/data-analysis-arima-vs-prophet-144eeao.jpg differ
diff --git a/public/plaza-covers/data-analysis-g6tcgg.jpg b/public/plaza-covers/data-analysis-g6tcgg.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/data-analysis-g6tcgg.jpg differ
diff --git a/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg b/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg
new file mode 100644
index 0000000..a39faf3
Binary files /dev/null and b/public/plaza-covers/data-analysis-shapley-vs-markov-11a7w0y.jpg differ
diff --git a/public/plaza-covers/data-analysis-sql-9sel2d.jpg b/public/plaza-covers/data-analysis-sql-9sel2d.jpg
new file mode 100644
index 0000000..1b25426
Binary files /dev/null and b/public/plaza-covers/data-analysis-sql-9sel2d.jpg differ
diff --git a/public/plaza-covers/data-analysis-ztijjg.jpg b/public/plaza-covers/data-analysis-ztijjg.jpg
new file mode 100644
index 0000000..0969588
Binary files /dev/null and b/public/plaza-covers/data-analysis-ztijjg.jpg differ
diff --git a/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg b/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg
new file mode 100644
index 0000000..1b25426
Binary files /dev/null and b/public/plaza-covers/data-analysis-实时大屏指标设计与-sql-模板.jpg differ
diff --git a/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg b/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/data-analysis-数据质量监控规则库搭建.jpg differ
diff --git a/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg b/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg
new file mode 100644
index 0000000..1b25426
Binary files /dev/null and b/public/plaza-covers/data-analysis-文本情感分析-评论洞察报告.jpg differ
diff --git a/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg b/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg
new file mode 100644
index 0000000..0969588
Binary files /dev/null and b/public/plaza-covers/data-analysis-用户行为漏斗分析实战案例.jpg differ
diff --git a/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg b/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg
new file mode 100644
index 0000000..a39faf3
Binary files /dev/null and b/public/plaza-covers/data-analysis-营销归因-shapley-vs-markov.jpg differ
diff --git a/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg b/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/data-analysis-销售预测模型-arima-vs-prophet.jpg differ
diff --git a/public/plaza-covers/lifestyle-100-40-l2vjup.jpg b/public/plaza-covers/lifestyle-100-40-l2vjup.jpg
new file mode 100644
index 0000000..e2e61e9
Binary files /dev/null and b/public/plaza-covers/lifestyle-100-40-l2vjup.jpg differ
diff --git a/public/plaza-covers/lifestyle-1o3s87z.jpg b/public/plaza-covers/lifestyle-1o3s87z.jpg
new file mode 100644
index 0000000..e9f9bda
Binary files /dev/null and b/public/plaza-covers/lifestyle-1o3s87z.jpg differ
diff --git a/public/plaza-covers/lifestyle-1wfbcos.jpg b/public/plaza-covers/lifestyle-1wfbcos.jpg
new file mode 100644
index 0000000..f73669e
Binary files /dev/null and b/public/plaza-covers/lifestyle-1wfbcos.jpg differ
diff --git a/public/plaza-covers/lifestyle-30-173irnz.jpg b/public/plaza-covers/lifestyle-30-173irnz.jpg
new file mode 100644
index 0000000..e9f9bda
Binary files /dev/null and b/public/plaza-covers/lifestyle-30-173irnz.jpg differ
diff --git a/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg b/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg
new file mode 100644
index 0000000..e9f9bda
Binary files /dev/null and b/public/plaza-covers/lifestyle-30-天早睡挑战执行记录.jpg differ
diff --git a/public/plaza-covers/lifestyle-7-1cwt66m.jpg b/public/plaza-covers/lifestyle-7-1cwt66m.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/public/plaza-covers/lifestyle-7-1cwt66m.jpg differ
diff --git a/public/plaza-covers/lifestyle-m33c9i.jpg b/public/plaza-covers/lifestyle-m33c9i.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/public/plaza-covers/lifestyle-m33c9i.jpg differ
diff --git a/public/plaza-covers/lifestyle-o90i4e.jpg b/public/plaza-covers/lifestyle-o90i4e.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/public/plaza-covers/lifestyle-o90i4e.jpg differ
diff --git a/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg b/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg
new file mode 100644
index 0000000..f73669e
Binary files /dev/null and b/public/plaza-covers/lifestyle-厨房收纳改造-小空间大利用.jpg differ
diff --git a/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg b/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/public/plaza-covers/lifestyle-周末徒步装备清单与路线.jpg differ
diff --git a/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg b/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg
new file mode 100644
index 0000000..e9f9bda
Binary files /dev/null and b/public/plaza-covers/lifestyle-周末窑烤面包新手日记.jpg differ
diff --git a/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg b/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/public/plaza-covers/lifestyle-手冲咖啡入门-7-日练习.jpg differ
diff --git a/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg b/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg
new file mode 100644
index 0000000..e2e61e9
Binary files /dev/null and b/public/plaza-covers/lifestyle-断舍离-衣柜-100-件到-40-件.jpg differ
diff --git a/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg b/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg
new file mode 100644
index 0000000..ea7b9b7
Binary files /dev/null and b/public/plaza-covers/lifestyle-阳台花园从零搭建指南.jpg differ
diff --git a/public/plaza-covers/other-0-1000-35irqz.jpg b/public/plaza-covers/other-0-1000-35irqz.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/public/plaza-covers/other-0-1000-35irqz.jpg differ
diff --git a/public/plaza-covers/other-176h7rn.jpg b/public/plaza-covers/other-176h7rn.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/public/plaza-covers/other-176h7rn.jpg differ
diff --git a/public/plaza-covers/other-187ouof.jpg b/public/plaza-covers/other-187ouof.jpg
new file mode 100644
index 0000000..3c2e59b
Binary files /dev/null and b/public/plaza-covers/other-187ouof.jpg differ
diff --git a/public/plaza-covers/other-1nd2tz4.jpg b/public/plaza-covers/other-1nd2tz4.jpg
new file mode 100644
index 0000000..5dca65c
Binary files /dev/null and b/public/plaza-covers/other-1nd2tz4.jpg differ
diff --git a/public/plaza-covers/other-20-1de41cz.jpg b/public/plaza-covers/other-20-1de41cz.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/public/plaza-covers/other-20-1de41cz.jpg differ
diff --git a/public/plaza-covers/other-vr-60-1yt52pj.jpg b/public/plaza-covers/other-vr-60-1yt52pj.jpg
new file mode 100644
index 0000000..3c2e59b
Binary files /dev/null and b/public/plaza-covers/other-vr-60-1yt52pj.jpg differ
diff --git a/public/plaza-covers/other-vr-健身-60-天体验报告.jpg b/public/plaza-covers/other-vr-健身-60-天体验报告.jpg
new file mode 100644
index 0000000..3c2e59b
Binary files /dev/null and b/public/plaza-covers/other-vr-健身-60-天体验报告.jpg differ
diff --git a/public/plaza-covers/other-xu49pe.jpg b/public/plaza-covers/other-xu49pe.jpg
new file mode 100644
index 0000000..15cbd88
Binary files /dev/null and b/public/plaza-covers/other-xu49pe.jpg differ
diff --git a/public/plaza-covers/other-个人知识库搭建方法论.jpg b/public/plaza-covers/other-个人知识库搭建方法论.jpg
new file mode 100644
index 0000000..3c2e59b
Binary files /dev/null and b/public/plaza-covers/other-个人知识库搭建方法论.jpg differ
diff --git a/public/plaza-covers/other-二手交易向可持续生活转型.jpg b/public/plaza-covers/other-二手交易向可持续生活转型.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/public/plaza-covers/other-二手交易向可持续生活转型.jpg differ
diff --git a/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg b/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/public/plaza-covers/other-城市观鸟入门-常见-20-种鸟.jpg differ
diff --git a/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg b/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg
new file mode 100644
index 0000000..6d3eb2b
Binary files /dev/null and b/public/plaza-covers/other-播客频道从-0-到-1000-订阅.jpg differ
diff --git a/public/plaza-covers/other-社群运营-从群聊到价值观.jpg b/public/plaza-covers/other-社群运营-从群聊到价值观.jpg
new file mode 100644
index 0000000..15cbd88
Binary files /dev/null and b/public/plaza-covers/other-社群运营-从群聊到价值观.jpg differ
diff --git a/public/plaza-covers/other-自由职业者财务规划入门.jpg b/public/plaza-covers/other-自由职业者财务规划入门.jpg
new file mode 100644
index 0000000..5dca65c
Binary files /dev/null and b/public/plaza-covers/other-自由职业者财务规划入门.jpg differ
diff --git a/public/plaza-covers/study-notes-6owsyr.jpg b/public/plaza-covers/study-notes-6owsyr.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-6owsyr.jpg differ
diff --git a/public/plaza-covers/study-notes-docker-1adiwh7.jpg b/public/plaza-covers/study-notes-docker-1adiwh7.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-docker-1adiwh7.jpg differ
diff --git a/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg b/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-docker-容器化从零到部署.jpg differ
diff --git a/public/plaza-covers/study-notes-fobvok.jpg b/public/plaza-covers/study-notes-fobvok.jpg
new file mode 100644
index 0000000..15cbd88
Binary files /dev/null and b/public/plaza-covers/study-notes-fobvok.jpg differ
diff --git a/public/plaza-covers/study-notes-git-code-review-1avjty.jpg b/public/plaza-covers/study-notes-git-code-review-1avjty.jpg
new file mode 100644
index 0000000..edd5abf
Binary files /dev/null and b/public/plaza-covers/study-notes-git-code-review-1avjty.jpg differ
diff --git a/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg b/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg
new file mode 100644
index 0000000..edd5abf
Binary files /dev/null and b/public/plaza-covers/study-notes-git-团队分支规范与-code-review.jpg differ
diff --git a/public/plaza-covers/study-notes-pandas-1mg4mom.jpg b/public/plaza-covers/study-notes-pandas-1mg4mom.jpg
new file mode 100644
index 0000000..edd5abf
Binary files /dev/null and b/public/plaza-covers/study-notes-pandas-1mg4mom.jpg differ
diff --git a/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg b/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg
new file mode 100644
index 0000000..edd5abf
Binary files /dev/null and b/public/plaza-covers/study-notes-pandas-数据分析速查手册.jpg differ
diff --git a/public/plaza-covers/study-notes-pca-54364r.jpg b/public/plaza-covers/study-notes-pca-54364r.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-pca-54364r.jpg differ
diff --git a/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg b/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-typescript-10-1sddjtb.jpg differ
diff --git a/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg b/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-typescript-类型体操-10-道经典题解析.jpg differ
diff --git a/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg b/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg
new file mode 100644
index 0000000..15cbd88
Binary files /dev/null and b/public/plaza-covers/study-notes-技术文档英语阅读训练计划.jpg differ
diff --git a/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg b/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-系统设计面试-分布式缓存怎么答.jpg differ
diff --git a/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg b/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg
new file mode 100644
index 0000000..a48a853
Binary files /dev/null and b/public/plaza-covers/study-notes-线性代数直觉笔记-特征值与-pca.jpg differ
diff --git a/public/plaza-covers/travel-10-ap683v.jpg b/public/plaza-covers/travel-10-ap683v.jpg
new file mode 100644
index 0000000..d7437c5
Binary files /dev/null and b/public/plaza-covers/travel-10-ap683v.jpg differ
diff --git a/public/plaza-covers/travel-15qb47w.jpg b/public/plaza-covers/travel-15qb47w.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/public/plaza-covers/travel-15qb47w.jpg differ
diff --git a/public/plaza-covers/travel-3-1lbpfse.jpg b/public/plaza-covers/travel-3-1lbpfse.jpg
new file mode 100644
index 0000000..f5e5f1f
Binary files /dev/null and b/public/plaza-covers/travel-3-1lbpfse.jpg differ
diff --git a/public/plaza-covers/travel-4-70oznz.jpg b/public/plaza-covers/travel-4-70oznz.jpg
new file mode 100644
index 0000000..4490c59
Binary files /dev/null and b/public/plaza-covers/travel-4-70oznz.jpg differ
diff --git a/public/plaza-covers/travel-48-p5s92m.jpg b/public/plaza-covers/travel-48-p5s92m.jpg
new file mode 100644
index 0000000..f5e5f1f
Binary files /dev/null and b/public/plaza-covers/travel-48-p5s92m.jpg differ
diff --git a/public/plaza-covers/travel-6-1hwz8po.jpg b/public/plaza-covers/travel-6-1hwz8po.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/public/plaza-covers/travel-6-1hwz8po.jpg differ
diff --git a/public/plaza-covers/travel-fjj9j0.jpg b/public/plaza-covers/travel-fjj9j0.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/public/plaza-covers/travel-fjj9j0.jpg differ
diff --git a/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg b/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/public/plaza-covers/travel-云南大理丽江-6-日省钱攻略.jpg differ
diff --git a/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg b/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg
new file mode 100644
index 0000000..d7437c5
Binary files /dev/null and b/public/plaza-covers/travel-冰岛环岛-10-日自驾攻略.jpg differ
diff --git a/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg b/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg
new file mode 100644
index 0000000..f5e5f1f
Binary files /dev/null and b/public/plaza-covers/travel-新加坡亲子-3-日轻松游.jpg differ
diff --git a/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg b/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg
new file mode 100644
index 0000000..4490c59
Binary files /dev/null and b/public/plaza-covers/travel-清迈慢生活-4-日深度游.jpg differ
diff --git a/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg b/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/public/plaza-covers/travel-西藏林芝桃花季摄影路线.jpg differ
diff --git a/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg b/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg
new file mode 100644
index 0000000..f5e5f1f
Binary files /dev/null and b/public/plaza-covers/travel-釜山海鲜美食-48-小时.jpg differ
diff --git a/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg b/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg
new file mode 100644
index 0000000..a178c53
Binary files /dev/null and b/public/plaza-covers/travel-阿尔卑斯徒步-少女峰周边.jpg differ
diff --git a/public/plaza-covers/work-report-1y4i5h1.jpg b/public/plaza-covers/work-report-1y4i5h1.jpg
new file mode 100644
index 0000000..206f41a
Binary files /dev/null and b/public/plaza-covers/work-report-1y4i5h1.jpg differ
diff --git a/public/plaza-covers/work-report-2025-1fz5i3.jpg b/public/plaza-covers/work-report-2025-1fz5i3.jpg
new file mode 100644
index 0000000..884182f
Binary files /dev/null and b/public/plaza-covers/work-report-2025-1fz5i3.jpg differ
diff --git a/public/plaza-covers/work-report-72ek9j.jpg b/public/plaza-covers/work-report-72ek9j.jpg
new file mode 100644
index 0000000..00bf9c0
Binary files /dev/null and b/public/plaza-covers/work-report-72ek9j.jpg differ
diff --git a/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg b/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg
new file mode 100644
index 0000000..884182f
Binary files /dev/null and b/public/plaza-covers/work-report-b2b-saas-9-4vrvjf.jpg differ
diff --git a/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg b/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg
new file mode 100644
index 0000000..884182f
Binary files /dev/null and b/public/plaza-covers/work-report-b2b-saas-客户成功月报-9-月.jpg differ
diff --git a/public/plaza-covers/work-report-mindspace-qwuy2q.jpg b/public/plaza-covers/work-report-mindspace-qwuy2q.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/work-report-mindspace-qwuy2q.jpg differ
diff --git a/public/plaza-covers/work-report-mindspace-公开发布指南.jpg b/public/plaza-covers/work-report-mindspace-公开发布指南.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/work-report-mindspace-公开发布指南.jpg differ
diff --git a/public/plaza-covers/work-report-okr-ivflp3.jpg b/public/plaza-covers/work-report-okr-ivflp3.jpg
new file mode 100644
index 0000000..00bf9c0
Binary files /dev/null and b/public/plaza-covers/work-report-okr-ivflp3.jpg differ
diff --git a/public/plaza-covers/work-report-q3-1v4dgld.jpg b/public/plaza-covers/work-report-q3-1v4dgld.jpg
new file mode 100644
index 0000000..509e7ba
Binary files /dev/null and b/public/plaza-covers/work-report-q3-1v4dgld.jpg differ
diff --git a/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg b/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/work-report-sprint-42-8a7tiy.jpg differ
diff --git a/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg b/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg
new file mode 100644
index 0000000..00bf9c0
Binary files /dev/null and b/public/plaza-covers/work-report-产品经理年度-okr-回顾与反思.jpg differ
diff --git a/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg b/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg
new file mode 100644
index 0000000..00bf9c0
Binary files /dev/null and b/public/plaza-covers/work-report-品牌升级方案执行进度追踪.jpg differ
diff --git a/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg b/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg
new file mode 100644
index 0000000..bdf35c2
Binary files /dev/null and b/public/plaza-covers/work-report-研发团队-sprint-回顾-第-42-期.jpg differ
diff --git a/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg b/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg
new file mode 100644
index 0000000..206f41a
Binary files /dev/null and b/public/plaza-covers/work-report-跨部门项目协同周报-模板与实践.jpg differ
diff --git a/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg b/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg
new file mode 100644
index 0000000..884182f
Binary files /dev/null and b/public/plaza-covers/work-report-远程办公效率调研-2025-秋季.jpg differ
diff --git a/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg b/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg
new file mode 100644
index 0000000..509e7ba
Binary files /dev/null and b/public/plaza-covers/work-report-销售团队-q3-业绩复盘与目标拆解.jpg differ
diff --git a/scripts/compensate-user-billing.mjs b/scripts/compensate-user-billing.mjs
index 41775dc..b283c9e 100644
--- a/scripts/compensate-user-billing.mjs
+++ b/scripts/compensate-user-billing.mjs
@@ -47,7 +47,7 @@ if (!process.env.DATABASE_URL) {
/** wx_mk4zzaps duplicate Finish billing (2026-06-16 session 20260616_600). */
const DUPLICATE_COST_CENTS = 1735;
const DUPLICATE_TOKENS = 838567;
-/** Unique bills at 2/6 分/1k vs documented DeepSeek ×3 default (0.302/0.605 分/1k). */
+/** Unique bills at 2/6 分/1k vs documented DeepSeek ×1 default (0.101/0.202 分/1k). */
const RATE_OVERCHARGE_CENTS = 1467;
async function main() {
diff --git a/scripts/enrich-plaza-posts.mjs b/scripts/enrich-plaza-posts.mjs
index 91434aa..1ee5f8a 100644
--- a/scripts/enrich-plaza-posts.mjs
+++ b/scripts/enrich-plaza-posts.mjs
@@ -103,13 +103,15 @@ async function ensureBundle(conn, row, html) {
async function main() {
const pool = createDbPool();
const [rows] = await pool.query(
- `SELECT pp.id AS post_id, pp.title,
+ `SELECT pp.id AS post_id, pp.title, pp.summary, pp.tags,
+ c.slug AS category_slug,
pr.id AS publication_id, pr.page_id, pr.page_version_id, pr.user_id, pr.status AS pub_status,
p.space_id, p.category_id, p.title AS page_title,
pv.bundle_asset_id,
(SELECT av.id FROM h5_asset_versions av
WHERE av.asset_id = pv.bundle_asset_id AND av.version_no = 1 LIMIT 1) AS bundle_version_id
FROM plaza_posts pp
+ JOIN plaza_categories c ON c.id = pp.category_id
JOIN h5_publish_records pr ON pr.id = pp.publication_id
JOIN h5_page_records p ON p.id = pr.page_id
JOIN h5_page_versions pv ON pv.id = pr.page_version_id
@@ -122,7 +124,17 @@ async function main() {
try {
await conn.beginTransaction();
for (const row of rows) {
- const { summary, html, skipHtml } = getEnrichment(row.post_id, row.title);
+ let tags = [];
+ try {
+ tags = typeof row.tags === 'string' ? JSON.parse(row.tags) : row.tags ?? [];
+ } catch {
+ tags = [];
+ }
+ const { summary, html, skipHtml } = getEnrichment(row.post_id, row.title, {
+ categorySlug: row.category_slug,
+ summary: row.summary,
+ tags,
+ });
const summaryOnly = skipHtml && row.bundle_asset_id;
if (!summaryOnly) {
await ensureBundle(conn, row, html);
diff --git a/scripts/install-prod-services.sh b/scripts/install-prod-services.sh
new file mode 100644
index 0000000..131ee14
--- /dev/null
+++ b/scripts/install-prod-services.sh
@@ -0,0 +1,185 @@
+#!/usr/bin/env bash
+# 安装 g2.tkmind.cn + plaza.tkmind.cn 生产 LaunchAgent(登录自启 + 崩溃自动重启)
+#
+# 架构:
+# cloudflared → g2 :8090 (Caddy) → Portal :8081
+# cloudflared → plaza :3001 (Next.js) → Portal :8081
+#
+# 用法:pnpm setup:prod-services
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+LOG_DIR="${HOME}/Library/Logs"
+PORTAL_PLIST="${HOME}/Library/LaunchAgents/cn.tkmind.memind-portal.plist"
+PLAZA_PLIST="${HOME}/Library/LaunchAgents/cn.tkmind.plaza.plist"
+G2_LB_PLIST="${HOME}/Library/LaunchAgents/cn.tkmind.g2-lb.plist"
+CF_PLIST="${HOME}/Library/LaunchAgents/com.cloudflare.cloudflared.plist"
+PORTAL_RUN="${ROOT}/scripts/run-memind-portal-prod.sh"
+PLAZA_RUN="${ROOT}/scripts/run-plaza-prod.sh"
+CADDYFILE="${ROOT}/scripts/g2-lb.Caddyfile"
+UID_NUM="$(id -u)"
+GUI="gui/${UID_NUM}"
+PATH_ENV="/opt/homebrew/bin:/opt/homebrew/opt/node@22/bin:/usr/local/bin:/usr/bin:/bin"
+
+chmod +x "${PORTAL_RUN}" "${PLAZA_RUN}" "${ROOT}/scripts/install-g2-lb.sh"
+
+mkdir -p "${LOG_DIR}"
+
+cat >"${PORTAL_PLIST}" <
+
+
+
+ Label
+ cn.tkmind.memind-portal
+ ProgramArguments
+
+ ${PORTAL_RUN}
+
+ WorkingDirectory
+ ${ROOT}
+ EnvironmentVariables
+
+ PATH
+ ${PATH_ENV}
+
+ RunAtLoad
+
+ KeepAlive
+
+ ThrottleInterval
+ 10
+ StandardOutPath
+ ${LOG_DIR}/memind-portal.log
+ StandardErrorPath
+ ${LOG_DIR}/memind-portal.log
+
+
+EOF
+
+cat >"${PLAZA_PLIST}" <
+
+
+
+ Label
+ cn.tkmind.plaza
+ ProgramArguments
+
+ ${PLAZA_RUN}
+
+ WorkingDirectory
+ ${ROOT}
+ EnvironmentVariables
+
+ PATH
+ ${PATH_ENV}
+
+ RunAtLoad
+
+ KeepAlive
+
+ ThrottleInterval
+ 15
+ StandardOutPath
+ ${LOG_DIR}/plaza-prod.log
+ StandardErrorPath
+ ${LOG_DIR}/plaza-prod.log
+
+
+EOF
+
+if [[ ! -f "${G2_LB_PLIST}" ]]; then
+ cat >"${G2_LB_PLIST}" <
+
+
+
+ Label
+ cn.tkmind.g2-lb
+ ProgramArguments
+
+ /opt/homebrew/bin/caddy
+ run
+ --config
+ ${CADDYFILE}
+
+ RunAtLoad
+
+ KeepAlive
+
+ ThrottleInterval
+ 10
+ StandardOutPath
+ ${LOG_DIR}/g2-lb.log
+ StandardErrorPath
+ ${LOG_DIR}/g2-lb.log
+
+
+EOF
+fi
+
+install_agent() {
+ local label="$1"
+ local plist="$2"
+ if launchctl print "${GUI}/${label}" >/dev/null 2>&1; then
+ launchctl bootout "${GUI}/${label}" 2>/dev/null || true
+ sleep 1
+ fi
+ launchctl bootstrap "${GUI}" "${plist}"
+ launchctl enable "${GUI}/${label}"
+ launchctl kickstart -k "${GUI}/${label}"
+}
+
+echo "==> 释放端口 8081 / 3001(避免与手动进程冲突)…"
+for port in 8081 3001; do
+ lsof -ti "TCP:${port}" -sTCP:LISTEN 2>/dev/null | xargs kill -9 2>/dev/null || true
+done
+sleep 1
+
+echo "==> 安装 LaunchAgent…"
+install_agent "cn.tkmind.memind-portal" "${PORTAL_PLIST}"
+sleep 2
+install_agent "cn.tkmind.plaza" "${PLAZA_PLIST}"
+install_agent "cn.tkmind.g2-lb" "${G2_LB_PLIST}"
+
+if [[ -f "${CF_PLIST}" ]]; then
+ install_agent "com.cloudflare.cloudflared" "${CF_PLIST}"
+else
+ echo "⚠ 未找到 ${CF_PLIST},请确认 cloudflared LaunchAgent 已配置" >&2
+fi
+
+echo "==> 等待服务就绪…"
+for _ in $(seq 1 60); do
+ portal_ok=0
+ plaza_ok=0
+ g2_ok=0
+ curl -sf "http://127.0.0.1:8081/api/status" >/dev/null 2>&1 && portal_ok=1
+ curl -sf "http://127.0.0.1:3001/plaza" >/dev/null 2>&1 && plaza_ok=1
+ curl -sf "http://127.0.0.1:8090/api/status" >/dev/null 2>&1 && g2_ok=1
+ if [[ "${portal_ok}" -eq 1 && "${plaza_ok}" -eq 1 && "${g2_ok}" -eq 1 ]]; then
+ break
+ fi
+ sleep 2
+done
+
+echo ""
+echo "=== 生产服务状态 ==="
+launchctl list | grep -E "cn.tkmind\.(memind-portal|plaza|g2-lb)|com.cloudflare.cloudflared" || true
+curl -s -o /dev/null -w "Portal :8081 → %{http_code}\n" "http://127.0.0.1:8081/api/status" || true
+curl -s -o /dev/null -w "g2 LB :8090 → %{http_code}\n" "http://127.0.0.1:8090/api/status" || true
+curl -s -o /dev/null -w "Plaza :3001 → %{http_code}\n" "http://127.0.0.1:3001/plaza" || true
+curl -s -o /dev/null -w "外网 g2 → %{http_code}\n" "https://g2.tkmind.cn/api/status" || true
+curl -s -o /dev/null -w "外网 plaza → %{http_code}\n" "https://plaza.tkmind.cn/plaza" || true
+
+echo ""
+echo "✅ 生产 LaunchAgent 已安装(登录自启 + KeepAlive)"
+echo " Portal cn.tkmind.memind-portal → ~/Library/Logs/memind-portal.log"
+echo " Plaza cn.tkmind.plaza → ~/Library/Logs/plaza-prod.log"
+echo " g2 LB cn.tkmind.g2-lb → ~/Library/Logs/g2-lb.log"
+echo " 隧道 com.cloudflare.cloudflared"
+echo ""
+echo "管理命令:"
+echo " launchctl kickstart -k ${GUI}/cn.tkmind.memind-portal"
+echo " launchctl kickstart -k ${GUI}/cn.tkmind.plaza"
+echo " tail -f ~/Library/Logs/memind-portal.log ~/Library/Logs/plaza-prod.log"
diff --git a/scripts/plaza-demo-posts.mjs b/scripts/plaza-demo-posts.mjs
new file mode 100644
index 0000000..c018bdd
--- /dev/null
+++ b/scripts/plaza-demo-posts.mjs
@@ -0,0 +1,578 @@
+/** 各分类补充演示帖(每类 7 条,与 seed-plaza-demo.mjs 原有条目合并使用) */
+export const EXTRA_DEMO_POSTS = [
+ // work-report +7
+ {
+ category: 'work-report',
+ title: '跨部门项目协同周报 · 模板与实践',
+ summary: '研发、产品、运营三方对齐模板,附 RACI 矩阵与风险升级机制。',
+ tags: ['协同', '周报', '模板'],
+ likes: 72,
+ comments: 14,
+ views: 2100,
+ author: 'john',
+ },
+ {
+ category: 'work-report',
+ title: '销售团队 Q3 业绩复盘与目标拆解',
+ summary: '区域完成率、客单价变化与 Q4 冲刺计划,含 Top10 客户分析。',
+ tags: ['销售', '复盘', 'Q3'],
+ likes: 54,
+ comments: 9,
+ views: 1680,
+ author: 'admin',
+ },
+ {
+ category: 'work-report',
+ title: '远程办公效率调研 · 2025 秋季',
+ summary: '200 人样本:专注时长、会议占比与工具偏好,给管理者的 5 条建议。',
+ tags: ['远程', '效率', '调研'],
+ likes: 98,
+ comments: 22,
+ views: 3400,
+ author: 'john',
+ },
+ {
+ category: 'work-report',
+ title: '产品经理年度 OKR 回顾与反思',
+ summary: '目标达成率 78%,未达成项根因与下年度优先级调整思路。',
+ tags: ['OKR', '产品', '复盘'],
+ likes: 115,
+ comments: 19,
+ views: 2800,
+ author: 'john',
+ },
+ {
+ category: 'work-report',
+ title: 'B2B SaaS 客户成功月报 · 9 月',
+ summary: 'NRR 112%、健康度红黄绿分布与 3 个流失预警案例复盘。',
+ tags: ['SaaS', '客户成功'],
+ likes: 67,
+ comments: 11,
+ views: 1950,
+ author: 'admin',
+ },
+ {
+ category: 'work-report',
+ title: '研发团队 Sprint 回顾 · 第 42 期',
+ summary: 'Velocity 趋势、阻塞项统计与下一 Sprint 容量规划建议。',
+ tags: ['敏捷', 'Sprint'],
+ likes: 43,
+ comments: 8,
+ views: 1200,
+ author: 'john',
+ },
+ {
+ category: 'work-report',
+ title: '品牌升级方案执行进度追踪',
+ summary: 'VI 落地、渠道物料与内部培训三节进度,附里程碑甘特图。',
+ tags: ['品牌', '项目管理'],
+ likes: 61,
+ comments: 10,
+ views: 1750,
+ author: 'admin',
+ },
+
+ // study-notes +7
+ {
+ category: 'study-notes',
+ title: 'TypeScript 类型体操 10 道经典题解析',
+ summary: 'Conditional、Mapped、Template Literal 从入门到面试高频。',
+ tags: ['TypeScript', '类型'],
+ likes: 186,
+ comments: 34,
+ views: 6200,
+ author: 'john',
+ },
+ {
+ category: 'study-notes',
+ title: '系统设计面试:分布式缓存怎么答',
+ summary: 'Cache-Aside、一致性、热点 Key 与穿透防护的标准答题框架。',
+ tags: ['系统设计', '面试'],
+ likes: 224,
+ comments: 41,
+ views: 7800,
+ author: 'john',
+ },
+ {
+ category: 'study-notes',
+ title: '线性代数直觉笔记 · 特征值与 PCA',
+ summary: '不用公式堆:用图像旋转理解特征分解与降维直觉。',
+ tags: ['数学', 'PCA'],
+ likes: 142,
+ comments: 28,
+ views: 4100,
+ author: 'admin',
+ },
+ {
+ category: 'study-notes',
+ title: 'Docker 容器化从零到部署',
+ summary: 'Dockerfile 最佳实践、Compose 多服务与生产环境注意事项。',
+ tags: ['Docker', 'DevOps'],
+ likes: 167,
+ comments: 31,
+ views: 5300,
+ author: 'john',
+ },
+ {
+ category: 'study-notes',
+ title: 'Pandas 数据分析速查手册',
+ summary: '读写、分组、透视、合并与可视化 30 个常用片段,可复制即用。',
+ tags: ['Python', 'Pandas'],
+ likes: 198,
+ comments: 36,
+ views: 6500,
+ author: 'john',
+ },
+ {
+ category: 'study-notes',
+ title: 'Git 团队分支规范与 Code Review',
+ summary: 'GitFlow vs Trunk、PR 模板与 Review 清单,适合 10 人以内团队。',
+ tags: ['Git', '规范'],
+ likes: 89,
+ comments: 17,
+ views: 2600,
+ author: 'admin',
+ },
+ {
+ category: 'study-notes',
+ title: '技术文档英语阅读训练计划',
+ summary: '21 天计划:RFC 段落拆解、术语表与每日 15 分钟精读练习。',
+ tags: ['英语', '文档'],
+ likes: 76,
+ comments: 15,
+ views: 2200,
+ author: 'john',
+ },
+
+ // creative +7
+ {
+ category: 'creative',
+ title: '国潮茶饮品牌 VIS 视觉系统',
+ summary: 'Logo 变体、杯身纹样与门店导视的统一设计语言。',
+ tags: ['品牌', 'VIS'],
+ likes: 134,
+ comments: 18,
+ views: 3800,
+ author: 'john',
+ },
+ {
+ category: 'creative',
+ title: '科幻短片《归途》分镜脚本',
+ summary: '12 场关键镜头描述、色调参考与音效设计备忘。',
+ tags: ['分镜', '科幻'],
+ likes: 92,
+ comments: 14,
+ views: 2400,
+ author: 'admin',
+ },
+ {
+ category: 'creative',
+ title: '手作 ceramics 工作室品牌摄影',
+ summary: '自然光布光、肌理特写与产品叙事的三组拍摄方案。',
+ tags: ['摄影', '陶瓷'],
+ likes: 108,
+ comments: 12,
+ views: 2900,
+ author: 'john',
+ },
+ {
+ category: 'creative',
+ title: '城市夜景长曝光摄影系列',
+ summary: '车轨、霓虹反射与 30s 曝光参数对照,附后期调色流程。',
+ tags: ['摄影', '夜景'],
+ likes: 156,
+ comments: 24,
+ views: 4500,
+ author: 'john',
+ },
+ {
+ category: 'creative',
+ title: '极简 UI 图标集 · 200 Icons',
+ summary: '2px 线宽、24×24 网格的线性图标库,含 Figma 组件规范。',
+ tags: ['UI', '图标'],
+ likes: 178,
+ comments: 29,
+ views: 5100,
+ author: 'admin',
+ },
+ {
+ category: 'creative',
+ title: '水墨风 AI 插画实验合集',
+ summary: 'Prompt 控制笔触、留白与构图的 8 组可复现参数。',
+ tags: ['AI绘画', '水墨'],
+ likes: 143,
+ comments: 21,
+ views: 3900,
+ author: 'john',
+ },
+ {
+ category: 'creative',
+ title: '复古胶片人像调色预设包',
+ summary: 'Kodak / Fuji 模拟 LUT + Lightroom 预设,适合户外人像。',
+ tags: ['调色', '胶片'],
+ likes: 87,
+ comments: 13,
+ views: 2100,
+ author: 'admin',
+ },
+
+ // business +7
+ {
+ category: 'business',
+ title: '精酿啤酒馆开业 30 天数据复盘',
+ summary: '坪效、复购率与爆款酒款分析,第二个月优化动作清单。',
+ tags: ['精酿', '复盘'],
+ likes: 82,
+ comments: 12,
+ views: 2300,
+ author: 'john',
+ },
+ {
+ category: 'business',
+ title: '日式美甲沙龙会员体系设计',
+ summary: '储值梯度、生日礼遇与转介绍奖励的完整规则说明。',
+ tags: ['美甲', '会员'],
+ likes: 64,
+ comments: 9,
+ views: 1700,
+ author: 'admin',
+ },
+ {
+ category: 'business',
+ title: '社区健身房私教转化漏斗优化',
+ summary: '体验课到年卡的三步话术与异议处理,转化率 +18pp。',
+ tags: ['健身', '转化'],
+ likes: 71,
+ comments: 11,
+ views: 2000,
+ author: 'john',
+ },
+ {
+ category: 'business',
+ title: '独立书店文创周边选品策略',
+ summary: '本地作者、手账与联名款组合,提升客单而不损品牌调性。',
+ tags: ['书店', '文创'],
+ likes: 93,
+ comments: 16,
+ views: 2600,
+ author: 'john',
+ },
+ {
+ category: 'business',
+ title: '宠物洗护上门服务 SOP 手册',
+ summary: '预约、消毒、服务流程与客户沟通标准,可直接培训使用。',
+ tags: ['宠物', 'SOP'],
+ likes: 55,
+ comments: 8,
+ views: 1500,
+ author: 'admin',
+ },
+ {
+ category: 'business',
+ title: '轻食沙拉店午餐高峰运营方案',
+ summary: '备料节奏、动线设计与套餐组合,12:00–13:30 翻台率提升 35%。',
+ tags: ['轻食', '运营'],
+ likes: 68,
+ comments: 10,
+ views: 1850,
+ author: 'john',
+ },
+ {
+ category: 'business',
+ title: '民宿旺季定价与房源包装指南',
+ summary: '动态定价区间、首图拍摄与评价回复模板,适合 5 间以内体量。',
+ tags: ['民宿', '定价'],
+ likes: 79,
+ comments: 14,
+ views: 2200,
+ author: 'admin',
+ },
+
+ // travel +7
+ {
+ category: 'travel',
+ title: '冰岛环岛 10 日自驾攻略',
+ summary: 'Ring Road 路线、天气应对与必看瀑布/冰川清单,含预算表。',
+ tags: ['冰岛', '自驾'],
+ likes: 267,
+ comments: 48,
+ views: 9200,
+ author: 'john',
+ },
+ {
+ category: 'travel',
+ title: '清迈慢生活 4 日深度游',
+ summary: '古城寺庙、宁曼路咖啡与周日夜市,避开网红排队点。',
+ tags: ['泰国', '清迈'],
+ likes: 198,
+ comments: 35,
+ views: 6800,
+ author: 'admin',
+ },
+ {
+ category: 'travel',
+ title: '西藏林芝桃花季摄影路线',
+ summary: '3 月下旬最佳机位、海拔适应与装备清单,附样片参数。',
+ tags: ['西藏', '摄影'],
+ likes: 223,
+ comments: 39,
+ views: 7500,
+ author: 'john',
+ },
+ {
+ category: 'travel',
+ title: '新加坡亲子 3 日轻松游',
+ summary: '动物园、滨海湾与本地美食,推车友好路线与交通卡攻略。',
+ tags: ['新加坡', '亲子'],
+ likes: 156,
+ comments: 27,
+ views: 4900,
+ author: 'john',
+ },
+ {
+ category: 'travel',
+ title: '云南大理丽江 6 日省钱攻略',
+ summary: '淡季住宿、拼车与必去小众点,人均 ¥2800 舒适玩法。',
+ tags: ['云南', '省钱'],
+ likes: 184,
+ comments: 33,
+ views: 6100,
+ author: 'admin',
+ },
+ {
+ category: 'travel',
+ title: '釜山海鲜美食 48 小时',
+ summary: '札嘎其市场、甘川文化村与海边 Cafe,周末快闪指南。',
+ tags: ['韩国', '美食'],
+ likes: 142,
+ comments: 24,
+ views: 4300,
+ author: 'john',
+ },
+ {
+ category: 'travel',
+ title: '阿尔卑斯徒步 · 少女峰周边',
+ summary: 'Easy / Moderate 两条路线、齿轮火车时刻与登山装备建议。',
+ tags: ['瑞士', '徒步'],
+ likes: 201,
+ comments: 36,
+ views: 6700,
+ author: 'admin',
+ },
+
+ // data-analysis +7
+ {
+ category: 'data-analysis',
+ title: '用户行为漏斗分析实战案例',
+ summary: '注册→激活→付费五步漏斗,定位最大流失环节与优化假设。',
+ tags: ['漏斗', '行为分析'],
+ likes: 134,
+ comments: 23,
+ views: 3600,
+ author: 'john',
+ },
+ {
+ category: 'data-analysis',
+ title: 'A/B 测试显著性检验指南',
+ summary: '样本量计算、p-value 误读与 Bayesian 备选方案对照。',
+ tags: ['AB测试', '统计'],
+ likes: 98,
+ comments: 17,
+ views: 2800,
+ author: 'admin',
+ },
+ {
+ category: 'data-analysis',
+ title: '销售预测模型:ARIMA vs Prophet',
+ summary: '同一数据集两种模型对比,含 Python 代码与误差指标。',
+ tags: ['预测', '时序'],
+ likes: 87,
+ comments: 15,
+ views: 2500,
+ author: 'john',
+ },
+ {
+ category: 'data-analysis',
+ title: '文本情感分析:评论洞察报告',
+ summary: '电商评论 NLP pipeline,主题聚类与负面词云解读。',
+ tags: ['NLP', '情感分析'],
+ likes: 76,
+ comments: 13,
+ views: 2200,
+ author: 'john',
+ },
+ {
+ category: 'data-analysis',
+ title: '实时大屏指标设计与 SQL 模板',
+ summary: 'GMV、DAU、转化率三类大屏的指标定义与刷新策略。',
+ tags: ['大屏', 'SQL'],
+ likes: 112,
+ comments: 19,
+ views: 3100,
+ author: 'admin',
+ },
+ {
+ category: 'data-analysis',
+ title: '营销归因:Shapley vs Markov',
+ summary: '多触点归因两种方法适用场景与结果差异案例。',
+ tags: ['归因', '营销'],
+ likes: 69,
+ comments: 12,
+ views: 1900,
+ author: 'john',
+ },
+ {
+ category: 'data-analysis',
+ title: '数据质量监控规则库搭建',
+ summary: '空值、重复、时效性三类规则 + 告警分级与值班流程。',
+ tags: ['数据质量', '监控'],
+ likes: 83,
+ comments: 14,
+ views: 2400,
+ author: 'admin',
+ },
+
+ // lifestyle +7
+ {
+ category: 'lifestyle',
+ title: '厨房收纳改造:小空间大利用',
+ summary: '垂直收纳、标签系统与动线优化,周末 4 小时完成。',
+ tags: ['收纳', '厨房'],
+ likes: 121,
+ comments: 19,
+ views: 3300,
+ author: 'john',
+ },
+ {
+ category: 'lifestyle',
+ title: '30 天早睡挑战执行记录',
+ summary: '23:30 前入睡、屏幕宵禁与睡眠质量变化曲线。',
+ tags: ['睡眠', '习惯'],
+ likes: 88,
+ comments: 16,
+ views: 2400,
+ author: 'admin',
+ },
+ {
+ category: 'lifestyle',
+ title: '周末徒步装备清单与路线',
+ summary: '一日轻徒步:鞋服、补给与安全装备,附 3 条城市周边路线。',
+ tags: ['徒步', '户外'],
+ likes: 102,
+ comments: 18,
+ views: 2900,
+ author: 'john',
+ },
+ {
+ category: 'lifestyle',
+ title: '阳台花园从零搭建指南',
+ summary: '光照评估、盆器选择与前 10 种好养植物推荐。',
+ tags: ['园艺', '阳台'],
+ likes: 95,
+ comments: 17,
+ views: 2700,
+ author: 'john',
+ },
+ {
+ category: 'lifestyle',
+ title: '断舍离:衣柜 100 件到 40 件',
+ summary: '胶囊衣橱原则、捐赠流程与 30 天不再购衣挑战记录。',
+ tags: ['断舍离', '极简'],
+ likes: 113,
+ comments: 22,
+ views: 3200,
+ author: 'admin',
+ },
+ {
+ category: 'lifestyle',
+ title: '手冲咖啡入门 7 日练习',
+ summary: '研磨度、水温与注水节奏对照表,从酸到平衡的味觉笔记。',
+ tags: ['咖啡', '手冲'],
+ likes: 79,
+ comments: 14,
+ views: 2100,
+ author: 'john',
+ },
+ {
+ category: 'lifestyle',
+ title: '周末窑烤面包新手日记',
+ summary: '天然酵母起种、折叠节奏与第一次成功欧包的全记录。',
+ tags: ['烘焙', '面包'],
+ likes: 91,
+ comments: 16,
+ views: 2500,
+ author: 'admin',
+ },
+
+ // other +7
+ {
+ category: 'other',
+ title: '自由职业者财务规划入门',
+ summary: '现金流缓冲、税筹与保险配置,适合刚转自由职业的前 12 个月。',
+ tags: ['自由职业', '财务'],
+ likes: 68,
+ comments: 12,
+ views: 1900,
+ author: 'john',
+ },
+ {
+ category: 'other',
+ title: '播客频道从 0 到 1000 订阅',
+ summary: '选题、剪辑流程与跨平台分发,第 8 集突破 1000 订阅复盘。',
+ tags: ['播客', '创作'],
+ likes: 74,
+ comments: 13,
+ views: 2100,
+ author: 'admin',
+ },
+ {
+ category: 'other',
+ title: '城市观鸟入门:常见 20 种鸟',
+ summary: '公园/湿地观测点、识别特征与入门望远镜选购建议。',
+ tags: ['观鸟', '自然'],
+ likes: 52,
+ comments: 9,
+ views: 1400,
+ author: 'john',
+ },
+ {
+ category: 'other',
+ title: '二手交易向可持续生活转型',
+ summary: '闲鱼/多抓鱼使用心得与「买前问三遍」决策清单。',
+ tags: ['可持续', '二手'],
+ likes: 61,
+ comments: 11,
+ views: 1700,
+ author: 'john',
+ },
+ {
+ category: 'other',
+ title: '个人知识库搭建方法论',
+ summary: 'PARA + 渐进式总结,Obsidian/Notion 选型与迁移经验。',
+ tags: ['知识管理', 'PKM'],
+ likes: 129,
+ comments: 24,
+ views: 3500,
+ author: 'admin',
+ },
+ {
+ category: 'other',
+ title: 'VR 健身 60 天体验报告',
+ summary: 'Beat Saber + Supernatural 组合,体重与体态变化数据。',
+ tags: ['VR', '健身'],
+ likes: 84,
+ comments: 15,
+ views: 2300,
+ author: 'john',
+ },
+ {
+ category: 'other',
+ title: '社群运营:从群聊到价值观',
+ summary: '冷启动、活跃机制与冲突处理,200 人社群 6 个月实践。',
+ tags: ['社群', '运营'],
+ likes: 97,
+ comments: 18,
+ views: 2700,
+ author: 'admin',
+ },
+];
diff --git a/scripts/plaza-rich-content.mjs b/scripts/plaza-rich-content.mjs
index 897096e..1628042 100644
--- a/scripts/plaza-rich-content.mjs
+++ b/scripts/plaza-rich-content.mjs
@@ -845,9 +845,21 @@ export const ENRICHMENTS = {
},
};
-export function getEnrichment(postId, fallbackTitle) {
+export function getEnrichment(postId, fallbackTitle, meta = {}) {
const item = ENRICHMENTS[postId];
if (item) return item;
+
+ const categorySlug = meta.categorySlug ?? meta.category_slug ?? '';
+ const summary = meta.summary ?? '';
+ const tags = meta.tags ?? [];
+
+ if (categorySlug) {
+ return {
+ summary: summary || `${fallbackTitle} — MindSpace 精选内容`,
+ html: buildCategoryPage(categorySlug, fallbackTitle, summary || fallbackTitle, tags),
+ };
+ }
+
return {
summary: `${fallbackTitle} — MindSpace 精选`,
html: buildJohnPage({
@@ -860,3 +872,215 @@ export function getEnrichment(postId, fallbackTitle) {
}),
};
}
+
+const CATEGORY_THEMES = {
+ 'work-report': {
+ tag: '职场报告',
+ tagEmoji: '📊',
+ accent: '#2d7d6a',
+ accent2: '#4ecdc4',
+ images: [IMG.growth, IMG.ai, IMG.supply, IMG.guide],
+ },
+ 'study-notes': {
+ tag: '学习笔记',
+ tagEmoji: '📚',
+ accent: '#f97316',
+ accent2: '#fb923c',
+ images: [IMG.rust, IMG.llm, IMG.guide, IMG.opensource],
+ },
+ creative: {
+ tag: '创意作品',
+ tagEmoji: '🎨',
+ accent: '#c084fc',
+ accent2: '#a855f7',
+ images: [IMG.cyber, IMG.music, IMG.guide],
+ },
+ business: {
+ tag: '门店展示',
+ tagEmoji: '🏪',
+ accent: '#d97706',
+ accent2: '#fbbf24',
+ images: [IMG.coffee, IMG.pilates, IMG.guide],
+ },
+ travel: {
+ tag: '旅行攻略',
+ tagEmoji: '✈️',
+ accent: '#2d7d6a',
+ accent2: '#4ecdc4',
+ images: [IMG.malaysia, IMG.kyoto, IMG.travel, IMG.guide],
+ },
+ 'data-analysis': {
+ tag: '数据分析',
+ tagEmoji: '📈',
+ accent: '#38bdf8',
+ accent2: '#0ea5e9',
+ images: [IMG.data, IMG.growth, IMG.guide],
+ },
+ lifestyle: {
+ tag: '生活记录',
+ tagEmoji: '🌿',
+ accent: '#2d7d6a',
+ accent2: '#4ecdc4',
+ images: [IMG.desk, IMG.write, IMG.guide],
+ },
+ other: {
+ tag: '其他',
+ tagEmoji: '💡',
+ accent: '#34d399',
+ accent2: '#10b981',
+ images: [IMG.opensource, IMG.wish, IMG.guide],
+ },
+};
+
+function hashTitle(title) {
+ let hash = 0;
+ for (let i = 0; i < title.length; i += 1) {
+ hash = (hash * 31 + title.charCodeAt(i)) >>> 0;
+ }
+ return hash;
+}
+
+function buildHeroTitleHtml(title) {
+ const trimmed = String(title).trim();
+ if (trimmed.length <= 6) {
+ return `${esc(trimmed)}`;
+ }
+ const splitAt = Math.min(8, Math.ceil(trimmed.length * 0.42));
+ const head = trimmed.slice(0, splitAt);
+ const tail = trimmed.slice(splitAt);
+ return `${esc(head)}${esc(tail)}`;
+}
+
+function buildCategorySections(categorySlug, title, summary, tags) {
+ const tagList =
+ tags.length > 0
+ ? `${tags.map((tag) => `- ${esc(tag)}
`).join('')}
`
+ : '';
+
+ const metricsPool = {
+ 'work-report': [
+ { value: '92%', label: '目标完成', note: '本期 OKR' },
+ { value: '3', label: '关键实验', note: '已验证假设' },
+ { value: '↓18%', label: '阻塞时长', note: '较上期' },
+ ],
+ 'study-notes': [
+ { value: '15min', label: '阅读时长', note: '核心章节' },
+ { value: '8', label: '要点', note: '可复用片段' },
+ { value: '3', label: '练习题', note: '附参考答案' },
+ ],
+ creative: [
+ { value: '4K', label: '输出规格', note: '主视觉' },
+ { value: '3', label: '延展场景', note: '社媒 / 印刷' },
+ { value: '12', label: '配色', note: '品牌体系' },
+ ],
+ business: [
+ { value: '+24%', label: '转化提升', note: '活动期' },
+ { value: '4.8', label: '评分', note: '5 分制' },
+ { value: '86%', label: '复购意向', note: '问卷样本' },
+ ],
+ travel: [
+ { value: '5-10', label: '行程天数', note: '弹性安排' },
+ { value: '¥680', label: '日均预算', note: '舒适型参考' },
+ { value: '12+', label: '精选点位', note: '含美食' },
+ ],
+ 'data-analysis': [
+ { value: '3层', label: '下钻维度', note: '渠道/品类/客单' },
+ { value: '99%', label: '数据覆盖', note: '核心指标' },
+ { value: 'T+1', label: '刷新频率', note: '看板更新' },
+ ],
+ lifestyle: [
+ { value: '21天', label: '挑战周期', note: '习惯养成' },
+ { value: '12', label: '实践要点', note: '可执行清单' },
+ { value: '↓32%', label: '焦虑自评', note: '挑战前后' },
+ ],
+ other: [
+ { value: '6', label: '关键动作', note: '冷启动路径' },
+ { value: '1000+', label: '读者', note: '累计触达' },
+ { value: '4.6', label: '满意度', note: '反馈均值' },
+ ],
+ };
+
+ const cardsPool = {
+ 'work-report': [
+ { title: '背景与目标', desc: '明确本期业务目标与衡量口径,对齐 stakeholder 预期。', tag: '目标' },
+ { title: '执行进展', desc: '按里程碑拆解完成度,标注风险与依赖项。', tag: '进展' },
+ { title: '数据洞察', desc: '用 3 个核心指标讲清「发生了什么、为什么」。', tag: '洞察' },
+ { title: '下一步', desc: '列出 2–3 个可验证的下一周期动作。', tag: '行动' },
+ ],
+ 'study-notes': [
+ { title: '核心概念', desc: '用一句话 + 生活类比建立直觉,再展开细节。', tag: '概念' },
+ { title: '常见误区', desc: '面试与实践中高频踩坑点对照表。', tag: '避坑' },
+ { title: '代码/例题', desc: '可复制片段,标注输入输出与边界条件。', tag: '实践' },
+ ],
+ travel: [
+ { title: 'Day 1–2', desc: '抵达与市区精华,控制步行强度。', tag: '行程' },
+ { title: 'Day 3–4', desc: '深度体验与小众点位,预留弹性时间。', tag: '深度' },
+ { title: '美食地图', desc: '早/午/晚各 2 家备选,标注人均与排队时段。', tag: '美食' },
+ ],
+ creative: [
+ { title: '概念阐述', desc: '设计意图、受众与使用场景说明。', tag: '概念' },
+ { title: '视觉规范', desc: '字体、配色、网格与留白规则。', tag: '规范' },
+ { title: '延展应用', desc: '海报、社媒、印刷等多场景适配方案。', tag: '延展' },
+ ],
+ };
+
+ const sections = [
+ {
+ type: 'metrics',
+ title: '核心亮点',
+ hint: title,
+ items: metricsPool[categorySlug] ?? metricsPool.other,
+ },
+ ];
+
+ if (cardsPool[categorySlug]) {
+ sections.push({
+ type: 'cards',
+ title: '内容结构',
+ hint: summary.slice(0, 60),
+ items: cardsPool[categorySlug],
+ });
+ }
+
+ sections.push({
+ type: 'text',
+ title: '详细说明',
+ html: `${esc(summary)}
${tagList}`,
+ });
+
+ sections.push({
+ type: 'quote',
+ html: `「${esc(title)}」—— 由 MindSpace 创作并公开发布到 TKMind 广场。`,
+ });
+
+ return sections;
+}
+
+export function buildCategoryPage(categorySlug, title, summary, tags = []) {
+ const theme = CATEGORY_THEMES[categorySlug] ?? CATEGORY_THEMES.other;
+ const heroImage = theme.images[hashTitle(title) % theme.images.length];
+ const bgDark = categorySlug === 'creative' ? '#0f0a1a' : '#0a1628';
+
+ return buildJohnPage({
+ title,
+ description: summary,
+ tag: theme.tag,
+ tagEmoji: theme.tagEmoji,
+ accent: theme.accent,
+ accent2: theme.accent2,
+ bgDark,
+ heroImage,
+ heroTitleHtml: buildHeroTitleHtml(title),
+ heroSubtitle: summary,
+ heroBtn: '开始阅读',
+ sections: buildCategorySections(categorySlug, title, summary, tags),
+ footer: 'TKMind Plaza · MindSpace 公开发布',
+ });
+}
+
+// legacy export — post_id keyed enrichments kept for existing seeded posts
+export function getEnrichmentLegacy(postId, fallbackTitle) {
+ const item = ENRICHMENTS[postId];
+ if (item) return item;
+ return getEnrichment(postId, fallbackTitle);
+}
diff --git a/scripts/run-memind-portal-prod.sh b/scripts/run-memind-portal-prod.sh
new file mode 100755
index 0000000..ec9cfee
--- /dev/null
+++ b/scripts/run-memind-portal-prod.sh
@@ -0,0 +1,23 @@
+#!/usr/bin/env bash
+# Memind Portal 生产进程(g2.tkmind.cn / Plaza API 共用 :8081)
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+if [[ -f "${ROOT}/.env" ]]; then
+ set -a
+ # shellcheck disable=SC1091
+ source "${ROOT}/.env"
+ set +a
+fi
+
+export NODE_ENV=production
+export H5_PORT="${H5_PORT:-8081}"
+
+NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@22/bin/node}"
+if [[ ! -x "${NODE_BIN}" ]]; then
+ NODE_BIN="$(command -v node)"
+fi
+
+exec "${NODE_BIN}" server.mjs
diff --git a/scripts/run-plaza-prod.sh b/scripts/run-plaza-prod.sh
new file mode 100755
index 0000000..5e955f4
--- /dev/null
+++ b/scripts/run-plaza-prod.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# Plaza Next.js 生产进程(plaza.tkmind.cn → Cloudflare Tunnel :3001)
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+cd "$ROOT"
+
+if [[ -f "${ROOT}/.env" ]]; then
+ set -a
+ # shellcheck disable=SC1091
+ source "${ROOT}/.env"
+ set +a
+fi
+
+export NODE_ENV=production
+PORTAL_PORT="${H5_PORT:-8081}"
+PLAZA_PORT="${PLAZA_PORT:-3001}"
+
+NODE_BIN="${NODE_BIN:-/opt/homebrew/opt/node@22/bin/node}"
+if [[ ! -x "${NODE_BIN}" ]]; then
+ NODE_BIN="$(command -v node)"
+fi
+
+echo "==> 等待 Portal :${PORTAL_PORT}…"
+for _ in $(seq 1 120); do
+ if curl -sf "http://127.0.0.1:${PORTAL_PORT}/auth/status" >/dev/null 2>&1; then
+ break
+ fi
+ sleep 1
+done
+
+if ! curl -sf "http://127.0.0.1:${PORTAL_PORT}/auth/status" >/dev/null 2>&1; then
+ echo "Portal 未就绪 (:${PORTAL_PORT}),Plaza 退出" >&2
+ exit 1
+fi
+
+exec "${NODE_BIN}" scripts/start-plaza-prod.mjs -- --skip-build --no-portal
diff --git a/scripts/seed-plaza-covers.mjs b/scripts/seed-plaza-covers.mjs
index 09591e8..9ae90d7 100644
--- a/scripts/seed-plaza-covers.mjs
+++ b/scripts/seed-plaza-covers.mjs
@@ -23,11 +23,96 @@ function loadEnvFile(filePath) {
loadEnvFile(path.join(root, '../../.env.local'));
loadEnvFile(path.join(root, '.env'));
+const COVER_QUALITY = 'auto=format&fit=crop&w=1920&h=1200&q=90';
+
+const CATEGORY_IMAGE_POOLS = {
+ 'work-report': [
+ `https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1460925895917-afdab827c52f?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1551288049-bebda4e38f71?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1522071820081-009f0129c71c?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1486312338219-ce68d2c6f44d?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1504384308090-c894fdcc538d?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1542744173-8e7e53415bb0?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1600880292203-757bb62b4baf?${COVER_QUALITY}`,
+ ],
+ 'study-notes': [
+ `https://images.unsplash.com/photo-1456513080510-7bf3a84b82f8?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1516116216624-53e697fedbea?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1515879214367-846362d3526?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1676299085922-0a9a504459f8?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1516321318423-f06f85e504b3?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1434030216411-0b793f4b4173?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1503676260728-1c00da094a0b?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1524995998267-8169c4e50d05?${COVER_QUALITY}`,
+ ],
+ creative: [
+ `https://images.unsplash.com/photo-1513364776144-60967b0f800f?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1514565131-fce0801e5785?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1511379938544-be09687a4d4b?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1541961017774-22349e4a1262?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1558591710-4b4a1ae0f04d?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1561998338-13ad7886534d?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1579783902614-a3fb3927b6a5?${COVER_QUALITY}`,
+ ],
+ business: [
+ `https://images.unsplash.com/photo-1441986300917-64674bd600d8?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1518611012118-696072aa579a?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1554118811-1e0d58224f24?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1447933601403-0c6688de566e?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1556742049-0cfed4f6a45d?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1560472354-b33ff0c44a43?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1414235077428-338989a2e8c0?${COVER_QUALITY}`,
+ ],
+ travel: [
+ `https://images.unsplash.com/photo-1506905925346-21bda4d32df4?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1596422846544-75c675fcd5d0?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1493976040374-85c8e577f47e?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1488646953014-85cb44e25828?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1476514525535-07fb3b4fcc5f?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1501785888041-af3ef285b470?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1469854523086-cc02fe5d8800?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1528127269322-539801943592?${COVER_QUALITY}`,
+ ],
+ 'data-analysis': [
+ `https://images.unsplash.com/photo-1551288049-bebda4e38f71?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1543286386-713bdd548da4?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1504868584819-f8e8b4b6d7e3?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1555949963-aa79dcee981c?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1518186285589-2f7649de83e0?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1573164713714-d95e436ab8d6?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1639322537504-6427a16b0a28?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1591696205602-2f950c417cb9?${COVER_QUALITY}`,
+ ],
+ lifestyle: [
+ `https://images.unsplash.com/photo-1493663284031-b7e3aefcae8e?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1497215842964-222b430dc094?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1455390582260-0446deae2f3f?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1416879595882-3373a0480b5b?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1502672260266-1c1ef2d93688?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1498805903192-2f734adc632a?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1517487881594-2787fef5ebf7?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1556911220-e15b29be8c8f?${COVER_QUALITY}`,
+ ],
+ other: [
+ `https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1484480974693-6ca0a7823cce?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1516321318423-f06f85e504b3?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1529156069898-49953e39b3ac?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1517245386807-bb43f82c33c4?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1522202176988-66273c2fd55f?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1515378792601-1a587fd37b2d?${COVER_QUALITY}`,
+ `https://images.unsplash.com/photo-1523240795612-9a054b0db644?${COVER_QUALITY}`,
+ ],
+};
+
const COVER_SPECS = [
{
match: 'Q2 产品增长复盘',
file: 'work-report-growth.jpg',
- url: 'https://images.unsplash.com/photo-1551288049-bebda4e38f71?auto=format&fit=crop&w=1280&h=800&q=85',
+ url: `https://images.unsplash.com/photo-1551288049-bebda4e38f71?${COVER_QUALITY}`,
},
{
match: 'AI 客服落地',
@@ -137,21 +222,36 @@ const CATEGORY_FALLBACKS = {
other: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?auto=format&fit=crop&w=1280&h=800&q=85',
};
-function slugify(value) {
- return String(value)
+function hashTitle(title) {
+ let hash = 0;
+ for (let i = 0; i < title.length; i += 1) {
+ hash = (hash * 31 + title.charCodeAt(i)) >>> 0;
+ }
+ return hash;
+}
+
+function coverFileName(categorySlug, title) {
+ const hash = hashTitle(title).toString(36);
+ const asciiSlug = String(title)
+ .normalize('NFKD')
+ .replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
- .replace(/[^\p{L}\p{N}]+/gu, '-')
+ .replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
- .slice(0, 48);
+ .slice(0, 32);
+ const suffix = asciiSlug ? `${asciiSlug}-${hash}` : hash;
+ return `${categorySlug}-${suffix}.jpg`;
}
function resolveSpec(title, categorySlug) {
const spec = COVER_SPECS.find((item) => title.includes(item.match));
if (spec) return spec;
+ const pool = CATEGORY_IMAGE_POOLS[categorySlug] ?? CATEGORY_IMAGE_POOLS.other;
+ const url = pool[hashTitle(title) % pool.length];
return {
match: title,
- file: `${categorySlug}-${slugify(title)}.jpg`,
- url: CATEGORY_FALLBACKS[categorySlug] ?? CATEGORY_FALLBACKS.other,
+ file: coverFileName(categorySlug, title),
+ url,
};
}
@@ -208,7 +308,7 @@ const [posts] = await pool.query(
let updated = 0;
for (const post of posts) {
const spec = resolveSpec(post.title, post.category_slug);
- await downloadCover(spec, CATEGORY_FALLBACKS[post.category_slug]);
+ await downloadCover(spec, (CATEGORY_IMAGE_POOLS[post.category_slug] ?? CATEGORY_IMAGE_POOLS.other)[0]);
const coverUrl = `${coverBase}/${spec.file}`;
if (post.cover_url === coverUrl) continue;
await pool.query(`UPDATE plaza_posts SET cover_url = ?, updated_at = ? WHERE id = ?`, [
diff --git a/scripts/seed-plaza-demo.mjs b/scripts/seed-plaza-demo.mjs
index 57107f7..137e731 100644
--- a/scripts/seed-plaza-demo.mjs
+++ b/scripts/seed-plaza-demo.mjs
@@ -6,6 +6,7 @@ import { fileURLToPath } from 'node:url';
import { createDbPool, initSchema } from '../db.mjs';
import { createPlazaPostService } from '../plaza-posts.mjs';
import { initializeDefaultSpace } from '../mindspace.mjs';
+import { EXTRA_DEMO_POSTS } from './plaza-demo-posts.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
@@ -25,7 +26,7 @@ function loadEnvFile(filePath) {
loadEnvFile(path.join(root, '../../.env.local'));
loadEnvFile(path.join(root, '.env'));
-const DEMO_POSTS = [
+const BASE_DEMO_POSTS = [
{
category: 'work-report',
title: 'Q2 产品增长复盘:从 0 到 10 万 DAU',
@@ -198,6 +199,8 @@ const DEMO_POSTS = [
},
];
+const DEMO_POSTS = [...BASE_DEMO_POSTS, ...EXTRA_DEMO_POSTS];
+
async function loadUser(pool, username) {
const [rows] = await pool.query(
`SELECT id, username, slug, display_name FROM h5_users WHERE username = ? LIMIT 1`,
diff --git a/scripts/start-plaza-prod.mjs b/scripts/start-plaza-prod.mjs
index eacf11e..451b850 100644
--- a/scripts/start-plaza-prod.mjs
+++ b/scripts/start-plaza-prod.mjs
@@ -21,6 +21,22 @@ const plazaPublicBase = (
).replace(/\/$/, '');
const portalUrl = `http://127.0.0.1:${portalPort}`;
const skipBuild = process.argv.includes('--skip-build');
+const noPortal = process.argv.includes('--no-portal');
+
+function resolveNpmBin() {
+ const candidates = [
+ process.env.NPM_BIN,
+ '/opt/homebrew/opt/node@22/bin/npm',
+ '/opt/homebrew/bin/npm',
+ '/usr/local/bin/npm',
+ ].filter(Boolean);
+ for (const candidate of candidates) {
+ if (fs.existsSync(candidate)) return candidate;
+ }
+ return 'npm';
+}
+
+const npmBin = resolveNpmBin();
function loadEnvFile(filePath) {
if (!fs.existsSync(filePath)) return;
@@ -127,6 +143,9 @@ freePort(plazaPort);
try {
if (!(await portOpen(portalPort))) {
+ if (noPortal) {
+ throw new Error(`Portal 未运行 (${portalUrl}),请先启动 cn.tkmind.memind-portal`);
+ }
console.log(`==> 启动 Portal @ ${portalUrl}`);
portal = spawnChild('node', ['server.mjs'], 'portal');
await waitFor(() => portOpen(portalPort), 'Portal');
@@ -136,14 +155,14 @@ try {
if (!skipBuild) {
console.log('==> 构建 Plaza Next.js(生产)…');
- execSync('npm run build', { cwd: plazaDir, stdio: 'inherit', env: { ...process.env, ...plazaEnv } });
+ execSync(`"${npmBin}" run build`, { cwd: plazaDir, stdio: 'inherit', env: { ...process.env, ...plazaEnv }, shell: true });
} else {
console.log('==> 跳过 next build(--skip-build)');
}
console.log(`==> 启动 Plaza Next.js 正式服务 (0.0.0.0:${plazaPort})`);
plaza = spawnChild(
- 'npm',
+ npmBin,
['run', 'start', '--', '-H', '0.0.0.0', '-p', String(plazaPort)],
'plaza',
plazaDir,
diff --git a/server.mjs b/server.mjs
index a84fa4f..2b799b3 100644
--- a/server.mjs
+++ b/server.mjs
@@ -21,7 +21,7 @@ import {
userSessionCookie,
} from './user-auth.mjs';
import { createWikiAuth } from './wiki-auth.mjs';
-import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublishDir } from './user-publish.mjs';
+import { PUBLISH_ROOT_DIR, PUBLISH_KEY_UUID, PUBLIC_ZONE_DIR, resolvePublicBaseUrl, resolvePublishDir } from './user-publish.mjs';
import { ensureWorkspaceHtmlThumbnail, startWorkspaceThumbnailWatcher, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
import { startWorkspaceAssetSyncWatcher } from './mindspace-workspace-sync.mjs';
import { attachRequestId, sendData, sendError } from './api-response.mjs';
@@ -31,6 +31,7 @@ import { createMindSpaceService } from './mindspace.mjs';
import { createAssetService } from './mindspace-assets.mjs';
import { createPageService, pageInternals } from './mindspace-pages.mjs';
import { createPageLiveEditService } from './mindspace-page-live-edit.mjs';
+import { createImageGenerationService } from './image-generation.mjs';
import { createPageEditSessionService } from './mindspace-page-edit-session.mjs';
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
import { createPublicationService } from './mindspace-publications.mjs';
@@ -54,7 +55,6 @@ import { createCleanupService } from './mindspace-cleanup.mjs';
import { createAgentJobService } from './mindspace-agent-jobs.mjs';
import { createMindSpaceAgentRunner } from './mindspace-agent-runner.mjs';
import {
- analyzeChatMessageForSave,
buildChatSavePreviewFrameUrl,
buildChatSaveThumbnailUrl,
buildWorkspaceAssetUrl,
@@ -62,7 +62,6 @@ import {
buildWorkspaceThumbnailUrl,
injectHtmlBaseHref,
resolveChatSaveAnalysis,
- resolveStaticHtmlContent,
} from './mindspace-chat-save.mjs';
import { generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
import { scanContent } from './mindspace-content-scan.mjs';
@@ -101,6 +100,7 @@ const API_SECRET = process.env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
const INTERNAL_AGENT_SECRET = process.env.MINDSPACE_INTERNAL_AGENT_SECRET ?? API_SECRET;
const ACCESS_PASSWORD = process.env.H5_ACCESS_PASSWORD;
const USERS_ROOT = process.env.H5_USERS_ROOT ?? path.join(__dirname, 'users');
+const PUBLIC_BASE_URL = resolvePublicBaseUrl(process.env);
const app = express();
app.set('trust proxy', 1);
@@ -176,6 +176,7 @@ let mindSpaceAssets = null;
let mindSpaceAudit = null;
let mindSpacePages = null;
let mindSpacePageLiveEdit = null;
+let imageGeneration = null;
let mindSpacePageEditSession = null;
let mindSpacePublications = null;
let plazaPosts = null;
@@ -225,6 +226,18 @@ async function bootstrapUserAuth() {
return rows[0]?.user_id ?? null;
},
});
+ imageGeneration = createImageGenerationService({
+ pool,
+ h5Root: __dirname,
+ publicBaseUrl: PUBLIC_BASE_URL,
+ resolveUserIdForAgentSession: async (sessionId) => {
+ const [rows] = await pool.query(
+ `SELECT user_id FROM h5_user_sessions WHERE agent_session_id = ? LIMIT 1`,
+ [sessionId],
+ );
+ return rows[0]?.user_id ?? null;
+ },
+ });
mindSpacePublications = createPublicationService(pool, {
storageRoot:
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
@@ -436,7 +449,16 @@ app.post('/auth/login', jsonBody, async (req, res) => {
return res.status(401).json({ message: result.message });
}
setUserLoginCookies(res, req, result.token);
- return res.json({ authenticated: true, user: result.user, mode: 'user' });
+ const row = await userAuth.getUserById(result.user.id);
+ const capabilityState = row ? await userAuth.resolveUserCapabilities(row) : null;
+ return res.json({
+ authenticated: true,
+ user: result.user,
+ mode: 'user',
+ capabilities: capabilityState?.capabilities,
+ grantedSkills: capabilityState?.grantedSkills ?? [],
+ unrestricted: capabilityState?.unrestricted,
+ });
}
if (!legacyAuth) {
@@ -1289,6 +1311,7 @@ api.use(async (req, res, next) => {
if (req.path === '/status') return next();
if (req.path.startsWith('/internal/agent/')) return next();
if (req.path === '/agent/mindspace_page_patch') return next();
+ if (req.path === '/agent/generate_image') return next();
const plazaPublic = isPlazaPublicRead(req.path, req.method);
@@ -2068,6 +2091,7 @@ async function resolveChatSaveBundle(user, h5Root, input = {}) {
username: user.username,
h5Root,
selectedLinkIndex,
+ publicBaseUrl: PUBLIC_BASE_URL,
});
return {
source,
@@ -2224,12 +2248,13 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
req.body?.session_id,
req.body?.message_id,
);
- const analysis = analyzeChatMessageForSave({
+ const { analysis, resolvedHtml } = await resolveChatSaveAnalysis({
content: source.content,
userId: req.currentUser.id,
username: req.currentUser.username,
h5Root: __dirname,
selectedLinkIndex: Number(req.body?.selected_link_index ?? 0),
+ publicBaseUrl: PUBLIC_BASE_URL,
});
const snapshot = {
session_name: source.session.name,
@@ -2237,13 +2262,9 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
role: source.message.role,
content_mode: analysis.contentMode,
public_url: analysis.previewUrl,
- relative_path: analysis.relativePath,
+ relative_path: resolvedHtml?.relativePath ?? analysis.relativePath,
};
- let resolvedHtml = null;
- if (analysis.contentMode === 'static_html') {
- resolvedHtml = await resolveStaticHtmlContent(analysis).catch(() => null);
- }
const privacyScan = scanContent(resolvedHtml?.content ?? source.content);
assertPrivateSaveAllowed(categoryCode, privacyScan, req.body?.acknowledged_finding_ids);
@@ -2309,7 +2330,7 @@ api.post('/mindspace/v1/pages/save-from-chat', async (req, res) => {
if (analysis.contentMode === 'static_html' && resolvedHtml?.content && analysis.relativePath) {
const publishDir = resolvePublishDir(__dirname, req.currentUser);
- await ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, {
+ void ensureWorkspaceHtmlThumbnail(publishDir, analysis.relativePath, resolvedHtml.content, {
title: pageInput.title,
subtitle: pageInput.summary,
}).catch(() => {});
@@ -2557,6 +2578,37 @@ api.post('/agent/mindspace_page_patch', async (req, res) => {
}
});
+api.post('/agent/generate_image', async (req, res) => {
+ if (!imageGeneration) {
+ return res.status(503).json({ message: '图片生成未启用' });
+ }
+ try {
+ const result = await imageGeneration.generateForAgentSession(req.body ?? {});
+ return sendData(res, req, {
+ relative_path: result.relativePath,
+ public_url: result.publicUrl,
+ mime_type: result.mimeType,
+ size: result.size,
+ style: result.style,
+ quality: result.quality,
+ revised_prompt: result.revisedPrompt,
+ prompt: result.prompt,
+ });
+ } catch (error) {
+ const code = error?.code ?? 'internal_error';
+ const statusByCode = {
+ image_gen_unavailable: 503,
+ image_gen_not_configured: 503,
+ invalid_request: 400,
+ invalid_output_path: 400,
+ forbidden: 403,
+ image_gen_failed: 502,
+ };
+ const status = statusByCode[code] ?? 500;
+ return sendError(res, req, status, code, error?.message || '图片生成失败', error?.details);
+ }
+});
+
api.get('/mindspace/v1/pages/:pageId/thumbnail', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
diff --git a/skills-registry.mjs b/skills-registry.mjs
index c38a5e7..7241689 100644
--- a/skills-registry.mjs
+++ b/skills-registry.mjs
@@ -15,6 +15,7 @@ export const DEFAULT_USER_SKILLS = {
search: true,
'form-builder': true,
'table-viewer': true,
+ 'image-designer': true,
[PUBLISH_SKILL_NAME]: false,
};
@@ -24,6 +25,7 @@ export const USER_ROLE_SKILL_PRESETS = {
creator: {
...DEFAULT_USER_SKILLS,
[PUBLISH_SKILL_NAME]: true,
+ 'image-designer': true,
kanban: true,
timeline: true,
},
diff --git a/skills-registry.test.mjs b/skills-registry.test.mjs
index 664b2a2..ef9748b 100644
--- a/skills-registry.test.mjs
+++ b/skills-registry.test.mjs
@@ -15,6 +15,7 @@ const h5Root = path.dirname(fileURLToPath(import.meta.url));
test('lists static-page-publish in platform catalog', () => {
const catalog = listPlatformSkillCatalog(h5Root);
assert.ok(catalog.some((item) => item.name === 'static-page-publish'));
+ assert.ok(catalog.some((item) => item.name === 'image-designer'));
});
test('granting publish skill enables static_publish capability', () => {
@@ -46,5 +47,6 @@ test('DEFAULT_USER_SKILLS enables common platform skills', () => {
assert.equal(DEFAULT_USER_SKILLS.search, true);
assert.equal(DEFAULT_USER_SKILLS['form-builder'], true);
assert.equal(DEFAULT_USER_SKILLS['table-viewer'], true);
+ assert.equal(DEFAULT_USER_SKILLS['image-designer'], true);
assert.equal(DEFAULT_USER_SKILLS['static-page-publish'], false);
});
diff --git a/skills/image-designer/SKILL.md b/skills/image-designer/SKILL.md
new file mode 100644
index 0000000..1255e1e
--- /dev/null
+++ b/skills/image-designer/SKILL.md
@@ -0,0 +1,127 @@
+---
+name: image-designer
+description: 高级视觉设计师技能:根据用户需求生成高质量图片,保存到 MindSpace 工作区
+---
+
+# 高级视觉设计师(AI 生图)
+
+你是一位 **高级视觉设计师 / 艺术指导**。你的任务不是简单「画一张图」,而是:
+
+- 理解用户的商业/品牌/内容目标
+- 把模糊需求翻译成可执行的视觉方案
+- 生成可直接用于页面 hero、封面、海报、产品图、社交配图的高质量图片
+- 交付可引用、可复用的文件路径与预览链接
+
+## 何时使用
+
+- 用户要「生成图片 / 做一张图 / 设计封面 / hero 图 / 海报 / 插画 / 产品图」
+- 用户要为静态页面、MindSpace 卡片、Plaza 帖子准备主视觉
+- 用户描述风格、主题、配色、构图,希望直接得到成品图
+
+## 前提
+
+1. 用户已开通 **image-designer** 技能
+2. 服务端已启用图片生成(`H5_IMAGE_GEN_ENABLED=1`,并配置 OpenAI 兼容 `/v1/images/generations`)
+3. 你具备 **shell** 权限(用于调用生成 API)
+4. 建议同时启用 **image_read**(生成后用 `read_image` 自检效果)
+
+## 设计师工作流
+
+1. **澄清需求**(信息不足时最多追问 3 点)
+ - 用途:页面 hero / 封面 / 海报 / 产品图 / 头像 / 背景
+ - 主体:人物、产品、场景、抽象概念
+ - 风格:写实 / 插画 / 扁平 / 3D / 国潮 / 极简 / 电影感
+ - 画幅:方图 1:1、竖版 3:4、横版 16:9
+ - 品牌色、情绪、禁用元素(文字、logo、水印等)
+2. **形成视觉方案**(先对用户用 2–4 句中文说明设计方向,再执行生图)
+3. **调用生成 API**(每次只生成 1 张,确认满意后再批量)
+4. **自检**(用 `read_image` 查看生成结果;不满意则调整 prompt 重生成)
+5. **交付**(给出相对路径 + 可点击公网链接 + 使用建议)
+
+## 生成 API(必须)
+
+使用当前 Agent **session_id**,通过 shell 调用 Portal API:
+
+```bash
+curl -sS -X POST 'http://127.0.0.1:8081/api/agent/generate_image' \
+ -H 'Content-Type: application/json' \
+ -d '{
+ "session_id": "<当前会话ID>",
+ "prompt": "用户原始需求(中文或英文均可)",
+ "output_path": "assets/hero.png",
+ "style_notes": "modern editorial, warm sunset palette, premium travel mood",
+ "aspect_ratio": "3:4 portrait hero",
+ "brand_colors": "#ff6b35, #24243e",
+ "subject": "Malaysia travel landscape",
+ "size": "1024x1792",
+ "style": "vivid",
+ "quality": "hd"
+ }'
+```
+
+### 字段说明
+
+| 字段 | 必填 | 说明 |
+|------|------|------|
+| `session_id` | 是 | 当前 Agent 会话 ID |
+| `prompt` | 是 | 用户需求的自然语言描述 |
+| `output_path` | 否 | 工作区相对路径,默认 `assets/.png` |
+| `style_notes` | 否 | 风格/氛围/材质/镜头语言(英文更稳) |
+| `aspect_ratio` | 否 | 画幅意图,如 `1:1` / `3:4` / `16:9` |
+| `brand_colors` | 否 | 品牌主色 hex |
+| `subject` | 否 | 画面主体 |
+| `size` | 否 | `1024x1024` / `1024x1792` / `1792x1024` 等 |
+| `style` | 否 | `vivid`(更饱和)或 `natural`(更自然) |
+| `quality` | 否 | `standard` 或 `hd` |
+
+### 输出路径规范
+
+- 页面 hero / 封面:`assets/-hero.png`
+- 公开配图:`public/-cover.png`
+- 多图系列:`assets/-01.png`、`assets/-02.png`
+- **禁止**写到工作区外;仅支持 `.png` / `.jpg` / `.webp`
+
+## Prompt 编写原则(设计师视角)
+
+1. **先定用途再定风格**:海报要冲击,报告封面要克制,产品图要清晰
+2. **主体 + 场景 + 光线 + 材质 + 构图** 五要素齐全
+3. **默认不要文字**:除非用户明确要求图中文字;UI 文案应放在 HTML 里
+4. **避免侵权风格**:不要写「某某品牌/logo/明星脸/受版权保护的角色」
+5. **中文需求可保留**,但 `style_notes` / 镜头描述建议用英文以提高稳定性
+6. 不满意时优先改:**构图 > 光线 > 风格 > 细节**,不要一次改太多变量
+
+## 与静态页发布协作
+
+若用户最终要生成 HTML 页面:
+
+1. 先用本技能生成 hero 图到 `assets/`
+2. 再在 `mindspace-cover` 元数据中引用,例如 `"cover":"assets/hero.png"`
+3. 需要发布链接时,配合 **static-page-publish** 技能
+
+## 回复格式(必须)
+
+交付图片时使用 Markdown 可点击链接:
+
+```markdown
+已为你生成马来西亚旅行 hero 图:
+
+- 文件:`assets/malaysia-hero.png`
+- 预览:[马来西亚旅行 Hero](https://goo.tkmind.cn/MindSpace/<用户ID>/assets/malaysia-hero.png)
+
+设计说明:电影感暖色日落、3:4 竖版构图,适合信息流封面与页面首屏。
+```
+
+要求:
+
+- **必须**给出相对路径
+- **必须**给出完整可点击 URL(若 API 返回 `publicUrl` 则直接使用)
+- 简要说明设计决策(风格、配色、构图)
+- 若效果未达标,主动提出 1–2 个可执行的修改方向
+
+## 禁止事项
+
+- 不要声称「已生成」但未实际调用 API 或未写入文件
+- 不要用 `read_image` 读取外链来代替本地生成文件
+- 不要生成违法、色情、暴力、歧视、欺诈类内容
+- 不要把图片保存到 `users/`、项目根目录或其它用户目录
+- 不要在未确认需求时连续生成大量图片(浪费额度)
diff --git a/src/App.tsx b/src/App.tsx
index 5fd3443..99c311b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,6 +5,7 @@ import { AuthView } from './components/AuthView';
import { ChatView } from './components/ChatView';
import { MindSpaceView } from './components/MindSpaceView';
import { ChatProvider } from './context/ChatProvider';
+import { AppSessionProvider } from './context/AppSessionContext';
import { PREVIEW_USER } from './dev/mindspacePreviewData';
import { MindSpaceRoute } from './routes/MindSpaceRoute';
import type { CapabilityMap, PortalUser } from './types';
@@ -88,16 +89,18 @@ function AuthenticatedApp({
);
return (
-
-
- }
- />
-
- } />
-
-
+
+
+
+ }
+ />
+
+ } />
+
+
+
);
}
@@ -115,18 +118,32 @@ export function App() {
setUnauthorizedHandler(() => {
setAuthed(false);
setUser(null);
+ setGrantedSkills(undefined);
+ setCapabilities(undefined);
if (window.location.pathname !== '/') {
navigate('/', { replace: true });
}
});
- void checkAuth().then((status) => {
- setLegacyMode(status.mode === 'legacy');
- setAuthed(status.authenticated);
- setUser(status.user ?? null);
- setCapabilities(status.capabilities);
- setGrantedSkills(status.grantedSkills);
- });
- return () => setUnauthorizedHandler(null);
+
+ const syncAuth = () =>
+ checkAuth().then((status) => {
+ setLegacyMode(status.mode === 'legacy');
+ setAuthed(status.authenticated);
+ setUser(status.user ?? null);
+ setCapabilities(status.capabilities);
+ setGrantedSkills(status.grantedSkills);
+ });
+
+ void syncAuth();
+
+ const handleFocus = () => {
+ void syncAuth();
+ };
+ window.addEventListener('focus', handleFocus);
+ return () => {
+ window.removeEventListener('focus', handleFocus);
+ setUnauthorizedHandler(null);
+ };
}, [mindSpacePreview, navigate]);
if (mindSpacePreview) {
diff --git a/src/api/client.ts b/src/api/client.ts
index eb340ab..81f6394 100644
--- a/src/api/client.ts
+++ b/src/api/client.ts
@@ -272,14 +272,15 @@ export async function checkAuth(): Promise {
if (!response.ok) return { authenticated: false };
const status = (await response.json()) as AuthStatus;
if (status.authenticated) resetUnauthorizedGuard();
- if (status.authenticated && status.mode === 'user' && !status.capabilities) {
+ if (status.authenticated && status.mode === 'user') {
try {
const me = await getMe();
return {
...status,
- capabilities: me.capabilities,
- grantedSkills: me.grantedSkills,
- user: me.user,
+ user: me.user ?? status.user,
+ capabilities: me.capabilities ?? status.capabilities,
+ grantedSkills: me.grantedSkills ?? status.grantedSkills,
+ unrestricted: me.unrestricted ?? status.unrestricted,
};
} catch {
return status;
diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx
index e24cc88..1bf5408 100644
--- a/src/components/ChatPanel.tsx
+++ b/src/components/ChatPanel.tsx
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
+import { useAppSession } from '../context/AppSessionContext';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar';
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
@@ -44,6 +45,7 @@ export function ChatPanel({
onClose?: () => void;
}) {
const online = useNetworkStatus();
+ const { capabilities: sessionCapabilities, grantedSkills: sessionGrantedSkills } = useAppSession();
const [input, setInput] = useState('');
const [pageSource, setPageSource] = useState(null);
const [voiceNotice, setVoiceNotice] = useState(null);
@@ -56,9 +58,14 @@ export function ChatPanel({
const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting';
const offlineBlocked = !online;
const publishSkillName = user?.publishSkillName ?? 'static-page-publish';
- const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false;
- const canPublish = Boolean(capabilities?.static_publish) || hasPublishSkill;
- const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { grantedSkills, canPublish });
+ const effectiveCapabilities = capabilities ?? sessionCapabilities;
+ const effectiveGrantedSkills = grantedSkills ?? sessionGrantedSkills;
+ const hasPublishSkill = effectiveGrantedSkills?.includes(publishSkillName) ?? false;
+ const canPublish = Boolean(effectiveCapabilities?.static_publish) || hasPublishSkill;
+ const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, {
+ grantedSkills: effectiveGrantedSkills,
+ canPublish,
+ });
const compact = variant === 'compact';
const handleSubmit = async () => {
@@ -128,6 +135,9 @@ export function ChatPanel({
onSaved={(result) => {
setPageSource(null);
onPageSaved?.(result);
+ if (result.kind === 'page' && result.pageId) {
+ onClose?.();
+ }
}}
/>
)}
diff --git a/src/components/ChatSkillIcons.tsx b/src/components/ChatSkillIcons.tsx
index 0f91962..1b4563a 100644
--- a/src/components/ChatSkillIcons.tsx
+++ b/src/components/ChatSkillIcons.tsx
@@ -90,6 +90,17 @@ function AnalyzeIcon({ className }: IconProps) {
);
}
+function ImageIcon({ className }: IconProps) {
+ return (
+
+ );
+}
+
const ICONS: Record JSX.Element> = {
spark: SparkIcon,
page: PageIcon,
@@ -99,6 +110,7 @@ const ICONS: Record JSX.Element> = {
table: TableIcon,
summary: SummaryIcon,
analyze: AnalyzeIcon,
+ image: ImageIcon,
};
export function ChatSkillIcon({ id, className }: { id: ChatSkillIconId; className?: string }) {
diff --git a/src/components/MindSpacePageDetail.tsx b/src/components/MindSpacePageDetail.tsx
index eae733e..882b009 100644
--- a/src/components/MindSpacePageDetail.tsx
+++ b/src/components/MindSpacePageDetail.tsx
@@ -156,6 +156,7 @@ export function MindSpacePageDetail({
const [plazaPost, setPlazaPost] = useState(null);
const [pushToPlaza, setPushToPlaza] = useState(false);
const [fixNotice, setFixNotice] = useState(null);
+ const [saving, setSaving] = useState(false);
const applyPageRecord = useCallback(
(next: MindSpacePage, options?: { recordHistory?: boolean }) => {
@@ -468,6 +469,61 @@ export function MindSpacePageDetail({
}
}, [applyPageRecord, content, markPreviewRefreshPending, page, pageId, summary, title, triggerChangeHighlight]);
+ const isDirty =
+ Boolean(page) &&
+ (title !== page.title ||
+ summary !== page.summary ||
+ (content ?? '') !== (page.content ?? '') ||
+ templateId !== page.templateId);
+
+ const save = useCallback(async () => {
+ if (!page || saving) return;
+ if (!title.trim()) {
+ setError('标题不能为空');
+ return;
+ }
+ if (!content.trim()) {
+ setError('页面内容不能为空');
+ return;
+ }
+ setSaving(true);
+ setError(null);
+ try {
+ const updated = await updateMindSpacePage(page.id, {
+ expectedVersion: page.versionNo,
+ title: title.trim(),
+ summary: summary.trim(),
+ content,
+ templateId,
+ changeNote: `保存草稿 v${page.versionNo + 1}`,
+ });
+ applyPageRecord(updated);
+ resetDraft({
+ title: updated.title,
+ summary: updated.summary,
+ content: updated.content ?? '',
+ });
+ setPreviewContent(updated.content ?? '');
+ setPreviewRefreshPending(false);
+ setPreviewKey((value) => value + 1);
+ await onSaved();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : '页面保存失败');
+ } finally {
+ setSaving(false);
+ }
+ }, [
+ applyPageRecord,
+ content,
+ onSaved,
+ page,
+ resetDraft,
+ saving,
+ summary,
+ templateId,
+ title,
+ ]);
+
const applyOneClickFix = async () => {
if (!page) return;
setPublishing(true);
@@ -520,6 +576,11 @@ export function MindSpacePageDetail({
) {
return;
}
+ if (event.key.toLowerCase() === 's') {
+ event.preventDefault();
+ void save();
+ return;
+ }
if (event.key.toLowerCase() === 'z' && event.shiftKey) {
const next = redo();
if (next) {
@@ -538,7 +599,7 @@ export function MindSpacePageDetail({
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
- }, [redo, syncPreviewAfterHistory, undo]);
+ }, [redo, save, syncPreviewAfterHistory, undo]);
useEffect(() => {
if (!page || !onContextUpdate) return;
@@ -1049,6 +1110,21 @@ export function MindSpacePageDetail({
/>
)}
+
+
+ {isDirty
+ ? '有未保存的修改。保存会创建新版本,历史版本不会被覆盖。'
+ : '保存会创建新版本,历史版本不会被覆盖。'}
+
+
+
@@ -1083,6 +1159,17 @@ export function MindSpacePageDetail({
>
刷新预览{previewRefreshPending ? ' · 有新修改' : ''}
+ {page.contentFormat === 'html' ? (
+
+ ) : null}
{page.contentFormat === 'html' ? (
@@ -1169,6 +1256,9 @@ export function MindSpacePageDetail({
onRefresh={() => handleManualPreviewRefresh()}
refreshPending={previewRefreshPending}
onContentChange={handlePreviewContentChange}
+ onSave={() => void save()}
+ saving={saving}
+ canSave={isDirty && Boolean(title.trim()) && Boolean(content.trim())}
/>
) : null}
>
diff --git a/src/components/MindSpacePageFullscreenPreview.tsx b/src/components/MindSpacePageFullscreenPreview.tsx
index 81bad57..e26c2fd 100644
--- a/src/components/MindSpacePageFullscreenPreview.tsx
+++ b/src/components/MindSpacePageFullscreenPreview.tsx
@@ -16,6 +16,9 @@ export function MindSpacePageFullscreenPreview({
onRefresh,
refreshPending = false,
onContentChange,
+ onSave,
+ saving = false,
+ canSave = false,
}: {
pageTitle: string;
title: string;
@@ -30,6 +33,9 @@ export function MindSpacePageFullscreenPreview({
onRefresh: () => void;
refreshPending?: boolean;
onContentChange: (html: string) => void;
+ onSave?: () => void;
+ saving?: boolean;
+ canSave?: boolean;
}) {
useEffect(() => {
const previousOverflow = document.body.style.overflow;
@@ -51,6 +57,13 @@ export function MindSpacePageFullscreenPreview({
}
const mod = event.metaKey || event.ctrlKey;
+ if (mod && !event.altKey && event.key.toLowerCase() === 's') {
+ if (onSave && canSave && !saving) {
+ event.preventDefault();
+ onSave();
+ }
+ return;
+ }
if (mod && !event.altKey && event.key.toLowerCase() === 'z') {
if (event.shiftKey) {
if (canRedo && onRedo?.()) event.preventDefault();
@@ -66,7 +79,7 @@ export function MindSpacePageFullscreenPreview({
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
- }, [canRedo, canUndo, onClose, onRedo, onUndo]);
+ }, [canRedo, canSave, canUndo, onClose, onRedo, onSave, onUndo, saving]);
return (
@@ -93,7 +106,18 @@ export function MindSpacePageFullscreenPreview({
>
刷新预览{refreshPending ? ' · 有新修改' : ''}
-
diff --git a/src/components/PageSaveDialog.tsx b/src/components/PageSaveDialog.tsx
index b6f87ea..e69e4fb 100644
--- a/src/components/PageSaveDialog.tsx
+++ b/src/components/PageSaveDialog.tsx
@@ -151,16 +151,43 @@ export function PageSaveDialog({
const privateNeedsAck =
categoryCode === 'private' && privacyFindings.length > 0 && analysis?.privacyScan.allowed;
+ const resolveSaveTitle = () =>
+ title.trim() || analysis?.suggestedTitle?.trim() || '';
+
+ const resolveSaveSummary = () =>
+ summary.trim() || analysis?.suggestedSummary?.trim() || '';
+
+ const effectiveTitle = resolveSaveTitle();
+ const canSave =
+ Boolean(effectiveTitle) &&
+ !analysisLoading &&
+ !analysisError &&
+ !privateBlocked &&
+ !(privateNeedsAck && !privateAcknowledged);
+
const save = async () => {
- if (!title.trim()) return;
+ const saveTitle = resolveSaveTitle();
+ if (!saveTitle) {
+ setSaveError('请填写页面标题');
+ return;
+ }
+ const saveSummary = resolveSaveSummary();
+ if (!title.trim() && saveTitle !== title) {
+ setTitle(saveTitle);
+ titleRef.current = saveTitle;
+ }
+ if (!summary.trim() && saveSummary) {
+ setSummary(saveSummary);
+ summaryRef.current = saveSummary;
+ }
setSaving(true);
setSaveError(null);
try {
const result = await saveChatMessageAsPage({
sessionId,
messageId,
- title: title.trim(),
- summary: summary.trim(),
+ title: saveTitle,
+ summary: saveSummary,
templateId: isStaticHtml ? 'static-html' : templateId,
categoryCode,
selectedLinkIndex,
@@ -170,8 +197,12 @@ export function PageSaveDialog({
: undefined,
});
if (result.kind === 'page') {
+ const pageId = result.page?.id;
+ if (!pageId) {
+ throw new Error('保存成功但未返回页面 ID');
+ }
setSaveNotice(`已保存到${CATEGORY_LABELS[categoryCode]}`);
- onSaved({ kind: 'page', categoryCode: 'draft', pageId: result.page.id });
+ onSaved({ kind: 'page', categoryCode: 'draft', pageId });
return;
}
setSaveNotice(`已保存到${CATEGORY_LABELS[result.categoryCode]}`);
@@ -370,14 +401,7 @@ export function PageSaveDialog({
type="button"
className="page-save-primary"
onClick={() => void save()}
- disabled={
- saving ||
- analysisLoading ||
- !title.trim() ||
- Boolean(analysisError) ||
- privateBlocked ||
- (privateNeedsAck && !privateAcknowledged)
- }
+ disabled={saving || !canSave}
>
{saving ? '正在保存…' : categoryCode === 'draft' ? '保存并进入编辑' : '保存到空间'}
diff --git a/src/components/PageSavePreviewPanel.tsx b/src/components/PageSavePreviewPanel.tsx
index a530fd3..a2392ec 100644
--- a/src/components/PageSavePreviewPanel.tsx
+++ b/src/components/PageSavePreviewPanel.tsx
@@ -85,9 +85,11 @@ export function PageSavePreviewPanel({
]);
if (!analysis.hasHtmlContent) {
+ const hint = analysis.filename || analysis.relativePath || analysis.previewUrl;
return (
- 本地未找到页面文件,请确认 Agent 已将 HTML 保存到 MindSpace 工作区。
+ 本地未找到页面文件{hint ? `(${hint})` : ''}。请确认 Agent 已用 write 写入{' '}
+ public/页面.html,且聊天里的链接路径与实际文件一致;若页面只在公网可访问,请稍后重试或重新生成。
);
}
diff --git a/src/context/AppSessionContext.tsx b/src/context/AppSessionContext.tsx
new file mode 100644
index 0000000..a813d8d
--- /dev/null
+++ b/src/context/AppSessionContext.tsx
@@ -0,0 +1,29 @@
+import { createContext, useContext, type ReactNode } from 'react';
+import type { CapabilityMap } from '../types';
+
+type AppSessionContextValue = {
+ capabilities?: CapabilityMap;
+ grantedSkills?: string[];
+};
+
+const AppSessionContext = createContext({});
+
+export function AppSessionProvider({
+ capabilities,
+ grantedSkills,
+ children,
+}: {
+ capabilities?: CapabilityMap;
+ grantedSkills?: string[];
+ children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export function useAppSession() {
+ return useContext(AppSessionContext);
+}
diff --git a/src/hooks/useTKMindChat.ts b/src/hooks/useTKMindChat.ts
index 67404fa..8338ed9 100644
--- a/src/hooks/useTKMindChat.ts
+++ b/src/hooks/useTKMindChat.ts
@@ -507,30 +507,43 @@ export function useTKMindChat(
setChatState('loading');
setError(null);
- let sessionId = readStoredSessionId(userRef.current?.id);
- let staleSession = false;
+ let sessionId: string | null = null;
let freshSession: Session | null = null;
- if (sessionId) {
- try {
- await getSession(sessionId);
- } catch (err) {
- if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) {
- clearStoredSessionId(userRef.current?.id);
- sessionId = null;
- staleSession = true;
- } else {
- throw err;
- }
- }
+ setSessionsLoading(true);
+ try {
+ const items = sortAndTrim(await listSessions());
+ setSessions(items);
+ sessionId = items[0]?.id ?? null;
+ } catch {
+ // Fall back to stored session or create a new one when the list is unavailable.
+ } finally {
+ setSessionsLoading(false);
}
if (!sessionId) {
- freshSession = await startSession();
- sessionId = freshSession.id;
- writeStoredSessionId(userRef.current?.id, sessionId);
- if (staleSession) {
- setNotice('上次会话已失效,已为你新建对话');
+ let staleSession = false;
+ sessionId = readStoredSessionId(userRef.current?.id);
+ if (sessionId) {
+ try {
+ await getSession(sessionId);
+ } catch (err) {
+ if (err instanceof ApiError && (err.status === 403 || err.status === 404 || err.status === 400)) {
+ clearStoredSessionId(userRef.current?.id);
+ sessionId = null;
+ staleSession = true;
+ } else {
+ throw err;
+ }
+ }
+ }
+
+ if (!sessionId) {
+ freshSession = await startSession();
+ sessionId = freshSession.id;
+ if (staleSession) {
+ setNotice('上次会话已失效,已为你新建对话');
+ }
}
}
diff --git a/src/index.css b/src/index.css
index dce7ffd..e6acab1 100644
--- a/src/index.css
+++ b/src/index.css
@@ -990,6 +990,12 @@ body,
background: #2f6f57;
}
+.page-save-actions button:disabled,
+.page-save-primary:disabled {
+ cursor: not-allowed;
+ opacity: 0.48;
+}
+
.page-save-error {
color: #8b2d20;
font-size: 13px;
@@ -1602,6 +1608,8 @@ body,
display: flex;
flex-direction: column;
min-width: 168px;
+ max-height: min(320px, 50vh);
+ overflow-y: auto;
padding: 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
diff --git a/src/utils/chatSkills.ts b/src/utils/chatSkills.ts
index 10ff7b0..4e67f1d 100644
--- a/src/utils/chatSkills.ts
+++ b/src/utils/chatSkills.ts
@@ -13,7 +13,8 @@ export type ChatSkillIconId =
| 'form'
| 'table'
| 'summary'
- | 'analyze';
+ | 'analyze'
+ | 'image';
export type ChatSkillOption = {
id: string;
diff --git a/user-auth.mjs b/user-auth.mjs
index eb29d8b..6539756 100644
--- a/user-auth.mjs
+++ b/user-auth.mjs
@@ -1,6 +1,7 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
+import argon2 from 'argon2';
import { computeDeltaCostCents, loadBillingConfig, normalizeTokenState } from './billing.mjs';
import { buildInsufficientBalancePayload, loadRechargeConfig } from './billing-recharge.mjs';
import {
@@ -64,25 +65,38 @@ function hashPasswordPbkdf2(password, salt) {
return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
}
-function hashPasswordArgon2id(password, salt) {
- return crypto
- .argon2Sync(PASSWORD_ALGORITHM_ARGON2ID, {
- message: password,
- nonce: Buffer.from(salt, 'hex'),
+async function hashPasswordArgon2id(password, salt) {
+ if (typeof crypto.argon2Sync === 'function') {
+ return crypto
+ .argon2Sync(PASSWORD_ALGORITHM_ARGON2ID, {
+ message: password,
+ nonce: Buffer.from(salt, 'hex'),
+ parallelism: ARGON2_PARALLELISM,
+ tagLength: ARGON2_TAG_LENGTH,
+ memory: ARGON2_MEMORY,
+ passes: ARGON2_PASSES,
+ })
+ .toString('hex');
+ }
+ return (
+ await argon2.hash(password, {
+ type: argon2.argon2id,
+ salt: Buffer.from(salt, 'hex'),
+ memoryCost: ARGON2_MEMORY,
+ timeCost: ARGON2_PASSES,
parallelism: ARGON2_PARALLELISM,
- tagLength: ARGON2_TAG_LENGTH,
- memory: ARGON2_MEMORY,
- passes: ARGON2_PASSES,
+ hashLength: ARGON2_TAG_LENGTH,
+ raw: true,
})
- .toString('hex');
+ ).toString('hex');
}
-function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID) {
+async function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID) {
const salt = crypto.randomBytes(16).toString('hex');
if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) {
return {
salt,
- passwordHash: hashPasswordArgon2id(password, salt),
+ passwordHash: await hashPasswordArgon2id(password, salt),
passwordAlgorithm: PASSWORD_ALGORITHM_ARGON2ID,
};
}
@@ -93,10 +107,10 @@ function createPasswordRecord(password, algorithm = PASSWORD_ALGORITHM_ARGON2ID)
};
}
-function verifyPassword(password, row) {
+async function verifyPassword(password, row) {
const algorithm = row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2;
if (algorithm === PASSWORD_ALGORITHM_ARGON2ID) {
- return safeEqual(hashPasswordArgon2id(password, row.salt), row.password_hash);
+ return safeEqual(await hashPasswordArgon2id(password, row.salt), row.password_hash);
}
return safeEqual(hashPasswordPbkdf2(password, row.salt), row.password_hash);
}
@@ -337,7 +351,7 @@ export function createUserAuth(pool, options = {}) {
return { ok: false, message: '请输入有效邮箱' };
}
- const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
+ const { salt, passwordHash, passwordAlgorithm } = await createPasswordRecord(password);
const userId = crypto.randomUUID();
const layout = await publishLayoutFor({ id: userId, username: normalized });
const workspaceRoot = layout.publishDir;
@@ -433,7 +447,7 @@ export function createUserAuth(pool, options = {}) {
return { ok: false, message: '用户名或密码错误' };
}
- if (!verifyPassword(password, row)) {
+ if (!(await verifyPassword(password, row))) {
const current =
failure && failure.resetAt > now
? failure
@@ -447,7 +461,7 @@ export function createUserAuth(pool, options = {}) {
}
if ((row.password_algorithm || PASSWORD_ALGORITHM_PBKDF2) !== PASSWORD_ALGORITHM_ARGON2ID) {
- const nextPassword = createPasswordRecord(password);
+ const nextPassword = await createPasswordRecord(password);
await pool.query(
`UPDATE h5_users
SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ?
@@ -490,7 +504,7 @@ export function createUserAuth(pool, options = {}) {
return { ok: false, message: '账户已禁用,请联系管理员' };
}
- const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
+ const { salt, passwordHash, passwordAlgorithm } = await createPasswordRecord(password);
const now = Date.now();
await pool.query(
`UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`,
@@ -720,7 +734,7 @@ export function createUserAuth(pool, options = {}) {
const root = isAdmin
? path.resolve(workspaceRoot || path.join(usersRoot, normalized))
: (await publishLayoutFor({ id: userId, username: normalized })).publishDir;
- const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(password);
+ const { salt, passwordHash, passwordAlgorithm } = await createPasswordRecord(password);
const now = Date.now();
const conn = await pool.getConnection();
@@ -1263,7 +1277,7 @@ export function createUserAuth(pool, options = {}) {
);
if (rows.length === 0) return;
- const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(adminPassword);
+ const { salt, passwordHash, passwordAlgorithm } = await createPasswordRecord(adminPassword);
const now = Date.now();
await pool.query(
`UPDATE h5_users SET salt = ?, password_hash = ?, password_algorithm = ?, updated_at = ? WHERE id = ?`,
@@ -1923,7 +1937,7 @@ export function createUserAuth(pool, options = {}) {
}) => {
const normalized = await generateWechatUsername(openid);
const randomPassword = crypto.randomBytes(24).toString('base64url');
- const { salt, passwordHash, passwordAlgorithm } = createPasswordRecord(randomPassword);
+ const { salt, passwordHash, passwordAlgorithm } = await createPasswordRecord(randomPassword);
const userId = crypto.randomUUID();
const layout = await publishLayoutFor({ id: userId, username: normalized });
const workspaceRoot = layout.publishDir;