Files
ok_r/app/meeting/page.tsx
karlcarlo fc9cfda29e feat: 添加 API 客户端以处理 basePath 并更新所有 fetch 调用
移除 next.config.js 中的 standalone 输出配置,添加 lib/api-client.ts 封装 fetch 请求,
自动为所有 API 调用添加配置的 basePath。更新所有页面和组件中的 fetch 调用以使用新的 fetchApi 函数,
确保在部署到子路径时 API 请求能正确工作。同时调整 tsconfig.json 的格式并修复项目创建时的字段名。
2026-01-28 15:54:19 +08:00

552 lines
20 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';
export const dynamic = 'force-dynamic';
import { useEffect, useState, useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { StatusDot } from '@/components/status-badge';
import { ProgressRing } from '@/components/progress-ring';
import { useUser } from '@/components/user-context';
import { calculateProgress, formatDateRange } from '@/lib/kr-utils';
import { REASON_CODES } from '@/lib/constants';
import type { Project, KR, User, Meeting } from '@/types/database';
import { toast } from 'sonner';
import {
CalendarClock,
FileText,
AlertTriangle,
CheckCircle,
Loader2,
Save,
Presentation,
} from 'lucide-react';
import { getErrorMessage } from '@/lib/error-utils';
import { fetchApi } from '@/lib/api-client';
interface ProjectWithRelations extends Project {
owner: User | null;
pm: User | null;
}
interface KRWithRelations extends KR {
owner: User | null;
}
const meetingSchema = z.object({
summary: z.string().min(1, '总结不能为空'),
decisions: z.string().optional(),
risks: z.string().optional(),
next_period_commitments: z.string().optional(),
});
type MeetingFormValues = z.infer<typeof meetingSchema>;
export default function MeetingPage() {
const searchParams = useSearchParams();
const initialProjectId = searchParams.get('project_id');
const { currentUser } = useUser();
const [projects, setProjects] = useState<ProjectWithRelations[]>([]);
const [krs, setKRs] = useState<KRWithRelations[]>([]);
const [meetings, setMeetings] = useState<Meeting[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>(initialProjectId || '');
const [periodStart, setPeriodStart] = useState<string>('');
const [periodEnd, setPeriodEnd] = useState<string>('');
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showPresentation, setShowPresentation] = useState(false);
const form = useForm<MeetingFormValues>({
resolver: zodResolver(meetingSchema),
defaultValues: {
summary: '',
decisions: '',
risks: '',
next_period_commitments: '',
},
});
useEffect(() => {
async function fetchData() {
try {
const [projectsRes, krsRes, meetingsRes] = await Promise.all([
fetchApi('/api/projects'),
fetchApi('/api/krs'),
fetchApi('/api/meetings'),
]);
if (projectsRes.ok) {
const data = await projectsRes.json();
setProjects(data);
}
if (krsRes.ok) {
const data = await krsRes.json();
setKRs(data);
}
if (meetingsRes.ok) {
const data = await meetingsRes.json();
setMeetings(data);
}
} catch (error) {
console.error('Failed to fetch data:', error);
} finally {
setIsLoading(false);
}
}
fetchData();
const today = new Date();
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 7);
setPeriodStart(weekAgo.toISOString().split('T')[0]);
setPeriodEnd(today.toISOString().split('T')[0]);
}, []);
const periodKRs = useMemo(() => {
if (!selectedProjectId || !periodStart || !periodEnd) return [];
return krs.filter(kr => {
if (kr.project_id !== selectedProjectId) return false;
return kr.period_start <= periodEnd && kr.period_end >= periodStart;
});
}, [krs, selectedProjectId, periodStart, periodEnd]);
const completedKRs = periodKRs.filter(kr => calculateProgress(kr) >= 1);
const incompleteKRs = periodKRs.filter(kr => calculateProgress(kr) < 1);
const riskKRs = periodKRs.filter(kr => kr.status === 'red' || kr.status === 'yellow');
const generateSummary = () => {
const totalCount = periodKRs.length;
const completedCount = completedKRs.length;
const completionRate = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
let summary = `本周期完成情况总结:\n`;
summary += `- 共 ${totalCount} 个 KR,完成 ${completedCount} 个\n`;
summary += `- 完成率:${completionRate}%\n\n`;
if (completedKRs.length > 0) {
summary += `已完成:\n`;
completedKRs.forEach(kr => {
summary += `- ${kr.title} (${kr.current_value}/${kr.target_value} ${kr.unit})\n`;
});
summary += '\n';
}
if (incompleteKRs.length > 0) {
summary += `未完成:\n`;
incompleteKRs.forEach(kr => {
const progress = Math.round(calculateProgress(kr) * 100);
summary += `- ${kr.title} (进度 ${progress}%`;
if (kr.reason_code) {
summary += ` - ${REASON_CODES[kr.reason_code]}`;
}
summary += `)\n`;
});
}
form.setValue('summary', summary);
if (riskKRs.length > 0) {
let risks = '';
riskKRs.forEach(kr => {
risks += `- ${kr.title}`;
if (kr.reason_code) {
risks += `: ${REASON_CODES[kr.reason_code]}`;
}
if (kr.note) {
risks += ` (${kr.note})`;
}
risks += '\n';
});
form.setValue('risks', risks);
}
};
const onSubmit = async (values: MeetingFormValues) => {
if (!selectedProjectId || !periodStart || !periodEnd) {
toast.error('请选择项目和周期');
return;
}
setIsSubmitting(true);
try {
const res = await fetchApi('/api/meetings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
project_id: selectedProjectId,
period_start: periodStart,
period_end: periodEnd,
...values,
created_by: currentUser?.id,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || '保存失败');
}
toast.success('会议纪要已保存');
form.reset();
} catch (error) {
toast.error(error instanceof Error ? error.message : '保存失败');
} finally {
setIsSubmitting(false);
}
};
const selectedProject = projects.find(p => p.id === selectedProjectId);
if (isLoading) {
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="animate-pulse space-y-6">
<div className="h-8 bg-slate-200 rounded w-32" />
<div className="h-64 bg-slate-200 rounded-lg" />
</div>
</div>
);
}
if (showPresentation) {
return (
<div className="min-h-screen bg-slate-900 text-white p-8">
<Button
variant="outline"
onClick={() => setShowPresentation(false)}
className="mb-8 text-white border-white hover:bg-slate-800"
>
退出演示模式
</Button>
<div className="max-w-4xl mx-auto space-y-8">
<div className="text-center mb-12">
<h1 className="text-4xl font-bold mb-4">{selectedProject?.name} 例会</h1>
<p className="text-xl text-slate-400">
{formatDateRange(periodStart, periodEnd)}
</p>
</div>
<div className="grid grid-cols-3 gap-6 mb-12">
<Card className="bg-slate-800 border-slate-700">
<CardContent className="pt-6 text-center">
<p className="text-5xl font-bold text-white">{periodKRs.length}</p>
<p className="text-slate-400">总 KR 数</p>
</CardContent>
</Card>
<Card className="bg-emerald-900 border-emerald-700">
<CardContent className="pt-6 text-center">
<p className="text-5xl font-bold text-emerald-400">{completedKRs.length}</p>
<p className="text-emerald-300">已完成</p>
</CardContent>
</Card>
<Card className="bg-rose-900 border-rose-700">
<CardContent className="pt-6 text-center">
<p className="text-5xl font-bold text-rose-400">{riskKRs.length}</p>
<p className="text-rose-300">需关注</p>
</CardContent>
</Card>
</div>
<Card className="bg-slate-800 border-slate-700">
<CardHeader>
<CardTitle className="text-white">KR 完成情况</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{periodKRs.map(kr => (
<div key={kr.id} className="flex items-center gap-4 p-3 bg-slate-700 rounded-lg">
<ProgressRing progress={calculateProgress(kr)} status={kr.status} size={48} />
<div className="flex-1">
<p className="font-medium text-white">{kr.title}</p>
<p className="text-sm text-slate-400">{kr.owner?.name}</p>
</div>
<div className="text-right">
<p className="text-lg font-semibold text-white">
{kr.current_value}/{kr.target_value} {kr.unit}
</p>
</div>
</div>
))}
</CardContent>
</Card>
{form.getValues('risks') && (
<Card className="bg-amber-900 border-amber-700">
<CardHeader>
<CardTitle className="text-amber-100 flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
风险与阻塞
</CardTitle>
</CardHeader>
<CardContent>
<pre className="text-amber-100 whitespace-pre-wrap text-lg">
{form.getValues('risks')}
</pre>
</CardContent>
</Card>
)}
</div>
</div>
);
}
return (
<div className="max-w-6xl mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-800 flex items-center gap-2">
<CalendarClock className="h-6 w-6 text-teal-600" />
例会模式
</h1>
<p className="text-muted-foreground mt-1">选择项目和周期,自动生成会议总结</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">会议设置</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-sm font-medium mb-2 block">选择项目</label>
<Select value={selectedProjectId} onValueChange={setSelectedProjectId}>
<SelectTrigger>
<SelectValue placeholder="选择项目" />
</SelectTrigger>
<SelectContent>
{projects.map(project => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<label className="text-sm font-medium mb-2 block">开始日期</label>
<Input
type="date"
value={periodStart}
onChange={e => setPeriodStart(e.target.value)}
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">结束日期</label>
<Input
type="date"
value={periodEnd}
onChange={e => setPeriodEnd(e.target.value)}
/>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={generateSummary}
disabled={!selectedProjectId}
variant="outline"
>
<FileText className="h-4 w-4 mr-2" />
自动生成总结
</Button>
<Button
onClick={() => setShowPresentation(true)}
disabled={!selectedProjectId || periodKRs.length === 0}
variant="outline"
>
<Presentation className="h-4 w-4 mr-2" />
演示模式
</Button>
</div>
</CardContent>
</Card>
{selectedProjectId && periodKRs.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">
周期内 KR ({periodKRs.length})
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{periodKRs.map(kr => (
<div key={kr.id} className="flex items-center gap-4 p-3 bg-slate-50 rounded-lg">
<ProgressRing progress={calculateProgress(kr)} status={kr.status} size={48} />
<div className="flex-1 min-w-0">
<p className="font-medium text-slate-800 truncate">{kr.title}</p>
<p className="text-sm text-muted-foreground">{kr.owner?.name}</p>
</div>
<div className="text-right">
<p className="font-medium">
{kr.current_value}/{kr.target_value} {kr.unit}
</p>
{kr.reason_code && (
<p className="text-xs text-amber-600">{REASON_CODES[kr.reason_code]}</p>
)}
</div>
</div>
))}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle className="text-lg">会议纪要</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="summary"
render={({ field }) => (
<FormItem>
<FormLabel>总结 *</FormLabel>
<FormControl>
<Textarea placeholder="本周期完成情况总结" rows={8} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="decisions"
render={({ field }) => (
<FormItem>
<FormLabel>决议</FormLabel>
<FormControl>
<Textarea placeholder="会议决议" rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="risks"
render={({ field }) => (
<FormItem>
<FormLabel>风险</FormLabel>
<FormControl>
<Textarea placeholder="风险与阻塞" rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="next_period_commitments"
render={({ field }) => (
<FormItem>
<FormLabel>下周期承诺</FormLabel>
<FormControl>
<Textarea placeholder="下周期承诺事项" rows={3} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isSubmitting || !selectedProjectId}
className="bg-teal-600 hover:bg-teal-700"
>
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
<Save className="h-4 w-4 mr-2" />
保存会议纪要
</Button>
</form>
</Form>
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">周期统计</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-3 bg-slate-50 rounded-lg">
<span className="text-muted-foreground">总 KR 数</span>
<span className="text-xl font-bold">{periodKRs.length}</span>
</div>
<div className="flex items-center justify-between p-3 bg-emerald-50 rounded-lg">
<span className="flex items-center gap-2 text-emerald-700">
<CheckCircle className="h-4 w-4" />
已完成
</span>
<span className="text-xl font-bold text-emerald-700">{completedKRs.length}</span>
</div>
<div className="flex items-center justify-between p-3 bg-amber-50 rounded-lg">
<span className="flex items-center gap-2 text-amber-700">
<AlertTriangle className="h-4 w-4" />
需关注
</span>
<span className="text-xl font-bold text-amber-700">{riskKRs.length}</span>
</div>
{periodKRs.length > 0 && (
<div className="pt-4 border-t">
<p className="text-sm text-muted-foreground mb-2">完成率</p>
<p className="text-3xl font-bold text-slate-800">
{Math.round((completedKRs.length / periodKRs.length) * 100)}%
</p>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-lg">历史会议</CardTitle>
</CardHeader>
<CardContent>
{meetings.filter(m => m.project_id === selectedProjectId).length === 0 ? (
<p className="text-center text-muted-foreground py-4">暂无历史记录</p>
) : (
<div className="space-y-3">
{meetings
.filter(m => m.project_id === selectedProjectId)
.slice(0, 5)
.map(meeting => (
<div key={meeting.id} className="p-3 bg-slate-50 rounded-lg">
<p className="text-sm font-medium">
{formatDateRange(meeting.period_start, meeting.period_end)}
</p>
<p className="text-xs text-muted-foreground line-clamp-2 mt-1">
{meeting.summary.split('\n')[0]}
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}