Files
ok_r/app/projects/[id]/krs/new/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

463 lines
16 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 { useState, useEffect } from 'react';
import { useRouter, useParams } from 'next/navigation';
import Link from 'next/link';
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, FormDescription } from '@/components/ui/form';
import { useUser } from '@/components/user-context';
import { toast } from 'sonner';
import { PERIOD_TYPES, DIRECTION_LABELS } from '@/lib/constants';
import type { Objective, Metric, Template } from '@/types/database';
import { ArrowLeft, Loader2 } from 'lucide-react';
import { getErrorMessage } from '@/lib/error-utils';
import { fetchApi } from '@/lib/api-client';
const formSchema = z.object({
title: z.string().min(1, 'KR标题不能为空'),
objective_id: z.string().optional(),
period_type: z.enum(['custom', '3d', '1w', '2w', 'monthly']),
period_start: z.string().min(1, '开始日期不能为空'),
period_end: z.string().min(1, '结束日期不能为空'),
metric_name: z.string().min(1, '指标名称不能为空'),
unit: z.string().min(1, '单位不能为空'),
direction: z.enum(['increase', 'decrease']),
target_value: z.number().positive('目标值必须大于0'),
owner_user_id: z.string().optional(),
note: z.string().optional(),
});
type FormValues = z.infer<typeof formSchema>;
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
export default function NewKRPage() {
const router = useRouter();
const params = useParams();
const projectId = params.id as string;
const { currentUser, users } = useUser();
const [isSubmitting, setIsSubmitting] = useState(false);
const [objectives, setObjectives] = useState<Objective[]>([]);
const [metrics, setMetrics] = useState<Metric[]>([]);
const [templates, setTemplates] = useState<Template[]>([]);
const [isBooleanMetric, setIsBooleanMetric] = useState(false);
const today = new Date().toISOString().split('T')[0];
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
objective_id: '',
period_type: 'custom',
period_start: today,
period_end: today,
metric_name: '',
unit: '',
direction: 'increase',
target_value: 0,
owner_user_id: currentUser?.id || '',
note: '',
},
});
useEffect(() => {
async function fetchData() {
const [objRes, metricsRes, templatesRes] = await Promise.all([
fetchApi(`/api/objectives?project_id=${projectId}`),
fetchApi('/api/metrics?active_only=true'),
fetchApi('/api/templates?active_only=true'),
]);
if (objRes.ok) {
const data = await objRes.json();
setObjectives(data);
}
if (metricsRes.ok) {
const data = await metricsRes.json();
setMetrics(data);
}
if (templatesRes.ok) {
const data = await templatesRes.json();
setTemplates(data);
}
}
fetchData();
}, [projectId]);
const handlePeriodTypeChange = (value: string) => {
form.setValue('period_type', value as FormValues['period_type']);
const start = new Date(form.getValues('period_start'));
let endDate: Date;
switch (value) {
case '3d':
endDate = new Date(start);
endDate.setDate(start.getDate() + 2);
break;
case '1w':
endDate = new Date(start);
endDate.setDate(start.getDate() + 6);
break;
case '2w':
endDate = new Date(start);
endDate.setDate(start.getDate() + 13);
break;
case 'monthly':
endDate = new Date(start);
endDate.setMonth(start.getMonth() + 1);
endDate.setDate(endDate.getDate() - 1);
break;
default:
return;
}
form.setValue('period_end', endDate.toISOString().split('T')[0]);
};
const handleMetricSelect = (metricId: string) => {
const metric = metrics.find(m => m.id === metricId);
if (metric) {
form.setValue('metric_name', metric.name);
form.setValue('unit', metric.unit);
form.setValue('direction', metric.direction);
}
};
const handleBooleanToggle = (checked: boolean) => {
setIsBooleanMetric(checked);
if (checked) {
form.setValue('metric_name', '完成状态');
form.setValue('unit', '状态');
form.setValue('target_value', 1);
} else {
form.setValue('metric_name', '');
form.setValue('unit', '');
form.setValue('target_value', 0);
}
};
const onSubmit = async (values: FormValues) => {
if (new Date(values.period_end) < new Date(values.period_start)) {
toast.error('结束日期不能早于开始日期');
return;
}
setIsSubmitting(true);
try {
const res = await fetchApi('/api/krs', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...values,
project_id: projectId,
objective_id: values.objective_id || null,
owner_user_id: values.owner_user_id || currentUser?.id,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || '创建失败');
}
const kr = await res.json();
toast.success('KR 创建成功');
router.push(`/krs/${kr.id}`);
} catch (error) {
toast.error(getErrorMessage(error));
} finally {
setIsSubmitting(false);
}
};
return (
<div className="max-w-2xl mx-auto px-4 py-8">
<Link
href={`/projects/${projectId}`}
className="inline-flex items-center text-sm text-muted-foreground hover:text-slate-800 mb-6"
>
<ArrowLeft className="h-4 w-4 mr-1" />
返回项目
</Link>
<Card>
<CardHeader>
<CardTitle>新建 KR (Key Result)</CardTitle>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>KR 标题 *</FormLabel>
<FormControl>
<Input placeholder="如:完成首页加载速度优化" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{objectives.length > 0 && (
<FormField
control={form.control}
name="objective_id"
render={({ field }) => (
<FormItem>
<FormLabel>关联目标 (Objective)</FormLabel>
<Select
onValueChange={(value) => field.onChange(value === "unspecified" ? "" : value)}
value={field.value || "unspecified"}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择关联的目标" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="unspecified">不关联</SelectItem>
{objectives.map(obj => (
<SelectItem key={obj.id} value={obj.id}>
{obj.title}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="grid grid-cols-3 gap-4">
<FormField
control={form.control}
name="period_type"
render={({ field }) => (
<FormItem>
<FormLabel>周期类型</FormLabel>
<Select onValueChange={handlePeriodTypeChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(PERIOD_TYPES).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="period_start"
render={({ field }) => (
<FormItem>
<FormLabel>开始日期 *</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="period_end"
render={({ field }) => (
<FormItem>
<FormLabel>结束日期 *</FormLabel>
<FormControl>
<Input type="date" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{metrics.length > 0 && (
<div>
<FormLabel>从指标库选择</FormLabel>
<Select onValueChange={handleMetricSelect}>
<SelectTrigger className="mt-2">
<SelectValue placeholder="选择预设指标" />
</SelectTrigger>
<SelectContent>
{metrics.map(metric => (
<SelectItem key={metric.id} value={metric.id}>
{metric.name} ({metric.unit})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center space-x-2 my-4">
<Switch id="boolean-mode" onCheckedChange={handleBooleanToggle} />
<Label htmlFor="boolean-mode">这是里程碑式 KR(仅需标记完成/未完成)</Label>
</div>
{!isBooleanMetric && (
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="metric_name"
render={({ field }) => (
<FormItem>
<FormLabel>指标名称 *</FormLabel>
<FormControl>
<Input placeholder="如:页面加载时间" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="unit"
render={({ field }) => (
<FormItem>
<FormLabel>单位 *</FormLabel>
<FormControl>
<Input placeholder="如:秒、个、%" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="direction"
render={({ field }) => (
<FormItem>
<FormLabel>方向</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value} disabled={isBooleanMetric}>
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
{Object.entries(DIRECTION_LABELS).map(([key, label]) => (
<SelectItem key={key} value={key}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="target_value"
render={({ field }) => (
<FormItem>
<FormLabel>目标值 *</FormLabel>
<FormControl>
<Input
type="number"
step="any"
{...field}
onChange={e => field.onChange(parseFloat(e.target.value) || 0)}
disabled={isBooleanMetric}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="owner_user_id"
render={({ field }) => (
<FormItem>
<FormLabel>负责人</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="选择负责人" />
</SelectTrigger>
</FormControl>
<SelectContent>
{users.map(user => (
<SelectItem key={user.id} value={user.id}>
{user.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormDescription>
每人每个周期最多 3 个 KR
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="note"
render={({ field }) => (
<FormItem>
<FormLabel>备注</FormLabel>
<FormControl>
<Textarea placeholder="补充说明" rows={2} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4">
<Button type="submit" disabled={isSubmitting} className="bg-teal-600 hover:bg-teal-700">
{isSubmitting && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
创建 KR
</Button>
<Button type="button" variant="outline" onClick={() => router.back()}>
取消
</Button>
</div>
</form>
</Form>
</CardContent>
</Card>
</div>
);
}