This repository has been archived on 2026-08-27. You can view files and clone it, but cannot push or open issues or pull requests.
Files
2026-08-07 14:39:31 +08:00

247 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import workData from "../data/work-list.json";
import styles from "./WorkList.module.css";
type WorkItem = {
id: string;
code: string;
name: string;
kind: "project" | "milestone" | "task" | "subtask";
owner: { name: string; initial: string; tone: string };
status: string;
priority: string;
progress: number;
startDate: string;
dueDate: string;
tags: string[];
children: WorkItem[];
};
type FlatRow = { item: WorkItem; level: number; parentId?: string };
const initialItems = workData.items as WorkItem[];
function parentIds(items: WorkItem[]): string[] {
return items.flatMap((item) => item.children.length ? [item.id, ...parentIds(item.children)] : []);
}
function flatten(items: WorkItem[], expanded: Set<string>, level = 0, parentId?: string): FlatRow[] {
const rows: FlatRow[] = [];
items.forEach((item) => {
rows.push({ item, level, parentId });
if (item.children.length && expanded.has(item.id)) rows.push(...flatten(item.children, expanded, level + 1, item.id));
});
return rows;
}
function filterTree(items: WorkItem[], search: string, status: string, priority: string): WorkItem[] {
return items.flatMap((item) => {
const children = filterTree(item.children, search, status, priority);
const matchesSearch = !search || `${item.name}${item.code}${item.owner.name}${item.tags.join("")}`.toLowerCase().includes(search.toLowerCase());
const matchesStatus = status === "全部状态" || item.status === status;
const matchesPriority = priority === "全部优先级" || item.priority === priority;
return (matchesSearch && matchesStatus && matchesPriority) || children.length ? [{ ...item, children }] : [];
});
}
function findItem(items: WorkItem[], id: string): WorkItem | undefined {
for (const item of items) {
if (item.id === id) return item;
const child = findItem(item.children, id);
if (child) return child;
}
}
function insertChild(items: WorkItem[], parentId: string, child: WorkItem): WorkItem[] {
return items.map((item) => item.id === parentId ? { ...item, children: [...item.children, child] } : { ...item, children: insertChild(item.children, parentId, child) });
}
export default function WorkListPage() {
const [items, setItems] = useState<WorkItem[]>(initialItems);
const [expanded, setExpanded] = useState<Set<string>>(() => new Set(parentIds(initialItems)));
const [selected, setSelected] = useState<Set<string>>(new Set());
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("全部状态");
const [priorityFilter, setPriorityFilter] = useState("全部优先级");
const [activeTab, setActiveTab] = useState("全部工作");
const [detailId, setDetailId] = useState<string | null>(null);
const [createOpen, setCreateOpen] = useState(false);
const [toast, setToast] = useState("");
useEffect(() => {
const saved = window.localStorage.getItem("orbit-work-list");
if (saved) {
try { setItems(JSON.parse(saved)); }
catch { window.localStorage.removeItem("orbit-work-list"); }
}
}, []);
useEffect(() => {
window.localStorage.setItem("orbit-work-list", JSON.stringify(items));
}, [items]);
const filtered = useMemo(() => filterTree(items, search, statusFilter, priorityFilter), [items, search, statusFilter, priorityFilter]);
const rows = useMemo(() => flatten(filtered, expanded), [filtered, expanded]);
const detailItem = detailId ? findItem(items, detailId) : undefined;
const notify = (message: string) => {
setToast(message);
window.setTimeout(() => setToast(""), 2300);
};
const toggleExpand = (id: string) => setExpanded((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const toggleSelect = (id: string) => setSelected((current) => {
const next = new Set(current);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const toggleAll = () => {
if (rows.length > 0 && rows.every(({ item }) => selected.has(item.id))) setSelected(new Set());
else setSelected(new Set(rows.map(({ item }) => item.id)));
};
const updateStatus = (id: string, status: string) => {
const update = (source: WorkItem[]): WorkItem[] => source.map((item) => item.id === id ? { ...item, status } : { ...item, children: update(item.children) });
setItems(update);
notify("工作状态已保存到本地");
};
const createItem = (name: string, parentId: string, priority: string) => {
const parent = findItem(items, parentId);
const item: WorkItem = {
id: `task-${Date.now()}`,
code: `TASK-${String(Date.now()).slice(-4)}`,
name,
kind: parent?.kind === "task" ? "subtask" : "task",
owner: { name: "林知夏", initial: "林", tone: "blue" },
status: "待处理",
priority,
progress: 0,
startDate: "2026-08-07",
dueDate: "2026-08-21",
tags: ["新任务"],
children: [],
};
setItems((current) => insertChild(current, parentId, item));
setExpanded((current) => new Set([...current, parentId]));
setCreateOpen(false);
notify("新任务已添加到工作列表");
};
return (
<div className={styles.shell}>
<aside className={styles.sidebar}>
<Link href="/" className={styles.brand}><span className={styles.brandMark}><i /><i /><i /></span><strong>Orbit<span>Flow</span></strong></Link>
<nav>
<p></p>
<Link href="/"><span></span></Link>
<a className={styles.active}><span></span><b>36</b></a>
<Link href="/approval"><span></span><em>NEW</em></Link>
<p></p>
<Link href="/"><span></span></Link>
<Link href="/"><span></span></Link>
<Link href="/"><span></span></Link>
<p></p>
<a><span></span></a>
<a><span></span></a>
</nav>
<div className={styles.sidebarFooter}><span></span><div><strong></strong><small></small></div><button></button></div>
</aside>
<main className={styles.main}>
<header className={styles.topbar}>
<div className={styles.breadcrumb}><span></span><b>/</b><strong></strong></div>
<label className={styles.globalSearch}><span></span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索工作项、编号或负责人" /><kbd> K</kbd></label>
<div className={styles.topActions}><button onClick={() => notify("暂无新通知")}><i /></button><span className={styles.avatar}></span></div>
</header>
<section className={styles.content}>
<div className={styles.heading}>
<div><p>WORK MANAGEMENT</p><h1></h1><span></span></div>
<div><button className={styles.secondaryButton} onClick={() => notify("已生成当前视图的演示导出")}> </button><button className={styles.primaryButton} onClick={() => setCreateOpen(true)}> </button></div>
</div>
<div className={styles.summaryGrid}>
<article><span className={`${styles.summaryIcon} ${styles.blue}`}></span><p><strong>{workData.summary.total}</strong><small> 3 </small></p></article>
<article><span className={`${styles.summaryIcon} ${styles.purple}`}></span><p><strong>{workData.summary.inProgress}</strong><small> 4 </small></p></article>
<article><span className={`${styles.summaryIcon} ${styles.orange}`}>!</span><p><strong>{workData.summary.dueSoon}</strong><small>7 </small></p></article>
<article><span className={`${styles.summaryIcon} ${styles.green}`}></span><p><strong>{workData.summary.completed}</strong><small> 44%</small></p></article>
</div>
<section className={styles.tablePanel}>
<div className={styles.tabs}>
<div>{["全部工作", "我负责的", "我参与的", "已归档"].map((tab) => <button key={tab} className={activeTab === tab ? styles.activeTab : ""} onClick={() => { setActiveTab(tab); notify(`已切换到“${tab}”视图`); }}>{tab}{tab === "全部工作" && <b>36</b>}</button>)}</div>
<button className={styles.viewSettings} onClick={() => notify("列设置已打开为模板提示")}> </button>
</div>
<div className={styles.toolbar}>
<label><span></span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="筛选当前列表" /></label>
<select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value)}><option></option><option></option><option></option><option></option><option></option><option></option></select>
<select value={priorityFilter} onChange={(event) => setPriorityFilter(event.target.value)}><option></option><option></option><option></option><option></option></select>
<button onClick={() => { setExpanded(new Set(parentIds(filtered))); notify("已展开全部层级"); }}> </button>
<button onClick={() => setExpanded(new Set())}> </button>
<span className={styles.resultCount}> {rows.length} </span>
</div>
<div className={styles.tableWrap}>
<table>
<thead><tr><th className={styles.checkCell}><input type="checkbox" checked={rows.length > 0 && rows.every(({ item }) => selected.has(item.id))} onChange={toggleAll} aria-label="选择全部" /></th><th></th><th></th><th></th><th></th><th></th><th></th><th></th><th /></tr></thead>
<tbody>
{rows.map(({ item, level }) => (
<tr key={item.id} className={`${styles[`level${level}`]} ${selected.has(item.id) ? styles.selectedRow : ""}`}>
<td className={styles.checkCell}><input type="checkbox" checked={selected.has(item.id)} onChange={() => toggleSelect(item.id)} aria-label={`选择${item.name}`} /></td>
<td className={styles.nameCell}>
<div className={styles.treeName} style={{ paddingLeft: `${level * 23}px` }}>
{level > 0 && <span className={styles.treeGuide} style={{ left: `${level * 23 - 13}px` }} />}
{item.children.length ? <button className={`${styles.expandButton} ${expanded.has(item.id) ? styles.expanded : ""}`} onClick={() => toggleExpand(item.id)}></button> : <span className={styles.leafDot} />}
<span className={`${styles.kindIcon} ${styles[item.kind]}`}>{item.kind === "project" ? "项" : item.kind === "milestone" ? "碑" : item.kind === "task" ? "任" : "子"}</span>
<button className={styles.itemTitle} onClick={() => setDetailId(item.id)}><strong>{item.name}</strong><small>{item.code}</small></button>
{item.tags.slice(0, 1).map((tag) => <span className={styles.rowTag} key={tag}>{tag}</span>)}
</div>
</td>
<td><div className={styles.owner}><span className={styles[item.owner.tone]}>{item.owner.initial}</span><strong>{item.owner.name}</strong></div></td>
<td><span className={`${styles.status} ${styles[`status_${item.status}`]}`}><i />{item.status}</span></td>
<td><span className={`${styles.priority} ${styles[`priority_${item.priority}`]}`}>{item.priority === "高" ? "↑" : item.priority === "低" ? "↓" : "="} {item.priority}</span></td>
<td><div className={styles.progress}><span><i style={{ width: `${item.progress}%` }} /></span><b>{item.progress}%</b></div></td>
<td className={styles.dateCell}>{item.startDate.slice(5).replace("-", "/")}</td>
<td className={`${styles.dateCell} ${item.dueDate <= "2026-08-10" && item.status !== "已完成" ? styles.dueSoon : ""}`}>{item.dueDate.slice(5).replace("-", "/")}</td>
<td><button className={styles.rowAction} onClick={() => setDetailId(item.id)}></button></td>
</tr>
))}
{rows.length === 0 && <tr><td colSpan={9}><div className={styles.empty}><span></span><strong></strong><p></p></div></td></tr>}
</tbody>
</table>
</div>
<footer className={styles.pagination}><span> 36 20 </span><div><button disabled></button><button className={styles.currentPage}>1</button><button>2</button><button></button></div></footer>
</section>
</section>
</main>
{selected.size > 0 && <div className={styles.bulkBar}><span> <strong>{selected.size}</strong> </span><i /><button onClick={() => notify("已批量更新负责人")}></button><button onClick={() => notify("已批量更新状态")}></button><button onClick={() => notify("工作项已加入归档演示")}></button><button onClick={() => setSelected(new Set())}></button></div>}
{detailItem && <DetailDrawer item={detailItem} onClose={() => setDetailId(null)} updateStatus={updateStatus} notify={notify} />}
{createOpen && <CreateModal items={items} onClose={() => setCreateOpen(false)} onCreate={createItem} />}
{toast && <div className={styles.toast}><span></span>{toast}</div>}
</div>
);
}
function DetailDrawer({ item, onClose, updateStatus, notify }: { item: WorkItem; onClose: () => void; updateStatus: (id: string, status: string) => void; notify: (message: string) => void }) {
return <div className={styles.drawerBackdrop} onMouseDown={onClose}><aside className={styles.drawer} onMouseDown={(event) => event.stopPropagation()}><header><div><span className={`${styles.kindIcon} ${styles[item.kind]}`}>{item.kind === "project" ? "项" : item.kind === "milestone" ? "碑" : item.kind === "task" ? "任" : "子"}</span><div><small>{item.code}</small><h2>{item.name}</h2></div></div><button onClick={onClose}>×</button></header><div className={styles.drawerBody}><div className={styles.detailProgress}><div><span></span><strong>{item.progress}%</strong></div><span><i style={{ width: `${item.progress}%` }} /></span></div><section><h3></h3><dl><div><dt></dt><dd><span className={`${styles.miniAvatar} ${styles[item.owner.tone]}`}>{item.owner.initial}</span>{item.owner.name}</dd></div><div><dt></dt><dd><select value={item.status} onChange={(event) => updateStatus(item.id, event.target.value)}><option></option><option></option><option></option><option></option><option></option></select></dd></div><div><dt></dt><dd><span className={`${styles.priority} ${styles[`priority_${item.priority}`]}`}>{item.priority}</span></dd></div><div><dt></dt><dd>{item.startDate} {item.dueDate}</dd></div></dl></section><section><h3></h3><div className={styles.detailTags}>{item.tags.map((tag) => <span key={tag}>{tag}</span>)}<button onClick={() => notify("标签编辑为模板交互")}></button></div></section><section><h3></h3><div className={styles.activity}><span className={`${styles.miniAvatar} ${styles.blue}`}></span><p><strong></strong> <small> 14:32</small></p></div><div className={styles.activity}><span className={`${styles.miniAvatar} ${styles.purple}`}></span><p><strong>屿</strong> <small> 18:08</small></p></div></section></div><footer><button onClick={onClose}></button><button onClick={() => notify("编辑模式已作为模板提示")}></button></footer></aside></div>;
}
function CreateModal({ items, onClose, onCreate }: { items: WorkItem[]; onClose: () => void; onCreate: (name: string, parentId: string, priority: string) => void }) {
const [name, setName] = useState("");
const [parentId, setParentId] = useState(items[0]?.id ?? "");
const [priority, setPriority] = useState("中");
const parents = flatten(items, new Set(parentIds(items))).filter(({ item }) => item.kind !== "subtask");
return <div className={styles.modalBackdrop} onMouseDown={onClose}><form className={styles.modal} onMouseDown={(event) => event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (name.trim() && parentId) onCreate(name.trim(), parentId, priority); }}><header><div><h2></h2><p></p></div><button type="button" onClick={onClose}>×</button></header><label><span></span><input autoFocus value={name} onChange={(event) => setName(event.target.value)} placeholder="请输入任务名称" required /></label><label><span></span><select value={parentId} onChange={(event) => setParentId(event.target.value)}>{parents.map(({ item, level }) => <option key={item.id} value={item.id}>{" ".repeat(level)}{item.name}</option>)}</select></label><fieldset><legend></legend>{["高", "中", "低"].map((value) => <label key={value}><input type="radio" name="priority" checked={priority === value} onChange={() => setPriority(value)} />{value}</label>)}</fieldset><div className={styles.modalRow}><label><span></span><input type="date" defaultValue="2026-08-07" /></label><label><span></span><input type="date" defaultValue="2026-08-21" /></label></div><footer><button type="button" onClick={onClose}></button><button type="submit"></button></footer></form></div>;
}