feat(mindspace): 公开页分享组件、workspace 路径与 Plaza 发布增强

- 新增 public share widget 与 workspace relative path 解析
- 增强 publications/chat-plaza 发布链路与缩略图 demo
- 补充 schema、cover 检查脚本与回归测试

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-05 23:14:41 +08:00
parent 69be1e5293
commit e2ad3bf62b
39 changed files with 2184 additions and 256 deletions
+47
View File
@@ -771,6 +771,53 @@ export async function migrateSchema(pool) {
)
`);
if (!(await columnExists(pool, 'h5_page_records', 'workspace_relative_path'))) {
await pool.query(
`ALTER TABLE h5_page_records
ADD COLUMN workspace_relative_path VARCHAR(512) NULL AFTER draft_content_ref`,
);
}
if (!(await indexExists(pool, 'h5_page_records', 'idx_h5_pages_workspace_path'))) {
await pool.query(
`ALTER TABLE h5_page_records
ADD KEY idx_h5_pages_workspace_path (user_id, workspace_relative_path, updated_at)`,
);
}
if (!(await columnExists(pool, 'h5_assets', 'workspace_relative_path'))) {
await pool.query(
`ALTER TABLE h5_assets
ADD COLUMN workspace_relative_path VARCHAR(512) NULL AFTER display_name`,
);
}
if (!(await indexExists(pool, 'h5_assets', 'idx_h5_assets_workspace_path'))) {
await pool.query(
`ALTER TABLE h5_assets
ADD KEY idx_h5_assets_workspace_path (user_id, workspace_relative_path, updated_at)`,
);
}
await pool.query(
`UPDATE h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
SET p.workspace_relative_path = JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path'))
WHERE p.workspace_relative_path IS NULL
AND JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path') IS NOT NULL
AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) <> ''`,
);
await pool.query(
`UPDATE h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
SET a.workspace_relative_path = CASE
WHEN a.original_filename LIKE 'public/%' OR a.original_filename LIKE 'oa/%'
THEN a.original_filename
WHEN c.category_code IN ('public', 'oa')
THEN CONCAT(c.category_code, '/', a.original_filename)
ELSE NULL
END
WHERE a.workspace_relative_path IS NULL
AND a.status <> 'deleted'
AND c.category_code IN ('public', 'oa')`,
);
await pool.query(`
CREATE TABLE IF NOT EXISTS h5_user_feedback (
id CHAR(36) PRIMARY KEY,
+65 -7
View File
@@ -25,6 +25,10 @@ import {
DEFAULT_IMAGE_UPLOAD_MAX_BYTES,
normalizeImageForStorage,
} from './user-image-normalize.mjs';
import {
normalizeWorkspaceRelativePath,
resolveAssetWorkspaceRelativePath,
} from './mindspace-workspace-path.mjs';
const ALLOWED_EXTENSIONS = new Map([
['.txt', 'text/plain'],
@@ -184,9 +188,15 @@ function isPublicImageAsset(row) {
return row?.category_code === 'public';
}
function isCanonicalPublicImageStorageKey(storageKey) {
const normalized = String(storageKey ?? '').replace(/\\/g, '/');
if (isWorkspaceStorageKey(normalized)) return false;
return isPublicImageStorageKey(normalized);
}
function publicUrlForAsset(row) {
if (!isPublicImageAsset(row)) return null;
if (row?.storage_key && isPublicImageStorageKey(row.storage_key)) {
if (row?.storage_key && isCanonicalPublicImageStorageKey(row.storage_key)) {
return buildUserImagePublicUrl({
userId: row.user_id,
storageKey: row.storage_key,
@@ -194,9 +204,16 @@ function publicUrlForAsset(row) {
originalFilename: row.original_filename,
});
}
const workspacePath = normalizeWorkspaceRelativePath(row?.workspace_relative_path);
if (workspacePath && isWorkspaceStorageKey(row?.storage_key)) {
return createMindSpacePublicUrl({
publicBaseUrl: resolvePublicBaseUrl(),
ownerKey: row.user_id,
relativePath: workspacePath,
});
}
if (row?.category_code === 'public' && row?.id) {
const legacy = publicTempImageUrl(row.user_id, row.id, row.mime_type, row.original_filename);
return legacy;
return publicTempImageUrl(row.user_id, row.id, row.mime_type, row.original_filename);
}
return null;
}
@@ -651,12 +668,17 @@ export function createAssetService(pool, options = {}) {
const now = Date.now();
const visibility = shouldPublishImage ? 'public_candidate' : visibilityForCategory(upload.category_code);
const indexedWorkspacePath = resolveAssetWorkspaceRelativePath({
categoryCode: upload.category_code,
originalFilename: storedFilename,
explicitPath: workspaceRelativePath,
});
await conn.query(
`INSERT INTO h5_assets
(id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
display_name, current_version_id, size_bytes, checksum, risk_level, visibility,
display_name, workspace_relative_path, current_version_id, size_bytes, checksum, risk_level, visibility,
status, source_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'upload', ?, ?)`,
[
assetId,
userId,
@@ -666,6 +688,7 @@ export function createAssetService(pool, options = {}) {
storedMimeType,
storedFilename,
upload.filename,
indexedWorkspacePath,
versionId,
storedSizeBytes,
storedChecksum,
@@ -1191,12 +1214,17 @@ export function createAssetService(pool, options = {}) {
}
const now = Date.now();
const visibility = shouldPublishImage ? 'public_candidate' : visibilityForCategory(category.category_code);
const indexedWorkspacePath = resolveAssetWorkspaceRelativePath({
categoryCode: category.category_code,
originalFilename: storedFilename,
explicitPath: workspaceRelativePath,
});
await conn.query(
`INSERT INTO h5_assets
(id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
display_name, current_version_id, size_bytes, checksum, risk_level, visibility,
display_name, workspace_relative_path, current_version_id, size_bytes, checksum, risk_level, visibility,
status, source_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
assetId,
userId,
@@ -1206,6 +1234,7 @@ export function createAssetService(pool, options = {}) {
storedMimeType,
storedFilename,
displayName || path.basename(storedFilename),
indexedWorkspacePath,
versionId,
storedSizeBytes,
storedChecksum,
@@ -1370,6 +1399,33 @@ export function createAssetService(pool, options = {}) {
return expired;
};
const findAssetByWorkspaceRelativePath = async (userId, relativePath) => {
const normalized = normalizeWorkspaceRelativePath(relativePath);
if (!normalized) return null;
const [rows] = await pool.query(
`SELECT a.*, c.category_code, av.storage_key
FROM h5_assets a
JOIN h5_space_categories c ON c.id = a.category_id AND c.user_id = a.user_id
JOIN h5_asset_versions av ON av.id = a.current_version_id
WHERE a.user_id = ?
AND a.status <> 'deleted'
AND (
a.workspace_relative_path = ?
OR (
(a.workspace_relative_path IS NULL OR a.workspace_relative_path = '')
AND (
a.original_filename = ?
OR CONCAT(c.category_code, '/', a.original_filename) = ?
)
)
)
ORDER BY a.updated_at DESC, a.id DESC
LIMIT 1`,
[userId, normalized, normalized, normalized],
);
return rows[0] ? assetResponse(rows[0]) : null;
};
return {
storageRoot,
createUpload,
@@ -1379,6 +1435,7 @@ export function createAssetService(pool, options = {}) {
cancelUpload,
createChatAsset,
listAssets,
findAssetByWorkspaceRelativePath,
deleteAsset,
readAsset,
readPublicAsset,
@@ -1440,5 +1497,6 @@ export const assetInternals = {
normalizeFilename,
expectedMimeType,
assetTypeForMime,
publicUrlForAsset,
registerUploadArtifactForConversation,
};
+27
View File
@@ -689,3 +689,30 @@ test('createUpload rejects another users category id', async () => {
(error) => error.code === 'category_not_found',
);
});
test('publicUrlForAsset uses MindSpace public URL for workspace-synced images', () => {
const url = assetInternals.publicUrlForAsset({
id: 'asset-1',
user_id: 'user-1',
category_code: 'public',
mime_type: 'image/png',
original_filename: 'mapo-tofu-recipe.thumbnail.png',
workspace_relative_path: 'public/mapo-tofu-recipe.thumbnail.png',
storage_key: 'workspace://user-1/public/mapo-tofu-recipe.thumbnail.png',
});
assert.match(url, /\/MindSpace\/user-1\/public\/mapo-tofu-recipe\.thumbnail\.png$/);
});
test('publicUrlForAsset does not route workspace image paths through imgproxy', () => {
const url = assetInternals.publicUrlForAsset({
id: 'asset-2',
user_id: 'user-1',
category_code: 'public',
mime_type: 'image/jpeg',
original_filename: 'images/2026-07-04/photo.jpg',
workspace_relative_path: 'public/images/2026-07-04/photo.jpg',
storage_key: 'workspace://user-1/public/images/2026-07-04/photo.jpg',
});
assert.match(url, /\/MindSpace\/user-1\/public\/images\/2026-07-04\/photo\.jpg$/);
assert.doesNotMatch(url, /20081|workspace:\/\//);
});
+221 -9
View File
@@ -1,3 +1,7 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
export function slugFromPageTitle(title, pageId) {
const ascii = String(title ?? '')
.normalize('NFKC')
@@ -126,9 +130,21 @@ export async function ensureChatPageForPlaza({
});
}
export async function ensurePagePublicationForPlaza({ userId, page, mindSpacePublications }) {
export async function ensurePagePublicationForPlaza({
userId,
page,
mindSpacePublications,
refreshIfOnline = true,
}) {
const existing = await mindSpacePublications.getCurrent(userId, page.id);
if (existing?.id) return existing;
if (existing?.id) {
if (refreshIfOnline && page.pageType === 'html' && mindSpacePublications.refreshOnlinePublicationHtml) {
return (
(await mindSpacePublications.refreshOnlinePublicationHtml(userId, page.id)) ?? existing
);
}
return existing;
}
const preferredSlug = slugFromPageTitle(page.title, page.id);
return mindSpacePublications.publish(userId, page.id, {
@@ -139,6 +155,80 @@ export async function ensurePagePublicationForPlaza({ userId, page, mindSpacePub
});
}
function throwAlreadyPublished(postId) {
throw Object.assign(new Error('该内容已发布到广场'), {
code: 'ALREADY_PUBLISHED',
details: { post_id: postId },
});
}
async function assertWorkspacePathNotAlreadyOnPlaza({ userId, relativePath, mindSpacePages }) {
if (!mindSpacePages?.findPlazaContextByWorkspacePath) return null;
const context = await mindSpacePages.findPlazaContextByWorkspacePath(userId, relativePath);
if (
context?.postId &&
['published', 'pending_review'].includes(String(context.postStatus ?? ''))
) {
throwAlreadyPublished(context.postId);
}
return context;
}
async function ensureWorkspaceHtmlPageForPlaza({
userId,
relativePath,
content,
title,
mindSpacePages,
}) {
const normalized = normalizePublicHtmlRelativePath(relativePath);
let page = await mindSpacePages.findPageByRelativePath(userId, normalized);
const pageInput = {
title: title ?? page?.title ?? 'MindSpace 页面',
content,
contentFormat: 'html',
pageType: 'html',
categoryCode: 'draft',
};
if (page) {
return mindSpacePages.updatePage(userId, page.id, {
...pageInput,
expectedVersion: page.versionNo,
});
}
return mindSpacePages.createFromChat(userId, pageInput, {
snapshot: {
content_mode: 'static_html',
relative_path: normalized,
},
});
}
async function publishPageToPlaza({ userId, page, mindSpacePublications, plazaPosts }) {
const publication = await ensurePagePublicationForPlaza({
userId,
page,
mindSpacePublications,
refreshIfOnline: true,
});
const { post } = await ensurePlazaPostForPublication({
userId,
publication,
plazaPosts,
});
return {
pageId: page.id,
publicationId: publication.id,
publicUrl: publication.publicUrl ?? publication.public_url ?? null,
post: {
id: post.id,
status: post.status,
},
};
}
export async function ensurePlazaPostForPublication({
userId,
publication,
@@ -173,6 +263,7 @@ export async function quickPlazaFromChat({
throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' });
}
const { analysis } = bundle;
const page = await ensureChatPageForPlaza({
userId: user.id,
bundle,
@@ -182,25 +273,146 @@ export async function quickPlazaFromChat({
skipThumbnail: true,
});
const publication = await ensurePagePublicationForPlaza({
if (analysis.contentMode === 'static_html' && analysis.relativePath) {
await assertWorkspacePathNotAlreadyOnPlaza({
userId: user.id,
relativePath: analysis.relativePath,
mindSpacePages,
});
}
return publishPageToPlaza({
userId: user.id,
page,
mindSpacePublications,
});
const { post } = await ensurePlazaPostForPublication({
userId: user.id,
publication,
plazaPosts,
});
}
function titleFromPublicHtml(content, relativePath) {
const match = String(content ?? '').match(/<title[^>]*>([^<]+)<\/title>/i);
const fromTitle = match?.[1]?.replace(/\s+/g, ' ').trim();
if (fromTitle) return fromTitle;
const basename = path.basename(String(relativePath ?? ''), '.html');
return basename || 'MindSpace 页面';
}
function normalizePublicHtmlRelativePath(relativePath) {
const normalized = normalizeWorkspaceRelativePath(relativePath);
if (!normalized || !normalized.startsWith('public/') || !normalized.toLowerCase().endsWith('.html')) {
throw Object.assign(new Error('仅支持 workspace 公开 HTML 页面'), { code: 'invalid_public_html_path' });
}
return normalized;
}
export async function getQuickPlazaFromPublicHtmlStatus({
user,
relativePath,
mindSpacePages,
mindSpacePublications,
plazaPosts,
}) {
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' });
}
const normalized = normalizePublicHtmlRelativePath(relativePath);
const context = await mindSpacePages.findPlazaContextByWorkspacePath?.(user.id, normalized);
if (
context?.postId &&
['published', 'pending_review'].includes(String(context.postStatus ?? ''))
) {
return {
published: true,
pageId: context.pageId,
publicationId: context.publicationId,
post: {
id: context.postId,
status: context.postStatus,
},
};
}
const page = context?.pageId
? { id: context.pageId }
: await mindSpacePages.findPageByRelativePath(user.id, normalized);
if (!page) {
return { published: false };
}
const publication =
context?.publicationId != null
? { id: context.publicationId }
: await mindSpacePublications.getCurrent(user.id, page.id);
if (!publication?.id) {
return { published: false, pageId: page.id };
}
const post = await plazaPosts.findPostByPublicationId(publication.id, { userId: user.id });
if (!post || !['published', 'pending_review'].includes(post.status)) {
return {
published: false,
pageId: page.id,
publicationId: publication.id,
};
}
return {
published: true,
pageId: page.id,
publicationId: publication.id,
publicUrl: publication.publicUrl ?? publication.public_url ?? null,
post: {
id: post.id,
status: post.status,
},
};
}
export async function quickPlazaFromPublicHtml({
user,
relativePath,
mindSpacePages,
mindSpacePublications,
plazaPosts,
publishDir,
}) {
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
throw Object.assign(new Error('Plaza 或 MindSpace 未启用'), { code: 'plaza_unavailable' });
}
const normalized = normalizePublicHtmlRelativePath(relativePath);
await assertWorkspacePathNotAlreadyOnPlaza({
userId: user.id,
relativePath: normalized,
mindSpacePages,
});
const resolvedRoot = path.resolve(publishDir);
const filePath = path.resolve(publishDir, normalized);
if (filePath !== resolvedRoot && !filePath.startsWith(`${resolvedRoot}${path.sep}`)) {
throw Object.assign(new Error('路径越界'), { code: 'invalid_public_html_path' });
}
let content;
try {
content = await fs.readFile(filePath, 'utf8');
} catch {
throw Object.assign(new Error('页面文件不存在'), { code: 'static_page_not_found' });
}
const page = await ensureWorkspaceHtmlPageForPlaza({
userId: user.id,
relativePath: normalized,
content,
title: titleFromPublicHtml(content, normalized),
mindSpacePages,
});
return publishPageToPlaza({
userId: user.id,
page,
mindSpacePublications,
plazaPosts,
});
}
+67
View File
@@ -14,3 +14,70 @@ test('slugFromPageTitle falls back to page when title has no ascii', () => {
test('slugFromPageTitle keeps ascii slug and page suffix', () => {
assert.equal(slugFromPageTitle('Travel Guide 2026', 'abcd-1234-5678-9012'), 'travel-guide-2026-abcd1234');
});
test('quickPlazaFromPublicHtml rejects non-public html paths', async () => {
await assert.rejects(
() =>
import('./mindspace-chat-plaza.mjs').then(({ quickPlazaFromPublicHtml }) =>
quickPlazaFromPublicHtml({
user: { id: 'user-1' },
relativePath: 'pages/demo.html',
mindSpacePages: {},
mindSpacePublications: {},
plazaPosts: {},
publishDir: '/tmp/publish',
}),
),
(error) => error?.code === 'invalid_public_html_path',
);
});
test('getQuickPlazaFromPublicHtmlStatus reports published plaza posts', async () => {
const { getQuickPlazaFromPublicHtmlStatus } = await import('./mindspace-chat-plaza.mjs');
const result = await getQuickPlazaFromPublicHtmlStatus({
user: { id: 'user-1' },
relativePath: 'public/demo.html',
mindSpacePages: {
async findPlazaContextByWorkspacePath(userId, relativePath) {
assert.equal(userId, 'user-1');
assert.equal(relativePath, 'public/demo.html');
return {
pageId: 'page-1',
publicationId: 'pub-1',
postId: 'post-1',
postStatus: 'published',
};
},
},
mindSpacePublications: {},
plazaPosts: {},
});
assert.equal(result.published, true);
assert.equal(result.post.id, 'post-1');
});
test('quickPlazaFromPublicHtml blocks duplicate plaza posts for the same workspace html', async () => {
const { quickPlazaFromPublicHtml } = await import('./mindspace-chat-plaza.mjs');
await assert.rejects(
() =>
quickPlazaFromPublicHtml({
user: { id: 'user-1' },
relativePath: 'public/demo.html',
mindSpacePages: {
async findPlazaContextByWorkspacePath() {
return {
pageId: 'page-chat',
publicationId: 'pub-chat',
postId: 'post-chat',
postStatus: 'published',
};
},
},
mindSpacePublications: {},
plazaPosts: {},
publishDir: '/tmp/publish',
}),
(error) => error?.code === 'ALREADY_PUBLISHED' && error?.details?.post_id === 'post-chat',
);
});
+14 -6
View File
@@ -33,13 +33,19 @@ async function findPageByWorkspaceRelativePath(pool, userId, relativePath) {
const [rows] = await pool.query(
`SELECT p.id, p.updated_at
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
WHERE p.user_id = ?
AND p.status <> 'deleted'
AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
AND (
p.workspace_relative_path = ?
OR (
(p.workspace_relative_path IS NULL OR p.workspace_relative_path = '')
AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
)
)
ORDER BY p.updated_at DESC, p.id DESC
LIMIT 1`,
[userId, relativePath],
[userId, relativePath, relativePath],
);
const row = rows[0];
if (!row) return null;
@@ -58,16 +64,18 @@ function parseJsonColumn(value, fallback = {}) {
async function loadIndexedWorkspacePages(pool, userId) {
const [rows] = await pool.query(
`SELECT p.id, p.updated_at, p.source_asset_id, pv.source_snapshot_json
`SELECT p.id, p.updated_at, p.source_asset_id, p.workspace_relative_path, pv.source_snapshot_json
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
WHERE p.user_id = ? AND p.status <> 'deleted'`,
[userId],
);
const byPath = new Map();
for (const row of rows) {
const snapshot = parseJsonColumn(row.source_snapshot_json);
const relativePath = normalizePublicHtmlPath(snapshot.relative_path);
const relativePath =
normalizePublicHtmlPath(row.workspace_relative_path) ??
normalizePublicHtmlPath(snapshot.relative_path);
if (relativePath) {
byPath.set(relativePath, {
id: row.id,
+48 -5
View File
@@ -446,6 +446,8 @@ export function createPageService(pool, options = {}) {
source.createdAt != null && Number.isFinite(Number(source.createdAt))
? Math.floor(Number(source.createdAt))
: Date.now();
const pageWorkspaceRelativePath =
normalizeWorkspaceRelativePath(source.snapshot?.relative_path) || null;
const contentAssetType = normalized.contentFormat === 'html' ? 'html' : 'markdown';
const contentMimeType =
@@ -498,8 +500,8 @@ export function createPageService(pool, options = {}) {
`INSERT INTO h5_page_records
(id, user_id, space_id, category_id, source_session_id, source_message_id,
source_asset_id, title, summary, page_type, template_id, draft_content_ref,
current_version_id, status, visibility, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 'private', ?, ?)`,
workspace_relative_path, current_version_id, status, visibility, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft', 'private', ?, ?)`,
[
pageId,
userId,
@@ -513,6 +515,7 @@ export function createPageService(pool, options = {}) {
normalized.pageType,
normalized.templateId,
stored.storageKey,
pageWorkspaceRelativePath,
pageVersionId,
now,
now,
@@ -539,7 +542,7 @@ export function createPageService(pool, options = {}) {
await conn.query(
`UPDATE h5_page_records
SET title = ?, summary = ?, page_type = ?, template_id = ?,
draft_content_ref = ?, current_version_id = ?, updated_at = ?
draft_content_ref = ?, workspace_relative_path = ?, current_version_id = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[
normalized.title,
@@ -547,6 +550,7 @@ export function createPageService(pool, options = {}) {
normalized.pageType,
normalized.templateId,
stored.storageKey,
pageWorkspaceRelativePath,
pageVersionId,
now,
pageId,
@@ -659,14 +663,52 @@ export function createPageService(pool, options = {}) {
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
WHERE p.user_id = ?
AND p.status <> 'deleted'
AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
AND (
p.workspace_relative_path = ?
OR (
(p.workspace_relative_path IS NULL OR p.workspace_relative_path = '')
AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
)
)
ORDER BY p.updated_at DESC, p.id DESC
LIMIT 1`,
[userId, normalized],
[userId, normalized, normalized],
);
return rows[0] ? pageResponse(rows[0]) : null;
};
const findPlazaContextByWorkspacePath = async (userId, relativePath) => {
const normalized = normalizeWorkspaceRelativePath(relativePath);
if (!normalized) return null;
const [rows] = await pool.query(
`SELECT p.id AS page_id, pr.id AS publication_id, pp.id AS post_id, pp.status AS post_status
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
LEFT JOIN h5_publish_records pr ON pr.page_id = p.id AND pr.user_id = p.user_id AND pr.status = 'online'
LEFT JOIN plaza_posts pp ON pp.publication_id = pr.id AND pp.user_id = p.user_id AND pp.status != 'hidden'
WHERE p.user_id = ?
AND p.status <> 'deleted'
AND (
p.workspace_relative_path = ?
OR JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
)
ORDER BY
CASE pp.status WHEN 'published' THEN 0 WHEN 'pending_review' THEN 1 ELSE 2 END,
p.updated_at DESC,
p.id DESC
LIMIT 1`,
[userId, normalized, normalized],
);
const row = rows[0];
if (!row) return null;
return {
pageId: row.page_id,
publicationId: row.publication_id ?? null,
postId: row.post_id ?? null,
postStatus: row.post_status ?? null,
};
};
const listPages = async (userId, filters = {}) => {
const clauses = [`p.user_id = ?`, `p.status <> 'deleted'`];
const params = [userId];
@@ -1448,6 +1490,7 @@ export function createPageService(pool, options = {}) {
findPageBySourceAsset,
findPageBySourceMessage,
findPageByRelativePath,
findPlazaContextByWorkspacePath,
getPage,
getDeletePreview,
deletePage,
+205
View File
@@ -0,0 +1,205 @@
import crypto from 'node:crypto';
const PUBLIC_FILE_SHARE_SCRIPT = `(function(){
var root=document.querySelector('[data-mindspace-public-share]');
if(!root)return;
var shareButton=root.querySelector('[data-action="share"]');
var captureButton=root.querySelector('[data-action="capture"]');
var plazaButton=root.querySelector('[data-action="plaza"]');
var dialog=root.querySelector('[data-mindspace-public-share-dialog]');
var plazaCancel=root.querySelector('[data-action="plaza-cancel"]');
var plazaConfirm=root.querySelector('[data-action="plaza-confirm"]');
var plazaAlreadyClose=root.querySelector('[data-action="plaza-already-close"]');
var plazaSuccessClose=root.querySelector('[data-action="plaza-success-close"]');
var plazaErrorClose=root.querySelector('[data-action="plaza-error-close"]');
var plazaAlreadyLink=root.querySelector('[data-plaza-already-link]');
var plazaSuccessLink=root.querySelector('[data-plaza-success-link]');
var plazaViews={
confirm:root.querySelector('[data-plaza-view="confirm"]'),
loading:root.querySelector('[data-plaza-view="loading"]'),
already:root.querySelector('[data-plaza-view="already"]'),
success:root.querySelector('[data-plaza-view="success"]'),
error:root.querySelector('[data-plaza-view="error"]')
};
var status=root.querySelector('small');
var timer=null;
var publishing=false;
var checking=false;
function setStatus(text,err,persist){
if(!status)return;
status.textContent=text||'';
status.classList.toggle('is-error',!!err);
if(timer)clearTimeout(timer);
if(text&&!persist)timer=setTimeout(function(){status.textContent='';status.classList.remove('is-error');},3200);
}
function cleanUrl(){var url=new URL(location.href);url.hash='';url.searchParams.delete('download');url.searchParams.delete('export');return url.toString();}
function withParam(url,key,value){var next=new URL(url);next.searchParams.set(key,value);return next.toString();}
function fallbackCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();var ok=document.execCommand('copy');ta.remove();if(!ok)throw new Error('复制失败');}
async function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(text);return;}catch(e){}}fallbackCopy(text);}
function workspaceRelativePath(){var parts=location.pathname.split('/').filter(Boolean);if(parts[0]==='MindSpace'&&parts.length>=3)return parts.slice(2).join('/');return'';}
function showPlazaView(name){Object.keys(plazaViews).forEach(function(key){var view=plazaViews[key];if(view)view.hidden=key!==name;});}
function openPlazaDialog(){if(!dialog)return;dialog.hidden=false;showPlazaView('loading');void refreshPlazaDialog();}
function closePlazaDialog(){if(dialog)dialog.hidden=true;showPlazaView('confirm');}
function apiErrorBody(payload){return payload&&(payload.error||payload)||{};}
function resolvePlazaUrl(payload){var body=apiErrorBody(payload);if(body.plaza_url)return body.plaza_url;var details=body.details;if(details&&details.plaza_url)return details.plaza_url;var postId=details&&(details.post_id||details.postId);if(!postId)return'';return location.origin.replace(/:\\d+$/,'')+':3001/plaza/p/'+encodeURIComponent(postId);}
function showAlreadyPublished(plazaUrl){showPlazaView('already');if(plazaAlreadyLink){if(plazaUrl){plazaAlreadyLink.href=plazaUrl;plazaAlreadyLink.hidden=false;}else{plazaAlreadyLink.hidden=true;}}}
async function refreshPlazaDialog(){
if(checking)return;
var relativePath=workspaceRelativePath();
if(!relativePath){showPlazaView('error');return;}
checking=true;
try{
var res=await fetch('/api/mindspace/v1/pages/quick-plaza-from-public-html/status?relative_path='+encodeURIComponent(relativePath),{credentials:'same-origin'});
var payload=await res.json().catch(function(){return{};});
if(!res.ok){
if(res.status===401)throw new Error('请先登录 MindSpace');
var errBody=apiErrorBody(payload);
throw new Error(errBody.message||'无法检查发布状态');
}
var data=payload.data||{};
if(data.published){showAlreadyPublished(data.plaza_url||resolvePlazaUrl({details:{post_id:data.post&&data.post.id}}));return;}
showPlazaView('confirm');
}catch(e){
showPlazaView('error');
setStatus(e&&e.message?e.message:'无法检查发布状态',true);
}
finally{checking=false;}
}
shareButton&&shareButton.addEventListener('click',async function(){var url=cleanUrl();var title=document.title||'MindSpace';try{if(navigator.share){await navigator.share({title:title,url:url});setStatus('已打开分享');return;}await copyText(url);setStatus('链接已复制');}catch(e){setStatus(e&&e.message?e.message:'分享失败',true);}});
captureButton&&captureButton.addEventListener('click',function(){setStatus('正在生成长图,请稍候');var link=document.createElement('a');link.href=withParam(cleanUrl(),'download','long-image');link.download='';document.body.appendChild(link);link.click();link.remove();});
plazaButton&&plazaButton.addEventListener('click',openPlazaDialog);
plazaCancel&&plazaCancel.addEventListener('click',closePlazaDialog);
plazaAlreadyClose&&plazaAlreadyClose.addEventListener('click',closePlazaDialog);
plazaSuccessClose&&plazaSuccessClose.addEventListener('click',closePlazaDialog);
plazaErrorClose&&plazaErrorClose.addEventListener('click',closePlazaDialog);
dialog&&dialog.addEventListener('click',function(event){if(event.target===dialog)closePlazaDialog();});
plazaConfirm&&plazaConfirm.addEventListener('click',async function(){
if(publishing)return;
var relativePath=workspaceRelativePath();
if(!relativePath){setStatus('无法识别页面路径',true);closePlazaDialog();return;}
publishing=true;
plazaConfirm.disabled=true;
showPlazaView('loading');
try{
var res=await fetch('/api/mindspace/v1/pages/quick-plaza-from-public-html',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json'},body:JSON.stringify({relative_path:relativePath})});
var payload=await res.json().catch(function(){return{};});
if(!res.ok){
if(res.status===401)throw new Error('请先登录 MindSpace');
var errBody=apiErrorBody(payload);
var plazaUrl=resolvePlazaUrl(payload);
if(errBody.code==='ALREADY_PUBLISHED'){showAlreadyPublished(plazaUrl);setStatus('该内容已在广场发布',false,true);return;}
throw new Error(errBody.message||'发布失败');
}
var data=payload.data||{};
var successUrl=data.plaza_url||resolvePlazaUrl({details:{post_id:data.post&&data.post.id}});
showPlazaView('success');
if(plazaSuccessLink&&successUrl){plazaSuccessLink.href=successUrl;plazaSuccessLink.hidden=false;}
else if(plazaSuccessLink){plazaSuccessLink.hidden=true;}
setStatus('发布成功',false,true);
if(successUrl)window.open(successUrl,'_blank','noopener,noreferrer');
}catch(e){
showPlazaView('error');
setStatus(e&&e.message?e.message:'发布失败',true);
}finally{publishing=false;plazaConfirm.disabled=false;}
});
})();`;
const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto
.createHash('sha256')
.update(PUBLIC_FILE_SHARE_SCRIPT)
.digest('base64');
export function injectPublicFileShareButton(html) {
const source = String(html ?? '');
if (!source || source.includes('data-mindspace-public-share')) {
return { html: source, scriptHashes: [] };
}
const markup = `
<style id="mindspace-public-share-style">
[data-mindspace-public-share]{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;gap:6px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share] div[data-mindspace-public-share-actions]{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
[data-mindspace-public-share] button{border:0;border-radius:999px;padding:10px 15px;background:#2f6f57;color:#fff;font-size:14px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.18);cursor:pointer}
[data-mindspace-public-share] button[data-action="capture"]{background:#8f6b2f}
[data-mindspace-public-share] button[data-action="plaza"]{background:#5a4a7b}
[data-mindspace-public-share] button:active{transform:translateY(1px)}
[data-mindspace-public-share] button:disabled{opacity:.65;cursor:wait}
[data-mindspace-public-share] small{min-height:18px;max-width:220px;border-radius:999px;padding:4px 9px;background:rgba(255,255,255,.92);color:#245845;font-size:12px;text-align:right;box-shadow:0 4px 14px rgba(0,0,0,.1)}
[data-mindspace-public-share] small:empty{display:none}
[data-mindspace-public-share] small.is-error{color:#8b2d20}
[data-mindspace-public-share-dialog]{position:fixed;inset:0;z-index:2147483646;display:grid;place-items:center;padding:20px;background:rgba(7,12,10,.72);backdrop-filter:blur(8px)}
[data-mindspace-public-share-dialog][hidden]{display:none!important}
[data-mindspace-public-share-dialog-panel]{width:min(360px,100%);padding:24px;border-radius:22px;background:rgba(18,24,32,.96) !important;border:1px solid rgba(255,255,255,.12);box-shadow:0 24px 80px rgba(0,0,0,.38);text-align:center;color:#fff !important;color-scheme:dark;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share-dialog-panel] h3{margin:0 0 10px;font-size:18px;font-weight:600;color:#fff !important}
[data-mindspace-public-share-dialog-panel] p{margin:0 0 16px;font-size:14px;line-height:1.5;color:rgba(255,255,255,.86) !important}
[data-mindspace-public-share-dialog-panel] [data-plaza-view][hidden]{display:none!important}
[data-mindspace-public-share-dialog-actions]{display:flex;justify-content:center;gap:10px;flex-wrap:wrap}
[data-mindspace-public-share-dialog-actions] button{border-radius:999px;padding:8px 18px;font:inherit;font-size:14px;cursor:pointer;box-shadow:none}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-cancel"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-already-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-success-close"],[data-mindspace-public-share-dialog-actions] [data-action="plaza-error-close"]{border:1px solid rgba(255,255,255,.18) !important;background:rgba(255,255,255,.08) !important;color:#fff !important}
[data-mindspace-public-share-dialog-actions] [data-action="plaza-confirm"]{border:1px solid rgba(47,111,87,.35) !important;background:#2f6f57 !important;color:#fff !important;font-weight:600}
[data-mindspace-public-share-dialog-link]{display:inline-block;margin:0 0 14px;font-size:14px;color:#8fd4b4 !important;text-decoration:underline}
[data-mindspace-public-share-dialog-link][hidden]{display:none!important}
@media(max-width:640px){[data-mindspace-public-share]{right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px))}[data-mindspace-public-share] button{padding:9px 13px;font-size:13px}}
</style>
<div data-mindspace-public-share>
<div data-mindspace-public-share-dialog hidden>
<div data-mindspace-public-share-dialog-panel role="dialog" aria-modal="true" aria-labelledby="mindspace-public-plaza-title">
<div data-plaza-view="confirm">
<h3 id="mindspace-public-plaza-title">发布到 Plaza</h3>
<p>将把当前页面发布并推送到发现广场,确认继续?</p>
<div data-mindspace-public-share-dialog-actions">
<button type="button" data-action="plaza-cancel">取消</button>
<button type="button" data-action="plaza-confirm">确认发布</button>
</div>
</div>
<div data-plaza-view="loading" hidden>
<h3>请稍候</h3>
<p>正在检查发布状态…</p>
</div>
<div data-plaza-view="already" hidden>
<h3>已在广场发布</h3>
<p>该内容已发布到 Plaza,无法重复发布。如需重新发布,请先在广场删除原帖。</p>
<a data-plaza-already-link data-mindspace-public-share-dialog-link href="#" target="_blank" rel="noopener noreferrer">查看已有帖子</a>
<div data-mindspace-public-share-dialog-actions">
<button type="button" data-action="plaza-already-close">知道了</button>
</div>
</div>
<div data-plaza-view="success" hidden>
<h3>发布成功</h3>
<p>内容已推送到 Plaza 广场。</p>
<a data-plaza-success-link data-mindspace-public-share-dialog-link href="#" target="_blank" rel="noopener noreferrer">查看 Plaza 帖子</a>
<div data-mindspace-public-share-dialog-actions">
<button type="button" data-action="plaza-success-close">关闭</button>
</div>
</div>
<div data-plaza-view="error" hidden>
<h3>操作失败</h3>
<p>请稍后重试,或先登录 MindSpace。</p>
<div data-mindspace-public-share-dialog-actions">
<button type="button" data-action="plaza-error-close">关闭</button>
</div>
</div>
</div>
</div>
<small aria-live="polite"></small>
<div data-mindspace-public-share-actions>
<button type="button" data-action="plaza">发布 Plaza</button>
<button type="button" data-action="capture">保存长图</button>
<button type="button" data-action="share">公开分享</button>
</div>
</div>
<script>${PUBLIC_FILE_SHARE_SCRIPT}</script>`;
if (/<\/body>/i.test(source)) {
return {
html: source.replace(/<\/body>/i, `${markup}</body>`),
scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH],
};
}
return {
html: `${source}${markup}`,
scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH],
};
}
export const publicShareWidgetInternals = {
PUBLIC_FILE_SHARE_SCRIPT_HASH,
};
+20
View File
@@ -0,0 +1,20 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { injectPublicFileShareButton } from './mindspace-public-share-widget.mjs';
test('injectPublicFileShareButton adds plaza entry and confirm dialog', () => {
const result = injectPublicFileShareButton('<!doctype html><html><body><h1>Hi</h1></body></html>');
assert.match(result.html, /data-action="plaza"/);
assert.match(result.html, /发布到 Plaza/);
assert.match(result.html, /data-action="plaza-confirm"/);
assert.match(result.html, /quick-plaza-from-public-html/);
assert.match(result.html, /quick-plaza-from-public-html\/status/);
assert.match(result.html, /data-plaza-view="already"/);
assert.match(result.html, /已在广场发布/);
assert.match(result.html, /data-mindspace-public-share-dialog-panel role="dialog"/);
assert.doesNotMatch(result.html, /data-mindspace-public-share-dialog-panel"/);
assert.match(result.html, /color:#fff !important/);
assert.match(result.html, /apiErrorBody/);
assert.match(result.html, /ALREADY_PUBLISHED/);
assert.equal(result.scriptHashes.length, 1);
});
+130 -8
View File
@@ -250,6 +250,16 @@ function replaceImgproxyStandardImageReferences(html, publicBaseUrl) {
);
}
/** Fix legacy publication HTML that dropped the public/ segment from MindSpace image URLs. */
export function rewriteBrokenMindSpacePublicImageUrls(html) {
const source = String(html ?? '');
if (!source.includes('/images/')) return source;
return source.replace(
/((?:https?:\/\/[^/"'<>?\s,)]+)?\/MindSpace\/[^/"'<>?\s,)]+)\/images\/(\d{4}-\d{2}-\d{2}\/)/gi,
'$1/public/images/$2',
);
}
function workspacePublicAssetRelativeUrl(htmlRelativePath, assetRelativePath) {
const htmlPath = String(htmlRelativePath ?? '').replace(/^\/+/, '') || 'public/index.html';
const assetPath = String(assetRelativePath ?? '').replace(/^\/+/, '');
@@ -262,11 +272,26 @@ export function rewriteWorkspacePublicAssetReferences(html, htmlRelativePath) {
const source = String(html ?? '');
if (!source) return source;
return source.replace(
/public\/((?:images|\.tmp-images)\/[^"'<>@\s)]+(?:\?[^"'<>)\s]*)?)/gi,
/(?<![/:])public\/((?:images|\.tmp-images)\/[^"'<>@\s)]+(?:\?[^"'<>)\s]*)?)/gi,
(value, assetRelativePath) => workspacePublicAssetRelativeUrl(htmlRelativePath, `public/${assetRelativePath}`),
);
}
const PUBLICATION_WORKSPACE_ASSET_PATTERN =
/(["'(\s])(?:\.\.\/)*(?:public\/)?((?:\.tmp-images|images\/\d{4}-\d{2}-\d{2})\/[^"'<>)\s]+)/gi;
/** Rewrite workspace-relative image paths to canonical /u/{slug}/public/... URLs for /u/.../pages/ routes. */
export function rewritePublicationCanonicalAssetUrls(html, ownerSlug) {
const slug = String(ownerSlug ?? '').trim();
if (!slug) return String(html ?? '');
const source = String(html ?? '');
if (!source.includes('.tmp-images/') && !source.includes('images/')) return source;
const base = `/u/${encodeURIComponent(slug)}/public/`;
return source.replace(PUBLICATION_WORKSPACE_ASSET_PATTERN, (_value, prefix, assetPath) => {
return `${prefix}${base}${assetPath}`;
});
}
async function localizePrivateImageReferences({
pool,
userId,
@@ -297,7 +322,9 @@ async function localizePrivateImageReferences({
if (!asset) continue;
if (imgproxySigner) {
replacements.set(assetId, imgproxySigner.buildUrl(imgproxySigner.baseUrl, asset.storage_key, 'display'));
const storageKey = String(asset.storage_key ?? '').trim();
if (!storageKey) continue;
replacements.set(assetId, imgproxySigner.buildUrl(storageKey, 'display'));
} else {
const mimeType = String(asset.mime_type || 'application/octet-stream');
const buffer = await fs.readFile(absoluteStoragePath(asset.storage_key));
@@ -335,6 +362,12 @@ async function prepareHtmlPublishContent({
absoluteStoragePath,
imgproxySigner,
});
// imgproxy URLs embed /users/... storage keys; convert them to public standard images
// before replacePrivateResourceReferences, which would otherwise treat them as private paths.
publishContent = replaceImgproxyStandardImageReferences(
publishContent,
resolvePublicBaseUrl(),
);
const publishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null;
const resolvedHtmlRelativePath =
htmlRelativePath
@@ -344,6 +377,7 @@ async function prepareHtmlPublishContent({
publishDir,
});
publishContent = rewriteWorkspacePublicAssetReferences(publishContent, resolvedHtmlRelativePath);
publishContent = rewritePublicationCanonicalAssetUrls(publishContent, ownerSlug);
const linkIndex = await loadWorkspaceDownloadLinkIndex(pool, userId);
publishContent = rewriteRelativeDownloadLinks(publishContent, {
htmlRelativePath: resolvedHtmlRelativePath,
@@ -483,7 +517,13 @@ export function createPublicationService(pool, options = {}) {
}),
absoluteStoragePath,
h5Root,
imgproxySigner: imgproxySigner ? { buildUrl: (path, preset) => imgproxySigner.buildUrl(imgproxySigner.baseUrl, path, preset) } : null,
imgproxySigner: imgproxySigner
? {
baseUrl: imgproxySigner.baseUrl,
buildUrl: (storagePath, preset = 'display') =>
imgproxySigner.buildUrl(imgproxySigner.baseUrl, storagePath, preset),
}
: null,
});
};
@@ -535,6 +575,18 @@ export function createPublicationService(pool, options = {}) {
input.expiresAt,
accessMode === 'time_limited',
);
const [onlineRows] = await pool.query(
`SELECT id, url_slug, public_url FROM h5_publish_records
WHERE user_id = ? AND page_id = ? AND status = 'online' LIMIT 1`,
[userId, pageId],
);
const currentOnline = onlineRows[0] ?? null;
if (currentOnline) {
throw publicationError('页面已发布,请先下线后再重新发布', 'page_already_online', {
publicationId: currentOnline.id,
publicUrl: currentOnline.public_url,
});
}
const page = await loadVersion(userId, pageId, input.pageVersionId);
if (page.page_version_id !== page.current_version_id) {
throw publicationError('只能发布页面的当前版本', 'invalid_state_transition');
@@ -632,6 +684,17 @@ export function createPublicationService(pool, options = {}) {
limit: publicPageLimit,
});
}
const [onlineLock] = await conn.query(
`SELECT id, public_url FROM h5_publish_records
WHERE user_id = ? AND page_id = ? AND status = 'online' LIMIT 1 FOR UPDATE`,
[userId, pageId],
);
if (onlineLock[0]) {
throw publicationError('页面已发布,请先下线后再重新发布', 'page_already_online', {
publicationId: onlineLock[0].id,
publicUrl: onlineLock[0].public_url,
});
}
const [categories] = await conn.query(
`SELECT id FROM h5_space_categories
WHERE user_id = ? AND space_id = ? AND category_code = 'public' LIMIT 1`,
@@ -712,11 +775,6 @@ export function createPublicationService(pool, options = {}) {
ORDER BY published_at DESC LIMIT 1 FOR UPDATE`,
[userId, pageId],
);
await conn.query(
`UPDATE h5_publish_records SET status = 'offline', offline_at = ?, updated_at = ?
WHERE user_id = ? AND page_id = ? AND status = 'online'`,
[now, now, userId, pageId],
);
await conn.query(
`UPDATE h5_page_versions
SET immutable = 1, bundle_asset_id = ?, security_scan_id = ?
@@ -850,6 +908,67 @@ export function createPublicationService(pool, options = {}) {
return publicationResponse(rows[0]);
};
const refreshOnlinePublicationHtml = async (userId, pageId) => {
const [pubRows] = await pool.query(
`SELECT pr.id, pr.url_slug, pr.public_url, pr.page_version_id, pr.access_mode,
pr.status, pr.view_count, pr.published_at, pr.offline_at, pr.expires_at,
pv.bundle_asset_id, av.id AS asset_version_id, av.storage_key
FROM h5_publish_records pr
JOIN h5_page_records p ON p.id = pr.page_id AND p.user_id = pr.user_id
JOIN h5_page_versions pv ON pv.id = pr.page_version_id
JOIN h5_assets a ON a.id = pv.bundle_asset_id
JOIN h5_asset_versions av ON av.asset_id = a.id AND av.version_no = 1
WHERE pr.page_id = ? AND pr.user_id = ? AND pr.status = 'online'
ORDER BY pr.published_at DESC
LIMIT 1`,
[pageId, userId],
);
const publication = pubRows[0];
if (!publication) return null;
const [pageRows] = await pool.query(
`SELECT current_version_id FROM h5_page_records
WHERE id = ? AND user_id = ? AND status <> 'deleted'
LIMIT 1`,
[pageId, userId],
);
const currentVersionId = pageRows[0]?.current_version_id;
if (!currentVersionId) {
throw publicationError('页面不存在', 'page_not_found');
}
const page = await loadVersion(userId, pageId, currentVersionId);
if (page.page_type !== 'html') {
return publicationResponse(publication);
}
const ownerSlug = await loadOwnerSlug(userId);
const publishContent = await preparePublishContent(page, ownerSlug, publication.url_slug);
const html = pageInternals.renderPublicationHtml({ ...page, content: publishContent });
const htmlBytes = Buffer.byteLength(html);
const checksum = crypto.createHash('sha256').update(html).digest('hex');
const writtenPath = absoluteStoragePath(publication.storage_key);
await fs.mkdir(path.dirname(writtenPath), { recursive: true });
await fs.writeFile(writtenPath, html);
const now = Date.now();
await pool.query(
`UPDATE h5_assets
SET size_bytes = ?, checksum = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[htmlBytes, checksum, now, publication.bundle_asset_id, userId],
);
await pool.query(
`UPDATE h5_asset_versions
SET size_bytes = ?, checksum = ?
WHERE id = ?`,
[htmlBytes, checksum, publication.asset_version_id],
);
return publicationResponse({
...publication,
updated_at: now,
});
};
const updatePublicationStatus = async (userId, publicationId, { accessMode, expiresAt }) => {
const conn = await pool.getConnection();
try {
@@ -1128,6 +1247,7 @@ export function createPublicationService(pool, options = {}) {
check,
publish,
getCurrent,
refreshOnlinePublicationHtml,
getPublicHomepage,
getStats,
offline,
@@ -1231,8 +1351,10 @@ export const publicationInternals = {
publicHomepageResponse,
buildPublicationThumbnailFallback,
replaceImgproxyStandardImageReferences,
rewriteBrokenMindSpacePublicImageUrls,
workspacePublicAssetRelativeUrl,
rewriteWorkspacePublicAssetReferences,
rewritePublicationCanonicalAssetUrls,
localizePrivateImageReferences,
collectPublicationCompanionArtifacts,
prepareHtmlPublishContent,
+212 -9
View File
@@ -248,6 +248,104 @@ test('prepareHtmlPublishContent inlines owned private image assets before scanni
}
});
test('prepareHtmlPublishContent signs private image assets when imgproxy signer is enabled', async () => {
const storageKey = 'users/user-1/images/2026-07-03/demo-asset-1.jpg';
const pool = {
async query(sql, params) {
if (sql.includes('original_filename')) {
return [[]];
}
assert.match(sql, /h5_assets/);
assert.equal(params[0], 'user-1');
assert.deepEqual(params[1], ['asset-1']);
return [
[
{
id: 'asset-1',
mime_type: 'image/png',
storage_key: storageKey,
},
],
];
},
};
const imgproxySigner = {
baseUrl: 'https://img.example.test',
buildUrl: (baseUrl, storagePath, preset = 'display') => {
assert.equal(baseUrl, 'https://img.example.test');
assert.equal(storagePath, storageKey);
assert.equal(preset, 'display');
return `https://img.example.test/sig/plain/local:///${storagePath}`;
},
};
const prepared = await publicationInternals.prepareHtmlPublishContent({
pool,
userId: 'user-1',
html: '<!doctype html><img src="/api/mindspace/v1/assets/asset-1/download?inline=1&v=1">',
ownerSlug: 'john',
urlSlug: 'night-market-essay',
absoluteStoragePath: () => {
throw new Error('should not read private storage when imgproxy is enabled');
},
imgproxySigner: {
baseUrl: imgproxySigner.baseUrl,
buildUrl: (storagePath, preset = 'display') =>
imgproxySigner.buildUrl(imgproxySigner.baseUrl, storagePath, preset),
},
});
assert.doesNotMatch(prepared, /\/api\/mindspace\/v1\/assets\//);
assert.doesNotMatch(prepared, /thumbnail\.png/);
assert.doesNotMatch(prepared, /plain\/local:\/\//);
});
test('prepareHtmlPublishContent converts signed private images to public standard urls', async () => {
const storageKey =
'users/user-1/images/2026-07-03/60270b88dda7b7ccb8a49d8e2746e80b-asset-1.jpg';
const pool = {
async query(sql, params) {
if (sql.includes('original_filename')) {
return [[]];
}
assert.match(sql, /h5_assets/);
assert.equal(params[0], 'user-1');
assert.deepEqual(params[1], ['asset-1']);
return [
[
{
id: 'asset-1',
mime_type: 'image/jpeg',
storage_key: storageKey,
},
],
];
},
};
const imgproxySigner = {
baseUrl: 'https://img.example.test',
buildUrl: (storagePath, preset = 'display') =>
`https://img.example.test/sig/rs:fit:1280:1280:0/q:85/plain/local:///${storagePath}@${preset}`,
};
const prepared = await publicationInternals.prepareHtmlPublishContent({
pool,
userId: 'user-1',
html: '<!doctype html><img src="/api/mindspace/v1/assets/asset-1/download?inline=1&v=1">',
ownerSlug: 'john',
urlSlug: 'night-market-essay',
absoluteStoragePath: () => {
throw new Error('should not read private storage when imgproxy is enabled');
},
imgproxySigner,
});
assert.match(prepared, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-07-03\//);
assert.doesNotMatch(prepared, /\/api\/mindspace\/v1\/assets\//);
assert.doesNotMatch(prepared, /thumbnail\.png/);
assert.doesNotMatch(prepared, /plain\/local:\/\//);
});
test('prepareHtmlPublishContent rewrites imgproxy local image urls to public standard images', async () => {
const prepared = await publicationInternals.prepareHtmlPublishContent({
pool: {
@@ -264,7 +362,7 @@ test('prepareHtmlPublishContent rewrites imgproxy local image urls to public sta
},
});
assert.match(prepared, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/images\/2026-06-29\/hero\.jpg/);
assert.match(prepared, /https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-06-29\/hero\.jpg/);
assert.doesNotMatch(prepared, /plain\/local:\/\//);
});
@@ -544,12 +642,44 @@ test('prepareHtmlPublishContent rewrites imgproxy urls in srcset and css url con
},
});
assert.match(prepared, /\/MindSpace\/user-1\/images\/2026-06-29\/thumb\.jpg 1x/);
assert.match(prepared, /\/MindSpace\/user-1\/images\/2026-06-29\/hero\.jpg 2x/);
assert.match(prepared, /url\(https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/images\/2026-06-29\/bg\.jpg\)/);
assert.match(prepared, /\/MindSpace\/user-1\/public\/images\/2026-06-29\/thumb\.jpg 1x/);
assert.match(prepared, /\/MindSpace\/user-1\/public\/images\/2026-06-29\/hero\.jpg 2x/);
assert.match(prepared, /url\(https:\/\/m\.tkmind\.cn\/MindSpace\/user-1\/public\/images\/2026-06-29\/bg\.jpg\)/);
assert.doesNotMatch(prepared, /plain\/local:\/\//);
});
test('prepareHtmlPublishContent preserves absolute MindSpace public image urls', async () => {
const prepared = await publicationInternals.prepareHtmlPublishContent({
pool: {
async query() {
return [[]];
},
},
userId: 'user-1',
html: '<!doctype html><img src="http://127.0.0.1:5173/MindSpace/user-1/public/images/2026-07-05/demo.jpg" alt="demo">',
ownerSlug: 'john',
urlSlug: 'story',
htmlRelativePath: 'public/index.html',
absoluteStoragePath: () => {
throw new Error('should not read private storage');
},
});
assert.match(
prepared,
/src="http:\/\/127\.0\.0\.1:5173\/MindSpace\/user-1\/public\/images\/2026-07-05\/demo\.jpg"/,
);
assert.doesNotMatch(prepared, /\/MindSpace\/user-1\/images\/2026-07-05\//);
});
test('rewriteBrokenMindSpacePublicImageUrls restores missing public segment', () => {
const html =
'<img src="http://127.0.0.1:5173/MindSpace/user-1/images/2026-07-05/demo.jpg" alt="demo">';
const out = publicationInternals.rewriteBrokenMindSpacePublicImageUrls(html);
assert.match(out, /\/MindSpace\/user-1\/public\/images\/2026-07-05\/demo\.jpg/);
assert.doesNotMatch(out, /\/MindSpace\/user-1\/images\/2026-07-05\//);
});
test('prepareHtmlPublishContent rewrites workspace public asset paths relative to html output', async () => {
const prepared = await publicationInternals.prepareHtmlPublishContent({
pool: {
@@ -572,9 +702,82 @@ test('prepareHtmlPublishContent rewrites workspace public asset paths relative t
},
});
assert.match(prepared, /"cover":"\.\.\/images\/2026-06-29\/hero\.jpg"/);
assert.match(prepared, /url\('\.\.\/images\/2026-06-29\/bg\.jpg'\)/);
assert.match(prepared, /src="\.\.\/\.tmp-images\/card\.png"/);
assert.doesNotMatch(prepared, /public\/images\//);
assert.doesNotMatch(prepared, /public\/\.tmp-images\//);
assert.match(prepared, /"cover":"\/u\/john\/public\/images\/2026-06-29\/hero\.jpg"/);
assert.match(prepared, /url\('\/u\/john\/public\/images\/2026-06-29\/bg\.jpg'\)/);
assert.match(prepared, /src="\/u\/john\/public\/\.tmp-images\/card\.png"/);
assert.doesNotMatch(prepared, /src="public\//);
assert.doesNotMatch(prepared, /"cover":"public\//);
});
test('rewritePublicationCanonicalAssetUrls rewrites workspace tmp image paths for /u pages routes', () => {
const html = [
'<img src=".tmp-images/one.jpg">',
'<img src="../.tmp-images/two.jpg">',
`<section style="background-image:url('.tmp-images/three.jpg')"></section>`,
].join('');
const out = publicationInternals.rewritePublicationCanonicalAssetUrls(html, 'john');
assert.match(out, /src="\/u\/john\/public\/\.tmp-images\/one\.jpg"/);
assert.match(out, /src="\/u\/john\/public\/\.tmp-images\/two\.jpg"/);
assert.match(out, /url\('\/u\/john\/public\/\.tmp-images\/three\.jpg'\)/);
});
test('publish rejects republish while page is still online', async () => {
const pageRow = {
page_id: 'page-1',
title: 'Report',
summary: '',
page_type: 'article',
template_id: 'editorial',
current_version_id: 'version-1',
source_session_id: null,
source_message_id: null,
user_id: 'user-1',
space_id: 'space-1',
page_version_id: 'version-1',
version_no: 1,
bundle_asset_id: null,
storage_key: 'users/user-1/pages/page-1/versions/v1.md',
content: '# Report',
};
const pool = {
async query(sql, params = []) {
if (sql.includes('FROM h5_page_records p') && sql.includes('JOIN h5_page_versions')) {
return [[pageRow]];
}
if (
sql.includes('FROM h5_publish_records') &&
sql.includes("status = 'online'") &&
sql.includes('page_id = ?')
) {
return [[{ id: 'pub-online', url_slug: 'report', public_url: '/u/john/pages/report' }]];
}
if (sql.includes('FROM h5_publish_records pr') && sql.includes('pr.page_id <>')) {
return [[]];
}
return [[]];
},
async getConnection() {
throw new Error('publish should fail before opening a transaction');
},
};
const service = createPublicationService(pool, {
storageRoot: '/tmp',
h5Root: '/tmp',
idFactory: () => 'id-1',
});
await assert.rejects(
() =>
service.publish('user-1', 'page-1', {
pageVersionId: 'version-1',
accessMode: 'public',
urlSlug: 'report',
acknowledgedFindingIds: [],
}),
(error) => {
assert.equal(error.code, 'page_already_online');
assert.equal(error.details.publicationId, 'pub-online');
return true;
},
);
});
+2
View File
@@ -31,6 +31,7 @@ export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({
'findPageByRelativePath',
'findPageBySourceAsset',
'findPageBySourceMessage',
'findPlazaContextByWorkspacePath',
'getDeletePreview',
'getPage',
'listPages',
@@ -56,6 +57,7 @@ export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({
'getStats',
'offline',
'publish',
'refreshOnlinePublicationHtml',
'resolvePrivateLink',
'resolvePublic',
'updatePublicationStatus',
@@ -47,6 +47,7 @@ function createStubAdapter() {
async createPage() { return { ok: true }; },
async deletePage() { return { ok: true }; },
async findPageByRelativePath() { return null; },
async findPlazaContextByWorkspacePath() { return null; },
async findPageBySourceAsset() { return null; },
async getDeletePreview() { return { ok: true }; },
async getPage() { return { ok: true }; },
@@ -74,6 +75,7 @@ function createStubAdapter() {
async check() { return { ok: true }; },
async cleanupExpiredUnconfirmedPublications() { return { cleaned: 0 }; },
async getCurrent() { return null; },
async refreshOnlinePublicationHtml() { return null; },
async getPublicHomepage() { return null; },
async getStats() { return { ok: true }; },
async offline() { return { ok: true }; },
+29
View File
@@ -0,0 +1,29 @@
import { normalizeWorkspaceRelativePath } from './mindspace-pages.mjs';
export { normalizeWorkspaceRelativePath };
export function resolvePageWorkspaceRelativePath(snapshot) {
if (!snapshot || typeof snapshot !== 'object') return null;
return normalizeWorkspaceRelativePath(snapshot.relative_path) || null;
}
export function resolveAssetWorkspaceRelativePath({
categoryCode = null,
originalFilename = null,
explicitPath = null,
} = {}) {
const explicit = normalizeWorkspaceRelativePath(explicitPath);
if (explicit) return explicit;
const filename = String(originalFilename ?? '')
.replace(/\\/g, '/')
.replace(/^\/+/, '');
if (!filename) return null;
if (/^(?:public|oa)\//i.test(filename)) {
return normalizeWorkspaceRelativePath(filename);
}
const category = String(categoryCode ?? '').trim();
if (category === 'public' || category === 'oa') {
return normalizeWorkspaceRelativePath(`${category}/${filename}`);
}
return null;
}
+38
View File
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
resolveAssetWorkspaceRelativePath,
resolvePageWorkspaceRelativePath,
} from './mindspace-workspace-path.mjs';
test('resolvePageWorkspaceRelativePath normalizes html workspace paths', () => {
assert.equal(
resolvePageWorkspaceRelativePath({ relative_path: 'demo.html' }),
'public/demo.html',
);
assert.equal(resolvePageWorkspaceRelativePath({ relative_path: 'public/demo.html' }), 'public/demo.html');
assert.equal(resolvePageWorkspaceRelativePath(null), null);
});
test('resolveAssetWorkspaceRelativePath prefers explicit public image paths', () => {
assert.equal(
resolveAssetWorkspaceRelativePath({
explicitPath: 'public/images/2026-07-05/hero.png',
}),
'public/images/2026-07-05/hero.png',
);
assert.equal(
resolveAssetWorkspaceRelativePath({
categoryCode: 'public',
originalFilename: 'report.docx',
}),
'public/report.docx',
);
assert.equal(
resolveAssetWorkspaceRelativePath({
categoryCode: 'draft',
originalFilename: 'notes.md',
}),
null,
);
});
+14 -3
View File
@@ -7,6 +7,7 @@ import {
resolveUserWorkspaceRoot,
resolveZoneDir,
} from './user-space.mjs';
import { resolveAssetWorkspaceRelativePath } from './mindspace-workspace-path.mjs';
import { assetInternals } from './mindspace-assets.mjs';
import { runBasicFileScan } from './mindspace-scan.mjs';
import { buildWorkspaceStorageKey } from './workspace-storage.mjs';
@@ -193,12 +194,16 @@ export function createWorkspaceAssetSync({
const now = Date.now();
const visibility = category.category_code === 'public' ? 'public_candidate' : 'private';
const indexedWorkspacePath = resolveAssetWorkspaceRelativePath({
categoryCode: category.category_code,
originalFilename: file.filename,
});
await conn.query(
`INSERT INTO h5_assets
(id, user_id, space_id, category_id, asset_type, mime_type, original_filename,
display_name, current_version_id, size_bytes, checksum, risk_level, visibility,
display_name, workspace_relative_path, current_version_id, size_bytes, checksum, risk_level, visibility,
status, source_type, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'workspace', ?, ?)`,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'workspace', ?, ?)`,
[
assetId,
userId,
@@ -208,6 +213,7 @@ export function createWorkspaceAssetSync({
detectedMimeType,
file.filename,
path.basename(file.filename, path.extname(file.filename)) || file.filename,
indexedWorkspacePath,
versionId,
buffer.length,
checksum,
@@ -308,10 +314,14 @@ export function createWorkspaceAssetSync({
);
const now = Date.now();
const indexedWorkspacePath = resolveAssetWorkspaceRelativePath({
categoryCode: category.category_code,
originalFilename: file.filename,
});
await conn.query(
`UPDATE h5_assets
SET current_version_id = ?, size_bytes = ?, checksum = ?, mime_type = ?,
asset_type = ?, risk_level = ?, status = ?, updated_at = ?
asset_type = ?, risk_level = ?, status = ?, workspace_relative_path = ?, updated_at = ?
WHERE id = ? AND user_id = ?`,
[
versionId,
@@ -321,6 +331,7 @@ export function createWorkspaceAssetSync({
assetInternals.assetTypeForMime(detectedMimeType),
scan.riskLevel,
assetStatus,
indexedWorkspacePath,
now,
existing.id,
userId,
+2
View File
@@ -39,6 +39,8 @@
"build:mindspace-service-runtime": "node scripts/build-mindspace-service-runtime.mjs",
"check:mindspace-public-links": "node scripts/check-mindspace-public-links.mjs --downloads-only",
"check:mindspace-public-links:all": "node scripts/check-mindspace-public-links.mjs --all-links",
"check:mindspace-cover": "node scripts/check-mindspace-cover.mjs",
"demo:thumbnails": "node scripts/thumbnail-preview-demo.mjs",
"check:conversation-package-manifest": "node scripts/check-conversation-package-manifest.mjs",
"check:memory-v2": "node scripts/check-memory-v2-health.mjs",
"canary:memory-v2-app": "node scripts/check-memory-v2-app-canary.mjs",
+30 -19
View File
@@ -10,7 +10,7 @@ export function isPlazaEmbedRequest(query = {}) {
export function publishedPageCspForEmbed(isFullHtml) {
if (isFullHtml) {
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src data: https:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors *; script-src 'unsafe-inline'";
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: https: http:; font-src https: data:; base-uri 'none'; form-action 'self'; frame-ancestors *; script-src 'unsafe-inline'";
}
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors *; script-src 'unsafe-inline'";
}
@@ -22,13 +22,22 @@ export function allowPlazaEmbedFrame(res) {
const EMBED_BOOTSTRAP = `<script id="plaza-embed-bootstrap">(function(){
if(window.parent===window)return;
var d=document;
function isDecorative(el){
if(!el||el.nodeType!==1)return true;
var cls=String(el.className||'');
if(/\b(bg-particles|glow-orb|particle|backdrop|ambient|bg-glow)\b/i.test(cls))return true;
var st=window.getComputedStyle(el);
if(st.position==='fixed')return true;
if(st.position==='absolute'&&(Number(st.zIndex||0)<=0||st.pointerEvents==='none'))return true;
return false;
}
function blocks(){
var out=[],hero=d.querySelector('.hero');
if(hero)out.push(hero);
d.querySelectorAll('.section,.quote-section').forEach(function(el){out.push(el);});
d.querySelectorAll('.section,.quote-section,.container,main,article').forEach(function(el){out.push(el);});
var foot=d.querySelector('.footer');
if(foot)out.push(foot);
return out.length?out:[d.body||d.documentElement];
return out;
}
function fullHeight(){
var body=d.body,root=d.documentElement;
@@ -41,27 +50,21 @@ const EMBED_BOOTSTRAP = `<script id="plaza-embed-bootstrap">(function(){
}
var els=blocks();
for(var i=0;i<els.length;i++){
if(!els[i])continue;
if(!els[i]||isDecorative(els[i]))continue;
measured=Math.max(measured,bottomOf(els[i]),els[i].offsetTop+els[i].offsetHeight);
}
if(body){
for(var j=0;j<body.children.length;j++){
measured=Math.max(measured,bottomOf(body.children[j]));
var child=body.children[j];
if(isDecorative(child))continue;
measured=Math.max(measured,bottomOf(child),child.offsetTop+child.offsetHeight);
}
}
if(body&&body.lastElementChild){
if(body&&body.lastElementChild&&!isDecorative(body.lastElementChild)){
var last=body.lastElementChild;
measured=Math.max(measured,bottomOf(last),last.offsetTop+last.offsetHeight);
}
measured=Math.max(
measured,
root?root.scrollHeight:0,
root?root.offsetHeight:0,
body?body.scrollHeight:0,
body?body.offsetHeight:0,
420
);
return Math.ceil(measured);
return Math.max(Math.ceil(measured),420);
}
function restoreBlock(block){
if(!block)return;
@@ -97,6 +100,7 @@ const EMBED_BOOTSTRAP = `<script id="plaza-embed-bootstrap">(function(){
}
function showAll(){
var b=blocks();
if(!b.length&&d.body)b=[d.body];
for(var i=0;i<b.length;i++){
b[i].style.display='';
b[i].style.visibility='';
@@ -138,10 +142,17 @@ const EMBED_BOOTSTRAP = `<script id="plaza-embed-bootstrap">(function(){
});
if(typeof ResizeObserver!=='undefined'){
var ro=new ResizeObserver(function(){reportHeight();});
ro.observe(d.documentElement);
if(d.body)ro.observe(d.body);
}
if(typeof MutationObserver!=='undefined'){
function observeBlocks(){
blocks().forEach(function(el){if(el)ro.observe(el);});
}
observeBlocks();
if(typeof MutationObserver!=='undefined'){
new MutationObserver(function(){
observeBlocks();
scheduleReport();
}).observe(d.documentElement,{childList:true,subtree:true,attributes:true});
}
} else if(typeof MutationObserver!=='undefined'){
new MutationObserver(function(){scheduleReport();}).observe(d.documentElement,{childList:true,subtree:true,attributes:true});
}
d.querySelectorAll('img,video,iframe').forEach(function(el){
+7 -4
View File
@@ -36,6 +36,7 @@ test('injectPlazaEmbedBootstrap adds script before closing body', () => {
test('publishedPageCspForEmbed allows inline script', () => {
assert.match(publishedPageCspForEmbed(true), /script-src 'unsafe-inline'/);
assert.match(publishedPageCspForEmbed(true), /img-src 'self' data: https: http:/);
});
test('allowPlazaEmbedFrame removes legacy X-Frame-Options header', () => {
@@ -66,10 +67,12 @@ test('preparePublicationHtmlForEmbed strips CSP and injects bootstrap', () => {
assert.match(out, /\.hero\{height:auto!important;min-height:0!important/);
});
test('embed bootstrap does not cap tall publication height', () => {
test('embed bootstrap measures content blocks without document scrollHeight feedback', () => {
const html = '<!doctype html><html><body><main style="height:24000px">Tall</main></body></html>';
const out = preparePublicationHtmlForEmbed(html);
assert.doesNotMatch(out, /Math\.min\(measured,\s*16000\)/);
assert.match(out, /body\.children\.length/);
assert.match(out, /MutationObserver/);
assert.doesNotMatch(out, /root\?root\.scrollHeight/);
assert.doesNotMatch(out, /body\?body\.scrollHeight/);
assert.match(out, /isDecorative/);
assert.match(out, /bg-particles/);
assert.match(out, /blocks\(\)\.forEach\(function\(el\)\{if\(el\)ro\.observe\(el\)/);
});
+27 -16
View File
@@ -152,29 +152,40 @@ export function renderPlazaPostRedirectHtml(post, targetUrl, { preview = false }
(function () {
var frame = document.querySelector('.post-frame');
if (!frame) return;
var appliedHeight = 420;
function applyHeight(height) {
frame.style.height = Math.max(Number(height) || 0, window.innerHeight, 420) + 'px';
var next = Math.max(Number(height) || 0, 420);
if (
appliedHeight > 420 &&
next > appliedHeight &&
frame.offsetHeight > 420 &&
next <= frame.offsetHeight + 24
) {
return appliedHeight;
}
appliedHeight = Math.max(next, window.innerHeight, 420);
frame.style.height = appliedHeight + 'px';
return appliedHeight;
}
function measureFrameDocument() {
try {
var doc = frame.contentDocument;
var body = doc && doc.body;
var root = doc && doc.documentElement;
if (!body && !root) return 0;
var measured = Math.max(
body ? body.scrollHeight : 0,
body ? body.offsetHeight : 0,
root ? root.scrollHeight : 0,
root ? root.offsetHeight : 0
);
if (body) {
for (var i = 0; i < body.children.length; i += 1) {
var child = body.children[i];
var rect = child.getBoundingClientRect();
measured = Math.max(measured, rect.bottom + (root ? root.scrollTop : 0));
}
if (!body) return 0;
var scrollTop = (doc.documentElement && doc.documentElement.scrollTop) || body.scrollTop || 0;
var measured = 0;
var blocks = body.querySelectorAll('.hero,.section,.quote-section,.footer,main,article');
for (var i = 0; i < blocks.length; i += 1) {
var el = blocks[i];
var rect = el.getBoundingClientRect();
measured = Math.max(measured, rect.bottom + scrollTop, el.offsetTop + el.offsetHeight);
}
return Math.ceil(measured);
for (var j = 0; j < body.children.length; j += 1) {
var child = body.children[j];
var childRect = child.getBoundingClientRect();
measured = Math.max(measured, childRect.bottom + scrollTop, child.offsetTop + child.offsetHeight);
}
return Math.ceil(Math.max(measured, 420));
} catch (error) {
return 0;
}
@@ -0,0 +1,36 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>越南旅游指南</title>
<style>
:root { --accent: #2f6f57; --bg: #f4f0e7; }
body { margin: 0; font-family: Georgia, serif; background: var(--bg); color: #18211d; line-height: 1.7; }
main { max-width: 720px; margin: 0 auto; padding: 48px 24px 72px; }
h1 { font-size: 2.2rem; margin-bottom: 0.5rem; color: var(--accent); }
.lead { color: #5f6963; margin-bottom: 2rem; }
section { margin-bottom: 2rem; }
h2 { font-size: 1.25rem; margin-bottom: 0.75rem; }
</style>
</head>
<body>
<main>
<h1>越南旅游指南</h1>
<p class="lead">河内古城、下龙湾、会安古镇 — 东南亚最值得深度探索的目的地之一。</p>
<section>
<h2>河内</h2>
<p>三十六行街、还剑湖、火车街咖啡,适合 2–3 天慢游。</p>
</section>
<section>
<h2>下龙湾</h2>
<p>世界自然遗产,建议预订一夜 cruise,清晨看雾锁峰林。</p>
</section>
<section>
<h2>会安</h2>
<p>灯笼古城与 tailoring 文化,傍晚沿河散步最为出片。</p>
</section>
<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>
</main>
</body>
</html>
@@ -0,0 +1,47 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="河内、下龙湾与会安 — 越南深度游完整攻略">
<meta name="mindspace-cover" content='{"tag":"旅行","emoji":"✈️","accent":"#ff6b35","accent2":"#24243e","subtitle":"河内·下龙湾·会安古镇","cover":"https://images.unsplash.com/photo-1559592413-7cec4d0cae2b?auto=format&fit=crop&w=1200&q=80"}'>
<title>越南旅游指南</title>
<style>
:root { --accent: #ff6b35; --accent2: #24243e; --bg: #fff8f2; }
body { margin: 0; font-family: Georgia, serif; background: linear-gradient(160deg, var(--bg), #ffe8d6); color: #18211d; line-height: 1.7; }
.hero { position: relative; height: 280px; overflow: hidden; }
.hero img { width: 100%; height: 100%; object-fit: cover; display: block; }
.hero::after { content: ''; position: absolute; inset: 0; background: linear-gradient(to top, rgba(36,36,62,0.55), transparent 60%); }
main { max-width: 720px; margin: -48px auto 0; padding: 0 24px 72px; position: relative; z-index: 1; }
.card { background: #fff; border-radius: 16px; padding: 32px; box-shadow: 0 12px 40px rgba(24,33,29,0.1); }
h1 { font-size: 2.2rem; margin: 0 0 0.5rem; color: var(--accent2); }
.lead { color: #5f6963; margin-bottom: 2rem; }
section { margin-bottom: 1.5rem; }
h2 { font-size: 1.15rem; margin-bottom: 0.5rem; color: var(--accent); }
</style>
</head>
<body>
<div class="hero">
<img src="https://images.unsplash.com/photo-1559592413-7cec4d0cae2b?auto=format&fit=crop&w=1200&q=80" alt="下龙湾" />
</div>
<main>
<div class="card">
<h1>越南旅游指南</h1>
<p class="lead">河内古城、下龙湾、会安古镇 — 东南亚最值得深度探索的目的地之一。</p>
<section>
<h2>河内</h2>
<p>三十六行街、还剑湖、火车街咖啡,适合 2–3 天慢游。</p>
</section>
<section>
<h2>下龙湾</h2>
<p>世界自然遗产,建议预订一夜 cruise,清晨看雾锁峰林。</p>
</section>
<section>
<h2>会安</h2>
<p>灯笼古城与 tailoring 文化,傍晚沿河散步最为出片。</p>
</section>
<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>
</div>
</main>
</body>
</html>
+41 -35
View File
@@ -3,65 +3,71 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MindSpace 预览图 Demo</title>
<title>MindSpace 缩略图 · 方案 A 对比</title>
<style>
:root { color-scheme: light; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f4f0e7; color: #18211d; }
body { margin: 0; padding: 24px; }
body { margin: 0; padding: 24px; max-width: 960px; }
h1 { margin: 0 0 8px; font-size: 28px; }
.lead { margin: 0 0 24px; color: #5f6963; line-height: 1.6; }
.compare { display: grid; gap: 28px; }
.sample { padding: 20px; border-radius: 20px; background: #fff; box-shadow: 0 10px 30px rgba(24,33,29,0.08); }
.sample h2 { margin: 0 0 16px; font-size: 20px; }
.pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 18px; }
.sample h2 { margin: 0 0 8px; font-size: 20px; }
.hint { margin: 0 0 16px; font-size: 13px; color: #6d7771; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.thumb { display: grid; gap: 8px; }
.thumb img { width: 100%; border-radius: 12px; box-shadow: 0 8px 24px rgba(24,33,29,0.12); }
.thumb span { font-size: 13px; color: #6d7771; }
.feed-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; max-width: 520px; }
.feed-card { border: none; border-radius: 12px; overflow: hidden; background: #fff; box-shadow: 0 2px 10px rgba(24,33,29,0.07); }
.thumb img { width: 100%; max-width: 270px; border-radius: 12px; box-shadow: 0 8px 24px rgba(24,33,29,0.12); }
.thumb span { font-size: 12px; line-height: 1.5; }
.thumb span.bad { color: #b45309; }
.thumb span.good { color: #2f6f57; }
.feed-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; max-width: 520px; margin-top: 18px; }
.feed-card { border-radius: 12px; overflow: hidden; background: #fff; box-shadow: 0 2px 10px rgba(24,33,29,0.07); }
.feed-cover { aspect-ratio: 3 / 4; background: #ece7df; }
.feed-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.feed-body { padding: 10px 12px 12px; }
.feed-body h3 { margin: 0; font-size: 14px; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.feed-body p { margin: 6px 0 0; font-size: 12px; color: #939c97; }
code { background: #f0ebe3; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
</style>
</head>
<body>
<h1>MindSpace 预览图升级 Demo</h1>
<p class="lead">左侧为旧版横版缩略图,中间为新版 3:4 信息流封面(自动从 HTML 提取标题、配色、emoji),右侧为保存页面时实际写入的 thumbnail.svg。下方是小红书风格卡片效果。</p>
<h1>方案 Amindspace-cover 对比 Demo</h1>
<p class="lead">
页面生成时在 <code>&lt;head&gt;</code> 写入 <code>mindspace-cover</code> 元数据并引用 hero 主图,
缩略图会从「纯色渐变」升级为「照片封面 + 标题」。
交付前可跑 <code>npm run check:mindspace-cover</code> 自检。
</p>
<div class="compare">
<section class="sample">
<h2>样本 1</h2>
<h2>越南旅游指南</h2>
<p class="hint">左侧:未写 mindspace-cover(退化为纯色渐变) · 右侧:方案 A(完整元数据 + hero 主图)</p>
<div class="pair">
<div class="thumb"><img src="./malaysia-travel-legacy.svg" alt="旧版" /><span>旧版(横版字卡)</span></div>
<div class="thumb"><img src="./malaysia-travel-feed.svg" alt="新版" /><span>新版(3:4 精美封面)</span></div>
<div class="thumb"><img src="./malaysia-travel-generated.svg" alt="生成结果" /><span>generateHtmlThumbnail 输出</span></div>
<div class="thumb">
<img src="./vietnam-guide-bad-feed.svg" alt="缺 cover" />
<span class="bad">❌ 缺 mindspace-cover · 无图 · tag=旅行</span>
</div>
<div class="thumb">
<img src="./vietnam-guide-good-feed.svg" alt="方案 A" />
<span class="good">✅ 方案 A · 照片封面 · tag=旅行</span>
</div>
</div>
<div class="feed-grid" style="margin-top:18px">
<div class="feed-grid">
<article class="feed-card">
<div class="feed-cover"><img src="./malaysia-travel-feed.svg" alt="马来西亚旅游攻略 预览图" /></div>
<div class="feed-cover"><img src="./vietnam-guide-bad-feed.svg" alt="越南旅游指南 预览图" /></div>
<div class="feed-body">
<h3>马来西亚旅游攻略</h3>
<p>旅行攻略 · 新版 3:4 封面</p>
<h3>越南旅游指南</h3>
<p>缺 cover · 信息流效果</p>
</div>
</article></div>
</section>
<section class="sample">
<h2>样本 2</h2>
<div class="pair">
<div class="thumb"><img src="./mapo-tofu-legacy.svg" alt="旧版" /><span>旧版(横版字卡)</span></div>
<div class="thumb"><img src="./mapo-tofu-feed.svg" alt="新版" /><span>新版(3:4 精美封面)</span></div>
<div class="thumb"><img src="./mapo-tofu-generated.svg" alt="生成结果" /><span>generateHtmlThumbnail 输出</span></div>
</div>
<div class="feed-grid" style="margin-top:18px">
</article>
<article class="feed-card">
<div class="feed-cover"><img src="./mapo-tofu-feed.svg" alt="麻婆豆腐 预览图" /></div>
<div class="feed-cover"><img src="./vietnam-guide-good-feed.svg" alt="越南旅游指南 预览图" /></div>
<div class="feed-body">
<h3>麻婆豆腐</h3>
<p>美食专题 · 新版 3:4 封面</p>
<h3>越南旅游指南</h3>
<p>方案 A · 信息流效果</p>
</div>
</article></div>
</section>
</div>
</article>
</div>
</section></div>
</body>
</html>
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="540" height="720" viewBox="0 0 540 720">
<defs>
<linearGradient id="theme" x1="18%" y1="0%" x2="82%" y2="100%">
<stop offset="0%" stop-color="#4e8e76"/>
<stop offset="48%" stop-color="#2f6f57"/>
<stop offset="100%" stop-color="#f4f0e7"/>
</linearGradient>
<radialGradient id="themeGlow" cx="72%" cy="16%" r="42%">
<stop offset="0%" stop-color="#9adac2" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#f4f0e7" stop-opacity="0"/>
</radialGradient>
<linearGradient id="themeShade" x1="50%" y1="55%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#000000" stop-opacity="0"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0.72"/>
</linearGradient>
<linearGradient id="sky" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#4f8fb8"/>
<stop offset="42%" stop-color="#f6d7a8" stop-opacity="0.88"/>
<stop offset="68%" stop-color="#e39a4d"/>
<stop offset="100%" stop-color="#24343a"/>
</linearGradient>
<linearGradient id="overlay" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#000000" stop-opacity="0"/>
<stop offset="52%" stop-color="#000000" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1518" stop-opacity="0.82"/>
</linearGradient>
<radialGradient id="sun" cx="78%" cy="18%" r="34%">
<stop offset="0%" stop-color="#ffe9c7" stop-opacity="0.95"/>
<stop offset="55%" stop-color="#e39a4d" stop-opacity="0.24"/>
<stop offset="100%" stop-color="#e39a4d" stop-opacity="0"/>
</radialGradient>
<radialGradient id="vignette" cx="50%" cy="48%" r="72%">
<stop offset="55%" stop-color="#000000" stop-opacity="0"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0.42"/>
</radialGradient>
<filter id="blurSoft" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="24"/>
</filter>
<filter id="blurTight" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="10"/>
</filter>
<filter id="grain" x="0%" y="0%" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="3" stitchTiles="stitch"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.14 0"/>
</filter>
</defs>
<rect width="540" height="720" fill="url(#sky)"/>
<rect width="540" height="720" fill="url(#sun)"/>
<circle cx="118" cy="168" r="58" fill="#fff8ef" opacity="0.16" filter="url(#blurSoft)"/>
<circle cx="430" cy="248" r="42" fill="#fff8ef" opacity="0.2" filter="url(#blurTight)"/>
<circle cx="360" cy="118" r="26" fill="#ffe9c7" opacity="0.34" filter="url(#blurTight)"/>
<circle cx="72" cy="320" r="18" fill="#fff8ef" opacity="0.28" filter="url(#blurTight)"/>
<path d="M0 470 L90 404 L180 438 L286 372 L392 418 L500 360 L540 388 L540 720 L0 720 Z" fill="#24343a" opacity="0.92"/>
<path d="M0 520 L120 468 L250 502 L360 456 L470 492 L540 460 L540 720 L0 720 Z" fill="#0d1518" opacity="0.55"/>
<rect width="540" height="720" fill="url(#overlay)"/>
<rect width="540" height="720" fill="url(#vignette)" opacity="0.42"/>
<rect width="540" height="720" filter="url(#grain)" opacity="0.28"/>
<text x="34" y="42" fill="rgba(255,255,255,0.72)" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" font-weight="700" letter-spacing="3.2">旅行</text>
<text x="34" y="636" fill="#ffffff" font-family="Georgia, 'Times New Roman', serif" font-size="38" font-weight="600" letter-spacing="-0.4">越南旅游指南</text>
<text x="34" y="672" fill="rgba(255,255,255,0.72)" font-family="ui-sans-serif, system-ui, sans-serif" font-size="14" letter-spacing="0.2">河内古城、下龙湾、会安古镇 — 东南亚最值得深度探索的目的地之一。</text>
<text x="506" y="698" text-anchor="end" fill="rgba(255,255,255,0.34)" font-family="ui-sans-serif, system-ui, sans-serif" font-size="10" letter-spacing="2.8">TKMIND</text>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="540" height="720" viewBox="0 0 540 720">
<defs>
<linearGradient id="theme" x1="18%" y1="0%" x2="82%" y2="100%">
<stop offset="0%" stop-color="#4e8e76"/>
<stop offset="48%" stop-color="#2f6f57"/>
<stop offset="100%" stop-color="#f4f0e7"/>
</linearGradient>
<radialGradient id="themeGlow" cx="72%" cy="16%" r="42%">
<stop offset="0%" stop-color="#9adac2" stop-opacity="0.55"/>
<stop offset="100%" stop-color="#f4f0e7" stop-opacity="0"/>
</radialGradient>
<linearGradient id="themeShade" x1="50%" y1="55%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#000000" stop-opacity="0"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0.72"/>
</linearGradient>
<linearGradient id="sky" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#4f8fb8"/>
<stop offset="42%" stop-color="#f6d7a8" stop-opacity="0.88"/>
<stop offset="68%" stop-color="#e39a4d"/>
<stop offset="100%" stop-color="#24343a"/>
</linearGradient>
<linearGradient id="overlay" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#000000" stop-opacity="0"/>
<stop offset="52%" stop-color="#000000" stop-opacity="0.08"/>
<stop offset="100%" stop-color="#0d1518" stop-opacity="0.82"/>
</linearGradient>
<radialGradient id="sun" cx="78%" cy="18%" r="34%">
<stop offset="0%" stop-color="#ffe9c7" stop-opacity="0.95"/>
<stop offset="55%" stop-color="#e39a4d" stop-opacity="0.24"/>
<stop offset="100%" stop-color="#e39a4d" stop-opacity="0"/>
</radialGradient>
<radialGradient id="vignette" cx="50%" cy="48%" r="72%">
<stop offset="55%" stop-color="#000000" stop-opacity="0"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0.42"/>
</radialGradient>
<filter id="blurSoft" x="-30%" y="-30%" width="160%" height="160%">
<feGaussianBlur stdDeviation="24"/>
</filter>
<filter id="blurTight" x="-40%" y="-40%" width="180%" height="180%">
<feGaussianBlur stdDeviation="10"/>
</filter>
<filter id="grain" x="0%" y="0%" width="100%" height="100%">
<feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="3" stitchTiles="stitch"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.14 0"/>
</filter>
</defs>
<rect width="540" height="720" fill="url(#sky)"/>
<rect width="540" height="720" fill="url(#sun)"/>
<circle cx="118" cy="168" r="58" fill="#fff8ef" opacity="0.16" filter="url(#blurSoft)"/>
<circle cx="430" cy="248" r="42" fill="#fff8ef" opacity="0.2" filter="url(#blurTight)"/>
<circle cx="360" cy="118" r="26" fill="#ffe9c7" opacity="0.34" filter="url(#blurTight)"/>
<circle cx="72" cy="320" r="18" fill="#fff8ef" opacity="0.28" filter="url(#blurTight)"/>
<path d="M0 470 L90 404 L180 438 L286 372 L392 418 L500 360 L540 388 L540 720 L0 720 Z" fill="#24343a" opacity="0.92"/>
<path d="M0 520 L120 468 L250 502 L360 456 L470 492 L540 460 L540 720 L0 720 Z" fill="#0d1518" opacity="0.55"/>
<rect width="540" height="720" fill="url(#overlay)"/>
<rect width="540" height="720" fill="url(#vignette)" opacity="0.42"/>
<rect width="540" height="720" filter="url(#grain)" opacity="0.28"/>
<text x="34" y="42" fill="rgba(255,255,255,0.72)" font-family="ui-sans-serif, system-ui, sans-serif" font-size="11" font-weight="700" letter-spacing="3.2">旅行</text>
<text x="34" y="636" fill="#ffffff" font-family="Georgia, 'Times New Roman', serif" font-size="38" font-weight="600" letter-spacing="-0.4">越南旅游指南</text>
<text x="34" y="672" fill="rgba(255,255,255,0.72)" font-family="ui-sans-serif, system-ui, sans-serif" font-size="14" letter-spacing="0.2">河内古城、下龙湾、会安古镇 — 东南亚最值得深度探索的目的地之一。</text>
<text x="506" y="698" text-anchor="end" fill="rgba(255,255,255,0.34)" font-family="ui-sans-serif, system-ui, sans-serif" font-size="10" letter-spacing="2.8">TKMIND</text>
</svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 254 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 254 KiB

+4
View File
@@ -72,6 +72,7 @@ CREATE TABLE IF NOT EXISTS h5_assets (
mime_type VARCHAR(128) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
display_name VARCHAR(255) NOT NULL,
workspace_relative_path VARCHAR(512) NULL,
current_version_id CHAR(36) NULL,
size_bytes BIGINT NOT NULL DEFAULT 0,
checksum CHAR(64) NOT NULL,
@@ -85,6 +86,7 @@ CREATE TABLE IF NOT EXISTS h5_assets (
KEY idx_h5_assets_category (user_id, category_id, updated_at),
KEY idx_h5_assets_parent (user_id, parent_id),
KEY idx_h5_assets_checksum (user_id, checksum),
KEY idx_h5_assets_workspace_path (user_id, workspace_relative_path, updated_at),
CONSTRAINT fk_h5_asset_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_asset_space FOREIGN KEY (space_id) REFERENCES h5_user_spaces(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_asset_category FOREIGN KEY (category_id) REFERENCES h5_space_categories(id) ON DELETE CASCADE,
@@ -150,6 +152,7 @@ CREATE TABLE IF NOT EXISTS h5_page_records (
page_type VARCHAR(32) NOT NULL DEFAULT 'article',
template_id VARCHAR(64) NOT NULL DEFAULT 'editorial',
draft_content_ref VARCHAR(512) NULL,
workspace_relative_path VARCHAR(512) NULL,
current_version_id CHAR(36) NULL,
current_publish_id CHAR(36) NULL,
status ENUM('draft', 'reviewing', 'risk_found', 'ready', 'published', 'protected', 'expired', 'offline', 'deleted') NOT NULL DEFAULT 'draft',
@@ -160,6 +163,7 @@ CREATE TABLE IF NOT EXISTS h5_page_records (
KEY idx_h5_pages_user_status (user_id, status, updated_at),
KEY idx_h5_pages_category (user_id, category_id, updated_at),
KEY idx_h5_pages_source_session (user_id, source_session_id),
KEY idx_h5_pages_workspace_path (user_id, workspace_relative_path, updated_at),
CONSTRAINT fk_h5_page_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_page_space FOREIGN KEY (space_id) REFERENCES h5_user_spaces(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_page_category FOREIGN KEY (category_id) REFERENCES h5_space_categories(id) ON DELETE CASCADE,
+151
View File
@@ -0,0 +1,151 @@
#!/usr/bin/env node
/**
* Scan MindSpace public/*.html for mindspace-cover compliance (scheme A gate).
*
* Usage:
* node scripts/check-mindspace-cover.mjs
* node scripts/check-mindspace-cover.mjs --user <uuid>
* node scripts/check-mindspace-cover.mjs --root /path/to/MindSpace
* node scripts/check-mindspace-cover.mjs --file public/thumbnail-demo-samples/vietnam-guide-good.html
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseMindspaceCoverMeta } from '../mindspace-cover-meta.mjs';
import { hasPlatformBrandMarker } from '../mindspace-page-tag.mjs';
import {
hasMindspaceCoverMeta,
hasRasterShareImageHint,
hasShareDescription,
} from '../wechat/verify/share-preview.mjs';
const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const DEFAULT_PUBLISH_ROOT = 'MindSpace';
function parseArgs(argv) {
let root = path.join(repoRoot, DEFAULT_PUBLISH_ROOT);
let userId = null;
let file = null;
for (let i = 2; i < argv.length; i += 1) {
if (argv[i] === '--root' && argv[i + 1]) {
root = path.resolve(argv[i + 1]);
i += 1;
} else if (argv[i] === '--user' && argv[i + 1]) {
userId = argv[i + 1];
i += 1;
} else if (argv[i] === '--file' && argv[i + 1]) {
file = path.resolve(argv[i + 1]);
i += 1;
} else if (argv[i] === '--help' || argv[i] === '-h') {
console.log(`Usage: node scripts/check-mindspace-cover.mjs [--root ${DEFAULT_PUBLISH_ROOT}] [--user <uuid>] [--file <html>]`);
process.exit(0);
}
}
return { root, userId, file };
}
function hasCoverImageHint(html) {
if (hasRasterShareImageHint(html)) return true;
const coverMeta = parseMindspaceCoverMeta(html);
const cover = String(coverMeta?.cover ?? coverMeta?.image ?? '').trim();
if (!cover) return false;
if (/^https?:\/\//i.test(cover)) return true;
if (/\.svg(?:[?#]|$)/i.test(cover)) return false;
return true;
}
function auditCoverHtml(htmlPath, html) {
const issues = [];
if (!hasMindspaceCoverMeta(html)) {
issues.push({ level: 'error', code: 'missing_mindspace_cover', message: '缺少 <meta name="mindspace-cover">' });
}
if (!hasShareDescription(html)) {
issues.push({ level: 'error', code: 'missing_description', message: '缺少 description / og:description' });
}
if (!hasPlatformBrandMarker(html)) {
issues.push({ level: 'warn', code: 'missing_platform_brand', message: '缺少 data-mindspace-page-tag="platform-brand" 页脚' });
}
const coverMeta = parseMindspaceCoverMeta(html);
if (coverMeta) {
if (!String(coverMeta.tag ?? '').trim()) {
issues.push({ level: 'warn', code: 'missing_tag', message: 'mindspace-cover 缺少 tag' });
}
if (!String(coverMeta.subtitle ?? '').trim()) {
issues.push({ level: 'warn', code: 'missing_subtitle', message: 'mindspace-cover 缺少 subtitle' });
}
if (!String(coverMeta.accent ?? '').trim()) {
issues.push({ level: 'warn', code: 'missing_accent', message: 'mindspace-cover 缺少 accent' });
}
}
if (!hasCoverImageHint(html)) {
issues.push({
level: 'warn',
code: 'missing_cover_image',
message: '无 raster 封面图(cover/image/og:image),缩略图会退化为纯色渐变',
});
}
return issues;
}
function collectHtmlFiles(root, userId) {
const files = [];
const walk = (dir) => {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === '.agents' || entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
walk(full);
continue;
}
if (!entry.name.endsWith('.html') || entry.name.endsWith('.thumbnail.svg')) continue;
files.push(full);
}
};
if (userId) {
walk(path.join(root, userId, 'public'));
return files;
}
if (!fs.existsSync(root)) return files;
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name === 'wiki' || entry.name.startsWith('.')) continue;
walk(path.join(root, entry.name, 'public'));
}
return files;
}
const { root, userId, file } = parseArgs(process.argv);
const targets = file ? [file] : collectHtmlFiles(root, userId);
const results = [];
for (const htmlPath of targets) {
const html = fs.readFileSync(htmlPath, 'utf8');
const issues = auditCoverHtml(htmlPath, html);
if (issues.length) {
results.push({ htmlPath, issues });
}
}
if (results.length === 0) {
const scope = file ?? `${root}${userId ? ` (user ${userId})` : ''}`;
console.log(`OK: ${targets.length} 个 HTML 页面均符合 mindspace-cover 规范 (${scope})`);
process.exit(0);
}
let errors = 0;
let warns = 0;
console.error(`Found cover issues in ${results.length} / ${targets.length} HTML files:`);
for (const { htmlPath, issues } of results) {
const rel = file ? htmlPath : path.relative(root, htmlPath);
console.error(`\n${rel}`);
for (const issue of issues) {
const prefix = issue.level === 'error' ? ' ✗' : ' ⚠';
console.error(`${prefix} [${issue.code}] ${issue.message}`);
if (issue.level === 'error') errors += 1;
else warns += 1;
}
}
console.error(`\n合计: ${errors} 错误, ${warns} 警告`);
process.exit(errors > 0 ? 1 : 0);
+38
View File
@@ -198,6 +198,29 @@ try {
assert.match(stillV1.body, /V1 内容/);
assert.doesNotMatch(stillV1.body, /V2 内容/);
const blockedRepublish = await request(`/api/mindspace/v1/pages/${page.id}/publish`, {
method: 'POST',
headers: authHeaders(owner.cookie),
body: JSON.stringify({
page_version_id: updated.body.data.currentVersionId,
access_mode: 'owner_only',
url_slug: 'immutable-page',
acknowledged_finding_ids: [],
}),
});
assert.equal(blockedRepublish.response.status, 409);
assert.equal(blockedRepublish.body.error.code, 'page_already_online');
const offlineBeforeRepublish = await request(
`/api/mindspace/v1/publications/${published.body.data.id}/offline`,
{
method: 'POST',
headers: authHeaders(owner.cookie),
body: '{}',
},
);
assert.equal(offlineBeforeRepublish.response.status, 200);
const republished = await request(`/api/mindspace/v1/pages/${page.id}/publish`, {
method: 'POST',
headers: authHeaders(owner.cookie),
@@ -218,7 +241,21 @@ try {
assert.equal(ownerProtected.response.status, 200);
assert.match(ownerProtected.body, /V2 内容/);
let currentPublicationId = republished.body.data.id;
const offlineCurrentPublication = async () => {
const response = await request(
`/api/mindspace/v1/publications/${currentPublicationId}/offline`,
{
method: 'POST',
headers: authHeaders(owner.cookie),
body: '{}',
},
);
assert.equal(response.response.status, 200, JSON.stringify(response.body));
};
const publishMode = async (accessMode, extra = {}) => {
await offlineCurrentPublication();
const response = await request(`/api/mindspace/v1/pages/${page.id}/publish`, {
method: 'POST',
headers: authHeaders(owner.cookie),
@@ -231,6 +268,7 @@ try {
}),
});
assert.equal(response.response.status, 201, JSON.stringify(response.body));
currentPublicationId = response.body.data.id;
return response.body.data;
};
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { upsertMindspaceCoverMeta } from '../mindspace-cover-meta.mjs';
import { ensureWorkspaceHtmlThumbnail } from '../mindspace-workspace-thumbnails.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const PATCHES = [
{
rel: 'MindSpace/02f8634f-8988-4313-80e2-a289bb319faa/public/123.html',
description: 'Tang 的 123 简单展示页',
cover: {
tag: '精选页面',
emoji: '✨',
accent: '#667eea',
accent2: '#764ba2',
subtitle: '欢迎来到 Tang 的 123 页面',
cover: 'https://images.unsplash.com/photo-1557683316-973673baf926?auto=format&fit=crop&w=1200&q=80',
},
},
{
rel: 'MindSpace/02f8634f-8988-4313-80e2-a289bb319faa/public/essay.html',
description: '夏夜的风,带着一丝凉意 — TKMind 散文',
cover: {
tag: '精选页面',
emoji: '🌙',
accent: '#4a5a9c',
accent2: '#0b0e1a',
subtitle: '夏夜随笔 · 星空与萤火',
cover: 'https://images.unsplash.com/photo-1419242902214-272b4217a589?auto=format&fit=crop&w=1200&q=80',
},
},
{
rel: 'MindSpace/02f8634f-8988-4313-80e2-a289bb319faa/public/prose.html',
description: '黄昏时分,河堤漫步 — 散文《黄昏书简》',
cover: {
tag: '精选页面',
emoji: '🌅',
accent: '#c9ad93',
accent2: '#6b4f3a',
subtitle: '黄昏书简 · 与时光温柔和解',
cover: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?auto=format&fit=crop&w=1200&q=80',
},
},
{
rel: 'MindSpace/1c99b83b-0454-474f-a5d2-129d34506a32/public/MIT-计算机科学课程学习路线.html',
description: 'MIT 计算机科学课程体系 · 分阶段学习路线与资源汇总',
cover: {
tag: '报告',
emoji: '📚',
accent: '#6a67ce',
accent2: '#24243e',
subtitle: '7 大章节 · 从数学基础到 AI 方向',
cover: 'https://images.unsplash.com/photo-1517694712202-9963a793792c?auto=format&fit=crop&w=1200&q=80',
},
},
{
rel: 'MindSpace/1c99b83b-0454-474f-a5d2-129d34506a32/public/太湖三白.html',
description: '太湖三白 · 白鱼、白虾、银鱼 — 江南水中三宝',
cover: {
tag: '美食',
emoji: '🐟',
accent: '#2e86ab',
accent2: '#0b2a44',
subtitle: '千年太湖,一味三鲜',
cover: 'https://images.unsplash.com/photo-1546069901-ba9599a7e63c?auto=format&fit=crop&w=1200&q=80',
},
},
{
rel: 'MindSpace/1c99b83b-0454-474f-a5d2-129d34506a32/public/春江花月夜.html',
description: '春江花月夜 · 张若虚 — 孤篇盖全唐',
cover: {
tag: '精选页面',
emoji: '🌕',
accent: '#c9a227',
accent2: '#0a0e1a',
subtitle: '春江潮水连海平 · 海上明月共潮生',
cover: 'https://images.unsplash.com/photo-1444703686981-a3abbc4d4fe3?auto=format&fit=crop&w=1200&q=80',
},
},
];
function upsertDescription(html, description) {
const meta = `<meta name="description" content="${description.replace(/"/g, '&quot;')}">`;
if (/<meta[^>]+name=["']description["']/i.test(html)) {
return html.replace(/<meta[^>]+name=["']description["'][^>]*>/i, meta);
}
if (/<meta[^>]+name=["']viewport["'][^>]*>/i.test(html)) {
return html.replace(/(<meta[^>]+name=["']viewport["'][^>]*>)/i, `$1\n${meta}`);
}
return html.replace(/<head([^>]*)>/i, `<head$1>\n${meta}`);
}
function upsertPlatformBrand(html) {
if (/data-mindspace-page-tag=["']platform-brand["']/i.test(html)) return html;
const brand = '\n<p data-mindspace-page-tag="platform-brand" style="text-align:center;margin:24px 0 12px;font-size:12px;opacity:0.55">TKMind · 智趣</p>\n';
return html.replace(/<\/body>/i, `${brand}</body>`);
}
for (const patch of PATCHES) {
const abs = path.join(root, patch.rel);
let html = await fs.readFile(abs, 'utf8');
html = upsertDescription(html, patch.description);
html = upsertMindspaceCoverMeta(html, patch.cover);
html = upsertPlatformBrand(html);
await fs.writeFile(abs, html, 'utf8');
const publishDir = path.dirname(abs);
const htmlRel = path.basename(abs);
await ensureWorkspaceHtmlThumbnail(publishDir, htmlRel, html, {
title: patch.cover.subtitle.split('·')[0].trim(),
subtitle: patch.description,
force: true,
});
console.log(`${patch.rel}`);
}
console.log('\n完成 6 页 patch + 缩略图重建');
+74 -83
View File
@@ -4,38 +4,29 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
buildFeedThumbnailSvg,
buildThumbnailSvg,
extractCoverSignals,
generateHtmlThumbnail,
resolveCoverDataUri,
} from '../mindspace-thumbnails.mjs';
const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const sampleDir = path.join(root, 'public', 'thumbnail-demo-samples');
const outputDir = path.join(root, 'public', 'thumbnail-demo');
const samples = [
/** A/B pairs: bad = 缺 mindspace-covergood = 方案 A 完整元数据 + hero 图 */
const pairs = [
{
id: 'malaysia-travel',
file: path.join(root, 'MindSpace', 'john', 'malaysia-travel-guide.html'),
meta: {
tag: '旅行攻略',
subtitle: '马来西亚深度游',
cover: 'https://images.unsplash.com/photo-1596422846543-75c6fc197f07?auto=format&fit=crop&w=1200&q=80',
},
},
{
id: 'mapo-tofu',
file: path.join(root, 'MindSpace', 'john', 'mapo-tofu.html'),
meta: {
tag: '美食专题',
cover: 'https://images.unsplash.com/photo-1525755662778-989d0529657e?auto=format&fit=crop&w=1200&q=80',
},
id: 'vietnam-guide',
label: '越南旅游指南',
bad: path.join(sampleDir, 'vietnam-guide-bad.html'),
good: path.join(sampleDir, 'vietnam-guide-good.html'),
},
];
function galleryCard({ id, title, subtitle }) {
function galleryCard({ id, title, subtitle, variant }) {
return `
<article class="feed-card">
<div class="feed-cover"><img src="./${id}-feed.svg" alt="${title} 预览图" /></div>
<div class="feed-cover"><img src="./${id}-${variant}-feed.svg" alt="${title} 预览图" /></div>
<div class="feed-body">
<h3>${title}</h3>
<p>${subtitle}</p>
@@ -43,47 +34,54 @@ function galleryCard({ id, title, subtitle }) {
</article>`;
}
async function renderVariant({ htmlPath, variant, id }) {
const html = await fs.readFile(htmlPath, 'utf8');
const signals = extractCoverSignals(html);
const coverDataUri = signals.image
? await resolveCoverDataUri({
storageRoot: outputDir,
contentBaseDir: path.dirname(htmlPath),
imageUrl: signals.image,
})
: null;
const feedSvg = buildFeedThumbnailSvg(signals, { coverDataUri });
const feedPath = path.join(outputDir, `${id}-${variant}-feed.svg`);
await fs.writeFile(feedPath, feedSvg, 'utf8');
await generateHtmlThumbnail(outputDir, `${id}-${variant}-generated.svg`, html, {
contentBaseDir: path.dirname(htmlPath),
title: signals.title,
subtitle: signals.subtitle,
});
return { signals, hasPhoto: Boolean(coverDataUri) };
}
async function main() {
await fs.mkdir(outputDir, { recursive: true });
const cards = [];
const sections = [];
for (const sample of samples) {
let html = '';
try {
html = await fs.readFile(sample.file, 'utf8');
} catch {
console.warn(`跳过缺失样本: ${sample.file}`);
continue;
}
for (const pair of pairs) {
const bad = await renderVariant({ htmlPath: pair.bad, variant: 'bad', id: pair.id });
const good = await renderVariant({ htmlPath: pair.good, variant: 'good', id: pair.id });
const signals = extractCoverSignals(html, sample.meta);
const legacySvg = buildThumbnailSvg({
title: signals.title,
subtitle: sample.meta.subtitle ?? signals.subtitle,
accent: signals.accent,
});
const coverDataUri = signals.image
? await resolveCoverDataUri({
storageRoot: outputDir,
contentBaseDir: path.dirname(sample.file),
imageUrl: signals.image,
})
: null;
const feedSvg = buildFeedThumbnailSvg(signals, { coverDataUri });
await fs.writeFile(path.join(outputDir, `${sample.id}-legacy.svg`), legacySvg, 'utf8');
await fs.writeFile(path.join(outputDir, `${sample.id}-feed.svg`), feedSvg, 'utf8');
await generateHtmlThumbnail(outputDir, `${sample.id}-generated.svg`, html, {
...sample.meta,
contentBaseDir: path.dirname(sample.file),
});
cards.push(
galleryCard({
id: sample.id,
title: signals.title,
subtitle: `${signals.tag} · 新版 3:4 封面`,
}),
);
sections.push(`
<section class="sample">
<h2>${pair.label}</h2>
<p class="hint">左侧未写 mindspace-cover退化为纯色渐变 · 右侧方案 A完整元数据 + hero 主图</p>
<div class="pair">
<div class="thumb">
<img src="./${pair.id}-bad-feed.svg" alt="缺 cover" />
<span class="bad"> mindspace-cover · ${bad.hasPhoto ? '有图' : '无图'} · tag=${bad.signals.tag}</span>
</div>
<div class="thumb">
<img src="./${pair.id}-good-feed.svg" alt="方案 A" />
<span class="good"> 方案 A · ${good.hasPhoto ? '照片封面' : '场景/渐变'} · tag=${good.signals.tag}</span>
</div>
</div>
<div class="feed-grid">
${galleryCard({ id: pair.id, title: pair.label, subtitle: '缺 cover · 信息流效果', variant: 'bad' })}
${galleryCard({ id: pair.id, title: pair.label, subtitle: '方案 A · 信息流效果', variant: 'good' })}
</div>
</section>`);
}
const html = `<!doctype html>
@@ -91,52 +89,45 @@ async function main() {
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MindSpace 预览 Demo</title>
<title>MindSpace 缩略 · 方案 A 对比</title>
<style>
:root { color-scheme: light; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f4f0e7; color: #18211d; }
body { margin: 0; padding: 24px; }
body { margin: 0; padding: 24px; max-width: 960px; }
h1 { margin: 0 0 8px; font-size: 28px; }
.lead { margin: 0 0 24px; color: #5f6963; line-height: 1.6; }
.compare { display: grid; gap: 28px; }
.sample { padding: 20px; border-radius: 20px; background: #fff; box-shadow: 0 10px 30px rgba(24,33,29,0.08); }
.sample h2 { margin: 0 0 16px; font-size: 20px; }
.pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 18px; }
.sample h2 { margin: 0 0 8px; font-size: 20px; }
.hint { margin: 0 0 16px; font-size: 13px; color: #6d7771; }
.pair { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
.thumb { display: grid; gap: 8px; }
.thumb img { width: 100%; border-radius: 12px; box-shadow: 0 8px 24px rgba(24,33,29,0.12); }
.thumb span { font-size: 13px; color: #6d7771; }
.feed-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; max-width: 520px; }
.feed-card { border: none; border-radius: 12px; overflow: hidden; background: #fff; box-shadow: 0 2px 10px rgba(24,33,29,0.07); }
.thumb img { width: 100%; max-width: 270px; border-radius: 12px; box-shadow: 0 8px 24px rgba(24,33,29,0.12); }
.thumb span { font-size: 12px; line-height: 1.5; }
.thumb span.bad { color: #b45309; }
.thumb span.good { color: #2f6f57; }
.feed-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; max-width: 520px; margin-top: 18px; }
.feed-card { border-radius: 12px; overflow: hidden; background: #fff; box-shadow: 0 2px 10px rgba(24,33,29,0.07); }
.feed-cover { aspect-ratio: 3 / 4; background: #ece7df; }
.feed-cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.feed-body { padding: 10px 12px 12px; }
.feed-body h3 { margin: 0; font-size: 14px; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.feed-body p { margin: 6px 0 0; font-size: 12px; color: #939c97; }
code { background: #f0ebe3; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
</style>
</head>
<body>
<h1>MindSpace 预览图升级 Demo</h1>
<p class="lead">左侧为旧版横版缩略图中间为新版 3:4 信息流封面自动从 HTML 提取标题配色emoji右侧为保存页面时实际写入的 thumbnail.svg下方是小红书风格卡片效果</p>
<div class="compare">
${cards
.map(
(card, index) => `
<section class="sample">
<h2>样本 ${index + 1}</h2>
<div class="pair">
<div class="thumb"><img src="./${samples[index].id}-legacy.svg" alt="旧版" /><span>旧版横版字卡</span></div>
<div class="thumb"><img src="./${samples[index].id}-feed.svg" alt="新版" /><span>新版3:4 精美封面</span></div>
<div class="thumb"><img src="./${samples[index].id}-generated.svg" alt="生成结果" /><span>generateHtmlThumbnail 输出</span></div>
</div>
<div class="feed-grid" style="margin-top:18px">${card}</div>
</section>`,
)
.join('')}
</div>
<h1>方案 Amindspace-cover 对比 Demo</h1>
<p class="lead">
页面生成时在 <code>&lt;head&gt;</code> <code>mindspace-cover</code> hero
缩略图会从纯色渐变升级为照片封面 + 标题
交付前可跑 <code>npm run check:mindspace-cover</code>
</p>
<div class="compare">${sections.join('')}</div>
</body>
</html>`;
await fs.writeFile(path.join(outputDir, 'index.html'), html, 'utf8');
console.log(`已生成预览页: ${path.join(outputDir, 'index.html')}`);
console.log(`已生成对比页: ${path.join(outputDir, 'index.html')}`);
console.log('本地查看: http://localhost:5173/thumbnail-demo/');
}
+134 -43
View File
@@ -78,6 +78,8 @@ import { createPageEditSessionService } from './mindspace-page-edit-session.mjs'
import { suggestCoverMetaWithAi } from './mindspace-cover-ai.mjs';
import {
publicationInternals,
rewriteBrokenMindSpacePublicImageUrls,
rewritePublicationCanonicalAssetUrls,
rewriteWorkspacePublicAssetReferences,
} from './mindspace-publications.mjs';
import { createPlazaPostService, formatPostRow, mapPlazaError } from './plaza-posts.mjs';
@@ -122,7 +124,9 @@ import {
normalizePublicHtmlRelativePath,
syncPublicHtmlAfterFinish,
} from './mindspace-public-finish-sync.mjs';
import { quickPlazaFromChat } from './mindspace-chat-plaza.mjs';
import { quickPlazaFromChat, quickPlazaFromPublicHtml, getQuickPlazaFromPublicHtmlStatus } from './mindspace-chat-plaza.mjs';
import { injectPublicFileShareButton } from './mindspace-public-share-widget.mjs';
import { resolvePlazaPostPath, resolvePlazaPublicBase } from './src/utils/public-site-bases.mjs';
import { extractCoverSignals, generateHtmlThumbnail } from './mindspace-thumbnails.mjs';
import {
extractSharePreviewMeta,
@@ -2143,6 +2147,16 @@ function ensurePlazaInteractions(res, req) {
return true;
}
function resolvePlazaPostUrlForRequest(postId, req) {
const host = String(req.headers['x-forwarded-host'] || req.headers.host || '').split(':')[0];
const base = resolvePlazaPublicBase({
configuredBase: process.env.PLAZA_PUBLIC_BASE ?? '',
dev: process.env.NODE_ENV !== 'production',
hostname: host,
});
return resolvePlazaPostPath(base, postId);
}
function plazaRouteError(res, req, error) {
const status = mapPlazaError(error);
const code = error?.code ?? 'internal_error';
@@ -2191,6 +2205,7 @@ function mindSpaceError(res, req, error) {
invalid_source_message: 409,
version_conflict: 409,
slug_conflict: 409,
page_already_online: 409,
invalid_state_transition: 409,
invalid_publish_input: 400,
publication_not_found: 404,
@@ -3369,6 +3384,76 @@ api.post('/mindspace/v1/pages/quick-plaza-from-chat', async (req, res) => {
}
});
api.get('/mindspace/v1/pages/quick-plaza-from-public-html/status', async (req, res) => {
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用');
}
try {
const relativePath = String(req.query?.relative_path ?? req.query?.relativePath ?? '').trim();
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
const result = await getQuickPlazaFromPublicHtmlStatus({
user: req.currentUser,
relativePath,
mindSpacePages,
mindSpacePublications,
plazaPosts,
});
const postId = result.post?.id;
return sendData(res, req, {
...result,
plaza_url: postId ? resolvePlazaPostUrlForRequest(postId, req) : null,
});
} catch (error) {
if (error?.code && mapPlazaError(error) !== 500) {
return plazaRouteError(res, req, error);
}
return mindSpaceError(res, req, error);
}
});
api.post('/mindspace/v1/pages/quick-plaza-from-public-html', async (req, res) => {
if (!mindSpacePages || !mindSpacePublications || !plazaPosts) {
return sendError(res, req, 503, 'plaza_unavailable', 'Plaza 或 MindSpace 未启用');
}
const startedAt = Date.now();
try {
const relativePath = String(req.body?.relative_path ?? req.body?.relativePath ?? '').trim();
const publishDir = resolveMindSpaceUserPublishDir(__dirname, req.currentUser);
const result = await quickPlazaFromPublicHtml({
user: req.currentUser,
relativePath,
mindSpacePages,
mindSpacePublications,
plazaPosts,
publishDir,
});
const plazaUrl = resolvePlazaPostUrlForRequest(result.post.id, req);
console.info('[quick-plaza-public-html] ok', {
ms: Date.now() - startedAt,
pageId: result.pageId,
publicationId: result.publicationId,
postId: result.post?.id,
relativePath,
});
return sendData(res, req, { ...result, plaza_url: plazaUrl }, 201);
} catch (error) {
console.error('[quick-plaza-public-html] error:', { ms: Date.now() - startedAt, error });
if (error?.code === 'ALREADY_PUBLISHED') {
const postId = error?.details?.post_id ?? error?.details?.postId;
if (postId) {
error.details = {
...(error.details ?? {}),
plaza_url: resolvePlazaPostUrlForRequest(postId, req),
};
}
}
if (error?.code && mapPlazaError(error) !== 500) {
return plazaRouteError(res, req, error);
}
return mindSpaceError(res, req, error);
}
});
async function handleChatSaveDocx(req, res) {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
@@ -4822,49 +4907,12 @@ function publishedPageCsp(html, { embed = false, raw = false, wechatShare = fals
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline' https://res.wx.qq.com";
}
if (raw && isFullHtml) {
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'";
return "default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; script-src 'unsafe-inline'";
}
if (isFullHtml) {
return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrcDirective({ hashes: scriptHashes })}`;
return `default-src 'none'; style-src 'unsafe-inline' https:; img-src 'self' data: http: https:; font-src 'self' https: data:; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'; ${scriptSrcDirective({ hashes: scriptHashes })}`;
}
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'";
}
const PUBLIC_FILE_SHARE_SCRIPT = `(function(){var root=document.querySelector('[data-mindspace-public-share]');if(!root)return;var shareButton=root.querySelector('[data-action="share"]');var captureButton=root.querySelector('[data-action="capture"]');var status=root.querySelector('small');var timer=null;function setStatus(text,err){if(!status)return;status.textContent=text||'';status.classList.toggle('is-error',!!err);if(timer)clearTimeout(timer);if(text)timer=setTimeout(function(){status.textContent='';status.classList.remove('is-error');},2200);}function cleanUrl(){var url=new URL(location.href);url.hash='';url.searchParams.delete('download');url.searchParams.delete('export');return url.toString();}function withParam(url,key,value){var next=new URL(url);next.searchParams.set(key,value);return next.toString();}function fallbackCopy(text){var ta=document.createElement('textarea');ta.value=text;ta.style.position='fixed';ta.style.left='-9999px';ta.style.opacity='0';document.body.appendChild(ta);ta.focus();ta.select();var ok=document.execCommand('copy');ta.remove();if(!ok)throw new Error('复制失败');}async function copyText(text){if(navigator.clipboard&&navigator.clipboard.writeText){try{await navigator.clipboard.writeText(text);return;}catch(e){}}fallbackCopy(text);}shareButton&&shareButton.addEventListener('click',async function(){var url=cleanUrl();var title=document.title||'MindSpace';try{if(navigator.share){await navigator.share({title:title,url:url});setStatus('已打开分享');return;}await copyText(url);setStatus('链接已复制');}catch(e){setStatus(e&&e.message?e.message:'分享失败',true);}});captureButton&&captureButton.addEventListener('click',function(){setStatus('正在生成长图,请稍候');var link=document.createElement('a');link.href=withParam(cleanUrl(),'download','long-image');link.download='';document.body.appendChild(link);link.click();link.remove();});})();`;
const PUBLIC_FILE_SHARE_SCRIPT_HASH = crypto
.createHash('sha256')
.update(PUBLIC_FILE_SHARE_SCRIPT)
.digest('base64');
function injectPublicFileShareButton(html) {
const source = String(html ?? '');
if (!source || source.includes('data-mindspace-public-share')) {
return { html: source, scriptHashes: [] };
}
const markup = `
<style id="mindspace-public-share-style">
[data-mindspace-public-share]{position:fixed;right:18px;bottom:calc(18px + env(safe-area-inset-bottom,0px));z-index:2147483000;display:flex;flex-direction:column;align-items:flex-end;gap:6px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
[data-mindspace-public-share] div{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap}
[data-mindspace-public-share] button{border:0;border-radius:999px;padding:10px 15px;background:#2f6f57;color:#fff;font-size:14px;font-weight:700;box-shadow:0 8px 24px rgba(0,0,0,.18);cursor:pointer}
[data-mindspace-public-share] button[data-action="capture"]{background:#8f6b2f}
[data-mindspace-public-share] button:active{transform:translateY(1px)}
[data-mindspace-public-share] small{min-height:18px;max-width:180px;border-radius:999px;padding:4px 9px;background:rgba(255,255,255,.92);color:#245845;font-size:12px;text-align:right;box-shadow:0 4px 14px rgba(0,0,0,.1)}
[data-mindspace-public-share] small:empty{display:none}
[data-mindspace-public-share] small.is-error{color:#8b2d20}
@media(max-width:640px){[data-mindspace-public-share]{right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px))}[data-mindspace-public-share] button{padding:9px 13px;font-size:13px}}
</style>
<div data-mindspace-public-share><small aria-live="polite"></small><div><button type="button" data-action="capture"></button><button type="button" data-action="share"></button></div></div>
<script>${PUBLIC_FILE_SHARE_SCRIPT}</script>`;
if (/<\/body>/i.test(source)) {
return {
html: source.replace(/<\/body>/i, `${markup}</body>`),
scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH],
};
}
return {
html: `${source}${markup}`,
scriptHashes: [PUBLIC_FILE_SHARE_SCRIPT_HASH],
};
return "default-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src 'none'; connect-src 'self'; base-uri 'none'; form-action 'self'; frame-ancestors 'self'";
}
function extractOgImageUrl(html) {
@@ -5232,8 +5280,11 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title, longImageUrl }) {
</html>`;
}
async function sendPublishedPage(req, res, result, { embed = false, raw = false } = {}) {
let html = result.html;
async function sendPublishedPage(req, res, result, { embed = false, raw = false, ownerSlug = null } = {}) {
let html = rewriteBrokenMindSpacePublicImageUrls(result.html);
if (ownerSlug) {
html = rewritePublicationCanonicalAssetUrls(html, ownerSlug);
}
const origin = resolveRequestOrigin(req);
const originalPath = req.originalUrl || req.url || '';
const sharePath = removeQueryParam(removeQueryParam(originalPath, 'download'), 'export');
@@ -5458,6 +5509,7 @@ async function resolvePublishedRoute(req, res, password = null) {
return await sendPublishedPage(req, res, result, {
embed: isPlazaEmbedRequest(req.query),
raw: String(req.query.view ?? '').toLowerCase() === 'raw',
ownerSlug: req.params.ownerSlug,
});
} catch (error) {
if (error?.code === 'publication_password_required') {
@@ -5505,6 +5557,45 @@ app.get('/u/:ownerSlug/pages/:urlSlug', async (req, res) => {
return resolvePublishedRoute(req, res);
});
app.use('/u/:ownerSlug/public', async (req, res) => {
await userAuthReady;
const ownerSlug = String(req.params.ownerSlug ?? '').trim().toLowerCase();
if (!ownerSlug) return res.status(404).json({ message: '用户不存在' });
if (!authPool) return res.status(503).send('MindSpace 未启用');
try {
const [rows] = await authPool.query(
`SELECT id FROM h5_users WHERE LOWER(COALESCE(slug, username)) = ? LIMIT 1`,
[ownerSlug],
);
const userId = rows[0]?.id ? String(rows[0].id) : null;
if (!userId) return res.status(404).json({ message: '用户不存在' });
const relativePath = String(req.path ?? '')
.replace(/^\/+/, '')
.split('/')
.map((segment) => decodeURIComponent(segment))
.filter(Boolean);
if (relativePath.length === 0) return res.status(404).json({ message: '文件不存在' });
const publishDir = resolveMindSpaceUserPublishDir(__dirname, { id: userId });
const resolvedRoot = path.resolve(publishDir);
const filePath = path.resolve(publishDir, PUBLIC_ZONE_DIR, ...relativePath);
if (filePath !== resolvedRoot && !filePath.startsWith(`${resolvedRoot}${path.sep}`)) {
return res.status(403).json({ message: '禁止访问' });
}
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
return res.status(404).json({ message: '文件不存在' });
}
return res.sendFile(filePath, (err) => {
if (err && !res.headersSent) res.status(404).json({ message: '文件不存在' });
});
} catch {
return res.status(500).send('文件加载失败');
}
});
app.get('/u/:ownerSlug', async (req, res) => {
await userAuthReady;
if (!mindSpacePublications) return res.status(503).send('MindSpace 未启用');
+20 -1
View File
@@ -92,7 +92,26 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
**禁止**省略 mindspace-cover 或填写与页面无关的通用配色;促销/运动/品牌页必须写明 `tag``accent``cover`
本地对比示例:`node scripts/thumbnail-preview-demo.mjs``/thumbnail-demo/`
本地对比示例:`npm run demo:thumbnails``/thumbnail-demo/`(左侧缺 cover vs 右侧方案 A)
### 交付前自检(必须)
写完 `public/*.html` 后,**必须**运行 cover 合规检查;有 error 则补全元数据后再交付链接:
```bash
npm run check:mindspace-cover
# 或指定用户:node scripts/check-mindspace-cover.mjs --user <uuid>
```
| 检查项 | 级别 | 说明 |
|--------|------|------|
| `mindspace-cover` meta | error | 缺失则缩略图为默认绿色渐变 |
| `description` | error | 副标题来源 |
| `cover` raster 主图 | warn | 缺失则无法生成照片封面 |
| `tag` / `accent` / `subtitle` | warn | 影响分类与配色 |
| 平台品牌页脚 | warn | 分享与编辑规范 |
**视觉类页面**若有 warn `missing_cover_image`,视为封面未达标,必须补 hero 图后再回复用户。
## 平台页脚标记(必须)
+24 -3
View File
@@ -1,10 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { ApiError, quickPlazaFromChat } from '../api/client';
import { resolvePlazaPostUrl } from '../utils/publicUrl';
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
type PlazaPublishPhase =
| { kind: 'confirm' }
| { kind: 'loading' }
| { kind: 'success'; plazaUrl: string }
| { kind: 'already'; plazaUrl: string; message: string }
@@ -28,11 +29,15 @@ export function ChatPlazaPublishModal({
messageId: string;
onClose: () => void;
}) {
const [phase, setPhase] = useState<PlazaPublishPhase>({ kind: 'loading' });
const [phase, setPhase] = useState<PlazaPublishPhase>({ kind: 'confirm' });
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
const startPublish = useCallback(() => {
setPhase({ kind: 'loading' });
}, []);
useEffect(() => {
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
@@ -52,6 +57,7 @@ export function ChatPlazaPublishModal({
}, [onClose, phase.kind]);
useEffect(() => {
if (phase.kind !== 'loading') return;
const controller = new AbortController();
void quickPlazaFromChat({ sessionId, messageId }, controller.signal)
@@ -79,7 +85,7 @@ export function ChatPlazaPublishModal({
});
return () => controller.abort();
}, [sessionId, messageId]);
}, [phase.kind, sessionId, messageId]);
const canClose = phase.kind !== 'loading';
@@ -97,6 +103,21 @@ export function ChatPlazaPublishModal({
aria-busy={phase.kind === 'loading'}
onClick={(event) => event.stopPropagation()}
>
{phase.kind === 'confirm' && (
<div className="chat-plaza-publish-body">
<h3 id="chat-plaza-publish-title"> Plaza</h3>
<p>广</p>
<div className="chat-plaza-publish-actions">
<button type="button" className="chat-plaza-publish-close-btn" onClick={onClose}>
</button>
<button type="button" className="chat-plaza-publish-confirm-btn" onClick={startPublish}>
</button>
</div>
</div>
)}
{phase.kind === 'loading' && (
<div className="chat-plaza-publish-body">
<ChatLoadingSpinner className="chat-plaza-publish-spinner" />
+26 -1
View File
@@ -1532,8 +1532,17 @@ body,
text-underline-offset: 2px;
}
.chat-plaza-publish-close-btn {
.chat-plaza-publish-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
width: 100%;
margin-top: 8px;
}
.chat-plaza-publish-close-btn {
margin-top: 0;
padding: 8px 18px;
border: 1px solid rgba(24, 33, 29, 0.14);
border-radius: 999px;
@@ -1544,11 +1553,27 @@ body,
font-size: 14px;
}
.chat-plaza-publish-confirm-btn {
padding: 8px 18px;
border: 1px solid rgba(47, 111, 87, 0.35);
border-radius: 999px;
background: #2f6f57;
color: #fff;
cursor: pointer;
font: inherit;
font-size: 14px;
font-weight: 600;
}
.chat-plaza-publish-close-btn:hover {
border-color: rgba(47, 111, 87, 0.35);
color: #2f6f57;
}
.chat-plaza-publish-confirm-btn:hover {
background: #265a46;
}
.page-save-panel {
display: flex;
flex-direction: column;
+2 -2
View File
@@ -56,8 +56,8 @@ export function isImageAsset(asset: MindSpaceAsset) {
return asset.assetType === 'image' || asset.mimeType.startsWith('image/');
}
export function buildAssetImageUrl(asset: Pick<MindSpaceAsset, 'id' | 'updatedAt' | 'publicUrl'>) {
if (asset.publicUrl) return asset.publicUrl;
export function buildAssetImageUrl(asset: Pick<MindSpaceAsset, 'id' | 'updatedAt'>) {
// MindSpace 空间内始终走受鉴权的下载接口,避免 publicUrl(imgproxy / 公开静态路径)在本地或工作区资产上失效。
return `/api/mindspace/v1/assets/${asset.id}/download?inline=1&viewer=0&v=${asset.updatedAt}`;
}
+10
View File
@@ -224,6 +224,16 @@ description: 在专属 MindSpace 目录生成可公开访问的静态 HTML 报
**禁止**使用与页面无关的通用风景图逻辑促销/运动/品牌页必须写明 \`tag\`\`accent\`\`cover\`
### 交付前自检必须
写完 \`public/*.html\` 后运行 cover 合规检查;有 error 则补全后再交付链接:
\`\`\`bash
npm run check:mindspace-cover
\`\`\`
视觉类页面若出现 \`missing_cover_image\` 警告,必须补 hero 主图后再回复用户。对比效果:\`npm run demo:thumbnails\`\`/thumbnail-demo/\`
## HTML 模板建议
- 完整 \`<!DOCTYPE html>\`\`lang="zh-CN"\`
+3 -2
View File
@@ -28,8 +28,9 @@ export function buildPageGenerateAgentPrompt(intent, { wantsDocx = false } = {})
' - `<meta name="description" content="一句话摘要">`',
' - `<meta name="mindspace-cover" content=\'{"tag":"主题","accent":"#主色","accent2":"#辅色","subtitle":"卖点","cover":"assets/hero.jpg"}\'>`',
' tag/accent/subtitle 必须与页面主题一致;有 hero 图时 cover 指向同目录 raster 图(jpg/png)。',
'6. 页脚加 `<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>`(必须;禁止省略、不要用邮箱/tkmind.ai,也不要把该行 opacity 设太低导致看不见)。',
'7. 回复里只给一个正式域名链接;没有落盘或缺少上述元数据就不要发链接。',
'6. 交付前运行 `npm run check:mindspace-cover`(或 node scripts/check-mindspace-cover.mjs --user <uid>);有 error 则补元数据后再回复链接。',
'7. 页脚加 `<p data-mindspace-page-tag="platform-brand">TKMind · 智趣</p>`(必须;禁止省略、不要用邮箱/tkmind.ai,也不要把该行 opacity 设太低导致看不见)。',
'8. 回复里只给一个正式域名链接;没有落盘或缺少上述元数据就不要发链接。',
'',
buildCurrentTimeAgentPrefix({ timezone: scheduleTimezone }),
`用户需求:${topic}`,