Add visual approval flow designer

This commit is contained in:
Codex
2026-08-07 14:24:04 +08:00
parent 8322800b57
commit 94b003356e
7 changed files with 1062 additions and 257 deletions

252
app/AdminApp.tsx Normal file
View File

@@ -0,0 +1,252 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import mockData from "./data/mock-data.json";
type View = "overview" | "components" | "content" | "board" | "schedule";
type Toast = { id: number; message: string };
type BoardColumn = (typeof mockData.board)[number];
const navGroups = [
{
label: "工作台",
items: [
{ id: "overview" as View, icon: "⌂", label: "概览" },
{ id: "components" as View, icon: "◈", label: "组件样式", badge: "18" },
],
},
{
label: "业务管理",
items: [
{ id: "content" as View, icon: "▤", label: "内容管理", children: ["图文列表", "卡片内容", "分类标签"] },
{ id: "board" as View, icon: "▦", label: "流程泳道", badge: "9" },
{ id: "schedule" as View, icon: "◷", label: "定时任务" },
],
},
{
label: "系统设置",
items: [
{ id: "components" as View, icon: "♙", label: "成员与权限" },
{ id: "components" as View, icon: "⚙", label: "基础设置" },
],
},
];
const pageMeta: Record<View, { title: string; subtitle: string }> = {
overview: { title: "下午好,林知夏", subtitle: "这里是团队今天的工作概览,祝你高效完成每一项计划。" },
components: { title: "组件样式", subtitle: "可直接复用的标签、按钮、菜单与交互状态。" },
content: { title: "内容管理", subtitle: "通过图片文字列表与卡片视图管理业务内容。" },
board: { title: "流程泳道", subtitle: "拖动任务卡片,快速同步团队当前进度。" },
schedule: { title: "定时任务", subtitle: "纯前端模拟任务配置、状态切换与执行记录。" },
};
export default function Home() {
const [activeView, setActiveView] = useState<View>("overview");
const [expandedMenus, setExpandedMenus] = useState<string[]>(["内容管理"]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [search, setSearch] = useState("");
const [toasts, setToasts] = useState<Toast[]>([]);
const [board, setBoard] = useState<BoardColumn[]>(mockData.board);
const [dragged, setDragged] = useState<{ cardId: string; columnId: string } | null>(null);
const [schedules, setSchedules] = useState(mockData.schedules);
const [scheduleModal, setScheduleModal] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
useEffect(() => {
const saved = window.localStorage.getItem("orbit-schedules");
if (saved) {
try { setSchedules(JSON.parse(saved)); }
catch { window.localStorage.removeItem("orbit-schedules"); }
}
}, []);
useEffect(() => {
window.localStorage.setItem("orbit-schedules", JSON.stringify(schedules));
}, [schedules]);
const notify = (message: string) => {
const id = Date.now();
setToasts((current) => [...current, { id, message }]);
window.setTimeout(() => setToasts((current) => current.filter((item) => item.id !== id)), 2600);
};
const navigate = (view: View) => {
setActiveView(view);
setSidebarOpen(false);
setSearch("");
};
const filteredProjects = useMemo(
() => mockData.projects.filter((project) => `${project.title}${project.description}${project.tag}`.toLowerCase().includes(search.toLowerCase())),
[search],
);
const moveCard = (targetColumnId: string) => {
if (!dragged || dragged.columnId === targetColumnId) return;
let movingCard: BoardColumn["cards"][number] | undefined;
const nextBoard = board.map((column) => {
if (column.id !== dragged.columnId) return column;
movingCard = column.cards.find((card) => card.id === dragged.cardId);
return { ...column, cards: column.cards.filter((card) => card.id !== dragged.cardId) };
});
if (!movingCard) return;
setBoard(nextBoard.map((column) => column.id === targetColumnId ? { ...column, cards: [...column.cards, movingCard!] } : column));
setDragged(null);
notify("任务状态已更新");
};
const toggleSchedule = (id: number) => {
setSchedules((current) => current.map((schedule) => schedule.id === id ? { ...schedule, enabled: !schedule.enabled } : schedule));
notify("任务状态已保存到本地");
};
return (
<div className="app-shell">
<aside className={`sidebar ${sidebarOpen ? "is-open" : ""}`}>
<button className="brand" onClick={() => navigate("overview")} aria-label="返回概览">
<span className="brand-mark"><i /><i /><i /></span>
<span>Orbit<span>Flow</span></span>
</button>
<nav className="main-nav" aria-label="主导航">
{navGroups.map((group) => (
<div className="nav-group" key={group.label}>
<p>{group.label}</p>
{group.items.map((item) => {
const expanded = expandedMenus.includes(item.label);
return (
<div key={`${group.label}-${item.label}`}>
<button className={`nav-item ${activeView === item.id ? "active" : ""}`} onClick={() => {
navigate(item.id);
if (item.children) setExpandedMenus((current) => current.includes(item.label) ? current.filter((label) => label !== item.label) : [...current, item.label]);
}}>
<span className="nav-icon">{item.icon}</span><span>{item.label}</span>
{item.badge && <span className="nav-badge">{item.badge}</span>}
{item.children && <span className={`chevron ${expanded ? "expanded" : ""}`}></span>}
</button>
{item.children && expanded && (
<div className="subnav">
{item.children.map((child, index) => <button className={index === 0 && activeView === "content" ? "active" : ""} key={child} onClick={() => navigate("content")}>{child}</button>)}
</div>
)}
</div>
);
})}
</div>
))}
</nav>
<div className="sidebar-help"><span>?</span><div><strong></strong><small>使</small></div><button onClick={() => notify("帮助中心即将开放")}></button></div>
</aside>
{sidebarOpen && <button className="sidebar-backdrop" aria-label="关闭菜单" onClick={() => setSidebarOpen(false)} />}
<main className="main-area">
<header className="topbar">
<button className="mobile-menu" aria-label="打开菜单" onClick={() => setSidebarOpen(true)}></button>
<label className="global-search"><span></span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索项目、任务或成员..." /><kbd> K</kbd></label>
<div className="top-actions">
<button className="icon-button" aria-label="帮助" onClick={() => notify("你可以从左侧菜单浏览全部模板")}>?</button>
<button className="icon-button notification" aria-label="通知" onClick={() => notify("你有 3 条未读通知")}><i /></button>
<span className="divider" />
<div className="profile-wrap">
<button className="profile" onClick={() => setProfileOpen(!profileOpen)}><span className="avatar"></span><span><strong></strong><small></small></span><b></b></button>
{profileOpen && <div className="profile-menu"><button onClick={() => notify("个人资料功能为模板占位")}></button><button onClick={() => notify("偏好设置功能为模板占位")}></button><button onClick={() => setProfileOpen(false)}></button></div>}
</div>
</div>
</header>
<section className="page-content">
<div className="page-heading">
<div><p className="eyebrow">{activeView === "overview" ? "2026 年 8 月 7 日 · 星期五" : "ORBITFLOW TEMPLATE"}</p><h1>{pageMeta[activeView].title}</h1><p>{pageMeta[activeView].subtitle}</p></div>
<div className="heading-actions"><button className="button secondary" onClick={() => notify("示例数据已刷新")}> </button><button className="button primary" onClick={() => activeView === "schedule" ? setScheduleModal(true) : notify("已创建一条示例记录")}> {activeView === "schedule" ? "新建任务" : "快速创建"}</button></div>
</div>
{activeView === "overview" && <Overview onNavigate={navigate} notify={notify} />}
{activeView === "components" && <ComponentsShowcase notify={notify} />}
{activeView === "content" && <ContentView projects={filteredProjects} notify={notify} />}
{activeView === "board" && <BoardView board={board} dragged={dragged} setDragged={setDragged} moveCard={moveCard} notify={notify} />}
{activeView === "schedule" && <ScheduleView schedules={schedules} toggleSchedule={toggleSchedule} notify={notify} onCreate={() => setScheduleModal(true)} />}
</section>
</main>
{scheduleModal && <ScheduleModal onClose={() => setScheduleModal(false)} onSave={(name, frequency) => {
setSchedules((current) => [...current, { id: Math.max(...current.map((item) => item.id), 0) + 1, name, type: "自定义任务", cron: frequency === "每天" ? "0 9 * * *" : "0 9 * * 1", frequency: frequency === "每天" ? "每天 09:00" : "每周一 09:00", lastRun: "尚未执行", nextRun: frequency === "每天" ? "明天 09:00" : "下周一 09:00", enabled: true, success: 100 }]);
setScheduleModal(false); notify("新任务已保存到本地");
}} />}
<div className="toast-stack" aria-live="polite">{toasts.map((toast) => <div className="toast" key={toast.id}><span></span>{toast.message}</div>)}</div>
</div>
);
}
function Overview({ onNavigate, notify }: { onNavigate: (view: View) => void; notify: (message: string) => void }) {
return (
<>
<div className="stats-grid">
{mockData.stats.map((stat, index) => <article className="stat-card" key={stat.label}><div className={`stat-icon ${stat.tone}`}>{["▣", "✓", "♙", "↗"][index]}</div><div><p>{stat.label}</p><strong>{stat.value}</strong></div><span className={index === 1 ? "muted" : "positive"}>{stat.change}</span></article>)}
</div>
<div className="dashboard-grid">
<section className="panel project-panel">
<PanelHeader title="重点项目" subtitle="本周需要关注的项目进展" action="查看全部" onAction={() => onNavigate("content")} />
<div className="project-list compact">
{mockData.projects.map((project) => <article className="project-row" key={project.id}><img src={project.image} alt="" /><div className="project-info"><div className="project-title-line"><strong>{project.title}</strong><span className={`tag status-${project.status}`}>{project.status}</span></div><p>{project.description}</p><div className="progress-line"><span><i style={{ width: `${project.progress}%` }} /></span><b>{project.progress}%</b></div></div><div className="project-owner"><span className="mini-avatar">{project.owner.slice(-1)}</span><small>{project.owner}</small><b>{project.date}</b></div></article>)}
</div>
</section>
<section className="panel todo-panel">
<PanelHeader title="今日待办" subtitle="已完成 6 / 9" action="管理任务" onAction={() => onNavigate("board")} />
<div className="completion-bar"><i /></div>
<div className="todo-list">
{[["评审新版工作台原型", "10:00", true], ["与研发确认排期", "11:30", true], ["整理组件规范文档", "14:00", false], ["增长实验周会", "16:30", false]].map(([title, time, done]) => <label key={String(title)} className={done ? "done" : ""}><input type="checkbox" defaultChecked={Boolean(done)} onChange={() => notify("待办状态已更新")} /><span>{String(title)}</span><time>{String(time)}</time></label>)}
</div>
<button className="text-button add-todo" onClick={() => notify("已添加一条空白待办")}> </button>
</section>
</div>
<section className="panel activity-panel">
<PanelHeader title="团队动态" subtitle="项目中的最新协作记录" action="全部动态" onAction={() => notify("暂无更多动态")} />
<div className="activity-list">
{[["周", "周屿", "完成了任务", "后台筛选器交互优化", "12 分钟前", "green"], ["许", "许言", "上传了文件", "八月增长计划-v3.pdf", "35 分钟前", "blue"], ["陈", "陈序", "评论了", "确认新用户引导文案", "1 小时前", "orange"], ["林", "林知夏", "创建了项目", "新版工作台视觉升级", "2 小时前", "purple"]].map(([avatar, name, verb, target, time, tone]) => <div className="activity-item" key={String(target)}><span className={`activity-avatar ${tone}`}>{avatar}</span><p><strong>{name}</strong> {verb} <b>{target}</b><small>{time}</small></p><button aria-label="更多" onClick={() => notify("更多操作")}></button></div>)}
</div>
</section>
</>
);
}
function ComponentsShowcase({ notify }: { notify: (message: string) => void }) {
const [tab, setTab] = useState("基础组件");
return (
<div className="component-layout">
<div className="component-tabs">{["基础组件", "状态样式", "菜单示例"].map((item) => <button className={tab === item ? "active" : ""} key={item} onClick={() => setTab(item)}>{item}</button>)}</div>
{tab === "基础组件" && <>
<section className="panel specimen"><PanelHeader title="按钮 Buttons" subtitle="用于操作、提交和页面导航" /><div className="specimen-row"><button className="button primary" onClick={() => notify("主要按钮")}></button><button className="button secondary" onClick={() => notify("次要按钮")}></button><button className="button soft" onClick={() => notify("轻量按钮")}></button><button className="button danger" onClick={() => notify("危险操作示例")}></button><button className="button icon-combo" onClick={() => notify("下载开始")}> </button><button className="button secondary" disabled></button></div></section>
<section className="panel specimen"><PanelHeader title="标签 Tags" subtitle="用于分类、筛选与状态提示" /><div className="specimen-row tags-demo"><span className="tag blue"></span><span className="tag green"></span><span className="tag orange"></span><span className="tag purple"></span><span className="tag red"></span><span className="tag neutral"></span><span className="tag outlined"> ×</span></div></section>
<section className="panel specimen"><PanelHeader title="表单控件 Forms" subtitle="统一输入、选择与开关样式" /><div className="form-showcase"><label><span></span><input defaultValue="新版工作台升级" /></label><label><span></span><select defaultValue="design"><option value="design"></option><option></option></select></label><label><span></span><div className="input-with-icon"><i></i><input placeholder="搜索关键词" /></div></label><label className="switch-field"><span><small></small></span><input type="checkbox" defaultChecked /></label></div></section>
</>}
{tab === "状态样式" && <section className="panel specimen state-gallery"><PanelHeader title="反馈与状态" subtitle="覆盖成功、提示、警告和错误场景" /><div className="alert success"><b></b><span><strong></strong></span><button>×</button></div><div className="alert info"><b>i</b><span><strong></strong> JSON </span><button>×</button></div><div className="alert warning"><b>!</b><span><strong></strong></span><button>×</button></div><div className="alert error"><b>×</b><span><strong></strong></span><button>×</button></div></section>}
{tab === "菜单示例" && <section className="panel specimen menu-gallery"><PanelHeader title="菜单 Menu" subtitle="展示导航、操作菜单和分组层级" /><div className="menu-examples"><div className="demo-menu"><p></p><button> <kbd> V</kbd></button><button> <kbd> E</kbd></button><hr /><button> </button><button className="danger-text"> </button></div><div className="demo-menu dark"><p></p><button className="active"> </button><button> <span>9</span></button><button> </button><small>访</small><button> </button></div></div></section>}
</div>
);
}
function ContentView({ projects, notify }: { projects: typeof mockData.projects; notify: (message: string) => void }) {
const [display, setDisplay] = useState<"list" | "cards">("list");
return (
<section className="panel content-panel">
<div className="content-toolbar"><div className="filter-chips"><button className="active"> <b>24</b></button><button> <b>8</b></button><button> <b>16</b></button></div><div className="view-switch"><button className={display === "list" ? "active" : ""} onClick={() => setDisplay("list")}></button><button className={display === "cards" ? "active" : ""} onClick={() => setDisplay("cards")}></button></div></div>
{projects.length === 0 ? <div className="empty-state"><span></span><h3></h3><p></p></div> : display === "list" ? <div className="image-list">{projects.map((project) => <article key={project.id}><img src={project.image} alt={`${project.title}项目配图`} /><div><div className="content-title"><span className="tag blue">{project.tag}</span><span className={`tag status-${project.status}`}>{project.status}</span></div><h3>{project.title}</h3><p>{project.description}</p><small> {project.owner} · {project.date}</small></div><div className="content-progress"><strong>{project.progress}%</strong><span><i style={{ width: `${project.progress}%` }} /></span><button onClick={() => notify(`打开:${project.title}`)}> </button></div></article>)}</div> : <div className="content-cards">{projects.map((project) => <article key={project.id}><div className="card-image"><img src={project.image} alt="" /><span className="tag white">{project.tag}</span></div><div className="card-body"><span className={`tag status-${project.status}`}>{project.status}</span><h3>{project.title}</h3><p>{project.description}</p><div><span className="mini-avatar">{project.owner.slice(-1)}</span><small>{project.owner}</small><b>{project.progress}%</b></div></div></article>)}</div>}
</section>
);
}
function BoardView({ board, dragged, setDragged, moveCard, notify }: { board: BoardColumn[]; dragged: { cardId: string; columnId: string } | null; setDragged: (value: { cardId: string; columnId: string } | null) => void; moveCard: (columnId: string) => void; notify: (message: string) => void }) {
return <><div className="board-toolbar"><div className="avatar-stack"><span></span><span></span><span></span><span></span><b>+8</b></div><div><button className="button secondary"> </button><button className="button secondary"> </button></div></div><div className="kanban-board">{board.map((column) => <section className={`kanban-column ${dragged ? "dragging" : ""}`} key={column.id} onDragOver={(event) => event.preventDefault()} onDrop={() => moveCard(column.id)}><header><div><i style={{ background: column.color }} /><strong>{column.title}</strong><span>{column.cards.length}</span></div><button onClick={() => notify(`在“${column.title}”中添加任务`)}></button></header><div className="kanban-cards">{column.cards.map((card) => <article draggable key={card.id} onDragStart={() => setDragged({ cardId: card.id, columnId: column.id })} onDragEnd={() => setDragged(null)}><div className="kanban-card-top"><span>{card.id}</span><button onClick={() => notify("更多任务操作")}></button></div><h3>{card.title}</h3><div className="kanban-tags"><span className="tag neutral">{card.tag}</span>{card.priority === "紧急" && <span className="tag red"></span>}</div><footer><span className="mini-avatar">{card.owner.slice(-1)}</span><small>{card.owner}</small><time className={card.due === "今天" ? "today" : ""}> {card.due}</time></footer></article>)}<button className="add-card" onClick={() => notify("添加卡片为模板示例")}> </button></div></section>)}</div></>;
}
function ScheduleView({ schedules, toggleSchedule, notify, onCreate }: { schedules: typeof mockData.schedules; toggleSchedule: (id: number) => void; notify: (message: string) => void; onCreate: () => void }) {
const activeCount = schedules.filter((item) => item.enabled).length;
return <><div className="schedule-summary"><div><span className="summary-icon blue"></span><p><strong>{schedules.length}</strong></p></div><div><span className="summary-icon green"></span><p><strong>{activeCount}</strong></p></div><div><span className="summary-icon purple"></span><p><strong>128</strong></p></div><div><span className="summary-icon orange"></span><p><strong>98.5%</strong></p></div></div><section className="panel schedule-panel"><div className="schedule-toolbar"><div><button className="active"></button><button></button><button></button></div><button className="button primary" onClick={onCreate}> </button></div><div className="table-wrap"><table><thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th /></tr></thead><tbody>{schedules.map((schedule) => <tr key={schedule.id}><td><span className="task-type-icon">{schedule.type === "消息通知" ? "✧" : schedule.type === "文件归档" ? "▤" : "↻"}</span><div><strong>{schedule.name}</strong><small>{schedule.type}</small></div></td><td><strong>{schedule.frequency}</strong><code>{schedule.cron}</code></td><td>{schedule.lastRun}</td><td>{schedule.nextRun}</td><td><span className="success-rate"><i style={{ width: `${schedule.success}%` }} /></span>{schedule.success}%</td><td><button className={`toggle ${schedule.enabled ? "on" : ""}`} aria-label={`${schedule.enabled ? "停用" : "启用"}${schedule.name}`} onClick={() => toggleSchedule(schedule.id)}><i /></button><span className={schedule.enabled ? "running" : "disabled"}>{schedule.enabled ? "运行中" : "已停用"}</span></td><td><button className="row-action" onClick={() => notify(`编辑:${schedule.name}`)}></button></td></tr>)}</tbody></table></div></section></>;
}
function ScheduleModal({ onClose, onSave }: { onClose: () => void; onSave: (name: string, frequency: string) => void }) {
const [name, setName] = useState(""); const [frequency, setFrequency] = useState("每天");
return <div className="modal-backdrop" onMouseDown={onClose}><form className="modal" onMouseDown={(event) => event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (name.trim()) onSave(name.trim(), frequency); }}><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><option></option><option></option><option></option><option></option></select></label><fieldset><legend></legend><label><input type="radio" name="frequency" checked={frequency === "每天"} onChange={() => setFrequency("每天")} /></label><label><input type="radio" name="frequency" checked={frequency === "每周"} onChange={() => setFrequency("每周")} /></label></fieldset><div className="time-row"><label><span></span><input type="time" defaultValue="09:00" /></label><label><span></span><select><option>Asia/Shanghai</option></select></label></div><footer><button type="button" className="button secondary" onClick={onClose}></button><button className="button primary" type="submit"></button></footer></form></div>;
}
function PanelHeader({ title, subtitle, action, onAction }: { title: string; subtitle: string; action?: string; onAction?: () => void }) {
return <div className="panel-header"><div><h2>{title}</h2><p>{subtitle}</p></div>{action && <button onClick={onAction}>{action} </button>}</div>;
}

