diff --git a/mindspace-page-achievement-list.mjs b/mindspace-page-achievement-list.mjs new file mode 100644 index 0000000..4a9bf4d --- /dev/null +++ b/mindspace-page-achievement-list.mjs @@ -0,0 +1,53 @@ +import { enrichPagesWithAchievementStats } from './mindspace-page-achievement-stats.mjs'; + +export const DEFAULT_ACHIEVEMENT_LIMIT = 20; + +export function parseAchievementQuery(query = {}) { + const limit = Number.parseInt(String(query.limit ?? ''), 10); + const offset = Number.parseInt(String(query.offset ?? ''), 10); + const includeStatsRaw = String(query.include_stats ?? '0').trim().toLowerCase(); + const includeStats = includeStatsRaw === '1' || includeStatsRaw === 'true'; + + return { + status: typeof query.status === 'string' ? query.status : undefined, + categoryCode: typeof query.category_code === 'string' ? query.category_code : undefined, + limit: Number.isFinite(limit) ? Math.min(Math.max(limit, 1), 100) : DEFAULT_ACHIEVEMENT_LIMIT, + offset: Number.isFinite(offset) ? Math.max(offset, 0) : 0, + includeStats, + }; +} + +export async function listAchievementPagesForUser({ + pages, + userId, + query = {}, + pool = null, + logger = console, +} = {}) { + if (!pages || typeof pages.listPages !== 'function') { + throw new Error('MindSpace page service unavailable'); + } + + const parsed = parseAchievementQuery(query); + const result = await pages.listPages(userId, { + status: parsed.status, + categoryCode: parsed.categoryCode, + limit: parsed.limit, + offset: parsed.offset, + }); + + const items = await enrichPagesWithAchievementStats(result.items, { + ownerUserId: userId, + pool, + logger, + skipUmami: !parsed.includeStats, + }); + + return { + items, + total: result.total, + limit: result.limit, + offset: result.offset, + hasMore: Boolean(result.hasMore ?? result.offset + result.items.length < result.total), + }; +} diff --git a/mindspace-page-achievement-list.test.mjs b/mindspace-page-achievement-list.test.mjs new file mode 100644 index 0000000..a984cae --- /dev/null +++ b/mindspace-page-achievement-list.test.mjs @@ -0,0 +1,11 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseAchievementQuery } from './mindspace-page-achievement-list.mjs'; + +test('parseAchievementQuery defaults to fast paginated listing', () => { + const parsed = parseAchievementQuery({}); + assert.equal(parsed.limit, 20); + assert.equal(parsed.offset, 0); + assert.equal(parsed.includeStats, false); +}); diff --git a/mindspace-page-achievement-stats.mjs b/mindspace-page-achievement-stats.mjs new file mode 100644 index 0000000..280dff2 --- /dev/null +++ b/mindspace-page-achievement-stats.mjs @@ -0,0 +1,275 @@ +import crypto from 'node:crypto'; + +import { + pseudonymizeAnalyticsId, + resolveAnalyticsIdentity, + resolveMindSpaceAnalyticsConfig, +} from './mindspace-analytics.mjs'; + +const TOKEN_TTL_MS = 55 * 60 * 1000; +let cachedToken = ''; +let cachedTokenAt = 0; +let cachedBaseUrl = ''; + +function normalizeBaseUrl(value = '') { + return String(value ?? '').trim().replace(/\/$/, ''); +} + +function resolveUmamiCredentials(env = process.env, analyticsConfig = resolveMindSpaceAnalyticsConfig(env)) { + const baseUrl = normalizeBaseUrl(analyticsConfig?.analyticsUrl || env.MEMIND_ANALYTICS_URL || 'http://127.0.0.1:3100'); + const username = String(env.UMAMI_SSO_USERNAME || env.UMAMI_ADMIN_USERNAME || 'admin').trim(); + const password = String(env.UMAMI_ADMIN_PASSWORD || '').trim(); + const sharedSecret = String(env.MEMIND_UMAMI_SSO_SECRET || '').trim(); + const websiteId = String(analyticsConfig?.websiteId || env.MEMIND_ANALYTICS_WEBSITE_ID || '').trim(); + return { baseUrl, username, password, sharedSecret, websiteId }; +} + +async function loginUmamiViaSso({ baseUrl, username, sharedSecret }) { + const encoded = Buffer.from( + JSON.stringify({ + username, + exp: Math.floor(Date.now() / 1000) + 60, + nonce: crypto.randomUUID(), + }), + ).toString('base64url'); + const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url'); + const ticket = `${encoded}.${signature}`; + const response = await fetch(`${baseUrl}/api/auth/memind?ticket=${encodeURIComponent(ticket)}`, { + signal: AbortSignal.timeout(10_000), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.error?.message || body?.message || `Umami SSO 登录失败 (${response.status})`); + } + const token = String(body?.token ?? '').trim(); + if (!token) throw new Error('Umami SSO 登录响应缺少 token'); + return token; +} + +async function loginUmami(credentials) { + if (credentials.sharedSecret) { + return loginUmamiViaSso(credentials); + } + if (!credentials.password) { + throw new Error('未配置 Umami 凭据'); + } + const response = await fetch(`${credentials.baseUrl}/api/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ username: credentials.username, password: credentials.password }), + signal: AbortSignal.timeout(10_000), + }); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.message || `Umami 登录失败 (${response.status})`); + } + const token = String(body?.token ?? '').trim(); + if (!token) throw new Error('Umami 登录响应缺少 token'); + return token; +} + +async function getUmamiAuthToken(credentials) { + const now = Date.now(); + if (cachedToken && cachedBaseUrl === credentials.baseUrl && now - cachedTokenAt < TOKEN_TTL_MS) { + return cachedToken; + } + cachedToken = await loginUmami(credentials); + cachedTokenAt = now; + cachedBaseUrl = credentials.baseUrl; + return cachedToken; +} + +function pathnameFromUrl(value) { + const raw = String(value ?? '').trim(); + if (!raw) return ''; + try { + return new URL(raw, 'https://m.tkmind.cn').pathname.replace(/\/$/, '') || '/'; + } catch { + return raw.startsWith('/') ? raw.replace(/\/$/, '') || '/' : ''; + } +} + +function buildPageMatchKeys(page, userId) { + const keys = new Set(); + if (page?.id) keys.add(String(page.id)); + const workspacePath = String(page?.workspaceRelativePath ?? '').trim().replace(/^\/+/, ''); + if (workspacePath) { + keys.add(`/MindSpace/${userId}/${workspacePath}`.replace(/\/$/, '')); + } + for (const candidate of [page?.publicationUrl, page?.workspacePublicUrl]) { + const pathname = pathnameFromUrl(candidate); + if (pathname) keys.add(pathname); + } + return keys; +} + +export function mergeAchievementStatsForPage(page, umamiByPath, userId) { + const publicationViews = Number(page?.viewCount ?? 0); + const keys = buildPageMatchKeys(page, userId); + let umamiViews = 0; + let umamiClicks = 0; + let matched = false; + for (const key of keys) { + const stats = umamiByPath.get(key); + if (!stats) continue; + matched = true; + umamiViews = Math.max(umamiViews, Number(stats.views ?? 0)); + umamiClicks = Math.max(umamiClicks, Number(stats.clicks ?? 0)); + } + const viewCount = matched ? Math.max(publicationViews, umamiViews) : publicationViews; + const clickCount = matched ? umamiClicks : null; + let statsSource = 'none'; + if (matched && (umamiViews > 0 || umamiClicks > 0)) statsSource = 'umami'; + else if (publicationViews > 0) statsSource = 'publication'; + return { + ...page, + viewCount, + clickCount, + statsSource, + }; +} + +export async function fetchUmamiPageStatsByPath({ + ownerUserId, + env = process.env, + analyticsConfig = resolveMindSpaceAnalyticsConfig(env), + logger = console, +} = {}) { + if (!analyticsConfig?.enabled || !ownerUserId) return new Map(); + const credentials = resolveUmamiCredentials(env, analyticsConfig); + if (!credentials.websiteId) return new Map(); + if (!credentials.sharedSecret && !credentials.password) return new Map(); + + const ownerIdentity = resolveAnalyticsIdentity(ownerUserId, analyticsConfig); + if (!ownerIdentity) return new Map(); + + try { + const token = await getUmamiAuthToken(credentials); + const params = new URLSearchParams(); + params.set('page', '1'); + params.set('pageSize', '100'); + params.set('sortBy', 'generatedAt'); + params.set('sortOrder', 'desc'); + const endAt = Date.now(); + const startAt = endAt - 365 * 24 * 60 * 60 * 1000; + params.set('startAt', String(startAt)); + params.set('endAt', String(endAt)); + + const response = await fetch( + `${credentials.baseUrl}/api/websites/${encodeURIComponent(credentials.websiteId)}/memind-pages?${params.toString()}`, + { + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + }, + signal: AbortSignal.timeout(15_000), + }, + ); + const body = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(body?.message || `Umami memind-pages 请求失败 (${response.status})`); + } + + const rows = Array.isArray(body?.data) ? body.data : []; + const map = new Map(); + for (const row of rows) { + if (String(row?.ownerId ?? '') !== ownerIdentity) continue; + const urlPath = pathnameFromUrl(row?.urlPath); + const pageUrlPath = pathnameFromUrl(row?.pageUrl); + const stats = { + views: Number(row?.views ?? 0), + clicks: Number(row?.clicks ?? 0), + }; + if (urlPath) map.set(urlPath, stats); + if (pageUrlPath) map.set(pageUrlPath, stats); + } + return map; + } catch (error) { + logger?.warn?.( + '[MindSpace] Umami achievement stats unavailable:', + error instanceof Error ? error.message : error, + ); + return new Map(); + } +} + +export async function loadPublicationStatsByPageIds(pool, userId, pageIds) { + if (!pool || !userId || !Array.isArray(pageIds) || pageIds.length === 0) { + return new Map(); + } + const ids = [...new Set(pageIds.map((id) => String(id ?? '').trim()).filter(Boolean))]; + if (ids.length === 0) return new Map(); + const placeholders = ids.map(() => '?').join(','); + const [rows] = await pool.query( + `SELECT page_id, + SUM(view_count) AS total_view_count, + SUBSTRING_INDEX( + GROUP_CONCAT(id ORDER BY COALESCE(published_at, 0) DESC, updated_at DESC), + ',', + 1 + ) AS latest_publish_id + FROM h5_publish_records + WHERE user_id = ? AND page_id IN (${placeholders}) + GROUP BY page_id`, + [userId, ...ids], + ); + const map = new Map(); + for (const row of rows) { + map.set(String(row.page_id), { + viewCount: Number(row.total_view_count ?? 0), + publicationId: row.latest_publish_id ?? null, + }); + } + return map; +} + +function applyPublicationStats(pages, pubStatsByPageId) { + if (!pubStatsByPageId?.size) return pages; + return pages.map((page) => { + const pubStats = pubStatsByPageId.get(String(page?.id ?? '')); + if (!pubStats) return page; + const existingViews = Number(page?.viewCount ?? 0); + const mergedViews = Math.max(existingViews, pubStats.viewCount); + if (mergedViews === existingViews && (page?.publicationId || !pubStats.publicationId)) { + return page; + } + return { + ...page, + viewCount: mergedViews, + ...(page?.publicationId ? {} : pubStats.publicationId ? { publicationId: pubStats.publicationId } : {}), + ...(mergedViews > 0 && page?.statsSource === 'none' ? { statsSource: 'publication' } : {}), + }; + }); +} + +export async function enrichPagesWithAchievementStats(pages, { + ownerUserId, + env = process.env, + analyticsConfig = resolveMindSpaceAnalyticsConfig(env), + pool = null, + logger = console, + skipUmami = false, +} = {}) { + const pageIds = pages.map((page) => page?.id).filter(Boolean); + const pubStatsByPageId = await loadPublicationStatsByPageIds(pool, ownerUserId, pageIds); + const pagesWithPublicationStats = applyPublicationStats(pages, pubStatsByPageId); + if (skipUmami) { + return pagesWithPublicationStats.map((page) => + mergeAchievementStatsForPage(page, new Map(), ownerUserId), + ); + } + const umamiByPath = await fetchUmamiPageStatsByPath({ + ownerUserId, + env, + analyticsConfig, + logger, + }); + return pagesWithPublicationStats.map((page) => mergeAchievementStatsForPage(page, umamiByPath, ownerUserId)); +} + +export const achievementStatsInternals = { + buildPageMatchKeys, + mergeAchievementStatsForPage, + pathnameFromUrl, + pseudonymizeAnalyticsId, +}; diff --git a/mindspace-page-achievement-stats.test.mjs b/mindspace-page-achievement-stats.test.mjs new file mode 100644 index 0000000..3557838 --- /dev/null +++ b/mindspace-page-achievement-stats.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + achievementStatsInternals, + mergeAchievementStatsForPage, +} from './mindspace-page-achievement-stats.mjs'; + +const { buildPageMatchKeys, mergeAchievementStatsForPage: mergePage, pathnameFromUrl } = + achievementStatsInternals; + +test('buildPageMatchKeys includes workspace and publication paths', () => { + const keys = buildPageMatchKeys( + { + id: 'page-1', + workspaceRelativePath: 'public/demo.html', + publicationUrl: 'https://m.tkmind.cn/u/john/pages/demo', + }, + 'user-1', + ); + assert.ok(keys.has('page-1')); + assert.ok(keys.has('/MindSpace/user-1/public/demo.html')); + assert.ok(keys.has('/u/john/pages/demo')); +}); + +test('mergeAchievementStatsForPage prefers umami metrics when matched', () => { + const umamiByPath = new Map([ + ['/MindSpace/user-1/public/demo.html', { views: 12, clicks: 3 }], + ]); + const merged = mergePage( + { + id: 'page-1', + title: 'Demo', + workspaceRelativePath: 'public/demo.html', + viewCount: 5, + }, + umamiByPath, + 'user-1', + ); + assert.equal(merged.viewCount, 12); + assert.equal(merged.clickCount, 3); + assert.equal(merged.statsSource, 'umami'); +}); + +test('mergeAchievementStatsForPage keeps publication views without umami match', () => { + const merged = mergeAchievementStatsForPage( + { + id: 'page-2', + title: 'Draft', + viewCount: 7, + }, + new Map(), + 'user-2', + ); + assert.equal(merged.viewCount, 7); + assert.equal(merged.clickCount, null); + assert.equal(merged.statsSource, 'publication'); +}); + +test('pathnameFromUrl normalizes absolute and relative urls', () => { + assert.equal(pathnameFromUrl('https://m.tkmind.cn/u/john/pages/demo/'), '/u/john/pages/demo'); + assert.equal(pathnameFromUrl('/MindSpace/u/public/page.html'), '/MindSpace/u/public/page.html'); +}); diff --git a/mindspace-pages.mjs b/mindspace-pages.mjs index cff7aa1..f477c9b 100644 --- a/mindspace-pages.mjs +++ b/mindspace-pages.mjs @@ -135,6 +135,12 @@ function pageResponse(row, { includeContent = true, h5Root = null, env = process status: row.status, visibility: row.visibility, publicationAccessMode: row.pub_access_mode ?? null, + publicationId: row.pub_id ?? null, + publicationStatus: row.pub_status ?? null, + viewCount: asNumber(row.total_view_count ?? row.pub_view_count ?? 0), + clickCount: null, + statsSource: asNumber(row.total_view_count ?? row.pub_view_count ?? 0) > 0 ? 'publication' : 'none', + publishedAt: row.pub_published_at != null ? asNumber(row.pub_published_at) : null, publicationUrl: workspacePublicUrl ?? row.pub_public_url ?? null, workspaceRelativePath: workspaceRelativePath ?? null, workspacePublicUrl, @@ -727,21 +733,42 @@ export function createPageService(pool, options = {}) { clauses.push(`c.category_code = ?`); params.push(filters.categoryCode); } + if (Number.isFinite(filters.createdAfter)) { + clauses.push(`p.created_at >= ?`); + params.push(filters.createdAfter); + } + if (Number.isFinite(filters.createdBefore)) { + clauses.push(`p.created_at < ?`); + params.push(filters.createdBefore); + } const where = clauses.join(' AND '); const { limit, offset } = normalizeListPageFilters(filters); + const queryParams = [userId, ...params]; 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'`; + LEFT JOIN ( + SELECT page_id, + SUM(view_count) AS total_view_count, + MAX(published_at) AS last_published_at, + SUBSTRING_INDEX(GROUP_CONCAT(id ORDER BY COALESCE(published_at, 0) DESC, updated_at DESC), ',', 1) AS latest_publish_id + FROM h5_publish_records + WHERE user_id = ? + GROUP BY page_id + ) pub_stats ON pub_stats.page_id = p.id + LEFT JOIN h5_publish_records pr ON pr.id = COALESCE(p.current_publish_id, pub_stats.latest_publish_id)`; const [[countRows], [rows]] = await Promise.all([ - pool.query(`SELECT COUNT(*) AS total ${baseFrom} WHERE ${where}`, params), + pool.query(`SELECT COUNT(*) AS total ${baseFrom} WHERE ${where}`, queryParams), pool.query( - `SELECT p.*, c.category_code, pv.version_no, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url + `SELECT p.*, c.category_code, pv.version_no, + pr.id AS pub_id, pr.access_mode AS pub_access_mode, pr.public_url AS pub_public_url, + pr.view_count AS pub_view_count, pr.published_at AS pub_published_at, pr.status AS pub_status, + COALESCE(pub_stats.total_view_count, 0) AS total_view_count ${baseFrom} WHERE ${where} ORDER BY p.created_at DESC, p.updated_at DESC LIMIT ? OFFSET ?`, - [...params, limit, offset], + [...queryParams, limit, offset], ), ]); const total = asNumber(countRows[0]?.total); @@ -754,6 +781,28 @@ export function createPageService(pool, options = {}) { }; }; + const listPageCreatedDateBuckets = async (userId, { limit = 3 } = {}) => { + const safeLimit = Math.min(Math.max(Number(limit) || 3, 1), 31); + const [rows] = await pool.query( + `SELECT DATE(FROM_UNIXTIME(p.created_at / 1000)) AS date_key, + MIN(p.created_at) AS min_created_at, + MAX(p.created_at) AS max_created_at, + COUNT(*) AS page_count + FROM h5_page_records p + WHERE p.user_id = ? AND p.status <> 'deleted' + GROUP BY date_key + ORDER BY date_key DESC + LIMIT ?`, + [userId, safeLimit], + ); + return rows.map((row) => ({ + dateKey: String(row.date_key), + minCreatedAt: asNumber(row.min_created_at), + maxCreatedAt: asNumber(row.max_created_at), + pageCount: asNumber(row.page_count), + })); + }; + async function getPage(userId, pageId) { const [rows] = await pool.query( `SELECT p.*, c.category_code, pv.version_no, av.storage_key, pv.source_snapshot_json @@ -1241,13 +1290,27 @@ export function createPageService(pool, options = {}) { const deletePage = async (userId, pageId, options = {}) => { const removeFromPlaza = Boolean(options.removeFromPlaza); - const page = await getPage(userId, pageId); + const [metaRows] = await pool.query( + `SELECT p.id, p.title, p.page_type, p.space_id, p.source_asset_id + FROM h5_page_records p + WHERE p.id = ? AND p.user_id = ? AND p.status <> 'deleted' + LIMIT 1`, + [pageId, userId], + ); + const pageMeta = metaRows[0]; + if (!pageMeta) throw pageError('页面不存在', 'page_not_found'); + + const pageDetail = await getPage(userId, pageId).catch(() => null); + const contentFormat = + pageDetail?.contentFormat ?? (pageMeta.page_type === 'html' ? 'html' : 'markdown'); + const pageContent = pageDetail?.content ?? ''; + 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 + LEFT 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], @@ -1258,7 +1321,7 @@ export function createPageService(pool, options = {}) { workspaceHtmlRelativePath = null; } const embeddedAssetIds = - page.contentFormat === 'html' ? extractAssetIdsFromHtml(page.content ?? '') : []; + contentFormat === 'html' ? extractAssetIdsFromHtml(pageContent) : []; const conn = await pool.getConnection(); let result; @@ -1333,7 +1396,7 @@ export function createPageService(pool, options = {}) { const purgeResult = await purgeWorkspacePageArtifacts({ publishDir: workspacePublishDir, htmlRelativePath: workspaceHtmlRelativePath, - html: page.content ?? '', + html: pageContent, }).catch((error) => { console.warn('[MindSpace] workspace purge failed:', error?.message ?? error); return { removed: [], skipped: [] }; @@ -1494,6 +1557,7 @@ export function createPageService(pool, options = {}) { redactPage, createRedactedCopy: redactPage, listPages, + listPageCreatedDateBuckets, findPageBySourceAsset, findPageBySourceMessage, findPageByRelativePath, @@ -1602,6 +1666,7 @@ export const pageInternals = { normalizePageInput, normalizeListPageFilters, parseJsonColumn, + pageResponse, renderContent, renderPreviewHtml, renderHtmlPreview, diff --git a/mindspace-pages.test.mjs b/mindspace-pages.test.mjs index 603316d..02857d2 100644 --- a/mindspace-pages.test.mjs +++ b/mindspace-pages.test.mjs @@ -88,6 +88,45 @@ test('normalizeListPageFilters clamps limit and offset', () => { }); }); +test('pageResponse includes publication metrics when publish row is present', () => { + const response = pageInternals.pageResponse( + { + id: 'page-1', + user_id: 'user-1', + category_id: 'cat-1', + category_code: 'public', + source_session_id: null, + source_message_id: null, + source_asset_id: null, + title: '成果页面', + summary: '摘要', + page_type: 'html', + template_id: 'static-html', + status: 'published', + visibility: 'public', + current_version_id: 'ver-1', + version_no: 3, + pub_access_mode: 'public', + pub_public_url: 'https://m.tkmind.cn/u/john/pages/demo', + pub_id: 'pub-1', + pub_status: 'online', + pub_view_count: 128, + pub_published_at: 1_700_000_000_000, + created_at: 1_699_000_000_000, + updated_at: 1_700_100_000_000, + }, + { includeContent: false }, + ); + + assert.equal(response.publicationId, 'pub-1'); + assert.equal(response.publicationStatus, 'online'); + assert.equal(response.viewCount, 128); + assert.equal(response.clickCount, null); + assert.equal(response.statsSource, 'publication'); + assert.equal(response.publishedAt, 1_700_000_000_000); + assert.equal(response.publicationUrl, 'https://m.tkmind.cn/u/john/pages/demo'); +}); + test('preview rendering escapes active HTML and emits a restrictive CSP', () => { const html = pageInternals.renderPreviewHtml({ title: '', @@ -295,3 +334,70 @@ test('getPage falls back to workspace html when storage asset is missing', async assert.equal(page.title, 'Page Demo'); assert.match(page.content, /

Body<\/h1>/); }); + +test('deletePage succeeds when page content asset chain is incomplete', async () => { + const userId = 'user-1'; + const pageId = 'page-broken'; + let deletedPage = false; + const conn = { + async beginTransaction() {}, + async commit() {}, + async rollback() {}, + async release() {}, + query: async (sql) => { + const normalized = String(sql); + if (normalized.includes('FOR UPDATE') && normalized.includes('h5_page_records')) { + return [[{ id: pageId, space_id: 'space-1', title: 'test', source_asset_id: null }]]; + } + if (normalized.includes('FROM h5_page_versions pv')) { + return [[]]; + } + if (normalized.includes('FROM h5_publish_records') && normalized.includes("status = 'online'")) { + return [[]]; + } + if (normalized.includes('FROM h5_agent_jobs')) { + return [[{ job_count: 0 }]]; + } + if (normalized.includes("SET status = 'deleted'") && normalized.includes('h5_page_records')) { + deletedPage = true; + return [{ affectedRows: 1 }]; + } + if (normalized.includes('UPDATE h5_agent_jobs')) { + return [{ affectedRows: 0 }]; + } + return [[]]; + }, + }; + const pool = { + getConnection: async () => conn, + query: async (sql) => { + const normalized = String(sql); + if ( + normalized.includes('FROM h5_page_records p') && + normalized.includes('page_type') + ) { + return [[{ + id: pageId, + title: 'test', + page_type: 'markdown', + space_id: 'space-1', + source_asset_id: null, + }]]; + } + if (normalized.includes('JOIN h5_asset_versions')) { + return [[]]; + } + if (normalized.includes('source_snapshot_json')) { + return [[{ source_snapshot_json: null }]]; + } + return [[]]; + }, + }; + + const pageService = createPageService(pool, { storageRoot: '/tmp/unused', h5Root: null }); + const result = await pageService.deletePage(userId, pageId); + assert.equal(result.deleted, true); + assert.equal(result.pageId, pageId); + assert.equal(result.title, 'test'); + assert.equal(deletedPage, true); +}); diff --git a/mindspace-server-adapter-contract.mjs b/mindspace-server-adapter-contract.mjs index 03fbe85..96e6942 100644 --- a/mindspace-server-adapter-contract.mjs +++ b/mindspace-server-adapter-contract.mjs @@ -81,6 +81,7 @@ export const MINDSPACE_SERVER_ADAPTER_BINDINGS = Object.freeze({ 'getDeletePreview', 'getPage', 'listPages', + 'listPageCreatedDateBuckets', 'listVersions', 'localizePrivateResources', 'redactPage', diff --git a/mindspace-service/scripts/run-mindspace-prod.sh b/mindspace-service/scripts/run-mindspace-prod.sh index aa08e2c..f164a2c 100644 --- a/mindspace-service/scripts/run-mindspace-prod.sh +++ b/mindspace-service/scripts/run-mindspace-prod.sh @@ -47,7 +47,7 @@ if [[ -n "${EXPLICIT_H5_USERS_ROOT}" ]]; then fi export NODE_ENV=production -export MINDSPACE_MEMIND_ROOT="${MINDSPACE_MEMIND_ROOT:-${ROOT}/memind-source}" +export MINDSPACE_MEMIND_ROOT="${MINDSPACE_MEMIND_ROOT:-${MEMIND_ROOT}}" export MINDSPACE_SERVICE_H5_ROOT="${MINDSPACE_SERVICE_H5_ROOT:-${MEMIND_ROOT}}" export MINDSPACE_STORAGE_ROOT="${MINDSPACE_STORAGE_ROOT:-${ROOT}/data/mindspace}" export H5_USERS_ROOT="${H5_USERS_ROOT:-${ROOT}/users}" diff --git a/scripts/wechat-mp-menu.mjs b/scripts/wechat-mp-menu.mjs index 187faa3..31625ce 100755 --- a/scripts/wechat-mp-menu.mjs +++ b/scripts/wechat-mp-menu.mjs @@ -26,9 +26,19 @@ const MENU = { url: 'https://m.tkmind.cn', }, { - type: 'view', name: 'M空间', - url: 'https://m.tkmind.cn/space', + sub_button: [ + { + type: 'view', + name: '空间首页', + url: 'https://m.tkmind.cn/space', + }, + { + type: 'view', + name: 'M成果', + url: 'https://m.tkmind.cn/space/achievements', + }, + ], }, { type: 'view', diff --git a/server.mjs b/server.mjs index 4d83511..d170923 100644 --- a/server.mjs +++ b/server.mjs @@ -1334,6 +1334,7 @@ attachPortalMindSpacePageCoreRoutes(api, { getMindSpacePageLiveEdit: () => mindSpacePageLiveEdit, getMindSpacePageEditSession: () => mindSpacePageEditSession, getUserAuth: () => userAuth, + getAuthPool: () => authPool, syncUserGeneratedPages, ownsAgentSession, ensureMindSpaceEnabled, diff --git a/server/portal-mindspace-page-core-routes.mjs b/server/portal-mindspace-page-core-routes.mjs index 40a0f04..3f5fde4 100644 --- a/server/portal-mindspace-page-core-routes.mjs +++ b/server/portal-mindspace-page-core-routes.mjs @@ -1,3 +1,5 @@ +import { listAchievementPagesForUser } from '../mindspace-page-achievement-list.mjs'; + function assertRouter(api) { if ( !api || @@ -72,6 +74,7 @@ export function attachPortalMindSpacePageCoreRoutes( getMindSpacePageLiveEdit = () => null, getMindSpacePageEditSession = () => null, getUserAuth = () => null, + getAuthPool = () => null, syncUserGeneratedPages, ownsAgentSession, ensureMindSpaceEnabled, @@ -161,6 +164,35 @@ export function attachPortalMindSpacePageCoreRoutes( } }); + api.get('/mindspace/v1/pages/achievements', async (req, res) => { + const pages = getMindSpacePages(); + if (!pages) { + return res.status(503).json({ message: 'MindSpace 未启用' }); + } + try { + schedulePageListSync(req.currentUser.id); + const result = await listAchievementPagesForUser({ + pages, + userId: req.currentUser.id, + query: req.query, + pool: getAuthPool?.() ?? null, + logger, + }); + return res.json({ + data: result.items, + page: { + total: result.total, + limit: result.limit, + offset: result.offset, + has_more: result.hasMore, + next_cursor: null, + }, + }); + } catch (error) { + return handleMindSpaceError(res, req, error); + } + }); + api.get('/mindspace/v1/pages/:pageId', async (req, res) => { const pages = getMindSpacePages(); if (!pages) { diff --git a/server/portal-mindspace-page-core-routes.test.mjs b/server/portal-mindspace-page-core-routes.test.mjs index 4b2e313..48c1f0a 100644 --- a/server/portal-mindspace-page-core-routes.test.mjs +++ b/server/portal-mindspace-page-core-routes.test.mjs @@ -102,6 +102,7 @@ test('page core module preserves route inventory and order', () => { assert.deepEqual([...api.routes.keys()], [ 'POST /mindspace/v1/pages', 'GET /mindspace/v1/pages', + 'GET /mindspace/v1/pages/achievements', 'GET /mindspace/v1/pages/:pageId', 'DELETE /mindspace/v1/pages/:pageId', 'GET /mindspace/v1/pages/:pageId/delete-preview', @@ -522,3 +523,31 @@ test('page core routes preserve unavailable and mapped error responses', async ( assert.equal(failureRes.statusCode, 418); assert.equal(failure.calls.routeErrors[0].error.message, 'list failed'); }); + +test('achievements route uses listPages and enriches stats before page detail route', async () => { + const api = createRouterRecorder(); + const setup = createDependencies({ + getMindSpacePages: () => ({ + async listPages(userId, input) { + return { + items: [{ id: 'page-1', title: 'Demo', viewCount: 3, clickCount: null, statsSource: 'publication' }], + total: 1, + limit: input.limit ?? 20, + offset: input.offset ?? 0, + hasMore: false, + }; + }, + }), + getAuthPool: () => null, + }); + attachPortalMindSpacePageCoreRoutes(api, setup.dependencies); + + const res = createResponseRecorder(); + await api.routes.get('GET /mindspace/v1/pages/achievements')( + createRequest({ query: { limit: '20', offset: '0' } }), + res, + ); + assert.equal(res.statusCode, 200); + assert.equal(res.body.data[0].viewCount, 3); + assert.equal(res.body.page.total, 1); +}); diff --git a/src/analytics/productAnalytics.ts b/src/analytics/productAnalytics.ts index 13dd814..93de95b 100644 --- a/src/analytics/productAnalytics.ts +++ b/src/analytics/productAnalytics.ts @@ -54,6 +54,7 @@ export function normalizeProductRoute(pathname: string, search = '') { export function resolveProductRouteName(pathname: string) { if (pathname === '/') return 'chat'; if (pathname === '/space') return 'mindspace_home'; + if (pathname === '/space/achievements') return 'mindspace_achievements'; if (pathname.startsWith('/space/page/')) return 'mindspace_page'; if (pathname.startsWith('/feedback/')) return 'feedback_detail'; if (pathname === '/feedback') return 'feedback'; diff --git a/src/api/client.ts b/src/api/client.ts index 155bda2..9c0989d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -88,7 +88,9 @@ export { getMindSpacePage, getMindSpacePageDeletePreview, getMindSpacePageLiveRevision, + isMindSpacePageNotFoundError, listMindSpacePages, + listMindSpaceAchievementPages, openMindSpaceDraftPreviewWindow, regenerateMindSpacePageThumbnail, rewriteMindSpacePageDownloadLinks, diff --git a/src/api/mindspace-pages.ts b/src/api/mindspace-pages.ts index f6e1ebe..822655c 100644 --- a/src/api/mindspace-pages.ts +++ b/src/api/mindspace-pages.ts @@ -29,6 +29,14 @@ export async function getMindSpacePageDeletePreview( return result.data; } +export function isMindSpacePageNotFoundError(err: unknown): boolean { + return ( + err instanceof ApiError && + err.status === 404 && + (err.code === 'page_not_found' || err.message === '页面不存在') + ); +} + export async function deleteMindSpacePage( pageId: string, options?: { removeFromPlaza?: boolean }, @@ -61,6 +69,26 @@ export async function listMindSpacePages(options?: { return { items: result.data, page: result.page ?? {} }; } +export async function listMindSpaceAchievementPages(options?: { + status?: string; + limit?: number; + offset?: number; + categoryCode?: string; + includeStats?: boolean; +}): 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); + if (options?.includeStats) params.set('include_stats', '1'); + const query = params.toString() ? `?${params.toString()}` : ''; + const result = await apiFetch<{ data: MindSpacePage[]; page?: MindSpaceListPage }>( + `/mindspace/v1/pages/achievements${query}`, + ); + return { items: result.data, page: result.page ?? {} }; +} + export async function getMindSpacePage(pageId: string): Promise { const result = await apiFetch<{ data: MindSpacePage }>( `/mindspace/v1/pages/${encodeURIComponent(pageId)}`, diff --git a/src/components/MindSpaceAchievementsPanel.tsx b/src/components/MindSpaceAchievementsPanel.tsx new file mode 100644 index 0000000..4885ee5 --- /dev/null +++ b/src/components/MindSpaceAchievementsPanel.tsx @@ -0,0 +1,283 @@ +import { useMemo } from 'react'; + +import type { MindSpacePage } from '../types'; + +const PAGE_STATUS_LABELS: Record = { + draft: '草稿', + reviewing: '待检查', + risk_found: '发现风险', + ready: '可发布', + published: '已公开', + protected: '受保护', + expired: '已过期', + offline: '已下线', +}; + +function formatDateTime(timestamp: number) { + return new Date(timestamp).toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatDateHeading(timestamp: number) { + return new Date(timestamp).toLocaleDateString('zh-CN', { + year: 'numeric', + month: 'long', + day: 'numeric', + weekday: 'short', + }); +} + +function formatMetric(value: number | null | undefined) { + if (value == null) return '—'; + return value.toLocaleString('zh-CN'); +} + +function formatClickMetric(page: MindSpacePage) { + if (page.clickCount == null) return '—'; + return formatMetric(page.clickCount); +} + +function groupPagesByCreatedDate(pages: MindSpacePage[]) { + const groups = new Map(); + for (const page of pages) { + const key = new Date(page.createdAt).toISOString().slice(0, 10); + const bucket = groups.get(key); + if (bucket) bucket.push(page); + else groups.set(key, [page]); + } + return [...groups.entries()] + .sort(([left], [right]) => right.localeCompare(left)) + .map(([dateKey, items]) => ({ + dateKey, + heading: formatDateHeading(items[0]?.createdAt ?? Date.parse(`${dateKey}T00:00:00`)), + items, + })); +} + +type MindSpaceAchievementsPanelProps = { + items: MindSpacePage[]; + total: number; + loading: boolean; + pageIndex: number; + pageSize: number; + selectedIds: string[]; + deleting: boolean; + deletingPageId?: string | null; + onBack: () => void; + onOpenPage: (page: MindSpacePage) => void; + onShowPage: (pageId: string) => void; + onTogglePage: (pageId: string, checked: boolean) => void; + onToggleDateGroup: (pageIds: string[], checked: boolean) => void; + onToggleAll: () => void; + onClearSelection: () => void; + onDeleteSelected: () => void; + onDeletePage: (page: MindSpacePage) => void; + onPrevPage: () => void; + onNextPage: () => void; + onFirstPage: () => void; +}; + +export function MindSpaceAchievementsPanel({ + items, + total, + loading, + pageIndex, + pageSize, + selectedIds, + deleting, + deletingPageId, + onBack, + onOpenPage, + onShowPage, + onTogglePage, + onToggleDateGroup, + onToggleAll, + onClearSelection, + onDeleteSelected, + onDeletePage, + onPrevPage, + onNextPage, + onFirstPage, +}: MindSpaceAchievementsPanelProps) { + const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]); + const grouped = useMemo(() => groupPagesByCreatedDate(items), [items]); + const allVisibleSelected = + items.length > 0 && items.every((page) => selectedSet.has(page.id)); + const pageCount = Math.max(1, Math.ceil(total / pageSize)); + + return ( +
+
+
+ +

MY ACHIEVEMENTS

+

我的成果展示

+
+ {total} 个页面 +
+ +

+ 按创建日期汇总你制作过的页面,每页 {pageSize} 条。浏览量来自公开页访问统计;点击量需开启 Umami 后才会显示。 +

+ + {items.length > 0 && ( +
+ + 已选 {selectedIds.length} 项 + {selectedIds.length > 0 && ( + + )} + + {total > pageSize && pageIndex > 0 && ( + + )} +
+ )} + + {loading && items.length === 0 ? ( +
正在加载成果列表…
+ ) : total > 0 ? ( + <> +
+ {grouped.map((group) => { + const groupIds = group.items.map((page) => page.id); + const groupSelected = + groupIds.length > 0 && groupIds.every((id) => selectedSet.has(id)); + return ( +
+
+

{group.heading}

+ +
+
+ + + + + + + + + + + + + + + {group.items.map((page) => ( + + + + + + + + + + + ))} + +
+ 选择 + 标题状态创建时间最后修改浏览量点击量操作
+ + onTogglePage(page.id, event.currentTarget.checked) + } + /> + + + + {PAGE_STATUS_LABELS[page.status] ?? page.status}{formatDateTime(page.createdAt)}{formatDateTime(page.updatedAt)}{formatMetric(page.viewCount ?? 0)}{formatClickMetric(page)} + +
+
+
+ ); + })} +
+ + {total > pageSize && ( +
+ + + 第 {pageIndex + 1} / {pageCount} 页 + + +
+ )} + + ) : ( +
+

上传资料或从聊天保存页面后,你的成果会按日期集中展示在这里。

+
+ )} +
+ ); +} diff --git a/src/components/MindSpaceView.tsx b/src/components/MindSpaceView.tsx index 5c20966..6a68c68 100644 --- a/src/components/MindSpaceView.tsx +++ b/src/components/MindSpaceView.tsx @@ -15,10 +15,12 @@ import { getMindSpacePage, getMindSpacePageDeletePreview, ignoreMindSpaceScheduleReminder, + isMindSpacePageNotFoundError, listMindSpaceAgentJobs, listMindSpaceAssets, listMindSpaceCleanupItems, listMindSpacePages, + listMindSpaceAchievementPages, retryMindSpaceAgentJob, runMindSpaceAgentJob, runMindSpaceCleanup, @@ -55,6 +57,7 @@ import { } from '../utils/mindspaceCards'; import { PREVIEW_ASSETS, PREVIEW_PAGES, PREVIEW_SPACE } from '../dev/mindspacePreviewData'; import { MindSpaceFeedCard } from './MindSpaceFeedCard'; +import { MindSpaceAchievementsPanel } from './MindSpaceAchievementsPanel'; import { MindSpacePageDetail } from './MindSpacePageDetail'; import { MindSpaceModal } from './MindSpaceModal'; import { MindSpaceDeletePlazaOption } from './MindSpaceDeletePlazaOption'; @@ -83,6 +86,7 @@ const CATEGORY_ACTIONS: Partial> = { const AGENT_JOBS_PAGE_SIZE = 10; const RECENT_PAGES_PAGE_SIZE = 6; const ALL_PAGES_PAGE_SIZE = 15; +const ACHIEVEMENTS_PAGE_SIZE = 20; function sortPagesByCreatedAt(pages: MindSpacePage[]) { return [...pages].sort((left, right) => { @@ -499,6 +503,7 @@ function canGenerateWithAgent(asset: MindSpaceAsset) { type MindSpaceRouteSync = { pushHome: () => void; + pushAchievements: () => void; pushCategory: (code: MindSpaceSaveCategory | MindSpaceCategory['code']) => void; pushPage: (pageId: string) => void; }; @@ -528,6 +533,7 @@ export function MindSpaceView({ previewMode = false, initialPageId, initialCategoryCode, + initialAchievementsOpen = false, onBack, onLogout, onOpenFeedback, @@ -537,6 +543,7 @@ export function MindSpaceView({ previewMode?: boolean; initialPageId?: string | null; initialCategoryCode?: MindSpaceSaveCategory | null; + initialAchievementsOpen?: boolean; onBack: () => void; onLogout: () => void; onOpenFeedback?: () => void; @@ -589,6 +596,13 @@ export function MindSpaceView({ const [allPagesTotal, setAllPagesTotal] = useState(0); const [allPagesPageIndex, setAllPagesPageIndex] = useState(0); const [allPagesLoading, setAllPagesLoading] = useState(false); + const [achievementsOpen, setAchievementsOpen] = useState(initialAchievementsOpen); + const [achievementsItems, setAchievementsItems] = useState([]); + const [achievementsTotal, setAchievementsTotal] = useState(0); + const [achievementsPageIndex, setAchievementsPageIndex] = useState(0); + const [achievementsLoading, setAchievementsLoading] = useState(false); + const [selectedAchievementPageIds, setSelectedAchievementPageIds] = useState([]); + const [bulkDeletingAchievements, setBulkDeletingAchievements] = useState(false); const [selectedAllPageIds, setSelectedAllPageIds] = useState([]); const [bulkDeletingAllPages, setBulkDeletingAllPages] = useState(false); const [selectedScheduleReminderIds, setSelectedScheduleReminderIds] = useState([]); @@ -605,7 +619,7 @@ export function MindSpaceView({ const [pageDeleteRemoveFromPlaza, setPageDeleteRemoveFromPlaza] = useState(false); const [deletingPageId, setDeletingPageId] = useState(null); const [bulkPageDeleteOpen, setBulkPageDeleteOpen] = useState(false); - const [bulkPageDeleteSource, setBulkPageDeleteSource] = useState<'all' | 'draft'>('all'); + const [bulkPageDeleteSource, setBulkPageDeleteSource] = useState<'all' | 'draft' | 'achievements'>('all'); const [bulkPageDeletePreviews, setBulkPageDeletePreviews] = useState< MindSpacePageDeletePreview[] | null >(null); @@ -649,11 +663,11 @@ export function MindSpaceView({ }, [selectedPageId]); useEffect(() => { - if (!allPagesOpen && !selectedPageId) return; + if (!allPagesOpen && !achievementsOpen && !selectedPageId) return; window.requestAnimationFrame(() => { window.scrollTo({ top: 0, left: 0 }); }); - }, [allPagesOpen, selectedPageId]); + }, [allPagesOpen, achievementsOpen, selectedPageId]); useEffect(() => { if (!selectedPageId || !session?.id || previewMode || pageFullscreenPreviewOpen) return; @@ -802,8 +816,30 @@ export function MindSpaceView({ setPageDeleteTarget(null); setPageDeletePreview(null); setPageDeleteRemoveFromPlaza(false); + setSelectedAchievementPageIds((ids) => ids.filter((id) => id !== pageDeleteTarget.id)); + setAchievementsItems((items) => items.filter((page) => page.id !== pageDeleteTarget.id)); + setAchievementsTotal((total) => Math.max(0, total - 1)); await refreshMindSpaceSnapshot({ quota: result.quota, freedBytes: result.freedBytes }); + if (achievementsOpen) { + await loadAchievementsPage(achievementsPageIndex); + } } catch (err) { + if (isMindSpacePageNotFoundError(err)) { + if (selectedPageId === pageDeleteTarget.id) { + closePage(); + } + setPageDeleteTarget(null); + setPageDeletePreview(null); + setPageDeleteRemoveFromPlaza(false); + setSelectedAchievementPageIds((ids) => ids.filter((id) => id !== pageDeleteTarget.id)); + setAchievementsItems((items) => items.filter((page) => page.id !== pageDeleteTarget.id)); + setAchievementsTotal((total) => Math.max(0, total - 1)); + await refreshMindSpaceSnapshot(); + if (achievementsOpen) { + await loadAchievementsPage(achievementsPageIndex); + } + return; + } setError(err instanceof Error ? err.message : '删除页面失败'); } finally { setDeletingPageId(null); @@ -959,12 +995,58 @@ export function MindSpaceView({ setSelectedPageId(null); setNewPageOpen(false); setAgentJobsPanelOpen(false); + setAchievementsOpen(false); setAllPagesOpen(true); setSelectedAllPageIds([]); routeSync?.pushHome(); void loadAllPagesPage(0); }; + const loadAchievementsPage = async (pageIndex: number) => { + if (previewMode) { + setAchievementsItems(PREVIEW_PAGES); + setAchievementsTotal(PREVIEW_PAGES.length); + setAchievementsPageIndex(0); + setSelectedAchievementPageIds([]); + return; + } + setAchievementsLoading(true); + setError(null); + try { + const { items, page } = await listMindSpaceAchievementPages({ + limit: ACHIEVEMENTS_PAGE_SIZE, + offset: pageIndex * ACHIEVEMENTS_PAGE_SIZE, + includeStats: false, + }); + setAchievementsItems(items); + setAchievementsPageIndex(pageIndex); + setAchievementsTotal(page.total ?? items.length); + setSelectedAchievementPageIds([]); + } catch (err) { + setError(err instanceof Error ? err.message : '成果列表加载失败'); + } finally { + setAchievementsLoading(false); + } + }; + + const openAchievementsPanel = () => { + setSelectedCategory(null); + setSelectedPageId(null); + setNewPageOpen(false); + setAgentJobsPanelOpen(false); + setAllPagesOpen(false); + setAchievementsOpen(true); + setSelectedAchievementPageIds([]); + routeSync?.pushAchievements(); + void loadAchievementsPage(0); + }; + + const closeAchievementsPanel = () => { + setAchievementsOpen(false); + setSelectedAchievementPageIds([]); + routeSync?.pushHome(); + }; + const refreshAgentJobsSummary = async () => { if (previewMode) return; const { page } = await listMindSpaceAgentJobs({ limit: 1, offset: 0 }); @@ -999,6 +1081,7 @@ export function MindSpaceView({ setSelectedPageId(null); setNewPageOpen(false); setAllPagesOpen(false); + setAchievementsOpen(false); setAgentJobsPanelOpen(true); routeSync?.pushHome(); void loadAgentJobsPage(0); @@ -1066,6 +1149,9 @@ export function MindSpaceView({ if (allPagesOpen) { await loadAllPagesPage(allPagesPageIndex); } + if (achievementsOpen) { + await loadAchievementsPage(achievementsPageIndex); + } } catch { // ignore background list refresh errors } @@ -1114,7 +1200,7 @@ export function MindSpaceView({ useEffect(() => { if (previewMode) return; const refreshSchedule = () => { - if (!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen) { + if (!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && !achievementsOpen) { void refreshSpaceQuietly(); } }; @@ -1130,6 +1216,7 @@ export function MindSpaceView({ newPageOpen, agentJobsPanelOpen, allPagesOpen, + achievementsOpen, ]); useEffect(() => { @@ -1137,10 +1224,21 @@ export function MindSpaceView({ }, [initialPageId]); useEffect(() => { - if (!space || !initialCategoryCode || initialCategoryCode === 'draft') return; + if (!space || !initialCategoryCode || initialCategoryCode === 'draft' || initialAchievementsOpen) return; const category = space.categories.find((item) => item.code === initialCategoryCode); if (category) void openCategory(category); - }, [space, initialCategoryCode]); + }, [space, initialCategoryCode, initialAchievementsOpen]); + + useEffect(() => { + if (!initialAchievementsOpen || previewMode) return; + setSelectedCategory(null); + setSelectedPageId(null); + setNewPageOpen(false); + setAgentJobsPanelOpen(false); + setAllPagesOpen(false); + setAchievementsOpen(true); + void loadAchievementsPage(0); + }, [initialAchievementsOpen, previewMode]); useEffect(() => { if (previewMode || !agentJob || !['queued', 'running'].includes(agentJob.status)) return; @@ -1167,6 +1265,7 @@ export function MindSpaceView({ const openCategory = async (category: MindSpaceCategory) => { setAgentJobsPanelOpen(false); setAllPagesOpen(false); + setAchievementsOpen(false); setSelectedCategory(category); setSelectedPageId(null); setNewPageOpen(false); @@ -1586,13 +1685,13 @@ export function MindSpaceView({ }, [bulkPageDeletePreviews]); const closeBulkPageDeleteDialog = () => { - if (bulkDeletingAllPages || bulkDeletingDraftPages) return; + if (bulkDeletingAllPages || bulkDeletingDraftPages || bulkDeletingAchievements) return; setBulkPageDeleteOpen(false); setBulkPageDeletePreviews(null); setBulkPageDeleteRemoveFromPlaza(false); }; - const startBulkPageDelete = async (pageIds: string[], source: 'all' | 'draft') => { + const startBulkPageDelete = async (pageIds: string[], source: 'all' | 'draft' | 'achievements') => { if (pageIds.length === 0) return; if (previewMode) { setError('预览模式下无法删除页面'); @@ -1632,7 +1731,11 @@ export function MindSpaceView({ if (!bulkPageDeletePreviews || bulkPageDeletePreviews.length === 0) return; const deletingIds = bulkPageDeletePreviews.map((preview) => preview.page.id); const setBulkDeleting = - bulkPageDeleteSource === 'all' ? setBulkDeletingAllPages : setBulkDeletingDraftPages; + bulkPageDeleteSource === 'all' + ? setBulkDeletingAllPages + : bulkPageDeleteSource === 'draft' + ? setBulkDeletingDraftPages + : setBulkDeletingAchievements; setBulkDeleting(true); setError(null); const failedIds: string[] = []; @@ -1645,6 +1748,10 @@ export function MindSpaceView({ }); if (selectedPageId === preview.page.id) closePage(); } catch (err) { + if (isMindSpacePageNotFoundError(err)) { + if (selectedPageId === preview.page.id) closePage(); + continue; + } failedIds.push(preview.page.id); failedNames.push(preview.page.title); console.error('[MindSpace] delete page failed:', preview.page.id, err); @@ -1653,8 +1760,10 @@ export function MindSpaceView({ const deletedIds = deletingIds.filter((id) => !failedIds.includes(id)); if (bulkPageDeleteSource === 'all') { setSelectedAllPageIds((ids) => ids.filter((id) => failedIds.includes(id))); - } else { + } else if (bulkPageDeleteSource === 'draft') { setSelectedDraftPageIds((ids) => ids.filter((id) => failedIds.includes(id))); + } else { + setSelectedAchievementPageIds((ids) => ids.filter((id) => failedIds.includes(id))); } setBulkPageDeleteOpen(false); setBulkPageDeletePreviews(null); @@ -1668,6 +1777,12 @@ export function MindSpaceView({ : allPagesPageIndex; await loadAllPagesPage(nextPageIndex); } + if (bulkPageDeleteSource === 'achievements' && deletedIds.length > 0) { + setAchievementsItems((items) => items.filter((page) => !deletedIds.includes(page.id))); + setAchievementsTotal((total) => Math.max(0, total - deletedIds.length)); + setSelectedAchievementPageIds((ids) => ids.filter((id) => !deletedIds.includes(id))); + await loadAchievementsPage(achievementsPageIndex); + } if (failedNames.length > 0) { setError(`有 ${failedNames.length} 个页面删除失败:${failedNames.slice(0, 3).join('、')}`); } @@ -1698,6 +1813,45 @@ export function MindSpaceView({ void startBulkPageDelete([...selectedAllPageIds], 'all'); }; + const selectedAchievementPageSet = useMemo( + () => new Set(selectedAchievementPageIds), + [selectedAchievementPageIds], + ); + + const toggleAchievementPageSelected = (pageId: string, checked: boolean) => { + setSelectedAchievementPageIds((ids) => + checked ? (ids.includes(pageId) ? ids : [...ids, pageId]) : ids.filter((id) => id !== pageId), + ); + }; + + const toggleAchievementDateGroupSelected = (pageIds: string[], checked: boolean) => { + setSelectedAchievementPageIds((ids) => { + if (!checked) { + const remove = new Set(pageIds); + return ids.filter((id) => !remove.has(id)); + } + return [...new Set([...ids, ...pageIds])]; + }); + }; + + const toggleAllAchievementPagesSelected = () => { + const allSelected = + achievementsItems.length > 0 && + achievementsItems.every((page) => selectedAchievementPageSet.has(page.id)); + if (allSelected) { + const visibleIds = new Set(achievementsItems.map((page) => page.id)); + setSelectedAchievementPageIds((ids) => ids.filter((id) => !visibleIds.has(id))); + return; + } + setSelectedAchievementPageIds((ids) => [ + ...new Set([...ids, ...achievementsItems.map((page) => page.id)]), + ]); + }; + + const removeSelectedAchievementPages = () => { + void startBulkPageDelete([...selectedAchievementPageIds], 'achievements'); + }; + const allPagesPageCount = Math.max(1, Math.ceil(allPagesTotal / ALL_PAGES_PAGE_SIZE)); const previewAsset = useMemo( @@ -1926,7 +2080,13 @@ export function MindSpaceView({ ); const showHomeModules = - space && !selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen; + space && + !selectedCategory && + !selectedPageId && + !newPageOpen && + !agentJobsPanelOpen && + !allPagesOpen && + !achievementsOpen; const rawUpcomingReminders = previewMode ? [] : space?.schedule?.upcomingReminders ?? []; const scheduleReminders = previewMode ? PREVIEW_SCHEDULE_REMINDERS.map((item) => ({ ...item })) @@ -2066,7 +2226,7 @@ export function MindSpaceView({
- {!allPagesOpen && ( + {!allPagesOpen && !achievementsOpen && (

MINDSPACE

@@ -2088,6 +2248,7 @@ export function MindSpaceView({ setSelectedCategory(null); setSelectedPageId(null); setAllPagesOpen(false); + setAchievementsOpen(false); setNewPageOpen(true); }} > @@ -2362,7 +2523,7 @@ export function MindSpaceView({
站内浏览
@@ -2444,6 +2605,29 @@ export function MindSpaceView({
+ ) : achievementsOpen ? ( + void openPageViewInNewTab(page)} + onShowPage={(pageId) => showPage(pageId)} + onTogglePage={toggleAchievementPageSelected} + onToggleDateGroup={toggleAchievementDateGroupSelected} + onToggleAll={toggleAllAchievementPagesSelected} + onClearSelection={() => setSelectedAchievementPageIds([])} + onDeleteSelected={() => void removeSelectedAchievementPages()} + onDeletePage={(page) => void openPageDeleteDialog(page)} + onPrevPage={() => void loadAchievementsPage(achievementsPageIndex - 1)} + onNextPage={() => void loadAchievementsPage(achievementsPageIndex + 1)} + onFirstPage={() => void loadAchievementsPage(0)} + /> ) : allPagesOpen ? (
@@ -3023,6 +3207,17 @@ export function MindSpaceView({ ))}
+
+
+ SHOWCASE + {recentPagesTotal} 项 +
+

我的成果展示

+

按日期查看页面标题、创建/修改时间与访问数据。

+ +
{space.categories .filter((category) => category.code === 'draft') .map((category) => ( @@ -3048,7 +3243,7 @@ export function MindSpaceView({
)} - {!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && ( + {!selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen && !achievementsOpen && (
@@ -3433,7 +3628,7 @@ export function MindSpaceView({ eyebrow="DELETE PAGES" className="mindspace-delete-dialog" disableClose={ - bulkPageDeleteLoading || bulkDeletingAllPages || bulkDeletingDraftPages + bulkPageDeleteLoading || bulkDeletingAllPages || bulkDeletingDraftPages || bulkDeletingAchievements } > {bulkPageDeleteLoading &&
正在分析连带内容…
} @@ -3460,7 +3655,7 @@ export function MindSpaceView({ @@ -3468,9 +3663,11 @@ export function MindSpaceView({ type="button" className="mindspace-primary" onClick={() => void confirmBulkPageDelete()} - disabled={bulkDeletingAllPages || bulkDeletingDraftPages} + disabled={bulkDeletingAllPages || bulkDeletingDraftPages || bulkDeletingAchievements} > - {bulkDeletingAllPages || bulkDeletingDraftPages ? '删除中…' : '确认删除'} + {bulkDeletingAllPages || bulkDeletingDraftPages || bulkDeletingAchievements + ? '删除中…' + : '确认删除'}
diff --git a/src/dev/mindspacePreviewData.ts b/src/dev/mindspacePreviewData.ts index 4c48497..ac695a3 100644 --- a/src/dev/mindspacePreviewData.ts +++ b/src/dev/mindspacePreviewData.ts @@ -111,6 +111,8 @@ export const PREVIEW_PAGES: MindSpacePage[] = [ currentVersionId: 'page-1-v2', versionNo: 2, contentFormat: 'markdown', + viewCount: 86, + clickCount: 5, createdAt: Date.now() - 172_800_000, updatedAt: Date.now() - 43_200_000, }, @@ -130,6 +132,8 @@ export const PREVIEW_PAGES: MindSpacePage[] = [ currentVersionId: 'page-2-v1', versionNo: 1, contentFormat: 'html', + viewCount: 12, + clickCount: 2, createdAt: Date.now() - 259_200_000, updatedAt: Date.now() - 86_400_000, }, diff --git a/src/index.css b/src/index.css index 910c2ad..f0f9abc 100644 --- a/src/index.css +++ b/src/index.css @@ -7964,6 +7964,212 @@ body, margin-bottom: 12px; } +.mindspace-achievements-intro { + margin: 0 0 18px; + color: rgba(24, 33, 29, 0.72); + font-size: 14px; + line-height: 1.6; +} + +.mindspace-achievements-bulkbar { + margin-bottom: 14px; +} + +.mindspace-achievements-bulkbar .mindspace-achievements-back-page { + margin-left: auto; + padding: 5px 11px; + border: 1px solid rgba(47, 111, 87, 0.18); + border-radius: 999px; + color: #2f6f57; + background: rgba(235, 248, 240, 0.72); + font-size: 12px; + font-weight: 600; + line-height: 1.4; +} + +.mindspace-achievements-bulkbar .mindspace-achievements-back-page:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.mindspace-achievements-pagination { + gap: 10px; + margin-top: 16px; +} + +.mindspace-achievements-pagination button { + padding: 5px 11px; + border: 1px solid rgba(24, 33, 29, 0.1); + border-radius: 999px; + background: rgba(255, 252, 244, 0.92); + color: #2f6f57; + font-size: 12px; + font-weight: 600; + line-height: 1.4; +} + +.mindspace-achievements-pagination span { + font-size: 12px; +} + +.mindspace-achievements-groups { + display: flex; + flex-direction: column; + gap: 22px; +} + +.mindspace-achievements-group h3 { + margin: 0; + font-size: 16px; + font-weight: 700; + color: #18211d; +} + +.mindspace-achievements-group-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.mindspace-achievements-group-select { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: rgba(24, 33, 29, 0.72); + cursor: pointer; + user-select: none; +} + +.mindspace-achievements-group-select input { + width: 14px; + height: 14px; + accent-color: #2f6f57; + cursor: pointer; +} + +.mindspace-achievements-col-select { + width: 52px; + text-align: center; +} + +.mindspace-achievements-col-select input { + width: 14px; + height: 14px; + accent-color: #2f6f57; + cursor: pointer; +} + +.mindspace-achievements-delete-link { + padding: 0; + border: 0; + background: transparent; + color: #9b3d2f; + font-size: 12px; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.mindspace-achievements-delete-link:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.mindspace-achievements-load-more { + display: flex; + justify-content: center; + padding: 18px 0 8px; + color: rgba(24, 33, 29, 0.62); + font-size: 13px; +} + +.mindspace-achievements-load-more button { + padding: 8px 14px; + border: 1px solid rgba(47, 111, 87, 0.24); + border-radius: 999px; + background: rgba(235, 248, 240, 0.8); + color: #1f4f3d; + cursor: pointer; +} + +.mindspace-achievements-table-wrap { + overflow-x: auto; + border: 1px solid rgba(24, 33, 29, 0.08); + border-radius: 16px; + background: rgba(255, 252, 244, 0.92); +} + +.mindspace-achievements-table { + width: 100%; + min-width: 760px; + border-collapse: collapse; + font-size: 13px; +} + +.mindspace-achievements-table th, +.mindspace-achievements-table td { + padding: 12px 14px; + border-bottom: 1px solid rgba(24, 33, 29, 0.08); + text-align: left; + vertical-align: top; +} + +.mindspace-achievements-table th { + color: rgba(24, 33, 29, 0.62); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + background: rgba(245, 240, 229, 0.72); +} + +.mindspace-achievements-table tbody tr:last-child td { + border-bottom: none; +} + +.mindspace-achievements-title { + min-width: 220px; +} + +.mindspace-achievements-title-link { + display: inline; + padding: 0; + border: 0; + background: transparent; + color: #2f6f57; + font: inherit; + font-weight: 700; + cursor: pointer; + text-align: left; + text-decoration: underline; + text-underline-offset: 2px; +} + +.mindspace-achievements-title-link:hover { + color: #1f4f3d; +} + +.mindspace-achievements-detail-link { + display: inline-block; + margin-left: 10px; + padding: 0; + border: 0; + background: transparent; + color: rgba(24, 33, 29, 0.56); + font-size: 12px; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.mindspace-card-achievements { + border-color: rgba(47, 111, 87, 0.18); + background: linear-gradient(180deg, rgba(235, 248, 240, 0.92), rgba(255, 252, 244, 0.96)); +} + .mindspace-all-pages .mindspace-feed-card-wrap { display: flex; flex-direction: column; diff --git a/src/routes/MindSpaceRoute.tsx b/src/routes/MindSpaceRoute.tsx index 322ce00..b5f0de9 100644 --- a/src/routes/MindSpaceRoute.tsx +++ b/src/routes/MindSpaceRoute.tsx @@ -20,6 +20,7 @@ export function MindSpaceRoute({ const location = useLocation(); const [searchParams] = useSearchParams(); const pageMatch = matchPath('/space/page/:pageId', location.pathname); + const achievementsMatch = matchPath('/space/achievements', location.pathname); const pageId = pageMatch?.params.pageId ?? null; const categoryCode = parseCategory(searchParams.get('category')); @@ -28,11 +29,13 @@ export function MindSpaceRoute({ user={user} initialPageId={pageId} initialCategoryCode={categoryCode} + initialAchievementsOpen={Boolean(achievementsMatch)} onBack={() => navigate('/')} onLogout={onLogout} onOpenFeedback={() => navigate('/feedback')} routeSync={{ pushHome: () => navigate('/space'), + pushAchievements: () => navigate('/space/achievements'), pushCategory: (code) => navigate(`/space?category=${code}`), pushPage: (id) => navigate(`/space/page/${id}`), }} diff --git a/src/types.ts b/src/types.ts index 545c3ad..ae51348 100644 --- a/src/types.ts +++ b/src/types.ts @@ -870,6 +870,12 @@ export type MindSpacePage = { status: 'draft' | 'reviewing' | 'risk_found' | 'ready' | 'published' | 'protected' | 'expired' | 'offline'; visibility: 'private' | 'internal' | 'public'; publicationAccessMode?: 'public' | 'password' | 'private_link' | 'time_limited' | 'login_required' | 'owner_only' | null; + publicationId?: string | null; + publicationStatus?: string | null; + viewCount?: number; + clickCount?: number | null; + statsSource?: 'none' | 'publication' | 'umami'; + publishedAt?: number | null; publicationUrl?: string | null; workspaceRelativePath?: string | null; workspacePublicUrl?: string | null;