From ed8bc6d1b147c290cf3cef537ae44bc77db0d790 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 10 Aug 2026 08:06:21 +0800 Subject: [PATCH] feat(admin): add MindSpace SEO/GEO controls and template catalog page Expose SEO/GEO switches on the MindSpace config form and add template catalog management UI wired to shared Memind modules. Co-authored-by: Cursor --- package.json | 1 + scripts/verify-template-catalog-local.mjs | 128 +++++++++++ server/app.mjs | 29 +++ server/bootstrap.mjs | 7 + server/index.mjs | 2 + src/App.tsx | 3 + src/admin/AdminNav.tsx | 1 + src/admin/pages/MindSpacePage.tsx | 146 ++++++++++-- src/admin/pages/TemplateCatalogPage.tsx | 267 ++++++++++++++++++++++ src/admin/pages/UserDetailPage.tsx | 79 ++++++- src/api/client.ts | 24 ++ src/index.css | 57 +++++ src/types.ts | 40 ++++ 13 files changed, 768 insertions(+), 16 deletions(-) create mode 100644 scripts/verify-template-catalog-local.mjs create mode 100644 src/admin/pages/TemplateCatalogPage.tsx diff --git a/package.json b/package.json index 5dd95eb..efb3247 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "pro_restart": "bash scripts/pro_restart.sh", "build": "vite build", "test:orchestrator": "node --test server/orchestrator-routes.test.mjs", + "verify:template-catalog-local": "node scripts/verify-template-catalog-local.mjs", "preview": "node scripts/preview.mjs", "dev:preview": "vite preview" }, diff --git a/scripts/verify-template-catalog-local.mjs b/scripts/verify-template-catalog-local.mjs new file mode 100644 index 0000000..c05f6d3 --- /dev/null +++ b/scripts/verify-template-catalog-local.mjs @@ -0,0 +1,128 @@ +#!/usr/bin/env node +/** + * Local smoke test for page template catalog admin API (memind_adm 8085). + * Usage: node scripts/verify-template-catalog-local.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); +const base = process.env.ADM_DEV_BACKEND ?? 'http://127.0.0.1:8085'; + +function loadEnvFile(filePath) { + if (!fs.existsSync(filePath)) return; + for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const eq = trimmed.indexOf('='); + if (eq <= 0) continue; + const key = trimmed.slice(0, eq).trim(); + let value = trimmed.slice(eq + 1).trim(); + if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (!(key in process.env)) process.env[key] = value; + } +} + +loadEnvFile(path.join(root, '.env')); + +const username = process.env.H5_ADMIN_USERNAME?.trim() || 'admin'; +const password = process.env.H5_ADMIN_PASSWORD?.trim() || process.env.ADMIN_PASSWORD?.trim() || ''; + +async function request(method, urlPath, { body, cookie } = {}) { + const res = await fetch(`${base}${urlPath}`, { + method, + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(cookie ? { Cookie: cookie } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + const text = await res.text(); + let json = null; + try { + json = text ? JSON.parse(text) : null; + } catch { + json = { raw: text.slice(0, 300) }; + } + return { status: res.status, json, setCookie: res.headers.getSetCookie?.() ?? [] }; +} + +function cookieFromSetCookie(setCookie) { + return setCookie.map((c) => c.split(';')[0]).join('; '); +} + +function assertOk(cond, msg) { + if (!cond) throw new Error(msg); +} + +async function main() { + console.log(`==> Admin API base: ${base}`); + + if (!password) { + console.error('缺少 H5_ADMIN_PASSWORD(请在 memind_adm/.env 配置)'); + process.exit(1); + } + + const login = await request('POST', '/auth/login', { + body: { username, password }, + }); + assertOk(login.status === 200, `登录失败 HTTP ${login.status}: ${JSON.stringify(login.json)}`); + const cookie = cookieFromSetCookie(login.setCookie); + assertOk(cookie, '登录未返回 session cookie'); + console.log(`✓ 管理员登录 (${username})`); + + const catalog = await request('GET', '/admin-api/template-catalog', { cookie }); + assertOk(catalog.status === 200, `template-catalog HTTP ${catalog.status}: ${JSON.stringify(catalog.json)}`); + assertOk(Array.isArray(catalog.json?.items), 'items 应为数组'); + assertOk(catalog.json.items.length >= 3, `应至少 seed 3 个 page-template skill,实际 ${catalog.json.items.length}`); + const travel = catalog.json.items.find((item) => item.skillName === 'page-template-travel'); + assertOk(travel, '缺少 page-template-travel'); + console.log( + `✓ GET /admin-api/template-catalog (${catalog.json.items.length} 项,travel=${travel.priceCents} 分)`, + ); + + const originalSort = travel.sortOrder ?? 10; + const patchedSort = originalSort === 11 ? 12 : 11; + const update = await request('PUT', '/admin-api/template-catalog/page-template-travel', { + cookie, + body: { sortOrder: patchedSort }, + }); + assertOk(update.status === 200, `update HTTP ${update.status}: ${JSON.stringify(update.json)}`); + assertOk(update.json?.item?.sortOrder === patchedSort, 'sortOrder 未更新'); + console.log(`✓ PUT sortOrder ${originalSort} → ${patchedSort}`); + + const restore = await request('PUT', '/admin-api/template-catalog/page-template-travel', { + cookie, + body: { sortOrder: originalSort }, + }); + assertOk(restore.status === 200, `restore HTTP ${restore.status}`); + console.log(`✓ PUT sortOrder 恢复为 ${originalSort}`); + + const users = await request('GET', '/admin-api/users?page=1&pageSize=5&role=user', { cookie }); + assertOk(users.status === 200, `users HTTP ${users.status}`); + const sampleUser = users.json?.users?.[0]; + assertOk(sampleUser?.id, '需要至少一个 user 账号做授权测试'); + + const grant = await request( + 'POST', + `/admin-api/users/${sampleUser.id}/template-grants/page-template-travel`, + { cookie }, + ); + assertOk(grant.status === 200, `grant HTTP ${grant.status}: ${JSON.stringify(grant.json)}`); + assertOk(grant.json?.ok === true, 'grant 应返回 ok:true'); + assertOk( + Array.isArray(grant.json?.grantedSkills) && grant.json.grantedSkills.includes('page-template-travel'), + 'grant 应包含 page-template-travel', + ); + console.log(`✓ POST template-grants → user ${sampleUser.username}`); + + console.log('\n全部 template-catalog Admin API 联调通过。'); +} + +main().catch((err) => { + console.error('\n联调失败:', err.message); + process.exit(1); +}); diff --git a/server/app.mjs b/server/app.mjs index 497c61b..7f32749 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -111,6 +111,7 @@ export function createAdminApp(services) { wordFilterService, planCatalogService, subscriptionService, + templateCatalogService, planSyncService, } = services; const app = express(); @@ -377,6 +378,7 @@ export function createAdminApp(services) { if (!updateMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' }); const result = await updateMindSpaceConfig(pool, { publicPageLimit: req.body?.publicPageLimit, + seoGeo: req.body?.seoGeo, analytics: req.body?.analytics, }); res.json({ config: result }); @@ -1317,6 +1319,33 @@ export function createAdminApp(services) { res.json(result); }); + // ── Page template catalog ───────────────────────────────────────────────── + + adminApi.get('/template-catalog', requireAdmin, async (_req, res) => { + if (!templateCatalogService?.adminListCatalog) { + return res.status(503).json({ message: '模板商城未启用' }); + } + res.json({ items: await templateCatalogService.adminListCatalog() }); + }); + + adminApi.put('/template-catalog/:skillName', requireAdmin, async (req, res) => { + if (!templateCatalogService?.adminUpsertCatalog) { + return res.status(503).json({ message: '模板商城未启用' }); + } + const result = await templateCatalogService.adminUpsertCatalog(req.params.skillName, req.body ?? {}); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.post('/users/:userId/template-grants/:skillName', requireAdmin, async (req, res) => { + if (!templateCatalogService?.adminGrantTemplate) { + return res.status(503).json({ message: '模板商城未启用' }); + } + const result = await templateCatalogService.adminGrantTemplate(req.params.userId, req.params.skillName); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + app.use('/admin-api', adminApi); if (services.createOpsApi) { diff --git a/server/bootstrap.mjs b/server/bootstrap.mjs index e7e70aa..74bf002 100644 --- a/server/bootstrap.mjs +++ b/server/bootstrap.mjs @@ -165,6 +165,12 @@ export async function bootstrapAdminServices() { getPlanAsync: (planType) => planCatalogService.getPlan(planType), }); subscriptionService._planCatalogService = planCatalogService; + const { createPageTemplateCatalogService } = await importMemind('page-template-catalog.mjs'); + const templateCatalogService = createPageTemplateCatalogService(pool, { + userAuth, + h5Root, + }); + await templateCatalogService.ensureReady(); await ensureSystemTestAccountSchema(pool); const systemTestAccountService = createSystemTestAccountService(pool); @@ -196,6 +202,7 @@ export async function bootstrapAdminServices() { wordFilterService, planCatalogService, subscriptionService, + templateCatalogService, USER_COOKIE, userLoginCookies, clearUserSessionCookie, diff --git a/server/index.mjs b/server/index.mjs index 1d22725..a0c7ff2 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -117,6 +117,7 @@ ready wordFilterService, planCatalogService, subscriptionService, + templateCatalogService, planSyncService, USER_COOKIE, userLoginCookies, @@ -146,6 +147,7 @@ ready wordFilterService, planCatalogService, subscriptionService, + templateCatalogService, planSyncService, USER_COOKIE, userLoginCookies, diff --git a/src/App.tsx b/src/App.tsx index 4401813..0bf78cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import { MindSearchPage } from './admin/pages/MindSearchPage'; import { ProvidersPage } from './admin/pages/ProvidersPage'; import { SkillsPage } from './admin/pages/SkillsPage'; import { SystemTestsPage } from './admin/pages/SystemTestsPage'; +import { TemplateCatalogPage } from './admin/pages/TemplateCatalogPage'; import { UserDetailPage } from './admin/pages/UserDetailPage'; import { UsersPage } from './admin/pages/UsersPage'; import { WechatPage } from './admin/pages/WechatPage'; @@ -121,6 +122,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void } } /> } /> } /> + } /> } /> } /> } /> @@ -173,6 +175,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) { pathname.startsWith('/users') || pathname.startsWith('/billing') || pathname.startsWith('/image-quota') + || pathname.startsWith('/template-catalog') || pathname.startsWith('/capabilities') || pathname.startsWith('/skills') || pathname.startsWith('/system-tests') diff --git a/src/admin/AdminNav.tsx b/src/admin/AdminNav.tsx index b6ef962..8ce78d1 100644 --- a/src/admin/AdminNav.tsx +++ b/src/admin/AdminNav.tsx @@ -24,6 +24,7 @@ const NAV_SECTIONS: NavSection[] = [ items: [ { to: '/billing', label: '计费中心', end: false }, { to: '/image-quota', label: '图片额度' }, + { to: '/template-catalog', label: '页面模板' }, ], }, { diff --git a/src/admin/pages/MindSpacePage.tsx b/src/admin/pages/MindSpacePage.tsx index b8f5bf2..f9e1731 100644 --- a/src/admin/pages/MindSpacePage.tsx +++ b/src/admin/pages/MindSpacePage.tsx @@ -1,14 +1,37 @@ import { useCallback, useEffect, useState, type FormEvent } from 'react'; import { getMindSpaceAdminConfig, updateMindSpaceAdminConfig } from '../../api/client'; -import type { MindSpaceAdminConfig } from '../../types'; +import type { MindSpaceAdminConfig, MindSpaceSeoGeoConfig } from '../../types'; + +function defaultSeoGeoConfig(): MindSpaceSeoGeoConfig { + return { + enabled: false, + seo: { + enabled: false, + canonical: true, + sitemap: false, + robotsTxt: false, + baiduPush: false, + }, + geo: { + enabled: false, + jsonLd: false, + llmsTxt: false, + }, + }; +} function safeConfig(config: MindSpaceAdminConfig | null): MindSpaceAdminConfig { - return config ?? { publicPageLimit: 10, analytics: { enabled: false, websiteId: '', analyticsUrl: 'http://127.0.0.1:3100', domains: '127.0.0.1,localhost', idSecretConfigured: false } }; + return config ?? { + publicPageLimit: 10, + seoGeo: defaultSeoGeoConfig(), + analytics: { enabled: false, websiteId: '', analyticsUrl: 'http://127.0.0.1:3100', domains: '127.0.0.1,localhost', idSecretConfigured: false }, + }; } export function MindSpacePage() { const [config, setConfig] = useState(null); const [limit, setLimit] = useState('10'); + const [seoGeo, setSeoGeo] = useState(defaultSeoGeoConfig()); const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); @@ -22,6 +45,7 @@ export function MindSpacePage() { const nextConfig = await getMindSpaceAdminConfig(); setConfig(nextConfig); setLimit(String(nextConfig.publicPageLimit)); + setSeoGeo(nextConfig.seoGeo ?? defaultSeoGeoConfig()); } catch (err) { setError(err instanceof Error ? err.message : '加载 MindSpace 配置失败'); } finally { @@ -39,10 +63,14 @@ export function MindSpacePage() { setError(null); setMessage(null); try { - const nextConfig = await updateMindSpaceAdminConfig({ publicPageLimit: Number(limit) }); + const nextConfig = await updateMindSpaceAdminConfig({ + publicPageLimit: Number(limit), + seoGeo, + }); setConfig(nextConfig); setLimit(String(nextConfig.publicPageLimit)); - setMessage(`已更新为 ${nextConfig.publicPageLimit},主站会立即按新值校验。`); + setSeoGeo(nextConfig.seoGeo ?? defaultSeoGeoConfig()); + setMessage(`已更新 MindSpace 配置,SEO/GEO 总开关:${nextConfig.seoGeo?.enabled ? '开启' : '关闭'}。`); } catch (err) { setError(err instanceof Error ? err.message : '保存 MindSpace 配置失败'); } finally { @@ -52,6 +80,15 @@ export function MindSpacePage() { const current = safeConfig(config); + const updateSeoGeo = (patch: Partial) => { + setSeoGeo((currentSeoGeo) => ({ + ...currentSeoGeo, + ...patch, + seo: { ...currentSeoGeo.seo, ...(patch.seo ?? {}) }, + geo: { ...currentSeoGeo.geo, ...(patch.geo ?? {}) }, + })); + }; + return (
@@ -62,12 +99,12 @@ export function MindSpacePage() { {error &&

{error}

} {message &&

{message}

} +

公开页面上限

当前值:{loading ? '—' : current.publicPageLimit}。建议在修改后同步检查主站发布流程。

- -
- - -
-
+ +
+

SEO / GEO

+

+ 仅对「已确认公开、access_mode=public、status=online」的发布页生效。私有、密码、登录可见或未确认页面会强制 noindex,不会进入 sitemap / llms.txt。 +

+
+ + + + + + + + +
+
+
+ + +
+
); } diff --git a/src/admin/pages/TemplateCatalogPage.tsx b/src/admin/pages/TemplateCatalogPage.tsx new file mode 100644 index 0000000..679cee8 --- /dev/null +++ b/src/admin/pages/TemplateCatalogPage.tsx @@ -0,0 +1,267 @@ +import { useEffect, useMemo, useState } from 'react'; +import { fetchAdminTemplateCatalog, updateAdminTemplateCatalogItem } from '../../api/client'; +import type { AdminTemplateCatalogItem } from '../../types'; + +type DraftRow = { + label: string; + description: string; + previewUrl: string; + priceYuan: string; + billingMode: AdminTemplateCatalogItem['billingMode']; + status: AdminTemplateCatalogItem['status']; + sortOrder: string; +}; + +const STATUS_LABELS: Record = { + draft: '草稿', + active: '上架', + archived: '下架', +}; + +const BILLING_LABELS: Record = { + free: '免费', + one_time: '一次性', + subscription: '订阅', +}; + +function toDraft(item: AdminTemplateCatalogItem): DraftRow { + return { + label: item.label, + description: item.description ?? '', + previewUrl: item.previewUrl ?? '', + priceYuan: item.priceCents > 0 ? (item.priceCents / 100).toFixed(item.priceCents % 100 === 0 ? 0 : 2) : '0', + billingMode: item.billingMode, + status: item.status, + sortOrder: String(item.sortOrder ?? 0), + }; +} + +function resolvePreviewSrc(previewUrl: string | null | undefined) { + if (!previewUrl) return null; + if (/^https?:\/\//i.test(previewUrl)) return previewUrl; + const portalOrigin = import.meta.env.VITE_PORTAL_ORIGIN?.trim() || 'http://127.0.0.1:5173'; + return `${portalOrigin.replace(/\/$/, '')}${previewUrl.startsWith('/') ? previewUrl : `/${previewUrl}`}`; +} + +export function TemplateCatalogPage() { + const [items, setItems] = useState([]); + const [drafts, setDrafts] = useState>({}); + const [loading, setLoading] = useState(false); + const [busySkill, setBusySkill] = useState(null); + const [error, setError] = useState(null); + const [message, setMessage] = useState(null); + + const portalPreviewBase = + import.meta.env.VITE_PORTAL_ORIGIN?.trim()?.replace(/\/$/, '') || 'http://127.0.0.1:5173'; + + const load = async () => { + setLoading(true); + setError(null); + try { + const catalog = await fetchAdminTemplateCatalog(); + setItems(catalog); + setDrafts(Object.fromEntries(catalog.map((item) => [item.skillName, toDraft(item)]))); + } catch (err) { + setError(err instanceof Error ? err.message : '加载模板目录失败'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const sortedItems = useMemo( + () => [...items].sort((a, b) => a.sortOrder - b.sortOrder || a.label.localeCompare(b.label, 'zh-CN')), + [items], + ); + + const updateDraft = (skillName: string, patch: Partial) => { + setDrafts((current) => ({ + ...current, + [skillName]: { ...(current[skillName] ?? toDraft(items.find((item) => item.skillName === skillName)!)), ...patch }, + })); + }; + + const saveItem = async (skillName: string) => { + const draft = drafts[skillName]; + if (!draft) return; + const priceYuan = Number(draft.priceYuan); + const sortOrder = Number(draft.sortOrder); + if (!Number.isFinite(priceYuan) || priceYuan < 0) { + setError('价格必须是非负数'); + return; + } + if (!Number.isFinite(sortOrder)) { + setError('排序必须是数字'); + return; + } + setBusySkill(skillName); + setError(null); + setMessage(null); + try { + const result = await updateAdminTemplateCatalogItem(skillName, { + label: draft.label.trim(), + description: draft.description.trim(), + previewUrl: draft.previewUrl.trim() || null, + priceCents: Math.round(priceYuan * 100), + billingMode: draft.billingMode, + status: draft.status, + sortOrder: Math.floor(sortOrder), + }); + setMessage(`已保存「${result.item?.label ?? skillName}」`); + await load(); + } catch (err) { + setError(err instanceof Error ? err.message : '保存失败'); + } finally { + setBusySkill(null); + } + }; + + return ( +
+
+

页面模板商城

+

+ 管理 Portal 聊天页「模板商城」中的可定价 page-template skill:改价、上下架、封面与排序。用户授权请在「用户管理 → + 用户详情」中操作。 +

+
+ + {error &&

{error}

} + {message &&

{message}

} + +
+ {loading && items.length === 0 ? ( +

加载中…

+ ) : sortedItems.length === 0 ? ( +

暂无模板条目。请确认 Memind 已 seed page-template-* skill。

+ ) : ( +
+ + + + + + + + + + + + + + + {sortedItems.map((item) => { + const draft = drafts[item.skillName] ?? toDraft(item); + const previewSrc = resolvePreviewSrc(draft.previewUrl || item.previewUrl); + const livePreviewUrl = `${portalPreviewBase}/api/mindspace/v1/template-catalog/${encodeURIComponent(item.skillName)}/preview`; + return ( + + + +
封面Skill / 名称描述价格(元)计费状态排序预览 URL +
+ {previewSrc ? ( + + ) : ( + +
{item.skillName}
+ updateDraft(item.skillName, { label: event.target.value })} + /> +
+