feat(mindspace): add SEO/GEO delivery, page template catalog, and admin hooks
Memind CI / Test, build, and release guards (push) Has been cancelled

Enable optional SEO/GEO injection and discovery routes for confirmed public pages while keeping private pages noindex. Add premium page template skills, portal catalog API, template shop UI, and Baidu push gated by memind_adm config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-10 08:06:12 +08:00
parent 7c65540366
commit fde6503bdf
158 changed files with 9194 additions and 270 deletions
+196 -46
View File
@@ -1,10 +1,12 @@
import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent } from 'react';
import { ChangeEvent, useCallback, useEffect, useLayoutEffect, useRef, useState, type ClipboardEvent, type MutableRefObject } from 'react';
import { BrainCircuit, Database, Image, ImageOff, ImagePlus } from 'lucide-react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar';
import {
applyPageTemplatePrefill,
CHAT_SKILL_OPTIONS,
filterChatSkills,
isPageTemplateChatSkillId,
mergeChatSkillPromptWithInput,
} from '../utils/chatSkills';
import { getMessageSaveActions } from '../utils/messageSave';
@@ -34,6 +36,8 @@ import {
} from '../utils/chatAttachment';
import { AvatarPicker } from './AvatarPicker';
import { ChatSkillPicker } from './ChatSkillPicker';
import { ChatSkillIcon } from './ChatSkillIcons';
import { PageTemplateShopModal } from './PageTemplateShopModal';
import { ChatLoadingSpinner } from './ChatLoadingSpinner';
import { ChatSharePreviewModal } from './ChatSharePreviewModal';
import { ChatPlazaPublishModal } from './ChatPlazaPublishModal';
@@ -103,9 +107,9 @@ function formatUploadMegabytes(bytes: number) {
return `${Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(1)}MB`;
}
function UploadIcon() {
function UploadIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className={className}>
<path
d="M8.5 12.5 12 9l3.5 3.5"
stroke="currentColor"
@@ -175,6 +179,10 @@ export function ChatPanel({
onApproveTool,
onPageSaved,
onClose,
balanceCents = 0,
onBalanceUpdate,
onGrantedSkillsUpdate,
onOpenRecharge,
}: {
variant: 'full' | 'compact';
user?: PortalUser | null;
@@ -219,6 +227,10 @@ export function ChatPanel({
categoryCode?: MindSpaceSaveCategory;
}) => void;
onClose?: () => void;
balanceCents?: number;
onBalanceUpdate?: (balanceCents: number) => void;
onGrantedSkillsUpdate?: (skills: string[]) => void;
onOpenRecharge?: () => void;
}) {
const online = useNetworkStatus();
const [input, setInput] = useState('');
@@ -231,7 +243,15 @@ export function ChatPanel({
const [pgRequired, setPgRequired] = useState(false);
const [imageGenerationMode, setImageGenerationMode] = useState<ImageGenerationMode>('auto');
const [imageGenerationTipVisible, setImageGenerationTipVisible] = useState(false);
const [pgTipVisible, setPgTipVisible] = useState(false);
const [deepReasoningTipVisible, setDeepReasoningTipVisible] = useState(false);
const [chatControlOnboardingStep, setChatControlOnboardingStep] = useState<0 | 1 | 2 | null>(null);
const [templateShopOpen, setTemplateShopOpen] = useState(false);
const [activeTemplatePrefill, setActiveTemplatePrefill] = useState<{
skillId: string;
label: string;
prompt: string;
} | null>(null);
const [pendingImages, setPendingImages] = useState<PendingChatImage[]>([]);
const [pendingFiles, setPendingFiles] = useState<PendingChatFile[]>([]);
const [uploadingImage, setUploadingImage] = useState(false);
@@ -240,7 +260,10 @@ export function ChatPanel({
const [fileError, setFileError] = useState<string | null>(null);
const [voiceStopSignal, setVoiceStopSignal] = useState(0);
const pendingSkillRef = useRef<string | null>(null);
const chatInputRef = useRef<HTMLTextAreaElement>(null);
const imageGenerationTipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pgTipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const deepReasoningTipTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [randomPrompt] = useState(
() => CHAT_PLACEHOLDER_PROMPTS[Math.floor(Math.random() * CHAT_PLACEHOLDER_PROMPTS.length)],
);
@@ -324,18 +347,66 @@ export function ChatPanel({
if (imageGenerationTipTimerRef.current) {
clearTimeout(imageGenerationTipTimerRef.current);
}
if (pgTipTimerRef.current) {
clearTimeout(pgTipTimerRef.current);
}
if (deepReasoningTipTimerRef.current) {
clearTimeout(deepReasoningTipTimerRef.current);
}
}, []);
const showTransientControlTip = useCallback(
(
setVisible: (visible: boolean) => void,
timerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>,
) => {
setVisible(true);
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => {
setVisible(false);
timerRef.current = null;
}, 5_000);
},
[],
);
const showImageGenerationSlowTip = useCallback(() => {
setImageGenerationTipVisible(true);
if (imageGenerationTipTimerRef.current) {
clearTimeout(imageGenerationTipTimerRef.current);
}
imageGenerationTipTimerRef.current = setTimeout(() => {
setImageGenerationTipVisible(false);
imageGenerationTipTimerRef.current = null;
}, 5_000);
}, []);
showTransientControlTip(setImageGenerationTipVisible, imageGenerationTipTimerRef);
}, [showTransientControlTip]);
const handlePgToggle = useCallback(() => {
setPgRequired((current) => {
const next = !current;
if (next) {
showTransientControlTip(setPgTipVisible, pgTipTimerRef);
} else {
setPgTipVisible(false);
if (pgTipTimerRef.current) {
clearTimeout(pgTipTimerRef.current);
pgTipTimerRef.current = null;
}
}
return next;
});
}, [showTransientControlTip]);
const handleDeepReasoningToggle = useCallback(() => {
setForceDeepReasoning((current) => {
const next = !current;
if (next) {
showTransientControlTip(setDeepReasoningTipVisible, deepReasoningTipTimerRef);
} else {
setDeepReasoningTipVisible(false);
if (deepReasoningTipTimerRef.current) {
clearTimeout(deepReasoningTipTimerRef.current);
deepReasoningTipTimerRef.current = null;
}
}
return next;
});
}, [showTransientControlTip]);
const handleImageGenerationClick = useCallback(() => {
setImageGenerationMode((current) => nextImageGenerationMode(current));
@@ -432,9 +503,11 @@ export function ChatPanel({
const placeholder = offlineBlocked
? '网络断开,恢复连接后可继续输入'
: compact
? '随时召唤你的小助手吧'
: randomPrompt;
: activeTemplatePrefill
? '描述页面主题,例如「仙居玩水攻略」或「儿童饮食偏好调查」'
: compact
? '随时召唤你的小助手吧'
: randomPrompt;
const connectStatusText =
uploadingImage || uploadingFile
? '正在上传,发出后会自动继续…'
@@ -456,6 +529,23 @@ export function ChatPanel({
? '提交中…'
: null;
const applyTemplatePrefill = useCallback((prompt: string, skillId: string, label: string) => {
pendingSkillRef.current = skillId;
// 输入框只保留用户主题;模板指令存 state,发送时再拼接。
setInput((current) => applyPageTemplatePrefill('', current));
setActiveTemplatePrefill({ skillId, label, prompt });
window.requestAnimationFrame(() => {
chatInputRef.current?.focus();
chatInputRef.current?.setSelectionRange(0, 0);
});
}, []);
const clearTemplatePrefill = useCallback(() => {
setInput((current) => applyPageTemplatePrefill('', current));
pendingSkillRef.current = null;
setActiveTemplatePrefill(null);
}, []);
const canUpload = Boolean(onUploadImage || onUploadFile);
const uploadFieldClass = canUpload ? ' chat-input-field-with-image-upload' : '';
@@ -620,9 +710,15 @@ export function ChatPanel({
const submitText = useCallback(async (textOverride?: string, skillIdOverride?: string) => {
const baseText = typeof textOverride === 'string' ? textOverride : input;
const trimmed = baseText.trim();
const selectedChatSkill = skillIdOverride ?? pendingSkillRef.current ?? undefined;
const userTheme = baseText.trim();
const templateSelection = activeTemplatePrefill;
const trimmed = templateSelection?.prompt
? applyPageTemplatePrefill(templateSelection.prompt, userTheme).trim()
: userTheme;
const selectedChatSkill =
skillIdOverride ?? pendingSkillRef.current ?? templateSelection?.skillId ?? undefined;
pendingSkillRef.current = null;
setActiveTemplatePrefill(null);
if ((!trimmed && pendingImages.length === 0 && pendingFiles.length === 0) || voiceDisabled) return;
if (pendingImages.length > 0 && !onUploadImage) {
setImageError('当前会话暂不支持图片发送');
@@ -789,7 +885,7 @@ export function ChatPanel({
setPendingFiles([]);
} catch (err) {
suppressVoiceUpdateRef.current = false;
setInput(trimmed);
setInput(userTheme);
setPendingImages(uploadedImages.map((item) => ({
...item,
uploadStatus: item.uploadedUrl ? 'uploaded' : 'error',
@@ -808,6 +904,7 @@ export function ChatPanel({
setUploadingFile(false);
suppressVoiceUpdateRef.current = false;
}, [
activeTemplatePrefill,
forceDeepReasoning,
pgRequired,
imageGenerationMode,
@@ -1087,6 +1184,21 @@ export function ChatPanel({
/>
)}
{onGrantedSkillsUpdate && onBalanceUpdate && onOpenRecharge && (
<PageTemplateShopModal
open={templateShopOpen}
balanceCents={balanceCents}
onClose={() => setTemplateShopOpen(false)}
onBalanceUpdate={onBalanceUpdate}
onGrantedSkillsUpdate={onGrantedSkillsUpdate}
onOpenRecharge={onOpenRecharge}
onUseTemplate={(prompt, skillId, label) => {
applyTemplatePrefill(prompt, skillId, label);
setTemplateShopOpen(false);
}}
/>
)}
<footer className={compact ? 'space-chat-panel-footer' : `footer${showHomeWelcome ? ' footer-home' : ''}`}>
{connectStatusText && (
<div className="chat-connect-status" role="status" aria-live="polite">
@@ -1172,6 +1284,16 @@ export function ChatPanel({
)}
</div>
)}
{activeTemplatePrefill && !taskInputStatusText && (
<div className="chat-template-prefill-banner" role="status">
<span>
{activeTemplatePrefill.label}· Enter
</span>
<button type="button" className="chat-template-prefill-clear" onClick={clearTemplatePrefill}>
</button>
</div>
)}
<div className={`chat-input-row${showHomeWelcome ? ' chat-input-row-home' : ''}`}>
<div className={`chat-input-shell${showHomeWelcome ? ' chat-input-shell-home' : ''}`}>
{canUpload && (
@@ -1183,7 +1305,7 @@ export function ChatPanel({
disabled={uploadDisabled}
onClick={() => uploadInputRef.current?.click()}
>
<UploadIcon />
<UploadIcon className="chat-image-upload-trigger-icon" />
</button>
<input
ref={uploadInputRef}
@@ -1211,6 +1333,7 @@ export function ChatPanel({
</div>
) : (
<textarea
ref={chatInputRef}
className={`input chat-input-field${uploadFieldClass}`}
rows={1}
placeholder={placeholder}
@@ -1245,30 +1368,55 @@ export function ChatPanel({
onboardingActive={chatControlOnboardingStep === 0}
onSelect={submitText}
onPrefill={(prompt, skillId) => {
if (skillId && isPageTemplateChatSkillId(skillId)) {
const label =
CHAT_SKILL_OPTIONS.find((item) => item.id === skillId || item.skillName === skillId)?.label ??
'页面模板';
applyTemplatePrefill(prompt, skillId, label);
return;
}
pendingSkillRef.current = skillId ?? null;
setActiveTemplatePrefill(null);
setInput((current) => mergeChatSkillPromptWithInput(prompt, current));
}}
/>
)}
<label
className={`chat-deep-reasoning-toggle${pgRequired ? ' is-active' : ''}${chatControlOnboardingStep === 1 ? ' chat-control-onboarding-active' : ''}`}
title="勾选后,本轮生成的可保存数据必须使用你的专属 PostgreSQL 数据空间"
>
<input
type="checkbox"
checked={pgRequired}
{onGrantedSkillsUpdate && (
<button
type="button"
className="chat-template-shop-trigger"
disabled={voiceDisabled}
aria-label="使用 PostgreSQL 数据空间"
onChange={(event) => setPgRequired(event.target.checked)}
/>
title="购买或管理页面模板"
onClick={() => setTemplateShopOpen(true)}
>
<ChatSkillIcon id="page" className="chat-template-shop-trigger-icon" />
<span className="chat-template-shop-trigger-label"></span>
</button>
)}
<button
type="button"
className={`chat-deep-reasoning-toggle chat-icon-toggle${pgRequired ? ' is-active' : ''}${pgTipVisible ? ' chat-icon-toggle-tip-active' : ''}${chatControlOnboardingStep === 1 ? ' chat-control-onboarding-active' : ''}`}
disabled={voiceDisabled}
role="checkbox"
aria-checked={pgRequired}
aria-label="使用 PostgreSQL 数据空间"
title="点击开启后,本轮生成的可保存数据必须使用你的专属 PostgreSQL 数据空间"
onClick={handlePgToggle}
>
<Database className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
<span className="chat-icon-toggle-label">db</span>
{pgTipVisible && (
<span className="chat-control-onboarding-tip chat-icon-toggle-tip" role="status">
PostgreSQL
</span>
)}
{chatControlOnboardingStep === 1 && (
<span className="chat-control-onboarding-tip" role="status">使</span>
)}
</label>
</button>
<button
type="button"
className={`chat-deep-reasoning-toggle chat-image-generation-toggle is-${imageGenerationMode}${imageGenerationMode === 'required' ? ' is-active' : ''}${imageGenerationTipVisible ? ' chat-image-generation-tip-active' : ''}`}
className={`chat-deep-reasoning-toggle chat-icon-toggle chat-image-generation-toggle is-${imageGenerationMode}${imageGenerationMode === 'required' ? ' is-active' : ''}${imageGenerationTipVisible ? ' chat-icon-toggle-tip-active' : ''}`}
disabled={voiceDisabled}
role="checkbox"
aria-checked={imageGenerationMode === 'auto' ? 'mixed' : imageGenerationMode === 'required'}
@@ -1283,31 +1431,33 @@ export function ChatPanel({
) : (
<Image className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
)}
<span className="chat-image-generation-state" aria-hidden="true">
{imageGenerationMode === 'auto' ? 'A' : imageGenerationMode === 'required' ? '✓' : '×'}
</span>
{imageGenerationTipVisible && (
<span className="chat-control-onboarding-tip chat-image-generation-tip" role="status">
<span className="chat-control-onboarding-tip chat-icon-toggle-tip" role="status">
</span>
)}
</button>
<label
className={`chat-deep-reasoning-toggle${forceDeepReasoning ? ' is-active' : ''}${chatControlOnboardingStep === 2 ? ' chat-control-onboarding-active' : ''}`}
title="勾选后,本轮消息使用深度推理完成"
<button
type="button"
className={`chat-deep-reasoning-toggle chat-icon-toggle${forceDeepReasoning ? ' is-active' : ''}${deepReasoningTipVisible ? ' chat-icon-toggle-tip-active' : ''}${chatControlOnboardingStep === 2 ? ' chat-control-onboarding-active' : ''}`}
disabled={voiceDisabled}
role="checkbox"
aria-checked={forceDeepReasoning}
aria-label="使用深度推理"
title="点击开启后,本轮消息使用深度推理完成"
onClick={handleDeepReasoningToggle}
>
<input
type="checkbox"
checked={forceDeepReasoning}
disabled={voiceDisabled}
aria-label="使用深度推理"
onChange={(event) => setForceDeepReasoning(event.target.checked)}
/>
<BrainCircuit className="chat-deep-reasoning-toggle-icon" aria-hidden="true" />
<span className="chat-icon-toggle-label"></span>
{deepReasoningTipVisible && (
<span className="chat-control-onboarding-tip chat-icon-toggle-tip" role="status">
</span>
)}
{chatControlOnboardingStep === 2 && (
<span className="chat-control-onboarding-tip" role="status"></span>
)}
</label>
</button>
{chatState === 'streaming' ? (
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
+682
View File
@@ -0,0 +1,682 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import QRCode from 'qrcode';
import {
createTemplateWechatCheckout,
formatTemplatePrice,
getMyPageTemplates,
getPageTemplateCatalog,
getTemplatePreviewUrl,
purchasePageTemplate,
togglePageTemplateFavorite,
} from '../api/page-template-catalog';
import { getMe, getRechargeOrder } from '../api/client';
import { ApiError } from '../api/core';
import type { PageTemplateCatalogItem, RechargeOrder } from '../types';
import { CHAT_SKILL_OPTIONS, mergeChatSkillPromptWithInput } from '../utils/chatSkills';
import {
filterTemplatesByCategory,
sortTemplates,
TEMPLATE_CATEGORIES,
TEMPLATE_SORT_OPTIONS,
type TemplateCategoryId,
type TemplateSortId,
} from '../utils/template-shop-utils';
import { invokeWechatJsapiPay, isWeChatBrowser } from '../utils/wechatPay';
import { TemplatePreviewThumb } from './TemplatePreviewThumb';
function formatYuan(cents: number) {
return `¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2)}`;
}
function detectPayScene(): 'native' | 'h5' | 'jsapi' {
if (isWeChatBrowser()) return 'jsapi';
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) ? 'h5' : 'native';
}
type ShopTab = 'shop' | 'mine' | 'favorites';
function formatUsageCount(value: number | undefined) {
const n = Number(value ?? 0);
if (n >= 10000) return `${(n / 10000).toFixed(1)} 万次使用`;
return `${n} 次使用`;
}
function formatFavoriteCount(value: number | undefined) {
const n = Number(value ?? 0);
if (n >= 10000) return `${(n / 10000).toFixed(1)} 万收藏`;
return `${n} 次收藏`;
}
function formatMyUsageCount(value: number | undefined) {
const n = Number(value ?? 0);
return n > 0 ? `我已用 ${n}` : '尚未使用';
}
type PageTemplateShopModalProps = {
open: boolean;
balanceCents: number;
onClose: () => void;
onBalanceUpdate: (balanceCents: number) => void;
onGrantedSkillsUpdate: (skills: string[]) => void;
onOpenRecharge: () => void;
onUseTemplate: (prompt: string, skillId: string, label: string) => void;
};
function resolveSkillOption(skillName: string) {
return CHAT_SKILL_OPTIONS.find((item) => item.skillName === skillName || item.id === skillName);
}
export function PageTemplateShopModal({
open,
balanceCents,
onClose,
onBalanceUpdate,
onGrantedSkillsUpdate,
onOpenRecharge,
onUseTemplate,
}: PageTemplateShopModalProps) {
const [items, setItems] = useState<PageTemplateCatalogItem[]>([]);
const [mineItems, setMineItems] = useState<PageTemplateCatalogItem[]>([]);
const [activeTab, setActiveTab] = useState<ShopTab>('shop');
const [activeCategory, setActiveCategory] = useState<TemplateCategoryId>('all');
const [activeSort, setActiveSort] = useState<TemplateSortId>('hot');
const [loading, setLoading] = useState(false);
const [favoritingSkill, setFavoritingSkill] = useState<string | null>(null);
const [purchasingSkill, setPurchasingSkill] = useState<string | null>(null);
const [wechatOrder, setWechatOrder] = useState<(RechargeOrder & { skillName?: string; label?: string }) | null>(
null,
);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [polling, setPolling] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [previewSkill, setPreviewSkill] = useState<PageTemplateCatalogItem | null>(null);
const pollRef = useRef<number | null>(null);
const redirectedRef = useRef(false);
const stopPolling = useCallback(() => {
if (pollRef.current != null) {
window.clearInterval(pollRef.current);
pollRef.current = null;
}
setPolling(false);
}, []);
const loadCatalog = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [catalog, mine] = await Promise.all([getPageTemplateCatalog(), getMyPageTemplates()]);
setItems(catalog);
setMineItems(mine);
} catch (err) {
setError(err instanceof Error ? err.message : '加载模板商城失败');
} finally {
setLoading(false);
}
}, []);
const syncItemStats = useCallback((skillName: string, patch: Partial<PageTemplateCatalogItem>) => {
const merge = (list: PageTemplateCatalogItem[]) =>
list.map((entry) => (entry.skillName === skillName ? { ...entry, ...patch } : entry));
setItems((current) => merge(current));
setMineItems((current) => merge(current));
}, []);
const refreshProfile = useCallback(async (nextBalance?: number) => {
if (typeof nextBalance === 'number') {
onBalanceUpdate(nextBalance);
}
const me = await getMe().catch(() => null);
if (me?.grantedSkills) {
onGrantedSkillsUpdate(me.grantedSkills);
}
if (typeof me?.user?.balance_cents === 'number') {
onBalanceUpdate(me.user.balance_cents);
}
}, [onBalanceUpdate, onGrantedSkillsUpdate]);
const markOwned = useCallback((skillName: string) => {
setItems((current) => {
const next = current.map((entry) =>
entry.skillName === skillName ? { ...entry, owned: true, granted: true } : entry,
);
const ownedItem = next.find((entry) => entry.skillName === skillName);
if (ownedItem) {
setMineItems((mine) =>
mine.some((entry) => entry.skillName === skillName) ? mine : [ownedItem, ...mine],
);
}
return next;
});
}, []);
const startPolling = useCallback(
(orderId: string, skillName: string, label: string) => {
stopPolling();
setPolling(true);
pollRef.current = window.setInterval(() => {
void getRechargeOrder(orderId)
.then(async (result) => {
if (result.order.status === 'paid') {
stopPolling();
setWechatOrder(null);
setQrDataUrl(null);
await refreshProfile(result.balanceCents ?? undefined);
markOwned(skillName);
setSuccessMessage(`已购买「${label}」,可在 Skill 列表中使用`);
} else if (result.order.status === 'expired') {
stopPolling();
setWechatOrder(null);
setQrDataUrl(null);
setError('支付订单已过期,请重新发起');
}
})
.catch(() => {});
}, 2000);
},
[markOwned, refreshProfile, stopPolling],
);
useEffect(() => {
if (!open) {
setError(null);
setSuccessMessage(null);
setPurchasingSkill(null);
setWechatOrder(null);
setQrDataUrl(null);
setPreviewSkill(null);
setActiveTab('shop');
setActiveCategory('all');
setActiveSort('hot');
stopPolling();
redirectedRef.current = false;
return;
}
void loadCatalog();
}, [open, loadCatalog, stopPolling]);
const handleToggleFavorite = async (item: PageTemplateCatalogItem) => {
setFavoritingSkill(item.skillName);
setError(null);
try {
const result = await togglePageTemplateFavorite(item.skillName);
syncItemStats(item.skillName, {
favorited: result.favorited,
favoriteCount: result.favoriteCount,
});
} catch (err) {
setError(err instanceof Error ? err.message : '收藏操作失败');
} finally {
setFavoritingSkill(null);
}
};
const favoriteCount = useMemo(() => items.filter((item) => item.favorited).length, [items]);
const displayItems = useMemo(() => {
const base =
activeTab === 'shop'
? items
: activeTab === 'mine'
? mineItems
: items.filter((item) => item.favorited);
const filtered =
activeTab === 'shop' ? filterTemplatesByCategory(base, activeCategory) : base;
return sortTemplates(filtered, activeSort);
}, [activeCategory, activeSort, activeTab, items, mineItems]);
useEffect(() => () => stopPolling(), [stopPolling]);
useEffect(() => {
if (!wechatOrder?.codeUrl) {
setQrDataUrl(null);
return;
}
void QRCode.toDataURL(wechatOrder.codeUrl, {
width: 220,
margin: 1,
color: { dark: '#18211d', light: '#ffffff' },
}).then(setQrDataUrl);
}, [wechatOrder?.codeUrl]);
const handleBalancePurchase = async (item: PageTemplateCatalogItem) => {
if (item.owned) return;
setPurchasingSkill(`${item.skillName}:balance`);
setError(null);
setSuccessMessage(null);
try {
const result = await purchasePageTemplate(item.skillName);
await refreshProfile(result.balanceCents);
markOwned(item.skillName);
setSuccessMessage(
result.alreadyOwned
? `${item.label}」已开通,可直接使用`
: `已购买「${item.label}」,可在 Skill 列表中使用`,
);
} catch (err) {
if (err instanceof ApiError && err.status === 402) {
setError('余额不足,可改用微信支付或先充值');
return;
}
setError(err instanceof Error ? err.message : '购买失败,请稍后重试');
} finally {
setPurchasingSkill(null);
}
};
const handleWechatPurchase = async (item: PageTemplateCatalogItem) => {
if (item.owned || item.priceCents <= 0) return;
setPurchasingSkill(`${item.skillName}:wechat`);
setError(null);
setSuccessMessage(null);
try {
const payScene = detectPayScene();
let checkout = await createTemplateWechatCheckout(item.skillName, payScene);
if (!checkout.ok || !checkout.order) {
setError('创建支付订单失败');
return;
}
if (checkout.grantedSkills && checkout.skillName) {
await refreshProfile(checkout.balanceCents);
markOwned(checkout.skillName);
setSuccessMessage(`${item.label}」已开通,可直接使用`);
return;
}
const order = checkout.order;
setWechatOrder({ ...order, skillName: item.skillName, label: item.label });
if (order.payMode === 'jsapi' && order.jsapiParams) {
startPolling(order.id, item.skillName, item.label);
const result = await invokeWechatJsapiPay(order.jsapiParams);
if (result === 'cancel') {
setError('已取消支付,可重新发起');
}
return;
}
if (order.payMode === 'h5' && order.h5Url && !redirectedRef.current) {
redirectedRef.current = true;
window.location.href = order.h5Url;
}
startPolling(order.id, item.skillName, item.label);
} catch (err) {
setError(err instanceof Error ? err.message : '微信支付发起失败');
} finally {
setPurchasingSkill(null);
}
};
const handleUse = (item: PageTemplateCatalogItem) => {
const option = resolveSkillOption(item.skillName);
if (!option) {
setError('模板 skill 未配置,请联系管理员');
return;
}
onUseTemplate(option.buildPrompt(option.skillName), option.id, item.label);
setPreviewSkill(null);
onClose();
};
if (!open) return null;
return createPortal(
<div className="recharge-backdrop" role="presentation" onClick={onClose}>
<div
className="recharge-dialog template-shop-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="template-shop-title"
onClick={(event) => event.stopPropagation()}
>
<div className="template-shop-sticky-head">
<div className="recharge-dialog-bar">
<div>
<p className="recharge-eyebrow"></p>
<h3 id="template-shop-title"></h3>
</div>
<button type="button" className="recharge-close" onClick={onClose} aria-label="关闭">
</button>
</div>
<div className="recharge-balance-row">
<span></span>
<strong>{formatYuan(balanceCents)}</strong>
</div>
<p className="recharge-muted template-shop-intro">
使
</p>
<div className="template-shop-tabs" role="tablist" aria-label="模板商城分类">
<button
type="button"
role="tab"
aria-selected={activeTab === 'shop'}
className={`template-shop-tab${activeTab === 'shop' ? ' is-active' : ''}`}
onClick={() => setActiveTab('shop')}
>
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'mine'}
className={`template-shop-tab${activeTab === 'mine' ? ' is-active' : ''}`}
onClick={() => setActiveTab('mine')}
>
{mineItems.length > 0 ? ` (${mineItems.length})` : ''}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === 'favorites'}
className={`template-shop-tab${activeTab === 'favorites' ? ' is-active' : ''}`}
onClick={() => setActiveTab('favorites')}
>
{favoriteCount > 0 ? ` (${favoriteCount})` : ''}
</button>
</div>
{activeTab === 'shop' && (
<div className="template-shop-toolbar">
<div className="template-shop-category-row" role="group" aria-label="模板品类">
{TEMPLATE_CATEGORIES.map((category) => (
<button
key={category.id}
type="button"
className={`template-shop-category-chip${
activeCategory === category.id ? ' is-active' : ''
}`}
onClick={() => setActiveCategory(category.id)}
>
{category.label}
</button>
))}
</div>
<label className="template-shop-sort">
<select
aria-label="排序方式"
value={activeSort}
onChange={(event) => setActiveSort(event.target.value as TemplateSortId)}
>
{TEMPLATE_SORT_OPTIONS.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
</div>
)}
{activeTab !== 'shop' && (
<div className="template-shop-toolbar template-shop-toolbar-compact">
<label className="template-shop-sort">
<select
aria-label="排序方式"
value={activeSort}
onChange={(event) => setActiveSort(event.target.value as TemplateSortId)}
>
{TEMPLATE_SORT_OPTIONS.map((option) => (
<option key={option.id} value={option.id}>
{option.label}
</option>
))}
</select>
</label>
</div>
)}
{error && <p className="recharge-error">{error}</p>}
{successMessage && <p className="template-shop-success">{successMessage}</p>}
</div>
<div className="template-shop-scroll-body">
{loading ? (
<p className="recharge-muted"></p>
) : (
<div className="template-shop-grid" role="list">
{displayItems.map((item) => {
const balanceBusy = purchasingSkill === `${item.skillName}:balance`;
const wechatBusy = purchasingSkill === `${item.skillName}:wechat`;
const affordable = item.priceCents <= 0 || balanceCents >= item.priceCents;
return (
<article
key={item.skillName}
role="listitem"
className={`template-shop-cell${item.owned ? ' is-owned' : ''}`}
>
<button
type="button"
className="template-shop-cell-preview"
aria-label={`预览「${item.label}`}
onClick={() => setPreviewSkill(item)}
>
<TemplatePreviewThumb
previewUrl={getTemplatePreviewUrl(item.skillName)}
label={item.label}
/>
<span className="template-shop-cell-title">{item.label}</span>
<span className="template-shop-cell-stats">
<span>{formatUsageCount(item.usageCount)}</span>
{activeTab === 'shop' || activeTab === 'favorites' ? (
<span>{formatFavoriteCount(item.favoriteCount)}</span>
) : (
<span>{formatMyUsageCount(item.myUsageCount)}</span>
)}
</span>
<span className="template-shop-price">
{item.owned ? '已拥有' : formatTemplatePrice(item.priceCents)}
</span>
</button>
<div className="template-shop-cell-actions">
{activeTab !== 'mine' && (
<button
type="button"
className={`template-shop-favorite-btn${item.favorited ? ' is-active' : ''}`}
disabled={favoritingSkill === item.skillName}
onClick={() => void handleToggleFavorite(item)}
>
{favoritingSkill === item.skillName ? '…' : item.favorited ? '已收藏' : '收藏'}
</button>
)}
{item.owned ? (
<button
type="button"
className="recharge-pay-btn template-shop-use-btn"
onClick={() => handleUse(item)}
>
使
</button>
) : item.priceCents <= 0 ? (
<button
type="button"
className="recharge-pay-btn"
disabled={balanceBusy}
onClick={() => void handleBalancePurchase(item)}
>
{balanceBusy ? '…' : '开通'}
</button>
) : (
<>
<button
type="button"
className="recharge-pay-btn template-shop-secondary-btn"
disabled={balanceBusy || wechatBusy}
onClick={() => {
if (!affordable) {
onClose();
onOpenRecharge();
return;
}
void handleBalancePurchase(item);
}}
title={affordable ? '余额购买' : '余额不足,去充值'}
>
{balanceBusy ? '…' : affordable ? '余额' : '充值'}
</button>
<button
type="button"
className="recharge-pay-btn"
disabled={balanceBusy || wechatBusy}
onClick={() => void handleWechatPurchase(item)}
>
{wechatBusy ? '…' : '微信'}
</button>
</>
)}
</div>
</article>
);
})}
</div>
)}
{wechatOrder && (
<div className="template-shop-wechat-panel">
<p className="recharge-muted">
{wechatOrder.label ?? '页面模板'}{formatYuan(wechatOrder.amountCents)}
{polling ? ' 等待支付结果' : ''}
</p>
{qrDataUrl && (
<img src={qrDataUrl} alt="微信支付二维码" className="template-shop-wechat-qr" />
)}
</div>
)}
{!loading && displayItems.length === 0 && (
<p className="recharge-muted">
{activeTab === 'mine'
? '你还没有已拥有的模板,去商城看看吧。'
: activeTab === 'favorites'
? '还没有收藏的模板,在商城点击「收藏」即可加入。'
: activeCategory === 'all'
? '暂无可购模板,请稍后再来。'
: '该品类暂无模板,试试其它分类。'}
</p>
)}
</div>
{previewSkill && (
<div
className="template-shop-preview-overlay"
role="presentation"
onClick={() => setPreviewSkill(null)}
>
<div
className="template-shop-preview-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="template-preview-title"
onClick={(event) => event.stopPropagation()}
>
<div className="template-shop-preview-dialog-bar">
<div>
<p className="recharge-eyebrow"></p>
<h4 id="template-preview-title">{previewSkill.label}</h4>
{previewSkill.description ? (
<p className="template-shop-preview-desc">{previewSkill.description}</p>
) : null}
</div>
<button
type="button"
className="recharge-close"
onClick={() => setPreviewSkill(null)}
aria-label="关闭预览"
>
</button>
</div>
<iframe
title={`${previewSkill.label} 效果预览`}
src={getTemplatePreviewUrl(previewSkill.skillName)}
sandbox="allow-scripts allow-same-origin"
className="template-shop-preview-frame"
/>
<div className="template-shop-preview-footer">
<div className="template-shop-preview-meta">
<span className="template-shop-price">
{previewSkill.owned ? '已拥有' : formatTemplatePrice(previewSkill.priceCents)}
</span>
<span className="template-shop-cell-stats">
<span>{formatUsageCount(previewSkill.usageCount)}</span>
<span>{formatFavoriteCount(previewSkill.favoriteCount)}</span>
</span>
</div>
{previewSkill.owned ? (
<button
type="button"
className="recharge-pay-btn template-shop-use-btn"
onClick={() => handleUse(previewSkill)}
>
使
</button>
) : previewSkill.priceCents <= 0 ? (
<button
type="button"
className="recharge-pay-btn"
disabled={purchasingSkill === `${previewSkill.skillName}:balance`}
onClick={() => void handleBalancePurchase(previewSkill)}
>
{purchasingSkill === `${previewSkill.skillName}:balance` ? '开通中…' : '免费开通'}
</button>
) : (
<>
<button
type="button"
className="recharge-pay-btn template-shop-secondary-btn"
disabled={
purchasingSkill === `${previewSkill.skillName}:balance` ||
purchasingSkill === `${previewSkill.skillName}:wechat`
}
onClick={() => void handleBalancePurchase(previewSkill)}
>
{purchasingSkill === `${previewSkill.skillName}:balance`
? '购买中…'
: balanceCents >= previewSkill.priceCents
? '余额购买'
: '余额不足'}
</button>
<button
type="button"
className="recharge-pay-btn"
disabled={
purchasingSkill === `${previewSkill.skillName}:balance` ||
purchasingSkill === `${previewSkill.skillName}:wechat`
}
onClick={() => void handleWechatPurchase(previewSkill)}
>
{purchasingSkill === `${previewSkill.skillName}:wechat` ? '发起中…' : '微信支付'}
</button>
{balanceCents < previewSkill.priceCents && (
<button
type="button"
className="template-shop-recharge-link"
onClick={() => {
setPreviewSkill(null);
onClose();
onOpenRecharge();
}}
>
</button>
)}
</>
)}
</div>
</div>
</div>
)}
</div>
</div>,
document.body,
);
}
export function buildTemplatePrefillPrompt(skillName: string, userInput = '') {
const option = resolveSkillOption(skillName);
if (!option) return userInput;
return mergeChatSkillPromptWithInput(option.buildPrompt(option.skillName), userInput);
}
+23 -3
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useChat } from '../context/ChatProvider';
import { getMe } from '../api/client';
import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
import { useGoalRunBanner } from '../hooks/useGoalRunBanner';
import type { PageEditSubChatBridge } from '../hooks/usePageEditSubChat';
@@ -52,11 +53,26 @@ export function SpaceChatPanel({
notice,
dismissNotice,
openRecharge,
completeRecharge,
uploadChatImage,
uploadChatAttachment,
retryConnect,
} = chat;
const { capabilities, grantedSkills, followAgentRun, goalRunEnabled } = mainChat;
const { capabilities, grantedSkills, followAgentRun, goalRunEnabled, balanceCents } = mainChat;
const [localGrantedSkills, setLocalGrantedSkills] = useState<string[] | undefined>();
const effectiveGrantedSkills = localGrantedSkills ?? grantedSkills;
useEffect(() => {
if (!open) return;
setLocalGrantedSkills(undefined);
}, [open]);
const handleGrantedSkillsUpdate = (skills: string[]) => {
setLocalGrantedSkills(skills);
void getMe().then((me) => {
if (me?.grantedSkills) setLocalGrantedSkills(me.grantedSkills);
});
};
const goalRunBanner = useGoalRunBanner({
userId: user.id,
sessionId: session?.id,
@@ -159,7 +175,11 @@ export function SpaceChatPanel({
pendingTool={pendingTool}
session={session}
capabilities={capabilities ?? undefined}
grantedSkills={grantedSkills}
grantedSkills={effectiveGrantedSkills}
balanceCents={balanceCents ?? 0}
onBalanceUpdate={(nextBalance) => completeRecharge(nextBalance)}
onGrantedSkillsUpdate={handleGrantedSkillsUpdate}
onOpenRecharge={() => openRecharge(false)}
onSubmit={(text, imageUrls, previewImageUrls, options) =>
chatBridge
? void submit(
+37
View File
@@ -0,0 +1,37 @@
import { useEffect, useRef, useState } from 'react';
type TemplatePreviewThumbProps = {
previewUrl: string;
label: string;
};
export function TemplatePreviewThumb({ previewUrl, label }: TemplatePreviewThumbProps) {
const hostRef = useRef<HTMLSpanElement>(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const host = hostRef.current;
if (!host) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry?.isIntersecting) {
setVisible(true);
observer.disconnect();
}
},
{ rootMargin: '160px 0px' },
);
observer.observe(host);
return () => observer.disconnect();
}, [previewUrl]);
return (
<span ref={hostRef} className="template-shop-cell-thumb template-shop-cell-thumb-live">
{visible ? (
<iframe src={previewUrl} title={`${label} 预览缩略图`} tabIndex={-1} />
) : (
<span className="template-shop-thumb-placeholder" aria-hidden="true" />
)}
</span>
);
}