feat(工作台): 细化计划进展工时证据
关键改动: - 支持需求覆盖和调研方向开始工作记录 - 日报按计划记录开始时间和进度下次开始时间计算证据 - 更新版本编辑校验、项目展示和 workflow 说明 Co-Authored-By: Codex GPT-5 <codex@openai.com>
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { RouteGuard } from '@/components/auth/Guard';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Search, FolderKanban, Package, ChevronDown, Plus, X, MoreHorizontal, Trash2 } from 'lucide-react';
|
||||
import { Search, FolderKanban, Package, ChevronDown, Plus, X, MoreHorizontal, Trash2, Pencil } from 'lucide-react';
|
||||
import { useProductStore } from '@/stores/useProductStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useVersionPlanStore } from '@/stores/useVersionPlanStore';
|
||||
@@ -25,7 +25,7 @@ export default function ProjectsPage() {
|
||||
|
||||
function ProjectsPageContent() {
|
||||
const router = useRouter();
|
||||
const { overview, fetchOverview, createProject, deleteProject } = useProductStore();
|
||||
const { overview, fetchOverview, createProject, updateProject, deleteProject } = useProductStore();
|
||||
const { requirements, fetchRequirements } = useRequirementStore();
|
||||
const { plans, fetchPlans } = useVersionPlanStore();
|
||||
const { tasks: devTasks, fetchTasks: fetchDevTasks } = useDevTaskStore();
|
||||
@@ -33,6 +33,7 @@ function ProjectsPageContent() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [productFilter, setProductFilter] = useState<string>('all');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingProject, setEditingProject] = useState<ProjectWithContext | null>(null);
|
||||
|
||||
useEffect(() => { fetchOverview(); }, [fetchOverview]);
|
||||
useEffect(() => { fetchRequirements(); }, [fetchRequirements]);
|
||||
@@ -127,6 +128,7 @@ function ProjectsPageContent() {
|
||||
proj={proj}
|
||||
versionProgressMap={versionProgressMap}
|
||||
onClick={() => router.push(`/projects/${proj.id}`)}
|
||||
onEdit={() => setEditingProject(proj)}
|
||||
onDelete={() => {
|
||||
if (!confirm(`确认删除项目「${proj.name}」?此操作不可恢复。`)) return;
|
||||
deleteProject(proj.productId, proj.id);
|
||||
@@ -150,6 +152,18 @@ function ProjectsPageContent() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingProject && (
|
||||
<EditProjectModal
|
||||
project={editingProject}
|
||||
overview={overview}
|
||||
onClose={() => setEditingProject(null)}
|
||||
onSubmit={async (name) => {
|
||||
updateProject(editingProject.productId, editingProject.id, { name });
|
||||
setEditingProject(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -160,13 +174,16 @@ function ProjectRow({
|
||||
proj,
|
||||
versionProgressMap,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
proj: ProjectWithContext;
|
||||
versionProgressMap: Record<string, number>;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const canEdit = useHasPermission('project:edit');
|
||||
const canDelete = useHasPermission('project:delete');
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const activeVersions = proj.versions.filter((v) => (versionProgressMap[v.id] ?? 0) < 100);
|
||||
@@ -200,7 +217,7 @@ function ProjectRow({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{canDelete && (
|
||||
{(canEdit || canDelete) && (
|
||||
<div className="relative shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
@@ -213,19 +230,33 @@ function ProjectRow({
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full z-50 mt-1 min-w-[160px] rounded-lg border border-[var(--line)] bg-[var(--bg-card)] py-1 shadow-[var(--shadow-md)]">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!canActuallyDelete) return;
|
||||
setMenuOpen(false);
|
||||
onDelete();
|
||||
}}
|
||||
disabled={!canActuallyDelete}
|
||||
title={canActuallyDelete ? '' : `项目下还有 ${versionCount} 个版本,不可删除`}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] transition-colors ${canActuallyDelete ? 'text-red-600 hover:bg-red-50' : 'text-[var(--ink-muted)] cursor-not-allowed'}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除项目
|
||||
</button>
|
||||
{canEdit && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
onEdit();
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] text-[var(--ink-soft)] transition-colors hover:bg-[var(--bg-hover)]"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
编辑项目
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!canActuallyDelete) return;
|
||||
setMenuOpen(false);
|
||||
onDelete();
|
||||
}}
|
||||
disabled={!canActuallyDelete}
|
||||
title={canActuallyDelete ? '' : `项目下还有 ${versionCount} 个版本,不可删除`}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left text-[12px] transition-colors ${canActuallyDelete ? 'text-red-600 hover:bg-red-50' : 'text-[var(--ink-muted)] cursor-not-allowed'}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
删除项目
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -335,6 +366,94 @@ function EmptyState() {
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── EditProjectModal ───────────────────────────────────────────── */
|
||||
|
||||
function EditProjectModal({
|
||||
project,
|
||||
overview,
|
||||
onClose,
|
||||
onSubmit,
|
||||
}: {
|
||||
project: ProjectWithContext;
|
||||
overview: any[];
|
||||
onClose: () => void;
|
||||
onSubmit: (name: string) => Promise<void>;
|
||||
}) {
|
||||
const [name, setName] = useState(project.name);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [duplicateError, setDuplicateError] = useState(false);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const nextName = name.trim();
|
||||
if (!nextName) return;
|
||||
|
||||
const product = overview.find((p: any) => p.id === project.productId);
|
||||
const exists = product?.projects?.some(
|
||||
(proj: any) => proj.id !== project.id && proj.name === nextName,
|
||||
);
|
||||
if (exists) {
|
||||
setDuplicateError(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit(nextName);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="w-full max-w-md rounded-2xl bg-[var(--bg-card)] p-6 shadow-[var(--shadow-md)]">
|
||||
<div className="mb-5 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-[var(--ink)]">编辑项目</h2>
|
||||
<button type="button" onClick={onClose} className="text-[var(--ink-muted)] hover:text-[var(--ink)]">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">
|
||||
项目名称 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => { setName(e.target.value); setDuplicateError(false); }}
|
||||
className={`h-9 w-full rounded-lg border bg-[var(--bg)] px-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:outline-none focus:ring-2 ${
|
||||
duplicateError
|
||||
? 'border-red-500 focus:border-red-500 focus:ring-red-100'
|
||||
: 'border-[var(--line)] focus:border-[var(--accent)] focus:ring-[var(--accent-ring)]'
|
||||
}`}
|
||||
/>
|
||||
{duplicateError && (
|
||||
<p className="mt-1 text-[11px] text-red-500">该产品下已存在同名项目</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="h-8 rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-hover)]"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !name.trim() || name.trim() === project.name}
|
||||
className="h-8 rounded-lg bg-[var(--accent)] px-3 text-[13px] font-medium text-white shadow-[var(--shadow-sm)] hover:bg-[var(--accent-hover)] disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{submitting ? '保存中…' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─── CreateProjectModal ─────────────────────────────────────────── */
|
||||
|
||||
function CreateProjectModal({
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react';
|
||||
import { AlertTriangle, Calendar, Check, ChevronLeft, Clock, FileText, Link2, Pencil, Search, Settings, Sparkles, UserPlus, X } from 'lucide-react';
|
||||
import { useProductStore } from '@/stores/useProductStore';
|
||||
import { useRequirementStore } from '@/stores/useRequirementStore';
|
||||
import { useOvertimeStore } from '@/stores/useOvertimeStore';
|
||||
import { getVersionDetail } from '@/lib/derive';
|
||||
import { getVersionDetail, type Priority, type VersionWithContext } from '@/lib/derive';
|
||||
import type { Role } from '@/lib/stage';
|
||||
import { CapsuleStages } from '@/components/version/CapsuleStages';
|
||||
import { FilterSelect } from '@/components/FilterSelect';
|
||||
@@ -36,6 +36,16 @@ import { getRequirementCoverageSummary } from '@/lib/version-plan';
|
||||
import { buildVersionProgressMap } from '@/lib/version-progress';
|
||||
import { canSubmitReleaseForm, getReleaseProgressWarning } from '@/lib/version-release';
|
||||
import { isVersionReadonly } from '@/lib/version-status';
|
||||
import {
|
||||
VERSION_DEVELOPMENT_TYPE_OPTIONS,
|
||||
buildVersionName,
|
||||
canSubmitVersionEditForm,
|
||||
getEditableVersionNumber,
|
||||
getRecommendedExpectedReleaseDate,
|
||||
getRecommendedWorkDays,
|
||||
getVersionDevelopmentTypeOption,
|
||||
type VersionDevelopmentType,
|
||||
} from '@/lib/version-form';
|
||||
|
||||
function formatOverviewDateTime(value?: string | null): string {
|
||||
if (!value) return '-';
|
||||
@@ -107,6 +117,7 @@ export default function VersionDetailPage() {
|
||||
}, [visibleTabs, activeTab]);
|
||||
const [showMemberModal, setShowMemberModal] = useState(false);
|
||||
const [showRecommendModal, setShowRecommendModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showReleaseModal, setShowReleaseModal] = useState(false);
|
||||
const [recommendationDataReady, setRecommendationDataReady] = useState(false);
|
||||
|
||||
@@ -209,11 +220,12 @@ export default function VersionDetailPage() {
|
||||
});
|
||||
|
||||
const renderActions = () => {
|
||||
const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release' }[] = [];
|
||||
const buttons: { label: string; action: () => void; danger?: boolean; tone?: 'release'; icon?: JSX.Element }[] = [];
|
||||
if (versionReadonly) return null;
|
||||
if (version.status !== 'released' && version.status !== 'closed') {
|
||||
buttons.push({ label: '发版', action: () => setShowReleaseModal(true), tone: 'release' });
|
||||
}
|
||||
buttons.push({ label: '编辑', action: () => setShowEditModal(true), icon: <Pencil className="h-3.5 w-3.5" /> });
|
||||
if (version.status === 'planned') {
|
||||
buttons.push({ label: '删除', action: () => {
|
||||
if (confirm('确认删除该版本?关联的需求会回到需求池,版本下的计划、开发任务、测试用例、Bug 将被清除。')) {
|
||||
@@ -243,8 +255,9 @@ export default function VersionDetailPage() {
|
||||
<button
|
||||
key={btn.label}
|
||||
onClick={btn.action}
|
||||
className={`h-7 px-3 rounded-md text-[12px] font-medium border transition-colors ${btn.danger ? 'border-red-200 text-red-600 hover:bg-red-50' : btn.tone === 'release' ? 'border-emerald-200 text-emerald-700 hover:bg-emerald-50' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
|
||||
className={`inline-flex h-7 items-center gap-1.5 rounded-md border px-3 text-[12px] font-medium transition-colors ${btn.danger ? 'border-red-200 text-red-600 hover:bg-red-50' : btn.tone === 'release' ? 'border-emerald-200 text-emerald-700 hover:bg-emerald-50' : 'border-[var(--line)] text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]'}`}
|
||||
>
|
||||
{btn.icon}
|
||||
{btn.label}
|
||||
</button>
|
||||
));
|
||||
@@ -950,6 +963,17 @@ export default function VersionDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showEditModal && !versionReadonly && (
|
||||
<VersionEditModal
|
||||
version={version}
|
||||
onSubmit={(data) => {
|
||||
updateVersion(version.productId, version.id, data);
|
||||
setShowEditModal(false);
|
||||
}}
|
||||
onClose={() => setShowEditModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRecommendModal && !versionReadonly && (
|
||||
<MemberRecommendationModal
|
||||
groups={memberRecommendationGroups}
|
||||
@@ -992,6 +1016,189 @@ export default function VersionDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const PRIORITY_OPTIONS: Priority[] = ['P0', 'P1', 'P2', 'P3', 'P4'];
|
||||
|
||||
function toDateInputValue(value?: string | null): string {
|
||||
if (!value) return '';
|
||||
return value.includes('T') ? value.slice(0, 10) : value;
|
||||
}
|
||||
|
||||
function VersionEditModal({ version, onSubmit, onClose }: {
|
||||
version: VersionWithContext;
|
||||
onSubmit: (data: { name: string; priority: Priority; expectedReleaseDate: string }) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [versionNumber, setVersionNumber] = useState(() => getEditableVersionNumber(version.projectName, version.name));
|
||||
const [priority, setPriority] = useState<Priority>((version.priority ?? 'P2') as Priority);
|
||||
const [developmentType, setDevelopmentType] = useState<VersionDevelopmentType | ''>('');
|
||||
const [productDesignCompleted, setProductDesignCompleted] = useState(false);
|
||||
const [expectedReleaseDate, setExpectedReleaseDate] = useState(() => toDateInputValue(version.expectedReleaseDate));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const selectedDevelopmentType = developmentType ? getVersionDevelopmentTypeOption(developmentType) : undefined;
|
||||
const recommendedWorkDays = selectedDevelopmentType
|
||||
? getRecommendedWorkDays(selectedDevelopmentType, { productDesignCompleted })
|
||||
: 0;
|
||||
const canSubmit = canSubmitVersionEditForm({ versionNumber, expectedReleaseDate, submitting });
|
||||
|
||||
const handleVersionInput = (value: string) => {
|
||||
setVersionNumber(value.replace(/[^\d.]/g, ''));
|
||||
};
|
||||
|
||||
const applyRecommendedReleaseDate = (type: VersionDevelopmentType, productDesigned: boolean) => {
|
||||
setExpectedReleaseDate(getRecommendedExpectedReleaseDate(type, new Date(), { productDesignCompleted: productDesigned }));
|
||||
};
|
||||
|
||||
const handleDevelopmentTypeChange = (type: VersionDevelopmentType) => {
|
||||
setDevelopmentType(type);
|
||||
applyRecommendedReleaseDate(type, productDesignCompleted);
|
||||
};
|
||||
|
||||
const handleProductDesignCompletedChange = (completed: boolean) => {
|
||||
setProductDesignCompleted(completed);
|
||||
if (developmentType) applyRecommendedReleaseDate(developmentType, completed);
|
||||
};
|
||||
|
||||
const handleSubmit = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!canSubmit) return;
|
||||
setSubmitting(true);
|
||||
onSubmit({
|
||||
name: buildVersionName(version.projectName, versionNumber),
|
||||
priority,
|
||||
expectedReleaseDate,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/40 px-4" onClick={onClose}>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl border border-[var(--line)] bg-[var(--bg-card)] shadow-md"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 border-b border-[var(--line)] px-5 py-4">
|
||||
<div>
|
||||
<h3 className="text-[15px] font-semibold text-[var(--ink)]">编辑版本</h3>
|
||||
<p className="mt-1 text-[11px] text-[var(--ink-muted)]">
|
||||
{version.productName} / {version.projectName}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="rounded-md p-1.5 text-[var(--ink-muted)] hover:bg-[var(--bg-subtle)] hover:text-[var(--ink)]">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-5 py-4">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-[12px] font-medium text-[var(--ink-soft)]">版本号<span className="text-red-500">*</span></span>
|
||||
<input
|
||||
value={versionNumber}
|
||||
onChange={(event) => handleVersionInput(event.target.value)}
|
||||
placeholder="1.0"
|
||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] tabular-nums text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
{version.projectName && versionNumber && (
|
||||
<p className="mt-1.5 text-[11px] text-[var(--ink-muted)]">保存后版本名:<span className="font-medium text-[var(--ink-soft)]">{buildVersionName(version.projectName, versionNumber)}</span></p>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-[var(--ink-soft)]">优先级</div>
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{PRIORITY_OPTIONS.map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
onClick={() => setPriority(item)}
|
||||
className={`h-9 rounded-lg border px-3 text-[12px] font-medium transition-colors ${priority === item ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-[var(--ink-soft)]">项目开发类型</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{VERSION_DEVELOPMENT_TYPE_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
type="button"
|
||||
onClick={() => handleDevelopmentTypeChange(option.key)}
|
||||
className={`rounded-lg border px-3 py-2 text-left transition-colors ${developmentType === option.key ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}
|
||||
>
|
||||
<span className="block text-[12px] font-medium">{option.label}</span>
|
||||
<span className="mt-0.5 block text-[10px] text-[var(--ink-muted)]">{option.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1.5 text-[12px] font-medium text-[var(--ink-soft)]">产品设计是否已完成</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProductDesignCompletedChange(false)}
|
||||
className={`h-9 rounded-lg border px-3 text-[12px] font-medium transition-colors ${!productDesignCompleted ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}
|
||||
>
|
||||
否,计入产品设计
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProductDesignCompletedChange(true)}
|
||||
className={`h-9 rounded-lg border px-3 text-[12px] font-medium transition-colors ${productDesignCompleted ? 'border-[var(--accent)] bg-[var(--accent-soft)] text-[var(--accent)]' : 'border-[var(--line)] bg-[var(--bg-card)] text-[var(--ink-soft)] hover:border-[var(--accent)]'}`}
|
||||
>
|
||||
是,只算开发测试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-1.5 flex items-center gap-1.5 text-[12px] font-medium text-[var(--ink-soft)]">
|
||||
<Calendar className="h-3.5 w-3.5" />
|
||||
期望发版日期<span className="text-red-500">*</span>
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={expectedReleaseDate}
|
||||
onChange={(event) => setExpectedReleaseDate(event.target.value)}
|
||||
required
|
||||
className="h-9 w-full rounded-lg border border-[var(--line)] bg-[var(--bg)] px-3 text-[13px] text-[var(--ink)] outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
{selectedDevelopmentType && (
|
||||
<p className="mt-1.5 text-[11px] leading-5 text-[var(--ink-muted)]">
|
||||
推荐周期 {recommendedWorkDays} 个工作日:
|
||||
{selectedDevelopmentType.stages.map((stage, index) => {
|
||||
const skipped = productDesignCompleted && stage.key === 'product_design';
|
||||
return (
|
||||
<span key={stage.key}>
|
||||
{index > 0 && ' + '}
|
||||
<span className={skipped ? 'text-[var(--ink-muted)] line-through' : ''}>
|
||||
{stage.label}{stage.workDays}天
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 border-t border-[var(--line)] px-5 py-4">
|
||||
<button type="button" onClick={onClose} className="h-8 rounded-md border border-[var(--line)] px-3 text-[12px] font-medium text-[var(--ink-soft)] hover:bg-[var(--bg-subtle)]">
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" disabled={!canSubmit} className="h-8 rounded-md bg-[var(--accent)] px-4 text-[12px] font-semibold text-white hover:bg-[var(--accent-hover)] disabled:cursor-not-allowed disabled:opacity-50">
|
||||
{submitting ? '保存中' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReleaseVersionModal({ progress, initialDate, onSubmit, onClose }: {
|
||||
progress: number;
|
||||
initialDate: string;
|
||||
|
||||
Reference in New Issue
Block a user