Add MindSpace page live edit, chat skills, and H5 deploy tooling.

Introduce page edit sessions with draft preview and patch API, chat skill picker, user memory profile, h5ApiBase resolution, voice WAV transport, and scripts for 105/g2 deployment and Plaza local dev.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
John
2026-06-15 22:09:38 -07:00
parent 3cd322ccfe
commit 6ee6fd64dd
94 changed files with 7015 additions and 2136 deletions
+24 -4
View File
@@ -12,6 +12,7 @@ import {
buildWechatAuthorizeUrl,
isMobileExternalBrowser,
isWechatContext,
isWechatEntryUrl,
readWechatAuthError,
readWechatPendingToken,
} from '../utils/wechat';
@@ -51,7 +52,6 @@ export function AuthView({
const [success, setSuccess] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [wechatEnabled, setWechatEnabled] = useState(false);
const [scanEnabled, setScanEnabled] = useState(false);
const [wechatContext, setWechatContext] = useState(() =>
isWechatContext({ search: window.location.search }),
);
@@ -76,7 +76,6 @@ export function AuthView({
if (legacyMode) return;
void getWechatAuthConfig().then((config) => {
setWechatEnabled(config.enabled);
setScanEnabled(Boolean(config.scanEnabled));
setWechatContext((prev) =>
isWechatContext({
search: window.location.search,
@@ -89,8 +88,30 @@ export function AuthView({
serverInWechat: config.inWechat,
}),
);
const params = new URLSearchParams(window.location.search);
if (
config.enabled &&
isWechatEntryUrl(window.location.search) &&
isWechatContext({
search: window.location.search,
serverInWechat: config.inWechat,
}) &&
!params.get('wechat_pending') &&
!params.get('wechat_error')
) {
window.location.replace(
buildWechatAuthorizeUrl({
returnTo: returnTo ?? undefined,
utmSource: params.get('utm_source') ?? params.get('from') ?? 'wechat',
utmMedium: params.get('utm_medium') ?? undefined,
utmCampaign: params.get('utm_campaign') ?? undefined,
intent: 'login',
}),
);
}
});
}, [legacyMode]);
}, [legacyMode, returnTo]);
const isLogin = mode === 'login' && !legacyMode;
const showWechatOneClick = isLogin && wechatContext && wechatEnabled;
@@ -310,7 +331,6 @@ export function AuthView({
{isDesktopScanMode && (
<>
<WechatDesktopLogin
scanEnabled={scanEnabled}
returnTo={returnTo}
onBindingGate={(token) => setPendingToken(token)}
onComplete={() => window.location.reload()}
+175 -4
View File
@@ -1,23 +1,86 @@
import { useEffect, useRef, useState, type CSSProperties } from 'react';
import { getMyBillingLedger, getMyUsage } from '../api/client';
import type { LedgerEntry, UsageRecord } from '../types';
const RING_R = 16;
const CIRC = 2 * Math.PI * RING_R;
const POPOVER_WIDTH = 260;
const RECENT_USAGE_LIMIT = 5;
const RECENT_LEDGER_LIMIT = 8;
function formatYuan(cents: number) {
return `¥${(cents / 100).toFixed(2)}`;
}
function formatTokenCount(tokens: number) {
if (tokens >= 100_000) {
return `${(tokens / 10_000).toFixed(1).replace(/\.0$/, '')} 万`;
}
if (tokens >= 10_000) {
return `${(tokens / 10_000).toFixed(2).replace(/\.?0+$/, '')} 万`;
}
if (tokens >= 1_000) {
return `${(tokens / 1_000).toFixed(1).replace(/\.0$/, '')}k`;
}
return tokens.toLocaleString('zh-CN');
}
function formatTokenCompact(tokens: number) {
if (tokens >= 1_000) {
return `${(tokens / 1_000).toFixed(1).replace(/\.0$/, '')}k`;
}
return String(tokens);
}
function formatUsageTime(ts: number) {
const date = new Date(ts);
const now = new Date();
const isToday =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate();
const time = date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
if (isToday) return `今天 ${time}`;
return date.toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatLedgerLabel(entry: LedgerEntry) {
if (entry.note === '新用户赠送') return '新用户赠送';
if (entry.note?.startsWith('order:')) return '微信支付充值';
if (entry.note === '用户自助充值') return '微信支付充值';
if (entry.note === '管理员充值') return '管理员充值';
if (entry.type === 'refund') return '退款';
if (entry.type === 'adjust') return entry.note?.trim() || '账户调整';
return entry.note?.trim() || '账户充值';
}
type BalanceRingProps = {
balanceCents: number;
totalCreditCents?: number;
tokensUsed?: number;
onRecharge: (force?: boolean) => void;
};
export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: BalanceRingProps) {
export function BalanceRing({
balanceCents,
totalCreditCents,
tokensUsed = 0,
onRecharge,
}: BalanceRingProps) {
const [open, setOpen] = useState(false);
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
const [usageRecords, setUsageRecords] = useState<UsageRecord[] | null>(null);
const [usageLoading, setUsageLoading] = useState(false);
const [usageError, setUsageError] = useState<string | null>(null);
const [ledgerEntries, setLedgerEntries] = useState<LedgerEntry[] | null>(null);
const [ledgerLoading, setLedgerLoading] = useState(false);
const [ledgerError, setLedgerError] = useState<string | null>(null);
const wrapRef = useRef<HTMLDivElement>(null);
const popoverWidth = 220;
const total = Math.max(totalCreditCents ?? balanceCents, balanceCents, 0);
const spent = Math.max(0, total - balanceCents);
@@ -29,6 +92,7 @@ export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: Bala
const low = balanceCents > 0 && balanceCents <= 100;
const empty = balanceCents <= 0;
const showRecentUsage = low || empty;
useEffect(() => {
if (!open) return;
@@ -48,7 +112,7 @@ export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: Bala
const anchor = wrapRef.current;
if (!anchor) return;
const rect = anchor.getBoundingClientRect();
const width = Math.min(popoverWidth, window.innerWidth - 24);
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({
@@ -68,6 +132,54 @@ export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: Bala
};
}, [open]);
useEffect(() => {
if (!open) return;
let cancelled = false;
setUsageLoading(true);
setUsageError(null);
setLedgerLoading(true);
setLedgerError(null);
void getMyUsage()
.then((records) => {
if (!cancelled) setUsageRecords(records);
})
.catch((err) => {
if (!cancelled) {
setUsageError(err instanceof Error ? err.message : '加载失败');
}
})
.finally(() => {
if (!cancelled) setUsageLoading(false);
});
void getMyBillingLedger(RECENT_LEDGER_LIMIT)
.then((entries) => {
if (!cancelled) setLedgerEntries(entries);
})
.catch((err) => {
if (!cancelled) {
setLedgerError(err instanceof Error ? err.message : '加载失败');
}
})
.finally(() => {
if (!cancelled) setLedgerLoading(false);
});
return () => {
cancelled = true;
};
}, [open, tokensUsed, balanceCents]);
useEffect(() => {
if (open) return;
setUsageRecords(null);
setUsageError(null);
setUsageLoading(false);
setLedgerEntries(null);
setLedgerError(null);
setLedgerLoading(false);
}, [open]);
const recentUsage = (usageRecords ?? []).slice(0, RECENT_USAGE_LIMIT);
const recentLedger = (ledgerEntries ?? []).slice(0, RECENT_LEDGER_LIMIT);
const centerLabel = total <= 0 ? '—' : empty ? '0%' : `${pct}%`;
const ariaLabel = total > 0 ? `账户额度,剩余 ${pct}%` : '账户额度';
@@ -122,13 +234,72 @@ export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: Bala
<strong>{formatYuan(spent)}</strong>
</div>
<div className="balance-popover-row">
<span className="balance-popover-label balance-popover-label-muted">累计额度</span>
<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>
<div className="balance-popover-section">
<h5>AI 消耗</h5>
<div className="balance-popover-row">
<span className="balance-popover-label balance-popover-label-muted">累计 Token</span>
<span>{formatTokenCount(tokensUsed)}</span>
</div>
<p className="balance-popover-hint">按人民币结算,每次对话按实际用量扣费</p>
</div>
<div className="balance-popover-section">
<h5>充值明细</h5>
{ledgerLoading ? (
<p className="balance-popover-muted">加载中…</p>
) : ledgerError ? (
<p className="balance-popover-muted">{ledgerError}</p>
) : recentLedger.length === 0 ? (
<p className="balance-popover-muted">暂无充值记录</p>
) : (
<ul className="balance-usage-list">
{recentLedger.map((row) => (
<li key={row.id} className="balance-usage-item">
<span className="balance-usage-time">{formatUsageTime(row.createdAt)}</span>
<span className="balance-usage-tokens">{formatLedgerLabel(row)}</span>
<strong className="balance-usage-credit">
+{formatYuan(Math.abs(row.amountCents))}
</strong>
</li>
))}
</ul>
)}
</div>
<div
className={`balance-popover-section balance-popover-usage${showRecentUsage ? ' expanded' : ''}`}
>
<h5>近期扣费</h5>
{usageLoading ? (
<p className="balance-popover-muted">加载中…</p>
) : usageError ? (
<p className="balance-popover-muted">{usageError}</p>
) : recentUsage.length === 0 ? (
<p className="balance-popover-muted">暂无扣费记录</p>
) : (
<ul className="balance-usage-list">
{recentUsage.map((row) => (
<li key={row.id} className="balance-usage-item">
<span className="balance-usage-time">{formatUsageTime(row.createdAt)}</span>
<span className="balance-usage-tokens">
输入 {formatTokenCompact(row.inputTokens)} / 输出{' '}
{formatTokenCompact(row.outputTokens)}
</span>
<strong className="balance-usage-cost">{formatYuan(row.costCents)}</strong>
</li>
))}
</ul>
)}
</div>
<button
type="button"
className="balance-popover-cta"
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useRef, useState } from 'react';
import { WechatAccountButton } from './WechatAccountButton';
type MenuItem = {
id: string;
label: string;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
};
export function ChatHeaderMoreMenu({
items,
returnTo,
showWechat = false,
}: {
items: MenuItem[];
returnTo?: string;
showWechat?: boolean;
}) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleClick = (event: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
const handleKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('click', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [open]);
return (
<div className={`header-more-menu${open ? ' open' : ''}`} ref={wrapRef}>
<button
type="button"
className="header-icon-btn"
aria-label="更多操作"
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((prev) => !prev)}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="5" r="1.75" />
<circle cx="12" cy="12" r="1.75" />
<circle cx="12" cy="19" r="1.75" />
</svg>
</button>
{open && (
<div className="header-more-dropdown" role="menu" aria-label="更多操作">
{items.map((item) => (
<button
key={item.id}
type="button"
className={`header-more-item${item.danger ? ' header-more-item-danger' : ''}`}
role="menuitem"
disabled={item.disabled}
onClick={() => {
setOpen(false);
item.onClick();
}}
>
{item.label}
</button>
))}
{showWechat && (
<>
<div className="header-more-divider" role="separator" />
<div className="header-more-wechat">
<WechatAccountButton returnTo={returnTo} />
</div>
</>
)}
</div>
)}
</div>
);
}
+22 -16
View File
@@ -1,8 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { openAvatarPicker } from '../utils/userAvatar';
import { buildPublishSkillPrompt } from '../utils/publishSkill';
import { CHAT_SKILL_OPTIONS, filterChatSkills } from '../utils/chatSkills';
import { AvatarPicker } from './AvatarPicker';
import { ChatSkillPicker } from './ChatSkillPicker';
import { MessageList } from './MessageList';
import { PageSaveDialog } from './PageSaveDialog';
import { VoiceInputButton } from './VoiceInputButton';
@@ -22,6 +23,7 @@ export function ChatPanel({
onStop,
onApproveTool,
onPageSaved,
onClose,
}: {
variant: 'full' | 'compact';
user?: PortalUser | null;
@@ -39,6 +41,7 @@ export function ChatPanel({
pageId?: string;
categoryCode?: MindSpaceSaveCategory;
}) => void;
onClose?: () => void;
}) {
const online = useNetworkStatus();
const [input, setInput] = useState('');
@@ -50,11 +53,12 @@ export function ChatPanel({
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, chatState, pendingTool]);
const busy = chatState === 'streaming' || chatState === 'loading';
const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting';
const offlineBlocked = !online;
const publishSkillName = user?.publishSkillName ?? 'static-page-publish';
const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false;
const canPublish = Boolean(capabilities?.static_publish) || hasPublishSkill;
const chatSkills = filterChatSkills(CHAT_SKILL_OPTIONS, { grantedSkills, canPublish });
const compact = variant === 'compact';
const handleSubmit = async () => {
@@ -66,9 +70,11 @@ export function ChatPanel({
const placeholder = offlineBlocked
? '网络断开,恢复连接后可继续输入'
: compact
? '在这里继续和 Agent 对话…'
: '输入任务,例如:列出当前目录文件';
: chatState === 'connecting'
? '正在连接会话…'
: compact
? '在这里继续和 Agent 对话…'
: '输入任务,例如:列出当前目录文件';
const handleVoiceSend = async (text: string) => {
setVoiceNotice(null);
@@ -152,18 +158,13 @@ export function ChatPanel({
}
}}
/>
{!compact && canPublish && (
<button
type="button"
className="ghost-btn chat-input-action"
{chatSkills.length > 0 && (
<ChatSkillPicker
skills={chatSkills}
disabled={voiceDisabled}
title={`加载 ${publishSkillName} 技能并生成 HTML`}
onClick={() => {
setInput(buildPublishSkillPrompt(publishSkillName));
}}
>
静态页面
</button>
onSelect={onSubmit}
onPrefill={setInput}
/>
)}
{chatState === 'streaming' ? (
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
@@ -180,6 +181,11 @@ export function ChatPanel({
</button>
)}
</div>
{compact && onClose && (
<button type="button" className="space-chat-panel-dismiss" onClick={onClose}>
关闭
</button>
)}
</footer>
</>
);
+107
View File
@@ -0,0 +1,107 @@
import type { ChatSkillIconId } from '../utils/chatSkills';
type IconProps = { className?: string };
function SparkIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M12 2.5 13.4 8.6 19 10l-5.6 1.4L12 17.5 10.6 11.4 5 10l5.6-1.4L12 2.5Zm-6.2 9.8 1.8.5-.5 1.8-1.8-.5.5-1.8Zm12.4 0 .5 1.8-1.8.5-.5-1.8 1.8-.5ZM12 18.8l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9.9-2.1Z"
/>
</svg>
);
}
function PageIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M7 3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8.83a2 2 0 0 0-.59-1.41l-3.83-3.83A2 2 0 0 0 13.17 3H7Zm0 2h5v4a1 1 0 0 0 1 1h4v10H7V5Zm7 0.41L18.59 8H14V5.41ZM9 13h6v2H9v-2Zm0 4h4v2H9v-2Z"
/>
</svg>
);
}
function SearchIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M10.5 3a7.5 7.5 0 1 0 4.73 13.32l4.08 4.08 1.41-1.41-4.08-4.08A7.47 7.47 0 0 0 10.5 3Zm0 2a5.5 5.5 0 1 1 0 11 5.5 5.5 0 0 1 0-11Z"
/>
</svg>
);
}
function WebIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2Zm7.93 9h-3.18a15.7 15.7 0 0 0-1.14-4.02A8.03 8.03 0 0 1 19.93 11ZM12 4c.95 0 2.29 1.58 3.1 4H8.9C9.71 5.58 11.05 4 12 4ZM4.39 7.98A8.03 8.03 0 0 1 8.39 11H5.07a8.02 8.02 0 0 1-.68-3.02ZM5.07 13h3.32a15.7 15.7 0 0 0-1.14 4.02A8.03 8.03 0 0 1 4.39 16.02ZM8.9 18h6.2c-.81 2.42-2.15 4-3.1 4s-2.29-1.58-3.1-4Zm5.71 1.98A15.7 15.7 0 0 0 15.61 13h3.32a8.03 8.03 0 0 1-4 6.98ZM15.61 11a15.7 15.7 0 0 0 1.14-4.02h3.18a8.03 8.03 0 0 1-4.32 4.02H15.61Z"
/>
</svg>
);
}
function FormIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M5 3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2H5Zm2 4h10v2H7V7Zm0 4h10v2H7v-2Zm0 4h6v2H7v-2Z"
/>
</svg>
);
}
function TableIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M4 5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V5Zm2 0v3h12V5H6Zm0 5v4h5v-4H6Zm7 0v4h5v-4h-5ZM6 16v3h5v-3H6Zm7 0v3h5v-3h-5Z"
/>
</svg>
);
}
function SummaryIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M4 5a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2H4V5Zm0 4h16v10a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V9Zm3 2v2h10v-2H7Zm0 4v2h7v-2H7Z"
/>
</svg>
);
}
function AnalyzeIcon({ className }: IconProps) {
return (
<svg className={className} viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path
fill="currentColor"
d="M3 3h2l3.6 7.59-1.35 2.44a3 3 0 0 0 2.61 4.47H19v-2H9.64a1 1 0 0 1-.87-1.5l.96-1.74L7.5 7H19V5H5.21l-.43-2H3Zm13 14a4 4 0 1 0 .01 8.01A4 4 0 0 0 16 17Zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z"
/>
</svg>
);
}
const ICONS: Record<ChatSkillIconId, (props: IconProps) => JSX.Element> = {
spark: SparkIcon,
page: PageIcon,
search: SearchIcon,
web: WebIcon,
form: FormIcon,
table: TableIcon,
summary: SummaryIcon,
analyze: AnalyzeIcon,
};
export function ChatSkillIcon({ id, className }: { id: ChatSkillIconId; className?: string }) {
const Icon = ICONS[id];
return <Icon className={className} />;
}
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useRef, useState } from 'react';
import type { ChatSkillOption } from '../utils/chatSkills';
import { ChatSkillIcon } from './ChatSkillIcons';
export function ChatSkillPicker({
skills,
disabled,
onSelect,
onPrefill,
}: {
skills: ChatSkillOption[];
disabled?: boolean;
onSelect: (prompt: string) => void | Promise<void>;
onPrefill?: (prompt: string) => void;
}) {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handleClick = (event: MouseEvent) => {
if (wrapRef.current && !wrapRef.current.contains(event.target as Node)) {
setOpen(false);
}
};
const handleKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false);
};
document.addEventListener('click', handleClick);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('click', handleClick);
document.removeEventListener('keydown', handleKey);
};
}, [open]);
if (skills.length === 0) return null;
const handlePick = (skill: ChatSkillOption) => {
setOpen(false);
const prompt = skill.buildPrompt(skill.skillName);
if (skill.prefillOnly) {
onPrefill?.(prompt);
return;
}
void onSelect(prompt);
};
return (
<div className={`chat-skill-picker${open ? ' open' : ''}`} ref={wrapRef}>
<button
type="button"
className="chat-skill-trigger"
disabled={disabled}
aria-expanded={open}
aria-haspopup="menu"
title="选择 Skill 并发送"
onClick={() => setOpen((prev) => !prev)}
>
<ChatSkillIcon id="spark" className="chat-skill-trigger-icon" />
<span className="chat-skill-trigger-label">skill</span>
</button>
{open && (
<div className="chat-skill-menu" role="menu" aria-label="Skill 列表">
{skills.map((skill) => (
<button
key={skill.id}
type="button"
className="chat-skill-menu-item"
role="menuitem"
onClick={() => handlePick(skill)}
>
<span className="chat-skill-menu-leading">
<ChatSkillIcon id={skill.icon} className="chat-skill-menu-icon" />
<span className="chat-skill-menu-label">{skill.label}</span>
</span>
<span className="chat-skill-menu-tag">skill</span>
</button>
))}
</div>
)}
</div>
);
}
+91 -22
View File
@@ -1,14 +1,17 @@
import { useEffect, useState } from 'react';
import { useChat } from '../context/ChatProvider';
import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
import type { CapabilityMap, PortalUser } from '../types';
import { useNetworkStatus } from '../hooks/useNetworkStatus';
import { getSessionDisplayName } from '../utils/sessions';
import { openAvatarPicker } from '../utils/userAvatar';
import { BalanceRing } from './BalanceRing';
import { HistorySidebar } from './HistorySidebar';
import { TKMindAvatar } from './TKMindAvatar';
import { ChatPanel } from './ChatPanel';
import { WechatBindPrompt } from './WechatBindPrompt';
import { WechatAccountButton } from './WechatAccountButton';
import { ChatHeaderMoreMenu } from './ChatHeaderMoreMenu';
import type { MindSpaceSaveCategory } from '../types';
export function ChatView({
@@ -46,12 +49,14 @@ export function ChatView({
rememberCurrentContext,
refreshProjectMemory,
switchSession,
deleteSession,
retryConnect,
dismissNotice,
onSidebarOpen,
workingDir,
balanceCents,
totalCreditCents,
tokensUsed,
openRecharge,
} = useChat();
const online = useNetworkStatus();
@@ -63,7 +68,7 @@ export function ChatView({
}
}, [sidebarOpen, onSidebarOpen]);
const busy = chatState === 'streaming' || chatState === 'loading';
const busy = chatState === 'streaming' || chatState === 'loading' || chatState === 'connecting';
const handleSelectSession = (sessionId: string) => {
void switchSession(sessionId);
@@ -75,7 +80,37 @@ export function ChatView({
setSidebarOpen(false);
};
const sessionTitle = session ? getSessionDisplayName(session) : '连接中…';
const sessionTitle = session
? getSessionDisplayName(session)
: chatState === 'connecting'
? '新对话'
: '连接中…';
const moreMenuItems = [
{
id: 'new-session',
label: '新会话',
onClick: () => void handleNewSession(),
},
{
id: 'remember',
label: '记住本轮',
onClick: () => void rememberCurrentContext(),
disabled: busy || memoryLoading || messages.length === 0,
},
{
id: 'refresh-memory',
label: memoryLoading ? '同步中…' : '刷新记忆',
onClick: () => void refreshProjectMemory(),
disabled: busy || memoryLoading,
},
...(onOpenAdmin
? [{ id: 'admin', label: '管理', onClick: () => onOpenAdmin() }]
: []),
...(onLogout
? [{ id: 'logout', label: '登出', onClick: () => onLogout(), danger: true }]
: []),
];
return (
<div className="app-shell">
@@ -87,6 +122,7 @@ export function ChatView({
onClose={() => setSidebarOpen(false)}
onSelect={handleSelectSession}
onNew={handleNewSession}
onDelete={(sessionId) => void deleteSession(sessionId)}
/>
<div className="app">
@@ -106,11 +142,12 @@ export function ChatView({
<div className="header-sub">{sessionTitle}</div>
</div>
</div>
<div className="header-actions">
<div className="header-actions header-actions-desktop">
{typeof balanceCents === 'number' && (
<BalanceRing
balanceCents={balanceCents}
totalCreditCents={totalCreditCents}
tokensUsed={tokensUsed}
onRecharge={openRecharge}
/>
)}
@@ -152,6 +189,41 @@ export function ChatView({
</button>
)}
</div>
<div className="header-actions header-actions-mobile">
{typeof balanceCents === 'number' && (
<BalanceRing
balanceCents={balanceCents}
totalCreditCents={totalCreditCents}
tokensUsed={tokensUsed}
onRecharge={openRecharge}
/>
)}
{onOpenSpace && (
<button
type="button"
className="header-icon-btn"
aria-label="我的空间"
title="我的空间"
onClick={() => onOpenSpace?.()}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M4 8.5V19a1 1 0 001 1h14a1 1 0 001-1V8.5M4 8.5l8-4.5 8 4.5M4 8.5h16M9 21v-6h6v6"
stroke="currentColor"
strokeWidth="1.75"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
<ChatHeaderMoreMenu
items={moreMenuItems}
returnTo={window.location.pathname}
showWechat={Boolean(user)}
/>
</div>
</header>
{!online && (
@@ -163,7 +235,7 @@ export function ChatView({
{typeof balanceCents === 'number' && balanceCents <= 0 && (
<div className="banner banner-error">
<span>余额不足,请充值后继续使用</span>
<button type="button" className="banner-action" onClick={() => openRecharge(true)}>
<button type="button" className="banner-action" onClick={() => openRecharge(false)}>
去充值
</button>
</div>
@@ -177,26 +249,23 @@ export function ChatView({
</div>
)}
{user?.publishUrl && (
<div className="banner banner-info publish-banner">
<span>
你的发布目录:
<a href={user.publishUrl} target="_blank" rel="noreferrer">
{user.publishUrl}
</a>
{Boolean(capabilities?.static_publish) || (grantedSkills?.includes(user.publishSkillName ?? 'static-page-publish') ?? false)
? '(已开通静态页面技能,生成后可直接访问)'
: '(需管理员在「技能配置」中勾选 static-page-publish)'}
</span>
</div>
)}
{notice && (
<div className="banner banner-info">
<div className={`banner${notice === INSUFFICIENT_BALANCE_NOTICE ? ' banner-warning' : ' banner-info'}`}>
<span>{notice}</span>
<button type="button" className="banner-dismiss" onClick={dismissNotice}>
知道了
</button>
{notice === INSUFFICIENT_BALANCE_NOTICE ? (
<>
<button type="button" className="banner-action" onClick={() => openRecharge(false)}>
去充值
</button>
<button type="button" className="banner-dismiss" onClick={dismissNotice}>
知道了
</button>
</>
) : (
<button type="button" className="banner-dismiss" onClick={dismissNotice}>
知道了
</button>
)}
</div>
)}
+60 -18
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import { AvatarPicker } from './AvatarPicker';
import { appConfig } from '../config';
import type { Session } from '../types';
import { getSessionListLabel } from '../utils/sessions';
import { getSessionListLabel, groupSessionsByDate } from '../utils/sessions';
function formatSessionTime(iso?: string): string {
if (!iso) return '';
@@ -19,7 +19,20 @@ function formatSessionTime(iso?: string): string {
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
}
function CloseIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M18 6L6 18M6 6l12 12"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
);
}
type HistorySidebarProps = {
@@ -30,6 +43,7 @@ type HistorySidebarProps = {
onClose: () => void;
onSelect: (sessionId: string) => void;
onNew: () => void;
onDelete: (sessionId: string) => void;
};
export function HistorySidebar({
@@ -40,6 +54,7 @@ export function HistorySidebar({
onClose,
onSelect,
onNew,
onDelete,
}: HistorySidebarProps) {
const bodyRef = useRef<HTMLDivElement>(null);
const [visibleCount, setVisibleCount] = useState(appConfig.sessionPageSize);
@@ -59,9 +74,18 @@ export function HistorySidebar({
}
}, [visibleCount, sessions.length]);
const handleDelete = useCallback(
(sessionId: string, label: string) => {
if (!window.confirm(`确定删除「${label}」?此操作不可恢复。`)) return;
onDelete(sessionId);
},
[onDelete],
);
if (!open) return null;
const visibleSessions = sessions.slice(0, visibleCount);
const sessionGroups = groupSessionsByDate(visibleSessions);
const hasMore = visibleCount < sessions.length;
return (
@@ -87,22 +111,40 @@ export function HistorySidebar({
<div className="sidebar-empty">暂无历史对话</div>
) : (
<>
<ul className="sidebar-list">
{visibleSessions.map((item) => (
<li key={item.id}>
<button
type="button"
className={`sidebar-item ${item.id === activeSessionId ? 'sidebar-item-active' : ''}`}
onClick={() => onSelect(item.id)}
>
<span className="sidebar-item-title">{getSessionListLabel(item)}</span>
<span className="sidebar-item-meta">
{formatSessionTime(item.updated_at ?? item.created_at)}
</span>
</button>
</li>
))}
</ul>
{sessionGroups.map((group) => (
<section key={group.dateKey || group.label} className="sidebar-date-group">
<div className="sidebar-date-divider">
<span>{group.label}</span>
</div>
<ul className="sidebar-list">
{group.sessions.map((item) => {
const label = getSessionListLabel(item);
return (
<li key={item.id} className="sidebar-item-wrap">
<button
type="button"
className={`sidebar-item ${item.id === activeSessionId ? 'sidebar-item-active' : ''}`}
onClick={() => onSelect(item.id)}
>
<span className="sidebar-item-title">{label}</span>
<span className="sidebar-item-meta">
{formatSessionTime(item.updated_at ?? item.created_at)}
</span>
</button>
<button
type="button"
className="sidebar-item-delete"
aria-label={`删除 ${label}`}
onClick={() => handleDelete(item.id, label)}
>
<CloseIcon />
</button>
</li>
);
})}
</ul>
</section>
))}
{hasMore && <div className="sidebar-more">向下滚动加载更多…</div>}
</>
)}
+270 -81
View File
@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { usePageDraftHistory, type PageDraftSnapshot } from '../hooks/usePageDraftHistory';
import {
checkMindSpacePagePublication,
redactMindSpacePage,
@@ -10,8 +11,6 @@ import {
offlineMindSpacePublication,
publishMindSpacePage,
publishPageToPlaza,
fetchMindSpacePageDraftPreview,
openMindSpaceDraftPreviewWindow,
updateMindSpacePage,
} from '../api/client';
import type {
@@ -19,14 +18,20 @@ import type {
MindSpacePageDeletePreview,
MindSpacePublicationStats,
MindSpacePublishCheck,
MindSpaceQuota,
MindSpaceRedactionChange,
PlazaCategory,
PlazaPostBrief,
} from '../types';
import { resolvePlazaPostUrl, resolvePublicPageUrl } from '../utils/publicUrl';
import { PagePreviewFrame } from './PagePreviewFrame';
import { MindSpacePageDraftPreviewFrame, type MindSpacePreviewChangeHighlight } from './MindSpacePageDraftPreviewFrame';
import {
MindSpacePageFullscreenPreview,
type MindSpacePageFullscreenPreviewChat,
} from './MindSpacePageFullscreenPreview';
import { MindSpacePagePreviewPanel } from './MindSpacePagePreviewPanel';
import { MindSpaceModal } from './MindSpaceModal';
import { mergeMindSpacePagePatch, type MindSpacePagePatch } from '../utils/mindspacePagePatch';
type AccessMode = MindSpacePublishCheck['accessMode'];
@@ -91,10 +96,15 @@ export function MindSpacePageDetail({
onSaved,
onContextUpdate,
autoOpenPlaza = false,
refreshTrigger = 0,
incomingPagePatch = null,
onPagePatchHandled,
overlayChat = null,
onFullscreenPreviewChange,
}: {
pageId: string;
onBack: () => void;
onSaved: () => Promise<void>;
onSaved: (options?: { quota?: MindSpaceQuota; freedBytes?: number }) => Promise<void>;
onContextUpdate?: (value: {
page: MindSpacePage;
title: string;
@@ -102,17 +112,33 @@ export function MindSpacePageDetail({
content: string;
}) => void;
autoOpenPlaza?: boolean;
refreshTrigger?: number;
incomingPagePatch?: { patch: MindSpacePagePatch; sourceMessageId: string } | null;
onPagePatchHandled?: (sourceMessageId: string) => void;
overlayChat?: MindSpacePageFullscreenPreviewChat | null;
onFullscreenPreviewChange?: (open: boolean) => void;
}) {
const [page, setPage] = useState<MindSpacePage | null>(null);
const [title, setTitle] = useState('');
const [summary, setSummary] = useState('');
const [content, setContent] = useState('');
const {
snapshot: draft,
update: updateDraft,
replace: replaceDraft,
undo,
redo,
reset: resetDraft,
canUndo,
canRedo,
} = usePageDraftHistory({ title: '', summary: '', content: '' });
const { title, summary, content } = draft;
const [templateId, setTemplateId] = useState('editorial');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [previewKey, setPreviewKey] = useState(0);
const [previewOpening, setPreviewOpening] = useState(false);
const [fullscreenPreviewOpen, setFullscreenPreviewOpen] = useState(false);
const [changeHighlight, setChangeHighlight] = useState<MindSpacePreviewChangeHighlight | null>(
null,
);
const changeHighlightRevisionRef = useRef(0);
const [publishOpen, setPublishOpen] = useState(false);
const [urlSlug, setUrlSlug] = useState('');
const [accessMode, setAccessMode] = useState<AccessMode>('public');
@@ -136,6 +162,24 @@ export function MindSpacePageDetail({
const [plazaPost, setPlazaPost] = useState<PlazaPostBrief | null>(null);
const [pushToPlaza, setPushToPlaza] = useState(false);
const [fixNotice, setFixNotice] = useState<MindSpaceRedactionChange[] | null>(null);
const [patchNotice, setPatchNotice] = useState<string | null>(null);
const [patchBusy, setPatchBusy] = useState(false);
const applyPageRecord = useCallback(
(next: MindSpacePage, options?: { recordHistory?: boolean }) => {
setPage(next);
replaceDraft(
{
title: next.title,
summary: next.summary,
content: next.content ?? '',
},
{ recordHistory: options?.recordHistory },
);
setTemplateId(next.templateId);
},
[replaceDraft],
);
const load = async () => {
setLoading(true);
@@ -143,9 +187,11 @@ export function MindSpacePageDetail({
try {
const next = await getMindSpacePage(pageId);
setPage(next);
setTitle(next.title);
setSummary(next.summary);
setContent(next.content ?? '');
resetDraft({
title: next.title,
summary: next.summary,
content: next.content ?? '',
});
setTemplateId(next.templateId);
setUrlSlug(next.publication?.urlSlug ?? slugFromTitle(next.title, next.id));
setAccessMode(next.publication?.accessMode ?? 'public');
@@ -320,10 +366,10 @@ export function MindSpacePageDetail({
setDeletingPage(true);
setError(null);
try {
await deleteMindSpacePage(page.id);
const result = await deleteMindSpacePage(page.id);
setDeletePreviewOpen(false);
setDeletePreview(null);
await onSaved();
await onSaved({ quota: result.quota, freedBytes: result.freedBytes });
onBack();
} catch (err) {
setError(err instanceof Error ? err.message : '删除页面失败');
@@ -364,6 +410,81 @@ export function MindSpacePageDetail({
const publishWarningFindings =
publishCheck?.findings.filter((finding) => !finding.blocking) ?? [];
const triggerChangeHighlight = useCallback((previous: PageDraftSnapshot) => {
changeHighlightRevisionRef.current += 1;
setChangeHighlight({
revision: changeHighlightRevisionRef.current,
previousTitle: previous.title,
previousContent: previous.content,
});
}, []);
const refreshFromServer = useCallback(async () => {
const currentPage = page;
if (!currentPage) return;
try {
const next = await getMindSpacePage(pageId);
const serverChanged =
next.versionNo !== currentPage.versionNo || next.updatedAt !== currentPage.updatedAt;
if (!serverChanged) return;
const draftChanged =
next.title !== title ||
next.summary !== summary ||
(next.content ?? '') !== content;
if (draftChanged) {
triggerChangeHighlight({ title, summary, content });
}
applyPageRecord(next, { recordHistory: true });
if (draftChanged) {
setPreviewKey((value) => value + 1);
}
} catch (err) {
setError(err instanceof Error ? err.message : '页面刷新失败');
}
}, [applyPageRecord, content, page, pageId, summary, title, triggerChangeHighlight]);
const applyIncomingPatch = useCallback(
async (patch: MindSpacePagePatch) => {
if (!page) return;
const merged = mergeMindSpacePagePatch({ title, summary, content }, patch);
triggerChangeHighlight({ title, summary, content });
setPatchBusy(true);
setPatchNotice(null);
setError(null);
try {
const updated = await updateMindSpacePage(page.id, {
expectedVersion: page.versionNo,
title: merged.title,
summary: merged.summary,
content: merged.content,
templateId,
changeNote: 'Agent 对话修改',
});
applyPageRecord(updated, { recordHistory: true });
setPreviewKey((value) => value + 1);
setPatchNotice('Agent 修改已同步到预览');
} catch (err) {
applyPageRecord(
{
...page,
title: merged.title,
summary: merged.summary,
content: merged.content,
},
{ recordHistory: true },
);
setPreviewKey((value) => value + 1);
setPatchNotice('预览已更新(保存到服务器失败,请稍后手动保存)');
setError(err instanceof Error ? err.message : '保存 Agent 修改失败');
} finally {
setPatchBusy(false);
}
},
[applyPageRecord, content, page, summary, templateId, title, triggerChangeHighlight],
);
const applyOneClickFix = async () => {
if (!page) return;
setPublishing(true);
@@ -378,10 +499,7 @@ export function MindSpacePageDetail({
content,
});
const updatedPage = result.page;
setPage(updatedPage);
setTitle(updatedPage.title);
setSummary(updatedPage.summary);
setContent(updatedPage.content ?? content);
applyPageRecord(updatedPage, { recordHistory: true });
setFixNotice(result.changes);
await runPublishCheck(updatedPage, { managePublishing: false });
} catch (err) {
@@ -396,60 +514,93 @@ export function MindSpacePageDetail({
void load();
}, [pageId]);
useEffect(() => {
if (!refreshTrigger) return;
void refreshFromServer();
}, [refreshTrigger, refreshFromServer]);
useEffect(() => {
if (!incomingPagePatch || !page || patchBusy) return;
let cancelled = false;
void applyIncomingPatch(incomingPagePatch.patch).finally(() => {
if (!cancelled) onPagePatchHandled?.(incomingPagePatch.sourceMessageId);
});
return () => {
cancelled = true;
};
}, [
incomingPagePatch?.sourceMessageId,
incomingPagePatch?.patch,
page?.id,
patchBusy,
applyIncomingPatch,
onPagePatchHandled,
]);
useEffect(() => {
if (autoOpenPlaza && page?.publication && !plazaOpen) {
void openPlazaPanel();
}
}, [autoOpenPlaza, page]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const mod = event.metaKey || event.ctrlKey;
if (!mod || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
) {
return;
}
if (event.key.toLowerCase() === 'z' && event.shiftKey) {
if (redo()) event.preventDefault();
return;
}
if (event.key.toLowerCase() === 'z') {
if (undo()) event.preventDefault();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [redo, undo]);
useEffect(() => {
if (!page || !onContextUpdate) return;
onContextUpdate({ page, title, summary, content });
}, [page, title, summary, content, onContextUpdate]);
const openFullscreenPreview = async () => {
if (!page) return;
setPreviewOpening(true);
setError(null);
try {
const html = await fetchMindSpacePageDraftPreview(page.id, {
title,
summary,
content,
templateId,
});
openMindSpaceDraftPreviewWindow(html);
} catch (err) {
setError(err instanceof Error ? err.message : '无法打开预览');
} finally {
setPreviewOpening(false);
}
const openFullscreenPreview = () => {
if (!page || page.contentFormat !== 'html' || !content.trim()) return;
setFullscreenPreviewOpen(true);
onFullscreenPreviewChange?.(true);
};
const save = async () => {
if (!page) return;
setSaving(true);
setError(null);
try {
const updated = await updateMindSpacePage(page.id, {
expectedVersion: page.versionNo,
title,
summary,
content,
templateId,
changeNote: `保存草稿 v${page.versionNo + 1}`,
});
setPage(updated);
setPreviewKey((value) => value + 1);
await onSaved();
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '页面保存失败');
} finally {
setSaving(false);
}
const closeFullscreenPreview = () => {
setFullscreenPreviewOpen(false);
onFullscreenPreviewChange?.(false);
};
const handlePreviewUndo = () => {
if (!undo()) return false;
setPreviewKey((value) => value + 1);
return true;
};
const handlePreviewRedo = () => {
if (!redo()) return false;
setPreviewKey((value) => value + 1);
return true;
};
useEffect(() => {
return () => {
onFullscreenPreviewChange?.(false);
};
}, [onFullscreenPreviewChange]);
const publicPageUrl = page?.publication
? resolvePublicPageUrl(page.publication.publicUrl)
: '';
@@ -467,6 +618,7 @@ export function MindSpacePageDetail({
}
return (
<>
<section className="mindspace-page-detail">
<div className="mindspace-section-heading">
<div>
@@ -474,7 +626,7 @@ export function MindSpacePageDetail({
返回页面列表
</button>
<p className="mindspace-eyebrow">PAGE DETAIL · V{page.versionNo}</p>
<h2>{truncateLabel(page.title, 80)}</h2>
<h2>{truncateLabel(title || page.title, 80)}</h2>
</div>
<div className="mindspace-page-status">
<span>{STATUS_LABELS[page.status] ?? page.status}</span>
@@ -497,6 +649,7 @@ export function MindSpacePageDetail({
</div>
{error && !deletePreviewOpen && <div className="mindspace-security-note">{error}</div>}
{patchNotice ? <div className="mindspace-fix-notice">{patchNotice}</div> : null}
<MindSpaceModal
open={deletePreviewOpen}
@@ -875,7 +1028,11 @@ export function MindSpacePageDetail({
<div className="mindspace-page-editor">
<label>
标题
<input value={title} maxLength={255} onChange={(event) => setTitle(event.target.value)} />
<input
value={title}
maxLength={255}
onChange={(event) => updateDraft({ ...draft, title: event.target.value })}
/>
</label>
<label>
摘要
@@ -883,7 +1040,7 @@ export function MindSpacePageDetail({
value={summary}
rows={4}
maxLength={1000}
onChange={(event) => setSummary(event.target.value)}
onChange={(event) => updateDraft({ ...draft, summary: event.target.value })}
/>
</label>
{page.contentFormat !== 'html' && (
@@ -907,33 +1064,30 @@ export function MindSpacePageDetail({
className="mindspace-page-content-input"
value={content}
rows={18}
onChange={(event) => setContent(event.target.value)}
onChange={(event) => updateDraft({ ...draft, content: event.target.value })}
/>
</label>
)}
<div className="mindspace-page-editor-actions">
<span>保存会创建新版本,历史版本不会被覆盖。</span>
<button
type="button"
className="mindspace-primary"
onClick={() => void save()}
disabled={saving || !title.trim() || !content.trim()}
>
{saving ? '保存中…' : '保存新版本'}
</button>
</div>
</div>
<div className="mindspace-page-preview">
<div className="mindspace-page-preview-bar">
<span>{page.contentFormat === 'html' ? '页面预览' : '安全预览'}</span>
<div className="mindspace-page-preview-actions">
{patchBusy ? <span className="mindspace-page-preview-sync">同步 Agent 修改…</span> : null}
<button type="button" onClick={() => undo()} disabled={!canUndo} title="撤销 (⌘Z)">
撤销
</button>
<button type="button" onClick={() => redo()} disabled={!canRedo} title="重做 (⌘⇧Z)">
重做
</button>
<button
type="button"
onClick={() => void openFullscreenPreview()}
disabled={previewOpening || !content.trim()}
onClick={openFullscreenPreview}
disabled={page.contentFormat !== 'html' || !content.trim()}
title={page.contentFormat === 'html' ? '全屏预览并对话改页' : '仅 HTML 页面支持全屏编辑'}
>
{previewOpening ? '打开中…' : '全屏打开'}
全屏预览
</button>
<button type="button" onClick={() => setPreviewKey((value) => value + 1)}>
刷新
@@ -947,12 +1101,17 @@ export function MindSpacePageDetail({
summary={summary}
content={content}
reloadKey={previewKey}
onContentChange={setContent}
onContentChange={(html) =>
replaceDraft({ ...draft, content: html }, { recordHistory: true })
}
/>
) : (
<PagePreviewFrame
title={`${page.title} 预览`}
src={`/api/mindspace/v1/pages/${page.id}/preview?v=${page.versionNo}`}
<MindSpacePageDraftPreviewFrame
pageId={page.id}
title={title}
summary={summary}
content={content}
templateId={templateId}
reloadKey={previewKey}
minHeight={680}
/>
@@ -964,7 +1123,7 @@ export function MindSpacePageDetail({
className="mindspace-page-content-input"
value={content}
rows={22}
onChange={(event) => setContent(event.target.value)}
onChange={(event) => updateDraft({ ...draft, content: event.target.value })}
/>
</details>
)}
@@ -998,5 +1157,35 @@ export function MindSpacePageDetail({
))}
</section>
</section>
{fullscreenPreviewOpen && page && page.contentFormat === 'html' ? (
<MindSpacePageFullscreenPreview
pageId={page.id}
pageTitle={title || page.title}
title={title}
summary={summary}
content={content}
templateId={templateId}
reloadKey={previewKey}
patchBusy={patchBusy}
changeHighlight={changeHighlight}
canUndo={canUndo}
canRedo={canRedo}
onUndo={handlePreviewUndo}
onRedo={handlePreviewRedo}
onClose={closeFullscreenPreview}
onRefresh={() => setPreviewKey((value) => value + 1)}
onAgentIdleSync={() => void refreshFromServer()}
chat={
overlayChat
? {
...overlayChat,
onPagePatch: (patch) => void applyIncomingPatch(patch),
}
: null
}
/>
) : null}
</>
);
}
@@ -0,0 +1,132 @@
import { useEffect, useRef, useState } from 'react';
import { fetchMindSpacePageDraftPreview } from '../api/client';
import { applyPageChangeHighlight } from '../utils/mindspacePageChangeHighlight';
export type MindSpacePreviewChangeHighlight = {
revision: number;
previousTitle: string;
previousContent: string;
};
export function MindSpacePageDraftPreviewFrame({
pageId,
title,
summary,
content,
templateId,
reloadKey = 0,
minHeight = 680,
maxHeight = 4800,
className = 'mindspace-page-preview-frame',
changeHighlight = null,
}: {
pageId: string;
title: string;
summary: string;
content: string;
templateId: string;
reloadKey?: number;
minHeight?: number;
maxHeight?: number;
className?: string;
changeHighlight?: MindSpacePreviewChangeHighlight | null;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [srcdoc, setSrcdoc] = useState('');
const [height, setHeight] = useState(minHeight);
const [initialLoading, setInitialLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const requestRef = useRef(0);
const hasRenderedRef = useRef(false);
const appliedHighlightRevisionRef = useRef<number | null>(null);
useEffect(() => {
const requestId = requestRef.current + 1;
requestRef.current = requestId;
if (!hasRenderedRef.current) setInitialLoading(true);
setError(null);
const timer = window.setTimeout(() => {
void fetchMindSpacePageDraftPreview(pageId, { title, summary, content, templateId })
.then((html) => {
if (requestRef.current !== requestId) return;
setSrcdoc(html);
hasRenderedRef.current = true;
setInitialLoading(false);
})
.catch((err) => {
if (requestRef.current !== requestId) return;
setError(err instanceof Error ? err.message : '预览加载失败');
setInitialLoading(false);
});
}, hasRenderedRef.current ? 450 : 120);
return () => window.clearTimeout(timer);
}, [pageId, title, summary, content, templateId, reloadKey]);
useEffect(() => {
setHeight(minHeight);
}, [srcdoc, reloadKey, minHeight]);
useEffect(() => {
const iframe = iframeRef.current;
if (!iframe || !srcdoc) return;
const syncHeight = () => {
try {
const doc = iframe.contentDocument;
if (!doc) return;
const body = doc.body;
const root = doc.documentElement;
const next = Math.ceil(
Math.max(
body?.scrollHeight ?? 0,
body?.offsetHeight ?? 0,
root?.scrollHeight ?? 0,
root?.offsetHeight ?? 0,
minHeight,
),
);
setHeight(Math.min(next, maxHeight));
if (
changeHighlight &&
appliedHighlightRevisionRef.current !== changeHighlight.revision
) {
appliedHighlightRevisionRef.current = changeHighlight.revision;
applyPageChangeHighlight(doc, {
previousTitle: changeHighlight.previousTitle,
nextTitle: title,
previousContent: changeHighlight.previousContent,
nextContent: content,
});
}
} catch {
setHeight(minHeight);
}
};
iframe.addEventListener('load', syncHeight);
return () => iframe.removeEventListener('load', syncHeight);
}, [changeHighlight, content, maxHeight, minHeight, srcdoc, title]);
if (error) {
return <div className="mindspace-state mindspace-error">{error}</div>;
}
return (
<>
{initialLoading && !srcdoc ? (
<div className="mindspace-state mindspace-page-preview-loading">正在加载预览…</div>
) : null}
<iframe
ref={iframeRef}
title={`${title || '页面'} 预览`}
srcDoc={srcdoc}
sandbox="allow-same-origin"
className={className}
style={{ height: `${height}px`, visibility: srcdoc ? 'visible' : 'hidden' }}
/>
</>
);
}
@@ -0,0 +1,209 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { usePageEditSubChat } from '../hooks/usePageEditSubChat';
import type { MindSpaceChatContext, MindSpaceSaveCategory, PortalUser } from '../types';
import type { MindSpacePagePatch } from '../utils/mindspacePagePatch';
import {
MindSpacePageDraftPreviewFrame,
type MindSpacePreviewChangeHighlight,
} from './MindSpacePageDraftPreviewFrame';
import { MindSpaceSpaceChat } from './MindSpaceSpaceChat';
export type MindSpacePageFullscreenPreviewChat = {
context: MindSpaceChatContext;
user: PortalUser;
parentSessionId?: string | null;
h5ApiBase?: string | null;
onOpenFullChat: () => void;
onPageSaved?: (result: {
kind: 'page' | 'category';
pageId?: string;
categoryCode?: MindSpaceSaveCategory;
}) => void;
onSubSessionChange?: (sessionId: string | null) => void;
onPagePatch?: (patch: MindSpacePagePatch) => void;
onForkUnavailable?: () => void;
};
export function MindSpacePageFullscreenPreview({
pageId,
pageTitle,
title,
summary,
content,
templateId,
reloadKey,
patchBusy = false,
changeHighlight = null,
canUndo = false,
canRedo = false,
onUndo,
onRedo,
onClose,
onRefresh,
onAgentIdleSync,
chat,
}: {
pageId: string;
pageTitle: string;
title: string;
summary: string;
content: string;
templateId: string;
reloadKey: number;
patchBusy?: boolean;
changeHighlight?: MindSpacePreviewChangeHighlight | null;
canUndo?: boolean;
canRedo?: boolean;
onUndo?: () => boolean;
onRedo?: () => boolean;
onClose: () => void;
onRefresh: () => void;
onAgentIdleSync?: () => void;
chat?: MindSpacePageFullscreenPreviewChat | null;
}) {
const [chatOpen, setChatOpen] = useState(true);
const [subChatFallback, setSubChatFallback] = useState(false);
const subChatEnabled = Boolean(chat?.parentSessionId);
useEffect(() => {
setSubChatFallback(false);
}, [pageId, chat?.parentSessionId]);
const prevSubChatStateRef = useRef(subChatEnabled ? 'loading' : 'idle');
const subChat = usePageEditSubChat({
pageId,
pageTitle: pageTitle || title,
parentSessionId: chat?.parentSessionId,
h5ApiBase: chat?.h5ApiBase,
user: chat?.user,
enabled: subChatEnabled && !subChatFallback,
onSessionChange: chat?.onSubSessionChange,
onPagePatch: chat?.onPagePatch,
onForkUnavailable: () => {
setSubChatFallback(true);
chat?.onForkUnavailable?.();
},
});
const subChatContext = useMemo(() => {
if (!chat?.context) return null;
return {
...chat.context,
pageEditMode: true,
parentAgentSessionId: chat.parentSessionId ?? chat.context.parentAgentSessionId,
agentSessionId: subChat.session?.id ?? chat.context.agentSessionId,
} satisfies MindSpaceChatContext;
}, [chat, subChat.session?.id]);
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, []);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
) {
return;
}
const mod = event.metaKey || event.ctrlKey;
if (mod && !event.altKey && event.key.toLowerCase() === 'z') {
if (event.shiftKey) {
if (canRedo && onRedo?.()) event.preventDefault();
} else if (canUndo && onUndo?.()) {
event.preventDefault();
}
return;
}
if (event.key !== 'Escape') return;
if (chatOpen) return;
onClose();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [canRedo, canUndo, chatOpen, onClose, onRedo, onUndo]);
useEffect(() => {
if (!onAgentIdleSync || !subChatEnabled) return;
const previous = prevSubChatStateRef.current;
prevSubChatStateRef.current = subChat.chatState;
const wasBusy = previous === 'streaming' || previous === 'waiting';
if (wasBusy && subChat.chatState === 'idle') {
onAgentIdleSync();
}
}, [onAgentIdleSync, subChat.chatState, subChatEnabled]);
return (
<div className="mindspace-page-fullscreen-preview" role="dialog" aria-modal="true" aria-label="页面全屏预览">
<header className="mindspace-page-fullscreen-preview-toolbar">
<div className="mindspace-page-fullscreen-preview-title">
<span className="mindspace-eyebrow">PREVIEW · EDIT</span>
<strong>{pageTitle || '页面预览'}</strong>
</div>
<div className="mindspace-page-fullscreen-preview-actions">
{patchBusy ? (
<span className="mindspace-page-preview-sync">同步 Agent 修改…</span>
) : null}
{subChatEnabled && !subChatFallback && subChat.chatState === 'loading' ? (
<span className="mindspace-page-preview-sync">启动编辑 Agent…</span>
) : null}
{subChatFallback ? (
<span className="mindspace-page-preview-sync">使用主对话编辑(子 Agent 未就绪)</span>
) : null}
<button type="button" onClick={onUndo} disabled={!canUndo} title="撤销 (⌘Z)">
撤销
</button>
<button type="button" onClick={onRedo} disabled={!canRedo} title="重做 (⌘⇧Z)">
重做
</button>
<button type="button" onClick={onRefresh}>
刷新
</button>
<button type="button" className="mindspace-primary" onClick={onClose}>
退出预览
</button>
</div>
</header>
<div className="mindspace-page-fullscreen-preview-body">
<MindSpacePageDraftPreviewFrame
pageId={pageId}
title={title}
summary={summary}
content={content}
templateId={templateId}
reloadKey={reloadKey}
changeHighlight={changeHighlight}
minHeight={720}
maxHeight={12000}
className="mindspace-page-fullscreen-preview-frame"
/>
</div>
{chat && subChatContext ? (
<MindSpaceSpaceChat
context={subChatContext}
user={chat.user}
onOpenFullChat={chat.onOpenFullChat}
onPageSaved={chat.onPageSaved}
open={chatOpen}
onOpenChange={setChatOpen}
variant="overlay"
chatBridge={subChatEnabled && !subChatFallback ? subChat : undefined}
hideOpenFullChat={!subChatFallback}
title={subChatFallback ? 'TKMind' : '页面编辑 Agent'}
/>
) : null}
</div>
);
}
+23 -4
View File
@@ -9,6 +9,12 @@ export function MindSpaceSpaceChat({
user,
onOpenFullChat,
onPageSaved,
open: controlledOpen,
onOpenChange,
variant = 'default',
chatBridge,
hideOpenFullChat = false,
title,
}: {
context: MindSpaceChatContext;
user: PortalUser;
@@ -18,17 +24,27 @@ export function MindSpaceSpaceChat({
pageId?: string;
categoryCode?: MindSpaceSaveCategory;
}) => void;
open?: boolean;
onOpenChange?: (open: boolean) => void;
variant?: 'default' | 'overlay';
chatBridge?: import('../hooks/usePageEditSubChat').PageEditSubChatBridge;
hideOpenFullChat?: boolean;
title?: string;
}) {
const { chatState, pendingTool } = useChat();
const [open, setOpen] = useState(false);
const [internalOpen, setInternalOpen] = useState(false);
const open = controlledOpen ?? internalOpen;
const setOpen = onOpenChange ?? setInternalOpen;
const wrapperClass =
variant === 'overlay' ? 'space-chat-stack space-chat-stack-overlay' : 'space-chat-stack';
return (
<>
<div className={wrapperClass}>
<SpaceChatFab
open={open}
streaming={chatState === 'streaming'}
pendingTool={Boolean(pendingTool)}
onClick={() => setOpen((current) => !current)}
onClick={() => setOpen(!open)}
/>
<SpaceChatPanel
open={open}
@@ -37,7 +53,10 @@ export function MindSpaceSpaceChat({
onClose={() => setOpen(false)}
onOpenFullChat={onOpenFullChat}
onPageSaved={onPageSaved}
chatBridge={chatBridge}
hideOpenFullChat={hideOpenFullChat}
title={title}
/>
</>
</div>
);
}
+457 -129
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState, type MouseEvent } from 'react';
import { useEffect, useMemo, useRef, useState, type MouseEvent } from 'react';
import { useLocation } from 'react-router-dom';
import { useChat } from '../context/ChatProvider';
import {
cancelMindSpaceAgentJob,
createMindSpaceAgentJob,
@@ -7,6 +8,7 @@ import {
createMindSpacePageFromAsset,
deleteMindSpaceAsset,
deleteMindSpacePage,
bindMindSpacePageLiveEdit,
getMindSpaceAgentJob,
getMindSpace,
getMindSpacePageDeletePreview,
@@ -28,15 +30,25 @@ import type {
MindSpaceCleanupItem,
MindSpacePage,
MindSpacePageDeletePreview,
MindSpaceQuota,
MindSpaceSaveCategory,
PortalUser,
} from '../types';
import { buildMindSpaceChatContext } from '../utils/mindspaceChatContext';
import {
extractMindSpacePagePatch,
patchKey,
type MindSpacePagePatch,
} from '../utils/mindspacePagePatch';
import { getDisplayText } from '../utils/message';
import { resolveH5ApiBase } from '../utils/h5ApiBase';
import { MindSpaceSpaceChat } from './MindSpaceSpaceChat';
import {
assetIcon,
assetSourceLabel,
buildAssetImageUrl,
buildAssetPreviewFrameConfig,
isImageAsset,
isPreviewableAsset,
isWorkAsset,
} from '../utils/mindspaceCards';
@@ -49,6 +61,7 @@ import { WorkCover } from './WorkCover';
import { buildAssetSharePayload, type SharePayload } from '../utils/shareChannels';
import { resolvePublicPageUrl, resolvePlazaHomeUrl } from '../utils/publicUrl';
import { PagePreviewFrame } from './PagePreviewFrame';
import { BalanceRing } from './BalanceRing';
const CATEGORY_DESCRIPTIONS: Record<MindSpaceCategory['code'], string> = {
oa: '上传文档、表格和资料,让 AI 生成报告与页面',
@@ -66,6 +79,7 @@ const CATEGORY_ACTIONS: Partial<Record<MindSpaceCategory['code'], string>> = {
};
const AGENT_JOBS_PAGE_SIZE = 10;
const IMAGE_PAGE_SIZE = 10;
const JOB_STATUS_LABELS: Record<MindSpaceAgentJob['status'], string> = {
queued: '排队中',
@@ -82,7 +96,7 @@ function formatBytes(bytes: number) {
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
type AssetFilter = 'all' | 'files' | 'pages';
type AssetFilter = 'all' | 'images' | 'files' | 'pages';
function canGenerateWithAgent(asset: MindSpaceAsset) {
const textLikeMimeTypes = new Set([
@@ -105,6 +119,19 @@ type MindSpaceRouteSync = {
pushPage: (pageId: string) => void;
};
function MindSpaceBalanceRing() {
const { balanceCents, totalCreditCents, tokensUsed, openRecharge } = useChat();
if (typeof balanceCents !== 'number') return null;
return (
<BalanceRing
balanceCents={balanceCents}
totalCreditCents={totalCreditCents}
tokensUsed={tokensUsed}
onRecharge={openRecharge}
/>
);
}
export function MindSpaceView({
user,
previewMode = false,
@@ -141,6 +168,7 @@ export function MindSpaceView({
const [newPageTemplate, setNewPageTemplate] = useState('editorial');
const [creatingPage, setCreatingPage] = useState(false);
const [assetFilter, setAssetFilter] = useState<AssetFilter>('all');
const [imagePage, setImagePage] = useState(0);
const [previewAssetId, setPreviewAssetId] = useState<string | null>(null);
const [editingAssetId, setEditingAssetId] = useState<string | null>(null);
const [cleanupOpen, setCleanupOpen] = useState(false);
@@ -175,6 +203,18 @@ export function MindSpaceView({
summary: string;
content: string;
} | null>(null);
const [pageRefreshTrigger, setPageRefreshTrigger] = useState(0);
const [incomingPagePatch, setIncomingPagePatch] = useState<{
patch: MindSpacePagePatch;
sourceMessageId: string;
} | null>(null);
const [pageFullscreenPreviewOpen, setPageFullscreenPreviewOpen] = useState(false);
const [pageEditSubSessionId, setPageEditSubSessionId] = useState<string | null>(null);
const [pageEditSubSessionActive, setPageEditSubSessionActive] = useState(false);
const appliedPatchKeysRef = useRef(new Set<string>());
const { chatState, messages, session } = useChat();
const prevChatStateRef = useRef(chatState);
const h5ApiBase = useMemo(() => resolveH5ApiBase(), []);
const location = useLocation();
const fromPlaza = useMemo(() => {
const params = new URLSearchParams(location.search);
@@ -188,9 +228,43 @@ export function MindSpaceView({
useEffect(() => {
if (!selectedPageId) {
setPageLiveContext(null);
setIncomingPagePatch(null);
setPageFullscreenPreviewOpen(false);
setPageEditSubSessionId(null);
setPageEditSubSessionActive(false);
}
appliedPatchKeysRef.current.clear();
}, [selectedPageId]);
useEffect(() => {
if (!selectedPageId || !session?.id || previewMode) return;
if (pageFullscreenPreviewOpen && pageEditSubSessionActive) return;
void bindMindSpacePageLiveEdit(selectedPageId, session.id).catch(() => {});
}, [
selectedPageId,
session?.id,
previewMode,
pageFullscreenPreviewOpen,
pageEditSubSessionActive,
]);
useEffect(() => {
if (!selectedPageId || previewMode || pageFullscreenPreviewOpen) return;
if (chatState !== 'idle') return;
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i];
if (message.role !== 'assistant') continue;
const patch = extractMindSpacePagePatch(getDisplayText(message));
if (!patch) continue;
const key = `${message.id}:${patchKey(patch)}`;
if (appliedPatchKeysRef.current.has(key)) return;
appliedPatchKeysRef.current.add(key);
setIncomingPagePatch({ patch, sourceMessageId: message.id });
return;
}
}, [chatState, messages, previewMode, selectedPageId, pageFullscreenPreviewOpen]);
const mindspaceChatContext = useMemo(() => {
if (!space) return null;
return buildMindSpaceChatContext({
@@ -203,6 +277,10 @@ export function MindSpaceView({
assets,
focusedAsset: agentAsset,
route: `${location.pathname}${location.search}`,
agentSessionId: pageEditSubSessionId ?? session?.id ?? null,
h5ApiBase,
pageEditMode: pageFullscreenPreviewOpen,
parentAgentSessionId: pageFullscreenPreviewOpen ? session?.id ?? null : null,
});
}, [
space,
@@ -215,6 +293,10 @@ export function MindSpaceView({
agentAsset,
location.pathname,
location.search,
session?.id,
h5ApiBase,
pageFullscreenPreviewOpen,
pageEditSubSessionId,
]);
const truncateLabel = (text: string, max = 48) => {
@@ -263,13 +345,13 @@ export function MindSpaceView({
setDeletingPageId(pageDeleteTarget.id);
setError(null);
try {
await deleteMindSpacePage(pageDeleteTarget.id);
const result = await deleteMindSpacePage(pageDeleteTarget.id);
if (selectedPageId === pageDeleteTarget.id) {
closePage();
}
setPageDeleteTarget(null);
setPageDeletePreview(null);
await load();
await refreshMindSpaceSnapshot({ quota: result.quota, freedBytes: result.freedBytes });
} catch (err) {
setError(err instanceof Error ? err.message : '删除页面失败');
} finally {
@@ -314,6 +396,7 @@ export function MindSpaceView({
setSelectedCategory(null);
setUploadOpen(false);
setPendingDeleteId(null);
setImagePage(0);
routeSync?.pushHome();
};
@@ -343,11 +426,15 @@ export function MindSpaceView({
setCleanupRunning(true);
setError(null);
try {
await runMindSpaceCleanup(selectedCleanupIds);
const result = await runMindSpaceCleanup(selectedCleanupIds);
if (result.quota) {
setSpace((prev) => (prev ? { ...prev, quota: result.quota! } : prev));
} else {
await refreshSpaceQuietly();
}
setCleanupOpen(false);
setCleanupItems([]);
setSelectedCleanupIds([]);
await load();
} catch (err) {
setError(err instanceof Error ? err.message : '清理失败');
} finally {
@@ -427,6 +514,64 @@ export function MindSpaceView({
}
};
const refreshSpaceQuietly = async () => {
if (previewMode) return null;
try {
const nextSpace = await getMindSpace();
setSpace(nextSpace);
return nextSpace;
} catch {
return null;
}
};
const refreshPagesSilently = async () => {
if (previewMode) return;
try {
const nextPages = await listMindSpacePages();
setPages(nextPages);
} catch {
// ignore background list refresh errors
}
};
const refreshMindSpaceSnapshot = async (options?: {
quota?: MindSpaceQuota;
freedBytes?: number;
}) => {
if (options?.quota) {
setSpace((prev) => (prev ? { ...prev, quota: options.quota! } : prev));
} else if (typeof options?.freedBytes === 'number' && options.freedBytes > 0) {
setSpace((prev) => {
if (!prev) return prev;
const usedBytes = Math.max(0, prev.quota.usedBytes - options.freedBytes!);
return {
...prev,
quota: {
...prev.quota,
usedBytes,
availableBytes: Math.max(
0,
prev.quota.quotaBytes - usedBytes - prev.quota.reservedBytes,
),
},
};
});
} else {
await refreshSpaceQuietly();
}
await refreshPagesSilently();
};
useEffect(() => {
const previous = prevChatStateRef.current;
prevChatStateRef.current = chatState;
const wasBusy = previous === 'streaming' || previous === 'waiting';
if (!wasBusy || chatState !== 'idle' || !selectedPageId || previewMode) return;
setPageRefreshTrigger((value) => value + 1);
void refreshPagesSilently();
}, [chatState, selectedPageId, previewMode]);
useEffect(() => {
void load();
}, []);
@@ -470,6 +615,7 @@ export function MindSpaceView({
setNewPageOpen(false);
setUploadOpen(false);
setPendingDeleteId(null);
setImagePage(0);
setAssetsLoading(true);
setError(null);
try {
@@ -573,11 +719,9 @@ export function MindSpaceView({
}
};
const occupiedBytes = space ? space.quota.usedBytes + space.quota.reservedBytes : 0;
const usedPercent = space
? Math.min(
100,
((space.quota.usedBytes + space.quota.reservedBytes) / space.quota.quotaBytes) * 100,
)
? Math.min(100, (occupiedBytes / space.quota.quotaBytes) * 100)
: 0;
const openAssetEditor = async (asset: MindSpaceAsset) => {
@@ -675,11 +819,35 @@ export function MindSpaceView({
}
};
const filteredAssets = assets.filter((asset) => {
if (assetFilter === 'files') return !isWorkAsset(asset);
if (assetFilter === 'pages') return isWorkAsset(asset);
return true;
});
const imageAssets = useMemo(() => assets.filter(isImageAsset), [assets]);
const fileAssets = useMemo(
() => assets.filter((asset) => !isWorkAsset(asset) && !isImageAsset(asset)),
[assets],
);
const workAssets = useMemo(() => assets.filter(isWorkAsset), [assets]);
const visibleImageAssets =
assetFilter === 'all' || assetFilter === 'images' ? imageAssets : [];
const imagePaginationEnabled = visibleImageAssets.length > IMAGE_PAGE_SIZE;
const imagePageCount = Math.max(
1,
Math.ceil(visibleImageAssets.length / IMAGE_PAGE_SIZE),
);
const safeImagePage = Math.min(imagePage, imagePageCount - 1);
const pagedImageAssets = imagePaginationEnabled
? visibleImageAssets.slice(
safeImagePage * IMAGE_PAGE_SIZE,
safeImagePage * IMAGE_PAGE_SIZE + IMAGE_PAGE_SIZE,
)
: visibleImageAssets;
const visibleCardAssets =
assetFilter === 'all'
? [...fileAssets, ...workAssets]
: assetFilter === 'files'
? fileAssets
: assetFilter === 'pages'
? workAssets
: [];
const previewAsset = useMemo(
() => assets.find((asset) => asset.id === previewAssetId) ?? null,
@@ -687,6 +855,64 @@ export function MindSpaceView({
);
const previewAssetFrame = previewAsset ? buildAssetPreviewFrameConfig(previewAsset) : null;
const renderAssetActions = (asset: MindSpaceAsset) => (
<div className="mindspace-item-actions">
{isPreviewableAsset(asset) && (
<button type="button" onClick={() => setPreviewAssetId(asset.id)}>
预览
</button>
)}
{isWorkAsset(asset) && (
<button
type="button"
onClick={() => void openAssetEditor(asset)}
disabled={editingAssetId === asset.id}
>
{editingAssetId === asset.id ? '打开中…' : '编辑'}
</button>
)}
{asset.sourcePageId && (
<button type="button" onClick={() => showPage(asset.sourcePageId!, true)}>
发布到广场
</button>
)}
{canGenerateWithAgent(asset) && (
<button type="button" onClick={() => openAgentGenerator(asset)}>
AI 生成页面
</button>
)}
{asset.status === 'ready' && asset.scanStatus !== 'blocked' ? (
<button
type="button"
className="mindspace-asset-share"
onClick={() => setSharePayload(buildAssetSharePayload(asset, selectedCategory?.code))}
>
分享
</button>
) : (
<span className="mindspace-asset-blocked">暂不可用</span>
)}
{pendingDeleteId === asset.id ? (
<>
<button
type="button"
className="mindspace-delete-confirm"
onClick={() => void removeAsset(asset.id)}
>
确认删除
</button>
<button type="button" onClick={() => setPendingDeleteId(null)}>
取消
</button>
</>
) : (
<button type="button" onClick={() => setPendingDeleteId(asset.id)}>
删除
</button>
)}
</div>
);
const createPage = async () => {
if (!newPageTitle.trim() || !newPageContent.trim()) return;
if (previewMode) {
@@ -779,6 +1005,16 @@ export function MindSpaceView({
发现广场
</a>
)}
{previewMode ? (
<BalanceRing
balanceCents={user.balanceCents}
totalCreditCents={user.totalCreditCents}
tokensUsed={user.tokensUsed}
onRecharge={() => {}}
/>
) : (
<MindSpaceBalanceRing />
)}
<span>{user.displayName}</span>
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
登出
@@ -827,7 +1063,7 @@ export function MindSpaceView({
<div className="mindspace-hero-quota-top">
<span>免费版空间</span>
<strong>
{formatBytes(space.quota.usedBytes)} / {formatBytes(space.quota.quotaBytes)}
{formatBytes(occupiedBytes)} / {formatBytes(space.quota.quotaBytes)}
</strong>
</div>
<div
@@ -841,6 +1077,11 @@ export function MindSpaceView({
<span style={{ width: `${usedPercent}%` }} />
</div>
<p className="mindspace-hero-quota-meta">
{space.quota.reservedBytes > 0 && (
<>
含上传预留 {formatBytes(space.quota.reservedBytes)} ·{' '}
</>
)}
单文件上限 {formatBytes(space.quota.maxFileBytes)} · 可发布页面{' '}
{space.quota.publicPageUsed}/{space.quota.publicPageLimit} · 今日 AI{' '}
{space.quota.aiDailyUsed}/{space.quota.aiDailyLimit}
@@ -853,14 +1094,20 @@ export function MindSpaceView({
if (!cleanupOpen) void loadCleanupItems();
}}
>
{cleanupOpen ? '收起清理' : '清理临时文件'}
{cleanupOpen ? '收起清理' : '清理空间'}
</button>
</aside>
)}
{cleanupOpen && (
<div className="mindspace-hero-cleanup">
{cleanupLoading && <p>正在扫描可清理的临时文件…</p>}
{!cleanupLoading && cleanupItems.length === 0 && <p>当前没有可清理的临时文件。</p>}
{cleanupLoading && <p>正在扫描可清理项…</p>}
{!cleanupLoading && cleanupItems.length === 0 && (
<p>
{space && occupiedBytes >= space.quota.quotaBytes * 0.85
? `空间已用 ${formatBytes(occupiedBytes)} / ${formatBytes(space.quota.quotaBytes)},暂无可自动清理的临时文件或历史版本。请到下方分类中手动删除不需要的页面或资产。`
: '当前没有可清理的临时文件或历史页面版本。'}
</p>
)}
{!cleanupLoading && cleanupItems.length > 0 && (
<>
<p>
@@ -953,9 +1200,53 @@ export function MindSpaceView({
<MindSpacePageDetail
pageId={selectedPageId}
onBack={closePage}
onSaved={load}
onSaved={refreshMindSpaceSnapshot}
onContextUpdate={setPageLiveContext}
autoOpenPlaza={selectedPageAutoPlaza}
refreshTrigger={pageRefreshTrigger}
incomingPagePatch={incomingPagePatch}
onPagePatchHandled={() => setIncomingPagePatch(null)}
onFullscreenPreviewChange={(open) => {
setPageFullscreenPreviewOpen(open);
if (!open) {
setPageEditSubSessionId(null);
setPageEditSubSessionActive(false);
}
}}
overlayChat={
mindspaceChatContext && session?.id
? {
context: mindspaceChatContext,
user,
parentSessionId: session.id,
h5ApiBase,
onOpenFullChat: onBack,
onSubSessionChange: (subSessionId) => {
setPageEditSubSessionId(subSessionId);
setPageEditSubSessionActive(Boolean(subSessionId));
},
onForkUnavailable: () => {
setPageEditSubSessionId(null);
setPageEditSubSessionActive(false);
if (selectedPageId && session?.id) {
void bindMindSpacePageLiveEdit(selectedPageId, session.id).catch(
() => {},
);
}
},
onPageSaved: (result) => {
if (result.kind === 'page' && result.pageId) {
void refreshPagesSilently();
if (result.pageId === selectedPageId) {
setPageRefreshTrigger((value) => value + 1);
} else {
showPage(result.pageId);
}
}
},
}
: null
}
/>
)
) : newPageOpen ? (
@@ -1149,6 +1440,7 @@ export function MindSpaceView({
{(
[
['all', '全部'],
['images', '图片'],
['files', '资料'],
['pages', '页面'],
] as const
@@ -1157,119 +1449,150 @@ export function MindSpaceView({
type="button"
key={value}
className={assetFilter === value ? 'is-active' : undefined}
onClick={() => setAssetFilter(value)}
onClick={() => {
setAssetFilter(value);
setImagePage(0);
}}
>
{label}
{value === 'images' && imageAssets.length > 0
? ` (${imageAssets.length})`
: ''}
</button>
))}
</div>
<div className="mindspace-item-grid">
{filteredAssets.map((asset) => (
<article
className={`mindspace-item-card${isWorkAsset(asset) ? ' is-work' : ' is-file'}`}
key={asset.id}
>
<button
type="button"
className="mindspace-item-card-main"
onClick={() => {
if (isWorkAsset(asset)) {
void openAssetEditor(asset);
return;
}
if (isPreviewableAsset(asset)) setPreviewAssetId(asset.id);
}}
>
{isWorkAsset(asset) ? (
<div className="mindspace-item-cover">
{isPreviewableAsset(asset) ? (
<WorkCover
thumbnailUrl={`/api/mindspace/v1/assets/${asset.id}/thumbnail?v=${asset.updatedAt}`}
previewAsset={asset}
fallbackLabel={assetIcon(asset)}
title={asset.displayName}
/>
) : (
<span>{assetIcon(asset)}</span>
)}
</div>
) : (
<div className="mindspace-item-icon">{assetIcon(asset)}</div>
)}
<div className="mindspace-item-copy">
<span className="mindspace-item-badge">{assetSourceLabel(asset)}</span>
<h3>{asset.displayName}</h3>
<p>
{formatBytes(asset.sizeBytes)} ·{' '}
{new Date(asset.updatedAt).toLocaleDateString()}
</p>
</div>
</button>
<div className="mindspace-item-actions">
{isPreviewableAsset(asset) && (
<button type="button" onClick={() => setPreviewAssetId(asset.id)}>
预览
</button>
)}
{isWorkAsset(asset) && (
<button
type="button"
onClick={() => void openAssetEditor(asset)}
disabled={editingAssetId === asset.id}
>
{editingAssetId === asset.id ? '打开中…' : '编辑'}
</button>
)}
{asset.sourcePageId && (
<button
type="button"
onClick={() => showPage(asset.sourcePageId!, true)}
>
发布到广场
</button>
)}
{canGenerateWithAgent(asset) && (
<button type="button" onClick={() => openAgentGenerator(asset)}>
AI 生成页面
</button>
)}
{asset.status === 'ready' && asset.scanStatus !== 'blocked' ? (
<button
type="button"
className="mindspace-asset-share"
onClick={() =>
setSharePayload(
buildAssetSharePayload(asset, selectedCategory?.code),
)
}
>
分享
</button>
) : (
<span className="mindspace-asset-blocked">暂不可用</span>
)}
{pendingDeleteId === asset.id ? (
<>
<button
type="button"
className="mindspace-delete-confirm"
onClick={() => void removeAsset(asset.id)}
>
确认删除
</button>
<button type="button" onClick={() => setPendingDeleteId(null)}>
取消
</button>
</>
) : (
<button type="button" onClick={() => setPendingDeleteId(asset.id)}>
删除
</button>
)}
{visibleImageAssets.length > 0 && (
<>
{assetFilter === 'all' && (
<div className="mindspace-section-subheading">
<h3>图片</h3>
<span>{visibleImageAssets.length} 张</span>
</div>
</article>
))}
</div>
)}
<div
className={`mindspace-image-grid${imagePaginationEnabled ? ' is-compact' : ''}`}
>
{pagedImageAssets.map((asset) => (
<article
className={`mindspace-image-card${imagePaginationEnabled ? ' is-compact' : ''}`}
key={asset.id}
>
<button
type="button"
className="mindspace-image-thumb"
onClick={() => setPreviewAssetId(asset.id)}
title={asset.displayName}
>
<img
src={buildAssetImageUrl(asset)}
alt={asset.displayName}
loading="lazy"
/>
</button>
{!imagePaginationEnabled && (
<>
<div className="mindspace-image-meta">
<span className="mindspace-image-name">{asset.displayName}</span>
<span className="mindspace-image-size">
{formatBytes(asset.sizeBytes)}
</span>
</div>
{renderAssetActions(asset)}
</>
)}
</article>
))}
</div>
{imagePaginationEnabled && (
<div className="mindspace-pagination mindspace-image-pagination">
<button
type="button"
disabled={safeImagePage <= 0}
onClick={() => setImagePage((page) => Math.max(0, page - 1))}
>
上一页
</button>
<span>
第 {safeImagePage + 1} / {imagePageCount} 页 · 共{' '}
{visibleImageAssets.length} 张
</span>
<button
type="button"
disabled={safeImagePage >= imagePageCount - 1}
onClick={() =>
setImagePage((page) => Math.min(imagePageCount - 1, page + 1))
}
>
下一页
</button>
</div>
)}
</>
)}
{visibleCardAssets.length > 0 && (
<>
{assetFilter === 'all' && visibleImageAssets.length > 0 && (
<div className="mindspace-section-subheading">
<h3>资料与页面</h3>
<span>{visibleCardAssets.length} 项</span>
</div>
)}
<div className="mindspace-item-grid">
{visibleCardAssets.map((asset) => (
<article
className={`mindspace-item-card${isWorkAsset(asset) ? ' is-work' : ' is-file'}`}
key={asset.id}
>
<button
type="button"
className="mindspace-item-card-main"
onClick={() => {
if (isWorkAsset(asset)) {
void openAssetEditor(asset);
return;
}
if (isPreviewableAsset(asset)) setPreviewAssetId(asset.id);
}}
>
{isWorkAsset(asset) ? (
<div className="mindspace-item-cover">
{isPreviewableAsset(asset) ? (
<WorkCover
thumbnailUrl={`/api/mindspace/v1/assets/${asset.id}/thumbnail?v=${asset.updatedAt}`}
previewAsset={asset}
fallbackLabel={assetIcon(asset)}
title={asset.displayName}
/>
) : (
<span>{assetIcon(asset)}</span>
)}
</div>
) : (
<div className="mindspace-item-icon">{assetIcon(asset)}</div>
)}
<div className="mindspace-item-copy">
<span className="mindspace-item-badge">
{assetSourceLabel(asset)}
</span>
<h3>{asset.displayName}</h3>
<p>
{formatBytes(asset.sizeBytes)} ·{' '}
{new Date(asset.updatedAt).toLocaleDateString()}
</p>
</div>
</button>
{renderAssetActions(asset)}
</article>
))}
</div>
</>
)}
{visibleImageAssets.length === 0 && visibleCardAssets.length === 0 && (
<div className="mindspace-empty">
<h2>该分类下暂无内容</h2>
<p>试试切换其他筛选,或上传新资料。</p>
</div>
)}
</>
)}
@@ -1670,14 +1993,19 @@ export function MindSpaceView({
)}
</MindSpaceModal>
{!previewMode && mindspaceChatContext && (
{!previewMode && mindspaceChatContext && !pageFullscreenPreviewOpen && (
<MindSpaceSpaceChat
context={mindspaceChatContext}
user={user}
onOpenFullChat={onBack}
onPageSaved={(result) => {
if (result.kind === 'page' && result.pageId) {
void load().then(() => showPage(result.pageId!));
void refreshPagesSilently();
if (result.pageId === selectedPageId) {
setPageRefreshTrigger((value) => value + 1);
} else {
showPage(result.pageId);
}
}
}}
/>
+46 -6
View File
@@ -1,5 +1,7 @@
import { useEffect } from 'react';
import { useChat } from '../context/ChatProvider';
import { INSUFFICIENT_BALANCE_NOTICE } from '../hooks/useTKMindChat';
import type { PageEditSubChatBridge } from '../hooks/usePageEditSubChat';
import type { MindSpaceChatContext, MindSpaceSaveCategory, PortalUser } from '../types';
import { formatContextChip } from '../utils/mindspaceChatContext';
import { ChatPanel } from './ChatPanel';
@@ -12,6 +14,9 @@ export function SpaceChatPanel({
onClose,
onOpenFullChat,
onPageSaved,
chatBridge,
hideOpenFullChat = false,
title = 'TKMind',
}: {
open: boolean;
context: MindSpaceChatContext;
@@ -23,7 +28,12 @@ export function SpaceChatPanel({
pageId?: string;
categoryCode?: MindSpaceSaveCategory;
}) => void;
chatBridge?: PageEditSubChatBridge;
hideOpenFullChat?: boolean;
title?: string;
}) {
const mainChat = useChat();
const chat = chatBridge ?? mainChat;
const {
session,
messages,
@@ -33,8 +43,11 @@ export function SpaceChatPanel({
stop,
approveTool,
error,
notice,
dismissNotice,
openRecharge,
retryConnect,
} = useChat();
} = chat;
useEffect(() => {
if (!open) return;
@@ -55,22 +68,44 @@ export function SpaceChatPanel({
<div className="space-chat-panel-brand">
<TKMindAvatar size="sm" />
<div>
<strong>TKMind</strong>
<strong>{title}</strong>
<span className="space-chat-context-chip" title={context.route}>
{contextLabel}
</span>
</div>
</div>
<div className="space-chat-panel-actions">
<button type="button" className="ghost-btn" onClick={onOpenFullChat}>
打开完整聊天
</button>
{!hideOpenFullChat ? (
<button type="button" className="ghost-btn" onClick={onOpenFullChat}>
打开完整聊天
</button>
) : null}
<button type="button" className="space-chat-panel-close" onClick={onClose} aria-label="关闭">
×
</button>
</div>
</header>
{notice && (
<div className="space-chat-panel-error space-chat-panel-notice">
<span>{notice}</span>
{notice === INSUFFICIENT_BALANCE_NOTICE ? (
<>
<button type="button" onClick={() => openRecharge(false)}>
去充值
</button>
<button type="button" onClick={dismissNotice}>
知道了
</button>
</>
) : (
<button type="button" onClick={dismissNotice}>
知道了
</button>
)}
</div>
)}
{error && (
<div className="space-chat-panel-error">
<span>{error}</span>
@@ -87,10 +122,15 @@ export function SpaceChatPanel({
chatState={chatState}
pendingTool={pendingTool}
session={session}
onSubmit={(text) => submit(text, { mindspaceContext: context })}
onSubmit={(text) =>
chatBridge
? void submit(text, context)
: void submit(text, { mindspaceContext: context })
}
onStop={stop}
onApproveTool={approveTool}
onPageSaved={onPageSaved}
onClose={onClose}
/>
</div>
);
+7 -33
View File
@@ -1,54 +1,28 @@
import { useEffect, useState } from 'react';
import QRCode from 'qrcode';
import { buildPageUrlForWechatOpen } from '../utils/wechat';
import { WechatScanLogin } from './WechatScanLogin';
/** PC 浏览器微信登录:优先开放平台扫码,否则展示「页面链接」二维码供手机微信打开 */
/** PC 浏览器微信扫码登录(开放平台优先,否则公众号 OAuth + PC 轮询) */
export function WechatDesktopLogin({
scanEnabled,
returnTo,
onBindingGate,
onComplete,
onError,
}: {
scanEnabled: boolean;
returnTo?: string | null;
onBindingGate: (pendingToken: string) => void;
onComplete: () => void;
onError: (message: string) => void;
}) {
const [pageQrDataUrl, setPageQrDataUrl] = useState<string | null>(null);
useEffect(() => {
if (scanEnabled) return;
const url = buildPageUrlForWechatOpen();
void QRCode.toDataURL(url, { margin: 1, width: 180 }).then(setPageQrDataUrl).catch(() => {
onError('无法生成微信登录二维码');
});
}, [scanEnabled, onError]);
return (
<div className="login-wechat-scan-card">
<div className="login-wechat-scan-head">
<span>微信扫码登录</span>
</div>
{scanEnabled ? (
<WechatScanLogin
returnTo={returnTo}
onBindingGate={onBindingGate}
onComplete={onComplete}
onError={onError}
/>
) : (
<div className="login-wechat-scan-body">
{pageQrDataUrl ? (
<img src={pageQrDataUrl} alt="微信扫码登录" className="login-wechat-scan-qr" />
) : (
<div className="login-wechat-scan-loading">正在生成二维码…</div>
)}
<p className="login-wechat-scan-tip">请使用微信扫一扫</p>
</div>
)}
<WechatScanLogin
returnTo={returnTo}
onBindingGate={onBindingGate}
onComplete={onComplete}
onError={onError}
/>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
export function WechatQrDisplay({
qrDataUrl,
wxLoginContainerId,
wxLoginReady,
loading,
expired,
onRefresh,
tip,
}: {
qrDataUrl?: string | null;
wxLoginContainerId?: string;
wxLoginReady?: boolean;
loading: boolean;
expired: boolean;
onRefresh: () => void;
tip?: string;
}) {
const useWxLogin = Boolean(wxLoginContainerId);
return (
<div className="login-wechat-scan-body">
{loading && !qrDataUrl && !wxLoginReady && (
<div className="login-wechat-scan-loading">正在生成二维码…</div>
)}
{useWxLogin ? (
<div className="login-wechat-scan-qr-wrap">
<div
id={wxLoginContainerId}
className={`login-wechat-wxlogin${expired ? ' is-expired' : ''}`}
/>
{expired && (
<button
type="button"
className="login-wechat-scan-expired-mask"
onClick={onRefresh}
aria-label="二维码已过期,点击刷新"
>
<span className="login-wechat-scan-expired-text">二维码已过期</span>
<span className="login-wechat-scan-expired-action">点击刷新</span>
</button>
)}
</div>
) : (
qrDataUrl && (
<div className="login-wechat-scan-qr-wrap">
<img
src={qrDataUrl}
alt="微信扫码登录"
className={`login-wechat-scan-qr${expired ? ' is-expired' : ''}`}
/>
{expired && (
<button
type="button"
className="login-wechat-scan-expired-mask"
onClick={onRefresh}
aria-label="二维码已过期,点击刷新"
>
<span className="login-wechat-scan-expired-text">二维码已过期</span>
<span className="login-wechat-scan-expired-action">点击刷新</span>
</button>
)}
</div>
)
)}
<p className="login-wechat-scan-tip">
{expired ? (
<button type="button" className="login-link" onClick={onRefresh}>
刷新二维码
</button>
) : (
tip ?? '请使用微信扫一扫'
)}
</p>
</div>
);
}
+139 -38
View File
@@ -1,6 +1,57 @@
import { useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useId, useRef, useState } from 'react';
import QRCode from 'qrcode';
import { pollWechatScanLogin, startWechatScanLogin } from '../api/client';
import { WechatQrDisplay } from './WechatQrDisplay';
const WX_LOGIN_SCRIPT = 'https://res.wx.qq.com/connect/zh_CN/htmledition/js/wxLogin.js';
type WxLoginCtor = new (config: {
self_redirect?: boolean;
id: string;
appid: string;
scope: string;
redirect_uri: string;
state: string;
style?: string;
href?: string;
stylelite?: number;
}) => void;
let wxLoginScriptPromise: Promise<void> | null = null;
function loadWxLoginScript() {
if (typeof window === 'undefined') {
return Promise.reject(new Error('微信扫码组件不可用'));
}
if (window.WxLogin) return Promise.resolve();
if (!wxLoginScriptPromise) {
wxLoginScriptPromise = new Promise((resolve, reject) => {
const existing = document.querySelector<HTMLScriptElement>(
`script[src="${WX_LOGIN_SCRIPT}"]`,
);
if (existing) {
existing.addEventListener('load', () => resolve(), { once: true });
existing.addEventListener('error', () => reject(new Error('微信扫码组件加载失败')), {
once: true,
});
return;
}
const script = document.createElement('script');
script.src = WX_LOGIN_SCRIPT;
script.async = true;
script.onload = () => resolve();
script.onerror = () => reject(new Error('微信扫码组件加载失败'));
document.head.appendChild(script);
});
}
return wxLoginScriptPromise;
}
declare global {
interface Window {
WxLogin?: WxLoginCtor;
}
}
export function WechatScanLogin({
returnTo,
@@ -13,39 +64,45 @@ export function WechatScanLogin({
onComplete: () => void;
onError: (message: string) => void;
}) {
const containerId = useId().replace(/:/g, '');
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [ready, setReady] = useState(false);
const [loading, setLoading] = useState(true);
const [expired, setExpired] = useState(false);
const stateRef = useRef<string | null>(null);
const pollRef = useRef<number | null>(null);
const expireRef = useRef<number | null>(null);
const sessionKeyRef = useRef(0);
const stopPolling = () => {
const stopPolling = useCallback(() => {
if (pollRef.current !== null) {
window.clearInterval(pollRef.current);
pollRef.current = null;
}
};
if (expireRef.current !== null) {
window.clearTimeout(expireRef.current);
expireRef.current = null;
}
}, []);
const start = async () => {
const markExpired = useCallback(() => {
stopPolling();
setLoading(true);
setExpired(false);
setExpired(true);
setReady(false);
setQrDataUrl(null);
try {
const session = await startWechatScanLogin(returnTo ?? undefined);
stateRef.current = session.state;
const dataUrl = await QRCode.toDataURL(session.qrUrl, {
margin: 1,
width: 180,
});
setQrDataUrl(dataUrl);
const expiresAt = Date.now() + session.expiresInMs;
const container = document.getElementById(containerId);
if (container) container.innerHTML = '';
}, [stopPolling, containerId]);
const startPolling = useCallback(
(state: string, expiresInMs: number) => {
const expiresAt = Date.now() + expiresInMs;
expireRef.current = window.setTimeout(() => {
markExpired();
}, expiresInMs);
pollRef.current = window.setInterval(() => {
const state = stateRef.current;
if (!state) return;
if (Date.now() > expiresAt) {
stopPolling();
setExpired(true);
markExpired();
return;
}
void pollWechatScanLogin(state)
@@ -61,7 +118,7 @@ export function WechatScanLogin({
return;
}
if (result.status === 'expired') {
setExpired(true);
markExpired();
return;
}
onError(result.message ?? '微信扫码登录失败');
@@ -71,34 +128,78 @@ export function WechatScanLogin({
onError(err instanceof Error ? err.message : '微信扫码登录失败');
});
}, 2000);
},
[markExpired, stopPolling, onBindingGate, onComplete, onError],
);
const start = useCallback(async () => {
stopPolling();
setLoading(true);
setExpired(false);
setReady(false);
setQrDataUrl(null);
sessionKeyRef.current += 1;
const sessionKey = sessionKeyRef.current;
const container = document.getElementById(containerId);
if (container) container.innerHTML = '';
try {
const session = await startWechatScanLogin(returnTo ?? undefined);
if (sessionKey !== sessionKeyRef.current) return;
stateRef.current = session.state;
if (session.mode === 'open') {
await loadWxLoginScript();
if (sessionKey !== sessionKeyRef.current || !window.WxLogin) return;
const host = document.getElementById(containerId);
if (!host) throw new Error('微信扫码容器不可用');
host.innerHTML = '';
new window.WxLogin({
self_redirect: false,
id: containerId,
appid: session.openAppId,
scope: 'snsapi_login',
redirect_uri: encodeURIComponent(session.redirectUri),
state: session.state,
stylelite: 1,
});
setReady(true);
} else {
const dataUrl = await QRCode.toDataURL(session.qrUrl, {
margin: 1,
width: 180,
});
if (sessionKey !== sessionKeyRef.current) return;
setQrDataUrl(dataUrl);
}
startPolling(session.state, session.expiresInMs);
} catch (err) {
onError(err instanceof Error ? err.message : '无法启动扫码登录');
} finally {
setLoading(false);
if (sessionKey === sessionKeyRef.current) {
setLoading(false);
}
}
};
}, [returnTo, containerId, stopPolling, startPolling, onError]);
useEffect(() => {
void start();
return () => stopPolling();
// 仅 returnTo 变化时重新拉码,避免父组件回调引用变化导致重复请求
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [returnTo]);
return (
<div className="login-wechat-scan-body">
{loading && <div className="login-wechat-scan-loading">正在生成二维码…</div>}
{!loading && qrDataUrl && !expired && (
<img src={qrDataUrl} alt="微信扫码登录" className="login-wechat-scan-qr" />
)}
{expired && <div className="login-wechat-scan-loading">二维码已过期</div>}
<p className="login-wechat-scan-tip">
{expired ? (
<button type="button" className="login-link" onClick={() => void start()}>
刷新二维码
</button>
) : (
'请使用微信扫一扫,在手机上确认登录'
)}
</p>
</div>
<WechatQrDisplay
qrDataUrl={qrDataUrl}
wxLoginContainerId={qrDataUrl ? undefined : containerId}
wxLoginReady={ready}
loading={loading}
expired={expired}
onRefresh={() => void start()}
tip="请使用微信扫一扫,在手机上确认登录"
/>
);
}