Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed8bc6d1b1 | |||
| 7120d4b5ea |
@@ -13,6 +13,7 @@
|
|||||||
"pro_restart": "bash scripts/pro_restart.sh",
|
"pro_restart": "bash scripts/pro_restart.sh",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"test:orchestrator": "node --test server/orchestrator-routes.test.mjs",
|
"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",
|
"preview": "node scripts/preview.mjs",
|
||||||
"dev:preview": "vite preview"
|
"dev:preview": "vite preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -111,6 +111,7 @@ export function createAdminApp(services) {
|
|||||||
wordFilterService,
|
wordFilterService,
|
||||||
planCatalogService,
|
planCatalogService,
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
|
templateCatalogService,
|
||||||
planSyncService,
|
planSyncService,
|
||||||
} = services;
|
} = services;
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -377,6 +378,7 @@ export function createAdminApp(services) {
|
|||||||
if (!updateMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' });
|
if (!updateMindSpaceConfig) return res.status(503).json({ message: 'MindSpace 配置未启用' });
|
||||||
const result = await updateMindSpaceConfig(pool, {
|
const result = await updateMindSpaceConfig(pool, {
|
||||||
publicPageLimit: req.body?.publicPageLimit,
|
publicPageLimit: req.body?.publicPageLimit,
|
||||||
|
seoGeo: req.body?.seoGeo,
|
||||||
analytics: req.body?.analytics,
|
analytics: req.body?.analytics,
|
||||||
});
|
});
|
||||||
res.json({ config: result });
|
res.json({ config: result });
|
||||||
@@ -1317,6 +1319,33 @@ export function createAdminApp(services) {
|
|||||||
res.json(result);
|
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);
|
app.use('/admin-api', adminApi);
|
||||||
|
|
||||||
if (services.createOpsApi) {
|
if (services.createOpsApi) {
|
||||||
|
|||||||
@@ -165,6 +165,12 @@ export async function bootstrapAdminServices() {
|
|||||||
getPlanAsync: (planType) => planCatalogService.getPlan(planType),
|
getPlanAsync: (planType) => planCatalogService.getPlan(planType),
|
||||||
});
|
});
|
||||||
subscriptionService._planCatalogService = planCatalogService;
|
subscriptionService._planCatalogService = planCatalogService;
|
||||||
|
const { createPageTemplateCatalogService } = await importMemind('page-template-catalog.mjs');
|
||||||
|
const templateCatalogService = createPageTemplateCatalogService(pool, {
|
||||||
|
userAuth,
|
||||||
|
h5Root,
|
||||||
|
});
|
||||||
|
await templateCatalogService.ensureReady();
|
||||||
await ensureSystemTestAccountSchema(pool);
|
await ensureSystemTestAccountSchema(pool);
|
||||||
const systemTestAccountService = createSystemTestAccountService(pool);
|
const systemTestAccountService = createSystemTestAccountService(pool);
|
||||||
|
|
||||||
@@ -196,6 +202,7 @@ export async function bootstrapAdminServices() {
|
|||||||
wordFilterService,
|
wordFilterService,
|
||||||
planCatalogService,
|
planCatalogService,
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
|
templateCatalogService,
|
||||||
USER_COOKIE,
|
USER_COOKIE,
|
||||||
userLoginCookies,
|
userLoginCookies,
|
||||||
clearUserSessionCookie,
|
clearUserSessionCookie,
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ ready
|
|||||||
wordFilterService,
|
wordFilterService,
|
||||||
planCatalogService,
|
planCatalogService,
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
|
templateCatalogService,
|
||||||
planSyncService,
|
planSyncService,
|
||||||
USER_COOKIE,
|
USER_COOKIE,
|
||||||
userLoginCookies,
|
userLoginCookies,
|
||||||
@@ -146,6 +147,7 @@ ready
|
|||||||
wordFilterService,
|
wordFilterService,
|
||||||
planCatalogService,
|
planCatalogService,
|
||||||
subscriptionService,
|
subscriptionService,
|
||||||
|
templateCatalogService,
|
||||||
planSyncService,
|
planSyncService,
|
||||||
USER_COOKIE,
|
USER_COOKIE,
|
||||||
userLoginCookies,
|
userLoginCookies,
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export async function listUsagePaged(pool, query) {
|
|||||||
);
|
);
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT r.id, r.user_id, u.username, u.display_name, r.agent_session_id, r.request_id,
|
`SELECT r.id, r.user_id, u.username, u.display_name, r.agent_session_id, r.request_id,
|
||||||
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.created_at
|
r.input_tokens, r.output_tokens, r.cost_cents, r.balance_after_cents, r.billing_source, r.created_at
|
||||||
FROM h5_usage_records r
|
FROM h5_usage_records r
|
||||||
JOIN h5_users u ON u.id = r.user_id
|
JOIN h5_users u ON u.id = r.user_id
|
||||||
${where}
|
${where}
|
||||||
@@ -176,6 +176,7 @@ export async function listUsagePaged(pool, query) {
|
|||||||
outputTokens: Number(row.output_tokens),
|
outputTokens: Number(row.output_tokens),
|
||||||
costCents: Number(row.cost_cents),
|
costCents: Number(row.cost_cents),
|
||||||
balanceAfterCents: Number(row.balance_after_cents),
|
balanceAfterCents: Number(row.balance_after_cents),
|
||||||
|
billingSource: row.billing_source ?? 'wallet',
|
||||||
createdAt: Number(row.created_at),
|
createdAt: Number(row.created_at),
|
||||||
})),
|
})),
|
||||||
total: Number(total),
|
total: Number(total),
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { MindSearchPage } from './admin/pages/MindSearchPage';
|
|||||||
import { ProvidersPage } from './admin/pages/ProvidersPage';
|
import { ProvidersPage } from './admin/pages/ProvidersPage';
|
||||||
import { SkillsPage } from './admin/pages/SkillsPage';
|
import { SkillsPage } from './admin/pages/SkillsPage';
|
||||||
import { SystemTestsPage } from './admin/pages/SystemTestsPage';
|
import { SystemTestsPage } from './admin/pages/SystemTestsPage';
|
||||||
|
import { TemplateCatalogPage } from './admin/pages/TemplateCatalogPage';
|
||||||
import { UserDetailPage } from './admin/pages/UserDetailPage';
|
import { UserDetailPage } from './admin/pages/UserDetailPage';
|
||||||
import { UsersPage } from './admin/pages/UsersPage';
|
import { UsersPage } from './admin/pages/UsersPage';
|
||||||
import { WechatPage } from './admin/pages/WechatPage';
|
import { WechatPage } from './admin/pages/WechatPage';
|
||||||
@@ -121,6 +122,7 @@ function AdminApp({ user, onLogout }: { user: PortalUser; onLogout: () => void }
|
|||||||
<Route path="users/:userId" element={<UserDetailPage />} />
|
<Route path="users/:userId" element={<UserDetailPage />} />
|
||||||
<Route path="billing/*" element={<BillingPage />} />
|
<Route path="billing/*" element={<BillingPage />} />
|
||||||
<Route path="image-quota" element={<ImageQuotaPage />} />
|
<Route path="image-quota" element={<ImageQuotaPage />} />
|
||||||
|
<Route path="template-catalog" element={<TemplateCatalogPage />} />
|
||||||
<Route path="capabilities" element={<CapabilitiesPage />} />
|
<Route path="capabilities" element={<CapabilitiesPage />} />
|
||||||
<Route path="skills" element={<SkillsPage />} />
|
<Route path="skills" element={<SkillsPage />} />
|
||||||
<Route path="system-tests" element={<SystemTestsPage />} />
|
<Route path="system-tests" element={<SystemTestsPage />} />
|
||||||
@@ -173,6 +175,7 @@ function loginRedirectPath(pathname: string, role: string | undefined) {
|
|||||||
pathname.startsWith('/users')
|
pathname.startsWith('/users')
|
||||||
|| pathname.startsWith('/billing')
|
|| pathname.startsWith('/billing')
|
||||||
|| pathname.startsWith('/image-quota')
|
|| pathname.startsWith('/image-quota')
|
||||||
|
|| pathname.startsWith('/template-catalog')
|
||||||
|| pathname.startsWith('/capabilities')
|
|| pathname.startsWith('/capabilities')
|
||||||
|| pathname.startsWith('/skills')
|
|| pathname.startsWith('/skills')
|
||||||
|| pathname.startsWith('/system-tests')
|
|| pathname.startsWith('/system-tests')
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const NAV_SECTIONS: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: '/billing', label: '计费中心', end: false },
|
{ to: '/billing', label: '计费中心', end: false },
|
||||||
{ to: '/image-quota', label: '图片额度' },
|
{ to: '/image-quota', label: '图片额度' },
|
||||||
|
{ to: '/template-catalog', label: '页面模板' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -450,6 +450,11 @@ function UsageRecordsTable({
|
|||||||
result: PagedResult<UsageRecord>;
|
result: PagedResult<UsageRecord>;
|
||||||
onPage: (page: number) => void;
|
onPage: (page: number) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const formatBillingSource = (row: UsageRecord) => {
|
||||||
|
if (row.billingSource === 'subscription') return '套餐额度';
|
||||||
|
return `¥${formatYuan(row.costCents)}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="admin-table-wrap">
|
<div className="admin-table-wrap">
|
||||||
@@ -474,8 +479,10 @@ function UsageRecordsTable({
|
|||||||
</td>
|
</td>
|
||||||
<td className="billing-num">{row.inputTokens.toLocaleString()}</td>
|
<td className="billing-num">{row.inputTokens.toLocaleString()}</td>
|
||||||
<td className="billing-num">{row.outputTokens.toLocaleString()}</td>
|
<td className="billing-num">{row.outputTokens.toLocaleString()}</td>
|
||||||
<td className="billing-num">¥{formatYuan(row.costCents)}</td>
|
<td className="billing-num">{formatBillingSource(row)}</td>
|
||||||
<td className="billing-num">¥{formatYuan(row.balanceAfterCents)}</td>
|
<td className="billing-num">
|
||||||
|
{row.billingSource === 'subscription' ? '—' : `¥${formatYuan(row.balanceAfterCents)}`}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -238,7 +238,9 @@ export function DashboardPage() {
|
|||||||
<td>
|
<td>
|
||||||
in {row.inputTokens} / out {row.outputTokens}
|
in {row.inputTokens} / out {row.outputTokens}
|
||||||
</td>
|
</td>
|
||||||
<td>¥{formatYuan(row.costCents)}</td>
|
<td>
|
||||||
|
{row.billingSource === 'subscription' ? '套餐额度' : `¥${formatYuan(row.costCents)}`}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,14 +1,37 @@
|
|||||||
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
import { useCallback, useEffect, useState, type FormEvent } from 'react';
|
||||||
import { getMindSpaceAdminConfig, updateMindSpaceAdminConfig } from '../../api/client';
|
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 {
|
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() {
|
export function MindSpacePage() {
|
||||||
const [config, setConfig] = useState<MindSpaceAdminConfig | null>(null);
|
const [config, setConfig] = useState<MindSpaceAdminConfig | null>(null);
|
||||||
const [limit, setLimit] = useState('10');
|
const [limit, setLimit] = useState('10');
|
||||||
|
const [seoGeo, setSeoGeo] = useState<MindSpaceSeoGeoConfig>(defaultSeoGeoConfig());
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -22,6 +45,7 @@ export function MindSpacePage() {
|
|||||||
const nextConfig = await getMindSpaceAdminConfig();
|
const nextConfig = await getMindSpaceAdminConfig();
|
||||||
setConfig(nextConfig);
|
setConfig(nextConfig);
|
||||||
setLimit(String(nextConfig.publicPageLimit));
|
setLimit(String(nextConfig.publicPageLimit));
|
||||||
|
setSeoGeo(nextConfig.seoGeo ?? defaultSeoGeoConfig());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : '加载 MindSpace 配置失败');
|
setError(err instanceof Error ? err.message : '加载 MindSpace 配置失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -39,10 +63,14 @@ export function MindSpacePage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
try {
|
try {
|
||||||
const nextConfig = await updateMindSpaceAdminConfig({ publicPageLimit: Number(limit) });
|
const nextConfig = await updateMindSpaceAdminConfig({
|
||||||
|
publicPageLimit: Number(limit),
|
||||||
|
seoGeo,
|
||||||
|
});
|
||||||
setConfig(nextConfig);
|
setConfig(nextConfig);
|
||||||
setLimit(String(nextConfig.publicPageLimit));
|
setLimit(String(nextConfig.publicPageLimit));
|
||||||
setMessage(`已更新为 ${nextConfig.publicPageLimit},主站会立即按新值校验。`);
|
setSeoGeo(nextConfig.seoGeo ?? defaultSeoGeoConfig());
|
||||||
|
setMessage(`已更新 MindSpace 配置,SEO/GEO 总开关:${nextConfig.seoGeo?.enabled ? '开启' : '关闭'}。`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : '保存 MindSpace 配置失败');
|
setError(err instanceof Error ? err.message : '保存 MindSpace 配置失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -52,6 +80,15 @@ export function MindSpacePage() {
|
|||||||
|
|
||||||
const current = safeConfig(config);
|
const current = safeConfig(config);
|
||||||
|
|
||||||
|
const updateSeoGeo = (patch: Partial<MindSpaceSeoGeoConfig>) => {
|
||||||
|
setSeoGeo((currentSeoGeo) => ({
|
||||||
|
...currentSeoGeo,
|
||||||
|
...patch,
|
||||||
|
seo: { ...currentSeoGeo.seo, ...(patch.seo ?? {}) },
|
||||||
|
geo: { ...currentSeoGeo.geo, ...(patch.geo ?? {}) },
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-page">
|
<div className="admin-page">
|
||||||
<div className="admin-page-head">
|
<div className="admin-page-head">
|
||||||
@@ -62,12 +99,12 @@ export function MindSpacePage() {
|
|||||||
{error && <p className="banner banner-error">{error}</p>}
|
{error && <p className="banner banner-error">{error}</p>}
|
||||||
{message && <p className="banner banner-info">{message}</p>}
|
{message && <p className="banner banner-info">{message}</p>}
|
||||||
|
|
||||||
|
<form className="admin-form" onSubmit={handleSave}>
|
||||||
<section className="admin-card">
|
<section className="admin-card">
|
||||||
<h2>公开页面上限</h2>
|
<h2>公开页面上限</h2>
|
||||||
<p className="muted">
|
<p className="muted">
|
||||||
当前值:{loading ? '—' : current.publicPageLimit}。建议在修改后同步检查主站发布流程。
|
当前值:{loading ? '—' : current.publicPageLimit}。建议在修改后同步检查主站发布流程。
|
||||||
</p>
|
</p>
|
||||||
<form className="admin-form" onSubmit={handleSave}>
|
|
||||||
<label className="admin-form-row">
|
<label className="admin-form-row">
|
||||||
<span>每个用户最多可在线的公开页面数</span>
|
<span>每个用户最多可在线的公开页面数</span>
|
||||||
<input
|
<input
|
||||||
@@ -80,16 +117,97 @@ export function MindSpacePage() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className="admin-actions">
|
|
||||||
<button type="submit" className="send-btn" disabled={busy || loading}>
|
|
||||||
{busy ? '保存中...' : '保存配置'}
|
|
||||||
</button>
|
|
||||||
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}>
|
|
||||||
重新加载
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-card">
|
||||||
|
<h2>SEO / GEO</h2>
|
||||||
|
<p className="muted">
|
||||||
|
仅对「已确认公开、access_mode=public、status=online」的发布页生效。私有、密码、登录可见或未确认页面会强制 noindex,不会进入 sitemap / llms.txt。
|
||||||
|
</p>
|
||||||
|
<div className="admin-form">
|
||||||
|
<label className="admin-form-row">
|
||||||
|
<span>启用 SEO / GEO 总开关</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={seoGeo.enabled}
|
||||||
|
onChange={(e) => updateSeoGeo({ enabled: e.target.checked })}
|
||||||
|
disabled={loading || busy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-form-row">
|
||||||
|
<span>SEO:页面 meta + canonical 注入</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={seoGeo.seo.enabled}
|
||||||
|
onChange={(e) => updateSeoGeo({ seo: { ...seoGeo.seo, enabled: e.target.checked } })}
|
||||||
|
disabled={loading || busy || !seoGeo.enabled}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-form-row">
|
||||||
|
<span>SEO:sitemap.xml</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={seoGeo.seo.sitemap}
|
||||||
|
onChange={(e) => updateSeoGeo({ seo: { ...seoGeo.seo, sitemap: e.target.checked } })}
|
||||||
|
disabled={loading || busy || !seoGeo.enabled || !seoGeo.seo.enabled}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-form-row">
|
||||||
|
<span>SEO:robots.txt</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={seoGeo.seo.robotsTxt}
|
||||||
|
onChange={(e) => updateSeoGeo({ seo: { ...seoGeo.seo, robotsTxt: e.target.checked } })}
|
||||||
|
disabled={loading || busy || !seoGeo.enabled || !seoGeo.seo.enabled}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-form-row">
|
||||||
|
<span>SEO:百度主动推送</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={seoGeo.seo.baiduPush}
|
||||||
|
onChange={(e) => updateSeoGeo({ seo: { ...seoGeo.seo, baiduPush: e.target.checked } })}
|
||||||
|
disabled={loading || busy || !seoGeo.enabled || !seoGeo.seo.enabled}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-form-row">
|
||||||
|
<span>GEO:JSON-LD 结构化数据</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={seoGeo.geo.jsonLd}
|
||||||
|
onChange={(e) => updateSeoGeo({ geo: { ...seoGeo.geo, jsonLd: e.target.checked, enabled: e.target.checked || seoGeo.geo.llmsTxt } })}
|
||||||
|
disabled={loading || busy || !seoGeo.enabled}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-form-row">
|
||||||
|
<span>GEO:llms.txt(AI 搜索发现)</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={seoGeo.geo.llmsTxt}
|
||||||
|
onChange={(e) => updateSeoGeo({ geo: { ...seoGeo.geo, llmsTxt: e.target.checked, enabled: seoGeo.geo.jsonLd || e.target.checked } })}
|
||||||
|
disabled={loading || busy || !seoGeo.enabled}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="admin-form-row">
|
||||||
|
<span>GEO 子开关(结构化 + 发现层)</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={seoGeo.geo.enabled}
|
||||||
|
onChange={(e) => updateSeoGeo({ geo: { ...seoGeo.geo, enabled: e.target.checked } })}
|
||||||
|
disabled={loading || busy || !seoGeo.enabled}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className="admin-actions">
|
||||||
|
<button type="submit" className="send-btn" disabled={busy || loading}>
|
||||||
|
{busy ? '保存中...' : '保存配置'}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost-btn" onClick={() => void load()} disabled={busy}>
|
||||||
|
重新加载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<AdminTemplateCatalogItem['status'], string> = {
|
||||||
|
draft: '草稿',
|
||||||
|
active: '上架',
|
||||||
|
archived: '下架',
|
||||||
|
};
|
||||||
|
|
||||||
|
const BILLING_LABELS: Record<AdminTemplateCatalogItem['billingMode'], string> = {
|
||||||
|
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<AdminTemplateCatalogItem[]>([]);
|
||||||
|
const [drafts, setDrafts] = useState<Record<string, DraftRow>>({});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [busySkill, setBusySkill] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [message, setMessage] = useState<string | null>(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<DraftRow>) => {
|
||||||
|
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 (
|
||||||
|
<div className="admin-page">
|
||||||
|
<div className="admin-page-head">
|
||||||
|
<h2>页面模板商城</h2>
|
||||||
|
<p className="muted">
|
||||||
|
管理 Portal 聊天页「模板商城」中的可定价 page-template skill:改价、上下架、封面与排序。用户授权请在「用户管理 →
|
||||||
|
用户详情」中操作。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="banner banner-error">{error}</p>}
|
||||||
|
{message && <p className="banner banner-info">{message}</p>}
|
||||||
|
|
||||||
|
<section className="admin-card">
|
||||||
|
{loading && items.length === 0 ? (
|
||||||
|
<p className="muted">加载中…</p>
|
||||||
|
) : sortedItems.length === 0 ? (
|
||||||
|
<p className="muted">暂无模板条目。请确认 Memind 已 seed page-template-* skill。</p>
|
||||||
|
) : (
|
||||||
|
<div className="admin-table-wrap">
|
||||||
|
<table className="admin-table template-catalog-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>封面</th>
|
||||||
|
<th>Skill / 名称</th>
|
||||||
|
<th>描述</th>
|
||||||
|
<th>价格(元)</th>
|
||||||
|
<th>计费</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>排序</th>
|
||||||
|
<th>预览 URL</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{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 (
|
||||||
|
<tr key={item.skillName}>
|
||||||
|
<td>
|
||||||
|
{previewSrc ? (
|
||||||
|
<img src={previewSrc} alt="" className="template-catalog-thumb" loading="lazy" />
|
||||||
|
) : (
|
||||||
|
<div className="template-catalog-thumb template-catalog-thumb-fallback" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="mono">{item.skillName}</div>
|
||||||
|
<input
|
||||||
|
className="admin-inline-input"
|
||||||
|
value={draft.label}
|
||||||
|
onChange={(event) => updateDraft(item.skillName, { label: event.target.value })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<textarea
|
||||||
|
className="admin-inline-textarea"
|
||||||
|
rows={3}
|
||||||
|
value={draft.description}
|
||||||
|
onChange={(event) => updateDraft(item.skillName, { description: event.target.value })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="admin-inline-input admin-inline-input-narrow"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={draft.priceYuan}
|
||||||
|
onChange={(event) => updateDraft(item.skillName, { priceYuan: event.target.value })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
className="admin-inline-input"
|
||||||
|
value={draft.billingMode}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateDraft(item.skillName, {
|
||||||
|
billingMode: event.target.value as AdminTemplateCatalogItem['billingMode'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Object.entries(BILLING_LABELS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<select
|
||||||
|
className="admin-inline-input"
|
||||||
|
value={draft.status}
|
||||||
|
onChange={(event) =>
|
||||||
|
updateDraft(item.skillName, {
|
||||||
|
status: event.target.value as AdminTemplateCatalogItem['status'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{Object.entries(STATUS_LABELS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="admin-inline-input admin-inline-input-narrow"
|
||||||
|
inputMode="numeric"
|
||||||
|
value={draft.sortOrder}
|
||||||
|
onChange={(event) => updateDraft(item.skillName, { sortOrder: event.target.value })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
className="admin-inline-input"
|
||||||
|
placeholder="/assets/template-previews/..."
|
||||||
|
value={draft.previewUrl}
|
||||||
|
onChange={(event) => updateDraft(item.skillName, { previewUrl: event.target.value })}
|
||||||
|
/>
|
||||||
|
<a href={livePreviewUrl} target="_blank" rel="noreferrer" className="admin-inline-link">
|
||||||
|
在线预览
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="send-btn"
|
||||||
|
disabled={busySkill === item.skillName}
|
||||||
|
onClick={() => void saveItem(item.skillName)}
|
||||||
|
>
|
||||||
|
{busySkill === item.skillName ? '保存中…' : '保存'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, Navigate, useParams } from 'react-router-dom';
|
import { Link, Navigate, useParams } from 'react-router-dom';
|
||||||
import { getAdminUser, rechargeUser, updateAdminUser, fetchUserImageQuota, setUserImageQuota } from '../../api/client';
|
import { getAdminUser, rechargeUser, updateAdminUser, fetchUserImageQuota, setUserImageQuota, fetchAdminTemplateCatalog, grantUserPageTemplate } from '../../api/client';
|
||||||
import { CapabilitySettings } from '../../components/CapabilitySettings';
|
import { CapabilitySettings } from '../../components/CapabilitySettings';
|
||||||
import { PolicySettings } from '../../components/PolicySettings';
|
import { PolicySettings } from '../../components/PolicySettings';
|
||||||
import { SkillSettings } from '../../components/SkillSettings';
|
import { SkillSettings } from '../../components/SkillSettings';
|
||||||
import { useAdminUsers } from '../hooks/useAdminUsers';
|
import { useAdminUsers } from '../hooks/useAdminUsers';
|
||||||
import { formatYuan } from '../utils/format';
|
import { formatYuan } from '../utils/format';
|
||||||
import type { ImageQuotaView, PortalUser } from '../../types';
|
import type { AdminTemplateCatalogItem, ImageQuotaView, PortalUser } from '../../types';
|
||||||
|
|
||||||
function formatBytes(bytes: number) {
|
function formatBytes(bytes: number) {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
@@ -27,6 +27,9 @@ export function UserDetailPage() {
|
|||||||
const [imageQuotaLoading, setImageQuotaLoading] = useState(false);
|
const [imageQuotaLoading, setImageQuotaLoading] = useState(false);
|
||||||
const [imageRemaining, setImageRemaining] = useState('');
|
const [imageRemaining, setImageRemaining] = useState('');
|
||||||
const [imageGrantNote, setImageGrantNote] = useState('');
|
const [imageGrantNote, setImageGrantNote] = useState('');
|
||||||
|
const [templateCatalog, setTemplateCatalog] = useState<AdminTemplateCatalogItem[]>([]);
|
||||||
|
const [templateCatalogLoading, setTemplateCatalogLoading] = useState(false);
|
||||||
|
const [grantingTemplate, setGrantingTemplate] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user?.spaceQuotaBytes) return;
|
if (!user?.spaceQuotaBytes) return;
|
||||||
@@ -91,6 +94,28 @@ export function UserDetailPage() {
|
|||||||
setImageRemaining(String(imageQuota.remaining));
|
setImageRemaining(String(imageQuota.remaining));
|
||||||
}, [imageQuota?.remaining, imageQuota?.unlimited]);
|
}, [imageQuota?.remaining, imageQuota?.unlimited]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user || user.role !== 'user') {
|
||||||
|
setTemplateCatalog([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setTemplateCatalogLoading(true);
|
||||||
|
void fetchAdminTemplateCatalog()
|
||||||
|
.then((items) => {
|
||||||
|
if (!cancelled) setTemplateCatalog(items);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setTemplateCatalog([]);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setTemplateCatalogLoading(false);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [user?.id, user?.role]);
|
||||||
|
|
||||||
const handleRecharge = async (e: React.FormEvent) => {
|
const handleRecharge = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
@@ -148,6 +173,23 @@ export function UserDetailPage() {
|
|||||||
: `剩余 ${imageQuota.remaining ?? 0} / 总计 ${imageQuota.total ?? 0}(套餐 ${imageQuota.limit} + 充值 ${imageQuota.bonus},已用 ${imageQuota.used})`
|
: `剩余 ${imageQuota.remaining ?? 0} / 总计 ${imageQuota.total ?? 0}(套餐 ${imageQuota.limit} + 充值 ${imageQuota.bonus},已用 ${imageQuota.used})`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
const handleTemplateGrant = async (skillName: string) => {
|
||||||
|
if (!user) return;
|
||||||
|
setMessage(null);
|
||||||
|
setLocalError(null);
|
||||||
|
setError(null);
|
||||||
|
setGrantingTemplate(skillName);
|
||||||
|
try {
|
||||||
|
await grantUserPageTemplate(user.id, skillName);
|
||||||
|
const label = templateCatalog.find((item) => item.skillName === skillName)?.label ?? skillName;
|
||||||
|
setMessage(`已为用户开通「${label}」`);
|
||||||
|
} catch (err) {
|
||||||
|
setLocalError(err instanceof Error ? err.message : '模板授权失败');
|
||||||
|
} finally {
|
||||||
|
setGrantingTemplate(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (!loading && !user && !error) {
|
if (!loading && !user && !error) {
|
||||||
return <Navigate to="/users" replace />;
|
return <Navigate to="/users" replace />;
|
||||||
}
|
}
|
||||||
@@ -315,6 +357,39 @@ export function UserDetailPage() {
|
|||||||
<p className="muted">当前总额约 {currentQuotaMb} MB。</p>
|
<p className="muted">当前总额约 {currentQuotaMb} MB。</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-card">
|
||||||
|
<h2>页面模板授权</h2>
|
||||||
|
<p className="muted">
|
||||||
|
为用户免费开通可定价 page-template skill(写入购买记录并启用 skill)。完整目录与改价见
|
||||||
|
{' '}
|
||||||
|
<Link to="/template-catalog">页面模板</Link>。
|
||||||
|
</p>
|
||||||
|
{templateCatalogLoading ? (
|
||||||
|
<p className="muted">加载模板目录…</p>
|
||||||
|
) : templateCatalog.length === 0 ? (
|
||||||
|
<p className="muted">暂无模板目录。</p>
|
||||||
|
) : (
|
||||||
|
<div className="template-grant-list">
|
||||||
|
{templateCatalog.map((item) => (
|
||||||
|
<div key={item.skillName} className="template-grant-row">
|
||||||
|
<div>
|
||||||
|
<strong>{item.label}</strong>
|
||||||
|
<div className="muted mono">{item.skillName}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost-btn"
|
||||||
|
disabled={grantingTemplate === item.skillName}
|
||||||
|
onClick={() => void handleTemplateGrant(item.skillName)}
|
||||||
|
>
|
||||||
|
{grantingTemplate === item.skillName ? '开通中…' : '免费开通'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<CapabilitySettings users={users} userId={user.id} userOnly />
|
<CapabilitySettings users={users} userId={user.id} userOnly />
|
||||||
<SkillSettings users={users} userId={user.id} userOnly />
|
<SkillSettings users={users} userId={user.id} userOnly />
|
||||||
<PolicySettings users={users} userId={user.id} userOnly />
|
<PolicySettings users={users} userId={user.id} userOnly />
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ import type {
|
|||||||
WechatWebNotification,
|
WechatWebNotification,
|
||||||
MindSearchConfig,
|
MindSearchConfig,
|
||||||
MindSearchServiceTestResult,
|
MindSearchServiceTestResult,
|
||||||
|
AdminTemplateCatalogItem,
|
||||||
|
AdminTemplateCatalogPatch,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import type {
|
import type {
|
||||||
OrchestratorCanaryReadiness,
|
OrchestratorCanaryReadiness,
|
||||||
@@ -1432,3 +1434,25 @@ export async function fetchImageQuotaLedger(params: {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
}>(`/admin-api/image-quota/ledger${q.toString() ? `?${q}` : ''}`);
|
}>(`/admin-api/image-quota/ledger${q.toString() ? `?${q}` : ''}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminTemplateCatalog(): Promise<AdminTemplateCatalogItem[]> {
|
||||||
|
const result = await portalFetch<{ items: AdminTemplateCatalogItem[] }>('/admin-api/template-catalog');
|
||||||
|
return result.items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateAdminTemplateCatalogItem(
|
||||||
|
skillName: string,
|
||||||
|
patch: AdminTemplateCatalogPatch,
|
||||||
|
): Promise<{ ok: boolean; item?: AdminTemplateCatalogItem }> {
|
||||||
|
return portalFetch(`/admin-api/template-catalog/${encodeURIComponent(skillName)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(patch),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function grantUserPageTemplate(userId: string, skillName: string) {
|
||||||
|
return portalFetch<{ ok: boolean; skillName: string; grantedSkills?: string[] }>(
|
||||||
|
`/admin-api/users/${encodeURIComponent(userId)}/template-grants/${encodeURIComponent(skillName)}`,
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -2740,3 +2740,60 @@ body,
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-catalog-table .admin-inline-input,
|
||||||
|
.template-catalog-table .admin-inline-textarea,
|
||||||
|
.template-catalog-table select {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 108px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border-input);
|
||||||
|
background: var(--color-bg-base);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-catalog-table .admin-inline-input-narrow {
|
||||||
|
min-width: 72px;
|
||||||
|
max-width: 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-catalog-table .admin-inline-textarea {
|
||||||
|
min-height: 72px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-catalog-thumb {
|
||||||
|
width: 96px;
|
||||||
|
height: 54px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--color-border-input);
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-catalog-thumb-fallback {
|
||||||
|
background: linear-gradient(135deg, #0a1628, #2f6f57);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-inline-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-grant-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-grant-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--color-border-input);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|||||||
@@ -244,6 +244,7 @@ export type UsageRecord = {
|
|||||||
outputTokens: number;
|
outputTokens: number;
|
||||||
costCents: number;
|
costCents: number;
|
||||||
balanceAfterCents: number;
|
balanceAfterCents: number;
|
||||||
|
billingSource: 'wallet' | 'subscription';
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -343,8 +344,25 @@ export type WechatIntentRouterRuntimeState = {
|
|||||||
config: WechatIntentRouterAdminConfig;
|
config: WechatIntentRouterAdminConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MindSpaceSeoGeoConfig = {
|
||||||
|
enabled: boolean;
|
||||||
|
seo: {
|
||||||
|
enabled: boolean;
|
||||||
|
canonical: boolean;
|
||||||
|
sitemap: boolean;
|
||||||
|
robotsTxt: boolean;
|
||||||
|
baiduPush: boolean;
|
||||||
|
};
|
||||||
|
geo: {
|
||||||
|
enabled: boolean;
|
||||||
|
jsonLd: boolean;
|
||||||
|
llmsTxt: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type MindSpaceAdminConfig = {
|
export type MindSpaceAdminConfig = {
|
||||||
publicPageLimit: number;
|
publicPageLimit: number;
|
||||||
|
seoGeo: MindSpaceSeoGeoConfig;
|
||||||
analytics: {
|
analytics: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
websiteId: string;
|
websiteId: string;
|
||||||
@@ -774,3 +792,26 @@ export type MindSearchServiceTestResult = {
|
|||||||
resultCount?: number | null;
|
resultCount?: number | null;
|
||||||
message: string;
|
message: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminTemplateCatalogItem = {
|
||||||
|
skillName: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
previewUrl: string | null;
|
||||||
|
priceCents: number;
|
||||||
|
currency: string;
|
||||||
|
billingMode: 'free' | 'one_time' | 'subscription';
|
||||||
|
status: 'draft' | 'active' | 'archived';
|
||||||
|
sortOrder: number;
|
||||||
|
chatSkillId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminTemplateCatalogPatch = {
|
||||||
|
label?: string;
|
||||||
|
description?: string;
|
||||||
|
previewUrl?: string | null;
|
||||||
|
priceCents?: number;
|
||||||
|
billingMode?: AdminTemplateCatalogItem['billingMode'];
|
||||||
|
status?: AdminTemplateCatalogItem['status'];
|
||||||
|
sortOrder?: number;
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user