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 (
+
+ );
+ })}
+
+
+ {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;