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:
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 }; },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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){
|
||||
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>方案 A:mindspace-cover 对比 Demo</h1>
|
||||
<p class="lead">
|
||||
页面生成时在 <code><head></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 |
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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, '"')}">`;
|
||||
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 + 缩略图重建');
|
||||
@@ -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-cover,good = 方案 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>方案 A:mindspace-cover 对比 Demo</h1>
|
||||
<p class="lead">
|
||||
页面生成时在 <code><head></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
@@ -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 未启用');
|
||||
|
||||
@@ -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 图后再回复用户。
|
||||
|
||||
## 平台页脚标记(必须)
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"\`
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user