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