- 新增 NANO_OUTLOOK_MCP_URL 和 NANO_OUTLOOK_MCP_SERVER_ID 环境变量配置 - 实现 Outlook 邮件和日历的分页查询功能,添加安全参数验证 - 为 app-instance 创建脚本添加 Outlook MCP 服务器 ID 参数 - 更新前端 Outlook 页面实现邮件列表和日历事件的分页浏览 - 添加 Git 忽略文件配置和 Docker 挂载路径修复 BREAKING CHANGE: Outlook 集成现在需要配置 MCP URL 和服务器 ID 环境变量
1408 lines
51 KiB
TypeScript
1408 lines
51 KiB
TypeScript
'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>
|
||
);
|
||
}
|