diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index b5c61c9..96534d0 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -1,3 +1,52 @@ +@import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500;9..144,600;9..144,700&family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,600;9..40,700&family=JetBrains+Mono:wght@400;500&display=swap'); + @tailwind base; @tailwind components; @tailwind utilities; + +:root { + --bg: #f7f4ee; + --bg-elevated: #fbf9f4; + --ink: #1a1814; + --ink-soft: #4a4640; + --ink-muted: #8a847a; + --line: #e6e0d4; + --line-soft: #efeae0; + --accent: #2563eb; + --accent-soft: #dbeafe; +} + +html, body { + background: var(--bg); + color: var(--ink); + font-family: 'DM Sans', system-ui, sans-serif; + font-feature-settings: 'ss01', 'ss02', 'cv11'; + -webkit-font-smoothing: antialiased; +} + +.font-display { + font-family: 'Fraunces', Georgia, serif; + font-feature-settings: 'ss01'; +} + +.font-mono { + font-family: 'JetBrains Mono', monospace; +} + +.grain::before { + content: ''; + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0.03; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +@keyframes fade-up { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.animate-fade-up { + animation: fade-up 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) backwards; +} diff --git a/apps/web/app/products/[id]/page.tsx b/apps/web/app/products/[id]/page.tsx index fc5e7b2..7114ee1 100644 --- a/apps/web/app/products/[id]/page.tsx +++ b/apps/web/app/products/[id]/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; +import { ChevronLeft, Pencil, Trash2 } from 'lucide-react'; import { RequirementStatus } from '@ftb/shared'; import { useProductStore } from '@/stores/useProductStore'; import { useRequirementStore } from '@/stores/useRequirementStore'; @@ -9,13 +10,28 @@ import { RequirementTable } from '@/components/product/RequirementTable'; import { RequirementForm } from '@/components/product/RequirementForm'; import { ProductForm } from '@/components/product/ProductForm'; +const TABS = [ + { key: 'requirements' as const, label: '需求池', index: '01' }, + { key: 'projects' as const, label: '项目', index: '02' }, + { key: 'versions' as const, label: '版本', index: '03' }, +]; + export default function ProductDetailPage() { const params = useParams(); const router = useRouter(); const productId = params.id as string; const { currentProduct, fetchProduct, updateProduct, deleteProduct } = useProductStore(); - const { requirements, statusFilter, fetchRequirements, createRequirement, updateRequirement, updateStatus, deleteRequirement, setStatusFilter } = useRequirementStore(); + const { + requirements, + statusFilter, + fetchRequirements, + createRequirement, + updateRequirement, + updateStatus, + deleteRequirement, + setStatusFilter, + } = useRequirementStore(); const [showReqForm, setShowReqForm] = useState(false); const [editingReq, setEditingReq] = useState(null); @@ -45,89 +61,142 @@ export default function ProductDetailPage() { }; const handleDeleteProduct = async () => { - if (confirm('确定要删除此产品吗?')) { + if (confirm('确定要删除此产品吗?此操作不可撤销。')) { await deleteProduct(productId); router.push('/products'); } }; if (!currentProduct) { - return
加载中...
; + return ( +
+

Loading...

+
+ ); } return ( -
-
- router.push('/products')}>产品列表 - / - {currentProduct.name} -
+
+
+
+ -
-

{currentProduct.name}

-
- - +
+
+

+ ── Product +

+

+ {currentProduct.name} +

