迭代 ONE-OS 原型导航:VoltAgent 视觉改版、密码门禁与侧栏按业务线切换;车辆氢费明细标注目录同步至 v1.5。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "oneos-prototype-nav",
|
||||
"pageId": "nav",
|
||||
"updatedAt": 1783589987167,
|
||||
"updatedAt": 1783600000000,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "opn-hero",
|
||||
@@ -51,37 +51,6 @@
|
||||
},
|
||||
"markdownMap": {},
|
||||
"directory": {
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"nodes": []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,15 +3,24 @@
|
||||
* ONE-OS 原型目录导航:按菜单分组快速进入各功能原型
|
||||
*/
|
||||
import './style.css';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
ArrowRight,
|
||||
Compass,
|
||||
ArrowUpRight,
|
||||
Clock3,
|
||||
ExternalLink,
|
||||
FileCode2,
|
||||
History,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
Lock,
|
||||
Search,
|
||||
X,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
countNavLinks,
|
||||
@@ -29,13 +38,49 @@ import {
|
||||
type RecentUpdateEntry,
|
||||
} from './nav-data';
|
||||
|
||||
function linkMeta(link: NavLinkItem, record: PrototypeRegistryRecord | null): string | undefined {
|
||||
const parts: string[] = [];
|
||||
if (link.version || record?.version) parts.push(link.version || record?.version || '');
|
||||
if (link.lastUpdatedLabel) parts.push(`${link.lastUpdatedLabel}更新`);
|
||||
function formatRecordUpdatedAt(record: PrototypeRegistryRecord | null): string | undefined {
|
||||
if (record?.lastUpdated) {
|
||||
const date = new Date(record.lastUpdated);
|
||||
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];
|
||||
if (latest?.summary && parts.length < 2) parts.push(latest.summary);
|
||||
return parts.filter(Boolean).join(' · ') || undefined;
|
||||
if (latest) {
|
||||
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 = {
|
||||
@@ -43,6 +88,99 @@ const NEW_TAB_LINK_PROPS = {
|
||||
rel: 'noopener noreferrer',
|
||||
} 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({
|
||||
link,
|
||||
meta,
|
||||
@@ -54,7 +192,9 @@ function NavLinkCard({
|
||||
}) {
|
||||
const Icon = iconForLink(link.title);
|
||||
const record = getPrototypeRegistryRecord(link.prototypeId);
|
||||
const metaText = meta ?? linkMeta(link, record);
|
||||
const metaText = meta ?? linkMeta(record);
|
||||
const version = link.version || record?.version;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -64,13 +204,16 @@ function NavLinkCard({
|
||||
data-annotation-id={`opn-link-${link.prototypeId}-${link.id}`}
|
||||
>
|
||||
<span className="opn-link-card-icon" aria-hidden>
|
||||
<Icon size={20} />
|
||||
<Icon size={18} strokeWidth={1.75} />
|
||||
</span>
|
||||
<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>}
|
||||
</span>
|
||||
<ArrowRight size={18} className="opn-link-card-arrow" aria-hidden />
|
||||
<ArrowUpRight size={16} className="opn-link-card-arrow" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -87,10 +230,10 @@ function SubGroupPanel({
|
||||
<div className="opn-subgroup" data-annotation-id={`opn-group-${group.id}`}>
|
||||
<header className="opn-subgroup-header">
|
||||
<span className="opn-subgroup-icon" aria-hidden>
|
||||
<Icon size={18} />
|
||||
<Icon size={16} strokeWidth={1.75} />
|
||||
</span>
|
||||
<h3>{group.title}</h3>
|
||||
<span className="opn-subgroup-count">{group.links.length} 个原型</span>
|
||||
<span className="opn-subgroup-count">共{group.links.length}个原型</span>
|
||||
</header>
|
||||
<div className="opn-link-grid">
|
||||
{group.links.map((link) => (
|
||||
@@ -109,6 +252,9 @@ function SectionBlock({
|
||||
onSelectLink: (link: NavLinkItem) => void;
|
||||
}) {
|
||||
const Icon = section.icon;
|
||||
const linkCount = section.links.length
|
||||
+ section.subGroups.reduce((sum, group) => sum + group.links.length, 0);
|
||||
|
||||
return (
|
||||
<section
|
||||
className="opn-section"
|
||||
@@ -118,11 +264,14 @@ function SectionBlock({
|
||||
<header className="opn-section-header">
|
||||
<div className="opn-section-heading">
|
||||
<span className="opn-section-icon" aria-hidden>
|
||||
<Icon size={22} />
|
||||
<Icon size={20} strokeWidth={1.75} />
|
||||
</span>
|
||||
<div>
|
||||
<h2>{section.title}</h2>
|
||||
<p>{section.description}</p>
|
||||
<div className="opn-section-title-row">
|
||||
<h2>{section.title}</h2>
|
||||
<span className="opn-section-badge">{linkCount}个原型页</span>
|
||||
</div>
|
||||
{section.description && <p>{section.description}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -146,32 +295,43 @@ function SectionBlock({
|
||||
);
|
||||
}
|
||||
|
||||
function ChangelogEntryRow({ entry, isLatest }: { entry: PrototypeChangelogEntry; isLatest?: boolean }) {
|
||||
function ChangelogEntryRow({
|
||||
entry,
|
||||
isLatest,
|
||||
}: {
|
||||
entry: PrototypeChangelogEntry;
|
||||
isLatest?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<li className={`opn-changelog-item${isLatest ? ' opn-changelog-item--latest' : ''}`}>
|
||||
<div className="opn-changelog-item-head">
|
||||
<span className="opn-changelog-version">{entry.version}</span>
|
||||
<time className="opn-changelog-time" dateTime={`${entry.date}T${entry.time}`}>
|
||||
{entry.date}
|
||||
{' '}
|
||||
{entry.time}
|
||||
</time>
|
||||
<div className="opn-changelog-rail" aria-hidden>
|
||||
<span className="opn-changelog-dot" />
|
||||
</div>
|
||||
<div className="opn-changelog-content">
|
||||
<div className="opn-changelog-item-head">
|
||||
<span className="opn-changelog-version">{entry.version}</span>
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -186,6 +346,7 @@ function ChangelogDetailPanel({
|
||||
const record = getPrototypeRegistryRecord(link.prototypeId);
|
||||
const changelog = record?.changelog || [];
|
||||
const Icon = iconForLink(link.title);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
@@ -194,6 +355,7 @@ function ChangelogDetailPanel({
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.requestAnimationFrame(() => panelRef.current?.focus());
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
@@ -203,27 +365,29 @@ function ChangelogDetailPanel({
|
||||
return (
|
||||
<div className="opn-detail-overlay" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="opn-detail-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="opn-detail-title"
|
||||
tabIndex={-1}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
data-annotation-id={`opn-detail-${link.prototypeId}`}
|
||||
>
|
||||
<header className="opn-detail-header">
|
||||
<div className="opn-detail-heading">
|
||||
<span className="opn-detail-icon" aria-hidden>
|
||||
<Icon size={22} />
|
||||
<Icon size={20} strokeWidth={1.75} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="opn-detail-eyebrow">Prototype Changelog</p>
|
||||
<h2 id="opn-detail-title">{link.title}</h2>
|
||||
<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 && (
|
||||
<>
|
||||
{' '}
|
||||
· 最近更新
|
||||
{' '}
|
||||
<span className="opn-detail-dot" aria-hidden>·</span>
|
||||
<Clock3 size={13} aria-hidden />
|
||||
{new Date(record.lastUpdated).toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
@@ -235,7 +399,7 @@ function ChangelogDetailPanel({
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="opn-detail-actions">
|
||||
<div className="opn-detail-toolbar">
|
||||
<a
|
||||
className="opn-detail-open-btn"
|
||||
href={link.href}
|
||||
@@ -243,7 +407,7 @@ function ChangelogDetailPanel({
|
||||
aria-label={`查看 ${link.title} 原型(在新标签页打开)`}
|
||||
>
|
||||
查看原型
|
||||
<ExternalLink size={16} aria-hidden />
|
||||
<ExternalLink size={14} strokeWidth={1.75} aria-hidden />
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
@@ -251,7 +415,7 @@ function ChangelogDetailPanel({
|
||||
onClick={onClose}
|
||||
aria-label="关闭变更日志"
|
||||
>
|
||||
<X size={20} aria-hidden />
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -259,17 +423,23 @@ function ChangelogDetailPanel({
|
||||
<div className="opn-detail-body">
|
||||
<section className="opn-detail-section" aria-labelledby="opn-changelog-title">
|
||||
<header className="opn-detail-section-header">
|
||||
<History size={18} aria-hidden />
|
||||
<History size={16} aria-hidden />
|
||||
<h3 id="opn-changelog-title">变更日志与历史记录</h3>
|
||||
</header>
|
||||
{changelog.length > 0 ? (
|
||||
<ol className="opn-changelog-list">
|
||||
{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>
|
||||
) : (
|
||||
<p className="opn-detail-empty">暂无自动记录的变更日志。原型更新并保存后,将在此展示版本说明与历史记录。</p>
|
||||
<p className="opn-detail-empty">
|
||||
暂无自动记录的变更日志。原型更新并保存后,将在此展示版本说明与历史记录。
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
@@ -289,34 +459,39 @@ function RecentUpdatesPanel({
|
||||
return (
|
||||
<section className="opn-updates" data-annotation-id="opn-recent-updates" aria-labelledby="opn-updates-title">
|
||||
<header className="opn-updates-header">
|
||||
<h2 id="opn-updates-title">最近更新</h2>
|
||||
<p>点击条目查看变更说明与历史记录;右上角可在新标签页打开原型</p>
|
||||
<div>
|
||||
<p className="opn-updates-eyebrow">Activity</p>
|
||||
<h2 id="opn-updates-title">最近更新</h2>
|
||||
</div>
|
||||
<p>点击条目查看完整变更说明;右上角可在新标签页打开原型</p>
|
||||
</header>
|
||||
<ol className="opn-updates-list">
|
||||
{updates.map((item) => (
|
||||
<li key={`${item.prototypeId}-${item.version}-${item.date}-${item.time}`} className="opn-updates-item">
|
||||
<div className="opn-updates-main">
|
||||
<button
|
||||
type="button"
|
||||
className="opn-updates-link"
|
||||
onClick={() => onSelectLink({
|
||||
id: item.prototypeId,
|
||||
title: item.title,
|
||||
href: `/prototypes/${item.prototypeId}`,
|
||||
prototypeId: item.prototypeId,
|
||||
version: item.version,
|
||||
})}
|
||||
>
|
||||
{item.title}
|
||||
</button>
|
||||
<span className="opn-updates-version">{item.version}</span>
|
||||
</div>
|
||||
<p className="opn-updates-summary">{item.summary}</p>
|
||||
<p className="opn-updates-meta">
|
||||
{item.date}
|
||||
{' '}
|
||||
{item.time}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="opn-updates-link"
|
||||
onClick={() => onSelectLink({
|
||||
id: item.prototypeId,
|
||||
title: item.title,
|
||||
href: `/prototypes/${item.prototypeId}`,
|
||||
prototypeId: item.prototypeId,
|
||||
version: item.version,
|
||||
})}
|
||||
>
|
||||
<span className="opn-updates-link-main">
|
||||
<span className="opn-updates-link-title">{item.title}</span>
|
||||
<span className="opn-updates-summary">{item.summary}</span>
|
||||
</span>
|
||||
<span className="opn-updates-side">
|
||||
<span className="opn-updates-version">{item.version}</span>
|
||||
<span className="opn-updates-meta">
|
||||
{item.date}
|
||||
{' '}
|
||||
{item.time}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
@@ -325,6 +500,7 @@ function RecentUpdatesPanel({
|
||||
}
|
||||
|
||||
export default function OneosPrototypeNavApp() {
|
||||
const [isAuthed, setIsAuthed] = useState(readAuthSession);
|
||||
const sections = useMemo(() => loadNavSections(), []);
|
||||
const recentUpdates = useMemo(() => loadRecentUpdates(), []);
|
||||
const registryUpdatedAt = useMemo(() => getRegistryUpdatedAt(), []);
|
||||
@@ -332,6 +508,8 @@ export default function OneosPrototypeNavApp() {
|
||||
const totalCount = useMemo(() => countNavLinks(sections), [sections]);
|
||||
const [query, setQuery] = useState('');
|
||||
const [selectedLink, setSelectedLink] = useState<NavLinkItem | null>(null);
|
||||
const [activeSectionId, setActiveSectionId] = useState(() => resolveInitialSectionId(loadNavSections()));
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleSelectLink = useCallback((link: NavLinkItem) => {
|
||||
setSelectedLink(link);
|
||||
@@ -341,6 +519,37 @@ export default function OneosPrototypeNavApp() {
|
||||
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 filteredLinks = useMemo(() => {
|
||||
if (!normalizedQuery) return [];
|
||||
@@ -353,53 +562,57 @@ export default function OneosPrototypeNavApp() {
|
||||
}, [allLinks, normalizedQuery]);
|
||||
|
||||
const visibleSections = useMemo(() => {
|
||||
if (!normalizedQuery) return sections;
|
||||
const matchedIds = new Set(filteredLinks.map((link) => link.prototypeId));
|
||||
return sections
|
||||
.map((section) => {
|
||||
const links = section.links.filter((link) => matchedIds.has(link.prototypeId));
|
||||
const subGroups = section.subGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
links: group.links.filter((link) => matchedIds.has(link.prototypeId)),
|
||||
}))
|
||||
.filter((group) => group.links.length > 0);
|
||||
if (!links.length && !subGroups.length) return null;
|
||||
return { ...section, links, subGroups };
|
||||
})
|
||||
.filter((section): section is NavSection => Boolean(section));
|
||||
}, [sections, normalizedQuery, filteredLinks]);
|
||||
if (normalizedQuery) {
|
||||
const matchedIds = new Set(filteredLinks.map((link) => link.prototypeId));
|
||||
return sections
|
||||
.map((section) => {
|
||||
const links = section.links.filter((link) => matchedIds.has(link.prototypeId));
|
||||
const subGroups = section.subGroups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
links: group.links.filter((link) => matchedIds.has(link.prototypeId)),
|
||||
}))
|
||||
.filter((group) => group.links.length > 0);
|
||||
if (!links.length && !subGroups.length) return null;
|
||||
return { ...section, links, subGroups };
|
||||
})
|
||||
.filter((section): section is NavSection => Boolean(section));
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="opn-page" data-annotation-id="opn-page">
|
||||
<a className="opn-skip" href="#opn-main">跳到导航内容</a>
|
||||
|
||||
<header className="opn-hero" data-annotation-id="opn-hero">
|
||||
<div className="opn-hero-inner">
|
||||
<p className="opn-eyebrow">
|
||||
<Compass size={16} aria-hidden />
|
||||
ONE-OS · 原型中心
|
||||
</p>
|
||||
<h1>原型导航</h1>
|
||||
<p className="opn-lead">
|
||||
按系统菜单分组浏览全部可交互原型;点击卡片先查看变更说明,再决定是否打开原型页面。
|
||||
</p>
|
||||
|
||||
<div className="opn-hero-meta">
|
||||
<span className="opn-stat">
|
||||
<Layers size={16} aria-hidden />
|
||||
<header className="opn-topbar" data-annotation-id="opn-topbar">
|
||||
<div className="opn-topbar-inner">
|
||||
<div className="opn-brand">
|
||||
<span className="opn-brand-mark" aria-hidden>
|
||||
<Zap size={14} strokeWidth={2.25} />
|
||||
</span>
|
||||
<span className="opn-brand-text">ONE-OS · Prototype Hub</span>
|
||||
</div>
|
||||
<div className="opn-topbar-stats">
|
||||
<span className="opn-topbar-stat">
|
||||
<LayoutGrid size={14} aria-hidden />
|
||||
{totalCount}
|
||||
{' '}
|
||||
个原型入口
|
||||
</span>
|
||||
<span className="opn-stat">
|
||||
{sections.length}
|
||||
{' '}
|
||||
个一级分组
|
||||
原型
|
||||
</span>
|
||||
{registryUpdatedAt && (
|
||||
<span className="opn-stat opn-stat--muted">
|
||||
注册表
|
||||
<span className="opn-topbar-stat opn-topbar-stat--muted">
|
||||
同步
|
||||
{' '}
|
||||
{new Date(registryUpdatedAt).toLocaleString('zh-CN', {
|
||||
month: 'numeric',
|
||||
@@ -410,16 +623,27 @@ export default function OneosPrototypeNavApp() {
|
||||
</span>
|
||||
)}
|
||||
</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">
|
||||
<Search size={18} aria-hidden />
|
||||
<Search size={17} aria-hidden />
|
||||
<input
|
||||
ref={searchRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="搜索菜单或原型名称…"
|
||||
aria-label="搜索原型"
|
||||
/>
|
||||
{!query && <kbd className="opn-search-kbd" aria-hidden>/</kbd>}
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -427,29 +651,32 @@ export default function OneosPrototypeNavApp() {
|
||||
onClick={() => setQuery('')}
|
||||
aria-label="清除搜索"
|
||||
>
|
||||
<X size={16} aria-hidden />
|
||||
<X size={15} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
</header>
|
||||
</section>
|
||||
|
||||
<div className="opn-layout">
|
||||
<div className={`opn-layout${normalizedQuery ? ' opn-layout--search' : ''}`}>
|
||||
{!normalizedQuery && (
|
||||
<aside className="opn-sidebar" aria-label="分组目录">
|
||||
<p className="opn-sidebar-title">快速跳转</p>
|
||||
<p className="opn-sidebar-title">Directory</p>
|
||||
<nav className="opn-sidebar-nav">
|
||||
{sections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
const isActive = activeSectionId === section.id;
|
||||
return (
|
||||
<a
|
||||
<button
|
||||
key={section.id}
|
||||
className="opn-sidebar-link"
|
||||
href={`#opn-section-${section.id}`}
|
||||
type="button"
|
||||
className={`opn-sidebar-link${isActive ? ' opn-sidebar-link--active' : ''}`}
|
||||
onClick={() => handleSelectSection(section.id)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
<Icon size={16} aria-hidden />
|
||||
{section.title}
|
||||
</a>
|
||||
<Icon size={15} strokeWidth={1.75} aria-hidden />
|
||||
<span>{sidebarSectionLabel(section)}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
@@ -461,7 +688,7 @@ export default function OneosPrototypeNavApp() {
|
||||
<p className="opn-search-result" role="status">
|
||||
找到
|
||||
{' '}
|
||||
{filteredLinks.length}
|
||||
<strong>{filteredLinks.length}</strong>
|
||||
{' '}
|
||||
个匹配「
|
||||
{query.trim()}
|
||||
@@ -478,7 +705,11 @@ export default function OneosPrototypeNavApp() {
|
||||
</div>
|
||||
) : (
|
||||
visibleSections.map((section) => (
|
||||
<SectionBlock key={section.id} section={section} onSelectLink={handleSelectLink} />
|
||||
<SectionBlock
|
||||
key={section.id}
|
||||
section={section}
|
||||
onSelectLink={handleSelectLink}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ const GROUP_ICONS: Record<string, LucideIcon> = {
|
||||
const SECTION_META: Record<string, { icon: LucideIcon; description: string }> = {
|
||||
OneOS: {
|
||||
icon: Layers,
|
||||
description: '按业务菜单分组,进入各功能模块的可交互原型。',
|
||||
description: '',
|
||||
},
|
||||
小羚羚: {
|
||||
icon: Smartphone,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"updatedAt": "2026-07-09T09:39:48.227Z",
|
||||
"updatedAt": "2026-07-09T10:33:56.938Z",
|
||||
"prototypes": {
|
||||
"oneos-web-workbench": {
|
||||
"title": "工作台",
|
||||
@@ -975,10 +975,10 @@
|
||||
},
|
||||
"vehicle-h2-fee-ledger": {
|
||||
"title": "车辆氢费明细",
|
||||
"version": "v1.4",
|
||||
"revision": 4,
|
||||
"lastUpdated": "2026-07-09T09:39:46.953Z",
|
||||
"contentHash": "1515eef3f81e6a77708c09e77ee238e52fd5f0a33fd2c8301050cafe089d4881",
|
||||
"version": "v1.5",
|
||||
"revision": 5,
|
||||
"lastUpdated": "2026-07-09T10:01:49.887Z",
|
||||
"contentHash": "ffe27c79a960ac86f3ecfe0ebe9d976c0a51a30c51fe2773439297e02b86dee3",
|
||||
"trackedFiles": [
|
||||
".spec/PRD.md",
|
||||
".spec/prototype-comments.json",
|
||||
@@ -987,6 +987,19 @@
|
||||
"index.tsx"
|
||||
],
|
||||
"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",
|
||||
"date": "2026-07-09",
|
||||
@@ -1221,9 +1234,80 @@
|
||||
"vrsV2Data.js"
|
||||
],
|
||||
"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": [
|
||||
{
|
||||
"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",
|
||||
"title": "收款记录",
|
||||
@@ -1988,50 +2072,6 @@
|
||||
"components/RiskTagDrawer.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
@@ -5,7 +5,7 @@
|
||||
"version": 2,
|
||||
"prototypeName": "vehicle-h2-fee-ledger",
|
||||
"pageId": "list",
|
||||
"updatedAt": 1783589987168,
|
||||
"updatedAt": 1783588824941,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "vh2-header",
|
||||
@@ -300,13 +300,6 @@
|
||||
"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",
|
||||
@@ -321,6 +314,112 @@
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user