View File

@@ -0,0 +1,366 @@
.designerShell {
--navy: #142033;
--text: #2a3548;
--muted: #7d8798;
--line: #e5e8ee;
--blue: #3370ff;
--blueSoft: #eaf1ff;
--green: #25a66a;
--greenSoft: #eaf8f1;
--orange: #e79024;
--orangeSoft: #fff4e5;
--purple: #7656d6;
--purpleSoft: #f0ecfd;
min-width: 1040px;
min-height: 100vh;
overflow: hidden;
background: #f4f6f9;
color: var(--text);
font-family: Inter, "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
}
.topbar {
position: relative;
z-index: 30;
height: 68px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 0 22px;
border-bottom: 1px solid var(--line);
background: #fff;
box-shadow: 0 2px 9px rgba(27, 38, 59, .04);
}
.topbarLeft, .topbarActions, .processIdentity > div, .settingsHeader > div, .previewModal header > div {
display: flex;
align-items: center;
}
.topbarLeft { gap: 12px; }
.backButton {
width: 34px;
height: 34px;
display: grid;
place-items: center;
border: 1px solid #e2e6ec;
border-radius: 8px;
color: #6f7a8c;
text-decoration: none;
font-size: 18px;
}
.backButton:hover { border-color: #abc2fa; color: var(--blue); background: #f7f9ff; }
.brandMark { position: relative; width: 25px; height: 25px; display: block; margin-left: 2px; transform: rotate(-11deg); }
.brandMark i { position: absolute; bottom: 1px; display: block; width: 6px; border-radius: 4px; background: var(--blue); }
.brandMark i:nth-child(1) { left: 1px; height: 12px; background: #7ca5ff; }
.brandMark i:nth-child(2) { left: 9px; height: 22px; background: var(--navy); }
.brandMark i:nth-child(3) { left: 17px; height: 16px; }
.processIdentity { min-width: 260px; display: flex; flex-direction: column; gap: 2px; }
.processIdentity > div { gap: 8px; }
.processIdentity input {
width: 185px;
padding: 0;
border: 0;
outline: 0;
background: transparent;
color: var(--navy);
font-size: 14px;
font-weight: 750;
}
.processIdentity small { color: #8e97a6; font-size: 9px; }
.draft, .published { padding: 3px 7px; border-radius: 5px; font-size: 8px; font-weight: 700; }
.draft { background: var(--orangeSoft); color: var(--orange); }
.published { background: var(--greenSoft); color: var(--green); }
.topbarActions { gap: 8px; }
.topbarActions button { min-height: 36px; padding: 0 13px; border-radius: 7px; cursor: pointer; font-size: 10px; font-weight: 650; }
.quietButton { border: 1px solid #dde2e9; background: #fff; color: #5e6b7f; }
.quietButton:hover { border-color: #b7c8e9; background: #f9fbff; color: var(--blue); }
.publishButton { border: 1px solid var(--blue); background: var(--blue); color: #fff; box-shadow: 0 5px 13px rgba(51,112,255,.2); }
.publishButton:hover { background: #235bd4; }
.workspace {
height: calc(100vh - 68px);
display: grid;
grid-template-columns: 235px minmax(520px, 1fr) 326px;
}
.palette, .propertyPanel { position: relative; z-index: 10; overflow-y: auto; background: #fff; }
.palette { border-right: 1px solid var(--line); }
.propertyPanel { border-left: 1px solid var(--line); }
.paletteHeader {
height: 68px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 17px;
border-bottom: 1px solid #edf0f4;
}
.paletteHeader > div { display: flex; flex-direction: column; gap: 4px; }
.paletteHeader strong { color: var(--navy); font-size: 13px; }
.paletteHeader small { color: #98a1af; font-size: 9px; }
.paletteHeader > button { display: none; border: 0; background: none; color: #8490a2; font-size: 18px; }
.paletteSection { padding: 17px 13px 4px; }
.paletteSection > p { margin: 0 4px 10px; color: #98a0ae; font-size: 9px; font-weight: 750; letter-spacing: .7px; }
.paletteItem {
width: 100%;
min-height: 64px;
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
padding: 9px 10px;
border: 1px solid #e5e9ef;
border-radius: 9px;
background: #fff;
cursor: grab;
text-align: left;
transition: border-color .16s, box-shadow .16s, transform .16s;
}
.paletteItem:hover { border-color: #a9c2fa; box-shadow: 0 7px 18px rgba(38,72,137,.08); transform: translateY(-1px); }
.paletteItem:active { cursor: grabbing; }
.paletteIcon, .nodeIcon {
display: grid;
place-items: center;
flex: 0 0 auto;
border-radius: 8px;
font-weight: 800;
}
.paletteIcon { width: 35px; height: 35px; font-size: 15px; }
.blue { background: var(--blueSoft); color: var(--blue); }
.green { background: var(--greenSoft); color: var(--green); }
.orange { background: var(--orangeSoft); color: var(--orange); }
.purple { background: var(--purpleSoft); color: var(--purple); }
.gray { background: #edf0f4; color: #697589; }
.paletteItem > span:nth-child(2) { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 4px; }
.paletteItem strong { color: #36445b; font-size: 10px; }
.paletteItem small { overflow: hidden; color: #8e98a8; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
.paletteItem > b { color: #a1a9b6; font-size: 15px; }
.disabledItem { background: #fbfcfd; }
.helpCard { display: flex; gap: 10px; margin: 20px 13px; padding: 13px; border: 1px solid #dbe5fb; border-radius: 9px; background: #f6f9ff; }
.helpCard > span { width: 24px; height: 24px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: var(--blueSoft); color: var(--blue); font-size: 10px; font-weight: 800; }
.helpCard div { min-width: 0; }
.helpCard strong { color: #405273; font-size: 9px; }
.helpCard p { margin: 4px 0 0; color: #77849a; font-size: 8px; line-height: 1.6; }
.canvasArea { min-width: 0; display: flex; flex-direction: column; background: #f2f4f8; }
.canvasToolbar {
height: 47px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 0 16px;
border-bottom: 1px solid #e2e6ec;
background: rgba(255,255,255,.86);
}
.breadcrumb { min-width: 0; display: flex; align-items: center; gap: 8px; color: #8993a4; font-size: 9px; }
.breadcrumb strong { overflow: hidden; color: #536077; text-overflow: ellipsis; white-space: nowrap; }
.breadcrumb b { color: #bdc3cc; font-weight: 400; }
.canvasTools { display: flex; align-items: center; overflow: hidden; border: 1px solid #dde2e9; border-radius: 7px; background: #fff; }
.canvasTools button { height: 29px; padding: 0 9px; border: 0; border-right: 1px solid #edf0f3; background: #fff; color: #647085; cursor: pointer; font-size: 9px; }
.canvasTools button:hover { background: #f3f6fc; color: var(--blue); }
.canvasTools span { min-width: 43px; color: #7e899a; font-size: 8px; text-align: center; }
.mobilePaletteButton { display: none; padding: 6px 10px; border: 1px solid #bfd0f5; border-radius: 6px; background: #fff; color: var(--blue); font-size: 9px; }
.canvasScroller { min-height: 0; flex: 1; overflow: auto; }
.canvasGrid { min-width: 750px; min-height: 100%; padding: 42px 34px 120px; background: #f4f6f9; }
.flowWrap {
width: 680px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
transform-origin: center top;
transition: transform .18s ease;
}
.flowStep { width: 100%; display: flex; flex-direction: column; align-items: center; }
.processNode {
position: relative;
width: 310px;
min-height: 86px;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 15px;
border: 1px solid #dfe4eb;
border-radius: 10px;
background: #fff;
box-shadow: 0 5px 16px rgba(28,42,68,.07);
cursor: pointer;
text-align: left;
transition: border-color .16s, box-shadow .16s, transform .16s;
}
.processNode:hover { border-color: #a9c3fb; box-shadow: 0 9px 22px rgba(28,67,143,.1); transform: translateY(-1px); }
.selectedNode { border: 2px solid var(--blue); box-shadow: 0 0 0 4px rgba(51,112,255,.1), 0 9px 22px rgba(28,67,143,.1); }
.nodeIcon { width: 39px; height: 39px; font-size: 16px; }
.nodeCopy { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: 3px; }
.nodeCopy small { color: #9aa3b0; font-size: 7px; font-weight: 750; letter-spacing: .4px; text-transform: uppercase; }
.nodeCopy strong { overflow: hidden; color: #28374e; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.nodeCopy em { overflow: hidden; color: #8791a1; font-size: 8px; font-style: normal; text-overflow: ellipsis; white-space: nowrap; }
.approverPreview { display: flex; align-items: center; flex: 0 0 auto; }
.approverPreview i { width: 22px; height: 22px; display: grid; place-items: center; margin-left: -5px; border: 2px solid #fff; border-radius: 50%; background: #dce8ff; color: #3567c8; font-size: 7px; font-style: normal; font-weight: 700; }
.approverPreview b { margin-left: 5px; color: #7f8999; font-size: 7px; font-weight: 600; }
.nodeMore { position: absolute; top: 7px; right: 9px; color: #b0b6c0; font-size: 7px; letter-spacing: 1px; }
.node_start, .node_end { width: 270px; min-height: 72px; }
.connector { position: relative; width: 2px; height: 42px; background: #b8c1cf; }
.connector::after { content: ""; position: absolute; bottom: -1px; left: -3px; width: 6px; height: 6px; border-right: 2px solid #9ca7b7; border-bottom: 2px solid #9ca7b7; transform: rotate(45deg); }
.connector i { position: absolute; top: 16px; left: -3px; width: 8px; height: 8px; border: 2px solid #fff; border-radius: 50%; background: #b6c0cd; }
.conditionGroup { position: relative; width: 680px; padding: 0 18px 22px; border: 1px dashed transparent; border-radius: 13px; }
.selectedCondition { border-color: #94b4f8; background: rgba(233,240,255,.5); }
.conditionTitle {
width: 310px;
min-height: 72px;
display: flex;
align-items: center;
gap: 11px;
margin: 0 auto;
padding: 11px 14px;
border: 1px solid #f0d7b2;
border-radius: 10px;
background: #fff;
box-shadow: 0 5px 16px rgba(28,42,68,.06);
cursor: pointer;
text-align: left;
}
.conditionTitle > span:nth-child(2) { flex: 1; display: flex; flex-direction: column; gap: 3px; }
.conditionTitle small { color: var(--orange); font-size: 7px; font-weight: 750; }
.conditionTitle strong { color: #2e3b50; font-size: 11px; }
.conditionTitle > b { color: #a0a7b2; font-size: 8px; font-weight: 500; }
.branchRail { position: relative; width: calc(50% + 2px); height: 37px; margin: 0 auto; border-right: 2px solid #b8c1cf; border-bottom: 2px solid #b8c1cf; border-left: 2px solid #b8c1cf; }
.branchRail::before { content: ""; position: absolute; top: 0; left: 50%; width: 2px; height: 18px; background: #b8c1cf; }
.branchGrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 24px; }
.branchLane { display: flex; flex-direction: column; align-items: center; }
.branchLabel { width: 100%; min-height: 56px; padding: 9px 11px; border: 1px solid #e3e7ed; border-radius: 8px; background: #fff; }
.branchLabel > span { width: 17px; height: 17px; display: grid; place-items: center; float: left; margin-right: 7px; border-radius: 4px; background: var(--orangeSoft); color: var(--orange); font-size: 7px; font-weight: 750; }
.branchLabel strong { display: block; color: #4a5669; font-size: 9px; }
.branchLabel code { display: block; margin: 5px 0 0 24px; color: #9a7b55; font-size: 7px; }
.branchConnector { width: 2px; height: 23px; background: #b8c1cf; }
.branchLane .processNode { width: 100%; }
.branchJoin { position: relative; width: calc(50% + 2px); height: 37px; margin: 0 auto; border-right: 2px solid #b8c1cf; border-top: 2px solid #b8c1cf; border-left: 2px solid #b8c1cf; }
.branchJoin i { position: absolute; bottom: -1px; left: 50%; width: 2px; height: 20px; background: #b8c1cf; }
.canvasAdd { display: flex; align-items: center; gap: 7px; margin-top: 18px; padding: 7px 12px; border: 1px dashed #aeb9c9; border-radius: 8px; background: rgba(255,255,255,.75); color: #7a8799; cursor: pointer; font-size: 8px; }
.canvasAdd:hover { border-color: var(--blue); color: var(--blue); }
.canvasAdd span { font-size: 13px; }
.settingsHeader { min-height: 67px; display: flex; align-items: center; justify-content: space-between; padding: 0 16px; border-bottom: 1px solid #edf0f4; }
.settingsHeader > div { gap: 10px; }
.settingsHeader .nodeIcon { width: 33px; height: 33px; font-size: 13px; }
.settingsHeader > div > span:last-child { display: flex; flex-direction: column; gap: 3px; }
.settingsHeader strong { max-width: 190px; overflow: hidden; color: var(--navy); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.settingsHeader small { color: #98a1af; font-size: 7px; }
.settingsHeader > button { width: 27px; height: 27px; border: 0; border-radius: 6px; background: #fff2f2; color: #d85656; cursor: pointer; }
.settingsTabs { height: 42px; display: flex; padding: 0 13px; border-bottom: 1px solid #edf0f4; }
.settingsTabs button { position: relative; flex: 1; border: 0; background: transparent; color: #8791a1; cursor: pointer; font-size: 9px; }
.settingsTabs button.activeTab { color: var(--blue); font-weight: 700; }
.settingsTabs button.activeTab::after { content: ""; position: absolute; right: 20%; bottom: -1px; left: 20%; height: 2px; border-radius: 2px; background: var(--blue); }
.settingsBody { padding: 18px 16px 45px; }
.field { display: flex; flex-direction: column; gap: 7px; margin-bottom: 16px; }
.field > span, .settingLabel strong { color: #536076; font-size: 9px; font-weight: 700; }
.field input, .field textarea {
width: 100%;
padding: 9px 10px;
border: 1px solid #dde2e9;
border-radius: 7px;
outline: 0;
background: #fff;
color: #3e4b60;
font: inherit;
font-size: 9px;
resize: vertical;
}
.field input:focus, .field textarea:focus { border-color: #8eb0fa; box-shadow: 0 0 0 3px rgba(51,112,255,.08); }
.settingGroup, .approverSetting, .branchSettings { margin: 19px 0; padding-top: 17px; border-top: 1px solid #edf0f4; }
.settingLabel { display: flex; flex-direction: column; gap: 3px; margin-bottom: 10px; }
.settingLabel small { color: #99a2b0; font-size: 7px; }
.segmented { display: flex; padding: 3px; border-radius: 7px; background: #f0f2f5; }
.segmented button { flex: 1; height: 28px; border: 0; border-radius: 5px; background: transparent; color: #7e899b; cursor: pointer; font-size: 8px; }
.segmented button.segmentActive { background: #fff; color: var(--blue); box-shadow: 0 2px 6px rgba(43,56,81,.08); font-weight: 700; }
.peopleList { display: flex; flex-direction: column; gap: 7px; }
.peopleList > div { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid #e5e9ef; border-radius: 7px; }
.peopleList > div > span { width: 27px; height: 27px; display: grid; place-items: center; border-radius: 50%; background: var(--blueSoft); color: #3b68c6; font-size: 8px; font-weight: 700; }
.peopleList p { flex: 1; display: flex; flex-direction: column; gap: 2px; margin: 0; }
.peopleList p strong { color: #47556b; font-size: 9px; }
.peopleList p small { color: #9ba3b0; font-size: 7px; }
.peopleList button { border: 0; background: none; color: #a2aab6; cursor: pointer; }
.addPeople { width: 100%; height: 34px; margin-top: 8px; border: 1px dashed #b8c4d7; border-radius: 7px; background: #fbfcfe; color: var(--blue); cursor: pointer; font-size: 8px; }
.branchSettings > div { margin-bottom: 12px; padding: 11px; border: 1px solid #e5e9ef; border-radius: 8px; background: #fafbfc; }
.branchSettings > div > div:first-child { display: flex; align-items: center; gap: 7px; margin-bottom: 12px; }
.branchSettings > div > div:first-child span { width: 18px; height: 18px; display: grid; place-items: center; border-radius: 4px; background: var(--orangeSoft); color: var(--orange); font-size: 7px; font-weight: 700; }
.branchSettings > div > div:first-child strong { color: #566276; font-size: 9px; }
.branchSettings .field { margin-bottom: 10px; }
.branchSettings .field:last-child { margin-bottom: 0; }
.infoBox { display: flex; gap: 9px; padding: 11px; border: 1px solid #dbe5fa; border-radius: 8px; background: #f6f9ff; }
.infoBox span { width: 20px; height: 20px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 50%; background: var(--blueSoft); color: var(--blue); font-size: 8px; font-weight: 800; }
.infoBox p { margin: 1px 0 0; color: #6e7c92; font-size: 8px; line-height: 1.6; }
.permissions { border: 1px solid #e5e9ef; border-radius: 8px; overflow: hidden; }
.permissions > div { min-height: 40px; display: grid; grid-template-columns: 1fr 42px 42px; align-items: center; padding: 0 9px; border-bottom: 1px solid #edf0f4; }
.permissions > div:last-child { border-bottom: 0; }
.permissions span, .permissions strong { color: #627086; font-size: 8px; }
.permissions input { accent-color: var(--blue); }
.permissionHead { background: #f7f8fa; }
.advancedSettings { display: flex; flex-direction: column; gap: 10px; }
.advancedSettings > label { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px; border: 1px solid #e5e9ef; border-radius: 8px; }
.advancedSettings > label span { display: flex; flex-direction: column; gap: 4px; }
.advancedSettings strong { color: #506076; font-size: 9px; }
.advancedSettings small { color: #98a1af; font-size: 7px; }
.advancedSettings input { width: 32px; height: 18px; accent-color: var(--blue); }
.advancedSettings > button { height: 36px; border: 1px dashed #b8c4d7; border-radius: 7px; background: #fbfcfe; color: var(--blue); cursor: pointer; font-size: 8px; }
.noSelection { min-height: 360px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 30px; text-align: center; }
.noSelection > span { width: 52px; height: 52px; display: grid; place-items: center; border-radius: 50%; background: #f0f3f7; color: #9aa3b1; font-size: 22px; }
.noSelection strong { margin: 14px 0 5px; color: #556176; font-size: 11px; }
.noSelection p { margin: 0; color: #949dab; font-size: 8px; line-height: 1.6; }
.modalBackdrop { position: fixed; inset: 0; z-index: 80; display: grid; place-items: center; padding: 25px; background: rgba(16,25,42,.5); backdrop-filter: blur(4px); }
.previewModal { width: 580px; max-height: calc(100vh - 50px); overflow: auto; border-radius: 13px; background: #fff; box-shadow: 0 30px 80px rgba(12,21,37,.28); }
.previewModal header, .jsonDrawer header { display: flex; align-items: flex-start; justify-content: space-between; padding: 20px; border-bottom: 1px solid #edf0f4; }
.previewModal header > div { gap: 11px; }
.previewModal header > div > span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 9px; background: var(--blueSoft); color: var(--blue); }
.previewModal h2, .jsonDrawer h2 { margin: 0 0 4px; color: var(--navy); font-size: 15px; }
.previewModal header p, .jsonDrawer header p { margin: 0; color: #929cab; font-size: 8px; }
.previewModal header > button, .jsonDrawer header > button { width: 28px; height: 28px; border: 0; border-radius: 50%; background: #f2f4f7; color: #778296; cursor: pointer; font-size: 16px; }
.previewForm { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; padding: 15px 20px; border-bottom: 1px solid #edf0f4; background: #fafbfc; }
.previewForm > div { display: flex; flex-direction: column; gap: 4px; }
.previewForm span { color: #98a1af; font-size: 7px; }
.previewForm strong { color: #4b586d; font-size: 9px; }
.previewForm .hitBranch { color: var(--orange); }
.previewRoute { padding: 20px 85px 25px; }
.previewRoute > div { position: relative; display: flex; gap: 11px; min-height: 62px; }
.previewDot { position: relative; z-index: 2; width: 25px; height: 25px; display: grid; place-items: center; flex: 0 0 auto; border: 2px solid #fff; border-radius: 50%; background: var(--blue); box-shadow: 0 0 0 1px #a9c2fa; color: #fff; font-size: 7px; font-weight: 700; }
.previewRoute p { display: flex; flex-direction: column; gap: 4px; margin: 3px 0 0; }
.previewRoute strong { color: #435168; font-size: 9px; }
.previewRoute small { color: #9099a8; font-size: 7px; }
.previewRoute i { position: absolute; top: 24px; left: 12px; bottom: 0; width: 1px; background: #cbd3df; }
.previewModal footer, .jsonDrawer footer { display: flex; justify-content: flex-end; gap: 8px; padding: 14px 20px; border-top: 1px solid #edf0f4; }
.previewModal footer button, .jsonDrawer footer button { min-height: 34px; padding: 0 13px; border: 1px solid #dce1e8; border-radius: 7px; background: #fff; color: #657187; cursor: pointer; font-size: 8px; }
.previewModal footer button:last-child, .jsonDrawer footer button:last-child { border-color: var(--blue); background: var(--blue); color: #fff; }
.jsonDrawer { position: fixed; inset: 0 0 0 auto; width: 520px; display: flex; flex-direction: column; background: #fff; box-shadow: -20px 0 60px rgba(12,21,37,.18); }
.jsonDrawer pre { flex: 1; margin: 0; padding: 18px; overflow: auto; background: #101a2c; color: #c9d9f5; font: 9px/1.7 "SFMono-Regular", Consolas, monospace; white-space: pre-wrap; }
.toast { position: fixed; right: 25px; bottom: 24px; z-index: 100; min-width: 225px; display: flex; align-items: center; gap: 9px; padding: 12px 14px; border: 1px solid #dce3ec; border-radius: 9px; background: #fff; box-shadow: 0 16px 35px rgba(23,35,58,.18); color: #4b596e; font-size: 9px; animation: toastIn .2s ease-out; }
.toast span { width: 19px; height: 19px; display: grid; place-items: center; border-radius: 50%; background: var(--greenSoft); color: var(--green); font-weight: 800; }
@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 1180px) {
.designerShell { min-width: 840px; }
.workspace { grid-template-columns: minmax(500px, 1fr) 320px; }
.palette { position: fixed; top: 68px; bottom: 0; left: 0; z-index: 50; width: 235px; transform: translateX(-100%); box-shadow: 14px 0 30px rgba(24,38,61,.13); transition: transform .2s ease; }
.paletteOpen { transform: translateX(0); }
.paletteHeader > button, .mobilePaletteButton { display: block; }
}
@media (max-width: 860px) {
.designerShell { min-width: 680px; }
.topbar { padding: 0 13px; }
.processIdentity { min-width: 210px; }
.processIdentity input { width: 145px; }
.topbarActions .quietButton:first-child { display: none; }
.workspace { grid-template-columns: minmax(390px, 1fr) 290px; }
.conditionGroup { width: 640px; }
.flowWrap { width: 640px; }
.canvasGrid { padding-right: 16px; padding-left: 16px; }
}
@media (prefers-reduced-motion: reduce) {
.processNode, .paletteItem, .flowWrap, .palette { transition: none; }
.toast { animation: none; }
}

300
app/approval/page.tsx Normal file
View File

@@ -0,0 +1,300 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import defaultFlow from "../data/approval-flow.json";
import styles from "./ApprovalDesigner.module.css";
type NodeType = "start" | "approval" | "condition" | "cc" | "end";
type ApprovalMode = "single" | "any" | "all";
type FlowNode = {
id: string;
type: NodeType;
title: string;
description: string;
approvers: string[];
mode: ApprovalMode;
allowTransfer?: boolean;
autoPass?: boolean;
branches?: Branch[];
};
type Branch = {
id: string;
label: string;
expression: string;
node: FlowNode;
};
type FlowData = {
id: string;
name: string;
description: string;
status: string;
version: string;
updatedAt: string;
nodes: FlowNode[];
};
const library: Array<{ type: NodeType; label: string; description: string; icon: string; tone: string }> = [
{ type: "approval", label: "审批人", description: "指定成员或角色审批", icon: "✓", tone: "blue" },
{ type: "condition", label: "条件分支", description: "根据表单字段自动分流", icon: "⑂", tone: "orange" },
{ type: "cc", label: "抄送人", description: "将审批结果通知成员", icon: "◎", tone: "purple" },
];
const people = ["直属主管", "部门负责人", "财务负责人", "预算管理员", "采购专员", "法务负责人"];
export default function ApprovalDesignerPage() {
const [flow, setFlow] = useState<FlowData>(defaultFlow as unknown as FlowData);
const [selectedId, setSelectedId] = useState("approval-1");
const [zoom, setZoom] = useState(1);
const [previewOpen, setPreviewOpen] = useState(false);
const [jsonOpen, setJsonOpen] = useState(false);
const [paletteOpen, setPaletteOpen] = useState(false);
const [toast, setToast] = useState("");
const [saved, setSaved] = useState(true);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const stored = window.localStorage.getItem("orbit-approval-flow");
if (stored) {
try { setFlow(JSON.parse(stored)); }
catch { window.localStorage.removeItem("orbit-approval-flow"); }
}
setLoaded(true);
}, []);
useEffect(() => {
if (!loaded) return;
setSaved(false);
const timer = window.setTimeout(() => {
window.localStorage.setItem("orbit-approval-flow", JSON.stringify(flow));
setSaved(true);
}, 500);
return () => window.clearTimeout(timer);
}, [flow, loaded]);
const selectedNode = useMemo(() => flow.nodes.find((node) => node.id === selectedId), [flow.nodes, selectedId]);
const notify = (message: string) => {
setToast(message);
window.setTimeout(() => setToast(""), 2400);
};
const updateNode = (patch: Partial<FlowNode>) => {
setFlow((current) => ({
...current,
nodes: current.nodes.map((node) => node.id === selectedId ? { ...node, ...patch } : node),
updatedAt: new Date().toLocaleString("zh-CN", { hour12: false }),
}));
};
const addNode = (type: NodeType) => {
const id = `${type}-${Date.now()}`;
const common = { id, type, approvers: [] as string[], mode: "single" as ApprovalMode };
let node: FlowNode;
if (type === "approval") node = { ...common, title: "新审批节点", description: "请选择审批人", approvers: ["直属主管"], allowTransfer: true };
else if (type === "cc") node = { ...common, title: "新抄送节点", description: "审批通过后自动抄送", approvers: ["申请人"] };
else node = {
...common,
title: "新条件分支",
description: "根据表单字段设置条件",
branches: [
{ id: `${id}-a`, label: "条件 1", expression: "totalAmount >= 1000", node: { ...common, id: `${id}-node-a`, type: "approval", title: "条件审批人", description: "满足条件时执行", approvers: ["部门负责人"] } },
{ id: `${id}-b`, label: "其他情况", expression: "otherwise", node: { ...common, id: `${id}-node-b`, type: "cc", title: "默认抄送", description: "未满足条件时执行", approvers: ["申请人"] } },
],
};
setFlow((current) => {
const endIndex = current.nodes.findIndex((item) => item.type === "end");
const nodes = [...current.nodes];
nodes.splice(endIndex < 0 ? nodes.length : endIndex, 0, node);
return { ...current, nodes, updatedAt: new Date().toLocaleString("zh-CN", { hour12: false }) };
});
setSelectedId(id);
setPaletteOpen(false);
notify(`已添加${type === "approval" ? "审批" : type === "condition" ? "条件" : "抄送"}节点`);
};
const deleteNode = () => {
if (!selectedNode || selectedNode.type === "start" || selectedNode.type === "end") return;
setFlow((current) => ({ ...current, nodes: current.nodes.filter((node) => node.id !== selectedId) }));
setSelectedId("start-1");
notify("节点已删除");
};
const validateFlow = () => {
const emptyApprovers = flow.nodes.filter((node) => (node.type === "approval" || node.type === "cc") && node.approvers.length === 0);
const invalidBranches = flow.nodes.filter((node) => node.type === "condition" && (!node.branches || node.branches.length < 2));
if (emptyApprovers.length || invalidBranches.length) notify(`发现 ${emptyApprovers.length + invalidBranches.length} 项配置需要完善`);
else notify(`流程校验通过,共 ${flow.nodes.length} 个主节点`);
};
const publish = () => {
window.localStorage.setItem("orbit-approval-flow", JSON.stringify({ ...flow, status: "published" }));
setFlow((current) => ({ ...current, status: "published" }));
notify("演示流程已发布到本地");
};
const resetFlow = () => {
setFlow(defaultFlow as unknown as FlowData);
setSelectedId("approval-1");
window.localStorage.removeItem("orbit-approval-flow");
notify("已恢复 JSON 示例流程");
};
return (
<div className={styles.designerShell}>
<header className={styles.topbar}>
<div className={styles.topbarLeft}>
<Link href="/" className={styles.backButton} aria-label="返回后台模板"></Link>
<span className={styles.brandMark}><i /><i /><i /></span>
<div className={styles.processIdentity}>
<div><input value={flow.name} onChange={(event) => setFlow((current) => ({ ...current, name: event.target.value }))} aria-label="流程名称" /><span className={flow.status === "published" ? styles.published : styles.draft}>{flow.status === "published" ? "已发布" : "草稿"}</span></div>
<small>{saved ? "✓ 已自动保存到本地" : "正在保存…"} · {flow.version}</small>
</div>
</div>
<div className={styles.topbarActions}>
<button className={styles.quietButton} onClick={() => setJsonOpen(true)}> JSON</button>
<button className={styles.quietButton} onClick={validateFlow}> </button>
<button className={styles.quietButton} onClick={() => setPreviewOpen(true)}> </button>
<button className={styles.publishButton} onClick={publish}></button>
</div>
</header>
<div className={styles.workspace}>
<aside className={`${styles.palette} ${paletteOpen ? styles.paletteOpen : ""}`}>
<div className={styles.paletteHeader}><div><strong></strong><small></small></div><button onClick={() => setPaletteOpen(false)}>×</button></div>
<div className={styles.paletteSection}>
<p></p>
{library.map((item) => (
<button key={item.type} className={styles.paletteItem} draggable onDragStart={(event) => event.dataTransfer.setData("nodeType", item.type)} onClick={() => addNode(item.type)}>
<span className={`${styles.paletteIcon} ${styles[item.tone]}`}>{item.icon}</span>
<span><strong>{item.label}</strong><small>{item.description}</small></span>
<b></b>
</button>
))}
</div>
<div className={styles.paletteSection}>
<p></p>
<button className={`${styles.paletteItem} ${styles.disabledItem}`} onClick={() => notify("自动化节点为后续扩展示例")}><span className={`${styles.paletteIcon} ${styles.green}`}></span><span><strong></strong><small></small></span><b></b></button>
<button className={`${styles.paletteItem} ${styles.disabledItem}`} onClick={() => notify("数据操作节点为后续扩展示例")}><span className={`${styles.paletteIcon} ${styles.gray}`}></span><span><strong></strong><small></small></span><b></b></button>
</div>
<div className={styles.helpCard}><span>?</span><div><strong></strong><p></p></div></div>
</aside>
<main className={styles.canvasArea} onDragOver={(event) => event.preventDefault()} onDrop={(event) => { const type = event.dataTransfer.getData("nodeType") as NodeType; if (type) addNode(type); }}>
<div className={styles.canvasToolbar}>
<button className={styles.mobilePaletteButton} onClick={() => setPaletteOpen(true)}> </button>
<div className={styles.breadcrumb}><span></span><b>/</b><span></span><b>/</b><strong>{flow.name}</strong></div>
<div className={styles.canvasTools}><button onClick={() => setZoom(Math.max(.8, zoom - .1))}></button><span>{Math.round(zoom * 100)}%</span><button onClick={() => setZoom(Math.min(1.2, zoom + .1))}></button><button onClick={() => setZoom(1)}></button></div>
</div>
<div className={styles.canvasScroller}>
<div className={styles.canvasGrid}>
<div className={styles.flowWrap} style={{ transform: `scale(${zoom})` }}>
{flow.nodes.map((node, index) => (
<div className={styles.flowStep} key={node.id}>
{node.type === "condition" ? (
<ConditionNode node={node} selected={selectedId === node.id} onSelect={() => setSelectedId(node.id)} />
) : (
<ProcessNode node={node} selected={selectedId === node.id} onSelect={() => setSelectedId(node.id)} />
)}
{index < flow.nodes.length - 1 && <div className={styles.connector}><i /></div>}
</div>
))}
<button className={styles.canvasAdd} onClick={() => addNode("approval")}><span></span></button>
</div>
</div>
</div>
</main>
<aside className={styles.propertyPanel}>
{selectedNode ? <NodeSettings node={selectedNode} updateNode={updateNode} deleteNode={deleteNode} notify={notify} /> : <div className={styles.noSelection}><span></span><strong></strong><p></p></div>}
</aside>
</div>
{previewOpen && <PreviewModal flow={flow} onClose={() => setPreviewOpen(false)} />}
{jsonOpen && <JsonDrawer flow={flow} resetFlow={resetFlow} onClose={() => setJsonOpen(false)} notify={notify} />}
{toast && <div className={styles.toast}><span></span>{toast}</div>}
</div>
);
}
function ProcessNode({ node, selected, onSelect }: { node: FlowNode; selected: boolean; onSelect: () => void }) {
const meta = {
start: { icon: "▶", tone: "green", eyebrow: "流程发起" },
approval: { icon: "✓", tone: "blue", eyebrow: node.mode === "all" ? "会签审批" : node.mode === "any" ? "或签审批" : "单人审批" },
cc: { icon: "◎", tone: "purple", eyebrow: "抄送通知" },
end: { icon: "■", tone: "gray", eyebrow: "流程完成" },
condition: { icon: "⑂", tone: "orange", eyebrow: "条件分支" },
}[node.type];
return (
<button className={`${styles.processNode} ${selected ? styles.selectedNode : ""} ${styles[`node_${node.type}`]}`} onClick={onSelect}>
<span className={`${styles.nodeIcon} ${styles[meta.tone]}`}>{meta.icon}</span>
<span className={styles.nodeCopy}><small>{meta.eyebrow}</small><strong>{node.title}</strong><em>{node.description}</em></span>
{node.approvers.length > 0 && <span className={styles.approverPreview}>{node.approvers.slice(0, 2).map((person) => <i key={person}>{person.slice(0, 1)}</i>)}<b>{node.approvers.length} </b></span>}
{node.type !== "start" && node.type !== "end" && <span className={styles.nodeMore}></span>}
</button>
);
}
function ConditionNode({ node, selected, onSelect }: { node: FlowNode; selected: boolean; onSelect: () => void }) {
return (
<div className={`${styles.conditionGroup} ${selected ? styles.selectedCondition : ""}`}>
<button className={styles.conditionTitle} onClick={onSelect}><span className={`${styles.nodeIcon} ${styles.orange}`}></span><span><small></small><strong>{node.title}</strong></span><b> </b></button>
<div className={styles.branchRail} />
<div className={styles.branchGrid}>
{node.branches?.map((branch, index) => (
<div className={styles.branchLane} key={branch.id}>
<div className={styles.branchLabel}><span>{index + 1}</span><strong>{branch.label}</strong><code>{branch.expression}</code></div>
<div className={styles.branchConnector} />
<ProcessNode node={branch.node} selected={false} onSelect={onSelect} />
</div>
))}
</div>
<div className={styles.branchJoin}><i /></div>
</div>
);
}
function NodeSettings({ node, updateNode, deleteNode, notify }: { node: FlowNode; updateNode: (patch: Partial<FlowNode>) => void; deleteNode: () => void; notify: (message: string) => void }) {
const [tab, setTab] = useState("节点设置");
const addPerson = () => {
const next = people.find((person) => !node.approvers.includes(person));
if (!next) return notify("可选示例审批人已全部添加");
updateNode({ approvers: [...node.approvers, next] });
};
return (
<>
<div className={styles.settingsHeader}><div><span className={`${styles.nodeIcon} ${styles[node.type === "condition" ? "orange" : node.type === "cc" ? "purple" : node.type === "start" ? "green" : "blue"]}`}>{node.type === "condition" ? "⑂" : node.type === "cc" ? "◎" : "✓"}</span><span><strong>{node.title}</strong><small> ID · {node.id}</small></span></div>{node.type !== "start" && node.type !== "end" && <button onClick={deleteNode}></button>}</div>
<div className={styles.settingsTabs}>{["节点设置", "表单权限", "高级"].map((item) => <button key={item} className={tab === item ? styles.activeTab : ""} onClick={() => setTab(item)}>{item}</button>)}</div>
<div className={styles.settingsBody}>
{tab === "节点设置" && <>
<label className={styles.field}><span></span><input value={node.title} onChange={(event) => updateNode({ title: event.target.value })} /></label>
<label className={styles.field}><span></span><textarea value={node.description} onChange={(event) => updateNode({ description: event.target.value })} rows={3} /></label>
{node.type === "approval" && <>
<div className={styles.settingGroup}><div className={styles.settingLabel}><strong></strong><small></small></div><div className={styles.segmented}>{[["single", "单人"], ["any", "或签"], ["all", "会签"]].map(([value, label]) => <button key={value} className={node.mode === value ? styles.segmentActive : ""} onClick={() => updateNode({ mode: value as ApprovalMode })}>{label}</button>)}</div></div>
<ApproverPicker node={node} updateNode={updateNode} addPerson={addPerson} />
</>}
{node.type === "cc" && <ApproverPicker node={node} updateNode={updateNode} addPerson={addPerson} title="抄送成员" />}
{node.type === "condition" && <div className={styles.branchSettings}>{node.branches?.map((branch, index) => <div key={branch.id}><div><span>{index + 1}</span><strong> {index + 1}</strong></div><label className={styles.field}><span></span><input value={branch.label} onChange={(event) => updateNode({ branches: node.branches?.map((item) => item.id === branch.id ? { ...item, label: event.target.value } : item) })} /></label><label className={styles.field}><span></span><input value={branch.expression} onChange={(event) => updateNode({ branches: node.branches?.map((item) => item.id === branch.id ? { ...item, expression: event.target.value } : item) })} /></label></div>)}</div>}
{(node.type === "start" || node.type === "end") && <div className={styles.infoBox}><span>i</span><p>{node.type === "start" ? "发起节点用于设置流程适用范围和发起人权限。" : "结束节点会汇总审批结果,并生成完整的流程记录。"}</p></div>}
</>}
{tab === "表单权限" && <div className={styles.permissions}><div className={styles.permissionHead}><span></span><span></span><span></span></div>{["采购事由", "采购金额", "费用归属", "附件凭证"].map((field, index) => <div key={field}><strong>{field}</strong><input type="checkbox" defaultChecked /><input type="checkbox" defaultChecked={index < 2} /></div>)}</div>}
{tab === "高级" && <div className={styles.advancedSettings}><label><span><strong></strong><small></small></span><input type="checkbox" checked={node.allowTransfer ?? true} onChange={(event) => updateNode({ allowTransfer: event.target.checked })} /></label><label><span><strong></strong><small></small></span><input type="checkbox" checked={node.autoPass ?? false} onChange={(event) => updateNode({ autoPass: event.target.checked })} /></label><button onClick={() => notify("超时规则已添加为演示配置")}> </button></div>}
</div>
</>
);
}
function ApproverPicker({ node, updateNode, addPerson, title = "审批人" }: { node: FlowNode; updateNode: (patch: Partial<FlowNode>) => void; addPerson: () => void; title?: string }) {
return <div className={styles.approverSetting}><div className={styles.settingLabel}><strong>{title}</strong><small></small></div><div className={styles.peopleList}>{node.approvers.map((person) => <div key={person}><span>{person.slice(0, 1)}</span><p><strong>{person}</strong><small>{person.includes("负责人") ? "角色" : "动态成员"}</small></p><button onClick={() => updateNode({ approvers: node.approvers.filter((item) => item !== person) })}>×</button></div>)}</div><button className={styles.addPeople} onClick={addPerson}> </button></div>;
}
function PreviewModal({ flow, onClose }: { flow: FlowData; onClose: () => void }) {
return <div className={styles.modalBackdrop} onMouseDown={onClose}><div className={styles.previewModal} onMouseDown={(event) => event.stopPropagation()}><header><div><span></span><div><h2></h2><p> 8,600 </p></div></div><button onClick={onClose}>×</button></header><div className={styles.previewForm}><div><span></span><strong> · </strong></div><div><span></span><strong>¥ 8,600.00</strong></div><div><span></span><strong className={styles.hitBranch}> 5,000 </strong></div></div><div className={styles.previewRoute}>{flow.nodes.map((node, index) => <div key={node.id}><span className={styles.previewDot}>{index + 1}</span><p><strong>{node.title}</strong><small>{node.type === "condition" ? "命中高金额分支" : node.approvers.join("、") || node.description}</small></p>{index < flow.nodes.length - 1 && <i />}</div>)}</div><footer><button onClick={onClose}></button><button onClick={onClose}></button></footer></div></div>;
}
function JsonDrawer({ flow, resetFlow, onClose, notify }: { flow: FlowData; resetFlow: () => void; onClose: () => void; notify: (message: string) => void }) {
return <div className={styles.modalBackdrop} onMouseDown={onClose}><aside className={styles.jsonDrawer} onMouseDown={(event) => event.stopPropagation()}><header><div><h2>JSON </h2><p></p></div><button onClick={onClose}>×</button></header><pre>{JSON.stringify(flow, null, 2)}</pre><footer><button onClick={resetFlow}></button><button onClick={() => { navigator.clipboard?.writeText(JSON.stringify(flow, null, 2)); notify("JSON 已复制"); }}> JSON</button></footer></aside></div>;
}

View File

@@ -0,0 +1,78 @@
{
"id": "FLOW-2026-018",
"name": "采购申请审批",
"description": "适用于办公用品、软件服务及项目采购申请",
"status": "draft",
"version": "v1.6",
"updatedAt": "2026-08-07 14:30",
"nodes": [
{
"id": "start-1",
"type": "start",
"title": "发起人",
"description": "所有员工均可发起",
"approvers": ["全体员工"],
"mode": "single"
},
{
"id": "approval-1",
"type": "approval",
"title": "直属主管审批",
"description": "由发起人的直属主管审批",
"approvers": ["直属主管"],
"mode": "single"
},
{
"id": "condition-1",
"type": "condition",
"title": "采购金额判断",
"description": "根据含税采购总额自动分流",
"approvers": [],
"mode": "single",
"branches": [
{
"id": "branch-high",
"label": "金额 ≥ 5,000 元",
"expression": "totalAmount >= 5000",
"node": {
"id": "approval-finance",
"type": "approval",
"title": "财务负责人审批",
"description": "财务合规与预算审核",
"approvers": ["财务负责人", "部门负责人"],
"mode": "all"
}
},
{
"id": "branch-normal",
"label": "金额 < 5,000 元",
"expression": "totalAmount < 5000",
"node": {
"id": "approval-budget",
"type": "approval",
"title": "预算管理员审批",
"description": "确认预算科目与剩余额度",
"approvers": ["预算管理员"],
"mode": "single"
}
}
]
},
{
"id": "cc-1",
"type": "cc",
"title": "抄送采购执行人",
"description": "审批通过后自动通知",
"approvers": ["采购专员", "申请人"],
"mode": "single"
},
{
"id": "end-1",
"type": "end",
"title": "流程结束",
"description": "生成采购执行单",
"approvers": [],
"mode": "single"
}
]
}

32
app/page.module.css Normal file
View File

@@ -0,0 +1,32 @@
.approvalLauncher {
position: fixed;
right: 26px;
bottom: 26px;
z-index: 45;
width: 208px;
min-height: 64px;
display: flex;
align-items: center;
gap: 11px;
padding: 10px 12px;
border: 1px solid #295fcf;
border-radius: 12px;
background: #3276f6;
box-shadow: 0 16px 34px rgba(30, 86, 196, .28);
color: white;
text-decoration: none;
transition: transform .18s ease, background .18s ease;
}
.approvalLauncher:hover { transform: translateY(-2px); background: #215ecb; }
.launcherIcon { width: 36px; height: 36px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 9px; background: rgba(255,255,255,.16); font-size: 18px; }
.approvalLauncher > span:nth-child(2) { display: flex; flex: 1; flex-direction: column; gap: 3px; }
.approvalLauncher strong { font-size: 12px; }
.approvalLauncher small { color: rgba(255,255,255,.72); font-size: 9px; }
.approvalLauncher b { font-size: 17px; }
@media (max-width: 620px) {
.approvalLauncher { right: 14px; bottom: 14px; width: 54px; min-height: 54px; padding: 9px; border-radius: 50%; }
.approvalLauncher > span:nth-child(2), .approvalLauncher b { display: none; }
.launcherIcon { width: 34px; height: 34px; background: transparent; }
}

View File

@@ -1,252 +1,17 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import mockData from "./data/mock-data.json";
type View = "overview" | "components" | "content" | "board" | "schedule";
type Toast = { id: number; message: string };
type BoardColumn = (typeof mockData.board)[number];
const navGroups = [
{
label: "工作台",
items: [
{ id: "overview" as View, icon: "⌂", label: "概览" },
{ id: "components" as View, icon: "◈", label: "组件样式", badge: "18" },
],
},
{
label: "业务管理",
items: [
{ id: "content" as View, icon: "▤", label: "内容管理", children: ["图文列表", "卡片内容", "分类标签"] },
{ id: "board" as View, icon: "▦", label: "流程泳道", badge: "9" },
{ id: "schedule" as View, icon: "◷", label: "定时任务" },
],
},
{
label: "系统设置",
items: [
{ id: "components" as View, icon: "♙", label: "成员与权限" },
{ id: "components" as View, icon: "⚙", label: "基础设置" },
],
},
];
const pageMeta: Record<View, { title: string; subtitle: string }> = {
overview: { title: "下午好,林知夏", subtitle: "这里是团队今天的工作概览,祝你高效完成每一项计划。" },
components: { title: "组件样式", subtitle: "可直接复用的标签、按钮、菜单与交互状态。" },
content: { title: "内容管理", subtitle: "通过图片文字列表与卡片视图管理业务内容。" },
board: { title: "流程泳道", subtitle: "拖动任务卡片,快速同步团队当前进度。" },
schedule: { title: "定时任务", subtitle: "纯前端模拟任务配置、状态切换与执行记录。" },
};
import Link from "next/link";
import AdminApp from "./AdminApp";
import styles from "./page.module.css";
// AdminApp owns the localStorage-backed template interactions.
export default function Home() {
const [activeView, setActiveView] = useState<View>("overview");
const [expandedMenus, setExpandedMenus] = useState<string[]>(["内容管理"]);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [search, setSearch] = useState("");
const [toasts, setToasts] = useState<Toast[]>([]);
const [board, setBoard] = useState<BoardColumn[]>(mockData.board);
const [dragged, setDragged] = useState<{ cardId: string; columnId: string } | null>(null);
const [schedules, setSchedules] = useState(mockData.schedules);
const [scheduleModal, setScheduleModal] = useState(false);
const [profileOpen, setProfileOpen] = useState(false);
useEffect(() => {
const saved = window.localStorage.getItem("orbit-schedules");
if (saved) {
try { setSchedules(JSON.parse(saved)); }
catch { window.localStorage.removeItem("orbit-schedules"); }
}
}, []);
useEffect(() => {
window.localStorage.setItem("orbit-schedules", JSON.stringify(schedules));
}, [schedules]);
const notify = (message: string) => {
const id = Date.now();
setToasts((current) => [...current, { id, message }]);
window.setTimeout(() => setToasts((current) => current.filter((item) => item.id !== id)), 2600);
};
const navigate = (view: View) => {
setActiveView(view);
setSidebarOpen(false);
setSearch("");
};
const filteredProjects = useMemo(
() => mockData.projects.filter((project) => `${project.title}${project.description}${project.tag}`.toLowerCase().includes(search.toLowerCase())),
[search],
);
const moveCard = (targetColumnId: string) => {
if (!dragged || dragged.columnId === targetColumnId) return;
let movingCard: BoardColumn["cards"][number] | undefined;
const nextBoard = board.map((column) => {
if (column.id !== dragged.columnId) return column;
movingCard = column.cards.find((card) => card.id === dragged.cardId);
return { ...column, cards: column.cards.filter((card) => card.id !== dragged.cardId) };
});
if (!movingCard) return;
setBoard(nextBoard.map((column) => column.id === targetColumnId ? { ...column, cards: [...column.cards, movingCard!] } : column));
setDragged(null);
notify("任务状态已更新");
};
const toggleSchedule = (id: number) => {
setSchedules((current) => current.map((schedule) => schedule.id === id ? { ...schedule, enabled: !schedule.enabled } : schedule));
notify("任务状态已保存到本地");
};
return (
<div className="app-shell">
<aside className={`sidebar ${sidebarOpen ? "is-open" : ""}`}>
<button className="brand" onClick={() => navigate("overview")} aria-label="返回概览">
<span className="brand-mark"><i /><i /><i /></span>
<span>Orbit<span>Flow</span></span>
</button>
<nav className="main-nav" aria-label="主导航">
{navGroups.map((group) => (
<div className="nav-group" key={group.label}>
<p>{group.label}</p>
{group.items.map((item) => {
const expanded = expandedMenus.includes(item.label);
return (
<div key={`${group.label}-${item.label}`}>
<button className={`nav-item ${activeView === item.id ? "active" : ""}`} onClick={() => {
navigate(item.id);
if (item.children) setExpandedMenus((current) => current.includes(item.label) ? current.filter((label) => label !== item.label) : [...current, item.label]);
}}>
<span className="nav-icon">{item.icon}</span><span>{item.label}</span>
{item.badge && <span className="nav-badge">{item.badge}</span>}
{item.children && <span className={`chevron ${expanded ? "expanded" : ""}`}></span>}
</button>
{item.children && expanded && (
<div className="subnav">
{item.children.map((child, index) => <button className={index === 0 && activeView === "content" ? "active" : ""} key={child} onClick={() => navigate("content")}>{child}</button>)}
</div>
)}
</div>
);
})}
</div>
))}
</nav>
<div className="sidebar-help"><span>?</span><div><strong></strong><small>使</small></div><button onClick={() => notify("帮助中心即将开放")}></button></div>
</aside>
{sidebarOpen && <button className="sidebar-backdrop" aria-label="关闭菜单" onClick={() => setSidebarOpen(false)} />}
<main className="main-area">
<header className="topbar">
<button className="mobile-menu" aria-label="打开菜单" onClick={() => setSidebarOpen(true)}></button>
<label className="global-search"><span></span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索项目、任务或成员..." /><kbd> K</kbd></label>
<div className="top-actions">
<button className="icon-button" aria-label="帮助" onClick={() => notify("你可以从左侧菜单浏览全部模板")}>?</button>
<button className="icon-button notification" aria-label="通知" onClick={() => notify("你有 3 条未读通知")}><i /></button>
<span className="divider" />
<div className="profile-wrap">
<button className="profile" onClick={() => setProfileOpen(!profileOpen)}><span className="avatar"></span><span><strong></strong><small></small></span><b></b></button>
{profileOpen && <div className="profile-menu"><button onClick={() => notify("个人资料功能为模板占位")}></button><button onClick={() => notify("偏好设置功能为模板占位")}></button><button onClick={() => setProfileOpen(false)}></button></div>}
</div>
</div>
</header>
<section className="page-content">
<div className="page-heading">
<div><p className="eyebrow">{activeView === "overview" ? "2026 年 8 月 7 日 · 星期五" : "ORBITFLOW TEMPLATE"}</p><h1>{pageMeta[activeView].title}</h1><p>{pageMeta[activeView].subtitle}</p></div>
<div className="heading-actions"><button className="button secondary" onClick={() => notify("示例数据已刷新")}> </button><button className="button primary" onClick={() => activeView === "schedule" ? setScheduleModal(true) : notify("已创建一条示例记录")}> {activeView === "schedule" ? "新建任务" : "快速创建"}</button></div>
</div>
{activeView === "overview" && <Overview onNavigate={navigate} notify={notify} />}
{activeView === "components" && <ComponentsShowcase notify={notify} />}
{activeView === "content" && <ContentView projects={filteredProjects} notify={notify} />}
{activeView === "board" && <BoardView board={board} dragged={dragged} setDragged={setDragged} moveCard={moveCard} notify={notify} />}
{activeView === "schedule" && <ScheduleView schedules={schedules} toggleSchedule={toggleSchedule} notify={notify} onCreate={() => setScheduleModal(true)} />}
</section>
</main>
{scheduleModal && <ScheduleModal onClose={() => setScheduleModal(false)} onSave={(name, frequency) => {
setSchedules((current) => [...current, { id: Math.max(...current.map((item) => item.id), 0) + 1, name, type: "自定义任务", cron: frequency === "每天" ? "0 9 * * *" : "0 9 * * 1", frequency: frequency === "每天" ? "每天 09:00" : "每周一 09:00", lastRun: "尚未执行", nextRun: frequency === "每天" ? "明天 09:00" : "下周一 09:00", enabled: true, success: 100 }]);
setScheduleModal(false); notify("新任务已保存到本地");
}} />}
<div className="toast-stack" aria-live="polite">{toasts.map((toast) => <div className="toast" key={toast.id}><span></span>{toast.message}</div>)}</div>
</div>
);
}
function Overview({ onNavigate, notify }: { onNavigate: (view: View) => void; notify: (message: string) => void }) {
return (
<>
<div className="stats-grid">
{mockData.stats.map((stat, index) => <article className="stat-card" key={stat.label}><div className={`stat-icon ${stat.tone}`}>{["▣", "✓", "♙", "↗"][index]}</div><div><p>{stat.label}</p><strong>{stat.value}</strong></div><span className={index === 1 ? "muted" : "positive"}>{stat.change}</span></article>)}
</div>
<div className="dashboard-grid">
<section className="panel project-panel">
<PanelHeader title="重点项目" subtitle="本周需要关注的项目进展" action="查看全部" onAction={() => onNavigate("content")} />
<div className="project-list compact">
{mockData.projects.map((project) => <article className="project-row" key={project.id}><img src={project.image} alt="" /><div className="project-info"><div className="project-title-line"><strong>{project.title}</strong><span className={`tag status-${project.status}`}>{project.status}</span></div><p>{project.description}</p><div className="progress-line"><span><i style={{ width: `${project.progress}%` }} /></span><b>{project.progress}%</b></div></div><div className="project-owner"><span className="mini-avatar">{project.owner.slice(-1)}</span><small>{project.owner}</small><b>{project.date}</b></div></article>)}
</div>
</section>
<section className="panel todo-panel">
<PanelHeader title="今日待办" subtitle="已完成 6 / 9" action="管理任务" onAction={() => onNavigate("board")} />
<div className="completion-bar"><i /></div>
<div className="todo-list">
{[["评审新版工作台原型", "10:00", true], ["与研发确认排期", "11:30", true], ["整理组件规范文档", "14:00", false], ["增长实验周会", "16:30", false]].map(([title, time, done]) => <label key={String(title)} className={done ? "done" : ""}><input type="checkbox" defaultChecked={Boolean(done)} onChange={() => notify("待办状态已更新")} /><span>{String(title)}</span><time>{String(time)}</time></label>)}
</div>
<button className="text-button add-todo" onClick={() => notify("已添加一条空白待办")}> </button>
</section>
</div>
<section className="panel activity-panel">
<PanelHeader title="团队动态" subtitle="项目中的最新协作记录" action="全部动态" onAction={() => notify("暂无更多动态")} />
<div className="activity-list">
{[["周", "周屿", "完成了任务", "后台筛选器交互优化", "12 分钟前", "green"], ["许", "许言", "上传了文件", "八月增长计划-v3.pdf", "35 分钟前", "blue"], ["陈", "陈序", "评论了", "确认新用户引导文案", "1 小时前", "orange"], ["林", "林知夏", "创建了项目", "新版工作台视觉升级", "2 小时前", "purple"]].map(([avatar, name, verb, target, time, tone]) => <div className="activity-item" key={String(target)}><span className={`activity-avatar ${tone}`}>{avatar}</span><p><strong>{name}</strong> {verb} <b>{target}</b><small>{time}</small></p><button aria-label="更多" onClick={() => notify("更多操作")}></button></div>)}
</div>
</section>
<AdminApp />
<Link className={styles.approvalLauncher} href="/approval">
<span className={styles.launcherIcon}></span>
<span><strong></strong><small></small></span>
<b></b>
</Link>
</>
);
}
function ComponentsShowcase({ notify }: { notify: (message: string) => void }) {
const [tab, setTab] = useState("基础组件");
return (
<div className="component-layout">
<div className="component-tabs">{["基础组件", "状态样式", "菜单示例"].map((item) => <button className={tab === item ? "active" : ""} key={item} onClick={() => setTab(item)}>{item}</button>)}</div>
{tab === "基础组件" && <>
<section className="panel specimen"><PanelHeader title="按钮 Buttons" subtitle="用于操作、提交和页面导航" /><div className="specimen-row"><button className="button primary" onClick={() => notify("主要按钮")}></button><button className="button secondary" onClick={() => notify("次要按钮")}></button><button className="button soft" onClick={() => notify("轻量按钮")}></button><button className="button danger" onClick={() => notify("危险操作示例")}></button><button className="button icon-combo" onClick={() => notify("下载开始")}> </button><button className="button secondary" disabled></button></div></section>
<section className="panel specimen"><PanelHeader title="标签 Tags" subtitle="用于分类、筛选与状态提示" /><div className="specimen-row tags-demo"><span className="tag blue"></span><span className="tag green"></span><span className="tag orange"></span><span className="tag purple"></span><span className="tag red"></span><span className="tag neutral"></span><span className="tag outlined"> ×</span></div></section>
<section className="panel specimen"><PanelHeader title="表单控件 Forms" subtitle="统一输入、选择与开关样式" /><div className="form-showcase"><label><span></span><input defaultValue="新版工作台升级" /></label><label><span></span><select defaultValue="design"><option value="design"></option><option></option></select></label><label><span></span><div className="input-with-icon"><i></i><input placeholder="搜索关键词" /></div></label><label className="switch-field"><span><small></small></span><input type="checkbox" defaultChecked /></label></div></section>
</>}
{tab === "状态样式" && <section className="panel specimen state-gallery"><PanelHeader title="反馈与状态" subtitle="覆盖成功、提示、警告和错误场景" /><div className="alert success"><b></b><span><strong></strong></span><button>×</button></div><div className="alert info"><b>i</b><span><strong></strong> JSON </span><button>×</button></div><div className="alert warning"><b>!</b><span><strong></strong></span><button>×</button></div><div className="alert error"><b>×</b><span><strong></strong></span><button>×</button></div></section>}
{tab === "菜单示例" && <section className="panel specimen menu-gallery"><PanelHeader title="菜单 Menu" subtitle="展示导航、操作菜单和分组层级" /><div className="menu-examples"><div className="demo-menu"><p></p><button> <kbd> V</kbd></button><button> <kbd> E</kbd></button><hr /><button> </button><button className="danger-text"> </button></div><div className="demo-menu dark"><p></p><button className="active"> </button><button> <span>9</span></button><button> </button><small>访</small><button> </button></div></div></section>}
</div>
);
}
function ContentView({ projects, notify }: { projects: typeof mockData.projects; notify: (message: string) => void }) {
const [display, setDisplay] = useState<"list" | "cards">("list");
return (
<section className="panel content-panel">
<div className="content-toolbar"><div className="filter-chips"><button className="active"> <b>24</b></button><button> <b>8</b></button><button> <b>16</b></button></div><div className="view-switch"><button className={display === "list" ? "active" : ""} onClick={() => setDisplay("list")}></button><button className={display === "cards" ? "active" : ""} onClick={() => setDisplay("cards")}></button></div></div>
{projects.length === 0 ? <div className="empty-state"><span></span><h3></h3><p></p></div> : display === "list" ? <div className="image-list">{projects.map((project) => <article key={project.id}><img src={project.image} alt={`${project.title}项目配图`} /><div><div className="content-title"><span className="tag blue">{project.tag}</span><span className={`tag status-${project.status}`}>{project.status}</span></div><h3>{project.title}</h3><p>{project.description}</p><small> {project.owner} · {project.date}</small></div><div className="content-progress"><strong>{project.progress}%</strong><span><i style={{ width: `${project.progress}%` }} /></span><button onClick={() => notify(`打开:${project.title}`)}> </button></div></article>)}</div> : <div className="content-cards">{projects.map((project) => <article key={project.id}><div className="card-image"><img src={project.image} alt="" /><span className="tag white">{project.tag}</span></div><div className="card-body"><span className={`tag status-${project.status}`}>{project.status}</span><h3>{project.title}</h3><p>{project.description}</p><div><span className="mini-avatar">{project.owner.slice(-1)}</span><small>{project.owner}</small><b>{project.progress}%</b></div></div></article>)}</div>}
</section>
);
}
function BoardView({ board, dragged, setDragged, moveCard, notify }: { board: BoardColumn[]; dragged: { cardId: string; columnId: string } | null; setDragged: (value: { cardId: string; columnId: string } | null) => void; moveCard: (columnId: string) => void; notify: (message: string) => void }) {
return <><div className="board-toolbar"><div className="avatar-stack"><span></span><span></span><span></span><span></span><b>+8</b></div><div><button className="button secondary"> </button><button className="button secondary"> </button></div></div><div className="kanban-board">{board.map((column) => <section className={`kanban-column ${dragged ? "dragging" : ""}`} key={column.id} onDragOver={(event) => event.preventDefault()} onDrop={() => moveCard(column.id)}><header><div><i style={{ background: column.color }} /><strong>{column.title}</strong><span>{column.cards.length}</span></div><button onClick={() => notify(`在“${column.title}”中添加任务`)}></button></header><div className="kanban-cards">{column.cards.map((card) => <article draggable key={card.id} onDragStart={() => setDragged({ cardId: card.id, columnId: column.id })} onDragEnd={() => setDragged(null)}><div className="kanban-card-top"><span>{card.id}</span><button onClick={() => notify("更多任务操作")}></button></div><h3>{card.title}</h3><div className="kanban-tags"><span className="tag neutral">{card.tag}</span>{card.priority === "紧急" && <span className="tag red"></span>}</div><footer><span className="mini-avatar">{card.owner.slice(-1)}</span><small>{card.owner}</small><time className={card.due === "今天" ? "today" : ""}> {card.due}</time></footer></article>)}<button className="add-card" onClick={() => notify("添加卡片为模板示例")}> </button></div></section>)}</div></>;
}
function ScheduleView({ schedules, toggleSchedule, notify, onCreate }: { schedules: typeof mockData.schedules; toggleSchedule: (id: number) => void; notify: (message: string) => void; onCreate: () => void }) {
const activeCount = schedules.filter((item) => item.enabled).length;
return <><div className="schedule-summary"><div><span className="summary-icon blue"></span><p><strong>{schedules.length}</strong></p></div><div><span className="summary-icon green"></span><p><strong>{activeCount}</strong></p></div><div><span className="summary-icon purple"></span><p><strong>128</strong></p></div><div><span className="summary-icon orange"></span><p><strong>98.5%</strong></p></div></div><section className="panel schedule-panel"><div className="schedule-toolbar"><div><button className="active"></button><button></button><button></button></div><button className="button primary" onClick={onCreate}> </button></div><div className="table-wrap"><table><thead><tr><th></th><th></th><th></th><th></th><th></th><th></th><th /></tr></thead><tbody>{schedules.map((schedule) => <tr key={schedule.id}><td><span className="task-type-icon">{schedule.type === "消息通知" ? "✧" : schedule.type === "文件归档" ? "▤" : "↻"}</span><div><strong>{schedule.name}</strong><small>{schedule.type}</small></div></td><td><strong>{schedule.frequency}</strong><code>{schedule.cron}</code></td><td>{schedule.lastRun}</td><td>{schedule.nextRun}</td><td><span className="success-rate"><i style={{ width: `${schedule.success}%` }} /></span>{schedule.success}%</td><td><button className={`toggle ${schedule.enabled ? "on" : ""}`} aria-label={`${schedule.enabled ? "停用" : "启用"}${schedule.name}`} onClick={() => toggleSchedule(schedule.id)}><i /></button><span className={schedule.enabled ? "running" : "disabled"}>{schedule.enabled ? "运行中" : "已停用"}</span></td><td><button className="row-action" onClick={() => notify(`编辑:${schedule.name}`)}></button></td></tr>)}</tbody></table></div></section></>;
}
function ScheduleModal({ onClose, onSave }: { onClose: () => void; onSave: (name: string, frequency: string) => void }) {
const [name, setName] = useState(""); const [frequency, setFrequency] = useState("每天");
return <div className="modal-backdrop" onMouseDown={onClose}><form className="modal" onMouseDown={(event) => event.stopPropagation()} onSubmit={(event) => { event.preventDefault(); if (name.trim()) onSave(name.trim(), frequency); }}><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><option></option><option></option><option></option><option></option></select></label><fieldset><legend></legend><label><input type="radio" name="frequency" checked={frequency === "每天"} onChange={() => setFrequency("每天")} /></label><label><input type="radio" name="frequency" checked={frequency === "每周"} onChange={() => setFrequency("每周")} /></label></fieldset><div className="time-row"><label><span></span><input type="time" defaultValue="09:00" /></label><label><span></span><select><option>Asia/Shanghai</option></select></label></div><footer><button type="button" className="button secondary" onClick={onClose}></button><button className="button primary" type="submit"></button></footer></form></div>;
}
function PanelHeader({ title, subtitle, action, onAction }: { title: string; subtitle: string; action?: string; onAction?: () => void }) {
return <div className="panel-header"><div><h2>{title}</h2><p>{subtitle}</p></div>{action && <button onClick={onAction}>{action} </button>}</div>;
}