feat: 实现产品/项目/版本三大模块完整功能
- 产品列表:拖拽排序持久化、编辑弹窗、删除校验(活跃版本需迁移) - 项目列表:新建项目表单(产品选择+重名校验)、产品筛选、版本归属修复 - 版本列表:新建版本表单(迭代类型自动生成版本号)、状态/项目筛选、倒序排列 - 项目详情页:概览统计、人员墙(参与次数)、版本时间线(阶段流水线+进度条) - 全局优化:筛选栏统一为header下方固定行、按钮颜色改为主题蓝、API探测300ms、store缓存 - 数据持久化到localStorage,支持离线开发 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,31 @@
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1';
|
||||
|
||||
let apiAvailable: boolean | null = null;
|
||||
let probePromise: Promise<boolean> | null = null;
|
||||
|
||||
async function checkApi(): Promise<boolean> {
|
||||
if (apiAvailable !== null) return apiAvailable;
|
||||
if (probePromise) return probePromise;
|
||||
probePromise = (async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products`, { method: 'HEAD', signal: AbortSignal.timeout(300) });
|
||||
apiAvailable = res.ok;
|
||||
} catch {
|
||||
apiAvailable = false;
|
||||
}
|
||||
return apiAvailable;
|
||||
})();
|
||||
return probePromise;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const available = await checkApi();
|
||||
if (!available) throw new Error('API 不可用');
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json', ...options?.headers },
|
||||
...options,
|
||||
});
|
||||
}).catch(() => null);
|
||||
if (!res) throw new Error('API 不可用');
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({}));
|
||||
throw new Error(error.message || `请求失败: ${res.status}`);
|
||||
|
||||
98
apps/web/lib/derive.ts
Normal file
98
apps/web/lib/derive.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { VersionStatus } from './version-status';
|
||||
import type { Stage, Role } from './stage';
|
||||
|
||||
interface VersionMember {
|
||||
role: Role;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface RoleProgress {
|
||||
role: Role;
|
||||
percent: number;
|
||||
daysSpent: number;
|
||||
}
|
||||
|
||||
interface ProductOverviewLike {
|
||||
id: string;
|
||||
name: string;
|
||||
projects: { id: string; name: string; description: string; createdAt: string }[];
|
||||
versions: {
|
||||
id: string; name: string; status?: string; releaseDate: string | null; createdAt: string;
|
||||
currentStage?: Stage; startDate?: string | null; expectedReleaseDate?: string | null;
|
||||
members?: VersionMember[]; progress?: RoleProgress[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ProjectWithContext {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
versions: VersionWithContext[];
|
||||
}
|
||||
|
||||
export interface VersionWithContext {
|
||||
id: string;
|
||||
name: string;
|
||||
status: VersionStatus;
|
||||
releaseDate: string | null;
|
||||
createdAt: string;
|
||||
productId: string;
|
||||
productName: string;
|
||||
projectName: string;
|
||||
currentStage?: Stage;
|
||||
startDate?: string | null;
|
||||
expectedReleaseDate?: string | null;
|
||||
members?: VersionMember[];
|
||||
progress?: RoleProgress[];
|
||||
}
|
||||
|
||||
export function flattenProjects(overview: ProductOverviewLike[]): ProjectWithContext[] {
|
||||
const result: ProjectWithContext[] = [];
|
||||
for (const product of overview) {
|
||||
for (const project of product.projects) {
|
||||
const versions = product.versions
|
||||
.filter((v) => v.name.toLowerCase().startsWith(project.name.toLowerCase()))
|
||||
.map((v) => ({
|
||||
...v,
|
||||
status: (v.status || 'released') as VersionStatus,
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
projectName: project.name,
|
||||
}));
|
||||
result.push({
|
||||
...project,
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
versions,
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function flattenVersions(overview: ProductOverviewLike[]): VersionWithContext[] {
|
||||
const result: VersionWithContext[] = [];
|
||||
for (const product of overview) {
|
||||
for (const version of product.versions) {
|
||||
const project = product.projects.find((p) =>
|
||||
version.name.toLowerCase().startsWith(p.name.toLowerCase()),
|
||||
);
|
||||
result.push({
|
||||
...version,
|
||||
status: (version.status || 'released') as VersionStatus,
|
||||
productId: product.id,
|
||||
productName: product.name,
|
||||
projectName: project?.name || '未关联',
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getProjectDetail(overview: ProductOverviewLike[], projectId: string): ProjectWithContext | null {
|
||||
const projects = flattenProjects(overview);
|
||||
return projects.find((p) => p.id === projectId) || null;
|
||||
}
|
||||
36
apps/web/lib/stage.ts
Normal file
36
apps/web/lib/stage.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type Stage = 'requirement' | 'product_design' | 'ui_design' | 'dev' | 'integration' | 'testing' | 'released';
|
||||
|
||||
export type Role = 'product' | 'ui' | 'frontend' | 'backend' | 'testing';
|
||||
|
||||
export const STAGES: { key: Stage; label: string }[] = [
|
||||
{ key: 'requirement', label: '需求' },
|
||||
{ key: 'product_design', label: '产品设计' },
|
||||
{ key: 'ui_design', label: 'UI 设计' },
|
||||
{ key: 'dev', label: '开发' },
|
||||
{ key: 'integration', label: '联调' },
|
||||
{ key: 'testing', label: '测试' },
|
||||
{ key: 'released', label: '上线' },
|
||||
];
|
||||
|
||||
export const STAGE_INDEX: Record<Stage, number> = STAGES.reduce(
|
||||
(acc, s, i) => ({ ...acc, [s.key]: i }),
|
||||
{} as Record<Stage, number>,
|
||||
);
|
||||
|
||||
export const STAGE_LABEL: Record<Stage, string> = STAGES.reduce(
|
||||
(acc, s) => ({ ...acc, [s.key]: s.label }),
|
||||
{} as Record<Stage, string>,
|
||||
);
|
||||
|
||||
export const ROLES: { key: Role; label: string }[] = [
|
||||
{ key: 'product', label: '产品' },
|
||||
{ key: 'ui', label: 'UI' },
|
||||
{ key: 'frontend', label: '前端' },
|
||||
{ key: 'backend', label: '后端' },
|
||||
{ key: 'testing', label: '测试' },
|
||||
];
|
||||
|
||||
export const ROLE_LABEL: Record<Role, string> = ROLES.reduce(
|
||||
(acc, r) => ({ ...acc, [r.key]: r.label }),
|
||||
{} as Record<Role, string>,
|
||||
);
|
||||
19
apps/web/lib/version-status.ts
Normal file
19
apps/web/lib/version-status.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export type VersionStatus = 'developing' | 'planned' | 'released';
|
||||
|
||||
export const VERSION_STATUS_LABEL: Record<VersionStatus, string> = {
|
||||
developing: '开发中',
|
||||
planned: '规划中',
|
||||
released: '已发布',
|
||||
};
|
||||
|
||||
export const VERSION_STATUS_DOT: Record<VersionStatus, string> = {
|
||||
developing: 'bg-blue-500',
|
||||
planned: 'bg-orange-500',
|
||||
released: 'bg-zinc-300',
|
||||
};
|
||||
|
||||
export const VERSION_STATUS_BG: Record<VersionStatus, string> = {
|
||||
developing: 'bg-blue-500/10 text-blue-600',
|
||||
planned: 'bg-orange-500/10 text-orange-600',
|
||||
released: 'bg-zinc-100 text-zinc-600',
|
||||
};
|
||||
Reference in New Issue
Block a user