feat: add AirShelf core implementation

This commit is contained in:
zyc
2026-06-05 10:21:40 +08:00
parent 2ba1058329
commit cfdcd84a30
252 changed files with 70828 additions and 0 deletions
@@ -0,0 +1,46 @@
const iconPaths: Record<string, string> = {
layoutDashboard: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
package: '<path d="M11 21.7a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7Z"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="M12 22V12"/><path d="m7.5 4.3 9 5.1"/>',
clapperboard: '<path d="m12.3 3.5 3 4"/><path d="M20.2 6 3 11l-.9-2.4a2 2 0 0 1 1.3-2.5l13.5-4a2 2 0 0 1 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
sparkles: '<path d="M12 3l1.7 4.6L18 9l-4.3 1.4L12 15l-1.7-4.6L6 9l4.3-1.4L12 3Z"/><path d="M19 15l.9 2.1L22 18l-2.1.9L19 21l-.9-2.1L16 18l2.1-.9L19 15Z"/>',
images: '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21"/>',
folder: '<path d="M3 6.5A2.5 2.5 0 0 1 5.5 4H9l2 2h7.5A2.5 2.5 0 0 1 21 8.5v8A2.5 2.5 0 0 1 18.5 19h-13A2.5 2.5 0 0 1 3 16.5Z"/><path d="M3 9h18"/>',
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v2"/><circle cx="9.5" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
creditCard: '<rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/>',
settings: '<path d="M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.2a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.2A1.7 1.7 0 0 0 4.7 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3 1.7 1.7 0 0 0 1.1-1.5V3a2 2 0 1 1 4 0v.2a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8 1.7 1.7 0 0 0 1.5 1h.1a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"/>',
search: '<circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/>',
bell: '<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10 21a2 2 0 0 0 4 0"/>',
chevronLeft: '<path d="m15 18-6-6 6-6"/>',
chevronRight: '<path d="m9 18 6-6-6-6"/>',
productPlus: '<path d="M12 22V12"/><path d="M16 17h6"/><path d="M19 14v6"/><path d="M21 10.5V8a2 2 0 0 0-1-1.7l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.7l7 4a2 2 0 0 0 2 0l1.7-1"/><path d="m3.3 7 8.7 5 8.7-5"/><path d="m7.5 4.3 9 5.1"/>',
arrowUp: '<path d="M12 19V5"/><path d="m5 12 7-7 7 7"/>',
helpCircle: '<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 5 0c0 1.5-2.5 2-2.5 4"/><path d="M12 17h.01"/>'
};
const iconAliases: Record<string, string> = {
dashboard: "layoutDashboard",
library: "folder"
};
export function IconKitSvg({ name, size = 16, strokeWidth = 1.5 }: { name: string; size?: number; strokeWidth?: number }) {
const key = iconAliases[name] || name;
return (
<svg
className="ui-icon"
data-icon={key}
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={strokeWidth}
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
aria-hidden="true"
dangerouslySetInnerHTML={{ __html: iconPaths[key] || iconPaths.helpCircle }}
/>
);
}
@@ -0,0 +1,80 @@
import { Check, Search } from "lucide-react";
import type { Product, User } from "../types";
import type { NavItem, Notice, Page } from "../routes/route-config";
import { mainNav, parentPage, supplementNav } from "../routes/route-config";
type Navigate = (page: Page) => void;
export function Sidebar({ page, navigate, user, products }: { page: Page; navigate: Navigate; user: User; products: Product[] }) {
const activePage = parentPage(page);
return (
<aside className="sidebar">
<div className="brand">
<div className="flame">
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2c1 3 4 5 4 9a4 4 0 0 1-4 4 4 4 0 0 1-4-4 5 5 0 0 1 1.5-3.5C10.5 6 11.5 4 12 2zm-1 13c0 2 1 3 1 5 1-1 3-2 3-5 0-1.5-1-2-2-3-1 1-2 2-2 3z" />
</svg>
</div>
<div><div className="name">流·Studio</div></div>
</div>
<div className="search-box">
<Search size={14} />
<input id="global-search" placeholder="搜索" />
<span className="kbd">⌘K</span>
</div>
<div className="nav-section">主要</div>
<nav>
{mainNav.map((item) => (
<NavButton key={item.page} item={{ ...item, badge: item.page === "products" ? String(products.length) : item.badge }} active={activePage === item.page} navigate={navigate} />
))}
</nav>
<div className="nav-section">补充页面</div>
<nav>
{supplementNav.map((item) => <NavButton key={item.page} item={item} active={activePage === item.page} navigate={navigate} />)}
</nav>
<div className="aside-foot">
<button className="user" type="button">
<div className="av">{user.username.slice(0, 1).toUpperCase()}</div>
<div className="em">{user.username}</div>
</button>
</div>
</aside>
);
}
export function NavButton({ item, active, navigate }: { item: NavItem; active: boolean; navigate: Navigate }) {
const Icon = item.icon;
return (
<button className={active ? "active" : ""} type="button" onClick={() => navigate(item.page)}>
<Icon size={14} />
<span>{item.label}</span>
{item.badge && <span className="pill-mini">{item.badge}</span>}
</button>
);
}
export function Decorations() {
// Exact 设计稿只用 grid-bg 做底纹;旧版散点/角标是绝对定位写死坐标,会压在
// topbar 和 page-head 上(重叠 bug),且不在设计稿里。品牌 mono 签名由各页面 inline
// 的 // 注释、[ ALL ] 等承载,这里只保留底纹。
return <div className="grid-bg" />;
}
export function ToastLike({ notice }: { notice: NonNullable<Notice> }) {
return (
<div className={`inline-toast ${notice.type}`}>
<div className="ic-t"><Check size={12} /></div>
<div className="txt">{notice.text}<span className="mono">[ {notice.type.toUpperCase()} ]</span></div>
</div>
);
}
export function CornerMark({ pos }: { pos: "tl" | "tr" | "bl" | "br" }) {
return (
<span className={`corner-mark ${pos}`}>
<svg viewBox="0 0 22 21" fill="none">
<path d="M10.5 4C10.5 7.31371 7.81371 10 4.5 10H0.5V11H4.5C7.81371 11 10.5 13.6863 10.5 17V21H11.5V17C11.5 13.6863 14.1863 11 17.5 11H21.5V10H17.5C14.1863 10 11.5 7.31371 11.5 4V0H10.5V4Z" fill="currentColor" />
</svg>
</span>
);
}
+71
View File
@@ -0,0 +1,71 @@
import type { ReactNode } from "react";
import { Shield, X } from "lucide-react";
export function SettingRow({ title, desc, action, toggle, checked }: { title: string; desc: string; action?: string; toggle?: boolean; checked?: boolean }) {
return (
<div className="setting-row">
<div><strong>{title}</strong><span>{desc}</span></div>
{toggle ? <label className="switch"><input type="checkbox" defaultChecked={checked} /><span className="slider" /></label> : <button className="btn btn-sm" type="button">{action}</button>}
</div>
);
}
export function TeamModal({ open, title, subtitle, icon, close, children, footer }: {
open: boolean;
title: string;
subtitle: string;
icon: ReactNode;
close: () => void;
children: ReactNode;
footer?: ReactNode;
}) {
if (!open) return null;
return (
<div className="modal-bg show" onClick={close}>
<div className="modal invite-modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h"><div className="ic-m">{icon}</div><div className="ti">{title}<span>{subtitle}</span></div><button className="x modal-x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
<div className="modal-b">{children}</div>
<div className="modal-f"><button className="btn" type="button" onClick={close}>取消</button>{footer || <button className="btn btn-primary" type="button" onClick={close}>保存</button>}</div>
</div>
</div>
);
}
export function ConfirmModal({ open, title, detail, confirmText, onCancel, onConfirm }: {
open: boolean;
title: string;
detail: string;
confirmText: string;
onCancel: () => void;
onConfirm: () => void | Promise<unknown>;
}) {
if (!open) return null;
return (
<div className="modal-bg show" onClick={onCancel}>
<div className="modal" onClick={(event) => event.stopPropagation()}>
<span className="corner-tr">+</span><span className="corner-bl">+</span>
<div className="modal-h"><div className="ic-m"><Shield size={16} /></div><div className="ti">{title}<span>// CONFIRM</span></div></div>
<div className="modal-b">{detail}</div>
<div className="modal-f"><button className="btn" type="button" onClick={onCancel}>取消</button><button className="btn btn-primary" type="button" onClick={() => void onConfirm()}>{confirmText}</button></div>
</div>
</div>
);
}
export function Drawer({ title, open, close, children }: { title: string; open: boolean; close: () => void; children: ReactNode }) {
if (!open) return null;
return (
<>
<div className="drawer-bg show" onClick={close} />
<aside className="drawer show">
<div className="drawer-h"><h3>{title}</h3><button className="x" type="button" onClick={close} aria-label="关闭"><X size={14} /></button></div>
<div className="drawer-b">{children}</div>
</aside>
</>
);
}
export function EmptyPanel({ title, action, onAction }: { title: string; action: string; onAction: () => void }) {
return <div className="empty-state show"><h3>{title}</h3><p>// 先创建资料后进入下一步</p><button className="btn btn-primary" type="button" onClick={onAction}>{action}</button></div>;
}
@@ -0,0 +1,29 @@
import { stageMeta, stageOrder } from "../routes/stage-config";
export function Progress({ status }: { status: string }) {
const current = Math.max(stageOrder.indexOf(status as never), 0);
return (
<div className="prog">
{stageOrder.map((stage, index) => <span key={stage} className={index < current ? "done" : index === current ? "cur" : ""} />)}
</div>
);
}
export function FragmentStageStep({ stage, done, active, lineDone, isLast, onClick }: {
stage: string;
done: boolean;
active: boolean;
lineDone: boolean;
isLast: boolean;
onClick: () => void;
}) {
const meta = stageMeta[stage];
return (
<>
<button className={`stage-step ${done ? "done" : ""} ${active ? "active" : ""}`} type="button" onClick={onClick}>
<div className="num">{done ? "✓" : meta.no}</div><div className="lbl">{meta.label}</div><div className="st">{done ? "已确认" : active ? "进行中" : "未开始"}</div>
</button>
{!isLast && <div className={`stage-line ${lineDone ? "done" : ""}`} />}
</>
);
}