Files
beaver_project/app-instance/frontend/app/(app)/outlook/page.tsx
steven_li cdfc222c9f feat: 添加swarms团队编排功能并优化agent委派系统
- 引入AgentTeamOrchestrator支持多agent协同任务执行
- 增加第三方swarms库依赖并配置git协议替换以改善包管理
- 扩展DelegationManager支持团队任务调度和进度跟踪
- 实现中文bigram分词算法提升中文任务检索准确性
- 调整A2AClient和DelegationManager超时时间从30秒增至600秒
- 优化AgentRunResult状态判断逻辑增加有意义摘要检测
- 修改Dockerfile配置npm仓库镜像地址和git协议映射
- 更新CLI命令行接口支持网关端口配置传递
- 调整提供者超时配置机制增强请求稳定性
- 移除过时的support_group字段简化agent描述符结构
- 增强错误处理和进度事件报告机制改进用户体验
2026-04-14 14:34:23 +08:00

1432 lines
55 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
AlertCircle,
CalendarDays,
CheckCircle2,
Inbox,
Loader2,
Mail,
MailOpen,
RefreshCw,
Save,
Send,
Settings2,
Unplug,
} from 'lucide-react';
import {
connectOutlook,
disconnectOutlook,
getOutlookEvents,
getOutlookMessageDetail,
getOutlookMessages,
getOutlookOverview,
getOutlookStatus,
testOutlookConnection,
} from '@/lib/api';
import type {
OutlookConnectionPayload,
OutlookConnectionTestResult,
OutlookEventListResponse,
OutlookEventSummary,
OutlookMessageDetail,
OutlookMessageListResponse,
OutlookMessageSummary,
OutlookPageInfo,
OutlookStatus,
} from '@/types';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Skeleton } from '@/components/ui/skeleton';
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';
type OutlookMailboxView = 'inbox' | 'sent';
const MAILBOX_PAGE_SIZE = 20;
const CALENDAR_PAGE_SIZE = 100;
const EMPTY_FORM: OutlookFormState = {
email: '',
password: '',
username: '',
domain: '',
service_endpoint: '',
server: '',
autodiscover: false,
default_timezone: 'Asia/Shanghai',
};
function toFormState(status: OutlookStatus | null): OutlookFormState {
const defaults = status?.defaults.fields;
const saved = status?.saved;
return {
email: saved?.email || '',
password: '',
username: saved?.username || '',
domain: saved?.domain || defaults?.domain || '',
service_endpoint: saved?.service_endpoint || defaults?.service_endpoint || '',
server: saved?.server || defaults?.server || '',
autodiscover: saved?.autodiscover ?? defaults?.autodiscover ?? false,
default_timezone: saved?.default_timezone || defaults?.default_timezone || 'Asia/Shanghai',
};
}
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(locale, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
function toLocalDateKey(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function formatDateKey(value?: string | null): string | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return toLocalDateKey(date);
}
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, locale: AppLocale = 'zh-CN'): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(locale, {
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
function buildCalendarRange(anchorKey: string): { startTime: string; endTime: string } {
const anchor = new Date(`${anchorKey}T00:00:00`);
const start = Number.isNaN(anchor.getTime()) ? new Date() : anchor;
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 7);
return {
startTime: start.toISOString(),
endTime: end.toISOString(),
};
}
function mailboxLabel(
mailbox?:
| {
emailAddress?: {
name?: string | null;
address?: string | null;
} | null;
}
| null
): string {
const name = mailbox?.emailAddress?.name?.trim();
const address = mailbox?.emailAddress?.address?.trim();
if (name && address) return `${name} <${address}>`;
return address || name || '-';
}
function statusVariant(connected: boolean): 'default' | 'secondary' {
return connected ? 'default' : 'secondary';
}
function sanitizeEmailHtml(html: string): string {
if (typeof window === 'undefined') return html;
const parser = new DOMParser();
const documentRef = parser.parseFromString(html, 'text/html');
const blockedSelectors = [
'script',
'style',
'iframe',
'object',
'embed',
'form',
'input',
'button',
'textarea',
'select',
'meta',
'link',
'base',
'img',
];
const removableFormattingAttrs = new Set([
'style',
'class',
'id',
'bgcolor',
'color',
'face',
'width',
'height',
'cellpadding',
'cellspacing',
'align',
]);
for (const selector of blockedSelectors) {
documentRef.querySelectorAll(selector).forEach((node) => node.remove());
}
documentRef.querySelectorAll('*').forEach((element) => {
for (const attr of Array.from(element.attributes)) {
const name = attr.name.toLowerCase();
const value = attr.value.trim();
if (removableFormattingAttrs.has(name)) {
element.removeAttribute(attr.name);
continue;
}
if (name.startsWith('on')) {
element.removeAttribute(attr.name);
continue;
}
if (name === 'srcdoc') {
element.removeAttribute(attr.name);
continue;
}
if ((name === 'href' || name === 'src') && value) {
const normalized = value.toLowerCase();
if (
normalized.startsWith('javascript:')
|| normalized.startsWith('data:')
|| normalized.startsWith('file:')
) {
element.removeAttribute(attr.name);
}
}
}
if (element.tagName.toLowerCase() === 'a') {
element.setAttribute('target', '_blank');
element.setAttribute('rel', 'noreferrer noopener');
}
});
return documentRef.body.innerHTML;
}
function buildEmailPreviewDocument(html: string, locale: AppLocale = 'zh-CN'): string {
return `<!DOCTYPE html>
<html lang="${locale}">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
:root {
color-scheme: light;
}
* {
box-sizing: border-box;
max-width: 100%;
word-break: break-word;
}
body {
margin: 0;
padding: 24px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 14px;
line-height: 1.75;
color: #0f172a;
background: #ffffff;
}
p, ul, ol, table, blockquote, pre {
margin: 0 0 1em;
}
table {
width: 100% !important;
border-collapse: collapse;
}
td, th {
border: 1px solid #e2e8f0;
padding: 8px 10px;
vertical-align: top;
}
a {
color: #2563eb;
text-decoration: underline;
}
blockquote {
margin-left: 0;
padding-left: 12px;
border-left: 3px solid #cbd5e1;
color: #475569;
}
pre {
white-space: pre-wrap;
border-radius: 12px;
background: #f8fafc;
padding: 12px;
}
</style>
</head>
<body>${sanitizeEmailHtml(html)}</body>
</html>`;
}
function renderPlainText(content: string): React.ReactNode[] {
const urlPattern = /(https?:\/\/[^\s<]+|mailto:[^\s<]+)/gi;
const lines = content.split(/\r?\n/);
const nodes: React.ReactNode[] = [];
lines.forEach((line, lineIndex) => {
const parts = line.split(urlPattern);
parts.forEach((part, partIndex) => {
if (!part) return;
if (urlPattern.test(part)) {
nodes.push(
<a
key={`link-${lineIndex}-${partIndex}`}
href={part}
target="_blank"
rel="noreferrer noopener"
className="text-primary underline underline-offset-2 break-all"
>
{part}
</a>
);
} else {
nodes.push(<React.Fragment key={`text-${lineIndex}-${partIndex}`}>{part}</React.Fragment>);
}
urlPattern.lastIndex = 0;
});
if (lineIndex < lines.length - 1) {
nodes.push(<br key={`br-${lineIndex}`} />);
}
});
return nodes;
}
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);
const [testResult, setTestResult] = useState<OutlookConnectionTestResult | null>(null);
const [overview, setOverview] = useState<Awaited<ReturnType<typeof getOutlookOverview>> | null>(null);
const [statusLoading, setStatusLoading] = useState(true);
const [overviewLoading, setOverviewLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
const [disconnecting, setDisconnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedMessageRef, setSelectedMessageRef] = useState<{
id: string;
changekey?: string | null;
} | null>(null);
const [selectedMessage, setSelectedMessage] = useState<OutlookMessageDetail | null>(null);
const [messageLoading, setMessageLoading] = useState(false);
const [selectedEvent, setSelectedEvent] = useState<OutlookEventSummary | null>(null);
const [activeView, setActiveView] = useState<OutlookView>('settings');
const [inboxPage, setInboxPage] = useState<OutlookMessageListResponse | null>(null);
const [sentPage, setSentPage] = useState<OutlookMessageListResponse | null>(null);
const [calendarPage, setCalendarPage] = useState<OutlookEventListResponse | null>(null);
const [calendarAnchorKey, setCalendarAnchorKey] = useState<string>(toLocalDateKey(new Date()));
const [mailboxLoading, setMailboxLoading] = useState<Record<OutlookMailboxView, boolean>>({
inbox: false,
sent: false,
});
const [calendarLoading, setCalendarLoading] = useState(false);
const applyStatus = useCallback((nextStatus: OutlookStatus, forceFormSync = false) => {
setStatus(nextStatus);
if (forceFormSync || !formDirty) {
setForm(toFormState(nextStatus));
setFormDirty(false);
}
}, [formDirty]);
const loadOverview = useCallback(async (preserveExisting = false) => {
setOverviewLoading(true);
try {
const nextOverview = await getOutlookOverview();
setOverview(nextOverview);
setError(null);
} catch (err: any) {
if (!preserveExisting) {
setOverview(null);
}
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 }));
try {
const nextPage = await getOutlookMessages(view === 'inbox' ? 'inbox' : 'sentitems', {
top: MAILBOX_PAGE_SIZE,
skip,
});
if (view === 'inbox') {
setInboxPage(nextPage);
} else {
setSentPage(nextPage);
}
setError(null);
} catch (err: any) {
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);
try {
const range = buildCalendarRange(anchorKey);
const nextPage = await getOutlookEvents({
...range,
top: CALENDAR_PAGE_SIZE,
skip: 0,
});
setCalendarPage(nextPage);
setError(null);
} catch (err: any) {
setError(err.message || t('加载日程失败', 'Failed to load calendar events'));
} finally {
setCalendarLoading(false);
}
}, [t]);
const loadStatus = useCallback(async (
background = false,
options?: {
forceFormSync?: boolean;
preserveOverview?: boolean;
}
) => {
if (background) {
setRefreshing(true);
} else {
setStatusLoading(true);
}
setError(null);
try {
const nextStatus = await getOutlookStatus();
applyStatus(nextStatus, options?.forceFormSync);
if (!background) {
setStatusLoading(false);
}
if (nextStatus.configured) {
await loadOverview(options?.preserveOverview ?? background);
} else {
setOverview(null);
setOverviewLoading(false);
}
} catch (err: any) {
setError(err.message || t('加载 Outlook 集成状态失败', 'Failed to load Outlook integration status'));
setOverviewLoading(false);
} finally {
if (background) {
setRefreshing(false);
} else {
setStatusLoading(false);
}
}
}, [applyStatus, loadOverview, t]);
useEffect(() => {
void loadStatus();
}, [loadStatus]);
useEffect(() => {
if (!selectedMessageRef) {
setSelectedMessage(null);
return;
}
let cancelled = false;
setMessageLoading(true);
getOutlookMessageDetail(selectedMessageRef.id, selectedMessageRef.changekey)
.then((message) => {
if (!cancelled) {
setSelectedMessage(message);
}
})
.catch((err: any) => {
if (!cancelled) {
setError(err.message || t('加载邮件详情失败', 'Failed to load message details'));
}
})
.finally(() => {
if (!cancelled) {
setMessageLoading(false);
}
});
return () => {
cancelled = true;
};
}, [selectedMessageRef, t]);
const canTest = useMemo(
() => Boolean(
form.email.trim()
&& form.password
&& (form.autodiscover || (form.service_endpoint || '').trim() || (form.server || '').trim())
),
[form.autodiscover, form.email, form.password, form.server, form.service_endpoint]
);
const isConfigured = Boolean(status?.configured);
const isConnected = Boolean(status?.connected);
const inboxCount = overview?.recentInbox.length ?? 0;
const sentCount = overview?.recentSent.length ?? 0;
const eventCount = overview?.todayEvents.length ?? 0;
const overviewWarnings = overview?.warnings || [];
const testWarnings = testResult?.warnings || [];
const statusPending = statusLoading && !status;
const overviewPending = overviewLoading && !overview;
const availableViews = useMemo(() => {
if (!isConfigured) {
return [
{
id: 'settings' as const,
label: t('设置', 'Settings'),
hint: t('配置 Outlook 连接', 'Configure the Outlook connection'),
icon: Settings2,
count: null,
},
];
}
return [
{
id: 'inbox' as const,
label: t('收件箱', 'Inbox'),
hint: t('最近接收邮件', 'Recently received mail'),
icon: Inbox,
count: null,
},
{
id: 'sent' as const,
label: t('发件箱', 'Sent'),
hint: t('最近发送记录', 'Recently sent messages'),
icon: Send,
count: null,
},
{
id: 'calendar' as const,
label: t('日程', 'Calendar'),
hint: t('未来 7 天', 'Next 7 days'),
icon: CalendarDays,
count: overviewPending ? null : eventCount,
},
{
id: 'settings' as const,
label: t('设置', 'Settings'),
hint: t('连接与状态', 'Connection and status'),
icon: Settings2,
count: null,
},
];
}, [eventCount, inboxCount, isConfigured, overviewPending, sentCount, t]);
useEffect(() => {
if (!availableViews.some((view) => view.id === activeView)) {
setActiveView(availableViews[0].id);
}
}, [activeView, availableViews]);
useEffect(() => {
if (!isConfigured) {
return;
}
if (activeView === 'inbox' && !inboxPage && !mailboxLoading.inbox) {
void loadMailboxPage('inbox', 0);
}
if (activeView === 'sent' && !sentPage && !mailboxLoading.sent) {
void loadMailboxPage('sent', 0);
}
if (activeView === 'calendar' && !calendarPage && !calendarLoading) {
void loadCalendarPage(calendarAnchorKey);
}
}, [
activeView,
calendarAnchorKey,
calendarLoading,
calendarPage,
inboxPage,
isConfigured,
loadCalendarPage,
loadMailboxPage,
mailboxLoading.inbox,
mailboxLoading.sent,
sentPage,
]);
const updateField = <K extends keyof OutlookFormState>(key: K, value: OutlookFormState[K]) => {
setFormDirty(true);
setForm((current) => ({ ...current, [key]: value }));
};
const handleTest = async () => {
setTesting(true);
setError(null);
try {
const result = await testOutlookConnection(form);
setTestResult(result);
} catch (err: any) {
setError(err.message || t('测试连接失败', 'Failed to test the connection'));
setTestResult(null);
} finally {
setTesting(false);
}
};
const handleConnect = async () => {
setSaving(true);
setError(null);
try {
await connectOutlook(form);
setForm((current) => ({ ...current, password: '' }));
setFormDirty(false);
setTestResult(null);
setInboxPage(null);
setSentPage(null);
setCalendarPage(null);
setCalendarAnchorKey(toLocalDateKey(new Date()));
await loadStatus(true, { forceFormSync: true });
setActiveView('inbox');
} catch (err: any) {
setError(err.message || t('保存 Outlook 配置失败', 'Failed to save Outlook settings'));
} finally {
setSaving(false);
}
};
const handleDisconnect = async () => {
setDisconnecting(true);
setError(null);
try {
await disconnectOutlook();
setOverview(null);
setTestResult(null);
setSelectedMessageRef(null);
setSelectedEvent(null);
setInboxPage(null);
setSentPage(null);
setCalendarPage(null);
setCalendarAnchorKey(toLocalDateKey(new Date()));
setActiveView('settings');
setFormDirty(false);
await loadStatus(true, { forceFormSync: true });
} catch (err: any) {
setError(err.message || t('断开 Outlook 连接失败', 'Failed to disconnect Outlook'));
} finally {
setDisconnecting(false);
}
};
const refreshOverview = async () => {
await loadStatus(true, { preserveOverview: true });
if (activeView === 'inbox') {
await loadMailboxPage('inbox', inboxPage?.page.skip ?? 0);
} else if (activeView === 'sent') {
await loadMailboxPage('sent', sentPage?.page.skip ?? 0);
} else if (activeView === 'calendar') {
await loadCalendarPage(calendarAnchorKey);
}
};
return (
<div className="min-h-full">
<div className="mx-auto max-w-7xl space-y-6 p-6">
<section className="rounded-2xl border bg-card px-4 py-4 shadow-sm">
<div className="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-wrap items-center gap-2 text-sm">
<div className="mr-2 flex items-center gap-2 text-lg font-semibold text-foreground">
<Mail className="h-5 w-5" />
Outlook
</div>
{statusPending ? (
<>
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-6 w-24 rounded-full" />
<Skeleton className="h-6 w-14 rounded-full" />
<Skeleton className="h-4 w-40" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-36" />
</>
) : (
<>
<Badge variant={statusVariant(isConnected)}>
{isConnected ? t('已连通', 'Connected') : isConfigured ? t('已配置', 'Configured') : t('未配置', 'Not configured')}
</Badge>
<Badge variant={status?.mcp_registered ? 'default' : 'secondary'}>
{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">{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">
{t('最近刷新', 'Last refreshed')} {formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined, locale)}
</span>
</>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{isConfigured ? (
<>
<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>
</section>
{error && (
<Card className="border-destructive bg-destructive/5">
<CardContent className="pt-6">
<div className="flex items-start gap-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{error}</span>
</div>
</CardContent>
</Card>
)}
{!error && overviewWarnings.length > 0 && (
<Card className="border-amber-300 bg-amber-50/70">
<CardContent className="space-y-2 pt-6 text-sm text-amber-900">
{overviewWarnings.map((warning, index) => (
<div key={`${warning}-${index}`} className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{warning}</span>
</div>
))}
</CardContent>
</Card>
)}
<Tabs value={activeView} onValueChange={(value) => setActiveView(value as OutlookView)} className="space-y-6">
<TabsList
className={`grid h-auto w-full gap-2 rounded-2xl border bg-muted/40 p-2 shadow-sm ${
isConfigured ? 'grid-cols-2 lg:grid-cols-4' : 'max-w-xs grid-cols-1'
}`}
>
{availableViews.map((view) => {
const Icon = view.icon;
return (
<TabsTrigger
key={view.id}
value={view.id}
className="h-auto rounded-xl border border-transparent px-4 py-3 data-[state=active]:border-border data-[state=active]:shadow-sm"
>
<div className="flex w-full items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-muted text-muted-foreground">
<Icon className="h-4 w-4" />
</span>
<div className="text-left">
<p className="text-sm font-semibold">{view.label}</p>
{typeof view.count === 'number' ? (
<p className="text-xs text-muted-foreground">{t(`${view.count}`, `${view.count} items`)}</p>
) : null}
</div>
</div>
</div>
</TabsTrigger>
);
})}
</TabsList>
<TabsContent value="inbox" className="mt-0">
<MessageCard
title={t('收件箱', 'Inbox')}
icon={<MailOpen className="h-4 w-4" />}
items={inboxPage?.value || []}
page={inboxPage?.page || null}
loading={mailboxLoading.inbox || (activeView === 'inbox' && !inboxPage)}
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}
onPreviousPage={() => void loadMailboxPage('inbox', Math.max(0, (inboxPage?.page.skip ?? 0) - MAILBOX_PAGE_SIZE))}
onNextPage={() => {
const nextSkip = inboxPage?.page.next_skip;
if (typeof nextSkip === 'number') {
void loadMailboxPage('inbox', nextSkip);
}
}}
/>
</TabsContent>
<TabsContent value="sent" className="mt-0">
<MessageCard
title={t('发件箱', 'Sent')}
icon={<Send className="h-4 w-4" />}
items={sentPage?.value || []}
page={sentPage?.page || null}
loading={mailboxLoading.sent || (activeView === 'sent' && !sentPage)}
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}
onPreviousPage={() => void loadMailboxPage('sent', Math.max(0, (sentPage?.page.skip ?? 0) - MAILBOX_PAGE_SIZE))}
onNextPage={() => {
const nextSkip = sentPage?.page.next_skip;
if (typeof nextSkip === 'number') {
void loadMailboxPage('sent', nextSkip);
}
}}
/>
</TabsContent>
<TabsContent value="calendar" className="mt-0">
<EventCard
items={calendarPage?.value || []}
startDate={calendarAnchorKey}
loading={calendarLoading || (activeView === 'calendar' && !calendarPage)}
locale={locale}
onOpen={(item) => setSelectedEvent(item)}
onRefresh={() => void loadCalendarPage(calendarAnchorKey)}
refreshing={calendarLoading}
onPreviousWeek={() => {
const next = new Date(`${calendarAnchorKey}T00:00:00`);
next.setDate(next.getDate() - 7);
setCalendarAnchorKey(toLocalDateKey(next));
setCalendarPage(null);
}}
onNextWeek={() => {
const next = new Date(`${calendarAnchorKey}T00:00:00`);
next.setDate(next.getDate() + 7);
setCalendarAnchorKey(toLocalDateKey(next));
setCalendarPage(null);
}}
onCurrentWeek={() => {
const nextKey = toLocalDateKey(new Date());
setCalendarAnchorKey(nextKey);
setCalendarPage(null);
}}
/>
</TabsContent>
<TabsContent value="settings" className="mt-0">
<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">{t('连接设置', 'Connection settings')}</CardTitle>
</CardHeader>
<CardContent className="space-y-5 pt-6">
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('邮箱地址', 'Email address')} required>
<Input
value={form.email}
onChange={(event) => updateField('email', event.target.value)}
placeholder="you@boardware.com"
/>
</Field>
<Field label={t('用户名', 'Username')}>
<Input
value={form.username}
onChange={(event) => updateField('username', event.target.value)}
placeholder={t('留空时默认取邮箱前缀', 'Leave blank to default to the email prefix')}
/>
</Field>
<Field label={t('密码', 'Password')} required>
<Input
type="password"
value={form.password}
onChange={(event) => updateField('password', event.target.value)}
placeholder={t('请输入邮箱密码', 'Enter the mailbox password')}
/>
</Field>
<Field label={t('域', 'Domain')}>
<Input
value={form.domain}
onChange={(event) => updateField('domain', event.target.value)}
placeholder="boardware.com.mo"
/>
</Field>
<Field label="EWS URL">
<Input
value={form.service_endpoint}
onChange={(event) => updateField('service_endpoint', event.target.value)}
placeholder="https://mail.boardware.com.mo/EWS/Exchange.asmx"
disabled={form.autodiscover}
/>
</Field>
<Field label="Server Host">
<Input
value={form.server}
onChange={(event) => updateField('server', event.target.value)}
placeholder="mail.boardware.com.mo"
disabled={form.autodiscover}
/>
</Field>
<Field label={t('时区', 'Timezone')}>
<Input
value={form.default_timezone}
onChange={(event) => updateField('default_timezone', event.target.value)}
placeholder="Asia/Shanghai"
/>
</Field>
<div className="rounded-2xl border border-dashed bg-muted/30 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<Label htmlFor="autodiscover" className="text-sm font-medium">
Autodiscover
</Label>
<p className="mt-1 text-xs text-muted-foreground">
{t('开启后优先使用 Exchange 自动发现,不再强依赖手填 EWS URL。', 'When enabled, Exchange autodiscover is preferred so the EWS URL does not need to be entered manually.')}
</p>
</div>
<Switch
id="autodiscover"
checked={form.autodiscover}
onCheckedChange={(checked) => updateField('autodiscover', checked)}
/>
</div>
</div>
</div>
<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"
onClick={handleDisconnect}
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">{t('测试成功', 'Test succeeded')}</Badge>
<span className="text-muted-foreground">{testResult.mailbox}</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={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">
{testWarnings.map((warning, index) => (
<div key={`${warning}-${index}`} className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{warning}</span>
</div>
))}
</div>
)}
</div>
)}
</CardContent>
</Card>
<Card className="rounded-[28px] shadow-sm">
<CardHeader className="border-b pb-5">
<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">
{statusPending ? (
<>
<Skeleton className="h-6 w-20 rounded-full" />
<Skeleton className="h-6 w-24 rounded-full" />
<Skeleton className="h-6 w-14 rounded-full" />
</>
) : (
<>
<Badge variant={statusVariant(isConnected)}>
{isConnected ? t('已连通', 'Connected') : isConfigured ? t('已配置', 'Configured') : t('未配置', 'Not configured')}
</Badge>
<Badge variant={status?.mcp_registered ? 'default' : 'secondary'}>
{status?.mcp_registered ? t('MCP 已注册', 'MCP registered') : t('MCP 未注册', 'MCP not registered')}
</Badge>
<Badge variant="secondary">{status?.provider || 'ews'}</Badge>
</>
)}
</div>
<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={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={t('最近刷新', 'Last refreshed')}
value={formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined, locale)}
loading={statusPending || overviewPending}
/>
{status?.error && (
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-3 text-sm text-destructive">
{status.error}
</div>
)}
<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'
? 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>
</Card>
</div>
</TabsContent>
</Tabs>
<Dialog open={Boolean(selectedMessageRef)} onOpenChange={(open) => !open && setSelectedMessageRef(null)}>
<DialogContent className="sm:max-w-5xl">
<DialogHeader>
<DialogTitle>{selectedMessage?.subject || t('邮件详情', 'Message details')}</DialogTitle>
<DialogDescription>
{selectedMessage?.receivedDateTime ? formatDateTime(selectedMessage.receivedDateTime, locale) : t('正在加载', 'Loading')}
</DialogDescription>
</DialogHeader>
{messageLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : 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={t('发件人', 'From')} value={mailboxLabel(selectedMessage.from)} />
<InfoRow
label={t('收件人', 'To')}
value={(selectedMessage.toRecipients || []).map(mailboxLabel).filter(Boolean).join(locale === 'en-US' ? '; ' : '') || '-'}
/>
<InfoRow
label={t('抄送', 'Cc')}
value={(selectedMessage.ccRecipients || []).map(mailboxLabel).filter(Boolean).join(locale === 'en-US' ? '; ' : '') || '-'}
/>
<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 ? 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={t('邮件正文', 'Message body')}
srcDoc={buildEmailPreviewDocument(
selectedMessage.body?.content || selectedMessage.bodyPreview || '',
locale
)}
className="h-[60vh] w-full bg-white"
sandbox="allow-popups allow-popups-to-escape-sandbox"
/>
) : (
<ScrollArea className="h-[60vh]">
<div className="p-5 text-sm leading-7 text-foreground whitespace-pre-wrap break-words">
{renderPlainText(selectedMessage.body?.content || selectedMessage.bodyPreview || '')}
</div>
</ScrollArea>
)}
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">{t('未加载到邮件详情。', 'Message details were not loaded.')}</p>
)}
</DialogContent>
</Dialog>
<Dialog open={Boolean(selectedEvent)} onOpenChange={(open) => !open && setSelectedEvent(null)}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{selectedEvent?.subject || t('日程详情', 'Event details')}</DialogTitle>
<DialogDescription>
{selectedEvent
? `${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={t('组织者', 'Organizer')} value={mailboxLabel(selectedEvent.organizer)} />
<InfoRow label={t('地点', 'Location')} value={selectedEvent.location?.displayName || '-'} />
<InfoRow
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">{t('说明', 'Notes')}</p>
<div className="rounded-lg border bg-muted/40 p-3 whitespace-pre-wrap">
{selectedEvent.bodyPreview || t('没有更多说明。', 'No additional notes.')}
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
</div>
</div>
);
}
function Field({
label,
required = false,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="space-y-2">
<Label className="text-sm font-medium">
{label}
{required ? <span className="ml-1 text-destructive">*</span> : null}
</Label>
{children}
</div>
);
}
function InfoRow({ label, value, loading = false }: { label: string; value: string; loading?: boolean }) {
return (
<div className="flex items-start justify-between gap-4 text-sm">
<span className="text-muted-foreground">{label}</span>
{loading ? (
<Skeleton className="h-4 w-32 max-w-[70%]" />
) : (
<span className="max-w-[70%] text-right break-all">{value}</span>
)}
</div>
);
}
function MiniStat({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-2xl border bg-card p-3">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="mt-1 text-lg font-semibold text-foreground">{value}</p>
</div>
);
}
function TopStat({ label, value, loading = false }: { label: string; value: string; loading?: boolean }) {
return (
<div className="rounded-full border bg-background px-3 py-1 text-sm">
<span className="text-muted-foreground">{label}</span>
{loading ? (
<Skeleton className="ml-2 inline-flex h-4 w-8 align-middle" />
) : (
<span className="ml-2 font-semibold text-foreground">{value}</span>
)}
</div>
);
}
function MessageCard({
title,
icon,
items,
page,
locale,
loading = false,
emptyLabel,
onOpen,
onRefresh,
refreshing,
onPreviousPage,
onNextPage,
}: {
title: string;
icon: React.ReactNode;
items: OutlookMessageSummary[];
page: OutlookPageInfo | null;
locale: AppLocale;
loading?: boolean;
emptyLabel: string;
onOpen: (item: OutlookMessageSummary) => void;
onRefresh: () => void;
refreshing: boolean;
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 ? t(`${currentPage} 页 · 本页 ${page.returned}`, `Page ${currentPage} · ${page.returned} messages`) : t('正在读取邮件…', 'Loading messages...');
return (
<Card className="rounded-[28px] shadow-sm">
<CardHeader className="flex flex-row items-center justify-between gap-4 border-b pb-5">
<div className="space-y-1">
<CardTitle className="flex items-center gap-2 text-base">
{icon}
{title}
</CardTitle>
<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"
size="sm"
onClick={onNextPage}
disabled={!page || !page.has_more || refreshing}
>
{t('下一页', 'Next')}
</Button>
</div>
</CardHeader>
<CardContent className="pt-6">
{loading ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="rounded-2xl border bg-card p-4">
<Skeleton className="h-5 w-1/3" />
<Skeleton className="mt-2 h-4 w-1/4" />
<Skeleton className="mt-4 h-4 w-full" />
<Skeleton className="mt-2 h-4 w-5/6" />
</div>
))}
</div>
) : items.length === 0 ? (
<div className="rounded-3xl border border-dashed bg-muted/30 p-8 text-center text-sm text-muted-foreground">
{emptyLabel}
</div>
) : (
<div className="space-y-3">
{items.map((item) => (
<button
key={item.id || `${item.subject}-${item.receivedDateTime}`}
type="button"
onClick={() => item.id && onOpen(item)}
className="w-full rounded-2xl border bg-card p-4 text-left transition-colors hover:bg-muted/40"
>
<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 || 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 || 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 ? t('已读', 'Read') : t('未读', 'Unread')}
</Badge>
<span className="text-xs text-muted-foreground">{formatDateTime(item.receivedDateTime, locale)}</span>
</div>
</div>
</button>
))}
</div>
)}
</CardContent>
</Card>
);
}
function EventCard({
items,
startDate,
locale,
loading = false,
onOpen,
onRefresh,
refreshing,
onPreviousWeek,
onNextWeek,
onCurrentWeek,
}: {
items: OutlookEventSummary[];
startDate?: string | null;
locale: AppLocale;
loading?: boolean;
onOpen: (item: OutlookEventSummary) => void;
onRefresh: () => void;
refreshing: boolean;
onPreviousWeek: () => void;
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) => {
const next = new Date(anchor);
next.setDate(anchor.getDate() + index);
return next;
});
const eventsByDay = weekDays.map((day) => {
const key = toLocalDateKey(day);
return {
key,
label: formatDayLabel(day, locale),
items: items
.filter((item) => formatDateKey(item.start?.dateTime) === key)
.sort((left, right) => {
const leftTime = new Date(left.start?.dateTime || '').getTime();
const rightTime = new Date(right.start?.dateTime || '').getTime();
return leftTime - rightTime;
}),
};
});
return (
<Card className="rounded-[28px] shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 border-b pb-5">
<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], 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' : ''}`} />
</Button>
</div>
</CardHeader>
<CardContent className="pt-6">
{loading ? (
<div className="grid gap-3 lg:grid-cols-2 2xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div key={index} className="rounded-2xl border bg-card p-4">
<Skeleton className="h-5 w-24" />
<Skeleton className="mt-2 h-4 w-16" />
<Skeleton className="mt-6 h-16 w-full" />
</div>
))}
</div>
) : (
<div className="grid gap-3 lg:grid-cols-2 2xl:grid-cols-3">
{eventsByDay.map((day) => (
<div key={day.key} className="rounded-2xl border bg-card p-4">
<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">{t(`${day.items.length} 条安排`, `${day.items.length} events`)}</p>
</div>
</div>
{day.items.length === 0 ? (
<p className="mt-4 text-sm text-muted-foreground">{t('暂无安排', 'No events')}</p>
) : (
<div className="mt-4 space-y-3">
{day.items.map((item) => (
<button
key={item.id || `${item.subject}-${item.start?.dateTime}`}
type="button"
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 || t('(无主题)', '(No subject)')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{formatTime(item.start?.dateTime, locale)} - {formatTime(item.end?.dateTime, locale)}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{item.location?.displayName || t('未设置地点', 'No location set')}
</p>
</button>
))}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}