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:
john
2026-06-26 15:19:03 +08:00
parent 9ed4fd48d7
commit 9b4a25799f
162 changed files with 17276 additions and 2054 deletions
+229 -42
View File
@@ -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>
);
+26 -1
View File
@@ -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" />
+1
View File
@@ -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}
/>
+10 -1
View File
@@ -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)}>
+9 -2
View File
@@ -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}
/>
+21 -9
View File
@@ -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);
+377 -14
View File
@@ -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}
+270
View File
@@ -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>
);
}
+1 -1
View File
@@ -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)}
/>
+1 -1
View File
@@ -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="关闭">
+287
View File
@@ -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,
);
}