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:
@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user