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:
@@ -44,6 +44,7 @@ function AuthenticatedApp({
|
||||
grantedSkills,
|
||||
goalRunEnabled,
|
||||
onUserUpdate,
|
||||
onGrantedSkillsUpdate,
|
||||
onLogout,
|
||||
}: {
|
||||
user: PortalUser | null;
|
||||
@@ -51,6 +52,7 @@ function AuthenticatedApp({
|
||||
grantedSkills?: string[];
|
||||
goalRunEnabled?: boolean;
|
||||
onUserUpdate: (user: PortalUser) => void;
|
||||
onGrantedSkillsUpdate: (skills: string[]) => void;
|
||||
onLogout: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
@@ -68,6 +70,7 @@ function AuthenticatedApp({
|
||||
user={user}
|
||||
capabilities={capabilities}
|
||||
grantedSkills={grantedSkills}
|
||||
onGrantedSkillsUpdate={onGrantedSkillsUpdate}
|
||||
onUserUpdate={onUserUpdate}
|
||||
onOpenSpace={
|
||||
user
|
||||
@@ -227,6 +230,7 @@ export function App() {
|
||||
grantedSkills={grantedSkills}
|
||||
goalRunEnabled={goalRunEnabled}
|
||||
onUserUpdate={setUser}
|
||||
onGrantedSkillsUpdate={setGrantedSkills}
|
||||
onLogout={() => {
|
||||
clearAllStoredSessionIds();
|
||||
setAuthed(false);
|
||||
|
||||
@@ -462,6 +462,14 @@ export async function getRechargeOrder(orderId: string): Promise<{
|
||||
return portalFetch(`/auth/billing/recharge-orders/${orderId}`);
|
||||
}
|
||||
|
||||
export {
|
||||
getPageTemplateCatalog,
|
||||
getMyPageTemplates,
|
||||
purchasePageTemplate,
|
||||
createTemplateWechatCheckout,
|
||||
formatTemplatePrice,
|
||||
} from './page-template-catalog';
|
||||
|
||||
export async function purchaseSpaceQuota(sizeMb: number): Promise<{
|
||||
quota: MindSpaceQuota;
|
||||
balanceCents: number;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { PageTemplateCatalogItem, PageTemplateFavoriteResult, PageTemplatePurchaseResult, RechargeOrder } from '../types';
|
||||
import { API, ApiError, apiFetch } from './core';
|
||||
|
||||
export type TemplateCheckoutResult = {
|
||||
ok: boolean;
|
||||
order?: RechargeOrder & { skillName?: string; label?: string };
|
||||
skillName?: string;
|
||||
grantedSkills?: string[];
|
||||
balanceCents?: number;
|
||||
alreadyOwned?: boolean;
|
||||
};
|
||||
|
||||
export async function getPageTemplateCatalog(): Promise<PageTemplateCatalogItem[]> {
|
||||
const result = await apiFetch<{ data?: { items?: PageTemplateCatalogItem[] }; items?: PageTemplateCatalogItem[] }>(
|
||||
'/mindspace/v1/template-catalog',
|
||||
);
|
||||
return result.data?.items ?? result.items ?? [];
|
||||
}
|
||||
|
||||
export async function getMyPageTemplates(): Promise<PageTemplateCatalogItem[]> {
|
||||
const result = await apiFetch<{ data?: { items?: PageTemplateCatalogItem[] }; items?: PageTemplateCatalogItem[] }>(
|
||||
'/mindspace/v1/template-catalog/mine',
|
||||
);
|
||||
return result.data?.items ?? result.items ?? [];
|
||||
}
|
||||
|
||||
export async function purchasePageTemplate(skillName: string): Promise<PageTemplatePurchaseResult> {
|
||||
try {
|
||||
const result = await apiFetch<{ data: PageTemplatePurchaseResult }>(
|
||||
`/mindspace/v1/template-catalog/${encodeURIComponent(skillName)}/purchase`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.status === 402) {
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createTemplateWechatCheckout(
|
||||
skillName: string,
|
||||
payScene: 'native' | 'h5' | 'jsapi',
|
||||
): Promise<TemplateCheckoutResult> {
|
||||
const result = await apiFetch<{ data: TemplateCheckoutResult }>(
|
||||
`/mindspace/v1/template-catalog/${encodeURIComponent(skillName)}/checkout`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ payScene }),
|
||||
},
|
||||
);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function togglePageTemplateFavorite(skillName: string): Promise<PageTemplateFavoriteResult> {
|
||||
const result = await apiFetch<{ data: PageTemplateFavoriteResult }>(
|
||||
`/mindspace/v1/template-catalog/${encodeURIComponent(skillName)}/favorite`,
|
||||
{ method: 'POST' },
|
||||
);
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export function getTemplatePreviewUrl(skillName: string) {
|
||||
return `${API}/mindspace/v1/template-catalog/${encodeURIComponent(skillName)}/preview`;
|
||||
}
|
||||
|
||||
export function formatTemplatePrice(cents: number) {
|
||||
if (cents <= 0) return '免费';
|
||||
return `¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2)}`;
|
||||
}
|
||||
|
||||
export { API, ApiError };
|
||||
+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>
|
||||
);
|
||||
}
|
||||
+597
-190
@@ -696,6 +696,8 @@ body,
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-input-row .chat-skill-trigger,
|
||||
.app-shell-h5 .chat-input-row .chat-template-shop-trigger,
|
||||
.app-shell-h5 .chat-input-row .chat-icon-toggle.chat-deep-reasoning-toggle,
|
||||
.app-shell-h5 .chat-input-row .chat-input-action {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
@@ -704,60 +706,30 @@ body,
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-input-row .chat-deep-reasoning-toggle {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
padding: 2px 3px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-deep-reasoning-toggle input {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-deep-reasoning-toggle-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-deep-reasoning-toggle-label {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
font-size: 10px;
|
||||
line-height: 1.05;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-skill-trigger {
|
||||
.app-shell-h5 .chat-skill-trigger,
|
||||
.app-shell-h5 .chat-template-shop-trigger,
|
||||
.app-shell-h5 .chat-icon-toggle.chat-deep-reasoning-toggle {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-skill-trigger-icon {
|
||||
.app-shell-h5 .chat-skill-trigger-icon,
|
||||
.app-shell-h5 .chat-template-shop-trigger-icon,
|
||||
.app-shell-h5 .chat-icon-toggle .chat-deep-reasoning-toggle-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-skill-trigger-label {
|
||||
.app-shell-h5 .chat-skill-trigger-label,
|
||||
.app-shell-h5 .chat-template-shop-trigger-label,
|
||||
.app-shell-h5 .chat-icon-toggle-label {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -777,17 +749,16 @@ body,
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-image-upload-trigger {
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
transform: translateY(-50%);
|
||||
left: 6px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-image-upload-trigger-icon,
|
||||
.app-shell-h5 .chat-image-upload-trigger svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.app-shell-h5 .chat-input-row textarea.input.chat-input-field.chat-input-field-with-image-upload,
|
||||
@@ -2931,29 +2902,32 @@ body,
|
||||
|
||||
.chat-image-upload-trigger {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 7px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(24, 33, 29, 0.18);
|
||||
border-radius: 7px;
|
||||
background: rgba(24, 33, 29, 0.08);
|
||||
color: #5d6963;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.chat-image-upload-trigger-icon,
|
||||
.chat-image-upload-trigger svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.chat-image-upload-trigger:hover:not(:disabled) {
|
||||
background: rgba(24, 33, 29, 0.12);
|
||||
color: #4b5651;
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-strong);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.chat-image-upload-trigger:disabled {
|
||||
@@ -3165,6 +3139,7 @@ body,
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-text-secondary);
|
||||
background: var(--color-bg-elevated);
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
@@ -3173,28 +3148,62 @@ body,
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle:hover {
|
||||
.chat-icon-toggle.chat-deep-reasoning-toggle {
|
||||
gap: 1px;
|
||||
font-weight: 400;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle:hover:not(:disabled) {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-strong);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle input {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
accent-color: #79b7ff;
|
||||
.chat-deep-reasoning-toggle:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle-label {
|
||||
.chat-skill-trigger,
|
||||
.chat-template-shop-trigger,
|
||||
.chat-icon-toggle.chat-deep-reasoning-toggle {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
gap: 0;
|
||||
line-height: 1.1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
flex: 0 0 auto;
|
||||
min-width: 42px;
|
||||
height: 42px;
|
||||
min-height: 42px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle-icon {
|
||||
.chat-skill-trigger:hover:not(:disabled),
|
||||
.chat-template-shop-trigger:hover:not(:disabled),
|
||||
.chat-icon-toggle.chat-deep-reasoning-toggle:hover:not(:disabled) {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-strong);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.chat-skill-trigger:disabled,
|
||||
.chat-template-shop-trigger:disabled,
|
||||
.chat-icon-toggle.chat-deep-reasoning-toggle:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-skill-trigger-icon,
|
||||
.chat-template-shop-trigger-icon,
|
||||
.chat-icon-toggle .chat-deep-reasoning-toggle-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
@@ -3264,21 +3273,6 @@ body,
|
||||
background: rgba(121, 183, 255, 0.14);
|
||||
}
|
||||
|
||||
.chat-image-generation-toggle {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-image-generation-tip-active {
|
||||
z-index: 31;
|
||||
}
|
||||
|
||||
.chat-image-generation-tip {
|
||||
max-width: min(188px, calc(100vw - 20px));
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-image-generation-toggle.is-disabled {
|
||||
color: var(--color-text-tertiary);
|
||||
border-color: color-mix(in srgb, var(--color-border-input) 72%, transparent);
|
||||
@@ -3290,59 +3284,20 @@ body,
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.chat-image-generation-state {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 1px;
|
||||
min-width: 10px;
|
||||
height: 10px;
|
||||
padding: 0 1px;
|
||||
border-radius: 5px;
|
||||
color: currentColor;
|
||||
background: var(--color-bg-elevated);
|
||||
font-size: 8px;
|
||||
font-weight: 800;
|
||||
line-height: 10px;
|
||||
.chat-image-generation-tip-active,
|
||||
.chat-icon-toggle-tip-active {
|
||||
position: relative;
|
||||
z-index: 31;
|
||||
}
|
||||
|
||||
.chat-image-generation-tip,
|
||||
.chat-icon-toggle-tip {
|
||||
max-width: min(188px, calc(100vw - 20px));
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle:has(input:disabled) {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.chat-skill-trigger {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
min-width: 42px;
|
||||
height: 42px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-skill-trigger:hover:not(:disabled) {
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border-strong);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.chat-skill-trigger:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chat-skill-trigger-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.chat-skill-trigger-label {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
@@ -3351,6 +3306,43 @@ body,
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.chat-template-shop-trigger-label,
|
||||
.chat-icon-toggle-label {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.chat-icon-toggle-label {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.chat-template-prefill-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 0 0 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgba(88, 196, 146, 0.28);
|
||||
border-radius: 12px;
|
||||
background: rgba(47, 111, 87, 0.14);
|
||||
color: #c7f0d8;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.chat-template-prefill-clear {
|
||||
flex-shrink: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #9fe0bc;
|
||||
font-size: 12px;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.chat-skill-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
@@ -4985,6 +4977,449 @@ body,
|
||||
box-shadow: 0 28px 100px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.template-shop-dialog {
|
||||
width: min(800px, calc(100vw - 32px));
|
||||
max-height: min(90vh, 820px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.template-shop-sticky-head {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.template-shop-intro {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.template-shop-scroll-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.template-shop-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.template-shop-toolbar-compact {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.template-shop-category-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.template-shop-category-chip {
|
||||
padding: 5px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.template-shop-category-chip.is-active {
|
||||
background: rgba(99, 102, 241, 0.2);
|
||||
border-color: rgba(129, 140, 248, 0.45);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.template-shop-sort select {
|
||||
min-width: 140px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.88);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.template-shop-thumb-placeholder {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.12), rgba(15, 23, 42, 0.9));
|
||||
}
|
||||
|
||||
.template-shop-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.template-shop-tab {
|
||||
flex: 1;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.template-shop-tab.is-active {
|
||||
background: rgba(99, 102, 241, 0.18);
|
||||
border-color: rgba(129, 140, 248, 0.45);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.template-shop-cell-stats {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 10px;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.52);
|
||||
}
|
||||
|
||||
.template-shop-favorite-btn {
|
||||
padding: 4px 4px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-shop-favorite-btn.is-active {
|
||||
border-color: rgba(251, 191, 36, 0.45);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.template-shop-cell-thumb-live {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background: #0a1628;
|
||||
}
|
||||
|
||||
.template-shop-cell-thumb-live iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 400%;
|
||||
height: 400%;
|
||||
transform: scale(0.25);
|
||||
transform-origin: top left;
|
||||
border: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.template-shop-preview-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.template-shop-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.template-shop-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.template-shop-cell.is-owned {
|
||||
border-color: rgba(88, 196, 146, 0.35);
|
||||
background: rgba(47, 111, 87, 0.12);
|
||||
}
|
||||
|
||||
.template-shop-cell-preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.template-shop-cell-preview:hover .template-shop-cell-thumb,
|
||||
.template-shop-cell-preview:focus-visible .template-shop-cell-thumb {
|
||||
border-color: rgba(158, 197, 255, 0.45);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.template-shop-cell-thumb {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--color-bg-hover) 80%, transparent);
|
||||
transition: border-color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.template-shop-cell-title {
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
color: #eef2f7;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-shop-cell .template-shop-price {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.template-shop-cell-actions {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(0, 1fr);
|
||||
gap: 4px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.template-shop-cell-actions .template-shop-favorite-btn,
|
||||
.template-shop-cell-actions .recharge-pay-btn {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 28px;
|
||||
padding: 4px 4px;
|
||||
border-radius: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-shop-thumb-btn {
|
||||
position: relative;
|
||||
width: 88px;
|
||||
height: 56px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in srgb, var(--color-bg-hover) 80%, transparent);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.template-shop-thumb-btn:hover .template-shop-thumb-hint,
|
||||
.template-shop-thumb-btn:focus-visible .template-shop-thumb-hint {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.template-shop-thumb-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.template-shop-thumb-fallback {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #0a1628, #2f6f57);
|
||||
}
|
||||
|
||||
.template-shop-thumb-hint {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(8, 14, 22, 0.55);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.template-shop-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.template-shop-preview-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(10, 16, 14, 0.55);
|
||||
}
|
||||
|
||||
.template-shop-preview-dialog {
|
||||
width: min(920px, calc(100vw - 32px));
|
||||
max-height: calc(100vh - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: var(--radius-xl);
|
||||
background: var(--color-bg-elevated);
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.template-shop-preview-dialog-bar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.template-shop-preview-dialog-bar h4 {
|
||||
margin: 4px 0 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.template-shop-preview-frame {
|
||||
width: 100%;
|
||||
height: min(58vh, 480px);
|
||||
border: 1px solid var(--color-border-input);
|
||||
border-radius: var(--radius-md);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.template-shop-preview-desc {
|
||||
margin: 6px 0 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.template-shop-preview-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.template-shop-preview-footer .recharge-pay-btn {
|
||||
min-height: 36px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.template-shop-price {
|
||||
flex-shrink: 0;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
background: rgba(61, 139, 253, 0.16);
|
||||
color: #9ec5ff;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.template-shop-cell.is-owned .template-shop-price,
|
||||
.is-owned .template-shop-price {
|
||||
background: rgba(88, 196, 146, 0.18);
|
||||
color: #9fe0bc;
|
||||
}
|
||||
|
||||
.template-shop-desc {
|
||||
margin: 0;
|
||||
color: #a9b4c2;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-shop-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.template-shop-actions .recharge-pay-btn {
|
||||
min-height: 34px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.template-shop-use-btn {
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.template-shop-recharge-link {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #9ec5ff;
|
||||
font-size: 13px;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.template-shop-success {
|
||||
margin: 0;
|
||||
color: #9fe0bc;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.template-shop-secondary-btn {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: #dbeaff;
|
||||
}
|
||||
|
||||
.template-shop-wechat-panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
justify-items: center;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.template-shop-wechat-qr {
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.recharge-dialog-bar {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -10097,17 +10532,16 @@ body,
|
||||
}
|
||||
|
||||
.chat-image-upload-trigger {
|
||||
left: 8px;
|
||||
top: 50%;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 7px;
|
||||
transform: translateY(-50%);
|
||||
left: 6px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.chat-image-upload-trigger-icon,
|
||||
.chat-image-upload-trigger svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.chat-input-row textarea.input.chat-input-field.chat-input-field-with-image-upload,
|
||||
@@ -10116,6 +10550,8 @@ body,
|
||||
}
|
||||
|
||||
.chat-input-row .chat-skill-trigger,
|
||||
.chat-input-row .chat-template-shop-trigger,
|
||||
.chat-input-row .chat-icon-toggle.chat-deep-reasoning-toggle,
|
||||
.chat-input-row .chat-input-action {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
@@ -10124,65 +10560,34 @@ body,
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
padding: 2px 5px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chat-input-row .chat-deep-reasoning-toggle {
|
||||
box-sizing: border-box;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 24px;
|
||||
padding: 2px 3px;
|
||||
border-radius: 6px;
|
||||
font-size: 10px;
|
||||
flex-direction: row;
|
||||
gap: 2px;
|
||||
font-weight: 600;
|
||||
white-space: normal;
|
||||
.chat-skill-trigger,
|
||||
.chat-template-shop-trigger,
|
||||
.chat-icon-toggle.chat-deep-reasoning-toggle {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chat-input-action-home {
|
||||
box-shadow: 0 8px 18px rgba(121, 146, 255, 0.28);
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle input {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle-icon {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.chat-deep-reasoning-toggle-label {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
font-size: 10px;
|
||||
line-height: 1.05;
|
||||
text-align: left;
|
||||
max-width: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.chat-skill-trigger {
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.chat-skill-trigger-icon {
|
||||
.chat-skill-trigger-icon,
|
||||
.chat-template-shop-trigger-icon,
|
||||
.chat-icon-toggle .chat-deep-reasoning-toggle-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.chat-skill-trigger-label {
|
||||
.chat-skill-trigger-label,
|
||||
.chat-template-shop-trigger-label,
|
||||
.chat-icon-toggle-label {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -10680,13 +11085,15 @@ body,
|
||||
background: rgba(47, 111, 87, 0.08);
|
||||
}
|
||||
|
||||
.space-chat-panel-footer .chat-skill-trigger {
|
||||
.space-chat-panel-footer .chat-skill-trigger,
|
||||
.space-chat-panel-footer .chat-template-shop-trigger {
|
||||
border-color: rgba(24, 33, 29, 0.14);
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: var(--ms-soft);
|
||||
}
|
||||
|
||||
.space-chat-panel-footer .chat-skill-trigger:hover:not(:disabled) {
|
||||
.space-chat-panel-footer .chat-skill-trigger:hover:not(:disabled),
|
||||
.space-chat-panel-footer .chat-template-shop-trigger:hover:not(:disabled) {
|
||||
color: var(--ms-green-deep);
|
||||
border-color: rgba(47, 111, 87, 0.24);
|
||||
background: rgba(47, 111, 87, 0.08);
|
||||
|
||||
@@ -993,6 +993,40 @@ export type AuthStatus = {
|
||||
goalRun?: GoalRunClientPolicy | null;
|
||||
};
|
||||
|
||||
export type PageTemplateCatalogItem = {
|
||||
skillName: string;
|
||||
label: string;
|
||||
description: string;
|
||||
previewUrl: string | null;
|
||||
priceCents: number;
|
||||
currency: string;
|
||||
billingMode: 'free' | 'one_time' | 'subscription';
|
||||
status: string;
|
||||
sortOrder: number;
|
||||
chatSkillId: string;
|
||||
owned: boolean;
|
||||
granted: boolean;
|
||||
usageCount: number;
|
||||
favoriteCount: number;
|
||||
favorited?: boolean;
|
||||
myUsageCount?: number;
|
||||
};
|
||||
|
||||
export type PageTemplateFavoriteResult = {
|
||||
ok: boolean;
|
||||
favorited: boolean;
|
||||
favoriteCount: number;
|
||||
};
|
||||
|
||||
export type PageTemplatePurchaseResult = {
|
||||
ok: boolean;
|
||||
skillName: string;
|
||||
priceCents?: number;
|
||||
balanceCents?: number;
|
||||
grantedSkills?: string[];
|
||||
alreadyOwned?: boolean;
|
||||
};
|
||||
|
||||
export type PathGrant = {
|
||||
path: string;
|
||||
mode: 'read' | 'readwrite';
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
applyPageTemplatePrefill,
|
||||
buildChatSkillPrompt,
|
||||
CHAT_SKILL_DEFINITIONS,
|
||||
filterChatSkills as filterChatSkillDefinitions,
|
||||
isPageTemplateChatSkillId,
|
||||
mergeChatSkillPromptWithInput,
|
||||
} from '../../chat-skills.mjs';
|
||||
import { buildPublishSkillPrompt, PUBLISH_SKILL_NAME } from './publishSkill';
|
||||
@@ -46,4 +48,8 @@ export function filterChatSkills(
|
||||
}
|
||||
|
||||
export { buildPublishSkillPrompt, PUBLISH_SKILL_NAME };
|
||||
export { mergeChatSkillPromptWithInput };
|
||||
export {
|
||||
applyPageTemplatePrefill,
|
||||
isPageTemplateChatSkillId,
|
||||
mergeChatSkillPromptWithInput,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PageTemplateCatalogItem } from '../types';
|
||||
|
||||
export type TemplateCategoryId =
|
||||
| 'all'
|
||||
| 'travel'
|
||||
| 'commerce'
|
||||
| 'event'
|
||||
| 'content'
|
||||
| 'business'
|
||||
| 'local'
|
||||
| 'form'
|
||||
| 'career';
|
||||
|
||||
export const TEMPLATE_CATEGORIES: { id: TemplateCategoryId; label: string }[] = [
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'travel', label: '旅游出行' },
|
||||
{ id: 'commerce', label: '电商促销' },
|
||||
{ id: 'event', label: '活动邀请' },
|
||||
{ id: 'content', label: '内容展示' },
|
||||
{ id: 'business', label: '商业办公' },
|
||||
{ id: 'local', label: '本地生活' },
|
||||
{ id: 'form', label: '表单问卷' },
|
||||
{ id: 'career', label: '职业简历' },
|
||||
];
|
||||
|
||||
export const TEMPLATE_CATEGORY_MAP: Record<string, TemplateCategoryId> = {
|
||||
'page-template-travel': 'travel',
|
||||
'page-template-hotel': 'travel',
|
||||
'page-template-campaign': 'commerce',
|
||||
'page-template-product-grid': 'commerce',
|
||||
'page-template-event': 'event',
|
||||
'page-template-wedding': 'event',
|
||||
'page-template-charity': 'event',
|
||||
'page-template-blog': 'content',
|
||||
'page-template-portfolio': 'content',
|
||||
'page-template-photography': 'content',
|
||||
'page-template-podcast': 'content',
|
||||
'page-template-saas': 'business',
|
||||
'page-template-agency': 'business',
|
||||
'page-template-startup': 'business',
|
||||
'page-template-course': 'business',
|
||||
'page-template-restaurant': 'local',
|
||||
'page-template-fitness': 'local',
|
||||
'page-template-realestate': 'local',
|
||||
'page-template-survey': 'form',
|
||||
'page-template-resume': 'career',
|
||||
};
|
||||
|
||||
export type TemplateSortId = 'hot' | 'price-asc' | 'price-desc' | 'name';
|
||||
|
||||
export const TEMPLATE_SORT_OPTIONS: { id: TemplateSortId; label: string }[] = [
|
||||
{ id: 'hot', label: '热门优先' },
|
||||
{ id: 'price-asc', label: '价格从低到高' },
|
||||
{ id: 'price-desc', label: '价格从高到低' },
|
||||
{ id: 'name', label: '名称 A-Z' },
|
||||
];
|
||||
|
||||
export function filterTemplatesByCategory(
|
||||
items: PageTemplateCatalogItem[],
|
||||
category: TemplateCategoryId,
|
||||
) {
|
||||
if (category === 'all') return items;
|
||||
return items.filter((item) => TEMPLATE_CATEGORY_MAP[item.skillName] === category);
|
||||
}
|
||||
|
||||
export function sortTemplates(items: PageTemplateCatalogItem[], sort: TemplateSortId) {
|
||||
const next = [...items];
|
||||
switch (sort) {
|
||||
case 'hot':
|
||||
next.sort((a, b) => (b.usageCount ?? 0) - (a.usageCount ?? 0) || a.label.localeCompare(b.label, 'zh'));
|
||||
break;
|
||||
case 'price-asc':
|
||||
next.sort((a, b) => a.priceCents - b.priceCents || a.label.localeCompare(b.label, 'zh'));
|
||||
break;
|
||||
case 'price-desc':
|
||||
next.sort((a, b) => b.priceCents - a.priceCents || a.label.localeCompare(b.label, 'zh'));
|
||||
break;
|
||||
case 'name':
|
||||
next.sort((a, b) => a.label.localeCompare(b.label, 'zh'));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
Reference in New Issue
Block a user