Files
beaver_project/app-instance/frontend/app/(app)/outlook/page.tsx
steven_li 4b0bf65ace ```
feat(engine): 优化智能体循环中的助手消息处理逻辑

- 在没有工具调用时才添加助手消息到上下文
- 确保工具调用响应正确添加到消息上下文中
- 修复了消息构建的条件逻辑

fix(cron): 改进定时任务调度的时间解析功能

- 添加正则表达式导入用于时间显示解析
- 实现从显示文本中提取毫秒间隔的功能
- 增强整数转换的安全性,避免类型错误
- 优化定时任务配置的解析逻辑

feat(outlook): 增强Outlook集成的功能和稳定性

- 将默认超时时间从10秒增加到180秒
- 为状态检查函数添加可选的验证参数
- 串行执行邮件概览获取操作而非并行
- 改进连接状态验证逻辑

feat(channel): 添加设备名称作为会话标识的选项

- 为终端WebSocket适配器添加新的配置选项
- 实现基于设备名称生成会话对等ID的功能
- 记录原始对等ID和设备名称的元数据
- 支持从设备名称创建会话对等ID

feat(skills): 完善技能学习评估系统和进度跟踪

- 在应用启动时自动调度待评估的技能草稿
- 为技能评估工作创建独立的循环工厂
- 实现异步技能评估任务的取消和清理机制
- 添加技能评估进度报告和状态跟踪功能
- 扩展会话列表API以包含更多详细信息
- 防止对不存在的会话进行操作
- 优化技能草稿提交和评估的业务逻辑

perf(skills): 提升技能评估的并发性能

- 实现并行技能案例评估以提高效率
- 添加最大并行案例数的环境变量控制
- 实现实时评估进度更新和回调机制
- 优化评估过程中的资源管理和同步

refactor(services): 创建隔离的智能体循环实例

- 添加创建独立智能体循环的工厂方法
- 确保新循环继承运行时服务配置
- 支持技能评估等需要隔离环境的场景
```
2026-06-15 14:48:16 +08:00

1447 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

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