Add smart ACK provider for WeChat MP replies
Replace fixed ackText with a rule-based AckProvider that picks response templates by message type and intent (translate, summary, rewrite, poster, ppt, mindmap, code, search, schedule). Pure sync, zero I/O, auto-falls back to config.ackText on any error. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+229
-42
@@ -3,6 +3,8 @@ import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
const RING_R = 16;
|
||||
const CIRC = 2 * Math.PI * RING_R;
|
||||
const POPOVER_WIDTH = 260;
|
||||
const HEADER_POPOVER_OPEN_EVENT = 'tkmind:header-popover-open';
|
||||
const HEADER_POPOVER_ID = 'balance-ring';
|
||||
|
||||
function formatYuan(cents: number) {
|
||||
return `¥${(cents / 100).toFixed(2)}`;
|
||||
@@ -21,33 +23,101 @@ function formatTokenCount(tokens: number) {
|
||||
return tokens.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function formatCallsApprox(tokens: number) {
|
||||
const calls = Math.floor(tokens / 3000);
|
||||
return `约 ${calls} 次`;
|
||||
}
|
||||
|
||||
type ActiveSubscription = {
|
||||
planType: string;
|
||||
periodTokensLimit: number;
|
||||
periodTokensUsed: number;
|
||||
periodEnd: number;
|
||||
overageRate: number;
|
||||
};
|
||||
|
||||
type BalanceRingProps = {
|
||||
balanceCents: number;
|
||||
totalCreditCents?: number;
|
||||
tokensUsed?: number;
|
||||
subscription?: ActiveSubscription | null;
|
||||
onRecharge: (force?: boolean) => void;
|
||||
onSubscribe?: () => void;
|
||||
};
|
||||
|
||||
const PLAN_LABELS: Record<string, string> = {
|
||||
free: '免费版',
|
||||
lite: '轻量版',
|
||||
standard: '标准版',
|
||||
pro: '专业版',
|
||||
};
|
||||
|
||||
export function BalanceRing({
|
||||
balanceCents,
|
||||
totalCreditCents,
|
||||
tokensUsed = 0,
|
||||
subscription,
|
||||
onRecharge,
|
||||
onSubscribe,
|
||||
}: BalanceRingProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Subscription quota mode: only paid plans change the ring display.
|
||||
// Free plan runs silently in the background — balance ring stays primary.
|
||||
const isPaidPlan = Boolean(subscription) && subscription!.planType !== 'free';
|
||||
const hasSub = isPaidPlan;
|
||||
const subUnlimited = hasSub && subscription!.periodTokensLimit === 0;
|
||||
const subLimit = subscription?.periodTokensLimit ?? 0;
|
||||
const subUsed = subscription?.periodTokensUsed ?? 0;
|
||||
const subRemaining = Math.max(0, subLimit - subUsed);
|
||||
const subPct = subLimit > 0 ? Math.round((subRemaining / subLimit) * 100) : 100;
|
||||
const subLow = hasSub && !subUnlimited && subPct <= 15;
|
||||
const subEmpty = hasSub && !subUnlimited && subRemaining <= 0;
|
||||
|
||||
// Balance mode (used when no active subscription or overage).
|
||||
const total = Math.max(totalCreditCents ?? balanceCents, balanceCents, 0);
|
||||
const spent = Math.max(0, total - balanceCents);
|
||||
const pct = total > 0 ? Math.round((balanceCents / total) * 100) : 0;
|
||||
const balancePct = total > 0 ? Math.round((balanceCents / total) * 100) : 0;
|
||||
const spentLen = total > 0 ? (spent / total) * CIRC : 0;
|
||||
const remainLen = total > 0 ? (balanceCents / total) * CIRC : 0;
|
||||
const spentPct = total > 0 ? (spent / total) * 100 : 0;
|
||||
const remainPct = total > 0 ? (balanceCents / total) * 100 : 0;
|
||||
const balanceLow = balanceCents > 0 && balanceCents <= 100;
|
||||
const balanceEmpty = balanceCents <= 0;
|
||||
|
||||
const low = balanceCents > 0 && balanceCents <= 100;
|
||||
const empty = balanceCents <= 0;
|
||||
// Ring display: subscription quota takes priority over balance.
|
||||
let ringRemainLen: number;
|
||||
let ringSpentLen: number;
|
||||
let centerLabel: string;
|
||||
let ariaLabel: string;
|
||||
let low: boolean;
|
||||
let empty: boolean;
|
||||
|
||||
if (hasSub && !subUnlimited) {
|
||||
const usedLen = subLimit > 0 ? (subUsed / subLimit) * CIRC : 0;
|
||||
ringRemainLen = subLimit > 0 ? (subRemaining / subLimit) * CIRC : CIRC;
|
||||
ringSpentLen = usedLen;
|
||||
centerLabel = subEmpty ? '0%' : `${subPct}%`;
|
||||
ariaLabel = `本月额度,剩余 ${subPct}%`;
|
||||
low = subLow;
|
||||
empty = subEmpty;
|
||||
} else if (hasSub && subUnlimited) {
|
||||
ringRemainLen = CIRC;
|
||||
ringSpentLen = 0;
|
||||
centerLabel = '∞';
|
||||
ariaLabel = '专业版,不限额度';
|
||||
low = false;
|
||||
empty = false;
|
||||
} else {
|
||||
ringRemainLen = remainLen;
|
||||
ringSpentLen = spentLen;
|
||||
centerLabel = total <= 0 ? '—' : balanceEmpty ? '0%' : `${balancePct}%`;
|
||||
ariaLabel = total > 0 ? `账户余额,剩余 ${balancePct}%` : '账户余额';
|
||||
low = balanceLow;
|
||||
empty = balanceEmpty;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -60,6 +130,17 @@ export function BalanceRing({
|
||||
return () => document.removeEventListener('click', handleClick);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOtherPopoverOpen = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ id?: string }>).detail;
|
||||
if (detail?.id && detail.id !== HEADER_POPOVER_ID) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
|
||||
return () => window.removeEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
@@ -87,8 +168,9 @@ export function BalanceRing({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const centerLabel = total <= 0 ? '—' : empty ? '0%' : `${pct}%`;
|
||||
const ariaLabel = total > 0 ? `账户额度,剩余 ${pct}%` : '账户额度';
|
||||
const periodEndDate = subscription
|
||||
? new Date(subscription.periodEnd).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' })
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className={`balance-popover-wrap${open ? ' open' : ''}`} ref={wrapRef}>
|
||||
@@ -99,7 +181,17 @@ export function BalanceRing({
|
||||
aria-expanded={open}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpen((value) => !value);
|
||||
setOpen((value) => {
|
||||
const next = !value;
|
||||
if (next) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(HEADER_POPOVER_OPEN_EVENT, {
|
||||
detail: { id: HEADER_POPOVER_ID },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<svg className="balance-ring-svg" viewBox="0 0 40 40" aria-hidden="true">
|
||||
@@ -109,7 +201,7 @@ export function BalanceRing({
|
||||
cx="20"
|
||||
cy="20"
|
||||
r={RING_R}
|
||||
strokeDasharray={`${spentLen} ${CIRC}`}
|
||||
strokeDasharray={`${ringSpentLen} ${CIRC}`}
|
||||
strokeDashoffset={0}
|
||||
/>
|
||||
<circle
|
||||
@@ -117,37 +209,116 @@ export function BalanceRing({
|
||||
cx="20"
|
||||
cy="20"
|
||||
r={RING_R}
|
||||
strokeDasharray={`${remainLen} ${CIRC}`}
|
||||
strokeDashoffset={-spentLen}
|
||||
strokeDasharray={`${ringRemainLen} ${CIRC}`}
|
||||
strokeDashoffset={-ringSpentLen}
|
||||
/>
|
||||
</svg>
|
||||
<span className="balance-ring-center">{centerLabel}</span>
|
||||
</button>
|
||||
|
||||
<div className="balance-popover" role="dialog" aria-label="账户额度" style={popoverStyle}>
|
||||
<h4>账户额度</h4>
|
||||
<div className="balance-popover-row">
|
||||
<span className="balance-popover-label">
|
||||
<span className="balance-dot balance-dot-remain" aria-hidden="true" />
|
||||
剩余
|
||||
</span>
|
||||
<strong>{formatYuan(balanceCents)}</strong>
|
||||
</div>
|
||||
<div className="balance-popover-row">
|
||||
<span className="balance-popover-label">
|
||||
<span className="balance-dot balance-dot-spent" aria-hidden="true" />
|
||||
已消耗
|
||||
</span>
|
||||
<strong>{formatYuan(spent)}</strong>
|
||||
</div>
|
||||
<div className="balance-popover-row">
|
||||
<span className="balance-popover-label balance-popover-label-muted">累计充值</span>
|
||||
<span>{formatYuan(total)}</span>
|
||||
</div>
|
||||
<div className="balance-popover-bar" aria-hidden="true">
|
||||
<div className="balance-popover-bar-spent" style={{ width: `${spentPct}%` }} />
|
||||
<div className="balance-popover-bar-remain" style={{ width: `${remainPct}%` }} />
|
||||
</div>
|
||||
{hasSub ? (
|
||||
<>
|
||||
<h4>
|
||||
{PLAN_LABELS[subscription!.planType] ?? subscription!.planType}
|
||||
{subscription!.planType !== 'free' && (
|
||||
<span className="balance-plan-badge">订阅中</span>
|
||||
)}
|
||||
</h4>
|
||||
|
||||
{subUnlimited ? (
|
||||
<div className="balance-popover-row">
|
||||
<span className="balance-popover-label balance-popover-label-muted">本月额度</span>
|
||||
<span>不限量</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="balance-popover-row">
|
||||
<span className="balance-popover-label">
|
||||
<span className="balance-dot balance-dot-remain" aria-hidden="true" />
|
||||
剩余额度
|
||||
</span>
|
||||
<strong>{formatCallsApprox(subRemaining)}</strong>
|
||||
</div>
|
||||
<div className="balance-popover-row">
|
||||
<span className="balance-popover-label">
|
||||
<span className="balance-dot balance-dot-spent" aria-hidden="true" />
|
||||
已使用
|
||||
</span>
|
||||
<strong>{formatCallsApprox(subUsed)}</strong>
|
||||
</div>
|
||||
<div className="balance-popover-bar" aria-hidden="true">
|
||||
<div
|
||||
className="balance-popover-bar-spent"
|
||||
style={{ width: `${subLimit > 0 ? (subUsed / subLimit) * 100 : 0}%` }}
|
||||
/>
|
||||
<div
|
||||
className="balance-popover-bar-remain"
|
||||
style={{ width: `${subLimit > 0 ? (subRemaining / subLimit) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{periodEndDate && (
|
||||
<p className="balance-popover-hint">周期截止 {periodEndDate}</p>
|
||||
)}
|
||||
|
||||
<div className="balance-popover-section">
|
||||
<h5>余额账户</h5>
|
||||
<div className="balance-popover-row">
|
||||
<span className="balance-popover-label balance-popover-label-muted">可用余额</span>
|
||||
<span>{formatYuan(balanceCents)}</span>
|
||||
</div>
|
||||
{subscription!.overageRate < 1 && (
|
||||
<p className="balance-popover-hint">
|
||||
超出套餐后按 {Math.round(subscription!.overageRate * 10)} 折从余额扣费
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(subEmpty || subLow) && (
|
||||
<p className="balance-popover-warning">
|
||||
{subEmpty ? '本月额度已用完' : '额度即将耗尽'}
|
||||
{balanceCents > 0 ? ',将从余额扣费' : ',请充值继续使用'}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<h4>账户额度</h4>
|
||||
<div className="balance-popover-row">
|
||||
<span className="balance-popover-label">
|
||||
<span className="balance-dot balance-dot-remain" aria-hidden="true" />
|
||||
剩余
|
||||
</span>
|
||||
<strong>{formatYuan(balanceCents)}</strong>
|
||||
</div>
|
||||
<div className="balance-popover-row">
|
||||
<span className="balance-popover-label">
|
||||
<span className="balance-dot balance-dot-spent" aria-hidden="true" />
|
||||
已消耗
|
||||
</span>
|
||||
<strong>{formatYuan(spent)}</strong>
|
||||
</div>
|
||||
<div className="balance-popover-row">
|
||||
<span className="balance-popover-label balance-popover-label-muted">累计充值</span>
|
||||
<span>{formatYuan(total)}</span>
|
||||
</div>
|
||||
<div className="balance-popover-bar" aria-hidden="true">
|
||||
<div className="balance-popover-bar-spent" style={{ width: `${spentPct}%` }} />
|
||||
<div className="balance-popover-bar-remain" style={{ width: `${remainPct}%` }} />
|
||||
</div>
|
||||
{(balanceLow || balanceEmpty) && (
|
||||
<p className="balance-popover-warning">余额不足,请充值后继续</p>
|
||||
)}
|
||||
{subscription?.planType === 'free' && subscription.periodTokensLimit > 0 && (
|
||||
<p className="balance-popover-hint">
|
||||
每月赠送 {formatCallsApprox(Math.max(0, subscription.periodTokensLimit - subscription.periodTokensUsed))} 免费额度,余额用于超出部分
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="balance-popover-section">
|
||||
<h5>AI 消耗</h5>
|
||||
@@ -158,16 +329,32 @@ export function BalanceRing({
|
||||
<p className="balance-popover-hint">按人民币结算,每次对话按实际用量扣费</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="balance-popover-cta"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onRecharge(empty);
|
||||
}}
|
||||
>
|
||||
去充值
|
||||
</button>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{onSubscribe && (
|
||||
<button
|
||||
type="button"
|
||||
className="balance-popover-cta"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onSubscribe();
|
||||
}}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
{hasSub && subscription!.planType !== 'free' ? '管理套餐' : '升级套餐'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="balance-popover-cta"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onRecharge(empty);
|
||||
}}
|
||||
style={onSubscribe ? { flex: 1 } : {}}
|
||||
>
|
||||
去充值
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { WechatAccountButton } from './WechatAccountButton';
|
||||
const HEADER_POPOVER_OPEN_EVENT = 'tkmind:header-popover-open';
|
||||
const HEADER_POPOVER_ID = 'header-more-menu';
|
||||
|
||||
type MenuItem = {
|
||||
id: string;
|
||||
@@ -39,6 +41,17 @@ export function ChatHeaderMoreMenu({
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOtherPopoverOpen = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ id?: string }>).detail;
|
||||
if (detail?.id && detail.id !== HEADER_POPOVER_ID) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
|
||||
return () => window.removeEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={`header-more-menu${open ? ' open' : ''}`} ref={wrapRef}>
|
||||
<button
|
||||
@@ -47,7 +60,19 @@ export function ChatHeaderMoreMenu({
|
||||
aria-label="更多操作"
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
onClick={() =>
|
||||
setOpen((prev) => {
|
||||
const next = !prev;
|
||||
if (next) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(HEADER_POPOVER_OPEN_EVENT, {
|
||||
detail: { id: HEADER_POPOVER_ID },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return next;
|
||||
})
|
||||
}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<circle cx="12" cy="5" r="1.75" />
|
||||
|
||||
@@ -325,6 +325,7 @@ export function ChatPanel({
|
||||
streaming={chatState === 'streaming'}
|
||||
onAvatarClick={compact ? undefined : openAvatarPicker}
|
||||
onSaveAsPage={(message) => setPageSource(message)}
|
||||
publishUserId={user?.id}
|
||||
publishUsername={user?.username}
|
||||
compact={compact}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ChatPanel } from './ChatPanel';
|
||||
import { WechatBindPrompt } from './WechatBindPrompt';
|
||||
import { WechatAccountButton } from './WechatAccountButton';
|
||||
import { ChatHeaderMoreMenu } from './ChatHeaderMoreMenu';
|
||||
import { NotificationCenter } from './NotificationCenter';
|
||||
import type { MindSpaceSaveCategory } from '../types';
|
||||
|
||||
export function ChatView({
|
||||
@@ -56,7 +57,9 @@ export function ChatView({
|
||||
balanceCents,
|
||||
totalCreditCents,
|
||||
tokensUsed,
|
||||
subscription,
|
||||
openRecharge,
|
||||
openSubscribe,
|
||||
uploadChatImage,
|
||||
} = useChat();
|
||||
const online = useNetworkStatus();
|
||||
@@ -148,9 +151,12 @@ export function ChatView({
|
||||
balanceCents={balanceCents}
|
||||
totalCreditCents={totalCreditCents}
|
||||
tokensUsed={tokensUsed}
|
||||
subscription={subscription}
|
||||
onRecharge={openRecharge}
|
||||
onSubscribe={openSubscribe}
|
||||
/>
|
||||
)}
|
||||
<NotificationCenter onOpenRecharge={() => openRecharge(false)} />
|
||||
{onOpenAdmin && (
|
||||
<button type="button" className="ghost-btn" onClick={onOpenAdmin}>
|
||||
管理
|
||||
@@ -196,9 +202,12 @@ export function ChatView({
|
||||
balanceCents={balanceCents}
|
||||
totalCreditCents={totalCreditCents}
|
||||
tokensUsed={tokensUsed}
|
||||
subscription={subscription}
|
||||
onRecharge={openRecharge}
|
||||
onSubscribe={openSubscribe}
|
||||
/>
|
||||
)}
|
||||
<NotificationCenter onOpenRecharge={() => openRecharge(false)} />
|
||||
{onOpenSpace && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -251,7 +260,7 @@ export function ChatView({
|
||||
|
||||
{notice && (
|
||||
<div className={`banner${notice === INSUFFICIENT_BALANCE_NOTICE ? ' banner-warning' : ' banner-info'}`}>
|
||||
<span>{notice}</span>
|
||||
<span style={{ whiteSpace: 'pre-line' }}>{notice}</span>
|
||||
{notice === INSUFFICIENT_BALANCE_NOTICE ? (
|
||||
<>
|
||||
<button type="button" className="banner-action" onClick={() => openRecharge(false)}>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Message } from '../types';
|
||||
import { getDisplayText, getImageUrls, getThinking } from '../utils/message';
|
||||
import { getMessageSaveActions } from '../utils/messageSave';
|
||||
import { renderMarkdown } from '../utils/markdown';
|
||||
import { filterText } from '../utils/wordFilter';
|
||||
import { formatChatTime, shouldShowTimestamp } from '../utils/time';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
import { UserAvatar } from './UserAvatar';
|
||||
@@ -123,6 +124,7 @@ function MessageRow({
|
||||
onAvatarClick,
|
||||
onSaveAsPage,
|
||||
saveDisabled,
|
||||
publishUserId,
|
||||
publishUsername,
|
||||
compact = false,
|
||||
}: {
|
||||
@@ -131,12 +133,14 @@ function MessageRow({
|
||||
onAvatarClick?: () => void;
|
||||
onSaveAsPage?: (message: Message) => void;
|
||||
saveDisabled?: boolean;
|
||||
publishUserId?: string;
|
||||
publishUsername?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const text = getDisplayText(message);
|
||||
const saveActions = getMessageSaveActions(text, publishUsername);
|
||||
const rawText = getDisplayText(message);
|
||||
const text = filterText(rawText);
|
||||
const saveActions = getMessageSaveActions(text, { userId: publishUserId, username: publishUsername });
|
||||
const thinking = getThinking(message);
|
||||
const isUser = message.role === 'user';
|
||||
const imageUrls = getImageUrls(message);
|
||||
@@ -283,6 +287,7 @@ export function MessageList({
|
||||
streaming,
|
||||
onAvatarClick,
|
||||
onSaveAsPage,
|
||||
publishUserId,
|
||||
publishUsername,
|
||||
compact = false,
|
||||
}: {
|
||||
@@ -290,6 +295,7 @@ export function MessageList({
|
||||
streaming: boolean;
|
||||
onAvatarClick?: () => void;
|
||||
onSaveAsPage?: (message: Message) => void;
|
||||
publishUserId?: string;
|
||||
publishUsername?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
@@ -316,6 +322,7 @@ export function MessageList({
|
||||
onAvatarClick={onAvatarClick}
|
||||
onSaveAsPage={onSaveAsPage}
|
||||
saveDisabled={streaming}
|
||||
publishUserId={publishUserId}
|
||||
publishUsername={publishUsername}
|
||||
compact={compact}
|
||||
/>
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { usePageDraftHistory, type PageDraftSnapshot } from '../hooks/usePageDraftHistory';
|
||||
import {
|
||||
checkMindSpacePagePublication,
|
||||
redactMindSpacePage,
|
||||
deleteMindSpacePage,
|
||||
fixMindSpacePagePublication,
|
||||
getMindSpacePageDeletePreview,
|
||||
getMindSpacePage,
|
||||
getMindSpacePublicationStats,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
offlineMindSpacePublication,
|
||||
publishMindSpacePage,
|
||||
publishPageToPlaza,
|
||||
redactMindSpacePage,
|
||||
updateMindSpacePage,
|
||||
} from '../api/client';
|
||||
import type {
|
||||
@@ -628,13 +629,24 @@ export function MindSpacePageDetail({
|
||||
setError(null);
|
||||
setFixNotice(null);
|
||||
try {
|
||||
const result = await redactMindSpacePage(page.id, {
|
||||
pageVersionId: page.currentVersionId,
|
||||
expectedVersion: page.versionNo,
|
||||
title,
|
||||
summary,
|
||||
content,
|
||||
});
|
||||
let result;
|
||||
try {
|
||||
result = await fixMindSpacePagePublication(page.id, {
|
||||
pageVersionId: page.currentVersionId,
|
||||
expectedVersion: page.versionNo,
|
||||
title,
|
||||
summary,
|
||||
content,
|
||||
});
|
||||
} catch {
|
||||
result = await redactMindSpacePage(page.id, {
|
||||
pageVersionId: page.currentVersionId,
|
||||
expectedVersion: page.versionNo,
|
||||
title,
|
||||
summary,
|
||||
content,
|
||||
});
|
||||
}
|
||||
const updatedPage = result.page;
|
||||
applyPageRecord(updatedPage, { recordHistory: true });
|
||||
setFixNotice(result.changes);
|
||||
@@ -1333,7 +1345,7 @@ export function MindSpacePageDetail({
|
||||
onFrameReady={setPreviewFrame}
|
||||
reloadKey={previewKey}
|
||||
minHeight={680}
|
||||
maxHeight={12000}
|
||||
maxHeight={Number.POSITIVE_INFINITY}
|
||||
/>
|
||||
)}
|
||||
{page.contentFormat === 'html' && (
|
||||
|
||||
@@ -130,7 +130,7 @@ export function MindSpacePageEditablePreviewFrame({
|
||||
ref={iframeRef}
|
||||
title={`${title || '页面'} 预览`}
|
||||
srcDoc={srcdoc}
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
sandbox="allow-scripts"
|
||||
scrolling={scrollInsideFrame ? 'yes' : 'no'}
|
||||
className={frameClassName}
|
||||
style={scrollInsideFrame ? { height: '100%' } : { height: `${height}px` }}
|
||||
|
||||
@@ -51,6 +51,8 @@ export function MindSpacePagePreviewPanel({
|
||||
const [thumbNotice, setThumbNotice] = useState<string | null>(null);
|
||||
const [thumbError, setThumbError] = useState<string | null>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
const thumbnailRefreshTokenRef = useRef(0);
|
||||
const latestDraftRef = useRef({ title, summary, content });
|
||||
|
||||
const thumbnailUrl = `/api/mindspace/v1/pages/${pageId}/thumbnail?v=${thumbnailVersion}`;
|
||||
|
||||
@@ -62,6 +64,38 @@ export function MindSpacePagePreviewPanel({
|
||||
setThumbnailFailed(false);
|
||||
}, [thumbnailUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
latestDraftRef.current = { title, summary, content };
|
||||
}, [content, summary, title]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const refreshToken = ++thumbnailRefreshTokenRef.current;
|
||||
const { title: draftTitle, summary: draftSummary, content: draftContent } = latestDraftRef.current;
|
||||
|
||||
const syncThumbnail = async () => {
|
||||
try {
|
||||
const result = await regenerateMindSpacePageThumbnail(pageId, {
|
||||
title: draftTitle,
|
||||
summary: draftSummary,
|
||||
content: draftContent,
|
||||
});
|
||||
if (cancelled || thumbnailRefreshTokenRef.current !== refreshToken) return;
|
||||
setThumbnailVersion(result.updatedAt ?? Date.now());
|
||||
setThumbnailFailed(false);
|
||||
} catch {
|
||||
if (cancelled || thumbnailRefreshTokenRef.current !== refreshToken) return;
|
||||
setThumbnailFailed(false);
|
||||
}
|
||||
};
|
||||
|
||||
void syncThumbnail();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pageId, reloadKey]);
|
||||
|
||||
const bumpThumbnail = () => {
|
||||
setThumbnailVersion(Date.now());
|
||||
setThumbnailFailed(false);
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
retryMindSpaceAgentJob,
|
||||
runMindSpaceAgentJob,
|
||||
runMindSpaceCleanup,
|
||||
purchaseSpaceQuota,
|
||||
uploadMindSpaceAsset,
|
||||
ApiError,
|
||||
} from '../api/client';
|
||||
@@ -58,6 +59,7 @@ import { buildAssetSharePayload, type SharePayload } from '../utils/shareChannel
|
||||
import { resolvePublicPageUrl, resolvePlazaHomeUrl } from '../utils/publicUrl';
|
||||
import { PagePreviewFrame } from './PagePreviewFrame';
|
||||
import { BalanceRing } from './BalanceRing';
|
||||
import { NotificationCenter } from './NotificationCenter';
|
||||
|
||||
const CATEGORY_DESCRIPTIONS: Record<MindSpaceCategory['code'], string> = {
|
||||
oa: '上传文档、表格和资料,让 AI 生成报告与页面',
|
||||
@@ -77,7 +79,7 @@ const CATEGORY_ACTIONS: Partial<Record<MindSpaceCategory['code'], string>> = {
|
||||
const AGENT_JOBS_PAGE_SIZE = 10;
|
||||
const RECENT_PAGES_PAGE_SIZE = 6;
|
||||
const IMAGE_PAGE_SIZE = 10;
|
||||
const MAX_UPLOAD_FILE_BYTES = 2 * 1024 * 1024;
|
||||
const DEFAULT_MAX_UPLOAD_FILE_BYTES = 5 * 1024 * 1024;
|
||||
const UPLOAD_FILE_EXTENSIONS = [
|
||||
'.doc',
|
||||
'.docx',
|
||||
@@ -135,6 +137,35 @@ const PREVIEW_SCHEDULE_ITEMS = [
|
||||
},
|
||||
] as const;
|
||||
|
||||
function formatScheduleClock(value: number | null | undefined, timezone?: string | null) {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return '—';
|
||||
try {
|
||||
return new Intl.DateTimeFormat('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
timeZone: timezone || undefined,
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return new Date(value).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatDigestSchedule(digest: {
|
||||
hour: number;
|
||||
minute: number;
|
||||
timezone?: string | null;
|
||||
nextRunAt?: number | null;
|
||||
}) {
|
||||
const time = `${String(digest.hour).padStart(2, '0')}:${String(digest.minute).padStart(2, '0')}`;
|
||||
const nextRun = formatScheduleClock(digest.nextRunAt, digest.timezone);
|
||||
return nextRun === '—' ? time : `${time} · 下次 ${nextRun}`;
|
||||
}
|
||||
|
||||
const PREVIEW_PAGE_READERS: Record<string, { kicker: string; body: string[] }> = {
|
||||
'page-1': {
|
||||
kicker: '市场趋势',
|
||||
@@ -182,8 +213,13 @@ const LONG_IMAGE_VIEWPORT_WIDTH = 1280;
|
||||
const LONG_IMAGE_VIEWPORT_HEIGHT = 720;
|
||||
const LONG_IMAGE_RENDER_SCALE = 2;
|
||||
const LONG_IMAGE_MAX_PIXELS = 64_000_000;
|
||||
const SPACE_PURCHASE_PRESETS_MB = [5, 10, 20];
|
||||
let html2CanvasLoader: Promise<Html2CanvasRenderer> | null = null;
|
||||
|
||||
function formatYuan(cents: number) {
|
||||
return `¥${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
async function loadHtml2CanvasRenderer(): Promise<Html2CanvasRenderer> {
|
||||
const existing = (window as Html2CanvasWindow).html2canvas;
|
||||
if (existing) return existing;
|
||||
@@ -246,10 +282,10 @@ function fileExtension(filename: string) {
|
||||
return dotIndex >= 0 ? filename.slice(dotIndex).toLowerCase() : '';
|
||||
}
|
||||
|
||||
function validateSelectedUploadFile(file: File) {
|
||||
function validateSelectedUploadFile(file: File, maxBytes: number) {
|
||||
if (file.size <= 0) return '文件不能为空,请重新选择。';
|
||||
if (file.size > MAX_UPLOAD_FILE_BYTES) {
|
||||
return `文件不能超过 ${formatBytes(MAX_UPLOAD_FILE_BYTES)},当前为 ${formatBytes(file.size)}。`;
|
||||
if (file.size > maxBytes) {
|
||||
return `文件不能超过 ${formatBytes(maxBytes)},当前为 ${formatBytes(file.size)}。`;
|
||||
}
|
||||
const extension = fileExtension(file.name);
|
||||
if (!UPLOAD_FILE_EXTENSIONS.includes(extension as (typeof UPLOAD_FILE_EXTENSIONS)[number])) {
|
||||
@@ -388,18 +424,25 @@ type MindSpaceRouteSync = {
|
||||
};
|
||||
|
||||
function MindSpaceBalanceRing() {
|
||||
const { balanceCents, totalCreditCents, tokensUsed, openRecharge } = useChat();
|
||||
const { balanceCents, totalCreditCents, tokensUsed, subscription, openRecharge, openSubscribe } = useChat();
|
||||
if (typeof balanceCents !== 'number') return null;
|
||||
return (
|
||||
<BalanceRing
|
||||
balanceCents={balanceCents}
|
||||
totalCreditCents={totalCreditCents}
|
||||
tokensUsed={tokensUsed}
|
||||
subscription={subscription}
|
||||
onRecharge={openRecharge}
|
||||
onSubscribe={openSubscribe}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MindSpaceNotificationCenter() {
|
||||
const { openRecharge } = useChat();
|
||||
return <NotificationCenter onOpenRecharge={() => openRecharge(false)} />;
|
||||
}
|
||||
|
||||
export function MindSpaceView({
|
||||
user,
|
||||
previewMode = false,
|
||||
@@ -431,6 +474,8 @@ export function MindSpaceView({
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
|
||||
const [selectedAssetIds, setSelectedAssetIds] = useState<string[]>([]);
|
||||
const [bulkDeletingAssets, setBulkDeletingAssets] = useState(false);
|
||||
const [newPageOpen, setNewPageOpen] = useState(false);
|
||||
const [newPageTitle, setNewPageTitle] = useState('');
|
||||
const [newPageContent, setNewPageContent] = useState('');
|
||||
@@ -477,7 +522,11 @@ export function MindSpaceView({
|
||||
} | null>(null);
|
||||
const [pageRefreshTrigger, setPageRefreshTrigger] = useState(0);
|
||||
const [pageFullscreenPreviewOpen, setPageFullscreenPreviewOpen] = useState(false);
|
||||
const { chatState, messages, session } = useChat();
|
||||
const [spacePurchaseOpen, setSpacePurchaseOpen] = useState(false);
|
||||
const [spacePurchaseMb, setSpacePurchaseMb] = useState('5');
|
||||
const [spacePurchasePending, setSpacePurchasePending] = useState(false);
|
||||
const [spacePurchaseMessage, setSpacePurchaseMessage] = useState<string | null>(null);
|
||||
const { chatState, messages, session, balanceCents, completeRecharge, openRecharge } = useChat();
|
||||
const prevChatStateRef = useRef(chatState);
|
||||
const h5ApiBase = useMemo(() => resolveH5ApiBase(), []);
|
||||
const location = useLocation();
|
||||
@@ -489,6 +538,7 @@ export function MindSpaceView({
|
||||
const params = new URLSearchParams(location.search);
|
||||
return params.get('return_to') || resolvePlazaHomeUrl();
|
||||
}, [location.search]);
|
||||
const maxUploadFileBytes = space?.quota.maxFileBytes ?? DEFAULT_MAX_UPLOAD_FILE_BYTES;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPageId) {
|
||||
@@ -688,6 +738,7 @@ export function MindSpaceView({
|
||||
setSelectedCategory(null);
|
||||
setUploadOpen(false);
|
||||
setPendingDeleteId(null);
|
||||
setSelectedAssetIds([]);
|
||||
setImagePage(0);
|
||||
routeSync?.pushHome();
|
||||
};
|
||||
@@ -953,7 +1004,7 @@ export function MindSpaceView({
|
||||
|
||||
const submitUpload = async () => {
|
||||
if (!selectedCategory || !selectedFile) return;
|
||||
const validationError = validateSelectedUploadFile(selectedFile);
|
||||
const validationError = validateSelectedUploadFile(selectedFile, maxUploadFileBytes);
|
||||
if (validationError) {
|
||||
setUploadError(validationError);
|
||||
return;
|
||||
@@ -968,8 +1019,8 @@ export function MindSpaceView({
|
||||
try {
|
||||
const fileToUpload = selectedFile.type.startsWith('image/')
|
||||
? await compressImageForUpload(selectedFile, {
|
||||
maxInputBytes: MAX_UPLOAD_FILE_BYTES,
|
||||
maxOutputBytes: MAX_UPLOAD_FILE_BYTES,
|
||||
maxInputBytes: maxUploadFileBytes,
|
||||
maxOutputBytes: maxUploadFileBytes,
|
||||
maxDimension: 1600,
|
||||
})
|
||||
: selectedFile;
|
||||
@@ -985,6 +1036,34 @@ export function MindSpaceView({
|
||||
}
|
||||
};
|
||||
|
||||
const submitSpacePurchase = async (requestedMb?: number) => {
|
||||
const sizeMb = Math.floor(Number(requestedMb ?? spacePurchaseMb));
|
||||
if (!Number.isFinite(sizeMb) || sizeMb <= 0) {
|
||||
setSpacePurchaseMessage('请输入大于 0 的空间大小(MB)');
|
||||
return;
|
||||
}
|
||||
setSpacePurchasePending(true);
|
||||
setSpacePurchaseMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await purchaseSpaceQuota(sizeMb);
|
||||
completeRecharge(result.balanceCents);
|
||||
await refreshMindSpaceSnapshot({ quota: result.quota });
|
||||
setSpacePurchaseMb(String(sizeMb));
|
||||
setSpacePurchaseMessage(
|
||||
`已扩容 ${sizeMb} MB,支付 ¥${(result.costCents / 100).toFixed(2)}。`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.code === 'INSUFFICIENT_BALANCE') {
|
||||
setSpacePurchaseMessage('余额不足,请先到余额里充值,再回来购买空间。');
|
||||
} else {
|
||||
setSpacePurchaseMessage(err instanceof Error ? err.message : '购买空间失败');
|
||||
}
|
||||
} finally {
|
||||
setSpacePurchasePending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0] ?? null;
|
||||
if (!file) {
|
||||
@@ -992,7 +1071,7 @@ export function MindSpaceView({
|
||||
setUploadError(null);
|
||||
return;
|
||||
}
|
||||
const validationError = validateSelectedUploadFile(file);
|
||||
const validationError = validateSelectedUploadFile(file, maxUploadFileBytes);
|
||||
if (validationError) {
|
||||
setSelectedFile(null);
|
||||
setUploadError(validationError);
|
||||
@@ -1014,6 +1093,7 @@ export function MindSpaceView({
|
||||
try {
|
||||
await deleteMindSpaceAsset(assetId);
|
||||
setPendingDeleteId(null);
|
||||
setSelectedAssetIds((ids) => ids.filter((id) => id !== assetId));
|
||||
await load();
|
||||
await openCategory(selectedCategory);
|
||||
} catch (err) {
|
||||
@@ -1050,6 +1130,10 @@ export function MindSpaceView({
|
||||
const usedPercent = space
|
||||
? Math.min(100, (occupiedBytes / space.quota.quotaBytes) * 100)
|
||||
: 0;
|
||||
const currentBalanceCents = typeof balanceCents === 'number' ? balanceCents : user.balanceCents;
|
||||
const purchaseMbValue = Math.max(0, Math.floor(Number(spacePurchaseMb) || 0));
|
||||
const purchaseCostCents = purchaseMbValue * 200;
|
||||
const canPurchaseSpace = purchaseMbValue > 0 && currentBalanceCents >= purchaseCostCents;
|
||||
|
||||
const openAssetEditor = async (asset: MindSpaceAsset) => {
|
||||
if (previewMode) {
|
||||
@@ -1186,6 +1270,70 @@ export function MindSpaceView({
|
||||
: assetFilter === 'pages'
|
||||
? workAssets
|
||||
: [];
|
||||
const visibleSelectableAssets = [...pagedImageAssets, ...visibleCardAssets];
|
||||
const selectedAssetSet = useMemo(() => new Set(selectedAssetIds), [selectedAssetIds]);
|
||||
const allVisibleAssetsSelected =
|
||||
visibleSelectableAssets.length > 0 &&
|
||||
visibleSelectableAssets.every((asset) => selectedAssetSet.has(asset.id));
|
||||
|
||||
useEffect(() => {
|
||||
const availableIds = new Set(assets.map((asset) => asset.id));
|
||||
setSelectedAssetIds((ids) => ids.filter((id) => availableIds.has(id)));
|
||||
}, [assets]);
|
||||
|
||||
const toggleAssetSelected = (assetId: string) => {
|
||||
setSelectedAssetIds((ids) =>
|
||||
ids.includes(assetId) ? ids.filter((id) => id !== assetId) : [...ids, assetId],
|
||||
);
|
||||
};
|
||||
|
||||
const toggleVisibleAssetsSelected = () => {
|
||||
const visibleIds = visibleSelectableAssets.map((asset) => asset.id);
|
||||
if (allVisibleAssetsSelected) {
|
||||
setSelectedAssetIds((ids) => ids.filter((id) => !visibleIds.includes(id)));
|
||||
return;
|
||||
}
|
||||
setSelectedAssetIds((ids) => Array.from(new Set([...ids, ...visibleIds])));
|
||||
};
|
||||
|
||||
const removeSelectedAssets = async () => {
|
||||
if (!selectedCategory || selectedAssetIds.length === 0) return;
|
||||
if (previewMode) {
|
||||
setError(previewBlocked().message);
|
||||
return;
|
||||
}
|
||||
setBulkDeletingAssets(true);
|
||||
setError(null);
|
||||
setAssetDeleteDialog(null);
|
||||
const deletingIds = [...selectedAssetIds];
|
||||
const failedIds: string[] = [];
|
||||
const failedNames: string[] = [];
|
||||
try {
|
||||
for (const assetId of deletingIds) {
|
||||
try {
|
||||
await deleteMindSpaceAsset(assetId);
|
||||
} catch (err) {
|
||||
const assetName = assets.find((asset) => asset.id === assetId)?.displayName ?? assetId;
|
||||
failedIds.push(assetId);
|
||||
failedNames.push(assetName);
|
||||
}
|
||||
}
|
||||
setSelectedAssetIds((ids) => ids.filter((id) => failedIds.includes(id)));
|
||||
await load();
|
||||
await openCategory(selectedCategory);
|
||||
if (failedNames.length > 0) {
|
||||
setAssetDeleteDialog({
|
||||
message: `有 ${failedNames.length} 个资产未删除,可能正在被页面使用。`,
|
||||
references: failedNames.slice(0, 6).map((title, index) => ({
|
||||
id: `${index}`,
|
||||
title,
|
||||
})),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setBulkDeletingAssets(false);
|
||||
}
|
||||
};
|
||||
|
||||
const previewAsset = useMemo(
|
||||
() => assets.find((asset) => asset.id === previewAssetId) ?? null,
|
||||
@@ -1219,6 +1367,16 @@ export function MindSpaceView({
|
||||
AI 生成页面
|
||||
</button>
|
||||
)}
|
||||
{asset.publicUrl && (
|
||||
<a
|
||||
className="mindspace-asset-share"
|
||||
href={asset.publicUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
公网图
|
||||
</a>
|
||||
)}
|
||||
{asset.status === 'ready' && asset.scanStatus !== 'blocked' ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -1315,7 +1473,19 @@ export function MindSpaceView({
|
||||
|
||||
const showHomeModules =
|
||||
space && !selectedCategory && !selectedPageId && !newPageOpen && !agentJobsPanelOpen && !allPagesOpen;
|
||||
const scheduleItems = previewMode ? PREVIEW_SCHEDULE_ITEMS : [];
|
||||
const scheduleItems = previewMode
|
||||
? PREVIEW_SCHEDULE_ITEMS
|
||||
: (space?.schedule?.todayTodoItems ?? []).map((item) => ({
|
||||
id: item.id,
|
||||
time: item.allDay ? '全天' : formatScheduleClock(item.dueAt ?? item.startAt, item.timezone),
|
||||
kind: item.kind === 'event' ? '日程' : '待办',
|
||||
title: item.title,
|
||||
meta:
|
||||
item.description?.trim() ||
|
||||
(item.sourceChannel === 'wechat' ? '来自服务号' : '来自空间同步'),
|
||||
tone: item.kind === 'event' ? 'event' : 'task',
|
||||
}));
|
||||
const scheduleDigests = previewMode ? [] : space?.schedule?.digestSubscriptions ?? [];
|
||||
const selectedPreviewPage = selectedPageId
|
||||
? pages.find((page) => page.id === selectedPageId) ?? null
|
||||
: null;
|
||||
@@ -1369,6 +1539,11 @@ export function MindSpaceView({
|
||||
) : (
|
||||
<MindSpaceBalanceRing />
|
||||
)}
|
||||
{previewMode ? (
|
||||
<NotificationCenter onOpenRecharge={() => {}} />
|
||||
) : (
|
||||
<MindSpaceNotificationCenter />
|
||||
)}
|
||||
<span>{user.displayName}</span>
|
||||
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
|
||||
登出
|
||||
@@ -1417,7 +1592,28 @@ export function MindSpaceView({
|
||||
{space && (
|
||||
<aside className="mindspace-hero-quota">
|
||||
<div className="mindspace-hero-quota-top">
|
||||
<span>免费版空间</span>
|
||||
<span className="mindspace-hero-quota-label">
|
||||
<span>空间容量</span>
|
||||
<button
|
||||
type="button"
|
||||
className="mindspace-hero-quota-buy-trigger"
|
||||
aria-label="购买空间扩容"
|
||||
onClick={() => {
|
||||
setSpacePurchaseMessage(null);
|
||||
setSpacePurchaseOpen(true);
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 5v14M5 12h14"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<span>扩容</span>
|
||||
</button>
|
||||
</span>
|
||||
<strong>
|
||||
{formatBytes(occupiedBytes)} / {formatBytes(space.quota.quotaBytes)}
|
||||
</strong>
|
||||
@@ -1438,6 +1634,7 @@ export function MindSpaceView({
|
||||
含上传预留 {formatBytes(space.quota.reservedBytes)} ·{' '}
|
||||
</>
|
||||
)}
|
||||
剩余 {formatBytes(space.quota.availableBytes)} ·{' '}
|
||||
单文件上限 {formatBytes(space.quota.maxFileBytes)} · 可发布页面{' '}
|
||||
{space.quota.publicPageUsed}/{space.quota.publicPageLimit} · 今日 AI{' '}
|
||||
{space.quota.aiDailyUsed}/{space.quota.aiDailyLimit}
|
||||
@@ -1541,8 +1738,30 @@ export function MindSpaceView({
|
||||
</div>
|
||||
<div className="mindspace-schedule-summary">
|
||||
<strong>今天 {scheduleItems.length || 0} 项</strong>
|
||||
<span>{scheduleItems.length ? '1 个提醒即将推送' : '还没有安排'}</span>
|
||||
<span>
|
||||
{scheduleItems.length > 0
|
||||
? '已有待办记录'
|
||||
: scheduleDigests.length > 0
|
||||
? `${scheduleDigests.length} 个服务号待办推送`
|
||||
: '还没有安排'}
|
||||
</span>
|
||||
</div>
|
||||
{scheduleDigests.length > 0 && (
|
||||
<div className="mindspace-schedule-list">
|
||||
{scheduleDigests.map((digest) => (
|
||||
<article className="mindspace-schedule-item" key={digest.id}>
|
||||
<time>{String(digest.hour).padStart(2, '0')}:{String(digest.minute).padStart(2, '0')}</time>
|
||||
<span className={`mindspace-schedule-kind is-${digest.status === 'active' ? 'event' : 'reminder'}`}>
|
||||
订阅
|
||||
</span>
|
||||
<div>
|
||||
<strong>每天待办摘要</strong>
|
||||
<small>{formatDigestSchedule(digest)}</small>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{scheduleItems.length > 0 ? (
|
||||
<div className="mindspace-schedule-list">
|
||||
{scheduleItems.map((item) => (
|
||||
@@ -1859,6 +2078,33 @@ export function MindSpaceView({
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mindspace-asset-bulkbar">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleVisibleAssetsSelected}
|
||||
disabled={visibleSelectableAssets.length === 0 || bulkDeletingAssets}
|
||||
>
|
||||
{allVisibleAssetsSelected ? '取消当前列表' : '选择当前列表'}
|
||||
</button>
|
||||
<span>已选 {selectedAssetIds.length} 项</span>
|
||||
{selectedAssetIds.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedAssetIds([])}
|
||||
disabled={bulkDeletingAssets}
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="mindspace-asset-bulk-delete"
|
||||
onClick={() => void removeSelectedAssets()}
|
||||
disabled={selectedAssetIds.length === 0 || bulkDeletingAssets}
|
||||
>
|
||||
{bulkDeletingAssets ? '删除中…' : '删除选中'}
|
||||
</button>
|
||||
</div>
|
||||
{visibleImageAssets.length > 0 && (
|
||||
<>
|
||||
{assetFilter === 'all' && (
|
||||
@@ -1875,6 +2121,14 @@ export function MindSpaceView({
|
||||
className={`mindspace-image-card${imagePaginationEnabled ? ' is-compact' : ''}`}
|
||||
key={asset.id}
|
||||
>
|
||||
<label className="mindspace-asset-select">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAssetSet.has(asset.id)}
|
||||
onChange={() => toggleAssetSelected(asset.id)}
|
||||
/>
|
||||
<span>选择</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="mindspace-image-thumb"
|
||||
@@ -1941,6 +2195,14 @@ export function MindSpaceView({
|
||||
className={`mindspace-item-card${isWorkAsset(asset) ? ' is-work' : ' is-file'}`}
|
||||
key={asset.id}
|
||||
>
|
||||
<label className="mindspace-asset-select is-card">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAssetSet.has(asset.id)}
|
||||
onChange={() => toggleAssetSelected(asset.id)}
|
||||
/>
|
||||
<span>选择</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="mindspace-item-card-main"
|
||||
@@ -2184,7 +2446,7 @@ export function MindSpaceView({
|
||||
</button>
|
||||
</div>
|
||||
<p className="mindspace-upload-dialog-desc">
|
||||
支持 {UPLOAD_FILE_TYPE_LABEL},单文件最大 {formatBytes(MAX_UPLOAD_FILE_BYTES)}。
|
||||
支持 {UPLOAD_FILE_TYPE_LABEL},单文件最大 {formatBytes(maxUploadFileBytes)}。
|
||||
</p>
|
||||
<input
|
||||
type="file"
|
||||
@@ -2337,6 +2599,107 @@ export function MindSpaceView({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<MindSpaceModal
|
||||
open={spacePurchaseOpen && Boolean(space)}
|
||||
onClose={() => setSpacePurchaseOpen(false)}
|
||||
title="购买空间"
|
||||
eyebrow="SPACE UPGRADE"
|
||||
className="mindspace-space-purchase-dialog"
|
||||
disableClose={spacePurchasePending}
|
||||
>
|
||||
{space && (
|
||||
<>
|
||||
<p className="mindspace-upload-dialog-desc">
|
||||
仅支持使用余额支付。购买成功后会立即同步后台空间配额,并在当前页面实时生效。
|
||||
</p>
|
||||
<div className="mindspace-space-purchase-summary">
|
||||
<div>
|
||||
<span>当前余额</span>
|
||||
<strong>{formatYuan(currentBalanceCents)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>当前剩余空间</span>
|
||||
<strong>{formatBytes(space.quota.availableBytes)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>购买价格</span>
|
||||
<strong>{purchaseMbValue > 0 ? `${formatYuan(purchaseCostCents)} / ${purchaseMbValue} MB` : '请选择容量'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mindspace-hero-purchase">
|
||||
<div className="mindspace-hero-purchase-row">
|
||||
{SPACE_PURCHASE_PRESETS_MB.map((sizeMb) => (
|
||||
<button
|
||||
key={sizeMb}
|
||||
type="button"
|
||||
className={`mindspace-hero-purchase-chip${purchaseMbValue === sizeMb ? ' is-active' : ''}`}
|
||||
disabled={spacePurchasePending}
|
||||
onClick={() => {
|
||||
setSpacePurchaseMb(String(sizeMb));
|
||||
setSpacePurchaseMessage(null);
|
||||
}}
|
||||
>
|
||||
{sizeMb} MB
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mindspace-hero-purchase-row">
|
||||
<input
|
||||
className="mindspace-hero-purchase-input"
|
||||
type="number"
|
||||
min="1"
|
||||
step="1"
|
||||
value={spacePurchaseMb}
|
||||
onChange={(event) => {
|
||||
setSpacePurchaseMb(event.target.value);
|
||||
setSpacePurchaseMessage(null);
|
||||
}}
|
||||
placeholder="自定义 MB"
|
||||
/>
|
||||
</div>
|
||||
<p className="mindspace-hero-purchase-meta">
|
||||
价格为每 1 MB = ¥2,购买后直接增加总容量,不展示在首页主区。
|
||||
</p>
|
||||
{spacePurchaseMessage && (
|
||||
<p className="mindspace-hero-purchase-message">{spacePurchaseMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
{!canPurchaseSpace && purchaseMbValue > 0 && (
|
||||
<div className="mindspace-space-purchase-recharge">
|
||||
<span>当前余额不足以支付这次扩容。</span>
|
||||
<button
|
||||
type="button"
|
||||
className="mindspace-secondary"
|
||||
onClick={() => {
|
||||
setSpacePurchaseOpen(false);
|
||||
openRecharge(false);
|
||||
}}
|
||||
>
|
||||
去余额充值
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mindspace-page-editor-actions">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSpacePurchaseOpen(false)}
|
||||
disabled={spacePurchasePending}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="mindspace-primary"
|
||||
onClick={() => void submitSpacePurchase()}
|
||||
disabled={spacePurchasePending || purchaseMbValue <= 0 || !canPurchaseSpace}
|
||||
>
|
||||
{spacePurchasePending ? '支付中…' : '确认购买'}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</MindSpaceModal>
|
||||
|
||||
<MindSpaceModal
|
||||
open={Boolean(pageDeleteTarget)}
|
||||
onClose={closePageDeleteDialog}
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import {
|
||||
clearNotifications,
|
||||
deleteNotification,
|
||||
listNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
subscribeNotificationEvents,
|
||||
} from '../api/client';
|
||||
import type { UserNotification } from '../types';
|
||||
|
||||
const POPOVER_WIDTH = 340;
|
||||
const HEADER_POPOVER_OPEN_EVENT = 'tkmind:header-popover-open';
|
||||
const HEADER_POPOVER_ID = 'notification-center';
|
||||
|
||||
function formatTime(timestamp: number) {
|
||||
const date = new Date(timestamp);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
return `${date.getMonth() + 1}/${date.getDate()} ${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
type Filter = 'all' | 'unread' | 'read';
|
||||
|
||||
function parseNotificationLines(body: string) {
|
||||
return body
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function NotificationCenter({
|
||||
onOpenRecharge,
|
||||
}: {
|
||||
onOpenRecharge?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const [items, setItems] = useState<UserNotification[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const unreadCount = useMemo(
|
||||
() => items.reduce((count, item) => count + (item.status === 'unread' ? 1 : 0), 0),
|
||||
[items],
|
||||
);
|
||||
|
||||
const load = async (nextFilter = filter) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const notifications = await listNotifications(nextFilter, 50);
|
||||
setItems(notifications);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '读取通知失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const syncAll = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void load('all');
|
||||
}
|
||||
};
|
||||
void load('all');
|
||||
const unsubscribe = subscribeNotificationEvents({
|
||||
onNotification: () => void load('all'),
|
||||
onSync: () => void load('all'),
|
||||
});
|
||||
window.addEventListener('focus', syncAll);
|
||||
document.addEventListener('visibilitychange', syncAll);
|
||||
return () => {
|
||||
unsubscribe();
|
||||
window.removeEventListener('focus', syncAll);
|
||||
document.removeEventListener('visibilitychange', syncAll);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleClick = (event: MouseEvent) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handleClick);
|
||||
return () => document.removeEventListener('click', handleClick);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOtherPopoverOpen = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ id?: string }>).detail;
|
||||
if (detail?.id && detail.id !== HEADER_POPOVER_ID) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
|
||||
return () => window.removeEventListener(HEADER_POPOVER_OPEN_EVENT, handleOtherPopoverOpen);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const updatePosition = () => {
|
||||
const anchor = wrapRef.current;
|
||||
if (!anchor) return;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const width = Math.min(POPOVER_WIDTH, window.innerWidth - 24);
|
||||
let left = rect.left + rect.width / 2 - width / 2;
|
||||
left = Math.max(12, Math.min(left, window.innerWidth - width - 12));
|
||||
setPopoverStyle({
|
||||
position: 'fixed',
|
||||
top: rect.bottom + 10,
|
||||
left,
|
||||
width,
|
||||
});
|
||||
};
|
||||
updatePosition();
|
||||
window.addEventListener('resize', updatePosition);
|
||||
window.addEventListener('scroll', updatePosition, true);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
window.removeEventListener('scroll', updatePosition, true);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const visibleItems = filter === 'all' ? items : items.filter((item) => item.status === filter);
|
||||
|
||||
return (
|
||||
<div className={`notification-center${open ? ' open' : ''}`} ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn notification-center-trigger"
|
||||
aria-label="通知中心"
|
||||
aria-expanded={open}
|
||||
onClick={() => {
|
||||
setOpen((value) => {
|
||||
const next = !value;
|
||||
if (next) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(HEADER_POPOVER_OPEN_EVENT, {
|
||||
detail: { id: HEADER_POPOVER_ID },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (!open) void load(filter);
|
||||
}}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 4a5 5 0 00-5 5v2.7c0 .5-.18.97-.5 1.33L5 15h14l-1.5-1.97a2.1 2.1 0 01-.5-1.33V9a5 5 0 00-5-5z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M10 18a2 2 0 004 0" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
|
||||
</svg>
|
||||
{unreadCount > 0 && <span className="notification-center-badge">{unreadCount > 99 ? '99+' : unreadCount}</span>}
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="notification-center-popover" role="dialog" aria-label="通知中心" style={popoverStyle}>
|
||||
<div className="notification-center-head">
|
||||
<div>
|
||||
<h4>通知中心</h4>
|
||||
<p>{unreadCount > 0 ? `${unreadCount} 条未读` : '通知已处理完'}</p>
|
||||
</div>
|
||||
<div className="notification-center-actions">
|
||||
<button type="button" className="ghost-btn" onClick={() => void markAllNotificationsRead().then(() => load(filter))}>
|
||||
全部已读
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="notification-center-clear-all-btn"
|
||||
onClick={() => void clearNotifications(filter).then(() => load(filter))}
|
||||
>
|
||||
全部清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="notification-center-filters">
|
||||
{(['all', 'unread', 'read'] as Filter[]).map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={`notification-center-filter${filter === value ? ' is-active' : ''}`}
|
||||
onClick={() => {
|
||||
setFilter(value);
|
||||
void load(value);
|
||||
}}
|
||||
>
|
||||
{value === 'all' ? '全部' : value === 'unread' ? '未读' : '已读'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{loading ? <p className="notification-center-state">正在读取通知…</p> : null}
|
||||
{error ? <p className="notification-center-state is-error">{error}</p> : null}
|
||||
{!loading && !error && visibleItems.length === 0 ? <p className="notification-center-state">暂时没有通知</p> : null}
|
||||
|
||||
<div className="notification-center-list">
|
||||
{visibleItems.map((item) => (
|
||||
<article key={item.id} className={`notification-center-item${item.status === 'unread' ? ' is-unread' : ''}`}>
|
||||
<div className="notification-center-item-head">
|
||||
<strong>{item.title}</strong>
|
||||
<span>{formatTime(item.createdAt)}</span>
|
||||
</div>
|
||||
{item.notificationType === 'todo_digest' ? (
|
||||
<div className="notification-center-digest">
|
||||
{parseNotificationLines(item.body).map((line, index) => (
|
||||
<div
|
||||
key={`${item.id}-${index}`}
|
||||
className={`notification-center-digest-line${index === 0 ? ' is-title' : ''}`}
|
||||
>
|
||||
{line}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>{item.body}</p>
|
||||
)}
|
||||
<div className="notification-center-item-actions">
|
||||
{item.status === 'unread' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="notification-center-read-btn"
|
||||
onClick={() => void markNotificationRead(item.id).then(() => load(filter))}
|
||||
>
|
||||
标为已读
|
||||
</button>
|
||||
) : (
|
||||
<span className="notification-center-read-tag">已读</span>
|
||||
)}
|
||||
{item.notificationType === 'balance_low' && onOpenRecharge ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => {
|
||||
if (item.status === 'unread') {
|
||||
void markNotificationRead(item.id).then(() => load(filter));
|
||||
}
|
||||
setOpen(false);
|
||||
onOpenRecharge();
|
||||
}}
|
||||
>
|
||||
去充值
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="notification-center-clear-btn"
|
||||
onClick={() => void deleteNotification(item.id).then(() => load(filter))}
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -112,7 +112,7 @@ export function PageSavePreviewPanel({
|
||||
title="页面预览"
|
||||
src={pagePreviewUrl}
|
||||
className="page-save-mini-page-frame"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
sandbox="allow-scripts"
|
||||
scrolling="yes"
|
||||
onError={() => setPreviewFailed(true)}
|
||||
/>
|
||||
|
||||
@@ -194,7 +194,7 @@ export function RechargeModal({
|
||||
<div className="recharge-dialog-bar">
|
||||
<div>
|
||||
<p className="recharge-eyebrow">账户充值</p>
|
||||
<h3 id="recharge-title">余额不足,请充值后继续</h3>
|
||||
<h3 id="recharge-title">{balanceCents <= 0 ? '余额不足,请充值后继续' : '为账户充值'}</h3>
|
||||
</div>
|
||||
{!force && (
|
||||
<button type="button" className="recharge-close" onClick={onClose} aria-label="关闭">
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { getAvailablePlans, purchaseSubscription, setAutoRenew } from '../api/client';
|
||||
import type { ActiveSubscription, PlanDefinition } from '../types';
|
||||
|
||||
// Plan tier order (matches PLAN_ORDER in billing-subscription.mjs)
|
||||
const PLAN_ORDER: Record<string, number> = { free: 0, lite: 1, standard: 2, pro: 3 };
|
||||
|
||||
function formatYuan(cents: number) {
|
||||
return `¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2)}`;
|
||||
}
|
||||
|
||||
function formatTokens(tokens: number) {
|
||||
if (tokens === 0) return '不限';
|
||||
if (tokens >= 10_000) return `${(tokens / 10_000).toFixed(0)} 万`;
|
||||
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(0)}k`;
|
||||
return tokens.toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
function callsApprox(tokens: number) {
|
||||
if (tokens === 0) return '不限';
|
||||
return `约 ${Math.floor(tokens / 3_000).toLocaleString()} 次`;
|
||||
}
|
||||
|
||||
function imageCallsApprox(images: number) {
|
||||
if (images === 0) return '不限';
|
||||
return `约 ${images.toLocaleString()} 次`;
|
||||
}
|
||||
|
||||
const MODEL_TIER_LABELS: Record<string, string> = {
|
||||
basic: '基础模型',
|
||||
standard: '标准模型',
|
||||
premium: '旗舰模型',
|
||||
};
|
||||
|
||||
const PLAN_HIGHLIGHT: Record<string, string> = {
|
||||
lite: '',
|
||||
standard: '推荐',
|
||||
pro: '旗舰',
|
||||
};
|
||||
|
||||
type SubscribeModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: (subscription: ActiveSubscription, balanceCents: number) => void;
|
||||
onRechargeNeeded: (shortfallCents: number) => void;
|
||||
};
|
||||
|
||||
export function SubscribeModal({ open, onClose, onSuccess, onRechargeNeeded }: SubscribeModalProps) {
|
||||
const [plans, setPlans] = useState<PlanDefinition[]>([]);
|
||||
const [currentSub, setCurrentSub] = useState<ActiveSubscription | null>(null);
|
||||
const [balanceCents, setBalanceCents] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [purchasing, setPurchasing] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [shortfall, setShortfall] = useState<{ planType: string; shortfallCents: number } | null>(null);
|
||||
const [autoRenew, setAutoRenewState] = useState(false);
|
||||
const [togglingAutoRenew, setTogglingAutoRenew] = useState(false);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPurchasing(null);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
setShortfall(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) { reset(); return; }
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void getAvailablePlans()
|
||||
.then((data) => {
|
||||
setPlans(data.plans);
|
||||
setCurrentSub(data.subscription);
|
||||
setBalanceCents(data.balanceCents);
|
||||
setAutoRenewState(data.subscription?.autoRenew ?? false);
|
||||
})
|
||||
.catch((err) => setError(err instanceof Error ? err.message : '无法加载套餐列表'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [open, reset]);
|
||||
|
||||
const handlePurchase = async (planType: string) => {
|
||||
setPurchasing(planType);
|
||||
setError(null);
|
||||
setShortfall(null);
|
||||
setSuccessMessage(null);
|
||||
try {
|
||||
const result = await purchaseSubscription(planType, autoRenew);
|
||||
const plan = plans.find((p) => p.key === planType);
|
||||
const renewNote = autoRenew ? ',已开启自动续费' : '';
|
||||
setSuccessMessage(`已开通 ${plan?.name ?? planType}${renewNote},余额剩余 ${formatYuan(result.balanceCents)}`);
|
||||
setCurrentSub(result.subscription);
|
||||
setBalanceCents(result.balanceCents);
|
||||
setAutoRenewState(result.subscription.autoRenew);
|
||||
window.setTimeout(() => onSuccess(result.subscription, result.balanceCents), 1000);
|
||||
} catch (err: unknown) {
|
||||
type E = { status?: number; code?: string; message?: string; details?: Record<string, unknown> };
|
||||
const e = err as E;
|
||||
if (e.status === 402 || e.code === 'INSUFFICIENT_BALANCE') {
|
||||
const plan = plans.find((p) => p.key === planType);
|
||||
const sf = Math.max(0, (plan?.priceCents ?? 0) - balanceCents);
|
||||
setShortfall({ planType, shortfallCents: sf });
|
||||
} else {
|
||||
setError(e.message ?? '订阅失败,请稍后重试');
|
||||
}
|
||||
} finally {
|
||||
setPurchasing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleAutoRenew = async (enabled: boolean) => {
|
||||
setTogglingAutoRenew(true);
|
||||
try {
|
||||
await setAutoRenew(enabled);
|
||||
setAutoRenewState(enabled);
|
||||
setCurrentSub((s) => s ? { ...s, autoRenew: enabled } : s);
|
||||
} catch (err: unknown) {
|
||||
const e = err as { message?: string };
|
||||
setError(e.message ?? '设置失败');
|
||||
} finally {
|
||||
setTogglingAutoRenew(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="recharge-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="subscribe-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="subscribe-title"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="subscribe-dialog-head">
|
||||
<div>
|
||||
<p className="recharge-eyebrow">升级套餐</p>
|
||||
<h3 id="subscribe-title">选择适合你的方案</h3>
|
||||
</div>
|
||||
<button type="button" className="recharge-close" onClick={onClose} aria-label="关闭">✕</button>
|
||||
</div>
|
||||
|
||||
{/* Balance row */}
|
||||
<div className="subscribe-balance-row">
|
||||
<span>当前余额</span>
|
||||
<strong>{formatYuan(balanceCents)}</strong>
|
||||
{currentSub && currentSub.planType !== 'free' && (
|
||||
<span className="subscribe-current-plan-tag">
|
||||
当前:{currentSub.planType} · 至 {new Date(currentSub.expiresAt).toLocaleDateString('zh-CN')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Plan grid */}
|
||||
{loading ? (
|
||||
<p className="recharge-muted" style={{ padding: '24px 0', textAlign: 'center' }}>加载套餐中…</p>
|
||||
) : (
|
||||
<div className="subscribe-plan-grid">
|
||||
{plans.map((plan) => {
|
||||
const canAfford = balanceCents >= plan.priceCents;
|
||||
const isActive = currentSub?.planType === plan.key;
|
||||
const badge = PLAN_HIGHLIGHT[plan.key];
|
||||
const isBuying = purchasing === plan.key;
|
||||
const currentOrder = PLAN_ORDER[currentSub?.planType ?? 'free'] ?? 0;
|
||||
const planOrder = PLAN_ORDER[plan.key] ?? 0;
|
||||
const isDowngrade = !!currentSub && planOrder < currentOrder;
|
||||
const isUpgrade = !!currentSub && !isActive && planOrder > currentOrder;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={plan.key}
|
||||
className={[
|
||||
'subscribe-plan-card',
|
||||
isActive ? 'subscribe-plan-card--active' : '',
|
||||
badge === '推荐' ? 'subscribe-plan-card--highlight' : '',
|
||||
isDowngrade ? 'subscribe-plan-card--locked' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
{badge && <span className="subscribe-plan-badge-top">{badge}</span>}
|
||||
|
||||
<div className="subscribe-plan-card-head">
|
||||
<span className="subscribe-plan-name">{plan.name}</span>
|
||||
<div className="subscribe-plan-price">
|
||||
<span className="subscribe-plan-price-num">{formatYuan(plan.priceCents)}</span>
|
||||
<span className="subscribe-plan-price-unit">/月</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="subscribe-plan-features">
|
||||
<li>
|
||||
<span className="subscribe-feat-label">对话</span>
|
||||
<span className="subscribe-feat-val">{callsApprox(plan.periodTokens)}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="subscribe-feat-label">图片</span>
|
||||
<span className="subscribe-feat-val">{imageCallsApprox(plan.periodImages)}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="subscribe-feat-label">Token</span>
|
||||
<span className="subscribe-feat-val">{formatTokens(plan.periodTokens)}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className="subscribe-feat-label">模型</span>
|
||||
<span className="subscribe-feat-val">{MODEL_TIER_LABELS[plan.modelTier] ?? plan.modelTier}</span>
|
||||
</li>
|
||||
{plan.overageRate < 1 && (
|
||||
<li>
|
||||
<span className="subscribe-feat-label">超量</span>
|
||||
<span className="subscribe-feat-val">{Math.round(plan.overageRate * 10)} 折</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{isActive ? (
|
||||
<button className="subscribe-plan-btn subscribe-plan-btn--current" disabled>当前套餐</button>
|
||||
) : isDowngrade ? (
|
||||
<button className="subscribe-plan-btn subscribe-plan-btn--locked" disabled>到期后可选</button>
|
||||
) : (
|
||||
<button
|
||||
className={`subscribe-plan-btn ${canAfford ? 'subscribe-plan-btn--buy' : 'subscribe-plan-btn--short'}`}
|
||||
disabled={!!purchasing}
|
||||
onClick={() => void handlePurchase(plan.key)}
|
||||
>
|
||||
{isBuying
|
||||
? '处理中…'
|
||||
: canAfford
|
||||
? isUpgrade ? `升级 ${formatYuan(plan.priceCents)}` : `订阅 ${formatYuan(plan.priceCents)}`
|
||||
: `差额 ${formatYuan(plan.priceCents - balanceCents)}`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Feedback messages */}
|
||||
{shortfall && !successMessage && (
|
||||
<div className="subscribe-shortfall">
|
||||
<span>余额不足,还差 <strong>{formatYuan(shortfall.shortfallCents)}</strong> 才能订阅该套餐</span>
|
||||
<button
|
||||
className="recharge-pay-btn"
|
||||
onClick={() => { onClose(); onRechargeNeeded(shortfall.shortfallCents); }}
|
||||
>
|
||||
去充值差额
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="recharge-error">{error}</p>}
|
||||
{successMessage && <p className="recharge-success">✓ {successMessage}</p>}
|
||||
|
||||
{/* Auto-renew toggle */}
|
||||
<div className="subscribe-autorenew-row">
|
||||
<label className="subscribe-autorenew-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoRenew}
|
||||
disabled={togglingAutoRenew}
|
||||
onChange={(e) => {
|
||||
if (currentSub && currentSub.planType !== 'free') {
|
||||
void handleToggleAutoRenew(e.target.checked);
|
||||
} else {
|
||||
setAutoRenewState(e.target.checked);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>自动续费</span>
|
||||
</label>
|
||||
<span className="subscribe-autorenew-hint">
|
||||
{currentSub && currentSub.planType !== 'free'
|
||||
? autoRenew
|
||||
? '到期自动扣费续订,余额不足时将不续订'
|
||||
: '到期后自动降回免费版'
|
||||
: autoRenew
|
||||
? '订阅后将自动续费'
|
||||
: '订阅后每月需手动续订'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="subscribe-legal">余额直接扣除 · 订阅立即生效</p>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user