feat(mindspace): 全部页面管理、级联删除与页面同步去重
支持全部页面分页/多选/删除/分享,删除时可选移除广场帖并清理工作区附件;修复并发同步重复创建页面,并补齐预览下载链接重写与 iframe 下载权限。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+184
-26
@@ -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('原始上传资料不会被删除');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user