fix: force dynamic for meeting page

This commit is contained in:
李超14pro
2026-01-25 21:55:29 +08:00
commit e3e5e89df6
112 changed files with 19073 additions and 0 deletions

550
app/meeting/page.tsx Normal file
View File

@@ -0,0 +1,550 @@
'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';
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([
fetch('/api/projects'),
fetch('/api/krs'),
fetch('/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 fetch('/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>
);
}