Initial commit: Memind H5 portal with MindSpace, Plaza, and agent jobs.
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getMe, getWechatAuthConfig, login, loginLegacy, register, resetPassword } from '../api/client';
|
||||
import type { CapabilityMap, PortalUser } from '../types';
|
||||
import { buildWechatAuthorizeUrl, isWechatContext, readWechatAuthError } from '../utils/wechat';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
import { resolvePlazaHomeUrl } from '../utils/publicUrl';
|
||||
|
||||
type Mode = 'login' | 'register' | 'reset';
|
||||
|
||||
const MODE_META: Record<Mode, { title: string; desc: string }> = {
|
||||
login: { title: 'TKMind', desc: '登录你的账号' },
|
||||
register: { title: '创建账号', desc: '填写信息完成注册' },
|
||||
reset: { title: '重置密码', desc: '验证注册邮箱后设置新密码' },
|
||||
};
|
||||
|
||||
export function AuthView({
|
||||
legacyMode,
|
||||
onAuth,
|
||||
}: {
|
||||
legacyMode?: boolean;
|
||||
onAuth: (
|
||||
user?: PortalUser | null,
|
||||
capabilities?: CapabilityMap,
|
||||
grantedSkills?: string[],
|
||||
) => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState<Mode>('login');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [wechatEnabled, setWechatEnabled] = useState(false);
|
||||
const [wechatContext, setWechatContext] = useState(() =>
|
||||
isWechatContext({ search: window.location.search }),
|
||||
);
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const fromPlaza = (searchParams.get('utm_source') ?? '').toLowerCase() === 'plaza';
|
||||
const returnTo = searchParams.get('return_to');
|
||||
|
||||
useEffect(() => {
|
||||
const wechatError = readWechatAuthError();
|
||||
if (wechatError) {
|
||||
setError(wechatError);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (legacyMode) return;
|
||||
void getWechatAuthConfig().then((config) => {
|
||||
setWechatEnabled(config.enabled);
|
||||
setWechatContext((prev) =>
|
||||
isWechatContext({
|
||||
search: window.location.search,
|
||||
serverInWechat: config.inWechat,
|
||||
}) || prev,
|
||||
);
|
||||
});
|
||||
}, [legacyMode]);
|
||||
|
||||
const showWechatLogin = !legacyMode && mode === 'login' && wechatContext && wechatEnabled;
|
||||
|
||||
const switchMode = (next: Mode) => {
|
||||
setMode(next);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
setConfirmPassword('');
|
||||
};
|
||||
|
||||
const handleWechatLogin = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
window.location.href = 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,
|
||||
});
|
||||
};
|
||||
|
||||
const handleLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (legacyMode) {
|
||||
await loginLegacy(password);
|
||||
onAuth(null);
|
||||
return;
|
||||
}
|
||||
const user = await login(username, password);
|
||||
const me = await getMe().catch(() => null);
|
||||
onAuth(user, me?.capabilities, me?.grantedSkills);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '登录失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await register(
|
||||
username,
|
||||
password,
|
||||
displayName || undefined,
|
||||
email,
|
||||
{
|
||||
utm_source: searchParams.get('utm_source') ?? undefined,
|
||||
utm_medium: searchParams.get('utm_medium') ?? undefined,
|
||||
utm_campaign: searchParams.get('utm_campaign') ?? undefined,
|
||||
ref: searchParams.get('ref') ?? undefined,
|
||||
},
|
||||
);
|
||||
const user = await login(username, password);
|
||||
const me = await getMe().catch(() => null);
|
||||
onAuth(user, me?.capabilities, me?.grantedSkills);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '注册失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (password !== confirmPassword) {
|
||||
setError('两次输入的密码不一致');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
try {
|
||||
await resetPassword(username, email, password);
|
||||
setSuccess('密码已重置,请使用新密码登录');
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
setTimeout(() => switchMode('login'), 1500);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '重置失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const meta = legacyMode
|
||||
? { title: 'TKMind', desc: '内部访问入口' }
|
||||
: fromPlaza
|
||||
? {
|
||||
...MODE_META[mode],
|
||||
desc:
|
||||
mode === 'register'
|
||||
? '注册后将返回 Plaza 继续浏览'
|
||||
: '登录后将返回 Plaza 继续浏览',
|
||||
}
|
||||
: MODE_META[mode];
|
||||
|
||||
const plazaBackUrl = (() => {
|
||||
if (!returnTo) return resolvePlazaHomeUrl();
|
||||
try {
|
||||
return new URL(returnTo, window.location.origin).toString();
|
||||
} catch {
|
||||
return resolvePlazaHomeUrl();
|
||||
}
|
||||
})();
|
||||
|
||||
const onSubmit =
|
||||
mode === 'register' && !legacyMode
|
||||
? handleRegister
|
||||
: mode === 'reset' && !legacyMode
|
||||
? handleReset
|
||||
: handleLogin;
|
||||
|
||||
const submitLabel =
|
||||
submitting
|
||||
? '处理中…'
|
||||
: legacyMode
|
||||
? '解锁'
|
||||
: mode === 'register'
|
||||
? '注册并登录'
|
||||
: mode === 'reset'
|
||||
? '确认重置'
|
||||
: '登录';
|
||||
|
||||
const submitDisabled =
|
||||
submitting ||
|
||||
(legacyMode
|
||||
? !password.trim()
|
||||
: mode === 'login'
|
||||
? !username.trim() || !password.trim()
|
||||
: mode === 'register'
|
||||
? !username.trim() || !password.trim() || !email.trim()
|
||||
: !username.trim() ||
|
||||
!email.trim() ||
|
||||
!password.trim() ||
|
||||
!confirmPassword.trim());
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
{!legacyMode && fromPlaza && (
|
||||
<div className="login-plaza-back">
|
||||
<a href={plazaBackUrl}>← 返回广场</a>
|
||||
</div>
|
||||
)}
|
||||
{!legacyMode && (
|
||||
<div className="login-topbar">
|
||||
{mode === 'login' ? (
|
||||
<>
|
||||
<button type="button" className="login-link" onClick={() => switchMode('register')}>
|
||||
注册
|
||||
</button>
|
||||
<span className="login-topbar-sep" aria-hidden="true" />
|
||||
<button type="button" className="login-link" onClick={() => switchMode('reset')}>
|
||||
忘记密码
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="login-link" onClick={() => switchMode('login')}>
|
||||
返回登录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form className="login-form" onSubmit={onSubmit}>
|
||||
<div className="login-brand">
|
||||
<TKMindAvatar size="md" className="login-brand-avatar" />
|
||||
<h1 className="login-title">{meta.title}</h1>
|
||||
<p className="login-desc">{meta.desc}</p>
|
||||
</div>
|
||||
|
||||
{showWechatLogin && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="login-wechat-btn"
|
||||
onClick={handleWechatLogin}
|
||||
disabled={submitting}
|
||||
>
|
||||
微信一键登录
|
||||
</button>
|
||||
<div className="login-divider" aria-hidden="true">
|
||||
<span>或</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="login-fields">
|
||||
{!legacyMode && (
|
||||
<input
|
||||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
autoComplete="username"
|
||||
autoFocus={!showWechatLogin}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'register' && !legacyMode && (
|
||||
<>
|
||||
<input
|
||||
className="login-input"
|
||||
type="email"
|
||||
placeholder="邮箱"
|
||||
value={email}
|
||||
autoComplete="email"
|
||||
required
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="login-input"
|
||||
type="text"
|
||||
placeholder="显示名称(可选)"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{mode === 'reset' && !legacyMode && (
|
||||
<input
|
||||
className="login-input"
|
||||
type="email"
|
||||
placeholder="注册邮箱"
|
||||
value={email}
|
||||
autoComplete="email"
|
||||
required
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<input
|
||||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||||
type="password"
|
||||
placeholder={mode === 'reset' ? '新密码(至少 6 位)' : '密码'}
|
||||
value={password}
|
||||
autoComplete={
|
||||
mode === 'register' || mode === 'reset' ? 'new-password' : 'current-password'
|
||||
}
|
||||
autoFocus={legacyMode}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
|
||||
{mode === 'reset' && !legacyMode && (
|
||||
<input
|
||||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||||
type="password"
|
||||
placeholder="确认新密码"
|
||||
value={confirmPassword}
|
||||
autoComplete="new-password"
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
{success && <p className="login-success">{success}</p>}
|
||||
|
||||
<button type="submit" className="login-btn" disabled={submitDisabled}>
|
||||
{submitLabel}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { useUserAvatar } from '../hooks/useUserAvatar';
|
||||
import {
|
||||
AVATAR_PICKER_OPEN_EVENT,
|
||||
getAvatarPickerInput,
|
||||
openAvatarPicker,
|
||||
registerAvatarPickerInput,
|
||||
} from '../utils/userAvatar';
|
||||
import { UserAvatar } from './UserAvatar';
|
||||
|
||||
type AvatarPickerProps = {
|
||||
variant?: 'sidebar' | 'compact';
|
||||
};
|
||||
|
||||
export function AvatarPicker({ variant = 'sidebar' }: AvatarPickerProps) {
|
||||
const inputId = useId();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const ownsInput = true;
|
||||
const { avatarUrl, updateAvatar, resetAvatar } = useUserAvatar();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ownsInput) return;
|
||||
|
||||
registerAvatarPickerInput(inputRef.current);
|
||||
const handleOpen = () => {
|
||||
if (!busy) inputRef.current?.click();
|
||||
};
|
||||
window.addEventListener(AVATAR_PICKER_OPEN_EVENT, handleOpen);
|
||||
|
||||
return () => {
|
||||
if (getAvatarPickerInput() === inputRef.current) {
|
||||
registerAvatarPickerInput(null);
|
||||
}
|
||||
window.removeEventListener(AVATAR_PICKER_OPEN_EVENT, handleOpen);
|
||||
};
|
||||
}, [busy, ownsInput]);
|
||||
|
||||
const triggerPicker = () => {
|
||||
if (busy) return;
|
||||
if (ownsInput) {
|
||||
inputRef.current?.click();
|
||||
} else {
|
||||
openAvatarPicker();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await updateAvatar(file);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '头像更新失败');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fileInput = ownsInput ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
id={inputId}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="avatar-picker-input"
|
||||
onChange={(e) => void handleChange(e)}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<>
|
||||
<UserAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
size="sm"
|
||||
onClick={triggerPicker}
|
||||
title={error ?? '点击更换头像'}
|
||||
/>
|
||||
{fileInput}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="avatar-picker">
|
||||
<button
|
||||
type="button"
|
||||
className="avatar-picker-trigger"
|
||||
disabled={busy}
|
||||
onClick={triggerPicker}
|
||||
>
|
||||
<UserAvatar avatarUrl={avatarUrl} size="md" />
|
||||
<span className="avatar-picker-text">
|
||||
<span className="avatar-picker-label">{busy ? '处理中…' : '更换我的头像'}</span>
|
||||
<span className="avatar-picker-hint">JPG · PNG · WebP · 最大 2MB</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{avatarUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="avatar-picker-reset"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
resetAvatar();
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
恢复默认
|
||||
</button>
|
||||
)}
|
||||
|
||||
{error && <p className="avatar-picker-error">{error}</p>}
|
||||
{fileInput}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
|
||||
const RING_R = 16;
|
||||
const CIRC = 2 * Math.PI * RING_R;
|
||||
|
||||
function formatYuan(cents: number) {
|
||||
return `¥${(cents / 100).toFixed(2)}`;
|
||||
}
|
||||
|
||||
type BalanceRingProps = {
|
||||
balanceCents: number;
|
||||
totalCreditCents?: number;
|
||||
onRecharge: (force?: boolean) => void;
|
||||
};
|
||||
|
||||
export function BalanceRing({ balanceCents, totalCreditCents, onRecharge }: BalanceRingProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [popoverStyle, setPopoverStyle] = useState<CSSProperties>({});
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const popoverWidth = 220;
|
||||
|
||||
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 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 low = balanceCents > 0 && balanceCents <= 100;
|
||||
const empty = balanceCents <= 0;
|
||||
|
||||
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(() => {
|
||||
if (!open) return;
|
||||
|
||||
const updatePosition = () => {
|
||||
const anchor = wrapRef.current;
|
||||
if (!anchor) return;
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
const width = Math.min(popoverWidth, 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 centerLabel = total <= 0 ? '—' : empty ? '0%' : `${pct}%`;
|
||||
const ariaLabel = total > 0 ? `账户额度,剩余 ${pct}%` : '账户额度';
|
||||
|
||||
return (
|
||||
<div className={`balance-popover-wrap${open ? ' open' : ''}`} ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`balance-ring-btn${low ? ' low' : ''}${empty ? ' empty' : ''}`}
|
||||
aria-label={ariaLabel}
|
||||
aria-expanded={open}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
setOpen((value) => !value);
|
||||
}}
|
||||
>
|
||||
<svg className="balance-ring-svg" viewBox="0 0 40 40" aria-hidden="true">
|
||||
<circle className="balance-ring-track" cx="20" cy="20" r={RING_R} />
|
||||
<circle
|
||||
className="balance-ring-spent"
|
||||
cx="20"
|
||||
cy="20"
|
||||
r={RING_R}
|
||||
strokeDasharray={`${spentLen} ${CIRC}`}
|
||||
strokeDashoffset={0}
|
||||
/>
|
||||
<circle
|
||||
className="balance-ring-remain"
|
||||
cx="20"
|
||||
cy="20"
|
||||
r={RING_R}
|
||||
strokeDasharray={`${remainLen} ${CIRC}`}
|
||||
strokeDashoffset={-spentLen}
|
||||
/>
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
className="balance-popover-cta"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
onRecharge(empty);
|
||||
}}
|
||||
>
|
||||
去充值
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
clearUserCapabilityOverrides,
|
||||
getRoleCapabilities,
|
||||
getUserCapabilities,
|
||||
listCapabilityCatalog,
|
||||
updateRoleCapabilities,
|
||||
updateUserCapabilities,
|
||||
} from '../api/client';
|
||||
import type { AdminUserRow, CapabilityDefinition, CapabilityMap } from '../types';
|
||||
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
};
|
||||
|
||||
type FilterType = 'all' | 'high' | 'enabled';
|
||||
|
||||
function CapabilityGrid({
|
||||
catalog,
|
||||
values,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
catalog: CapabilityDefinition[];
|
||||
values: CapabilityMap;
|
||||
disabled?: boolean;
|
||||
onChange: (key: string, allowed: boolean) => void;
|
||||
}) {
|
||||
const [filter, setFilter] = useState<FilterType>('all');
|
||||
|
||||
const grouped = catalog.reduce<Record<string, CapabilityDefinition[]>>((acc, item) => {
|
||||
acc[item.category] ??= [];
|
||||
acc[item.category].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const filterItem = (item: CapabilityDefinition) => {
|
||||
if (filter === 'high') return item.risk === 'high';
|
||||
if (filter === 'enabled') return Boolean(values[item.key]);
|
||||
return true;
|
||||
};
|
||||
|
||||
const FILTER_OPTIONS: { key: FilterType; label: string }[] = [
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'high', label: '高风险' },
|
||||
{ key: 'enabled', label: '已启用' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="capability-grid">
|
||||
<div className="capability-filter-bar">
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
className={`capability-filter-btn${filter === opt.key ? ' active' : ''}`}
|
||||
onClick={() => setFilter(opt.key)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{Object.entries(grouped).map(([category, items]) => {
|
||||
const visible = items.filter(filterItem);
|
||||
if (visible.length === 0) return null;
|
||||
const enabledCount = items.filter((i) => Boolean(values[i.key])).length;
|
||||
return (
|
||||
<div key={category} className="capability-group">
|
||||
<div className="capability-group-header">
|
||||
<h3>{category}</h3>
|
||||
<span className="capability-group-count">
|
||||
{enabledCount}/{items.length} 已启用
|
||||
</span>
|
||||
</div>
|
||||
<ul>
|
||||
{visible.map((item) => {
|
||||
const checked = Boolean(values[item.key]);
|
||||
return (
|
||||
<li key={item.key} className="capability-item">
|
||||
<div className="capability-item-info">
|
||||
<span className="capability-label">
|
||||
{item.label}
|
||||
<span className={`risk-pill risk-${item.risk}`}>
|
||||
风险 {RISK_LABEL[item.risk] ?? item.risk}
|
||||
</span>
|
||||
</span>
|
||||
<span className="muted capability-desc">{item.description}</span>
|
||||
</div>
|
||||
<label className="capability-toggle" aria-label={item.label}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(item.key, e.target.checked)}
|
||||
/>
|
||||
<span className="toggle-track">
|
||||
<span className="toggle-thumb" />
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CapabilitySettings({
|
||||
users,
|
||||
userId,
|
||||
userOnly = false,
|
||||
}: {
|
||||
users: AdminUserRow[];
|
||||
userId?: string;
|
||||
userOnly?: boolean;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<CapabilityDefinition[]>([]);
|
||||
const [roleCapabilities, setRoleCapabilities] = useState<CapabilityMap>({});
|
||||
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
|
||||
const [userCapabilities, setUserCapabilities] = useState<CapabilityMap>({});
|
||||
const [userOverrides, setUserOverrides] = useState<CapabilityMap>({});
|
||||
const [userUnrestricted, setUserUnrestricted] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catalogItems, roleState] = await Promise.all([
|
||||
listCapabilityCatalog(),
|
||||
getRoleCapabilities('user'),
|
||||
]);
|
||||
setCatalog(catalogItems);
|
||||
setRoleCapabilities(roleState.capabilities);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载权限配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUserCapabilities = useCallback(async (userId: string) => {
|
||||
if (!userId) {
|
||||
setUserCapabilities({});
|
||||
setUserOverrides({});
|
||||
setUserUnrestricted(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = await getUserCapabilities(userId);
|
||||
setUserCapabilities(state.capabilities);
|
||||
setUserOverrides(state.overrides);
|
||||
setUserUnrestricted(state.unrestricted);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载用户权限失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUserCapabilities(selectedUserId);
|
||||
}, [loadUserCapabilities, selectedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
setSelectedUserId(userId);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const saveRoleDefaults = async () => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateRoleCapabilities('user', roleCapabilities);
|
||||
setRoleCapabilities(result.capabilities);
|
||||
setMessage('普通用户默认权限已保存(对新用户及无单独配置的用户生效)');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const saveUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateUserCapabilities(selectedUserId, userCapabilities);
|
||||
setUserCapabilities(result.capabilities);
|
||||
await loadUserCapabilities(selectedUserId);
|
||||
setMessage('用户单独权限已保存');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await clearUserCapabilityOverrides(selectedUserId);
|
||||
await loadUserCapabilities(selectedUserId);
|
||||
setMessage('已恢复为角色默认权限');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const regularUsers = users.filter((user) => user.role === 'user');
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>能力权限</h2>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted capability-intro">
|
||||
控制各扩展/工具是否可用。用户沙箱(MindSpace/用户名)请在「技能配置」勾选 static-page-publish。
|
||||
「安全策略」可进一步收紧模式与 API。管理员不受限制。
|
||||
</p>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : (
|
||||
<>
|
||||
{!userOnly && (
|
||||
<>
|
||||
<h3 className="capability-section-title">普通用户默认权限</h3>
|
||||
<CapabilityGrid
|
||||
catalog={catalog}
|
||||
values={roleCapabilities}
|
||||
onChange={(key, allowed) =>
|
||||
setRoleCapabilities((prev) => ({ ...prev, [key]: allowed }))
|
||||
}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
|
||||
保存默认权限
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!userOnly && <h3 className="capability-section-title">单用户覆盖</h3>}
|
||||
{!userOnly && (
|
||||
<div className="admin-form capability-user-picker">
|
||||
<select
|
||||
value={selectedUserId}
|
||||
onChange={(e) => setSelectedUserId(e.target.value)}
|
||||
>
|
||||
<option value="">选择用户(可选)</option>
|
||||
{regularUsers.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.displayName} (@{user.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{selectedUserId && (
|
||||
<>
|
||||
{userUnrestricted ? (
|
||||
<p className="muted">该用户为管理员,不受能力限制。</p>
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(userOverrides).length > 0 && (
|
||||
<p className="muted">
|
||||
已设置 {Object.keys(userOverrides).length} 项单独覆盖,保存后将覆盖角色默认。
|
||||
</p>
|
||||
)}
|
||||
<CapabilityGrid
|
||||
catalog={catalog}
|
||||
values={userCapabilities}
|
||||
onChange={(key, allowed) =>
|
||||
setUserCapabilities((prev) => ({ ...prev, [key]: allowed }))
|
||||
}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
onClick={() => void saveUserOverrides()}
|
||||
>
|
||||
保存用户权限
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void resetUserOverrides()}
|
||||
>
|
||||
恢复角色默认
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||
import { openAvatarPicker } from '../utils/userAvatar';
|
||||
import { buildPublishSkillPrompt } from '../utils/publishSkill';
|
||||
import { AvatarPicker } from './AvatarPicker';
|
||||
import { MessageList } from './MessageList';
|
||||
import { PageSaveDialog } from './PageSaveDialog';
|
||||
import { VoiceInputButton } from './VoiceInputButton';
|
||||
import type { CapabilityMap, ChatState, Message, PortalUser, Session, ToolConfirmation } from '../types';
|
||||
import type { MindSpaceSaveCategory } from '../types';
|
||||
|
||||
export function ChatPanel({
|
||||
variant,
|
||||
user,
|
||||
messages,
|
||||
chatState,
|
||||
pendingTool,
|
||||
session,
|
||||
capabilities,
|
||||
grantedSkills,
|
||||
onSubmit,
|
||||
onStop,
|
||||
onApproveTool,
|
||||
onPageSaved,
|
||||
}: {
|
||||
variant: 'full' | 'compact';
|
||||
user?: PortalUser | null;
|
||||
messages: Message[];
|
||||
chatState: ChatState;
|
||||
pendingTool: ToolConfirmation | null;
|
||||
session: Session | null;
|
||||
capabilities?: CapabilityMap;
|
||||
grantedSkills?: string[];
|
||||
onSubmit: (text: string) => void | Promise<void>;
|
||||
onStop: () => void | Promise<void>;
|
||||
onApproveTool: (allow: boolean) => void | Promise<void>;
|
||||
onPageSaved?: (result: {
|
||||
kind: 'page' | 'category';
|
||||
pageId?: string;
|
||||
categoryCode?: MindSpaceSaveCategory;
|
||||
}) => void;
|
||||
}) {
|
||||
const online = useNetworkStatus();
|
||||
const [input, setInput] = useState('');
|
||||
const [pageSource, setPageSource] = useState<Message | null>(null);
|
||||
const [voiceNotice, setVoiceNotice] = useState<string | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, chatState, pendingTool]);
|
||||
|
||||
const busy = chatState === 'streaming' || chatState === 'loading';
|
||||
const offlineBlocked = !online;
|
||||
const publishSkillName = user?.publishSkillName ?? 'static-page-publish';
|
||||
const hasPublishSkill = grantedSkills?.includes(publishSkillName) ?? false;
|
||||
const canPublish = Boolean(capabilities?.static_publish) || hasPublishSkill;
|
||||
const compact = variant === 'compact';
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const text = input;
|
||||
if (!text.trim()) return;
|
||||
setInput('');
|
||||
await onSubmit(text);
|
||||
};
|
||||
|
||||
const placeholder = offlineBlocked
|
||||
? '网络断开,恢复连接后可继续输入'
|
||||
: compact
|
||||
? '在这里继续和 Agent 对话…'
|
||||
: '输入任务,例如:列出当前目录文件';
|
||||
|
||||
const handleVoiceSend = async (text: string) => {
|
||||
setVoiceNotice(null);
|
||||
await onSubmit(text);
|
||||
};
|
||||
|
||||
const voiceDisabled = busy || !!pendingTool || chatState === 'error' || offlineBlocked;
|
||||
|
||||
return (
|
||||
<>
|
||||
<main className={compact ? 'space-chat-panel-body' : 'main'}>
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streaming={chatState === 'streaming'}
|
||||
onAvatarClick={compact ? undefined : openAvatarPicker}
|
||||
onSaveAsPage={(message) => setPageSource(message)}
|
||||
publishUsername={user?.username}
|
||||
compact={compact}
|
||||
/>
|
||||
{!compact && (
|
||||
<div className="user-avatar-input-host" aria-hidden="true">
|
||||
<AvatarPicker variant="compact" />
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</main>
|
||||
|
||||
{pendingTool && (
|
||||
<div className={`tool-confirm${compact ? ' tool-confirm-compact' : ''}`}>
|
||||
<div className="tool-confirm-text">
|
||||
允许执行工具 <strong>{pendingTool.toolName}</strong>?
|
||||
{pendingTool.prompt && <p>{pendingTool.prompt}</p>}
|
||||
</div>
|
||||
<div className="tool-confirm-actions">
|
||||
<button type="button" onClick={() => void onApproveTool(true)}>
|
||||
允许
|
||||
</button>
|
||||
<button type="button" className="danger" onClick={() => void onApproveTool(false)}>
|
||||
拒绝
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pageSource?.id && session?.id && (
|
||||
<PageSaveDialog
|
||||
sessionId={session.id}
|
||||
messageId={pageSource.id}
|
||||
compact={compact}
|
||||
onClose={() => setPageSource(null)}
|
||||
onSaved={(result) => {
|
||||
setPageSource(null);
|
||||
onPageSaved?.(result);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<footer className={compact ? 'space-chat-panel-footer' : 'footer'}>
|
||||
{voiceNotice && (
|
||||
<div className="voice-notice" role="status">
|
||||
{voiceNotice}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-input-row">
|
||||
<VoiceInputButton
|
||||
disabled={voiceDisabled}
|
||||
onSend={(text) => void handleVoiceSend(text)}
|
||||
onError={setVoiceNotice}
|
||||
/>
|
||||
<textarea
|
||||
className="input"
|
||||
rows={1}
|
||||
placeholder={placeholder}
|
||||
value={input}
|
||||
disabled={voiceDisabled}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!compact && canPublish && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn chat-input-action"
|
||||
disabled={voiceDisabled}
|
||||
title={`加载 ${publishSkillName} 技能并生成 HTML`}
|
||||
onClick={() => {
|
||||
setInput(buildPublishSkillPrompt(publishSkillName));
|
||||
}}
|
||||
>
|
||||
静态页面
|
||||
</button>
|
||||
)}
|
||||
{chatState === 'streaming' ? (
|
||||
<button type="button" className="danger-btn chat-input-action" onClick={() => void onStop()}>
|
||||
停止
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn chat-input-action"
|
||||
disabled={!input.trim() || voiceDisabled}
|
||||
onClick={() => void handleSubmit()}
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useChat } from '../context/ChatProvider';
|
||||
import type { CapabilityMap, PortalUser } from '../types';
|
||||
import { useNetworkStatus } from '../hooks/useNetworkStatus';
|
||||
import { getSessionDisplayName } from '../utils/sessions';
|
||||
import { openAvatarPicker } from '../utils/userAvatar';
|
||||
import { HistorySidebar } from './HistorySidebar';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
import { ChatPanel } from './ChatPanel';
|
||||
import { BalanceRing } from './BalanceRing';
|
||||
import type { MindSpaceSaveCategory } from '../types';
|
||||
|
||||
export function ChatView({
|
||||
user,
|
||||
capabilities,
|
||||
grantedSkills,
|
||||
onLogout,
|
||||
onOpenSpace,
|
||||
onOpenPage,
|
||||
onOpenAdmin,
|
||||
}: {
|
||||
user?: PortalUser | null;
|
||||
capabilities?: CapabilityMap;
|
||||
grantedSkills?: string[];
|
||||
onUserUpdate?: (user: PortalUser) => void;
|
||||
onLogout?: () => void;
|
||||
onOpenSpace?: (target?: { categoryCode?: MindSpaceSaveCategory; pageId?: string }) => void;
|
||||
onOpenPage?: (pageId: string) => void;
|
||||
onOpenAdmin?: () => void;
|
||||
}) {
|
||||
const {
|
||||
session,
|
||||
sessions,
|
||||
sessionsLoading,
|
||||
messages,
|
||||
chatState,
|
||||
error,
|
||||
notice,
|
||||
pendingTool,
|
||||
memoryLoading,
|
||||
submit,
|
||||
stop,
|
||||
approveTool,
|
||||
newSession,
|
||||
rememberCurrentContext,
|
||||
refreshProjectMemory,
|
||||
switchSession,
|
||||
retryConnect,
|
||||
dismissNotice,
|
||||
onSidebarOpen,
|
||||
workingDir,
|
||||
balanceCents,
|
||||
totalCreditCents,
|
||||
openRecharge,
|
||||
} = useChat();
|
||||
const online = useNetworkStatus();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (sidebarOpen) {
|
||||
onSidebarOpen();
|
||||
}
|
||||
}, [sidebarOpen, onSidebarOpen]);
|
||||
|
||||
const busy = chatState === 'streaming' || chatState === 'loading';
|
||||
|
||||
const handleSelectSession = (sessionId: string) => {
|
||||
void switchSession(sessionId);
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
const handleNewSession = () => {
|
||||
void newSession();
|
||||
setSidebarOpen(false);
|
||||
};
|
||||
|
||||
const sessionTitle = session ? getSessionDisplayName(session) : '连接中…';
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<HistorySidebar
|
||||
open={sidebarOpen}
|
||||
sessions={sessions}
|
||||
activeSessionId={session?.id}
|
||||
loading={sessionsLoading}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
onSelect={handleSelectSession}
|
||||
onNew={handleNewSession}
|
||||
/>
|
||||
|
||||
<div className="app">
|
||||
<header className="header">
|
||||
<div className="header-left">
|
||||
<button
|
||||
type="button"
|
||||
className="header-menu-btn"
|
||||
aria-label="展开历史对话"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
☰
|
||||
</button>
|
||||
<TKMindAvatar size="sm" className="header-brand-avatar" />
|
||||
<div>
|
||||
<div className="header-title">{user?.displayName ?? 'TKMind'}</div>
|
||||
<div className="header-sub">{sessionTitle}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
{typeof balanceCents === 'number' && (
|
||||
<BalanceRing
|
||||
balanceCents={balanceCents}
|
||||
totalCreditCents={totalCreditCents}
|
||||
onRecharge={openRecharge}
|
||||
/>
|
||||
)}
|
||||
{onOpenAdmin && (
|
||||
<button type="button" className="ghost-btn" onClick={onOpenAdmin}>
|
||||
管理
|
||||
</button>
|
||||
)}
|
||||
{onOpenSpace && (
|
||||
<button type="button" className="ghost-btn" onClick={() => onOpenSpace?.()}>
|
||||
我的空间
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={busy || memoryLoading || messages.length === 0}
|
||||
onClick={() => void rememberCurrentContext()}
|
||||
title="把最近几条对话保存到 harness 项目知识库"
|
||||
>
|
||||
记住本轮
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={busy || memoryLoading}
|
||||
onClick={() => void refreshProjectMemory()}
|
||||
title="从 harness 和项目文件重新加载长期记忆"
|
||||
>
|
||||
{memoryLoading ? '同步中…' : '刷新记忆'}
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" onClick={() => void handleNewSession()}>
|
||||
新会话
|
||||
</button>
|
||||
{onLogout && (
|
||||
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
|
||||
登出
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{!online && (
|
||||
<div className="banner banner-offline">网络已断开,请检查连接后重试</div>
|
||||
)}
|
||||
|
||||
{typeof balanceCents === 'number' && balanceCents <= 0 && (
|
||||
<div className="banner banner-error">
|
||||
<span>余额不足,请充值后继续使用</span>
|
||||
<button type="button" className="banner-action" onClick={() => openRecharge(true)}>
|
||||
去充值
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{typeof balanceCents === 'number' && balanceCents > 0 && balanceCents <= 100 && (
|
||||
<div className="banner banner-warning">
|
||||
<span>余额较低(¥{(balanceCents / 100).toFixed(2)}),建议提前充值</span>
|
||||
<button type="button" className="banner-action" onClick={() => openRecharge(false)}>
|
||||
去充值
|
||||
</button>
|
||||
</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">
|
||||
<span>{notice}</span>
|
||||
<button type="button" className="banner-dismiss" onClick={dismissNotice}>
|
||||
知道了
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="banner banner-error">
|
||||
<span>{error}</span>
|
||||
<button type="button" className="banner-action" onClick={() => void retryConnect()}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatPanel
|
||||
variant="full"
|
||||
user={user}
|
||||
messages={messages}
|
||||
chatState={chatState}
|
||||
pendingTool={pendingTool}
|
||||
session={session}
|
||||
capabilities={capabilities}
|
||||
grantedSkills={grantedSkills}
|
||||
onSubmit={submit}
|
||||
onStop={stop}
|
||||
onApproveTool={approveTool}
|
||||
onPageSaved={(result) => {
|
||||
if (result.kind === 'page' && result.pageId) {
|
||||
onOpenPage?.(result.pageId);
|
||||
return;
|
||||
}
|
||||
onOpenSpace?.({ categoryCode: result.categoryCode });
|
||||
}}
|
||||
/>
|
||||
|
||||
{workingDir && <div className="meta">工作目录:{workingDir}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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';
|
||||
|
||||
function formatSessionTime(iso?: string): string {
|
||||
if (!iso) return '';
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) return '';
|
||||
|
||||
const now = new Date();
|
||||
const isToday =
|
||||
date.getFullYear() === now.getFullYear() &&
|
||||
date.getMonth() === now.getMonth() &&
|
||||
date.getDate() === now.getDate();
|
||||
|
||||
if (isToday) {
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
type HistorySidebarProps = {
|
||||
open: boolean;
|
||||
sessions: Session[];
|
||||
activeSessionId?: string;
|
||||
loading: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (sessionId: string) => void;
|
||||
onNew: () => void;
|
||||
};
|
||||
|
||||
export function HistorySidebar({
|
||||
open,
|
||||
sessions,
|
||||
activeSessionId,
|
||||
loading,
|
||||
onClose,
|
||||
onSelect,
|
||||
onNew,
|
||||
}: HistorySidebarProps) {
|
||||
const bodyRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(appConfig.sessionPageSize);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setVisibleCount(appConfig.sessionPageSize);
|
||||
}
|
||||
}, [open, sessions.length]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = bodyRef.current;
|
||||
if (!el) return;
|
||||
const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
if (nearBottom && visibleCount < sessions.length) {
|
||||
setVisibleCount((count) => Math.min(count + appConfig.sessionPageSize, sessions.length));
|
||||
}
|
||||
}, [visibleCount, sessions.length]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const visibleSessions = sessions.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < sessions.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" className="sidebar-backdrop" aria-label="关闭历史" onClick={onClose} />
|
||||
|
||||
<aside className="sidebar sidebar-open">
|
||||
<div className="sidebar-header">
|
||||
<span className="sidebar-title">历史对话</span>
|
||||
<button type="button" className="sidebar-close" aria-label="收起历史" onClick={onClose}>
|
||||
‹
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-body" ref={bodyRef} onScroll={handleScroll}>
|
||||
<button type="button" className="sidebar-new" onClick={onNew}>
|
||||
+ 新对话
|
||||
</button>
|
||||
|
||||
{loading && sessions.length === 0 ? (
|
||||
<div className="sidebar-empty">加载中…</div>
|
||||
) : sessions.length === 0 ? (
|
||||
<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>
|
||||
{hasMore && <div className="sidebar-more">向下滚动加载更多…</div>}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<AvatarPicker />
|
||||
</div>
|
||||
</aside>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useState } from 'react';
|
||||
import { useUserAvatar } from '../hooks/useUserAvatar';
|
||||
import type { Message } from '../types';
|
||||
import { getDisplayText, getThinking, getVisibleText } from '../utils/message';
|
||||
import { getMessageSaveActions } from '../utils/messageSave';
|
||||
import { renderMarkdown } from '../utils/markdown';
|
||||
import { formatChatTime, shouldShowTimestamp } from '../utils/time';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
import { UserAvatar } from './UserAvatar';
|
||||
|
||||
function CopyIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="8" y="8" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path
|
||||
d="M6 16H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M5 12.5l4.5 4.5L19 7.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolBadge({ message }: { message: Message }) {
|
||||
const tools = message.content.filter((c) => c.type === 'toolRequest' || c.type === 'toolResponse');
|
||||
if (tools.length === 0) return null;
|
||||
return (
|
||||
<div className="tool-badges">
|
||||
{tools.map((tool, i) => (
|
||||
<span key={`${message.id}-${i}`} className="tool-badge">
|
||||
{tool.type === 'toolRequest'
|
||||
? `🔧 ${tool.toolCall.functionName}`
|
||||
: `✓ 工具完成`}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`msg-copy${copied ? ' msg-copy-done' : ''}`}
|
||||
aria-label={copied ? '已复制' : '复制'}
|
||||
title={copied ? '已复制' : '复制'}
|
||||
onClick={() => void handleCopy()}
|
||||
>
|
||||
{copied ? <CheckIcon /> : <CopyIcon />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeDivider({ timestamp }: { timestamp: number }) {
|
||||
return (
|
||||
<div className="msg-time-divider">
|
||||
<span>{formatChatTime(timestamp)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronIcon({ open }: { open: boolean }) {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
className={open ? 'msg-actions-chevron is-open' : 'msg-actions-chevron'}
|
||||
>
|
||||
<path
|
||||
d="M9 6l6 6-6 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRow({
|
||||
message,
|
||||
avatarUrl,
|
||||
onAvatarClick,
|
||||
onSaveAsPage,
|
||||
saveDisabled,
|
||||
publishUsername,
|
||||
compact = false,
|
||||
}: {
|
||||
message: Message;
|
||||
avatarUrl: string | null;
|
||||
onAvatarClick?: () => void;
|
||||
onSaveAsPage?: (message: Message) => void;
|
||||
saveDisabled?: boolean;
|
||||
publishUsername?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const [actionsOpen, setActionsOpen] = useState(false);
|
||||
const text = getDisplayText(message);
|
||||
const saveActions = getMessageSaveActions(text, publishUsername);
|
||||
const thinking = getThinking(message);
|
||||
const isUser = message.role === 'user';
|
||||
const copyText = [thinking ? `【思考】\n${thinking}` : '', text].filter(Boolean).join('\n\n');
|
||||
|
||||
if (!text && !thinking && message.role === 'assistant') {
|
||||
return (
|
||||
<div className={`msg-row msg-row-assistant`}>
|
||||
<TKMindAvatar />
|
||||
<div className="msg-content">
|
||||
<ToolBadge message={message} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const hasAssistantActions =
|
||||
!isUser && Boolean(message.id && onSaveAsPage && copyText);
|
||||
const showActionsToggle = compact && Boolean(copyText);
|
||||
|
||||
return (
|
||||
<div className={`msg-row ${isUser ? 'msg-row-user' : 'msg-row-assistant'}`}>
|
||||
{!isUser && <TKMindAvatar />}
|
||||
<div className="msg-content">
|
||||
<div className={`msg-bubble-wrap${compact ? ' msg-bubble-wrap-compact' : ''}`}>
|
||||
<div className={compact ? 'msg-bubble-row' : undefined}>
|
||||
<div className={`msg-bubble ${isUser ? 'msg-bubble-user' : 'msg-bubble-assistant'}`}>
|
||||
{thinking && (
|
||||
<details className="thinking">
|
||||
<summary>思考中…</summary>
|
||||
<pre>{thinking}</pre>
|
||||
</details>
|
||||
)}
|
||||
{text && (
|
||||
<div
|
||||
className="bubble-text"
|
||||
dangerouslySetInnerHTML={{ __html: renderMarkdown(text) }}
|
||||
/>
|
||||
)}
|
||||
<ToolBadge message={message} />
|
||||
</div>
|
||||
{showActionsToggle && (
|
||||
<button
|
||||
type="button"
|
||||
className={`msg-actions-toggle${actionsOpen ? ' is-open' : ''}`}
|
||||
aria-label={actionsOpen ? '收起操作' : '展开操作'}
|
||||
aria-expanded={actionsOpen}
|
||||
onClick={() => setActionsOpen((current) => !current)}
|
||||
>
|
||||
<ChevronIcon open={actionsOpen} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{copyText && !compact && (
|
||||
<div className="msg-actions">
|
||||
<CopyButton text={copyText} />
|
||||
{hasAssistantActions && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="msg-save-page"
|
||||
disabled={saveDisabled}
|
||||
onClick={() => onSaveAsPage!(message)}
|
||||
>
|
||||
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
|
||||
</button>
|
||||
{saveActions.previewUrl && (
|
||||
<a
|
||||
className="msg-open-preview"
|
||||
href={saveActions.previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
打开预览
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{copyText && compact && actionsOpen && (
|
||||
<div className="msg-actions msg-actions-compact">
|
||||
<CopyButton text={copyText} />
|
||||
{hasAssistantActions && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="msg-save-page"
|
||||
disabled={saveDisabled}
|
||||
onClick={() => onSaveAsPage!(message)}
|
||||
>
|
||||
{saveActions.kind === 'page' ? '保存页面' : '保存为文章'}
|
||||
</button>
|
||||
{saveActions.previewUrl && (
|
||||
<a
|
||||
className="msg-open-preview"
|
||||
href={saveActions.previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
打开预览
|
||||
</a>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isUser && (
|
||||
<UserAvatar
|
||||
avatarUrl={avatarUrl}
|
||||
className="msg-avatar msg-avatar-user"
|
||||
onClick={onAvatarClick}
|
||||
title={onAvatarClick ? '点击更换头像' : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
streaming,
|
||||
onAvatarClick,
|
||||
onSaveAsPage,
|
||||
publishUsername,
|
||||
compact = false,
|
||||
}: {
|
||||
messages: Message[];
|
||||
streaming: boolean;
|
||||
onAvatarClick?: () => void;
|
||||
onSaveAsPage?: (message: Message) => void;
|
||||
publishUsername?: string;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const { avatarUrl } = useUserAvatar();
|
||||
|
||||
return (
|
||||
<div className="message-list">
|
||||
{messages.length === 0 && (
|
||||
<div className={`empty-state${compact ? ' empty-state-compact' : ''}`}>
|
||||
<TKMindAvatar />
|
||||
<h2>TKMind</h2>
|
||||
<p>{compact ? '继续和空间里的 Agent 对话' : '发送消息即可在本机执行 Agent 任务'}</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((message, index) => {
|
||||
const prev = index > 0 ? messages[index - 1] : undefined;
|
||||
const showTime = shouldShowTimestamp(message.created, prev?.created);
|
||||
return (
|
||||
<div key={message.id ?? `msg-${index}`} className="msg-block">
|
||||
{showTime && <TimeDivider timestamp={message.created} />}
|
||||
<MessageRow
|
||||
message={message}
|
||||
avatarUrl={avatarUrl}
|
||||
onAvatarClick={onAvatarClick}
|
||||
onSaveAsPage={onSaveAsPage}
|
||||
saveDisabled={streaming}
|
||||
publishUsername={publishUsername}
|
||||
compact={compact}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{streaming && (
|
||||
<div className="msg-row msg-row-assistant">
|
||||
<TKMindAvatar />
|
||||
<div className="msg-content">
|
||||
<div className="msg-bubble msg-bubble-assistant msg-typing">
|
||||
<span className="typing-dots">
|
||||
<span />
|
||||
<span />
|
||||
<span />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { MindSpacePage } from '../types';
|
||||
import { WorkCover } from './WorkCover';
|
||||
|
||||
const ACCESS_MODE_ICONS: Record<string, { icon: string; title: string }> = {
|
||||
public: { icon: '🌐', title: '完全公开' },
|
||||
password: { icon: '🔑', title: '密码访问' },
|
||||
private_link: { icon: '🔗', title: '私密链接' },
|
||||
time_limited: { icon: '⏱', title: '限时访问' },
|
||||
login_required:{ icon: '👤', title: '仅登录用户' },
|
||||
owner_only: { icon: '🔒', title: '仅自己可见' },
|
||||
};
|
||||
|
||||
const PAGE_STATUS_LABELS: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
reviewing: '待检查',
|
||||
risk_found: '发现风险',
|
||||
ready: '可发布',
|
||||
published: '已公开',
|
||||
protected: '受保护',
|
||||
expired: '已过期',
|
||||
offline: '已下线',
|
||||
};
|
||||
|
||||
const TEMPLATE_LABELS: Record<string, string> = {
|
||||
editorial: '长文',
|
||||
report: '报告',
|
||||
'knowledge-card': '知识卡片',
|
||||
profile: '个人介绍',
|
||||
};
|
||||
|
||||
function feedTypeLabel(page: MindSpacePage) {
|
||||
if (page.contentFormat === 'html') return '网页';
|
||||
return TEMPLATE_LABELS[page.templateId] ?? page.templateId;
|
||||
}
|
||||
|
||||
function formatFeedDate(timestamp: number) {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (24 * 60 * 60 * 1000));
|
||||
if (diffDays < 1) return '今天';
|
||||
if (diffDays === 1) return '昨天';
|
||||
if (diffDays < 7) return `${diffDays}天前`;
|
||||
return date.toLocaleDateString('zh-CN', { month: 'numeric', day: 'numeric' });
|
||||
}
|
||||
|
||||
const PREVIEW_THUMBNAILS: Record<string, string> = {
|
||||
'page-1': '/thumbnail-demo/mapo-tofu-feed.svg',
|
||||
'page-2': '/thumbnail-demo/malaysia-travel-feed.svg',
|
||||
};
|
||||
|
||||
export function MindSpaceFeedCard({
|
||||
page,
|
||||
previewMode = false,
|
||||
onClick,
|
||||
actions,
|
||||
}: {
|
||||
page: MindSpacePage;
|
||||
previewMode?: boolean;
|
||||
onClick: () => void;
|
||||
actions?: { label: string; onClick: (e: React.MouseEvent) => void }[];
|
||||
}) {
|
||||
const typeLabel = feedTypeLabel(page);
|
||||
const isHtml = page.contentFormat === 'html';
|
||||
const previewThumbnail = previewMode ? PREVIEW_THUMBNAILS[page.id] : null;
|
||||
|
||||
return (
|
||||
<div className="mindspace-feed-card-wrap">
|
||||
<button type="button" className="mindspace-feed-card" onClick={onClick}>
|
||||
<div className="mindspace-feed-cover">
|
||||
{isHtml ? (
|
||||
<WorkCover
|
||||
coverMode="thumbnail-first"
|
||||
thumbnailUrl={
|
||||
previewThumbnail ??
|
||||
`/api/mindspace/v1/pages/${page.id}/thumbnail?v=${page.updatedAt}`
|
||||
}
|
||||
previewUrl={`/api/mindspace/v1/pages/${page.id}/preview?v=${page.versionNo ?? page.updatedAt}`}
|
||||
fallbackLabel={typeLabel}
|
||||
title={page.title}
|
||||
imageClassName="mindspace-feed-cover-media"
|
||||
previewFrameClassName="mindspace-feed-cover-preview"
|
||||
/>
|
||||
) : (
|
||||
<div className="mindspace-feed-cover-fallback" aria-hidden="true">
|
||||
<span>{typeLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
<span className="mindspace-feed-badge">{typeLabel}</span>
|
||||
</div>
|
||||
<div className="mindspace-feed-body">
|
||||
<h3 className="mindspace-feed-title">{page.title}</h3>
|
||||
<p className="mindspace-feed-meta">
|
||||
{page.publicationAccessMode && ACCESS_MODE_ICONS[page.publicationAccessMode] ? (
|
||||
<span
|
||||
className="mindspace-feed-access-icon"
|
||||
title={ACCESS_MODE_ICONS[page.publicationAccessMode].title}
|
||||
aria-label={ACCESS_MODE_ICONS[page.publicationAccessMode].title}
|
||||
>
|
||||
{ACCESS_MODE_ICONS[page.publicationAccessMode].icon}
|
||||
</span>
|
||||
) : null}
|
||||
{PAGE_STATUS_LABELS[page.status] ?? page.status} · {formatFeedDate(page.updatedAt)}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
{actions && actions.length > 0 && (
|
||||
<div className="mindspace-feed-card-actions">
|
||||
{actions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
action.onClick(event);
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
export function MindSpaceModal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
eyebrow,
|
||||
children,
|
||||
className,
|
||||
disableClose = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
eyebrow?: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
disableClose?: boolean;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !disableClose) onClose();
|
||||
};
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open, onClose, disableClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="mindspace-upload-backdrop"
|
||||
role="presentation"
|
||||
onClick={disableClose ? undefined : onClose}
|
||||
>
|
||||
<section
|
||||
className={`mindspace-upload-dialog${className ? ` ${className}` : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="mindspace-modal-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mindspace-upload-dialog-bar">
|
||||
<div>
|
||||
{eyebrow ? <p className="mindspace-eyebrow">{eyebrow}</p> : null}
|
||||
<h3 id="mindspace-modal-title">{title}</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mindspace-upload-dialog-close"
|
||||
onClick={onClose}
|
||||
disabled={disableClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
regenerateMindSpacePageThumbnail,
|
||||
uploadMindSpacePageThumbnail,
|
||||
} from '../api/client';
|
||||
import {
|
||||
buildEditablePreviewDocument,
|
||||
MINDSPACE_PAGE_CONTENT_MESSAGE,
|
||||
} from '../utils/mindspaceVisualEditor';
|
||||
|
||||
function readFileAsBase64(file: File): Promise<{ base64: string; mimeType: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result ?? '');
|
||||
const match = result.match(/^data:([^;]+);base64,(.+)$/);
|
||||
if (!match) {
|
||||
reject(new Error('无法读取图片'));
|
||||
return;
|
||||
}
|
||||
resolve({ mimeType: match[1], base64: match[2] });
|
||||
};
|
||||
reader.onerror = () => reject(new Error('无法读取图片'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function MindSpacePagePreviewPanel({
|
||||
pageId,
|
||||
title,
|
||||
summary,
|
||||
content,
|
||||
reloadKey,
|
||||
onContentChange,
|
||||
}: {
|
||||
pageId: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
content: string;
|
||||
reloadKey: number;
|
||||
onContentChange: (html: string) => void;
|
||||
}) {
|
||||
const [previewFailed, setPreviewFailed] = useState(false);
|
||||
const [thumbnailFailed, setThumbnailFailed] = useState(false);
|
||||
const [thumbnailVersion, setThumbnailVersion] = useState(reloadKey);
|
||||
const [thumbBusy, setThumbBusy] = useState<'upload' | 'ai' | null>(null);
|
||||
const [thumbNotice, setThumbNotice] = useState<string | null>(null);
|
||||
const [thumbError, setThumbError] = useState<string | null>(null);
|
||||
const [srcdoc, setSrcdoc] = useState(() => buildEditablePreviewDocument(content));
|
||||
const iframeContentRef = useRef(content);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const uploadInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const thumbnailUrl = `/api/mindspace/v1/pages/${pageId}/thumbnail?v=${thumbnailVersion}`;
|
||||
|
||||
useEffect(() => {
|
||||
setThumbnailVersion(reloadKey);
|
||||
}, [reloadKey]);
|
||||
|
||||
useEffect(() => {
|
||||
setThumbnailFailed(false);
|
||||
}, [thumbnailUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
iframeContentRef.current = content;
|
||||
setSrcdoc(buildEditablePreviewDocument(content));
|
||||
setPreviewFailed(false);
|
||||
}, [reloadKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (content === iframeContentRef.current) return;
|
||||
iframeContentRef.current = content;
|
||||
setSrcdoc(buildEditablePreviewDocument(content));
|
||||
setPreviewFailed(false);
|
||||
}, [content]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
if (event.data?.type !== MINDSPACE_PAGE_CONTENT_MESSAGE) return;
|
||||
const html = String(event.data.html ?? '');
|
||||
if (!html.trim()) return;
|
||||
iframeContentRef.current = html;
|
||||
onContentChange(html);
|
||||
};
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
return () => window.removeEventListener('message', onMessage);
|
||||
}, [onContentChange]);
|
||||
|
||||
const bumpThumbnail = () => {
|
||||
setThumbnailVersion(Date.now());
|
||||
setThumbnailFailed(false);
|
||||
};
|
||||
|
||||
const handleUploadClick = () => {
|
||||
uploadInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleUploadFile = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setThumbError('请选择图片文件');
|
||||
return;
|
||||
}
|
||||
setThumbBusy('upload');
|
||||
setThumbError(null);
|
||||
setThumbNotice(null);
|
||||
try {
|
||||
const { base64, mimeType } = await readFileAsBase64(file);
|
||||
await uploadMindSpacePageThumbnail(pageId, {
|
||||
imageBase64: base64,
|
||||
mimeType,
|
||||
title,
|
||||
summary,
|
||||
content,
|
||||
});
|
||||
bumpThumbnail();
|
||||
setThumbNotice('封面已替换,保存页面后仍会保留');
|
||||
} catch (err) {
|
||||
setThumbError(err instanceof Error ? err.message : '上传封面失败');
|
||||
} finally {
|
||||
setThumbBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAiRegenerate = async () => {
|
||||
setThumbBusy('ai');
|
||||
setThumbError(null);
|
||||
setThumbNotice(null);
|
||||
try {
|
||||
const result = await regenerateMindSpacePageThumbnail(pageId, {
|
||||
title,
|
||||
summary,
|
||||
content,
|
||||
useAi: true,
|
||||
instruction: '根据当前页面内容与标题,生成更精美、可在信息流中直接展示的中文封面。',
|
||||
});
|
||||
if (result.content) {
|
||||
iframeContentRef.current = result.content;
|
||||
onContentChange(result.content);
|
||||
}
|
||||
bumpThumbnail();
|
||||
setThumbNotice('AI 已重新生成封面');
|
||||
} catch (err) {
|
||||
setThumbError(err instanceof Error ? err.message : 'AI 封面生成失败');
|
||||
} finally {
|
||||
setThumbBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-save-preview-dual mindspace-page-preview-dual">
|
||||
<div className="page-save-preview-pane page-save-preview-pane-page">
|
||||
<span className="page-save-preview-label">页面效果</span>
|
||||
<p className="page-save-preview-hint">
|
||||
点击文字直接编辑 · 点击图片替换 · 点击空白背景区域替换背景
|
||||
</p>
|
||||
<div className="page-save-mini-page mindspace-page-mini-page">
|
||||
{!previewFailed ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title="页面预览"
|
||||
srcDoc={srcdoc}
|
||||
className="page-save-mini-page-frame"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
scrolling="yes"
|
||||
onError={() => setPreviewFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="page-save-card-thumb-placeholder">页面预览加载失败</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-save-preview-pane page-save-preview-pane-thumb">
|
||||
<span className="page-save-preview-label">卡片预览图</span>
|
||||
<p className="page-save-preview-hint">保存后在信息流中显示的 3:4 封面</p>
|
||||
<div className="page-save-card-thumb">
|
||||
{!thumbnailFailed ? (
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={`${title || '页面'} 卡片预览图`}
|
||||
onError={() => setThumbnailFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="page-save-card-thumb-placeholder">预览图生成失败</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mindspace-page-thumb-actions">
|
||||
<input
|
||||
ref={uploadInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={(event) => void handleUploadFile(event)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUploadClick}
|
||||
disabled={thumbBusy !== null}
|
||||
>
|
||||
{thumbBusy === 'upload' ? '上传中…' : '上传替换'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleAiRegenerate()}
|
||||
disabled={thumbBusy !== null}
|
||||
>
|
||||
{thumbBusy === 'ai' ? '生成中…' : 'AI 重新生成'}
|
||||
</button>
|
||||
</div>
|
||||
{thumbNotice ? <p className="mindspace-page-thumb-note">{thumbNotice}</p> : null}
|
||||
{thumbError ? <p className="mindspace-page-thumb-error">{thumbError}</p> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from 'react';
|
||||
import { useChat } from '../context/ChatProvider';
|
||||
import type { MindSpaceChatContext, MindSpaceSaveCategory, PortalUser } from '../types';
|
||||
import { SpaceChatFab } from './SpaceChatFab';
|
||||
import { SpaceChatPanel } from './SpaceChatPanel';
|
||||
|
||||
export function MindSpaceSpaceChat({
|
||||
context,
|
||||
user,
|
||||
onOpenFullChat,
|
||||
onPageSaved,
|
||||
}: {
|
||||
context: MindSpaceChatContext;
|
||||
user: PortalUser;
|
||||
onOpenFullChat: () => void;
|
||||
onPageSaved?: (result: {
|
||||
kind: 'page' | 'category';
|
||||
pageId?: string;
|
||||
categoryCode?: MindSpaceSaveCategory;
|
||||
}) => void;
|
||||
}) {
|
||||
const { chatState, pendingTool } = useChat();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SpaceChatFab
|
||||
open={open}
|
||||
streaming={chatState === 'streaming'}
|
||||
pendingTool={Boolean(pendingTool)}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
/>
|
||||
<SpaceChatPanel
|
||||
open={open}
|
||||
context={context}
|
||||
user={user}
|
||||
onClose={() => setOpen(false)}
|
||||
onOpenFullChat={onOpenFullChat}
|
||||
onPageSaved={onPageSaved}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,66 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export function PagePreviewFrame({
|
||||
src,
|
||||
title,
|
||||
reloadKey = 0,
|
||||
minHeight = 680,
|
||||
maxHeight = 4800,
|
||||
className = 'mindspace-page-preview-frame',
|
||||
}: {
|
||||
src: string;
|
||||
title: string;
|
||||
reloadKey?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [height, setHeight] = useState(minHeight);
|
||||
|
||||
const syncHeight = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
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));
|
||||
} catch {
|
||||
setHeight(minHeight);
|
||||
}
|
||||
}, [maxHeight, minHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
setHeight(minHeight);
|
||||
}, [src, reloadKey, minHeight]);
|
||||
|
||||
useEffect(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (!iframe) return;
|
||||
iframe.addEventListener('load', syncHeight);
|
||||
return () => iframe.removeEventListener('load', syncHeight);
|
||||
}, [src, reloadKey, syncHeight]);
|
||||
|
||||
return (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
key={`${src}:${reloadKey}`}
|
||||
title={title}
|
||||
src={src}
|
||||
sandbox="allow-same-origin"
|
||||
className={className}
|
||||
style={{ height: `${height}px` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { analyzeChatSave, saveChatMessageAsPage } from '../api/client';
|
||||
import type { ChatSaveAnalysis, MindSpaceSaveCategory } from '../types';
|
||||
import { PageSavePreviewPanel } from './PageSavePreviewPanel';
|
||||
|
||||
const CATEGORY_OPTIONS: Array<{
|
||||
code: MindSpaceSaveCategory;
|
||||
label: string;
|
||||
hint: string;
|
||||
}> = [
|
||||
{
|
||||
code: 'draft',
|
||||
label: '页面草稿',
|
||||
hint: '继续编辑后再决定是否发布',
|
||||
},
|
||||
{
|
||||
code: 'oa',
|
||||
label: 'OA 工作区',
|
||||
hint: '与上传文档并列的资料卡片',
|
||||
},
|
||||
{
|
||||
code: 'private',
|
||||
label: '私人区',
|
||||
hint: '默认不可公开,发布前需检查',
|
||||
},
|
||||
{
|
||||
code: 'public',
|
||||
label: '公开区',
|
||||
hint: '可公开候选,仍需走发布流程',
|
||||
},
|
||||
];
|
||||
|
||||
const CATEGORY_LABELS: Record<MindSpaceSaveCategory, string> = {
|
||||
draft: '页面草稿',
|
||||
oa: 'OA 工作区',
|
||||
private: '私人区',
|
||||
public: '公开区',
|
||||
};
|
||||
|
||||
export type PageSaveDialogProps = {
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
compact?: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (result: {
|
||||
kind: 'page' | 'asset';
|
||||
categoryCode: MindSpaceSaveCategory;
|
||||
pageId?: string;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export function PageSaveDialog({
|
||||
sessionId,
|
||||
messageId,
|
||||
compact = false,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PageSaveDialogProps) {
|
||||
const [analysis, setAnalysis] = useState<ChatSaveAnalysis | null>(null);
|
||||
const [analysisLoading, setAnalysisLoading] = useState(true);
|
||||
const [analysisError, setAnalysisError] = useState<string | null>(null);
|
||||
const [selectedLinkIndex, setSelectedLinkIndex] = useState(0);
|
||||
const [title, setTitle] = useState('');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [templateId, setTemplateId] = useState('editorial');
|
||||
const [categoryCode, setCategoryCode] = useState<MindSpaceSaveCategory>('draft');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saveNotice, setSaveNotice] = useState<string | null>(null);
|
||||
const [privateAcknowledged, setPrivateAcknowledged] = useState(false);
|
||||
const [thumbnailVersion, setThumbnailVersion] = useState(0);
|
||||
const [previewScrollLock, setPreviewScrollLock] = useState(false);
|
||||
const titleRef = useRef('');
|
||||
const summaryRef = useRef('');
|
||||
|
||||
const loadAnalysis = async (
|
||||
linkIndex: number,
|
||||
options: { updateForm?: boolean; previewTitle?: string; previewSummary?: string } = {},
|
||||
) => {
|
||||
const updateForm = options.updateForm ?? true;
|
||||
if (updateForm) {
|
||||
setAnalysisLoading(true);
|
||||
setAnalysisError(null);
|
||||
}
|
||||
try {
|
||||
const result = await analyzeChatSave({
|
||||
sessionId,
|
||||
messageId,
|
||||
selectedLinkIndex: linkIndex,
|
||||
previewTitle: options.previewTitle,
|
||||
previewSummary: options.previewSummary,
|
||||
});
|
||||
setAnalysis(result);
|
||||
if (updateForm) {
|
||||
setTitle(result.suggestedTitle);
|
||||
setSummary(result.suggestedSummary);
|
||||
titleRef.current = result.suggestedTitle;
|
||||
summaryRef.current = result.suggestedSummary;
|
||||
setSelectedLinkIndex(result.selectedLinkIndex >= 0 ? result.selectedLinkIndex : linkIndex);
|
||||
}
|
||||
if (result.thumbnailUrl) {
|
||||
setThumbnailVersion(Date.now());
|
||||
}
|
||||
} catch (err) {
|
||||
if (updateForm) {
|
||||
setAnalysisError(err instanceof Error ? err.message : '无法分析消息内容');
|
||||
}
|
||||
} finally {
|
||||
if (updateForm) {
|
||||
setAnalysisLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadAnalysis(0);
|
||||
}, [sessionId, messageId]);
|
||||
|
||||
useEffect(() => {
|
||||
titleRef.current = title;
|
||||
}, [title]);
|
||||
|
||||
useEffect(() => {
|
||||
summaryRef.current = summary;
|
||||
}, [summary]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!analysis || analysis.contentMode !== 'static_html') return undefined;
|
||||
const timer = window.setTimeout(() => {
|
||||
setThumbnailVersion(Date.now());
|
||||
}, 500);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [title, summary, analysis?.contentMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !saving) onClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [onClose, saving]);
|
||||
|
||||
useEffect(() => {
|
||||
setPrivateAcknowledged(false);
|
||||
}, [categoryCode, analysis?.privacyScan?.findings.length]);
|
||||
|
||||
const isStaticHtml = analysis?.contentMode === 'static_html';
|
||||
const privacyFindings = analysis?.privacyScan.findings ?? [];
|
||||
const privateBlocked = categoryCode === 'private' && analysis?.privacyScan.allowed === false;
|
||||
const privateNeedsAck =
|
||||
categoryCode === 'private' && privacyFindings.length > 0 && analysis?.privacyScan.allowed;
|
||||
|
||||
const save = async () => {
|
||||
if (!title.trim()) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
const result = await saveChatMessageAsPage({
|
||||
sessionId,
|
||||
messageId,
|
||||
title: title.trim(),
|
||||
summary: summary.trim(),
|
||||
templateId: isStaticHtml ? 'static-html' : templateId,
|
||||
categoryCode,
|
||||
selectedLinkIndex,
|
||||
acknowledgedFindingIds:
|
||||
categoryCode === 'private' && privateNeedsAck && privateAcknowledged
|
||||
? privacyFindings.map((finding) => finding.id)
|
||||
: undefined,
|
||||
});
|
||||
if (result.kind === 'page') {
|
||||
setSaveNotice(`已保存到${CATEGORY_LABELS[categoryCode]}`);
|
||||
onSaved({ kind: 'page', categoryCode: 'draft', pageId: result.page.id });
|
||||
return;
|
||||
}
|
||||
setSaveNotice(`已保存到${CATEGORY_LABELS[result.categoryCode]}`);
|
||||
onSaved({ kind: 'asset', categoryCode: result.categoryCode });
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof Error ? err.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={`page-save-backdrop${previewScrollLock ? ' is-preview-scroll-locked' : ''}`}
|
||||
role="presentation"
|
||||
onClick={saving ? undefined : onClose}
|
||||
>
|
||||
<section
|
||||
className={`page-save-panel${compact ? ' page-save-panel-compact' : ' page-save-panel-wide'}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className={`page-save-scroll${previewScrollLock ? ' is-preview-scroll-locked' : ''}`}>
|
||||
<p className="mindspace-eyebrow">SAVE TO MINDSPACE</p>
|
||||
<h2>{isStaticHtml ? '保存生成的页面' : '保存 AI 回答为文章'}</h2>
|
||||
|
||||
{analysisLoading && <div className="page-save-state">正在识别页面内容…</div>}
|
||||
{analysisError && <div className="page-save-error">{analysisError}</div>}
|
||||
|
||||
{!analysisLoading && analysis && (
|
||||
<div className="page-save-layout">
|
||||
<div className="page-save-form">
|
||||
{isStaticHtml && analysis && (
|
||||
<div className="page-save-preview-block">
|
||||
<PageSavePreviewPanel
|
||||
sessionId={sessionId}
|
||||
messageId={messageId}
|
||||
selectedLinkIndex={selectedLinkIndex}
|
||||
title={title}
|
||||
summary={summary}
|
||||
analysis={analysis}
|
||||
thumbnailVersion={thumbnailVersion}
|
||||
onPreviewHoverChange={setPreviewScrollLock}
|
||||
/>
|
||||
{analysis.links.length > 1 && (
|
||||
<label>
|
||||
选择链接页面
|
||||
<select
|
||||
value={selectedLinkIndex}
|
||||
onChange={(event) => {
|
||||
const nextIndex = Number(event.target.value);
|
||||
setSelectedLinkIndex(nextIndex);
|
||||
void loadAnalysis(nextIndex);
|
||||
}}
|
||||
>
|
||||
{analysis.links.map((link, index) => (
|
||||
<option value={index} key={link.publicUrl}>
|
||||
{link.filename}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
<p className="page-save-source">
|
||||
来源文件:{analysis.filename ?? analysis.relativePath}
|
||||
{analysis.previewUrl ? ` · 公网链接仅作参考` : ''}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label>
|
||||
页面标题
|
||||
<input
|
||||
value={title}
|
||||
maxLength={255}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
摘要
|
||||
<textarea
|
||||
value={summary}
|
||||
maxLength={1000}
|
||||
rows={3}
|
||||
onChange={(event) => setSummary(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{!isStaticHtml && (
|
||||
<label>
|
||||
页面模板
|
||||
<select value={templateId} onChange={(event) => setTemplateId(event.target.value)}>
|
||||
<option value="editorial">编辑长文</option>
|
||||
<option value="report">分析报告</option>
|
||||
<option value="knowledge-card">知识卡片</option>
|
||||
<option value="profile">个人介绍</option>
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<fieldset className="page-save-targets">
|
||||
<legend>保存到</legend>
|
||||
{CATEGORY_OPTIONS.map((option) => {
|
||||
const disabledPrivate =
|
||||
option.code === 'private' && analysis.privacyScan.allowed === false;
|
||||
return (
|
||||
<label
|
||||
className={`page-save-target${disabledPrivate ? ' is-disabled' : ''}`}
|
||||
key={option.code}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="save-target"
|
||||
value={option.code}
|
||||
checked={categoryCode === option.code}
|
||||
disabled={disabledPrivate}
|
||||
onChange={() => setCategoryCode(option.code)}
|
||||
/>
|
||||
<span>
|
||||
<strong>{option.label}</strong>
|
||||
<small>
|
||||
{disabledPrivate
|
||||
? '内容含阻断级敏感信息,不能保存到私人区'
|
||||
: option.hint}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</fieldset>
|
||||
|
||||
{categoryCode === 'private' && (
|
||||
<div className="page-save-private-panel">
|
||||
<strong>私人区安全说明</strong>
|
||||
<p>私人区默认不可公开。若后续要发布,系统会要求脱敏副本和二次确认。</p>
|
||||
{privacyFindings.length > 0 && (
|
||||
<ul className="page-save-private-findings">
|
||||
{privacyFindings.map((finding) => (
|
||||
<li key={finding.id}>
|
||||
{finding.blocking ? '阻断' : '提醒'} · {finding.label ?? finding.type}(
|
||||
{finding.occurrenceCount} 处){finding.sampleMasked}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{privateNeedsAck && (
|
||||
<label className="page-save-private-ack">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={privateAcknowledged}
|
||||
onChange={(event) => setPrivateAcknowledged(event.target.checked)}
|
||||
/>
|
||||
<span>我已知悉上述敏感信息,确认保存到私人区</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!compact && (
|
||||
<aside className="page-save-aside">
|
||||
<p className="mindspace-eyebrow">WHERE IT GOES</p>
|
||||
<h3>保存后落点</h3>
|
||||
<div className="page-save-destination-preview">
|
||||
<span>{CATEGORY_LABELS[categoryCode]}</span>
|
||||
<div className="page-save-destination-grid">
|
||||
<div className="page-save-destination-card is-muted">📄 资料</div>
|
||||
<div
|
||||
className={`page-save-destination-card${categoryCode === 'draft' || isStaticHtml ? ' is-active' : ''}`}
|
||||
>
|
||||
{isStaticHtml ? '🌐 页面' : '📝 文章'}
|
||||
</div>
|
||||
<div className="page-save-destination-card is-muted">📊 表格</div>
|
||||
</div>
|
||||
<p>
|
||||
{categoryCode === 'draft'
|
||||
? '进入草稿编辑工作台,可预览真实页面并决定是否发布。'
|
||||
: '以资料卡片形式保存在该分区,与上传文件并列展示。'}
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saveNotice && <div className="page-save-notice">{saveNotice}</div>}
|
||||
{saveError && <div className="page-save-error">{saveError}</div>}
|
||||
</div>
|
||||
|
||||
<div className="page-save-actions">
|
||||
<button type="button" onClick={onClose} disabled={saving}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="page-save-primary"
|
||||
onClick={() => void save()}
|
||||
disabled={
|
||||
saving ||
|
||||
analysisLoading ||
|
||||
!title.trim() ||
|
||||
Boolean(analysisError) ||
|
||||
privateBlocked ||
|
||||
(privateNeedsAck && !privateAcknowledged)
|
||||
}
|
||||
>
|
||||
{saving ? '正在保存…' : categoryCode === 'draft' ? '保存并进入编辑' : '保存到空间'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { buildChatSaveThumbnailUrl, fetchPreviewAsset } from '../api/client';
|
||||
import type { ChatSaveAnalysis } from '../types';
|
||||
|
||||
export function PageSavePreviewPanel({
|
||||
sessionId,
|
||||
messageId,
|
||||
selectedLinkIndex,
|
||||
title,
|
||||
summary,
|
||||
analysis,
|
||||
thumbnailVersion,
|
||||
onPreviewHoverChange,
|
||||
}: {
|
||||
sessionId: string;
|
||||
messageId: string;
|
||||
selectedLinkIndex: number;
|
||||
title: string;
|
||||
summary: string;
|
||||
analysis: ChatSaveAnalysis;
|
||||
thumbnailVersion: number;
|
||||
onPreviewHoverChange?: (locked: boolean) => void;
|
||||
}) {
|
||||
const [thumbnailSrc, setThumbnailSrc] = useState<string | null>(null);
|
||||
const [thumbnailFailed, setThumbnailFailed] = useState(false);
|
||||
const [previewFailed, setPreviewFailed] = useState(false);
|
||||
|
||||
const pagePreviewUrl = analysis.localPreviewUrl ?? analysis.previewFrameUrl;
|
||||
|
||||
useEffect(() => {
|
||||
setPreviewFailed(false);
|
||||
}, [pagePreviewUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!analysis.hasHtmlContent) {
|
||||
setThumbnailSrc(null);
|
||||
setThumbnailFailed(false);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let objectUrl: string | null = null;
|
||||
let cancelled = false;
|
||||
|
||||
const loadThumbnail = async () => {
|
||||
setThumbnailFailed(false);
|
||||
const apiUrl = buildChatSaveThumbnailUrl({
|
||||
sessionId,
|
||||
messageId,
|
||||
selectedLinkIndex,
|
||||
previewTitle: title,
|
||||
previewSummary: summary,
|
||||
cacheKey: thumbnailVersion,
|
||||
});
|
||||
|
||||
try {
|
||||
objectUrl = await fetchPreviewAsset(apiUrl);
|
||||
if (!cancelled) setThumbnailSrc(objectUrl);
|
||||
return;
|
||||
} catch {
|
||||
if (cancelled) return;
|
||||
if (analysis.localThumbnailUrl) {
|
||||
setThumbnailSrc(`${analysis.localThumbnailUrl}?v=${thumbnailVersion}`);
|
||||
return;
|
||||
}
|
||||
setThumbnailSrc(null);
|
||||
setThumbnailFailed(true);
|
||||
}
|
||||
};
|
||||
|
||||
void loadThumbnail();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [
|
||||
analysis.hasHtmlContent,
|
||||
analysis.localThumbnailUrl,
|
||||
messageId,
|
||||
selectedLinkIndex,
|
||||
sessionId,
|
||||
summary,
|
||||
thumbnailVersion,
|
||||
title,
|
||||
]);
|
||||
|
||||
if (!analysis.hasHtmlContent) {
|
||||
return (
|
||||
<div className="page-save-error">
|
||||
本地未找到页面文件,请确认 Agent 已将 HTML 保存到 MindSpace 工作区。
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pagePreviewUrl) {
|
||||
return <div className="page-save-error">无法构建页面预览地址。</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-save-preview-dual">
|
||||
<div
|
||||
className="page-save-preview-pane page-save-preview-pane-page"
|
||||
onMouseEnter={() => onPreviewHoverChange?.(true)}
|
||||
onMouseLeave={() => onPreviewHoverChange?.(false)}
|
||||
onWheelCapture={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span className="page-save-preview-label">页面效果</span>
|
||||
<p className="page-save-preview-hint">微型页面,可上下滑动查看完整效果</p>
|
||||
<div className="page-save-mini-page">
|
||||
{!previewFailed ? (
|
||||
<iframe
|
||||
title="页面预览"
|
||||
src={pagePreviewUrl}
|
||||
className="page-save-mini-page-frame"
|
||||
sandbox="allow-same-origin allow-scripts"
|
||||
scrolling="yes"
|
||||
onError={() => setPreviewFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<div className="page-save-card-thumb-placeholder">页面预览加载失败</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="page-save-preview-pane page-save-preview-pane-thumb">
|
||||
<span className="page-save-preview-label">卡片预览图</span>
|
||||
<p className="page-save-preview-hint">保存后在信息流中显示的 3:4 封面</p>
|
||||
<div className="page-save-card-thumb">
|
||||
{thumbnailSrc && !thumbnailFailed ? (
|
||||
<img
|
||||
src={thumbnailSrc}
|
||||
alt={`${title || '页面'} 卡片预览图`}
|
||||
onError={() => setThumbnailFailed(true)}
|
||||
/>
|
||||
) : (
|
||||
<span className="page-save-card-thumb-placeholder">
|
||||
{thumbnailFailed ? '预览图生成失败' : '正在生成预览图…'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
clearUserPolicyOverrides,
|
||||
getRolePolicies,
|
||||
getUserPolicies,
|
||||
listPolicyCatalog,
|
||||
updateRolePolicies,
|
||||
updateUserPolicies,
|
||||
} from '../api/client';
|
||||
import type { AdminUserRow, PolicyDefinition, PolicyMap } from '../types';
|
||||
|
||||
const RISK_LABEL: Record<string, string> = {
|
||||
low: '低',
|
||||
medium: '中',
|
||||
high: '高',
|
||||
};
|
||||
|
||||
function PolicyGrid({
|
||||
catalog,
|
||||
values,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
catalog: PolicyDefinition[];
|
||||
values: PolicyMap;
|
||||
disabled?: boolean;
|
||||
onChange: (key: string, value: PolicyMap[string]) => void;
|
||||
}) {
|
||||
const grouped = catalog.reduce<Record<string, PolicyDefinition[]>>((acc, item) => {
|
||||
acc[item.category] ??= [];
|
||||
acc[item.category].push(item);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="capability-grid">
|
||||
{Object.entries(grouped).map(([category, items]) => (
|
||||
<div key={category} className="capability-group">
|
||||
<h3>{category}</h3>
|
||||
<ul>
|
||||
{items.map((item) => (
|
||||
<li key={item.key}>
|
||||
<label>
|
||||
{item.type === 'boolean' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(values[item.key])}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(item.key, e.target.checked)}
|
||||
/>
|
||||
) : (
|
||||
<select
|
||||
value={String(values[item.key] ?? item.defaultValue)}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(item.key, e.target.value)}
|
||||
>
|
||||
{(item.options ?? []).map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<span className="capability-label">
|
||||
{item.label}
|
||||
<span className={`risk-pill risk-${item.risk}`}>
|
||||
风险 {RISK_LABEL[item.risk] ?? item.risk}
|
||||
</span>
|
||||
</span>
|
||||
<span className="muted capability-desc">{item.description}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PolicySettings({
|
||||
users,
|
||||
userId,
|
||||
userOnly = false,
|
||||
}: {
|
||||
users: AdminUserRow[];
|
||||
userId?: string;
|
||||
userOnly?: boolean;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<PolicyDefinition[]>([]);
|
||||
const [rolePolicies, setRolePolicies] = useState<PolicyMap>({});
|
||||
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
|
||||
const [userPolicies, setUserPolicies] = useState<PolicyMap>({});
|
||||
const [userOverrides, setUserOverrides] = useState<PolicyMap>({});
|
||||
const [userUnrestricted, setUserUnrestricted] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catalogItems, roleState] = await Promise.all([
|
||||
listPolicyCatalog(),
|
||||
getRolePolicies('user'),
|
||||
]);
|
||||
setCatalog(catalogItems);
|
||||
setRolePolicies(roleState.policies);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载安全策略失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUserPolicies = useCallback(async (userId: string) => {
|
||||
if (!userId) {
|
||||
setUserPolicies({});
|
||||
setUserOverrides({});
|
||||
setUserUnrestricted(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = await getUserPolicies(userId);
|
||||
setUserPolicies(state.policies);
|
||||
setUserOverrides(state.overrides);
|
||||
setUserUnrestricted(state.unrestricted);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载用户策略失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUserPolicies(selectedUserId);
|
||||
}, [loadUserPolicies, selectedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
setSelectedUserId(userId);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const saveRoleDefaults = async () => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateRolePolicies('user', rolePolicies);
|
||||
setRolePolicies(result.policies);
|
||||
setMessage('普通用户默认安全策略已保存');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const saveUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateUserPolicies(selectedUserId, userPolicies);
|
||||
setUserPolicies(result.policies);
|
||||
await loadUserPolicies(selectedUserId);
|
||||
setMessage('用户单独策略已保存');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await clearUserPolicyOverrides(selectedUserId);
|
||||
await loadUserPolicies(selectedUserId);
|
||||
setMessage('已恢复为角色默认策略');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const regularUsers = users.filter((user) => user.role === 'user');
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>安全策略</h2>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted capability-intro">
|
||||
控制 TKMind 模式、工作区只读、网络出站与 API 代理锁定。与上方「能力权限」叠加生效(策略会进一步收紧高风险能力)。
|
||||
</p>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : (
|
||||
<>
|
||||
{!userOnly && (
|
||||
<>
|
||||
<h3 className="capability-section-title">普通用户默认策略</h3>
|
||||
<PolicyGrid
|
||||
catalog={catalog}
|
||||
values={rolePolicies}
|
||||
onChange={(key, value) => setRolePolicies((prev) => ({ ...prev, [key]: value }))}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
|
||||
保存默认策略
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!userOnly && <h3 className="capability-section-title">单用户策略覆盖</h3>}
|
||||
{!userOnly && (
|
||||
<div className="admin-form capability-user-picker">
|
||||
<select
|
||||
value={selectedUserId}
|
||||
onChange={(e) => setSelectedUserId(e.target.value)}
|
||||
>
|
||||
<option value="">选择用户(可选)</option>
|
||||
{regularUsers.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.displayName} (@{user.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{selectedUserId && (
|
||||
<>
|
||||
{userUnrestricted ? (
|
||||
<p className="muted">该用户为管理员,不受策略限制。</p>
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(userOverrides).length > 0 && (
|
||||
<p className="muted">
|
||||
已设置 {Object.keys(userOverrides).length} 项单独覆盖。
|
||||
</p>
|
||||
)}
|
||||
<PolicyGrid
|
||||
catalog={catalog}
|
||||
values={userPolicies}
|
||||
onChange={(key, value) =>
|
||||
setUserPolicies((prev) => ({ ...prev, [key]: value }))
|
||||
}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="send-btn"
|
||||
onClick={() => void saveUserOverrides()}
|
||||
>
|
||||
保存用户策略
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void resetUserOverrides()}
|
||||
>
|
||||
恢复角色默认
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,535 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
createLlmProviderKey,
|
||||
deleteLlmProviderKey,
|
||||
getLlmGlobalSettings,
|
||||
listLlmProviderCatalog,
|
||||
listLlmProviderKeys,
|
||||
selectLlmProviderKey,
|
||||
setLlmGlobalModel,
|
||||
syncLlmProviderToGoosed,
|
||||
testLlmProviderDraft,
|
||||
testLlmProviderKey,
|
||||
updateLlmProviderKey,
|
||||
} from '../api/client';
|
||||
import type {
|
||||
LlmConnectionTestResult,
|
||||
LlmGlobalSettings,
|
||||
LlmProviderDefinition,
|
||||
LlmProviderKeyRow,
|
||||
} from '../types';
|
||||
|
||||
const CUSTOM_PROVIDER_ID = '__custom__';
|
||||
|
||||
const emptyForm = {
|
||||
providerId: CUSTOM_PROVIDER_ID,
|
||||
name: 'Relay Buyer Ollama',
|
||||
apiKey: 'UqyHPKSSEZq0-oPnl8sru-7hZcJ2anPUL1yAVk866Vo',
|
||||
apiUrl: 'http://127.0.0.1:18300/relay/buyer/v1/chat/completions',
|
||||
basePath: '',
|
||||
modelsText: 'qwen2.5:3b',
|
||||
defaultModel: 'qwen2.5:3b',
|
||||
relayProvider: 'ollama',
|
||||
engine: 'openai',
|
||||
};
|
||||
|
||||
function parseModelsText(text: string) {
|
||||
return [...new Set(text.split(/[\n,]/).map((item) => item.trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function TestResultBanner({ result }: { result: LlmConnectionTestResult | null }) {
|
||||
if (!result) return null;
|
||||
if (!result.ok) {
|
||||
return <p className="banner banner-error">联通失败:{result.message ?? '未知错误'}</p>;
|
||||
}
|
||||
return (
|
||||
<p className="banner banner-info">
|
||||
联通成功({result.latencyMs}ms,模型 {result.model}):{result.reply}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderKeySettings() {
|
||||
const [catalog, setCatalog] = useState<LlmProviderDefinition[]>([]);
|
||||
const [keys, setKeys] = useState<LlmProviderKeyRow[]>([]);
|
||||
const [globalSettings, setGlobalSettings] = useState<LlmGlobalSettings | null>(null);
|
||||
const [globalModelDraft, setGlobalModelDraft] = useState('');
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [testingForm, setTestingForm] = useState(false);
|
||||
const [testingGlobal, setTestingGlobal] = useState(false);
|
||||
const [testingKeyId, setTestingKeyId] = useState<string | null>(null);
|
||||
const [formTestResult, setFormTestResult] = useState<LlmConnectionTestResult | null>(null);
|
||||
const [globalTestResult, setGlobalTestResult] = useState<LlmConnectionTestResult | null>(null);
|
||||
|
||||
const isCustom = form.providerId === CUSTOM_PROVIDER_ID;
|
||||
const selectedCatalog = useMemo(
|
||||
() => catalog.find((item) => item.id === form.providerId) ?? null,
|
||||
[catalog, form.providerId],
|
||||
);
|
||||
const customModels = useMemo(() => parseModelsText(form.modelsText), [form.modelsText]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catalogItems, keyRows, globalState] = await Promise.all([
|
||||
listLlmProviderCatalog(),
|
||||
listLlmProviderKeys(),
|
||||
getLlmGlobalSettings(),
|
||||
]);
|
||||
setCatalog(catalogItems);
|
||||
setKeys(keyRows);
|
||||
setGlobalSettings(globalState);
|
||||
setGlobalModelDraft(globalState.globalModel ?? '');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载 LLM 配置失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isCustom) {
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
defaultModel: current.defaultModel || customModels[0] || '',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (!selectedCatalog) return;
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
defaultModel: current.defaultModel || selectedCatalog.defaultModel,
|
||||
}));
|
||||
}, [customModels, isCustom, selectedCatalog]);
|
||||
|
||||
const handleCreate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
setFormTestResult(null);
|
||||
try {
|
||||
if (isCustom) {
|
||||
await createLlmProviderKey({
|
||||
providerId: CUSTOM_PROVIDER_ID,
|
||||
name: form.name,
|
||||
apiKey: form.apiKey,
|
||||
apiUrl: form.apiUrl,
|
||||
basePath: form.basePath || undefined,
|
||||
models: customModels,
|
||||
defaultModel: form.defaultModel || customModels[0],
|
||||
engine: form.engine,
|
||||
relayProvider: form.relayProvider || undefined,
|
||||
});
|
||||
} else {
|
||||
await createLlmProviderKey({
|
||||
providerId: form.providerId,
|
||||
name: form.name,
|
||||
apiKey: form.apiKey,
|
||||
defaultModel: form.defaultModel || undefined,
|
||||
});
|
||||
}
|
||||
setForm({ ...emptyForm, providerId: form.providerId });
|
||||
setMessage('LLM 配置已保存到数据库');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestForm = async () => {
|
||||
setTestingForm(true);
|
||||
setFormTestResult(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await testLlmProviderDraft({
|
||||
providerId: CUSTOM_PROVIDER_ID,
|
||||
name: form.name || 'test',
|
||||
apiKey: form.apiKey,
|
||||
apiUrl: form.apiUrl,
|
||||
basePath: form.basePath || undefined,
|
||||
models: customModels,
|
||||
defaultModel: form.defaultModel || customModels[0],
|
||||
testModel: form.defaultModel || customModels[0],
|
||||
relayProvider: form.relayProvider || undefined,
|
||||
});
|
||||
setFormTestResult(result);
|
||||
} catch (err) {
|
||||
setFormTestResult({
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : '联通测试失败',
|
||||
});
|
||||
} finally {
|
||||
setTestingForm(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyGlobalModel = async () => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
setGlobalTestResult(null);
|
||||
try {
|
||||
const result = await setLlmGlobalModel(globalModelDraft);
|
||||
setGlobalSettings(result.global);
|
||||
setMessage(`全局模型已设为 ${result.global.globalModel},并已同步到 TKMind Agent`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存全局模型失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestGlobal = async () => {
|
||||
if (!globalSettings?.keyId) {
|
||||
setError('请先启用一个 LLM 配置');
|
||||
return;
|
||||
}
|
||||
setTestingGlobal(true);
|
||||
setGlobalTestResult(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await testLlmProviderKey(
|
||||
globalSettings.keyId,
|
||||
globalModelDraft || globalSettings.globalModel || undefined,
|
||||
);
|
||||
setGlobalTestResult(result);
|
||||
} catch (err) {
|
||||
setGlobalTestResult({
|
||||
ok: false,
|
||||
message: err instanceof Error ? err.message : '联通测试失败',
|
||||
});
|
||||
} finally {
|
||||
setTestingGlobal(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = async (keyId: string) => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await selectLlmProviderKey(keyId);
|
||||
setMessage('已切换当前 LLM 并同步到 TKMind Agent');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '切换失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestKey = async (row: LlmProviderKeyRow) => {
|
||||
setTestingKeyId(row.id);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await testLlmProviderKey(row.id, row.defaultModel);
|
||||
if (result.ok) {
|
||||
setMessage(`「${row.name}」联通成功(${result.latencyMs}ms):${result.reply}`);
|
||||
} else {
|
||||
setError(`「${row.name}」联通失败:${result.message}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '联通测试失败');
|
||||
} finally {
|
||||
setTestingKeyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (row: LlmProviderKeyRow) => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await updateLlmProviderKey(row.id, {
|
||||
status: row.status === 'active' ? 'disabled' : 'active',
|
||||
});
|
||||
setMessage('状态已更新');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (row: LlmProviderKeyRow) => {
|
||||
if (!window.confirm(`确定删除配置「${row.name}」?`)) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await deleteLlmProviderKey(row.id);
|
||||
setMessage('配置已删除');
|
||||
await load();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSync = async () => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await syncLlmProviderToGoosed();
|
||||
setMessage(result.synced ? '已重新同步到 TKMind Agent' : '当前没有启用的 LLM 配置');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '同步失败');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>LLM Key 管理</h2>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted">
|
||||
服务启动时会自动导入 Relay Buyer Ollama(若尚未存在)。全局模型会写入
|
||||
GOOSE_MODEL / TKMIND_MODEL,全站对话均使用该模型。
|
||||
</p>
|
||||
|
||||
<div className="admin-card global-model-card">
|
||||
<div className="admin-card-head">
|
||||
<h3>全局模型</h3>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : !globalSettings?.keyId ? (
|
||||
<p className="muted">尚未启用 LLM 配置。保存并启用 Relay 后即可选择全局模型。</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="muted">
|
||||
当前 Provider:<strong>{globalSettings.providerLabel}</strong>(
|
||||
{globalSettings.keyName})
|
||||
</p>
|
||||
<div className="admin-form global-model-form">
|
||||
<select
|
||||
value={globalModelDraft}
|
||||
onChange={(e) => setGlobalModelDraft(e.target.value)}
|
||||
>
|
||||
{globalSettings.availableModels.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="send-btn" onClick={() => void handleApplyGlobalModel()}>
|
||||
应用全局模型
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={testingGlobal}
|
||||
onClick={() => void handleTestGlobal()}
|
||||
>
|
||||
{testingGlobal ? '测试中…' : '联通测试'}
|
||||
</button>
|
||||
</div>
|
||||
<TestResultBanner result={globalTestResult} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form className="admin-form" onSubmit={handleCreate}>
|
||||
<select
|
||||
value={form.providerId}
|
||||
onChange={(e) =>
|
||||
setForm((state) => ({
|
||||
...state,
|
||||
providerId: e.target.value,
|
||||
defaultModel:
|
||||
e.target.value === CUSTOM_PROVIDER_ID
|
||||
? parseModelsText(state.modelsText)[0] ?? ''
|
||||
: (catalog.find((item) => item.id === e.target.value)?.defaultModel ?? ''),
|
||||
}))
|
||||
}
|
||||
>
|
||||
{catalog.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
placeholder="配置名称"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((state) => ({ ...state, name: e.target.value }))}
|
||||
/>
|
||||
|
||||
{isCustom ? (
|
||||
<>
|
||||
<input
|
||||
placeholder="API 地址"
|
||||
value={form.apiUrl}
|
||||
onChange={(e) => setForm((state) => ({ ...state, apiUrl: e.target.value }))}
|
||||
/>
|
||||
<textarea
|
||||
placeholder="模型列表,每行一个"
|
||||
value={form.modelsText}
|
||||
rows={4}
|
||||
onChange={(e) =>
|
||||
setForm((state) => ({
|
||||
...state,
|
||||
modelsText: e.target.value,
|
||||
defaultModel: parseModelsText(e.target.value)[0] ?? state.defaultModel,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<input
|
||||
placeholder="Relay Provider(请求 body 中的 provider,如 ollama)"
|
||||
value={form.relayProvider}
|
||||
onChange={(e) => setForm((state) => ({ ...state, relayProvider: e.target.value }))}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<input
|
||||
placeholder="API Key / Bearer Token"
|
||||
type="password"
|
||||
value={form.apiKey}
|
||||
onChange={(e) => setForm((state) => ({ ...state, apiKey: e.target.value }))}
|
||||
/>
|
||||
|
||||
{isCustom ? (
|
||||
<select
|
||||
value={form.defaultModel || customModels[0] || ''}
|
||||
onChange={(e) => setForm((state) => ({ ...state, defaultModel: e.target.value }))}
|
||||
>
|
||||
{customModels.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<select
|
||||
value={form.defaultModel || selectedCatalog?.defaultModel || ''}
|
||||
onChange={(e) => setForm((state) => ({ ...state, defaultModel: e.target.value }))}
|
||||
>
|
||||
{(selectedCatalog?.models ?? []).map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
|
||||
<button type="submit" className="send-btn">
|
||||
添加配置
|
||||
</button>
|
||||
{isCustom && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={testingForm}
|
||||
onClick={() => void handleTestForm()}
|
||||
>
|
||||
{testingForm ? '测试中…' : '联通测试'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<TestResultBanner result={formTestResult} />
|
||||
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
|
||||
<div className="admin-card-head">
|
||||
<h3>已保存配置</h3>
|
||||
<button type="button" className="ghost-btn" onClick={() => void handleSync()}>
|
||||
手动同步 Agent
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : keys.length === 0 ? (
|
||||
<p className="muted">暂无配置,重启服务后会自动导入 Relay Buyer Ollama。</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>Endpoint</th>
|
||||
<th>模型</th>
|
||||
<th>Key</th>
|
||||
<th>状态</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{keys.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
{row.name}
|
||||
{row.isSelected && <span className="risk-pill risk-low">当前</span>}
|
||||
</td>
|
||||
<td className="mono">
|
||||
{row.providerKind === 'custom' ? (
|
||||
<>
|
||||
<div>{row.apiUrl}</div>
|
||||
{row.relayProvider && (
|
||||
<div className="muted">provider: {row.relayProvider}</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
row.providerLabel
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">
|
||||
<div>{row.defaultModel}</div>
|
||||
{row.models.length > 1 && (
|
||||
<div className="muted">+{row.models.length - 1} 个可选</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">{row.apiKeyMasked}</td>
|
||||
<td>{row.status === 'active' ? '可用' : '禁用'}</td>
|
||||
<td className="admin-actions">
|
||||
{row.providerKind === 'custom' && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={testingKeyId === row.id}
|
||||
onClick={() => void handleTestKey(row)}
|
||||
>
|
||||
{testingKeyId === row.id ? '测试中…' : '测试'}
|
||||
</button>
|
||||
)}
|
||||
{!row.isSelected && row.status === 'active' && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => void handleSelect(row.id)}
|
||||
>
|
||||
启用
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={row.isSelected}
|
||||
onClick={() => void handleToggleStatus(row)}
|
||||
>
|
||||
{row.status === 'active' ? '禁用' : '恢复'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={row.isSelected}
|
||||
onClick={() => void handleDelete(row)}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import QRCode from 'qrcode';
|
||||
import { createRechargeOrder, getBillingConfig, getRechargeOrder } from '../api/client';
|
||||
import type { BillingConfig, RechargeOrder } from '../types';
|
||||
|
||||
function formatYuan(cents: number) {
|
||||
return `¥${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2)}`;
|
||||
}
|
||||
|
||||
function isWeChatBrowser() {
|
||||
return /MicroMessenger/i.test(navigator.userAgent);
|
||||
}
|
||||
|
||||
function detectPayScene(): 'native' | 'h5' {
|
||||
// 微信内置浏览器走扫码:H5 收银台需商户单独开通,长按识别二维码即可支付
|
||||
if (isWeChatBrowser()) return 'native';
|
||||
return /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent) ? 'h5' : 'native';
|
||||
}
|
||||
|
||||
async function createRechargeOrderWithFallback(amountCents: number): Promise<RechargeOrder> {
|
||||
const payScene = detectPayScene();
|
||||
try {
|
||||
return await createRechargeOrder({ amountCents, payScene });
|
||||
} catch (err) {
|
||||
if (payScene !== 'h5') throw err;
|
||||
return createRechargeOrder({ amountCents, payScene: 'native' });
|
||||
}
|
||||
}
|
||||
|
||||
function pickDefaultTier(tiers: number[], balanceCents: number) {
|
||||
if (!tiers.length) return 1000;
|
||||
if (balanceCents <= 0) {
|
||||
return tiers.find((tier) => tier >= 1000) ?? tiers[0];
|
||||
}
|
||||
return tiers.find((tier) => tier >= 1000) ?? tiers[0];
|
||||
}
|
||||
|
||||
type RechargeModalProps = {
|
||||
open: boolean;
|
||||
force?: boolean;
|
||||
balanceCents: number;
|
||||
onClose: () => void;
|
||||
onSuccess: (balanceCents: number) => void;
|
||||
};
|
||||
|
||||
export function RechargeModal({
|
||||
open,
|
||||
force = false,
|
||||
balanceCents,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: RechargeModalProps) {
|
||||
const [config, setConfig] = useState<BillingConfig | null>(null);
|
||||
const [loadingConfig, setLoadingConfig] = useState(false);
|
||||
const [selectedCents, setSelectedCents] = useState(1000);
|
||||
const [order, setOrder] = useState<RechargeOrder | null>(null);
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const pollRef = useRef<number | null>(null);
|
||||
const redirectedRef = useRef(false);
|
||||
|
||||
const tiers = config?.tiersCents ?? [];
|
||||
const projectedBalance = balanceCents + (order?.amountCents ?? selectedCents);
|
||||
|
||||
const resetPayState = useCallback(() => {
|
||||
setOrder(null);
|
||||
setQrDataUrl(null);
|
||||
setPaying(false);
|
||||
setPolling(false);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
redirectedRef.current = false;
|
||||
if (pollRef.current != null) {
|
||||
window.clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
resetPayState();
|
||||
return;
|
||||
}
|
||||
setLoadingConfig(true);
|
||||
setError(null);
|
||||
void getBillingConfig()
|
||||
.then((loaded) => {
|
||||
setConfig(loaded);
|
||||
setSelectedCents(pickDefaultTier(loaded.tiersCents, loaded.balanceCents));
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : '无法加载充值配置');
|
||||
})
|
||||
.finally(() => setLoadingConfig(false));
|
||||
}, [open, resetPayState]);
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollRef.current != null) {
|
||||
window.clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
setPolling(false);
|
||||
}, []);
|
||||
|
||||
const startPolling = useCallback(
|
||||
(orderId: string) => {
|
||||
stopPolling();
|
||||
setPolling(true);
|
||||
pollRef.current = window.setInterval(() => {
|
||||
void getRechargeOrder(orderId)
|
||||
.then((result) => {
|
||||
if (result.order.status === 'paid') {
|
||||
stopPolling();
|
||||
const nextBalance = result.balanceCents ?? projectedBalance;
|
||||
setSuccessMessage(`充值成功,当前余额 ${formatYuan(nextBalance)}`);
|
||||
window.setTimeout(() => onSuccess(nextBalance), 900);
|
||||
} else if (result.order.status === 'expired') {
|
||||
stopPolling();
|
||||
setError('订单已过期,请重新发起支付');
|
||||
setOrder(null);
|
||||
setQrDataUrl(null);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, 2000);
|
||||
},
|
||||
[onSuccess, projectedBalance, stopPolling],
|
||||
);
|
||||
|
||||
useEffect(() => () => stopPolling(), [stopPolling]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!order?.codeUrl) {
|
||||
setQrDataUrl(null);
|
||||
return;
|
||||
}
|
||||
void QRCode.toDataURL(order.codeUrl, {
|
||||
width: 220,
|
||||
margin: 1,
|
||||
color: { dark: '#18211d', light: '#ffffff' },
|
||||
}).then(setQrDataUrl);
|
||||
}, [order?.codeUrl]);
|
||||
|
||||
const handlePay = async () => {
|
||||
if (!config?.wechatEnabled) {
|
||||
setError('微信支付尚未配置,请联系管理员');
|
||||
return;
|
||||
}
|
||||
setPaying(true);
|
||||
setError(null);
|
||||
setSuccessMessage(null);
|
||||
try {
|
||||
const created = await createRechargeOrderWithFallback(selectedCents);
|
||||
setOrder(created);
|
||||
if (created.payMode === 'h5' && created.h5Url && !redirectedRef.current) {
|
||||
redirectedRef.current = true;
|
||||
window.location.href = created.h5Url;
|
||||
}
|
||||
startPolling(created.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '创建支付订单失败');
|
||||
} finally {
|
||||
setPaying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const expireLabel = useMemo(() => {
|
||||
if (!order?.expireAt) return null;
|
||||
return new Date(order.expireAt).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}, [order?.expireAt]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="recharge-backdrop" role="presentation">
|
||||
<div
|
||||
className="recharge-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="recharge-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="recharge-dialog-bar">
|
||||
<div>
|
||||
<p className="recharge-eyebrow">账户充值</p>
|
||||
<h3 id="recharge-title">余额不足,请充值后继续</h3>
|
||||
</div>
|
||||
{!force && (
|
||||
<button type="button" className="recharge-close" onClick={onClose} aria-label="关闭">
|
||||
关闭
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="recharge-balance-row">
|
||||
<span>当前余额</span>
|
||||
<strong>{formatYuan(balanceCents)}</strong>
|
||||
</div>
|
||||
|
||||
{loadingConfig ? (
|
||||
<p className="recharge-muted">加载充值档位…</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="recharge-muted">
|
||||
最低充值 {formatYuan(config?.minRechargeCents ?? 500)},用于 AI 对话按量扣费
|
||||
</p>
|
||||
<div className="recharge-tier-grid">
|
||||
{tiers.map((tier) => (
|
||||
<button
|
||||
key={tier}
|
||||
type="button"
|
||||
className={`recharge-tier${selectedCents === tier ? ' recharge-tier-active' : ''}`}
|
||||
disabled={Boolean(order)}
|
||||
onClick={() => setSelectedCents(tier)}
|
||||
>
|
||||
{formatYuan(tier)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="recharge-projected">
|
||||
充值后预计余额:<strong>{formatYuan(projectedBalance)}</strong>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="recharge-error">{error}</p>}
|
||||
{successMessage && <p className="recharge-success">{successMessage}</p>}
|
||||
|
||||
{order?.payMode === 'native' && qrDataUrl && (
|
||||
<div className="recharge-qr-panel">
|
||||
<img src={qrDataUrl} alt="微信支付二维码" className="recharge-qr" />
|
||||
<p className="recharge-muted">
|
||||
{isWeChatBrowser()
|
||||
? '长按下方二维码,选择「识别图中二维码」完成支付'
|
||||
: '请使用微信扫一扫完成支付'}
|
||||
</p>
|
||||
{expireLabel && <p className="recharge-muted">订单将于 {expireLabel} 过期</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{order?.payMode === 'h5' && (
|
||||
<div className="recharge-h5-panel">
|
||||
<p className="recharge-muted">已跳转微信收银台;若未自动打开,请点击下方按钮。</p>
|
||||
{order.h5Url && (
|
||||
<a className="recharge-h5-link" href={order.h5Url}>
|
||||
打开微信支付
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="recharge-actions">
|
||||
{!order ? (
|
||||
<button
|
||||
type="button"
|
||||
className="recharge-pay-btn"
|
||||
disabled={paying || loadingConfig || !config?.wechatEnabled}
|
||||
onClick={() => void handlePay()}
|
||||
>
|
||||
{paying ? '创建订单中…' : '微信支付'}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="recharge-secondary-btn"
|
||||
disabled={polling}
|
||||
onClick={() => {
|
||||
resetPayState();
|
||||
}}
|
||||
>
|
||||
重新选择金额
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!config?.wechatEnabled && !loadingConfig && (
|
||||
<p className="recharge-warning">支付渠道尚未配置,请联系管理员开通。</p>
|
||||
)}
|
||||
|
||||
<p className="recharge-legal">支付完成后余额自动到账,如有疑问请联系客服。</p>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
SHARE_CHANNELS,
|
||||
canUseNativeShare,
|
||||
copyShareText,
|
||||
nativeShare,
|
||||
type ShareChannel,
|
||||
type SharePayload,
|
||||
} from '../utils/shareChannels';
|
||||
|
||||
export function ShareSheet({
|
||||
payload,
|
||||
onClose,
|
||||
}: {
|
||||
payload: SharePayload;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const handleChannel = async (channel: ShareChannel) => {
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const text = channel.buildText(payload);
|
||||
await copyShareText(text);
|
||||
setMessage(channel.hint);
|
||||
} catch {
|
||||
setMessage('复制失败,请手动复制链接');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNativeShare = async () => {
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await nativeShare(payload);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
setMessage('系统分享不可用,请选择其他平台');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="share-sheet-backdrop" role="presentation" onClick={onClose}>
|
||||
<section
|
||||
className="share-sheet-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="share-sheet-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="share-sheet-header">
|
||||
<div>
|
||||
<p className="share-sheet-eyebrow">SHARE</p>
|
||||
<h2 id="share-sheet-title">分享到</h2>
|
||||
<p className="share-sheet-subtitle">{payload.title}</p>
|
||||
</div>
|
||||
<button type="button" className="share-sheet-close" onClick={onClose} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="share-sheet-grid">
|
||||
{SHARE_CHANNELS.map((channel) => (
|
||||
<button
|
||||
key={channel.id}
|
||||
type="button"
|
||||
className={`share-sheet-option share-sheet-option-${channel.id}`}
|
||||
disabled={busy}
|
||||
onClick={() => void handleChannel(channel)}
|
||||
>
|
||||
<span className="share-sheet-option-icon" aria-hidden="true">
|
||||
{channel.label.slice(0, 1)}
|
||||
</span>
|
||||
<span className="share-sheet-option-label">{channel.label}</span>
|
||||
</button>
|
||||
))}
|
||||
{canUseNativeShare() && (
|
||||
<button
|
||||
type="button"
|
||||
className="share-sheet-option share-sheet-option-system"
|
||||
disabled={busy}
|
||||
onClick={() => void handleNativeShare()}
|
||||
>
|
||||
<span className="share-sheet-option-icon" aria-hidden="true">
|
||||
···
|
||||
</span>
|
||||
<span className="share-sheet-option-label">更多</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="share-sheet-link">
|
||||
<span className="muted">分享链接</span>
|
||||
<code>{payload.url}</code>
|
||||
</div>
|
||||
|
||||
{message && <p className="share-sheet-message">{message}</p>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
clearUserSkillOverrides,
|
||||
getRoleSkills,
|
||||
getUserSkills,
|
||||
listSkillCatalog,
|
||||
updateRoleSkills,
|
||||
updateUserSkills,
|
||||
} from '../api/client';
|
||||
import type { AdminUserRow, SkillDefinition, SkillMap } from '../types';
|
||||
|
||||
function SkillGrid({
|
||||
catalog,
|
||||
values,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
catalog: SkillDefinition[];
|
||||
values: SkillMap;
|
||||
disabled?: boolean;
|
||||
onChange: (name: string, enabled: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="capability-grid">
|
||||
<div className="capability-group">
|
||||
<h3>平台技能</h3>
|
||||
<ul>
|
||||
{catalog.map((item) => (
|
||||
<li key={item.name}>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(values[item.name])}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(item.name, e.target.checked)}
|
||||
/>
|
||||
<span className="capability-label">
|
||||
{item.label}
|
||||
<code className="skill-name-tag">{item.name}</code>
|
||||
{item.requiresPublish && (
|
||||
<span className="risk-pill risk-medium">含页面发布</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="muted capability-desc">{item.description}</span>
|
||||
</label>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SkillSettings({
|
||||
users,
|
||||
userId,
|
||||
userOnly = false,
|
||||
}: {
|
||||
users: AdminUserRow[];
|
||||
userId?: string;
|
||||
userOnly?: boolean;
|
||||
}) {
|
||||
const [catalog, setCatalog] = useState<SkillDefinition[]>([]);
|
||||
const [roleSkills, setRoleSkills] = useState<SkillMap>({});
|
||||
const [selectedUserId, setSelectedUserId] = useState(userId ?? '');
|
||||
const [userSkills, setUserSkills] = useState<SkillMap>({});
|
||||
const [userOverrides, setUserOverrides] = useState<SkillMap>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [catalogItems, roleState] = await Promise.all([
|
||||
listSkillCatalog(),
|
||||
getRoleSkills('user'),
|
||||
]);
|
||||
setCatalog(catalogItems);
|
||||
setRoleSkills(roleState.skills);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载技能目录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUserSkills = useCallback(async (userId: string) => {
|
||||
if (!userId) {
|
||||
setUserSkills({});
|
||||
setUserOverrides({});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const state = await getUserSkills(userId);
|
||||
setUserSkills(state.skills);
|
||||
setUserOverrides(state.overrides);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '加载用户技能失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUserSkills(selectedUserId);
|
||||
}, [loadUserSkills, selectedUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
setSelectedUserId(userId);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
const saveRoleDefaults = async () => {
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateRoleSkills('user', roleSkills);
|
||||
setRoleSkills(result.skills);
|
||||
setMessage('普通用户默认技能已保存,并已同步到各用户工作区');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const saveUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await updateUserSkills(selectedUserId, userSkills);
|
||||
setUserSkills(result.skills);
|
||||
await loadUserSkills(selectedUserId);
|
||||
setMessage('用户技能已保存,并已安装到其 MindSpace 发布目录');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
};
|
||||
|
||||
const resetUserOverrides = async () => {
|
||||
if (!selectedUserId) return;
|
||||
setMessage(null);
|
||||
setError(null);
|
||||
try {
|
||||
await clearUserSkillOverrides(selectedUserId);
|
||||
await loadUserSkills(selectedUserId);
|
||||
setMessage('已恢复为角色默认技能');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '重置失败');
|
||||
}
|
||||
};
|
||||
|
||||
const regularUsers = users.filter((user) => user.role === 'user');
|
||||
|
||||
return (
|
||||
<section className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<h2>技能配置</h2>
|
||||
<button type="button" className="ghost-btn" onClick={() => void load()}>
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted capability-intro">
|
||||
勾选后技能会安装到用户 <code>MindSpace/用户名/.agents/skills/</code>,对话中可通过 load_skill
|
||||
使用。开通「静态页面发布」技能会自动启用页面写入能力。
|
||||
</p>
|
||||
{error && <p className="banner banner-error">{error}</p>}
|
||||
{message && <p className="banner banner-info">{message}</p>}
|
||||
{loading ? (
|
||||
<p className="muted">加载中…</p>
|
||||
) : catalog.length === 0 ? (
|
||||
<p className="muted">暂无平台技能,请在 ui/h5/skills/ 下添加 SKILL.md</p>
|
||||
) : (
|
||||
<>
|
||||
{!userOnly && (
|
||||
<>
|
||||
<h3 className="capability-section-title">普通用户默认技能</h3>
|
||||
<SkillGrid
|
||||
catalog={catalog}
|
||||
values={roleSkills}
|
||||
onChange={(name, enabled) => setRoleSkills((prev) => ({ ...prev, [name]: enabled }))}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveRoleDefaults()}>
|
||||
保存默认技能
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!userOnly && <h3 className="capability-section-title">单用户技能覆盖</h3>}
|
||||
{!userOnly && (
|
||||
<div className="admin-form capability-user-picker">
|
||||
<select value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)}>
|
||||
<option value="">选择用户(可选)</option>
|
||||
{regularUsers.map((user) => (
|
||||
<option key={user.id} value={user.id}>
|
||||
{user.displayName} (@{user.username})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
{selectedUserId && (
|
||||
<>
|
||||
{Object.keys(userOverrides).length > 0 && (
|
||||
<p className="muted">
|
||||
已设置 {Object.keys(userOverrides).length} 项单独覆盖。
|
||||
</p>
|
||||
)}
|
||||
<SkillGrid
|
||||
catalog={catalog}
|
||||
values={userSkills}
|
||||
onChange={(name, enabled) =>
|
||||
setUserSkills((prev) => ({ ...prev, [name]: enabled }))
|
||||
}
|
||||
/>
|
||||
<div className="capability-actions">
|
||||
<button type="button" className="send-btn" onClick={() => void saveUserOverrides()}>
|
||||
保存用户技能
|
||||
</button>
|
||||
<button type="button" className="ghost-btn" onClick={() => void resetUserOverrides()}>
|
||||
恢复角色默认
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
|
||||
export function SpaceChatFab({
|
||||
open,
|
||||
streaming,
|
||||
pendingTool,
|
||||
onClick,
|
||||
}: {
|
||||
open: boolean;
|
||||
streaming: boolean;
|
||||
pendingTool: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`space-chat-fab${open ? ' is-open' : ''}${streaming ? ' is-streaming' : ''}${pendingTool ? ' has-pending-tool' : ''}`}
|
||||
aria-label={open ? '关闭 Agent 对话' : '打开 Agent 对话'}
|
||||
aria-expanded={open}
|
||||
onClick={onClick}
|
||||
>
|
||||
<TKMindAvatar size="sm" />
|
||||
{pendingTool && <span className="space-chat-fab-badge" aria-hidden="true" />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useChat } from '../context/ChatProvider';
|
||||
import type { MindSpaceChatContext, MindSpaceSaveCategory, PortalUser } from '../types';
|
||||
import { formatContextChip } from '../utils/mindspaceChatContext';
|
||||
import { ChatPanel } from './ChatPanel';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
|
||||
export function SpaceChatPanel({
|
||||
open,
|
||||
context,
|
||||
user,
|
||||
onClose,
|
||||
onOpenFullChat,
|
||||
onPageSaved,
|
||||
}: {
|
||||
open: boolean;
|
||||
context: MindSpaceChatContext;
|
||||
user: PortalUser;
|
||||
onClose: () => void;
|
||||
onOpenFullChat: () => void;
|
||||
onPageSaved?: (result: {
|
||||
kind: 'page' | 'category';
|
||||
pageId?: string;
|
||||
categoryCode?: MindSpaceSaveCategory;
|
||||
}) => void;
|
||||
}) {
|
||||
const {
|
||||
session,
|
||||
messages,
|
||||
chatState,
|
||||
pendingTool,
|
||||
submit,
|
||||
stop,
|
||||
approveTool,
|
||||
error,
|
||||
retryConnect,
|
||||
} = useChat();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const contextLabel = formatContextChip(context);
|
||||
|
||||
return (
|
||||
<div className="space-chat-panel" role="dialog" aria-modal="false" aria-label="Agent 对话">
|
||||
<header className="space-chat-panel-header">
|
||||
<div className="space-chat-panel-brand">
|
||||
<TKMindAvatar size="sm" />
|
||||
<div>
|
||||
<strong>TKMind</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>
|
||||
<button type="button" className="space-chat-panel-close" onClick={onClose} aria-label="关闭">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="space-chat-panel-error">
|
||||
<span>{error}</span>
|
||||
<button type="button" onClick={() => void retryConnect()}>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ChatPanel
|
||||
variant="compact"
|
||||
user={user}
|
||||
messages={messages}
|
||||
chatState={chatState}
|
||||
pendingTool={pendingTool}
|
||||
session={session}
|
||||
onSubmit={(text) => submit(text, { mindspaceContext: context })}
|
||||
onStop={stop}
|
||||
onApproveTool={approveTool}
|
||||
onPageSaved={onPageSaved}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import tkmindAvatar from '../assets/tkmind-avatar.png';
|
||||
|
||||
type TKMindAvatarProps = {
|
||||
className?: string;
|
||||
size?: 'sm' | 'md';
|
||||
};
|
||||
|
||||
export function TKMindAvatar({ className = '', size = 'sm' }: TKMindAvatarProps) {
|
||||
const sizeClass = size === 'md' ? 'tkmind-avatar-md' : 'tkmind-avatar-sm';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`tkmind-avatar ${sizeClass} msg-avatar msg-avatar-assistant ${className}`.trim()}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img src={tkmindAvatar} alt="TKMind" className="tkmind-avatar-img" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
type UserAvatarProps = {
|
||||
avatarUrl?: string | null;
|
||||
size?: 'sm' | 'md';
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
function DefaultUserAvatar() {
|
||||
return (
|
||||
<svg viewBox="0 0 40 40" fill="none" aria-hidden="true">
|
||||
<rect width="40" height="40" rx="6" fill="#c8c8c8" />
|
||||
<circle cx="20" cy="15" r="6.5" fill="#f5f5f5" />
|
||||
<path d="M9 31c2.5-5.5 7-8.5 11-8.5s8.5 3 11 8.5" fill="#f5f5f5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserAvatar({
|
||||
avatarUrl,
|
||||
size = 'sm',
|
||||
className = '',
|
||||
onClick,
|
||||
title,
|
||||
}: UserAvatarProps) {
|
||||
const sizeClass = size === 'md' ? 'user-avatar-md' : 'user-avatar-sm';
|
||||
const interactive = Boolean(onClick);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`user-avatar ${sizeClass} ${interactive ? 'user-avatar-interactive' : ''} ${className}`.trim()}
|
||||
role={interactive ? 'button' : undefined}
|
||||
tabIndex={interactive ? 0 : undefined}
|
||||
aria-label={interactive ? title ?? '更换头像' : undefined}
|
||||
title={title}
|
||||
onClick={onClick}
|
||||
onKeyDown={
|
||||
interactive
|
||||
? (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick?.();
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" className="user-avatar-img" />
|
||||
) : (
|
||||
<DefaultUserAvatar />
|
||||
)}
|
||||
{interactive && <span className="user-avatar-edit-badge" aria-hidden="true" />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { shouldShowVoiceInputControl, isVoiceInputAvailable } from '../voice/capabilities';
|
||||
import { VoiceInputDialog } from './VoiceInputDialog';
|
||||
|
||||
function MicIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 14a3 3 0 0 0 3-3V6a3 3 0 1 0-6 0v5a3 3 0 0 0 3 3Zm5-3a1 1 0 1 0-2 0 5 5 0 0 1-10 0 1 1 0 1 0-2 0 7 7 0 0 0 6 6.92V19H9a1 1 0 1 0 0 2h6a1 1 0 1 0 0-2h-2v-1.08A7 7 0 0 0 17 11Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function VoiceInputButton({
|
||||
disabled = false,
|
||||
onSend,
|
||||
onError,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onSend: (text: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sessionKey, setSessionKey] = useState(0);
|
||||
const [available, setAvailable] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
setAvailable(isVoiceInputAvailable());
|
||||
}, []);
|
||||
|
||||
if (!shouldShowVoiceInputControl()) return null;
|
||||
|
||||
const blocked = disabled || !available;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`voice-btn${open ? ' voice-btn-recording' : ''}`}
|
||||
disabled={blocked}
|
||||
aria-label="语音输入"
|
||||
title={available ? '语音输入' : '当前浏览器不支持麦克风,请改用文字输入'}
|
||||
onClick={() => {
|
||||
if (blocked) {
|
||||
if (!available) onError?.('当前浏览器不支持语音输入');
|
||||
return;
|
||||
}
|
||||
setSessionKey((value) => value + 1);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<MicIcon />
|
||||
</button>
|
||||
<VoiceInputDialog
|
||||
key={sessionKey}
|
||||
open={open}
|
||||
disabled={disabled}
|
||||
onClose={() => setOpen(false)}
|
||||
onSend={onSend}
|
||||
onError={onError}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useVoiceSession } from '../hooks/useVoiceSession';
|
||||
import { isVoiceInputAvailable } from '../voice/capabilities';
|
||||
import { VoiceWaveform } from './VoiceWaveform';
|
||||
|
||||
export function VoiceInputDialog({
|
||||
open,
|
||||
disabled = false,
|
||||
onClose,
|
||||
onSend,
|
||||
onError,
|
||||
}: {
|
||||
open: boolean;
|
||||
disabled?: boolean;
|
||||
onClose: () => void;
|
||||
onSend: (text: string) => void;
|
||||
onError?: (message: string) => void;
|
||||
}) {
|
||||
const { phase, text, analyser, liveRecognition, updateText, stopListening, finishFallbackRecording } =
|
||||
useVoiceSession({
|
||||
active: open && !disabled,
|
||||
onError,
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
stopListening();
|
||||
onClose();
|
||||
}, [onClose, stopListening]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') handleClose();
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open, handleClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const listening = phase === 'listening';
|
||||
const transcribing = phase === 'transcribing';
|
||||
const busy = phase === 'requesting' || transcribing;
|
||||
const canSend = Boolean(text.trim()) && !busy && !disabled;
|
||||
|
||||
const handleSend = () => {
|
||||
const value = text.trim();
|
||||
if (!value || disabled) return;
|
||||
stopListening();
|
||||
onSend(value);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const statusText =
|
||||
phase === 'requesting'
|
||||
? '正在准备麦克风…'
|
||||
: listening
|
||||
? liveRecognition
|
||||
? '正在聆听,文字会实时显示'
|
||||
: '正在录音,完成后点击「完成识别」'
|
||||
: transcribing
|
||||
? '正在识别…'
|
||||
: '可编辑识别结果后发送';
|
||||
|
||||
const supported = isVoiceInputAvailable();
|
||||
if (!supported) {
|
||||
return createPortal(
|
||||
<div className="voice-dialog-backdrop" role="presentation" onClick={handleClose}>
|
||||
<section className="voice-dialog" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
||||
<p className="voice-dialog-status">当前浏览器不支持语音输入,请改用文字发送。</p>
|
||||
<div className="voice-dialog-actions">
|
||||
<button type="button" className="ghost-btn" onClick={handleClose}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="voice-dialog-backdrop" role="presentation" onClick={handleClose}>
|
||||
<section
|
||||
className="voice-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="voice-dialog-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="voice-dialog-header">
|
||||
<div>
|
||||
<p className="voice-dialog-eyebrow">语音输入</p>
|
||||
<h3 id="voice-dialog-title">说出你想发送的内容</h3>
|
||||
</div>
|
||||
<button type="button" className="voice-dialog-close" onClick={handleClose} aria-label="关闭">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<VoiceWaveform analyser={analyser} active={listening || phase === 'requesting'} />
|
||||
|
||||
<p className="voice-dialog-status" role="status">
|
||||
{transcribing ? <span className="voice-btn-spinner" aria-hidden="true" /> : null}
|
||||
{statusText}
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
className="voice-dialog-input input"
|
||||
rows={4}
|
||||
placeholder={listening ? '识别文字会出现在这里…' : '输入或编辑要发送的内容…'}
|
||||
value={text}
|
||||
disabled={busy}
|
||||
onChange={(event) => updateText(event.target.value)}
|
||||
/>
|
||||
|
||||
<div className="voice-dialog-actions">
|
||||
<button type="button" className="ghost-btn" onClick={handleClose}>
|
||||
取消
|
||||
</button>
|
||||
{!liveRecognition && listening ? (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
disabled={transcribing}
|
||||
onClick={() => void finishFallbackRecording()}
|
||||
>
|
||||
完成识别
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="send-btn" disabled={!canSend} onClick={handleSend}>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
const BAR_COUNT = 24;
|
||||
|
||||
export function VoiceWaveform({
|
||||
analyser,
|
||||
active,
|
||||
}: {
|
||||
analyser: AnalyserNode | null;
|
||||
active: boolean;
|
||||
}) {
|
||||
const barsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!analyser || !active || !barsRef.current) return undefined;
|
||||
|
||||
const bars = Array.from(barsRef.current.children) as HTMLElement[];
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
let frame = 0;
|
||||
|
||||
const tick = () => {
|
||||
analyser.getByteFrequencyData(data);
|
||||
const step = Math.max(1, Math.floor(data.length / BAR_COUNT));
|
||||
for (let i = 0; i < BAR_COUNT; i += 1) {
|
||||
const sample = data[i * step] ?? 0;
|
||||
const height = Math.max(12, Math.round((sample / 255) * 100));
|
||||
bars[i]?.style.setProperty('--voice-bar-height', `${height}%`);
|
||||
}
|
||||
frame = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
tick();
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}, [analyser, active]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`voice-waveform${active ? ' voice-waveform-active' : ''}${active && !analyser ? ' voice-waveform-idle' : ''}`}
|
||||
aria-hidden="true"
|
||||
ref={barsRef}
|
||||
>
|
||||
{Array.from({ length: BAR_COUNT }, (_, index) => (
|
||||
<span key={index} className="voice-waveform-bar" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import type { MindSpaceAsset } from '../types';
|
||||
import { buildAssetPreviewFrameConfig } from '../utils/mindspaceCards';
|
||||
|
||||
export type WorkCoverMode = 'thumbnail-first' | 'preview-first' | 'preview-only';
|
||||
|
||||
export function WorkCover({
|
||||
thumbnailUrl,
|
||||
previewUrl,
|
||||
previewAsset,
|
||||
fallbackLabel,
|
||||
title,
|
||||
imageClassName = 'mindspace-item-cover-img',
|
||||
coverMode = 'thumbnail-first',
|
||||
previewFrameClassName = 'mindspace-preview-embed',
|
||||
}: {
|
||||
thumbnailUrl: string;
|
||||
previewUrl?: string;
|
||||
previewAsset?: Pick<MindSpaceAsset, 'id' | 'mimeType'>;
|
||||
fallbackLabel: string;
|
||||
title: string;
|
||||
imageClassName?: string;
|
||||
coverMode?: WorkCoverMode;
|
||||
previewFrameClassName?: string;
|
||||
}) {
|
||||
const [thumbFailed, setThumbFailed] = useState(false);
|
||||
const [previewFailed, setPreviewFailed] = useState(false);
|
||||
|
||||
const previewFrame =
|
||||
previewAsset != null
|
||||
? buildAssetPreviewFrameConfig(previewAsset)
|
||||
: previewUrl
|
||||
? { src: previewUrl, sandbox: 'allow-same-origin' as const }
|
||||
: null;
|
||||
|
||||
const showPreview =
|
||||
Boolean(previewFrame) &&
|
||||
!previewFailed &&
|
||||
(coverMode === 'preview-first' || coverMode === 'preview-only' || thumbFailed);
|
||||
|
||||
if (showPreview && previewFrame) {
|
||||
return (
|
||||
<div className={previewFrameClassName} aria-hidden="true">
|
||||
<iframe
|
||||
title={`${title} 预览`}
|
||||
src={previewFrame.src}
|
||||
{...(previewFrame.sandbox ? { sandbox: previewFrame.sandbox } : {})}
|
||||
loading="lazy"
|
||||
onError={() => setPreviewFailed(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (coverMode !== 'preview-only' && !thumbFailed) {
|
||||
return (
|
||||
<img
|
||||
className={imageClassName}
|
||||
src={thumbnailUrl}
|
||||
alt={`${title} 封面`}
|
||||
loading="lazy"
|
||||
onError={() => setThumbFailed(true)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (previewFrame && !previewFailed) {
|
||||
return (
|
||||
<div className={previewFrameClassName} aria-hidden="true">
|
||||
<iframe
|
||||
title={`${title} 预览`}
|
||||
src={previewFrame.src}
|
||||
{...(previewFrame.sandbox ? { sandbox: previewFrame.sandbox } : {})}
|
||||
loading="lazy"
|
||||
onError={() => setPreviewFailed(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{fallbackLabel}</span>;
|
||||
}
|
||||
Reference in New Issue
Block a user