feat(mindspace): add SEO/GEO delivery, page template catalog, and admin hooks
Memind CI / Test, build, and release guards (push) Has been cancelled
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:
+196
-46
@@ -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()}>
|
||||
停止
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user