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 4e7ef3d853
commit 66ca0b3bec
30 changed files with 2562 additions and 351 deletions
+30
View File
@@ -353,6 +353,36 @@ CREATE TABLE IF NOT EXISTS h5_user_sessions (
CONSTRAINT fk_h5_session_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_agent_runs (
id CHAR(36) PRIMARY KEY,
user_id CHAR(36) NOT NULL,
agent_session_id VARCHAR(128) NULL,
request_id VARCHAR(128) NOT NULL,
status ENUM('queued', 'running', 'retryable', 'succeeded', 'failed') NOT NULL DEFAULT 'queued',
attempts INT NOT NULL DEFAULT 0,
user_message_json LONGTEXT NOT NULL,
error_message TEXT NULL,
created_at BIGINT NOT NULL,
updated_at BIGINT NOT NULL,
started_at BIGINT NULL,
completed_at BIGINT NULL,
UNIQUE KEY uq_h5_agent_run_request (user_id, request_id),
KEY idx_h5_agent_run_user_status (user_id, status, updated_at),
KEY idx_h5_agent_run_session (agent_session_id, updated_at),
CONSTRAINT fk_h5_agent_run_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE,
CONSTRAINT fk_h5_agent_run_session FOREIGN KEY (agent_session_id) REFERENCES h5_user_sessions(agent_session_id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_agent_run_events (
id CHAR(36) PRIMARY KEY,
run_id CHAR(36) NOT NULL,
event_type VARCHAR(64) NOT NULL,
data_json JSON NULL,
created_at BIGINT NOT NULL,
KEY idx_h5_agent_run_event_run (run_id, created_at),
CONSTRAINT fk_h5_agent_run_event_run FOREIGN KEY (run_id) REFERENCES h5_agent_runs(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS h5_session_billing_state (
agent_session_id VARCHAR(128) NOT NULL PRIMARY KEY,
user_id CHAR(36) NOT NULL,
+947 -234
View File
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
import fs from 'node:fs';
import path from 'node:path';
import { buildPublicUrl } from './user-publish.mjs';
export const DOWNLOADABLE_FILE_PATTERN = /\.(?:docx?|pdf|xlsx?|pptx?|zip|csv|txt|md)$/i;
const URL_ATTR_PATTERN = /(\b(?:href|src)\s*=\s*["'])([^"']+)(["'])/gi;
export function isRelativeDownloadReference(value) {
const trimmed = String(value ?? '').trim();
if (!trimmed) return false;
const pathPart = trimmed.split('#')[0].split('?')[0];
if (/^([a-z][a-z0-9+.-]*:|\/|#|data:|mailto:|javascript:)/i.test(pathPart)) return false;
return DOWNLOADABLE_FILE_PATTERN.test(pathPart);
}
export function splitReferenceParts(relativeRef) {
const value = String(relativeRef ?? '');
const match = value.match(/^([^#?]+)(.*)$/);
return {
pathPart: match?.[1] ?? value,
suffix: match?.[2] ?? '',
};
}
export function resolveWorkspaceRelativeFilePath(htmlRelativePath, relativeRef) {
const { pathPart } = splitReferenceParts(relativeRef);
const ref = String(pathPart ?? '').replace(/^\/+/, '');
if (!ref) return ref;
if (/^(?:public|oa|private)\//.test(ref)) return ref;
const htmlPath = String(htmlRelativePath ?? 'public/index.html').replace(/^\/+/, '');
const htmlDir = path.posix.dirname(htmlPath);
if (!htmlDir || htmlDir === '.') return `public/${ref}`;
return path.posix.join(htmlDir, ref).replace(/\\/g, '/');
}
export function buildWorkspaceDownloadLinkIndex(assets = []) {
const byBasename = new Map();
const byPath = new Map();
for (const asset of assets) {
const id = asset.id;
const filename = String(asset.original_filename ?? asset.originalFilename ?? '').replace(/\\/g, '/');
if (!id || !filename) continue;
byPath.set(filename, id);
byBasename.set(path.posix.basename(filename), id);
if (filename.startsWith('public/')) {
byPath.set(filename.slice('public/'.length), id);
}
}
return { byBasename, byPath };
}
export function lookupAssetIdForWorkspacePath(index, workspaceRelativePath) {
const normalized = String(workspaceRelativePath ?? '').replace(/\\/g, '/');
if (!normalized || !index) return null;
return (
index.byPath.get(normalized)
?? index.byPath.get(`public/${normalized}`)
?? index.byBasename.get(path.posix.basename(normalized))
?? null
);
}
export function buildAssetDownloadUrl(assetId) {
return `/api/mindspace/v1/assets/${encodeURIComponent(assetId)}/download`;
}
export function buildPublicWorkspaceFileUrl(publicBaseUrl, publishKey, workspaceRelativePath) {
const clean = String(workspaceRelativePath ?? '').replace(/^\/+/, '');
return buildPublicUrl(publicBaseUrl, publishKey, clean);
}
function workspaceFileExists(publishDir, workspaceRelativePath) {
if (!publishDir || !workspaceRelativePath) return false;
const absolutePath = path.join(publishDir, ...String(workspaceRelativePath).split('/'));
try {
return fs.existsSync(absolutePath) && fs.statSync(absolutePath).isFile();
} catch {
return false;
}
}
export function resolveDownloadTargetUrl({
relativeRef,
htmlRelativePath = 'public/index.html',
publishDir = null,
linkIndex = null,
publicBaseUrl = '',
publishKey = null,
preferAssetDownload = true,
}) {
const { suffix } = splitReferenceParts(relativeRef);
const workspacePath = resolveWorkspaceRelativeFilePath(htmlRelativePath, relativeRef);
const assetId = lookupAssetIdForWorkspacePath(linkIndex, workspacePath);
const publicCandidates = [
workspacePath,
path.posix.join('public', path.posix.basename(workspacePath)),
];
if (preferAssetDownload && assetId) {
return `${buildAssetDownloadUrl(assetId)}${suffix}`;
}
if (publishDir && publishKey) {
for (const candidate of publicCandidates) {
if (workspaceFileExists(publishDir, candidate)) {
return `${buildPublicWorkspaceFileUrl(publicBaseUrl, publishKey, candidate)}${suffix}`;
}
}
}
if (assetId) {
return `${buildAssetDownloadUrl(assetId)}${suffix}`;
}
return null;
}
export function rewriteRelativeDownloadLinks(html, options = {}) {
const source = String(html ?? '');
if (!source) return { html: source, count: 0 };
let count = 0;
const rewritten = source.replace(URL_ATTR_PATTERN, (full, prefix, url, suffix) => {
if (!isRelativeDownloadReference(url)) return full;
const resolved = resolveDownloadTargetUrl({ ...options, relativeRef: url });
if (!resolved) return full;
count += 1;
return `${prefix}${resolved}${suffix}`;
});
return { html: rewritten, count };
}
export async function loadWorkspaceDownloadLinkIndex(pool, userId) {
const [rows] = await pool.query(
`SELECT id, original_filename
FROM h5_assets
WHERE user_id = ? AND status <> 'deleted'`,
[userId],
);
return buildWorkspaceDownloadLinkIndex(rows);
}
export async function prepareHtmlDownloadLinks(pool, userId, html, options = {}) {
const linkIndex = options.linkIndex ?? (await loadWorkspaceDownloadLinkIndex(pool, userId));
return rewriteRelativeDownloadLinks(html, { ...options, linkIndex });
}
export const downloadLinkInternals = {
isRelativeDownloadReference,
resolveWorkspaceRelativeFilePath,
buildWorkspaceDownloadLinkIndex,
lookupAssetIdForWorkspacePath,
resolveDownloadTargetUrl,
rewriteRelativeDownloadLinks,
};
+61
View File
@@ -0,0 +1,61 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { downloadLinkInternals } from './mindspace-html-download-links.mjs';
const {
isRelativeDownloadReference,
resolveWorkspaceRelativeFilePath,
buildWorkspaceDownloadLinkIndex,
resolveDownloadTargetUrl,
rewriteRelativeDownloadLinks,
} = downloadLinkInternals;
test('isRelativeDownloadReference accepts sibling docx links only', () => {
assert.equal(isRelativeDownloadReference('半导体行业趋势分析报告.docx'), true);
assert.equal(isRelativeDownloadReference('public/report.docx'), true);
assert.equal(isRelativeDownloadReference('/MindSpace/u/public/a.docx'), false);
assert.equal(isRelativeDownloadReference('https://example.com/a.pdf'), false);
assert.equal(isRelativeDownloadReference('#summary'), false);
});
test('resolveWorkspaceRelativeFilePath resolves from public html directory', () => {
assert.equal(
resolveWorkspaceRelativeFilePath('public/index.html', '半导体行业趋势分析报告.docx'),
'public/半导体行业趋势分析报告.docx',
);
assert.equal(
resolveWorkspaceRelativeFilePath('public/report.html', 'assets/data.csv'),
'public/assets/data.csv',
);
});
test('resolveDownloadTargetUrl prefers asset download API in preview mode', () => {
const linkIndex = buildWorkspaceDownloadLinkIndex([
{ id: 'asset-1', original_filename: 'public/半导体行业趋势分析报告.docx' },
]);
const url = resolveDownloadTargetUrl({
relativeRef: '半导体行业趋势分析报告.docx',
htmlRelativePath: 'public/index.html',
linkIndex,
preferAssetDownload: true,
});
assert.equal(url, '/api/mindspace/v1/assets/asset-1/download');
});
test('rewriteRelativeDownloadLinks rewrites href and src download attributes', () => {
const html = [
'<a href="半导体行业趋势分析报告.docx" download>下载</a>',
'<a href="#summary">摘要</a>',
].join('');
const linkIndex = buildWorkspaceDownloadLinkIndex([
{ id: 'asset-1', original_filename: '半导体行业趋势分析报告.docx' },
]);
const { html: rewritten, count } = rewriteRelativeDownloadLinks(html, {
htmlRelativePath: 'public/index.html',
linkIndex,
preferAssetDownload: true,
});
assert.equal(count, 1);
assert.match(rewritten, /href="\/api\/mindspace\/v1\/assets\/asset-1\/download"/);
assert.match(rewritten, /href="#summary"/);
});
+87
View File
@@ -0,0 +1,87 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import {
isRelativeDownloadReference,
resolveWorkspaceRelativeFilePath,
splitReferenceParts,
} from './mindspace-html-download-links.mjs';
import { workspaceThumbnailRelativePath } from './mindspace-workspace-thumbnails.mjs';
const PRIVATE_ASSET_URL_PATTERN =
/(?:https?:\/\/[^/]+)?\/api\/mindspace\/v1\/assets\/([a-z0-9-]+)\/download(?:\?[^"'<>\\\s)]*)?/gi;
const URL_ATTR_PATTERN = /(\b(?:href|src)\s*=\s*["'])([^"']+)(["'])/gi;
export function extractAssetIdsFromHtml(html) {
return [
...new Set(
[...String(html ?? '').matchAll(PRIVATE_ASSET_URL_PATTERN)]
.map((match) => match[1])
.filter(Boolean),
),
];
}
export function extractRelativeDownloadRefs(html) {
const refs = new Set();
for (const match of String(html ?? '').matchAll(URL_ATTR_PATTERN)) {
const url = match[2];
if (isRelativeDownloadReference(url)) {
refs.add(splitReferenceParts(url).pathPart);
}
}
return [...refs];
}
export function collectWorkspacePurgeTargets(htmlRelativePath, html) {
const targets = new Set();
const normalizedHtmlPath = String(htmlRelativePath ?? '').replace(/^\/+/, '');
if (normalizedHtmlPath) {
targets.add(normalizedHtmlPath);
if (normalizedHtmlPath.toLowerCase().endsWith('.html')) {
targets.add(workspaceThumbnailRelativePath(normalizedHtmlPath));
const pngThumb = normalizedHtmlPath.replace(/\.html$/i, '.thumbnail.png');
if (pngThumb !== normalizedHtmlPath) targets.add(pngThumb);
}
}
for (const ref of extractRelativeDownloadRefs(html)) {
targets.add(resolveWorkspaceRelativeFilePath(normalizedHtmlPath || 'public/index.html', ref));
}
return [...targets].filter(Boolean);
}
export async function purgeWorkspacePageArtifacts({
publishDir,
htmlRelativePath = null,
html = '',
} = {}) {
if (!publishDir) return { removed: [], skipped: [] };
const resolvedRoot = path.resolve(publishDir);
const removed = [];
const skipped = [];
for (const relativeTarget of collectWorkspacePurgeTargets(htmlRelativePath, html)) {
const absolutePath = path.resolve(publishDir, ...relativeTarget.split('/'));
if (
absolutePath !== resolvedRoot &&
!absolutePath.startsWith(`${resolvedRoot}${path.sep}`)
) {
skipped.push(relativeTarget);
continue;
}
try {
await fs.unlink(absolutePath);
removed.push(relativeTarget);
} catch (error) {
if (error?.code !== 'ENOENT') skipped.push(relativeTarget);
}
}
return { removed, skipped };
}
export const pagePurgeInternals = {
extractAssetIdsFromHtml,
extractRelativeDownloadRefs,
collectWorkspacePurgeTargets,
};
+23
View File
@@ -0,0 +1,23 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { pagePurgeInternals } from './mindspace-page-purge.mjs';
const { extractAssetIdsFromHtml, collectWorkspacePurgeTargets } = pagePurgeInternals;
test('extractAssetIdsFromHtml collects mindspace asset download urls', () => {
const html =
'<img src="/api/mindspace/v1/assets/asset-a/download?inline=1">' +
'<a href="https://example.com/api/mindspace/v1/assets/asset-b/download">link</a>';
assert.deepEqual(extractAssetIdsFromHtml(html).sort(), ['asset-a', 'asset-b']);
});
test('collectWorkspacePurgeTargets includes html, thumbnails, and sibling downloads', () => {
const html = [
'<a href="半导体行业趋势分析报告.docx" download>下载</a>',
'<img src="/api/mindspace/v1/assets/asset-a/download?inline=1">',
].join('');
const targets = collectWorkspacePurgeTargets('public/report.html', html);
assert.ok(targets.includes('public/report.html'));
assert.ok(targets.includes('public/report.thumbnail.svg'));
assert.ok(targets.includes('public/半导体行业趋势分析报告.docx'));
});
+29 -1
View File
@@ -35,6 +35,23 @@ function extractHtmlSummary(content) {
return match?.[1]?.trim().slice(0, 1000) ?? '';
}
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
WHERE p.user_id = ?
AND p.status <> 'deleted'
AND JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json, '$.relative_path')) = ?
ORDER BY p.updated_at DESC, p.id DESC
LIMIT 1`,
[userId, relativePath],
);
const row = rows[0];
if (!row) return null;
return { id: row.id, updatedAt: Number(row.updated_at ?? 0) };
}
async function loadIndexedWorkspacePages(pool, userId) {
const [rows] = await pool.query(
`SELECT p.id, p.updated_at, p.source_asset_id, pv.source_snapshot_json
@@ -99,6 +116,7 @@ async function listPublishPublicHtmlFiles(publishDir) {
}
async function upsertWorkspaceHtmlPage({
pool,
pageService,
userId,
indexedPages,
@@ -109,10 +127,18 @@ async function upsertWorkspaceHtmlPage({
}) {
const title = extractHtmlTitle(content) || titleFromRelativePath(relativePath);
const summary = extractHtmlSummary(content);
const existing =
let existing =
indexedPages.get(relativePath) ??
(assetId ? indexedPages.get(`asset:${assetId}`) : null);
if (!existing && pool) {
existing = await findPageByWorkspaceRelativePath(pool, userId, relativePath);
if (existing) {
indexedPages.set(relativePath, existing);
if (assetId) indexedPages.set(`asset:${assetId}`, existing);
}
}
const pageInput = {
title,
summary,
@@ -186,6 +212,7 @@ export async function syncGeneratedPagesFromPublicAssets({
continue;
}
const result = await upsertWorkspaceHtmlPage({
pool,
pageService,
userId,
indexedPages,
@@ -237,6 +264,7 @@ export async function syncGeneratedPagesFromPublicAssets({
const { path: assetPath } = await assetService.readAsset(userId, asset.id);
const content = await fs.readFile(assetPath, 'utf8');
const result = await upsertWorkspaceHtmlPage({
pool,
pageService,
userId,
indexedPages,
+47
View File
@@ -129,3 +129,50 @@ test('syncGeneratedPagesFromPublicAssets creates pages for unlinked public html
assert.equal(result.created, 1);
assert.equal(created[0].source.assetId, 'asset-1');
});
test('syncGeneratedPagesFromPublicAssets reuses db page when memory index is empty', async () => {
const publishDir = await fs.mkdtemp(path.join(os.tmpdir(), 'page-sync-dedupe-'));
const publicDir = path.join(publishDir, 'public');
await fs.mkdir(publicDir, { recursive: true });
await fs.writeFile(
path.join(publicDir, 'demo.html'),
'<html><head><title>Demo Page</title></head><body>Hi</body></html>',
'utf8',
);
const created = [];
const updated = [];
const pool = {
async query(sql) {
if (sql.includes('JSON_UNQUOTE(JSON_EXTRACT(pv.source_snapshot_json')) {
return [[{ id: 'page-existing', updated_at: 100 }]];
}
if (sql.includes('FROM h5_page_records p')) return [[]];
if (sql.includes('FROM h5_assets a')) return [[]];
return [[]];
},
};
const pageService = {
async createFromChat() {
created.push(true);
return { id: 'page-new' };
},
async updatePage(userId, pageId) {
updated.push({ userId, pageId });
return { id: pageId };
},
};
const result = await syncGeneratedPagesFromPublicAssets({
pool,
pageService,
assetService: null,
userId: 'user-1',
publishDir,
});
assert.equal(result.created, 0);
assert.equal(created.length, 0);
assert.equal(updated.length, 1);
assert.equal(updated[0].pageId, 'page-existing');
});
+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('原始上传资料不会被删除');
}
+13
View File
@@ -63,6 +63,17 @@ test('renderPublicationHtml serves html pages without escaping markup', () => {
assert.doesNotMatch(html, /MINDSPACE/);
});
test('normalizeListPageFilters clamps limit and offset', () => {
assert.deepEqual(pageInternals.normalizeListPageFilters({ limit: 999, offset: -5 }), {
limit: 100,
offset: 0,
});
assert.deepEqual(pageInternals.normalizeListPageFilters({ limit: 15, offset: 30 }), {
limit: 15,
offset: 30,
});
});
test('preview rendering escapes active HTML and emits a restrictive CSP', () => {
const html = pageInternals.renderPreviewHtml({
title: '<script>alert(1)</script>',
@@ -94,6 +105,7 @@ test('buildPageDeleteSummary lists cascade delete impact', () => {
page: { title: '麻婆豆腐', versionCount: 2 },
onlinePublication: { publicUrl: '/u/john/pages/mapo' },
offlinePublicationCount: 1,
plazaPosts: [{ id: 'post-1', title: '广场帖', status: 'published', publicationId: 'pub-1' }],
linkedAssets: [{ id: 'a1' }, { id: 'a2' }],
linkedAssetBytes: 2048,
linkedAgentJobCount: 1,
@@ -101,5 +113,6 @@ test('buildPageDeleteSummary lists cascade delete impact', () => {
});
assert.match(lines[0], /麻婆豆腐/);
assert.ok(lines.some((line) => line.includes('在线公开链接')));
assert.ok(lines.some((line) => line.includes('广场')));
assert.ok(lines.some((line) => line.includes('原始上传资料不会被删除')));
});
+18 -1
View File
@@ -5,7 +5,11 @@ import { localizeGoogleFontsCss } from './mindspace-html-localize.mjs';
import { replacePrivateResourceReferences, scanContent } from './mindspace-content-scan.mjs';
import { pageInternals } from './mindspace-pages.mjs';
import { loadMindSpaceConfig } from './mindspace-config.mjs';
import { buildPublicUrl, resolvePublicBaseUrl } from './user-publish.mjs';
import { buildPublicUrl, resolvePublicBaseUrl, resolvePublishDir } from './user-publish.mjs';
import {
loadWorkspaceDownloadLinkIndex,
rewriteRelativeDownloadLinks,
} from './mindspace-html-download-links.mjs';
import { createImgproxySigner } from './imgproxy-signer.mjs';
const SCANNER_VERSION = 'mindspace-content-v1';
@@ -247,6 +251,7 @@ async function prepareHtmlPublishContent({
htmlRelativePath = `public/${urlSlug}.html`,
absoluteStoragePath,
imgproxySigner = null,
h5Root = null,
}) {
let publishContent = (await localizeGoogleFontsCss(html)).html;
publishContent = replaceImgproxyStandardImageReferences(
@@ -261,6 +266,16 @@ async function prepareHtmlPublishContent({
imgproxySigner,
});
publishContent = rewriteWorkspacePublicAssetReferences(publishContent, htmlRelativePath);
const publishDir = h5Root ? resolvePublishDir(h5Root, { id: userId }) : null;
const linkIndex = await loadWorkspaceDownloadLinkIndex(pool, userId);
publishContent = rewriteRelativeDownloadLinks(publishContent, {
htmlRelativePath,
publishDir,
linkIndex,
publishKey: userId,
publicBaseUrl: resolvePublicBaseUrl(),
preferAssetDownload: false,
}).html;
return replacePrivateResourceReferences(
publishContent,
buildPublicationThumbnailFallback(ownerSlug, urlSlug),
@@ -269,6 +284,7 @@ async function prepareHtmlPublishContent({
export function createPublicationService(pool, options = {}) {
const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace'));
const h5Root = options.h5Root ?? null;
const idFactory = options.idFactory ?? (() => crypto.randomUUID());
const publicPageLimitFallback = Number(options.publicPageLimit ?? 5);
@@ -378,6 +394,7 @@ export function createPublicationService(pool, options = {}) {
ownerSlug,
urlSlug,
absoluteStoragePath,
h5Root,
imgproxySigner: imgproxySigner ? { buildUrl: (path, preset) => imgproxySigner.buildUrl(imgproxySigner.baseUrl, path, preset) } : null,
});
};
+3
View File
@@ -158,6 +158,9 @@ test('prepareHtmlPublishContent inlines owned private image assets before scanni
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']);
+1 -1
View File
@@ -34,7 +34,7 @@
"enrich:plaza": "node scripts/enrich-plaza-posts.mjs",
"build": "vite build",
"build:portal-runtime": "node scripts/build-portal-runtime.mjs",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs",
"test": "node --test auth.test.mjs asr-proxy.test.mjs billing.test.mjs billing-recharge.test.mjs wechat-pay.test.mjs wechat-oauth.test.mjs wechat-mp.test.mjs schedule-intent.test.mjs schedule-reminder-worker.test.mjs capabilities.test.mjs policies.test.mjs chat-skills.test.mjs chat-finish-sync.test.mjs conversation-display.test.mjs user-publish.test.mjs user-memory-profile.test.mjs skills-registry.test.mjs agent-run-gateway.test.mjs agent-run-routes.test.mjs mindspace.test.mjs mindspace-scan.test.mjs mindspace-assets.test.mjs mindspace-pages.test.mjs mindspace-html-download-links.test.mjs mindspace-page-purge.test.mjs mindspace-publications.test.mjs mindspace-chat-save.test.mjs mindspace-public-finish-sync.test.mjs mindspace-chat-context.test.mjs mindspace-content-scan.test.mjs mindspace-html-localize.test.mjs mindspace-visual-editor.test.mjs mindspace-cleanup.test.mjs mindspace-thumbnails.test.mjs mindspace-workspace-thumbnails.test.mjs mindspace-workspace-sync.test.mjs mindspace-asset-preview.test.mjs mindspace-agent-jobs.test.mjs mindspace-agent-runner.test.mjs mindspace-sandbox-mcp.test.mjs message-stream.test.mjs plaza-posts.test.mjs plaza-interactions.test.mjs plaza-algorithm.test.mjs plaza-seo.test.mjs plaza-ops.test.mjs user-auth.test.mjs llm-providers.test.mjs admin-guard.test.mjs user-feedback.test.mjs",
"verify:chat-finish-sync": "node scripts/verify-chat-finish-sync.mjs",
"verify:public-finish-sync-runtime": "node scripts/verify-public-finish-sync-runtime.mjs",
"verify:mindspace-publish-guards": "node scripts/verify-mindspace-publish-guards.mjs",
+266
View File
@@ -0,0 +1,266 @@
#!/usr/bin/env node
/**
* Delete all MindSpace pages for one user (uses the same cascade as the API).
*
* Usage:
* node scripts/purge-user-pages.mjs --username=john --dry-run
* node scripts/purge-user-pages.mjs --username=john --yes
* node scripts/purge-user-pages.mjs --user-id=... --yes --remove-from-plaza
*/
import path from 'node:path';
import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { createDbPool } from '../db.mjs';
import { createPageService } from '../mindspace-pages.mjs';
import { resolvePublishDir } from '../user-publish.mjs';
import { loadH5Environment } from './load-env.mjs';
const PUBLIC_HTML_SKIP_DIRS = new Set([
'.tmp-images',
'assets',
'images',
'shared',
'node_modules',
]);
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const root = path.join(scriptDir, '..');
loadH5Environment(scriptDir);
function readArg(name) {
const prefix = `--${name}=`;
const hit = process.argv.find((arg) => arg.startsWith(prefix));
return hit ? hit.slice(prefix.length).trim() : null;
}
const dryRun = process.argv.includes('--dry-run');
const confirmed = process.argv.includes('--yes');
const removeFromPlaza = process.argv.includes('--remove-from-plaza');
const purgeSources = process.argv.includes('--purge-sources');
const untilEmpty = process.argv.includes('--until-empty');
const username = readArg('username');
const userIdArg = readArg('user-id');
const batchSize = Math.max(1, Number(readArg('batch-size') ?? 100));
const storageRoot = process.env.MINDSPACE_STORAGE_ROOT
? path.resolve(process.env.MINDSPACE_STORAGE_ROOT)
: path.join(root, 'data', 'mindspace');
async function resolveUserId(pool) {
if (userIdArg) return userIdArg;
if (!username) {
throw new Error('请指定 --username=... 或 --user-id=...');
}
const [rows] = await pool.query(
`SELECT id, username, email FROM h5_users WHERE username = ? LIMIT 1`,
[username],
);
const row = rows[0];
if (!row) throw new Error(`用户不存在: ${username}`);
return row.id;
}
async function countRemainingPages(pool, userId) {
const [rows] = await pool.query(
`SELECT COUNT(*) AS count FROM h5_page_records WHERE user_id = ? AND status <> 'deleted'`,
[userId],
);
return Number(rows[0]?.count ?? 0);
}
async function listWorkspaceHtmlFiles(publishDir) {
const files = [];
async function walk(currentDir, relativeDir = '') {
let entries;
try {
entries = await fs.readdir(currentDir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
if (entry.isDirectory()) {
if (PUBLIC_HTML_SKIP_DIRS.has(entry.name)) continue;
await walk(path.join(currentDir, entry.name), path.posix.join(relativeDir, entry.name));
continue;
}
if (!entry.name.toLowerCase().endsWith('.html')) continue;
files.push(path.join(currentDir, entry.name));
}
}
await walk(publishDir);
return files;
}
async function purgeSyncSources(pool, userId, h5Root) {
const now = Date.now();
const publishDir = resolvePublishDir(h5Root, { id: userId });
const htmlFiles = await listWorkspaceHtmlFiles(publishDir);
let removedFiles = 0;
for (const filePath of htmlFiles) {
await fs.unlink(filePath).catch(() => {});
removedFiles += 1;
const thumbPath = filePath.replace(/\.html$/i, '.thumbnail.svg');
await fs.unlink(thumbPath).catch(() => {});
}
const [assetResult] = 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.status = 'deleted', a.deleted_at = ?, a.updated_at = ?
WHERE a.user_id = ?
AND c.category_code = 'public'
AND a.mime_type = 'text/html'
AND a.status <> 'deleted'`,
[now, now, userId],
);
const [plazaResult] = await pool.query(
`UPDATE plaza_posts SET status = 'hidden', updated_at = ?
WHERE user_id = ? AND status != 'hidden'`,
[now, userId],
);
return {
removedFiles,
deletedPublicHtmlAssets: Number(assetResult.affectedRows ?? 0),
hiddenPlazaPosts: Number(plazaResult.affectedRows ?? 0),
};
}
async function listAllPageIds(pool, userId) {
const ids = [];
let offset = 0;
while (true) {
const [rows] = await pool.query(
`SELECT id FROM h5_page_records
WHERE user_id = ? AND status <> 'deleted'
ORDER BY created_at ASC
LIMIT ? OFFSET ?`,
[userId, batchSize, offset],
);
if (rows.length === 0) break;
ids.push(...rows.map((row) => row.id));
if (rows.length < batchSize) break;
offset += rows.length;
}
return ids;
}
const pool = createDbPool();
const pageService = createPageService(pool, { h5Root: root, storageRoot });
try {
const userId = await resolveUserId(pool);
const [userRows] = await pool.query(
`SELECT username, email FROM h5_users WHERE id = ? LIMIT 1`,
[userId],
);
const user = userRows[0];
const pageIds = await listAllPageIds(pool, userId);
const [plazaRows] = await pool.query(
`SELECT COUNT(*) AS count
FROM plaza_posts pp
JOIN h5_publish_records pr ON pr.id = pp.publication_id
WHERE pr.user_id = ? AND pp.status != 'hidden'`,
[userId],
);
const plazaPostCount = Number(plazaRows[0]?.count ?? 0);
console.log(`用户: ${user?.username ?? userId} (${user?.email ?? 'unknown'})`);
console.log(`待删除页面: ${pageIds.length}`);
console.log(`广场帖子: ${plazaPostCount}${removeFromPlaza || purgeSources ? '将一并隐藏' : '保留'}`);
if (purgeSources) console.log('将清理工作区 public HTML 与公开 HTML 资产,避免同步复活页面');
if (untilEmpty) console.log('将循环删除直到账户下无剩余页面');
if (pageIds.length === 0) {
console.log('没有可删除的页面。');
process.exit(0);
}
if (dryRun) {
if (purgeSources) {
const publishDir = resolvePublishDir(root, { id: userId });
const htmlFiles = await listWorkspaceHtmlFiles(publishDir);
console.log(`[dry-run] 工作区 HTML 文件: ${htmlFiles.length}`);
}
console.log('[dry-run] 未执行删除。');
process.exit(0);
}
if (!confirmed) {
console.error('危险操作:请加 --yes 确认删除。可先 --dry-run 查看数量。');
process.exit(1);
}
let deleted = 0;
let failed = 0;
let freedBytes = 0;
let hiddenPlazaPosts = 0;
let removedFiles = 0;
let deletedPublicHtmlAssets = 0;
const started = Date.now();
const maxRounds = untilEmpty ? 20 : 1;
for (let round = 1; round <= maxRounds; round += 1) {
if (purgeSources) {
const sourceResult = await purgeSyncSources(pool, userId, root);
removedFiles += sourceResult.removedFiles;
deletedPublicHtmlAssets += sourceResult.deletedPublicHtmlAssets;
hiddenPlazaPosts += sourceResult.hiddenPlazaPosts;
if (round === 1) {
console.log(
`已清理同步来源:HTML 文件 ${sourceResult.removedFiles},公开 HTML 资产 ${sourceResult.deletedPublicHtmlAssets},广场帖 ${sourceResult.hiddenPlazaPosts}`,
);
}
}
const roundPageIds = round === 1 ? pageIds : await listAllPageIds(pool, userId);
if (roundPageIds.length === 0) break;
if (round > 1) {
console.log(`${round} 轮:发现 ${roundPageIds.length} 个复活/残留页面,继续删除…`);
}
for (const pageId of roundPageIds) {
try {
const result = await pageService.deletePage(userId, pageId, { removeFromPlaza });
deleted += 1;
freedBytes += Number(result.freedBytes ?? 0);
hiddenPlazaPosts += Number(result.hiddenPlazaPostCount ?? 0);
if (deleted % 25 === 0) {
const elapsedSec = ((Date.now() - started) / 1000).toFixed(1);
console.log(
`[${deleted}] 已删除 ${deleted} 页,失败 ${failed},释放 ${(freedBytes / (1024 * 1024)).toFixed(1)} MB,用时 ${elapsedSec}s`,
);
}
} catch (error) {
failed += 1;
const message = error instanceof Error ? error.message : String(error);
console.error(`删除失败 ${pageId}: ${message}`);
}
}
if (!untilEmpty) break;
const remainingAfterRound = await countRemainingPages(pool, userId);
if (remainingAfterRound === 0) break;
if (round === maxRounds) {
console.warn(`已达最大 ${maxRounds} 轮,仍有 ${remainingAfterRound} 页未删除`);
}
}
const remaining = await countRemainingPages(pool, userId);
const elapsedSec = ((Date.now() - started) / 1000).toFixed(1);
console.log('---');
console.log(`完成:成功 ${deleted},失败 ${failed},剩余 ${remaining}`);
console.log(`释放空间约 ${(freedBytes / (1024 * 1024)).toFixed(1)} MB`);
if (purgeSources) {
console.log(`清理 HTML 文件 ${removedFiles},公开 HTML 资产 ${deletedPublicHtmlAssets}`);
}
if (removeFromPlaza || purgeSources) console.log(`隐藏广场帖 ${hiddenPlazaPosts}`);
console.log(`总用时 ${elapsedSec}s`);
process.exit(failed > 0 || remaining > 0 ? 1 : 0);
} finally {
await pool.end();
}
+54 -17
View File
@@ -300,6 +300,7 @@ async function bootstrapUserAuth() {
},
});
mindSpacePublications = createPublicationService(pool, {
h5Root: __dirname,
storageRoot:
process.env.MINDSPACE_STORAGE_ROOT ?? path.join(__dirname, 'data', 'mindspace'),
publicPageLimit: Number(process.env.MINDSPACE_FREE_PUBLIC_PAGE_LIMIT ?? 5),
@@ -2664,21 +2665,35 @@ async function resolveOwnedAssistantMessage(userId, sessionId, messageId) {
const SAVE_TARGET_CATEGORIES = new Set(['draft', 'oa', 'public']);
const pageSyncInFlight = new Map();
async function syncUserGeneratedPages(userId) {
if (!mindSpacePages || !authPool || !userId) return;
await syncGeneratedPagesFromPublicAssets({
pool: authPool,
pageService: mindSpacePages,
assetService: mindSpaceAssets,
userId,
publishDir: resolvePublishDir(__dirname, { id: userId }),
syncWorkspaceAssets:
mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED
? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options)
: null,
}).catch((error) => {
console.warn('[MindSpace] page sync failed:', error?.message ?? error);
});
let inFlight = pageSyncInFlight.get(userId);
if (!inFlight) {
inFlight = syncGeneratedPagesFromPublicAssets({
pool: authPool,
pageService: mindSpacePages,
assetService: mindSpaceAssets,
userId,
publishDir: resolvePublishDir(__dirname, { id: userId }),
syncWorkspaceAssets:
mindSpaceAssets && WORKSPACE_MAINTENANCE_ENABLED
? (targetUserId, options) => mindSpaceAssets.syncWorkspaceAssets(targetUserId, options)
: null,
})
.catch((error) => {
console.warn('[MindSpace] page sync failed:', error?.message ?? error);
})
.finally(() => {
if (pageSyncInFlight.get(userId) === inFlight) {
pageSyncInFlight.delete(userId);
}
});
pageSyncInFlight.set(userId, inFlight);
}
await inFlight;
}
async function resolveChatSaveBundle(user, h5Root, input = {}) {
@@ -3083,10 +3098,24 @@ api.get('/mindspace/v1/pages', async (req, res) => {
if (!mindSpacePages) return res.status(503).json({ message: 'MindSpace 未启用' });
try {
await syncUserGeneratedPages(req.currentUser.id);
const pages = await mindSpacePages.listPages(req.currentUser.id, {
const limit = Number.parseInt(String(req.query.limit ?? ''), 10);
const offset = Number.parseInt(String(req.query.offset ?? ''), 10);
const result = await mindSpacePages.listPages(req.currentUser.id, {
status: typeof req.query.status === 'string' ? req.query.status : undefined,
categoryCode: typeof req.query.category_code === 'string' ? req.query.category_code : undefined,
...(Number.isFinite(limit) ? { limit } : {}),
...(Number.isFinite(offset) ? { offset } : {}),
});
return res.json({
data: result.items,
page: {
total: result.total,
limit: result.limit,
offset: result.offset,
has_more: result.hasMore,
next_cursor: null,
},
});
return res.json({ data: pages, page: { next_cursor: null, has_more: false } });
} catch (error) {
return mindSpaceError(res, req, error);
}
@@ -3109,7 +3138,14 @@ api.get('/mindspace/v1/pages/:pageId', async (req, res) => {
api.delete('/mindspace/v1/pages/:pageId', async (req, res) => {
if (!mindSpacePages || !ensureMindSpaceEnabled(res, req)) return;
try {
const result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId);
const removeFromPlaza =
String(req.query?.remove_from_plaza ?? req.body?.remove_from_plaza ?? '').toLowerCase() ===
'true' ||
req.query?.remove_from_plaza === '1' ||
req.body?.remove_from_plaza === true;
const result = await mindSpacePages.deletePage(req.currentUser.id, req.params.pageId, {
removeFromPlaza,
});
const quota = await mindSpace.getQuota(req.currentUser.id);
await mindSpaceAudit?.write({
userId: req.currentUser.id,
@@ -3119,6 +3155,7 @@ api.delete('/mindspace/v1/pages/:pageId', async (req, res) => {
ip: req.ip,
detail: {
offlinedPublicationCount: result.offlinedPublicationCount,
hiddenPlazaPostCount: result.hiddenPlazaPostCount,
deletedAssetCount: result.deletedAssetCount,
freedBytes: result.freedBytes,
},
@@ -4434,7 +4471,7 @@ function publishedPageShellHtml({ iframeUrl, shareUrl, title }) {
<body>
<div class="publication-shell">
<!-- Game-like publications need script execution, but not same-origin sandbox escape. -->
<iframe class="publication-frame" title="${safeTitle}" src="${iframeSrc}" sandbox="allow-scripts"></iframe>
<iframe class="publication-frame" title="${safeTitle}" src="${iframeSrc}" sandbox="allow-scripts allow-downloads"></iframe>
</div>
<button type="button" class="publication-share-fab" id="publication-share-fab">分享</button>
<div class="publication-share-sheet" id="publication-share-sheet" hidden>
+24 -6
View File
@@ -697,18 +697,36 @@ export async function getMindSpacePageDeletePreview(
return result.data;
}
export async function deleteMindSpacePage(pageId: string): Promise<MindSpacePageDeleteResult> {
export async function deleteMindSpacePage(
pageId: string,
options?: { removeFromPlaza?: boolean },
): Promise<MindSpacePageDeleteResult> {
const params = new URLSearchParams();
if (options?.removeFromPlaza) params.set('remove_from_plaza', 'true');
const query = params.toString() ? `?${params.toString()}` : '';
const result = await apiFetch<{ data: MindSpacePageDeleteResult }>(
`/mindspace/v1/pages/${encodeURIComponent(pageId)}`,
`/mindspace/v1/pages/${encodeURIComponent(pageId)}${query}`,
{ method: 'DELETE' },
);
return result.data;
}
export async function listMindSpacePages(status?: string): Promise<MindSpacePage[]> {
const query = status ? `?status=${encodeURIComponent(status)}` : '';
const result = await apiFetch<{ data: MindSpacePage[] }>(`/mindspace/v1/pages${query}`);
return result.data;
export async function listMindSpacePages(options?: {
status?: string;
limit?: number;
offset?: number;
categoryCode?: string;
}): Promise<{ items: MindSpacePage[]; page: MindSpaceListPage }> {
const params = new URLSearchParams();
if (options?.status) params.set('status', options.status);
if (options?.limit != null) params.set('limit', String(options.limit));
if (options?.offset != null) params.set('offset', String(options.offset));
if (options?.categoryCode) params.set('category_code', options.categoryCode);
const query = params.toString() ? `?${params.toString()}` : '';
const result = await apiFetch<{ data: MindSpacePage[]; page?: MindSpaceListPage }>(
`/mindspace/v1/pages${query}`,
);
return { items: result.data, page: result.page ?? {} };
}
export async function getMindSpacePage(pageId: string): Promise<MindSpacePage> {
+1 -1
View File
@@ -73,7 +73,7 @@ export function ChatSharePreviewModal({
<button type="button" className="chat-share-preview-close" onClick={onClose} aria-label="关闭"></button>
</div>
<div className="chat-share-preview-body">
<iframe src={previewUrl} title="页面预览" sandbox="allow-same-origin allow-scripts allow-popups" className="chat-share-preview-frame" />
<iframe src={previewUrl} title="页面预览" sandbox="allow-same-origin allow-scripts allow-popups allow-downloads" className="chat-share-preview-frame" />
<div className="chat-share-side-actions">
{phase.kind === 'done' ? (
@@ -0,0 +1,53 @@
import { resolvePlazaPostUrl } from '../utils/publicUrl';
import type { MindSpacePagePlazaPostPreview } from '../types';
function plazaPostStatusLabel(status: string): string {
if (status === 'published') return '已公开';
if (status === 'pending_review') return '待审核';
if (status === 'rejected') return '已拒绝';
return status;
}
export function MindSpaceDeletePlazaOption({
plazaPosts,
removeFromPlaza,
onRemoveFromPlazaChange,
}: {
plazaPosts: MindSpacePagePlazaPostPreview[];
removeFromPlaza: boolean;
onRemoveFromPlazaChange: (value: boolean) => void;
}) {
if (plazaPosts.length === 0) return null;
return (
<div className="mindspace-delete-plaza">
<p className="mindspace-upload-dialog-desc">
{plazaPosts.length === 1
? '该页面已发布到广场。'
: `该页面在广场有 ${plazaPosts.length} 个帖子。`}
</p>
<ul className="mindspace-delete-plaza-posts">
{plazaPosts.map((post) => (
<li key={post.id}>
<strong>{post.title || '未命名帖子'}</strong>
<span>{plazaPostStatusLabel(post.status)}</span>
<a href={resolvePlazaPostUrl(post.id)} target="_blank" rel="noreferrer">
广
</a>
</li>
))}
</ul>
<label className="mindspace-checkbox-row mindspace-delete-plaza-toggle">
<input
type="checkbox"
checked={removeFromPlaza}
onChange={(event) => onRemoveFromPlazaChange(event.target.checked)}
/>
广
</label>
<small className="mindspace-delete-plaza-hint">
广
</small>
</div>
);
}
+59 -3
View File
@@ -1,5 +1,5 @@
import type { MindSpacePage } from '../types';
import { WorkCover } from './WorkCover';
import { WorkCover, type WorkCoverMode } from './WorkCover';
const ACCESS_MODE_ICONS: Record<string, { icon: string; title: string }> = {
public: { icon: '🌐', title: '完全公开' },
@@ -56,6 +56,11 @@ export function MindSpaceFeedCard({
actions,
checked,
onCheckChange,
checkboxPlacement = 'top-left',
coverMode = 'thumbnail-first',
onOpenPage,
onEditPage,
onShare,
}: {
page: MindSpacePage;
previewMode?: boolean;
@@ -63,10 +68,16 @@ export function MindSpaceFeedCard({
actions?: { label: string; onClick: (e: React.MouseEvent) => void }[];
checked?: boolean;
onCheckChange?: (checked: boolean) => void;
checkboxPlacement?: 'top-left' | 'top-right';
coverMode?: WorkCoverMode;
onOpenPage?: () => void;
onEditPage?: () => void;
onShare?: () => void;
}) {
const typeLabel = feedTypeLabel(page);
const isHtml = page.contentFormat === 'html';
const previewThumbnail = previewMode ? PREVIEW_THUMBNAILS[page.id] : null;
const showLinkActions = Boolean(onOpenPage || onEditPage || onShare);
return (
<div className="mindspace-feed-card-wrap">
@@ -74,7 +85,12 @@ export function MindSpaceFeedCard({
<div className="mindspace-feed-cover">
{onCheckChange !== undefined && (
<label
className="mindspace-feed-select"
className={[
'mindspace-feed-select',
checkboxPlacement === 'top-right' ? 'is-top-right is-compact' : '',
]
.filter(Boolean)
.join(' ')}
onClick={(e) => e.stopPropagation()}
>
<input
@@ -87,7 +103,7 @@ export function MindSpaceFeedCard({
)}
{isHtml ? (
<WorkCover
coverMode="thumbnail-first"
coverMode={coverMode}
thumbnailUrl={
previewThumbnail ??
`/api/mindspace/v1/pages/${page.id}/thumbnail?v=${page.updatedAt}`
@@ -121,6 +137,46 @@ export function MindSpaceFeedCard({
</p>
</div>
</button>
{showLinkActions && (
<div className="mindspace-feed-card-links">
{onOpenPage && (
<button
type="button"
className="mindspace-feed-card-link"
onClick={(event) => {
event.stopPropagation();
onOpenPage();
}}
>
</button>
)}
{onEditPage && (
<button
type="button"
className="mindspace-feed-card-link"
onClick={(event) => {
event.stopPropagation();
onEditPage();
}}
>
</button>
)}
{onShare && (
<button
type="button"
className="mindspace-feed-card-link"
onClick={(event) => {
event.stopPropagation();
onShare();
}}
>
</button>
)}
</div>
)}
{actions && actions.length > 0 && (
<div className="mindspace-feed-card-actions">
{actions.map((action) => (
+13 -1
View File
@@ -33,6 +33,7 @@ import {
} from './MindSpacePageFullscreenPreview';
import { MindSpacePagePreviewPanel } from './MindSpacePagePreviewPanel';
import { MindSpaceModal } from './MindSpaceModal';
import { MindSpaceDeletePlazaOption } from './MindSpaceDeletePlazaOption';
import { MindSpacePublishSuccess } from './MindSpacePublishSuccess';
import { ShareSheet } from './ShareSheet';
import { buildPageSharePayload, type SharePayload } from '../utils/shareChannels';
@@ -269,6 +270,7 @@ export function MindSpacePageDetail({
const [deletePreviewOpen, setDeletePreviewOpen] = useState(false);
const [deletePreview, setDeletePreview] = useState<MindSpacePageDeletePreview | null>(null);
const [deletePreviewLoading, setDeletePreviewLoading] = useState(false);
const [deleteRemoveFromPlaza, setDeleteRemoveFromPlaza] = useState(false);
const [sharePayload, setSharePayload] = useState<SharePayload | null>(null);
const [previewFrame, setPreviewFrame] = useState<HTMLIFrameElement | null>(null);
const [isExportingImage, setIsExportingImage] = useState(false);
@@ -532,6 +534,7 @@ export function MindSpacePageDetail({
setDeletePreviewOpen(true);
setDeletePreviewLoading(true);
setDeletePreview(null);
setDeleteRemoveFromPlaza(false);
setError(null);
try {
setDeletePreview(await getMindSpacePageDeletePreview(page.id));
@@ -547,6 +550,7 @@ export function MindSpacePageDetail({
if (deletingPage) return;
setDeletePreviewOpen(false);
setDeletePreview(null);
setDeleteRemoveFromPlaza(false);
};
const confirmDeletePage = async () => {
@@ -554,9 +558,12 @@ export function MindSpacePageDetail({
setDeletingPage(true);
setError(null);
try {
const result = await deleteMindSpacePage(page.id);
const result = await deleteMindSpacePage(page.id, {
removeFromPlaza: deleteRemoveFromPlaza,
});
setDeletePreviewOpen(false);
setDeletePreview(null);
setDeleteRemoveFromPlaza(false);
await onSaved({ quota: result.quota, freedBytes: result.freedBytes });
onBack();
} catch (err) {
@@ -950,6 +957,11 @@ export function MindSpacePageDetail({
))}
</div>
)}
<MindSpaceDeletePlazaOption
plazaPosts={deletePreview.plazaPosts ?? []}
removeFromPlaza={deleteRemoveFromPlaza}
onRemoveFromPlazaChange={setDeleteRemoveFromPlaza}
/>
<div className="mindspace-page-editor-actions">
<button type="button" onClick={closeDeleteDialog} disabled={deletingPage}>
@@ -129,7 +129,7 @@ export function MindSpacePageDraftPreviewFrame({
ref={iframeRef}
title={`${title || '页面'} 预览`}
srcDoc={srcdoc}
sandbox="allow-same-origin"
sandbox="allow-same-origin allow-downloads"
className={className}
style={{ height: `${height}px`, visibility: srcdoc ? 'visible' : 'hidden' }}
/>
@@ -130,7 +130,7 @@ export function MindSpacePageEditablePreviewFrame({
ref={iframeRef}
title={`${title || '页面'} 预览`}
srcDoc={srcdoc}
sandbox="allow-scripts"
sandbox="allow-scripts allow-downloads"
scrolling={scrollInsideFrame ? 'yes' : 'no'}
className={frameClassName}
style={scrollInsideFrame ? { height: '100%' } : { height: `${height}px` }}
+366 -52
View File
@@ -53,10 +53,11 @@ import { PREVIEW_ASSETS, PREVIEW_PAGES, PREVIEW_SPACE } from '../dev/mindspacePr
import { MindSpaceFeedCard } from './MindSpaceFeedCard';
import { MindSpacePageDetail } from './MindSpacePageDetail';
import { MindSpaceModal } from './MindSpaceModal';
import { MindSpaceDeletePlazaOption } from './MindSpaceDeletePlazaOption';
import { ShareSheet } from './ShareSheet';
import { WorkCover } from './WorkCover';
import { buildAssetSharePayload, type SharePayload } from '../utils/shareChannels';
import { resolvePublicPageUrl, resolvePlazaHomeUrl } from '../utils/publicUrl';
import { buildAssetSharePayload, buildPageSharePayload, type SharePayload } from '../utils/shareChannels';
import { resolveMindSpaceHomeUrl, resolveMindSpacePageOwnerPreviewUrl, resolveMindSpacePagePublicViewUrl, resolvePublicPageUrl, resolvePlazaHomeUrl } from '../utils/publicUrl';
import { PagePreviewFrame } from './PagePreviewFrame';
import { BalanceRing } from './BalanceRing';
import { NotificationCenter } from './NotificationCenter';
@@ -77,6 +78,7 @@ const CATEGORY_ACTIONS: Partial<Record<MindSpaceCategory['code'], string>> = {
const AGENT_JOBS_PAGE_SIZE = 10;
const RECENT_PAGES_PAGE_SIZE = 6;
const ALL_PAGES_PAGE_SIZE = 15;
function sortPagesByCreatedAt(pages: MindSpacePage[]) {
return [...pages].sort((left, right) => {
@@ -526,6 +528,13 @@ export function MindSpaceView({
const [agentJobsPage, setAgentJobsPage] = useState(0);
const [agentJobsLoading, setAgentJobsLoading] = useState(false);
const [allPagesOpen, setAllPagesOpen] = useState(false);
const [allPagesItems, setAllPagesItems] = useState<MindSpacePage[]>([]);
const [allPagesTotal, setAllPagesTotal] = useState(0);
const [allPagesPageIndex, setAllPagesPageIndex] = useState(0);
const [allPagesLoading, setAllPagesLoading] = useState(false);
const [selectedAllPageIds, setSelectedAllPageIds] = useState<string[]>([]);
const [bulkDeletingAllPages, setBulkDeletingAllPages] = useState(false);
const [recentPagesTotal, setRecentPagesTotal] = useState(0);
const [exportingPageId, setExportingPageId] = useState<string | null>(null);
const [sharePayload, setSharePayload] = useState<SharePayload | null>(null);
const [previewPageTarget, setPreviewPageTarget] = useState<MindSpacePage | null>(null);
@@ -534,7 +543,15 @@ export function MindSpaceView({
null,
);
const [pageDeleteLoading, setPageDeleteLoading] = useState(false);
const [pageDeleteRemoveFromPlaza, setPageDeleteRemoveFromPlaza] = useState(false);
const [deletingPageId, setDeletingPageId] = useState<string | null>(null);
const [bulkPageDeleteOpen, setBulkPageDeleteOpen] = useState(false);
const [bulkPageDeleteSource, setBulkPageDeleteSource] = useState<'all' | 'draft'>('all');
const [bulkPageDeletePreviews, setBulkPageDeletePreviews] = useState<
MindSpacePageDeletePreview[] | null
>(null);
const [bulkPageDeleteLoading, setBulkPageDeleteLoading] = useState(false);
const [bulkPageDeleteRemoveFromPlaza, setBulkPageDeleteRemoveFromPlaza] = useState(false);
const [assetDeleteDialog, setAssetDeleteDialog] = useState<{
message: string;
references: Array<{ id: string; title: string; publishedOnline?: boolean }>;
@@ -632,6 +649,41 @@ export function MindSpaceView({
return `${value.slice(0, max - 1)}`;
};
const resolvePagePublicViewUrl = async (page: MindSpacePage): Promise<string | null> => {
const fromList = resolveMindSpacePagePublicViewUrl(page);
if (fromList) return fromList;
try {
const pageDetail = await getMindSpacePage(page.id);
const publicUrl = pageDetail.publication?.publicUrl ?? pageDetail.publicationUrl;
return publicUrl ? resolvePublicPageUrl(publicUrl) : null;
} catch {
return null;
}
};
const openPageViewInNewTab = async (page: MindSpacePage) => {
if (previewMode) {
setError('预览模式下暂不支持打开页面');
return;
}
const publicUrl = await resolvePagePublicViewUrl(page);
const viewUrl = publicUrl ?? resolveMindSpacePageOwnerPreviewUrl(page);
window.open(viewUrl, '_blank', 'noopener,noreferrer');
};
const openPageShare = async (page: MindSpacePage) => {
if (previewMode) {
setError('预览模式下暂不支持分享');
return;
}
const publicUrl = await resolvePagePublicViewUrl(page);
if (!publicUrl) {
setError('该页面尚未对外发布,暂无法分享链接;你可以先打开预览,或在编辑页完成发布后再分享');
return;
}
setSharePayload(buildPageSharePayload(page, publicUrl));
};
const showPage = (pageId: string, autoPlaza = false) => {
setSelectedPageId(pageId);
setSelectedPageAutoPlaza(autoPlaza);
@@ -649,6 +701,7 @@ export function MindSpaceView({
}
setPageDeleteTarget(page);
setPageDeletePreview(null);
setPageDeleteRemoveFromPlaza(false);
setPageDeleteLoading(true);
setError(null);
try {
@@ -665,6 +718,7 @@ export function MindSpaceView({
if (deletingPageId) return;
setPageDeleteTarget(null);
setPageDeletePreview(null);
setPageDeleteRemoveFromPlaza(false);
};
const confirmDeletePage = async () => {
@@ -672,12 +726,15 @@ export function MindSpaceView({
setDeletingPageId(pageDeleteTarget.id);
setError(null);
try {
const result = await deleteMindSpacePage(pageDeleteTarget.id);
const result = await deleteMindSpacePage(pageDeleteTarget.id, {
removeFromPlaza: pageDeleteRemoveFromPlaza,
});
if (selectedPageId === pageDeleteTarget.id) {
closePage();
}
setPageDeleteTarget(null);
setPageDeletePreview(null);
setPageDeleteRemoveFromPlaza(false);
await refreshMindSpaceSnapshot({ quota: result.quota, freedBytes: result.freedBytes });
} catch (err) {
setError(err instanceof Error ? err.message : '删除页面失败');
@@ -734,24 +791,7 @@ export function MindSpaceView({
};
const openPagePublicShare = async (page: MindSpacePage) => {
if (previewMode) {
setError('预览模式下暂不支持跳转分享页');
return;
}
if (page.publicationUrl) {
window.open(resolvePublicPageUrl(page.publicationUrl), '_blank', 'noopener,noreferrer');
return;
}
try {
const pageDetail = await getMindSpacePage(page.id);
if (!pageDetail.publication?.publicUrl) {
setError('该页面未发布,先发布后再分享');
return;
}
window.open(resolvePublicPageUrl(pageDetail.publication.publicUrl), '_blank', 'noopener,noreferrer');
} catch (err) {
setError(err instanceof Error ? err.message : '获取分享链接失败');
}
await openPageViewInNewTab(page);
};
const exportPageLongImage = async (page: MindSpacePage) => {
@@ -823,6 +863,41 @@ export function MindSpaceView({
}
};
const loadAllPagesPage = async (pageIndex: number) => {
if (previewMode) {
setAllPagesItems(PREVIEW_PAGES);
setAllPagesTotal(PREVIEW_PAGES.length);
setAllPagesPageIndex(0);
return;
}
setAllPagesLoading(true);
setError(null);
try {
const { items, page } = await listMindSpacePages({
limit: ALL_PAGES_PAGE_SIZE,
offset: pageIndex * ALL_PAGES_PAGE_SIZE,
});
setAllPagesItems(items);
setAllPagesPageIndex(pageIndex);
setAllPagesTotal(page.total ?? items.length);
} catch (err) {
setError(err instanceof Error ? err.message : '全部页面加载失败');
} finally {
setAllPagesLoading(false);
}
};
const openAllPagesPanel = () => {
setSelectedCategory(null);
setSelectedPageId(null);
setNewPageOpen(false);
setAgentJobsPanelOpen(false);
setAllPagesOpen(true);
setSelectedAllPageIds([]);
routeSync?.pushHome();
void loadAllPagesPage(0);
};
const refreshAgentJobsSummary = async () => {
if (previewMode) return;
const { page } = await listMindSpaceAgentJobs({ limit: 1, offset: 0 });
@@ -882,12 +957,13 @@ export function MindSpaceView({
setPages(PREVIEW_PAGES);
return;
}
const [nextSpace, nextPages] = await Promise.all([
const [nextSpace, recentPagesResult] = await Promise.all([
getMindSpace(),
listMindSpacePages(),
listMindSpacePages({ limit: RECENT_PAGES_PAGE_SIZE, offset: 0 }),
]);
setSpace(nextSpace);
setPages(nextPages);
setPages(recentPagesResult.items);
setRecentPagesTotal(recentPagesResult.page.total ?? recentPagesResult.items.length);
await refreshAgentJobsSummary();
} catch (err) {
setError(err instanceof Error ? err.message : '空间加载失败');
@@ -910,8 +986,15 @@ export function MindSpaceView({
const refreshPagesSilently = async () => {
if (previewMode) return;
try {
const nextPages = await listMindSpacePages();
setPages(nextPages);
const recentPagesResult = await listMindSpacePages({
limit: RECENT_PAGES_PAGE_SIZE,
offset: 0,
});
setPages(recentPagesResult.items);
setRecentPagesTotal(recentPagesResult.page.total ?? recentPagesResult.items.length);
if (allPagesOpen) {
await loadAllPagesPage(allPagesPageIndex);
}
} catch {
// ignore background list refresh errors
}
@@ -1009,6 +1092,13 @@ export function MindSpaceView({
return;
}
setAssets(await listMindSpaceAssets(category.code));
if (['draft', 'oa', 'public'].includes(category.code)) {
const { items } = await listMindSpacePages({ categoryCode: category.code, limit: 100 });
setPages(items);
if (category.code === 'draft') {
setSelectedDraftPageIds([]);
}
}
} catch (err) {
setError(err instanceof Error ? err.message : '资产加载失败');
} finally {
@@ -1389,37 +1479,135 @@ export function MindSpaceView({
}
};
const removeDraftPages = async () => {
if (selectedDraftPageIds.length === 0) return;
const bulkDeletePlazaPosts = useMemo(() => {
if (!bulkPageDeletePreviews) return [];
const seen = new Set<string>();
const posts = [];
for (const preview of bulkPageDeletePreviews) {
for (const post of preview.plazaPosts ?? []) {
if (seen.has(post.id)) continue;
seen.add(post.id);
posts.push(post);
}
}
return posts;
}, [bulkPageDeletePreviews]);
const closeBulkPageDeleteDialog = () => {
if (bulkDeletingAllPages || bulkDeletingDraftPages) return;
setBulkPageDeleteOpen(false);
setBulkPageDeletePreviews(null);
setBulkPageDeleteRemoveFromPlaza(false);
};
const startBulkPageDelete = async (pageIds: string[], source: 'all' | 'draft') => {
if (pageIds.length === 0) return;
if (previewMode) {
setError('预览模式下无法删除页面');
return;
}
setBulkDeletingDraftPages(true);
setBulkPageDeleteSource(source);
setBulkPageDeletePreviews(null);
setBulkPageDeleteRemoveFromPlaza(false);
setBulkPageDeleteLoading(true);
setBulkPageDeleteOpen(true);
setError(null);
try {
const previews = await Promise.all(
pageIds.map((pageId) =>
getMindSpacePageDeletePreview(pageId).catch((err) => {
console.error('[MindSpace] delete preview failed:', pageId, err);
return null;
}),
),
);
const valid = previews.filter(Boolean) as MindSpacePageDeletePreview[];
if (valid.length === 0) {
setBulkPageDeleteOpen(false);
setError('无法加载删除预览');
return;
}
setBulkPageDeletePreviews(valid);
} catch (err) {
setBulkPageDeleteOpen(false);
setError(err instanceof Error ? err.message : '无法加载删除预览');
} finally {
setBulkPageDeleteLoading(false);
}
};
const confirmBulkPageDelete = async () => {
if (!bulkPageDeletePreviews || bulkPageDeletePreviews.length === 0) return;
const deletingIds = bulkPageDeletePreviews.map((preview) => preview.page.id);
const setBulkDeleting =
bulkPageDeleteSource === 'all' ? setBulkDeletingAllPages : setBulkDeletingDraftPages;
setBulkDeleting(true);
setError(null);
const deletingIds = [...selectedDraftPageIds];
const failedIds: string[] = [];
const failedNames: string[] = [];
try {
for (const pageId of deletingIds) {
for (const preview of bulkPageDeletePreviews) {
try {
await deleteMindSpacePage(pageId);
if (selectedPageId === pageId) closePage();
} catch {
failedIds.push(pageId);
failedNames.push(pages.find((p) => p.id === pageId)?.title ?? pageId);
await deleteMindSpacePage(preview.page.id, {
removeFromPlaza: bulkPageDeleteRemoveFromPlaza,
});
if (selectedPageId === preview.page.id) closePage();
} catch (err) {
failedIds.push(preview.page.id);
failedNames.push(preview.page.title);
console.error('[MindSpace] delete page failed:', preview.page.id, err);
}
}
setSelectedDraftPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
const deletedIds = deletingIds.filter((id) => !failedIds.includes(id));
if (bulkPageDeleteSource === 'all') {
setSelectedAllPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
} else {
setSelectedDraftPageIds((ids) => ids.filter((id) => failedIds.includes(id)));
}
setBulkPageDeleteOpen(false);
setBulkPageDeletePreviews(null);
setBulkPageDeleteRemoveFromPlaza(false);
await refreshMindSpaceSnapshot();
await load();
if (bulkPageDeleteSource === 'all' && deletedIds.length > 0) {
const nextPageIndex =
allPagesPageIndex > 0 && allPagesItems.length <= deletedIds.length
? Math.max(0, allPagesPageIndex - 1)
: allPagesPageIndex;
await loadAllPagesPage(nextPageIndex);
}
if (failedNames.length > 0) {
setError(`${failedNames.length} 个页面删除失败:${failedNames.slice(0, 3).join('、')}`);
}
} finally {
setBulkDeletingDraftPages(false);
setBulkDeleting(false);
}
};
const removeDraftPages = () => {
void startBulkPageDelete([...selectedDraftPageIds], 'draft');
};
const selectedAllPageSet = useMemo(() => new Set(selectedAllPageIds), [selectedAllPageIds]);
const allCurrentAllPagesSelected =
allPagesItems.length > 0 && allPagesItems.every((page) => selectedAllPageSet.has(page.id));
const toggleAllPagesOnCurrentPageSelected = () => {
if (allCurrentAllPagesSelected) {
const currentIds = new Set(allPagesItems.map((page) => page.id));
setSelectedAllPageIds((ids) => ids.filter((id) => !currentIds.has(id)));
} else {
const merged = new Set([...selectedAllPageIds, ...allPagesItems.map((page) => page.id)]);
setSelectedAllPageIds([...merged]);
}
};
const removeSelectedAllPages = () => {
void startBulkPageDelete([...selectedAllPageIds], 'all');
};
const allPagesPageCount = Math.max(1, Math.ceil(allPagesTotal / ALL_PAGES_PAGE_SIZE));
const previewAsset = useMemo(
() => assets.find((asset) => asset.id === previewAssetId) ?? null,
[assets, previewAssetId],
@@ -2054,19 +2242,89 @@ export function MindSpaceView({
<p className="mindspace-eyebrow">ALL PAGES</p>
<h2></h2>
</div>
<span>{pages.length} </span>
<span>{allPagesTotal} </span>
</div>
{pages.length > 0 ? (
<div className="mindspace-feed-grid mindspace-recent-work-grid is-all-pages">
{sortedPages.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
onClick={() => showPage(page.id)}
/>
))}
</div>
{allPagesLoading && allPagesItems.length === 0 ? (
<div className="mindspace-state"></div>
) : allPagesTotal > 0 ? (
<>
<div className="mindspace-asset-bulkbar mindspace-all-pages-bulkbar">
<button
type="button"
onClick={toggleAllPagesOnCurrentPageSelected}
disabled={bulkDeletingAllPages || allPagesItems.length === 0}
>
{allCurrentAllPagesSelected ? '取消全选' : '全选'}
</button>
<span> {selectedAllPageIds.length} </span>
{selectedAllPageIds.length > 0 && (
<button
type="button"
onClick={() => setSelectedAllPageIds([])}
disabled={bulkDeletingAllPages}
>
</button>
)}
<button
type="button"
className="mindspace-asset-bulk-delete"
onClick={() => void removeSelectedAllPages()}
disabled={selectedAllPageIds.length === 0 || bulkDeletingAllPages}
>
{bulkDeletingAllPages ? '删除中…' : '删除选中'}
</button>
</div>
<div className="mindspace-feed-grid mindspace-recent-work-grid is-all-pages">
{allPagesItems.map((page) => (
<MindSpaceFeedCard
key={page.id}
page={page}
previewMode={previewMode}
coverMode="thumbnail-only"
checkboxPlacement="top-right"
checked={selectedAllPageSet.has(page.id)}
onCheckChange={(checked) => {
setSelectedAllPageIds((ids) =>
checked
? ids.includes(page.id)
? ids
: [...ids, page.id]
: ids.filter((id) => id !== page.id),
);
}}
onClick={() => void openPageViewInNewTab(page)}
onEditPage={() => showPage(page.id)}
onOpenPage={() => void openPageViewInNewTab(page)}
onShare={() => void openPageShare(page)}
/>
))}
</div>
{allPagesTotal > ALL_PAGES_PAGE_SIZE && (
<div className="mindspace-pagination">
<button
type="button"
disabled={allPagesPageIndex <= 0 || allPagesLoading}
onClick={() => void loadAllPagesPage(allPagesPageIndex - 1)}
>
</button>
<span>
{allPagesPageIndex + 1} / {allPagesPageCount}
</span>
<button
type="button"
disabled={
allPagesLoading ||
(allPagesPageIndex + 1) * ALL_PAGES_PAGE_SIZE >= allPagesTotal
}
onClick={() => void loadAllPagesPage(allPagesPageIndex + 1)}
>
</button>
</div>
)}
</>
) : (
<div className="mindspace-recent-work-empty">
<p></p>
@@ -2584,11 +2842,11 @@ export function MindSpaceView({
<p className="mindspace-eyebrow">RECENT WORK</p>
<h2>{pages.length > 0 ? '最近页面' : '这里会出现你的 AI 成果'}</h2>
</div>
{pages.length > 0 && (
{recentPagesTotal > 0 && (
<button
type="button"
className="mindspace-section-link"
onClick={() => setAllPagesOpen(true)}
onClick={openAllPagesPanel}
>
</button>
@@ -2933,6 +3191,11 @@ export function MindSpaceView({
{resolvePublicPageUrl(pageDeletePreview.onlinePublication.publicUrl)}
</a>
)}
<MindSpaceDeletePlazaOption
plazaPosts={pageDeletePreview.plazaPosts ?? []}
removeFromPlaza={pageDeleteRemoveFromPlaza}
onRemoveFromPlazaChange={setPageDeleteRemoveFromPlaza}
/>
<div className="mindspace-page-editor-actions">
<button type="button" onClick={closePageDeleteDialog} disabled={Boolean(deletingPageId)}>
@@ -2950,6 +3213,57 @@ export function MindSpaceView({
)}
</MindSpaceModal>
<MindSpaceModal
open={bulkPageDeleteOpen}
onClose={closeBulkPageDeleteDialog}
title="确认批量删除页面"
eyebrow="DELETE PAGES"
className="mindspace-delete-dialog"
disableClose={
bulkPageDeleteLoading || bulkDeletingAllPages || bulkDeletingDraftPages
}
>
{bulkPageDeleteLoading && <div className="mindspace-state"></div>}
{bulkPageDeletePreviews && (
<>
<p className="mindspace-upload-dialog-desc">
{bulkPageDeletePreviews.length} 线
</p>
<ul className="mindspace-delete-summary">
{bulkPageDeletePreviews.map((preview) => (
<li key={preview.page.id}>
{preview.page.title}
{preview.onlinePublication ? '(含在线公开链接)' : ''}
{(preview.plazaPosts?.length ?? 0) > 0 ? '(含广场帖子)' : ''}
</li>
))}
</ul>
<MindSpaceDeletePlazaOption
plazaPosts={bulkDeletePlazaPosts}
removeFromPlaza={bulkPageDeleteRemoveFromPlaza}
onRemoveFromPlazaChange={setBulkPageDeleteRemoveFromPlaza}
/>
<div className="mindspace-page-editor-actions">
<button
type="button"
onClick={closeBulkPageDeleteDialog}
disabled={bulkDeletingAllPages || bulkDeletingDraftPages}
>
</button>
<button
type="button"
className="mindspace-primary"
onClick={() => void confirmBulkPageDelete()}
disabled={bulkDeletingAllPages || bulkDeletingDraftPages}
>
{bulkDeletingAllPages || bulkDeletingDraftPages ? '删除中…' : '确认删除'}
</button>
</div>
</>
)}
</MindSpaceModal>
<MindSpaceModal
open={Boolean(assetDeleteDialog)}
onClose={() => setAssetDeleteDialog(null)}
+1 -1
View File
@@ -39,7 +39,7 @@ export function PagePreviewFrame({
key={`${src}:${reloadKey}`}
title={title}
src={src}
sandbox="allow-same-origin"
sandbox="allow-same-origin allow-downloads"
className={className}
style={{ height: `${height}px` }}
/>
+1 -1
View File
@@ -112,7 +112,7 @@ export function PageSavePreviewPanel({
title="页面预览"
src={pagePreviewUrl}
className="page-save-mini-page-frame"
sandbox="allow-scripts allow-same-origin"
sandbox="allow-scripts allow-same-origin allow-downloads"
scrolling="yes"
onError={() => setPreviewFailed(true)}
/>
+6 -1
View File
@@ -2,7 +2,7 @@ import { useState } from 'react';
import type { MindSpaceAsset } from '../types';
import { buildAssetPreviewFrameConfig } from '../utils/mindspaceCards';
export type WorkCoverMode = 'thumbnail-first' | 'preview-first' | 'preview-only';
export type WorkCoverMode = 'thumbnail-first' | 'thumbnail-only' | 'preview-first' | 'preview-only';
export function WorkCover({
thumbnailUrl,
@@ -34,6 +34,7 @@ export function WorkCover({
: null;
const showPreview =
coverMode !== 'thumbnail-only' &&
Boolean(previewFrame) &&
!previewFailed &&
(coverMode === 'preview-first' || coverMode === 'preview-only' || thumbFailed);
@@ -64,6 +65,10 @@ export function WorkCover({
);
}
if (coverMode === 'thumbnail-only') {
return <span>{fallbackLabel}</span>;
}
if (previewFrame && !previewFailed) {
return (
<div className={previewFrameClassName} aria-hidden="true">
+82
View File
@@ -5838,6 +5838,47 @@ body,
color: #2f6f57;
}
.mindspace-delete-plaza {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(24, 33, 29, 0.08);
display: grid;
gap: 10px;
}
.mindspace-delete-plaza-posts {
margin: 0;
padding-left: 20px;
display: grid;
gap: 8px;
}
.mindspace-delete-plaza-posts li {
display: grid;
gap: 4px;
line-height: 1.45;
}
.mindspace-delete-plaza-posts span {
color: rgba(24, 33, 29, 0.62);
font-size: 13px;
}
.mindspace-delete-plaza-posts a {
color: #2f6f57;
font-size: 13px;
word-break: break-all;
}
.mindspace-delete-plaza-toggle {
margin-top: 4px;
}
.mindspace-delete-plaza-hint {
color: rgba(24, 33, 29, 0.62);
line-height: 1.45;
}
.mindspace-delete-assets {
display: grid;
gap: 8px;
@@ -6188,6 +6229,47 @@ body,
container-type: size;
}
.mindspace-feed-select.is-top-right {
left: auto;
right: 8px;
}
.mindspace-feed-select.is-compact span {
display: none;
}
.mindspace-feed-card-links {
display: flex;
flex-wrap: wrap;
gap: 8px 12px;
padding: 0 12px 12px;
}
.mindspace-feed-card-link {
padding: 0;
border: 0;
background: transparent;
color: #2f6f57;
font-size: 13px;
font-weight: 700;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.mindspace-feed-card-link:hover {
color: #1f4f3d;
}
.mindspace-all-pages-bulkbar {
margin-bottom: 12px;
}
.mindspace-all-pages .mindspace-feed-card-wrap {
display: flex;
flex-direction: column;
}
.mindspace-feed-select {
position: absolute;
z-index: 2;
+9
View File
@@ -619,6 +619,13 @@ export type MindSpacePage = {
updatedAt: number;
};
export type MindSpacePagePlazaPostPreview = {
id: string;
title: string;
status: string;
publicationId: string;
};
export type MindSpacePageDeletePreview = {
page: {
id: string;
@@ -633,6 +640,7 @@ export type MindSpacePageDeletePreview = {
viewCount: number;
} | null;
offlinePublicationCount: number;
plazaPosts: MindSpacePagePlazaPostPreview[];
linkedAssets: Array<{
id: string;
displayName: string;
@@ -650,6 +658,7 @@ export type MindSpacePageDeleteResult = {
pageId: string;
title: string;
offlinedPublicationCount: number;
hiddenPlazaPostCount: number;
deletedAssetCount: number;
freedBytes: number;
clearedAgentJobCount: number;
+22
View File
@@ -36,3 +36,25 @@ export function resolveMindSpaceHomeUrl(): string {
}
return `${window.location.origin}/space`;
}
/** 已发布页面的独立访问链接(非空间内编辑路由)。未发布时返回 null。 */
export function resolveMindSpacePagePublicViewUrl(page: {
publicationUrl?: string | null;
}): string | null {
const value = String(page.publicationUrl ?? '').trim();
if (!value) return null;
return resolvePublicPageUrl(value);
}
/** @deprecated 使用 resolveMindSpacePagePublicViewUrl */
export const resolveMindSpacePageViewUrl = resolveMindSpacePagePublicViewUrl;
/** 未发布时供本人查看的预览链接(需登录,不可对外分享)。 */
export function resolveMindSpacePageOwnerPreviewUrl(page: {
id: string;
versionNo?: number;
updatedAt?: number;
}): string {
const version = page.versionNo ?? page.updatedAt ?? Date.now();
return `${window.location.origin}/api/mindspace/v1/pages/${encodeURIComponent(page.id)}/preview?v=${version}`;
}
+3 -3
View File
@@ -1,4 +1,4 @@
import { resolveMindSpaceHomeUrl } from './publicUrl';
import { resolveMindSpaceHomeUrl, resolveMindSpacePagePublicViewUrl, resolvePublicPageUrl } from './publicUrl';
import type { MindSpacePage } from '../types';
export type SharePayload = {
@@ -58,8 +58,8 @@ export const SHARE_CHANNELS: ShareChannel[] = [
export function buildPageSharePayload(page: MindSpacePage, pagePublicUrl?: string): SharePayload {
const publicUrl = pagePublicUrl
? new URL(pagePublicUrl, window.location.origin).toString()
: window.location.href;
? resolvePublicPageUrl(pagePublicUrl)
: resolveMindSpacePagePublicViewUrl(page) ?? window.location.href;
return {
title: page.title || '我的 MindSpace 页面',
url: publicUrl,