feat(mindspace): 全部页面管理、级联删除与页面同步去重

支持全部页面分页/多选/删除/分享,删除时可选移除广场帖并清理工作区附件;修复并发同步重复创建页面,并补齐预览下载链接重写与 iframe 下载权限。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-06-30 01:22:38 +08:00
parent 6e6d00568a
commit ea6c49f046
30 changed files with 2562 additions and 351 deletions
+184 -26
View File
@@ -12,6 +12,8 @@ import {
bufferToImageDataUri,
} from './mindspace-thumbnails.mjs';
import { resolvePublishDir } from './user-publish.mjs';
import { prepareHtmlDownloadLinks } from './mindspace-html-download-links.mjs';
import { purgeWorkspacePageArtifacts, extractAssetIdsFromHtml } from './mindspace-page-purge.mjs';
import { ensureWorkspaceHtmlThumbnail, workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
import { upsertMindspaceCoverMeta } from './mindspace-cover-meta.mjs';
@@ -87,8 +89,8 @@ function extensionForMime(mimeType, filename = '') {
return 'bin';
}
function pageResponse(row) {
return {
function pageResponse(row, { includeContent = true } = {}) {
const response = {
id: row.id,
categoryId: row.category_id,
categoryCode: row.category_code,
@@ -107,10 +109,21 @@ function pageResponse(row) {
publicationUrl: row.pub_public_url ?? null,
currentVersionId: row.current_version_id,
versionNo: asNumber(row.version_no),
content: row.content,
createdAt: asNumber(row.created_at),
updatedAt: asNumber(row.updated_at),
};
if (includeContent) {
response.content = row.content;
}
return response;
}
const LIST_PAGES_MAX_LIMIT = 100;
function normalizeListPageFilters(filters = {}) {
const limit = Math.min(Math.max(Number(filters.limit) || LIST_PAGES_MAX_LIMIT, 1), LIST_PAGES_MAX_LIMIT);
const offset = Math.max(Number(filters.offset) || 0, 0);
return { limit, offset };
}
function escapeHtml(value) {
@@ -563,17 +576,35 @@ export function createPageService(pool, options = {}) {
clauses.push(`p.status = ?`);
params.push(filters.status);
}
const [rows] = await pool.query(
`SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
FROM h5_page_records p
if (filters.categoryCode) {
clauses.push(`c.category_code = ?`);
params.push(filters.categoryCode);
}
const where = clauses.join(' AND ');
const { limit, offset } = normalizeListPageFilters(filters);
const baseFrom = `FROM h5_page_records p
JOIN h5_space_categories c ON c.id = p.category_id AND c.user_id = p.user_id
LEFT JOIN h5_page_versions pv ON pv.id = p.current_version_id
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'
WHERE ${clauses.join(' AND ')}
ORDER BY p.created_at DESC, p.updated_at DESC LIMIT 100`,
params,
);
return rows.map(pageResponse);
LEFT JOIN h5_publish_records pr ON pr.id = p.current_publish_id AND pr.status = 'online'`;
const [[countRows], [rows]] = await Promise.all([
pool.query(`SELECT COUNT(*) AS total ${baseFrom} WHERE ${where}`, params),
pool.query(
`SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url
${baseFrom}
WHERE ${where}
ORDER BY p.created_at DESC, p.updated_at DESC
LIMIT ? OFFSET ?`,
[...params, limit, offset],
),
]);
const total = asNumber(countRows[0]?.total);
return {
items: rows.map((row) => pageResponse(row, { includeContent: false })),
total,
limit,
offset,
hasMore: offset + rows.length < total,
};
};
async function getPage(userId, pageId) {
@@ -688,6 +719,47 @@ export function createPageService(pool, options = {}) {
};
};
const loadPageWorkspaceContext = async (userId, pageId) => {
const [rows] = await pool.query(
`SELECT pv.source_snapshot_json
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
LIMIT 1`,
[pageId, userId],
);
let snapshot = {};
try {
snapshot = JSON.parse(rows[0]?.source_snapshot_json ?? '{}');
} catch {
snapshot = {};
}
const workspaceHtmlRelativePath = snapshot.relative_path ?? 'public/index.html';
const workspacePublishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null;
return {
workspaceHtmlRelativePath,
workspacePublishDir,
publishKey: userId,
};
};
const finalizeHtmlPreview = async (userId, pageId, html) => {
const shell = renderHtmlPreview(html);
try {
const ctx = await loadPageWorkspaceContext(userId, pageId);
const { html: rewritten } = await prepareHtmlDownloadLinks(pool, userId, shell, {
htmlRelativePath: ctx.workspaceHtmlRelativePath,
publishDir: ctx.workspacePublishDir,
publishKey: ctx.publishKey,
publicBaseUrl: '',
preferAssetDownload: true,
});
return rewritten;
} catch {
return shell;
}
};
const localizePrivateResources = async (userId, pageId, input = {}) => {
const page = await getPage(userId, pageId);
if (input.pageVersionId && input.pageVersionId !== page.currentVersionId) {
@@ -795,7 +867,7 @@ export function createPageService(pool, options = {}) {
};
};
const collectLinkedAssets = async (conn, userId, pageId) => {
const collectLinkedAssets = async (conn, userId, pageId, options = {}) => {
const [versions] = await conn.query(
`SELECT pv.content_asset_id, pv.bundle_asset_id
FROM h5_page_versions pv
@@ -807,6 +879,12 @@ export function createPageService(pool, options = {}) {
if (version.content_asset_id) assetKind.set(version.content_asset_id, 'content');
if (version.bundle_asset_id) assetKind.set(version.bundle_asset_id, 'bundle');
}
if (options.sourceAssetId) {
assetKind.set(options.sourceAssetId, 'source');
}
for (const assetId of options.embeddedAssetIds ?? []) {
if (assetId) assetKind.set(assetId, 'embedded');
}
const assetIds = [...assetKind.keys()];
if (assetIds.length === 0) {
return { assets: [], totalBytes: 0, assetIds: [] };
@@ -830,6 +908,34 @@ export function createPageService(pool, options = {}) {
};
};
const listPlazaPostsForPage = async (conn, userId, pageId) => {
const [rows] = await conn.query(
`SELECT pp.id, pp.title, pp.status, pp.publication_id
FROM plaza_posts pp
JOIN h5_publish_records pr ON pr.id = pp.publication_id
WHERE pr.page_id = ? AND pr.user_id = ? AND pp.status != 'hidden'
ORDER BY pp.published_at DESC, pp.updated_at DESC`,
[pageId, userId],
);
return rows.map((row) => ({
id: row.id,
title: row.title,
status: row.status,
publicationId: row.publication_id,
}));
};
const hidePlazaPostsForPage = async (conn, userId, pageId, now) => {
const [result] = await conn.query(
`UPDATE plaza_posts pp
JOIN h5_publish_records pr ON pr.id = pp.publication_id
SET pp.status = 'hidden', pp.updated_at = ?
WHERE pr.page_id = ? AND pr.user_id = ? AND pp.status != 'hidden'`,
[now, pageId, userId],
);
return asNumber(result.affectedRows);
};
const offlineOnlinePublications = async (conn, userId, pageId, now) => {
const [rows] = await conn.query(
`SELECT id, page_version_id, access_mode
@@ -906,6 +1012,7 @@ export function createPageService(pool, options = {}) {
);
const page = rows[0];
if (!page) throw pageError('页面不存在', 'page_not_found');
const pageDetail = await getPage(userId, pageId).catch(() => null);
const conn = await pool.getConnection();
try {
@@ -913,7 +1020,13 @@ export function createPageService(pool, options = {}) {
`SELECT COUNT(*) AS version_count FROM h5_page_versions WHERE page_id = ?`,
[pageId],
);
const { assets, totalBytes } = await collectLinkedAssets(conn, userId, pageId);
const { assets, totalBytes } = await collectLinkedAssets(conn, userId, pageId, {
sourceAssetId: page.source_asset_id,
embeddedAssetIds:
pageDetail?.contentFormat === 'html'
? extractAssetIdsFromHtml(pageDetail.content ?? '')
: [],
});
const [publications] = await conn.query(
`SELECT id, status, public_url, access_mode, view_count
FROM h5_publish_records
@@ -929,6 +1042,7 @@ export function createPageService(pool, options = {}) {
);
const onlinePublication = publications.find((item) => item.status === 'online') ?? null;
const offlinePublicationCount = publications.filter((item) => item.status !== 'online').length;
const plazaPosts = await listPlazaPostsForPage(conn, userId, pageId);
const preview = {
page: {
id: page.id,
@@ -945,10 +1059,11 @@ export function createPageService(pool, options = {}) {
}
: null,
offlinePublicationCount,
plazaPosts,
linkedAssets: assets,
linkedAssetBytes: totalBytes,
linkedAgentJobCount: asNumber(agentJobs[0]?.job_count),
preservesSourceAsset: Boolean(page.source_asset_id),
preservesSourceAsset: false,
};
return { ...preview, summaryLines: buildPageDeleteSummary(preview) };
} finally {
@@ -956,27 +1071,54 @@ export function createPageService(pool, options = {}) {
}
};
const deletePage = async (userId, pageId) => {
const deletePage = async (userId, pageId, options = {}) => {
const removeFromPlaza = Boolean(options.removeFromPlaza);
const page = await getPage(userId, pageId);
let workspaceHtmlRelativePath = null;
try {
const [rows] = await pool.query(
`SELECT pv.source_snapshot_json
FROM h5_page_records p
JOIN h5_page_versions pv ON pv.id = p.current_version_id
WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted'
LIMIT 1`,
[pageId, userId],
);
const snapshot = JSON.parse(rows[0]?.source_snapshot_json ?? '{}');
workspaceHtmlRelativePath = snapshot.relative_path ?? null;
} catch {
workspaceHtmlRelativePath = null;
}
const embeddedAssetIds =
page.contentFormat === 'html' ? extractAssetIdsFromHtml(page.content ?? '') : [];
const conn = await pool.getConnection();
let result;
try {
await conn.beginTransaction();
const [pages] = await conn.query(
`SELECT id, space_id, title
`SELECT id, space_id, title, source_asset_id
FROM h5_page_records
WHERE id = ? AND user_id = ? AND status <> 'deleted'
LIMIT 1 FOR UPDATE`,
[pageId, userId],
);
const page = pages[0];
if (!page) throw pageError('页面不存在', 'page_not_found');
const pageRow = pages[0];
if (!pageRow) throw pageError('页面不存在', 'page_not_found');
const now = Date.now();
const { assetIds } = await collectLinkedAssets(conn, userId, pageId);
const { assetIds } = await collectLinkedAssets(conn, userId, pageId, {
sourceAssetId: pageRow.source_asset_id,
embeddedAssetIds,
});
const offlinedPublicationCount = await offlineOnlinePublications(conn, userId, pageId, now);
const hiddenPlazaPostCount = removeFromPlaza
? await hidePlazaPostsForPage(conn, userId, pageId, now)
: 0;
const { deletedAssetCount, freedBytes } = await softDeleteLinkedAssets(
conn,
userId,
page.space_id,
pageRow.space_id,
assetIds,
now,
);
@@ -997,16 +1139,17 @@ export function createPageService(pool, options = {}) {
await conn.query(
`UPDATE h5_page_records
SET status = 'deleted', deleted_at = ?, updated_at = ?,
current_publish_id = NULL, visibility = 'private'
current_publish_id = NULL, visibility = 'private', source_asset_id = NULL
WHERE id = ? AND user_id = ?`,
[now, now, pageId, userId],
);
await conn.commit();
return {
result = {
deleted: true,
pageId,
title: page.title,
title: pageRow.title,
offlinedPublicationCount,
hiddenPlazaPostCount,
deletedAssetCount,
freedBytes,
clearedAgentJobCount,
@@ -1017,6 +1160,15 @@ export function createPageService(pool, options = {}) {
} finally {
conn.release();
}
const workspacePublishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null;
const purgeResult = await purgeWorkspacePageArtifacts({
publishDir: workspacePublishDir,
htmlRelativePath: workspaceHtmlRelativePath,
html: page.content ?? '',
}).catch(() => ({ removed: [], skipped: [] }));
return { ...result, workspaceFilesRemoved: purgeResult.removed ?? [] };
};
const loadPageThumbnailContext = async (userId, pageId) => {
@@ -1177,7 +1329,7 @@ export function createPageService(pool, options = {}) {
const page = await getPage(userId, pageId);
const html =
page.contentFormat === 'html'
? renderHtmlPreview(page.content)
? await finalizeHtmlPreview(userId, pageId, page.content)
: renderPreviewHtml(page);
return { html, contentFormat: page.contentFormat };
},
@@ -1189,7 +1341,7 @@ export function createPageService(pool, options = {}) {
const templateId = input.templateId ?? page.templateId;
const html =
page.contentFormat === 'html'
? renderHtmlPreview(content)
? await finalizeHtmlPreview(userId, pageId, content)
: renderPreviewHtml({
title,
summary,
@@ -1222,6 +1374,7 @@ export function createPageService(pool, options = {}) {
export const pageInternals = {
escapeHtml,
normalizePageInput,
normalizeListPageFilters,
renderContent,
renderPreviewHtml,
renderHtmlPreview,
@@ -1294,6 +1447,11 @@ export function buildPageDeleteSummary(preview) {
if (preview.linkedAgentJobCount > 0) {
lines.push(`${preview.linkedAgentJobCount} 个 Agent 任务结果关联将被清除`);
}
if (preview.plazaPosts?.length > 0) {
lines.push(
`该页面在广场有 ${preview.plazaPosts.length} 个帖子;可在下方选择是否一并从广场移除`,
);
}
if (preview.preservesSourceAsset) {
lines.push('原始上传资料不会被删除');
}