迭代 ONE-OS 原型导航:VoltAgent 视觉改版、密码门禁与侧栏按业务线切换;车辆氢费明细标注目录同步至 v1.5。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-07-09 18:34:31 +08:00
parent 6ccbbef86a
commit 824512a224
7 changed files with 1445 additions and 574 deletions

View File

@@ -295,7 +295,7 @@ export function syncPrototypeNavRegistry(options = {}) {
let bumped = 0; let bumped = 0;
for (const prototypeId of targetIds) { for (const prototypeId of targetIds) {
if (prototypeId === NAV_PROTOTYPE_ID) continue; if (prototypeId === NAV_PROTOTYPE_ID && options.prototype !== NAV_PROTOTYPE_ID) continue;
const title = titleMap.get(prototypeId) || prototypeId; const title = titleMap.get(prototypeId) || prototypeId;
const prototypeDir = path.join(projectRoot, 'src/prototypes', prototypeId); const prototypeDir = path.join(projectRoot, 'src/prototypes', prototypeId);
if (!fs.existsSync(prototypeDir)) { if (!fs.existsSync(prototypeDir)) {

View File

@@ -5,7 +5,7 @@
"version": 2, "version": 2,
"prototypeName": "oneos-prototype-nav", "prototypeName": "oneos-prototype-nav",
"pageId": "nav", "pageId": "nav",
"updatedAt": 1783589987167, "updatedAt": 1783600000000,
"nodes": [ "nodes": [
{ {
"id": "opn-hero", "id": "opn-hero",
@@ -51,37 +51,6 @@
}, },
"markdownMap": {}, "markdownMap": {},
"directory": { "directory": {
"nodes": [ "nodes": []
{
"type": "folder",
"id": "oneos-project-nav",
"title": "ONE-OS 原型导航",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-prototype-nav",
"title": "原型导航(当前)",
"href": "/prototypes/oneos-prototype-nav",
"target": "self"
},
{
"type": "folder",
"id": "nav-folder-1782874576229-r8atbu",
"title": "OneOS",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-web-workbench",
"title": "工作台",
"href": "/prototypes/oneos-web-workbench",
"target": "self"
}
]
}
]
}
]
} }
} }

View File

@@ -3,15 +3,24 @@
* ONE-OS 原型目录导航:按菜单分组快速进入各功能原型 * ONE-OS 原型目录导航:按菜单分组快速进入各功能原型
*/ */
import './style.css'; import './style.css';
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { import {
ArrowRight, ArrowUpRight,
Compass, Clock3,
ExternalLink, ExternalLink,
FileCode2,
History, History,
Layers, LayoutGrid,
Lock,
Search, Search,
X, X,
Zap,
} from 'lucide-react'; } from 'lucide-react';
import { import {
countNavLinks, countNavLinks,
@@ -29,13 +38,49 @@ import {
type RecentUpdateEntry, type RecentUpdateEntry,
} from './nav-data'; } from './nav-data';
function linkMeta(link: NavLinkItem, record: PrototypeRegistryRecord | null): string | undefined { function formatRecordUpdatedAt(record: PrototypeRegistryRecord | null): string | undefined {
const parts: string[] = []; if (record?.lastUpdated) {
if (link.version || record?.version) parts.push(link.version || record?.version || ''); const date = new Date(record.lastUpdated);
if (link.lastUpdatedLabel) parts.push(`${link.lastUpdatedLabel}更新`); if (!Number.isNaN(date.getTime())) {
return date.toLocaleString('zh-CN', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
}
}
const latest = record?.changelog?.[0]; const latest = record?.changelog?.[0];
if (latest?.summary && parts.length < 2) parts.push(latest.summary); if (latest) {
return parts.filter(Boolean).join(' · ') || undefined; const time = latest.time.length === 5 ? `${latest.time}:00` : latest.time;
return `${latest.date.replace(/-/g, '/')} ${time}`;
}
return undefined;
}
function linkMeta(record: PrototypeRegistryRecord | null): string | undefined {
const updatedAt = formatRecordUpdatedAt(record);
if (updatedAt) return `${updatedAt} 更新`;
return undefined;
}
function sidebarSectionLabel(section: NavSection): string {
if (section.title === '小羚羚') return '小羚羚「小程序」';
return section.title;
}
function sectionIdFromHash(): string | null {
const match = window.location.hash.match(/^#opn-section-(.+)$/u);
return match ? match[1] : null;
}
function resolveInitialSectionId(sections: NavSection[]): string {
const fromHash = sectionIdFromHash();
if (fromHash && sections.some((section) => section.id === fromHash)) return fromHash;
return sections[0]?.id || '';
} }
const NEW_TAB_LINK_PROPS = { const NEW_TAB_LINK_PROPS = {
@@ -43,6 +88,99 @@ const NEW_TAB_LINK_PROPS = {
rel: 'noopener noreferrer', rel: 'noopener noreferrer',
} as const; } as const;
const AUTH_STORAGE_KEY = 'oneos-prototype-nav-auth';
const NAV_ACCESS_PASSWORD = 'lingniu';
function readAuthSession(): boolean {
try {
return sessionStorage.getItem(AUTH_STORAGE_KEY) === 'ok';
} catch {
return false;
}
}
function writeAuthSession(): void {
try {
sessionStorage.setItem(AUTH_STORAGE_KEY, 'ok');
} catch {
/* ignore quota / private mode */
}
}
function PasswordGate({ onUnlock }: { onUnlock: () => void }) {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleSubmit = (event: React.FormEvent) => {
event.preventDefault();
setSubmitting(true);
setError('');
window.setTimeout(() => {
if (password === NAV_ACCESS_PASSWORD) {
writeAuthSession();
onUnlock();
return;
}
setError('密码不正确,请重试');
setSubmitting(false);
inputRef.current?.focus();
inputRef.current?.select();
}, 120);
};
return (
<div className="opn-gate" data-annotation-id="opn-password-gate">
<div className="opn-gate-card" role="dialog" aria-modal="true" aria-labelledby="opn-gate-title">
<span className="opn-gate-icon" aria-hidden>
<Lock size={22} strokeWidth={1.75} />
</span>
<p className="opn-gate-eyebrow">Restricted Access</p>
<h1 id="opn-gate-title" className="opn-gate-title"></h1>
<p className="opn-gate-lead">使访</p>
<form className="opn-gate-form" onSubmit={handleSubmit}>
<div className="opn-gate-field">
<label className="opn-gate-label" htmlFor="opn-gate-password">
访
</label>
<input
ref={inputRef}
id="opn-gate-password"
className={`opn-gate-input${error ? ' opn-gate-input--error' : ''}`}
type="password"
value={password}
onChange={(event) => {
setPassword(event.target.value);
if (error) setError('');
}}
placeholder="请输入密码"
autoComplete="current-password"
aria-invalid={Boolean(error)}
aria-describedby={error ? 'opn-gate-error' : undefined}
disabled={submitting}
/>
</div>
{error && (
<p id="opn-gate-error" className="opn-gate-error" role="alert">
{error}
</p>
)}
<button type="submit" className="opn-gate-submit" disabled={submitting || !password.trim()}>
{submitting ? '验证中…' : '进入导航'}
</button>
</form>
</div>
</div>
);
}
function NavLinkCard({ function NavLinkCard({
link, link,
meta, meta,
@@ -54,7 +192,9 @@ function NavLinkCard({
}) { }) {
const Icon = iconForLink(link.title); const Icon = iconForLink(link.title);
const record = getPrototypeRegistryRecord(link.prototypeId); const record = getPrototypeRegistryRecord(link.prototypeId);
const metaText = meta ?? linkMeta(link, record); const metaText = meta ?? linkMeta(record);
const version = link.version || record?.version;
return ( return (
<button <button
type="button" type="button"
@@ -64,13 +204,16 @@ function NavLinkCard({
data-annotation-id={`opn-link-${link.prototypeId}-${link.id}`} data-annotation-id={`opn-link-${link.prototypeId}-${link.id}`}
> >
<span className="opn-link-card-icon" aria-hidden> <span className="opn-link-card-icon" aria-hidden>
<Icon size={20} /> <Icon size={18} strokeWidth={1.75} />
</span> </span>
<span className="opn-link-card-body"> <span className="opn-link-card-body">
<span className="opn-link-card-title">{link.title}</span> <span className="opn-link-card-title-row">
<span className="opn-link-card-title">{link.title}</span>
{version && <span className="opn-link-card-version">{version}</span>}
</span>
{metaText && <span className="opn-link-card-meta">{metaText}</span>} {metaText && <span className="opn-link-card-meta">{metaText}</span>}
</span> </span>
<ArrowRight size={18} className="opn-link-card-arrow" aria-hidden /> <ArrowUpRight size={16} className="opn-link-card-arrow" aria-hidden />
</button> </button>
); );
} }
@@ -87,10 +230,10 @@ function SubGroupPanel({
<div className="opn-subgroup" data-annotation-id={`opn-group-${group.id}`}> <div className="opn-subgroup" data-annotation-id={`opn-group-${group.id}`}>
<header className="opn-subgroup-header"> <header className="opn-subgroup-header">
<span className="opn-subgroup-icon" aria-hidden> <span className="opn-subgroup-icon" aria-hidden>
<Icon size={18} /> <Icon size={16} strokeWidth={1.75} />
</span> </span>
<h3>{group.title}</h3> <h3>{group.title}</h3>
<span className="opn-subgroup-count">{group.links.length} </span> <span className="opn-subgroup-count">{group.links.length}</span>
</header> </header>
<div className="opn-link-grid"> <div className="opn-link-grid">
{group.links.map((link) => ( {group.links.map((link) => (
@@ -109,6 +252,9 @@ function SectionBlock({
onSelectLink: (link: NavLinkItem) => void; onSelectLink: (link: NavLinkItem) => void;
}) { }) {
const Icon = section.icon; const Icon = section.icon;
const linkCount = section.links.length
+ section.subGroups.reduce((sum, group) => sum + group.links.length, 0);
return ( return (
<section <section
className="opn-section" className="opn-section"
@@ -118,11 +264,14 @@ function SectionBlock({
<header className="opn-section-header"> <header className="opn-section-header">
<div className="opn-section-heading"> <div className="opn-section-heading">
<span className="opn-section-icon" aria-hidden> <span className="opn-section-icon" aria-hidden>
<Icon size={22} /> <Icon size={20} strokeWidth={1.75} />
</span> </span>
<div> <div>
<h2>{section.title}</h2> <div className="opn-section-title-row">
<p>{section.description}</p> <h2>{section.title}</h2>
<span className="opn-section-badge">{linkCount}</span>
</div>
{section.description && <p>{section.description}</p>}
</div> </div>
</div> </div>
</header> </header>
@@ -146,32 +295,43 @@ function SectionBlock({
); );
} }
function ChangelogEntryRow({ entry, isLatest }: { entry: PrototypeChangelogEntry; isLatest?: boolean }) { function ChangelogEntryRow({
entry,
isLatest,
}: {
entry: PrototypeChangelogEntry;
isLatest?: boolean;
}) {
return ( return (
<li className={`opn-changelog-item${isLatest ? ' opn-changelog-item--latest' : ''}`}> <li className={`opn-changelog-item${isLatest ? ' opn-changelog-item--latest' : ''}`}>
<div className="opn-changelog-item-head"> <div className="opn-changelog-rail" aria-hidden>
<span className="opn-changelog-version">{entry.version}</span> <span className="opn-changelog-dot" />
<time className="opn-changelog-time" dateTime={`${entry.date}T${entry.time}`}> </div>
{entry.date} <div className="opn-changelog-content">
{' '} <div className="opn-changelog-item-head">
{entry.time} <span className="opn-changelog-version">{entry.version}</span>
</time> <time className="opn-changelog-time" dateTime={`${entry.date}T${entry.time}`}>
{entry.date}
{' '}
{entry.time}
</time>
</div>
<p className="opn-changelog-summary">{entry.summary}</p>
{entry.files.length > 0 && (
<details className="opn-changelog-files">
<summary>
<FileCode2 size={14} aria-hidden />
<span className="opn-changelog-files-count">{entry.files.length}</span>
</summary>
<ul>
{entry.files.map((file) => (
<li key={file}>{file}</li>
))}
</ul>
</details>
)}
</div> </div>
<p className="opn-changelog-summary">{entry.summary}</p>
{entry.files.length > 0 && (
<details className="opn-changelog-files">
<summary>
{entry.files.length}
</summary>
<ul>
{entry.files.map((file) => (
<li key={file}>{file}</li>
))}
</ul>
</details>
)}
</li> </li>
); );
} }
@@ -186,6 +346,7 @@ function ChangelogDetailPanel({
const record = getPrototypeRegistryRecord(link.prototypeId); const record = getPrototypeRegistryRecord(link.prototypeId);
const changelog = record?.changelog || []; const changelog = record?.changelog || [];
const Icon = iconForLink(link.title); const Icon = iconForLink(link.title);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
@@ -194,6 +355,7 @@ function ChangelogDetailPanel({
document.addEventListener('keydown', onKeyDown); document.addEventListener('keydown', onKeyDown);
const previousOverflow = document.body.style.overflow; const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
window.requestAnimationFrame(() => panelRef.current?.focus());
return () => { return () => {
document.removeEventListener('keydown', onKeyDown); document.removeEventListener('keydown', onKeyDown);
document.body.style.overflow = previousOverflow; document.body.style.overflow = previousOverflow;
@@ -203,27 +365,29 @@ function ChangelogDetailPanel({
return ( return (
<div className="opn-detail-overlay" role="presentation" onClick={onClose}> <div className="opn-detail-overlay" role="presentation" onClick={onClose}>
<div <div
ref={panelRef}
className="opn-detail-panel" className="opn-detail-panel"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="opn-detail-title" aria-labelledby="opn-detail-title"
tabIndex={-1}
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
data-annotation-id={`opn-detail-${link.prototypeId}`} data-annotation-id={`opn-detail-${link.prototypeId}`}
> >
<header className="opn-detail-header"> <header className="opn-detail-header">
<div className="opn-detail-heading"> <div className="opn-detail-heading">
<span className="opn-detail-icon" aria-hidden> <span className="opn-detail-icon" aria-hidden>
<Icon size={22} /> <Icon size={20} strokeWidth={1.75} />
</span> </span>
<div> <div>
<p className="opn-detail-eyebrow">Prototype Changelog</p>
<h2 id="opn-detail-title">{link.title}</h2> <h2 id="opn-detail-title">{link.title}</h2>
<p className="opn-detail-subtitle"> <p className="opn-detail-subtitle">
{record?.version || link.version || 'v1.0'} <span className="opn-detail-version">{record?.version || link.version || 'v1.0'}</span>
{record?.lastUpdated && ( {record?.lastUpdated && (
<> <>
{' '} <span className="opn-detail-dot" aria-hidden>·</span>
· <Clock3 size={13} aria-hidden />
{' '}
{new Date(record.lastUpdated).toLocaleString('zh-CN', { {new Date(record.lastUpdated).toLocaleString('zh-CN', {
month: 'numeric', month: 'numeric',
day: 'numeric', day: 'numeric',
@@ -235,7 +399,7 @@ function ChangelogDetailPanel({
</p> </p>
</div> </div>
</div> </div>
<div className="opn-detail-actions"> <div className="opn-detail-toolbar">
<a <a
className="opn-detail-open-btn" className="opn-detail-open-btn"
href={link.href} href={link.href}
@@ -243,7 +407,7 @@ function ChangelogDetailPanel({
aria-label={`查看 ${link.title} 原型(在新标签页打开)`} aria-label={`查看 ${link.title} 原型(在新标签页打开)`}
> >
<ExternalLink size={16} aria-hidden /> <ExternalLink size={14} strokeWidth={1.75} aria-hidden />
</a> </a>
<button <button
type="button" type="button"
@@ -251,7 +415,7 @@ function ChangelogDetailPanel({
onClick={onClose} onClick={onClose}
aria-label="关闭变更日志" aria-label="关闭变更日志"
> >
<X size={20} aria-hidden /> <X size={18} aria-hidden />
</button> </button>
</div> </div>
</header> </header>
@@ -259,17 +423,23 @@ function ChangelogDetailPanel({
<div className="opn-detail-body"> <div className="opn-detail-body">
<section className="opn-detail-section" aria-labelledby="opn-changelog-title"> <section className="opn-detail-section" aria-labelledby="opn-changelog-title">
<header className="opn-detail-section-header"> <header className="opn-detail-section-header">
<History size={18} aria-hidden /> <History size={16} aria-hidden />
<h3 id="opn-changelog-title"></h3> <h3 id="opn-changelog-title"></h3>
</header> </header>
{changelog.length > 0 ? ( {changelog.length > 0 ? (
<ol className="opn-changelog-list"> <ol className="opn-changelog-list">
{changelog.map((entry, index) => ( {changelog.map((entry, index) => (
<ChangelogEntryRow key={`${entry.version}-${entry.date}-${entry.time}`} entry={entry} isLatest={index === 0} /> <ChangelogEntryRow
key={`${entry.version}-${entry.date}-${entry.time}`}
entry={entry}
isLatest={index === 0}
/>
))} ))}
</ol> </ol>
) : ( ) : (
<p className="opn-detail-empty"></p> <p className="opn-detail-empty">
</p>
)} )}
</section> </section>
</div> </div>
@@ -289,34 +459,39 @@ function RecentUpdatesPanel({
return ( return (
<section className="opn-updates" data-annotation-id="opn-recent-updates" aria-labelledby="opn-updates-title"> <section className="opn-updates" data-annotation-id="opn-recent-updates" aria-labelledby="opn-updates-title">
<header className="opn-updates-header"> <header className="opn-updates-header">
<h2 id="opn-updates-title"></h2> <div>
<p></p> <p className="opn-updates-eyebrow">Activity</p>
<h2 id="opn-updates-title"></h2>
</div>
<p></p>
</header> </header>
<ol className="opn-updates-list"> <ol className="opn-updates-list">
{updates.map((item) => ( {updates.map((item) => (
<li key={`${item.prototypeId}-${item.version}-${item.date}-${item.time}`} className="opn-updates-item"> <li key={`${item.prototypeId}-${item.version}-${item.date}-${item.time}`} className="opn-updates-item">
<div className="opn-updates-main"> <button
<button type="button"
type="button" className="opn-updates-link"
className="opn-updates-link" onClick={() => onSelectLink({
onClick={() => onSelectLink({ id: item.prototypeId,
id: item.prototypeId, title: item.title,
title: item.title, href: `/prototypes/${item.prototypeId}`,
href: `/prototypes/${item.prototypeId}`, prototypeId: item.prototypeId,
prototypeId: item.prototypeId, version: item.version,
version: item.version, })}
})} >
> <span className="opn-updates-link-main">
{item.title} <span className="opn-updates-link-title">{item.title}</span>
</button> <span className="opn-updates-summary">{item.summary}</span>
<span className="opn-updates-version">{item.version}</span> </span>
</div> <span className="opn-updates-side">
<p className="opn-updates-summary">{item.summary}</p> <span className="opn-updates-version">{item.version}</span>
<p className="opn-updates-meta"> <span className="opn-updates-meta">
{item.date} {item.date}
{' '} {' '}
{item.time} {item.time}
</p> </span>
</span>
</button>
</li> </li>
))} ))}
</ol> </ol>
@@ -325,6 +500,7 @@ function RecentUpdatesPanel({
} }
export default function OneosPrototypeNavApp() { export default function OneosPrototypeNavApp() {
const [isAuthed, setIsAuthed] = useState(readAuthSession);
const sections = useMemo(() => loadNavSections(), []); const sections = useMemo(() => loadNavSections(), []);
const recentUpdates = useMemo(() => loadRecentUpdates(), []); const recentUpdates = useMemo(() => loadRecentUpdates(), []);
const registryUpdatedAt = useMemo(() => getRegistryUpdatedAt(), []); const registryUpdatedAt = useMemo(() => getRegistryUpdatedAt(), []);
@@ -332,6 +508,8 @@ export default function OneosPrototypeNavApp() {
const totalCount = useMemo(() => countNavLinks(sections), [sections]); const totalCount = useMemo(() => countNavLinks(sections), [sections]);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [selectedLink, setSelectedLink] = useState<NavLinkItem | null>(null); const [selectedLink, setSelectedLink] = useState<NavLinkItem | null>(null);
const [activeSectionId, setActiveSectionId] = useState(() => resolveInitialSectionId(loadNavSections()));
const searchRef = useRef<HTMLInputElement>(null);
const handleSelectLink = useCallback((link: NavLinkItem) => { const handleSelectLink = useCallback((link: NavLinkItem) => {
setSelectedLink(link); setSelectedLink(link);
@@ -341,6 +519,37 @@ export default function OneosPrototypeNavApp() {
setSelectedLink(null); setSelectedLink(null);
}, []); }, []);
const handleSelectSection = useCallback((sectionId: string) => {
setActiveSectionId(sectionId);
window.history.replaceState(null, '', `#opn-section-${sectionId}`);
document.getElementById('opn-main')?.scrollTo({ top: 0 });
}, []);
useEffect(() => {
const onHashChange = () => {
const fromHash = sectionIdFromHash();
if (fromHash && sections.some((section) => section.id === fromHash)) {
setActiveSectionId(fromHash);
}
};
window.addEventListener('hashchange', onHashChange);
return () => window.removeEventListener('hashchange', onHashChange);
}, [sections]);
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
const tag = (event.target as HTMLElement | null)?.tagName;
const isTyping = tag === 'INPUT' || tag === 'TEXTAREA' || (event.target as HTMLElement)?.isContentEditable;
if (isTyping || selectedLink) return;
if (event.key === '/') {
event.preventDefault();
searchRef.current?.focus();
}
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [selectedLink]);
const normalizedQuery = query.trim().toLowerCase(); const normalizedQuery = query.trim().toLowerCase();
const filteredLinks = useMemo(() => { const filteredLinks = useMemo(() => {
if (!normalizedQuery) return []; if (!normalizedQuery) return [];
@@ -353,53 +562,57 @@ export default function OneosPrototypeNavApp() {
}, [allLinks, normalizedQuery]); }, [allLinks, normalizedQuery]);
const visibleSections = useMemo(() => { const visibleSections = useMemo(() => {
if (!normalizedQuery) return sections; if (normalizedQuery) {
const matchedIds = new Set(filteredLinks.map((link) => link.prototypeId)); const matchedIds = new Set(filteredLinks.map((link) => link.prototypeId));
return sections return sections
.map((section) => { .map((section) => {
const links = section.links.filter((link) => matchedIds.has(link.prototypeId)); const links = section.links.filter((link) => matchedIds.has(link.prototypeId));
const subGroups = section.subGroups const subGroups = section.subGroups
.map((group) => ({ .map((group) => ({
...group, ...group,
links: group.links.filter((link) => matchedIds.has(link.prototypeId)), links: group.links.filter((link) => matchedIds.has(link.prototypeId)),
})) }))
.filter((group) => group.links.length > 0); .filter((group) => group.links.length > 0);
if (!links.length && !subGroups.length) return null; if (!links.length && !subGroups.length) return null;
return { ...section, links, subGroups }; return { ...section, links, subGroups };
}) })
.filter((section): section is NavSection => Boolean(section)); .filter((section): section is NavSection => Boolean(section));
}, [sections, normalizedQuery, filteredLinks]); }
const activeSection = sections.find((section) => section.id === activeSectionId) || sections[0];
return activeSection ? [activeSection] : [];
}, [sections, normalizedQuery, filteredLinks, activeSectionId]);
if (!isAuthed) {
return (
<div className="opn-page opn-page--gate" data-annotation-id="opn-page">
<PasswordGate onUnlock={() => setIsAuthed(true)} />
</div>
);
}
return ( return (
<div className="opn-page" data-annotation-id="opn-page"> <div className="opn-page" data-annotation-id="opn-page">
<a className="opn-skip" href="#opn-main"></a> <a className="opn-skip" href="#opn-main"></a>
<header className="opn-hero" data-annotation-id="opn-hero"> <header className="opn-topbar" data-annotation-id="opn-topbar">
<div className="opn-hero-inner"> <div className="opn-topbar-inner">
<p className="opn-eyebrow"> <div className="opn-brand">
<Compass size={16} aria-hidden /> <span className="opn-brand-mark" aria-hidden>
ONE-OS · <Zap size={14} strokeWidth={2.25} />
</p> </span>
<h1></h1> <span className="opn-brand-text">ONE-OS · Prototype Hub</span>
<p className="opn-lead"> </div>
<div className="opn-topbar-stats">
</p> <span className="opn-topbar-stat">
<LayoutGrid size={14} aria-hidden />
<div className="opn-hero-meta">
<span className="opn-stat">
<Layers size={16} aria-hidden />
{totalCount} {totalCount}
{' '} {' '}
</span>
<span className="opn-stat">
{sections.length}
{' '}
</span> </span>
{registryUpdatedAt && ( {registryUpdatedAt && (
<span className="opn-stat opn-stat--muted"> <span className="opn-topbar-stat opn-topbar-stat--muted">
{' '} {' '}
{new Date(registryUpdatedAt).toLocaleString('zh-CN', { {new Date(registryUpdatedAt).toLocaleString('zh-CN', {
month: 'numeric', month: 'numeric',
@@ -410,16 +623,27 @@ export default function OneosPrototypeNavApp() {
</span> </span>
)} )}
</div> </div>
</div>
</header>
<section className="opn-hero" data-annotation-id="opn-hero">
<div className="opn-hero-inner">
<p className="opn-eyebrow">Prototype Directory</p>
<h1>
<span className="opn-hero-accent">OneOS</span>
</h1>
<label className="opn-search" data-annotation-id="opn-search"> <label className="opn-search" data-annotation-id="opn-search">
<Search size={18} aria-hidden /> <Search size={17} aria-hidden />
<input <input
ref={searchRef}
type="search" type="search"
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
placeholder="搜索菜单或原型名称…" placeholder="搜索菜单或原型名称…"
aria-label="搜索原型" aria-label="搜索原型"
/> />
{!query && <kbd className="opn-search-kbd" aria-hidden>/</kbd>}
{query && ( {query && (
<button <button
type="button" type="button"
@@ -427,29 +651,32 @@ export default function OneosPrototypeNavApp() {
onClick={() => setQuery('')} onClick={() => setQuery('')}
aria-label="清除搜索" aria-label="清除搜索"
> >
<X size={16} aria-hidden /> <X size={15} aria-hidden />
</button> </button>
)} )}
</label> </label>
</div> </div>
</header> </section>
<div className="opn-layout"> <div className={`opn-layout${normalizedQuery ? ' opn-layout--search' : ''}`}>
{!normalizedQuery && ( {!normalizedQuery && (
<aside className="opn-sidebar" aria-label="分组目录"> <aside className="opn-sidebar" aria-label="分组目录">
<p className="opn-sidebar-title"></p> <p className="opn-sidebar-title">Directory</p>
<nav className="opn-sidebar-nav"> <nav className="opn-sidebar-nav">
{sections.map((section) => { {sections.map((section) => {
const Icon = section.icon; const Icon = section.icon;
const isActive = activeSectionId === section.id;
return ( return (
<a <button
key={section.id} key={section.id}
className="opn-sidebar-link" type="button"
href={`#opn-section-${section.id}`} className={`opn-sidebar-link${isActive ? ' opn-sidebar-link--active' : ''}`}
onClick={() => handleSelectSection(section.id)}
aria-current={isActive ? 'page' : undefined}
> >
<Icon size={16} aria-hidden /> <Icon size={15} strokeWidth={1.75} aria-hidden />
{section.title} <span>{sidebarSectionLabel(section)}</span>
</a> </button>
); );
})} })}
</nav> </nav>
@@ -461,7 +688,7 @@ export default function OneosPrototypeNavApp() {
<p className="opn-search-result" role="status"> <p className="opn-search-result" role="status">
{' '} {' '}
{filteredLinks.length} <strong>{filteredLinks.length}</strong>
{' '} {' '}
{query.trim()} {query.trim()}
@@ -478,7 +705,11 @@ export default function OneosPrototypeNavApp() {
</div> </div>
) : ( ) : (
visibleSections.map((section) => ( visibleSections.map((section) => (
<SectionBlock key={section.id} section={section} onSelectLink={handleSelectLink} /> <SectionBlock
key={section.id}
section={section}
onSelectLink={handleSelectLink}
/>
)) ))
)} )}

View File

@@ -95,7 +95,7 @@ const GROUP_ICONS: Record<string, LucideIcon> = {
const SECTION_META: Record<string, { icon: LucideIcon; description: string }> = { const SECTION_META: Record<string, { icon: LucideIcon; description: string }> = {
OneOS: { OneOS: {
icon: Layers, icon: Layers,
description: '按业务菜单分组,进入各功能模块的可交互原型。', description: '',
}, },
: { : {
icon: Smartphone, icon: Smartphone,

View File

@@ -1,6 +1,6 @@
{ {
"version": 1, "version": 1,
"updatedAt": "2026-07-09T09:39:48.227Z", "updatedAt": "2026-07-09T10:33:56.938Z",
"prototypes": { "prototypes": {
"oneos-web-workbench": { "oneos-web-workbench": {
"title": "工作台", "title": "工作台",
@@ -975,10 +975,10 @@
}, },
"vehicle-h2-fee-ledger": { "vehicle-h2-fee-ledger": {
"title": "车辆氢费明细", "title": "车辆氢费明细",
"version": "v1.4", "version": "v1.5",
"revision": 4, "revision": 5,
"lastUpdated": "2026-07-09T09:39:46.953Z", "lastUpdated": "2026-07-09T10:01:49.887Z",
"contentHash": "1515eef3f81e6a77708c09e77ee238e52fd5f0a33fd2c8301050cafe089d4881", "contentHash": "ffe27c79a960ac86f3ecfe0ebe9d976c0a51a30c51fe2773439297e02b86dee3",
"trackedFiles": [ "trackedFiles": [
".spec/PRD.md", ".spec/PRD.md",
".spec/prototype-comments.json", ".spec/prototype-comments.json",
@@ -987,6 +987,19 @@
"index.tsx" "index.tsx"
], ],
"changelog": [ "changelog": [
{
"version": "v1.5",
"date": "2026-07-09",
"time": "18:01",
"summary": "更新需求说明与标注",
"files": [
".spec/PRD.md",
".spec/prototype-comments.json",
"globals.ts",
"H2LedgerPage.jsx",
"index.tsx"
]
},
{ {
"version": "v1.4", "version": "v1.4",
"date": "2026-07-09", "date": "2026-07-09",
@@ -1221,9 +1234,80 @@
"vrsV2Data.js" "vrsV2Data.js"
], ],
"changelog": [] "changelog": []
},
"oneos-prototype-nav": {
"title": "原型导航",
"version": "v1.1",
"revision": 1,
"lastUpdated": "2026-07-09T10:33:56.745Z",
"contentHash": "564fe84bcf20fddeb53e953a70dc3eb8fe07806d767fecb8c1597c6d1ebd4b21",
"trackedFiles": [
".spec/prototype-comments.json",
"index.tsx",
"nav-data.ts",
"style.css",
"xll-nav-menu.json",
"xll-nav-source.json"
],
"changelog": [
{
"version": "v1.1",
"date": "2026-07-09",
"time": "18:31",
"summary": "VoltAgent 视觉改版、密码门禁与侧栏按业务线切换、批注体验优化",
"files": [
"index.tsx",
"style.css",
"nav-data.ts",
".spec/prototype-comments.json"
]
},
{
"version": "v1.0",
"date": "2026-07-09",
"time": "17:52",
"summary": "原型导航与版本追踪上线",
"files": [
"index.tsx",
"style.css",
"nav-data.ts",
"nav-menu.json",
"prototype-registry.json"
]
}
]
} }
}, },
"recentUpdates": [ "recentUpdates": [
{
"prototypeId": "oneos-prototype-nav",
"title": "原型导航",
"version": "v1.1",
"date": "2026-07-09",
"time": "18:31",
"summary": "VoltAgent 视觉改版、密码门禁与侧栏按业务线切换、批注体验优化",
"files": [
"index.tsx",
"style.css",
"nav-data.ts",
".spec/prototype-comments.json"
]
},
{
"prototypeId": "vehicle-h2-fee-ledger",
"title": "车辆氢费明细",
"version": "v1.5",
"date": "2026-07-09",
"time": "18:01",
"summary": "更新需求说明与标注",
"files": [
".spec/PRD.md",
".spec/prototype-comments.json",
"globals.ts",
"H2LedgerPage.jsx",
"index.tsx"
]
},
{ {
"prototypeId": "payment-records", "prototypeId": "payment-records",
"title": "收款记录", "title": "收款记录",
@@ -1988,50 +2072,6 @@
"components/RiskTagDrawer.tsx", "components/RiskTagDrawer.tsx",
"components/RiskTagForm.tsx" "components/RiskTagForm.tsx"
] ]
},
{
"prototypeId": "vehicle-management",
"title": "车辆资产",
"version": "v1.3",
"date": "2026-07-09",
"time": "17:39",
"summary": "更新需求说明与标注",
"files": [
".spec/prototype-comments.json",
".spec/prototype-review.md",
".spec/requirements-prd-detail.md",
".spec/requirements-prd-list.md",
".spec/ui-review.md",
"annotation-source.json",
"components/DateRangeCalendarPanel.tsx",
"components/DateRangeFilterField.tsx",
"components/DetailAccidentRecordsTab.tsx",
"components/DetailAnnualReviewRecordsTab.tsx",
"components/DetailBasicInfoTab.tsx",
"components/DetailExpireField.tsx"
]
},
{
"prototypeId": "vehicle-management",
"title": "车辆资产",
"version": "v1.2",
"date": "2026-07-09",
"time": "17:38",
"summary": "更新需求说明与标注",
"files": [
".spec/prototype-comments.json",
".spec/prototype-review.md",
".spec/requirements-prd-detail.md",
".spec/requirements-prd-list.md",
".spec/ui-review.md",
"annotation-source.json",
"components/DateRangeCalendarPanel.tsx",
"components/DateRangeFilterField.tsx",
"components/DetailAccidentRecordsTab.tsx",
"components/DetailAnnualReviewRecordsTab.tsx",
"components/DetailBasicInfoTab.tsx",
"components/DetailExpireField.tsx"
]
} }
] ]
} }

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
"version": 2, "version": 2,
"prototypeName": "vehicle-h2-fee-ledger", "prototypeName": "vehicle-h2-fee-ledger",
"pageId": "list", "pageId": "list",
"updatedAt": 1783589987168, "updatedAt": 1783588824941,
"nodes": [ "nodes": [
{ {
"id": "vh2-header", "id": "vh2-header",
@@ -300,13 +300,6 @@
"title": "ONE-OS 原型导航", "title": "ONE-OS 原型导航",
"defaultExpanded": true, "defaultExpanded": true,
"children": [ "children": [
{
"type": "link",
"id": "nav-link-oneos-prototype-nav",
"title": "原型导航",
"href": "/prototypes/oneos-prototype-nav",
"target": "self"
},
{ {
"type": "folder", "type": "folder",
"id": "nav-folder-1782874576229-r8atbu", "id": "nav-folder-1782874576229-r8atbu",
@@ -321,6 +314,112 @@
"target": "self" "target": "self"
} }
] ]
},
{
"type": "link",
"id": "nav-link-lease-business-line-overview",
"title": "业务条线说明",
"href": "/prototypes/lease-business-line-overview",
"target": "self"
},
{
"type": "folder",
"id": "nav-folder-prototypes-oneos-web",
"title": "ONE-OS Web 端",
"defaultExpanded": true,
"children": [
{
"type": "link",
"id": "nav-link-oneos-web-login",
"title": "登录",
"href": "/prototypes/oneos-web-login",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-vehicle-asset",
"title": "车辆管理",
"href": "/prototypes/oneos-web-vehicle-asset",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-contract-template",
"title": "合同模板管理",
"href": "/prototypes/oneos-web-contract-template",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-help-center",
"title": "帮助中心",
"href": "/prototypes/oneos-web-help-center",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-finance",
"title": "财务管理",
"href": "/prototypes/oneos-web-finance",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-procurement",
"title": "采购管理",
"href": "/prototypes/oneos-web-procurement",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-lease-contract",
"title": "车辆租赁合同",
"href": "/prototypes/oneos-web-lease-contract",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-h2-station",
"title": "加氢站管理",
"href": "/prototypes/oneos-web-h2-station",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-data-analysis",
"title": "数据分析",
"href": "/prototypes/oneos-web-data-analysis",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-ledger-data",
"title": "台账数据",
"href": "/prototypes/oneos-web-ledger-data",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-business",
"title": "业务管理",
"href": "/prototypes/oneos-web-business",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-ops",
"title": "运维管理",
"href": "/prototypes/oneos-web-ops",
"target": "self"
},
{
"type": "link",
"id": "nav-link-oneos-web-requirements",
"title": "需求说明",
"href": "/prototypes/oneos-web-requirements",
"target": "self"
}
]
} }
] ]
}, },