feat: 添加swarms团队编排功能并优化agent委派系统

- 引入AgentTeamOrchestrator支持多agent协同任务执行
- 增加第三方swarms库依赖并配置git协议替换以改善包管理
- 扩展DelegationManager支持团队任务调度和进度跟踪
- 实现中文bigram分词算法提升中文任务检索准确性
- 调整A2AClient和DelegationManager超时时间从30秒增至600秒
- 优化AgentRunResult状态判断逻辑增加有意义摘要检测
- 修改Dockerfile配置npm仓库镜像地址和git协议映射
- 更新CLI命令行接口支持网关端口配置传递
- 调整提供者超时配置机制增强请求稳定性
- 移除过时的support_group字段简化agent描述符结构
- 增强错误处理和进度事件报告机制改进用户体验
This commit is contained in:
2026-04-14 14:34:23 +08:00
parent fee9007da6
commit cdfc222c9f
85 changed files with 5443 additions and 1392 deletions

View File

@ -54,6 +54,9 @@ import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import type { AppLocale } from '@/lib/i18n/core';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
type OutlookFormState = OutlookConnectionPayload;
type OutlookView = 'inbox' | 'sent' | 'calendar' | 'settings';
@ -88,11 +91,11 @@ function toFormState(status: OutlookStatus | null): OutlookFormState {
};
}
function formatDateTime(value?: string | null): string {
function formatDateTime(value?: string | null, locale: AppLocale = 'zh-CN'): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@ -115,19 +118,19 @@ function formatDateKey(value?: string | null): string | null {
return toLocalDateKey(date);
}
function formatDayLabel(value: Date): string {
return new Intl.DateTimeFormat('zh-CN', {
function formatDayLabel(value: Date, locale: AppLocale = 'zh-CN'): string {
return new Intl.DateTimeFormat(locale, {
month: '2-digit',
day: '2-digit',
weekday: 'short',
}).format(value);
}
function formatTime(value?: string | null): string {
function formatTime(value?: string | null, locale: AppLocale = 'zh-CN'): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', {
return new Intl.DateTimeFormat(locale, {
hour: '2-digit',
minute: '2-digit',
}).format(date);
@ -241,9 +244,9 @@ function sanitizeEmailHtml(html: string): string {
return documentRef.body.innerHTML;
}
function buildEmailPreviewDocument(html: string): string {
function buildEmailPreviewDocument(html: string, locale: AppLocale = 'zh-CN'): string {
return `<!DOCTYPE html>
<html lang="zh-CN">
<html lang="${locale}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@ -334,6 +337,8 @@ function renderPlainText(content: string): React.ReactNode[] {
}
export default function OutlookPage() {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const [status, setStatus] = useState<OutlookStatus | null>(null);
const [form, setForm] = useState<OutlookFormState>(EMPTY_FORM);
const [formDirty, setFormDirty] = useState(false);
@ -382,11 +387,11 @@ export default function OutlookPage() {
if (!preserveExisting) {
setOverview(null);
}
setError(err.message || '加载 Outlook 概览失败');
setError(err.message || t('加载 Outlook 概览失败', 'Failed to load the Outlook overview'));
} finally {
setOverviewLoading(false);
}
}, []);
}, [t]);
const loadMailboxPage = useCallback(async (view: OutlookMailboxView, skip = 0) => {
setMailboxLoading((current) => ({ ...current, [view]: true }));
@ -402,11 +407,17 @@ export default function OutlookPage() {
}
setError(null);
} catch (err: any) {
setError(err.message || `加载${view === 'inbox' ? '收件箱' : '发件箱'}失败`);
setError(
err.message
|| t(
`加载${view === 'inbox' ? '收件箱' : '发件箱'}失败`,
`Failed to load the ${view === 'inbox' ? 'inbox' : 'sent mailbox'}`
)
);
} finally {
setMailboxLoading((current) => ({ ...current, [view]: false }));
}
}, []);
}, [t]);
const loadCalendarPage = useCallback(async (anchorKey: string) => {
setCalendarLoading(true);
@ -420,11 +431,11 @@ export default function OutlookPage() {
setCalendarPage(nextPage);
setError(null);
} catch (err: any) {
setError(err.message || '加载日程失败');
setError(err.message || t('加载日程失败', 'Failed to load calendar events'));
} finally {
setCalendarLoading(false);
}
}, []);
}, [t]);
const loadStatus = useCallback(async (
background = false,
@ -452,7 +463,7 @@ export default function OutlookPage() {
setOverviewLoading(false);
}
} catch (err: any) {
setError(err.message || '加载 Outlook 集成状态失败');
setError(err.message || t('加载 Outlook 集成状态失败', 'Failed to load Outlook integration status'));
setOverviewLoading(false);
} finally {
if (background) {
@ -461,7 +472,7 @@ export default function OutlookPage() {
setStatusLoading(false);
}
}
}, [applyStatus, loadOverview]);
}, [applyStatus, loadOverview, t]);
useEffect(() => {
void loadStatus();
@ -483,7 +494,7 @@ export default function OutlookPage() {
})
.catch((err: any) => {
if (!cancelled) {
setError(err.message || '加载邮件详情失败');
setError(err.message || t('加载邮件详情失败', 'Failed to load message details'));
}
})
.finally(() => {
@ -495,7 +506,7 @@ export default function OutlookPage() {
return () => {
cancelled = true;
};
}, [selectedMessageRef]);
}, [selectedMessageRef, t]);
const canTest = useMemo(
() => Boolean(
@ -519,8 +530,8 @@ export default function OutlookPage() {
return [
{
id: 'settings' as const,
label: '设置',
hint: '配置 Outlook 连接',
label: t('设置', 'Settings'),
hint: t('配置 Outlook 连接', 'Configure the Outlook connection'),
icon: Settings2,
count: null,
},
@ -530,34 +541,34 @@ export default function OutlookPage() {
return [
{
id: 'inbox' as const,
label: '收件箱',
hint: '最近接收邮件',
label: t('收件箱', 'Inbox'),
hint: t('最近接收邮件', 'Recently received mail'),
icon: Inbox,
count: null,
},
{
id: 'sent' as const,
label: '发件箱',
hint: '最近发送记录',
label: t('发件箱', 'Sent'),
hint: t('最近发送记录', 'Recently sent messages'),
icon: Send,
count: null,
},
{
id: 'calendar' as const,
label: '日程',
hint: '未来 7 天',
label: t('日程', 'Calendar'),
hint: t('未来 7 天', 'Next 7 days'),
icon: CalendarDays,
count: overviewPending ? null : eventCount,
},
{
id: 'settings' as const,
label: '设置',
hint: '连接与状态',
label: t('设置', 'Settings'),
hint: t('连接与状态', 'Connection and status'),
icon: Settings2,
count: null,
},
];
}, [eventCount, inboxCount, isConfigured, overviewPending, sentCount]);
}, [eventCount, inboxCount, isConfigured, overviewPending, sentCount, t]);
useEffect(() => {
if (!availableViews.some((view) => view.id === activeView)) {
@ -604,7 +615,7 @@ export default function OutlookPage() {
const result = await testOutlookConnection(form);
setTestResult(result);
} catch (err: any) {
setError(err.message || '测试连接失败');
setError(err.message || t('测试连接失败', 'Failed to test the connection'));
setTestResult(null);
} finally {
setTesting(false);
@ -626,7 +637,7 @@ export default function OutlookPage() {
await loadStatus(true, { forceFormSync: true });
setActiveView('inbox');
} catch (err: any) {
setError(err.message || '保存 Outlook 配置失败');
setError(err.message || t('保存 Outlook 配置失败', 'Failed to save Outlook settings'));
} finally {
setSaving(false);
}
@ -649,7 +660,7 @@ export default function OutlookPage() {
setFormDirty(false);
await loadStatus(true, { forceFormSync: true });
} catch (err: any) {
setError(err.message || '断开 Outlook 连接失败');
setError(err.message || t('断开 Outlook 连接失败', 'Failed to disconnect Outlook'));
} finally {
setDisconnecting(false);
}
@ -688,16 +699,16 @@ export default function OutlookPage() {
) : (
<>
<Badge variant={statusVariant(isConnected)}>
{isConnected ? '已连通' : isConfigured ? '已配置' : '未配置'}
{isConnected ? t('已连通', 'Connected') : isConfigured ? t('已配置', 'Configured') : t('未配置', 'Not configured')}
</Badge>
<Badge variant={status?.mcp_registered ? 'default' : 'secondary'}>
{status?.mcp_registered ? 'MCP 已注册' : 'MCP 未注册'}
{status?.mcp_registered ? t('MCP 已注册', 'MCP registered') : t('MCP 未注册', 'MCP not registered')}
</Badge>
<Badge variant="secondary">{status?.provider || 'ews'}</Badge>
<span className="text-muted-foreground"> {overview?.mailbox || status?.saved?.email || '-'}</span>
<span className="text-muted-foreground"> {status?.saved?.default_timezone || overview?.timezone || form.default_timezone}</span>
<span className="text-muted-foreground">{t('邮箱', 'Mailbox')} {overview?.mailbox || status?.saved?.email || '-'}</span>
<span className="text-muted-foreground">{t('时区', 'Timezone')} {status?.saved?.default_timezone || overview?.timezone || form.default_timezone}</span>
<span className="text-muted-foreground">
{formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined)}
{t('最近刷新', 'Last refreshed')} {formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined, locale)}
</span>
</>
)}
@ -706,14 +717,14 @@ export default function OutlookPage() {
<div className="flex flex-wrap items-center gap-2">
{isConfigured ? (
<>
<TopStat label="收件箱" value={String(inboxCount)} loading={overviewPending} />
<TopStat label="发件箱" value={String(sentCount)} loading={overviewPending} />
<TopStat label="日程" value={String(eventCount)} loading={overviewPending} />
<TopStat label={t('收件箱', 'Inbox')} value={String(inboxCount)} loading={overviewPending} />
<TopStat label={t('发件箱', 'Sent')} value={String(sentCount)} loading={overviewPending} />
<TopStat label={t('日程', 'Calendar')} value={String(eventCount)} loading={overviewPending} />
</>
) : null}
<Button variant="outline" size="sm" onClick={() => void refreshOverview()}>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
{t('刷新', 'Refresh')}
</Button>
</div>
</div>
@ -765,7 +776,7 @@ export default function OutlookPage() {
<div className="text-left">
<p className="text-sm font-semibold">{view.label}</p>
{typeof view.count === 'number' ? (
<p className="text-xs text-muted-foreground">{view.count} </p>
<p className="text-xs text-muted-foreground">{t(`${view.count}`, `${view.count} items`)}</p>
) : null}
</div>
</div>
@ -777,12 +788,13 @@ export default function OutlookPage() {
<TabsContent value="inbox" className="mt-0">
<MessageCard
title="收件箱"
title={t('收件箱', 'Inbox')}
icon={<MailOpen className="h-4 w-4" />}
items={inboxPage?.value || []}
page={inboxPage?.page || null}
loading={mailboxLoading.inbox || (activeView === 'inbox' && !inboxPage)}
emptyLabel="还没有读取到收件箱邮件"
emptyLabel={t('还没有读取到收件箱邮件', 'No inbox messages have been loaded yet')}
locale={locale}
onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)}
onRefresh={() => void loadMailboxPage('inbox', inboxPage?.page.skip ?? 0)}
refreshing={mailboxLoading.inbox}
@ -798,12 +810,13 @@ export default function OutlookPage() {
<TabsContent value="sent" className="mt-0">
<MessageCard
title="发件箱"
title={t('发件箱', 'Sent')}
icon={<Send className="h-4 w-4" />}
items={sentPage?.value || []}
page={sentPage?.page || null}
loading={mailboxLoading.sent || (activeView === 'sent' && !sentPage)}
emptyLabel="还没有读取到已发送邮件"
emptyLabel={t('还没有读取到已发送邮件', 'No sent messages have been loaded yet')}
locale={locale}
onOpen={(item) => setSelectedMessageRef(item.id ? { id: item.id, changekey: item.changekey } : null)}
onRefresh={() => void loadMailboxPage('sent', sentPage?.page.skip ?? 0)}
refreshing={mailboxLoading.sent}
@ -822,6 +835,7 @@ export default function OutlookPage() {
items={calendarPage?.value || []}
startDate={calendarAnchorKey}
loading={calendarLoading || (activeView === 'calendar' && !calendarPage)}
locale={locale}
onOpen={(item) => setSelectedEvent(item)}
onRefresh={() => void loadCalendarPage(calendarAnchorKey)}
refreshing={calendarLoading}
@ -849,33 +863,33 @@ export default function OutlookPage() {
<div className="grid gap-6 xl:grid-cols-[1.08fr,0.92fr]">
<Card className="rounded-[28px] shadow-sm">
<CardHeader className="border-b pb-5">
<CardTitle className="text-xl text-foreground"></CardTitle>
<CardTitle className="text-xl text-foreground">{t('连接设置', 'Connection settings')}</CardTitle>
</CardHeader>
<CardContent className="space-y-5 pt-6">
<div className="grid gap-4 md:grid-cols-2">
<Field label="邮箱地址" required>
<Field label={t('邮箱地址', 'Email address')} required>
<Input
value={form.email}
onChange={(event) => updateField('email', event.target.value)}
placeholder="you@boardware.com"
/>
</Field>
<Field label="用户名">
<Field label={t('用户名', 'Username')}>
<Input
value={form.username}
onChange={(event) => updateField('username', event.target.value)}
placeholder="留空时默认取邮箱前缀"
placeholder={t('留空时默认取邮箱前缀', 'Leave blank to default to the email prefix')}
/>
</Field>
<Field label="密码" required>
<Field label={t('密码', 'Password')} required>
<Input
type="password"
value={form.password}
onChange={(event) => updateField('password', event.target.value)}
placeholder="请输入邮箱密码"
placeholder={t('请输入邮箱密码', 'Enter the mailbox password')}
/>
</Field>
<Field label="域">
<Field label={t('域', 'Domain')}>
<Input
value={form.domain}
onChange={(event) => updateField('domain', event.target.value)}
@ -898,7 +912,7 @@ export default function OutlookPage() {
disabled={form.autodiscover}
/>
</Field>
<Field label="时区">
<Field label={t('时区', 'Timezone')}>
<Input
value={form.default_timezone}
onChange={(event) => updateField('default_timezone', event.target.value)}
@ -912,7 +926,7 @@ export default function OutlookPage() {
Autodiscover
</Label>
<p className="mt-1 text-xs text-muted-foreground">
使 Exchange EWS URL
{t('开启后优先使用 Exchange 自动发现,不再强依赖手填 EWS URL。', 'When enabled, Exchange autodiscover is preferred so the EWS URL does not need to be entered manually.')}
</p>
</div>
<Switch
@ -927,11 +941,11 @@ export default function OutlookPage() {
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={handleTest} disabled={!canTest || testing}>
{testing ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{t('测试连接', 'Test connection')}
</Button>
<Button onClick={handleConnect} disabled={!canTest || saving}>
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />}
{t('保存并启用', 'Save and enable')}
</Button>
<Button
variant="outline"
@ -939,21 +953,21 @@ export default function OutlookPage() {
disabled={!status?.configured || disconnecting}
>
{disconnecting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Unplug className="mr-2 h-4 w-4" />}
{t('断开连接', 'Disconnect')}
</Button>
</div>
{testResult && (
<div className="rounded-3xl border bg-muted/30 p-4 text-sm">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="default"></Badge>
<Badge variant="default">{t('测试成功', 'Test succeeded')}</Badge>
<span className="text-muted-foreground">{testResult.mailbox}</span>
<span className="text-muted-foreground">: {testResult.resolved_username}</span>
<span className="text-muted-foreground">{t('用户名', 'Username')}: {testResult.resolved_username}</span>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<MiniStat label="检测到文件夹" value={String(testResult.sample.folders.length)} />
<MiniStat label="收件箱样本" value={String(testResult.sample.inbox.length)} />
<MiniStat label="日程样本" value={String(testResult.sample.events.length)} />
<MiniStat label={t('检测到文件夹', 'Detected folders')} value={String(testResult.sample.folders.length)} />
<MiniStat label={t('收件箱样本', 'Inbox samples')} value={String(testResult.sample.inbox.length)} />
<MiniStat label={t('日程样本', 'Calendar samples')} value={String(testResult.sample.events.length)} />
</div>
{testWarnings.length > 0 && (
<div className="mt-4 space-y-2 rounded-2xl border border-amber-300 bg-amber-50/80 p-3 text-amber-900">
@ -972,7 +986,7 @@ export default function OutlookPage() {
<Card className="rounded-[28px] shadow-sm">
<CardHeader className="border-b pb-5">
<CardTitle className="text-xl text-foreground"></CardTitle>
<CardTitle className="text-xl text-foreground">{t('连接状态', 'Connection status')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4 pt-6">
<div className="flex flex-wrap gap-2">
@ -985,27 +999,27 @@ export default function OutlookPage() {
) : (
<>
<Badge variant={statusVariant(isConnected)}>
{isConnected ? '已连通' : isConfigured ? '已配置' : '未配置'}
{isConnected ? t('已连通', 'Connected') : isConfigured ? t('已配置', 'Configured') : t('未配置', 'Not configured')}
</Badge>
<Badge variant={status?.mcp_registered ? 'default' : 'secondary'}>
{status?.mcp_registered ? 'MCP 已注册' : 'MCP 未注册'}
{status?.mcp_registered ? t('MCP 已注册', 'MCP registered') : t('MCP 未注册', 'MCP not registered')}
</Badge>
<Badge variant="secondary">{status?.provider || 'ews'}</Badge>
</>
)}
</div>
<InfoRow label="邮箱" value={status?.saved?.email || '-'} loading={statusPending} />
<InfoRow label="用户名" value={status?.saved?.username || '-'} loading={statusPending} />
<InfoRow label="域" value={status?.saved?.domain || '-'} loading={statusPending} />
<InfoRow label={t('邮箱', 'Email')} value={status?.saved?.email || '-'} loading={statusPending} />
<InfoRow label={t('用户名', 'Username')} value={status?.saved?.username || '-'} loading={statusPending} />
<InfoRow label={t('域', 'Domain')} value={status?.saved?.domain || '-'} loading={statusPending} />
<InfoRow label="EWS URL" value={status?.saved?.service_endpoint || '-'} loading={statusPending} />
<InfoRow label="Server Host" value={status?.saved?.server || '-'} loading={statusPending} />
<InfoRow label="时区" value={status?.saved?.default_timezone || status?.defaults.fields.default_timezone || '-'} loading={statusPending} />
<InfoRow label="最近验证" value={formatDateTime(status?.meta?.last_verified_at as string | undefined)} loading={statusPending} />
<InfoRow label="最近接入" value={formatDateTime(status?.meta?.last_connected_at as string | undefined)} loading={statusPending} />
<InfoRow label={t('时区', 'Timezone')} value={status?.saved?.default_timezone || status?.defaults.fields.default_timezone || '-'} loading={statusPending} />
<InfoRow label={t('最近验证', 'Last verified')} value={formatDateTime(status?.meta?.last_verified_at as string | undefined, locale)} loading={statusPending} />
<InfoRow label={t('最近接入', 'Last connected')} value={formatDateTime(status?.meta?.last_connected_at as string | undefined, locale)} loading={statusPending} />
<InfoRow
label="最近刷新"
value={formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined)}
label={t('最近刷新', 'Last refreshed')}
value={formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined, locale)}
loading={statusPending || overviewPending}
/>
@ -1017,12 +1031,15 @@ export default function OutlookPage() {
<div className="rounded-3xl border bg-muted/30 p-4">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
{t('当前存储位置', 'Current storage mode')}
</p>
<p className="mt-2 text-sm font-medium text-foreground">
{status?.storage_mode === 'authz'
? '当前为 AuthZ 模式。Outlook 凭据保存在 AuthZ Service由外置 Outlook MCP 按 backend 身份读取。'
: <> workspace Outlook workspace <code>state/bw_outlook_mcp</code></>}
? t(
'当前为 AuthZ 模式。Outlook 凭据保存在 AuthZ Service由外置 Outlook MCP 按 backend 身份读取。',
'AuthZ mode is active. Outlook credentials are stored in the AuthZ service and read by the external Outlook MCP using the backend identity.'
)
: <>{t('当前为 workspace 模式。Outlook 状态文件会写入当前 workspace 的', 'Workspace mode is active. Outlook state files are written to')} <code>state/bw_outlook_mcp</code>.</>}
</p>
</div>
</CardContent>
@ -1034,9 +1051,9 @@ export default function OutlookPage() {
<Dialog open={Boolean(selectedMessageRef)} onOpenChange={(open) => !open && setSelectedMessageRef(null)}>
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>{selectedMessage?.subject || '邮件详情'}</DialogTitle>
<DialogTitle>{selectedMessage?.subject || t('邮件详情', 'Message details')}</DialogTitle>
<DialogDescription>
{selectedMessage?.receivedDateTime ? formatDateTime(selectedMessage.receivedDateTime) : '正在加载'}
{selectedMessage?.receivedDateTime ? formatDateTime(selectedMessage.receivedDateTime, locale) : t('正在加载', 'Loading')}
</DialogDescription>
</DialogHeader>
{messageLoading ? (
@ -1046,32 +1063,33 @@ export default function OutlookPage() {
) : selectedMessage ? (
<div className="grid gap-4 lg:grid-cols-[280px,1fr]">
<div className="space-y-4 rounded-2xl border bg-muted/20 p-4 text-sm">
<InfoRow label="发件人" value={mailboxLabel(selectedMessage.from)} />
<InfoRow label={t('发件人', 'From')} value={mailboxLabel(selectedMessage.from)} />
<InfoRow
label="收件人"
value={(selectedMessage.toRecipients || []).map(mailboxLabel).filter(Boolean).join('') || '-'}
label={t('收件人', 'To')}
value={(selectedMessage.toRecipients || []).map(mailboxLabel).filter(Boolean).join(locale === 'en-US' ? '; ' : '') || '-'}
/>
<InfoRow
label="抄送"
value={(selectedMessage.ccRecipients || []).map(mailboxLabel).filter(Boolean).join('') || '-'}
label={t('抄送', 'Cc')}
value={(selectedMessage.ccRecipients || []).map(mailboxLabel).filter(Boolean).join(locale === 'en-US' ? '; ' : '') || '-'}
/>
<InfoRow label="接收时间" value={formatDateTime(selectedMessage.receivedDateTime)} />
<InfoRow label={t('接收时间', 'Received at')} value={formatDateTime(selectedMessage.receivedDateTime, locale)} />
<div className="flex flex-wrap gap-2">
<Badge variant={selectedMessage.isRead ? 'secondary' : 'default'}>
{selectedMessage.isRead ? '已读' : '未读'}
{selectedMessage.isRead ? t('已读', 'Read') : t('未读', 'Unread')}
</Badge>
</div>
</div>
<div className="overflow-hidden rounded-2xl border bg-background">
<div className="border-b px-4 py-3 text-xs font-medium uppercase tracking-[0.18em] text-muted-foreground">
{t('正文', 'Body')}
</div>
{selectedMessage.body?.contentType?.toLowerCase() === 'html' ? (
<iframe
title="邮件正文"
title={t('邮件正文', 'Message body')}
srcDoc={buildEmailPreviewDocument(
selectedMessage.body?.content || selectedMessage.bodyPreview || ''
selectedMessage.body?.content || selectedMessage.bodyPreview || '',
locale
)}
className="h-[60vh] w-full bg-white"
sandbox="allow-popups allow-popups-to-escape-sandbox"
@ -1086,7 +1104,7 @@ export default function OutlookPage() {
</div>
</div>
) : (
<p className="text-sm text-muted-foreground"></p>
<p className="text-sm text-muted-foreground">{t('未加载到邮件详情。', 'Message details were not loaded.')}</p>
)}
</DialogContent>
</Dialog>
@ -1094,26 +1112,26 @@ export default function OutlookPage() {
<Dialog open={Boolean(selectedEvent)} onOpenChange={(open) => !open && setSelectedEvent(null)}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{selectedEvent?.subject || '日程详情'}</DialogTitle>
<DialogTitle>{selectedEvent?.subject || t('日程详情', 'Event details')}</DialogTitle>
<DialogDescription>
{selectedEvent
? `${formatDateTime(selectedEvent.start?.dateTime)} - ${formatDateTime(selectedEvent.end?.dateTime)}`
: '日程详情'}
? `${formatDateTime(selectedEvent.start?.dateTime, locale)} - ${formatDateTime(selectedEvent.end?.dateTime, locale)}`
: t('日程详情', 'Event details')}
</DialogDescription>
</DialogHeader>
{selectedEvent && (
<div className="space-y-4 text-sm">
<InfoRow label="组织者" value={mailboxLabel(selectedEvent.organizer)} />
<InfoRow label="地点" value={selectedEvent.location?.displayName || '-'} />
<InfoRow label={t('组织者', 'Organizer')} value={mailboxLabel(selectedEvent.organizer)} />
<InfoRow label={t('地点', 'Location')} value={selectedEvent.location?.displayName || '-'} />
<InfoRow
label="参会人"
value={(selectedEvent.attendees || []).map(mailboxLabel).filter(Boolean).join('') || '-'}
label={t('参会人', 'Attendees')}
value={(selectedEvent.attendees || []).map(mailboxLabel).filter(Boolean).join(locale === 'en-US' ? '; ' : '') || '-'}
/>
<Separator />
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p>
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{t('说明', 'Notes')}</p>
<div className="rounded-lg border bg-muted/40 p-3 whitespace-pre-wrap">
{selectedEvent.bodyPreview || '没有更多说明。'}
{selectedEvent.bodyPreview || t('没有更多说明。', 'No additional notes.')}
</div>
</div>
</div>
@ -1185,6 +1203,7 @@ function MessageCard({
icon,
items,
page,
locale,
loading = false,
emptyLabel,
onOpen,
@ -1197,6 +1216,7 @@ function MessageCard({
icon: React.ReactNode;
items: OutlookMessageSummary[];
page: OutlookPageInfo | null;
locale: AppLocale;
loading?: boolean;
emptyLabel: string;
onOpen: (item: OutlookMessageSummary) => void;
@ -1205,8 +1225,9 @@ function MessageCard({
onPreviousPage: () => void;
onNextPage: () => void;
}) {
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const currentPage = page ? Math.floor(page.skip / Math.max(page.top, 1)) + 1 : 1;
const pageLabel = page ? `${currentPage} 页 · 本页 ${page.returned}` : '正在读取邮件…';
const pageLabel = page ? t(`${currentPage} 页 · 本页 ${page.returned}`, `Page ${currentPage} · ${page.returned} messages`) : t('正在读取邮件…', 'Loading messages...');
return (
<Card className="rounded-[28px] shadow-sm">
@ -1216,14 +1237,14 @@ function MessageCard({
{icon}
{title}
</CardTitle>
<p className="text-sm text-muted-foreground">{loading ? '正在读取邮件…' : pageLabel}</p>
<p className="text-sm text-muted-foreground">{loading ? t('正在读取邮件…', 'Loading messages...') : pageLabel}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
<Button variant="outline" size="sm" onClick={onPreviousPage} disabled={!page || page.skip === 0 || refreshing}>
{t('上一页', 'Previous')}
</Button>
<Button
variant="outline"
@ -1231,7 +1252,7 @@ function MessageCard({
onClick={onNextPage}
disabled={!page || !page.has_more || refreshing}
>
{t('下一页', 'Next')}
</Button>
</div>
</CardHeader>
@ -1262,17 +1283,17 @@ function MessageCard({
>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-foreground">{item.subject || '(无主题)'}</p>
<p className="truncate font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
<p className="mt-1 truncate text-xs text-muted-foreground">{mailboxLabel(item.from)}</p>
<p className="mt-3 line-clamp-2 text-sm leading-6 text-muted-foreground">
{item.bodyPreview || '没有预览内容。'}
{item.bodyPreview || t('没有预览内容。', 'No preview available.')}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 lg:flex-col lg:items-end">
<Badge variant={item.isRead ? 'secondary' : 'default'}>
{item.isRead ? '已读' : '未读'}
{item.isRead ? t('已读', 'Read') : t('未读', 'Unread')}
</Badge>
<span className="text-xs text-muted-foreground">{formatDateTime(item.receivedDateTime)}</span>
<span className="text-xs text-muted-foreground">{formatDateTime(item.receivedDateTime, locale)}</span>
</div>
</div>
</button>
@ -1287,6 +1308,7 @@ function MessageCard({
function EventCard({
items,
startDate,
locale,
loading = false,
onOpen,
onRefresh,
@ -1297,6 +1319,7 @@ function EventCard({
}: {
items: OutlookEventSummary[];
startDate?: string | null;
locale: AppLocale;
loading?: boolean;
onOpen: (item: OutlookEventSummary) => void;
onRefresh: () => void;
@ -1305,6 +1328,7 @@ function EventCard({
onNextWeek: () => void;
onCurrentWeek: () => void;
}) {
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const initialAnchor = startDate ? new Date(startDate) : new Date();
const anchor = Number.isNaN(initialAnchor.getTime()) ? new Date() : initialAnchor;
const weekDays = Array.from({ length: 7 }, (_, index) => {
@ -1316,7 +1340,7 @@ function EventCard({
const key = toLocalDateKey(day);
return {
key,
label: formatDayLabel(day),
label: formatDayLabel(day, locale),
items: items
.filter((item) => formatDateKey(item.start?.dateTime) === key)
.sort((left, right) => {
@ -1333,21 +1357,21 @@ function EventCard({
<div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
<CalendarDays className="h-4 w-4" />
{t('日程安排', 'Schedule')}
</CardTitle>
<p className="text-sm text-muted-foreground">
{formatDayLabel(weekDays[0])} - {formatDayLabel(weekDays[weekDays.length - 1])}
{formatDayLabel(weekDays[0], locale)} - {formatDayLabel(weekDays[weekDays.length - 1], locale)}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onPreviousWeek} disabled={refreshing}>
{t('上一周', 'Previous week')}
</Button>
<Button variant="outline" size="sm" onClick={onCurrentWeek} disabled={refreshing}>
{t('本周', 'This week')}
</Button>
<Button variant="outline" size="sm" onClick={onNextWeek} disabled={refreshing}>
{t('下一周', 'Next week')}
</Button>
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
@ -1372,11 +1396,11 @@ function EventCard({
<div className="flex items-center justify-between gap-3">
<div>
<p className="font-medium text-foreground">{day.label}</p>
<p className="text-xs text-muted-foreground">{day.items.length} </p>
<p className="text-xs text-muted-foreground">{t(`${day.items.length} 条安排`, `${day.items.length} events`)}</p>
</div>
</div>
{day.items.length === 0 ? (
<p className="mt-4 text-sm text-muted-foreground"></p>
<p className="mt-4 text-sm text-muted-foreground">{t('暂无安排', 'No events')}</p>
) : (
<div className="mt-4 space-y-3">
{day.items.map((item) => (
@ -1386,12 +1410,12 @@ function EventCard({
onClick={() => onOpen(item)}
className="w-full rounded-xl border bg-background p-3 text-left transition-colors hover:bg-muted/40"
>
<p className="font-medium text-foreground">{item.subject || '(无主题)'}</p>
<p className="font-medium text-foreground">{item.subject || t('(无主题)', '(No subject)')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{formatTime(item.start?.dateTime)} - {formatTime(item.end?.dateTime)}
{formatTime(item.start?.dateTime, locale)} - {formatTime(item.end?.dateTime, locale)}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{item.location?.displayName || '未设置地点'}
{item.location?.displayName || t('未设置地点', 'No location set')}
</p>
</button>
))}