Files
beaver_project/app-instance/frontend/app/(app)/outlook/page.tsx
steven_li b3767dd4ab feat(outlook): 添加 Outlook MCP 集成支持并优化分页功能
- 新增 NANO_OUTLOOK_MCP_URL 和 NANO_OUTLOOK_MCP_SERVER_ID 环境变量配置
- 实现 Outlook 邮件和日历的分页查询功能,添加安全参数验证
- 为 app-instance 创建脚本添加 Outlook MCP 服务器 ID 参数
- 更新前端 Outlook 页面实现邮件列表和日历事件的分页浏览
- 添加 Git 忽略文件配置和 Docker 挂载路径修复

BREAKING CHANGE: Outlook 集成现在需要配置 MCP URL 和服务器 ID 环境变量
2026-03-16 17:01:58 +08:00

1408 lines
51 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';
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): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', {
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): string {
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
weekday: 'short',
}).format(value);
}
function formatTime(value?: string | null): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat('zh-CN', {
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): string {
return `<!DOCTYPE html>
<html lang="zh-CN">
<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 [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 || '加载 Outlook 概览失败');
} finally {
setOverviewLoading(false);
}
}, []);
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 || `加载${view === 'inbox' ? '收件箱' : '发件箱'}失败`);
} finally {
setMailboxLoading((current) => ({ ...current, [view]: false }));
}
}, []);
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 || '加载日程失败');
} finally {
setCalendarLoading(false);
}
}, []);
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 || '加载 Outlook 集成状态失败');
setOverviewLoading(false);
} finally {
if (background) {
setRefreshing(false);
} else {
setStatusLoading(false);
}
}
}, [applyStatus, loadOverview]);
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 || '加载邮件详情失败');
}
})
.finally(() => {
if (!cancelled) {
setMessageLoading(false);
}
});
return () => {
cancelled = true;
};
}, [selectedMessageRef]);
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: '设置',
hint: '配置 Outlook 连接',
icon: Settings2,
count: null,
},
];
}
return [
{
id: 'inbox' as const,
label: '收件箱',
hint: '最近接收邮件',
icon: Inbox,
count: null,
},
{
id: 'sent' as const,
label: '发件箱',
hint: '最近发送记录',
icon: Send,
count: null,
},
{
id: 'calendar' as const,
label: '日程',
hint: '未来 7 天',
icon: CalendarDays,
count: overviewPending ? null : eventCount,
},
{
id: 'settings' as const,
label: '设置',
hint: '连接与状态',
icon: Settings2,
count: null,
},
];
}, [eventCount, inboxCount, isConfigured, overviewPending, sentCount]);
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 || '测试连接失败');
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 || '保存 Outlook 配置失败');
} 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 || '断开 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 ? '已连通' : isConfigured ? '已配置' : '未配置'}
</Badge>
<Badge variant={status?.mcp_registered ? 'default' : 'secondary'}>
{status?.mcp_registered ? 'MCP 已注册' : 'MCP 未注册'}
</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">
{formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined)}
</span>
</>
)}
</div>
<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} />
</>
) : null}
<Button variant="outline" size="sm" onClick={() => void refreshOverview()}>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</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">{view.count} </p>
) : null}
</div>
</div>
</div>
</TabsTrigger>
);
})}
</TabsList>
<TabsContent value="inbox" className="mt-0">
<MessageCard
title="收件箱"
icon={<MailOpen className="h-4 w-4" />}
items={inboxPage?.value || []}
page={inboxPage?.page || null}
loading={mailboxLoading.inbox || (activeView === 'inbox' && !inboxPage)}
emptyLabel="还没有读取到收件箱邮件"
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="发件箱"
icon={<Send className="h-4 w-4" />}
items={sentPage?.value || []}
page={sentPage?.page || null}
loading={mailboxLoading.sent || (activeView === 'sent' && !sentPage)}
emptyLabel="还没有读取到已发送邮件"
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)}
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"></CardTitle>
</CardHeader>
<CardContent className="space-y-5 pt-6">
<div className="grid gap-4 md:grid-cols-2">
<Field label="邮箱地址" required>
<Input
value={form.email}
onChange={(event) => updateField('email', event.target.value)}
placeholder="you@boardware.com"
/>
</Field>
<Field label="用户名">
<Input
value={form.username}
onChange={(event) => updateField('username', event.target.value)}
placeholder="留空时默认取邮箱前缀"
/>
</Field>
<Field label="密码" required>
<Input
type="password"
value={form.password}
onChange={(event) => updateField('password', event.target.value)}
placeholder="请输入邮箱密码"
/>
</Field>
<Field label="域">
<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="时区">
<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">
使 Exchange EWS URL
</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" />}
</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" />}
</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" />}
</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>
<span className="text-muted-foreground">{testResult.mailbox}</span>
<span className="text-muted-foreground">: {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)} />
</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"></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 ? '已连通' : isConfigured ? '已配置' : '未配置'}
</Badge>
<Badge variant={status?.mcp_registered ? 'default' : 'secondary'}>
{status?.mcp_registered ? 'MCP 已注册' : 'MCP 未注册'}
</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="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="最近刷新"
value={formatDateTime((overview?.meta?.last_overview_refresh_at || status?.meta?.last_overview_refresh_at) as string | undefined)}
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">
</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></>}
</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 || '邮件详情'}</DialogTitle>
<DialogDescription>
{selectedMessage?.receivedDateTime ? formatDateTime(selectedMessage.receivedDateTime) : '正在加载'}
</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="发件人" value={mailboxLabel(selectedMessage.from)} />
<InfoRow
label="收件人"
value={(selectedMessage.toRecipients || []).map(mailboxLabel).filter(Boolean).join('') || '-'}
/>
<InfoRow
label="抄送"
value={(selectedMessage.ccRecipients || []).map(mailboxLabel).filter(Boolean).join('') || '-'}
/>
<InfoRow label="接收时间" value={formatDateTime(selectedMessage.receivedDateTime)} />
<div className="flex flex-wrap gap-2">
<Badge variant={selectedMessage.isRead ? 'secondary' : 'default'}>
{selectedMessage.isRead ? '已读' : '未读'}
</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">
</div>
{selectedMessage.body?.contentType?.toLowerCase() === 'html' ? (
<iframe
title="邮件正文"
srcDoc={buildEmailPreviewDocument(
selectedMessage.body?.content || selectedMessage.bodyPreview || ''
)}
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"></p>
)}
</DialogContent>
</Dialog>
<Dialog open={Boolean(selectedEvent)} onOpenChange={(open) => !open && setSelectedEvent(null)}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{selectedEvent?.subject || '日程详情'}</DialogTitle>
<DialogDescription>
{selectedEvent
? `${formatDateTime(selectedEvent.start?.dateTime)} - ${formatDateTime(selectedEvent.end?.dateTime)}`
: '日程详情'}
</DialogDescription>
</DialogHeader>
{selectedEvent && (
<div className="space-y-4 text-sm">
<InfoRow label="组织者" value={mailboxLabel(selectedEvent.organizer)} />
<InfoRow label="地点" value={selectedEvent.location?.displayName || '-'} />
<InfoRow
label="参会人"
value={(selectedEvent.attendees || []).map(mailboxLabel).filter(Boolean).join('') || '-'}
/>
<Separator />
<div className="space-y-2">
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground"></p>
<div className="rounded-lg border bg-muted/40 p-3 whitespace-pre-wrap">
{selectedEvent.bodyPreview || '没有更多说明。'}
</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,
loading = false,
emptyLabel,
onOpen,
onRefresh,
refreshing,
onPreviousPage,
onNextPage,
}: {
title: string;
icon: React.ReactNode;
items: OutlookMessageSummary[];
page: OutlookPageInfo | null;
loading?: boolean;
emptyLabel: string;
onOpen: (item: OutlookMessageSummary) => void;
onRefresh: () => void;
refreshing: boolean;
onPreviousPage: () => void;
onNextPage: () => void;
}) {
const currentPage = page ? Math.floor(page.skip / Math.max(page.top, 1)) + 1 : 1;
const pageLabel = page ? `${currentPage} 页 · 本页 ${page.returned}` : '正在读取邮件…';
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 ? '正在读取邮件…' : 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}>
</Button>
<Button
variant="outline"
size="sm"
onClick={onNextPage}
disabled={!page || !page.has_more || refreshing}
>
</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 || '(无主题)'}</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 || '没有预览内容。'}
</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 ? '已读' : '未读'}
</Badge>
<span className="text-xs text-muted-foreground">{formatDateTime(item.receivedDateTime)}</span>
</div>
</div>
</button>
))}
</div>
)}
</CardContent>
</Card>
);
}
function EventCard({
items,
startDate,
loading = false,
onOpen,
onRefresh,
refreshing,
onPreviousWeek,
onNextWeek,
onCurrentWeek,
}: {
items: OutlookEventSummary[];
startDate?: string | null;
loading?: boolean;
onOpen: (item: OutlookEventSummary) => void;
onRefresh: () => void;
refreshing: boolean;
onPreviousWeek: () => void;
onNextWeek: () => void;
onCurrentWeek: () => void;
}) {
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),
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" />
</CardTitle>
<p className="text-sm text-muted-foreground">
{formatDayLabel(weekDays[0])} - {formatDayLabel(weekDays[weekDays.length - 1])}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onPreviousWeek} disabled={refreshing}>
</Button>
<Button variant="outline" size="sm" onClick={onCurrentWeek} disabled={refreshing}>
</Button>
<Button variant="outline" size="sm" onClick={onNextWeek} disabled={refreshing}>
</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">{day.items.length} </p>
</div>
</div>
{day.items.length === 0 ? (
<p className="mt-4 text-sm text-muted-foreground"></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 || '(无主题)'}</p>
<p className="mt-1 text-xs text-muted-foreground">
{formatTime(item.start?.dateTime)} - {formatTime(item.end?.dateTime)}
</p>
<p className="mt-2 text-sm text-muted-foreground">
{item.location?.displayName || '未设置地点'}
</p>
</button>
))}
</div>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}