Build OrbitFlow frontend admin template
This commit is contained in:
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# dependencies and local caches
|
||||
/node_modules
|
||||
/.npm-cache/
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# tests and builds
|
||||
/coverage
|
||||
/.next/
|
||||
/.vinext/
|
||||
/out/
|
||||
/dist/
|
||||
/.wrangler/
|
||||
/outputs/
|
||||
/work/
|
||||
*.tsbuildinfo
|
||||
|
||||
# local files
|
||||
.DS_Store
|
||||
*.pem
|
||||
*.starter
|
||||
*.pre-tsbuild
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
.env*
|
||||
.vercel
|
||||
next-env.d.ts
|
||||
5
.openai/hosting.json
Normal file
5
.openai/hosting.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"project_id": "appgprj_6a757242007c81919f3f5da2a4bd99f9",
|
||||
"d1": null,
|
||||
"r2": null
|
||||
}
|
||||
51
README.md
Normal file
51
README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# OrbitFlow React 后台模板
|
||||
|
||||
一个无需后端即可运行的现代后台管理模板。项目基于 React 19、Next.js 与 vinext,所有演示业务数据来自本地 JSON;定时任务配置通过 `localStorage` 保存在当前浏览器。
|
||||
|
||||
## 已包含
|
||||
|
||||
- 深色多级侧边菜单与移动端抽屉导航
|
||||
- 标签、按钮、表单、状态反馈与菜单样式
|
||||
- 图片文字列表和响应式卡片视图
|
||||
- 支持拖放的四列流程泳道
|
||||
- 定时任务列表、启停开关和新建配置弹窗
|
||||
- 全局搜索、待办勾选、Toast 提示等交互
|
||||
- 桌面、平板和手机响应式布局
|
||||
|
||||
## 本地运行
|
||||
|
||||
需要 Node.js `>=22.13.0`。
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
生产构建与测试:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
## 数据位置
|
||||
|
||||
演示数据集中在 `app/data/mock-data.json`,包含:
|
||||
|
||||
- `stats`:概览指标
|
||||
- `projects`:项目与图文内容
|
||||
- `board`:泳道及任务卡片
|
||||
- `schedules`:定时任务初始配置
|
||||
|
||||
后续接入真实接口时,可以保留页面组件,只替换这一层的数据读取方式。当前 `.openai/hosting.json` 中的 D1 和 R2 均为 `null`,项目不依赖数据库或对象存储。
|
||||
|
||||
## 主要文件
|
||||
|
||||
- `app/page.tsx`:页面、导航和交互逻辑
|
||||
- `app/globals.css`:完整视觉系统与响应式样式
|
||||
- `app/data/mock-data.json`:可直接修改的模拟数据
|
||||
- `tests/rendered-html.test.mjs`:渲染与纯前端约束检查
|
||||
|
||||
## 注意
|
||||
|
||||
任务启停和新建任务会写入浏览器本地存储。清除浏览器站点数据后会恢复为 JSON 中的初始内容。
|
||||
88
app/data/mock-data.json
Normal file
88
app/data/mock-data.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"stats": [
|
||||
{ "label": "进行中项目", "value": "24", "change": "+12%", "tone": "blue" },
|
||||
{ "label": "今日待办", "value": "18", "change": "6 项已完成", "tone": "orange" },
|
||||
{ "label": "团队成员", "value": "42", "change": "+3 本月", "tone": "green" },
|
||||
{ "label": "按时完成率", "value": "92%", "change": "+4.2%", "tone": "purple" }
|
||||
],
|
||||
"projects": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "新版工作台视觉升级",
|
||||
"description": "统一后台设计语言,完成核心业务页面的组件迁移。",
|
||||
"image": "https://images.unsplash.com/photo-1522071820081-009f0129c71c?auto=format&fit=crop&w=320&q=80",
|
||||
"tag": "设计系统",
|
||||
"status": "进行中",
|
||||
"progress": 72,
|
||||
"owner": "林知夏",
|
||||
"date": "08-18"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "客户数据看板重构",
|
||||
"description": "优化指标层级与筛选效率,支持多角色查看。",
|
||||
"image": "https://images.unsplash.com/photo-1552664730-d307ca884978?auto=format&fit=crop&w=320&q=80",
|
||||
"tag": "数据产品",
|
||||
"status": "评审中",
|
||||
"progress": 48,
|
||||
"owner": "周屿",
|
||||
"date": "08-22"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "会员增长自动化",
|
||||
"description": "搭建触达策略与任务编排,验证首批增长实验。",
|
||||
"image": "https://images.unsplash.com/photo-1521737711867-e3b97375f902?auto=format&fit=crop&w=320&q=80",
|
||||
"tag": "增长实验",
|
||||
"status": "待开始",
|
||||
"progress": 16,
|
||||
"owner": "许言",
|
||||
"date": "08-28"
|
||||
}
|
||||
],
|
||||
"board": [
|
||||
{
|
||||
"id": "todo",
|
||||
"title": "待处理",
|
||||
"color": "#8a94a6",
|
||||
"cards": [
|
||||
{ "id": "K-1024", "title": "补充移动端导航规范", "tag": "设计", "priority": "普通", "owner": "林知夏", "due": "今天" },
|
||||
{ "id": "K-1025", "title": "整理季度复盘数据", "tag": "运营", "priority": "紧急", "owner": "许言", "due": "08-09" },
|
||||
{ "id": "K-1026", "title": "确认新用户引导文案", "tag": "内容", "priority": "普通", "owner": "陈序", "due": "08-11" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "doing",
|
||||
"title": "进行中",
|
||||
"color": "#3276f6",
|
||||
"cards": [
|
||||
{ "id": "K-1019", "title": "后台筛选器交互优化", "tag": "研发", "priority": "紧急", "owner": "周屿", "due": "今天" },
|
||||
{ "id": "K-1021", "title": "建立组件使用示例库", "tag": "设计", "priority": "普通", "owner": "林知夏", "due": "08-10" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "review",
|
||||
"title": "待验收",
|
||||
"color": "#8b5cf6",
|
||||
"cards": [
|
||||
{ "id": "K-1015", "title": "成员权限配置页面", "tag": "研发", "priority": "普通", "owner": "唐可", "due": "08-08" },
|
||||
{ "id": "K-1017", "title": "活动落地页数据埋点", "tag": "数据", "priority": "普通", "owner": "许言", "due": "08-09" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "done",
|
||||
"title": "已完成",
|
||||
"color": "#20a26a",
|
||||
"cards": [
|
||||
{ "id": "K-1008", "title": "工作台首屏性能优化", "tag": "研发", "priority": "普通", "owner": "周屿", "due": "08-06" },
|
||||
{ "id": "K-1011", "title": "八月内容排期确认", "tag": "内容", "priority": "普通", "owner": "陈序", "due": "08-05" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"schedules": [
|
||||
{ "id": 1, "name": "每日经营数据汇总", "type": "数据同步", "cron": "0 9 * * *", "frequency": "每天 09:00", "lastRun": "今天 09:00", "nextRun": "明天 09:00", "enabled": true, "success": 98 },
|
||||
{ "id": 2, "name": "会员到期提醒", "type": "消息通知", "cron": "0 10 * * 1-5", "frequency": "工作日 10:00", "lastRun": "今天 10:00", "nextRun": "明天 10:00", "enabled": true, "success": 100 },
|
||||
{ "id": 3, "name": "周度报表归档", "type": "文件归档", "cron": "0 18 * * 5", "frequency": "每周五 18:00", "lastRun": "08-01 18:00", "nextRun": "08-08 18:00", "enabled": true, "success": 96 },
|
||||
{ "id": 4, "name": "测试数据清理", "type": "系统维护", "cron": "0 2 1 * *", "frequency": "每月 1 日 02:00", "lastRun": "08-01 02:00", "nextRun": "09-01 02:00", "enabled": false, "success": 100 }
|
||||
]
|
||||
}
|
||||
433
app/globals.css
Normal file
433
app/globals.css
Normal file
@@ -0,0 +1,433 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--ink: #152034;
|
||||
--text: #27344a;
|
||||
--muted: #7a8497;
|
||||
--line: #e8ebf2;
|
||||
--soft-line: #f0f2f6;
|
||||
--surface: #ffffff;
|
||||
--canvas: #f5f7fb;
|
||||
--sidebar: #111b2e;
|
||||
--sidebar-soft: #19263d;
|
||||
--blue: #3276f6;
|
||||
--blue-dark: #215ecb;
|
||||
--blue-soft: #edf4ff;
|
||||
--green: #20a26a;
|
||||
--green-soft: #eaf8f1;
|
||||
--orange: #e99124;
|
||||
--orange-soft: #fff5e7;
|
||||
--purple: #8157d9;
|
||||
--purple-soft: #f3effd;
|
||||
--red: #e45252;
|
||||
--red-soft: #feeeee;
|
||||
--shadow: 0 8px 26px rgba(35, 48, 78, 0.06);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { background: var(--canvas); }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--canvas);
|
||||
color: var(--text);
|
||||
font-family: Inter, "SF Pro Display", "PingFang SC", "Microsoft YaHei", system-ui, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
button, input, select { font: inherit; }
|
||||
button { color: inherit; }
|
||||
button:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid rgba(50, 118, 246, .18); outline-offset: 2px; }
|
||||
|
||||
.app-shell { min-height: 100vh; }
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
inset: 0 auto 0 0;
|
||||
z-index: 30;
|
||||
width: 244px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
background: var(--sidebar);
|
||||
color: #b8c1d2;
|
||||
border-right: 1px solid #25314a;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 82px;
|
||||
padding: 0 28px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
letter-spacing: -.4px;
|
||||
}
|
||||
.brand > span:last-child span { color: #72a0ff; }
|
||||
.brand-mark { position: relative; width: 27px; height: 27px; display: block; transform: rotate(-12deg); }
|
||||
.brand-mark i { position: absolute; display: block; width: 8px; height: 24px; border-radius: 5px; background: #fff; }
|
||||
.brand-mark i:nth-child(1) { left: 0; top: 3px; height: 16px; background: #74a4ff; }
|
||||
.brand-mark i:nth-child(2) { left: 9px; top: 0; height: 24px; }
|
||||
.brand-mark i:nth-child(3) { right: 0; bottom: 0; height: 17px; background: #477ff2; }
|
||||
.main-nav { flex: 1; padding: 4px 16px 20px; }
|
||||
.nav-group { margin: 0 0 22px; }
|
||||
.nav-group > p { margin: 0 12px 8px; color: #65738a; font-size: 11px; font-weight: 700; letter-spacing: 1.2px; }
|
||||
.nav-item {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: #aeb8ca;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
transition: background .18s ease, color .18s ease;
|
||||
}
|
||||
.nav-item:hover { background: rgba(255,255,255,.05); color: #fff; }
|
||||
.nav-item.active { background: #2c64d7; color: #fff; box-shadow: 0 6px 16px rgba(24, 78, 191, .24); }
|
||||
.nav-icon { width: 20px; text-align: center; color: inherit; font-size: 18px; line-height: 1; }
|
||||
.nav-badge { margin-left: auto; min-width: 22px; padding: 2px 6px; border-radius: 9px; background: rgba(255,255,255,.1); color: #9dacbf; font-size: 11px; text-align: center; }
|
||||
.nav-item.active .nav-badge { background: rgba(255,255,255,.2); color: #fff; }
|
||||
.chevron { margin-left: auto; transition: transform .2s ease; }
|
||||
.chevron.expanded { transform: rotate(180deg); }
|
||||
.subnav { position: relative; margin: 4px 0 4px 22px; padding-left: 22px; }
|
||||
.subnav::before { content: ""; position: absolute; left: 6px; top: 4px; bottom: 4px; width: 1px; background: #2b3850; }
|
||||
.subnav button { display: block; width: 100%; padding: 7px 6px; border: 0; background: none; color: #748198; cursor: pointer; text-align: left; font-size: 13px; }
|
||||
.subnav button:hover, .subnav button.active { color: #d8e4fb; }
|
||||
.sidebar-help { display: flex; align-items: center; gap: 10px; margin: 0 16px 18px; padding: 13px; border: 1px solid #2a3952; border-radius: 10px; background: #17243a; }
|
||||
.sidebar-help > span { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 50%; background: #23395c; color: #7da7ff; font-weight: 800; }
|
||||
.sidebar-help div { flex: 1; display: flex; flex-direction: column; gap: 3px; }
|
||||
.sidebar-help strong { color: #e3e8f1; font-size: 12px; }
|
||||
.sidebar-help small { color: #718099; font-size: 10px; }
|
||||
.sidebar-help button { border: 0; background: none; color: #77869d; cursor: pointer; }
|
||||
|
||||
.main-area { min-height: 100vh; margin-left: 244px; }
|
||||
.topbar { position: sticky; top: 0; z-index: 20; height: 72px; display: flex; align-items: center; justify-content: space-between; padding: 0 34px; border-bottom: 1px solid var(--line); background: rgba(255,255,255,.95); backdrop-filter: blur(12px); }
|
||||
.global-search { width: min(420px, 40vw); height: 39px; display: flex; align-items: center; gap: 9px; padding: 0 11px; border: 1px solid transparent; border-radius: 9px; background: #f3f5f8; color: #8c96a8; transition: border-color .2s, background .2s; }
|
||||
.global-search:focus-within { border-color: #b8cffd; background: #fff; box-shadow: 0 0 0 3px rgba(50,118,246,.08); }
|
||||
.global-search span { font-size: 20px; transform: rotate(-20deg); }
|
||||
.global-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--text); font-size: 13px; }
|
||||
.global-search input::placeholder { color: #9ba4b3; }
|
||||
kbd { padding: 3px 6px; border: 1px solid #dfe3e9; border-bottom-width: 2px; border-radius: 5px; background: #fff; color: #9aa3b2; font-family: inherit; font-size: 10px; }
|
||||
.top-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.icon-button { position: relative; width: 36px; height: 36px; border: 0; border-radius: 50%; background: transparent; color: #697386; cursor: pointer; font-size: 17px; }
|
||||
.icon-button:hover { background: #f2f4f7; color: var(--blue); }
|
||||
.notification i { position: absolute; top: 6px; right: 6px; width: 6px; height: 6px; border: 2px solid #fff; border-radius: 50%; background: var(--red); box-sizing: content-box; }
|
||||
.divider { width: 1px; height: 24px; margin: 0 8px; background: var(--line); }
|
||||
.profile-wrap { position: relative; }
|
||||
.profile { display: flex; align-items: center; gap: 9px; padding: 3px; border: 0; background: transparent; cursor: pointer; text-align: left; }
|
||||
.avatar, .mini-avatar, .activity-avatar, .avatar-stack span { display: grid; place-items: center; border-radius: 50%; background: #d9e6ff; color: #285dbd; font-weight: 700; }
|
||||
.avatar { width: 36px; height: 36px; background: #dfe9ff; border: 2px solid #fff; box-shadow: 0 0 0 1px #dbe0e9; font-size: 13px; }
|
||||
.profile > span:nth-child(2) { display: flex; flex-direction: column; gap: 2px; }
|
||||
.profile strong { color: var(--ink); font-size: 12px; }
|
||||
.profile small { color: #8c95a6; font-size: 10px; }
|
||||
.profile b { margin-left: 5px; color: #9099a8; font-size: 12px; }
|
||||
.profile-menu { position: absolute; top: 48px; right: 0; width: 160px; padding: 7px; border: 1px solid var(--line); border-radius: 10px; background: #fff; box-shadow: 0 15px 35px rgba(35, 48, 78, .14); }
|
||||
.profile-menu button { width: 100%; padding: 9px 10px; border: 0; border-radius: 7px; background: transparent; cursor: pointer; text-align: left; font-size: 12px; }
|
||||
.profile-menu button:hover { background: #f3f6fb; color: var(--blue); }
|
||||
.mobile-menu { display: none; border: 0; background: transparent; cursor: pointer; font-size: 20px; }
|
||||
|
||||
.page-content { max-width: 1520px; margin: 0 auto; padding: 30px 36px 48px; }
|
||||
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 28px; }
|
||||
.page-heading h1 { margin: 3px 0 7px; color: var(--ink); font-size: 27px; line-height: 1.2; letter-spacing: -.7px; }
|
||||
.page-heading > div > p:last-child { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.eyebrow { margin: 0; color: var(--blue); font-size: 10px; font-weight: 800; letter-spacing: 1.1px; text-transform: uppercase; }
|
||||
.heading-actions { display: flex; gap: 10px; }
|
||||
.button { min-height: 38px; padding: 0 15px; border: 1px solid transparent; border-radius: 8px; cursor: pointer; font-size: 12px; font-weight: 650; transition: transform .15s, box-shadow .15s, background .15s; }
|
||||
.button:hover:not(:disabled) { transform: translateY(-1px); }
|
||||
.button.primary { border-color: var(--blue); background: var(--blue); color: #fff; box-shadow: 0 5px 13px rgba(50,118,246,.2); }
|
||||
.button.primary:hover { background: var(--blue-dark); }
|
||||
.button.secondary { border-color: #dfe3ea; background: #fff; color: #5f6b7e; }
|
||||
.button.secondary:hover { border-color: #b9c8e1; background: #fbfcfe; }
|
||||
.button.soft { background: var(--blue-soft); color: var(--blue); }
|
||||
.button.danger { background: var(--red); color: #fff; }
|
||||
.button.icon-combo { border-color: #d8def0; background: #263650; color: #fff; }
|
||||
.button:disabled { background: #f2f3f5; color: #b1b6bf; cursor: not-allowed; }
|
||||
|
||||
.panel { border: 1px solid var(--line); border-radius: 12px; background: var(--surface); box-shadow: var(--shadow); }
|
||||
.panel-header { min-height: 75px; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 18px 21px; border-bottom: 1px solid var(--soft-line); }
|
||||
.panel-header h2 { margin: 0 0 4px; color: var(--ink); font-size: 15px; letter-spacing: -.2px; }
|
||||
.panel-header p { margin: 0; color: #8e97a7; font-size: 11px; }
|
||||
.panel-header > button { border: 0; background: transparent; color: var(--blue); cursor: pointer; font-size: 11px; }
|
||||
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; margin-bottom: 17px; }
|
||||
.stat-card { position: relative; min-height: 105px; display: flex; align-items: center; gap: 14px; padding: 20px; border: 1px solid var(--line); border-radius: 11px; background: #fff; box-shadow: 0 4px 16px rgba(35,48,78,.04); }
|
||||
.stat-icon, .summary-icon { display: grid; place-items: center; border-radius: 10px; font-weight: 800; }
|
||||
.stat-icon { width: 45px; height: 45px; font-size: 19px; }
|
||||
.blue { background: var(--blue-soft); color: var(--blue); }
|
||||
.green { background: var(--green-soft); color: var(--green); }
|
||||
.orange { background: var(--orange-soft); color: var(--orange); }
|
||||
.purple { background: var(--purple-soft); color: var(--purple); }
|
||||
.stat-card div:nth-child(2) p { margin: 0 0 5px; color: #828c9d; font-size: 11px; }
|
||||
.stat-card div:nth-child(2) strong { color: var(--ink); font-size: 24px; line-height: 1; letter-spacing: -.5px; }
|
||||
.stat-card > span:last-child { position: absolute; right: 15px; bottom: 17px; padding: 3px 6px; border-radius: 10px; font-size: 9px; }
|
||||
.positive { background: var(--green-soft); color: var(--green); }
|
||||
.muted { background: #f3f4f6; color: #8b94a3; }
|
||||
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(315px, .8fr); gap: 17px; margin-bottom: 17px; }
|
||||
.project-row { min-height: 94px; display: flex; align-items: center; gap: 13px; padding: 13px 20px; border-bottom: 1px solid var(--soft-line); }
|
||||
.project-row:last-child { border-bottom: 0; }
|
||||
.project-row > img { width: 60px; height: 60px; border-radius: 9px; object-fit: cover; }
|
||||
.project-info { min-width: 0; flex: 1; }
|
||||
.project-title-line { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
|
||||
.project-title-line strong { overflow: hidden; color: var(--ink); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.project-info > p { margin: 0 0 9px; overflow: hidden; color: #8992a2; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.progress-line { display: flex; align-items: center; gap: 8px; }
|
||||
.progress-line > span { width: min(210px, 70%); height: 5px; overflow: hidden; border-radius: 4px; background: #edf0f4; }
|
||||
.progress-line i, .content-progress i { display: block; height: 100%; border-radius: inherit; background: var(--blue); }
|
||||
.progress-line b { color: #6f7a8c; font-size: 9px; }
|
||||
.project-owner { width: 66px; display: grid; grid-template-columns: 24px 1fr; align-items: center; gap: 3px; }
|
||||
.mini-avatar { width: 23px; height: 23px; font-size: 9px; }
|
||||
.project-owner small { overflow: hidden; color: #687486; font-size: 9px; white-space: nowrap; }
|
||||
.project-owner b { grid-column: 1 / -1; margin-top: 2px; color: #a0a8b5; font-size: 9px; font-weight: 500; }
|
||||
.tag { display: inline-flex; align-items: center; width: fit-content; min-height: 21px; padding: 2px 8px; border-radius: 5px; background: #f1f3f6; color: #697488; font-size: 9px; font-weight: 700; white-space: nowrap; }
|
||||
.tag.blue, .status-进行中 { background: var(--blue-soft); color: var(--blue); }
|
||||
.tag.green, .status-已完成 { background: var(--green-soft); color: var(--green); }
|
||||
.tag.orange, .status-待开始 { background: var(--orange-soft); color: var(--orange); }
|
||||
.tag.purple, .status-评审中 { background: var(--purple-soft); color: var(--purple); }
|
||||
.tag.red { background: var(--red-soft); color: var(--red); }
|
||||
.tag.neutral { background: #f1f3f6; color: #687489; }
|
||||
.tag.outlined { border: 1px solid #dce1e9; background: #fff; }
|
||||
.tag.white { background: rgba(255,255,255,.9); color: var(--ink); }
|
||||
.completion-bar { height: 5px; margin: 0 20px 7px; overflow: hidden; border-radius: 4px; background: #edf0f4; }
|
||||
.completion-bar i { display: block; width: 66%; height: 100%; background: var(--blue); }
|
||||
.todo-list { padding: 3px 20px 0; }
|
||||
.todo-list label { min-height: 45px; display: flex; align-items: center; gap: 9px; border-bottom: 1px solid var(--soft-line); cursor: pointer; }
|
||||
.todo-list input { width: 16px; height: 16px; accent-color: var(--blue); }
|
||||
.todo-list span { flex: 1; color: #445168; font-size: 11px; }
|
||||
.todo-list time { color: #9aa2b0; font-size: 9px; }
|
||||
.todo-list label.done span { color: #9ca4b1; text-decoration: line-through; }
|
||||
.text-button { border: 0; background: transparent; color: var(--blue); cursor: pointer; font-size: 10px; }
|
||||
.add-todo { margin: 13px 20px 16px; }
|
||||
.activity-list { display: grid; grid-template-columns: repeat(4, 1fr); padding: 13px 20px; }
|
||||
.activity-item { min-width: 0; display: flex; align-items: center; gap: 10px; padding: 5px 17px; border-right: 1px solid var(--soft-line); }
|
||||
.activity-item:first-child { padding-left: 0; }
|
||||
.activity-item:last-child { padding-right: 0; border-right: 0; }
|
||||
.activity-avatar { width: 32px; height: 32px; flex: 0 0 auto; font-size: 10px; }
|
||||
.activity-item p { min-width: 0; flex: 1; margin: 0; overflow: hidden; color: #788294; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.activity-item p strong { color: var(--ink); }
|
||||
.activity-item p b { color: #42516a; font-weight: 600; }
|
||||
.activity-item p small { display: block; margin-top: 4px; color: #a1a8b3; font-size: 9px; }
|
||||
.activity-item > button { border: 0; background: transparent; color: #a4abb7; cursor: pointer; font-size: 10px; }
|
||||
|
||||
.component-tabs { display: flex; gap: 4px; width: fit-content; margin-bottom: 16px; padding: 4px; border: 1px solid var(--line); border-radius: 9px; background: #fff; }
|
||||
.component-tabs button { padding: 8px 14px; border: 0; border-radius: 6px; background: transparent; color: #7b8596; cursor: pointer; font-size: 11px; }
|
||||
.component-tabs button.active { background: var(--blue); color: #fff; }
|
||||
.specimen { margin-bottom: 16px; }
|
||||
.specimen-row { min-height: 112px; display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 24px; }
|
||||
.tags-demo { min-height: 95px; }
|
||||
.form-showcase { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 20px; padding: 25px; }
|
||||
.form-showcase > label, .modal > label, .time-row label { display: flex; flex-direction: column; gap: 8px; color: #4d596e; font-size: 11px; font-weight: 650; }
|
||||
.form-showcase input, .form-showcase select, .modal input, .modal select { width: 100%; height: 40px; padding: 0 12px; border: 1px solid #dfe3ea; border-radius: 7px; outline: 0; background: #fff; color: #38465c; font-size: 11px; }
|
||||
.form-showcase input:focus, .form-showcase select:focus, .modal input:focus, .modal select:focus { border-color: #8db2ff; box-shadow: 0 0 0 3px rgba(50,118,246,.08); }
|
||||
.input-with-icon { position: relative; }
|
||||
.input-with-icon i { position: absolute; left: 11px; top: 9px; color: #929bac; font-size: 17px; font-style: normal; }
|
||||
.input-with-icon input { padding-left: 34px; }
|
||||
.switch-field { flex-direction: row !important; align-items: center; justify-content: space-between; padding: 0 4px; }
|
||||
.switch-field > span { display: flex; flex-direction: column; gap: 4px; }
|
||||
.switch-field small { color: #9aa2b0; font-weight: 400; }
|
||||
.switch-field input { width: 38px; height: 21px; accent-color: var(--blue); }
|
||||
.state-gallery { padding-bottom: 20px; }
|
||||
.state-gallery .panel-header { margin-bottom: 20px; }
|
||||
.alert { min-height: 58px; display: flex; align-items: center; gap: 12px; margin: 10px 22px; padding: 11px 15px; border: 1px solid; border-radius: 8px; font-size: 11px; }
|
||||
.alert > b { width: 25px; height: 25px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid currentColor; border-radius: 50%; }
|
||||
.alert > span { flex: 1; display: flex; flex-direction: column; gap: 3px; }
|
||||
.alert strong { color: currentColor; }
|
||||
.alert > button { border: 0; background: none; color: currentColor; cursor: pointer; }
|
||||
.alert.success { border-color: #bfe9d4; background: #f3fbf7; color: var(--green); }
|
||||
.alert.info { border-color: #cfe0ff; background: #f5f8ff; color: var(--blue); }
|
||||
.alert.warning { border-color: #f2dbb5; background: #fffaf1; color: #c67817; }
|
||||
.alert.error { border-color: #f4c8c8; background: #fff6f6; color: var(--red); }
|
||||
.menu-examples { min-height: 320px; display: flex; align-items: flex-start; gap: 36px; padding: 28px; background: #f5f7fa; }
|
||||
.demo-menu { width: 230px; padding: 7px; border: 1px solid var(--line); border-radius: 10px; background: #fff; box-shadow: 0 15px 35px rgba(35,48,78,.1); }
|
||||
.demo-menu p, .demo-menu small { display: block; margin: 7px 10px; color: #a0a8b5; font-size: 9px; font-weight: 700; letter-spacing: .5px; text-transform: uppercase; }
|
||||
.demo-menu button { width: 100%; display: flex; align-items: center; gap: 8px; padding: 10px; border: 0; border-radius: 7px; background: transparent; cursor: pointer; text-align: left; font-size: 11px; }
|
||||
.demo-menu button:hover, .demo-menu button.active { background: #f0f5ff; color: var(--blue); }
|
||||
.demo-menu button kbd, .demo-menu button span { margin-left: auto; }
|
||||
.demo-menu hr { height: 1px; margin: 4px 7px; border: 0; background: var(--line); }
|
||||
.demo-menu .danger-text { color: var(--red); }
|
||||
.demo-menu.dark { border-color: #27354d; background: var(--sidebar); color: #b9c3d5; }
|
||||
.demo-menu.dark button:hover, .demo-menu.dark button.active { background: #2c64d7; color: #fff; }
|
||||
.demo-menu.dark button span { min-width: 20px; padding: 2px 5px; border-radius: 8px; background: rgba(255,255,255,.1); text-align: center; }
|
||||
|
||||
.content-panel { overflow: hidden; }
|
||||
.content-toolbar { min-height: 66px; display: flex; align-items: center; justify-content: space-between; padding: 12px 18px; border-bottom: 1px solid var(--line); }
|
||||
.filter-chips { display: flex; gap: 4px; }
|
||||
.filter-chips button, .schedule-toolbar > div button { padding: 8px 11px; border: 0; border-radius: 6px; background: transparent; color: #778195; cursor: pointer; font-size: 11px; }
|
||||
.filter-chips button.active, .schedule-toolbar > div button.active { background: var(--blue-soft); color: var(--blue); font-weight: 700; }
|
||||
.filter-chips b { margin-left: 4px; font-size: 9px; }
|
||||
.view-switch { display: flex; padding: 3px; border: 1px solid var(--line); border-radius: 7px; }
|
||||
.view-switch button { width: 30px; height: 28px; border: 0; border-radius: 5px; background: transparent; color: #8992a2; cursor: pointer; }
|
||||
.view-switch button.active { background: #eaf1ff; color: var(--blue); }
|
||||
.image-list article { min-height: 142px; display: grid; grid-template-columns: 156px 1fr 145px; align-items: center; gap: 20px; padding: 18px; border-bottom: 1px solid var(--soft-line); }
|
||||
.image-list article:last-child { border-bottom: 0; }
|
||||
.image-list img { width: 156px; height: 105px; border-radius: 9px; object-fit: cover; }
|
||||
.content-title { display: flex; gap: 6px; margin-bottom: 9px; }
|
||||
.image-list h3, .content-cards h3 { margin: 0 0 7px; color: var(--ink); font-size: 14px; }
|
||||
.image-list p, .content-cards p { margin: 0 0 11px; color: #778296; font-size: 11px; line-height: 1.6; }
|
||||
.image-list small { color: #9aa2b0; font-size: 9px; }
|
||||
.content-progress { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
|
||||
.content-progress strong { color: var(--ink); font-size: 18px; }
|
||||
.content-progress > span { width: 105px; height: 5px; border-radius: 4px; background: #edf0f4; }
|
||||
.content-progress button { margin-top: 8px; border: 0; background: transparent; color: var(--blue); cursor: pointer; font-size: 10px; }
|
||||
.content-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; padding: 20px; }
|
||||
.content-cards > article { overflow: hidden; border: 1px solid var(--line); border-radius: 10px; background: #fff; transition: transform .18s, box-shadow .18s; }
|
||||
.content-cards > article:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
|
||||
.card-image { position: relative; height: 145px; }
|
||||
.card-image img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.card-image .tag { position: absolute; top: 12px; left: 12px; }
|
||||
.card-body { padding: 16px; }
|
||||
.card-body h3 { margin-top: 10px; }
|
||||
.card-body > div { display: flex; align-items: center; gap: 7px; margin-top: 15px; padding-top: 12px; border-top: 1px solid var(--soft-line); }
|
||||
.card-body small { flex: 1; color: #697588; font-size: 10px; }
|
||||
.card-body b { color: var(--blue); font-size: 11px; }
|
||||
.empty-state { min-height: 350px; display: grid; place-content: center; justify-items: center; color: #9aa3b1; text-align: center; }
|
||||
.empty-state > span { width: 55px; height: 55px; display: grid; place-items: center; border-radius: 50%; background: #f2f5f9; font-size: 24px; transform: rotate(-20deg); }
|
||||
.empty-state h3 { margin: 14px 0 5px; color: #556176; font-size: 14px; }
|
||||
.empty-state p { margin: 0; font-size: 11px; }
|
||||
|
||||
.board-toolbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
|
||||
.board-toolbar > div:last-child { display: flex; gap: 8px; }
|
||||
.avatar-stack { display: flex; align-items: center; padding-left: 8px; }
|
||||
.avatar-stack span { width: 30px; height: 30px; margin-left: -8px; border: 2px solid var(--canvas); font-size: 9px; }
|
||||
.avatar-stack span:nth-child(2) { background: #eee5ff; color: #754bc3; }
|
||||
.avatar-stack span:nth-child(3) { background: #ffe9d2; color: #c4741b; }
|
||||
.avatar-stack span:nth-child(4) { background: #dff4e9; color: #21845b; }
|
||||
.avatar-stack b { margin-left: 6px; color: #7d8798; font-size: 10px; }
|
||||
.kanban-board { display: grid; grid-template-columns: repeat(4, minmax(245px, 1fr)); gap: 13px; overflow-x: auto; padding-bottom: 12px; }
|
||||
.kanban-column { min-height: 540px; border: 1px solid var(--line); border-radius: 11px; background: #eef1f6; }
|
||||
.kanban-column.dragging { border-color: #b9cdf5; }
|
||||
.kanban-column > header { min-height: 51px; display: flex; align-items: center; justify-content: space-between; padding: 0 13px; }
|
||||
.kanban-column > header > div { display: flex; align-items: center; gap: 8px; }
|
||||
.kanban-column > header i { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.kanban-column > header strong { color: var(--ink); font-size: 12px; }
|
||||
.kanban-column > header span { min-width: 20px; padding: 2px 6px; border-radius: 9px; background: #dde2e9; color: #778194; font-size: 9px; text-align: center; }
|
||||
.kanban-column > header button { border: 0; background: transparent; color: #7e8899; cursor: pointer; font-size: 17px; }
|
||||
.kanban-cards { padding: 0 8px 10px; }
|
||||
.kanban-cards article { margin-bottom: 8px; padding: 13px; border: 1px solid #e1e5eb; border-radius: 9px; background: #fff; box-shadow: 0 3px 8px rgba(35,48,78,.04); cursor: grab; }
|
||||
.kanban-cards article:active { cursor: grabbing; opacity: .75; }
|
||||
.kanban-card-top { display: flex; align-items: center; justify-content: space-between; }
|
||||
.kanban-card-top > span { color: #a0a8b4; font-size: 8px; font-weight: 700; letter-spacing: .4px; }
|
||||
.kanban-card-top button { border: 0; background: transparent; color: #a0a7b2; cursor: pointer; font-size: 9px; }
|
||||
.kanban-cards h3 { min-height: 37px; margin: 9px 0 10px; color: var(--ink); font-size: 11px; line-height: 1.6; }
|
||||
.kanban-tags { display: flex; gap: 5px; }
|
||||
.kanban-cards footer { display: flex; align-items: center; gap: 6px; margin-top: 14px; padding-top: 11px; border-top: 1px solid var(--soft-line); }
|
||||
.kanban-cards footer small { flex: 1; color: #768195; font-size: 9px; }
|
||||
.kanban-cards footer time { color: #929baa; font-size: 8px; }
|
||||
.kanban-cards footer time.today { color: var(--red); }
|
||||
.add-card { width: 100%; min-height: 38px; border: 1px dashed #cdd3dc; border-radius: 8px; background: transparent; color: #8b95a5; cursor: pointer; font-size: 10px; }
|
||||
.add-card:hover { border-color: #8eaff2; background: rgba(255,255,255,.55); color: var(--blue); }
|
||||
|
||||
.schedule-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 16px; }
|
||||
.schedule-summary > div { display: flex; align-items: center; gap: 13px; min-height: 88px; padding: 17px; border: 1px solid var(--line); border-radius: 10px; background: #fff; }
|
||||
.summary-icon { width: 40px; height: 40px; }
|
||||
.schedule-summary p { display: flex; flex-direction: column; gap: 5px; margin: 0; color: #8490a2; font-size: 10px; }
|
||||
.schedule-summary strong { color: var(--ink); font-size: 20px; }
|
||||
.schedule-panel { overflow: hidden; }
|
||||
.schedule-toolbar { min-height: 67px; display: flex; align-items: center; justify-content: space-between; padding: 11px 18px; border-bottom: 1px solid var(--line); }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; min-width: 980px; }
|
||||
th { height: 42px; padding: 0 15px; background: #f8f9fb; color: #8d96a7; font-size: 9px; font-weight: 700; text-align: left; }
|
||||
td { height: 72px; padding: 10px 15px; border-top: 1px solid var(--soft-line); color: #6e798c; font-size: 10px; }
|
||||
td:first-child { display: flex; align-items: center; gap: 10px; }
|
||||
td:first-child div, td:nth-child(2) { display: flex; flex-direction: column; gap: 4px; }
|
||||
td strong { color: #405069; font-size: 10px; }
|
||||
td small { color: #9ba3b0; font-size: 8px; }
|
||||
.task-type-icon { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 7px; background: var(--blue-soft); color: var(--blue); font-size: 14px; }
|
||||
code { width: fit-content; padding: 2px 5px; border-radius: 4px; background: #f2f4f7; color: #8a94a5; font-size: 8px; }
|
||||
.success-rate { display: inline-block; width: 46px; height: 4px; margin-right: 5px; border-radius: 3px; background: #e9edf2; vertical-align: middle; }
|
||||
.success-rate i { display: block; height: 100%; border-radius: inherit; background: var(--green); }
|
||||
.toggle { position: relative; width: 31px; height: 18px; padding: 0; border: 0; border-radius: 10px; background: #d7dce4; cursor: pointer; vertical-align: middle; }
|
||||
.toggle i { position: absolute; top: 3px; left: 3px; width: 12px; height: 12px; border-radius: 50%; background: #fff; transition: left .2s; }
|
||||
.toggle.on { background: var(--blue); }
|
||||
.toggle.on i { left: 16px; }
|
||||
td .running, td .disabled { margin-left: 6px; font-size: 9px; }
|
||||
td .running { color: var(--green); }
|
||||
td .disabled { color: #9aa2af; }
|
||||
.row-action { border: 0; background: transparent; color: #9099a8; cursor: pointer; }
|
||||
|
||||
.modal-backdrop { position: fixed; inset: 0; z-index: 60; display: grid; place-items: center; padding: 20px; background: rgba(14, 23, 39, .48); backdrop-filter: blur(4px); }
|
||||
.modal { width: min(520px, 100%); padding: 24px; border: 1px solid #e2e6ec; border-radius: 14px; background: #fff; box-shadow: 0 28px 70px rgba(14,23,39,.22); }
|
||||
.modal header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 23px; }
|
||||
.modal h2 { margin: 0 0 5px; color: var(--ink); font-size: 19px; }
|
||||
.modal header p { margin: 0; color: #8c96a5; font-size: 10px; }
|
||||
.modal header > button { width: 30px; height: 30px; border: 0; border-radius: 50%; background: #f3f4f7; color: #788293; cursor: pointer; font-size: 18px; }
|
||||
.modal > label { margin-bottom: 16px; }
|
||||
.modal fieldset { display: flex; gap: 20px; margin: 0 0 16px; padding: 13px; border: 1px solid #e2e6ec; border-radius: 8px; }
|
||||
.modal legend { padding: 0 5px; color: #4d596e; font-size: 10px; font-weight: 700; }
|
||||
.modal fieldset label { display: flex; align-items: center; gap: 6px; color: #687488; font-size: 11px; }
|
||||
.modal fieldset input { accent-color: var(--blue); }
|
||||
.time-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.modal footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; padding-top: 17px; border-top: 1px solid var(--line); }
|
||||
.toast-stack { position: fixed; right: 24px; bottom: 24px; z-index: 80; display: flex; flex-direction: column; gap: 9px; }
|
||||
.toast { min-width: 235px; display: flex; align-items: center; gap: 10px; padding: 12px 15px; border: 1px solid #dfe5ed; border-radius: 9px; background: #fff; box-shadow: 0 15px 35px rgba(25,38,61,.15); color: #47556c; font-size: 11px; animation: toast-in .25s ease-out; }
|
||||
.toast span { width: 20px; height: 20px; display: grid; place-items: center; border-radius: 50%; background: var(--green-soft); color: var(--green); font-weight: 800; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
.sidebar-backdrop { display: none; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.stats-grid, .schedule-summary { grid-template-columns: repeat(2, 1fr); }
|
||||
.dashboard-grid { grid-template-columns: 1fr; }
|
||||
.activity-list { grid-template-columns: repeat(2, 1fr); gap: 10px 0; }
|
||||
.activity-item:nth-child(2) { border-right: 0; }
|
||||
.form-showcase { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.sidebar { transform: translateX(-100%); transition: transform .22s ease; }
|
||||
.sidebar.is-open { transform: translateX(0); }
|
||||
.sidebar-backdrop { position: fixed; inset: 0; z-index: 25; display: block; border: 0; background: rgba(13,21,35,.45); }
|
||||
.main-area { margin-left: 0; }
|
||||
.mobile-menu { display: block; }
|
||||
.topbar { gap: 12px; padding: 0 18px; }
|
||||
.global-search { width: auto; flex: 1; }
|
||||
.global-search kbd, .icon-button, .divider, .profile > span:nth-child(2), .profile b { display: none; }
|
||||
.page-content { padding: 24px 20px 40px; }
|
||||
.content-cards { grid-template-columns: repeat(2, 1fr); }
|
||||
.kanban-board { grid-template-columns: repeat(4, 270px); }
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.topbar { height: 62px; }
|
||||
.page-content { padding: 20px 14px 35px; }
|
||||
.page-heading { align-items: flex-start; flex-direction: column; margin-bottom: 20px; }
|
||||
.heading-actions { width: 100%; }
|
||||
.heading-actions .button { flex: 1; }
|
||||
.stats-grid, .schedule-summary { grid-template-columns: 1fr; gap: 10px; }
|
||||
.stat-card { min-height: 88px; }
|
||||
.activity-list { grid-template-columns: 1fr; }
|
||||
.activity-item, .activity-item:first-child { padding: 7px 0; border-right: 0; border-bottom: 1px solid var(--soft-line); }
|
||||
.activity-item:last-child { border-bottom: 0; }
|
||||
.project-row { align-items: flex-start; padding: 13px; }
|
||||
.project-row > img { width: 52px; height: 52px; }
|
||||
.project-owner { display: none; }
|
||||
.form-showcase { grid-template-columns: 1fr; }
|
||||
.specimen-row { align-items: stretch; }
|
||||
.specimen-row .button { flex: 1 1 40%; }
|
||||
.menu-examples { flex-direction: column; }
|
||||
.demo-menu { width: 100%; }
|
||||
.content-toolbar { align-items: flex-start; gap: 10px; }
|
||||
.filter-chips { overflow-x: auto; }
|
||||
.image-list article { grid-template-columns: 100px 1fr; gap: 13px; }
|
||||
.image-list img { width: 100px; height: 88px; }
|
||||
.content-progress { grid-column: 1 / -1; align-items: stretch; flex-direction: row; }
|
||||
.content-progress > span { flex: 1; margin-top: 7px; }
|
||||
.content-progress button { margin: 0 0 0 auto; }
|
||||
.content-cards { grid-template-columns: 1fr; padding: 14px; }
|
||||
.board-toolbar { gap: 10px; }
|
||||
.board-toolbar .button { padding: 0 9px; }
|
||||
.schedule-toolbar { align-items: flex-start; flex-direction: column; }
|
||||
.time-row { grid-template-columns: 1fr; }
|
||||
.toast-stack { right: 12px; bottom: 12px; left: 12px; }
|
||||
.toast { min-width: 0; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
|
||||
}
|
||||
35
app/layout.tsx
Normal file
35
app/layout.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers } from "next/headers";
|
||||
import "./globals.css";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const requestHeaders = await headers();
|
||||
const host = requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host") ?? "localhost:3000";
|
||||
const protocol = requestHeaders.get("x-forwarded-proto") ?? (host.startsWith("localhost") ? "http" : "https");
|
||||
const origin = `${protocol}://${host}`;
|
||||
|
||||
return {
|
||||
title: "OrbitFlow · React 后台模板",
|
||||
description: "包含组件样式、内容列表、流程泳道和定时任务的纯前端 React 后台模板。",
|
||||
icons: { icon: "/favicon.svg", shortcut: "/favicon.svg" },
|
||||
openGraph: {
|
||||
title: "OrbitFlow · React 后台模板",
|
||||
description: "纯前端、JSON 数据驱动的现代后台模板。",
|
||||
images: [{ url: new URL("/og.png", origin).toString(), width: 1200, height: 630, alt: "OrbitFlow 后台模板" }],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "OrbitFlow · React 后台模板",
|
||||
description: "组件、泳道、定时任务,一套可直接使用的纯前端后台模板。",
|
||||
images: [new URL("/og.png", origin).toString()],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
252
app/page.tsx
Normal file
252
app/page.tsx
Normal 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>;
|
||||
}
|
||||
45
build/sites-vite-plugin.ts
Normal file
45
build/sites-vite-plugin.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { access, cp, mkdir, rm } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Packages Sites metadata and migrations after Vite finishes compiling.
|
||||
export function sites(): Plugin {
|
||||
let root = process.cwd();
|
||||
|
||||
return {
|
||||
name: "sites",
|
||||
apply: "build",
|
||||
configResolved(config) {
|
||||
root = config.root;
|
||||
},
|
||||
async closeBundle() {
|
||||
const outputDirectory = resolve(root, "dist", ".openai");
|
||||
const hostingConfig = resolve(root, ".openai", "hosting.json");
|
||||
const drizzleSource = resolve(root, "drizzle");
|
||||
|
||||
await rm(outputDirectory, { recursive: true, force: true });
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
|
||||
if (await exists(hostingConfig)) {
|
||||
await cp(hostingConfig, resolve(outputDirectory, "hosting.json"));
|
||||
}
|
||||
if (await exists(drizzleSource)) {
|
||||
await cp(drizzleSource, resolve(outputDirectory, "drizzle"), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
10132
package-lock.json
generated
Normal file
10132
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
package.json
Normal file
38
package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "orbitflow-admin-template",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vinext dev",
|
||||
"build": "vinext build",
|
||||
"start": "vinext start",
|
||||
"test": "npm run build && node --test tests/rendered-html.test.mjs",
|
||||
"lint": "eslint . --ignore-pattern dist --ignore-pattern .next"
|
||||
},
|
||||
"dependencies": {
|
||||
"next": "16.2.6",
|
||||
"react": "19.2.6",
|
||||
"react-dom": "19.2.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "1.37.1",
|
||||
"@tailwindcss/postcss": "4.2.1",
|
||||
"@types/node": "22.19.19",
|
||||
"@types/react": "19.2.14",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"@vitejs/plugin-rsc": "0.5.26",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-config-next": "16.2.6",
|
||||
"react-server-dom-webpack": "19.2.6",
|
||||
"tailwindcss": "4.2.1",
|
||||
"typescript": "5.9.3",
|
||||
"vinext": "0.0.50",
|
||||
"vite": "8.0.13",
|
||||
"wrangler": "4.92.0"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
6
public/favicon.svg
Normal file
6
public/favicon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="#111B2E"/>
|
||||
<rect x="7" y="12" width="5" height="13" rx="2.5" transform="rotate(-12 7 12)" fill="#72A0FF"/>
|
||||
<rect x="14" y="6" width="5" height="19" rx="2.5" transform="rotate(-12 14 6)" fill="white"/>
|
||||
<rect x="21" y="10" width="5" height="15" rx="2.5" transform="rotate(-12 21 10)" fill="#3276F6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 452 B |
BIN
public/og.png
Normal file
BIN
public/og.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
46
tests/rendered-html.test.mjs
Normal file
46
tests/rendered-html.test.mjs
Normal file
@@ -0,0 +1,46 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
async function render() {
|
||||
const workerUrl = new URL("../dist/server/index.js", import.meta.url);
|
||||
workerUrl.searchParams.set("test", `${process.pid}-${Date.now()}`);
|
||||
const { default: worker } = await import(workerUrl.href);
|
||||
|
||||
return worker.fetch(
|
||||
new Request("http://localhost/", { headers: { accept: "text/html" } }),
|
||||
{ ASSETS: { fetch: async () => new Response("Not found", { status: 404 }) } },
|
||||
{ waitUntil() {}, passThroughOnException() {} },
|
||||
);
|
||||
}
|
||||
|
||||
test("server-renders the OrbitFlow admin template", async () => {
|
||||
const response = await render();
|
||||
assert.equal(response.status, 200);
|
||||
assert.match(response.headers.get("content-type") ?? "", /^text\/html\b/i);
|
||||
|
||||
const html = await response.text();
|
||||
assert.match(html, /OrbitFlow/);
|
||||
assert.match(html, /下午好,林知夏/);
|
||||
assert.match(html, /流程泳道/);
|
||||
assert.match(html, /定时任务/);
|
||||
assert.doesNotMatch(html, /codex-preview|Your site is taking shape/);
|
||||
});
|
||||
|
||||
test("uses local JSON data without backend bindings", async () => {
|
||||
const [data, hosting, page] = await Promise.all([
|
||||
readFile(new URL("../app/data/mock-data.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../.openai/hosting.json", import.meta.url), "utf8"),
|
||||
readFile(new URL("../app/page.tsx", import.meta.url), "utf8"),
|
||||
]);
|
||||
|
||||
const mock = JSON.parse(data);
|
||||
const hostingConfig = JSON.parse(hosting);
|
||||
assert.ok(mock.projects.length >= 3);
|
||||
assert.ok(mock.board.length >= 4);
|
||||
assert.ok(mock.schedules.length >= 4);
|
||||
assert.equal(hostingConfig.d1, null);
|
||||
assert.equal(hostingConfig.r2, null);
|
||||
assert.match(page, /localStorage/);
|
||||
assert.doesNotMatch(page, /fetch\(|axios|\/api\//);
|
||||
});
|
||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
59
vite.config.ts
Normal file
59
vite.config.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import vinext from "vinext";
|
||||
import { defineConfig } from "vite";
|
||||
import hostingConfig from "./.openai/hosting.json";
|
||||
import { sites } from "./build/sites-vite-plugin";
|
||||
|
||||
const SITE_CREATOR_PLACEHOLDER_DATABASE_ID =
|
||||
"00000000-0000-4000-8000-000000000000";
|
||||
|
||||
const { d1, r2 } = hostingConfig;
|
||||
|
||||
// macOS Seatbelt blocks FSEvents, so Codex previews need polling for HMR.
|
||||
const isCodexSeatbeltSandbox = process.env.CODEX_SANDBOX === "seatbelt";
|
||||
|
||||
const localBindingConfig = {
|
||||
main: "./worker/index.ts",
|
||||
compatibility_flags: ["nodejs_compat"],
|
||||
d1_databases: d1
|
||||
? [
|
||||
{
|
||||
binding: d1,
|
||||
database_name: "site-creator-d1",
|
||||
database_id: SITE_CREATOR_PLACEHOLDER_DATABASE_ID,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
r2_buckets: r2
|
||||
? [
|
||||
{
|
||||
binding: r2,
|
||||
bucket_name: "site-creator-r2",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
};
|
||||
|
||||
export default defineConfig(async () => {
|
||||
// Keep Wrangler and Miniflare state project-local. These are non-secret tool
|
||||
// settings; application environment belongs in ignored `.env*` files.
|
||||
process.env.WRANGLER_WRITE_LOGS ??= "false";
|
||||
process.env.WRANGLER_LOG_PATH ??= ".wrangler/logs";
|
||||
process.env.MINIFLARE_REGISTRY_PATH ??= ".wrangler/registry";
|
||||
|
||||
// Wrangler snapshots its log path while the Cloudflare plugin is imported.
|
||||
const { cloudflare } = await import("@cloudflare/vite-plugin");
|
||||
|
||||
return {
|
||||
server: isCodexSeatbeltSandbox
|
||||
? { watch: { useFsEvents: false, usePolling: true } }
|
||||
: undefined,
|
||||
plugins: [
|
||||
vinext(),
|
||||
sites(),
|
||||
cloudflare({
|
||||
viteEnvironment: { name: "rsc", childEnvironments: ["ssr"] },
|
||||
config: localBindingConfig,
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
50
worker/index.ts
Normal file
50
worker/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/** Cloudflare hosting adapter for the frontend-only OrbitFlow template. */
|
||||
import {
|
||||
handleImageOptimization,
|
||||
DEFAULT_DEVICE_SIZES,
|
||||
DEFAULT_IMAGE_SIZES,
|
||||
} from "vinext/server/image-optimization";
|
||||
import handler from "vinext/server/app-router-entry";
|
||||
|
||||
interface Env {
|
||||
ASSETS: { fetch(request: Request): Promise<Response> };
|
||||
IMAGES: {
|
||||
input(stream: ReadableStream): {
|
||||
transform(options: Record<string, unknown>): {
|
||||
output(options: { format: string; quality: number }): Promise<{ response(): Response }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface ExecutionContext {
|
||||
waitUntil(promise: Promise<unknown>): void;
|
||||
passThroughOnException(): void;
|
||||
}
|
||||
|
||||
const worker = {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (url.pathname === "/_vinext/image") {
|
||||
const allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFAULT_IMAGE_SIZES];
|
||||
return handleImageOptimization(
|
||||
request,
|
||||
{
|
||||
fetchAsset: (path) => env.ASSETS.fetch(new Request(new URL(path, request.url))),
|
||||
transformImage: async (body, { width, format, quality }) => {
|
||||
const result = await env.IMAGES.input(body)
|
||||
.transform(width > 0 ? { width } : {})
|
||||
.output({ format, quality });
|
||||
return result.response();
|
||||
},
|
||||
},
|
||||
allowedWidths,
|
||||
);
|
||||
}
|
||||
|
||||
return handler.fetch(request, env, ctx);
|
||||
},
|
||||
};
|
||||
|
||||
export default worker;
|
||||
Reference in New Issue
Block a user