feat(mindspace): add achievements showcase with paginated management
Memind CI / Test, build, and release guards (push) Successful in 3m28s

Ship the M成果 page with list/delete UX, fix deletePage for incomplete page records, route /space/achievements, and add M成果 under the WeChat M空间 menu.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-12 15:21:42 +08:00
parent e9cecf8733
commit 421e204711
21 changed files with 1405 additions and 29 deletions
+73 -8
View File
@@ -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,