+ {currentProduct.description && ( +

+ {currentProduct.description} +

+ )} +
+
+ + +
+
+ + {editingProduct && ( +
+

── 编辑产品

+ { + await updateProduct(productId, data); + setEditingProduct(false); + await fetchProduct(productId); + }} + onCancel={() => setEditingProduct(false)} + /> +
+ )} + +
+ {TABS.map((tab) => ( + + ))}
+ + {activeTab === 'requirements' && ( +
+ {(showReqForm || editingReq) && ( +
+

+ ── {editingReq ? '编辑需求' : '新建需求'} +

+ { + setShowReqForm(false); + setEditingReq(null); + }} + /> +
+ )} + setEditingReq(req)} + onStatusChange={(id, status) => updateStatus(productId, id, status)} + onDelete={(id) => deleteRequirement(productId, id)} + onCreate={() => setShowReqForm(true)} + /> +
+ )} + + {activeTab === 'projects' && ( +
+

项目管理

+

即将到来 · Coming Soon

+
+ )} + + {activeTab === 'versions' && ( +
+

版本管理

+

即将到来 · Coming Soon

+
+ )}
- - {currentProduct.description && ( -

{currentProduct.description}

- )} - - {editingProduct && ( -
- { await updateProduct(productId, data); setEditingProduct(false); await fetchProduct(productId); }} - onCancel={() => setEditingProduct(false)} - /> -
- )} - -
- {(['requirements', 'projects', 'versions'] as const).map((tab) => ( - - ))} -
- - {activeTab === 'requirements' && ( - <> - {(showReqForm || editingReq) && ( -
-

{editingReq ? '编辑需求' : '新建需求'}

- { setShowReqForm(false); setEditingReq(null); }} - /> -
- )} - setEditingReq(req)} - onStatusChange={(id, status) => updateStatus(productId, id, status)} - onDelete={(id) => deleteRequirement(productId, id)} - onCreate={() => setShowReqForm(true)} - /> - - )} - - {activeTab === 'projects' && ( -
项目管理(后续迭代实现)
- )} - - {activeTab === 'versions' && ( -
版本管理(后续迭代实现)
- )}
); } diff --git a/apps/web/app/products/page.tsx b/apps/web/app/products/page.tsx index 932be90..30e492e 100644 --- a/apps/web/app/products/page.tsx +++ b/apps/web/app/products/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import { useRouter } from 'next/navigation'; +import { Plus, Search } from 'lucide-react'; import { useProductStore } from '@/stores/useProductStore'; import { ProductCard } from '@/components/product/ProductCard'; import { ProductForm } from '@/components/product/ProductForm'; @@ -10,50 +11,102 @@ export default function ProductsPage() { const router = useRouter(); const { products, loading, fetchProducts, createProduct } = useProductStore(); const [showForm, setShowForm] = useState(false); + const [search, setSearch] = useState(''); useEffect(() => { fetchProducts(); }, [fetchProducts]); + const filtered = products.filter((p) => + p.name.toLowerCase().includes(search.toLowerCase()), + ); + const handleCreate = async (data: { name: string; description: string }) => { await createProduct(data); setShowForm(false); }; return ( -
-
-

产品列表

- +
+
+ +
+
+
+

+ ── 02 / Products +

+

+ 产品目录 +

+

+ 组织内所有产品的索引。每个产品聚合需求池、发布版本与归属项目。 +

+
+ +
+
+ + setSearch(e.target.value)} + placeholder="搜索产品" + className="w-56 rounded-md border border-[var(--line)] bg-[var(--bg-elevated)] py-2 pl-9 pr-3 text-[13px] text-[var(--ink)] placeholder:text-[var(--ink-muted)] focus:border-[var(--ink)] focus:outline-none" + /> +
+ +
+
+ +
+

+ Showing +

+

+ {String(filtered.length).padStart(2, '0')} +

+ / {String(products.length).padStart(2, '0')} 个产品 +
+ + {showForm && ( +
+

── 新建产品

+ setShowForm(false)} /> +
+ )} + + {loading ? ( +
+

Loading...

+
+ ) : filtered.length === 0 ? ( +
+

+ {search ? '没有匹配的产品' : '尚未创建任何产品'} +

+

+ {search ? '试试其他关键字' : '点击右上角按钮,创建第一个产品'} +

+
+ ) : ( +
+ {filtered.map((product, idx) => ( + router.push(`/products/${id}`)} + /> + ))} +
+ )}
- - {showForm && ( -
-

新建产品

- setShowForm(false)} /> -
- )} - - {loading ? ( -
加载中...
- ) : products.length === 0 ? ( -
暂无产品,点击上方按钮创建
- ) : ( -
- {products.map((product) => ( - router.push(`/products/${id}`)} - /> - ))} -
- )}
); } diff --git a/apps/web/components/layout/Sidebar.tsx b/apps/web/components/layout/Sidebar.tsx index 568e278..9374061 100644 --- a/apps/web/components/layout/Sidebar.tsx +++ b/apps/web/components/layout/Sidebar.tsx @@ -1,63 +1,114 @@ 'use client'; import { usePathname, useRouter } from 'next/navigation'; +import { Inbox, Package, FolderKanban, Tag, Users, Sparkles } from 'lucide-react'; const NAV_ITEMS = [ - { label: '与我相关', path: '/workspace', icon: '📋' }, - { label: '产品', path: '/products', icon: '📦' }, - { label: '项目', path: '/projects', icon: '📁' }, - { label: '版本', path: '/versions', icon: '🏷️' }, + { label: '与我相关', path: '/workspace', icon: Inbox, index: '01' }, + { label: '产品', path: '/products', icon: Package, index: '02' }, + { label: '项目', path: '/projects', icon: FolderKanban, index: '03' }, + { label: '版本', path: '/versions', icon: Tag, index: '04' }, ]; const ADMIN_ITEMS = [ - { label: '成员管理', path: '/admin/members', icon: '👥' }, + { label: '成员管理', path: '/admin/members', icon: Users }, ]; export function Sidebar() { const pathname = usePathname(); const router = useRouter(); - const isActive = (path: string) => pathname.startsWith(path); + const isActive = (path: string) => + pathname === path || (path !== '/' && pathname.startsWith(path)); return ( -