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 环境变量
This commit is contained in:
2026-03-16 17:01:58 +08:00
parent 04501fea22
commit b3767dd4ab
20 changed files with 1671 additions and 83 deletions

View File

@ -19,7 +19,9 @@ import {
import {
connectOutlook,
disconnectOutlook,
getOutlookEvents,
getOutlookMessageDetail,
getOutlookMessages,
getOutlookOverview,
getOutlookStatus,
testOutlookConnection,
@ -27,9 +29,12 @@ import {
import type {
OutlookConnectionPayload,
OutlookConnectionTestResult,
OutlookEventListResponse,
OutlookEventSummary,
OutlookMessageDetail,
OutlookMessageListResponse,
OutlookMessageSummary,
OutlookPageInfo,
OutlookStatus,
} from '@/types';
import { Badge } from '@/components/ui/badge';
@ -52,6 +57,10 @@ 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: '',
@ -124,6 +133,18 @@ function formatTime(value?: string | null): string {
}).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?:
| {
@ -333,6 +354,15 @@ export default function OutlookPage() {
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);
@ -358,6 +388,44 @@ export default function OutlookPage() {
}
}, []);
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?: {
@ -465,14 +533,14 @@ export default function OutlookPage() {
label: '收件箱',
hint: '最近接收邮件',
icon: Inbox,
count: overviewPending ? null : inboxCount,
count: null,
},
{
id: 'sent' as const,
label: '发件箱',
hint: '最近发送记录',
icon: Send,
count: overviewPending ? null : sentCount,
count: null,
},
{
id: 'calendar' as const,
@ -497,6 +565,33 @@ export default function OutlookPage() {
}
}, [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 }));
@ -524,6 +619,10 @@ export default function OutlookPage() {
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) {
@ -542,6 +641,10 @@ export default function OutlookPage() {
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 });
@ -554,6 +657,13 @@ export default function OutlookPage() {
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 (
@ -601,7 +711,7 @@ export default function OutlookPage() {
<TopStat label="日程" value={String(eventCount)} loading={overviewPending} />
</>
) : null}
<Button variant="outline" size="sm" onClick={() => void loadStatus(true)}>
<Button variant="outline" size="sm" onClick={() => void refreshOverview()}>
<RefreshCw className={`mr-2 h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
@ -669,10 +779,20 @@ export default function OutlookPage() {
<MessageCard
title="收件箱"
icon={<MailOpen className="h-4 w-4" />}
items={overview?.recentInbox || []}
loading={overviewPending}
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>
@ -680,21 +800,48 @@ export default function OutlookPage() {
<MessageCard
title="发件箱"
icon={<Send className="h-4 w-4" />}
items={overview?.recentSent || []}
loading={overviewPending}
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={overview?.todayEvents || []}
startDate={overview?.today}
loading={overviewPending}
items={calendarPage?.value || []}
startDate={calendarAnchorKey}
loading={calendarLoading || (activeView === 'calendar' && !calendarPage)}
onOpen={(item) => setSelectedEvent(item)}
onRefresh={refreshOverview}
refreshing={refreshing}
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>
@ -1037,27 +1184,29 @@ 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 pageSize = 8;
const [page, setPage] = useState(1);
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const visibleItems = items.slice((page - 1) * pageSize, page * pageSize);
useEffect(() => {
if (page > totalPages) {
setPage(totalPages);
}
}, [page, totalPages]);
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">
@ -1067,26 +1216,24 @@ function MessageCard({
{icon}
{title}
</CardTitle>
<p className="text-sm text-muted-foreground">{loading ? '正在读取邮件…' : `${items.length}`}</p>
<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>
{!loading && totalPages > 1 ? (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setPage((current) => Math.max(1, current - 1))} disabled={page === 1}>
</Button>
<span className="text-sm text-muted-foreground">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
disabled={page === totalPages}
>
</Button>
</div>
) : null}
</CardHeader>
<CardContent className="pt-6">
{loading ? (
@ -1106,7 +1253,7 @@ function MessageCard({
</div>
) : (
<div className="space-y-3">
{visibleItems.map((item) => (
{items.map((item) => (
<button
key={item.id || `${item.subject}-${item.receivedDateTime}`}
type="button"
@ -1144,6 +1291,9 @@ function EventCard({
onOpen,
onRefresh,
refreshing,
onPreviousWeek,
onNextWeek,
onCurrentWeek,
}: {
items: OutlookEventSummary[];
startDate?: string | null;
@ -1151,6 +1301,9 @@ function EventCard({
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;
@ -1186,9 +1339,20 @@ function EventCard({
{formatDayLabel(weekDays[0])} - {formatDayLabel(weekDays[weekDays.length - 1])}
</p>
</div>
<Button variant="ghost" size="sm" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</Button>
<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 ? (