扩展氢能站点、加氢记录与台账原型链路,新增工作台、车辆资产 H5、自营物流等原型,并同步导航注册、PRD 资源与交付技能。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
413
src/common/oneos-app-shell/OneOsAppShell.tsx
Normal file
413
src/common/oneos-app-shell/OneOsAppShell.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import './oneos-app-shell.css';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
LayoutDashboard,
|
||||
Menu,
|
||||
Moon,
|
||||
Sun,
|
||||
PanelLeftClose,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Maximize,
|
||||
X,
|
||||
Sparkles,
|
||||
type LucideIcon,
|
||||
FolderOpen,
|
||||
FileText,
|
||||
Truck,
|
||||
Shield,
|
||||
Wallet,
|
||||
Fuel,
|
||||
BatteryCharging,
|
||||
Users,
|
||||
Workflow,
|
||||
Settings,
|
||||
ClipboardList,
|
||||
BarChart3,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import navMenuJson from '../../prototypes/oneos-prototype-nav/nav-menu.json';
|
||||
import {
|
||||
buildShellMenuFromNav,
|
||||
collectOpenKeys,
|
||||
findMenuPath,
|
||||
hrefPathname,
|
||||
type ShellMenuItem,
|
||||
} from './nav-from-prototypes';
|
||||
import {
|
||||
readStoredOneOsTheme,
|
||||
setOneOsTheme,
|
||||
type OneOsTheme,
|
||||
} from './theme';
|
||||
import { ShellNoticeCenter } from './ShellNoticeCenter';
|
||||
import {
|
||||
isNoticesSync,
|
||||
postNoticeAction,
|
||||
type ShellNoticeItem,
|
||||
} from './notice-bridge';
|
||||
|
||||
const AVATAR_URL = 'https://unpkg.com/@vbenjs/static-source@0.1.7/source/avatar-v1.webp';
|
||||
|
||||
/** 演示壳自身,不在内容区嵌套打开 */
|
||||
export const PROTOTYPE_DEMO_HREF = '/prototypes/oneos-prototype-demo';
|
||||
|
||||
function iconForLabel(label: string): LucideIcon {
|
||||
if (/原型演示|工作台/.test(label)) return LayoutDashboard;
|
||||
if (/审批/.test(label)) return ClipboardList;
|
||||
if (/车辆|运维|资产/.test(label)) return Truck;
|
||||
if (/安全/.test(label)) return Shield;
|
||||
if (/财务|应收|收款|应结/.test(label)) return Wallet;
|
||||
if (/加氢|氢/.test(label)) return Fuel;
|
||||
if (/充电/.test(label)) return BatteryCharging;
|
||||
if (/用户|客户|机构|角色|部门/.test(label)) return Users;
|
||||
if (/工作流|流程/.test(label)) return Workflow;
|
||||
if (/系统|菜单|字典|参数|日志/.test(label)) return Settings;
|
||||
if (/BI|统计|分析|台账|明细|盈亏|回款/.test(label)) return BarChart3;
|
||||
if (/合同|业务|保险|供应商/.test(label)) return FileText;
|
||||
if (/任务|工单|调度/.test(label)) return Wrench;
|
||||
if (/配置|模板/.test(label)) return FolderOpen;
|
||||
return FolderOpen;
|
||||
}
|
||||
|
||||
export type ShellTab = {
|
||||
href: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
export type OneOsAppShellProps = {
|
||||
children: React.ReactNode;
|
||||
activeHref?: string;
|
||||
pageTitle?: string;
|
||||
breadcrumb?: string[];
|
||||
/** 自定义导航:用于演示壳在内容区切换;不传则整页跳转 */
|
||||
onNavigate?: (item: ShellMenuItem) => void;
|
||||
/** 多页签(演示壳) */
|
||||
tabs?: ShellTab[];
|
||||
onTabSelect?: (href: string) => void;
|
||||
onTabClose?: (href: string) => void;
|
||||
onRefresh?: () => void;
|
||||
brandHref?: string;
|
||||
/** 原型演示:顶栏「版本更新」图标,打开更新日志 */
|
||||
onOpenReleaseNotes?: () => void;
|
||||
/** 是否有未读版本更新(显示角标) */
|
||||
releaseNotesUnread?: boolean;
|
||||
};
|
||||
|
||||
export function OneOsAppShell({
|
||||
children,
|
||||
activeHref = PROTOTYPE_DEMO_HREF,
|
||||
pageTitle = '原型演示',
|
||||
breadcrumb,
|
||||
onNavigate: onNavigateProp,
|
||||
tabs,
|
||||
onTabSelect,
|
||||
onTabClose,
|
||||
onRefresh,
|
||||
brandHref = PROTOTYPE_DEMO_HREF,
|
||||
onOpenReleaseNotes,
|
||||
releaseNotesUnread = false,
|
||||
}: OneOsAppShellProps) {
|
||||
const menu = useMemo(
|
||||
() => buildShellMenuFromNav(navMenuJson as Parameters<typeof buildShellMenuFromNav>[0]),
|
||||
[],
|
||||
);
|
||||
const path = useMemo(() => findMenuPath(menu, activeHref) || [], [menu, activeHref]);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [openKeys, setOpenKeys] = useState<string[]>(() => collectOpenKeys(path));
|
||||
const [theme, setTheme] = useState<OneOsTheme>(() => readStoredOneOsTheme());
|
||||
const [shellNotices, setShellNotices] = useState<ShellNoticeItem[]>([]);
|
||||
const [shellUnread, setShellUnread] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
setOneOsTheme(theme);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMsg = (e: MessageEvent) => {
|
||||
if (!isNoticesSync(e.data)) return;
|
||||
setShellNotices(e.data.notices);
|
||||
setShellUnread(e.data.unreadCount);
|
||||
};
|
||||
window.addEventListener('message', onMsg);
|
||||
return () => window.removeEventListener('message', onMsg);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setShellNotices([]);
|
||||
setShellUnread(0);
|
||||
}, [activeHref]);
|
||||
|
||||
const frameWindow = useCallback((): Window | null => {
|
||||
if (typeof document === 'undefined') return null;
|
||||
const frame = document.querySelector<HTMLIFrameElement>('.oneos-shell-frame');
|
||||
return frame?.contentWindow || null;
|
||||
}, []);
|
||||
|
||||
const onNoticeRead = useCallback(
|
||||
(notice: ShellNoticeItem) => {
|
||||
setShellNotices((prev) =>
|
||||
prev.map((n) => (n.id === notice.id ? { ...n, read: true } : n)),
|
||||
);
|
||||
setShellUnread((c) => Math.max(0, c - (notice.read ? 0 : 1)));
|
||||
const win = frameWindow();
|
||||
if (win) postNoticeAction(win, 'read', notice.id);
|
||||
},
|
||||
[frameWindow],
|
||||
);
|
||||
|
||||
const onNoticeOpen = useCallback(
|
||||
(notice: ShellNoticeItem) => {
|
||||
const win = frameWindow();
|
||||
if (win) postNoticeAction(win, 'open', notice.id);
|
||||
},
|
||||
[frameWindow],
|
||||
);
|
||||
|
||||
const onNoticeHandle = useCallback(
|
||||
(notice: ShellNoticeItem) => {
|
||||
setShellNotices((prev) =>
|
||||
prev.map((n) => (n.id === notice.id ? { ...n, read: true } : n)),
|
||||
);
|
||||
setShellUnread((c) => Math.max(0, c - (notice.read ? 0 : 1)));
|
||||
const win = frameWindow();
|
||||
if (win) postNoticeAction(win, 'handle', notice.id);
|
||||
},
|
||||
[frameWindow],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setOpenKeys((prev) => {
|
||||
const next = collectOpenKeys(path);
|
||||
return Array.from(new Set([...prev, ...next]));
|
||||
});
|
||||
}, [path]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((prev) => {
|
||||
const next: OneOsTheme = prev === 'dark' ? 'light' : 'dark';
|
||||
setOneOsTheme(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const crumbs = breadcrumb?.length
|
||||
? breadcrumb
|
||||
: path.length
|
||||
? path.map((p) => p.label)
|
||||
: ['OneOS', pageTitle];
|
||||
|
||||
const selectedKey = path[path.length - 1]?.key;
|
||||
const showTabs = Array.isArray(tabs);
|
||||
|
||||
const handleNavigate = useCallback(
|
||||
(item: ShellMenuItem) => {
|
||||
if (onNavigateProp) {
|
||||
onNavigateProp(item);
|
||||
return;
|
||||
}
|
||||
if (item.href && typeof window !== 'undefined') {
|
||||
window.location.href = item.href;
|
||||
}
|
||||
},
|
||||
[onNavigateProp],
|
||||
);
|
||||
|
||||
const toggleOpen = useCallback((key: string) => {
|
||||
setOpenKeys((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
|
||||
}, []);
|
||||
|
||||
const renderItems = (items: ShellMenuItem[], level = 0): React.ReactNode =>
|
||||
items.map((item) => {
|
||||
const hasChildren = !!item.children?.length;
|
||||
const opened = openKeys.includes(item.key);
|
||||
const selected =
|
||||
item.key === selectedKey ||
|
||||
(!!item.href && hrefPathname(item.href) === hrefPathname(activeHref));
|
||||
const Icon = iconForLabel(item.label);
|
||||
|
||||
return (
|
||||
<li key={item.key} className={`oneos-shell-menu__item oneos-shell-menu__item--lv${level}`}>
|
||||
<button
|
||||
type="button"
|
||||
className={`oneos-shell-menu__btn${selected ? ' is-active' : ''}${hasChildren ? ' is-parent' : ''}`}
|
||||
title={item.label}
|
||||
aria-expanded={hasChildren ? opened : undefined}
|
||||
onClick={() => {
|
||||
if (hasChildren) toggleOpen(item.key);
|
||||
else handleNavigate(item);
|
||||
}}
|
||||
>
|
||||
<Icon className="oneos-shell-menu__icon" size={16} strokeWidth={1.75} aria-hidden />
|
||||
{!collapsed && <span className="oneos-shell-menu__label">{item.label}</span>}
|
||||
{!collapsed && hasChildren && (
|
||||
<span className="oneos-shell-menu__arrow" aria-hidden>
|
||||
{opened ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{!collapsed && hasChildren && opened && (
|
||||
<ul className="oneos-shell-menu__sub">{renderItems(item.children!, level + 1)}</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`oneos-shell${collapsed ? ' oneos-shell--collapsed' : ''}`}
|
||||
data-oneos-theme={theme}
|
||||
>
|
||||
<aside className="oneos-shell-aside" aria-label="羚牛 OneOS 导航">
|
||||
<div className="oneos-shell-brand">
|
||||
<a className="oneos-shell-brand__link" href={brandHref} title="羚牛OneOS">
|
||||
<span className="oneos-shell-brand__mark" aria-hidden>
|
||||
<svg viewBox="0 0 32 32" width="28" height="28">
|
||||
<defs>
|
||||
<linearGradient id="oneosBrandGrad" x1="0" y1="0" x2="32" y2="32">
|
||||
<stop offset="0%" stopColor="#3FB87C" />
|
||||
<stop offset="100%" stopColor="#2B9260" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="32" height="32" rx="9" fill="url(#oneosBrandGrad)" />
|
||||
<path
|
||||
d="M8 20.5L12.2 9h3.1l4.2 11.5h-3.1l-.8-2.3h-4.7l-.8 2.3H8zm4.2-4.7h3.2L14 11.2h-.1L12.2 15.8zM21 9h2.8v11.5H21V9z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
{!collapsed && <span className="oneos-shell-brand__text">羚牛OneOS</span>}
|
||||
</a>
|
||||
</div>
|
||||
<nav className="oneos-shell-nav">
|
||||
<ul className="oneos-shell-menu">{renderItems(menu)}</ul>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="oneos-shell-main">
|
||||
<div className="oneos-shell-chrome">
|
||||
<header className="oneos-shell-header">
|
||||
<div className="oneos-shell-header__left">
|
||||
<button
|
||||
type="button"
|
||||
className="oneos-shell-icon-btn"
|
||||
aria-label={collapsed ? '展开侧栏' : '收起侧栏'}
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
{collapsed ? <Menu size={18} /> : <PanelLeftClose size={18} />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="oneos-shell-icon-btn"
|
||||
aria-label="刷新"
|
||||
onClick={() => (onRefresh ? onRefresh() : window.location.reload())}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
<nav className="oneos-shell-breadcrumb" aria-label="面包屑">
|
||||
<ol>
|
||||
{crumbs.map((c, i) => (
|
||||
<li key={`${c}-${i}`}>
|
||||
{i > 0 && <span className="oneos-shell-breadcrumb__sep">/</span>}
|
||||
<span className={i === crumbs.length - 1 ? 'is-current' : undefined}>{c}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="oneos-shell-header__right">
|
||||
<label className="oneos-shell-search">
|
||||
<Search size={14} aria-hidden />
|
||||
<input type="search" placeholder="搜索" aria-label="搜索" />
|
||||
<kbd>⌘ K</kbd>
|
||||
</label>
|
||||
{onOpenReleaseNotes ? (
|
||||
<button
|
||||
type="button"
|
||||
className={`oneos-shell-release-btn${releaseNotesUnread ? ' has-unread' : ''}`}
|
||||
onClick={onOpenReleaseNotes}
|
||||
aria-label="版本更新"
|
||||
title="版本更新日志"
|
||||
data-annotation-id="shell-release-notes"
|
||||
>
|
||||
<span className="oneos-shell-release-btn__ring" aria-hidden />
|
||||
<span className="oneos-shell-release-btn__icon" aria-hidden>
|
||||
<Sparkles size={16} strokeWidth={2.25} />
|
||||
</span>
|
||||
{releaseNotesUnread ? (
|
||||
<span className="oneos-shell-release-btn__dot" aria-hidden />
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="oneos-shell-icon-btn"
|
||||
aria-label={theme === 'dark' ? '切换浅色模式' : '切换暗色模式'}
|
||||
title={theme === 'dark' ? '浅色模式' : '暗色模式'}
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
|
||||
</button>
|
||||
<button type="button" className="oneos-shell-icon-btn" aria-label="全屏" title="全屏(演示)">
|
||||
<Maximize size={16} />
|
||||
</button>
|
||||
<ShellNoticeCenter
|
||||
notices={shellNotices}
|
||||
unreadCount={shellUnread}
|
||||
onRead={onNoticeRead}
|
||||
onOpen={onNoticeOpen}
|
||||
onHandle={onNoticeHandle}
|
||||
/>
|
||||
<button type="button" className="oneos-shell-avatar" aria-label="超级管理员">
|
||||
<img src={AVATAR_URL} alt="" width={28} height={28} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{showTabs && tabs!.length > 0 && (
|
||||
<div className="oneos-shell-tabs" role="tablist" aria-label="打开的页签">
|
||||
{tabs!.map((tab) => {
|
||||
const active = hrefPathname(tab.href) === hrefPathname(activeHref);
|
||||
return (
|
||||
<div
|
||||
key={tab.href}
|
||||
className={`oneos-shell-tab${active ? ' is-active' : ''}`}
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="oneos-shell-tab__label"
|
||||
onClick={() => onTabSelect?.(tab.href)}
|
||||
>
|
||||
{tab.title}
|
||||
</button>
|
||||
{onTabClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="oneos-shell-tab__close"
|
||||
aria-label={`关闭 ${tab.title}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTabClose(tab.href);
|
||||
}}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="oneos-shell-content">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OneOsAppShell;
|
||||
12
src/common/oneos-app-shell/README.md
Normal file
12
src/common/oneos-app-shell/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# 羚牛 OneOS 应用外壳
|
||||
|
||||
> 实现:`src/common/oneos-app-shell/`
|
||||
> 宿主页:`/prototypes/oneos-prototype-demo`(原型演示)
|
||||
|
||||
## 用法
|
||||
|
||||
- **带外壳浏览所有原型**:打开「原型演示」,从左侧菜单点选;内容区 iframe 嵌入。
|
||||
- **单独打开某原型**:访问 `/prototypes/<id>`,无侧栏顶栏。
|
||||
- **浅色 / 暗色**:顶栏月亮/太阳按钮切换;暗色参照原型导航页风格。对 OneOS 目录下原型生效(含直链),**不包括「旧 ONEOS」**。
|
||||
|
||||
菜单数据:`oneos-prototype-nav/nav-menu.json`。
|
||||
364
src/common/oneos-app-shell/ShellNoticeCenter.tsx
Normal file
364
src/common/oneos-app-shell/ShellNoticeCenter.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Bell, BellOff, X } from 'lucide-react';
|
||||
import type { ShellNoticeItem } from './notice-bridge';
|
||||
|
||||
/** 气泡/标签:x条新通知,请尽快处理(阿拉伯数字) */
|
||||
function formatNewMessageTip(count: number): string {
|
||||
return `${count}条新通知,请尽快处理`;
|
||||
}
|
||||
|
||||
function compareNoticePriority(a: ShellNoticeItem, b: ShellNoticeItem): number {
|
||||
const aUrge = a.type === '催办提醒' ? 0 : 1;
|
||||
const bUrge = b.type === '催办提醒' ? 0 : 1;
|
||||
if (aUrge !== bUrge) return aUrge - bUrge;
|
||||
return b.time.localeCompare(a.time);
|
||||
}
|
||||
|
||||
type AllTab = 'unread' | 'read';
|
||||
|
||||
type ShellNoticeCenterProps = {
|
||||
notices: ShellNoticeItem[];
|
||||
unreadCount: number;
|
||||
onRead: (notice: ShellNoticeItem) => void;
|
||||
onOpen: (notice: ShellNoticeItem) => void;
|
||||
onHandle: (notice: ShellNoticeItem) => void;
|
||||
};
|
||||
|
||||
function NoticeListItem({
|
||||
notice,
|
||||
showHandle,
|
||||
showSummary = false,
|
||||
onSelect,
|
||||
onHandle,
|
||||
}: {
|
||||
notice: ShellNoticeItem;
|
||||
showHandle: boolean;
|
||||
showSummary?: boolean;
|
||||
onSelect: (notice: ShellNoticeItem) => void;
|
||||
onHandle: (notice: ShellNoticeItem) => void;
|
||||
}) {
|
||||
const canHandle = !!(notice.href || notice.taskId);
|
||||
return (
|
||||
<li className={`oneos-shell-notify__item${!notice.read ? ' is-unread' : ''}`}>
|
||||
<button type="button" className="oneos-shell-notify__item-main" onClick={() => onSelect(notice)}>
|
||||
<div className="oneos-shell-notify__meta">
|
||||
<span className="oneos-shell-notify__dot" aria-hidden />
|
||||
<span className="oneos-shell-notify__time">{notice.time}</span>
|
||||
{notice.type === '催办提醒' ? (
|
||||
<span className="oneos-shell-notify__chip oneos-shell-notify__chip--urge">催办提醒</span>
|
||||
) : null}
|
||||
<span className="oneos-shell-notify__chip">{notice.bizTag}</span>
|
||||
</div>
|
||||
<p className="oneos-shell-notify__text">{notice.detail || notice.title}</p>
|
||||
{showSummary && notice.summary ? (
|
||||
<p className="oneos-shell-notify__summary">{notice.summary}</p>
|
||||
) : null}
|
||||
</button>
|
||||
{showHandle && canHandle ? (
|
||||
<button
|
||||
type="button"
|
||||
className="oneos-shell-notify__handle"
|
||||
onClick={() => onHandle(notice)}
|
||||
>
|
||||
去处理
|
||||
</button>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** 顶栏铃铛 + 新消息闪烁提示 + 下拉通知中心(对齐工作台 NoticePanel) */
|
||||
export function ShellNoticeCenter({
|
||||
notices,
|
||||
unreadCount,
|
||||
onRead,
|
||||
onOpen: _onOpen,
|
||||
onHandle,
|
||||
}: ShellNoticeCenterProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [allOpen, setAllOpen] = useState(false);
|
||||
const [allTab, setAllTab] = useState<AllTab>('unread');
|
||||
const [detailNotice, setDetailNotice] = useState<ShellNoticeItem | null>(null);
|
||||
const wrapRef = useRef<HTMLDivElement>(null);
|
||||
const allModalRef = useRef<HTMLDivElement>(null);
|
||||
const [portalRoot, setPortalRoot] = useState<HTMLElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const shell = wrapRef.current?.closest('.oneos-shell') as HTMLElement | null;
|
||||
setPortalRoot(shell || document.body);
|
||||
}, []);
|
||||
|
||||
const pool = useMemo(
|
||||
() => notices.filter((n) => !n.read).slice().sort(compareNoticePriority),
|
||||
[notices],
|
||||
);
|
||||
const unreadList = pool;
|
||||
const readList = useMemo(
|
||||
() => notices.filter((n) => n.read).slice().sort(compareNoticePriority),
|
||||
[notices],
|
||||
);
|
||||
const allTabList = allTab === 'unread' ? unreadList : readList;
|
||||
|
||||
const closeAll = () => {
|
||||
setOpen(false);
|
||||
setAllOpen(false);
|
||||
setDetailNotice(null);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open && !detailNotice && !allOpen) return;
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (allOpen) {
|
||||
if (allModalRef.current && !allModalRef.current.contains(target)) {
|
||||
setAllOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!wrapRef.current?.contains(target)) closeAll();
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (detailNotice) setDetailNotice(null);
|
||||
else if (allOpen) setAllOpen(false);
|
||||
else setOpen(false);
|
||||
};
|
||||
document.addEventListener('mousedown', onDoc);
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDoc);
|
||||
document.removeEventListener('keydown', onKey);
|
||||
};
|
||||
}, [open, detailNotice, allOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allOpen) return;
|
||||
const lockEl = portalRoot || document.body;
|
||||
const prev = lockEl.style.overflow;
|
||||
lockEl.style.overflow = 'hidden';
|
||||
return () => {
|
||||
lockEl.style.overflow = prev;
|
||||
};
|
||||
}, [allOpen, portalRoot]);
|
||||
|
||||
const handleSelect = (notice: ShellNoticeItem) => {
|
||||
onRead(notice);
|
||||
setDetailNotice(notice);
|
||||
setOpen(false);
|
||||
setAllOpen(false);
|
||||
};
|
||||
|
||||
const handleAction = (notice: ShellNoticeItem) => {
|
||||
onRead(notice);
|
||||
onHandle(notice);
|
||||
closeAll();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="oneos-shell-notify" ref={wrapRef} data-annotation-id="wb-notice">
|
||||
{unreadCount > 0 && !open && !detailNotice && !allOpen ? (
|
||||
<span className="oneos-shell-notify__callout" aria-live="polite">
|
||||
{formatNewMessageTip(unreadCount)}
|
||||
</span>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`oneos-shell-icon-btn oneos-shell-icon-btn--notify${unreadCount > 0 ? ' has-unread' : ''}${open || detailNotice || allOpen ? ' is-open' : ''}`}
|
||||
aria-label={unreadCount > 0 ? formatNewMessageTip(unreadCount) : '通知'}
|
||||
aria-expanded={open || !!detailNotice || allOpen}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => {
|
||||
if (detailNotice || allOpen) {
|
||||
setDetailNotice(null);
|
||||
setAllOpen(false);
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
setOpen((v) => !v);
|
||||
}}
|
||||
>
|
||||
<Bell size={16} />
|
||||
</button>
|
||||
|
||||
{open && !detailNotice && !allOpen ? (
|
||||
<div className="oneos-shell-notify__panel" role="dialog" aria-label="通知中心">
|
||||
<div className="oneos-shell-notify__header">
|
||||
<h3 className="oneos-shell-notify__title">
|
||||
通知中心
|
||||
{unreadCount > 0 ? (
|
||||
<span className="oneos-shell-notify__tag">{formatNewMessageTip(unreadCount)}</span>
|
||||
) : null}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="oneos-shell-notify__link"
|
||||
onClick={() => {
|
||||
setAllTab(unreadCount > 0 ? 'unread' : 'read');
|
||||
setOpen(false);
|
||||
setAllOpen(true);
|
||||
}}
|
||||
>
|
||||
查看全部
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="oneos-shell-notify__body">
|
||||
{pool.length ? (
|
||||
<ul className="oneos-shell-notify__list">
|
||||
{pool.map((notice) => (
|
||||
<NoticeListItem
|
||||
key={notice.id}
|
||||
notice={notice}
|
||||
showHandle
|
||||
onSelect={handleSelect}
|
||||
onHandle={handleAction}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="oneos-shell-notify__empty" role="status">
|
||||
<div className="oneos-shell-notify__empty-visual" aria-hidden="true">
|
||||
<span className="oneos-shell-notify__empty-ring" />
|
||||
<span className="oneos-shell-notify__empty-icon">
|
||||
<BellOff size={22} strokeWidth={1.75} />
|
||||
</span>
|
||||
</div>
|
||||
<p className="oneos-shell-notify__empty-title">暂无新消息</p>
|
||||
<p className="oneos-shell-notify__empty-desc">
|
||||
当前通知均已读完
|
||||
<br />
|
||||
有新催办或业务提醒时会显示在这里
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{allOpen && portalRoot
|
||||
? createPortal(
|
||||
<div className="oneos-shell-notify__all-overlay" role="presentation">
|
||||
<div
|
||||
className="oneos-shell-notify__all-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="全部通知"
|
||||
ref={allModalRef}
|
||||
>
|
||||
<div className="oneos-shell-notify__all-head">
|
||||
<h3 className="oneos-shell-notify__all-title">全部通知</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="oneos-shell-notify__all-close"
|
||||
aria-label="关闭"
|
||||
onClick={() => setAllOpen(false)}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="oneos-shell-notify__all-tabs" role="tablist" aria-label="通知分类">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={allTab === 'unread'}
|
||||
className={`oneos-shell-notify__all-tab${allTab === 'unread' ? ' is-active' : ''}`}
|
||||
onClick={() => setAllTab('unread')}
|
||||
>
|
||||
未读
|
||||
<span className="oneos-shell-notify__all-count">{unreadList.length}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={allTab === 'read'}
|
||||
className={`oneos-shell-notify__all-tab${allTab === 'read' ? ' is-active' : ''}`}
|
||||
onClick={() => setAllTab('read')}
|
||||
>
|
||||
已读
|
||||
<span className="oneos-shell-notify__all-count">{readList.length}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="oneos-shell-notify__all-body" role="tabpanel">
|
||||
{allTabList.length ? (
|
||||
<ul className="oneos-shell-notify__list">
|
||||
{allTabList.map((notice) => (
|
||||
<NoticeListItem
|
||||
key={notice.id}
|
||||
notice={notice}
|
||||
showHandle={allTab === 'unread'}
|
||||
showSummary
|
||||
onSelect={handleSelect}
|
||||
onHandle={handleAction}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="oneos-shell-notify__empty" role="status">
|
||||
<div className="oneos-shell-notify__empty-visual" aria-hidden="true">
|
||||
<span className="oneos-shell-notify__empty-ring" />
|
||||
<span className="oneos-shell-notify__empty-icon">
|
||||
<BellOff size={22} strokeWidth={1.75} />
|
||||
</span>
|
||||
</div>
|
||||
<p className="oneos-shell-notify__empty-title">
|
||||
{allTab === 'unread' ? '暂无未读通知' : '暂无已读通知'}
|
||||
</p>
|
||||
<p className="oneos-shell-notify__empty-desc">
|
||||
{allTab === 'unread'
|
||||
? '当前通知均已读完,有新催办或业务提醒时会显示在这里'
|
||||
: '已读通知会显示在这里'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
portalRoot,
|
||||
)
|
||||
: null}
|
||||
|
||||
{detailNotice ? (
|
||||
<div className="oneos-shell-notify__detail" role="dialog" aria-modal="true" aria-label="通知详情">
|
||||
<div className="oneos-shell-notify__detail-head">
|
||||
<h3 className="oneos-shell-notify__detail-title">
|
||||
{detailNotice.type === '催办提醒' ? (
|
||||
<span className="oneos-shell-notify__chip oneos-shell-notify__chip--urge">催办提醒</span>
|
||||
) : null}
|
||||
<span className="oneos-shell-notify__chip">{detailNotice.bizTag}</span>
|
||||
<span className="oneos-shell-notify__detail-name">{detailNotice.title}</span>
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="oneos-shell-notify__detail-close"
|
||||
onClick={() => setDetailNotice(null)}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
<div className="oneos-shell-notify__detail-body">
|
||||
<span className="oneos-shell-notify__time">{detailNotice.time}</span>
|
||||
<p className="oneos-shell-notify__detail-text">{detailNotice.detail || detailNotice.title}</p>
|
||||
{detailNotice.summary ? (
|
||||
<p className="oneos-shell-notify__detail-summary">{detailNotice.summary}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="oneos-shell-notify__detail-foot">
|
||||
<button type="button" className="oneos-shell-notify__detail-secondary" onClick={() => setDetailNotice(null)}>
|
||||
知道了
|
||||
</button>
|
||||
{(detailNotice.href || detailNotice.taskId) && (
|
||||
<button
|
||||
type="button"
|
||||
className="oneos-shell-notify__detail-primary"
|
||||
onClick={() => handleAction(detailNotice)}
|
||||
>
|
||||
去处理
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
src/common/oneos-app-shell/index.ts
Normal file
50
src/common/oneos-app-shell/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export { OneOsAppShell, PROTOTYPE_DEMO_HREF } from './OneOsAppShell';
|
||||
export type { OneOsAppShellProps, ShellTab } from './OneOsAppShell';
|
||||
export { ShellNoticeCenter } from './ShellNoticeCenter';
|
||||
export {
|
||||
ONEOS_NOTICE_ACTION,
|
||||
ONEOS_NOTICES_SYNC,
|
||||
isNoticeAction,
|
||||
isNoticesSync,
|
||||
postNoticeAction,
|
||||
postNoticesSync,
|
||||
type NoticeActionPayload,
|
||||
type NoticesSyncPayload,
|
||||
type ShellNoticeItem,
|
||||
} from './notice-bridge';
|
||||
export {
|
||||
ONEOS_SHELL_NAV,
|
||||
isShellNav,
|
||||
requestShellNav,
|
||||
type ShellNavPayload,
|
||||
} from './nav-bridge';
|
||||
export {
|
||||
buildShellMenuFromNav,
|
||||
findMenuPath,
|
||||
hrefPathname,
|
||||
itemKeyToHref,
|
||||
type ShellMenuItem,
|
||||
} from './nav-from-prototypes';
|
||||
export {
|
||||
ONEOS_RELEASE_DEMO_ACTION,
|
||||
RELEASE_SEEN_STORAGE_KEY,
|
||||
SHELL_CURRENT_RELEASE_VERSION,
|
||||
clearAllReleaseSeen,
|
||||
hasUnseenRelease,
|
||||
isReleaseDemoAction,
|
||||
postReleaseDemoAction,
|
||||
postReleaseDemoOpen,
|
||||
postReleaseDemoReset,
|
||||
type ReleaseDemoAction,
|
||||
type ReleaseDemoActionPayload,
|
||||
} from './release-demo-bridge';
|
||||
export {
|
||||
bootstrapOneOsTheme,
|
||||
broadcastOneOsTheme,
|
||||
isLegacyOneOsPrototype,
|
||||
LEGACY_ONEOS_PROTO_IDS,
|
||||
readStoredOneOsTheme,
|
||||
setOneOsTheme,
|
||||
toggleOneOsTheme,
|
||||
type OneOsTheme,
|
||||
} from './theme';
|
||||
29
src/common/oneos-app-shell/nav-bridge.ts
Normal file
29
src/common/oneos-app-shell/nav-bridge.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/** iframe 内页面 ↔ 外壳侧栏/页签导航 postMessage 协议 */
|
||||
|
||||
export const ONEOS_SHELL_NAV = 'ONEOS_SHELL_NAV';
|
||||
|
||||
export type ShellNavPayload = {
|
||||
type: typeof ONEOS_SHELL_NAV;
|
||||
href: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function isShellNav(data: unknown): data is ShellNavPayload {
|
||||
return (
|
||||
!!data &&
|
||||
typeof data === 'object' &&
|
||||
(data as ShellNavPayload).type === ONEOS_SHELL_NAV &&
|
||||
typeof (data as ShellNavPayload).href === 'string' &&
|
||||
(data as ShellNavPayload).href.startsWith('/prototypes/')
|
||||
);
|
||||
}
|
||||
|
||||
/** 在 iframe 内跳转时优先通知外壳;直链打开则本页跳转 */
|
||||
export function requestShellNav(href: string, title?: string): boolean {
|
||||
if (typeof window === 'undefined' || !href.startsWith('/prototypes/')) return false;
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({ type: ONEOS_SHELL_NAV, href, title } satisfies ShellNavPayload, '*');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
104
src/common/oneos-app-shell/nav-from-prototypes.ts
Normal file
104
src/common/oneos-app-shell/nav-from-prototypes.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 将 oneos-prototype-nav/nav-menu.json 转为羚牛 OneOS 侧栏菜单树
|
||||
*/
|
||||
|
||||
export type NavMenuNode = {
|
||||
id: string;
|
||||
kind: 'folder' | 'item';
|
||||
title: string;
|
||||
itemKey?: string;
|
||||
children?: NavMenuNode[];
|
||||
};
|
||||
|
||||
export type ShellMenuItem = {
|
||||
key: string;
|
||||
label: string;
|
||||
href?: string;
|
||||
children?: ShellMenuItem[];
|
||||
};
|
||||
|
||||
type NavMenuFile = {
|
||||
prototypes?: NavMenuNode[];
|
||||
};
|
||||
|
||||
export function itemKeyToHref(itemKey?: string): string | undefined {
|
||||
if (!itemKey) return undefined;
|
||||
if (itemKey.startsWith('/prototypes/')) return itemKey;
|
||||
if (itemKey.startsWith('prototypes/')) return `/${itemKey}`;
|
||||
return `/prototypes/${itemKey}`;
|
||||
}
|
||||
|
||||
function convertNode(node: NavMenuNode): ShellMenuItem {
|
||||
if (node.kind === 'item') {
|
||||
return {
|
||||
key: node.id || node.itemKey || node.title,
|
||||
label: node.title,
|
||||
href: itemKeyToHref(node.itemKey),
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: node.id || node.title,
|
||||
label: node.title,
|
||||
children: (node.children || []).map(convertNode),
|
||||
};
|
||||
}
|
||||
|
||||
/** 取「OneOS」根目录下的子树作为侧栏;若无则用整棵 prototypes */
|
||||
export function buildShellMenuFromNav(
|
||||
nav: NavMenuFile,
|
||||
options?: { excludeHrefs?: string[] },
|
||||
): ShellMenuItem[] {
|
||||
const roots = nav.prototypes || [];
|
||||
const oneos = roots.find((n) => n.kind === 'folder' && /oneos/i.test(n.title));
|
||||
const source = oneos?.children?.length ? oneos.children : roots;
|
||||
const exclude = new Set(
|
||||
(options?.excludeHrefs ?? ['/prototypes/oneos-prototype-demo']).map((h) => h.replace(/\/$/, '')),
|
||||
);
|
||||
|
||||
function filterTree(items: ShellMenuItem[]): ShellMenuItem[] {
|
||||
return items
|
||||
.map((it) => {
|
||||
if (it.href && exclude.has(it.href.replace(/\/$/, ''))) return null;
|
||||
if (it.children?.length) {
|
||||
const children = filterTree(it.children);
|
||||
if (!it.href && children.length === 0) return null;
|
||||
return { ...it, children };
|
||||
}
|
||||
return it;
|
||||
})
|
||||
.filter(Boolean) as ShellMenuItem[];
|
||||
}
|
||||
|
||||
return filterTree(source.map(convertNode));
|
||||
}
|
||||
|
||||
/** 去掉 query/hash 与尾部斜杠,供菜单高亮与路径匹配 */
|
||||
export function hrefPathname(href: string): string {
|
||||
return href.split(/[?#]/)[0]!.replace(/\/$/, '') || href;
|
||||
}
|
||||
|
||||
export function findMenuPath(
|
||||
items: ShellMenuItem[],
|
||||
activeHref: string,
|
||||
trail: ShellMenuItem[] = [],
|
||||
): ShellMenuItem[] | null {
|
||||
const activePath = hrefPathname(activeHref);
|
||||
for (const item of items) {
|
||||
const next = [...trail, item];
|
||||
if (item.href) {
|
||||
const itemPath = hrefPathname(item.href);
|
||||
if (activePath === itemPath || activePath.endsWith(itemPath)) {
|
||||
return next;
|
||||
}
|
||||
}
|
||||
if (item.children?.length) {
|
||||
const hit = findMenuPath(item.children, activeHref, next);
|
||||
if (hit) return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function collectOpenKeys(path: ShellMenuItem[]): string[] {
|
||||
return path.filter((n) => n.children?.length).map((n) => n.key);
|
||||
}
|
||||
60
src/common/oneos-app-shell/notice-bridge.ts
Normal file
60
src/common/oneos-app-shell/notice-bridge.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/** 工作台 iframe ↔ 外壳顶栏通知中心 postMessage 协议 */
|
||||
|
||||
export const ONEOS_NOTICES_SYNC = 'ONEOS_NOTICES_SYNC';
|
||||
export const ONEOS_NOTICE_ACTION = 'ONEOS_NOTICE_ACTION';
|
||||
|
||||
export type ShellNoticeItem = {
|
||||
id: string;
|
||||
type: string;
|
||||
bizTag: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
detail: string;
|
||||
time: string;
|
||||
read: boolean;
|
||||
href?: string;
|
||||
taskId?: string;
|
||||
};
|
||||
|
||||
export type NoticesSyncPayload = {
|
||||
type: typeof ONEOS_NOTICES_SYNC;
|
||||
unreadCount: number;
|
||||
notices: ShellNoticeItem[];
|
||||
source?: string;
|
||||
};
|
||||
|
||||
export type NoticeActionPayload = {
|
||||
type: typeof ONEOS_NOTICE_ACTION;
|
||||
action: 'read' | 'handle' | 'open';
|
||||
noticeId: string;
|
||||
};
|
||||
|
||||
export function isNoticesSync(data: unknown): data is NoticesSyncPayload {
|
||||
return (
|
||||
!!data &&
|
||||
typeof data === 'object' &&
|
||||
(data as NoticesSyncPayload).type === ONEOS_NOTICES_SYNC &&
|
||||
Array.isArray((data as NoticesSyncPayload).notices)
|
||||
);
|
||||
}
|
||||
|
||||
export function isNoticeAction(data: unknown): data is NoticeActionPayload {
|
||||
return (
|
||||
!!data &&
|
||||
typeof data === 'object' &&
|
||||
(data as NoticeActionPayload).type === ONEOS_NOTICE_ACTION &&
|
||||
typeof (data as NoticeActionPayload).noticeId === 'string'
|
||||
);
|
||||
}
|
||||
|
||||
export function postNoticesSync(target: Window, payload: Omit<NoticesSyncPayload, 'type'>) {
|
||||
target.postMessage({ type: ONEOS_NOTICES_SYNC, ...payload }, '*');
|
||||
}
|
||||
|
||||
export function postNoticeAction(
|
||||
target: Window,
|
||||
action: NoticeActionPayload['action'],
|
||||
noticeId: string,
|
||||
) {
|
||||
target.postMessage({ type: ONEOS_NOTICE_ACTION, action, noticeId }, '*');
|
||||
}
|
||||
1564
src/common/oneos-app-shell/oneos-app-shell.css
Normal file
1564
src/common/oneos-app-shell/oneos-app-shell.css
Normal file
File diff suppressed because it is too large
Load Diff
163
src/common/oneos-app-shell/oneos-theme-content.css
Normal file
163
src/common/oneos-app-shell/oneos-theme-content.css
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* OneOS 内容区暗色 token(参照原型导航 / VoltAgent 深色画布)
|
||||
* 由 html[data-oneos-theme="dark"] 驱动;浅色保持各页原有 :root 定义。
|
||||
*/
|
||||
|
||||
html[data-oneos-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
|
||||
--ln-primary: #00d992;
|
||||
--ln-primary-hover: #2fd6a1;
|
||||
--ln-primary-focus: #10b981;
|
||||
--ln-primary-active: #10b981;
|
||||
--ln-primary-soft: rgba(0, 217, 146, 0.12);
|
||||
--ln-primary-soft-strong: rgba(0, 217, 146, 0.2);
|
||||
--ln-ink: #f2f2f2;
|
||||
--ln-body: #bdbdbd;
|
||||
--ln-body-strong: #f2f2f2;
|
||||
--ln-muted: #8b949e;
|
||||
--ln-muted-soft: #6b7280;
|
||||
--ln-muted-80: #d1d5db;
|
||||
--ln-divider-soft: #1a1a1a;
|
||||
--ln-hairline: #3d3a39;
|
||||
--ln-hairline-strong: #565656;
|
||||
--ln-hairline-tertiary: #3d3a39;
|
||||
--ln-canvas: #101010;
|
||||
--ln-canvas-parchment: #101010;
|
||||
--ln-surface-pearl: #1a1a1a;
|
||||
--ln-surface-card: #1a1a1a;
|
||||
--ln-surface-strong: #222222;
|
||||
--ln-on-primary: #101010;
|
||||
--ln-success: #00d992;
|
||||
--ln-warning: #f59e0b;
|
||||
--ln-error: #f87171;
|
||||
--ln-brand-secure: #2fd6a1;
|
||||
--ln-shadow-soft: none;
|
||||
--ln-shadow-hover: none;
|
||||
--ln-shadow-float: none;
|
||||
--ln-shadow-modal: 0 20px 40px rgba(0, 0, 0, 0.45);
|
||||
--ln-link: #00d992;
|
||||
--ln-canvas-soft: #1a1a1a;
|
||||
|
||||
--vm-primary: var(--ln-primary);
|
||||
--vm-primary-dark: var(--ln-primary-focus);
|
||||
--vm-primary-soft: var(--ln-primary-soft);
|
||||
--vm-bg: var(--ln-canvas-parchment);
|
||||
--vm-surface: var(--ln-surface-card);
|
||||
--vm-border: var(--ln-hairline);
|
||||
--vm-text: var(--ln-ink);
|
||||
--vm-muted: var(--ln-muted);
|
||||
--vm-danger: var(--ln-error);
|
||||
--vm-warning: var(--ln-warning);
|
||||
--vm-link: var(--ln-link);
|
||||
}
|
||||
|
||||
html[data-oneos-theme='dark'],
|
||||
html[data-oneos-theme='dark'] body {
|
||||
background-color: #101010 !important;
|
||||
color: #f2f2f2;
|
||||
}
|
||||
|
||||
html[data-oneos-theme='dark'] #root {
|
||||
background-color: #101010;
|
||||
color: #f2f2f2;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
/* 常见页面根容器 */
|
||||
html[data-oneos-theme='dark'] .vm-page,
|
||||
html[data-oneos-theme='dark'] .wb-page,
|
||||
html[data-oneos-theme='dark'] .page,
|
||||
html[data-oneos-theme='dark'] .ant-layout,
|
||||
html[data-oneos-theme='dark'] .ant-layout-content {
|
||||
background: #101010 !important;
|
||||
color: #f2f2f2;
|
||||
}
|
||||
|
||||
html[data-oneos-theme='dark'] .ant-card,
|
||||
html[data-oneos-theme='dark'] .ant-table,
|
||||
html[data-oneos-theme='dark'] .ant-table-container,
|
||||
html[data-oneos-theme='dark'] .ant-table-thead > tr > th,
|
||||
html[data-oneos-theme='dark'] .ant-modal-content,
|
||||
html[data-oneos-theme='dark'] .ant-drawer-content,
|
||||
html[data-oneos-theme='dark'] .ant-input,
|
||||
html[data-oneos-theme='dark'] .ant-input-affix-wrapper,
|
||||
html[data-oneos-theme='dark'] .ant-select-selector,
|
||||
html[data-oneos-theme='dark'] .ant-picker {
|
||||
background: #1a1a1a !important;
|
||||
border-color: #3d3a39 !important;
|
||||
color: #f2f2f2 !important;
|
||||
}
|
||||
|
||||
html[data-oneos-theme='dark'] .ant-table-thead > tr > th {
|
||||
background: #161616 !important;
|
||||
color: #bdbdbd !important;
|
||||
}
|
||||
|
||||
html[data-oneos-theme='dark'] .ant-table-tbody > tr > td {
|
||||
border-color: #3d3a39 !important;
|
||||
color: #f2f2f2 !important;
|
||||
}
|
||||
|
||||
html[data-oneos-theme='dark'] .ant-table-tbody > tr:hover > td {
|
||||
background: #222222 !important;
|
||||
}
|
||||
|
||||
html[data-oneos-theme='dark'] .ant-btn-default {
|
||||
background: #1a1a1a !important;
|
||||
border-color: #3d3a39 !important;
|
||||
color: #f2f2f2 !important;
|
||||
}
|
||||
|
||||
html[data-oneos-theme='dark'] .ant-btn-primary {
|
||||
background: #00d992 !important;
|
||||
border-color: #00d992 !important;
|
||||
color: #101010 !important;
|
||||
}
|
||||
|
||||
/* 工作台 / 通用卡片:覆盖硬编码浅色渐变与表头 */
|
||||
html[data-oneos-theme='dark'] .wb-hero--welcome,
|
||||
html[data-oneos-theme='dark'] .wb-hero--personal,
|
||||
html[data-oneos-theme='dark'] .wb-panel,
|
||||
html[data-oneos-theme='dark'] .wb-insights,
|
||||
html[data-oneos-theme='dark'] .wb-section--flow,
|
||||
html[data-oneos-theme='dark'] .wb-section--approval,
|
||||
html[data-oneos-theme='dark'] .wb-kpi-tile,
|
||||
html[data-oneos-theme='dark'] .wb-notice-card {
|
||||
background: #1a1a1a !important;
|
||||
box-shadow: none;
|
||||
color: #f2f2f2;
|
||||
}
|
||||
|
||||
html[data-oneos-theme='dark'] .wb-kpi-chip {
|
||||
background: #222222 !important;
|
||||
box-shadow: none;
|
||||
color: #f2f2f2;
|
||||
}
|
||||
|
||||
html[data-oneos-theme='dark'] .wb-panel-header,
|
||||
html[data-oneos-theme='dark'] .wb-section-head,
|
||||
html[data-oneos-theme='dark'] .wb-todo-table thead th,
|
||||
html[data-oneos-theme='dark'] .wb-insight-table th,
|
||||
html[data-oneos-theme='dark'] .wb-insight-metric {
|
||||
background: #161616 !important;
|
||||
color: #bdbdbd;
|
||||
}
|
||||
|
||||
html[data-oneos-theme='dark'] .wb-page--layout-personal {
|
||||
background: #101010 !important;
|
||||
}
|
||||
|
||||
html[data-oneos-theme='dark'] .wb-todo-table tbody tr:hover td,
|
||||
html[data-oneos-theme='dark'] .wb-kpi-chip:hover {
|
||||
background: #222222 !important;
|
||||
}
|
||||
|
||||
html[data-oneos-theme='dark'] .wb-tabs,
|
||||
html[data-oneos-theme='dark'] .wb-drawer,
|
||||
html[data-oneos-theme='dark'] .wb-modal,
|
||||
html[data-oneos-theme='dark'] .wb-overlay-panel {
|
||||
background: #1a1a1a;
|
||||
color: #f2f2f2;
|
||||
border-color: #3d3a39;
|
||||
}
|
||||
64
src/common/oneos-app-shell/release-demo-bridge.ts
Normal file
64
src/common/oneos-app-shell/release-demo-bridge.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/** 原型演示外壳 ↔ 工作台 iframe:版本更新日志 */
|
||||
|
||||
export const ONEOS_RELEASE_DEMO_ACTION = 'ONEOS_RELEASE_DEMO_ACTION';
|
||||
|
||||
export type ReleaseDemoAction = 'open' | 'reset';
|
||||
|
||||
export type ReleaseDemoActionPayload = {
|
||||
type: typeof ONEOS_RELEASE_DEMO_ACTION;
|
||||
action: ReleaseDemoAction;
|
||||
};
|
||||
|
||||
export function isReleaseDemoAction(data: unknown): data is ReleaseDemoActionPayload {
|
||||
if (!data || typeof data !== 'object') return false;
|
||||
const payload = data as ReleaseDemoActionPayload;
|
||||
return (
|
||||
payload.type === ONEOS_RELEASE_DEMO_ACTION &&
|
||||
(payload.action === 'open' || payload.action === 'reset')
|
||||
);
|
||||
}
|
||||
|
||||
export function postReleaseDemoAction(target: Window, action: ReleaseDemoAction) {
|
||||
target.postMessage(
|
||||
{ type: ONEOS_RELEASE_DEMO_ACTION, action } satisfies ReleaseDemoActionPayload,
|
||||
'*',
|
||||
);
|
||||
}
|
||||
|
||||
/** @deprecated 使用 postReleaseDemoAction(target, 'open') */
|
||||
export function postReleaseDemoReset(target: Window) {
|
||||
postReleaseDemoAction(target, 'reset');
|
||||
}
|
||||
|
||||
export function postReleaseDemoOpen(target: Window) {
|
||||
postReleaseDemoAction(target, 'open');
|
||||
}
|
||||
|
||||
/** 与工作台 `utils/release-seen.ts` 同源 key */
|
||||
export const RELEASE_SEEN_STORAGE_KEY = 'oneos-wb-release-seen-v1';
|
||||
|
||||
/** 当前发布版本(外壳「未读」点用;与 workbench data/release-notes 保持一致) */
|
||||
export const SHELL_CURRENT_RELEASE_VERSION = '1.1.5';
|
||||
|
||||
export function clearAllReleaseSeen(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
window.localStorage.removeItem(RELEASE_SEEN_STORAGE_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function hasUnseenRelease(operatorName?: string): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
const raw = window.localStorage.getItem(RELEASE_SEEN_STORAGE_KEY);
|
||||
if (!raw) return true;
|
||||
const map = JSON.parse(raw) as Record<string, string>;
|
||||
if (!map || typeof map !== 'object') return true;
|
||||
if (operatorName) return map[operatorName] !== SHELL_CURRENT_RELEASE_VERSION;
|
||||
return !Object.values(map).includes(SHELL_CURRENT_RELEASE_VERSION);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
122
src/common/oneos-app-shell/theme.ts
Normal file
122
src/common/oneos-app-shell/theme.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* OneOS 浅色 / 暗色主题(localStorage + 跨 iframe postMessage)
|
||||
* 暗色参照原型导航页(VoltAgent 深色画布)。
|
||||
* 不对「旧 ONEOS」目录下原型生效。
|
||||
*/
|
||||
|
||||
import './oneos-theme-content.css';
|
||||
|
||||
export type OneOsTheme = 'light' | 'dark';
|
||||
|
||||
export const ONEOS_THEME_STORAGE_KEY = 'oneos-ui-theme';
|
||||
export const ONEOS_THEME_ATTR = 'data-oneos-theme';
|
||||
export const ONEOS_THEME_MESSAGE = 'ONEOS_THEME_CHANGE';
|
||||
|
||||
/** sidebar「旧ONEOS」目录下的原型,不接入主题切换 */
|
||||
export const LEGACY_ONEOS_PROTO_IDS = new Set([
|
||||
'oneos-web-business',
|
||||
'oneos-web-data-analysis',
|
||||
'oneos-web-finance',
|
||||
'oneos-web-help-center',
|
||||
'oneos-web-lease-contract',
|
||||
'oneos-web-ledger-data',
|
||||
'oneos-web-ops',
|
||||
'oneos-web-procurement',
|
||||
]);
|
||||
|
||||
export function isLegacyOneOsPrototype(id?: string | null): boolean {
|
||||
if (!id) return false;
|
||||
return LEGACY_ONEOS_PROTO_IDS.has(id);
|
||||
}
|
||||
|
||||
export function readStoredOneOsTheme(): OneOsTheme {
|
||||
if (typeof window === 'undefined') return 'light';
|
||||
try {
|
||||
const v = window.localStorage.getItem(ONEOS_THEME_STORAGE_KEY);
|
||||
if (v === 'dark' || v === 'light') return v;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
export function applyOneOsTheme(theme: OneOsTheme, root: HTMLElement = document.documentElement) {
|
||||
root.setAttribute(ONEOS_THEME_ATTR, theme);
|
||||
if (root === document.documentElement) {
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
}
|
||||
}
|
||||
|
||||
export function persistOneOsTheme(theme: OneOsTheme) {
|
||||
try {
|
||||
window.localStorage.setItem(ONEOS_THEME_STORAGE_KEY, theme);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcastOneOsTheme(theme: OneOsTheme) {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.dispatchEvent(new CustomEvent(ONEOS_THEME_MESSAGE, { detail: { theme } }));
|
||||
try {
|
||||
window.parent?.postMessage({ type: ONEOS_THEME_MESSAGE, theme }, '*');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const frames = document.querySelectorAll('iframe');
|
||||
frames.forEach((frame) => {
|
||||
try {
|
||||
frame.contentWindow?.postMessage({ type: ONEOS_THEME_MESSAGE, theme }, '*');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function setOneOsTheme(theme: OneOsTheme) {
|
||||
applyOneOsTheme(theme);
|
||||
persistOneOsTheme(theme);
|
||||
broadcastOneOsTheme(theme);
|
||||
}
|
||||
|
||||
export function toggleOneOsTheme(current?: OneOsTheme): OneOsTheme {
|
||||
const next: OneOsTheme = (current ?? readStoredOneOsTheme()) === 'dark' ? 'light' : 'dark';
|
||||
setOneOsTheme(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
type BootstrapOptions = {
|
||||
prototypeId?: string;
|
||||
/** 强制跳过(如旧 ONEOS) */
|
||||
skip?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 在原型预览入口调用:同步 localStorage / URL / 父页 postMessage。
|
||||
*/
|
||||
export function bootstrapOneOsTheme(options: BootstrapOptions = {}) {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (options.skip || isLegacyOneOsPrototype(options.prototypeId)) return;
|
||||
|
||||
const fromQuery = new URLSearchParams(window.location.search).get('oneosTheme');
|
||||
const initial: OneOsTheme =
|
||||
fromQuery === 'dark' || fromQuery === 'light' ? fromQuery : readStoredOneOsTheme();
|
||||
|
||||
applyOneOsTheme(initial);
|
||||
persistOneOsTheme(initial);
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const data = event.data;
|
||||
if (!data || data.type !== ONEOS_THEME_MESSAGE) return;
|
||||
if (data.theme !== 'dark' && data.theme !== 'light') return;
|
||||
applyOneOsTheme(data.theme);
|
||||
persistOneOsTheme(data.theme);
|
||||
});
|
||||
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key !== ONEOS_THEME_STORAGE_KEY) return;
|
||||
if (event.newValue === 'dark' || event.newValue === 'light') {
|
||||
applyOneOsTheme(event.newValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
567
src/common/selfOperatedLogisticsBridge.js
Normal file
567
src/common/selfOperatedLogisticsBridge.js
Normal file
@@ -0,0 +1,567 @@
|
||||
/**
|
||||
* 自营物流闭环桥:自营合同 ↔ 轻量调度任务 ↔ 车辆运营态 ↔ 物流业务明细自动生成
|
||||
* 原型本地种子 + localStorage,未接真实 API。
|
||||
* 规则见:src/resources/self-operated-logistics/assumptions.md
|
||||
*/
|
||||
|
||||
export const SO_CONTRACT_STORAGE_KEY = 'oneos.self-operated.contracts.v1';
|
||||
export const SO_DISPATCH_STORAGE_KEY = 'oneos.self-operated.dispatch-tasks.v1';
|
||||
export const SO_LEDGER_AUTO_STORAGE_KEY = 'oneos.self-operated.ledger-auto.v1';
|
||||
export const SO_VEHICLE_OPS_STORAGE_KEY = 'oneos.self-operated.vehicle-ops.v1';
|
||||
|
||||
export const CONTRACT_STATUS = {
|
||||
draft: { key: 'draft', label: '草稿', color: 'default' },
|
||||
active: { key: 'active', label: '生效', color: 'processing' },
|
||||
performing: { key: 'performing', label: '履约中', color: 'success' },
|
||||
ended: { key: 'ended', label: '已结束', color: 'default' },
|
||||
terminated: { key: 'terminated', label: '已终止', color: 'error' },
|
||||
};
|
||||
|
||||
export const DISPATCH_STATUS = {
|
||||
pending_assign: { key: 'pending_assign', label: '待派', color: 'default' },
|
||||
assigned: { key: 'assigned', label: '已派', color: 'processing' },
|
||||
in_progress: { key: 'in_progress', label: '执行中', color: 'warning' },
|
||||
completed: { key: 'completed', label: '已办结', color: 'success' },
|
||||
cancelled: { key: 'cancelled', label: '已取消', color: 'error' },
|
||||
};
|
||||
|
||||
/** inventory | self_operated */
|
||||
export const VEHICLE_OPS = {
|
||||
inventory: 'inventory',
|
||||
self_operated: 'self_operated',
|
||||
};
|
||||
|
||||
export const SEED_VEHICLES = [
|
||||
{ plateNo: '粤B·H2001', brandModel: '佛山飞驰·35T', opsStatus: VEHICLE_OPS.inventory },
|
||||
{ plateNo: '粤B·H2002', brandModel: '佛山飞驰·35T', opsStatus: VEHICLE_OPS.self_operated },
|
||||
{ plateNo: '粤B·H2003', brandModel: '未势能源·49T', opsStatus: VEHICLE_OPS.inventory },
|
||||
{ plateNo: '粤B·H2008', brandModel: '未势能源·49T', opsStatus: VEHICLE_OPS.inventory },
|
||||
];
|
||||
|
||||
export const SEED_DRIVERS = [
|
||||
{ name: '陈志强', phone: '13800138001' },
|
||||
{ name: '李伟', phone: '13800138002' },
|
||||
{ name: '王海涛', phone: '13800138003' },
|
||||
];
|
||||
|
||||
function nowStamp() {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function todayDate() {
|
||||
return nowStamp().slice(0, 10);
|
||||
}
|
||||
|
||||
function readJson(key, fallback) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return fallback;
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed == null ? fallback : parsed;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function writeJson(key, value) {
|
||||
localStorage.setItem(key, JSON.stringify(value));
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('oneos-self-operated-bridge', { detail: { key } }));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function roundMoney(value) {
|
||||
return Math.round(Number(value || 0) * 100) / 100;
|
||||
}
|
||||
|
||||
function computeAmount(unitPrice, quantity) {
|
||||
return roundMoney(Number(unitPrice || 0) * Number(quantity || 0));
|
||||
}
|
||||
|
||||
function computeTotalCost(row) {
|
||||
return roundMoney(
|
||||
Number(row.hydrogenFee || 0)
|
||||
+ Number(row.manualReimbursement || 0)
|
||||
+ Number(row.etcFee || 0)
|
||||
+ Number(row.electricityFee || 0)
|
||||
+ Number(row.salary || 0)
|
||||
+ Number(row.vehicleFee || 0),
|
||||
);
|
||||
}
|
||||
|
||||
function brandModelOf(plateNo) {
|
||||
const hit = SEED_VEHICLES.find((v) => v.plateNo === plateNo);
|
||||
return hit ? hit.brandModel : '';
|
||||
}
|
||||
|
||||
// —— 车辆运营态 ——
|
||||
|
||||
export function loadVehicleOpsMap() {
|
||||
const stored = readJson(SO_VEHICLE_OPS_STORAGE_KEY, null);
|
||||
if (stored && typeof stored === 'object') return stored;
|
||||
const map = {};
|
||||
SEED_VEHICLES.forEach((v) => {
|
||||
map[v.plateNo] = v.opsStatus;
|
||||
});
|
||||
writeJson(SO_VEHICLE_OPS_STORAGE_KEY, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
export function getVehicleOpsStatus(plateNo) {
|
||||
const map = loadVehicleOpsMap();
|
||||
return map[plateNo] || VEHICLE_OPS.inventory;
|
||||
}
|
||||
|
||||
export function setVehicleOpsStatus(plateNo, status) {
|
||||
const map = loadVehicleOpsMap();
|
||||
map[plateNo] = status;
|
||||
writeJson(SO_VEHICLE_OPS_STORAGE_KEY, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟运维交车:库存 → 自营
|
||||
*/
|
||||
export function simulateHandover(plateNo) {
|
||||
const plate = String(plateNo || '').trim();
|
||||
if (!plate) return { ok: false, message: '请选择车牌' };
|
||||
const current = getVehicleOpsStatus(plate);
|
||||
if (current === VEHICLE_OPS.self_operated) {
|
||||
return { ok: true, message: `${plate} 已在自营运营态`, already: true };
|
||||
}
|
||||
setVehicleOpsStatus(plate, VEHICLE_OPS.self_operated);
|
||||
return { ok: true, message: `已模拟交车:${plate} → 自营运营态` };
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟运维还车:自营 → 库存;存在未办结任务时拦截
|
||||
*/
|
||||
export function simulateReturn(plateNo) {
|
||||
const plate = String(plateNo || '').trim();
|
||||
if (!plate) return { ok: false, message: '请选择车牌' };
|
||||
const open = loadDispatchTasks().filter(
|
||||
(t) => t.plateNo === plate && !['completed', 'cancelled'].includes(t.status),
|
||||
);
|
||||
if (open.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `${plate} 仍有 ${open.length} 条未办结调度任务,无法还车`,
|
||||
};
|
||||
}
|
||||
setVehicleOpsStatus(plate, VEHICLE_OPS.inventory);
|
||||
return { ok: true, message: `已模拟还车:${plate} → 库存` };
|
||||
}
|
||||
|
||||
// —— 合同 ——
|
||||
|
||||
function seedContracts() {
|
||||
return [
|
||||
{
|
||||
id: 'soc-seed-001',
|
||||
contractCode: 'ZY-2026-0001',
|
||||
customerName: '深圳城配物流有限公司',
|
||||
businessName: '深圳南山短驳项目',
|
||||
vehicleType: '35T 氢能重卡',
|
||||
vehicleCount: 2,
|
||||
serviceStart: '2026-07-01',
|
||||
serviceEnd: '2026-12-31',
|
||||
handoverDate: '2026-07-05',
|
||||
handoverPlace: '深圳坪山运维基地',
|
||||
pricingSummary: '线路计价 · 元/趟',
|
||||
unitPriceDefault: 1800,
|
||||
status: 'active',
|
||||
attachmentName: '自营服务合同-南山短驳.pdf',
|
||||
createdAt: '2026-06-28 10:00',
|
||||
activatedAt: '2026-06-28 15:20',
|
||||
remark: '种子合同:已生效,可创建调度任务',
|
||||
},
|
||||
{
|
||||
id: 'soc-seed-002',
|
||||
contractCode: 'ZY-2026-0002',
|
||||
customerName: '广州港务运输集团',
|
||||
businessName: '南沙港区接驳',
|
||||
vehicleType: '49T 氢能重卡',
|
||||
vehicleCount: 1,
|
||||
serviceStart: '2026-08-01',
|
||||
serviceEnd: '2027-01-31',
|
||||
handoverDate: '2026-08-03',
|
||||
handoverPlace: '广州南沙停车场',
|
||||
pricingSummary: '线路计价 · 元/趟',
|
||||
unitPriceDefault: 2200,
|
||||
status: 'draft',
|
||||
attachmentName: '',
|
||||
createdAt: '2026-07-10 09:30',
|
||||
activatedAt: '',
|
||||
remark: '草稿:需业务确认生效',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function loadContracts() {
|
||||
const stored = readJson(SO_CONTRACT_STORAGE_KEY, null);
|
||||
if (Array.isArray(stored) && stored.length > 0) return stored;
|
||||
const seed = seedContracts();
|
||||
writeJson(SO_CONTRACT_STORAGE_KEY, seed);
|
||||
return seed;
|
||||
}
|
||||
|
||||
export function saveContracts(list) {
|
||||
writeJson(SO_CONTRACT_STORAGE_KEY, list || []);
|
||||
}
|
||||
|
||||
export function getContractById(id) {
|
||||
return loadContracts().find((c) => c.id === id) || null;
|
||||
}
|
||||
|
||||
export function getActiveContracts() {
|
||||
return loadContracts().filter((c) => c.status === 'active' || c.status === 'performing');
|
||||
}
|
||||
|
||||
export function createContract(payload) {
|
||||
const list = loadContracts();
|
||||
const n = list.length + 1;
|
||||
const row = {
|
||||
id: `soc-${Date.now()}`,
|
||||
contractCode: payload.contractCode || `ZY-2026-${String(n).padStart(4, '0')}`,
|
||||
customerName: String(payload.customerName || '').trim(),
|
||||
businessName: String(payload.businessName || '').trim(),
|
||||
vehicleType: String(payload.vehicleType || '').trim(),
|
||||
vehicleCount: Number(payload.vehicleCount) || 1,
|
||||
serviceStart: payload.serviceStart || '',
|
||||
serviceEnd: payload.serviceEnd || '',
|
||||
handoverDate: payload.handoverDate || '',
|
||||
handoverPlace: payload.handoverPlace || '',
|
||||
pricingSummary: payload.pricingSummary || '线路计价',
|
||||
unitPriceDefault: Number(payload.unitPriceDefault) || 0,
|
||||
status: 'draft',
|
||||
attachmentName: payload.attachmentName || '',
|
||||
createdAt: nowStamp(),
|
||||
activatedAt: '',
|
||||
remark: payload.remark || '',
|
||||
};
|
||||
list.unshift(row);
|
||||
saveContracts(list);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* 草稿 → 生效;进入可派车;标记可发起交车(原型 toast/handoverHint)
|
||||
*/
|
||||
export function activateContract(contractId) {
|
||||
const list = loadContracts();
|
||||
const idx = list.findIndex((c) => c.id === contractId);
|
||||
if (idx < 0) return { ok: false, message: '合同不存在' };
|
||||
const c = list[idx];
|
||||
if (c.status !== 'draft') {
|
||||
return { ok: false, message: `当前状态「${CONTRACT_STATUS[c.status]?.label || c.status}」不可生效` };
|
||||
}
|
||||
list[idx] = {
|
||||
...c,
|
||||
status: 'active',
|
||||
activatedAt: nowStamp(),
|
||||
};
|
||||
saveContracts(list);
|
||||
return {
|
||||
ok: true,
|
||||
contract: list[idx],
|
||||
message: `合同 ${c.contractCode} 已生效,可创建调度任务;交车请在调度侧「模拟交车」或运维交车办理`,
|
||||
handoverHint: {
|
||||
contractCode: c.contractCode,
|
||||
handoverDate: c.handoverDate,
|
||||
handoverPlace: c.handoverPlace,
|
||||
vehicleCount: c.vehicleCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function endContract(contractId) {
|
||||
const list = loadContracts();
|
||||
const idx = list.findIndex((c) => c.id === contractId);
|
||||
if (idx < 0) return { ok: false, message: '合同不存在' };
|
||||
const open = loadDispatchTasks().filter(
|
||||
(t) => t.contractId === contractId && !['completed', 'cancelled'].includes(t.status),
|
||||
);
|
||||
if (open.length > 0) {
|
||||
return { ok: false, message: `仍有 ${open.length} 条未办结调度任务,无法结束合同` };
|
||||
}
|
||||
list[idx] = { ...list[idx], status: 'ended' };
|
||||
saveContracts(list);
|
||||
return { ok: true, message: `合同 ${list[idx].contractCode} 已结束` };
|
||||
}
|
||||
|
||||
// —— 调度任务 ——
|
||||
|
||||
function seedDispatchTasks() {
|
||||
return [
|
||||
{
|
||||
id: 'sdt-seed-001',
|
||||
taskCode: 'DD-2026-0001',
|
||||
contractId: 'soc-seed-001',
|
||||
contractCode: 'ZY-2026-0001',
|
||||
businessName: '深圳南山短驳项目',
|
||||
customerName: '深圳城配物流有限公司',
|
||||
planDate: '2026-07-18',
|
||||
routeDesc: '坪山基地 → 南山仓 A',
|
||||
multiTrip: '否',
|
||||
routePricing: '线路计价',
|
||||
unitPrice: 1800,
|
||||
quantity: 1,
|
||||
plateNo: '粤B·H2002',
|
||||
driver: '陈志强',
|
||||
phone: '13800138001',
|
||||
status: 'assigned',
|
||||
salary: 350,
|
||||
remark: '种子任务:车辆已在自营态,可直接办结演示自动台账',
|
||||
createdAt: '2026-07-15 11:00',
|
||||
assignedAt: '2026-07-15 11:05',
|
||||
completedAt: '',
|
||||
ledgerRowId: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function loadDispatchTasks() {
|
||||
const stored = readJson(SO_DISPATCH_STORAGE_KEY, null);
|
||||
if (Array.isArray(stored) && stored.length > 0) return stored;
|
||||
const seed = seedDispatchTasks();
|
||||
writeJson(SO_DISPATCH_STORAGE_KEY, seed);
|
||||
return seed;
|
||||
}
|
||||
|
||||
export function saveDispatchTasks(list) {
|
||||
writeJson(SO_DISPATCH_STORAGE_KEY, list || []);
|
||||
}
|
||||
|
||||
export function createDispatchTask(payload) {
|
||||
const contract = getContractById(payload.contractId);
|
||||
if (!contract) return { ok: false, message: '请选择自营合同' };
|
||||
if (!['active', 'performing'].includes(contract.status)) {
|
||||
return { ok: false, message: '仅生效/履约中的合同可创建调度任务' };
|
||||
}
|
||||
const list = loadDispatchTasks();
|
||||
const n = list.length + 1;
|
||||
const plateNo = String(payload.plateNo || '').trim();
|
||||
const hasPlate = Boolean(plateNo);
|
||||
const row = {
|
||||
id: `sdt-${Date.now()}`,
|
||||
taskCode: `DD-2026-${String(n).padStart(4, '0')}`,
|
||||
contractId: contract.id,
|
||||
contractCode: contract.contractCode,
|
||||
businessName: contract.businessName,
|
||||
customerName: contract.customerName,
|
||||
planDate: payload.planDate || todayDate(),
|
||||
routeDesc: String(payload.routeDesc || '').trim(),
|
||||
multiTrip: payload.multiTrip === '是' ? '是' : '否',
|
||||
routePricing: payload.routePricing || contract.pricingSummary || '线路计价',
|
||||
unitPrice: Number(payload.unitPrice ?? contract.unitPriceDefault) || 0,
|
||||
quantity: Number(payload.quantity) || 1,
|
||||
plateNo,
|
||||
driver: String(payload.driver || '').trim(),
|
||||
phone: String(payload.phone || '').trim(),
|
||||
status: hasPlate ? 'assigned' : 'pending_assign',
|
||||
salary: Number(payload.salary) || 0,
|
||||
remark: String(payload.remark || '').trim(),
|
||||
createdAt: nowStamp(),
|
||||
assignedAt: hasPlate ? nowStamp() : '',
|
||||
completedAt: '',
|
||||
ledgerRowId: '',
|
||||
};
|
||||
list.unshift(row);
|
||||
saveDispatchTasks(list);
|
||||
if (contract.status === 'active') {
|
||||
const contracts = loadContracts();
|
||||
const cidx = contracts.findIndex((c) => c.id === contract.id);
|
||||
if (cidx >= 0) {
|
||||
contracts[cidx] = { ...contracts[cidx], status: 'performing' };
|
||||
saveContracts(contracts);
|
||||
}
|
||||
}
|
||||
return { ok: true, task: row, message: `已创建调度任务 ${row.taskCode}` };
|
||||
}
|
||||
|
||||
export function assignDispatchTask(taskId, payload) {
|
||||
const list = loadDispatchTasks();
|
||||
const idx = list.findIndex((t) => t.id === taskId);
|
||||
if (idx < 0) return { ok: false, message: '任务不存在' };
|
||||
const t = list[idx];
|
||||
if (['completed', 'cancelled'].includes(t.status)) {
|
||||
return { ok: false, message: '已办结/已取消任务不可改派' };
|
||||
}
|
||||
const plateNo = String(payload.plateNo || '').trim();
|
||||
if (!plateNo) return { ok: false, message: '请选择车牌' };
|
||||
list[idx] = {
|
||||
...t,
|
||||
plateNo,
|
||||
driver: String(payload.driver || t.driver || '').trim(),
|
||||
phone: String(payload.phone || t.phone || '').trim(),
|
||||
status: 'assigned',
|
||||
assignedAt: nowStamp(),
|
||||
};
|
||||
saveDispatchTasks(list);
|
||||
return { ok: true, task: list[idx], message: `已派车 ${plateNo}` };
|
||||
}
|
||||
|
||||
export function startDispatchTask(taskId) {
|
||||
const list = loadDispatchTasks();
|
||||
const idx = list.findIndex((t) => t.id === taskId);
|
||||
if (idx < 0) return { ok: false, message: '任务不存在' };
|
||||
const t = list[idx];
|
||||
if (t.status !== 'assigned') {
|
||||
return { ok: false, message: '仅「已派」任务可开始执行' };
|
||||
}
|
||||
if (!t.plateNo) return { ok: false, message: '请先派车' };
|
||||
list[idx] = { ...t, status: 'in_progress' };
|
||||
saveDispatchTasks(list);
|
||||
return { ok: true, task: list[idx], message: '任务已进入执行中' };
|
||||
}
|
||||
|
||||
export function cancelDispatchTask(taskId) {
|
||||
const list = loadDispatchTasks();
|
||||
const idx = list.findIndex((t) => t.id === taskId);
|
||||
if (idx < 0) return { ok: false, message: '任务不存在' };
|
||||
const t = list[idx];
|
||||
if (t.status === 'completed') {
|
||||
return { ok: false, message: '已办结任务不可取消(台账行已生成)' };
|
||||
}
|
||||
if (t.status === 'cancelled') return { ok: true, task: t, message: '任务已是取消状态' };
|
||||
list[idx] = { ...t, status: 'cancelled' };
|
||||
saveDispatchTasks(list);
|
||||
return { ok: true, task: list[idx], message: `已取消 ${t.taskCode}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* 办结 → 自动生成台账行(严格:车辆须已交车)
|
||||
*/
|
||||
export function completeDispatchTask(taskId, confirmPayload) {
|
||||
const list = loadDispatchTasks();
|
||||
const idx = list.findIndex((t) => t.id === taskId);
|
||||
if (idx < 0) return { ok: false, message: '任务不存在' };
|
||||
const t = list[idx];
|
||||
if (t.status === 'completed') {
|
||||
return { ok: false, message: '任务已办结', ledgerRowId: t.ledgerRowId };
|
||||
}
|
||||
if (t.status === 'cancelled') {
|
||||
return { ok: false, message: '已取消任务不可办结' };
|
||||
}
|
||||
if (!t.plateNo) return { ok: false, message: '请先派车再办结' };
|
||||
|
||||
const ops = getVehicleOpsStatus(t.plateNo);
|
||||
if (ops !== VEHICLE_OPS.self_operated) {
|
||||
return {
|
||||
ok: false,
|
||||
code: 'NEED_HANDOVER',
|
||||
message: `${t.plateNo} 尚未交车(当前非自营运营态),请先「模拟交车」或完成运维交车后再办结`,
|
||||
};
|
||||
}
|
||||
|
||||
const dispatchDate = (confirmPayload && confirmPayload.dispatchDate) || t.planDate || todayDate();
|
||||
const quantity = Number(confirmPayload?.quantity ?? t.quantity) || 1;
|
||||
const unitPrice = Number(confirmPayload?.unitPrice ?? t.unitPrice) || 0;
|
||||
const salary = Number(confirmPayload?.salary ?? t.salary) || 0;
|
||||
const remark = String(confirmPayload?.remark ?? t.remark ?? '').trim();
|
||||
|
||||
const year = dispatchDate.slice(0, 4);
|
||||
const month = String(Number(dispatchDate.slice(5, 7)) || 1);
|
||||
const amount = computeAmount(unitPrice, quantity);
|
||||
const ledgerDraft = {
|
||||
id: `auto-${t.id}`,
|
||||
year,
|
||||
month,
|
||||
dispatchDate,
|
||||
businessName: t.businessName,
|
||||
driver: t.driver,
|
||||
phone: t.phone,
|
||||
plateNo: t.plateNo,
|
||||
systemVehicleType: brandModelOf(t.plateNo),
|
||||
unitPrice,
|
||||
quantity,
|
||||
amount,
|
||||
hydrogenFee: 0,
|
||||
etcFee: 0,
|
||||
salary,
|
||||
electricityFee: 0,
|
||||
manualReimbursement: 0,
|
||||
dailySocialSecurityFee: 0,
|
||||
dailyTrailerServiceFee: 0,
|
||||
dailyTrailerFee: 0,
|
||||
dailyParkingFee: 0,
|
||||
dailyTireFee: 0,
|
||||
vehicleFee: 0,
|
||||
totalCost: 0,
|
||||
profitLoss: 0,
|
||||
multiTrip: t.multiTrip || '否',
|
||||
routePricing: t.routePricing || '线路计价',
|
||||
remark: remark ? `[调度自动·${t.taskCode}] ${remark}` : `[调度自动·${t.taskCode}]`,
|
||||
rowSource: 'auto',
|
||||
dispatchTaskId: t.id,
|
||||
dispatchTaskCode: t.taskCode,
|
||||
contractCode: t.contractCode,
|
||||
};
|
||||
ledgerDraft.totalCost = computeTotalCost(ledgerDraft);
|
||||
ledgerDraft.profitLoss = roundMoney(amount - ledgerDraft.totalCost);
|
||||
|
||||
const autoRows = loadAutoLedgerRows();
|
||||
const withoutDup = autoRows.filter((r) => r.dispatchTaskId !== t.id && r.id !== ledgerDraft.id);
|
||||
withoutDup.unshift(ledgerDraft);
|
||||
saveAutoLedgerRows(withoutDup);
|
||||
|
||||
list[idx] = {
|
||||
...t,
|
||||
status: 'completed',
|
||||
quantity,
|
||||
unitPrice,
|
||||
salary,
|
||||
remark,
|
||||
completedAt: nowStamp(),
|
||||
ledgerRowId: ledgerDraft.id,
|
||||
};
|
||||
saveDispatchTasks(list);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
task: list[idx],
|
||||
ledgerRow: ledgerDraft,
|
||||
message: `已办结 ${t.taskCode},并自动生成物流业务明细 1 行`,
|
||||
};
|
||||
}
|
||||
|
||||
// —— 台账自动行 ——
|
||||
|
||||
export function loadAutoLedgerRows() {
|
||||
const stored = readJson(SO_LEDGER_AUTO_STORAGE_KEY, null);
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
}
|
||||
|
||||
export function saveAutoLedgerRows(list) {
|
||||
writeJson(SO_LEDGER_AUTO_STORAGE_KEY, list || []);
|
||||
}
|
||||
|
||||
export function removeAutoLedgerRow(rowId) {
|
||||
const next = loadAutoLedgerRows().filter((r) => r.id !== rowId);
|
||||
saveAutoLedgerRows(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并种子/当前列表与自动生成行:自动行按 id 覆盖同 id,其余追加到顶部
|
||||
*/
|
||||
export function mergeLedgerWithAutoRows(baseRows) {
|
||||
const auto = loadAutoLedgerRows();
|
||||
const autoIds = new Set(auto.map((r) => r.id));
|
||||
const rest = (baseRows || []).filter((r) => !autoIds.has(r.id));
|
||||
return [...auto, ...rest];
|
||||
}
|
||||
|
||||
export function subscribeSelfOperatedBridge(handler) {
|
||||
const fn = (e) => handler(e?.detail);
|
||||
window.addEventListener('oneos-self-operated-bridge', fn);
|
||||
window.addEventListener('storage', fn);
|
||||
return () => {
|
||||
window.removeEventListener('oneos-self-operated-bridge', fn);
|
||||
window.removeEventListener('storage', fn);
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
# 业务部台账 · 产品需求说明(PRD)
|
||||
|
||||
> 原型路径:`/prototypes/business-dept-ledger`
|
||||
> 实现页:`src/prototypes/oneos-web-data-analysis/pages/05-业务部台账.jsx`
|
||||
|
||||
---
|
||||
|
||||
## 1. 模块定位
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 目标用户 | 业务部业管 / 运营查看汇总 |
|
||||
| 核心任务 | 按业务条线汇总查看业绩、成本、盈亏,并支持租赁/氢费下钻 |
|
||||
| 内容来源 | 各业务明细模块按月汇总(原型本地种子,未接真实 API) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 各业务条线数据统计来源(摘要)
|
||||
|
||||
主表分组表头在业务名称后方标注最小颗粒度来源;悬停可查看业绩/成本/盈亏字段口径。
|
||||
|
||||
| 业务条线 | 最小颗粒度 | 业绩 | 成本 | 盈亏 |
|
||||
|---|---|---|---|---|
|
||||
| 物流业务 | 物流业务明细 | 「金额」列 | 「总成本」列 | 金额 − 总成本 |
|
||||
| 租赁业务 | 租赁业务明细 | 「实收金额」列 | 「车辆实际成本」列 | 实收金额 − 车辆实际成本 |
|
||||
| 氢费业务 | 车辆氢费明细 | 「加氢总价(元)」列 | 「成本总价(元)」列 | 加氢总价 − 成本总价 |
|
||||
| 电费业务 | 充电订单 | 「对客费用(元)」列 | 「成本费用(元)」列 | 对客费用 − 成本费用 |
|
||||
| ETC业务 | ETC业务 | 字段口径待补充 | 字段口径待补充 | 字段口径待补充 |
|
||||
|
||||
完整判定、前置条件与代码映射见 [sector-data-source.md](./sector-data-source.md)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 验收重点
|
||||
|
||||
- [ ] 主表五条业务线后方分别显示:`物流业务明细` / `租赁业务明细` / `车辆氢费明细` / `充电订单` / `ETC业务`(不再显示「预留明细」)
|
||||
- [ ] 悬停业务名称或来源标注,可看到该条线最小颗粒度与业绩/成本/盈亏口径
|
||||
- [ ] 悬停「业绩」「成本」「盈亏」列头,提示对应明细字段
|
||||
- [ ] 电费来源为「充电订单」;ETC 来源为「ETC业务」
|
||||
- [ ] 文档与页面口径一致:`.spec/sector-data-source.md`、本 PRD
|
||||
@@ -0,0 +1,60 @@
|
||||
# 业务部台账 · 各业务条线数据统计来源
|
||||
|
||||
> 实现:`src/prototypes/oneos-web-data-analysis/pages/05-业务部台账.jsx`
|
||||
> 常量:`SECTOR_DATA_SOURCE`、`SECTOR_METRIC_SOURCE`
|
||||
> UI:主表分组表头「业务名称 ← 明细来源」;业绩/成本/盈亏列悬停展示字段口径
|
||||
|
||||
## 对照数据源
|
||||
|
||||
| 业务条线 | 最小颗粒度数据来源 | 原型本地种子 | 真实 API |
|
||||
|---|---|---|---|
|
||||
| 物流业务 | 物流业务明细 | 是(汇总种子) | 未接 |
|
||||
| 租赁业务 | 租赁业务明细 | 是(汇总种子) | 未接 |
|
||||
| 氢费业务 | 车辆氢费明细 | 是(汇总种子) | 未接 |
|
||||
| 电费业务 | 充电订单 | 是(汇总种子) | 未接 |
|
||||
| ETC业务 | ETC业务 | 是(汇总种子) | 未接 |
|
||||
|
||||
主表当前展示的是**按月(或客户/事业部)汇总后的结果**;上表「最小颗粒度」指最终应回溯到的明细模块。
|
||||
|
||||
## 业绩 / 成本 / 盈亏口径(判定顺序)
|
||||
|
||||
按业务条线取数;同一条线内先取业绩、成本字段,再计算盈亏。
|
||||
|
||||
| 优先级 | 指标 | 规则 |
|
||||
|---|---|---|
|
||||
| 1 | 业绩 | 取对应明细模块指定金额列(见下表) |
|
||||
| 2 | 成本 | 取对应明细模块指定成本列(见下表) |
|
||||
| 3 | 盈亏 | 业绩 − 成本(见下表公式) |
|
||||
|
||||
### 分条线字段映射
|
||||
|
||||
| 业务条线 | 业绩来源列 | 成本来源列 | 盈亏公式 |
|
||||
|---|---|---|---|
|
||||
| 物流业务 | 物流业务明细「金额」 | 物流业务明细「总成本」 | 金额 − 总成本 |
|
||||
| 租赁业务 | 租赁业务明细「实收金额」 | 租赁业务明细「车辆实际成本」 | 实收金额 − 车辆实际成本 |
|
||||
| 氢费业务 | 车辆氢费明细「加氢总价(元)」 | 车辆氢费明细「成本总价(元)」 | 加氢总价(元)− 成本总价(元) |
|
||||
| 电费业务 | 充电订单「对客费用(元)」 | 充电订单「成本费用(元)」 | 对客费用(元)− 成本费用(元) |
|
||||
| ETC业务 | ETC业务(字段口径待补充) | ETC业务(字段口径待补充) | ETC业务(字段口径待补充) |
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 仅在业务部台账主表分组列展示来源标注;钻取子页「数据来源」仍使用 `SECTOR_DATA_SOURCE` 短名。
|
||||
- ETC 业务本期仅确认最小颗粒度模块为「ETC业务」,业绩/成本/盈亏字段列名待业务补充后更新本规格与代码常量。
|
||||
|
||||
## 用户可见结果
|
||||
|
||||
| 位置 | 展示 |
|
||||
|---|---|
|
||||
| 分组表头业务名称后方 | `←物流业务明细` / `←租赁业务明细` / `←车辆氢费明细` / `←充电订单` / `←ETC业务` |
|
||||
| 悬停业务名称或来源标注 | 最小颗粒度 + 业绩/成本/盈亏口径全文 |
|
||||
| 悬停「业绩」「成本」「盈亏」列头 | 该列对应字段口径 |
|
||||
|
||||
## 与代码映射
|
||||
|
||||
| 常量 / 函数 | 作用 |
|
||||
|---|---|
|
||||
| `SECTOR_DATA_SOURCE` | 表头后方短名;钻取页数据来源短名 |
|
||||
| `SECTOR_METRIC_SOURCE` | 业绩/成本/盈亏字段口径 |
|
||||
| `buildSectorSourceTooltip` | 分组表头悬停全文 |
|
||||
| `renderSectorMetricTitle` | 指标列头悬停 |
|
||||
| `renderSectorGroupTitle` | 渲染「业务名 ← 来源」 |
|
||||
69
src/prototypes/business-dept-ledger/annotation-source.json
Normal file
69
src/prototypes/business-dept-ledger/annotation-source.json
Normal file
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"documentVersion": 1,
|
||||
"format": "axhub-annotation-source",
|
||||
"data": {
|
||||
"version": 2,
|
||||
"prototypeName": "business-dept-ledger",
|
||||
"pageId": "main",
|
||||
"updatedAt": 1783938000000,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "bdl-list-table",
|
||||
"index": 1,
|
||||
"title": "主表 · 业务条线数据来源",
|
||||
"pageId": "main",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
"[data-annotation-id=\"bdl-list-table\"]"
|
||||
],
|
||||
"fingerprint": "bdl-list-table",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "业务部台账主表各业务条线业绩/成本/盈亏数据来源口径。",
|
||||
"annotationText": "## 各业务条线数据统计来源\n\n分组表头业务名称后方标注最小颗粒度来源;悬停查看业绩/成本/盈亏字段口径。\n\n| 业务条线 | 最小颗粒度 | 业绩 | 成本 | 盈亏 |\n|---|---|---|---|---|\n| 物流业务 | 物流业务明细 | 金额 | 总成本 | 金额 − 总成本 |\n| 租赁业务 | 租赁业务明细 | 实收金额 | 车辆实际成本 | 实收金额 − 车辆实际成本 |\n| 氢费业务 | 车辆氢费明细 | 加氢总价(元) | 成本总价(元) | 加氢总价 − 成本总价 |\n| 电费业务 | 充电订单 | 对客费用(元) | 成本费用(元) | 对客费用 − 成本费用 |\n| ETC业务 | ETC业务 | 待补充 | 待补充 | 待补充 |\n\n完整规格见 `.spec/sector-data-source.md`。原型本地种子,未接真实 API。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
"createdAt": 1783938000000,
|
||||
"updatedAt": 1783938000000
|
||||
}
|
||||
],
|
||||
"notes": {
|
||||
"bdl-list-table": "## 各业务条线数据统计来源\n\n分组表头业务名称后方标注最小颗粒度来源;悬停查看业绩/成本/盈亏字段口径。\n\n| 业务条线 | 最小颗粒度 | 业绩 | 成本 | 盈亏 |\n|---|---|---|---|---|\n| 物流业务 | 物流业务明细 | 金额 | 总成本 | 金额 − 总成本 |\n| 租赁业务 | 租赁业务明细 | 实收金额 | 车辆实际成本 | 实收金额 − 车辆实际成本 |\n| 氢费业务 | 车辆氢费明细 | 加氢总价(元) | 成本总价(元) | 加氢总价 − 成本总价 |\n| 电费业务 | 充电订单 | 对客费用(元) | 成本费用(元) | 对客费用 − 成本费用 |\n| ETC业务 | ETC业务 | 待补充 | 待补充 | 待补充 |\n\n完整规格见 `.spec/sector-data-source.md`。原型本地种子,未接真实 API。",
|
||||
"bdl-doc-sector-data-source": "# 业务部台账 · 各业务条线数据统计来源\n\n见 `.spec/sector-data-source.md`。"
|
||||
},
|
||||
"directory": [
|
||||
{
|
||||
"type": "group",
|
||||
"id": "bdl-dir-logic",
|
||||
"title": "业务逻辑规格",
|
||||
"children": [
|
||||
{
|
||||
"type": "note",
|
||||
"id": "bdl-doc-sector-data-source",
|
||||
"title": "各业务条线数据统计来源",
|
||||
"markdownPath": ".spec/sector-data-source.md"
|
||||
},
|
||||
{
|
||||
"type": "note",
|
||||
"id": "bdl-doc-prd",
|
||||
"title": "产品需求说明(PRD)",
|
||||
"markdownPath": ".spec/requirements-prd.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "group",
|
||||
"id": "bdl-dir-page",
|
||||
"title": "页面",
|
||||
"children": [
|
||||
{
|
||||
"type": "annotation",
|
||||
"id": "bdl-list-table",
|
||||
"title": "主表 · 业务条线数据来源"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
376
src/prototypes/insurance-procurement/.spec/requirements-prd.md
Normal file
376
src/prototypes/insurance-procurement/.spec/requirements-prd.md
Normal file
@@ -0,0 +1,376 @@
|
||||
# 保险采购 · 产品需求说明(全模块)
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| 模块名称 | 保险采购 |
|
||||
| 所属系统 | ONE-OS(PC) |
|
||||
| 交互原型 | `/prototypes/insurance-procurement` |
|
||||
| 业务条线 | 运维管理条线(证照 / 保险相关) |
|
||||
| 文档状态 | AutoPRD · 已对齐可运行原型 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 一句话与目标
|
||||
|
||||
**一句话**
|
||||
在同一模块内管理车队**保单台账**与**采购前比价审批**两条业务线,保证续保不遗漏、变更可追溯,并为交车提供交强险 + 商业险合规校验。
|
||||
|
||||
**要解决的问题**
|
||||
- 保险信息散落在表格、邮件与线下批单中,台账不全、临期易漏
|
||||
- 停保 / 复驶 / 退保缺少统一留痕
|
||||
- 采购前多方比价难追溯,审批结果难跟踪
|
||||
- 交车时无法与车辆管理共用同一套「保险是否有效」口径
|
||||
|
||||
**本期目标**
|
||||
1. 一车一档维护五类险种台账;支持手工新增、批量识别、批量导入
|
||||
2. 支持停保 / 复驶 / 退保办理与操作历史
|
||||
3. 输出与车辆管理一致的车辆级保险状态,支撑交车拦截
|
||||
4. 比价单批次管理:多方报价、最晚付费预警、勾选提交采购审批(本页只读跟踪)
|
||||
|
||||
**非目标(本期不做)**
|
||||
- 比价单与保单台账**自动双向同步**;审批通过后**不自动写入**正式保单
|
||||
- 在本模块内办理审批(通过 / 驳回 / 撤回均在审批中心)
|
||||
- 维护行驶证、道路运输证等证照档案(见证照管理)
|
||||
- 事故赔付、保险上浮等事故链路台账(见事故管理)
|
||||
|
||||
---
|
||||
|
||||
## 2. 模块边界(最重要)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
IPC[保险采购]
|
||||
VM[车辆管理]
|
||||
OPS[交车管理]
|
||||
APPR[审批中心]
|
||||
CERT[证照管理]
|
||||
WB[工作台]
|
||||
IPC -->|输出保险状态口径| VM
|
||||
VM -->|交车前置校验| OPS
|
||||
CERT -.->|并列交车前置| OPS
|
||||
IPC -->|发起采购审批| APPR
|
||||
APPR -->|回写采购状态| IPC
|
||||
WB -->|临期 KPI / 快捷入口| IPC
|
||||
```
|
||||
|
||||
| 业务线 / 子域 | 做什么 | 不做什么 |
|
||||
|---------------|--------|----------|
|
||||
| 保单管理 | 一车一档、多通道录入、停保/复驶/退保、状态与交车口径 | 不替代证照管理;不办事故赔付 |
|
||||
| 比价单 | 批次比价、付费预警、提交采购、只读跟踪审批 | 不自动生成正式保单;不在本页审批 |
|
||||
| 与车辆 / 交车 | 提供交强 + 商业是否有效的统一口径 | 不在本页完成交车动作 |
|
||||
|
||||
**外部依赖(产品口径)**
|
||||
|
||||
| 依赖方 | 交互方式 | 产品要求 |
|
||||
|--------|----------|----------|
|
||||
| 车辆管理 | 读取保险状态 / 到期日 | 口径与本模块一致:异常则禁止交车 |
|
||||
| 交车管理 | 交付前校验交强 + 商业有效 | 不合规须可理解拦截原因 |
|
||||
| 审批中心 | 接收采购申请并回写状态 / 当前审批人 | 本页只读展示,不办理审批 |
|
||||
| 工作台 | KPI、快捷「发起保险采购」 | 跳转至本模块对应能力 |
|
||||
| 证照管理 | 并列交车前置 | 证照与保险各自独立维护 |
|
||||
|
||||
**关键约束**
|
||||
- **保单台账 ↔ 比价单互不自动同步**;审批通过后须运营在保单管理侧另行录入正式保单
|
||||
- **交车只看交强险 + 商业险**;超赔 / 货物 / 驾意仅参与临期预警,不参与交车判定
|
||||
- **最晚付费临期 / 超期**仅服务比价采购节奏,不写入保单台账
|
||||
|
||||
---
|
||||
|
||||
## 3. 用户与角色
|
||||
|
||||
| 角色 | 主要目标 |
|
||||
|------|----------|
|
||||
| 运维部 | 维护保单台账、办理停保/复驶/退保;交车前确认保险有效 |
|
||||
| 安全部 | 从合规视角关注保险与证照并列的交车前置 |
|
||||
| 采购部 | 创建比价单、录入报价、提交采购申请;关注临期与付费节奏 |
|
||||
| 业务管理组(业管) | 在审批中心确认报价等节点(本页只读看到结果) |
|
||||
|
||||
> 部门名对齐业务条线说明;`module-role-mapping` 将本模块主责记为运维部、安全部,采购部为比价线主操作用户。
|
||||
|
||||
---
|
||||
|
||||
## 4. 用户故事与故事点(业务条线说明口径)
|
||||
|
||||
> 故事点供排期参考,可按团队基准调整。合计约 **28 SP**。
|
||||
|
||||
### Epic A · 保单台账与交车合规(约 15 SP)
|
||||
|
||||
#### A1 · 一车一档台账与检索(约 3 SP)
|
||||
- **角色**:运维部、采购部
|
||||
- **起点**:需要按车牌 / VIN / 运营状态 / 保险状态等快速定位车辆保险档案。
|
||||
- **怎么运作**:
|
||||
1. 在列表筛选或点击 KPI 卡片缩小范围。
|
||||
2. 查看五类险种到期日与车辆级保险状态。
|
||||
3. 进入「管理」打开车辆保险档案。
|
||||
- **关键结果**:`可按条件定位车辆` / `空态提示无可匹配台账`
|
||||
- **闭环**:台账可检索、可下钻,与 KPI 口径一致。
|
||||
- **排期(可选)**:US-IPC-01 · 规模 M · 作为运维/采购,我想快速筛出台账车辆,以便处理续保与异常。
|
||||
|
||||
#### A2 · 多通道录入正式保单(约 5 SP)
|
||||
- **角色**:运维部、采购部
|
||||
- **起点**:拿到新保或续保材料(纸质 / 电子保单、批量表格)。
|
||||
- **怎么运作**:
|
||||
1. 选择新增、保单批量识别或批量导入。
|
||||
2. 识别类须人工确认后再落库;导入仅处理新保/续保。
|
||||
3. 按车牌/VIN(或批单场景按保单号)匹配车辆并写入对应险种台账。
|
||||
- **关键结果**:`确认后落库` / `识别失败可理解提示` / `车牌不一致则拦截`
|
||||
- **闭环**:正式保单进入一车一档,列表与车辆管理可见最新到期与状态。
|
||||
- **排期(可选)**:US-IPC-02 · 规模 L · 作为运维/采购,我想用识别或导入补齐台账,以便续保不遗漏。
|
||||
|
||||
#### A3 · 停保 / 复驶 / 退保留痕(约 4 SP)
|
||||
- **角色**:运维部
|
||||
- **起点**:车辆需临时停保、恢复行驶或办理退保。
|
||||
- **怎么运作**:
|
||||
1. 在车辆保险档案对**当前有效**记录发起对应办理。
|
||||
2. 填写时间、金额等业务字段并上传批单附件。
|
||||
3. 写入操作历史;更新险种与车辆状态展示。
|
||||
- **关键结果**:`变更可追溯` / `归档记录只读`
|
||||
- **闭环**:停保/复驶/退保全程留痕,交车口径随之更新。
|
||||
- **排期(可选)**:US-IPC-03 · 规模 M。
|
||||
|
||||
#### A4 · 交强 + 商业决定是否可交车(约 3 SP)
|
||||
- **角色**:运维部、采购部
|
||||
- **起点**:采购/运维维护交强险与商业险台账,作为交车前置合规数据。
|
||||
- **怎么运作**:
|
||||
1. 台账维护一车一档核心险种。
|
||||
2. 交车时按「交强 + 商业均为正常或临期」判定是否放行。
|
||||
3. 异常(未购买 / 已到期 / 已退保视同无效)则拦截并提示原因。
|
||||
- **关键结果**:`允许交车` / `禁止交车 · 保险异常`
|
||||
- **闭环**:交车合规与台账一致、可追溯;异常车辆不能进入履约运营。
|
||||
- **排期(可选)**:US-17 · 规模 M · 作为采购/运维,我想维护交强+商业险并在交车时校验,以便不合规车辆无法交车。
|
||||
|
||||
### Epic B · 比价采购与审批跟踪(约 13 SP)
|
||||
|
||||
#### B1 · 比价单批次与付费预警(约 4 SP)
|
||||
- **角色**:采购部
|
||||
- **起点**:需要按批次组织待购车辆与险种,并盯住最晚付费日。
|
||||
- **怎么运作**:
|
||||
1. 在比价单管理新建或编辑批次;可从临期预警一键带入。
|
||||
2. 用全部 / 临期(≤3 天)/ 超期看板筛选批次。
|
||||
3. 保存时校验购买记录、备注与附件。
|
||||
- **关键结果**:`可按付费节奏跟进` / `未满足保存条件则拦截`
|
||||
- **闭环**:采购节奏可视、可预警;不与保单台账自动混写。
|
||||
- **排期(可选)**:US-IPC-04 · 规模 M。
|
||||
|
||||
#### B2 · 多方报价与提交采购(约 5 SP)
|
||||
- **角色**:采购部
|
||||
- **起点**:同一购买需求需比较多家保司报价后发起采购。
|
||||
- **怎么运作**:
|
||||
1. 为每行录入多家报价并指定唯一最终比价。
|
||||
2. 填写最晚付费日,勾选可提交行。
|
||||
3. 提交采购申请进入审批中心。
|
||||
- **关键结果**:`审批中/已通过行不可再提` / `撤回或驳回后可再提`
|
||||
- **闭环**:比价过程可追溯;正式保单仍须审批通过后在台账侧补录。
|
||||
- **排期(可选)**:US-IPC-05 · 规模 L。
|
||||
|
||||
#### B3 · 审批只读回显(约 4 SP)
|
||||
- **角色**:采购部、业务管理组
|
||||
- **起点**:采购申请已在审批中心流转。
|
||||
- **怎么运作**:
|
||||
1. 本页展示采购状态与当前审批人(可为空)。
|
||||
2. 业管等角色在审批中心办理节点。
|
||||
3. 结果回写本页;通过后运营去保单管理录入正式保单。
|
||||
- **关键结果**:`本页不可办理审批` / `状态与审批中心一致`
|
||||
- **闭环**:采购决策可跟踪;台账仍以人工录入为准。
|
||||
- **排期(可选)**:US-IPC-06 · 规模 M。
|
||||
|
||||
---
|
||||
|
||||
## 5. 功能模块说明(正向 / 逆向)
|
||||
|
||||
### 5.1 保险台账列表与 KPI
|
||||
|
||||
**正向**
|
||||
1. 按车牌、VIN、品牌型号、运营状态、保险状态、险种 + 到期区间筛选。
|
||||
2. 点击 KPI(台账车辆、状态正常、核心逾期、核心待购等)联动列表或打开预警。
|
||||
3. 行内「管理」打开车辆保险档案。
|
||||
|
||||
**逆向 / 边界**
|
||||
|
||||
| 情况 | 系统表现 |
|
||||
|------|----------|
|
||||
| 无匹配数据 | 空态提示无可匹配台账车辆 |
|
||||
| 退出运营车辆 | 列表可查看但样式弱化 |
|
||||
| 险种到期筛选未选险种 | 须先选险种再筛到期区间 |
|
||||
| 车牌与多车牌条件互斥 | 按产品约定互斥,避免条件冲突 |
|
||||
|
||||
### 5.2 保单录入(新增 / 识别 / 导入)
|
||||
|
||||
**正向**
|
||||
1. 新增:完整字段手工录入后保存。
|
||||
2. 批量识别:选业务类型 → 上传 → 识别 → **人工确认** → 落库。
|
||||
3. 批量导入:下载模板填写 → 上传 → 仅新保/续保入库。
|
||||
4. 识别任务记录:查看进度,对待确认结果继续确认。
|
||||
|
||||
**逆向 / 边界**
|
||||
|
||||
| 情况 | 系统表现 |
|
||||
|------|----------|
|
||||
| 识别失败 | 提示清晰度 / 格式 / 损坏等原因 |
|
||||
| 无成功结果可确认 | 提示暂无识别成功结果 |
|
||||
| 识别车牌与所选车不一致 | 拦截并提示两边车牌 |
|
||||
| 导入中的停保/复驶/退保行 | 自动跳过 |
|
||||
| OCR 确认页 | 不展示承保险种明细、分项保费(手工路径仍可填) |
|
||||
|
||||
### 5.3 车辆保险档案 · 停保 / 复驶 / 退保
|
||||
|
||||
**正向**
|
||||
1. 分险种查看历史;对当前有效记录办理停保、复驶或退保。
|
||||
2. 填写业务时间 / 金额等并上传批单附件。
|
||||
3. 全周期时间轴与操作历史可追溯。
|
||||
|
||||
**逆向 / 边界**
|
||||
|
||||
| 情况 | 系统表现 |
|
||||
|------|----------|
|
||||
| 历史归档记录 | 只读,不可再办停保/退保 |
|
||||
| 已退保 | 仅允许复驶 |
|
||||
| 已停保 | 可复驶或退保 |
|
||||
| 新保覆盖已退保险种 | 旧记录归档后再写入新保单 |
|
||||
|
||||
### 5.4 比价单管理与编辑
|
||||
|
||||
**正向**
|
||||
1. 新建 / 编辑比价单,添加购买记录与多方报价。
|
||||
2. 设最终比价、最晚付费日;保存备注与附件。
|
||||
3. 勾选行提交采购;跟踪审批状态。
|
||||
4. 可从险种临期预警一键生成比价单(附件须补传)。
|
||||
|
||||
**逆向 / 边界**
|
||||
|
||||
| 情况 | 系统表现 |
|
||||
|------|----------|
|
||||
| 保存校验不通过 | 拦截并提示缺记录 / 备注 / 附件等 |
|
||||
| 审批中或已通过行 | 勾选禁用,不可重复提交 |
|
||||
| 撤回 / 驳回 | 恢复可提交 |
|
||||
| 修改行内险种 | 清空该行报价与最终比价 |
|
||||
| 删除比价单 | 二次确认,删除后不可恢复 |
|
||||
| 一键生成时已有审批中/通过的同车同险 | 跳过该条 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 关键业务逻辑(必须对齐)
|
||||
|
||||
### 6.1 险种范围
|
||||
|
||||
| 险种 | 台账 | 临期预警 | 交车判定 |
|
||||
|------|------|----------|----------|
|
||||
| 交强险 | 是 | 是 | **是(核心)** |
|
||||
| 商业险 | 是 | 是 | **是(核心)** |
|
||||
| 超赔险 | 是 | 是 | 否 |
|
||||
| 驾意险 | 是 | 是 | 否 |
|
||||
| 货物险 | 是 | 是 | 否 |
|
||||
|
||||
### 6.2 单险种状态(按优先级命中即止)
|
||||
|
||||
1. 已退保
|
||||
2. 无保单号 → 未购买
|
||||
3. 停保标记 → 已停保
|
||||
4. 有号但无到期日 → 未购买
|
||||
5. 到期日 ≤ 今天 → 已到期
|
||||
6. 到期日 ≤ 今天 + 30 天 → 临期
|
||||
7. 否则 → 正常
|
||||
|
||||
列表到期日取同车同险种**最新一条**记录。
|
||||
|
||||
### 6.3 车辆级保险状态(交车联动)
|
||||
|
||||
| 条件 | 车辆保险状态 | 交车 |
|
||||
|------|--------------|------|
|
||||
| 交强与商业均为正常或临期(仍在有效期内) | 正常 / 临期 | **允许**(临期可提示尽快续保) |
|
||||
| 交强或商业任一未购买或已到期 | 异常 | **禁止** |
|
||||
| 已退保 | 视同无有效保障 | **禁止** |
|
||||
|
||||
### 6.4 新保 / 续保录入必填(业务口径)
|
||||
|
||||
- 车牌与车架号至少填一项
|
||||
- 必填:险种、保险公司、保单号、生效日、到期日、保险费合计
|
||||
- 承保险种明细、分项保费:非必填
|
||||
|
||||
### 6.5 批单匹配方式
|
||||
|
||||
| 业务 | 匹配方式 |
|
||||
|------|----------|
|
||||
| 保单录入 | 按车牌 / 车架号匹配车辆 |
|
||||
| 停保 / 复驶 / 退保 | 按**保单号**在全库匹配已有记录 |
|
||||
|
||||
### 6.6 比价保存与提交
|
||||
|
||||
**保存须同时满足**:至少 1 条购买记录;每行已选车辆;整单备注非空;整单至少 1 个附件。
|
||||
|
||||
**提交采购另须**:至少勾选 1 行;行内已有报价并设最终比价;已填最晚付费日;采购状态为未提交 / 撤回 / 驳回;未保存须先保存。
|
||||
|
||||
**报价**:保司 + 金额大于 0;同一保司不可对同行重复报价。
|
||||
|
||||
**最晚付费预警(仅比价)**:≤ 3 个自然日为临期;已过为超期。
|
||||
|
||||
### 6.7 采购状态(购买记录行级)
|
||||
|
||||
| 状态 | 可否再提交 | 说明 |
|
||||
|------|------------|------|
|
||||
| 未提交 | 可以 | 默认 |
|
||||
| 审批中 | 不可以 | 本页提交后写入;勾选禁用 |
|
||||
| 审批通过 | 不可以 | 审批中心回写;勾选禁用 |
|
||||
| 撤回 / 审批驳回 | 可以 | 审批中心回写后可再提 |
|
||||
|
||||
> 审批办理不在本页;当前审批人由工作流回写,可为空。
|
||||
|
||||
---
|
||||
|
||||
## 7. 总览流程图
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
entry[保险采购入口]
|
||||
entry --> policy[保单管理]
|
||||
entry --> compare[比价单]
|
||||
|
||||
policy --> input[新增 / 识别 / 导入]
|
||||
input --> confirm[人工确认落库]
|
||||
confirm --> archive[车辆保险档案]
|
||||
archive --> change[停保 / 复驶 / 退保]
|
||||
archive --> status[更新车辆保险状态]
|
||||
status --> handover[交车校验交强+商业]
|
||||
|
||||
compare --> batch[新建或预警生成批次]
|
||||
batch --> quote[多方报价与最终比价]
|
||||
quote --> save[保存备注与附件]
|
||||
save --> submit[勾选提交采购]
|
||||
submit --> appr[审批中心流转]
|
||||
appr --> readonly[本页只读回显]
|
||||
readonly --> manual[审批通过后人工录入正式保单]
|
||||
manual --> policy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收清单(产品 / 测试)
|
||||
|
||||
**保单台账**
|
||||
- [ ] 筛选与 KPI 口径一致;空态文案正确
|
||||
- [ ] 五类险种独立台账;列表到期日取最新一条
|
||||
- [ ] 新增 / OCR 确认 / 导入(仅新保续保)均可落库
|
||||
- [ ] OCR 失败、车牌不一致有明确拦截
|
||||
- [ ] 停保 / 复驶 / 退保须附件并写入操作历史;归档只读
|
||||
|
||||
**交车合规**
|
||||
- [ ] 仅交强 + 商业决定车辆级状态
|
||||
- [ ] 异常禁止交车;正常/临期允许(临期可提示续保)
|
||||
- [ ] 超赔 / 货物 / 驾意不参与交车判定
|
||||
|
||||
**比价采购**
|
||||
- [ ] 保存 / 提交校验完整
|
||||
- [ ] 最晚付费临期 ≤3 天、超期看板正确
|
||||
- [ ] 审批中 / 通过行不可再提;撤回 / 驳回可再提
|
||||
- [ ] 审批通过**不**自动写入保单台账
|
||||
- [ ] 本页不可办理审批,状态只读回显
|
||||
|
||||
**边界**
|
||||
- [ ] 保单与比价互不自动同步
|
||||
- [ ] 一键生成跳过已审批中/通过的同车同险
|
||||
|
||||
---
|
||||
|
||||
## 9. 交付口径
|
||||
|
||||
> 「保险采购」在 OneOS PC 承载**保单台账**与**比价采购**两条独立业务线:运维部、采购部可一车一档维护交强 / 商业等五类险种并办理停保复驶退保,车辆管理与交车据此校验核心险种有效性;采购部可在比价单中完成多方报价、付费预警与采购审批跟踪,审批通过后须人工补录正式保单——台账与比价互不同步,审批不在本页办理。
|
||||
File diff suppressed because one or more lines are too long
68
src/prototypes/oneos-h5-h2-order/.spec/flows.md
Normal file
68
src/prototypes/oneos-h5-h2-order/.spec/flows.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 流程图
|
||||
|
||||
> 标注面板支持 Mermaid 渲染。下列流程与当前原型行为一致(本地 Store,未接真实 API)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 主路径:从打开到上报
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[打开加氢订单 H5] --> B[查看本站列表与 KPI]
|
||||
B --> C{前一日手工台账已上传?}
|
||||
C -->|否| D[提示:手工台账有缺失…]
|
||||
D --> E[进入手工台账 · 选中昨日]
|
||||
E --> F[拍照/相册上传并确认]
|
||||
F --> C
|
||||
C -->|是| G[点新增]
|
||||
G --> H[填写时间/车牌/面板/量价]
|
||||
H --> I{同站同车牌同时间已存在?}
|
||||
I -->|是| J[拦截提示]
|
||||
J --> H
|
||||
I -->|否| K[保存写入 Bridge]
|
||||
K --> B
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 手工台账补传
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[手工台账 Tab] --> B[月历:绿已传 / 橙未传]
|
||||
B --> C[点选 ≤今日 且 ≥首笔日]
|
||||
C --> D[上传图片]
|
||||
D --> E[二次确认]
|
||||
E --> F[归档不可删 · 可追加]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 编辑 / 删除与锁定
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[打开详情] --> L{羚牛车辆?}
|
||||
L -->|否| D[可编辑 / 可删除]
|
||||
L -->|是| B{已核对或已对账?}
|
||||
B -->|是| C[只读 · 禁改删]
|
||||
B -->|否| D
|
||||
D --> E[写回 Bridge]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 与 Web / 台账联动(概念)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant H5 as 加氢订单 H5
|
||||
participant BR as Bridge Store
|
||||
participant WEB as 加氢记录 Web
|
||||
participant LED as 车辆氢费明细
|
||||
participant SITE as 站点对账单
|
||||
H5->>BR: 新增/改删流水 · 上传台账
|
||||
WEB->>BR: 同 Store 读写 · 导出
|
||||
LED->>BR: 完成核对 → 已核对
|
||||
SITE->>BR: 提交对账单 → 已对账
|
||||
```
|
||||
52
src/prototypes/oneos-h5-h2-order/.spec/key-logic.md
Normal file
52
src/prototypes/oneos-h5-h2-order/.spec/key-logic.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 关键逻辑索引
|
||||
|
||||
本页汇总加氢订单(H5)复杂判定入口;细则见各规格全文。原型数据均为本地种子 / 内存 Store,**未接真实 API**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 判定主题一览
|
||||
|
||||
| 主题 | 一句话 | 全文 |
|
||||
|---|---|---|
|
||||
| 手工台账门禁 | **昨日**未传 → 今日禁新增;固定提示文案并直达台账 | [feature-manual-ledger.md](./feature-manual-ledger.md) |
|
||||
| 核对 ≠ 对账 | 能源部核对 / 站点对账单提交,触发方不同 | [reconcile-linkage.md](./reconcile-linkage.md) |
|
||||
| 记录锁定 | **仅羚牛**:已核对或已对账 → 站端不可改删;非羚牛不锁 | [feature-detail.md](./feature-detail.md)、[reconcile-linkage.md](./reconcile-linkage.md)、[fleet-plate-tag.md](./fleet-plate-tag.md) |
|
||||
| 重复键 | 同站 + 同车牌 + 同加氢时间 | Bridge / [Web record-data-model](../oneos-web-h2-station/.spec/record-data-model.md) |
|
||||
| 总额计算 | 总额 = 单价 × 量(两位小数) | [feature-create.md](./feature-create.md) |
|
||||
| 羚牛车辆 | 本地系统车牌集合 | [fleet-plate-tag.md](./fleet-plate-tag.md) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 手工台账门禁(摘要)
|
||||
|
||||
| 优先级 | 条件 | 结果 |
|
||||
|--------|------|------|
|
||||
| 1 | 编辑已有记录 | 不校验 |
|
||||
| 2 | 昨日早于本站首笔加氢日 | 不要求昨日台账,可新增 |
|
||||
| 3 | 昨日已有 ≥1 张台账图 | 可新增 |
|
||||
| 4 | 昨日未上传 | 提示并跳转手工台账(选中昨日) |
|
||||
|
||||
用户可见文案:`手工台账有缺失,作为对账重要依据,请先上传手工台账`
|
||||
|
||||
---
|
||||
|
||||
## 3. 核对 / 对账(摘要)
|
||||
|
||||
| 状态 | 谁触发 | 站端影响 |
|
||||
|---|---|---|
|
||||
| 已核对 | 车辆氢费明细「完成核对」 | **羚牛**不可改删 |
|
||||
| 已对账 | 站点信息对账单提交 | **羚牛**不可改删;回写对账时间 |
|
||||
| 非羚牛 | — | 不参与核对/对账展示与锁定 |
|
||||
|
||||
H5 列表主要展示核对状态(仅羚牛);对账细节见联动规格。
|
||||
|
||||
---
|
||||
|
||||
## 4. 代码路径
|
||||
|
||||
| 能力 | 路径 |
|
||||
|---|---|
|
||||
| Bridge | `src/common/h2VehicleLedgerBridge.js` |
|
||||
| 门禁工具 | `utils/manual-ledger.ts` → `isManualLedgerGateBlocked` |
|
||||
| 订单 CRUD | `utils/orders.ts` |
|
||||
| 页面 | `index.tsx` / `ManualLedgerPanel` / `CreateWizard` |
|
||||
64
src/prototypes/oneos-h5-h2-order/.spec/prototype-review.md
Normal file
64
src/prototypes/oneos-h5-h2-order/.spec/prototype-review.md
Normal file
@@ -0,0 +1,64 @@
|
||||
# Prototype Review
|
||||
|
||||
- 审查目标:`src/prototypes/oneos-h5-h2-order`(加氢站管理 → **加氢订单** H5;入口 `index.tsx`)
|
||||
- 用户资料/参考资料:
|
||||
- 本轮用户说明:对「加氢订单」执行 Prototype Review / 需求评审(未另附独立需求附件)
|
||||
- `.spec/`:`requirements-prd.md`(v1.7)、`roles.md`、`user-stories.md`、`key-logic.md`、`flows.md`、`feature-list.md`、`feature-create.md`、`feature-detail.md`、`feature-manual-ledger.md`、`fleet-plate-tag.md`、`reconcile-linkage.md`;早期 `2026-07-14-requirements-design.md`(已标注作废条目)
|
||||
- `src/resources/`:`oneos-web-legacy/…/车辆氢费明细-需求文档.md`(文首已注明以 verify-reconcile / reconcile-linkage 为准)
|
||||
- 关联规格:`oneos-web-h2-station/.spec/fleet-vehicle-verify.md`、`record-data-model.md`;`vehicle-h2-fee-ledger/.spec/verify-reconcile.md`
|
||||
- 源码:`index.tsx`、`components/*`、`utils/*`、`types.ts`、`annotation-source.json`;共享 Bridge / 品牌 Store
|
||||
- 生成时间:2026-07-18 20:14
|
||||
- 修复闭环:2026-07-18 20:32(按 Review 处理全部冲突与 P0–P3 项)
|
||||
- 资料冲突/待确认:**无**(已对齐)
|
||||
- 独立评估:`full`
|
||||
|
||||
## 总体点评
|
||||
|
||||
加氢订单 H5 主路径完整可演示。评审指出的冲突已闭环:**昨日门禁文案**、**列表顶栏橙条**、**锁定仅羚牛**(规格与实现一致)、**早期切站稿作废标注**、**删除确认「未锁定」**、历史 resources 权威顺序说明。剩余为原型边界内 Mock(OCR / 车机 / 无真实 API),不构成业务冲突。
|
||||
|
||||
## P0-P3 优先级问题
|
||||
|
||||
### P1 - 手工台账页文案把「今日上传」当成新增门禁 — **已修复**
|
||||
|
||||
- 处理:改为「今日不作为新增门禁,仅要求前一日已上传」;昨日缺失时台账页单独橙条并可选中昨日。
|
||||
|
||||
### P2 - 「记录锁定」规格未写明羚牛例外 — **已修复**
|
||||
|
||||
- 处理:`feature-detail.md` / `reconcile-linkage.md` / `key-logic.md` / `flows.md` / PRD v1.7 / US-04 统一为「锁定仅羚牛」;与 `isOrderLocked` 实现一致。
|
||||
|
||||
### P2 - 门禁提示缺列表顶栏橙条 — **已修复**
|
||||
|
||||
- 处理:订单列表顶部增加可点橙条(跳转台账并选中昨日);保留 FAB 旁提示 + Tab 红点。
|
||||
|
||||
### P3 - 早期设计文档仍写切站 — **已修复**
|
||||
|
||||
- 处理:`2026-07-14-requirements-design.md` 文首与表格标注作废/覆盖项;权威以 PRD 为准。台账规格区分 H5 固定本站 vs Web 多站。
|
||||
|
||||
### P3 - 删除确认文案偏「未对账」 — **已修复**
|
||||
|
||||
- 处理:确认语改为「确认删除车牌 … 的未锁定记录?」;台账二次确认与 Web 文案对齐。
|
||||
|
||||
## 完整性与项目对齐
|
||||
|
||||
| 维度 | 结论 |
|
||||
|------|------|
|
||||
| 核心用户 / 主流程 | 对齐 PRD v1.7 |
|
||||
| 范围边界 | 真登录 / 真 OCR / PLC / 原生 App / 后端联调仍不做 |
|
||||
| 资料冲突 | 已消除;resources 旧文档文首指向现行核对≠对账规格 |
|
||||
| 双端口径 | 门禁昨日、锁定羚牛、Bridge 共享 — 与 Web / 台账一致 |
|
||||
|
||||
## 业务逻辑连贯性
|
||||
|
||||
- 台账门禁:昨日未传 → 顶栏橙条 / Toast / 跳转台账选中昨日;今日状态 ≠ 门禁。
|
||||
- 锁定:先判羚牛,再判核对∨对账。
|
||||
- 总额偏差二次确认、重复键拦截:与 Web 共用规则。
|
||||
|
||||
## 状态、异常、边界与恢复
|
||||
|
||||
评审所列空态、门禁、必填、重复、脏返回、锁定、归档路径仍成立;本轮仅修正文案/提示位与规格一致性,未改变 Mock 边界。
|
||||
|
||||
## 证据与评估说明
|
||||
|
||||
- 用户资料优先级:无新附件;以 `.spec` PRD v1.7 为基线。
|
||||
- 读取范围:见文首;闭环改动见 `ManualLedgerPanel.tsx`、`index.tsx` 及上述规格文件。
|
||||
- 独立评估:`full`。
|
||||
38
src/prototypes/oneos-h5-h2-order/.spec/roles.md
Normal file
38
src/prototypes/oneos-h5-h2-order/.spec/roles.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 需求角色
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 适用原型 | 加氢订单(H5)`oneos-h5-h2-order` |
|
||||
| 关联 Web | 加氢记录 `oneos-web-h2-station`(同一业务、不同端) |
|
||||
| 数据 | 原型本地种子 + 共享 Bridge(未接真实登录 / API) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 角色一览
|
||||
|
||||
| 角色 | 端 | 核心诉求 | 本原型是否为主用户 |
|
||||
|---|---|---|---|
|
||||
| **加氢站操作人员** | H5(手机浏览器) | 在站场快速上报本站加氢流水;补传手工台账照片 | **是** |
|
||||
| **加氢站站长 / 多站管理员** | Web 为主,H5 可看本站 | 多站台账、导出、手工台账补传与督导 | 间接(H5 固定单站演示) |
|
||||
| **能源部核对人员** | 车辆氢费明细 | 完成核对,回写「已核对」 | 否(触发方在台账) |
|
||||
| **站点对账人员** | 站点信息 · 对账单 | 提交对账单,回写「已对账」 | 否(触发方在站点信息) |
|
||||
| **超级管理员** | Web | 查看全部站点流水与台账 | 否(Web 标注状态演示) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 本原型主角色:加氢站操作人员
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 场景 | 户外 / 站场,手机竖屏 |
|
||||
| 权限边界 | 仅本站数据;不可切站(原型固定演示站) |
|
||||
| 日常动作 | 看列表 KPI → 上传昨日手工台账(若缺失)→ 新增加氢记录 → 查看/编辑未锁定记录 |
|
||||
| 约束 | 前一日手工台账未上传则今日不可新增;已核对或已对账不可改删 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 角色边界(本期不做)
|
||||
|
||||
- 真登录与组织权限中心对接
|
||||
- 能源部 / 对账人员在 H5 内操作核对或对账
|
||||
- 跨站汇总与导出(属 Web「加氢记录」)
|
||||
84
src/prototypes/oneos-h5-h2-order/.spec/user-stories.md
Normal file
84
src/prototypes/oneos-h5-h2-order/.spec/user-stories.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# 用户故事
|
||||
|
||||
> 以加氢站操作人员为主;验收口径见 [requirements-prd.md](./requirements-prd.md)。
|
||||
|
||||
---
|
||||
|
||||
## US-01 查看本站加氢订单
|
||||
|
||||
**作为** 加氢站操作人员,
|
||||
**我希望** 打开加氢订单即看到本站列表与 KPI,
|
||||
**以便** 快速了解今日/近期上报情况。
|
||||
|
||||
**验收要点**
|
||||
|
||||
- 默认已登录本站;无切站
|
||||
- KPI:订单数 / 加氢量 / 加氢金额随列表汇总
|
||||
- 卡片展示加氢时间、车牌、量、额、核对状态等
|
||||
|
||||
---
|
||||
|
||||
## US-02 补传缺失的手工台账后再新增
|
||||
|
||||
**作为** 加氢站操作人员,
|
||||
**我希望** 在前一日手工台账未上传时被明确拦截,并直达台账页补传,
|
||||
**以便** 保证纸质台账作为对账依据不缺失。
|
||||
|
||||
**验收要点**
|
||||
|
||||
- 顶栏橙条 + 底栏提示:「手工台账有缺失,作为对账重要依据,请先上传手工台账」
|
||||
- 点「新增」或顶栏橙条跳转手工台账并选中昨日
|
||||
- 补传昨日确认后可新增
|
||||
|
||||
---
|
||||
|
||||
## US-03 新增加氢记录
|
||||
|
||||
**作为** 加氢站操作人员,
|
||||
**我希望** 用车牌键盘 + 面板拍照/OCR 快速填单,
|
||||
**以便** 在站场少打字完成上报。
|
||||
|
||||
**验收要点**
|
||||
|
||||
- 加氢时间默认=点击新增时刻(到分)
|
||||
- 站名只读为本站
|
||||
- 总额 = 单价 × 加氢量(界面不展示公式)
|
||||
- 同站同车牌同时间重复保存拦截
|
||||
|
||||
---
|
||||
|
||||
## US-04 查看详情并编辑/删除未锁定记录
|
||||
|
||||
**作为** 加氢站操作人员,
|
||||
**我希望** 打开详情查看字段与识别原图,并对未核对未对账记录改删,
|
||||
**以便** 纠正录入错误。
|
||||
|
||||
**验收要点**
|
||||
|
||||
- 羚牛车辆已核对或已对账:不可编辑/删除,有锁定说明;非羚牛不锁定
|
||||
- 详情展示车牌/面板含水印原图
|
||||
|
||||
---
|
||||
|
||||
## US-05 按日历管理手工台账
|
||||
|
||||
**作为** 加氢站操作人员,
|
||||
**我希望** 在月历上看已传/未传并补传过往日,
|
||||
**以便** 多日未操作后仍能补齐缺口。
|
||||
|
||||
**验收要点**
|
||||
|
||||
- 自首笔加氢日起绿/橙标记;首笔前不要求
|
||||
- 禁选未来日;已归档图不可删,仅可追加
|
||||
|
||||
---
|
||||
|
||||
## US-06 识别羚牛车辆
|
||||
|
||||
**作为** 加氢站操作人员,
|
||||
**我希望** 在列表/详情看到是否羚牛车辆,
|
||||
**以便** 理解后续核对对账是否适用该车。
|
||||
|
||||
**验收要点**
|
||||
|
||||
- 标签:羚牛车辆 / 非羚牛车辆(本地车辆集合判定)
|
||||
@@ -16,17 +16,6 @@ import {
|
||||
import { PhoneShell } from './PhoneShell';
|
||||
import { PlateKeyboardSheet } from './PlateKeyboardSheet';
|
||||
|
||||
function listDispenserBrandHints(): string[] {
|
||||
const store = (window as unknown as { H2DispenserBrandStore?: { labels?: () => string[] } })
|
||||
.H2DispenserBrandStore;
|
||||
if (!store || typeof store.labels !== 'function') return [];
|
||||
try {
|
||||
return store.labels().slice(0, 4);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
mode: 'create' | 'edit';
|
||||
draft: CreateDraft;
|
||||
@@ -107,7 +96,6 @@ export function CreateWizard({
|
||||
const [plateOpen, setPlateOpen] = useState(false);
|
||||
|
||||
const vehicleTag = useMemo(() => getVehicleTag(draft.plateNo), [draft.plateNo]);
|
||||
const brandHints = listDispenserBrandHints();
|
||||
|
||||
function patchDraft(patch: Partial<CreateDraft>) {
|
||||
const nextPrice = patch.unitPrice !== undefined ? patch.unitPrice : draft.unitPrice;
|
||||
@@ -380,12 +368,6 @@ export function CreateWizard({
|
||||
<p>请拍摄清晰的加氢机照片</p>
|
||||
</div>
|
||||
</div>
|
||||
{brandHints.length ? (
|
||||
<p className="h5-brand-hint" data-annotation-id="h5-dispenser-brands">
|
||||
OCR 参考品牌型号(站点信息维护):{brandHints.join('、')}
|
||||
{brandHints.length >= 4 ? '…' : ''}
|
||||
</p>
|
||||
) : null}
|
||||
<div
|
||||
className={`h5-shot h5-shot--compact${draft.panelPreviewUrl ? ' h5-shot--filled' : ''}`}
|
||||
role="button"
|
||||
|
||||
38
src/prototypes/oneos-h5-vehicle-assets/.spec/biz-timeline.md
Normal file
38
src/prototypes/oneos-h5-vehicle-assets/.spec/biz-timeline.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 业务记录 · 纵向时间轴
|
||||
|
||||
详情中租赁 / 保险历史 / 事故 / 故障 / 违章 / 异动 / 调拨 / 年审等业务记录,统一为**近 → 远纵向时间轴**展示(与租赁记录同一视觉与交互骨架)。
|
||||
|
||||
## 结构
|
||||
|
||||
1. **时间筛选**(事故及右侧 Tab):快捷「全部 / 近30天 / 近90天 / 近1年 / 自定义起止」;按各 Tab 关键时间过滤;无匹配可重置
|
||||
- **自定义起止**:底部原生滚轮日期选择(年 / 月 / 日,精确到日);起日不得晚于止日
|
||||
- 本模块凡需选日的入口,统一用该原生日期选择器,不使用浏览器原生 `date` 控件
|
||||
2. **时间节点**:左侧圆点 + 连线;节点日期取该条业务关键时间(近 → 远)
|
||||
3. **记录卡片**:主标题(多为完整时间)+ 键值行 + 角标状态
|
||||
4. **未闭环强调**:进行中 / 未还车 / 未解决 / 未处理等节点用强调色圆点与日期
|
||||
|
||||
## 各 Tab 关键时间
|
||||
|
||||
| Tab | 节点日期 / 筛选字段 | 卡片要点 |
|
||||
|-----|---------------------|----------|
|
||||
| 租赁 | 交车/还车较近者 | 合同、项目、客户、交还车;未还车文案;客户/项目搜索 |
|
||||
| 保险历史 | 采购时间(无则到期) | 保单号、公司、状态、到期;附件预览/下载 |
|
||||
| 事故 | 事故时间 | 标题为事故时间;下侧:单号、位置、等级、类型、客户、状态 |
|
||||
| 故障 | 上报时间 | 标题为上报时间;下侧:编号、等级、类型、解决;故障说明用只读文本域 |
|
||||
| 违章 | 违章时间 | 标题为违章时间;下侧:行为、位置、缴费、处理、客户 |
|
||||
| 异动 | 开始日 | 标题为开始时间;下侧:类型、目的地、预计结束 |
|
||||
| 调拨 | 调拨日 | 标题为调拨日期;下侧:出发城市、到达城市、停车场、接收人、接收日 |
|
||||
| 年审 | 执行时间 | 标题为执行时间;下侧:有效期至、检测站、执行人 |
|
||||
|
||||
保险 Tab 仍保留总览 + 五险种索引 + 当前摘要;**采购/批单历史**改为时间轴。
|
||||
|
||||
## 数据源
|
||||
|
||||
种子优先;不足时 H5 演示兜底(见 [demo-biz-records.md](./demo-biz-records.md))。
|
||||
|
||||
## 代码路径
|
||||
|
||||
- 通用:`components/RecordTimeline.tsx`、`TimelineDateFilter.tsx`、`NativeDatePickerSheet.tsx`
|
||||
- 租赁:`LeaseRecordsPanel.tsx`
|
||||
- 保险:`InsuranceRecordsPanel.tsx`
|
||||
- 其余:`BizTimelinePanels.tsx`
|
||||
@@ -0,0 +1,25 @@
|
||||
# 业务记录 · 演示用例数据(H5)
|
||||
|
||||
详情中租赁 / 事故 / 故障 / 违章 / 异动 / 调拨 / 年审等业务 Tab,在**台账种子无该车记录**时,由 H5 本地按车牌稳定生成演示记录;租赁不足 3 条时补足,便于查看完整时间轴。
|
||||
|
||||
## 判定优先级
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | PC 同源种子已有该车牌记录且条数足够 | 使用种子(不覆盖) |
|
||||
| 2 | 租赁可由车辆台账交还车字段推导 | 使用推导记录,不足 3 条再补历史演示 |
|
||||
| 3 | 仍为空,或条数不足 3 | 生成 / 补足演示用例(租赁至少 3 条:1 条在租 + 2 条已还车历史) |
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 仅 H5「车辆资产」详情生效;不写入真实 API
|
||||
- 演示字段取自该车台账(客户、项目、城市、停车场、里程等)+ 车牌哈希,换车内容会变、同车刷新稳定
|
||||
|
||||
## 空状态
|
||||
|
||||
无任何可展示记录时(极少):插画空态 + 标题「暂无记录」+ 引导回 PC 录入。
|
||||
|
||||
## 代码路径
|
||||
|
||||
- 兜底:`utils/demo-biz-records.ts`
|
||||
- 空态:`components/RecordsEmpty.tsx`
|
||||
@@ -0,0 +1,31 @@
|
||||
# 检验有效期剩余天数 · 沪牌等级评定
|
||||
|
||||
## 检验有效期 · 剩余天数标签
|
||||
|
||||
| 项 | 口径 |
|
||||
|----|------|
|
||||
| 展示位置 | 基本信息「证照 / 保险概览」· 检验有效期;证照信息「行驶证」· 检验有效期 |
|
||||
| 有有效日期 | 日期后方标签:`剩余x天`;已过期则为 `已过期x天` |
|
||||
| 无日期 | 文案「未上传」,无标签 |
|
||||
| 临期着色 | 剩余 ≤ 30 天:警告色;已过期:危险色;其余:主色浅底 |
|
||||
| 计算 | 到期日相对今天的日历天数差(与 PC 一致) |
|
||||
|
||||
## 沪牌等级评定(仅车牌以「沪」开头)
|
||||
|
||||
| 字段 | 展示 |
|
||||
|------|------|
|
||||
| 等级评定时间 | 日期;空为「未上传」 |
|
||||
| 剩余天数 | `剩余x天` / `已过期x天`;无日期为「—」 |
|
||||
|
||||
- 证照信息 Tab 在行驶证内展示沪牌等级评定(与 PC 一致);基本信息档案区亦有等级评定字段
|
||||
- 非沪牌不展示
|
||||
|
||||
## 数据源
|
||||
|
||||
- 检验有效期:台账检验有效期 / 行驶证检验有效期
|
||||
- 等级评定:台账等级评定时间(空时证照 Tab 可回退行驶证下次评定日)
|
||||
- 原型本地计算剩余天数,未接实时服务端日历接口
|
||||
|
||||
## 代码路径
|
||||
|
||||
- `components/detailTabs.tsx` → `ExpireValue` / `formatRemainDaysLabel` / `isShanghaiPlate`
|
||||
@@ -0,0 +1,37 @@
|
||||
# 保险记录 · 与保险采购对齐
|
||||
|
||||
详情「保险记录」Tab 数据与险种口径对齐 **保险采购 / PC 车辆资产保险记录**。
|
||||
|
||||
## 结构
|
||||
|
||||
1. **车辆保险档案总览**:整体保险状态(正常 / 临期 / 异常)及说明(交强险 + 商业险判定,与保险采购联动口径一致)
|
||||
2. **五险种索引**(单行横滑密排):交强险、商业险、超赔险、驾意险、货物险
|
||||
- 展示险种名 + 简短状态(正常/临期/未购等)+ 历史条数;到期日在下方详情区
|
||||
3. **当前险种详情**
|
||||
- 摘要:保单号、保险公司、到期日期、保费
|
||||
- 历史:纵向时间轴(近→远);节点取采购时间;卡片含保单号、公司、状态、到期、保单附件(预览 / 下载);不展示导入时间与付款时间
|
||||
|
||||
手机端保单附件:
|
||||
- **预览**:壳内底部弹层展示文件名与演示影像(正式环境预览真实 PDF)
|
||||
- **下载**:触发本地下载演示文件并 Toast「已开始下载」
|
||||
- 无附件显示「无附件」
|
||||
|
||||
## 状态口径(摘要)
|
||||
|
||||
| 整体状态 | 条件 |
|
||||
|----------|------|
|
||||
| 正常 | 交强险、商业险均在有效期内(含临期、停保仍可交车语义与 PC 一致时按 PC) |
|
||||
| 临期 | 交强或商业有效但临期 |
|
||||
| 异常 | 交强或商业未购买 / 已到期 / 已退保等 |
|
||||
|
||||
手机端只读录入;保单附件支持预览与下载(原型演示)。某险种无历史时展示带插画的紧凑空态,引导回 PC「保险采购」录入。
|
||||
|
||||
## 数据源
|
||||
|
||||
- `resolveVehicleInsuranceOverview`(保险采购记录 + 车辆保险台账种子)
|
||||
- 原型本地种子,未接正式保险采购写接口
|
||||
|
||||
## 代码路径
|
||||
|
||||
- UI:`components/InsuranceRecordsPanel.tsx`
|
||||
- 数据:`vehicle-management/utils/vehicleInsuranceRecords.ts`
|
||||
@@ -0,0 +1,42 @@
|
||||
# 租赁记录 · 纵向时间轴
|
||||
|
||||
详情「租赁记录」以**时间为主轴**展示,由近到远;支持按客户名称 / 项目名称即时搜索。
|
||||
|
||||
## 结构
|
||||
|
||||
1. **搜索栏**(时间轴上方):提示「搜索客户名称 / 项目名称」;可一键清空
|
||||
2. **纵向时间轴**:左侧圆点 + 连线;节点日期取该条记录交车/还车中较近的日期
|
||||
3. **记录卡片**(节点右侧):
|
||||
- 客户合同编码
|
||||
- 项目名称
|
||||
- **客户**(交车上方,始终展示)
|
||||
- 交车时间
|
||||
- 还车时间;未还车时显示 **未还车**(强调色)
|
||||
- 业务类型角标(租赁 / 物流)
|
||||
|
||||
## 排序
|
||||
|
||||
按节点日期降序(近 → 远)。搜索只过滤可见项,不改变排序规则。
|
||||
|
||||
## 搜索匹配
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 关键词为空 | 展示该车全部租赁记录 |
|
||||
| 2 | 客户名称或项目名称包含关键词(不区分大小写) | 保留该条 |
|
||||
| 3 | 均不包含 | 过滤掉;全部无匹配时展示「暂无匹配记录」+ 清空搜索 |
|
||||
|
||||
**前置条件**
|
||||
|
||||
- 该车无任何租赁记录:只展示「暂无记录」,不展示搜索栏
|
||||
- 仅匹配客户名称、项目名称;合同编码等不参与搜索(本期)
|
||||
|
||||
## 数据源
|
||||
|
||||
- `resolveVehicleLeaseRecords`(租赁记录种子;无种子时由车辆台账交还车信息推导)
|
||||
- 原型本地种子,未接真实租赁合同 API
|
||||
|
||||
## 代码路径
|
||||
|
||||
- UI:`components/LeaseRecordsPanel.tsx`
|
||||
- 数据:`vehicle-management/utils/leaseRecords.ts`
|
||||
@@ -0,0 +1,33 @@
|
||||
# 证照信息 · 与证照管理对齐
|
||||
|
||||
详情「证照信息」Tab 证照种类、字段与状态口径对齐 **证照管理 / PC 车辆资产证照 Tab**(八类)。
|
||||
|
||||
## 八类证照
|
||||
|
||||
| 顺序 | 证照 | 展示内容 | 状态角标 |
|
||||
|------|------|----------|----------|
|
||||
| 1 | 行驶证 | 影像 + 注册/发证/强制报废/检验有效期至;沪牌另显下次等级评定时间与剩余天数 | 已上传 / 未上传 |
|
||||
| 2 | 道路运输证 | 影像 + 经营许可证号、核发时间、证件有效期、审验有效期 | 已上传 / 未上传 |
|
||||
| 3 | 登记证 | 影像 | 已上传 / 未上传 |
|
||||
| 4 | 特种设备使用登记证 | 影像 | 已上传 / 未上传 |
|
||||
| 5 | 特种设备使用标识 | 影像 + 下次检验日期 | 已上传 / 未上传 |
|
||||
| 6 | 加氢卡 | 卡号/余额/配发信息;未绑定文案「尚未绑定加氢卡」 | 已绑定 / 未绑定 |
|
||||
| 7 | 安全阀 | 影像 + 本次检验日期、下次检测日期 | 已上传 / 未上传 |
|
||||
| 8 | 压力表 | 影像 + 本次检验日期、下次检验日期 | 已上传 / 未上传 |
|
||||
|
||||
顶部提供八类名称**单行横向滚动**索引;点选后自动锚点滚动到对应证照区块,选中项高亮。
|
||||
|
||||
## 日期与剩余天数
|
||||
|
||||
有明确到期/检验日的字段,日期后方可展示「剩余 x 天 / 已过期 x 天」(与检验有效期规则一致)。
|
||||
|
||||
## 数据源
|
||||
|
||||
- 与 PC 车辆资产同一套证照包解析(原型种子 + 台账回填)
|
||||
- 影像为原型演示样例图;正式环境以证照管理上传为准
|
||||
- 手机端只读,不提供编辑 / 跳转证照管理
|
||||
|
||||
## 代码路径
|
||||
|
||||
- UI:`components/LicenseCertPanel.tsx`
|
||||
- 数据:`vehicle-management/utils/vehicleLicenses.ts` → `LICENSE_CERT_NAV` / `resolveVehicleLicenseBundle`
|
||||
77
src/prototypes/oneos-h5-vehicle-assets/.spec/mileage-task.md
Normal file
77
src/prototypes/oneos-h5-vehicle-assets/.spec/mileage-task.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# 列表卡片 · 里程任务进度
|
||||
|
||||
> 原型本地演示种子:[`utils/mileage-task.ts`](../utils/mileage-task.ts)
|
||||
> **未接真实采购合同 / 任务工单 API**
|
||||
|
||||
## 业务背景
|
||||
|
||||
采购部发起车辆采购合同时,合同可关联对应批次车辆的**里程要求**与**时间起止**;里程任务通过**任务工单**下发到车辆。手机列表卡片展示该车当前里程任务完成情况,便于外勤扫一眼进度与是否赶得上截止。
|
||||
|
||||
## 展示位置
|
||||
|
||||
车辆卡片中,在「来源 / 客户名」等标签行**下方**,全宽分隔区:
|
||||
|
||||
1. 标题「里程任务」(有任务时右侧展示完成百分比)
|
||||
2. 有任务:进度条
|
||||
3. 有任务:剩余公里 + 按近 7 活跃日推算的预计完成天数
|
||||
4. 进行中任务:文案后方完成预判标签(对照任务截止剩余日历天)
|
||||
|
||||
## 判定 / 展示优先级
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 车辆未挂接有效里程任务工单 | 展示标题 +「暂无里程任务」(无进度条、无剩余/预计、无预判标签) |
|
||||
| 2 | 已完成里程 ≥ 目标里程(或进度 ≥ 100%) | 进度 100%、成功色进度条与百分比;文案「已完成」;**不**展示预判标签 |
|
||||
| 3 | 有任务且未完成,且近 7 个活跃日均里程可算 | 进度条 +「剩余 N km · 预计 D 天内完成」+ 预判标签;预判为「无法完成」时进度条/百分比为危险红;「有完成风险」为警告橙 |
|
||||
| 4 | 有任务且未完成,但无活跃日(近窗内日里程均 ≤ 0) | 进度条(警告橙)+「剩余 N km · 暂无法预计」+ 预判标签「剩余 x 天,预计有完成风险」 |
|
||||
|
||||
## 口径公式
|
||||
|
||||
| 项 | 口径 |
|
||||
|----|------|
|
||||
| 完成百分比 | `min(100, 保留 1 位小数(任务内已完成里程 / 合同目标里程 × 100))` |
|
||||
| 剩余公里 | `max(0, 目标里程 − 已完成里程)` |
|
||||
| 活跃日 | 自然日内该车行驶里程 **> 0 km** |
|
||||
| 近 7 活跃日均 | 取最近最多 **7** 个活跃日的日里程算术平均;不足 7 个则有几个算几个 |
|
||||
| 预计天数 D | `ceil(剩余公里 / 近 7 活跃日均)`;均里程为 0 或无活跃日 → 无法预计 |
|
||||
| 截止剩余日历天 x | 任务/合同时间窗截止日相对今天的剩余自然日(下限 0) |
|
||||
|
||||
## 完成预判标签(文案后方)
|
||||
|
||||
用预计天数 D(近 7 活跃日推算)对照截止剩余日历天 x:
|
||||
|
||||
| 顺序 | 条件 | 标签文案 | 色调 | 进度条 / 百分比 |
|
||||
|------|------|----------|------|----------------|
|
||||
| 1 | D 无法算出(无活跃日) | 剩余 x 天,预计有完成风险 | 警告橙 | 同警告橙 |
|
||||
| 2 | x ≤ 0 或 D > x | 剩余 x 天,预计无法完成 | 危险红 | 同危险红 |
|
||||
| 3 | D > x × 85%,或 (x − D) ≤ 7 | 剩余 x 天,预计有完成风险 | 警告橙 | 同警告橙 |
|
||||
| 4 | 其余 | 剩余 x 天,预计可完成 | 成功绿 | 默认主色蓝 |
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 仅列表卡片只读展示;不提供下发、编辑、跳转工单详情(本期不做)
|
||||
- 筛选 / KPI / 搜索不影响本区展示规则,只影响哪些车出现在列表
|
||||
|
||||
## 数据源(原型)
|
||||
|
||||
| 来源 | 说明 |
|
||||
|------|------|
|
||||
| 演示种子 | 按车牌稳定 hash:约 65% 挂接演示任务(目标里程、已完成、近窗活跃日里程序列、截止剩余日历天);其余为无任务 |
|
||||
| 台账导表 | 仅借用车牌标识做稳定种子;**不**把导表「当前总里程」直接当作任务进度真相 |
|
||||
|
||||
正式环境应由采购合同批次里程要求 + 时间起止 + 任务工单执行回写提供目标 / 已完成 / 日里程明细 / 截止日。
|
||||
|
||||
## 用户可见文案
|
||||
|
||||
| 场景 | 文案 |
|
||||
|------|------|
|
||||
| 无任务 | 暂无里程任务 |
|
||||
| 进行中 | 剩余 {N} km · 预计 {D} 天内完成 |
|
||||
| 进行中且无法预计 | 剩余 {N} km · 暂无法预计 |
|
||||
| 预判标签 | 剩余 {x} 天,预计无法完成 / 预计可完成 / 预计有完成风险 |
|
||||
| 已完成 | 已完成(无预判标签) |
|
||||
|
||||
## 代码路径
|
||||
|
||||
- 解析:`utils/mileage-task.ts` → `resolveMileageTaskCard` / `resolveMileageForecast`
|
||||
- 展示:`components/VehicleCard.tsx` + `styles/index.css`(`.h5-va-mileage-task*` / `.h5-va-mileage-forecast`)
|
||||
38
src/prototypes/oneos-h5-vehicle-assets/.spec/more-filters.md
Normal file
38
src/prototypes/oneos-h5-vehicle-assets/.spec/more-filters.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 更多筛选 · 判定规格
|
||||
|
||||
「更多筛选」底部面板内的条件与列表筛选项行(品牌车型 / 运营城市 / 车辆状态)叠加生效;任一非「全部」即点亮更多筛选角标。
|
||||
|
||||
## 面板项与匹配口径
|
||||
|
||||
| 优先级 | 筛选项 | 选项 | 匹配规则 | 数据源 |
|
||||
|--------|--------|------|----------|--------|
|
||||
| 1 | 证照状态 | 全部 / 正常 / 异常 | 异常 = 检验有效期早于今天;正常 = 非异常 | 台账证照有效期 |
|
||||
| 2 | 保险状态 | 全部 / 正常 / 异常 | 异常 = 保险状态为「异常」;正常 = 非异常 | 台账保险状态 |
|
||||
| 3 | 出库状态 | 全部 / 未出库 / 已出库 | 见下表(由车辆状态推导,无独立出库字段) | 台账车辆状态 |
|
||||
| 4 | 车辆来源 | 全部 / 自有 / 外租 / 挂靠 | 精确等于台账「车辆来源」 | 台账车辆来源 |
|
||||
| 5 | 在线状态 | 全部 / 在线 / 离线 | 精确等于台账「在线状态」 | 台账在线状态 |
|
||||
|
||||
以上条件与 KPI、搜索、品牌车型、运营城市、车辆状态为 **AND**。
|
||||
|
||||
## 出库状态推导(原型本地)
|
||||
|
||||
| 出库选项 | 车辆状态集合 |
|
||||
|----------|----------------|
|
||||
| 未出库 | 待验车、未备车、已备车、待交车 |
|
||||
| 已出库 | 有明确车辆状态,且不属于「未出库」集合(如已交车、待还车、异动中等) |
|
||||
|
||||
**前置条件**
|
||||
|
||||
- 车辆状态为空、`-`、`无`:不匹配「未出库」也不匹配「已出库」(选任一侧时该车被滤掉)
|
||||
- 正式环境若上线独立「出库」字段,以字段为准,本表仅作原型推导
|
||||
|
||||
## 交互与布局
|
||||
|
||||
- 面板挂在手机壳操作区内,宽度不超过壳层;内容区可滚动,底部「重置 / 完成」固定
|
||||
- 「重置」只清更多筛选五项,不清筛选项行与 KPI / 搜索
|
||||
- 「完成」关闭面板并保留当前选择;列表已随选择即时刷新
|
||||
|
||||
## 代码路径(实现对照)
|
||||
|
||||
- 匹配:`utils/filter.ts` → `matchOutboundFilter` / `matchVehicleSourceFilter` / `matchOnlineStatusFilter` 等
|
||||
- UI:`components/FilterSheet.tsx`
|
||||
276
src/prototypes/oneos-h5-vehicle-assets/.spec/requirements-prd.md
Normal file
276
src/prototypes/oneos-h5-vehicle-assets/.spec/requirements-prd.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# 车辆资产(H5)· 产品需求说明(全模块)
|
||||
|
||||
## 1. 一句话与目标
|
||||
|
||||
**一句话**
|
||||
在 App / 手机浏览器提供与 PC「车辆资产」口径一致的只读查车与异常浏览能力。
|
||||
|
||||
**要解决的问题**
|
||||
- 外勤与业务人员无法在手机上快速按车牌查车、看营运分类与证照/保险异常
|
||||
- PC 宽表页不适合塞进 WebView,需要独立手机信息架构
|
||||
|
||||
**本期目标**
|
||||
1. 列表:车牌搜索、KPI 分类横滑、证照/保险异常筛选、车辆卡片(含里程任务进度)
|
||||
2. 详情:多 Tab 对齐 PC,全程只读浏览
|
||||
3. 全角色同一套界面;手机端不做编辑
|
||||
|
||||
**非目标(本期不做)**
|
||||
- 任意字段编辑(含 Admin)
|
||||
- 导入 / 导出、催办、跳转证照管理或保险采购等业务处理页
|
||||
- 里程任务工单下发 / 编辑、从卡片跳转工单详情、按截止日逾期标红
|
||||
- 在租车型占比等 PC 尾部分析入口
|
||||
- 将 PC 页做响应式自适应
|
||||
|
||||
## 2. 模块边界
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
App[App WebView] --> H5[车辆资产 H5]
|
||||
H5 -.口径一致.-> PC[车辆资产 PC]
|
||||
H5 -.只读.-> Data[车辆台账与关联记录]
|
||||
```
|
||||
|
||||
| 业务线 / 子域 | 做什么 | 不做什么 |
|
||||
|---------------|--------|----------|
|
||||
| 查车列表 | 搜索、KPI、异常筛选、卡片进详情、卡片里程任务进度 | 批量操作、写操作、下发/编辑工单 |
|
||||
| 车辆详情 | 11 个 Tab 只读浏览 | 行内编辑、跳转处理 |
|
||||
|
||||
**外部依赖(产品口径)**
|
||||
|
||||
| 依赖方 | 交互方式 | 产品要求 |
|
||||
|--------|----------|----------|
|
||||
| 车辆资产 PC | 同一套台账与统计口径 | KPI / 证照异常 / 保险异常定义一致;台账数据同源(业务导表) |
|
||||
| 采购合同 / 任务工单 | 合同关联批次里程要求与起止;工单下发后回写进度 | 列表卡片只读展示完成百分比、剩余公里与预计天数;无工单时仍展示「暂无里程任务」 |
|
||||
| App 壳 | 内嵌打开本 H5 | 固定打开手机页,不打开 PC 宽表地址 |
|
||||
|
||||
**关键约束**
|
||||
- 手机端明确提示:修改请在 PC 完成
|
||||
- 筛选与 KPI 叠加:先按 KPI,再按证照/保险条件
|
||||
- 列表车辆数据与 PC「车辆资产」共用业务导表台账(含真实车牌、客户、运营状态分布)
|
||||
|
||||
## 3. 用户与角色
|
||||
|
||||
| 角色 | 主要目标 |
|
||||
|------|----------|
|
||||
| 运维 / 业务 / 管理等全角色 | 同一套界面查车、看异常与档案摘要 |
|
||||
| Admin | 与普通用户相同;手机端仍无编辑(改数回 PC) |
|
||||
|
||||
## 4. 用户故事与故事点(业务条线说明口径)
|
||||
|
||||
合计约 **15 SP**。
|
||||
|
||||
### Epic A · 列表查车(约 10 SP)
|
||||
|
||||
#### A1 · 车牌搜索与卡片列表
|
||||
- **角色**:全体手机用户
|
||||
- **起点**:从 App 进入车辆资产 H5
|
||||
- **怎么运作**:
|
||||
1. 顶栏左对齐「车辆资产」;搜索栏提示「搜索车牌 / VIN / 停车场 / 客户名称」,支持按这些关键词过滤;右侧扫码为占位提示
|
||||
2. KPI 横滑摘要卡(每屏 3 张、共 2 页;图标 + 计数 + 占比);下方固定筛选项行:品牌车型 / 运营城市(省→市,可只选省)/ 车辆状态 +「更多筛选」(证照 / 保险 / 出库 / 来源 / 在线);前三项为 App 原生底部选择器,选项来自业务导表;更多筛选面板限制在手机操作区内并可滚动
|
||||
3. 列表横卡含车牌、车型、来源标签、按运营状态变化的次要标签(租赁/自营→客户名省略;库存→未备车警告/已备车正常;非运营→非运营车辆;退出→三方退租/销售/报废)、营运状态圆点、里程与异常角标(不含车辆照片);标签下方展示「里程任务」分隔区(有任务:百分比 + 进度条 + 剩余公里/预计天数 + 完成预判标签;无任务:「暂无里程任务」);列表不展示总车辆数,不提供翻页按钮,滚动触底异步加载更多
|
||||
4. 底部 App Tab 为单条悬浮胶囊壳层预览:车辆 / 业务 / 工作台(中间圆形仅图标)/ 消息 / 我的;非车辆入口仅提示;点卡片进详情
|
||||
- **闭环**:找到目标车并进入详情
|
||||
- **排期**:US-01 · M
|
||||
|
||||
#### A2 · KPI 分类与异常筛选
|
||||
- **角色**:运维 / 业务
|
||||
- **起点**:需要按营运分类或证照/保险异常缩小范围
|
||||
- **怎么运作**:
|
||||
1. 横滑点选 KPI 摘要卡(全部营运/租赁/物流/库存/非运营/退出;每屏完整显示 3 张,翻页看其余 3 张;显示计数与相对占比)
|
||||
2. 筛选项行:品牌车型(品牌→车型)、运营城市(省→市,可只选省)、车辆状态(与 PC 字典一致,含调拨中 / 替换中等;无数据时计数为 0 仍可选);点「更多筛选」可再调证照状态、保险状态、出库状态、车辆来源、在线状态
|
||||
3. 列表随条件刷新并从顶部重新加载;不展示列表总辆数
|
||||
- **闭环**:得到与 PC 同口径的筛选结果
|
||||
- **排期**:US-02 · M
|
||||
|
||||
#### A3 · 卡片里程任务进度
|
||||
- **角色**:运维 / 业务 / 管理
|
||||
- **起点**:采购合同已为批次车辆关联里程要求与起止时间,并通过任务工单下发
|
||||
- **怎么运作**:
|
||||
1. 列表每张卡片固定展示「里程任务」区
|
||||
2. 有工单:展示完成百分比、进度条、剩余公里;预计完成天数按近 7 个活跃日(日里程 > 0)平均日里程推算;文案后方展示完成预判标签(对照任务截止剩余日历天:预计无法完成 / 可完成 / 有完成风险)
|
||||
3. 无工单:仍保留区块,文案「暂无里程任务」
|
||||
- **闭环**:不进详情也能判断该车里程任务是否在轨、还差多少
|
||||
- **排期**:US-04 · S
|
||||
|
||||
### Epic B · 详情只读(约 5 SP)
|
||||
|
||||
#### B1 · 多 Tab 档案浏览
|
||||
- **角色**:全体手机用户
|
||||
- **起点**:从列表进入某车详情
|
||||
- **怎么运作**:
|
||||
1. 详情与列表共用一体浅蓝渐变底;顶栏透明,仅返回 + 车牌;不挂底部悬浮 Tab
|
||||
2. 摘要为浮起白卡:车牌、品牌型号、营运状态(与列表同文案/色点)、来源·城市·项目等标签、里程、证照/保险异常角标(无车辆实景大图)
|
||||
3. 横滑切换与 PC 一致的 11 个 Tab;选中态为浅底主色字(对齐列表 Chip)
|
||||
4. 各 Tab 仅展示信息(白卡 Section / 记录列表),无编辑按钮;检验有效期后方展示「剩余 x 天」标签;沪牌车在档案信息额外展示等级评定时间与剩余天数;证照信息 Tab 对齐证照管理八类证照;保险记录 Tab 对齐保险采购五险种与采购历史;租赁记录为纵向时间轴(近→远,未还车标注),并支持按客户名称 / 项目名称搜索;事故等业务 Tab 无种子时展示演示用例;无数据时为空态插画
|
||||
- **闭环**:不回 PC 也能看完档案与业务记录摘要
|
||||
- **排期**:US-03 · M
|
||||
|
||||
## 5. 功能模块说明(正向 / 逆向)
|
||||
|
||||
### 5.1 列表
|
||||
|
||||
**正向**
|
||||
1. 默认展示「全部营运」KPI 下的卡片列表(首屏一批,触底异步加载更多;不含退出运营、不含非运营车辆)
|
||||
2. 搜索、切 KPI、改筛选后列表刷新并从顶部重新加载;不展示总车辆数、无翻页按钮
|
||||
3. 证照/保险异常在卡片上可见
|
||||
4. 每张卡片标签下方展示「里程任务」:有工单则百分比 + 进度条 + 剩余/预计 + 完成预判标签;无工单则「暂无里程任务」
|
||||
|
||||
**逆向 / 边界**
|
||||
|
||||
| 情况 | 系统表现 |
|
||||
|------|----------|
|
||||
| 无匹配 | 展示「暂无匹配车辆」+ 引导文案;可清空搜索或重置筛选 |
|
||||
| 仅筛选异常 | 只保留对应异常车辆 |
|
||||
| 更多筛选多项叠加 | 与 KPI / 搜索 / 筛选项行条件同时生效(AND) |
|
||||
| 已加载全部 | 底部提示「没有更多了」 |
|
||||
| 里程任务已完成 | 进度 100%,文案「已完成」,无预判标签 |
|
||||
| 有任务但近窗无活跃日 | 仍显示剩余公里,预计侧「暂无法预计」,预判标签为「剩余 x 天,预计有完成风险」 |
|
||||
| 按活跃日推算赶不上截止 | 预判标签「剩余 x 天,预计无法完成」 |
|
||||
|
||||
### 5.2 详情
|
||||
|
||||
**正向**
|
||||
1. 进入详情后背景与列表一体连续;摘要卡可见状态、标签、里程与异常
|
||||
2. 11 个 Tab 均可切换;内容区白卡展示字段或记录
|
||||
3. 检验有效期后方展示剩余/已过期天数标签;沪牌车额外展示等级评定时间与剩余天数
|
||||
4. 证照信息含八类证照(行驶证、道路运输证、登记证、特种设备使用登记证、特种设备使用标识、加氢卡、安全阀、压力表),字段与证照管理对齐;顶部索引单行横滑点选锚点
|
||||
5. 保险记录对齐保险采购:五险种卡片 + 当前保单摘要 + 采购/批单历史(纵向时间轴,只读)
|
||||
6. 租赁 / 事故 / 故障 / 违章 / 异动 / 调拨 / 年审均为纵向时间轴(近→远);事故及右侧 Tab 支持时间快捷/自定义筛选(自定义为手机端原生滚轮选日);调拨卡片展示出发城市、到达城市、停车场、接收人、接收日(不展示路线与调出人);租赁可搜客户/项目,未还车显示「未还车」
|
||||
7. 业务 Tab 无种子时展示演示用例;租赁不足 3 条时补足
|
||||
8. 无记录时展示美化空态;无底部 App Tab,避免挡内容
|
||||
|
||||
**逆向 / 边界**
|
||||
|
||||
| 情况 | 系统表现 |
|
||||
|------|----------|
|
||||
| 任意角色 | 均无编辑、删除、导入入口 |
|
||||
| 返回 | 顶栏返回或系统返回回到列表;列表底栏样式不受详情影响 |
|
||||
| 检验有效期为空 | 显示「未上传」,无剩余天数标签 |
|
||||
| 非沪牌 | 不展示等级评定时间 / 剩余天数 |
|
||||
| 未绑定加氢卡 | 显示「尚未绑定加氢卡」 |
|
||||
| 某险种无采购记录 | 显示「暂无记录」,引导回 PC 保险采购 |
|
||||
| 租赁未还车 | 还车字段显示「未还车」 |
|
||||
| 租赁搜索无匹配 | 展示「暂无匹配记录」,可清空搜索 |
|
||||
| 业务 Tab 无种子 | 展示稳定演示用例(非真实业务单据) |
|
||||
|
||||
## 6. 关键业务逻辑
|
||||
|
||||
### 6.1 KPI 口径(与 PC 一致)
|
||||
|
||||
| KPI | 口径 |
|
||||
|-----|------|
|
||||
| 全部营运(原「所有营运车辆」) | 非「非运营车辆」,且运营状态不为「退出运营」 |
|
||||
| 租赁 / 物流 | 运营状态等于对应值 |
|
||||
| 库存 | 可运营或待运营 |
|
||||
| 非运营 | 台账类型为非运营车辆 |
|
||||
| 退出运营 | 运营状态为退出运营 |
|
||||
|
||||
### 6.2 异常口径
|
||||
|
||||
| 异常 | 口径 |
|
||||
|------|------|
|
||||
| 证照异常 | 检验有效期早于今天 |
|
||||
| 保险异常 | 保险状态为「异常」 |
|
||||
|
||||
### 6.3 更多筛选口径
|
||||
|
||||
| 筛选项 | 口径 |
|
||||
|--------|------|
|
||||
| 证照 / 保险 | 同 §6.2 |
|
||||
| 出库状态 | 由车辆状态推导:未出库 = 待验车/未备车/已备车/待交车;已出库 = 其余有状态车辆(原型无独立出库字段) |
|
||||
| 车辆来源 | 自有 / 外租 / 挂靠,与台账一致 |
|
||||
| 在线状态 | 在线 / 离线,与台账一致 |
|
||||
|
||||
规格全文见 [more-filters.md](./more-filters.md)。
|
||||
|
||||
### 6.4 里程任务进度(列表卡片)
|
||||
|
||||
| 项 | 口径 |
|
||||
|----|------|
|
||||
| 来源 | 采购合同批次里程要求 + 时间起止 + 任务工单下发(原型为本地演示种子,未接真实 API) |
|
||||
| 完成百分比 | 任务内已完成里程 / 目标里程,封顶 100%,展示保留 1 位小数 |
|
||||
| 剩余公里 | 目标 − 已完成,下限 0 |
|
||||
| 活跃日 | 当日行驶里程 > 0 km |
|
||||
| 预计天数 D | 剩余 ÷ 近最多 7 个活跃日平均日里程,向上取整;无活跃日则「暂无法预计」 |
|
||||
| 截止剩余日历天 x | 任务时间窗相对今天的剩余自然日 |
|
||||
| 预判标签 | 用 D 对照 x:无法完成 / 有完成风险 / 可完成(见规格全文);无法完成时进度条与百分比为危险红,有完成风险为警告橙 |
|
||||
| 无工单 | 仍展示区块,文案「暂无里程任务」 |
|
||||
|
||||
规格全文见 [mileage-task.md](./mileage-task.md)。
|
||||
|
||||
### 6.5 检验有效期剩余天数 · 沪牌等级评定
|
||||
|
||||
| 项 | 口径 |
|
||||
|----|------|
|
||||
| 检验有效期 | 日期后方标签:剩余 x 天 / 已过期 x 天;空为「未上传」 |
|
||||
| 沪牌判定 | 车牌以「沪」开头 |
|
||||
| 等级评定时间 | 仅沪牌展示;空为「未上传」 |
|
||||
| 剩余天数 | 仅沪牌展示;相对等级评定日计算 |
|
||||
|
||||
规格全文见 [expire-remain-shanghai.md](./expire-remain-shanghai.md)。
|
||||
|
||||
### 6.6 证照信息(八类)
|
||||
|
||||
与证照管理一致:行驶证、道路运输证、登记证、特种设备使用登记证、特种设备使用标识、加氢卡、安全阀、压力表。规格全文见 [license-certs.md](./license-certs.md)。
|
||||
|
||||
### 6.7 保险记录(对齐保险采购)
|
||||
|
||||
五险种(交强 / 商业 / 超赔 / 驾意 / 货物)+ 当前保单摘要 + 采购历史(纵向时间轴)。规格全文见 [insurance-records.md](./insurance-records.md)。
|
||||
|
||||
### 6.8 业务记录纵向时间轴
|
||||
|
||||
租赁 / 保险历史 / 事故 / 故障 / 违章 / 异动 / 调拨 / 年审统一近→远时间轴。
|
||||
|
||||
| 能力 | 口径 |
|
||||
|------|------|
|
||||
| 时间筛选 | 事故及右侧 Tab:全部 / 近30天 / 近90天 / 近1年 / 自定义;按各 Tab 关键时间过滤;无匹配可重置 |
|
||||
| 自定义选日 | 手机端底部滚轮(年 / 月 / 日,精确到日);起日不得晚于止日;本模块选日入口统一该交互 |
|
||||
| 调拨卡片 | 出发城市、到达城市、停车场、接收人、接收日;不展示「路线」合并行与「调出」人 |
|
||||
|
||||
规格全文见 [biz-timeline.md](./biz-timeline.md)、[lease-records.md](./lease-records.md)。
|
||||
|
||||
### 6.9 业务记录演示用例与空态
|
||||
|
||||
无种子时 H5 本地生成演示用例;真正无数据时展示美化空态。规格全文见 [demo-biz-records.md](./demo-biz-records.md)。
|
||||
|
||||
## 7. 总览流程图
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
entry[App 打开车辆资产 H5] --> list[列表:搜索 / KPI / 筛选]
|
||||
list --> card[点车辆卡片]
|
||||
card --> detail[详情摘要]
|
||||
detail --> tabs[切换只读 Tab]
|
||||
tabs --> back[返回列表]
|
||||
```
|
||||
|
||||
## 8. 验收清单(产品 / 测试)
|
||||
|
||||
- [ ] 独立原型可打开,桌面 PhoneShell / 窄屏可用
|
||||
- [ ] KPI 六类计数与同数据 PC 一致
|
||||
- [ ] 证照异常、保险异常筛选结果与 PC 规则一致
|
||||
- [ ] 「更多筛选」含证照 / 保险 / 出库 / 来源 / 在线;面板不超出手机操作区,内容可滚动;启用后角标点亮
|
||||
- [ ] 出库:未出库对应待验车/未备车/已备车/待交车;来源与在线与台账字段一致
|
||||
- [ ] 车辆状态选项与 PC 字典一致,含「调拨中」「替换中」
|
||||
- [ ] 搜索支持车牌 / VIN / 停车场 / 客户名称过滤列表;可一键清空搜索
|
||||
- [ ] 无匹配时有引导操作(清空搜索 / 重置筛选)
|
||||
- [ ] 列表无总辆数、无翻页按钮;滚动触底可异步加载更多,到底提示「没有更多了」
|
||||
- [ ] 列表进详情:一体渐变底连续,无白顶栏色带;摘要为浮起白卡(状态/标签/里程/异常与列表同语义);详情无底栏
|
||||
- [ ] 详情检验有效期后方有「剩余 x 天」或「已过期 x 天」标签;空为「未上传」
|
||||
- [ ] 沪牌详情可见「等级评定时间」「剩余天数」;非沪牌不出现
|
||||
- [ ] 证照信息 Tab 可见八类证照,与证照管理种类一致
|
||||
- [ ] 保险记录可见五险种卡片、当前保单摘要与历史时间轴;整体状态口径与保险采购一致
|
||||
- [ ] 保单附件可预览与下载(原型演示)
|
||||
- [ ] 租赁 / 事故 / 故障 / 违章 / 异动 / 调拨 / 年审为近→远时间轴;租赁未还车显示「未还车」
|
||||
- [ ] 调拨卡片可见出发城市、到达城市、停车场、接收人、接收日;无「路线」「调出」
|
||||
- [ ] 事故及右侧业务 Tab 可按时间快捷或自定义区间筛选;自定义起止为底部滚轮选日(精确到日),无匹配可重置
|
||||
- [ ] 租赁记录可按客户名称 / 项目名称搜索;无匹配可清空搜索
|
||||
- [ ] 任意车辆打开上述业务 Tab 可见列表(种子或演示用例);空态有插画与说明
|
||||
- [ ] 详情 11 个 Tab 可进入且只读,无编辑按钮
|
||||
- [ ] 列表卡片标签下有「里程任务」区:有任务可见百分比、进度条、剩余公里与预计天数;无任务显示「暂无里程任务」
|
||||
- [ ] 预计天数按近 7 活跃日均里程推算;无活跃日时显示「暂无法预计」
|
||||
- [ ] 进行中任务文案后方有完成预判标签(剩余 x 天 + 预计无法完成 / 可完成 / 有完成风险)
|
||||
- [ ] 页脚说明「修改请在 PC」可见
|
||||
|
||||
**交付口径**
|
||||
本期交付可嵌入 App 的车辆资产只读 H5:能查、能筛异常、能看详情全 Tab,并在列表卡片扫读里程任务进度;写操作与异常处理仍回 PC。
|
||||
293
src/prototypes/oneos-h5-vehicle-assets/annotation-source.json
Normal file
293
src/prototypes/oneos-h5-vehicle-assets/annotation-source.json
Normal file
File diff suppressed because one or more lines are too long
@@ -0,0 +1,37 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
type ToastState = { id: number; message: string } | null;
|
||||
|
||||
let pushToast: ((message: string) => void) | null = null;
|
||||
|
||||
export function showAppToast(message: string) {
|
||||
pushToast?.(message);
|
||||
}
|
||||
|
||||
export function AppToastHost() {
|
||||
const [toast, setToast] = useState<ToastState>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let timer = 0;
|
||||
pushToast = (message: string) => {
|
||||
const id = Date.now();
|
||||
setToast({ id, message });
|
||||
window.clearTimeout(timer);
|
||||
timer = window.setTimeout(() => {
|
||||
setToast((current) => (current?.id === id ? null : current));
|
||||
}, 1800);
|
||||
};
|
||||
return () => {
|
||||
pushToast = null;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!toast) return null;
|
||||
|
||||
return (
|
||||
<div className="h5-va-toast" role="status" aria-live="polite">
|
||||
{toast.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import type { VehicleRecord } from '../utils/filter';
|
||||
import { resolveVehicleAccidentRecords } from '../../vehicle-management/utils/accidentRecords';
|
||||
import { resolveVehicleFaultRecords } from '../../vehicle-management/utils/faultRecords';
|
||||
import { resolveVehicleViolationRecords } from '../../vehicle-management/utils/violationRecords';
|
||||
import { resolveVehicleMovementRecords } from '../../vehicle-management/utils/movementRecords';
|
||||
import { resolveVehicleTransferRecords } from '../../vehicle-management/utils/transferRecords';
|
||||
import { resolveVehicleAnnualReviewRecords } from '../../vehicle-management/utils/annualReviewRecords';
|
||||
import {
|
||||
ensureAccidentRecords,
|
||||
ensureAnnualReviewRecords,
|
||||
ensureFaultRecords,
|
||||
ensureMovementRecords,
|
||||
ensureTransferRecords,
|
||||
ensureViolationRecords,
|
||||
} from '../utils/demo-biz-records';
|
||||
import { RecordsEmpty } from './RecordsEmpty';
|
||||
import {
|
||||
RecordTimeline,
|
||||
sortByDateDesc,
|
||||
timelineDatePart,
|
||||
type TimelineItem,
|
||||
} from './RecordTimeline';
|
||||
import {
|
||||
matchTimelineDate,
|
||||
useTimelineDateFilter,
|
||||
} from './TimelineDateFilter';
|
||||
|
||||
function PanelShell({
|
||||
label,
|
||||
emptyDesc,
|
||||
items,
|
||||
dateFieldLabel,
|
||||
}: {
|
||||
label: string;
|
||||
emptyDesc: string;
|
||||
items: TimelineItem[];
|
||||
dateFieldLabel: string;
|
||||
}) {
|
||||
const { range, filterActive, filterNode, reset } = useTimelineDateFilter(dateFieldLabel);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => items.filter((item) => matchTimelineDate(item.date, range)),
|
||||
[items, range],
|
||||
);
|
||||
|
||||
if (!items.length) {
|
||||
return <RecordsEmpty title="暂无记录" desc={emptyDesc} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h5-va-tl-panel h5-va-section--records" aria-label={label}>
|
||||
{filterNode}
|
||||
{!filtered.length ? (
|
||||
<div className="h5-va-empty h5-va-empty--inset" role="status">
|
||||
<p className="h5-va-empty-title">暂无匹配记录</p>
|
||||
<p className="h5-va-empty-desc">调整时间范围,或重置筛选查看全部</p>
|
||||
{filterActive ? (
|
||||
<div className="h5-va-empty-actions">
|
||||
<button type="button" className="h5-btn h5-btn--primary" onClick={reset}>
|
||||
重置时间筛选
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<RecordTimeline items={filtered} label={`${label}时间轴`} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AccidentRecordsPanel({ record }: { record: VehicleRecord }) {
|
||||
const items = useMemo(() => {
|
||||
const rows = sortByDateDesc(
|
||||
ensureAccidentRecords(record, resolveVehicleAccidentRecords(record)),
|
||||
(row) => timelineDatePart(row.accidentTime),
|
||||
);
|
||||
return rows.map((row): TimelineItem => ({
|
||||
id: row.id,
|
||||
date: timelineDatePart(row.accidentTime) || '—',
|
||||
accent: row.status === '处理中',
|
||||
badge: row.status,
|
||||
title: row.accidentTime || '—',
|
||||
meta: [
|
||||
{ label: '单号', value: row.accidentCode || '—' },
|
||||
{ label: '位置', value: row.accidentLocation || '—' },
|
||||
{ label: '等级', value: row.accidentLevel || '—' },
|
||||
{ label: '类型', value: row.accidentType || '—' },
|
||||
...(row.customerName ? [{ label: '客户', value: row.customerName }] : []),
|
||||
],
|
||||
}));
|
||||
}, [record]);
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
label="事故记录"
|
||||
emptyDesc="该车辆暂无事故记录,可回 PC 录入后同步查看"
|
||||
dateFieldLabel="事故时间"
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function FaultRecordsPanel({ record }: { record: VehicleRecord }) {
|
||||
const items = useMemo(() => {
|
||||
const rows = sortByDateDesc(
|
||||
ensureFaultRecords(record, resolveVehicleFaultRecords(record)),
|
||||
(row) => timelineDatePart(row.reportedAt),
|
||||
);
|
||||
return rows.map((row): TimelineItem => {
|
||||
const open = row.resolutionStatus !== '已解决';
|
||||
const description = (row.faultDescription || '').trim() || '—';
|
||||
return {
|
||||
id: row.id,
|
||||
date: timelineDatePart(row.reportedAt) || '—',
|
||||
accent: open,
|
||||
badge: row.resolutionStatus || '未解决',
|
||||
title: row.reportedAt || '—',
|
||||
meta: [
|
||||
{ label: '编号', value: row.faultNo || '—' },
|
||||
{ label: '等级', value: row.faultLevel || '—' },
|
||||
{ label: '类型', value: row.faultType || '—' },
|
||||
...(row.resolvedAt
|
||||
? [{ label: '解决', value: row.resolvedAt }]
|
||||
: [{ label: '解决', value: '未解决', pending: true }]),
|
||||
],
|
||||
footer: (
|
||||
<div className="h5-va-tl-fieldblock">
|
||||
<div className="h5-va-tl-fieldblock-label">故障说明</div>
|
||||
<textarea
|
||||
className="h5-va-tl-textarea"
|
||||
readOnly
|
||||
rows={3}
|
||||
value={description}
|
||||
aria-label="故障说明"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
}, [record]);
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
label="故障记录"
|
||||
emptyDesc="该车辆暂无故障记录,可回 PC 录入后同步查看"
|
||||
dateFieldLabel="上报时间"
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ViolationRecordsPanel({ record }: { record: VehicleRecord }) {
|
||||
const items = useMemo(() => {
|
||||
const rows = sortByDateDesc(
|
||||
ensureViolationRecords(record, resolveVehicleViolationRecords(record)),
|
||||
(row) => timelineDatePart(row.violationTime),
|
||||
);
|
||||
return rows.map((row): TimelineItem => ({
|
||||
id: row.id,
|
||||
date: timelineDatePart(row.violationTime) || '—',
|
||||
accent: row.processed === '未处理' || row.paymentStatus === '未缴费',
|
||||
badge: row.processed,
|
||||
title: row.violationTime || '—',
|
||||
meta: [
|
||||
{ label: '行为', value: row.violationBehavior || '—' },
|
||||
{ label: '位置', value: row.violationLocation || '—' },
|
||||
{ label: '缴费', value: row.paymentStatus || '—', pending: row.paymentStatus === '未缴费' },
|
||||
{ label: '处理', value: row.processed || '—', pending: row.processed === '未处理' },
|
||||
...(row.customerName ? [{ label: '客户', value: row.customerName }] : []),
|
||||
],
|
||||
}));
|
||||
}, [record]);
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
label="违章记录"
|
||||
emptyDesc="该车辆暂无违章记录,可回 PC 录入后同步查看"
|
||||
dateFieldLabel="违章时间"
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function MovementRecordsPanel({ record }: { record: VehicleRecord }) {
|
||||
const items = useMemo(() => {
|
||||
const rows = sortByDateDesc(
|
||||
ensureMovementRecords(record, resolveVehicleMovementRecords(record)),
|
||||
(row) => timelineDatePart(row.startDate),
|
||||
);
|
||||
return rows.map((row): TimelineItem => ({
|
||||
id: row.id,
|
||||
date: timelineDatePart(row.startDate) || '—',
|
||||
accent: row.status === '进行中',
|
||||
badge: row.status,
|
||||
title: row.startDate || '—',
|
||||
meta: [
|
||||
{ label: '类型', value: row.movementType || '—' },
|
||||
{
|
||||
label: '目的地',
|
||||
value: `${row.destinationType || ''} ${row.destinationName || ''}`.trim() || '—',
|
||||
},
|
||||
{
|
||||
label: '预计结束',
|
||||
value: row.estimatedEndDate || '进行中',
|
||||
pending: !row.estimatedEndDate,
|
||||
},
|
||||
],
|
||||
}));
|
||||
}, [record]);
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
label="异动记录"
|
||||
emptyDesc="该车辆暂无异动记录,可回 PC 录入后同步查看"
|
||||
dateFieldLabel="开始时间"
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function TransferRecordsPanel({ record }: { record: VehicleRecord }) {
|
||||
const items = useMemo(() => {
|
||||
const rows = sortByDateDesc(
|
||||
ensureTransferRecords(record, resolveVehicleTransferRecords(record)),
|
||||
(row) => timelineDatePart(row.transferDate),
|
||||
);
|
||||
return rows.map((row): TimelineItem => ({
|
||||
id: row.id,
|
||||
date: timelineDatePart(row.transferDate) || '—',
|
||||
badge: row.transferMethod || undefined,
|
||||
title: row.transferDate || '—',
|
||||
meta: [
|
||||
{ label: '出发城市', value: row.departureArea || '—' },
|
||||
{ label: '到达城市', value: row.receivingArea || '—' },
|
||||
{
|
||||
label: '停车场',
|
||||
value: `${row.departureParking || '—'} → ${row.receivingParking || '—'}`,
|
||||
},
|
||||
{ label: '接收人', value: row.recipientPerson || '—' },
|
||||
{ label: '接收日', value: row.receiveDate || '—' },
|
||||
],
|
||||
}));
|
||||
}, [record]);
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
label="调拨记录"
|
||||
emptyDesc="该车辆暂无调拨记录,可回 PC 录入后同步查看"
|
||||
dateFieldLabel="调拨日期"
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnnualReviewRecordsPanel({ record }: { record: VehicleRecord }) {
|
||||
const items = useMemo(() => {
|
||||
const rows = sortByDateDesc(
|
||||
ensureAnnualReviewRecords(record, resolveVehicleAnnualReviewRecords(record)),
|
||||
(row) => timelineDatePart(row.executeTime),
|
||||
);
|
||||
return rows.map((row): TimelineItem => ({
|
||||
id: row.id,
|
||||
date: timelineDatePart(row.executeTime) || timelineDatePart(row.inspectionValidUntil) || '—',
|
||||
title: row.executeTime || '—',
|
||||
meta: [
|
||||
{ label: '有效期至', value: row.inspectionValidUntil || '—' },
|
||||
{ label: '检测站', value: row.inspectionStation || '—' },
|
||||
{ label: '执行人', value: row.executor || '—' },
|
||||
],
|
||||
}));
|
||||
}, [record]);
|
||||
|
||||
return (
|
||||
<PanelShell
|
||||
label="年审记录"
|
||||
emptyDesc="该车辆暂无年审记录,可回 PC 录入后同步查看"
|
||||
dateFieldLabel="执行时间"
|
||||
items={items}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useEffect, useId, useRef } from 'react';
|
||||
import type {
|
||||
InsuranceFilter,
|
||||
LicenseFilter,
|
||||
OnlineFilter,
|
||||
OutboundFilter,
|
||||
} from '../utils/filter';
|
||||
import { IconX } from './Icons';
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
licenseStatus: LicenseFilter;
|
||||
insuranceStatus: InsuranceFilter;
|
||||
outboundStatus: OutboundFilter;
|
||||
vehicleSource: string;
|
||||
onlineStatus: OnlineFilter;
|
||||
onLicenseChange: (value: LicenseFilter) => void;
|
||||
onInsuranceChange: (value: InsuranceFilter) => void;
|
||||
onOutboundChange: (value: OutboundFilter) => void;
|
||||
onVehicleSourceChange: (value: string) => void;
|
||||
onOnlineChange: (value: OnlineFilter) => void;
|
||||
onReset: () => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const LICENSE_OPTIONS: Array<{ value: LicenseFilter; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '正常', label: '正常' },
|
||||
{ value: '异常', label: '异常' },
|
||||
];
|
||||
|
||||
const INSURANCE_OPTIONS: Array<{ value: InsuranceFilter; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '正常', label: '正常' },
|
||||
{ value: '异常', label: '异常' },
|
||||
];
|
||||
|
||||
const OUTBOUND_OPTIONS: Array<{ value: OutboundFilter; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '未出库', label: '未出库' },
|
||||
{ value: '已出库', label: '已出库' },
|
||||
];
|
||||
|
||||
const SOURCE_OPTIONS: Array<{ value: string; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '自有', label: '自有' },
|
||||
{ value: '外租', label: '外租' },
|
||||
{ value: '挂靠', label: '挂靠' },
|
||||
];
|
||||
|
||||
const ONLINE_OPTIONS: Array<{ value: OnlineFilter; label: string }> = [
|
||||
{ value: '', label: '全部' },
|
||||
{ value: '在线', label: '在线' },
|
||||
{ value: '离线', label: '离线' },
|
||||
];
|
||||
|
||||
type ChipGroupProps<T extends string> = {
|
||||
labelId: string;
|
||||
label: string;
|
||||
value: T;
|
||||
options: Array<{ value: T; label: string }>;
|
||||
onChange: (value: T) => void;
|
||||
};
|
||||
|
||||
function ChipGroup<T extends string>({ labelId, label, value, options, onChange }: ChipGroupProps<T>) {
|
||||
return (
|
||||
<div className="h5-va-sheet-section">
|
||||
<div className="h5-va-sheet-label" id={labelId}>{label}</div>
|
||||
<div className="h5-va-chip-row" role="group" aria-labelledby={labelId}>
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={`${labelId}-${option.label}`}
|
||||
type="button"
|
||||
className={`h5-va-chip${value === option.value ? ' is-active' : ''}`}
|
||||
aria-pressed={value === option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FilterSheet({
|
||||
open,
|
||||
licenseStatus,
|
||||
insuranceStatus,
|
||||
outboundStatus,
|
||||
vehicleSource,
|
||||
onlineStatus,
|
||||
onLicenseChange,
|
||||
onInsuranceChange,
|
||||
onOutboundChange,
|
||||
onVehicleSourceChange,
|
||||
onOnlineChange,
|
||||
onReset,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const titleId = useId();
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
closeRef.current?.focus();
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onCloseRef.current();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h5-va-sheet-mask"
|
||||
role="presentation"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="h5-va-sheet h5-va-sheet--filters"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
data-annotation-id="h5-va-filter"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="h5-va-sheet-handle" aria-hidden />
|
||||
<div className="h5-va-sheet-head">
|
||||
<h2 id={titleId}>更多筛选</h2>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
className="h5-nav-btn"
|
||||
onClick={onClose}
|
||||
aria-label="关闭更多筛选"
|
||||
>
|
||||
<IconX />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h5-va-sheet-body">
|
||||
<ChipGroup
|
||||
labelId="h5-va-license-label"
|
||||
label="证照状态"
|
||||
value={licenseStatus}
|
||||
options={LICENSE_OPTIONS}
|
||||
onChange={onLicenseChange}
|
||||
/>
|
||||
<ChipGroup
|
||||
labelId="h5-va-insurance-label"
|
||||
label="保险状态"
|
||||
value={insuranceStatus}
|
||||
options={INSURANCE_OPTIONS}
|
||||
onChange={onInsuranceChange}
|
||||
/>
|
||||
<ChipGroup
|
||||
labelId="h5-va-outbound-label"
|
||||
label="出库状态"
|
||||
value={outboundStatus}
|
||||
options={OUTBOUND_OPTIONS}
|
||||
onChange={onOutboundChange}
|
||||
/>
|
||||
<ChipGroup
|
||||
labelId="h5-va-source-label"
|
||||
label="车辆来源"
|
||||
value={vehicleSource}
|
||||
options={SOURCE_OPTIONS}
|
||||
onChange={onVehicleSourceChange}
|
||||
/>
|
||||
<ChipGroup
|
||||
labelId="h5-va-online-label"
|
||||
label="在线状态"
|
||||
value={onlineStatus}
|
||||
options={ONLINE_OPTIONS}
|
||||
onChange={onOnlineChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="h5-va-sheet-actions">
|
||||
<button type="button" className="h5-btn h5-btn--ghost" onClick={onReset}>
|
||||
重置
|
||||
</button>
|
||||
<button type="button" className="h5-btn h5-btn--primary" onClick={onClose}>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
512
src/prototypes/oneos-h5-vehicle-assets/components/Icons.tsx
Normal file
512
src/prototypes/oneos-h5-vehicle-assets/components/Icons.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function IconEmptyFleet({ size = 64, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 80 80" fill="none" aria-hidden>
|
||||
<circle cx="40" cy="40" r="36" fill="url(#h5-va-empty-glow)" />
|
||||
<rect x="16" y="28" width="48" height="28" rx="8" fill="#fff" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path
|
||||
d="M22 40h10.5l3-6h14l3.5 6H58"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="28" cy="50" r="3.2" fill="currentColor" />
|
||||
<circle cx="52" cy="50" r="3.2" fill="currentColor" />
|
||||
<path d="M54 22l8 8M62 22l-8 8" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" opacity="0.55" />
|
||||
<defs>
|
||||
<radialGradient id="h5-va-empty-glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(40 40) rotate(90) scale(36)">
|
||||
<stop stopColor="currentColor" stopOpacity="0.16" />
|
||||
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 业务记录空态 — 剪贴板 / 单据 */
|
||||
export function IconEmptyRecords({ size = 64, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 80 80" fill="none" aria-hidden>
|
||||
<circle cx="40" cy="40" r="36" fill="url(#h5-va-empty-rec-glow)" />
|
||||
<rect x="24" y="18" width="32" height="44" rx="8" fill="#fff" stroke="currentColor" strokeWidth="1.6" />
|
||||
<rect x="30" y="14" width="20" height="10" rx="4" fill="#fff" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M32 36h16M32 44h16M32 52h10" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" opacity="0.55" />
|
||||
<circle cx="54" cy="54" r="10" fill="#fff" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M50.5 54h7M54 50.5v7" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" opacity="0.45" />
|
||||
<defs>
|
||||
<radialGradient id="h5-va-empty-rec-glow" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(40 40) rotate(90) scale(36)">
|
||||
<stop stopColor="currentColor" stopOpacity="0.16" />
|
||||
<stop offset="1" stopColor="currentColor" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCheck({ size = 18, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M5 12.5l5 5L19 7" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 列表到底 — 勾选圆点 */
|
||||
export function IconListEnd({ size = 16, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" opacity="0.35" />
|
||||
<circle cx="12" cy="12" r="9" fill="currentColor" opacity="0.08" />
|
||||
<path
|
||||
d="M8 12.2l2.6 2.6L16.4 9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.9"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronLeft({ size = 20, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M15 18l-6-6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronRight({ size = 18, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M9 18l6-6-6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronDown({ size = 16, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M6 9l6 6 6-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSearch({ size = 18, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<circle cx="11" cy="11" r="7" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M20 20l-3.5-3.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSliders({ size = 18, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
<circle cx="4" cy="14" r="2.5" stroke="currentColor" strokeWidth="2" />
|
||||
<circle cx="12" cy="10" r="2.5" stroke="currentColor" strokeWidth="2" />
|
||||
<circle cx="20" cy="16" r="2.5" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFilter({ size = 18, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M4 5h16l-6.5 7.5V19l-3 1.5v-8L4 5z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconX({ size = 18, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCircleX({ size = 18, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M15 9l-6 6M9 9l6 6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCar({ size = 24, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M5 16l1.2-4.2A2 2 0 018.1 10h7.8a2 2 0 011.9 1.8L19 16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M3.5 16h17M7 16v1.5a1.5 1.5 0 003 0V16m4 0v1.5a1.5 1.5 0 003 0V16"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 10l1.2-2.4A1.5 1.5 0 019.99 7h4.02a1.5 1.5 0 011.29.6L16.5 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBell({ size = 22, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
{/* soft body fill — depth without heavy chrome */}
|
||||
<path
|
||||
d="M12 3.2c.48 0 .88.3 1.02.74A5.85 5.85 0 0118.1 9.6c0 2.4.72 4.05 1.4 5.15.18.3-.04.7-.4.7H4.9c-.36 0-.58-.4-.4-.7.68-1.1 1.4-2.75 1.4-5.15a5.85 5.85 0 015.08-5.66C11.12 3.5 11.52 3.2 12 3.2z"
|
||||
fill="currentColor"
|
||||
opacity="0.1"
|
||||
/>
|
||||
{/* bell body */}
|
||||
<path
|
||||
d="M12 3.2c.48 0 .88.3 1.02.74A5.85 5.85 0 0118.1 9.6c0 2.4.72 4.05 1.4 5.15.18.3-.04.7-.4.7H4.9c-.36 0-.58-.4-.4-.7.68-1.1 1.4-2.75 1.4-5.15a5.85 5.85 0 015.08-5.66C11.12 3.5 11.52 3.2 12 3.2z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
{/* hanger */}
|
||||
<path
|
||||
d="M12 3.15V2.15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<circle cx="12" cy="1.85" r="0.95" fill="currentColor" />
|
||||
{/* clapper */}
|
||||
<path
|
||||
d="M9.6 18.35a2.55 2.55 0 004.8 0"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPlus({ size = 18, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M12 5v14M5 12h14" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconScan({ size = 18, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M4 8V5a1 1 0 011-1h3M16 4h3a1 1 0 011 1v3M20 16v3a1 1 0 01-1 1h-3M8 20H5a1 1 0 01-1-1v-3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M5 12h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPlay({ size = 14, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M8 5.5v13l11-6.5L8 5.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPause({ size = 14, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<rect x="6" y="5" width="4" height="14" rx="1" />
|
||||
<rect x="14" y="5" width="4" height="14" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconAlert({ size = 14, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M12 3l10 18H2L12 3z" stroke="currentColor" strokeWidth="1.8" strokeLinejoin="round" />
|
||||
<path d="M12 10v4M12 17h.01" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconWallet({ size = 20, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<rect x="3" y="6" width="18" height="13" rx="2.5" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M3 10h18" stroke="currentColor" strokeWidth="1.8" />
|
||||
<circle cx="16.5" cy="14.5" r="1.2" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 业务 Tab:公文包 */
|
||||
export function IconBriefcase({ size = 20, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<rect x="3" y="7" width="18" height="13" rx="2.5" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M9 7V5.8A1.8 1.8 0 0110.8 4h2.4A1.8 1.8 0 0115 5.8V7" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M3 12h18" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M10 12v1.5a2 2 0 004 0V12" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChart({ size = 20, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M4 19V5M4 19h16" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M8 15v-4M12 15V8M16 15v-6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** 工作台中心凸起按钮用的饼图图标 */
|
||||
export function IconPie({ size = 22, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M12 3a9 9 0 109 9h-9V3z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M13.5 3.2A9 9 0 0120.8 10.5H13.5V3.2z" fill="currentColor" opacity="0.35" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMessage({ size = 20, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M5 6.5A2.5 2.5 0 017.5 4h9A2.5 2.5 0 0119 6.5v7A2.5 2.5 0 0116.5 16H10l-4 3.5V6.5z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconUser({ size = 20, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<circle cx="12" cy="8" r="3.5" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M5 19.5c1.5-3.2 4-4.5 7-4.5s5.5 1.3 7 4.5" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconHome({ size = 20, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M4 10.5L12 4l8 6.5V20a1 1 0 01-1 1h-5v-6H10v6H5a1 1 0 01-1-1v-9.5z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTasks({ size = 20, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M9 6h11M9 12h11M9 18h11" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
<path d="M5 6.2l1.2 1.2L8.2 5.4M5 12.2l1.2 1.2L8.2 11.4M5 18.2l1.2 1.2L8.2 17.4" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTruck({ size = 14, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M3 7h11v10H3V7zM14 10h4l3 3v4h-7v-7z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<circle cx="7" cy="18" r="1.6" fill="currentColor" />
|
||||
<circle cx="17" cy="18" r="1.6" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBox({ size = 14, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M4 8l8-4 8 4v9l-8 4-8-4V8z" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
<path d="M4 8l8 4 8-4M12 12v9" stroke="currentColor" strokeWidth="1.6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSort({ size = 16, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M8 6v12M8 6l-3 3M8 6l3 3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M16 18V6M16 18l-3-3M16 18l3-3" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBrand({ size = 16, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M4 16.5l1.4-4.6A2 2 0 017.3 10h9.4a2 2 0 011.9 1.9L20 16.5" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M6.5 16.5h11M8 16.5v1.2a1.2 1.2 0 002.4 0v-1.2m3.2 0v1.2a1.2 1.2 0 002.4 0v-1.2" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
|
||||
<path d="M8 10l1.1-2.2A1.2 1.2 0 0110.2 7h3.6a1.2 1.2 0 011.1.8L16 10" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconStatus({ size = 16, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<circle cx="12" cy="12" r="8" stroke="currentColor" strokeWidth="1.7" />
|
||||
<circle cx="12" cy="12" r="3.2" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBuilding({ size = 16, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path d="M5 20V6.5A1.5 1.5 0 016.5 5h7A1.5 1.5 0 0115 6.5V20" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
|
||||
<path d="M15 10h3.5A1.5 1.5 0 0120 11.5V20" stroke="currentColor" strokeWidth="1.7" strokeLinejoin="round" />
|
||||
<path d="M8 8.5h3M8 12h3M8 15.5h3M5 20h15" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** KPI:全部车辆 — 车队轮廓 */
|
||||
export function IconKpiFleet({ size = 14, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M4.5 15.5h15M7 15.5v1.4a1.1 1.1 0 002.2 0v-1.4m5.6 0v1.4a1.1 1.1 0 002.2 0v-1.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M5.2 15.5l1.1-3.8A1.8 1.8 0 018.05 10h7.9a1.8 1.8 0 011.75 1.7l1.1 3.8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8 10l1-2.1A1.2 1.2 0 0110.1 7h3.8a1.2 1.2 0 011.1.9L16 10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="8.2" cy="13.2" r="0.85" fill="currentColor" />
|
||||
<circle cx="15.8" cy="13.2" r="0.85" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** KPI:租赁 — 钥匙卡片 */
|
||||
export function IconKpiLease({ size = 14, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<circle cx="9" cy="10" r="3.2" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path
|
||||
d="M11.4 12.2L18 18.8M15.2 16l1.6 1.6M17 17.8l1.5 1.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="9" cy="10" r="1.1" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** KPI:物流 — 厢式货车 */
|
||||
export function IconKpiLogistics({ size = 14, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M3.5 7.5h10.5v9H3.5v-9z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M14 10.5h3.2L20 13.6v3H14v-6.1z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M3.5 12.2h10.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<circle cx="7" cy="17.6" r="1.55" stroke="currentColor" strokeWidth="1.5" />
|
||||
<circle cx="17" cy="17.6" r="1.55" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** KPI:库存 — 立体货箱 */
|
||||
export function IconKpiStock({ size = 14, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M4.5 9.2L12 5.5l7.5 3.7v7.1L12 20l-7.5-4V9.2z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path d="M4.5 9.2L12 13l7.5-3.8M12 13v7" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** KPI:非运营 — 暂停运营 */
|
||||
export function IconKpiIdle({ size = 14, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<circle cx="12" cy="12" r="8" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path d="M10 9.2v5.6M14 9.2v5.6" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** KPI:退出运营 — 离场箭头 */
|
||||
export function IconKpiExit({ size = 14, className }: Props) {
|
||||
return (
|
||||
<svg className={className} width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M10 5.5H7.2A2.2 2.2 0 005 7.7v8.6A2.2 2.2 0 007.2 18.5H10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<path
|
||||
d="M13 12h7.5M17.2 8.8L20.5 12l-3.3 3.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
import React, { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { VehicleRecord } from '../utils/filter';
|
||||
import { daysUntilExpire } from '../../vehicle-management/utils/vehicle';
|
||||
import {
|
||||
insuranceStatusTone,
|
||||
resolveVehicleInsuranceOverview,
|
||||
type InsuranceHistoryRow,
|
||||
type InsuranceTypeCoverage,
|
||||
type VehicleInsuranceTypeKey,
|
||||
} from '../../vehicle-management/utils/vehicleInsuranceRecords';
|
||||
import { showAppToast } from './AppToast';
|
||||
import { IconX } from './Icons';
|
||||
import { RecordsEmpty } from './RecordsEmpty';
|
||||
import {
|
||||
RecordTimeline,
|
||||
sortByDateDesc,
|
||||
timelineDatePart,
|
||||
type TimelineItem,
|
||||
} from './RecordTimeline';
|
||||
|
||||
function ExpireInline({ date }: { date: string }) {
|
||||
if (!date) return <span>—</span>;
|
||||
const days = daysUntilExpire(date);
|
||||
let tag = '';
|
||||
let tone: 'ok' | 'warn' | 'danger' = 'ok';
|
||||
if (days !== null) {
|
||||
if (days < 0) {
|
||||
tag = `已过期${Math.abs(days)}天`;
|
||||
tone = 'danger';
|
||||
} else {
|
||||
tag = `剩余${days}天`;
|
||||
if (days <= 30) tone = 'warn';
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span className="h5-va-expire">
|
||||
<span className="h5-va-expire-date">{date.slice(0, 10)}</span>
|
||||
{tag ? <span className={`h5-va-expire-tag h5-va-expire-tag--${tone}`}>{tag}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusPill({
|
||||
label,
|
||||
tone,
|
||||
}: {
|
||||
label: string;
|
||||
tone: 'green' | 'amber' | 'red' | 'gray' | 'blue';
|
||||
}) {
|
||||
return <span className={`h5-va-ins-pill h5-va-ins-pill--${tone}`}>{label}</span>;
|
||||
}
|
||||
|
||||
function shortStatusLabel(status: InsuranceTypeCoverage['status']): string {
|
||||
switch (status) {
|
||||
case 'not_purchased':
|
||||
return '未购';
|
||||
case 'valid':
|
||||
return '正常';
|
||||
case 'warning':
|
||||
return '临期';
|
||||
case 'expired':
|
||||
return '到期';
|
||||
case 'suspended':
|
||||
return '停保';
|
||||
case 'cancelled':
|
||||
return '退保';
|
||||
default:
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
|
||||
function TypeCard({
|
||||
coverage,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
coverage: InsuranceTypeCoverage;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const tone = insuranceStatusTone(coverage.status);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`h5-va-ins-type h5-va-ins-type--${tone}${active ? ' is-active' : ''}`}
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="h5-va-ins-type-title">{coverage.label}</span>
|
||||
<span className={`h5-va-ins-type-dot h5-va-ins-type-dot--${tone}`} aria-hidden />
|
||||
<span className="h5-va-ins-type-state">{shortStatusLabel(coverage.status)}</span>
|
||||
{coverage.history.length > 0 ? (
|
||||
<span className="h5-va-ins-type-count">{coverage.history.length}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function buildPolicyPreviewUrl(fileName: string): string {
|
||||
const seed = fileName.replace(/[^a-zA-Z0-9]/g, '').slice(0, 24) || 'policy';
|
||||
return `https://picsum.photos/seed/${seed}/720/960`;
|
||||
}
|
||||
|
||||
function downloadPolicyFile(fileName: string) {
|
||||
const content = [
|
||||
'OneOS 保单附件(原型演示)',
|
||||
`文件名:${fileName}`,
|
||||
'说明:正式环境将下载真实保单 PDF;本文件仅用于演示下载流程。',
|
||||
'',
|
||||
].join('\n');
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
const safeName = fileName.replace(/[\\/:*?"<>|]/g, '_');
|
||||
link.href = url;
|
||||
link.download = /\.pdf$/i.test(safeName) ? safeName.replace(/\.pdf$/i, '.txt') : `${safeName}.txt`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
showAppToast(`已开始下载:${fileName}`);
|
||||
}
|
||||
|
||||
function PolicyPreviewSheet({
|
||||
fileName,
|
||||
onClose,
|
||||
onDownload,
|
||||
}: {
|
||||
fileName: string;
|
||||
onClose: () => void;
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
const titleId = useId();
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const host = typeof document !== 'undefined'
|
||||
? (document.querySelector('.h5-phone') as HTMLElement | null)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
closeRef.current?.focus();
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const previewUrl = buildPolicyPreviewUrl(fileName);
|
||||
const sheet = (
|
||||
<div className="h5-va-sheet-mask h5-va-policy-mask" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="h5-va-sheet h5-va-sheet--filters h5-va-policy-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="h5-va-sheet-handle" aria-hidden />
|
||||
<div className="h5-va-sheet-head">
|
||||
<h2 id={titleId}>保单预览</h2>
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
className="h5-nav-btn"
|
||||
onClick={onClose}
|
||||
aria-label="关闭保单预览"
|
||||
>
|
||||
<IconX />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h5-va-sheet-body h5-va-policy-body">
|
||||
<div className="h5-va-policy-meta">
|
||||
<div className="h5-va-policy-meta-label">文件名</div>
|
||||
<div className="h5-va-policy-meta-name">{fileName}</div>
|
||||
<div className="h5-va-policy-meta-hint">原型演示影像,正式环境预览真实保单 PDF</div>
|
||||
</div>
|
||||
<div className="h5-va-policy-frame">
|
||||
<img src={previewUrl} alt={`保单预览 ${fileName}`} loading="lazy" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h5-va-sheet-actions">
|
||||
<button type="button" className="h5-btn h5-btn--ghost" onClick={onClose}>
|
||||
关闭
|
||||
</button>
|
||||
<button type="button" className="h5-btn h5-btn--primary" onClick={onDownload}>
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (host) return createPortal(sheet, host);
|
||||
return sheet;
|
||||
}
|
||||
|
||||
function HistoryTimeline({
|
||||
rows,
|
||||
onPreview,
|
||||
onDownload,
|
||||
}: {
|
||||
rows: InsuranceHistoryRow[];
|
||||
onPreview: (fileName: string) => void;
|
||||
onDownload: (fileName: string) => void;
|
||||
}) {
|
||||
const items = useMemo((): TimelineItem[] => {
|
||||
const sorted = sortByDateDesc(rows, (row) => {
|
||||
const purchase = timelineDatePart(row.purchaseTime);
|
||||
const expire = timelineDatePart(row.expireDate);
|
||||
return purchase || expire;
|
||||
});
|
||||
return sorted.map((row) => {
|
||||
const open = row.policyStatus === '正常';
|
||||
return {
|
||||
id: row.id,
|
||||
date: timelineDatePart(row.purchaseTime) || timelineDatePart(row.expireDate) || '—',
|
||||
accent: open && Boolean(row.remainText && !row.remainText.includes('过期')),
|
||||
badge: row.purchaseType,
|
||||
title: row.policyNo || '—',
|
||||
subtitle: row.company || undefined,
|
||||
meta: [
|
||||
{ label: '状态', value: row.policyStatus || '—' },
|
||||
{
|
||||
label: '到期',
|
||||
value: row.expireDate ? <ExpireInline date={row.expireDate} /> : '—',
|
||||
},
|
||||
],
|
||||
footer: (
|
||||
<div className="h5-va-ins-history-file">
|
||||
{row.policyFileName ? (
|
||||
<div className="h5-va-ins-file-row">
|
||||
<div className="h5-va-ins-file-name" title={row.policyFileName}>
|
||||
{row.policyFileName}
|
||||
</div>
|
||||
<div className="h5-va-ins-file-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="h5-va-ins-file-btn"
|
||||
onClick={() => onPreview(row.policyFileName!)}
|
||||
>
|
||||
预览
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h5-va-ins-file-btn h5-va-ins-file-btn--primary"
|
||||
onClick={() => onDownload(row.policyFileName!)}
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="h5-va-ins-file-empty">无附件</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
}, [rows, onPreview, onDownload]);
|
||||
|
||||
return <RecordTimeline items={items} label="采购批单历史时间轴" />;
|
||||
}
|
||||
|
||||
export function InsuranceRecordsPanel({ record }: { record: VehicleRecord }) {
|
||||
const overview = useMemo(() => resolveVehicleInsuranceOverview(record), [record]);
|
||||
const [activeType, setActiveType] = useState<VehicleInsuranceTypeKey>('compulsory');
|
||||
const [previewFile, setPreviewFile] = useState<string | null>(null);
|
||||
const active = overview.types.find((item) => item.key === activeType) ?? overview.types[0];
|
||||
|
||||
function handleDownload(fileName: string) {
|
||||
downloadPolicyFile(fileName);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h5-va-ins-panel">
|
||||
<section className="h5-va-ins-hero" aria-label="保险总览">
|
||||
<div className="h5-va-ins-hero-row">
|
||||
<span className="h5-va-ins-hero-title">车辆保险档案</span>
|
||||
<StatusPill
|
||||
label={overview.overallLabel}
|
||||
tone={overview.overallTone}
|
||||
/>
|
||||
</div>
|
||||
{overview.overallTip ? (
|
||||
<p className="h5-va-ins-hero-tip">{overview.overallTip}</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<div className="h5-va-ins-type-grid" role="tablist" aria-label="险种">
|
||||
{overview.types.map((coverage) => (
|
||||
<TypeCard
|
||||
key={coverage.key}
|
||||
coverage={coverage}
|
||||
active={active.key === coverage.key}
|
||||
onClick={() => setActiveType(coverage.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="h5-va-section" role="tabpanel" aria-label={active.label}>
|
||||
<div className="h5-va-ins-panel-head">
|
||||
<h3 className="h5-va-section-title">{active.label}</h3>
|
||||
<StatusPill label={active.statusLabel} tone={insuranceStatusTone(active.status)} />
|
||||
</div>
|
||||
|
||||
{active.history.length === 0 ? (
|
||||
<RecordsEmpty
|
||||
compact
|
||||
title={`${active.label}暂无记录`}
|
||||
desc="请在 PC「保险采购」录入或导入保单后,手机端即可同步查看"
|
||||
tip="切换上方险种可查看其他保险档案"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="h5-va-field-grid h5-va-ins-summary">
|
||||
<div className="h5-va-field">
|
||||
<div className="h5-va-field-label">保单号</div>
|
||||
<div className="h5-va-field-value h5-va-field-value--code">{active.policyNo || '—'}</div>
|
||||
</div>
|
||||
<div className="h5-va-field">
|
||||
<div className="h5-va-field-label">保险公司</div>
|
||||
<div className="h5-va-field-value">{active.company || '—'}</div>
|
||||
</div>
|
||||
<div className="h5-va-field">
|
||||
<div className="h5-va-field-label">到期日期</div>
|
||||
<div className="h5-va-field-value">
|
||||
{active.expireDate ? <ExpireInline date={active.expireDate} /> : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h5-va-field">
|
||||
<div className="h5-va-field-label">保费(元)</div>
|
||||
<div className="h5-va-field-value">{active.premium || '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h5-va-ins-history-list">
|
||||
<div className="h5-va-ins-history-caption">采购 / 批单历史</div>
|
||||
<HistoryTimeline
|
||||
rows={active.history}
|
||||
onPreview={setPreviewFile}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{previewFile ? (
|
||||
<PolicyPreviewSheet
|
||||
fileName={previewFile}
|
||||
onClose={() => setPreviewFile(null)}
|
||||
onDownload={() => handleDownload(previewFile)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import type { VehicleRecord } from '../utils/filter';
|
||||
import {
|
||||
resolveVehicleLeaseRecords,
|
||||
type LeaseRecordRow,
|
||||
} from '../../vehicle-management/utils/leaseRecords';
|
||||
import { ensureLeaseRecords } from '../utils/demo-biz-records';
|
||||
import { IconCircleX, IconSearch } from './Icons';
|
||||
import { RecordsEmpty } from './RecordsEmpty';
|
||||
import { RecordTimeline, type TimelineItem } from './RecordTimeline';
|
||||
|
||||
function hasReturnDate(row: LeaseRecordRow): boolean {
|
||||
const value = (row.returnDate || '').trim();
|
||||
return Boolean(value) && value !== '-' && value !== '—';
|
||||
}
|
||||
|
||||
/** 时间轴排序键:取交车/还车中较近的日期,近→远 */
|
||||
function timelineSortKey(row: LeaseRecordRow): string {
|
||||
const delivery = (row.deliveryDate || '').trim();
|
||||
const ret = hasReturnDate(row) ? row.returnDate.trim() : '';
|
||||
if (delivery && ret) return delivery > ret ? delivery : ret;
|
||||
return ret || delivery || '';
|
||||
}
|
||||
|
||||
function formatReturnLabel(row: LeaseRecordRow): string {
|
||||
return hasReturnDate(row) ? row.returnDate : '未还车';
|
||||
}
|
||||
|
||||
function matchesLeaseSearch(row: LeaseRecordRow, query: string): boolean {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return true;
|
||||
const customer = (row.customerName || '').toLowerCase();
|
||||
const project = (row.projectName || '').toLowerCase();
|
||||
return customer.includes(needle) || project.includes(needle);
|
||||
}
|
||||
|
||||
export function LeaseRecordsPanel({ record }: { record: VehicleRecord }) {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
const allRows = useMemo(() => {
|
||||
const list = [...ensureLeaseRecords(record, resolveVehicleLeaseRecords(record))];
|
||||
return list.sort((a, b) => timelineSortKey(b).localeCompare(timelineSortKey(a)));
|
||||
}, [record]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => allRows.filter((row) => matchesLeaseSearch(row, query)),
|
||||
[allRows, query],
|
||||
);
|
||||
|
||||
const items = useMemo(
|
||||
(): TimelineItem[] =>
|
||||
rows.map((row) => {
|
||||
const returned = hasReturnDate(row);
|
||||
return {
|
||||
id: row.id,
|
||||
date: timelineSortKey(row) || '—',
|
||||
accent: !returned,
|
||||
badge: row.businessType || undefined,
|
||||
title: row.contractNo || '—',
|
||||
subtitle: row.projectName || undefined,
|
||||
meta: [
|
||||
{ label: '客户', value: row.customerName || '—' },
|
||||
{ label: '交车', value: row.deliveryDate || '—' },
|
||||
{
|
||||
label: '还车',
|
||||
value: formatReturnLabel(row),
|
||||
pending: !returned,
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
[rows],
|
||||
);
|
||||
|
||||
const hasQuery = Boolean(query.trim());
|
||||
|
||||
if (!allRows.length) {
|
||||
return (
|
||||
<RecordsEmpty
|
||||
title="暂无记录"
|
||||
desc="该车辆暂无租赁交还车记录,可回 PC 录入后同步查看"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h5-va-tl-panel h5-va-section--records" aria-label="租赁记录">
|
||||
<div className="h5-va-tl-search h5-va-search">
|
||||
<IconSearch size={16} />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder="搜索客户名称 / 项目名称"
|
||||
enterKeyHint="search"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
inputMode="search"
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
aria-label="搜索客户名称或项目名称"
|
||||
/>
|
||||
{hasQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
className="h5-va-clear-btn"
|
||||
onClick={() => setQuery('')}
|
||||
aria-label="清空搜索"
|
||||
>
|
||||
<IconCircleX size={16} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!items.length ? (
|
||||
<div className="h5-va-empty h5-va-empty--inset" role="status">
|
||||
<p className="h5-va-empty-title">暂无匹配记录</p>
|
||||
<p className="h5-va-empty-desc">换个关键词,或清空搜索查看全部</p>
|
||||
<div className="h5-va-empty-actions">
|
||||
<button type="button" className="h5-btn h5-btn--primary" onClick={() => setQuery('')}>
|
||||
清空搜索
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<RecordTimeline items={items} label="租赁记录时间轴" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import type { VehicleRecord } from '../utils/filter';
|
||||
import {
|
||||
daysUntilExpire,
|
||||
formatDateYmd,
|
||||
isEmpty,
|
||||
isShanghaiPlate,
|
||||
} from '../../vehicle-management/utils/vehicle';
|
||||
import {
|
||||
certPhotoStatus,
|
||||
formatLicenseBalance,
|
||||
formatLicenseDisplay,
|
||||
LICENSE_CERT_NAV,
|
||||
resolveVehicleLicenseBundle,
|
||||
type VehicleLicenseBundle,
|
||||
} from '../../vehicle-management/utils/vehicleLicenses';
|
||||
|
||||
type CertKey = keyof VehicleLicenseBundle;
|
||||
|
||||
function ExpireValue({ date, emptyLabel = '未上传' }: { date: string; emptyLabel?: string }) {
|
||||
if (isEmpty(date)) return <>{emptyLabel}</>;
|
||||
const text = formatDateYmd(date) || String(date).slice(0, 10);
|
||||
const days = daysUntilExpire(date);
|
||||
let tag: string | null = null;
|
||||
let tone: 'ok' | 'warn' | 'danger' = 'ok';
|
||||
if (days !== null) {
|
||||
if (days < 0) {
|
||||
tag = `已过期${Math.abs(days)}天`;
|
||||
tone = 'danger';
|
||||
} else {
|
||||
tag = `剩余${days}天`;
|
||||
if (days <= 30) tone = 'warn';
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span className="h5-va-expire">
|
||||
<span className="h5-va-expire-date">{text}</span>
|
||||
{tag ? <span className={`h5-va-expire-tag h5-va-expire-tag--${tone}`}>{tag}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRemainDaysLabel(date: string): string {
|
||||
if (isEmpty(date)) return '—';
|
||||
const days = daysUntilExpire(date);
|
||||
if (days === null) return '—';
|
||||
return days < 0 ? `已过期${Math.abs(days)}天` : `剩余${days}天`;
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="h5-va-field">
|
||||
<div className="h5-va-field-label">{label}</div>
|
||||
<div className="h5-va-field-value">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CertStatusBadge({ status }: { status: 'uploaded' | 'empty' | 'bound' | 'unbound' }) {
|
||||
const label =
|
||||
status === 'uploaded' ? '已上传'
|
||||
: status === 'empty' ? '未上传'
|
||||
: status === 'bound' ? '已绑定'
|
||||
: '未绑定';
|
||||
return (
|
||||
<span className={`h5-va-cert-badge h5-va-cert-badge--${status === 'bound' || status === 'uploaded' ? 'ok' : 'empty'}`}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LicensePhotoRow({ photos, alt }: { photos: string[]; alt: string }) {
|
||||
if (!photos.length) {
|
||||
return <div className="h5-va-cert-photos-empty">未上传影像</div>;
|
||||
}
|
||||
return (
|
||||
<div className="h5-va-cert-photos" aria-label={`${alt}影像`}>
|
||||
{photos.map((url, index) => (
|
||||
<div key={`${url}-${index}`} className="h5-va-cert-photo">
|
||||
<img src={url} alt={`${alt}${index + 1}`} loading="lazy" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CertSection({
|
||||
id,
|
||||
title,
|
||||
status,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'uploaded' | 'empty' | 'bound' | 'unbound';
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className="h5-va-section h5-va-cert-section" id={id}>
|
||||
<div className="h5-va-cert-head">
|
||||
<h3 className="h5-va-section-title">{title}</h3>
|
||||
<CertStatusBadge status={status} />
|
||||
</div>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function certAnchorId(key: CertKey): string {
|
||||
return `h5-va-cert-${key}`;
|
||||
}
|
||||
|
||||
/** 在手机壳 .h5-body 内滚动到证照区块(scrollIntoView 会滚错外层容器) */
|
||||
function scrollCertSectionIntoBody(key: CertKey) {
|
||||
const body = document.querySelector('.h5-body') as HTMLElement | null;
|
||||
const target = document.getElementById(certAnchorId(key));
|
||||
if (!body || !target) return;
|
||||
|
||||
const tabs = body.querySelector('.h5-va-tabs') as HTMLElement | null;
|
||||
const indexBar = body.querySelector('.h5-va-cert-index') as HTMLElement | null;
|
||||
const stickyOffset = (tabs?.offsetHeight || 0) + (indexBar?.offsetHeight || 0) + 6;
|
||||
|
||||
const bodyRect = body.getBoundingClientRect();
|
||||
const targetRect = target.getBoundingClientRect();
|
||||
const nextTop = body.scrollTop + (targetRect.top - bodyRect.top) - stickyOffset;
|
||||
body.scrollTo({ top: Math.max(0, nextTop), behavior: 'smooth' });
|
||||
}
|
||||
|
||||
function scrollChipIntoIndex(chip: HTMLButtonElement | null | undefined) {
|
||||
if (!chip) return;
|
||||
const indexBar = chip.closest('.h5-va-cert-index') as HTMLElement | null;
|
||||
if (!indexBar) return;
|
||||
const nextLeft = chip.offsetLeft - (indexBar.clientWidth - chip.offsetWidth) / 2;
|
||||
indexBar.scrollTo({ left: Math.max(0, nextLeft), behavior: 'smooth' });
|
||||
}
|
||||
|
||||
export function LicenseCertPanel({ record }: { record: VehicleRecord }) {
|
||||
const bundle = resolveVehicleLicenseBundle(record);
|
||||
const shanghaiPlate = isShanghaiPlate(record.plateNo);
|
||||
const shEval = record.ratingTime || bundle.driverLicense.shNextEvaluation || '';
|
||||
const hasHydrogen = Boolean(bundle.hydrogenCard.cardNo);
|
||||
const [activeKey, setActiveKey] = useState<CertKey>('driverLicense');
|
||||
const chipRefs = useRef<Partial<Record<CertKey, HTMLButtonElement | null>>>({});
|
||||
|
||||
useEffect(() => {
|
||||
scrollChipIntoIndex(chipRefs.current[activeKey]);
|
||||
}, [activeKey]);
|
||||
|
||||
function selectCert(key: CertKey) {
|
||||
setActiveKey(key);
|
||||
// 等选中态与 sticky 高度稳定后再滚
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
scrollChipIntoIndex(chipRefs.current[key]);
|
||||
scrollCertSectionIntoBody(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h5-va-cert-panel">
|
||||
<div className="h5-va-cert-index" role="tablist" aria-label="证照分类">
|
||||
{LICENSE_CERT_NAV.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
ref={(node) => {
|
||||
chipRefs.current[item.key] = node;
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={activeKey === item.key}
|
||||
className={`h5-va-cert-index-chip${activeKey === item.key ? ' is-active' : ''}`}
|
||||
onClick={() => selectCert(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<CertSection
|
||||
id={certAnchorId('driverLicense')}
|
||||
title="行驶证"
|
||||
status={certPhotoStatus(bundle.driverLicense)}
|
||||
>
|
||||
<LicensePhotoRow photos={bundle.driverLicense.photos} alt="行驶证" />
|
||||
<div className="h5-va-field-grid">
|
||||
<Field label="注册日期" value={formatLicenseDisplay(bundle.driverLicense.regDate)} />
|
||||
<Field label="发证日期" value={formatLicenseDisplay(bundle.driverLicense.issueDate)} />
|
||||
<Field label="强制报废日期" value={formatLicenseDisplay(bundle.driverLicense.scrapDate)} />
|
||||
<Field
|
||||
label="检验有效期至"
|
||||
value={<ExpireValue date={bundle.driverLicense.expireDate || record.inspectExpire} />}
|
||||
/>
|
||||
</div>
|
||||
{shanghaiPlate ? (
|
||||
<div className="h5-va-cert-sh">
|
||||
<div className="h5-va-cert-sh-badge">沪牌车辆等级评定</div>
|
||||
<div className="h5-va-field-grid">
|
||||
<Field
|
||||
label="下次等级评定时间"
|
||||
value={isEmpty(shEval) ? '未上传' : (formatDateYmd(shEval) || shEval.slice(0, 10))}
|
||||
/>
|
||||
<Field label="剩余天数" value={formatRemainDaysLabel(shEval)} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CertSection>
|
||||
|
||||
<CertSection
|
||||
id={certAnchorId('transportLicense')}
|
||||
title="道路运输证"
|
||||
status={certPhotoStatus(bundle.transportLicense)}
|
||||
>
|
||||
<LicensePhotoRow photos={bundle.transportLicense.photos} alt="道路运输证" />
|
||||
<div className="h5-va-field-grid">
|
||||
<Field label="经营许可证号" value={formatLicenseDisplay(bundle.transportLicense.licenseNo)} />
|
||||
<Field label="核发时间" value={formatLicenseDisplay(bundle.transportLicense.issueDate)} />
|
||||
<Field label="证件有效期" value={<ExpireValue date={bundle.transportLicense.expireDate} />} />
|
||||
<Field label="审验有效期" value={<ExpireValue date={bundle.transportLicense.inspectValidUntil} />} />
|
||||
</div>
|
||||
</CertSection>
|
||||
|
||||
<CertSection
|
||||
id={certAnchorId('registrationCert')}
|
||||
title="登记证"
|
||||
status={certPhotoStatus(bundle.registrationCert)}
|
||||
>
|
||||
<LicensePhotoRow photos={bundle.registrationCert.photos} alt="登记证" />
|
||||
</CertSection>
|
||||
|
||||
<CertSection
|
||||
id={certAnchorId('specialEquipCert')}
|
||||
title="特种设备使用登记证"
|
||||
status={certPhotoStatus(bundle.specialEquipCert)}
|
||||
>
|
||||
<LicensePhotoRow photos={bundle.specialEquipCert.photos} alt="特种设备使用登记证" />
|
||||
</CertSection>
|
||||
|
||||
<CertSection
|
||||
id={certAnchorId('specialEquipDecal')}
|
||||
title="特种设备使用标识"
|
||||
status={certPhotoStatus(bundle.specialEquipDecal)}
|
||||
>
|
||||
<LicensePhotoRow photos={bundle.specialEquipDecal.photos} alt="特种设备使用标识" />
|
||||
<div className="h5-va-field-grid">
|
||||
<Field
|
||||
label="下次检验日期"
|
||||
value={<ExpireValue date={bundle.specialEquipDecal.nextInspectDate} />}
|
||||
/>
|
||||
</div>
|
||||
</CertSection>
|
||||
|
||||
<CertSection
|
||||
id={certAnchorId('hydrogenCard')}
|
||||
title="加氢卡"
|
||||
status={hasHydrogen ? 'bound' : 'unbound'}
|
||||
>
|
||||
{hasHydrogen ? (
|
||||
<div className="h5-va-field-grid">
|
||||
<Field label="加氢卡号" value={bundle.hydrogenCard.cardNo} />
|
||||
<Field label="实时余额" value={`¥ ${formatLicenseBalance(bundle.hydrogenCard.balance)}`} />
|
||||
<Field label="配发时间" value={formatLicenseDisplay(bundle.hydrogenCard.issueDate)} />
|
||||
<Field label="配发经办人" value={formatLicenseDisplay(bundle.hydrogenCard.issueUser)} />
|
||||
<Field label="卡类型" value={formatLicenseDisplay(bundle.hydrogenCard.cardType)} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="h5-va-cert-empty-note">该车辆尚未绑定加氢卡</div>
|
||||
)}
|
||||
</CertSection>
|
||||
|
||||
<CertSection
|
||||
id={certAnchorId('safetyValve')}
|
||||
title="安全阀"
|
||||
status={certPhotoStatus(bundle.safetyValve)}
|
||||
>
|
||||
<LicensePhotoRow photos={bundle.safetyValve.photos} alt="安全阀" />
|
||||
<div className="h5-va-field-grid">
|
||||
<Field label="本次检验日期" value={formatLicenseDisplay(bundle.safetyValve.inspectDate)} />
|
||||
<Field
|
||||
label="下次检测日期"
|
||||
value={<ExpireValue date={bundle.safetyValve.nextInspectDate} />}
|
||||
/>
|
||||
</div>
|
||||
</CertSection>
|
||||
|
||||
<CertSection
|
||||
id={certAnchorId('pressureGauge')}
|
||||
title="压力表"
|
||||
status={certPhotoStatus(bundle.pressureGauge)}
|
||||
>
|
||||
<LicensePhotoRow photos={bundle.pressureGauge.photos} alt="压力表" />
|
||||
<div className="h5-va-field-grid">
|
||||
<Field label="本次检验日期" value={formatLicenseDisplay(bundle.pressureGauge.inspectDate)} />
|
||||
<Field
|
||||
label="下次检验日期"
|
||||
value={<ExpireValue date={bundle.pressureGauge.nextInspectDate} />}
|
||||
/>
|
||||
</div>
|
||||
</CertSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
const ITEM_H = 36;
|
||||
const VISIBLE = 5;
|
||||
const PAD = Math.floor(VISIBLE / 2);
|
||||
|
||||
function pad2(n: number) {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function daysInMonth(year: number, month: number) {
|
||||
return new Date(year, month, 0).getDate();
|
||||
}
|
||||
|
||||
function parseYmd(value?: string): { y: number; m: number; d: number } {
|
||||
const now = new Date();
|
||||
if (!value || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
return { y: now.getFullYear(), m: now.getMonth() + 1, d: now.getDate() };
|
||||
}
|
||||
const [ys, ms, ds] = value.split('-');
|
||||
const y = Number(ys);
|
||||
const m = Number(ms);
|
||||
const d = Number(ds);
|
||||
if (!y || !m || !d) return { y: now.getFullYear(), m: now.getMonth() + 1, d: now.getDate() };
|
||||
return { y, m, d: Math.min(d, daysInMonth(y, m)) };
|
||||
}
|
||||
|
||||
function formatYmd(y: number, m: number, d: number) {
|
||||
return `${y}-${pad2(m)}-${pad2(d)}`;
|
||||
}
|
||||
|
||||
function formatDisplay(value?: string) {
|
||||
if (!value) return '';
|
||||
const { y, m, d } = parseYmd(value);
|
||||
return `${y}年${pad2(m)}月${pad2(d)}日`;
|
||||
}
|
||||
|
||||
type WheelProps = {
|
||||
options: { value: number; label: string }[];
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
'aria-label': string;
|
||||
};
|
||||
|
||||
function WheelColumn({ options, value, onChange, 'aria-label': ariaLabel }: WheelProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const lock = useRef(false);
|
||||
const timer = useRef(0);
|
||||
const idx = Math.max(
|
||||
0,
|
||||
options.findIndex((o) => o.value === value),
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
lock.current = true;
|
||||
el.scrollTop = idx * ITEM_H;
|
||||
requestAnimationFrame(() => {
|
||||
lock.current = false;
|
||||
});
|
||||
}, [idx, options.length]);
|
||||
|
||||
const settle = () => {
|
||||
const el = ref.current;
|
||||
if (!el || lock.current) return;
|
||||
const next = Math.round(el.scrollTop / ITEM_H);
|
||||
const clamped = Math.max(0, Math.min(options.length - 1, next));
|
||||
el.scrollTop = clamped * ITEM_H;
|
||||
const opt = options[clamped];
|
||||
if (opt && opt.value !== value) onChange(opt.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h5-va-date-wheel-col" aria-label={ariaLabel}>
|
||||
<div
|
||||
ref={ref}
|
||||
className="h5-va-date-wheel-scroll"
|
||||
onScroll={() => {
|
||||
window.clearTimeout(timer.current);
|
||||
timer.current = window.setTimeout(settle, 90);
|
||||
}}
|
||||
onTouchEnd={settle}
|
||||
onMouseUp={settle}
|
||||
>
|
||||
<div style={{ height: PAD * ITEM_H }} aria-hidden />
|
||||
{options.map((o) => (
|
||||
<div
|
||||
key={o.value}
|
||||
className={`h5-va-date-wheel-item${o.value === value ? ' is-active' : ''}`}
|
||||
style={{ height: ITEM_H }}
|
||||
>
|
||||
{o.label}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ height: PAD * ITEM_H }} aria-hidden />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type SheetProps = {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
value?: string;
|
||||
minYear?: number;
|
||||
maxYear?: number;
|
||||
onConfirm: (ymd: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/** iOS 风格底部滚轮日期选择(精确到日) */
|
||||
export function NativeDatePickerSheet({
|
||||
open,
|
||||
title = '选择日期',
|
||||
value,
|
||||
minYear = 2015,
|
||||
maxYear,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: SheetProps) {
|
||||
const parsed = parseYmd(value);
|
||||
const [y, setY] = useState(parsed.y);
|
||||
const [m, setM] = useState(parsed.m);
|
||||
const [d, setD] = useState(parsed.d);
|
||||
const [host, setHost] = useState<HTMLElement | null>(null);
|
||||
const yearMax = maxYear ?? new Date().getFullYear() + 1;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) return;
|
||||
setHost(document.querySelector('.h5-phone') as HTMLElement | null);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const next = parseYmd(value);
|
||||
setY(next.y);
|
||||
setM(next.m);
|
||||
setD(next.d);
|
||||
}, [open, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const maxDay = daysInMonth(y, m);
|
||||
useEffect(() => {
|
||||
if (d > maxDay) setD(maxDay);
|
||||
}, [d, maxDay]);
|
||||
|
||||
const years = useMemo(
|
||||
() => Array.from({ length: yearMax - minYear + 1 }, (_, i) => minYear + i).map((n) => ({ value: n, label: `${n}年` })),
|
||||
[minYear, yearMax],
|
||||
);
|
||||
const months = useMemo(
|
||||
() => Array.from({ length: 12 }, (_, i) => i + 1).map((n) => ({ value: n, label: `${n}月` })),
|
||||
[],
|
||||
);
|
||||
const days = useMemo(
|
||||
() => Array.from({ length: maxDay }, (_, i) => i + 1).map((n) => ({ value: n, label: `${n}日` })),
|
||||
[maxDay],
|
||||
);
|
||||
|
||||
if (!open || !host) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="h5-va-sheet-mask h5-va-native-mask" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="h5-va-sheet h5-va-native-sheet h5-va-date-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="h5-va-sheet-handle" aria-hidden />
|
||||
<div className="h5-va-native-head">
|
||||
<button type="button" className="h5-va-native-head-btn h5-va-native-head-btn--text" onClick={onClose}>
|
||||
取消
|
||||
</button>
|
||||
<h2 className="h5-va-native-title">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="h5-va-native-head-btn h5-va-native-head-btn--text h5-va-native-head-btn--done"
|
||||
onClick={() => {
|
||||
onConfirm(formatYmd(y, m, Math.min(d, daysInMonth(y, m))));
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</div>
|
||||
<div className="h5-va-date-wheels" aria-label="年 月 日">
|
||||
<div className="h5-va-date-wheel-highlight" aria-hidden />
|
||||
<WheelColumn aria-label="年" options={years} value={y} onChange={setY} />
|
||||
<WheelColumn aria-label="月" options={months} value={m} onChange={setM} />
|
||||
<WheelColumn aria-label="日" options={days} value={Math.min(d, maxDay)} onChange={setD} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
host,
|
||||
);
|
||||
}
|
||||
|
||||
type FieldProps = {
|
||||
value?: string;
|
||||
placeholder?: string;
|
||||
title?: string;
|
||||
disabled?: boolean;
|
||||
onChange: (ymd: string) => void;
|
||||
className?: string;
|
||||
'aria-label'?: string;
|
||||
};
|
||||
|
||||
/** 触发器 + 原生滚轮日期选择(精确到日) */
|
||||
export function NativeDateField({
|
||||
value,
|
||||
placeholder = '请选择',
|
||||
title = '选择日期',
|
||||
disabled,
|
||||
onChange,
|
||||
className,
|
||||
'aria-label': ariaLabel,
|
||||
}: FieldProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`h5-va-native-date-field${className ? ` ${className}` : ''}${!value ? ' is-empty' : ''}`}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel || title}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<span>{value ? formatDisplay(value) : placeholder}</span>
|
||||
</button>
|
||||
<NativeDatePickerSheet
|
||||
open={open}
|
||||
title={title}
|
||||
value={value}
|
||||
onConfirm={onChange}
|
||||
onClose={() => setOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
import React, { useEffect, useId, useMemo, useRef, useState } from 'react';
|
||||
import { IconCheck, IconChevronLeft, IconSearch, IconX } from './Icons';
|
||||
|
||||
export type PickerOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
meta?: string;
|
||||
};
|
||||
|
||||
type BaseProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
options: PickerOption[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onClose: () => void;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
/** Show leading back instead of close */
|
||||
onBack?: () => void;
|
||||
annotationId?: string;
|
||||
closeOnSelect?: boolean;
|
||||
};
|
||||
|
||||
function useSheetLock(open: boolean, onClose: () => void) {
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onCloseRef.current();
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
window.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
}
|
||||
|
||||
/** App 原生风格底部列表选择器 */
|
||||
export function NativePickerSheet({
|
||||
open,
|
||||
title,
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
onClose,
|
||||
searchable = false,
|
||||
searchPlaceholder = '搜索',
|
||||
emptyText = '暂无可选项',
|
||||
onBack,
|
||||
annotationId,
|
||||
closeOnSelect = true,
|
||||
}: BaseProps) {
|
||||
const titleId = useId();
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
useSheetLock(open, onClose);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('');
|
||||
return;
|
||||
}
|
||||
closeRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return options;
|
||||
return options.filter((item) => (
|
||||
item.label.toLowerCase().includes(q)
|
||||
|| item.value.toLowerCase().includes(q)
|
||||
|| (item.meta || '').toLowerCase().includes(q)
|
||||
));
|
||||
}, [options, query]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="h5-va-sheet-mask h5-va-native-mask" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="h5-va-sheet h5-va-native-sheet"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
data-annotation-id={annotationId}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="h5-va-sheet-handle" aria-hidden />
|
||||
<div className="h5-va-native-head">
|
||||
{onBack ? (
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
className="h5-va-native-head-btn"
|
||||
onClick={onBack}
|
||||
aria-label="返回"
|
||||
>
|
||||
<IconChevronLeft size={20} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
ref={closeRef}
|
||||
type="button"
|
||||
className="h5-va-native-head-btn h5-va-native-head-btn--text"
|
||||
onClick={onClose}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
)}
|
||||
<h2 id={titleId} className="h5-va-native-title">{title}</h2>
|
||||
<button type="button" className="h5-va-native-head-btn" onClick={onClose} aria-label="关闭">
|
||||
<IconX size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{searchable ? (
|
||||
<div className="h5-va-native-search">
|
||||
<IconSearch size={16} />
|
||||
<input
|
||||
type="search"
|
||||
value={query}
|
||||
placeholder={searchPlaceholder}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
aria-label={searchPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="h5-va-native-list" role="listbox" aria-label={title}>
|
||||
{filtered.length === 0 ? (
|
||||
<div className="h5-va-native-empty">{emptyText}</div>
|
||||
) : (
|
||||
filtered.map((option) => {
|
||||
const selected = option.value === value;
|
||||
return (
|
||||
<button
|
||||
key={option.value || '__all__'}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
className={`h5-va-native-option${selected ? ' is-selected' : ''}`}
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
if (closeOnSelect) onClose();
|
||||
}}
|
||||
>
|
||||
<span className="h5-va-native-option-main">
|
||||
<span className="h5-va-native-option-label">{option.label}</span>
|
||||
{option.meta ? <span className="h5-va-native-option-meta">{option.meta}</span> : null}
|
||||
</span>
|
||||
{selected ? (
|
||||
<span className="h5-va-native-option-check" aria-hidden>
|
||||
<IconCheck size={18} />
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BrandModelProps = {
|
||||
open: boolean;
|
||||
brand: string;
|
||||
model: string;
|
||||
brandOptions: Array<{ value: string; count: number }>;
|
||||
getModelOptions: (brand: string) => Array<{ value: string; count: number }>;
|
||||
onConfirm: (next: { brand: string; model: string }) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/** 品牌 → 车型 两级原生选择 */
|
||||
export function BrandModelPickerSheet({
|
||||
open,
|
||||
brand,
|
||||
model,
|
||||
brandOptions,
|
||||
getModelOptions,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: BrandModelProps) {
|
||||
const [step, setStep] = useState<'brand' | 'model'>('brand');
|
||||
const [draftBrand, setDraftBrand] = useState(brand);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStep('brand');
|
||||
setDraftBrand(brand);
|
||||
}, [open, brand]);
|
||||
|
||||
const brandPickerOptions = useMemo<PickerOption[]>(() => ([
|
||||
{ value: '', label: '全部品牌', meta: '不限' },
|
||||
...brandOptions.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.value,
|
||||
meta: `${item.count} 辆`,
|
||||
})),
|
||||
]), [brandOptions]);
|
||||
|
||||
const modelPickerOptions = useMemo<PickerOption[]>(() => {
|
||||
const models = getModelOptions(draftBrand);
|
||||
return [
|
||||
{ value: '', label: draftBrand ? `全部「${draftBrand}」` : '全部车型', meta: '不限型号' },
|
||||
...models.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.value,
|
||||
meta: `${item.count} 辆`,
|
||||
})),
|
||||
];
|
||||
}, [draftBrand, getModelOptions]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
if (step === 'brand') {
|
||||
return (
|
||||
<NativePickerSheet
|
||||
open
|
||||
title="选择品牌"
|
||||
options={brandPickerOptions}
|
||||
value={brand}
|
||||
searchable
|
||||
searchPlaceholder="搜索品牌"
|
||||
annotationId="h5-va-brand-picker"
|
||||
closeOnSelect={false}
|
||||
onClose={onClose}
|
||||
onChange={(nextBrand) => {
|
||||
if (!nextBrand) {
|
||||
onConfirm({ brand: '', model: '' });
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setDraftBrand(nextBrand);
|
||||
setStep('model');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NativePickerSheet
|
||||
open
|
||||
title="选择车型"
|
||||
options={modelPickerOptions}
|
||||
value={draftBrand === brand ? model : ''}
|
||||
searchable
|
||||
searchPlaceholder="搜索车型"
|
||||
annotationId="h5-va-model-picker"
|
||||
onBack={() => setStep('brand')}
|
||||
onClose={onClose}
|
||||
onChange={(nextModel) => {
|
||||
onConfirm({ brand: draftBrand, model: nextModel });
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type OperateCityProps = {
|
||||
open: boolean;
|
||||
/** 筛选值:空 | 省 | 省-市 */
|
||||
value: string;
|
||||
provinceOptions: Array<{ value: string; count: number }>;
|
||||
getCityOptions: (province: string) => Array<{ value: string; count: number }>;
|
||||
onConfirm: (next: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
/** 运营城市:省 → 市;支持只选省 */
|
||||
export function OperateCityPickerSheet({
|
||||
open,
|
||||
value,
|
||||
provinceOptions,
|
||||
getCityOptions,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: OperateCityProps) {
|
||||
const [step, setStep] = useState<'province' | 'city'>('province');
|
||||
const [draftProvince, setDraftProvince] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setStep('province');
|
||||
const raw = String(value || '').trim().replace(/-+/g, '-');
|
||||
if (!raw) {
|
||||
setDraftProvince('');
|
||||
return;
|
||||
}
|
||||
if (!raw.includes('-')) {
|
||||
setDraftProvince(raw);
|
||||
return;
|
||||
}
|
||||
setDraftProvince(raw.slice(0, raw.indexOf('-')));
|
||||
}, [open, value]);
|
||||
|
||||
const provincePickerOptions = useMemo<PickerOption[]>(() => ([
|
||||
{ value: '', label: '全部省份', meta: '不限' },
|
||||
...provinceOptions.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.value,
|
||||
meta: `${item.count} 辆`,
|
||||
})),
|
||||
]), [provinceOptions]);
|
||||
|
||||
const cityPickerOptions = useMemo<PickerOption[]>(() => {
|
||||
const cities = getCityOptions(draftProvince);
|
||||
return [
|
||||
{
|
||||
value: '',
|
||||
label: draftProvince ? `全部「${draftProvince}」` : '全部城市',
|
||||
meta: '仅按省筛选',
|
||||
},
|
||||
...cities.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.value,
|
||||
meta: `${item.count} 辆`,
|
||||
})),
|
||||
];
|
||||
}, [draftProvince, getCityOptions]);
|
||||
|
||||
const selectedCity = useMemo(() => {
|
||||
const raw = String(value || '').trim().replace(/-+/g, '-');
|
||||
if (!raw.includes('-')) return '';
|
||||
const province = raw.slice(0, raw.indexOf('-'));
|
||||
const city = raw.slice(raw.indexOf('-') + 1);
|
||||
return province === draftProvince ? city : '';
|
||||
}, [value, draftProvince]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
if (step === 'province') {
|
||||
return (
|
||||
<NativePickerSheet
|
||||
open
|
||||
title="选择省份"
|
||||
options={provincePickerOptions}
|
||||
value={value ? draftProvince : ''}
|
||||
searchable
|
||||
searchPlaceholder="搜索省份"
|
||||
annotationId="h5-va-city-picker"
|
||||
closeOnSelect={false}
|
||||
onClose={onClose}
|
||||
onChange={(nextProvince) => {
|
||||
if (!nextProvince) {
|
||||
onConfirm('');
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
setDraftProvince(nextProvince);
|
||||
setStep('city');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NativePickerSheet
|
||||
open
|
||||
title="选择城市"
|
||||
options={cityPickerOptions}
|
||||
value={selectedCity}
|
||||
searchable
|
||||
searchPlaceholder="搜索城市"
|
||||
annotationId="h5-va-city-picker"
|
||||
onBack={() => setStep('province')}
|
||||
onClose={onClose}
|
||||
onChange={(nextCity) => {
|
||||
if (!nextCity) {
|
||||
onConfirm(draftProvince);
|
||||
} else {
|
||||
onConfirm(`${draftProvince}-${nextCity}`);
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
126
src/prototypes/oneos-h5-vehicle-assets/components/PhoneShell.tsx
Normal file
126
src/prototypes/oneos-h5-vehicle-assets/components/PhoneShell.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
IconBell,
|
||||
IconBriefcase,
|
||||
IconCar,
|
||||
IconMessage,
|
||||
IconPie,
|
||||
IconUser,
|
||||
} from './Icons';
|
||||
import { showAppToast } from './AppToast';
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
/** 弹层等覆盖层,挂在手机壳内,避免超出操作区 */
|
||||
overlays?: React.ReactNode;
|
||||
bodyClassName?: string;
|
||||
showBottomNav?: boolean;
|
||||
left?: React.ReactNode;
|
||||
right?: React.ReactNode;
|
||||
headerMode?: 'app' | 'detail';
|
||||
};
|
||||
|
||||
export function PhoneShell({
|
||||
title = '车辆资产',
|
||||
children,
|
||||
overlays,
|
||||
bodyClassName,
|
||||
showBottomNav = false,
|
||||
left,
|
||||
right,
|
||||
headerMode = 'app',
|
||||
}: Props) {
|
||||
const now = new Date();
|
||||
const timeLabel = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
|
||||
const appHeader = (
|
||||
<header className="h5-nav h5-nav--app">
|
||||
<h1 className="h5-nav-brand">{title}</h1>
|
||||
<div className="h5-nav-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="h5-nav-icon-btn"
|
||||
aria-label="通知"
|
||||
onClick={() => showAppToast('通知中心开发中,本期仅支持查车')}
|
||||
>
|
||||
<IconBell />
|
||||
<span className="h5-nav-badge" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
const detailHeader = (
|
||||
<header className="h5-nav h5-nav--detail">
|
||||
<div className="h5-nav-side">{left || <span className="h5-nav-side-spacer" aria-hidden />}</div>
|
||||
<h1 className="h5-nav-title">{title}</h1>
|
||||
<div className="h5-nav-side h5-nav-side--end">{right || <span className="h5-nav-side-spacer" aria-hidden />}</div>
|
||||
</header>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[
|
||||
'h5-phone',
|
||||
showBottomNav ? 'h5-phone--tabs' : '',
|
||||
headerMode === 'app' || headerMode === 'detail' ? 'h5-phone--immersive' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
>
|
||||
<div className="h5-status-bar" aria-hidden>
|
||||
<span>{timeLabel}</span>
|
||||
<span className="h5-island" />
|
||||
<span>5G · 86%</span>
|
||||
</div>
|
||||
{headerMode === 'app' ? appHeader : detailHeader}
|
||||
<main className={`h5-body${bodyClassName ? ` ${bodyClassName}` : ''}`}>{children}</main>
|
||||
{overlays}
|
||||
{showBottomNav ? (
|
||||
<nav className="h5-tabbar" aria-label="App 底部导航">
|
||||
<div className="h5-tabbar-dock">
|
||||
<button type="button" className="h5-tab is-active" aria-current="page">
|
||||
<IconCar size={22} />
|
||||
<span>车辆</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h5-tab"
|
||||
onClick={() => showAppToast('业务模块未接入本页')}
|
||||
>
|
||||
<IconBriefcase size={22} />
|
||||
<span>业务</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h5-tab-center"
|
||||
onClick={() => showAppToast('工作台未接入本页')}
|
||||
aria-label="工作台"
|
||||
title="工作台"
|
||||
>
|
||||
<IconPie size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h5-tab"
|
||||
onClick={() => showAppToast('消息模块未接入本页')}
|
||||
>
|
||||
<span className="h5-tab-icon-wrap">
|
||||
<IconMessage size={22} />
|
||||
<span className="h5-tab-count">12</span>
|
||||
</span>
|
||||
<span>消息</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="h5-tab"
|
||||
onClick={() => showAppToast('我的模块未接入本页')}
|
||||
>
|
||||
<IconUser size={22} />
|
||||
<span>我的</span>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
|
||||
export type TimelineMetaRow = {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
pending?: boolean;
|
||||
};
|
||||
|
||||
export type TimelineItem = {
|
||||
id: string;
|
||||
date: string;
|
||||
/** 进行中 / 未闭环节点高亮 */
|
||||
accent?: boolean;
|
||||
badge?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
meta?: TimelineMetaRow[];
|
||||
footer?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function RecordTimeline({
|
||||
items,
|
||||
label,
|
||||
}: {
|
||||
items: TimelineItem[];
|
||||
label: string;
|
||||
}) {
|
||||
if (!items.length) return null;
|
||||
|
||||
return (
|
||||
<div className="h5-va-tl" aria-label={label}>
|
||||
{items.map((item, index) => (
|
||||
<article
|
||||
key={item.id}
|
||||
className={`h5-va-tl-item${item.accent ? ' is-open' : ''}`}
|
||||
>
|
||||
<div className="h5-va-tl-axis" aria-hidden>
|
||||
<div className="h5-va-tl-dot" />
|
||||
{index < items.length - 1 ? <div className="h5-va-tl-line" /> : null}
|
||||
</div>
|
||||
<div className="h5-va-tl-main">
|
||||
<div className="h5-va-tl-date">{item.date || '—'}</div>
|
||||
<div className="h5-va-tl-card">
|
||||
<div className="h5-va-tl-title">{item.title || '—'}</div>
|
||||
{item.subtitle ? <div className="h5-va-tl-subtitle">{item.subtitle}</div> : null}
|
||||
{item.meta?.length ? (
|
||||
<div className="h5-va-tl-meta">
|
||||
{item.meta.map((row) => (
|
||||
<div key={row.label} className="h5-va-tl-meta-row">
|
||||
<span className="h5-va-tl-meta-label">{row.label}</span>
|
||||
<span
|
||||
className={`h5-va-tl-meta-value${row.pending ? ' is-pending' : ''}`}
|
||||
>
|
||||
{row.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{item.footer}
|
||||
{item.badge ? <span className="h5-va-tl-badge">{item.badge}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 取日期时间字符串的排序 / 展示用日期部分 */
|
||||
export function timelineDatePart(value: string): string {
|
||||
const text = (value || '').trim();
|
||||
if (!text || text === '-' || text === '—') return '';
|
||||
return text.slice(0, 10);
|
||||
}
|
||||
|
||||
export function sortByDateDesc<T>(rows: T[], getDate: (row: T) => string): T[] {
|
||||
return [...rows].sort((a, b) => getDate(b).localeCompare(getDate(a)));
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { IconEmptyRecords } from './Icons';
|
||||
|
||||
export function RecordsEmpty({
|
||||
title = '暂无记录',
|
||||
desc = '该车辆暂无此类业务记录,可回 PC 录入后同步查看',
|
||||
tip = '演示环境已为多数车辆填充用例数据,刷新后仍为空可换车查看',
|
||||
compact = false,
|
||||
}: {
|
||||
title?: string;
|
||||
desc?: string;
|
||||
tip?: string;
|
||||
/** 嵌套在 Tab 区块内时使用更紧凑的空态 */
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`h5-va-empty h5-va-empty--inset${compact ? ' h5-va-empty--compact' : ''} h5-va-section--records`}
|
||||
role="status"
|
||||
>
|
||||
<div className="h5-va-empty-visual" aria-hidden>
|
||||
<span className="h5-va-empty-orb" />
|
||||
<span className="h5-va-empty-ring" />
|
||||
<IconEmptyRecords size={compact ? 56 : 68} className="h5-va-empty-icon" />
|
||||
</div>
|
||||
<p className="h5-va-empty-title">{title}</p>
|
||||
{desc ? <p className="h5-va-empty-desc">{desc}</p> : null}
|
||||
{tip ? <p className="h5-va-empty-tip">{tip}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { NativeDateField } from './NativeDatePickerSheet';
|
||||
|
||||
export type TimelineDateRange = {
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
|
||||
export type TimelineDatePreset = 'all' | '30' | '90' | '365' | 'custom';
|
||||
|
||||
export const TIMELINE_DATE_PRESETS: Array<{ id: TimelineDatePreset; label: string }> = [
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: '30', label: '近30天' },
|
||||
{ id: '90', label: '近90天' },
|
||||
{ id: '365', label: '近1年' },
|
||||
{ id: 'custom', label: '自定义' },
|
||||
];
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatYmd(date: Date): string {
|
||||
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
function addDays(base: Date, days: number): Date {
|
||||
const next = new Date(base);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 根据快捷项 / 自定义起止解析筛选区间(空表示不限) */
|
||||
export function resolveTimelineDateRange(
|
||||
preset: TimelineDatePreset,
|
||||
customStart: string,
|
||||
customEnd: string,
|
||||
today = new Date(),
|
||||
): TimelineDateRange {
|
||||
if (preset === 'custom') {
|
||||
return {
|
||||
start: (customStart || '').trim().slice(0, 10),
|
||||
end: (customEnd || '').trim().slice(0, 10),
|
||||
};
|
||||
}
|
||||
if (preset === 'all') return { start: '', end: '' };
|
||||
|
||||
const end = formatYmd(today);
|
||||
const days = preset === '30' ? 30 : preset === '90' ? 90 : 365;
|
||||
const start = formatYmd(addDays(today, -(days - 1)));
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export function matchTimelineDate(dateValue: string, range: TimelineDateRange): boolean {
|
||||
const ymd = (dateValue || '').trim().slice(0, 10);
|
||||
if (!ymd || ymd === '-' || ymd === '—') {
|
||||
return !range.start && !range.end;
|
||||
}
|
||||
if (range.start && ymd < range.start) return false;
|
||||
if (range.end && ymd > range.end) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function TimelineDateFilter({
|
||||
preset,
|
||||
customStart,
|
||||
customEnd,
|
||||
onPresetChange,
|
||||
onCustomStartChange,
|
||||
onCustomEndChange,
|
||||
onReset,
|
||||
dateFieldLabel = '时间',
|
||||
}: {
|
||||
preset: TimelineDatePreset;
|
||||
customStart: string;
|
||||
customEnd: string;
|
||||
onPresetChange: (value: TimelineDatePreset) => void;
|
||||
onCustomStartChange: (value: string) => void;
|
||||
onCustomEndChange: (value: string) => void;
|
||||
onReset: () => void;
|
||||
dateFieldLabel?: string;
|
||||
}) {
|
||||
const active = preset !== 'all' || Boolean(customStart || customEnd);
|
||||
|
||||
return (
|
||||
<div className="h5-va-tl-date-filter" aria-label={`${dateFieldLabel}筛选`}>
|
||||
<div className="h5-va-tl-date-filter-head">
|
||||
<span className="h5-va-tl-date-filter-label">{dateFieldLabel}</span>
|
||||
{active ? (
|
||||
<button type="button" className="h5-va-tl-date-filter-reset" onClick={onReset}>
|
||||
重置
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="h5-va-tl-date-chips" role="tablist" aria-label="时间快捷筛选">
|
||||
{TIMELINE_DATE_PRESETS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={preset === item.id}
|
||||
className={`h5-va-tl-date-chip${preset === item.id ? ' is-active' : ''}`}
|
||||
onClick={() => onPresetChange(item.id)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{preset === 'custom' ? (
|
||||
<div className="h5-va-tl-date-custom">
|
||||
<label className="h5-va-tl-date-input">
|
||||
<span>起</span>
|
||||
<NativeDateField
|
||||
value={customStart}
|
||||
placeholder="开始日期"
|
||||
title="开始日期"
|
||||
aria-label="开始日期"
|
||||
onChange={(ymd) => {
|
||||
onCustomStartChange(ymd);
|
||||
if (customEnd && ymd > customEnd) onCustomEndChange(ymd);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<span className="h5-va-tl-date-sep" aria-hidden>
|
||||
—
|
||||
</span>
|
||||
<label className="h5-va-tl-date-input">
|
||||
<span>止</span>
|
||||
<NativeDateField
|
||||
value={customEnd}
|
||||
placeholder="结束日期"
|
||||
title="结束日期"
|
||||
aria-label="结束日期"
|
||||
onChange={(ymd) => {
|
||||
if (customStart && ymd < customStart) {
|
||||
onCustomEndChange(customStart);
|
||||
return;
|
||||
}
|
||||
onCustomEndChange(ymd);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTimelineDateFilter(dateFieldLabel = '时间') {
|
||||
const [preset, setPreset] = useState<TimelineDatePreset>('all');
|
||||
const [customStart, setCustomStart] = useState('');
|
||||
const [customEnd, setCustomEnd] = useState('');
|
||||
|
||||
const range = useMemo(
|
||||
() => resolveTimelineDateRange(preset, customStart, customEnd),
|
||||
[preset, customStart, customEnd],
|
||||
);
|
||||
|
||||
const filterActive = preset !== 'all' || Boolean(customStart || customEnd);
|
||||
|
||||
function reset() {
|
||||
setPreset('all');
|
||||
setCustomStart('');
|
||||
setCustomEnd('');
|
||||
}
|
||||
|
||||
function handlePresetChange(next: TimelineDatePreset) {
|
||||
setPreset(next);
|
||||
if (next !== 'custom') {
|
||||
setCustomStart('');
|
||||
setCustomEnd('');
|
||||
}
|
||||
}
|
||||
|
||||
const filterNode = (
|
||||
<TimelineDateFilter
|
||||
preset={preset}
|
||||
customStart={customStart}
|
||||
customEnd={customEnd}
|
||||
onPresetChange={handlePresetChange}
|
||||
onCustomStartChange={setCustomStart}
|
||||
onCustomEndChange={setCustomEnd}
|
||||
onReset={reset}
|
||||
dateFieldLabel={dateFieldLabel}
|
||||
/>
|
||||
);
|
||||
|
||||
return { range, filterActive, filterNode, reset };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import type { VehicleRecord } from '../utils/filter';
|
||||
import {
|
||||
formatOperateStatusLabel,
|
||||
isInsuranceAbnormal,
|
||||
isLicenseExpired,
|
||||
resolveCardContextTag,
|
||||
} from '../utils/filter';
|
||||
import {
|
||||
formatMileageTaskMeta,
|
||||
formatProgressPct,
|
||||
resolveMileageTaskCard,
|
||||
} from '../utils/mileage-task';
|
||||
import { displayText } from '../../vehicle-management/utils/vehicle';
|
||||
import { IconChevronRight } from './Icons';
|
||||
|
||||
type Props = {
|
||||
record: VehicleRecord;
|
||||
onOpen: () => void;
|
||||
};
|
||||
|
||||
function statusTone(status: string): 'success' | 'warn' | 'muted' | 'default' {
|
||||
if (status === '租赁' || status === '物流' || status === '自营' || status === '可运营') return 'success';
|
||||
if (status === '待运营') return 'warn';
|
||||
if (status === '退出运营' || status === '非运营') return 'muted';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function formatMileage(value: string | undefined): string | null {
|
||||
if (!value || value === '—' || value === '-') return null;
|
||||
const num = Number(String(value).replace(/[^\d.]/g, ''));
|
||||
if (!Number.isFinite(num) || num < 0) return null;
|
||||
return `${Math.round(num).toLocaleString('zh-CN')} km`;
|
||||
}
|
||||
|
||||
export function VehicleCard({ record, onOpen }: Props) {
|
||||
const licenseAbnormal = isLicenseExpired(record);
|
||||
const insuranceAbnormal = isInsuranceAbnormal(record);
|
||||
const brandModel = [
|
||||
displayText(record.brand, ''),
|
||||
displayText(record.model, ''),
|
||||
].filter(Boolean).join(' ') || '—';
|
||||
const ownership = displayText(record.vehicleSource, '') || '自有';
|
||||
const contextTag = resolveCardContextTag(record);
|
||||
const mileage = formatMileage(record.mileage);
|
||||
const operateLabel = formatOperateStatusLabel(record.operateStatus || '');
|
||||
const tone = statusTone(record.operateStatus || '');
|
||||
const mileageTask = resolveMileageTaskCard(record);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="h5-va-card"
|
||||
onClick={onOpen}
|
||||
data-annotation-id="h5-va-card"
|
||||
aria-label={`查看车辆 ${record.plateNo}`}
|
||||
>
|
||||
<div className="h5-va-card-body">
|
||||
<div className="h5-va-card-row">
|
||||
<div className="h5-va-card-main">
|
||||
<div className="h5-va-plate">{record.plateNo}</div>
|
||||
<div className="h5-va-card-sub">{brandModel}</div>
|
||||
<div className="h5-va-card-tags">
|
||||
<span className="h5-va-tag">{ownership}</span>
|
||||
{contextTag ? (
|
||||
<span
|
||||
className={`h5-va-tag h5-va-tag--${contextTag.tone} h5-va-tag--context`}
|
||||
title={contextTag.title || contextTag.label}
|
||||
>
|
||||
{contextTag.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h5-va-card-side">
|
||||
<div className="h5-va-card-side-top">
|
||||
<span className="h5-va-status" data-tone={tone === 'default' ? undefined : tone}>
|
||||
<span className="h5-va-status-dot" aria-hidden />
|
||||
{operateLabel}
|
||||
</span>
|
||||
<span className="h5-va-card-chevron" aria-hidden>
|
||||
<IconChevronRight size={14} />
|
||||
</span>
|
||||
</div>
|
||||
{mileage ? <span className="h5-va-card-metric">{mileage}</span> : null}
|
||||
{(licenseAbnormal || insuranceAbnormal) ? (
|
||||
<div className="h5-va-card-flags">
|
||||
{licenseAbnormal ? <span className="h5-va-flag h5-va-flag--danger">证照异常</span> : null}
|
||||
{insuranceAbnormal ? <span className="h5-va-flag h5-va-flag--warn">保险异常</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h5-va-mileage-task" data-annotation-id="h5-va-mileage-task">
|
||||
<div className="h5-va-mileage-task-head">
|
||||
<span className="h5-va-mileage-task-title">里程任务</span>
|
||||
{mileageTask.kind !== 'empty' ? (
|
||||
<span
|
||||
className="h5-va-mileage-task-pct"
|
||||
data-tone={
|
||||
mileageTask.kind === 'done'
|
||||
? 'done'
|
||||
: mileageTask.forecast?.tone === 'fail'
|
||||
? 'fail'
|
||||
: mileageTask.forecast?.tone === 'risk'
|
||||
? 'risk'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{formatProgressPct(mileageTask.progressPct)}%
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{mileageTask.kind === 'empty' ? (
|
||||
<p className="h5-va-mileage-task-empty">暂无里程任务</p>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="h5-va-mileage-task-track"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={mileageTask.progressPct}
|
||||
aria-label={`里程任务完成 ${formatProgressPct(mileageTask.progressPct)}%`}
|
||||
data-tone={
|
||||
mileageTask.kind === 'done'
|
||||
? 'done'
|
||||
: mileageTask.forecast?.tone === 'fail'
|
||||
? 'fail'
|
||||
: mileageTask.forecast?.tone === 'risk'
|
||||
? 'risk'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="h5-va-mileage-task-fill"
|
||||
style={{ width: `${mileageTask.progressPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="h5-va-mileage-task-foot">
|
||||
<p className="h5-va-mileage-task-meta">{formatMileageTaskMeta(mileageTask)}</p>
|
||||
{mileageTask.forecast ? (
|
||||
<span
|
||||
className="h5-va-mileage-forecast"
|
||||
data-tone={mileageTask.forecast.tone}
|
||||
title={mileageTask.forecast.label}
|
||||
>
|
||||
{mileageTask.forecast.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import type { VehicleRecord } from '../utils/filter';
|
||||
import {
|
||||
formatOperateStatusLabel,
|
||||
isInsuranceAbnormal,
|
||||
isLicenseExpired,
|
||||
resolveCardContextTag,
|
||||
} from '../utils/filter';
|
||||
import { displayText, formatOperateCity } from '../../vehicle-management/utils/vehicle';
|
||||
import { PhoneShell } from './PhoneShell';
|
||||
import { IconChevronLeft } from './Icons';
|
||||
import { DETAIL_TABS, DetailTabContent, type DetailTabName } from './detailTabs';
|
||||
|
||||
type Props = {
|
||||
record: VehicleRecord;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
function statusTone(status: string): 'success' | 'warn' | 'muted' | 'default' {
|
||||
if (status === '租赁' || status === '物流' || status === '自营' || status === '可运营') return 'success';
|
||||
if (status === '待运营') return 'warn';
|
||||
if (status === '退出运营' || status === '非运营') return 'muted';
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function formatMileage(value: string | undefined): string | null {
|
||||
if (!value || value === '—' || value === '-') return null;
|
||||
const num = Number(String(value).replace(/[^\d.]/g, ''));
|
||||
if (!Number.isFinite(num) || num <= 0) return String(value);
|
||||
return `${Math.round(num).toLocaleString('zh-CN')} km`;
|
||||
}
|
||||
|
||||
export function VehicleDetail({ record, onBack }: Props) {
|
||||
const [tab, setTab] = useState<DetailTabName>('基本信息');
|
||||
const tabRefs = useRef<Partial<Record<DetailTabName, HTMLButtonElement | null>>>({});
|
||||
const licenseAbnormal = isLicenseExpired(record);
|
||||
const insuranceAbnormal = isInsuranceAbnormal(record);
|
||||
const city = formatOperateCity(record.location, '');
|
||||
const ownership = displayText(record.vehicleSource, '') || '自有';
|
||||
const contextTag = resolveCardContextTag(record);
|
||||
const brandModel = [
|
||||
displayText(record.brand, ''),
|
||||
displayText(record.model, ''),
|
||||
].filter(Boolean).join(' ') || '—';
|
||||
const operateLabel = formatOperateStatusLabel(record.operateStatus || '');
|
||||
const tone = statusTone(record.operateStatus || '');
|
||||
const mileage = formatMileage(record.mileage);
|
||||
|
||||
useEffect(() => {
|
||||
const active = tabRefs.current[tab];
|
||||
active?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
|
||||
}, [tab]);
|
||||
|
||||
useEffect(() => {
|
||||
const body = document.querySelector('.h5-body') as HTMLElement | null;
|
||||
body?.scrollTo({ top: 0, behavior: 'auto' });
|
||||
}, [record.id]);
|
||||
|
||||
return (
|
||||
<PhoneShell
|
||||
title={record.plateNo}
|
||||
headerMode="detail"
|
||||
left={(
|
||||
<button type="button" className="h5-nav-btn h5-nav-btn--back" onClick={onBack} aria-label="返回列表">
|
||||
<IconChevronLeft />
|
||||
<span className="h5-nav-back-label">返回</span>
|
||||
</button>
|
||||
)}
|
||||
bodyClassName="h5-body--detail"
|
||||
>
|
||||
<section className="h5-va-detail-hero" data-annotation-id="h5-va-detail">
|
||||
<div className="h5-va-detail-hero-row">
|
||||
<div className="h5-va-detail-hero-main">
|
||||
<div className="h5-va-detail-plate">{record.plateNo}</div>
|
||||
<div className="h5-va-detail-sub">{brandModel}</div>
|
||||
<div className="h5-va-detail-tags">
|
||||
<span className="h5-va-tag">{ownership}</span>
|
||||
{city ? <span className="h5-va-tag h5-va-tag--muted">{city}</span> : null}
|
||||
{contextTag ? (
|
||||
<span
|
||||
className={`h5-va-tag h5-va-tag--${contextTag.tone} h5-va-tag--context`}
|
||||
title={contextTag.title || contextTag.label}
|
||||
>
|
||||
{contextTag.label}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h5-va-detail-hero-side">
|
||||
<span className="h5-va-status" data-tone={tone === 'default' ? undefined : tone}>
|
||||
<span className="h5-va-status-dot" aria-hidden />
|
||||
{operateLabel}
|
||||
</span>
|
||||
{mileage ? <span className="h5-va-card-metric">{mileage}</span> : null}
|
||||
{(licenseAbnormal || insuranceAbnormal) ? (
|
||||
<div className="h5-va-card-flags">
|
||||
{licenseAbnormal ? <span className="h5-va-flag h5-va-flag--danger">证照异常</span> : null}
|
||||
{insuranceAbnormal ? <span className="h5-va-flag h5-va-flag--warn">保险异常</span> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="h5-va-tabs" role="tablist" aria-label="车辆档案分类" data-annotation-id="h5-va-tabs">
|
||||
{DETAIL_TABS.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
ref={(node) => {
|
||||
tabRefs.current[name] = node;
|
||||
}}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`h5-va-tab-${name}`}
|
||||
aria-selected={tab === name}
|
||||
aria-controls="h5-va-tab-panel"
|
||||
tabIndex={tab === name ? 0 : -1}
|
||||
className={`h5-va-tab${tab === name ? ' is-active' : ''}`}
|
||||
onClick={() => setTab(name)}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h5-va-tab-panel"
|
||||
role="tabpanel"
|
||||
id="h5-va-tab-panel"
|
||||
aria-labelledby={`h5-va-tab-${tab}`}
|
||||
>
|
||||
<DetailTabContent tab={tab} record={record} />
|
||||
</div>
|
||||
</PhoneShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import type { KpiKey, VehicleRecord } from '../utils/filter';
|
||||
import { KPI_CARDS } from '../utils/filter';
|
||||
import { VehicleCard } from './VehicleCard';
|
||||
import { showAppToast } from './AppToast';
|
||||
import {
|
||||
IconChevronDown,
|
||||
IconCircleX,
|
||||
IconEmptyFleet,
|
||||
IconKpiExit,
|
||||
IconKpiFleet,
|
||||
IconKpiIdle,
|
||||
IconKpiLease,
|
||||
IconKpiLogistics,
|
||||
IconKpiStock,
|
||||
IconListEnd,
|
||||
IconScan,
|
||||
IconSearch,
|
||||
IconSliders,
|
||||
} from './Icons';
|
||||
|
||||
type Props = {
|
||||
records: VehicleRecord[];
|
||||
hasMore: boolean;
|
||||
loadingMore: boolean;
|
||||
kpi: KpiKey;
|
||||
kpiCounts: Record<KpiKey, number>;
|
||||
plateQuery: string;
|
||||
filterActive: boolean;
|
||||
brandLabel: string;
|
||||
operateCityLabel: string;
|
||||
vehicleStatusLabel: string;
|
||||
brandActive: boolean;
|
||||
operateCityActive: boolean;
|
||||
vehicleStatusActive: boolean;
|
||||
moreFilterActive: boolean;
|
||||
onPlateQueryChange: (value: string) => void;
|
||||
onKpiChange: (key: KpiKey) => void;
|
||||
onOpenBrandPicker: () => void;
|
||||
onOpenCityPicker: () => void;
|
||||
onOpenStatusPicker: () => void;
|
||||
onOpenFilter: () => void;
|
||||
onOpenDetail: (record: VehicleRecord) => void;
|
||||
onClearSearch: () => void;
|
||||
onClearFilters: () => void;
|
||||
};
|
||||
|
||||
const KPI_TONE: Record<KpiKey, string> = {
|
||||
all: 'blue',
|
||||
lease: 'green',
|
||||
logistics: 'teal',
|
||||
stock: 'slate',
|
||||
nonOperating: 'orange',
|
||||
exit: 'gray',
|
||||
};
|
||||
|
||||
const KPI_SHORT: Record<KpiKey, string> = {
|
||||
all: '全部营运',
|
||||
lease: '租赁',
|
||||
logistics: '物流',
|
||||
stock: '库存',
|
||||
nonOperating: '非运营',
|
||||
exit: '退出运营',
|
||||
};
|
||||
|
||||
const KPI_ICON: Record<KpiKey, React.ReactNode> = {
|
||||
all: <IconKpiFleet size={14} />,
|
||||
lease: <IconKpiLease size={14} />,
|
||||
logistics: <IconKpiLogistics size={14} />,
|
||||
stock: <IconKpiStock size={14} />,
|
||||
nonOperating: <IconKpiIdle size={14} />,
|
||||
exit: <IconKpiExit size={14} />,
|
||||
};
|
||||
|
||||
const KPI_PAGE_SIZE = 3;
|
||||
|
||||
function chunkKpiCards<T>(items: T[], size: number): T[][] {
|
||||
const pages: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
pages.push(items.slice(i, i + size));
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
const KPI_PAGES = chunkKpiCards(KPI_CARDS, KPI_PAGE_SIZE);
|
||||
|
||||
/** 单页滚动步长 = 可视宽度 + 页间距(与 .h5-va-kpi gap 一致) */
|
||||
function getKpiPageStride(el: HTMLElement): number {
|
||||
const styles = window.getComputedStyle(el);
|
||||
const gapRaw = styles.columnGap || styles.gap || '0';
|
||||
const gap = Number.parseFloat(gapRaw) || 0;
|
||||
return (el.clientWidth || 1) + gap;
|
||||
}
|
||||
|
||||
function formatPercent(count: number, total: number): string {
|
||||
if (total <= 0) return '0%';
|
||||
return `${((count / total) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export function VehicleList({
|
||||
records,
|
||||
hasMore,
|
||||
loadingMore,
|
||||
kpi,
|
||||
kpiCounts,
|
||||
plateQuery,
|
||||
filterActive,
|
||||
brandLabel,
|
||||
operateCityLabel,
|
||||
vehicleStatusLabel,
|
||||
brandActive,
|
||||
operateCityActive,
|
||||
vehicleStatusActive,
|
||||
moreFilterActive,
|
||||
onPlateQueryChange,
|
||||
onKpiChange,
|
||||
onOpenBrandPicker,
|
||||
onOpenCityPicker,
|
||||
onOpenStatusPicker,
|
||||
onOpenFilter,
|
||||
onOpenDetail,
|
||||
onClearSearch,
|
||||
onClearFilters,
|
||||
}: Props) {
|
||||
const hasQuery = plateQuery.trim().length > 0;
|
||||
const allCount = kpiCounts.all || 0;
|
||||
const kpiRef = useRef<HTMLDivElement>(null);
|
||||
const stickyRef = useRef<HTMLDivElement>(null);
|
||||
const [kpiPage, setKpiPage] = useState(0);
|
||||
const [kpiAnimDir, setKpiAnimDir] = useState<'next' | 'prev'>('next');
|
||||
const [kpiEnterTick, setKpiEnterTick] = useState(0);
|
||||
const kpiPageRef = useRef(0);
|
||||
const [stickyStuck, setStickyStuck] = useState(false);
|
||||
const emptyHint = hasQuery
|
||||
? '换个关键词试试,或清空搜索后浏览全部车辆'
|
||||
: filterActive
|
||||
? '当前条件下没有结果,试试放宽筛选或重置后重来'
|
||||
: '这个分类下暂时没有车辆,切换上方 KPI 看看其他分类';
|
||||
const emptyActionHint = hasQuery
|
||||
? '支持车牌、VIN、停车场、客户名称'
|
||||
: filterActive
|
||||
? '筛选与 KPI 会叠加生效'
|
||||
: '列表会随分类与筛选实时更新';
|
||||
|
||||
useEffect(() => {
|
||||
const el = kpiRef.current;
|
||||
if (!el) return undefined;
|
||||
|
||||
function onScroll() {
|
||||
if (!el) return;
|
||||
const stride = getKpiPageStride(el);
|
||||
const page = Math.max(0, Math.min(KPI_PAGES.length - 1, Math.round(el.scrollLeft / stride)));
|
||||
if (page === kpiPageRef.current) return;
|
||||
setKpiAnimDir(page > kpiPageRef.current ? 'next' : 'prev');
|
||||
kpiPageRef.current = page;
|
||||
setKpiPage(page);
|
||||
setKpiEnterTick((tick) => tick + 1);
|
||||
}
|
||||
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => el.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
function goToKpiPage(pageIndex: number) {
|
||||
const el = kpiRef.current;
|
||||
if (!el) return;
|
||||
const next = Math.max(0, Math.min(KPI_PAGES.length - 1, pageIndex));
|
||||
if (next === kpiPageRef.current) return;
|
||||
setKpiAnimDir(next > kpiPageRef.current ? 'next' : 'prev');
|
||||
kpiPageRef.current = next;
|
||||
setKpiPage(next);
|
||||
setKpiEnterTick((tick) => tick + 1);
|
||||
el.scrollTo({
|
||||
left: next * getKpiPageStride(el),
|
||||
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth',
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const sticky = stickyRef.current;
|
||||
const body = document.querySelector('.h5-body') as HTMLElement | null;
|
||||
if (!sticky || !body) return undefined;
|
||||
|
||||
function onBodyScroll() {
|
||||
if (!body) return;
|
||||
setStickyStuck(body.scrollTop > 8);
|
||||
}
|
||||
|
||||
body.addEventListener('scroll', onBodyScroll, { passive: true });
|
||||
onBodyScroll();
|
||||
return () => body.removeEventListener('scroll', onBodyScroll);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const page = kpiRef.current?.querySelector('.h5-va-kpi-page.is-active-page');
|
||||
if (!(page instanceof HTMLElement)) return;
|
||||
page.classList.remove('is-entering');
|
||||
// Force reflow so the entrance animation can replay on each page change.
|
||||
void page.offsetWidth;
|
||||
page.classList.add('is-entering');
|
||||
}, [kpiEnterTick, kpiPage]);
|
||||
|
||||
return (
|
||||
<div className="h5-va-list">
|
||||
<div
|
||||
ref={stickyRef}
|
||||
className={`h5-va-sticky${stickyStuck ? ' is-stuck' : ''}`}
|
||||
>
|
||||
<div className="h5-va-search" data-annotation-id="h5-va-search">
|
||||
<IconSearch />
|
||||
<input
|
||||
type="search"
|
||||
value={plateQuery}
|
||||
placeholder="搜索车牌 / VIN / 停车场 / 客户名称"
|
||||
enterKeyHint="search"
|
||||
autoCapitalize="characters"
|
||||
autoCorrect="off"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
inputMode="text"
|
||||
onChange={(event) => onPlateQueryChange(event.target.value)}
|
||||
aria-label="搜索车牌 / VIN / 停车场 / 客户名称"
|
||||
/>
|
||||
{hasQuery ? (
|
||||
<button
|
||||
type="button"
|
||||
className="h5-va-clear-btn"
|
||||
onClick={onClearSearch}
|
||||
aria-label="清空搜索"
|
||||
>
|
||||
<IconCircleX />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className="h5-va-scan-btn"
|
||||
onClick={() => showAppToast('扫码查车开发中,请先用车牌搜索')}
|
||||
aria-label="扫码"
|
||||
>
|
||||
<IconScan />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={kpiRef}
|
||||
className="h5-va-kpi"
|
||||
data-annotation-id="h5-va-kpi"
|
||||
role="toolbar"
|
||||
aria-label="车辆分类"
|
||||
>
|
||||
{KPI_PAGES.map((pageCards, pageIndex) => (
|
||||
<div
|
||||
key={`kpi-page-${pageIndex}`}
|
||||
className={`h5-va-kpi-page${kpiPage === pageIndex ? ' is-active-page' : ''}`}
|
||||
data-anim={kpiPage === pageIndex ? kpiAnimDir : undefined}
|
||||
data-enter={kpiPage === pageIndex ? kpiEnterTick : undefined}
|
||||
>
|
||||
{pageCards.map((card) => {
|
||||
const count = kpiCounts[card.key];
|
||||
const shareBase = card.key === 'all' ? Math.max(allCount, 1) : allCount;
|
||||
return (
|
||||
<button
|
||||
key={card.key}
|
||||
type="button"
|
||||
className={`h5-va-kpi-item${kpi === card.key ? ' is-active' : ''}`}
|
||||
data-tone={KPI_TONE[card.key]}
|
||||
onClick={() => onKpiChange(card.key)}
|
||||
aria-pressed={kpi === card.key}
|
||||
title={card.desc}
|
||||
>
|
||||
<span className="h5-va-kpi-icon" aria-hidden>
|
||||
{KPI_ICON[card.key]}
|
||||
</span>
|
||||
<span className="h5-va-kpi-title">{KPI_SHORT[card.key]}</span>
|
||||
<span className="h5-va-kpi-val">{count.toLocaleString('zh-CN')}</span>
|
||||
<span className="h5-va-kpi-sub">
|
||||
{card.key === 'all' ? '' : formatPercent(count, shareBase)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="h5-va-kpi-dots" role="tablist" aria-label="KPI 分页">
|
||||
{KPI_PAGES.map((_, pageIndex) => (
|
||||
<button
|
||||
key={`kpi-dot-${pageIndex}`}
|
||||
type="button"
|
||||
className={`h5-va-kpi-dot-bar${kpiPage === pageIndex ? ' is-active' : ''}`}
|
||||
aria-label={`第 ${pageIndex + 1} 页`}
|
||||
aria-current={kpiPage === pageIndex ? 'true' : undefined}
|
||||
onClick={() => goToKpiPage(pageIndex)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="h5-va-chipbar" data-annotation-id="h5-va-filter" role="toolbar" aria-label="列表筛选">
|
||||
<button
|
||||
type="button"
|
||||
className={`h5-va-chipbar-item${brandActive ? ' is-active' : ''}`}
|
||||
onClick={onOpenBrandPicker}
|
||||
>
|
||||
<span className="h5-va-chipbar-label">{brandLabel}</span>
|
||||
<IconChevronDown size={11} className="h5-va-chipbar-caret" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`h5-va-chipbar-item${operateCityActive ? ' is-active' : ''}`}
|
||||
onClick={onOpenCityPicker}
|
||||
>
|
||||
<span className="h5-va-chipbar-label">{operateCityLabel}</span>
|
||||
<IconChevronDown size={11} className="h5-va-chipbar-caret" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`h5-va-chipbar-item${vehicleStatusActive ? ' is-active' : ''}`}
|
||||
onClick={onOpenStatusPicker}
|
||||
>
|
||||
<span className="h5-va-chipbar-label">{vehicleStatusLabel}</span>
|
||||
<IconChevronDown size={11} className="h5-va-chipbar-caret" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`h5-va-chipbar-more${moreFilterActive ? ' is-active' : ''}`}
|
||||
onClick={onOpenFilter}
|
||||
aria-label={moreFilterActive ? '更多筛选(已启用)' : '更多筛选'}
|
||||
title="更多筛选"
|
||||
>
|
||||
<IconSliders size={15} />
|
||||
{moreFilterActive ? <span className="h5-va-filter-dot" aria-hidden /> : null}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h5-va-cards">
|
||||
{records.length === 0 ? (
|
||||
<div className="h5-va-empty" role="status">
|
||||
<div className="h5-va-empty-visual" aria-hidden>
|
||||
<span className="h5-va-empty-orb" />
|
||||
<IconEmptyFleet size={72} className="h5-va-empty-icon" />
|
||||
</div>
|
||||
<p className="h5-va-empty-title">暂无匹配车辆</p>
|
||||
<p className="h5-va-empty-desc">{emptyHint}</p>
|
||||
<p className="h5-va-empty-tip">{emptyActionHint}</p>
|
||||
<div className="h5-va-empty-actions">
|
||||
{hasQuery ? (
|
||||
<button type="button" className="h5-btn h5-btn--primary" onClick={onClearSearch}>
|
||||
清空搜索
|
||||
</button>
|
||||
) : null}
|
||||
{filterActive ? (
|
||||
<button type="button" className="h5-btn h5-btn--primary" onClick={onClearFilters}>
|
||||
重置筛选
|
||||
</button>
|
||||
) : null}
|
||||
{!hasQuery && !filterActive ? (
|
||||
<button type="button" className="h5-btn h5-btn--primary" onClick={onOpenBrandPicker}>
|
||||
试试品牌筛选
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="h5-btn h5-btn--ghost" onClick={onOpenFilter}>
|
||||
更多筛选
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
records.map((record) => (
|
||||
<VehicleCard key={record.id} record={record} onOpen={() => onOpenDetail(record)} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{records.length > 0 ? (
|
||||
<div className="h5-va-loadmore" role="status" aria-live="polite">
|
||||
{loadingMore ? (
|
||||
<span className="h5-va-loadmore-loading">
|
||||
<span className="h5-va-loadmore-spinner" aria-hidden />
|
||||
加载中…
|
||||
</span>
|
||||
) : hasMore ? (
|
||||
<span className="h5-va-loadmore-hint">
|
||||
<span className="h5-va-loadmore-hint-line" aria-hidden />
|
||||
上拉加载更多
|
||||
<span className="h5-va-loadmore-hint-line" aria-hidden />
|
||||
</span>
|
||||
) : (
|
||||
<div className="h5-va-loadmore-end">
|
||||
<span className="h5-va-loadmore-end-line" aria-hidden />
|
||||
<span className="h5-va-loadmore-end-pill">
|
||||
<IconListEnd size={14} className="h5-va-loadmore-end-icon" />
|
||||
<span className="h5-va-loadmore-end-text">没有更多了</span>
|
||||
</span>
|
||||
<span className="h5-va-loadmore-end-line" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
235
src/prototypes/oneos-h5-vehicle-assets/components/detailTabs.tsx
Normal file
235
src/prototypes/oneos-h5-vehicle-assets/components/detailTabs.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import React from 'react';
|
||||
import type { VehicleRecord } from '../utils/filter';
|
||||
import { isInsuranceAbnormal, isLicenseExpired } from '../utils/filter';
|
||||
import {
|
||||
daysUntilExpire,
|
||||
displayUILabel,
|
||||
formatDateYmd,
|
||||
formatMileage,
|
||||
formatOperateCity,
|
||||
isEmpty,
|
||||
isShanghaiPlate,
|
||||
resolveOpsManagers,
|
||||
resolveProjectDelivery,
|
||||
resolveThirdPartyLeaseCompany,
|
||||
} from '../../vehicle-management/utils/vehicle';
|
||||
import { resolveVehicleModelParams } from '../../vehicle-management/utils/modelParams';
|
||||
import { InsuranceRecordsPanel } from './InsuranceRecordsPanel';
|
||||
import { LicenseCertPanel } from './LicenseCertPanel';
|
||||
import { LeaseRecordsPanel } from './LeaseRecordsPanel';
|
||||
import { RecordsEmpty } from './RecordsEmpty';
|
||||
import {
|
||||
AccidentRecordsPanel,
|
||||
AnnualReviewRecordsPanel,
|
||||
FaultRecordsPanel,
|
||||
MovementRecordsPanel,
|
||||
TransferRecordsPanel,
|
||||
ViolationRecordsPanel,
|
||||
} from './BizTimelinePanels';
|
||||
|
||||
export const DETAIL_TABS = [
|
||||
'基本信息',
|
||||
'型号参数',
|
||||
'证照信息',
|
||||
'保险记录',
|
||||
'租赁记录',
|
||||
'事故记录',
|
||||
'故障记录',
|
||||
'违章记录',
|
||||
'异动记录',
|
||||
'调拨记录',
|
||||
'年审记录',
|
||||
] as const;
|
||||
|
||||
export type DetailTabName = (typeof DETAIL_TABS)[number];
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
code,
|
||||
}: {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
code?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="h5-va-field">
|
||||
<div className="h5-va-field-label">{label}</div>
|
||||
<div className={`h5-va-field-value${code ? ' h5-va-field-value--code' : ''}`}>{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<section className="h5-va-section">
|
||||
<h3 className="h5-va-section-title">{title}</h3>
|
||||
<div className="h5-va-field-grid">{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBlock({ text = '暂无记录' }: { text?: string }) {
|
||||
return <RecordsEmpty title={text} />;
|
||||
}
|
||||
|
||||
/** 到期日 + 剩余/已过期天数标签(对齐 PC ExpireDate · alwaysShowRemainder) */
|
||||
function ExpireValue({ date, emptyLabel = '未上传' }: { date: string; emptyLabel?: string }) {
|
||||
if (isEmpty(date)) return <>{emptyLabel}</>;
|
||||
const text = formatDateYmd(date) || String(date).slice(0, 10);
|
||||
const days = daysUntilExpire(date);
|
||||
let tag: string | null = null;
|
||||
let tone: 'ok' | 'warn' | 'danger' = 'ok';
|
||||
if (days !== null) {
|
||||
if (days < 0) {
|
||||
tag = `已过期${Math.abs(days)}天`;
|
||||
tone = 'danger';
|
||||
} else {
|
||||
tag = `剩余${days}天`;
|
||||
if (days <= 30) tone = 'warn';
|
||||
}
|
||||
}
|
||||
return (
|
||||
<span className="h5-va-expire">
|
||||
<span className="h5-va-expire-date">{text}</span>
|
||||
{tag ? (
|
||||
<span className={`h5-va-expire-tag h5-va-expire-tag--${tone}`}>{tag}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatRemainDaysLabel(date: string): string {
|
||||
if (isEmpty(date)) return '—';
|
||||
const days = daysUntilExpire(date);
|
||||
if (days === null) return '—';
|
||||
return days < 0 ? `已过期${Math.abs(days)}天` : `剩余${days}天`;
|
||||
}
|
||||
|
||||
export function DetailTabContent({ tab, record }: { tab: DetailTabName; record: VehicleRecord }) {
|
||||
if (tab === '基本信息') {
|
||||
const project = resolveProjectDelivery(record, '无');
|
||||
const shanghaiPlate = isShanghaiPlate(record.plateNo);
|
||||
return (
|
||||
<>
|
||||
<Section title="档案信息">
|
||||
<Field label="车牌号" value={record.plateNo} />
|
||||
<Field label="VIN" value={displayUILabel(record.vin)} code />
|
||||
<Field label="品牌" value={displayUILabel(record.brand)} />
|
||||
<Field label="型号" value={displayUILabel(record.model)} />
|
||||
<Field label="车型" value={displayUILabel(record.vehicleType)} />
|
||||
<Field label="颜色" value={displayUILabel(record.color)} />
|
||||
<Field label="出厂年份" value={displayUILabel(record.year)} />
|
||||
<Field label="采购入库日期" value={displayUILabel(formatDateYmd(record.purchaseDate) || record.purchaseDate)} />
|
||||
<Field label="强制报废日期" value={displayUILabel(record.scrapDate)} />
|
||||
{shanghaiPlate ? (
|
||||
<>
|
||||
<Field
|
||||
label="等级评定时间"
|
||||
value={isEmpty(record.ratingTime)
|
||||
? '未上传'
|
||||
: (formatDateYmd(record.ratingTime) || record.ratingTime.slice(0, 10))}
|
||||
/>
|
||||
<Field label="剩余天数" value={formatRemainDaysLabel(record.ratingTime || '')} />
|
||||
</>
|
||||
) : null}
|
||||
</Section>
|
||||
<Section title="业务信息">
|
||||
<Field label="运营状态" value={displayUILabel(record.operateStatus)} />
|
||||
<Field label="车辆状态" value={displayUILabel(record.vehicleStatus)} />
|
||||
<Field label="运营城市" value={formatOperateCity(record.location, '无')} />
|
||||
<Field label="运维负责人" value={resolveOpsManagers(record).join('、') || '无'} />
|
||||
<Field label="所属项目" value={project.projectName} />
|
||||
<Field label="合同编码" value={project.contractNo} />
|
||||
<Field label="客户名称" value={project.companyName} />
|
||||
<Field label="车辆来源" value={displayUILabel(record.vehicleSource)} />
|
||||
<Field label="三方租赁公司" value={resolveThirdPartyLeaseCompany(record, '无')} />
|
||||
<Field label="运营公司" value={displayUILabel(record.operateCompany)} />
|
||||
<Field label="所有权" value={displayUILabel(record.ownership)} />
|
||||
<Field label="停车场" value={displayUILabel(record.parking)} />
|
||||
<Field label="当前里程" value={formatMileage(record.mileage, false, '无')} />
|
||||
</Section>
|
||||
<Section title="证照 / 保险概览">
|
||||
<Field label="证照状态" value={isLicenseExpired(record) ? '异常' : '正常'} />
|
||||
<Field label="检验有效期" value={<ExpireValue date={record.inspectExpire} />} />
|
||||
<Field label="保险状态" value={displayUILabel(record.insuranceStatus)} />
|
||||
<Field
|
||||
label="异常标记"
|
||||
value={(
|
||||
<>
|
||||
{isLicenseExpired(record) ? <span className="h5-va-flag h5-va-flag--danger">证照异常</span> : null}
|
||||
{isInsuranceAbnormal(record) ? <span className="h5-va-flag h5-va-flag--warn">保险异常</span> : null}
|
||||
{!isLicenseExpired(record) && !isInsuranceAbnormal(record) ? '无' : null}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (tab === '型号参数') {
|
||||
const params = resolveVehicleModelParams(record);
|
||||
return (
|
||||
<>
|
||||
<Section title="基础参数">
|
||||
<Field label="品牌" value={displayUILabel(params.brand)} />
|
||||
<Field label="型号" value={displayUILabel(params.model)} />
|
||||
<Field label="车型" value={displayUILabel(params.vehicleModel)} />
|
||||
<Field label="公告型号" value={displayUILabel(params.announcementModel)} />
|
||||
<Field label="燃料类型" value={displayUILabel(params.fuelType)} />
|
||||
<Field label="车辆类型" value={displayUILabel(params.vehicleType)} />
|
||||
<Field label="长宽高(mm)" value={`${params.lengthMm} × ${params.widthMm} × ${params.heightMm}`} />
|
||||
<Field label="轮胎数" value={displayUILabel(params.tireCount)} />
|
||||
</Section>
|
||||
<Section title="动力与续航">
|
||||
<Field label="电池类型" value={displayUILabel(params.batteryType)} />
|
||||
<Field label="电池厂商" value={displayUILabel(params.batteryVendor)} />
|
||||
<Field label="总功率" value={displayUILabel(params.totalPower)} />
|
||||
<Field label="电池续航(km)" value={displayUILabel(params.batteryRangeKm)} />
|
||||
<Field label="氢瓶容量" value={displayUILabel(params.hydrogenCapacity)} />
|
||||
<Field label="氢续航(km)" value={displayUILabel(params.hydrogenRangeKm)} />
|
||||
<Field label="冷机厂商" value={displayUILabel(params.coldUnitVendor)} />
|
||||
</Section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (tab === '证照信息') {
|
||||
return <LicenseCertPanel record={record} />;
|
||||
}
|
||||
|
||||
if (tab === '保险记录') {
|
||||
return <InsuranceRecordsPanel record={record} />;
|
||||
}
|
||||
|
||||
if (tab === '租赁记录') {
|
||||
return <LeaseRecordsPanel record={record} />;
|
||||
}
|
||||
|
||||
if (tab === '事故记录') {
|
||||
return <AccidentRecordsPanel record={record} />;
|
||||
}
|
||||
|
||||
if (tab === '故障记录') {
|
||||
return <FaultRecordsPanel record={record} />;
|
||||
}
|
||||
|
||||
if (tab === '违章记录') {
|
||||
return <ViolationRecordsPanel record={record} />;
|
||||
}
|
||||
|
||||
if (tab === '异动记录') {
|
||||
return <MovementRecordsPanel record={record} />;
|
||||
}
|
||||
|
||||
if (tab === '调拨记录') {
|
||||
return <TransferRecordsPanel record={record} />;
|
||||
}
|
||||
|
||||
if (tab === '年审记录') {
|
||||
return <AnnualReviewRecordsPanel record={record} />;
|
||||
}
|
||||
|
||||
return <EmptyBlock />;
|
||||
}
|
||||
383
src/prototypes/oneos-h5-vehicle-assets/index.tsx
Normal file
383
src/prototypes/oneos-h5-vehicle-assets/index.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
/**
|
||||
* @name 车辆资产(H5)
|
||||
* App / 手机浏览器:车辆资产只读查询,口径对齐 PC 车辆资产
|
||||
*/
|
||||
import './styles/index.css';
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
import vehiclesSeed from '../vehicle-management/data/vehicles.json';
|
||||
import { FilterSheet } from './components/FilterSheet';
|
||||
import { BrandModelPickerSheet, NativePickerSheet, OperateCityPickerSheet } from './components/NativePickerSheet';
|
||||
import { AppToastHost } from './components/AppToast';
|
||||
import { PhoneShell } from './components/PhoneShell';
|
||||
import { VehicleDetail } from './components/VehicleDetail';
|
||||
import { VehicleList } from './components/VehicleList';
|
||||
import {
|
||||
collectBrandOptions,
|
||||
collectCityOptionsByProvince,
|
||||
collectModelOptions,
|
||||
collectProvinceOptions,
|
||||
collectVehicleStatusOptions,
|
||||
countAllKpis,
|
||||
filterVehicles,
|
||||
formatBrandModelChipLabel,
|
||||
formatOperateCityChipLabel,
|
||||
type InsuranceFilter,
|
||||
type KpiKey,
|
||||
type LicenseFilter,
|
||||
type OnlineFilter,
|
||||
type OutboundFilter,
|
||||
type VehicleRecord,
|
||||
} from './utils/filter';
|
||||
|
||||
const PAGE_SIZE = 12;
|
||||
const LOAD_DELAY_MS = 420;
|
||||
const ALL_VEHICLES = vehiclesSeed as VehicleRecord[];
|
||||
const HISTORY_KEY = 'oneos-h5-va';
|
||||
|
||||
type AppView =
|
||||
| { name: 'list' }
|
||||
| { name: 'detail'; recordId: string };
|
||||
|
||||
function scrollPhoneBodyToTop() {
|
||||
const body = document.querySelector('.h5-body') as HTMLElement | null;
|
||||
body?.scrollTo({ top: 0, behavior: 'auto' });
|
||||
}
|
||||
|
||||
export default function OneosH5VehicleAssets() {
|
||||
useEffect(() => {
|
||||
clearHostPrototypeRouteInfo();
|
||||
}, []);
|
||||
|
||||
const [view, setView] = useState<AppView>({ name: 'list' });
|
||||
const [kpi, setKpi] = useState<KpiKey>('all');
|
||||
const [plateQuery, setPlateQuery] = useState('');
|
||||
const [licenseStatus, setLicenseStatus] = useState<LicenseFilter>('');
|
||||
const [insuranceStatus, setInsuranceStatus] = useState<InsuranceFilter>('');
|
||||
const [outboundStatus, setOutboundStatus] = useState<OutboundFilter>('');
|
||||
const [vehicleSource, setVehicleSource] = useState('');
|
||||
const [onlineStatus, setOnlineStatus] = useState<OnlineFilter>('');
|
||||
const [brand, setBrand] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [operateCity, setOperateCity] = useState('');
|
||||
const [vehicleStatus, setVehicleStatus] = useState('');
|
||||
const [filterOpen, setFilterOpen] = useState(false);
|
||||
const [brandPickerOpen, setBrandPickerOpen] = useState(false);
|
||||
const [cityPickerOpen, setCityPickerOpen] = useState(false);
|
||||
const [statusPickerOpen, setStatusPickerOpen] = useState(false);
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const loadingRef = useRef(false);
|
||||
const loadTimerRef = useRef(0);
|
||||
|
||||
const kpiCounts = useMemo(() => countAllKpis(ALL_VEHICLES), []);
|
||||
const brandOptions = useMemo(() => collectBrandOptions(ALL_VEHICLES), []);
|
||||
const provinceOptions = useMemo(() => collectProvinceOptions(ALL_VEHICLES), []);
|
||||
const statusOptions = useMemo(() => collectVehicleStatusOptions(ALL_VEHICLES), []);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => filterVehicles(ALL_VEHICLES, {
|
||||
kpi,
|
||||
plateQuery,
|
||||
licenseStatus,
|
||||
insuranceStatus,
|
||||
brand,
|
||||
model,
|
||||
operateCity,
|
||||
vehicleStatus,
|
||||
outboundStatus,
|
||||
vehicleSource,
|
||||
onlineStatus,
|
||||
}),
|
||||
[
|
||||
kpi,
|
||||
plateQuery,
|
||||
licenseStatus,
|
||||
insuranceStatus,
|
||||
brand,
|
||||
model,
|
||||
operateCity,
|
||||
vehicleStatus,
|
||||
outboundStatus,
|
||||
vehicleSource,
|
||||
onlineStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const visibleRecords = useMemo(
|
||||
() => filtered.slice(0, visibleCount),
|
||||
[filtered, visibleCount],
|
||||
);
|
||||
const hasMore = visibleCount < filtered.length;
|
||||
|
||||
const detailRecord = useMemo(() => {
|
||||
if (view.name !== 'detail') return null;
|
||||
return ALL_VEHICLES.find((item) => item.id === view.recordId) || null;
|
||||
}, [view]);
|
||||
|
||||
const filterActive = Boolean(
|
||||
licenseStatus
|
||||
|| insuranceStatus
|
||||
|| outboundStatus
|
||||
|| vehicleSource
|
||||
|| onlineStatus
|
||||
|| brand
|
||||
|| model
|
||||
|| operateCity
|
||||
|| vehicleStatus,
|
||||
);
|
||||
const brandActive = Boolean(brand || model);
|
||||
const operateCityActive = Boolean(operateCity);
|
||||
const vehicleStatusActive = Boolean(vehicleStatus);
|
||||
const moreFilterActive = Boolean(
|
||||
licenseStatus || insuranceStatus || outboundStatus || vehicleSource || onlineStatus,
|
||||
);
|
||||
const brandLabel = formatBrandModelChipLabel(brand, model);
|
||||
const operateCityLabel = formatOperateCityChipLabel(operateCity);
|
||||
const vehicleStatusLabel = vehicleStatus || '车辆状态';
|
||||
|
||||
const resetList = useCallback(() => {
|
||||
window.clearTimeout(loadTimerRef.current);
|
||||
loadingRef.current = false;
|
||||
setLoadingMore(false);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
scrollPhoneBodyToTop();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
function onPopState(event: PopStateEvent) {
|
||||
const state = event.state as { [HISTORY_KEY]?: string; recordId?: string } | null;
|
||||
if (state?.[HISTORY_KEY] === 'detail' && state.recordId) {
|
||||
setView({ name: 'detail', recordId: state.recordId });
|
||||
return;
|
||||
}
|
||||
setView({ name: 'list' });
|
||||
}
|
||||
window.addEventListener('popstate', onPopState);
|
||||
return () => window.removeEventListener('popstate', onPopState);
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
window.clearTimeout(loadTimerRef.current);
|
||||
}, []);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loadingRef.current || visibleCount >= filtered.length) return;
|
||||
loadingRef.current = true;
|
||||
setLoadingMore(true);
|
||||
window.clearTimeout(loadTimerRef.current);
|
||||
loadTimerRef.current = window.setTimeout(() => {
|
||||
setVisibleCount((count) => Math.min(count + PAGE_SIZE, filtered.length));
|
||||
loadingRef.current = false;
|
||||
setLoadingMore(false);
|
||||
}, LOAD_DELAY_MS);
|
||||
}, [filtered.length, visibleCount]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view.name !== 'list') return undefined;
|
||||
const body = document.querySelector('.h5-body') as HTMLElement | null;
|
||||
if (!body) return undefined;
|
||||
|
||||
function onScroll() {
|
||||
if (!body || loadingRef.current) return;
|
||||
const remain = body.scrollHeight - body.scrollTop - body.clientHeight;
|
||||
if (remain < 120) loadMore();
|
||||
}
|
||||
|
||||
body.addEventListener('scroll', onScroll, { passive: true });
|
||||
onScroll();
|
||||
return () => body.removeEventListener('scroll', onScroll);
|
||||
}, [view.name, loadMore, visibleRecords.length, filtered.length]);
|
||||
|
||||
function changeKpi(next: KpiKey) {
|
||||
setKpi(next);
|
||||
resetList();
|
||||
}
|
||||
|
||||
function changePlateQuery(value: string) {
|
||||
setPlateQuery(value);
|
||||
window.clearTimeout(loadTimerRef.current);
|
||||
loadingRef.current = false;
|
||||
setLoadingMore(false);
|
||||
setVisibleCount(PAGE_SIZE);
|
||||
}
|
||||
|
||||
function openDetail(record: VehicleRecord) {
|
||||
window.history.pushState(
|
||||
{ [HISTORY_KEY]: 'detail', recordId: record.id },
|
||||
'',
|
||||
);
|
||||
setView({ name: 'detail', recordId: record.id });
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
// 先切回列表:Make / iframe 里 history.back() 常不触发 popstate,会导致点返回无反应
|
||||
setView({ name: 'list' });
|
||||
const state = window.history.state as { [HISTORY_KEY]?: string; recordId?: string } | null;
|
||||
if (state?.[HISTORY_KEY] === 'detail') {
|
||||
window.history.replaceState({ [HISTORY_KEY]: 'list' }, '');
|
||||
}
|
||||
scrollPhoneBodyToTop();
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
setLicenseStatus('');
|
||||
setInsuranceStatus('');
|
||||
setOutboundStatus('');
|
||||
setVehicleSource('');
|
||||
setOnlineStatus('');
|
||||
setBrand('');
|
||||
setModel('');
|
||||
setOperateCity('');
|
||||
setVehicleStatus('');
|
||||
resetList();
|
||||
}
|
||||
|
||||
const listOverlays = (
|
||||
<>
|
||||
<BrandModelPickerSheet
|
||||
open={brandPickerOpen}
|
||||
brand={brand}
|
||||
model={model}
|
||||
brandOptions={brandOptions}
|
||||
getModelOptions={(nextBrand) => collectModelOptions(ALL_VEHICLES, nextBrand)}
|
||||
onConfirm={({ brand: nextBrand, model: nextModel }) => {
|
||||
setBrand(nextBrand);
|
||||
setModel(nextModel);
|
||||
resetList();
|
||||
}}
|
||||
onClose={() => setBrandPickerOpen(false)}
|
||||
/>
|
||||
|
||||
<OperateCityPickerSheet
|
||||
open={cityPickerOpen}
|
||||
value={operateCity}
|
||||
provinceOptions={provinceOptions}
|
||||
getCityOptions={(province) => collectCityOptionsByProvince(ALL_VEHICLES, province)}
|
||||
onConfirm={(next) => {
|
||||
setOperateCity(next);
|
||||
resetList();
|
||||
}}
|
||||
onClose={() => setCityPickerOpen(false)}
|
||||
/>
|
||||
|
||||
<NativePickerSheet
|
||||
open={statusPickerOpen}
|
||||
title="车辆状态"
|
||||
value={vehicleStatus}
|
||||
searchable={false}
|
||||
annotationId="h5-va-status-picker"
|
||||
options={[
|
||||
{ value: '', label: '全部状态', meta: '不限' },
|
||||
...statusOptions.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.value,
|
||||
meta: `${item.count} 辆`,
|
||||
})),
|
||||
]}
|
||||
onChange={(next) => {
|
||||
setVehicleStatus(next);
|
||||
resetList();
|
||||
}}
|
||||
onClose={() => setStatusPickerOpen(false)}
|
||||
/>
|
||||
|
||||
<FilterSheet
|
||||
open={filterOpen}
|
||||
licenseStatus={licenseStatus}
|
||||
insuranceStatus={insuranceStatus}
|
||||
outboundStatus={outboundStatus}
|
||||
vehicleSource={vehicleSource}
|
||||
onlineStatus={onlineStatus}
|
||||
onLicenseChange={(value) => {
|
||||
setLicenseStatus(value);
|
||||
resetList();
|
||||
}}
|
||||
onInsuranceChange={(value) => {
|
||||
setInsuranceStatus(value);
|
||||
resetList();
|
||||
}}
|
||||
onOutboundChange={(value) => {
|
||||
setOutboundStatus(value);
|
||||
resetList();
|
||||
}}
|
||||
onVehicleSourceChange={(value) => {
|
||||
setVehicleSource(value);
|
||||
resetList();
|
||||
}}
|
||||
onOnlineChange={(value) => {
|
||||
setOnlineStatus(value);
|
||||
resetList();
|
||||
}}
|
||||
onReset={() => {
|
||||
setLicenseStatus('');
|
||||
setInsuranceStatus('');
|
||||
setOutboundStatus('');
|
||||
setVehicleSource('');
|
||||
setOnlineStatus('');
|
||||
resetList();
|
||||
}}
|
||||
onClose={() => setFilterOpen(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
if (view.name === 'detail' && detailRecord) {
|
||||
return (
|
||||
<div className="h5-va-page">
|
||||
<VehicleDetail record={detailRecord} onBack={backToList} />
|
||||
<AppToastHost />
|
||||
<PrototypeAnnotationHost
|
||||
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||
options={{ pageId: 'detail' } as AnnotationViewerOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h5-va-page">
|
||||
<PhoneShell title="车辆资产" headerMode="app" showBottomNav overlays={listOverlays}>
|
||||
<VehicleList
|
||||
records={visibleRecords}
|
||||
hasMore={hasMore}
|
||||
loadingMore={loadingMore}
|
||||
kpi={kpi}
|
||||
kpiCounts={kpiCounts}
|
||||
plateQuery={plateQuery}
|
||||
filterActive={filterActive}
|
||||
brandLabel={brandLabel}
|
||||
operateCityLabel={operateCityLabel}
|
||||
vehicleStatusLabel={vehicleStatusLabel}
|
||||
brandActive={brandActive}
|
||||
operateCityActive={operateCityActive}
|
||||
vehicleStatusActive={vehicleStatusActive}
|
||||
moreFilterActive={moreFilterActive}
|
||||
onPlateQueryChange={changePlateQuery}
|
||||
onKpiChange={changeKpi}
|
||||
onOpenBrandPicker={() => setBrandPickerOpen(true)}
|
||||
onOpenCityPicker={() => setCityPickerOpen(true)}
|
||||
onOpenStatusPicker={() => setStatusPickerOpen(true)}
|
||||
onOpenFilter={() => setFilterOpen(true)}
|
||||
onOpenDetail={openDetail}
|
||||
onClearSearch={() => changePlateQuery('')}
|
||||
onClearFilters={clearFilters}
|
||||
/>
|
||||
</PhoneShell>
|
||||
|
||||
<AppToastHost />
|
||||
|
||||
<PrototypeAnnotationHost
|
||||
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||
options={{ pageId: 'list' } as AnnotationViewerOptions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* 同步 AutoPRD / .spec 到 annotation-source.json 顶层 directory
|
||||
*(Make 右侧「原型目录」只读 document.directory,不读 data.directory)
|
||||
*
|
||||
* 用法:node src/prototypes/oneos-h5-vehicle-assets/scripts/sync-annotation-directory.mjs
|
||||
*/
|
||||
import { readFileSync, readdirSync, writeFileSync, existsSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
const jsonPath = join(root, 'annotation-source.json');
|
||||
const specDir = join(root, '.spec');
|
||||
const prdPath = join(specDir, 'requirements-prd.md');
|
||||
|
||||
const PREFIX = 'h5-va';
|
||||
|
||||
/** @type {Array<{ file: string; title: string; id: string }>} */
|
||||
const TOPIC_SPECS = [
|
||||
{ file: 'biz-timeline.md', title: '业务记录时间轴', id: `${PREFIX}-doc-biz-timeline` },
|
||||
{ file: 'lease-records.md', title: '租赁记录(时间轴)', id: `${PREFIX}-doc-lease` },
|
||||
{ file: 'demo-biz-records.md', title: '业务记录演示与空态', id: `${PREFIX}-doc-demo-biz` },
|
||||
{ file: 'insurance-records.md', title: '保险记录', id: `${PREFIX}-doc-insurance` },
|
||||
{ file: 'license-certs.md', title: '证照信息(八类)', id: `${PREFIX}-doc-license-certs` },
|
||||
{ file: 'expire-remain-shanghai.md', title: '检验有效期与沪牌', id: `${PREFIX}-doc-expire-shanghai` },
|
||||
{ file: 'more-filters.md', title: '更多筛选', id: `${PREFIX}-doc-more-filters` },
|
||||
{ file: 'mileage-task.md', title: '里程任务进度', id: `${PREFIX}-doc-mileage-task` },
|
||||
];
|
||||
|
||||
function readText(path) {
|
||||
return readFileSync(path, 'utf8');
|
||||
}
|
||||
|
||||
/** 按 ## 一级标题切分 PRD 分章 */
|
||||
function splitPrdChapters(prd) {
|
||||
const lines = prd.split(/\r?\n/);
|
||||
/** @type {Array<{ title: string; body: string; slug: string }>} */
|
||||
const chapters = [];
|
||||
let current = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const m = line.match(/^##\s+(.+)\s*$/);
|
||||
if (m) {
|
||||
if (current) chapters.push(current);
|
||||
const title = m[1].trim();
|
||||
const slug = title
|
||||
.replace(/[^\u4e00-\u9fa5a-zA-Z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.slice(0, 40);
|
||||
current = { title, slug, body: `${line}\n` };
|
||||
continue;
|
||||
}
|
||||
if (current) current.body += `${line}\n`;
|
||||
}
|
||||
if (current) chapters.push(current);
|
||||
return chapters;
|
||||
}
|
||||
|
||||
function markdownNode(id, title, markdown, markdownPath) {
|
||||
return {
|
||||
type: 'markdown',
|
||||
id,
|
||||
title,
|
||||
markdownPath,
|
||||
markdown,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDirectory(prd) {
|
||||
const chapters = splitPrdChapters(prd);
|
||||
const chapterChildren = chapters.map((ch, index) =>
|
||||
markdownNode(
|
||||
`${PREFIX}-prd-ch-${String(index + 1).padStart(2, '0')}`,
|
||||
ch.title,
|
||||
ch.body.trimEnd() + '\n',
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
|
||||
const topicChildren = TOPIC_SPECS.filter((t) => existsSync(join(specDir, t.file))).map((t) => {
|
||||
const markdown = readText(join(specDir, t.file));
|
||||
return markdownNode(t.id, t.title, markdown, `.spec/${t.file}`);
|
||||
});
|
||||
|
||||
return {
|
||||
nodes: [
|
||||
{
|
||||
type: 'folder',
|
||||
id: `${PREFIX}-doc-root`,
|
||||
title: '车辆资产(H5)说明',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
markdownNode(
|
||||
`${PREFIX}-doc-prd`,
|
||||
'产品需求说明(PRD)',
|
||||
prd,
|
||||
'.spec/requirements-prd.md',
|
||||
),
|
||||
{
|
||||
type: 'folder',
|
||||
id: `${PREFIX}-doc-prd-chapters`,
|
||||
title: 'PRD 分章',
|
||||
defaultExpanded: true,
|
||||
children: chapterChildren,
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: `${PREFIX}-doc-specs`,
|
||||
title: '专题规格',
|
||||
defaultExpanded: true,
|
||||
children: topicChildren,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const doc = JSON.parse(readText(jsonPath));
|
||||
const prd = readText(prdPath);
|
||||
|
||||
if (!doc.markdownMap || typeof doc.markdownMap !== 'object') {
|
||||
doc.markdownMap = {};
|
||||
}
|
||||
if (!doc.assetMap || typeof doc.assetMap !== 'object') {
|
||||
doc.assetMap = {};
|
||||
}
|
||||
|
||||
// Make / PrototypeAnnotationHost 只读顶层 directory
|
||||
doc.directory = buildDirectory(prd);
|
||||
|
||||
// 清理误写在 data 下的 directory,避免双源不一致
|
||||
if (doc.data && typeof doc.data === 'object' && 'directory' in doc.data) {
|
||||
delete doc.data.directory;
|
||||
}
|
||||
if (doc.data && typeof doc.data === 'object' && 'markdown' in doc.data) {
|
||||
delete doc.data.markdown;
|
||||
}
|
||||
|
||||
doc.data.updatedAt = Date.now();
|
||||
|
||||
writeFileSync(jsonPath, `${JSON.stringify(doc, null, 2)}\n`, 'utf8');
|
||||
|
||||
const chapterCount = doc.directory.nodes[0].children.find((n) => n.id === `${PREFIX}-doc-prd-chapters`)
|
||||
?.children?.length;
|
||||
const specCount = doc.directory.nodes[0].children.find((n) => n.id === `${PREFIX}-doc-specs`)
|
||||
?.children?.length;
|
||||
|
||||
console.log(
|
||||
`[sync-annotation-directory] ok → top-level directory; PRD 分章 ${chapterCount};专题规格 ${specCount}`,
|
||||
);
|
||||
3415
src/prototypes/oneos-h5-vehicle-assets/styles/index.css
Normal file
3415
src/prototypes/oneos-h5-vehicle-assets/styles/index.css
Normal file
File diff suppressed because it is too large
Load Diff
366
src/prototypes/oneos-h5-vehicle-assets/utils/demo-biz-records.ts
Normal file
366
src/prototypes/oneos-h5-vehicle-assets/utils/demo-biz-records.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* H5 演示兜底:台账种子未覆盖的车辆,为业务记录 Tab 生成只读用例数据,便于验收列表样式。
|
||||
* 原型本地演示,未接真实业务 API;有真实种子时优先用种子。
|
||||
*/
|
||||
import type { VehicleRecord } from './filter';
|
||||
import type { AccidentRecordRow } from '../../vehicle-management/utils/accidentRecords';
|
||||
import type { FaultRecordRow } from '../../vehicle-management/utils/faultRecords';
|
||||
import type { ViolationRecordRow } from '../../vehicle-management/utils/violationRecords';
|
||||
import type { MovementRecordRow } from '../../vehicle-management/utils/movementRecords';
|
||||
import type { TransferRecordRow } from '../../vehicle-management/utils/transferRecords';
|
||||
import type { AnnualReviewRecordRow } from '../../vehicle-management/utils/annualReviewRecords';
|
||||
import type { LeaseRecordRow } from '../../vehicle-management/utils/leaseRecords';
|
||||
|
||||
function plateHash(plateNo: string): number {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < plateNo.length; i += 1) {
|
||||
h ^= plateNo.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
function pick<T>(items: T[], hash: number, offset = 0): T {
|
||||
return items[(hash + offset) % items.length];
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/** 由哈希生成稳定的演示日期(近 18 个月内) */
|
||||
function demoDate(hash: number, offsetDays: number): string {
|
||||
const base = Date.UTC(2026, 6, 1); // 2026-07-01
|
||||
const dayMs = 86400000;
|
||||
const span = (hash % 400) + offsetDays;
|
||||
const d = new Date(base - span * dayMs);
|
||||
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
function demoDateTime(hash: number, offsetDays: number, hour: number): string {
|
||||
return `${demoDate(hash, offsetDays)} ${pad2(hour)}:${pad2((hash >> 3) % 60)}:${pad2((hash >> 5) % 60)}`;
|
||||
}
|
||||
|
||||
function customerOf(record: VehicleRecord): string {
|
||||
return (record.customer || record.projectName || '演示客户').trim() || '演示客户';
|
||||
}
|
||||
|
||||
function slugPlate(plateNo: string): string {
|
||||
return plateNo.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
export function ensureAccidentRecords(
|
||||
record: VehicleRecord,
|
||||
rows: AccidentRecordRow[],
|
||||
): AccidentRecordRow[] {
|
||||
if (rows.length) return rows;
|
||||
const h = plateHash(record.plateNo);
|
||||
const plate = slugPlate(record.plateNo);
|
||||
return [
|
||||
{
|
||||
id: `demo-acc-${plate}-1`,
|
||||
plateNo: record.plateNo,
|
||||
accidentCode: `ACC${demoDate(h, 40).replace(/-/g, '')}01`,
|
||||
accidentTime: demoDateTime(h, 40, 10),
|
||||
accidentLocation: `${record.location || '运营城市'}附近主干道`,
|
||||
accidentType: pick(['追尾', '侧碰', '刮擦', '单方'], h, 0),
|
||||
customerName: customerOf(record),
|
||||
accidentLevel: pick(['轻微', '一般', '较大'], h, 1),
|
||||
ourDamageAmount: 800 + (h % 50) * 100,
|
||||
theirDamageAmount: 500 + (h % 30) * 80,
|
||||
responsibility: pick(['我方全责', '对方全责', '双方同责'], h, 2),
|
||||
status: '已结案',
|
||||
closedTime: demoDateTime(h, 20, 15),
|
||||
},
|
||||
{
|
||||
id: `demo-acc-${plate}-2`,
|
||||
plateNo: record.plateNo,
|
||||
accidentCode: `ACC${demoDate(h, 120).replace(/-/g, '')}02`,
|
||||
accidentTime: demoDateTime(h, 120, 16),
|
||||
accidentLocation: `${record.parking || '停车场'}出口匝道`,
|
||||
accidentType: pick(['刮擦', '追尾', '倒车碰撞'], h, 3),
|
||||
customerName: customerOf(record),
|
||||
accidentLevel: '轻微',
|
||||
ourDamageAmount: 300 + (h % 20) * 50,
|
||||
theirDamageAmount: 0,
|
||||
responsibility: '我方全责',
|
||||
status: '处理中',
|
||||
closedTime: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function ensureFaultRecords(
|
||||
record: VehicleRecord,
|
||||
rows: FaultRecordRow[],
|
||||
): FaultRecordRow[] {
|
||||
if (rows.length) return rows;
|
||||
const h = plateHash(record.plateNo);
|
||||
const plate = slugPlate(record.plateNo);
|
||||
return [
|
||||
{
|
||||
id: `demo-fault-${plate}-1`,
|
||||
plateNo: record.plateNo,
|
||||
faultNo: `GZ${demoDate(h, 12).replace(/-/g, '')}${pad2(h % 100)}01`,
|
||||
resolutionStatus: '',
|
||||
brand: record.brand || '—',
|
||||
model: record.model || '—',
|
||||
operateCompany: record.operateCompany || '—',
|
||||
faultLevel: pick(['L2-紧急', 'L3-一般', 'L4-提示'], h, 0),
|
||||
faultType: pick(['冷机故障', '供氢系统故障', '电气系统', '底盘异响'], h, 1),
|
||||
faultDescription: '演示:现场巡检发现异常,待运维确认处置',
|
||||
reportedAt: demoDateTime(h, 12, 9),
|
||||
resolvedAt: '',
|
||||
aiMatched: Boolean(h % 2),
|
||||
lastOperator: '运维演示',
|
||||
},
|
||||
{
|
||||
id: `demo-fault-${plate}-2`,
|
||||
plateNo: record.plateNo,
|
||||
faultNo: `GZ${demoDate(h, 90).replace(/-/g, '')}${pad2(h % 100)}02`,
|
||||
resolutionStatus: '已解决',
|
||||
brand: record.brand || '—',
|
||||
model: record.model || '—',
|
||||
operateCompany: record.operateCompany || '—',
|
||||
faultLevel: 'L4-提示',
|
||||
faultType: pick(['胎压预警', '传感器故障', '仪表提示'], h, 2),
|
||||
faultDescription: '演示:已更换配件并路试通过',
|
||||
reportedAt: demoDateTime(h, 90, 14),
|
||||
resolvedAt: demoDateTime(h, 88, 11),
|
||||
aiMatched: true,
|
||||
lastOperator: 'AI',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function ensureViolationRecords(
|
||||
record: VehicleRecord,
|
||||
rows: ViolationRecordRow[],
|
||||
): ViolationRecordRow[] {
|
||||
if (rows.length) return rows;
|
||||
const h = plateHash(record.plateNo);
|
||||
const plate = slugPlate(record.plateNo);
|
||||
return [
|
||||
{
|
||||
id: `demo-vio-${plate}-1`,
|
||||
plateNo: record.plateNo,
|
||||
customerName: customerOf(record),
|
||||
violationTime: demoDateTime(h, 35, 8),
|
||||
violationLocation: `${record.location || '城区'}限速路段`,
|
||||
violationBehavior: pick(['超速行驶', '违法停车', '不按规定车道行驶', '闯红灯'], h, 0),
|
||||
pointsDeducted: pick([0, 3, 6], h, 1),
|
||||
fineAmount: pick([100, 200, 500], h, 2),
|
||||
paymentStatus: pick(['已缴费', '未缴费'], h, 3),
|
||||
processed: pick(['已处理', '未处理'], h, 4),
|
||||
collectionUnit: `${record.location || '当地'}交警支队`,
|
||||
},
|
||||
{
|
||||
id: `demo-vio-${plate}-2`,
|
||||
plateNo: record.plateNo,
|
||||
customerName: customerOf(record),
|
||||
violationTime: demoDateTime(h, 180, 19),
|
||||
violationLocation: '高速服务区入口',
|
||||
violationBehavior: '违法停车',
|
||||
pointsDeducted: 0,
|
||||
fineAmount: 200,
|
||||
paymentStatus: '已缴费',
|
||||
processed: '已处理',
|
||||
collectionUnit: '高速公路交警大队',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function ensureMovementRecords(
|
||||
record: VehicleRecord,
|
||||
rows: MovementRecordRow[],
|
||||
): MovementRecordRow[] {
|
||||
if (rows.length) return rows;
|
||||
const h = plateHash(record.plateNo);
|
||||
const plate = slugPlate(record.plateNo);
|
||||
const startKm = Number(record.mileage) || 12000 + (h % 8000);
|
||||
return [
|
||||
{
|
||||
id: `demo-mov-${plate}-1`,
|
||||
plateNo: record.plateNo,
|
||||
startDate: demoDate(h, 8),
|
||||
estimatedEndDate: '',
|
||||
status: '进行中',
|
||||
destinationType: '客户场地',
|
||||
destinationName: customerOf(record),
|
||||
movementType: pick(['临时调车', '短驳', '项目支援'], h, 0),
|
||||
estimatedMileageKm: 80 + (h % 120),
|
||||
startMileageKm: startKm,
|
||||
startBatteryKwh: 40 + (h % 40),
|
||||
startHydrogenPct: 30 + (h % 50),
|
||||
endMileageKm: 0,
|
||||
endBatteryKwh: 0,
|
||||
endHydrogenPct: 0,
|
||||
createdBy: '运维演示',
|
||||
createdAt: demoDateTime(h, 8, 9),
|
||||
},
|
||||
{
|
||||
id: `demo-mov-${plate}-2`,
|
||||
plateNo: record.plateNo,
|
||||
startDate: demoDate(h, 70),
|
||||
estimatedEndDate: demoDate(h, 65),
|
||||
status: '已完成',
|
||||
destinationType: '停车场',
|
||||
destinationName: record.parking || '中心停车场',
|
||||
movementType: '回场',
|
||||
estimatedMileageKm: 40 + (h % 60),
|
||||
startMileageKm: Math.max(0, startKm - 200),
|
||||
startBatteryKwh: 55,
|
||||
startHydrogenPct: 60,
|
||||
endMileageKm: Math.max(0, startKm - 150),
|
||||
endBatteryKwh: 42,
|
||||
endHydrogenPct: 48,
|
||||
createdBy: '调度演示',
|
||||
createdAt: demoDateTime(h, 70, 10),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function ensureTransferRecords(
|
||||
record: VehicleRecord,
|
||||
rows: TransferRecordRow[],
|
||||
): TransferRecordRow[] {
|
||||
if (rows.length) return rows;
|
||||
const h = plateHash(record.plateNo);
|
||||
const plate = slugPlate(record.plateNo);
|
||||
const city = record.location || '广州';
|
||||
return [
|
||||
{
|
||||
id: `demo-trf-${plate}-1`,
|
||||
plateNo: record.plateNo,
|
||||
transferDate: demoDate(h, 55),
|
||||
transferOutPerson: '调出演示',
|
||||
recipientPerson: '接收演示',
|
||||
departureArea: pick(['粤东', '粤西', '珠三角', '长三角'], h, 0),
|
||||
receivingArea: pick(['珠三角', '长三角', '华中'], h, 1),
|
||||
transferMethod: pick(['自驾', '板车运输', '司机接力'], h, 2),
|
||||
departureParking: record.parking || `${city}停车场A`,
|
||||
receivingParking: `${city}停车场B`,
|
||||
receiveDate: demoDate(h, 52),
|
||||
},
|
||||
{
|
||||
id: `demo-trf-${plate}-2`,
|
||||
plateNo: record.plateNo,
|
||||
transferDate: demoDate(h, 200),
|
||||
transferOutPerson: '仓管演示',
|
||||
recipientPerson: '站长演示',
|
||||
departureArea: '总部仓',
|
||||
receivingArea: city,
|
||||
transferMethod: '板车运输',
|
||||
departureParking: '总部整备区',
|
||||
receivingParking: record.parking || `${city}停车场`,
|
||||
receiveDate: demoDate(h, 196),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function ensureAnnualReviewRecords(
|
||||
record: VehicleRecord,
|
||||
rows: AnnualReviewRecordRow[],
|
||||
): AnnualReviewRecordRow[] {
|
||||
if (rows.length) return rows;
|
||||
const h = plateHash(record.plateNo);
|
||||
const plate = slugPlate(record.plateNo);
|
||||
const valid = record.inspectExpire || demoDate(h, -200);
|
||||
return [
|
||||
{
|
||||
id: `demo-arv-${plate}-1`,
|
||||
plateNo: record.plateNo,
|
||||
inspectionValidUntil: valid.slice(0, 10),
|
||||
inspectionStation: `${record.location || '本地'}机动车检测站`,
|
||||
inspectionCost: 320 + (h % 80),
|
||||
m2Station: '',
|
||||
m2Cost: null,
|
||||
zbStation: '',
|
||||
zbCost: null,
|
||||
executor: '年审演示',
|
||||
executeTime: demoDateTime(h, 30, 11),
|
||||
},
|
||||
{
|
||||
id: `demo-arv-${plate}-2`,
|
||||
plateNo: record.plateNo,
|
||||
inspectionValidUntil: demoDate(h, 400),
|
||||
inspectionStation: '区域综合检测中心',
|
||||
inspectionCost: 280,
|
||||
m2Station: 'M2检测点',
|
||||
m2Cost: 120,
|
||||
zbStation: '',
|
||||
zbCost: null,
|
||||
executor: '运维演示',
|
||||
executeTime: demoDateTime(h, 390, 10),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildLeaseDemoRows(record: VehicleRecord): LeaseRecordRow[] {
|
||||
const h = plateHash(record.plateNo);
|
||||
const plate = slugPlate(record.plateNo);
|
||||
const customer = customerOf(record);
|
||||
const project = (record.projectName || `${customer}运营项目`).trim();
|
||||
const contract = (record.contractNo || `LNZLHT${demoDate(h, 0).replace(/-/g, '')}-DEMO`).trim();
|
||||
const biz: LeaseRecordRow['businessType'] = record.operateStatus === '物流' ? '物流' : '租赁';
|
||||
|
||||
return [
|
||||
{
|
||||
id: `demo-lease-${plate}-1`,
|
||||
plateNo: record.plateNo,
|
||||
contractNo: contract,
|
||||
projectName: project,
|
||||
customerName: customer,
|
||||
businessType: biz,
|
||||
deliveryDate: demoDate(h, 60),
|
||||
deliveryPerson: '交车演示',
|
||||
returnDate: '',
|
||||
returnPerson: '',
|
||||
},
|
||||
{
|
||||
id: `demo-lease-${plate}-2`,
|
||||
plateNo: record.plateNo,
|
||||
contractNo: `LNZLHT${demoDate(h, 200).replace(/-/g, '')}-018`,
|
||||
projectName: `${customer}冷链一期`,
|
||||
customerName: customer,
|
||||
businessType: '租赁',
|
||||
deliveryDate: demoDate(h, 200),
|
||||
deliveryPerson: '交车演示',
|
||||
returnDate: demoDate(h, 90),
|
||||
returnPerson: '还车演示',
|
||||
},
|
||||
{
|
||||
id: `demo-lease-${plate}-3`,
|
||||
plateNo: record.plateNo,
|
||||
contractNo: `LNZLHT${demoDate(h, 420).replace(/-/g, '')}-006`,
|
||||
projectName: `${customer}周转项目`,
|
||||
customerName: customer,
|
||||
businessType: biz,
|
||||
deliveryDate: demoDate(h, 420),
|
||||
deliveryPerson: '交车演示',
|
||||
returnDate: demoDate(h, 250),
|
||||
returnPerson: '还车演示',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** 保证至少 3 条,便于时间轴完整体验;有种子则保留并补足历史演示 */
|
||||
export function ensureLeaseRecords(
|
||||
record: VehicleRecord,
|
||||
rows: LeaseRecordRow[],
|
||||
): LeaseRecordRow[] {
|
||||
const MIN = 3;
|
||||
const demos = buildLeaseDemoRows(record);
|
||||
if (!rows.length) return demos;
|
||||
if (rows.length >= MIN) return rows;
|
||||
|
||||
const used = new Set(rows.map((row) => row.contractNo).filter(Boolean));
|
||||
const padded = [...rows];
|
||||
for (const demo of demos) {
|
||||
if (padded.length >= MIN) break;
|
||||
if (used.has(demo.contractNo)) continue;
|
||||
padded.push(demo);
|
||||
used.add(demo.contractNo);
|
||||
}
|
||||
return padded;
|
||||
}
|
||||
386
src/prototypes/oneos-h5-vehicle-assets/utils/filter.ts
Normal file
386
src/prototypes/oneos-h5-vehicle-assets/utils/filter.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import type { KpiKey, VehicleRecord } from '../../vehicle-management/types';
|
||||
import {
|
||||
countKpi,
|
||||
formatOperateCity,
|
||||
isLicenseExpired,
|
||||
isNonOperating,
|
||||
matchKpi,
|
||||
normalizePlate,
|
||||
} from '../../vehicle-management/utils/vehicle';
|
||||
import { KPI_CARDS } from '../../vehicle-management/utils/vehicle';
|
||||
|
||||
export { KPI_CARDS, countKpi, matchKpi, isLicenseExpired, isNonOperating, normalizePlate, formatOperateCity };
|
||||
export type { KpiKey, VehicleRecord };
|
||||
|
||||
export type LicenseFilter = '' | '正常' | '异常';
|
||||
export type InsuranceFilter = '' | '正常' | '异常';
|
||||
/** 出库状态(原型口径:由车辆状态推导) */
|
||||
export type OutboundFilter = '' | '未出库' | '已出库';
|
||||
export type OnlineFilter = '' | '在线' | '离线';
|
||||
|
||||
/** 仍在库、尚未交付出库的车辆状态 */
|
||||
const IN_STOCK_VEHICLE_STATUSES = new Set(['待验车', '未备车', '已备车', '待交车']);
|
||||
|
||||
export function isInsuranceAbnormal(record: VehicleRecord): boolean {
|
||||
return record.insuranceStatus === '异常';
|
||||
}
|
||||
|
||||
export function matchLicenseFilter(record: VehicleRecord, filter: LicenseFilter): boolean {
|
||||
if (!filter) return true;
|
||||
const abnormal = isLicenseExpired(record);
|
||||
return filter === '异常' ? abnormal : !abnormal;
|
||||
}
|
||||
|
||||
export function matchInsuranceFilter(record: VehicleRecord, filter: InsuranceFilter): boolean {
|
||||
if (!filter) return true;
|
||||
const abnormal = isInsuranceAbnormal(record);
|
||||
return filter === '异常' ? abnormal : !abnormal;
|
||||
}
|
||||
|
||||
export function matchPlateSearch(record: VehicleRecord, query: string): boolean {
|
||||
const raw = query.trim();
|
||||
if (!raw) return true;
|
||||
const qPlate = normalizePlate(raw);
|
||||
const qLower = raw.toLowerCase();
|
||||
if (qPlate && normalizePlate(record.plateNo).includes(qPlate)) return true;
|
||||
if (qPlate && normalizePlate(record.vin || '').includes(qPlate)) return true;
|
||||
const haystack = [record.parking, record.customer, record.projectName]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(qLower);
|
||||
}
|
||||
|
||||
export function matchBrandModelFilter(
|
||||
record: VehicleRecord,
|
||||
brand: string,
|
||||
model: string,
|
||||
): boolean {
|
||||
if (brand && record.brand !== brand) return false;
|
||||
if (model && record.model !== model) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function matchVehicleStatusFilter(record: VehicleRecord, status: string): boolean {
|
||||
if (!status) return true;
|
||||
return record.vehicleStatus === status;
|
||||
}
|
||||
|
||||
export function matchVehicleSourceFilter(record: VehicleRecord, source: string): boolean {
|
||||
if (!source) return true;
|
||||
return (record.vehicleSource || '').trim() === source;
|
||||
}
|
||||
|
||||
export function matchOnlineStatusFilter(record: VehicleRecord, online: OnlineFilter): boolean {
|
||||
if (!online) return true;
|
||||
return (record.onlineStatus || '').trim() === online;
|
||||
}
|
||||
|
||||
/**
|
||||
* 出库状态(原型推导,未接独立出库字段):
|
||||
* - 未出库:待验车 / 未备车 / 已备车 / 待交车
|
||||
* - 已出库:其余有车辆状态的车辆
|
||||
*/
|
||||
export function matchOutboundFilter(record: VehicleRecord, outbound: OutboundFilter): boolean {
|
||||
if (!outbound) return true;
|
||||
const status = (record.vehicleStatus || '').trim();
|
||||
if (!status || status === '-' || status === '无') return false;
|
||||
const inStock = IN_STOCK_VEHICLE_STATUSES.has(status);
|
||||
return outbound === '未出库' ? inStock : !inStock;
|
||||
}
|
||||
|
||||
/** 统一省-市键:折叠多余连字符(如 浙江省--嘉兴市 → 浙江省-嘉兴市) */
|
||||
export function normalizeOperateCityKey(locationOrKey: string): string {
|
||||
const raw = formatOperateCity(locationOrKey || '', '') || String(locationOrKey || '').trim();
|
||||
if (!raw || raw === '—' || raw === '-') return '';
|
||||
return raw.replace(/-+/g, '-');
|
||||
}
|
||||
|
||||
export function splitOperateCity(filterOrKey: string): { province: string; city: string } {
|
||||
const normalized = normalizeOperateCityKey(filterOrKey) || String(filterOrKey || '').trim().replace(/-+/g, '-');
|
||||
if (!normalized) return { province: '', city: '' };
|
||||
// 仅省(无「-」)
|
||||
if (!normalized.includes('-')) return { province: normalized, city: '' };
|
||||
const idx = normalized.indexOf('-');
|
||||
return {
|
||||
province: normalized.slice(0, idx),
|
||||
city: normalized.slice(idx + 1),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 运营城市筛选:
|
||||
* - 空:不限
|
||||
* - 仅省(如「浙江省」):该省下全部车辆
|
||||
* - 省-市(如「浙江省-嘉兴市」):精确匹配
|
||||
*/
|
||||
export function matchOperateCityFilter(record: VehicleRecord, filter: string): boolean {
|
||||
if (!filter) return true;
|
||||
const recordKey = normalizeOperateCityKey(record.location || '');
|
||||
if (!recordKey) return false;
|
||||
const { province: wantProvince, city: wantCity } = splitOperateCity(filter);
|
||||
const { province: gotProvince, city: gotCity } = splitOperateCity(recordKey);
|
||||
if (!wantProvince) return true;
|
||||
if (wantCity) return gotProvince === wantProvince && gotCity === wantCity;
|
||||
return gotProvince === wantProvince;
|
||||
}
|
||||
|
||||
export function collectBrandOptions(records: VehicleRecord[]): Array<{ value: string; count: number }> {
|
||||
const map = new Map<string, number>();
|
||||
for (const record of records) {
|
||||
const brand = (record.brand || '').trim();
|
||||
if (!brand || brand === '-') continue;
|
||||
map.set(brand, (map.get(brand) || 0) + 1);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.map(([value, count]) => ({ value, count }))
|
||||
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function collectModelOptions(
|
||||
records: VehicleRecord[],
|
||||
brand: string,
|
||||
): Array<{ value: string; count: number }> {
|
||||
const map = new Map<string, number>();
|
||||
for (const record of records) {
|
||||
if (brand && record.brand !== brand) continue;
|
||||
const model = (record.model || '').trim();
|
||||
if (!model || model === '-') continue;
|
||||
map.set(model, (map.get(model) || 0) + 1);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.map(([value, count]) => ({ value, count }))
|
||||
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value, 'zh-CN'));
|
||||
}
|
||||
|
||||
/** 与 PC 车辆资产字典对齐(筛选不展示「无」) */
|
||||
export const VEHICLE_STATUS_CATALOG = [
|
||||
'待验车',
|
||||
'未备车',
|
||||
'已备车',
|
||||
'待交车',
|
||||
'已交车',
|
||||
'待还车',
|
||||
'销售中',
|
||||
'替换中',
|
||||
'调拨中',
|
||||
'异动中',
|
||||
'三方退租中',
|
||||
] as const;
|
||||
|
||||
export function collectVehicleStatusOptions(
|
||||
records: VehicleRecord[],
|
||||
): Array<{ value: string; count: number }> {
|
||||
const map = new Map<string, number>();
|
||||
for (const status of VEHICLE_STATUS_CATALOG) {
|
||||
map.set(status, 0);
|
||||
}
|
||||
for (const record of records) {
|
||||
const status = (record.vehicleStatus || '').trim();
|
||||
if (!status || status === '-' || status === '无') continue;
|
||||
map.set(status, (map.get(status) || 0) + 1);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.map(([value, count]) => ({ value, count }))
|
||||
.sort((a, b) => {
|
||||
const ai = (VEHICLE_STATUS_CATALOG as readonly string[]).indexOf(a.value);
|
||||
const bi = (VEHICLE_STATUS_CATALOG as readonly string[]).indexOf(b.value);
|
||||
if (ai !== -1 || bi !== -1) return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
return b.count - a.count || a.value.localeCompare(b.value, 'zh-CN');
|
||||
});
|
||||
}
|
||||
|
||||
export function collectOperateCityOptions(
|
||||
records: VehicleRecord[],
|
||||
): Array<{ value: string; count: number }> {
|
||||
const map = new Map<string, number>();
|
||||
for (const record of records) {
|
||||
const city = normalizeOperateCityKey(record.location || '');
|
||||
if (!city) continue;
|
||||
map.set(city, (map.get(city) || 0) + 1);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.map(([value, count]) => ({ value, count }))
|
||||
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function collectProvinceOptions(
|
||||
records: VehicleRecord[],
|
||||
): Array<{ value: string; count: number }> {
|
||||
const map = new Map<string, number>();
|
||||
for (const record of records) {
|
||||
const key = normalizeOperateCityKey(record.location || '');
|
||||
if (!key) continue;
|
||||
const { province } = splitOperateCity(key);
|
||||
if (!province) continue;
|
||||
map.set(province, (map.get(province) || 0) + 1);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.map(([value, count]) => ({ value, count }))
|
||||
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function collectCityOptionsByProvince(
|
||||
records: VehicleRecord[],
|
||||
province: string,
|
||||
): Array<{ value: string; count: number }> {
|
||||
if (!province) return [];
|
||||
const map = new Map<string, number>();
|
||||
for (const record of records) {
|
||||
const key = normalizeOperateCityKey(record.location || '');
|
||||
if (!key) continue;
|
||||
const parts = splitOperateCity(key);
|
||||
if (parts.province !== province || !parts.city) continue;
|
||||
map.set(parts.city, (map.get(parts.city) || 0) + 1);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.map(([value, count]) => ({ value, count }))
|
||||
.sort((a, b) => b.count - a.count || a.value.localeCompare(b.value, 'zh-CN'));
|
||||
}
|
||||
|
||||
export function collectLicenseStatusOptions(
|
||||
records: VehicleRecord[],
|
||||
): Array<{ value: string; count: number }> {
|
||||
const map = new Map<string, number>();
|
||||
for (const record of records) {
|
||||
const status = (record.licenseStatus || '').trim();
|
||||
if (!status || status === '-') continue;
|
||||
map.set(status, (map.get(status) || 0) + 1);
|
||||
}
|
||||
const preferred = ['正常', '异常'];
|
||||
return [...map.entries()]
|
||||
.map(([value, count]) => ({ value, count }))
|
||||
.sort((a, b) => {
|
||||
const ai = preferred.indexOf(a.value);
|
||||
const bi = preferred.indexOf(b.value);
|
||||
if (ai !== -1 || bi !== -1) return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi);
|
||||
return b.count - a.count || a.value.localeCompare(b.value, 'zh-CN');
|
||||
});
|
||||
}
|
||||
|
||||
export function formatBrandModelChipLabel(brand: string, model: string): string {
|
||||
if (brand && model) return model.length > 6 ? `${brand}·${model.slice(0, 5)}…` : `${brand}·${model}`;
|
||||
if (brand) return brand;
|
||||
return '品牌车型';
|
||||
}
|
||||
|
||||
export function formatOperateCityChipLabel(filter: string): string {
|
||||
if (!filter) return '运营城市';
|
||||
const { province, city } = splitOperateCity(filter);
|
||||
if (city) {
|
||||
const full = `${province}-${city}`;
|
||||
if (full.length <= 8) return full;
|
||||
return city;
|
||||
}
|
||||
return province || '运营城市';
|
||||
}
|
||||
|
||||
export type CardContextTagTone = 'muted' | 'warn' | 'success' | 'neutral';
|
||||
|
||||
export type CardContextTag = {
|
||||
label: string;
|
||||
tone: CardContextTagTone;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/** 卡片次要标签:按运营状态展示客户名 / 备车状态 / 非运营 / 退出原因 */
|
||||
export function resolveCardContextTag(record: VehicleRecord): CardContextTag | null {
|
||||
const operateStatus = (record.operateStatus || '').trim();
|
||||
const vehicleStatus = (record.vehicleStatus || '').trim();
|
||||
const customer = (record.customer || record.projectName || '').trim();
|
||||
|
||||
if (operateStatus === '退出运营') {
|
||||
const reason = resolveExitReasonLabel(vehicleStatus, record);
|
||||
return { label: reason, tone: 'muted', title: reason };
|
||||
}
|
||||
|
||||
if (isNonOperating(record) || operateStatus === '非运营') {
|
||||
return { label: '非运营车辆', tone: 'muted' };
|
||||
}
|
||||
|
||||
// 导表「自营」在台账中映射为「物流」,展示口径仍按自营业务处理
|
||||
if (operateStatus === '租赁' || operateStatus === '自营' || operateStatus === '物流') {
|
||||
if (!customer || customer === '-') return null;
|
||||
return {
|
||||
label: customer,
|
||||
tone: 'muted',
|
||||
title: customer,
|
||||
};
|
||||
}
|
||||
|
||||
// 库存:可运营 / 待运营
|
||||
if (operateStatus === '可运营' || operateStatus === '待运营' || operateStatus === '库存') {
|
||||
if (vehicleStatus === '未备车') {
|
||||
return { label: '未备车', tone: 'warn' };
|
||||
}
|
||||
if (vehicleStatus === '已备车') {
|
||||
return { label: '已备车', tone: 'success' };
|
||||
}
|
||||
if (vehicleStatus) {
|
||||
return { label: vehicleStatus, tone: 'neutral', title: vehicleStatus };
|
||||
}
|
||||
return { label: '未备车', tone: 'warn' };
|
||||
}
|
||||
|
||||
if (customer && customer !== '-') {
|
||||
return { label: customer, tone: 'muted', title: customer };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveExitReasonLabel(vehicleStatus: string, record: VehicleRecord): string {
|
||||
const raw = `${vehicleStatus} ${record.outStatus || ''} ${record.vehicleStatus || ''}`;
|
||||
if (raw.includes('销售')) return '销售';
|
||||
if (raw.includes('报废')) return '报废';
|
||||
if (raw.includes('退租') || raw.includes('三方')) return '三方退租';
|
||||
if (vehicleStatus === '销售' || vehicleStatus === '报废' || vehicleStatus === '三方退租') {
|
||||
return vehicleStatus;
|
||||
}
|
||||
return '三方退租';
|
||||
}
|
||||
|
||||
export function formatOperateStatusLabel(status: string): string {
|
||||
if (status === '物流') return '自营';
|
||||
return status || '—';
|
||||
}
|
||||
|
||||
export function filterVehicles(
|
||||
records: VehicleRecord[],
|
||||
options: {
|
||||
kpi: KpiKey;
|
||||
plateQuery: string;
|
||||
licenseStatus: LicenseFilter;
|
||||
insuranceStatus: InsuranceFilter;
|
||||
brand?: string;
|
||||
model?: string;
|
||||
operateCity?: string;
|
||||
vehicleStatus?: string;
|
||||
outboundStatus?: OutboundFilter;
|
||||
vehicleSource?: string;
|
||||
onlineStatus?: OnlineFilter;
|
||||
},
|
||||
): VehicleRecord[] {
|
||||
return records.filter((record) => (
|
||||
matchKpi(record, options.kpi)
|
||||
&& matchPlateSearch(record, options.plateQuery)
|
||||
&& matchBrandModelFilter(record, options.brand || '', options.model || '')
|
||||
&& matchOperateCityFilter(record, options.operateCity || '')
|
||||
&& matchVehicleStatusFilter(record, options.vehicleStatus || '')
|
||||
&& matchOutboundFilter(record, options.outboundStatus || '')
|
||||
&& matchVehicleSourceFilter(record, options.vehicleSource || '')
|
||||
&& matchOnlineStatusFilter(record, options.onlineStatus || '')
|
||||
&& matchLicenseFilter(record, options.licenseStatus)
|
||||
&& matchInsuranceFilter(record, options.insuranceStatus)
|
||||
));
|
||||
}
|
||||
|
||||
export function countAllKpis(records: VehicleRecord[]): Record<KpiKey, number> {
|
||||
return {
|
||||
all: countKpi(records, 'all'),
|
||||
lease: countKpi(records, 'lease'),
|
||||
logistics: countKpi(records, 'logistics'),
|
||||
stock: countKpi(records, 'stock'),
|
||||
nonOperating: countKpi(records, 'nonOperating'),
|
||||
exit: countKpi(records, 'exit'),
|
||||
};
|
||||
}
|
||||
158
src/prototypes/oneos-h5-vehicle-assets/utils/mileage-task.ts
Normal file
158
src/prototypes/oneos-h5-vehicle-assets/utils/mileage-task.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { VehicleRecord } from './filter';
|
||||
|
||||
/** 里程任务完成预判(对比任务截止剩余日历天 vs 按活跃日推算的预计天数) */
|
||||
export type MileageForecastTone = 'ok' | 'risk' | 'fail';
|
||||
|
||||
export type MileageForecastTag = {
|
||||
tone: MileageForecastTone;
|
||||
/** 如:剩余 28 天,预计无法完成 */
|
||||
label: string;
|
||||
};
|
||||
|
||||
/** 列表卡片里程任务展示态(原型演示;未接真实工单 API) */
|
||||
export type MileageTaskCardView =
|
||||
| { kind: 'empty' }
|
||||
| {
|
||||
kind: 'active' | 'done';
|
||||
progressPct: number;
|
||||
remainingKm: number;
|
||||
/** null = 暂无法预计 */
|
||||
etaDays: number | null;
|
||||
/** 任务截止剩余日历天(合同/工单时间窗) */
|
||||
deadlineRemainingDays: number;
|
||||
/** 进行中才有;已完成无预判标签 */
|
||||
forecast: MileageForecastTag | null;
|
||||
};
|
||||
|
||||
function hashPlate(key: string): number {
|
||||
let h = 2166136261;
|
||||
for (let i = 0; i < key.length; i += 1) {
|
||||
h ^= key.charCodeAt(i);
|
||||
h = Math.imul(h, 16777619);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/** 近最多 7 个活跃日(日里程 > 0)的平均日里程;无活跃日返回 null */
|
||||
export function avgActiveDailyKm(activeDayKm: number[]): number | null {
|
||||
const active = activeDayKm.filter((km) => km > 0).slice(0, 7);
|
||||
if (active.length === 0) return null;
|
||||
const sum = active.reduce((acc, km) => acc + km, 0);
|
||||
return sum / active.length;
|
||||
}
|
||||
|
||||
export function computeEtaDays(remainingKm: number, avgDailyKm: number | null): number | null {
|
||||
if (remainingKm <= 0) return 0;
|
||||
if (avgDailyKm == null || avgDailyKm <= 0) return null;
|
||||
return Math.ceil(remainingKm / avgDailyKm);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用近 7 活跃日推算的预计天数,对照任务截止剩余日历天,给出完成预判标签。
|
||||
*
|
||||
* | 优先级 | 条件 | 标签 |
|
||||
* |--------|------|------|
|
||||
* | 1 | 预计天数无法算出(无活跃日) | 剩余 x 天,预计有完成风险 |
|
||||
* | 2 | 预计天数 > 截止剩余天(或已过截止) | 剩余 x 天,预计无法完成 |
|
||||
* | 3 | 预计天数 > 截止剩余天 × 85%,或缓冲 ≤ 7 天 | 剩余 x 天,预计有完成风险 |
|
||||
* | 4 | 其余 | 剩余 x 天,预计可完成 |
|
||||
*/
|
||||
export function resolveMileageForecast(
|
||||
etaDays: number | null,
|
||||
deadlineRemainingDays: number,
|
||||
): MileageForecastTag {
|
||||
const x = Math.max(0, Math.round(deadlineRemainingDays));
|
||||
const prefix = `剩余 ${x} 天`;
|
||||
|
||||
if (etaDays == null) {
|
||||
return { tone: 'risk', label: `${prefix},预计有完成风险` };
|
||||
}
|
||||
if (x <= 0 || etaDays > x) {
|
||||
return { tone: 'fail', label: `${prefix},预计无法完成` };
|
||||
}
|
||||
const buffer = x - etaDays;
|
||||
if (etaDays > x * 0.85 || buffer <= 7) {
|
||||
return { tone: 'risk', label: `${prefix},预计有完成风险` };
|
||||
}
|
||||
return { tone: 'ok', label: `${prefix},预计可完成` };
|
||||
}
|
||||
|
||||
/**
|
||||
* 按车牌稳定种子生成演示里程任务。
|
||||
* ~65% 有任务;活跃日序列用于预计天数;截止剩余天用于完成预判标签。
|
||||
*/
|
||||
export function resolveMileageTaskCard(record: VehicleRecord): MileageTaskCardView {
|
||||
const key = String(record.plateNo || record.id || '').trim() || 'unknown';
|
||||
const h = hashPlate(key);
|
||||
|
||||
// 约 35% 无任务 → 展示「暂无里程任务」
|
||||
if (h % 100 < 35) return { kind: 'empty' };
|
||||
|
||||
const targetKm = 8000 + (h % 12) * 1000;
|
||||
const progressSeed = 12 + (h % 88); // 12%–99%
|
||||
let completedKm = Math.round((targetKm * progressSeed) / 100);
|
||||
|
||||
// 少量演示「已完成」
|
||||
if (h % 17 === 0) completedKm = targetKm;
|
||||
|
||||
const remainingKm = Math.max(0, targetKm - completedKm);
|
||||
const progressPct = Math.min(100, Math.round((completedKm / targetKm) * 1000) / 10);
|
||||
|
||||
// 合同/工单时间窗:截止剩余日历天(演示种子 10–90 天)
|
||||
const deadlineRemainingDays = 10 + (h % 81);
|
||||
|
||||
if (remainingKm === 0 || progressPct >= 100) {
|
||||
return {
|
||||
kind: 'done',
|
||||
progressPct: 100,
|
||||
remainingKm: 0,
|
||||
etaDays: 0,
|
||||
deadlineRemainingDays,
|
||||
forecast: null,
|
||||
};
|
||||
}
|
||||
|
||||
// 构造「近窗」日里程:穿插 0 表示非活跃日;再取活跃日
|
||||
const windowDays: number[] = [];
|
||||
for (let i = 0; i < 14; i += 1) {
|
||||
const dayBits = (h >>> (i % 8)) + i * 37;
|
||||
if (dayBits % 5 === 0) {
|
||||
windowDays.push(0);
|
||||
} else {
|
||||
windowDays.push(35 + (dayBits % 180));
|
||||
}
|
||||
}
|
||||
|
||||
// 少数车无活跃日 → 暂无法预计
|
||||
const forcedIdle = h % 23 === 0;
|
||||
const activeSeq = forcedIdle ? windowDays.map(() => 0) : windowDays;
|
||||
const avg = avgActiveDailyKm(activeSeq);
|
||||
const etaDays = computeEtaDays(remainingKm, avg);
|
||||
const forecast = resolveMileageForecast(etaDays, deadlineRemainingDays);
|
||||
|
||||
return {
|
||||
kind: 'active',
|
||||
progressPct,
|
||||
remainingKm,
|
||||
etaDays,
|
||||
deadlineRemainingDays,
|
||||
forecast,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatKm(km: number): string {
|
||||
return Math.round(km).toLocaleString('zh-CN');
|
||||
}
|
||||
|
||||
/** 完成百分比展示:固定 1 位小数 */
|
||||
export function formatProgressPct(pct: number): string {
|
||||
const clamped = Math.min(100, Math.max(0, pct));
|
||||
return clamped.toFixed(1);
|
||||
}
|
||||
|
||||
export function formatMileageTaskMeta(view: Extract<MileageTaskCardView, { kind: 'active' | 'done' }>): string {
|
||||
if (view.kind === 'done') return '已完成';
|
||||
const remain = `剩余 ${formatKm(view.remainingKm)} km`;
|
||||
if (view.etaDays == null) return `${remain} · 暂无法预计`;
|
||||
return `${remain} · 预计 ${view.etaDays} 天内完成`;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,28 @@
|
||||
# 原型演示
|
||||
|
||||
> 原型 ID:`oneos-prototype-demo`
|
||||
> 外壳:`src/common/oneos-app-shell`
|
||||
|
||||
## 目标
|
||||
|
||||
提供带「羚牛 OneOS」左侧菜单与顶部栏的统一演示入口;从菜单进入各原型时,在**内容区**嵌入显示。
|
||||
|
||||
## 行为
|
||||
|
||||
| 入口 | 是否显示侧栏/顶栏 | 内容 |
|
||||
|------|-------------------|------|
|
||||
| `/prototypes/oneos-prototype-demo` | 是 | 欢迎页或 iframe 嵌入所选原型 |
|
||||
| `/prototypes/<其它原型>` 直链 | 否 | 该原型自身页面 |
|
||||
|
||||
- 默认打开「工作台-新」;侧栏**不展示**「原型演示」菜单项
|
||||
- 嵌入方式:内容区 `iframe` 加载 `/prototypes/...`(无外壳)
|
||||
- 地址同步:`#proto=<prototype-id>`(默认 `#proto=oneos-web-workbench-new`)
|
||||
- 页签:可同时打开多个原型,切换/关闭;关至空时回到工作台-新
|
||||
- **内容区跳转同步**:iframe 内通过页面功能跳转到其它 `/prototypes/...` 时,侧栏高亮与顶部页签同步打开对应项(`ONEOS_SHELL_NAV`)
|
||||
|
||||
## 验收
|
||||
|
||||
1. 打开「原型演示」可见侧栏与顶栏。
|
||||
2. 点左侧「工作台-新」等,内容区显示对应原型。
|
||||
3. 新标签直接打开 `/prototypes/oneos-web-workbench-new`,**没有**侧栏顶栏。
|
||||
4. 在工作台内点「去处理」等跳转其它原型时,侧栏切到对应菜单,顶部页签新增该页。
|
||||
52
src/prototypes/oneos-prototype-demo/annotation-source.json
Normal file
52
src/prototypes/oneos-prototype-demo/annotation-source.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"documentVersion": 1,
|
||||
"format": "axhub-annotation-source",
|
||||
"data": {
|
||||
"version": 2,
|
||||
"prototypeName": "oneos-prototype-demo",
|
||||
"pageId": "main",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "demo-shell",
|
||||
"index": 0,
|
||||
"title": "原型演示外壳",
|
||||
"pageId": "main",
|
||||
"locator": {
|
||||
"selectors": [
|
||||
".oneos-shell"
|
||||
],
|
||||
"fingerprint": "demo-shell",
|
||||
"path": []
|
||||
},
|
||||
"annotationText": "侧栏+顶栏仅在本页;菜单点选后在内容区 iframe 嵌入原型。直链打开原型无外壳。工作台新消息入口为顶栏铃铛:未读时闪烁气泡指向图标,点击下拉通知中心。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#346dff",
|
||||
"images": [],
|
||||
"createdAt": 1784376000000,
|
||||
"updatedAt": 1784376000000,
|
||||
"controls": []
|
||||
}
|
||||
],
|
||||
"updatedAt": 1784376000000
|
||||
},
|
||||
"markdownMap": {},
|
||||
"assetMap": {},
|
||||
"directory": {
|
||||
"nodes": [
|
||||
{
|
||||
"type": "folder",
|
||||
"id": "demo-doc-root",
|
||||
"title": "原型演示说明",
|
||||
"defaultExpanded": true,
|
||||
"children": [
|
||||
{
|
||||
"type": "markdown",
|
||||
"id": "demo-doc-prd",
|
||||
"title": "说明",
|
||||
"markdownPath": ".spec/requirements-prd.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
319
src/prototypes/oneos-prototype-demo/index.tsx
Normal file
319
src/prototypes/oneos-prototype-demo/index.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* @name 原型演示
|
||||
* 羚牛 OneOS 侧栏 + 顶栏;内容区嵌入各原型页面(iframe)。
|
||||
* 直接打开其它 /prototypes/* 地址时不带本外壳。
|
||||
* 默认打开「工作台-新」;侧栏不展示「原型演示」自身。
|
||||
*/
|
||||
import './style.css';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
type AnnotationSourceDocument,
|
||||
type AnnotationViewerOptions,
|
||||
} from '@axhub/annotation';
|
||||
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
|
||||
import {
|
||||
OneOsAppShell,
|
||||
PROTOTYPE_DEMO_HREF,
|
||||
type ShellMenuItem,
|
||||
type ShellTab,
|
||||
findMenuPath,
|
||||
buildShellMenuFromNav,
|
||||
broadcastOneOsTheme,
|
||||
hasUnseenRelease,
|
||||
hrefPathname,
|
||||
isLegacyOneOsPrototype,
|
||||
isShellNav,
|
||||
postReleaseDemoOpen,
|
||||
readStoredOneOsTheme,
|
||||
type OneOsTheme,
|
||||
} from '../../common/oneos-app-shell';
|
||||
import navMenuJson from '../oneos-prototype-nav/nav-menu.json';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
import { AnnotationViewer } from '@axhub/annotation';
|
||||
|
||||
const HASH_KEY = 'proto';
|
||||
const DEFAULT_PROTO_ID = 'oneos-web-workbench-new';
|
||||
const DEFAULT_HREF = `/prototypes/${DEFAULT_PROTO_ID}`;
|
||||
const DEFAULT_TITLE = '工作台-新';
|
||||
|
||||
function hrefToProtoId(href: string): string {
|
||||
return hrefPathname(href).replace(/^\/prototypes\//, '');
|
||||
}
|
||||
|
||||
/** 页签/菜单用 pathname;iframe 可保留 query */
|
||||
function tabHref(href: string): string {
|
||||
return hrefPathname(href);
|
||||
}
|
||||
|
||||
function protoIdToHref(id: string): string {
|
||||
return `/prototypes/${id}`;
|
||||
}
|
||||
|
||||
function readHashProto(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const raw = window.location.hash.replace(/^#/, '');
|
||||
if (raw.startsWith(`${HASH_KEY}=`)) {
|
||||
const v = raw.slice(HASH_KEY.length + 1).split('&')[0];
|
||||
return v ? decodeURIComponent(v) : null;
|
||||
}
|
||||
const params = new URLSearchParams(raw.includes('=') ? raw : '');
|
||||
const v = params.get(HASH_KEY);
|
||||
return v ? decodeURIComponent(v) : null;
|
||||
}
|
||||
|
||||
function writeHashProto(protoId: string) {
|
||||
if (typeof window === 'undefined') return;
|
||||
const next = `#${HASH_KEY}=${encodeURIComponent(protoId)}`;
|
||||
if (window.location.hash !== next) {
|
||||
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}${next}`);
|
||||
}
|
||||
}
|
||||
|
||||
function isDemoHref(href?: string): boolean {
|
||||
if (!href) return false;
|
||||
return href === PROTOTYPE_DEMO_HREF || href.endsWith('/oneos-prototype-demo');
|
||||
}
|
||||
|
||||
function resolveTitle(menu: ReturnType<typeof buildShellMenuFromNav>, href: string, fallback: string) {
|
||||
const path = findMenuPath(menu, href);
|
||||
return path?.[path.length - 1]?.label || fallback;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const menu = useMemo(
|
||||
() => buildShellMenuFromNav(navMenuJson as Parameters<typeof buildShellMenuFromNav>[0]),
|
||||
[],
|
||||
);
|
||||
|
||||
const initialId = readHashProto() || DEFAULT_PROTO_ID;
|
||||
const initialHref = protoIdToHref(initialId);
|
||||
const initialTitle = resolveTitle(menu, initialHref, initialId === DEFAULT_PROTO_ID ? DEFAULT_TITLE : initialId);
|
||||
|
||||
const [activeHref, setActiveHref] = useState(initialHref);
|
||||
const [tabs, setTabs] = useState<ShellTab[]>([{ href: initialHref, title: initialTitle }]);
|
||||
const [frameKey, setFrameKey] = useState(0);
|
||||
const [theme, setTheme] = useState<OneOsTheme>(() => readStoredOneOsTheme());
|
||||
const [releaseUnread, setReleaseUnread] = useState(() => hasUnseenRelease());
|
||||
const frameRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!readHashProto()) writeHashProto(DEFAULT_PROTO_ID);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== 'oneos-ui-theme') return;
|
||||
if (e.newValue === 'dark' || e.newValue === 'light') setTheme(e.newValue);
|
||||
};
|
||||
const onCustom = (e: Event) => {
|
||||
const detail = (e as CustomEvent<{ theme?: OneOsTheme }>).detail;
|
||||
if (detail?.theme === 'dark' || detail?.theme === 'light') setTheme(detail.theme);
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
window.addEventListener('ONEOS_THEME_CHANGE', onCustom as EventListener);
|
||||
const onMsg = (e: MessageEvent) => {
|
||||
if (e.data?.type === 'ONEOS_THEME_CHANGE' && (e.data.theme === 'dark' || e.data.theme === 'light')) {
|
||||
setTheme(e.data.theme);
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onMsg);
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage);
|
||||
window.removeEventListener('ONEOS_THEME_CHANGE', onCustom as EventListener);
|
||||
window.removeEventListener('message', onMsg);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const frameSrc = useMemo(() => {
|
||||
const protoId = hrefToProtoId(activeHref);
|
||||
if (isLegacyOneOsPrototype(protoId)) return activeHref;
|
||||
const sep = activeHref.includes('?') ? '&' : '?';
|
||||
return `${activeHref}${sep}oneosTheme=${theme}`;
|
||||
}, [activeHref, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const onHash = () => {
|
||||
const id = readHashProto() || DEFAULT_PROTO_ID;
|
||||
const href = protoIdToHref(id);
|
||||
const title = resolveTitle(menu, href, id);
|
||||
setActiveHref(href);
|
||||
setTabs((prev) => (prev.some((t) => t.href === href) ? prev : [...prev, { href, title }]));
|
||||
};
|
||||
window.addEventListener('hashchange', onHash);
|
||||
return () => window.removeEventListener('hashchange', onHash);
|
||||
}, [menu]);
|
||||
|
||||
const openProto = useCallback((href: string, title: string) => {
|
||||
const path = tabHref(href);
|
||||
const targetPath = isDemoHref(path) ? DEFAULT_HREF : path;
|
||||
const targetTitle = isDemoHref(path) ? DEFAULT_TITLE : title;
|
||||
const q = href.includes('?') ? `?${href.split('?')[1]!.split('#')[0]}` : '';
|
||||
const targetHref = isDemoHref(path) ? DEFAULT_HREF : `${targetPath}${q}`;
|
||||
setTabs((prev) =>
|
||||
prev.some((t) => tabHref(t.href) === targetPath)
|
||||
? prev
|
||||
: [...prev, { href: targetPath, title: targetTitle }],
|
||||
);
|
||||
setActiveHref(targetHref);
|
||||
writeHashProto(hrefToProtoId(targetPath));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onMsg = (e: MessageEvent) => {
|
||||
if (!isShellNav(e.data)) return;
|
||||
const href = e.data.href;
|
||||
const title =
|
||||
e.data.title || resolveTitle(menu, tabHref(href), hrefToProtoId(href));
|
||||
openProto(href, title);
|
||||
};
|
||||
window.addEventListener('message', onMsg);
|
||||
return () => window.removeEventListener('message', onMsg);
|
||||
}, [menu, openProto]);
|
||||
|
||||
const onNavigate = useCallback(
|
||||
(item: ShellMenuItem) => {
|
||||
if (!item.href) return;
|
||||
openProto(item.href, item.label);
|
||||
},
|
||||
[openProto],
|
||||
);
|
||||
|
||||
const onTabSelect = useCallback((href: string) => {
|
||||
setActiveHref(tabHref(href));
|
||||
writeHashProto(hrefToProtoId(href));
|
||||
}, []);
|
||||
|
||||
const onTabClose = useCallback(
|
||||
(href: string) => {
|
||||
const closePath = tabHref(href);
|
||||
setTabs((prev) => {
|
||||
const next = prev.filter((t) => tabHref(t.href) !== closePath);
|
||||
if (next.length === 0) {
|
||||
const fallback = { href: DEFAULT_HREF, title: DEFAULT_TITLE };
|
||||
setActiveHref(fallback.href);
|
||||
writeHashProto(DEFAULT_PROTO_ID);
|
||||
return [fallback];
|
||||
}
|
||||
if (tabHref(activeHref) === closePath) {
|
||||
const fallback = next[next.length - 1]!;
|
||||
setActiveHref(fallback.href);
|
||||
writeHashProto(hrefToProtoId(fallback.href));
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[activeHref],
|
||||
);
|
||||
|
||||
const onRefresh = useCallback(() => setFrameKey((k) => k + 1), []);
|
||||
|
||||
const onOpenReleaseNotes = useCallback(() => {
|
||||
const onWorkbench = hrefToProtoId(activeHref) === DEFAULT_PROTO_ID;
|
||||
const sendOpen = () => {
|
||||
const win = frameRef.current?.contentWindow;
|
||||
if (!win) return;
|
||||
try {
|
||||
postReleaseDemoOpen(win);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
if (!onWorkbench) {
|
||||
openProto(DEFAULT_HREF, DEFAULT_TITLE);
|
||||
setFrameKey((k) => k + 1);
|
||||
window.setTimeout(sendOpen, 450);
|
||||
return;
|
||||
}
|
||||
sendOpen();
|
||||
}, [activeHref, openProto]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncUnread = () => setReleaseUnread(hasUnseenRelease());
|
||||
syncUnread();
|
||||
window.addEventListener('storage', syncUnread);
|
||||
window.addEventListener('focus', syncUnread);
|
||||
return () => {
|
||||
window.removeEventListener('storage', syncUnread);
|
||||
window.removeEventListener('focus', syncUnread);
|
||||
};
|
||||
}, [frameKey, activeHref]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMsg = (e: MessageEvent) => {
|
||||
if (e.data?.type === 'ONEOS_RELEASE_SEEN') {
|
||||
setReleaseUnread(hasUnseenRelease());
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', onMsg);
|
||||
return () => window.removeEventListener('message', onMsg);
|
||||
}, []);
|
||||
|
||||
const path = useMemo(() => findMenuPath(menu, activeHref) || [], [menu, activeHref]);
|
||||
const pageTitle = path[path.length - 1]?.label || hrefToProtoId(activeHref);
|
||||
const breadcrumb = path.length ? path.map((p) => p.label) : ['OneOS', pageTitle];
|
||||
|
||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||
() => ({
|
||||
showToolbar: true,
|
||||
showThemeToggle: true,
|
||||
showColorFilter: true,
|
||||
emptyWhenNoData: false,
|
||||
toolbarEdge: 'right',
|
||||
currentPageId: 'main',
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<>
|
||||
<OneOsAppShell
|
||||
activeHref={activeHref}
|
||||
pageTitle={pageTitle}
|
||||
breadcrumb={breadcrumb}
|
||||
onNavigate={onNavigate}
|
||||
tabs={tabs}
|
||||
onTabSelect={onTabSelect}
|
||||
onTabClose={onTabClose}
|
||||
onRefresh={onRefresh}
|
||||
onOpenReleaseNotes={onOpenReleaseNotes}
|
||||
releaseNotesUnread={releaseUnread}
|
||||
brandHref={PROTOTYPE_DEMO_HREF}
|
||||
>
|
||||
<iframe
|
||||
ref={frameRef}
|
||||
key={`${activeHref}-${frameKey}-${theme}`}
|
||||
className="oneos-shell-frame"
|
||||
title={pageTitle}
|
||||
src={frameSrc}
|
||||
onLoad={(e) => {
|
||||
const win = e.currentTarget.contentWindow;
|
||||
if (!win || isLegacyOneOsPrototype(hrefToProtoId(activeHref))) return;
|
||||
try {
|
||||
win.postMessage({ type: 'ONEOS_THEME_CHANGE', theme }, '*');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
broadcastOneOsTheme(theme);
|
||||
}}
|
||||
/>
|
||||
</OneOsAppShell>
|
||||
<PrototypeAnnotationHost
|
||||
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||
options={annotationOptions}
|
||||
/>
|
||||
</>
|
||||
<AnnotationViewer
|
||||
source={annotationSourceDocument as unknown as AnnotationSourceDocument}
|
||||
options={{
|
||||
currentPageId: "main",
|
||||
toolbarEdge: 'right',
|
||||
showToolbar: true,
|
||||
showThemeToggle: true,
|
||||
showColorFilter: true,
|
||||
emptyWhenNoData: true,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
7
src/prototypes/oneos-prototype-demo/style.css
Normal file
7
src/prototypes/oneos-prototype-demo/style.css
Normal file
@@ -0,0 +1,7 @@
|
||||
/* 原型演示页:样式主要由 oneos-app-shell 提供 */
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -5,6 +5,12 @@
|
||||
"kind": "folder",
|
||||
"title": "OneOS",
|
||||
"children": [
|
||||
{
|
||||
"id": "item:prototypes:oneos-prototype-demo",
|
||||
"kind": "item",
|
||||
"title": "原型演示",
|
||||
"itemKey": "prototypes/oneos-prototype-demo"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:oneos-web-workbench-new",
|
||||
"kind": "item",
|
||||
@@ -116,17 +122,23 @@
|
||||
"title": "站点信息",
|
||||
"itemKey": "prototypes/oneos-web-h2-station-site"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:oneos-h5-h2-order",
|
||||
"kind": "item",
|
||||
"title": "加氢记录(H5)",
|
||||
"itemKey": "prototypes/oneos-h5-h2-order"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:oneos-web-h2-station",
|
||||
"kind": "item",
|
||||
"title": "加氢记录",
|
||||
"itemKey": "prototypes/oneos-web-h2-station"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:oneos-web-h2-station-weekly",
|
||||
"kind": "item",
|
||||
"title": "站点周报统计",
|
||||
"itemKey": "prototypes/oneos-web-h2-station-weekly"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:oneos-web-h2-station-analysis",
|
||||
"kind": "item",
|
||||
"title": "加氢站分析",
|
||||
"itemKey": "prototypes/oneos-web-h2-station-analysis"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -247,6 +259,12 @@
|
||||
"kind": "item",
|
||||
"title": "业务条线说明",
|
||||
"itemKey": "prototypes/lease-business-line-overview"
|
||||
},
|
||||
{
|
||||
"id": "item:prototypes:yunxiao-pipeline-handbook",
|
||||
"kind": "item",
|
||||
"title": "云效生产线手册",
|
||||
"itemKey": "prototypes/yunxiao-pipeline-handbook"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -213,21 +213,79 @@ const Component = function () {
|
||||
|
||||
var ALL_DATA_KEYS = SECTOR_COLUMNS.map(function (s) { return s.key; });
|
||||
|
||||
/** 各业务板块表头数据来源说明(显示在业务名称后方) */
|
||||
/**
|
||||
* 各业务板块最小颗粒度数据来源(显示在业务名称后方)
|
||||
* 完整口径见 `.spec/sector-data-source.md`
|
||||
*/
|
||||
var SECTOR_DATA_SOURCE = {
|
||||
logistics: '物流业务明细',
|
||||
lease: '租赁业务明细',
|
||||
hydrogen: '车辆氢费明细',
|
||||
elec: '预留明细(暂无页面)',
|
||||
etc: '预留明细(暂无页面)'
|
||||
elec: '充电订单',
|
||||
etc: 'ETC业务'
|
||||
};
|
||||
|
||||
/** 各业务板块 业绩 / 成本 / 盈亏 字段口径(表头悬停说明) */
|
||||
var SECTOR_METRIC_SOURCE = {
|
||||
logistics: {
|
||||
perf: '物流业务明细「金额」列',
|
||||
cost: '物流业务明细「总成本」列',
|
||||
profit: '金额 − 总成本'
|
||||
},
|
||||
lease: {
|
||||
perf: '租赁业务明细「实收金额」列',
|
||||
cost: '租赁业务明细「车辆实际成本」列',
|
||||
profit: '实收金额 − 车辆实际成本'
|
||||
},
|
||||
hydrogen: {
|
||||
perf: '车辆氢费明细「加氢总价(元)」列',
|
||||
cost: '车辆氢费明细「成本总价(元)」列',
|
||||
profit: '加氢总价(元)− 成本总价(元)'
|
||||
},
|
||||
elec: {
|
||||
perf: '充电订单「对客费用(元)」列',
|
||||
cost: '充电订单「成本费用(元)」列',
|
||||
profit: '对客费用(元)− 成本费用(元)'
|
||||
},
|
||||
etc: {
|
||||
perf: 'ETC业务(字段口径待补充)',
|
||||
cost: 'ETC业务(字段口径待补充)',
|
||||
profit: 'ETC业务(字段口径待补充)'
|
||||
}
|
||||
};
|
||||
|
||||
function buildSectorSourceTooltip(secKey) {
|
||||
var source = SECTOR_DATA_SOURCE[secKey];
|
||||
var metric = SECTOR_METRIC_SOURCE[secKey];
|
||||
if (!source) return '';
|
||||
var lines = ['最小颗粒度:' + source];
|
||||
if (metric) {
|
||||
lines.push('业绩:' + metric.perf);
|
||||
lines.push('成本:' + metric.cost);
|
||||
lines.push('盈亏:' + metric.profit);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function renderSectorMetricTitle(secKey, metricKey, label) {
|
||||
var metric = SECTOR_METRIC_SOURCE[secKey];
|
||||
var tip = metric && metric[metricKey] ? metric[metricKey] : '';
|
||||
if (!tip) return label;
|
||||
return React.createElement('span', { title: tip }, label);
|
||||
}
|
||||
|
||||
function renderSectorGroupTitle(sec) {
|
||||
var source = SECTOR_DATA_SOURCE[sec.key];
|
||||
if (!source) return sec.label;
|
||||
return React.createElement('span', { className: 'bdl-sector-head-inner' },
|
||||
return React.createElement('span', {
|
||||
className: 'bdl-sector-head-inner',
|
||||
title: buildSectorSourceTooltip(sec.key)
|
||||
},
|
||||
React.createElement('span', { className: 'bdl-sector-head-label' }, sec.label),
|
||||
React.createElement('span', { className: 'bdl-sector-head-source', title: '数据来源:' + source }, '←', source)
|
||||
React.createElement('span', {
|
||||
className: 'bdl-sector-head-source',
|
||||
title: buildSectorSourceTooltip(sec.key)
|
||||
}, '←', source)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1799,7 +1857,7 @@ const Component = function () {
|
||||
className: 'bdl-sector-group-head bdl-sector-group--' + sec.key,
|
||||
children: [
|
||||
{
|
||||
title: '业绩',
|
||||
title: renderSectorMetricTitle(sec.key, 'perf', '业绩'),
|
||||
dataIndex: sec.key + 'Perf',
|
||||
key: sec.key + 'Perf',
|
||||
width: amountWidth,
|
||||
@@ -1824,7 +1882,7 @@ const Component = function () {
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '成本',
|
||||
title: renderSectorMetricTitle(sec.key, 'cost', '成本'),
|
||||
dataIndex: sec.key + 'Cost',
|
||||
key: sec.key + 'Cost',
|
||||
width: amountWidth,
|
||||
@@ -1849,7 +1907,7 @@ const Component = function () {
|
||||
}
|
||||
},
|
||||
{
|
||||
title: '盈亏',
|
||||
title: renderSectorMetricTitle(sec.key, 'profit', '盈亏'),
|
||||
dataIndex: sec.key + 'Profit',
|
||||
key: sec.key + 'Profit',
|
||||
width: profitWidth,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
|
||||
1. **筛选区** — 名称、签约、地区、营业状态
|
||||
2. **KPI 分类** — 全部 / 签约·普通 / 预付余额预警 / 已欠费 / 无加氢
|
||||
3. **工具栏** — 全站余额提醒、批量导入、加氢机品牌管理、发起充值单、新建站点
|
||||
3. **工具栏** — 全站余额提醒、批量导入、批量导出、加氢机品牌管理、发起充值单、新建站点
|
||||
4. **站点列表** — 主数据 + 运营指标 + 操作列
|
||||
5. **分页栏** — 每页 5/10/20/50 条
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
|---|---|
|
||||
| 全站余额提醒设置 | 配置全局预付余额预警阈值 |
|
||||
| 批量导入 | 下载模板 → 上传 CSV/Excel → 预览确认导入 |
|
||||
| 批量导出 | 导出当前筛选结果为 Excel;多套价格时站名等基本信息列合并;**阶梯规则每档一行**。详见 [station-batch-export.md](./station-batch-export.md) |
|
||||
| 加氢机品牌管理 | 维护品牌与型号,供 H5 面板 OCR 参考(见 `.spec/dispenser-brand.md`) |
|
||||
| 发起充值单 | 为站点发起预付充值流程(原型演示) |
|
||||
| 新建站点 | 进入同页内嵌新建视图 |
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
### 1.2 本期目标
|
||||
|
||||
- 站点台账的查询、新建、编辑、查看、删除与批量导入
|
||||
- 站点台账的查询、新建、编辑、查看、删除、批量导入与**批量导出**
|
||||
- 营业状态、成本价格、预付余额等运营配置
|
||||
- **按站点生成氢费对账单 → 填写收票信息 → 提交结算**,并与「车辆氢费明细」联动回写
|
||||
|
||||
@@ -94,6 +94,13 @@
|
||||
|
||||
下载 CSV 模板 → 上传 → 解析预览 → 确认导入;站点名称不可重复。
|
||||
|
||||
### 4.4 批量导出
|
||||
|
||||
> 完整规则见 [station-batch-export.md](./station-batch-export.md)
|
||||
|
||||
列表工具栏 → **批量导出**:按当前筛选 + KPI 结果导出 `.xlsx`。
|
||||
基本信息列对齐导入模板;同一站点多套固定 / 阶梯价格时,**加氢站名称等基本信息纵向合并**;阶梯价的**每个档位各占一行**。
|
||||
|
||||
---
|
||||
|
||||
## 5. 运营配置
|
||||
@@ -171,6 +178,7 @@
|
||||
- [ ] 高频/低频/无加氢口径与车辆氢费明细一致
|
||||
- [ ] 加氢量、预付余额下钻数据来自共享 Store
|
||||
- [ ] 布局与租赁合同 vm 规范一致
|
||||
- [ ] **批量导出**:当前筛选结果可导出 Excel;多价站点基本信息列合并;阶梯规则每档一行
|
||||
|
||||
### 8.2 对账流程
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# 站点信息 · 批量导出(含价格配置行展开)
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 模块 | 加氢站管理 → 站点信息 |
|
||||
| 入口 | 列表工具栏 → **批量导出**(紧挨「批量导入」) |
|
||||
| 交互原型 | `/prototypes/oneos-web-h2-station-site` |
|
||||
| 实现 | `utils/h2-station-batch-export.ts`;页面:`pages/03-站点信息.jsx` → `handleBatchExportStations` |
|
||||
| 数据源 | 当前列表筛选 + KPI Tab 后的 `sortedList`(**非当前分页**);价格来自 `priceConfigs[]`(与价格配置弹窗同源);**未接真实 API** |
|
||||
|
||||
---
|
||||
|
||||
## 1. 业务目的
|
||||
|
||||
一次性导出当前可见站点台账快照,含基本信息与全部价格标准。
|
||||
同一站点若有多套固定 / 阶梯价格,**加氢站名称等基本信息列纵向合并**;阶梯价的**每个档位各占一行**。
|
||||
|
||||
---
|
||||
|
||||
## 2. 导出范围判定
|
||||
|
||||
| 优先级 | 条件 | 结果 |
|
||||
|---|---|---|
|
||||
| 1 | `sortedList.length === 0` | 阻止导出,提示「当前筛选结果为空,无法导出」 |
|
||||
| 2 | 有筛选 / KPI 结果 | 导出该结果内**全部站点**(不受分页限制) |
|
||||
|
||||
---
|
||||
|
||||
## 3. 行展开规则(价格)
|
||||
|
||||
| 优先级 | 条件 | 结果 |
|
||||
|---|---|---|
|
||||
| 1 | 固定价格配置 | **1 行**;「阶梯规则」为 `—` |
|
||||
| 2 | 阶梯价格配置,有 N 个档位 | **N 行**;「阶梯规则」每行一条(如 `≥0.00kg → 43.00元/kg`) |
|
||||
| 3 | 阶梯价格但无档位 / 无配置 | **1 行**;价格相关列 `—` |
|
||||
|
||||
**不拆行**:不按调价 `logs[]` 历史拆行。
|
||||
|
||||
---
|
||||
|
||||
## 4. 合并单元格
|
||||
|
||||
| 规则 | 说明 |
|
||||
|---|---|
|
||||
| 基本信息 11 列 | 同一站点全部导出行纵向合并 |
|
||||
| 同套阶梯多档 | 合并:定价方式、适用客户、当前成本单价、阶梯重置日、生效时间 |
|
||||
| 阶梯规则列 | **不合并**,每档一行 |
|
||||
| 仅 1 行 | 不写 merge |
|
||||
| 合并区内非首行 | 对应单元格内容留空,避免 Excel 显示重复文字 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 列定义
|
||||
|
||||
### 5.1 基本信息(合并)
|
||||
|
||||
与批量导入模板口径一致:
|
||||
|
||||
加氢站名称、省、市、详细地址、是否签约、签约开始时间、签约结束时间、营业状态、营业时间、联系人、联系电话。
|
||||
|
||||
| 字段 | 数据源 |
|
||||
|---|---|
|
||||
| 省 / 市 | `record.region[0]` / `region[1]` |
|
||||
| 是否签约 | `是` / `否` |
|
||||
| 联系电话 | `record.phone` |
|
||||
|
||||
### 5.2 价格配置
|
||||
|
||||
| 列 | 规则 |
|
||||
|---|---|
|
||||
| 定价方式 | `固定价格` / `阶梯价格`(同套多档合并) |
|
||||
| 适用客户 | 全部客户 / 客户名称(同套多档合并) |
|
||||
| 当前成本单价(元/kg) | `h2ResolveConfigCurrentPrice`(同套多档合并) |
|
||||
| 阶梯规则 | 固定价:`—`;阶梯价:**每档一行** `≥x kg → y 元/kg` |
|
||||
| 阶梯重置日 | 阶梯价:`每月N日`;固定价:`—`(同套多档合并) |
|
||||
| 生效时间 | `logs[]` 中 `effectiveTime ≤ 当前时间` 的**最新**一条;无则 `—`(同套多档合并) |
|
||||
|
||||
---
|
||||
|
||||
## 6. 文件格式
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 格式 | `.xlsx`(SheetJS / `xlsx`) |
|
||||
| 工作表名 | `站点信息` |
|
||||
| 文件名 | `加氢站点批量导出_YYYYMMDD_HHmm.xlsx` |
|
||||
|
||||
> CSV 无法表达合并单元格,故不提供 CSV 导出。
|
||||
|
||||
---
|
||||
|
||||
## 7. 用户可见结果
|
||||
|
||||
| 场景 | 结果 |
|
||||
|---|---|
|
||||
| 嘉兴站(1 固定 + 1 阶梯含 3 档) | Excel **4 行**;站名等 11 列合并;阶梯 3 行各写一条规则 |
|
||||
| 仅 1 套固定价 | 1 行,无合并 |
|
||||
| 筛选为空 | Toast 警告,不下载 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 验收要点
|
||||
|
||||
- [ ] 工具栏「批量导出」在「批量导入」之后
|
||||
- [ ] 导出范围为当前筛选 + KPI 后全部站点(非仅当前页)
|
||||
- [ ] 多套价格时基本信息列纵向合并
|
||||
- [ ] **阶梯规则每档一行**;同套共享价格列合并
|
||||
- [ ] 空列表不可导出
|
||||
|
||||
---
|
||||
|
||||
## 9. 代码路径
|
||||
|
||||
| 能力 | 路径 |
|
||||
|---|---|
|
||||
| AOA + merge + 下载 | `utils/h2-station-batch-export.ts` |
|
||||
| 按钮与 helpers 注入 | `pages/03-站点信息.jsx` → `handleBatchExportStations` |
|
||||
| 价格配置读取 | `h2GetStationPriceConfigs` 等(同页) |
|
||||
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@
|
||||
|
||||
import { TablePagination, COMPACT_PAGE_SIZE_OPTIONS } from '../../../common/TablePagination';
|
||||
import { OperationActions } from '../../../common/OperationActions';
|
||||
import { downloadStationExportXlsx } from '../utils/h2-station-batch-export';
|
||||
|
||||
var H2_REGION_CASCADER_OPTIONS = [
|
||||
{ value: '浙江省', label: '浙江省', children: [{ value: '嘉兴市', label: '嘉兴市' }, { value: '杭州市', label: '杭州市' }, { value: '宁波市', label: '宁波市' }] },
|
||||
@@ -7703,6 +7704,29 @@ const Component = function () {
|
||||
message.success('已下载导入模板');
|
||||
}, []);
|
||||
|
||||
var handleBatchExportStations = useCallback(function () {
|
||||
if (!sortedList || !sortedList.length) {
|
||||
message.warning('当前筛选结果为空,无法导出');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var result = downloadStationExportXlsx(sortedList, {
|
||||
getStationPriceConfigs: h2GetStationPriceConfigs,
|
||||
formatPriceTypeLabel: h2FormatPriceTypeLabel,
|
||||
formatPriceConfigScopeLabel: h2FormatPriceConfigScopeLabel,
|
||||
resolveConfigCurrentPrice: h2ResolveConfigCurrentPrice,
|
||||
formatTierRuleLine: h2FormatTierRuleLine,
|
||||
sortTierRules: h2SortTierRules,
|
||||
getTierResetTimeLabel: h2GetTierResetTimeLabel,
|
||||
formatYuanNum: h2FormatYuanNum,
|
||||
parseDateTimeMs: h2ParseDateTimeMs
|
||||
});
|
||||
message.success('已导出 ' + result.stationCount + ' 个站点、共 ' + result.rowCount + ' 行');
|
||||
} catch (err) {
|
||||
message.error('导出失败,请刷新页面后重试');
|
||||
}
|
||||
}, [sortedList, message]);
|
||||
|
||||
var parseImportFile = useCallback(function (file) {
|
||||
if (!file) return;
|
||||
var name = String(file.name || '').toLowerCase();
|
||||
@@ -9873,6 +9897,13 @@ const Component = function () {
|
||||
onClick: function () { setImportModalOpen(true); },
|
||||
'aria-label': '批量导入加氢站点'
|
||||
}, '批量导入'),
|
||||
React.createElement('button', {
|
||||
'data-vm-icon': 'download',
|
||||
type: 'button',
|
||||
className: 'vm-btn vm-btn-ghost ldb-toolbar-btn',
|
||||
onClick: handleBatchExportStations,
|
||||
'aria-label': '批量导出加氢站点'
|
||||
}, '批量导出'),
|
||||
React.createElement('button', {
|
||||
'data-vm-icon': 'settings',
|
||||
type: 'button',
|
||||
|
||||
@@ -13,6 +13,7 @@ const statementVerifiedFilterPrd = fs.readFileSync(path.join(specRoot, 'statemen
|
||||
const kpiBalanceAlertPrd = fs.readFileSync(path.join(specRoot, 'requirements-prd-kpi-balance-alert.md'), 'utf8');
|
||||
const periodEndPrd = fs.readFileSync(path.join(specRoot, 'period-end-balance.md'), 'utf8');
|
||||
const dispenserBrandPrd = fs.readFileSync(path.join(specRoot, 'dispenser-brand.md'), 'utf8');
|
||||
const stationBatchExportPrd = fs.readFileSync(path.join(specRoot, 'station-batch-export.md'), 'utf8');
|
||||
const scopeMd = fs.readFileSync(path.join(specRoot, 'requirements.md'), 'utf8');
|
||||
|
||||
const NODE_DEFS = [
|
||||
@@ -46,7 +47,7 @@ const NODE_DEFS = [
|
||||
pageId: 'site-info',
|
||||
color: '#d97706',
|
||||
aiPrompt: '工具栏操作与对账单氢费联动。',
|
||||
annotationText: '全站余额提醒、批量导入、加氢机品牌管理、充值单、新建站点;对账回写车辆氢费明细。',
|
||||
annotationText: '全站余额提醒、批量导入、批量导出、加氢机品牌管理、充值单、新建站点;对账回写车辆氢费明细。',
|
||||
locator: {
|
||||
selectors: ['.h2-station-page .vm-table-toolbar', '[data-annotation-id="h2-site-toolbar"]'],
|
||||
fingerprint: 'toolbar|statement',
|
||||
@@ -328,6 +329,13 @@ const source = {
|
||||
markdown: dispenserBrandPrd,
|
||||
markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/dispenser-brand.md',
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'h2-site-doc-station-batch-export',
|
||||
title: '批量导出',
|
||||
markdown: stationBatchExportPrd,
|
||||
markdownPath: 'src/prototypes/oneos-web-h2-station-site/.spec/station-batch-export.md',
|
||||
},
|
||||
{
|
||||
type: 'markdown',
|
||||
id: 'h2-site-doc-scope',
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
/**
|
||||
* 站点信息 · 批量导出 Excel(含价格配置 / 阶梯档位行展开 + 基本信息列合并)
|
||||
* 依赖:xlsx;价格解析由页面传入 helpers,与价格配置弹窗同源。
|
||||
*/
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
export const STATION_EXPORT_BASIC_HEADERS = [
|
||||
'加氢站名称',
|
||||
'省',
|
||||
'市',
|
||||
'详细地址',
|
||||
'是否签约',
|
||||
'签约开始时间',
|
||||
'签约结束时间',
|
||||
'营业状态',
|
||||
'营业时间',
|
||||
'联系人',
|
||||
'联系电话',
|
||||
] as const;
|
||||
|
||||
export const STATION_EXPORT_PRICE_HEADERS = [
|
||||
'定价方式',
|
||||
'适用客户',
|
||||
'当前成本单价(元/kg)',
|
||||
'阶梯规则',
|
||||
'阶梯重置日',
|
||||
'生效时间',
|
||||
] as const;
|
||||
|
||||
export const STATION_EXPORT_HEADERS = [
|
||||
...STATION_EXPORT_BASIC_HEADERS,
|
||||
...STATION_EXPORT_PRICE_HEADERS,
|
||||
] as const;
|
||||
|
||||
const BASIC_COL_COUNT = STATION_EXPORT_BASIC_HEADERS.length;
|
||||
/** 同套价格多档时合并:定价方式、适用客户、当前成本单价、阶梯重置日、生效时间(阶梯规则不合并) */
|
||||
const PRICE_SHARED_MERGE_COLS = [
|
||||
BASIC_COL_COUNT,
|
||||
BASIC_COL_COUNT + 1,
|
||||
BASIC_COL_COUNT + 2,
|
||||
BASIC_COL_COUNT + 4,
|
||||
BASIC_COL_COUNT + 5,
|
||||
];
|
||||
|
||||
export type StationExportHelpers = {
|
||||
getStationPriceConfigs: (record: any) => any[];
|
||||
formatPriceTypeLabel: (priceType: string) => string;
|
||||
formatPriceConfigScopeLabel: (config: any) => string;
|
||||
resolveConfigCurrentPrice: (config: any, nowMs?: number) => number | null;
|
||||
formatTierRuleLine: (rule: any) => string;
|
||||
sortTierRules: (tierRules: any[]) => any[];
|
||||
getTierResetTimeLabel: (config: any) => string;
|
||||
formatYuanNum: (v: any) => string;
|
||||
parseDateTimeMs: (val: any) => number;
|
||||
};
|
||||
|
||||
export type StationExportSheet = {
|
||||
aoa: (string | number)[][];
|
||||
merges: XLSX.Range[];
|
||||
stationCount: number;
|
||||
rowCount: number;
|
||||
};
|
||||
|
||||
type ExpandedPriceRow = {
|
||||
config: any | null;
|
||||
isConfigFirstRow: boolean;
|
||||
tierRule: any | null;
|
||||
};
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n < 10 ? `0${n}` : String(n);
|
||||
}
|
||||
|
||||
function stampFileName(): string {
|
||||
const d = new Date();
|
||||
return (
|
||||
`${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}_` +
|
||||
`${pad2(d.getHours())}${pad2(d.getMinutes())}`
|
||||
);
|
||||
}
|
||||
|
||||
/** 取 config.logs 中 effectiveTime ≤ now 的最新一条生效时间 */
|
||||
export function resolveConfigEffectiveTimeLabel(
|
||||
config: any,
|
||||
helpers: Pick<StationExportHelpers, 'parseDateTimeMs'>,
|
||||
nowMs?: number,
|
||||
): string {
|
||||
const logs = (config && config.logs) || [];
|
||||
const now = nowMs != null ? nowMs : Date.now();
|
||||
let best: { t: number; label: string } | null = null;
|
||||
for (let i = 0; i < logs.length; i += 1) {
|
||||
const log = logs[i];
|
||||
const t = helpers.parseDateTimeMs(log && log.effectiveTime);
|
||||
if (Number.isNaN(t) || t > now) continue;
|
||||
if (!best || t >= best.t) {
|
||||
best = { t, label: String(log.effectiveTime || '').trim() };
|
||||
}
|
||||
}
|
||||
return best && best.label ? best.label : '—';
|
||||
}
|
||||
|
||||
function buildBasicCells(record: any): string[] {
|
||||
const region = (record && record.region) || [];
|
||||
return [
|
||||
String((record && record.name) || '') || '—',
|
||||
String(region[0] || '') || '—',
|
||||
String(region[1] || '') || '—',
|
||||
String((record && record.addressDetail) || '') || '—',
|
||||
record && record.isSigned ? '是' : '否',
|
||||
String((record && record.contractStart) || '') || '—',
|
||||
String((record && record.contractEnd) || '') || '—',
|
||||
String((record && record.businessStatus) || '') || '—',
|
||||
String((record && record.businessHours) || '') || '—',
|
||||
String((record && record.contact) || '') || '—',
|
||||
String((record && record.phone) || '') || '—',
|
||||
];
|
||||
}
|
||||
|
||||
/** 固定价 1 行;阶梯价每个档位 1 行 */
|
||||
export function expandPriceConfigsToRows(
|
||||
configs: any[],
|
||||
helpers: Pick<StationExportHelpers, 'sortTierRules'>,
|
||||
): ExpandedPriceRow[] {
|
||||
const list = configs && configs.length ? configs : [null];
|
||||
const out: ExpandedPriceRow[] = [];
|
||||
list.forEach((cfg) => {
|
||||
if (!cfg) {
|
||||
out.push({ config: null, isConfigFirstRow: true, tierRule: null });
|
||||
return;
|
||||
}
|
||||
if (cfg.priceType === 'tiered') {
|
||||
const rules = helpers.sortTierRules(cfg.tierRules || []);
|
||||
if (!rules.length) {
|
||||
out.push({ config: cfg, isConfigFirstRow: true, tierRule: null });
|
||||
return;
|
||||
}
|
||||
rules.forEach((rule, idx) => {
|
||||
out.push({
|
||||
config: cfg,
|
||||
isConfigFirstRow: idx === 0,
|
||||
tierRule: rule,
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
out.push({ config: cfg, isConfigFirstRow: true, tierRule: null });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function formatTierCell(
|
||||
row: ExpandedPriceRow,
|
||||
helpers: StationExportHelpers,
|
||||
): string {
|
||||
if (!row.config || row.config.priceType !== 'tiered') return '—';
|
||||
if (!row.tierRule) return '—';
|
||||
return helpers.formatTierRuleLine(row.tierRule);
|
||||
}
|
||||
|
||||
function buildPriceCellsForExpandedRow(
|
||||
row: ExpandedPriceRow,
|
||||
helpers: StationExportHelpers,
|
||||
nowMs?: number,
|
||||
): string[] {
|
||||
const config = row.config;
|
||||
const tierLine = formatTierCell(row, helpers);
|
||||
|
||||
if (!config) {
|
||||
return ['—', '—', '—', '—', '—', '—'];
|
||||
}
|
||||
|
||||
if (!row.isConfigFirstRow) {
|
||||
return ['', '', '', tierLine, '', ''];
|
||||
}
|
||||
|
||||
const isTiered = config.priceType === 'tiered';
|
||||
const price = helpers.resolveConfigCurrentPrice(config, nowMs);
|
||||
return [
|
||||
helpers.formatPriceTypeLabel(config.priceType || 'fixed'),
|
||||
helpers.formatPriceConfigScopeLabel(config),
|
||||
price != null ? helpers.formatYuanNum(price) : '—',
|
||||
tierLine,
|
||||
isTiered ? helpers.getTierResetTimeLabel(config) : '—',
|
||||
resolveConfigEffectiveTimeLabel(config, helpers, nowMs),
|
||||
];
|
||||
}
|
||||
|
||||
function pushMergeRange(merges: XLSX.Range[], startRow: number, endRow: number, cols: number[]) {
|
||||
if (endRow <= startRow) return;
|
||||
cols.forEach((c) => {
|
||||
merges.push({
|
||||
s: { r: startRow, c },
|
||||
e: { r: endRow, c },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将站点列表展开为 AOA + 合并区域。
|
||||
* - 基本信息列:按站点纵向合并
|
||||
* - 固定价:每套 1 行
|
||||
* - 阶梯价:每个档位 1 行;同套内共享价格列合并
|
||||
*/
|
||||
export function buildStationExportSheet(
|
||||
stations: any[],
|
||||
helpers: StationExportHelpers,
|
||||
nowMs?: number,
|
||||
): StationExportSheet {
|
||||
const aoa: (string | number)[][] = [Array.from(STATION_EXPORT_HEADERS)];
|
||||
const merges: XLSX.Range[] = [];
|
||||
let stationCount = 0;
|
||||
let dataRowCount = 0;
|
||||
|
||||
(stations || []).forEach((record) => {
|
||||
const configs = helpers.getStationPriceConfigs(record);
|
||||
const expanded = expandPriceConfigsToRows(configs, helpers);
|
||||
const stationStart = aoa.length;
|
||||
const basic = buildBasicCells(record);
|
||||
let configBlockStart = -1;
|
||||
|
||||
expanded.forEach((row, idx) => {
|
||||
const priceCells = buildPriceCellsForExpandedRow(row, helpers, nowMs);
|
||||
if (idx === 0) {
|
||||
aoa.push([...basic, ...priceCells]);
|
||||
} else {
|
||||
aoa.push([...basic.map(() => ''), ...priceCells]);
|
||||
}
|
||||
dataRowCount += 1;
|
||||
|
||||
const excelRow = aoa.length - 1;
|
||||
if (row.isConfigFirstRow) {
|
||||
if (configBlockStart >= 0 && excelRow - 1 > configBlockStart) {
|
||||
pushMergeRange(merges, configBlockStart, excelRow - 1, PRICE_SHARED_MERGE_COLS);
|
||||
}
|
||||
configBlockStart = excelRow;
|
||||
}
|
||||
});
|
||||
|
||||
const stationEnd = aoa.length - 1;
|
||||
if (configBlockStart >= 0 && stationEnd > configBlockStart) {
|
||||
pushMergeRange(merges, configBlockStart, stationEnd, PRICE_SHARED_MERGE_COLS);
|
||||
}
|
||||
if (stationEnd > stationStart) {
|
||||
pushMergeRange(
|
||||
merges,
|
||||
stationStart,
|
||||
stationEnd,
|
||||
Array.from({ length: BASIC_COL_COUNT }, (_, c) => c),
|
||||
);
|
||||
}
|
||||
stationCount += 1;
|
||||
});
|
||||
|
||||
return { aoa, merges, stationCount, rowCount: dataRowCount };
|
||||
}
|
||||
|
||||
export function downloadStationExportXlsx(
|
||||
stations: any[],
|
||||
helpers: StationExportHelpers,
|
||||
options?: { nowMs?: number; filename?: string },
|
||||
): { stationCount: number; rowCount: number } {
|
||||
const sheet = buildStationExportSheet(stations, helpers, options && options.nowMs);
|
||||
const ws = XLSX.utils.aoa_to_sheet(sheet.aoa);
|
||||
if (sheet.merges.length) {
|
||||
ws['!merges'] = sheet.merges;
|
||||
}
|
||||
ws['!cols'] = STATION_EXPORT_HEADERS.map((h) => {
|
||||
if (h === '加氢站名称') return { wch: 28 };
|
||||
if (h === '详细地址') return { wch: 28 };
|
||||
if (h === '阶梯规则') return { wch: 28 };
|
||||
if (h === '适用客户') return { wch: 22 };
|
||||
if (h === '生效时间') return { wch: 18 };
|
||||
return { wch: 14 };
|
||||
});
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, '站点信息');
|
||||
const filename =
|
||||
(options && options.filename) || `加氢站点批量导出_${stampFileName()}.xlsx`;
|
||||
XLSX.writeFile(wb, filename);
|
||||
return { stationCount: sheet.stationCount, rowCount: sheet.rowCount };
|
||||
}
|
||||
44
src/prototypes/oneos-web-h2-station/.spec/feature-create.md
Normal file
44
src/prototypes/oneos-web-h2-station/.spec/feature-create.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 功能:新增 / 批量 / 编辑
|
||||
|
||||
## 目标
|
||||
|
||||
PC 端单条或批量录入加氢流水,与 H5 字段口径一致。
|
||||
|
||||
## 产品口径
|
||||
|
||||
| 项 | 规则 |
|
||||
|---|---|
|
||||
| 字段 | 时间(精确至秒)、站、车牌、里程(选填)、单价、量、总额(自动) |
|
||||
| 加氢时间 | 时间选择器,格式 `YYYY-MM-DD HH:mm:ss` |
|
||||
| 加氢站 | 按登录角色可见站点;**仅 1 站时默认并锁定**;**多站时占位「请选择」,须手选** |
|
||||
| 车牌标签 | 有车牌后:资产库命中→「羚牛车辆」,否则→「外部车辆」 |
|
||||
| 加氢总额 | 只读;= 单价 × 加氢量(两位小数),与 H5 一致 |
|
||||
| 门禁 | 昨日手工台账未上传不可进入新增(见手工台账规格) |
|
||||
| 重复键 | 同站 + 同车牌 + 同加氢时间 → 拦截 |
|
||||
| 总额偏差 | 提交时仍按自动计算结果校验(手改路径已关闭) |
|
||||
| 锁定 | 已核对/已对账不可编辑 |
|
||||
| 页头标题 | 批量新增页顶栏**不展示**「批量新增加氢记录」标题,仅保留返回 |
|
||||
|
||||
## 批量导入
|
||||
|
||||
| 项 | 规则 |
|
||||
|---|---|
|
||||
| 入口 | 批量新增页工具栏左侧「批量导入」 |
|
||||
| 交互 | 三步:①下载模板 ②选择重复策略 ③上传文件;结果摘要留在弹窗内 |
|
||||
| 模板 | CSV;列:加氢时间*、加氢站名称*、车牌号*、里程(km)选填、氢气单价*、加氢量*;**不含总额** |
|
||||
| 主键 | **加氢站 + 车牌号 + 加氢时间** |
|
||||
| 跳过重复 | 主键已存在(录入表或系统)则跳过,计入跳过条数 |
|
||||
| 覆盖重复 | 用导入行覆盖录入表行,或覆盖系统已有记录(提交时按原 id upsert) |
|
||||
| 报错 | 重复行写入错误日志,不导入;可下载错误日志 |
|
||||
| 校验失败 | 字段不合规则同样记入失败,不写入表格 |
|
||||
|
||||
## 验收
|
||||
|
||||
1. 时间可选到秒
|
||||
2. 单站默认、多站「请选择」
|
||||
3. 车牌旁标签:羚牛车辆 / 外部车辆
|
||||
4. 改单价或量后总额自动更新且不可手改
|
||||
5. 顶栏无「批量新增加氢记录」标题
|
||||
6. 批量导入:三步弹窗;可选跳过/覆盖/报错;失败可下载错误日志
|
||||
|
||||
详见 [record-data-model.md](./record-data-model.md)、[feature-manual-ledger.md](./feature-manual-ledger.md)。
|
||||
@@ -0,0 +1,15 @@
|
||||
# 功能:每日统计
|
||||
|
||||
## 目标
|
||||
|
||||
近若干日加氢量/金额汇总,默认收起,避免挤占列表第一屏。
|
||||
|
||||
## 产品口径
|
||||
|
||||
| 项 | 规则 |
|
||||
|---|---|
|
||||
| 默认 | 一行 KPI 摘要 +「展开明细」 |
|
||||
| 展开后 | 近 7 日卡片轨与翻页 |
|
||||
| 口径 | 随当前筛选结果实时汇总 |
|
||||
|
||||
全文见 [record-source-changelog.md](./record-source-changelog.md) §4。
|
||||
25
src/prototypes/oneos-web-h2-station/.spec/feature-export.md
Normal file
25
src/prototypes/oneos-web-h2-station/.spec/feature-export.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 功能:导出
|
||||
|
||||
## 目标
|
||||
|
||||
将当前筛选结果导出为 CSV,列结构对齐列表字段。
|
||||
|
||||
## 产品口径
|
||||
|
||||
| 导出列 | 说明 |
|
||||
|---|---|
|
||||
| 序号 | 1-based |
|
||||
| 加氢时间 / 加氢站名称 / 车牌号 | 列表同字段 |
|
||||
| **车辆来源** | 羚牛车辆 / 非羚牛车辆 |
|
||||
| **数据来源** | 站端上传 / 羚牛上传 |
|
||||
| 里程 / 单价 / 量 / 总额 | 数值列 |
|
||||
| 核对状态/时间/人 | 非羚牛导出为空串 |
|
||||
| 对账状态/时间/人 | 非羚牛导出为空串 |
|
||||
|
||||
## 验收
|
||||
|
||||
1. 无数据时提示不可导出
|
||||
2. 列名含「车辆来源」「数据来源」(非「车辆类型」「来源」)
|
||||
3. 与列表标签文案一致
|
||||
|
||||
代码:`pages/02-加氢记录.jsx` → `handleExport`。
|
||||
34
src/prototypes/oneos-web-h2-station/.spec/feature-list.md
Normal file
34
src/prototypes/oneos-web-h2-station/.spec/feature-list.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 功能:列表与筛选
|
||||
|
||||
## 目标
|
||||
|
||||
PC 端查看多站(按角色)加氢流水,支持筛选、每日统计摘要与行操作。
|
||||
|
||||
## 产品口径
|
||||
|
||||
| 项 | 规则 |
|
||||
|---|---|
|
||||
| 默认筛选行 | 加氢时间、车牌、站名、对账状态(`splitFilterFields`) |
|
||||
| 更多筛选 | 核对状态/时间、对账时间等;展开样式对齐物流业务明细 |
|
||||
| 列表标签 | 车辆来源(羚牛/非羚牛)+ 数据来源(站端上传/羚牛上传) |
|
||||
| 非羚牛 | 核对/对账相关列显示为空 |
|
||||
| 锁定 | 已核对或已对账(仅羚牛适用)禁改删 |
|
||||
| 操作列 | `OperationActions`:**详情**(外显)→ **更多**(编辑 / 变更日志 / 删除) |
|
||||
|
||||
### 详情弹窗
|
||||
|
||||
| 项 | 规则 |
|
||||
|---|---|
|
||||
| 入口 | 操作列「详情」(`Eye` + 文案「详情」,在「更多」之前) |
|
||||
| 字段 | 加氢时间、站名、车牌(车辆来源标签)、数据来源、里程、单价、加氢量、总额、核对/对账状态(非羚牛为空) |
|
||||
| 照片 | 车牌识别原图、加氢机面板原图;可预览放大 |
|
||||
| 无实拍 | 种子无 `platePhotoUrl` / `panelPhotoUrl` 时用本地 Canvas 演示占位图(含水印文案);**未接真实图片 API** |
|
||||
|
||||
## 验收
|
||||
|
||||
1. 筛选默认一行,可展开更多
|
||||
2. 标签与导出列名口径一致
|
||||
3. 非羚牛车辆核对/对账列为空
|
||||
4. 操作列「详情」可打开弹窗,展示记录字段与车牌/面板双图
|
||||
|
||||
详见 [requirements-prd.md](./requirements-prd.md)、[fleet-vehicle-verify.md](./fleet-vehicle-verify.md)。
|
||||
@@ -0,0 +1,63 @@
|
||||
# 功能:手工台账上传
|
||||
|
||||
## 目标
|
||||
|
||||
站端每日上传纸质**手工台账照片**,作为系统电子加氢记录的核对依据。多日未操作时,可通过日历查看缺口并补传过往日期。
|
||||
|
||||
## 产品口径
|
||||
|
||||
| 项 | 规则 |
|
||||
|---|---|
|
||||
| 入口 | H5:底部 Tab「手工台账」;Web:顶栏「手工台账」 |
|
||||
| 站点范围 | 仅展示当前登录用户角色对应站点;单站人员仅 1 个、多站可切换、超级管理员可选全部(原型用标注状态切换演示,未接真实登录) |
|
||||
| 上传形态 | 仅图片(拍照/相册),可多张 |
|
||||
| 维度 | **加氢站 + 自然日(本地日历日)** 一份 |
|
||||
| 日历 | 月历自本站**首笔加氢记录日**起算:绿=已传、橙=未传;首笔之前无需补传;禁选未来日 |
|
||||
| 补传 | 允许为过往未传日期补传;已传日期仅可**追加**新图 |
|
||||
| 归档 | **确认上传后的图片进入已归档状态,不可删除**;未确认的草稿图仍可删除 |
|
||||
| 二次确认 | Web 点「确认上传/补传」先弹窗:「提交后将无法修改,是否确认手工台账照片无误」;确认后才写入 |
|
||||
| 拦截 | **前一日手工台账未上传 → 今日禁止新增加氢记录**(编辑已有记录不拦;补传更早历史日不替代昨日校验) |
|
||||
| 数据 | 共享 `H2VehicleLedgerBridge` 内存 Store(未接真实 API);站名/编码解析见 Bridge.`h2BridgeResolveStationId` |
|
||||
|
||||
## 判定顺序(新增拦截)
|
||||
|
||||
| 优先级 | 条件 | 结果 |
|
||||
|--------|------|------|
|
||||
| 1 | 操作=编辑已有记录 | 不校验手工台账 |
|
||||
| 2 | 操作=新增,且昨日早于本站首笔加氢日(或不要求昨日台账) | 允许进入新增 |
|
||||
| 3 | 操作=新增,且目标站昨日已有 ≥1 张台账图 | 允许进入新增 |
|
||||
| 4 | 操作=新增,昨日未上传 | 顶部/Toast 提示「手工台账有缺失,作为对账重要依据,请先上传手工台账」,并**直接跳转**手工台账页且选中昨日 |
|
||||
|
||||
## 日历与补传
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 数据源 | `listManualLedgersByStation`;当日有 ≥1 张图视为已上传 |
|
||||
| 业务起点 | 取本站 Bridge 加氢记录中最早 `hydrogenTime` 自然日;无记录则历史日均不要求补传 |
|
||||
| 未传计数 | 本月内 `max(月初, 首笔日)`~min(月末, 今日) 中未上传的天数 |
|
||||
| 点选 | 仅 `dateKey ≤ 今日`;选中后右侧/下方展示该日草稿图与上传按钮 |
|
||||
| 归档不可删 | Store 已有图强制保留;`upsertManualLedger` 仅追加新图,忽略删除已有图的请求 |
|
||||
| 与拦截关系 | 仅 **昨日** 影响「禁止新增」;历史补传解决核对缺口;今日台账仍可按日常上传,但不作为新增门禁 |
|
||||
|
||||
## 用户可见
|
||||
|
||||
- 今日状态双反馈(Web 放在右侧「当日台账」卡片内):未上传「今日尚未上传手工台账(YYYY-MM-DD)」异常态;已上传「今日已上传手工台账(YYYY-MM-DD)」正常态(日常上传反馈,≠新增门禁)
|
||||
- 昨日缺失:列表顶栏橙提示 + Tab 红点;文案固定为「手工台账有缺失,作为对账重要依据,请先上传手工台账」
|
||||
- 日历:自首笔加氢日起绿/橙标记;首笔之前无橙点(无需补传);本月未传天数摘要
|
||||
- Web 右侧面板标题「手工台账」:空态提示;有图可点进全屏翻页预览并角标「已归档/待提交」;预览区内上传;底部确认前二次弹窗;提交后立即展示已归档缩略图;左右卡片等高
|
||||
|
||||
## 代码路径
|
||||
|
||||
- Bridge:`src/common/h2VehicleLedgerBridge.js`(`hasManualLedgerForDate` / `isManualLedgerGateBlocked` / `upsertManualLedger` / `listManualLedgersByStation`)
|
||||
- H5:`oneos-h5-h2-order` · `ManualLedgerPanel` + `utils/manual-ledger.ts`(`isManualLedgerGateBlocked` / `buildManualMonthCells` / `getFirstHydrogenDateKey`)
|
||||
- Web:`oneos-web-h2-station/pages/02-加氢记录.jsx`(`hrIsManualLedgerGateBlocked` / 跳转选中昨日)
|
||||
|
||||
## 验收
|
||||
|
||||
1. 昨日未上传时,H5/Web 列表顶栏提示固定文案;点「新增」被拦截并直接进入手工台账(选中昨日)
|
||||
2. 补传昨日台账并确认后,可新增加氢记录
|
||||
3. 日历自首笔加氢日起区分已传/未传;首笔之前不标未传、不计入补传;可点选范围内未传日补传
|
||||
4. 仅补传更早历史日、昨日仍未传时,新增仍被拦截
|
||||
5. 确认上传后的图显示已归档且不可删除;仅可追加补传
|
||||
6. H5 与 Web 共用同一 Store,一端上传另一端可见(同会话)
|
||||
7. Web 导出 CSV 列含「车辆来源」(羚牛车辆/非羚牛车辆)、「数据来源」(站端上传/羚牛上传),与列表标签口径一致
|
||||
33
src/prototypes/oneos-web-h2-station/.spec/fleet-plate-tag.md
Normal file
33
src/prototypes/oneos-web-h2-station/.spec/fleet-plate-tag.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# 车牌「羚牛车辆 / 非羚牛车辆」标签
|
||||
|
||||
原型本地判定,未接真实车辆管理 API。
|
||||
|
||||
全文(含核对/对账字段空展示规则,跨 Web / 台账 / H5):
|
||||
[../../oneos-web-h2-station/.spec/fleet-vehicle-verify.md](../../oneos-web-h2-station/.spec/fleet-vehicle-verify.md)
|
||||
|
||||
## 判定顺序
|
||||
|
||||
| 优先级 | 条件 | 结果 |
|
||||
|--------|------|------|
|
||||
| 1 | 车牌为空 | 不展示标签 |
|
||||
| 2 | 规范化后车牌 ∈ 本地系统车辆集合 | **羚牛车辆** |
|
||||
| 3 | 有车牌但不在集合中 | **非羚牛车辆** |
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 识别(OCR)或键盘输入得到车牌后才展示。
|
||||
- 比较前去掉尾缀 `F`,统一大写。
|
||||
|
||||
## 数据源
|
||||
|
||||
- 优先 `H2VehicleLedgerBridge.isLingniuVehicle`;本地回退见 `utils/fleet-plates.ts`(与 Bridge `H2_FLEET_PLATE_KEYS` 同步)。
|
||||
|
||||
## 用户可见结果
|
||||
|
||||
- 绿色标签「羚牛车辆」或橙色标签「非羚牛车辆」。
|
||||
- **非羚牛车辆**:列表/详情中核对状态、核对时间、对账状态、对账时间显示「—」,不展示核对待办语义。
|
||||
- 不拦截提交(仅提示归属)。
|
||||
|
||||
## 代码路径
|
||||
|
||||
- `getVehicleTag` / `isLingniuVehicle` → `CreateWizard`、`OrderCard`、`OrderDetail`
|
||||
@@ -0,0 +1,62 @@
|
||||
# 车牌「羚牛车辆 / 非羚牛车辆」与核对对账字段
|
||||
|
||||
原型本地判定,未接真实车辆管理 API。
|
||||
|
||||
## 1. 车辆来源判定
|
||||
|
||||
| 优先级 | 条件 | 结果 |
|
||||
|--------|------|------|
|
||||
| 1 | 车牌为空 | 不展示车辆来源标签 |
|
||||
| 2 | 规范化后车牌 ∈ 系统车辆列表 | **羚牛车辆**(绿) |
|
||||
| 3 | 有车牌但不在集合中 | **非羚牛车辆**(橙) |
|
||||
|
||||
比较前去掉尾缀 `F`,统一大写。
|
||||
|
||||
### 数据源
|
||||
|
||||
- 共享:`src/common/h2VehicleLedgerBridge.js` → `H2_FLEET_PLATE_KEYS` / `h2BridgeIsLingniuVehicle`
|
||||
- H5:`utils/fleet-plates.ts`(优先调 Bridge,否则本地同集合同步)
|
||||
- 正式环境应改为查询车辆管理 / 系统车辆列表接口
|
||||
|
||||
### 展示位置
|
||||
|
||||
| 模块 | 位置 |
|
||||
|------|------|
|
||||
| 加氢记录 Web | 车牌号列:车辆来源标签 + 数据来源标签 |
|
||||
| 车辆氢费明细 | 车牌号列:车辆来源标签 + 数据来源标签 |
|
||||
| 加氢记录 H5 | 新增向导、列表卡片、详情 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 核对 / 对账字段可见性
|
||||
|
||||
| 车辆来源 | 核对状态 / 核对时间 / 核对人 / 对账状态 / 对账时间(对账日期) / 对账人 |
|
||||
|----------|------------------------------------------------------------------------|
|
||||
| **羚牛车辆** | 正常展示业务值(未核/未对为「未核对」「未对账」或「—」) |
|
||||
| **非羚牛车辆** | **一律显示为空(—)**,不展示已核/未核、已对/未对等业务态 |
|
||||
|
||||
### 判定顺序(展示)
|
||||
|
||||
| 优先级 | 条件 | 结果 |
|
||||
|--------|------|------|
|
||||
| 1 | `!isLingniuVehicle(plateNo)` | 上述字段全部空展示 |
|
||||
| 2 | 羚牛车辆 | 按原核对/对账逻辑渲染 |
|
||||
|
||||
### 锁定与编辑
|
||||
|
||||
- 站端改删锁定、H5 详情锁定:**仅羚牛车辆**且已核对/已对账时生效。
|
||||
- 非羚牛车辆不以核对/对账状态锁定编辑。
|
||||
|
||||
### 演示种子
|
||||
|
||||
- Bridge:`rf-ext-1` / 车牌 `浙C77801F`(非羚牛)
|
||||
- 台账:`mock-h2-external-plate` / 车牌 `粤Z99999F`(非羚牛)
|
||||
|
||||
---
|
||||
|
||||
## 3. 代码路径
|
||||
|
||||
- Bridge:`h2BridgeIsLingniuVehicle` / `normalizePlateKey`
|
||||
- Web:`hrIsLingniuVehicle` / `hrShowVerifyReconcileFields` → `pages/02-加氢记录.jsx`
|
||||
- 台账:`isLingniuVehiclePlate` / `showVerifyReconcileFields` → `H2LedgerPage.jsx`
|
||||
- H5:`getVehicleTag` → `OrderCard` / `OrderDetail` / `CreateWizard`;`isOrderLocked` 跳过非羚牛
|
||||
58
src/prototypes/oneos-web-h2-station/.spec/flows.md
Normal file
58
src/prototypes/oneos-web-h2-station/.spec/flows.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# 流程图
|
||||
|
||||
> 标注面板支持 Mermaid 渲染。下列流程与当前原型行为一致(本地 Store,未接真实 API)。
|
||||
|
||||
---
|
||||
|
||||
## 1. 主路径:列表 → 门禁 → 新增 / 导出
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[打开加氢记录 Web] --> B[筛选 / 查看列表与每日统计]
|
||||
B --> C{昨日手工台账已上传?}
|
||||
C -->|否| D[顶栏提示:手工台账有缺失…]
|
||||
D --> E[点新增或点提示]
|
||||
E --> F[手工台账 Tab · 选中昨日]
|
||||
F --> G[上传并确认归档]
|
||||
G --> C
|
||||
C -->|是| H{操作}
|
||||
H -->|新增/批量| I[填表保存 · 重复键/总额校验]
|
||||
H -->|导出| J[按当前筛选导出 CSV]
|
||||
H -->|编辑/删除| K{已核对或已对账?}
|
||||
K -->|是| L[禁止]
|
||||
K -->|否| M[写回 Bridge]
|
||||
I --> B
|
||||
J --> B
|
||||
M --> B
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 手工台账(Web)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[手工台账 Tab] --> B[按角色选站]
|
||||
B --> C[月历绿/橙]
|
||||
C --> D[选日上传]
|
||||
D --> E[二次确认]
|
||||
E --> F[已归档不可删]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 与 H5 / 台账 / 对账单
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant WEB as 加氢记录 Web
|
||||
participant H5 as 加氢订单 H5
|
||||
participant BR as Bridge Store
|
||||
participant LED as 车辆氢费明细
|
||||
participant SITE as 站点对账单
|
||||
WEB->>BR: CRUD · 台账 · 导出读列表
|
||||
H5->>BR: CRUD · 台账
|
||||
LED->>BR: 完成核对
|
||||
SITE->>BR: 提交对账单
|
||||
Note over WEB,H5: 同会话共享可见
|
||||
```
|
||||
53
src/prototypes/oneos-web-h2-station/.spec/key-logic.md
Normal file
53
src/prototypes/oneos-web-h2-station/.spec/key-logic.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# 关键逻辑索引
|
||||
|
||||
本页汇总加氢记录(Web)复杂判定入口;细则见各规格全文。原型数据均为本地种子 / 内存 Store,**未接真实 API**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 判定主题一览
|
||||
|
||||
| 主题 | 一句话 | 全文 |
|
||||
|---|---|---|
|
||||
| 手工台账门禁 | **昨日**未传 → 今日禁新增;顶栏固定文案 + 直达昨日 | [../oneos-h5-h2-order/.spec/feature-manual-ledger.md](../oneos-h5-h2-order/.spec/feature-manual-ledger.md)(Web 同逻辑) |
|
||||
| 数据结构 / 重复键 / 总额 | 站端口径字段;主键拦截;总额偏差确认 | [record-data-model.md](./record-data-model.md) |
|
||||
| 车辆来源与核对可见性 | 羚牛车辆才展示核对/对账字段 | [fleet-vehicle-verify.md](./fleet-vehicle-verify.md) |
|
||||
| 数据来源 / 变更日志 | `recordSource`、行级 `changeLogs`、核对人/对账人 | [record-source-changelog.md](./record-source-changelog.md) |
|
||||
| 核对 ≠ 对账 | 台账完成核对 / 站点对账单提交 | [../oneos-h5-h2-order/.spec/reconcile-linkage.md](../oneos-h5-h2-order/.spec/reconcile-linkage.md) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 手工台账门禁(摘要)
|
||||
|
||||
与 H5 一致:
|
||||
|
||||
| 优先级 | 条件 | 结果 |
|
||||
|--------|------|------|
|
||||
| 1 | 编辑已有 | 不校验 |
|
||||
| 2 | 昨日早于首笔加氢日 | 可新增 |
|
||||
| 3 | 昨日已上传 | 可新增 |
|
||||
| 4 | 昨日未上传 | 提示并 `goToManualLedgerForGap`(选中昨日) |
|
||||
|
||||
文案:`手工台账有缺失,作为对账重要依据,请先上传手工台账`
|
||||
|
||||
代码:`pages/02-加氢记录.jsx` → `hrIsManualLedgerGateBlocked`;Bridge → `isManualLedgerGateBlocked`
|
||||
|
||||
---
|
||||
|
||||
## 3. 导出列口径
|
||||
|
||||
| 导出列名 | 取值 |
|
||||
|---|---|
|
||||
| 车辆来源 | 羚牛车辆 / 非羚牛车辆 |
|
||||
| 数据来源 | 站端上传 / 羚牛上传 |
|
||||
|
||||
与列表车牌列标签一致。
|
||||
|
||||
---
|
||||
|
||||
## 4. 代码路径
|
||||
|
||||
| 能力 | 路径 |
|
||||
|---|---|
|
||||
| 页面 | `pages/02-加氢记录.jsx` |
|
||||
| Bridge | `src/common/h2VehicleLedgerBridge.js` |
|
||||
| 标注 | `annotation-source.json`(`scripts/build-annotation-source.mjs` 生成) |
|
||||
@@ -0,0 +1,48 @@
|
||||
# 加氢订单(H5)· 对账 / 核对联动规则
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 代码路径 | Store:`src/common/h2VehicleLedgerBridge.js`;对账单:`oneos-web-h2-station-site`;PC 加氢记录:`oneos-web-h2-station`;车辆氢费明细:`vehicle-h2-fee-ledger` |
|
||||
| 规格全文 | [../vehicle-h2-fee-ledger/.spec/verify-reconcile.md](../vehicle-h2-fee-ledger/.spec/verify-reconcile.md) |
|
||||
| 数据源 | 原型本地种子 + 内存共享 Store(**未接真实 API**) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 两套状态(判定优先级)
|
||||
|
||||
| 优先级 | 状态 | 字段 | 谁触发 | 用户可见 |
|
||||
|---|---|---|---|---|
|
||||
| 1 | **核对** | `verifyStatus` / `verifiedAt` | 车辆氢费明细 · **完成核对** | 未核对 / 已核对 + 核对时间 |
|
||||
| 2 | **对账** | `reconcileStatus` / `reconcileDate` | 站点信息 · **对账单提交** | 未对账 / 已对账 + 对账时间 |
|
||||
|
||||
> `reconciledAt` 仅作完成核对时的兼容写入,**不得**再用于判定「已对账」。
|
||||
|
||||
## 2. 数据流
|
||||
|
||||
```text
|
||||
站端 H5 / Web 新增加氢记录
|
||||
→ Bridge.upsertRow(verify=unverified,reconcile=pending;成本单价/量/总额)
|
||||
→ 车辆氢费明细可见
|
||||
|
||||
能源部 · 完成核对
|
||||
→ verifyStatus=verified + verifiedAt
|
||||
→ 站端同步「已核对」,禁改删
|
||||
|
||||
站点信息 · 对账单提交(仅已核对 ∧ 未对账)
|
||||
→ reconcileStatus=reconciled + reconcileDate + statementRecordId
|
||||
→ 站端 / 台账同步「已对账」
|
||||
```
|
||||
|
||||
## 3. 用户可见结果(H5)
|
||||
|
||||
| 结果 | 行为 |
|
||||
|---|---|
|
||||
| 未核对 | 可编辑、删除 |
|
||||
| 已核对未对账 | 只读;提示能源部已核对 |
|
||||
| 已对账 | 只读;提示已纳入对账单 |
|
||||
|
||||
## 4. 边界
|
||||
|
||||
- 站端上报默认未核对、未对账
|
||||
- 对账时间只认 `reconcileDate`(对账单回写)
|
||||
- 跨页联动依赖同浏览器会话共享 Store
|
||||
39
src/prototypes/oneos-web-h2-station/.spec/roles.md
Normal file
39
src/prototypes/oneos-web-h2-station/.spec/roles.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# 需求角色
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 适用原型 | 加氢记录(Web)`oneos-web-h2-station` |
|
||||
| 关联 H5 | 加氢订单 `oneos-h5-h2-order`(同一 Bridge) |
|
||||
| 数据 | 原型本地种子 + 共享 Bridge(未接真实登录 / API) |
|
||||
|
||||
---
|
||||
|
||||
## 1. 角色一览
|
||||
|
||||
| 角色 | 端 | 核心诉求 | 本原型是否为主用户 |
|
||||
|---|---|---|---|
|
||||
| **加氢站操作人员(PC)** | Web | 在电脑上录入/批量补录、上传手工台账、导出 CSV | **是** |
|
||||
| **多站管理员** | Web | 在角色站点范围内切换站、查看多站流水 | **是**(标注状态可切单站/多站/超管) |
|
||||
| **超级管理员** | Web | 查看全部站点 | 演示态支持 |
|
||||
| **加氢站操作人员(手机)** | H5 | 站场快速上报 | 否(见 H5 原型) |
|
||||
| **能源部核对人员** | 车辆氢费明细 | 完成核对 | 否 |
|
||||
| **站点对账人员** | 站点信息 | 提交对账单 | 否 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 本原型主角色:站端 PC 人员
|
||||
|
||||
| 项 | 说明 |
|
||||
|---|---|
|
||||
| 场景 | 办公室 / 值班室 PC |
|
||||
| 权限边界 | 按登录角色可见站点(原型用标注「登录角色站点范围」演示) |
|
||||
| 日常动作 | 筛选列表 → 处理昨日台账门禁 → 新增/批量/编辑 → 导出 → 查看变更日志 |
|
||||
| 相对 H5 增量 | **导出**、多站筛选、批量新增、每日统计 KPI、变更日志弹窗 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 角色边界(本期不做)
|
||||
|
||||
- 真登录与权限中心
|
||||
- 在本页直接完成「核对」「对账」按钮(由台账 / 站点信息回写)
|
||||
- 预约加氢(已下线)
|
||||
85
src/prototypes/oneos-web-h2-station/.spec/user-stories.md
Normal file
85
src/prototypes/oneos-web-h2-station/.spec/user-stories.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 用户故事
|
||||
|
||||
> 以加氢站 PC 操作人员 / 多站管理员为主;验收口径见 [requirements-prd.md](./requirements-prd.md)。
|
||||
|
||||
---
|
||||
|
||||
## US-01 筛选并查看加氢流水
|
||||
|
||||
**作为** 站端 PC 人员,
|
||||
**我希望** 按时间、车牌、站名、核对/对账状态筛选列表,
|
||||
**以便** 快速定位待处理记录。
|
||||
|
||||
**验收要点**
|
||||
|
||||
- 默认一行筛选项;更多筛选/收起对齐物流业务明细
|
||||
- 列表含车辆来源、数据来源标签
|
||||
- 非羚牛车辆核对/对账列为空
|
||||
- 操作列「详情」可查看记录字段及车牌号、加氢机面板照片
|
||||
|
||||
---
|
||||
|
||||
## US-02 前一日台账缺失时被拦截并补传
|
||||
|
||||
**作为** 站端 PC 人员,
|
||||
**我希望** 昨日手工台账缺失时看到固定顶栏提示,点新增直达台账页,
|
||||
**以便** 先补对账依据再录电子记录。
|
||||
|
||||
**验收要点**
|
||||
|
||||
- 文案:`手工台账有缺失,作为对账重要依据,请先上传手工台账`
|
||||
- 跳转手工台账 Tab 并选中昨日
|
||||
- Tab 红点表示门禁未解除
|
||||
|
||||
---
|
||||
|
||||
## US-03 单条 / 批量新增加氢记录
|
||||
|
||||
**作为** 站端 PC 人员,
|
||||
**我希望** 单条或批量录入加氢流水,
|
||||
**以便** 补录高峰数据。
|
||||
|
||||
**验收要点**
|
||||
|
||||
- 字段:时间、站、车牌、里程(选填)、单价、量、总额
|
||||
- 重复键拦截;总额偏离单价×量 > 0.05 元需确认
|
||||
- 已核对/已对账不可改删
|
||||
|
||||
---
|
||||
|
||||
## US-04 上传与补传手工台账
|
||||
|
||||
**作为** 站端 PC 人员,
|
||||
**我希望** 在月历上查看已传/未传并上传照片,
|
||||
**以便** 与 H5 同一套规则管理纸质台账。
|
||||
|
||||
**验收要点**
|
||||
|
||||
- 站点按角色范围;确认前二次弹窗;归档不可删
|
||||
- 与 H5 同 Store
|
||||
|
||||
---
|
||||
|
||||
## US-05 导出当前筛选结果
|
||||
|
||||
**作为** 站端 PC 人员,
|
||||
**我希望** 导出与列表一致的 CSV,
|
||||
**以便** 线下对账或留档。
|
||||
|
||||
**验收要点**
|
||||
|
||||
- 列含:车辆来源(羚牛车辆/非羚牛车辆)、数据来源(站端上传/羚牛上传)
|
||||
- 含核对/对账人与时间列
|
||||
|
||||
---
|
||||
|
||||
## US-06 查看每日统计与变更日志
|
||||
|
||||
**作为** 站端 PC 人员,
|
||||
**我希望** 第一屏不被统计挤占,需要时再展开明细,并能看行级变更日志,
|
||||
**以便** 兼顾效率与审计。
|
||||
|
||||
**验收要点**
|
||||
|
||||
- 每日统计默认收起
|
||||
- 操作「变更日志」展示时间/操作人/改项/原值/新值
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 从 .spec/*.md 生成 annotation-source.json(含右侧原型目录)
|
||||
* 用法:node src/prototypes/oneos-web-h2-station/scripts/build-annotation-source.mjs
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const specDir = path.join(root, '.spec');
|
||||
const outFile = path.join(root, 'annotation-source.json');
|
||||
const now = Date.now();
|
||||
|
||||
function readMd(name) {
|
||||
return fs.readFileSync(path.join(specDir, name), 'utf8');
|
||||
}
|
||||
|
||||
function mdNode(id, title, file) {
|
||||
return {
|
||||
type: 'markdown',
|
||||
id,
|
||||
title,
|
||||
markdown: readMd(file),
|
||||
markdownPath: `.spec/${file}`,
|
||||
};
|
||||
}
|
||||
|
||||
const existing = JSON.parse(fs.readFileSync(outFile, 'utf8'));
|
||||
const markers = existing?.data?.nodes || [];
|
||||
|
||||
const source = {
|
||||
documentVersion: 1,
|
||||
format: 'axhub-annotation-source',
|
||||
data: {
|
||||
version: 2,
|
||||
prototypeName: 'oneos-web-h2-station',
|
||||
pageId: 'h2-record',
|
||||
updatedAt: now,
|
||||
nodes: markers.map((n) => ({
|
||||
...n,
|
||||
updatedAt: n.updatedAt || now,
|
||||
createdAt: n.createdAt || now,
|
||||
})),
|
||||
},
|
||||
markdownMap: existing.markdownMap || {},
|
||||
assetMap: existing.assetMap || {},
|
||||
directory: {
|
||||
nodes: [
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'hr-doc-root',
|
||||
title: '加氢记录(Web)说明',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
mdNode('hr-doc-prd', 'PRD 全文', 'requirements-prd.md'),
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'hr-doc-roles',
|
||||
title: '需求角色',
|
||||
defaultExpanded: true,
|
||||
children: [mdNode('hr-doc-roles-md', '角色说明', 'roles.md')],
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'hr-doc-stories',
|
||||
title: '用户故事',
|
||||
defaultExpanded: true,
|
||||
children: [mdNode('hr-doc-stories-md', '用户故事', 'user-stories.md')],
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'hr-doc-logic',
|
||||
title: '关键逻辑',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
mdNode('hr-doc-logic-index', '逻辑索引', 'key-logic.md'),
|
||||
mdNode('hr-doc-logic-data', '数据模型与校验', 'record-data-model.md'),
|
||||
mdNode('hr-doc-logic-fleet', '羚牛车辆与核对可见性', 'fleet-vehicle-verify.md'),
|
||||
mdNode('hr-doc-logic-source', '来源与变更日志', 'record-source-changelog.md'),
|
||||
mdNode('hr-doc-logic-manual', '手工台账门禁', 'feature-manual-ledger.md'),
|
||||
mdNode('hr-doc-logic-reconcile', '核对与对账联动', 'reconcile-linkage.md'),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'hr-doc-flows',
|
||||
title: '流程图',
|
||||
defaultExpanded: true,
|
||||
children: [mdNode('hr-doc-flows-md', '操作流程图', 'flows.md')],
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'hr-doc-features',
|
||||
title: '功能说明',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
mdNode('hr-doc-feat-list', '列表与筛选', 'feature-list.md'),
|
||||
mdNode('hr-doc-feat-create', '新增批量编辑', 'feature-create.md'),
|
||||
mdNode('hr-doc-feat-manual', '手工台账上传', 'feature-manual-ledger.md'),
|
||||
mdNode('hr-doc-feat-export', '导出', 'feature-export.md'),
|
||||
mdNode('hr-doc-feat-stats', '每日统计', 'feature-daily-stats.md'),
|
||||
mdNode('hr-doc-feat-fleet', '羚牛车辆标签', 'fleet-plate-tag.md'),
|
||||
mdNode('hr-doc-feat-source', '来源与变更日志', 'record-source-changelog.md'),
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'hr-doc-pages',
|
||||
title: '按页面',
|
||||
defaultExpanded: false,
|
||||
children: [
|
||||
{
|
||||
type: 'folder',
|
||||
id: 'hr-page-record',
|
||||
title: '加氢记录页',
|
||||
defaultExpanded: true,
|
||||
children: [
|
||||
{
|
||||
type: 'route',
|
||||
id: 'hr-route-record',
|
||||
title: 'h2-record',
|
||||
route: 'h2-record',
|
||||
payload: { pageId: 'h2-record' },
|
||||
},
|
||||
mdNode('hr-page-record-prd', '本页 PRD', 'requirements-prd.md'),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
fs.writeFileSync(outFile, `${JSON.stringify(source, null, 2)}\n`, 'utf8');
|
||||
console.log(
|
||||
`Wrote ${path.relative(process.cwd(), outFile)} (${source.data.nodes.length} markers, directory ready)`,
|
||||
);
|
||||
115
src/prototypes/oneos-web-workbench-new/.spec/approval-embed.md
Normal file
115
src/prototypes/oneos-web-workbench-new/.spec/approval-embed.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 工作台 · 审批中心嵌入卡片
|
||||
|
||||
> 实现:`components/ApprovalPanel.tsx` · `data/approvals.ts` · `style.css`(`.wb-ap-*`)
|
||||
> 数据:本地种子,**未接真实审批流程 API**
|
||||
|
||||
## 1. 布局对齐
|
||||
|
||||
- 标题区使用与「进行中的任务」相同的 `wb-panel-header wb-panel-header--dash`(固定 `height: 56px`),标题下分隔线与左侧对齐。
|
||||
- Tab 行固定 `height: 38.5px`、无上下外边距,通栏底部分隔线与左侧待办表头(thead)顶底对齐。
|
||||
|
||||
## 2. 卡片样式
|
||||
|
||||
- 对齐审批完整页 `ap-card`:白底边框卡片,**无左侧色条**。
|
||||
- 结构:左上类型标签 + **右上角操作文案** → 标题 → **发起时间 / 发起人** → 副标题 → 关键事实(2 列 dl)→ 底部横向时间轴。
|
||||
- **首屏可见**:卡片与时间轴采用紧凑间距(`.wb-ap-list` / `.wb-ap-card*` / `.wb-ap-timeline`),保证审批区第一屏可完整展示首张卡片(含并行网关节点);并行提示向上溢出,不预留大块顶空白。
|
||||
- 右上角不再展示「审批中:当前审批人」(审批人已在底栏时间轴);按 Tab:
|
||||
|
||||
| Tab | 右上角文案 |
|
||||
|-----|------------|
|
||||
| 待办 | 去处理 |
|
||||
| 发起 / 已办 | 催办 |
|
||||
| 抄送 | 查看 |
|
||||
|
||||
## 3. 横向时间轴 · 当前节点下标
|
||||
|
||||
| 顺序 | 条件 | 当前节点下标 |
|
||||
|------|------|----------------|
|
||||
| 1 | `status === 'approved'` | `steps.length`(全部 done) |
|
||||
| 2 | `status === 'rejected'` / `'terminated'` | `max(steps.length - 2, 0)`(完成前终态节点) |
|
||||
| 3 | `pending` / `processing` | `min(1, steps.length - 1)` |
|
||||
|
||||
节点状态:`index < current` → done;`index === current` → 按卡状态:`rejected` / `terminated` / `current`;其余 → todo。
|
||||
当前节点展示 `currentApprover`;发起节点(index 0)在 done/current/终态时展示 `initiatedBy`。
|
||||
终态节点**不展示**停留时长。
|
||||
|
||||
步骤来源:卡上有 `flowSteps` 时优先;否则按 `type` 取 `FLOW_LABELS_BY_TYPE` 线性节点;未知类型用 `发起 → 审批 → 完成`。
|
||||
|
||||
### 3.0 发起 Tab · 终态样例
|
||||
|
||||
| 种子 ID | status | 状态文案 | 时间轴表现 |
|
||||
|---------|--------|----------|------------|
|
||||
| `wb-init-rejected` | `rejected` | 被驳回 | 倒数第二节点标红(rejected) |
|
||||
| `wb-init-terminated` | `terminated` | 被终止 | 倒数第二节点标灰(terminated) |
|
||||
|
||||
另含共享 mock:`ac-22`(被驳回)。数据本地种子,**未接真实审批流程 API**。
|
||||
|
||||
## 3.1 并行网关展示
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 步骤 `type === 'serial'` | 单点:圆点 + 标签 + 审批人/时长 |
|
||||
| 2 | 步骤 `type === 'parallel'` | 步骤条**折叠**为单点,文案固定「并行网关」(不默认展开分支) |
|
||||
| 3 | 点击「并行网关」 | 节点**上方**出现对话标签提示:`N 路同时处理:分支A、分支B…`;再点或点空白关闭 |
|
||||
| 4 | 并行槽聚合状态 | 任一分支持 `current` → 槽位 current;全部 `done` → done;否则 todo |
|
||||
| 5 | AND 并行(演示) | 进入该槽时各分支同为 current;汇合后进入下一串行节点 |
|
||||
| 6 | 当前态副文案 | 槽位 current 时节点下方展示 `N 路并行`(非各分支审批人) |
|
||||
|
||||
用户可见:并行槽在时间轴上占 **1** 个窗口节点;与前后串行节点仍用横向连线连接。分支明细仅在点击提示中展示,**不**默认纵向展开虚线框。
|
||||
|
||||
演示种子:`wb-todo-1`(新桥冷链)为 `发起 → [法务审核 ∥ 财务复核] → 业管确认 → 完成`。
|
||||
|
||||
前置:仅当种子配置 `flowSteps` 含 parallel;**未接真实 BPMN/工作流引擎网关 API**。
|
||||
|
||||
代码:`ApprovalPanel.tsx` → `ParallelGatewayStep`;样式 `.wb-ap-tl-gateway*`。
|
||||
|
||||
## 4. 当前节点停留时长
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 无当前节点(已全部 done) | 不展示时长 |
|
||||
| 2 | 有 `currentNodeEnteredAt` | 以该时间起算 |
|
||||
| 3 | 缺省 | 回退 `initiatedAt` |
|
||||
|
||||
用户可见:当前节点审批人下方展示 `已处理 N天M小时`(不足 1 天仅 `已处理 H小时`)。
|
||||
口径:相对本地当前时间;原型种子字段,**未接真实节点进入时间 API**。
|
||||
|
||||
## 5. 过长流程可见窗口
|
||||
|
||||
窗口常量:`APPROVAL_FLOW_VISIBLE_WINDOW = 4`。
|
||||
|
||||
| 顺序 | 条件 | 可见范围 |
|
||||
|------|------|----------|
|
||||
| 1 | 节点总数 ≤ 4 | 完整显示全部节点 |
|
||||
| 2 | 当前下标 ≥ `n - 4`(完成前四个节点内) | 完整显示全部节点 |
|
||||
| 3 | 其余过长流程 | 仅显示当前最近 4 节点(优先含当前前一节点,再向后补足) |
|
||||
|
||||
并行槽按 **1** 个节点计入窗口。代码:`getApprovalFlowVisibleRange` → `getApprovalFlowNodes` 切片后交给 `ApprovalPanel` 渲染。
|
||||
|
||||
## 6. Tab 徽标口径(发起 / 已办)
|
||||
|
||||
> 实现:`data/approvals.ts` → `isApprovalBadgeActive` · `approvalCounts`;`ApprovalPanel` 传入当前演示登录人 `currentCreator`
|
||||
> 数据:本地种子,**未接真实审批流程 API**
|
||||
|
||||
### 6.1 判定优先级(是否计入徽标)
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | `status === 'approved'`(流程完成) | **不计**,徽标侧消失 |
|
||||
| 2 | `status === 'terminated'`(流程终止) | **不计**,徽标侧消失 |
|
||||
| 3 | `status === 'rejected'`(被驳回,后续节点不再推进) | **不计** |
|
||||
| 4 | `status === 'pending'` / `'processing'` | **计**(后续节点未全部完成) |
|
||||
| 5 | 其余 | 不计 |
|
||||
|
||||
### 6.2 各 Tab 计数范围
|
||||
|
||||
| Tab | 计数规则 |
|
||||
|-----|----------|
|
||||
| 待办 / 抄送 | 该 `listTab` 全部条数(与创建人无关) |
|
||||
| **发起** / **已办** | `listTab` 匹配 **且** `initiatedBy === 当前创建人` **且** `isApprovalBadgeActive` |
|
||||
|
||||
前置:当前创建人取审批中心演示用户 `CURRENT_USER`(`张明辉`,与共享 mock `initiatedBy` 对齐);列表仍展示该 Tab 全部种子,**仅徽标数字**按上表收窄。正式环境改为登录人。
|
||||
|
||||
用户可见:发起/已办仅在自己发起且流程仍在进行时出现数字徽标;流程完成或终止后对应条目不再贡献徽标。
|
||||
|
||||
代码路径:`ApprovalPanel.tsx`(`data-annotation-id="wb-approval-tab-*"`)· `approvals.ts#approvalCounts`。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 通知中心
|
||||
|
||||
> 实现:`components/NoticePanel.tsx` · `WorkbenchPage`(直链铃铛 / 嵌入同步)
|
||||
> 数据:`data/notices.ts` 本地种子,**未接真实消息 API**
|
||||
|
||||
## 1. 入口形态(as-built)
|
||||
|
||||
| 场景 | 入口 | 说明 |
|
||||
|------|------|------|
|
||||
| 直链打开工作台 | 页内右上角浮动铃铛 | 有未读时闪烁「N 条新信息」 |
|
||||
| 嵌入「原型演示」外壳 | 外壳顶栏铃铛 | `postNoticesSync` 同步未读与列表;页内不显示浮动铃铛 |
|
||||
| 主内容区 | **无**通知卡片 | 待办/审批铺满主焦点区 |
|
||||
|
||||
## 2. 列表规则(优先级)
|
||||
|
||||
| 顺序 | 规则 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 仅 `read=false` | 已读不进下拉池 |
|
||||
| 2 | `type === '催办提醒'` 优先于其它类型 | 催办排前 |
|
||||
| 3 | 同优先级按时间近→远 | `compareNoticePriority` |
|
||||
| 4 | 同屏展示约 2 条,可轮播 | 超过则轮播 |
|
||||
| 5 | 池为空 | 空态「暂无新消息」类文案 |
|
||||
|
||||
过滤:`noticesForRole(roleId)` — `roleIds` 含当前演示角色。
|
||||
|
||||
## 3. 用户动作
|
||||
|
||||
| 动作 | 结果 |
|
||||
|------|------|
|
||||
| 打开通知 | 可展开全文 / 标记已读 |
|
||||
| 去处理 | 已读 + 有 `href` 则跳转,或有 `taskId` 则打开待办详情 |
|
||||
| 外壳下拉操作 | 通过 postMessage `read` / `open` / `handle` 回传工作台 |
|
||||
|
||||
## 4. 边界
|
||||
|
||||
- 采购等角色种子可为空:空通知为可接受演示状态
|
||||
- 催办确认**不会**新增通知(见 [urge-channels.md](./urge-channels.md))
|
||||
- 版本更新系统通知 `n-release-notes` 与首次进入弹框联动,见 [release-notes-popup.md](./release-notes-popup.md)
|
||||
@@ -0,0 +1,7 @@
|
||||
# OneOS 应用外壳(工作台-新)
|
||||
|
||||
本页**直链不带**侧栏顶栏。
|
||||
|
||||
统一入口请使用 [原型演示](/prototypes/oneos-prototype-demo)(`oneos-prototype-demo`)。
|
||||
|
||||
原型演示顶栏提供「版本更新」图标(Sparkles),打开更新日志弹框;未阅版本时显示角点。经 `release-demo-bridge` 与工作台 iframe 通信,见 [release-notes-popup.md](./release-notes-popup.md)。
|
||||
@@ -0,0 +1,48 @@
|
||||
# 收款未关联账单预警 KPI
|
||||
|
||||
> 原型:`oneos-web-workbench-new` · KPI id:`payment-unlinked`
|
||||
> 数据:与收款记录种子口径对齐的静态样例,**未接实时 API**
|
||||
|
||||
## 判定
|
||||
|
||||
| 顺序 | 条件 | 说明 |
|
||||
|------|------|------|
|
||||
| 1 | 收款记录存在 | 主表收款维度 |
|
||||
| 2 | 未关联金额 > 0 | `未关联 = max(0, 到账金额 − 已认领金额)`;含部分认领 |
|
||||
| 3 | 计入笔数 | 每条收款计 1 笔 |
|
||||
|
||||
## 可见角色
|
||||
|
||||
| RoleId | 展示名 |
|
||||
|--------|--------|
|
||||
| `bizAdmin` | 业务管理部 |
|
||||
| `bizSales` | 业务部 |
|
||||
|
||||
其他角色不可见。不按个人姓名过滤(与现有工作台 KPI 一致)。
|
||||
|
||||
## 展示
|
||||
|
||||
- 标题:收款记录未关联账单
|
||||
- value / unit:笔数
|
||||
- hint:可含未关联金额合计摘要
|
||||
- detailHref:`/prototypes/payment-records`
|
||||
- items:客户、到账/未关联金额、收款时间
|
||||
|
||||
## 与入账短信
|
||||
|
||||
入账短信在收款入账时触发(见 `payment-records/.spec/payment-notify.md`);本 KPI 仅统计仍有未关联金额的记录,二者独立。
|
||||
|
||||
## 关联校验类预警(业管)
|
||||
|
||||
原运营看板「待关联异常」六项已全部迁入欢迎区预警指标(`bizAdmin`);看板 HUD / 合同在办 / 交车进度已去除:
|
||||
|
||||
| KPI id | 标题 | 笔数 | severity |
|
||||
|--------|------|------|----------|
|
||||
| `pickup-recv-unlinked` | 提车应收未关联收款 | 4 | danger |
|
||||
| `return-settle-unlinked` | 还车应结未关联收/付款 | 6 | danger |
|
||||
| `lease-bill-unlinked` | 租赁账单未关联收款 | 9 | warn |
|
||||
| `h2-prepay-unlinked` | 预充值氢费未关联 | 3 | warn |
|
||||
| `station-stmt-unlinked` | 加氢站对账未关联 | 5 | warn |
|
||||
| `h2-month-unlinked` | 氢费月账单未关联 | 7 | danger |
|
||||
|
||||
数据:`data/kpi.ts` 静态种子,**未接真实 API**。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 198 KiB |
@@ -0,0 +1,87 @@
|
||||
# 版本更新日志 · 首次进入弹框
|
||||
|
||||
> 实现:`components/ReleaseNotesModal.tsx` · `WorkbenchPage`(就绪后自动弹出) · 原型演示顶栏图标
|
||||
> 数据:`data/release-notes.ts` 本地种子(当前 **OneOS PC版 V1.1.5**)+ `utils/release-seen.ts`(localStorage)
|
||||
> **未接真实发布 API / 登录 SSO**
|
||||
|
||||
## 1. 目标
|
||||
|
||||
系统发布新版本后,用户**更新后首次进入工作台**(直链或嵌入原型演示外壳)时弹出更新日志,避免错过关键变更;确认后同版本不再自动弹出。之后可通过原型演示顶栏「版本更新」图标(或快速入口)打开,并**切换查阅过往版本**。
|
||||
|
||||
原型中「登录」等价于:**打开工作台且演示会话有效**(演示登录人见 `CURRENT_OPERATOR_BY_ROLE`)。
|
||||
|
||||
## 2. 判定优先级(自动弹出)
|
||||
|
||||
| 顺序 | 规则 | 不通过时 |
|
||||
|------|------|----------|
|
||||
| 1 | 存在已发布、未过期的当前 release(`CURRENT_RELEASE`) | 不弹 |
|
||||
| 2 | 当前演示角色在受众内(`audience === 'all'` 或含 `roleId`) | 不弹 |
|
||||
| 3 | `getSeenVersion(operatorName) !== release.version`(含无记录) | 不弹 |
|
||||
| 4 | 本页会话尚未自动弹出过(`sessionAutoShown`) | 不重复弹 |
|
||||
| 5 | 无更高优先级阻断弹框(见 §6;本轮恒为通过) | 不弹 |
|
||||
|
||||
入口:工作台挂载且角色/登录人就绪后的 `useEffect`(直链与嵌入外壳同源)。
|
||||
|
||||
## 3. 前置条件
|
||||
|
||||
- 不依赖真实 SSO;切换演示角色会切换演示登录人,已阅按**登录人名**隔离。
|
||||
- 主动打开「更新日志」**不受**已阅约束,始终可看当前发布版(及种子内历史摘要)。
|
||||
- 不与单据字段变更日志(台账 `ChangeLog*`)、原型导航 `prototype-registry` changelog 混用。
|
||||
|
||||
## 4. 数据源
|
||||
|
||||
| 项 | 来源 | 说明 |
|
||||
|----|------|------|
|
||||
| 当前发布版 | `data/release-notes.ts` → `CURRENT_RELEASE` | 版本号、标题、条目、受众 |
|
||||
| 历史摘要 | 同文件 `RELEASE_HISTORY` | 弹框内可选展示,**自动弹只弹最新一版** |
|
||||
| 已阅记忆 | `localStorage` key `oneos-wb-release-seen-v1` | `{ [operatorName]: version }` |
|
||||
| 系统通知 | `data/notices.ts` → id `n-release-notes` | 全角色;弹框确认时标已读 |
|
||||
|
||||
生产环境应对齐配置中心 / CMS + 用户偏好 API;本原型为本地种子。
|
||||
|
||||
## 5. 与通知中心关系(定稿)
|
||||
|
||||
| 通道 | 行为 |
|
||||
|------|------|
|
||||
| 弹框 | 强提醒;仅「新版本未阅」自动触发一次 |
|
||||
| 铃铛 | 同步一条 `系统通知`(`n-release-notes`),可再点开详情 |
|
||||
| 确认「知道了」 | 写入已阅版本 **且** 将该通知标已读(嵌入时经 `postNoticesSync` 同步外壳) |
|
||||
|
||||
仅点通知「关闭/知道了」而不点弹框:通知已读,但若未写已阅版本,刷新后**仍可能**再弹更新日志(已阅以 release-seen 为准)。
|
||||
|
||||
## 6. 弹框优先级队列(预留)
|
||||
|
||||
生产建议顺序:安全强制(改密等)> 协议更新 > **版本更新**。
|
||||
本轮原型仅实现版本更新单项;判定表第 5 步恒通过。
|
||||
|
||||
## 7. 多版本策略
|
||||
|
||||
- **自动弹(首次进入)**:只弹、只展示 **最新一版**(`CURRENT_RELEASE`,当前 V1.1.5);**不展示版本切换 chips**,用户仅需阅读本次发布内容。
|
||||
- 不做连环多弹窗;已阅仅记录当前 `CURRENT_RELEASE.version`。
|
||||
- **主动查阅**(顶栏「版本更新」图标 / 快速入口「更新日志」):`mode === 'history'`,弹框内可切换 `RELEASE_HISTORY` 查阅过往版本。
|
||||
|
||||
## 8. 用户可见结果
|
||||
|
||||
| 动作 | 结果 |
|
||||
|------|------|
|
||||
| 未阅新版本进入工作台 | 弹出更新日志 Modal,**仅最新版**;顶栏产品名 + 版本号**只展示一次**(如 `OneOS PC版` + `V1.1.5`),副文案为「更新日志 · 日期 · N 项变更」;无历史版本切换 |
|
||||
| 点「知道了」/ 关闭(本轮允许 Esc/遮罩关闭,等同确认) | 写入已阅;同版本刷新不再自动弹;对应系统通知已读 |
|
||||
| 快速入口 → 更新日志 | 随时打开 Modal(不强制未阅) |
|
||||
| 原型演示顶栏「版本更新」图标 | 随时打开更新日志;可切换过往版本;未阅时角点提示 |
|
||||
|
||||
## 9. 代码路径
|
||||
|
||||
| 能力 | 路径 |
|
||||
|------|------|
|
||||
| 种子与受众 | `data/release-notes.ts` |
|
||||
| 已阅读写 | `utils/release-seen.ts` |
|
||||
| Modal | `components/ReleaseNotesModal.tsx` |
|
||||
| 触发 / 通知联动 / 复位 | `components/WorkbenchPage.tsx` · 原型演示顶栏(`oneos-app-shell` + `release-demo-bridge`) |
|
||||
| 快捷入口注入 | `WorkbenchPage` 合并 `releaseNotes` action |
|
||||
|
||||
## 10. 边界
|
||||
|
||||
- 采购等角色:受众为全员时同样弹出;通知种子含该条。
|
||||
- 切换角色但登录人不变(如王冕:业管 ↔ 总经理):已阅共享,不重复弹。
|
||||
- 切换到其他登录人且该人未阅:可再弹。
|
||||
- 强制阅读、发布后台、埋点面板:**本轮不做**。
|
||||
420
src/prototypes/oneos-web-workbench-new/.spec/requirements-prd.md
Normal file
420
src/prototypes/oneos-web-workbench-new/.spec/requirements-prd.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# 工作台-新 · 产品需求文档(PRD)
|
||||
|
||||
> 原型 ID:`oneos-web-workbench-new`
|
||||
> 文档口径:**以当前可运行原型为准(as-built)**
|
||||
> 数据:本地种子 + localStorage;**未接真实 API**
|
||||
> 关联规格:[role-visibility.md](./role-visibility.md) · [todo-biz-admin.md](./todo-biz-admin.md) · [urge-channels.md](./urge-channels.md) · [role-insights.md](./role-insights.md) · [approval-embed.md](./approval-embed.md) · [payment-unlinked-kpi.md](./payment-unlinked-kpi.md) · [notice-center.md](./notice-center.md) · [release-notes-popup.md](./release-notes-popup.md)
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
各部门主管与业务人员每天要在多个系统之间切换,才能看到「本角色该跟的预警、待办、审批与经营指标」。缺少一个**按角色聚合**的开工首页时:
|
||||
|
||||
- 紧急预警与未完成待办容易漏看;
|
||||
- 审批与待办割裂,无法在同一屏推进;
|
||||
- 多角色人员(如同时兼业管与总经理视角)无法一次看到应合并的预警;
|
||||
- 催办只能线下沟通,缺少统一入口与演示闭环。
|
||||
|
||||
需要一个「工作台-新」页面,让用户登录后立刻按角色看到预警 → 看板 → 待办/审批,并完成办理跳转、催办演示与通知阅知。
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
提供 **轻量个人台** 工作台页面(无 A/B/C 布局切换):
|
||||
|
||||
1. **欢迎区**:问候、演示角色切换、人情化提示、预警 KPI 瓦片、快速入口
|
||||
2. **运营看板**:按角色展示经营/运营指标;多角色登录人可在看板内切换本人角色
|
||||
3. **主焦点双栏**:左侧「我的待办」、右侧「审批中心」
|
||||
4. **通知中心**:不占主区卡片;直链页用右上角铃铛,嵌入「原型演示」外壳时同步到外壳顶栏铃铛
|
||||
|
||||
直链打开 `/prototypes/oneos-web-workbench-new` **不带** OneOS 侧栏/顶栏;带外壳浏览请走 `/prototypes/oneos-prototype-demo`(默认嵌入本页)。
|
||||
|
||||
---
|
||||
|
||||
## 页面信息架构
|
||||
|
||||
```text
|
||||
欢迎区(问候 · 演示角色 · 快速入口 · 预警 KPI)
|
||||
运营看板(环形/表格等角色洞察 · 多角色可切)
|
||||
我的待办 | 审批中心
|
||||
通知:直链浮动铃铛 / 外壳顶栏铃铛(主区无通知卡片)
|
||||
浮层:KPI 明细 · 任务详情 · 催办弹窗 · 通知全文 · 快速发起确认 · 版本更新日志
|
||||
```
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph page [工作台-新]
|
||||
Hero[欢迎区含KPI与快速入口]
|
||||
Insights[运营看板]
|
||||
Todo[我的待办]
|
||||
Approval[审批中心]
|
||||
Hero --> Insights
|
||||
Insights --> Todo
|
||||
Insights --> Approval
|
||||
end
|
||||
Notice[通知中心铃铛入口]
|
||||
Hero -.-> Notice
|
||||
Todo --> Jump[跳转业务原型]
|
||||
Approval --> Jump
|
||||
Notice --> Jump
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 角色与演示身份(as-built)
|
||||
|
||||
| RoleId | 展示名 | 演示登录人名 | 催办 |
|
||||
|--------|--------|--------------|------|
|
||||
| bizAdmin | 业务管理部 | 王冕 | 本部门 · lease/self |
|
||||
| bizEnergy | 业务管理部-能源组 | 周凯 | 本部门 · energy |
|
||||
| bizSales | 业务部 | 王磊 | 本部门 · lease/self |
|
||||
| ops | 运维部 | 刘洋 | 本部门 · ops/lease |
|
||||
| procurement | 采购部 | 黄倩 | **无催办** |
|
||||
| safety | 安全部 | 孙敏 | 本部门 · safety |
|
||||
| finance | 财务部 | 陈静 | 本部门 · lease/energy |
|
||||
| legal | 法务部 | 赵律师 | 本部门 · lease/self |
|
||||
| gm | 总经理 | 王冕 | 仅 high 风险 · 全条线 |
|
||||
|
||||
**多角色演示**:登录人「王冕」映射角色集合 `['bizAdmin','gm']`。
|
||||
|
||||
- 预警 KPI:取该集合各角色 KPI **并集**(总经理专属卡与业管卡可同时出现)
|
||||
- 运营看板:可在看板内切换本人角色(业管 / 总经理)查看不同指标块
|
||||
- 待办 / 通知 / 快捷入口:仍按**当前选中的演示角色**过滤
|
||||
|
||||
角色选择持久化:`localStorage` key `oneos-wb-new-role-v1`。
|
||||
|
||||
---
|
||||
|
||||
## 功能规格摘要
|
||||
|
||||
### 1. 预警中心(KPI)
|
||||
|
||||
- 展示:等分等高瓦片;指标名 + 条数;无左侧「合计大块」
|
||||
- 标题栏元信息:共 N 项 / 合计 / 紧急项数(如有)
|
||||
- 规则说明:指标名旁帮助图标悬停提示
|
||||
- 点击:打开 KPI 明细抽屉,可「去处理 / 打开模块」跳转
|
||||
- 过滤:`kpisForRole` / `kpisForRoles`;总经理单角色时仅专属汇总卡;多角色并集
|
||||
- 业管/业务部含「收款未关联」等关联类预警,口径见 [payment-unlinked-kpi.md](./payment-unlinked-kpi.md)
|
||||
|
||||
### 2. 快速入口
|
||||
|
||||
- 位置:欢迎区内锚点展开
|
||||
- 分区:快速发起(`action` 固定)+ 功能模块(`link`,可按角色自定义)
|
||||
- 自定义:候选池 = 当前角色有权限的 link;记忆 key `oneos-wb-new-quick-links-v1`
|
||||
- 发起确认后 Toast,并跳转对应原型(合同/退车/保险/充值/交车等)
|
||||
|
||||
### 3. 运营看板
|
||||
|
||||
- 标题固定「运营看板」
|
||||
- 内容随角色变化(合同/氢费/运维/保险/财务/安全/法务/经营总览等种子块)
|
||||
- 多角色用户可在看板工具条右侧横排切换本人角色(`shortLabel`,不占额外垂直空间)
|
||||
- 业管三列筛选总览仪表盘:客户交车(租赁合同)/ 车型业务 / 省·省市分布;占比两位小数;看板本身不滚动
|
||||
- 运维四列看板:近 3 月年审执行率、车辆状态(租赁/在库)、交还车任务(默认地区总量、主管按人筛选)、故障登记(总数/闭环/闭环率);主管看全部,普通运维仅本地区/本人;详见 [role-insights.md](./role-insights.md)
|
||||
- **展示形态**:指挥台式长卡(渐变描边 / 网格与光晕动效)+ 顶栏工具条;详见 [role-insights.md](./role-insights.md)
|
||||
- **业管三列**:`layout: tri-columns` → `BizAdminCockpit`(卡片右上角**自定义下拉**筛选 + Bullet KPI 占比 + 双 KPI;非系统原生 select)
|
||||
- **运维四列**:`layout: ops-cockpit` → `OpsCockpit`
|
||||
|
||||
### 4. 我的待办
|
||||
|
||||
- 列表仅展示「未完成」任务(见 `isTodoIncomplete`)
|
||||
- 筛选:
|
||||
- **紧急程度**(单选):全部 / 正常 / 逾期(`flowStatus=overdue` 或 `slaHoursLeft<0`)
|
||||
- **业务类型**(多选 + 搜索):空=全部;恰选 1 条用专用列,否则通用紧凑列
|
||||
- 业管五条线字段与生成/闭环:见 [todo-biz-admin.md](./todo-biz-admin.md)
|
||||
- 行操作:详情抽屉 / 去处理(跳转 href)/ 催办(权限见下)
|
||||
|
||||
### 5. 审批中心
|
||||
|
||||
- 四 Tab:待办 / 发起 / 已办 / 抄送 + 徽标
|
||||
- **发起/已办徽标**:仅计「当前发起人发起且流程未完成/未终止/未驳回」的单据(演示发起人与审批种子一致)
|
||||
- 卡片:无左侧色条;按 Tab 显示去处理 / 催办 / 查看
|
||||
- 底栏横向时间轴;并行网关折叠;当前节点停留时长;过长流程窗口约 4 节点
|
||||
- 发起 Tab 含被驳回 / 被终止样例
|
||||
- 详见 [approval-embed.md](./approval-embed.md)
|
||||
|
||||
### 6. 通知中心
|
||||
|
||||
- 主区**不**展示通知卡片
|
||||
- **直链**:右上角浮动铃铛;有未读时闪烁「N 条新信息」
|
||||
- **嵌入原型演示**:通过 postMessage 同步未读与列表到外壳顶栏铃铛
|
||||
- 内容:仅未读;催办类型优先,再按时间近→远;同屏约 2 条轮播;全已读空态「暂无新消息」
|
||||
- 详见 [notice-center.md](./notice-center.md)
|
||||
|
||||
### 6.1 版本更新日志(首次进入弹框)
|
||||
|
||||
| 项 | 说明 |
|
||||
|----|------|
|
||||
| 触发 | 未阅当前发布版时,进入工作台自动弹 Modal,**仅展示最新版**(如 V1.1.5),不含历史版本切换 |
|
||||
| 已阅 | `localStorage` `oneos-wb-release-seen-v1`(按演示登录人);确认后同版本不再自动弹 |
|
||||
| 通知 | 同步系统通知 `n-release-notes`;弹框确认同时标已读 |
|
||||
| 历史 | 顶栏「版本更新」图标 / 快速入口可打开;弹框内可切换过往版本 |
|
||||
| 演示 | 未阅时顶栏图标有角点;首次进入自动弹框,确认后同版本不再自动弹 |
|
||||
| 规格全文 | [release-notes-popup.md](./release-notes-popup.md) |
|
||||
|
||||
### 7. 催办(演示)
|
||||
|
||||
判定入口:`roleCanUrgeTask`(优先级表见 [urge-channels.md](./urge-channels.md))。
|
||||
|
||||
确认后 **as-built** 行为:
|
||||
|
||||
1. Toast:`已通过{通道}催办「{责任人}」(演示 {时间})`(多选时通道名用顿号拼接)
|
||||
2. 埋点:`urge_sent`(含 channel;多选为拼接字符串)
|
||||
3. **不**新增通知中心未读消息(当前原型未写 notices)
|
||||
4. **不**真实发送邮件/短信/微信
|
||||
|
||||
通道:邮件 / 短信 / 转发到微信;**支持多选**,打开弹窗时**默认勾选邮件**;至少保留 1 种方可发送。详见 [urge-channels.md](./urge-channels.md) §3.1。
|
||||
|
||||
### 8. 已下线模块(勿再验收)
|
||||
|
||||
- 主区「任务流转」泳道看板
|
||||
- 布局方案 A/B/C 切换
|
||||
- 主区绩效/埋点分析看板(`AnalyticsBoard` 未挂载;埋点事件仍可能写入 localStorage)
|
||||
|
||||
---
|
||||
|
||||
## User Stories
|
||||
|
||||
1. As a 业务管理部用户, I want 打开工作台即看到业管相关预警 KPI, so that 我能优先处理超期账单与应收。
|
||||
2. As a 业务管理部用户, I want 在待办中按「租赁合同 / 提车应收 / 租赁账单 / 还车应结(部门) / 还车应结」查看字段, so that 列表信息与业务单据一致。
|
||||
3. As a 业务管理部用户, I want 提车应收与租赁账单在未收齐时一直留在待办, so that 部分收款不会误关单。
|
||||
4. As a 业务管理部用户, I want 还车应结(部门)在提交后从待办消失, so that 我知道本部门段已完成。
|
||||
5. As a 业务管理部用户, I want 四部门都提交后才看到最终还车应结待办及应补/应退, so that 我只处理可汇总的结算。
|
||||
6. As a 多角色登录人(王冕), I want KPI 同时展示业管与总经理预警, so that 我不必反复切换角色扫预警。
|
||||
7. As a 多角色登录人, I want 在运营看板内切换业管/总经理指标, so that 我能分别看部门与经营视角。
|
||||
8. As a 部门主管, I want 对待办中可催办的任务选择邮件/短信/微信催办, so that 我能演示催办动作。
|
||||
9. As a 采购部用户, I want 待办上不出现催办按钮, so that 符合采购无催办权限的设定。
|
||||
10. As a 总经理, I want 只能对高风险任务催办, so that 催办范围聚焦重大风险。
|
||||
11. As a 任意角色用户, I want 点击待办「去处理」跳到对应业务原型, so that 我能继续办理。
|
||||
12. As a 任意角色用户, I want 用紧急程度筛选逾期待办, so that 我先清超期项。
|
||||
13. As a 任意角色用户, I want 用业务类型多选筛选条线, so that 我只看相关任务。
|
||||
14. As a 任意角色用户, I want 在审批中心四 Tab 间切换并看到徽标, so that 我知道各队列数量。
|
||||
15. As a 发起人, I want 发起/已办徽标在流程完成后消失, so that 徽标只代表进行中的单。
|
||||
16. As a 审批用户, I want 在卡片上看到横向时间轴与并行网关提示, so that 我理解当前卡在哪一步。
|
||||
17. As a 任意角色用户, I want 通过铃铛只看未读通知且催办优先, so that 重要提醒不被淹没。
|
||||
18. As a 任意角色用户, I want 读通知或去处理后未读数下降, so that 我知道已处理。
|
||||
19. As a 直链打开工作台的用户, I want 右上角仍有通知铃铛, so that 即使没有外壳也能收通知。
|
||||
20. As a 嵌入原型演示的用户, I want 通知出现在外壳顶栏, so that 与 OneOS 产品形态一致。
|
||||
21. As a 任意角色用户, I want 用快速入口发起合同/退车/保险等, so that 高频动作少跳转。
|
||||
22. As a 任意角色用户, I want 自定义功能模块入口并记住选择, so that 快捷区符合个人习惯。
|
||||
23. As a 演示用户, I want 切换演示角色后 KPI/待办/通知/快捷入口一起变化, so that 可评审各角色视图。
|
||||
24. As a 演示用户, I want 角色选择刷新后仍保留, so that 评审过程不被重置。
|
||||
25. As a 测试人员, I want 在无真实 API 的种子数据下稳定复现条线与闭环规则, so that 可编写用例。
|
||||
26. As a 产品经理, I want 主区不再出现已下线的任务流转看板, so that 评审焦点不被旧模块干扰。
|
||||
27. As a 法务/财务/安全/运维/能源等角色用户, I want 只看本角色 KPI 与待办, so that 信息不串岗。
|
||||
28. As a 用户, I want KPI 规则通过帮助图标悬停查看, so that 列表更干净仍可理解口径。
|
||||
29. As a 用户, I want 欢迎区看到人情化随机提示, so that 开工体验更友好。
|
||||
30. As a 用户, I want 催办后看到 Toast 与演示时间戳, so that 我确认操作已触发(即使未真实发送)。
|
||||
31. As a 任意角色用户, I want 系统更新后首次进入工作台时看到更新日志弹框, so that 我不会错过关键变更。
|
||||
32. As a 任意角色用户, I want 确认更新日志后刷新不再自动弹出, so that 不被重复打断。
|
||||
33. As a 任意角色用户, I want 从快速入口再次打开更新日志, so that 我能事后查阅。
|
||||
34. As a 演示/测试人员, I want 从原型演示顶栏打开版本更新日志并切换过往版本, so that 可反复查阅变更说明。
|
||||
|
||||
---
|
||||
|
||||
## 正向流程
|
||||
|
||||
### F1 开工扫视(主路径)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[打开工作台] --> A2[未阅则弹更新日志]
|
||||
A2 --> B[确认演示角色]
|
||||
B --> C[阅读预警KPI]
|
||||
C --> D[浏览运营看板]
|
||||
D --> E[处理待办或审批]
|
||||
E --> F[需要时催办或跳转业务页]
|
||||
F --> G[铃铛处理未读通知]
|
||||
```
|
||||
|
||||
| 步骤 | 用户动作 | 系统响应 |
|
||||
|------|----------|----------|
|
||||
| 1 | 打开页面(直链或原型演示嵌入) | 加载种子;恢复 localStorage 角色;未阅版本则弹更新日志 |
|
||||
| 2 | (可选)确认更新日志 | 写入已阅并标系统通知已读 |
|
||||
| 3 | (可选)切换演示角色 | 刷新 KPI/待办/通知/快捷入口;待办筛选复位 |
|
||||
| 4 | 查看 KPI | 按角色或并集展示瓦片 |
|
||||
| 5 | 查看运营看板 | 展示当前角色洞察;多角色可切 |
|
||||
| 6 | 筛选并处理待办 | 列表按未完成+筛选更新;去处理跳转 |
|
||||
| 7 | 处理审批 Tab | 徽标与列表随 Tab 变化 |
|
||||
| 8 | 打开铃铛 | 仅未读;可已读/去处理 |
|
||||
|
||||
### F2 业管待办闭环(提车应收 / 租赁账单)
|
||||
|
||||
| 步骤 | 说明 |
|
||||
|------|------|
|
||||
| 生成 | 单据创建后任务进入待办(种子已存在) |
|
||||
| 在办 | `received < receivable` → 未完成 |
|
||||
| 关闭 | `received ≥ receivable` 或 `flowStatus=done` → 不再计入未完成 |
|
||||
|
||||
### F3 催办演示
|
||||
|
||||
| 步骤 | 用户动作 | 系统响应 |
|
||||
|------|----------|----------|
|
||||
| 1 | 对可催办任务点「催办」 | 打开三通道弹窗;默认勾选邮件 |
|
||||
| 2 | 多选通道并确认 | Toast(通道名顿号拼接)+ `urge_sent` 埋点;关闭弹窗 |
|
||||
| 3 | — | 不发真实消息;不写新通知 |
|
||||
|
||||
### F4 快速发起
|
||||
|
||||
| 步骤 | 用户动作 | 系统响应 |
|
||||
|------|----------|----------|
|
||||
| 1 | 打开快速入口 → 选发起类动作 | 确认弹窗 |
|
||||
| 2 | 确认 | Toast → 跳转目标原型 |
|
||||
|
||||
### F5 版本更新日志(首次进入)
|
||||
|
||||
| 步骤 | 用户动作 | 系统响应 |
|
||||
|------|----------|----------|
|
||||
| 1 | 打开工作台(直链或嵌入外壳) | 若当前登录人未阅 `CURRENT_RELEASE` 且受众匹配 → 弹出更新日志 |
|
||||
| 2 | 点「知道了」或关闭 | 写入已阅版本;系统通知 `n-release-notes` 标已读;同版本不再自动弹 |
|
||||
| 3 | (可选)快速入口 → 更新日志 | 再次打开 Modal(不依赖未阅) |
|
||||
| 4 | (可选)顶栏点「版本更新」图标 | 打开更新日志弹框,可切换过往版本查阅 |
|
||||
|
||||
---
|
||||
|
||||
## 逆向 / 异常 / 边界流程
|
||||
|
||||
| ID | 场景 | 期望 |
|
||||
|----|------|------|
|
||||
| R1 | 采购角色打开待办 | 任意任务均无「催办」 |
|
||||
| R2 | 总经理对非 high 风险任务 | 无「催办」 |
|
||||
| R3 | 任务 `managerCanUrge=false` | 无「催办」 |
|
||||
| R4 | 任务已 `done` | 不在未完成列表;无催办 |
|
||||
| R5 | 提车应收部分收款 | 仍在待办,未收金额 > 0 |
|
||||
| R6 | 还车应结(部门) `submitted=true` | 不算未完成 |
|
||||
| R7 | 通知全部已读 | 铃铛无闪烁气泡;空态「暂无新消息」 |
|
||||
| R8 | 业务类型多选为空 | 视为全部条线 |
|
||||
| R9 | 业务类型恰选 1 条 | 专用列表列;选多条或全部 → 通用列 |
|
||||
| R10 | 角色切换 | 紧急程度/业务类型筛选复位为默认 |
|
||||
| R11 | 自定义快捷模块勾选失效 id | 自动剔除;若全空则回退该角色全部 link |
|
||||
| R12 | KPI 当前角色为空 | 空态「当前角色暂无预警」 |
|
||||
| R13 | 待办当前筛选无数据 | 空态「当前分类暂无待办」 |
|
||||
| R14 | 去处理无 href | Toast 提示暂无跳转,可打开详情 |
|
||||
| R15 | 采购角色通知种子为空 | 通知池可为空(演示数据缺口,验收时注明) |
|
||||
| R16 | 嵌入外壳时 | 页内不显示浮动铃铛,由外壳展示通知 |
|
||||
| R17 | 已阅当前版本后刷新 | 不再自动弹出更新日志 |
|
||||
| R18 | 同登录人切换角色(王冕业管↔总经理) | 已阅共享,不重复弹 |
|
||||
| R19 | 切换到未阅的其他登录人 | 可再次自动弹出 |
|
||||
| R20 | 仅从铃铛读系统通知、未写已阅 | 刷新后仍可能再弹更新日志(已阅以 release-seen 为准) |
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
UrgeClick[点击催办]
|
||||
UrgeClick --> C1{canUrge?}
|
||||
C1 -->|否| Hide[不展示按钮]
|
||||
C1 -->|是| C2{managerCanUrge且未done?}
|
||||
C2 -->|否| Hide
|
||||
C2 -->|是| C3{条线匹配?}
|
||||
C3 -->|否| Hide
|
||||
C3 -->|是| C4{scope通过?}
|
||||
C4 -->|否| Hide
|
||||
C4 -->|是| Modal[打开催办弹窗]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
1. **页面模块**:欢迎(KPI+快速入口)、运营看板、待办、审批、通知入口;任务流转与绩效看板不挂主区。
|
||||
2. **角色模型**:角色配置含 `canUrge` / `urgeScope` / `urgeLines` / `scopeDepts` / `businessLines`;与 `isManager` 解耦。
|
||||
3. **多角色 KPI**:`roleIdsForOperator` → `kpisForRoles` 并集;待办仍按当前 `roleId`。
|
||||
4. **待办未完成**:统一 `isTodoIncomplete`(done 优先;应收类看金额;部门还车看 submitted)。
|
||||
5. **催办**:`roleCanUrgeTask` 为唯一权限判定入口;确认仅 Toast + 埋点。
|
||||
6. **通知**:主区不占栏;直链铃铛 / 嵌入同步外壳。
|
||||
7. **审批徽标**:发起/已办仅统计进行中的「我发起」单据。
|
||||
8. **持久化**:角色 `oneos-wb-new-role-v1`;快捷模块 `oneos-wb-new-quick-links-v1`;埋点 `oneos-wb-new-analytics-v1`;更新已阅 `oneos-wb-release-seen-v1`(按演示登录人)。
|
||||
9. **跳转**:统一导航助手;发起类动作映射固定原型路径。
|
||||
10. **外壳**:本页不内嵌侧栏顶栏;演示外壳为独立原型。
|
||||
11. **版本更新弹框**:种子 `CURRENT_RELEASE`;确认写已阅并标系统通知已读;快速入口可重开;详见 release-notes-popup。
|
||||
|
||||
业管待办 `todoDetail` 判别(原型决策摘要):
|
||||
|
||||
```text
|
||||
isTodoIncomplete:
|
||||
if flowStatus == done → false
|
||||
if pickup-receivable | lease-bill → received < receivable
|
||||
if return-settle-dept → !submitted
|
||||
else → true(未 done)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
### 什么是好的测试
|
||||
|
||||
- 只断言**外部行为与规则结果**(是否展示、是否计入未完成、过滤集合、徽标数字),不绑定 CSS class 或组件内部 state。
|
||||
- 优先测纯函数 seams,再补关键路径的界面验收清单。
|
||||
|
||||
### 必测 seams(已确认,不含外壳导航)
|
||||
|
||||
| Seam | 覆盖点 |
|
||||
|------|--------|
|
||||
| 角色可见性 | `roleIdsForOperator`、`kpisForRole(s)`、`tasksForRole`、`noticesForRole`、`insightsForRole`、角色快捷入口集合 |
|
||||
| 待办未完成与业管闭环 | `isTodoIncomplete`、`resolveTaskTodoLine`、`dueDayStatus`、`finalSettleSummary`、未收金额 |
|
||||
| 催办权限 | `roleCanUrgeTask` 全部分支(采购关、总经理仅 high、条线/部门不匹配、done、managerCanUrge=false) |
|
||||
| 审批徽标 | `approvalCounts` / `isApprovalBadgeActive`(完成/终止/驳回不计) |
|
||||
|
||||
### 界面验收(手工 / E2E)
|
||||
|
||||
- 布局顺序与双栏;角色切换后数据联动
|
||||
- 待办两筛选组合与空态
|
||||
- KPI 点击明细与帮助悬停
|
||||
- 催办 Toast;采购无按钮
|
||||
- 通知未读/已读/空态;直链铃铛可见
|
||||
- 快速入口自定义记忆
|
||||
- 未阅版本首次进入弹更新日志;确认后刷新不弹;顶栏版本更新图标可查阅并切换过往版本
|
||||
|
||||
### 先验
|
||||
|
||||
- 同类规则测试可参考租赁明细字段校验类规格的「判定表 + 优先级」写法。
|
||||
|
||||
---
|
||||
|
||||
## 验收清单(产品 / 测试)
|
||||
|
||||
1. 9 角色可切换;采购无催办;王冕多角色 KPI 并集。
|
||||
2. KPI 等分瓦片;无左侧合计大块;帮助悬停;可点明细。
|
||||
3. 运营看板标题正确;多角色横排切换;业管三列:客户/车型/区域筛选 + 总览仪表盘(占比两位小数);看板不滚动;待办/审批浅色滚动条。
|
||||
4. 待办筛选与业管五类列/闭环符合 todo-biz-admin。
|
||||
5. 审批四 Tab、徽标口径、时间轴与终态样例符合 approval-embed。
|
||||
6. 通知入口符合直链/嵌入两种形态;主区无通知卡片。
|
||||
7. 催办通道可多选(默认邮件)仅 Toast+埋点;不发真实消息、不写新通知。
|
||||
8. 快速入口发起跳转与自定义模块记忆可用。
|
||||
9. 无任务流转看板;无布局 A/B/C 切换。
|
||||
10. 直链无侧栏顶栏;右上角铃铛可用。
|
||||
11. 未阅新版本首次进入弹出更新日志;确认后刷新不再自动弹;顶栏「版本更新」图标可查阅并切换过往版本;嵌入外壳行为一致。
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 真实登录鉴权、权限中心、工单/审批 BPMN、报表 API
|
||||
- 真实邮件 / 短信 / 企微发送
|
||||
- 生产埋点 SDK
|
||||
- 收款流水与待办金额的实时联动(种子演示)
|
||||
- 恢复任务流转主区看板、多布局对比、主区绩效看板
|
||||
- 本 PRD 不覆盖「原型演示」外壳菜单与 iframe 路由(独立原型)
|
||||
- 版本更新发布后台、强制阅读、多端已阅 API、灰度定向(见 release-notes-popup P2)
|
||||
|
||||
---
|
||||
|
||||
## Further Notes
|
||||
|
||||
- 演示发起人(审批徽标)与工作台演示登录人可能不是同一人名,以审批种子规则为准验收徽标。
|
||||
- 部分 KPI `value` 与明细条数可不一一相等(演示密度),明细以抽屉列表为准。
|
||||
- 采购部可无通知种子;验收「空通知」为可接受演示状态。
|
||||
- 埋点事件目前主要落 `task_opened` / `task_viewed` / `urge_sent`;其它事件类型以代码实际调用为准,不强制验收未挂载的分析看板。
|
||||
|
||||
---
|
||||
|
||||
## 文档维护
|
||||
|
||||
| 变更类型 | 需同步 |
|
||||
|----------|--------|
|
||||
| 角色/催办/KPI 过滤 | 本文 + role-visibility + urge-channels |
|
||||
| 业管待办字段/闭环 | 本文 + todo-biz-admin |
|
||||
| 审批徽标/时间轴 | 本文 + approval-embed |
|
||||
| 通知入口/排序 | 本文 + notice-center |
|
||||
| 版本更新弹框/已阅 | 本文 + release-notes-popup |
|
||||
185
src/prototypes/oneos-web-workbench-new/.spec/role-insights.md
Normal file
185
src/prototypes/oneos-web-workbench-new/.spec/role-insights.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# 角色业务洞察(运营看板)
|
||||
|
||||
> 原型本地种子:[`data/role-insights.ts`](../data/role-insights.ts)
|
||||
> **未接真实 API**
|
||||
|
||||
## 判定 / 展示优先级
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 当前演示角色有对应 `ROLE_INSIGHTS[roleId]` | 展示该角色指标包 |
|
||||
| 2 | 无匹配包 | 回退业管看板(容错) |
|
||||
| 3 | 登录人 `OPERATOR_ROLE_IDS` 挂接 **≥2** 个角色 | 看板工具条右侧横排角色切换(`shortLabel`,悬停见全称),切换后刷新指标 |
|
||||
| 4 | 单角色登录人 | 不展示看板内角色切换(顶栏「模拟」仍可切演示角色) |
|
||||
|
||||
## 前置条件
|
||||
|
||||
- 仅工作台页面嵌入;不替代待办 / 流转 / 审批
|
||||
- 角色切换后 Tab 复位到第一块(`layout=tabs`)
|
||||
- 看板内角色切换只暴露「本人真实多角色」,与顶栏全量演示角色选择器解耦;兼岗切换(如王冕 → 运维)**不更换登录人**,切换条保持可见
|
||||
|
||||
## 布局
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 个人工作台主区 | **运营看板**置于「我的待办 / 审批中心」**上方** |
|
||||
| 2 | 标题 | 固定文案「运营看板」(不随角色包 title 变化)+ LIVE 徽标 |
|
||||
| 3 | 角色切换 | 多角色时置于工具条右侧横排胶囊(`shortLabel`);与标题同高,不额外占垂直空间 |
|
||||
| 4 | `layout === 'tabs'`(默认) | 指挥台式长卡;顶栏工具条(标题左 / Tab 右)+ 下方全宽环形仪表栅格 |
|
||||
| 5 | `layout === 'tri-columns'`(业管) | **三列筛选仪表盘**(`BizAdminCockpit`):左客户交车 / 中车型业务 / 右区域分布;大环占比 + 双 KPI;看板本身不滚动;无 Tab |
|
||||
| 6 | 指标块 `metrics` / `split` | 可点击环形 gauge;点击选中展开 hint;色调 good/warn/danger |
|
||||
| 7 | 表/条形图/`pulse` 块 | Tab 模式:切到该块时在条卡下方展开;多栏模式:直接内联在对应列 |
|
||||
| 8 | `fleet-share`(车辆资产使用) | Tab 切到该块时内联 `OnLeaseFleetSharePanel`(其他角色若配置仍可用;业管已改为三列交车看板) |
|
||||
| 9 | 动效 | 尊重 `prefers-reduced-motion` |
|
||||
|
||||
## 业管三列(`layout: tri-columns` · `BizAdminCockpit`)
|
||||
|
||||
| 顺序 | 列 | 标题 | 右上角筛选 | 未选中 | 选中后 |
|
||||
|------|----|------|------------|--------|--------|
|
||||
| 1 | 左 | 各项目交车情况 | 选择客户 | 租赁合同**总览仪表盘** | 该客户仪表盘 |
|
||||
| 2 | 中 | 车辆业务情况 | 选择车型 | **全部车型合计**总览仪表盘(交车/总数/占比) | 该车型总览仪表盘 |
|
||||
| 3 | 右 | 各省市分布 | 区域粒度「省 / 省市」+ 选择区域 | 全国汇总仪表盘 | 该区域仪表盘 |
|
||||
|
||||
### 总览仪表盘(UI · Data-Dense Bullet KPI)
|
||||
|
||||
| 元素 | 说明 |
|
||||
|------|------|
|
||||
| 布局 | **横向紧凑条**:左占比数字 + 色点;中双 KPI;底部分档 bullet 进度条 + 刻度 |
|
||||
| 占比 | **精确两位小数** + `%`;色调 good≥70 / warn≥40 / danger(文字与进度条同步,不只靠颜色) |
|
||||
| KPI | 交车数量 / 已交车数 · 租赁数量或车辆总数;等宽数字 |
|
||||
| 筛选控件 | **自定义下拉**(非系统原生 `<select>`):浅色面板、品牌绿选中勾、外点/Esc 关闭;选项 >8 时带搜索(车型强制搜索) |
|
||||
| 高度 | 单卡约一行半,三列并排,为下方待办/审批留出首屏空间 |
|
||||
| 动效 | 进度条宽度入场 450ms;尊重 `prefers-reduced-motion` |
|
||||
|
||||
### 左列:客户交车(租赁合同)
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 数据源 | `lease-contract-list-data.js` → `LEASE_CONTRACT_LIST_RECORDS` |
|
||||
| 2 | 聚合键 | `customerName`(空 →「未命名客户」) |
|
||||
| 3 | 租赁数量 | 合同 `vehicleCount` 合计(缺省则用 `vehicles.length`) |
|
||||
| 4 | 交车数量 | `vehicles` 中 `isContractVehicleDelivered`(`actualDelivery` / `deliveryTime` 有效)台数合计 |
|
||||
| 5 | 占比 | `交车数量 ÷ 租赁数量 × 100`,**四舍五入至两位小数** |
|
||||
| 6 | 未选客户 | 全客户合计总览 |
|
||||
| 7 | 已选客户 | 仅该客户合计 |
|
||||
|
||||
**实现**:`utils/fleet-contract-delivery.ts`,**未接真实 API**。
|
||||
|
||||
### 中列:车辆业务(品牌型号)
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 数据源 | `vehicle-management/data/vehicles.json` |
|
||||
| 2 | 聚合键 | `品牌 · 型号` |
|
||||
| 3 | 交车数量 | `operateStatus === '租赁'`(`matchKpi lease`) |
|
||||
| 4 | 车辆总数 | 该车型全部台数 |
|
||||
| 5 | 占比 | `交车 ÷ 总数 × 100`,两位小数 |
|
||||
| 6 | 排序 | 车辆总数降序 → 交车数 → 名称 |
|
||||
| 7 | 未选车型 | **全部车型合计**总览(交车合计 ÷ 车辆总数合计) |
|
||||
| 8 | 已选车型 | 该车型总览仪表盘;下拉可回到「全部车型」 |
|
||||
|
||||
**实现**:`utils/fleet-brand-model-stats.ts`,**未接真实 API**。
|
||||
|
||||
### 右列:各省市分布
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 粒度「省」 | 按 `parseRegionParts(location).province` 聚合 |
|
||||
| 2 | 粒度「省市」 | 按完整 `location`(`省-市`;空/`-` →「未知」)聚合 |
|
||||
| 3 | 已交车数 | `operateStatus === '租赁'` |
|
||||
| 4 | 车辆总数 | 该区域台数 |
|
||||
| 5 | 占比 | `已交 ÷ 总数 × 100`,两位小数 |
|
||||
| 6 | 排序 | 总数降序 → 区域名 |
|
||||
| 7 | 未选区域 | 当前粒度下全国合计 |
|
||||
| 8 | 切换粒度 | 清空已选区域 |
|
||||
|
||||
**实现**:`utils/fleet-region-dist.ts`,**未接真实 API**。
|
||||
|
||||
## 运维四列(`layout: ops-cockpit` · `OpsCockpit`)
|
||||
|
||||
| 顺序 | 列 | 标题 | 筛选 | 数据范围 |
|
||||
|------|----|------|------|----------|
|
||||
| 1 | 左 | 近 3 个月年审执行率 | 无 | 主管:全部地区;普通运维:本人所属地区 |
|
||||
| 2 | 左中 | 车辆状态 | 无 | 租赁 / 在库(口径同业管 · 车辆业务情况) |
|
||||
| 3 | 右中 | 交还车任务 | 主管右上角按人 | 默认地区总量;同地区运维共用该地区任务 |
|
||||
| 4 | 右 | 故障登记 | 主管右上角按人 | 总数 / 闭环数 / 闭环率 |
|
||||
|
||||
### 可见性(判定顺序)
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 登录人在 `OPS_STAFF` 且 `isSupervisor=true`(演示:刘洋) | 年审/交还车/故障看全部地区;交还车、故障可按人筛选 |
|
||||
| 2 | 普通运维 | 年审/交还车/故障仅本人所属地区;交还车默认地区合计;故障仅本人数据 |
|
||||
| 3 | 前置 | 原型种子见 `data/ops-cockpit.ts`;车辆状态见 `utils/ops-vehicle-status.ts`,**未接真实 API** |
|
||||
|
||||
**实现**:`components/OpsCockpit.tsx` · `data/ops-cockpit.ts` · `utils/ops-vehicle-status.ts`
|
||||
|
||||
### 全车型各省市(历史表,非业管默认)
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 聚合键 | `品牌 · 型号` + `location`(空或 `-` →「未知」) |
|
||||
| 2 | 总数 | 该键下车辆台数 |
|
||||
| 3 | 租赁数 | `operateStatus === '租赁'`(与车辆管理 KPI「租赁车辆」一致) |
|
||||
| 4 | 库存数 | `operateStatus` 为「可运营」或「待运营」(与 KPI「库存车辆」一致) |
|
||||
| 5 | 排序 | 总数降序 → 车型 → 省市 |
|
||||
|
||||
**数据源**:`vehicle-management/data/vehicles.json` → `utils/fleet-region-stats.ts`,**未接真实 API**。
|
||||
|
||||
### 异常未闭环业务(`kind: pulse`,非业管默认;判定顺序即展示顺序)
|
||||
|
||||
| 顺序 | id | 标签 | 主值 / 次值 | tone |
|
||||
|------|-----|------|-------------|------|
|
||||
| 1 | `pickup-recv` | 提车应收款 | 笔数 / 金额 | danger |
|
||||
| 2 | `lease-bill` | 租赁账单 | 笔数 / 金额 | warn |
|
||||
| 3 | `return-settle` | 还车应结款 | 笔数 / 金额 | danger |
|
||||
| 4 | `h2-prepay` | 预充值氢费 | 笔数 / 金额 | warn |
|
||||
| 5 | `station-stmt` | 加氢站对账单 | 笔数 / 金额 | warn |
|
||||
| 6 | `h2-month` | 氢费月账单 | 笔数 / 金额 | danger |
|
||||
|
||||
**数据源**:本地种子(若块配置 `pulse`),**未接真实 API**。
|
||||
|
||||
## 车辆资产使用(`fleet-share`,非业管默认)
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 块 `kind === 'fleet-share'` | 不渲染种子表;复用 `OnLeaseFleetSharePanel` |
|
||||
| 2 | 在租车辆数 > 0 | 内联「在租车型占比」仪表盘 |
|
||||
| 3 | 在租车辆数 = 0 | 文案「暂无在租车辆,无法查看车型占比」 |
|
||||
|
||||
**数据源**:`vehicle-management/utils/onLeaseFleet.ts`(租赁合同列表种子,**未接真实 API**)。
|
||||
|
||||
## 角色与内容块
|
||||
|
||||
| 角色 | 副标题语境 | 内容块 |
|
||||
|------|------------|--------|
|
||||
| 业务部(业务员) | 我的业务看板 | 合同/回款指标;合同里程完成表 |
|
||||
| 业务管理部 | 业管运营看板 | **三列卡片**:各项目交车情况(交车/租赁总数仪表);车辆业务情况(已交/总库存 + 品牌型号仪表);各省市分布(租赁/总车仪表) |
|
||||
| 能源组 | 能源氢费看板 | 月付款客户;预付款客户;加氢站对账/余额 |
|
||||
| 运维部 | 运维执行看板 | **四列卡片**(`OpsCockpit`):近 3 月年审执行率;车辆状态(租赁/在库,口径同业管);交还车任务(默认地区总量,主管可按人筛选);故障登记(总数/闭环/闭环率) |
|
||||
| 采购部 | 采购保险看板 | 保险有效天数;未交车未停保表 |
|
||||
| 财务部 | 财务经营看板 | 租赁/物流盈亏;回款;财务评级 |
|
||||
| 安全部 | 安全合规看板 | 违章/事故;安全评级 |
|
||||
| 法务部 | 法务风险看板 | 法务评级;合同法律风险提醒 |
|
||||
| 总经理 | 经营总览 | 跨部门摘要指标 |
|
||||
|
||||
## 用户可见结果
|
||||
|
||||
- 标题恒为「运营看板」;多角色用户可在看板工具条右侧横排切换本人角色(`shortLabel`)
|
||||
- 业管:三列筛选总览仪表盘(客户合同交车 / 车型 / 省·省市);**自定义下拉**筛选;Bullet KPI 两位小数占比 + 双 KPI;看板不滚动
|
||||
- 运维:四列卡片(年审执行率 / 车辆状态 / 交还车 / 故障闭环);主管可看全部并按人筛选,普通运维仅本地区/本人
|
||||
- 其他角色:Tab 切换多内容块;环形仪表带 good/warn/danger
|
||||
- 数值始终可见;尊重 `prefers-reduced-motion`
|
||||
- 全部为演示种子,可与正式报表口径不一致
|
||||
|
||||
## 代码路径
|
||||
|
||||
- UI:`components/RoleInsightsPanel.tsx` · 业管 `components/BizAdminCockpit.tsx` · 运维 `components/OpsCockpit.tsx`
|
||||
- 数据:`data/role-insights.ts` → `insightsForRole` / `layout`
|
||||
- 多角色映射:`data/roles.ts` → `OPERATOR_ROLE_IDS` / `roleIdsForOperator`(演示:王冕 → 业管 + 总经理 + 运维)
|
||||
- 资产使用:`vehicle-management/components/OnLeaseFleetSection.tsx` → `OnLeaseFleetSharePanel`
|
||||
- 全车型省市:`utils/fleet-region-stats.ts` ← `vehicle-management/data/vehicles.json`
|
||||
- 客户交车:`utils/fleet-contract-delivery.ts` ← `lease-contract-list-data.js`
|
||||
- 品牌型号:`utils/fleet-brand-model-stats.ts` ← `vehicles.json`
|
||||
- 省市分布:`utils/fleet-region-dist.ts` ← `vehicles.json`(省 / 省市粒度)
|
||||
- 运维看板:`data/ops-cockpit.ts` · `utils/ops-vehicle-status.ts` ← `vehicles.json`
|
||||
@@ -0,0 +1,65 @@
|
||||
# 角色可见性
|
||||
|
||||
## 角色字典
|
||||
|
||||
| key | 展示名 | KPI 源 | 催办权限 |
|
||||
|-----|--------|--------|----------|
|
||||
| `bizAdmin` | 业务管理部 | 租赁账单/提车应收/还车/氢费/**收款未关联**/提车应收未关联/还车应结未关联/租赁账单未关联 | canUrge;本部门 · lease/self |
|
||||
| `bizEnergy` | 业务管理部-能源组 | 加氢订单/导入/还车能源段 | canUrge;本部门 · energy |
|
||||
| `bizSales` | 业务部 | 合同跟进/客户风险/签约缺口/**收款未关联** | canUrge;本部门 · lease/self |
|
||||
| `ops` | 运维部 | 交还车/年审/保养 | canUrge;本部门 · ops/lease |
|
||||
| `procurement` | 采购部 | 保险/交车相关 | **无催办**(canUrge=false) |
|
||||
| `safety` | 安全部 | 还车安全段/违章事故 | canUrge;本部门 · safety |
|
||||
| `finance` | 财务部 | 超期未收/充值确认 | canUrge;本部门 · lease/energy |
|
||||
| `legal` | 法务部 | 超期未审/证照风险 | canUrge;本部门 · lease/self |
|
||||
| `gm` | 总经理 | 专属汇总卡(跨部门高风险 / 条线健康 / 闲置),等分铺满 | canUrge;高风险 · 全条线 |
|
||||
|
||||
## 过滤规则(优先级)
|
||||
|
||||
| 顺序 | 模块 | 规则 |
|
||||
|------|------|------|
|
||||
| 1 | KPI | 按登录人角色集合过滤:单角色时 `roleIds` 含该角色(`gm` 仅专属汇总卡);多角色时取各角色 KPI **并集**(`kpisForRoles`) |
|
||||
| 2 | 待办 | `roleIds` 含当前角色 |
|
||||
| 3 | 通知 | `roleIds` 含当前角色 |
|
||||
| 4 | 快捷入口 · 快速发起 | `QUICK_BY_ROLE[roleId]` 中 `kind === 'action'`,按角色固定展示 |
|
||||
| 5 | 快捷入口 · 功能模块 | 候选池 = 该角色 `kind === 'link'`(即角色有权限的模块);用户可自定义勾选记忆,见下表 |
|
||||
| 6 | 催办按钮 | `roleCanUrgeTask`:角色 `canUrge` + 条线/部门或高风险范围 + 任务 `managerCanUrge`(见 [urge-channels.md](./urge-channels.md)) |
|
||||
|
||||
### 功能模块自定义(判定顺序)
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 无 localStorage 或保存为空 | 展示该角色全部 `link` 模块(默认) |
|
||||
| 2 | 有保存 id 列表 | 仅保留仍属于当前角色候选池的 id(权限变更后剔除失效项) |
|
||||
| 3 | 过滤后为空 | 回退该角色全部 `link` 模块 |
|
||||
| 4 | 用户点「恢复默认」 | 清除该角色自定义,回退全部 |
|
||||
|
||||
- 存储 Key:`oneos-wb-new-quick-links-v1`(按 `roleId`)
|
||||
- 前置:仅原型本地;**未接真实权限 API**;候选池本身已是角色权限边界
|
||||
- 用户可见:功能模块标题旁「自定义」;遮罩打开时面板/遮罩挂到 `document.body`(z-index ≥ 200),避免被「我的待办」叠层盖住
|
||||
|
||||
> 原「流转」过滤规则已随任务流转模块下线移除,见 [task-flow.md](./task-flow.md)。
|
||||
|
||||
## 预警指标与多角色
|
||||
|
||||
| 顺序 | 判定 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 登录人在 `OPERATOR_ROLE_IDS` 有映射且长度 ≥ 2 | `kpisForRoles`:KPI 的 `roleIds` 与用户角色集合有交集即展示(并集,去重) |
|
||||
| 2 | 仅单角色 / 无映射 | 回退当前演示角色;`gm` 仍只出专属汇总卡 |
|
||||
| 3 | 前置 | 未接真实权限 API;演示种子见 `data/roles.ts` |
|
||||
|
||||
用户可见:标题旁帮助图标悬停说明「按角色自动配置;多角色合并全部预警」。
|
||||
|
||||
## 数据源
|
||||
|
||||
- `data/roles.ts` / `kpi.ts` / `tasks.ts` / `notices.ts` / `quick-actions.ts`
|
||||
- 角色记忆:`localStorage oneos-wb-new-role-v1`
|
||||
- 功能模块自定义:`localStorage oneos-wb-new-quick-links-v1`
|
||||
- 多角色演示:`OPERATOR_ROLE_IDS`(王冕 → `bizAdmin` + `gm` + `ops`)
|
||||
- 登录人解析:`sessionOperatorForRole(roleId)` — 兼岗角色切换时登录人不变(王冕切运维仍为王冕);顶栏「模拟」切到无映射岗位时才换演示人
|
||||
|
||||
## 与旧工作台差异
|
||||
|
||||
- 新增「业务部」「总经理」
|
||||
- 「业管-能源部」更名为「业务管理部-能源组」
|
||||
- 视觉与信息架构全新,旧页 `oneos-web-workbench` 保留对照
|
||||
@@ -0,0 +1,40 @@
|
||||
# 执行埋点与绩效
|
||||
|
||||
## 事件类型
|
||||
|
||||
| type | 触发 | 用途 |
|
||||
|------|------|------|
|
||||
| `task_viewed` | 再次打开详情 | 浏览次数 |
|
||||
| `task_opened` | 首次打开/去处理 | 开始计时 |
|
||||
| `task_completed` | 标记完成 | 完成数、时长 |
|
||||
| `urge_sent` | 催办确认 | 催办次数 |
|
||||
| `quick_action` | 快速入口点击 | 发起频次 |
|
||||
| `notify_clicked` | 通知去处理 | 触达转化 |
|
||||
|
||||
## 指标口径
|
||||
|
||||
| 指标 | 公式 |
|
||||
|------|------|
|
||||
| 平均执行时长 | `task_completed.meta.durationMin` 均值;时长 = 完成时刻 − 首次打开(无打开则演示默认 30 分) |
|
||||
| 完成率 | `完成数 / (完成数 + 当前未完成任务数)` |
|
||||
| 催办次数 | `urge_sent` 计数 |
|
||||
|
||||
## 存储
|
||||
|
||||
- Key:`oneos-wb-new-analytics-v1`(localStorage)
|
||||
- 种子:`data/analytics.ts`
|
||||
- 工具:`utils/analytics-store.ts`
|
||||
|
||||
## 看板过滤
|
||||
|
||||
- 总经理:全部门对比表
|
||||
- 其他角色:仅本部门行 + 全局汇总卡
|
||||
|
||||
## 说明
|
||||
|
||||
原型本地埋点,用于演示绩效评估思路;**非生产 SDK**。
|
||||
|
||||
## 主区状态(as-built)
|
||||
|
||||
`AnalyticsBoard` **未挂载**到工作台主区,不作为页面验收项。
|
||||
当前主路径实际写入的事件以代码调用为准,主要为 `task_opened` / `task_viewed` / `urge_sent`。
|
||||
32
src/prototypes/oneos-web-workbench-new/.spec/task-flow.md
Normal file
32
src/prototypes/oneos-web-workbench-new/.spec/task-flow.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# 任务流转(已下线)
|
||||
|
||||
> **状态**:工作台主区「任务流转」模块已去除,不再渲染 `TaskFlowBoard`。
|
||||
> 待办办理、催办、详情抽屉仍保留;本文件仅作历史规格归档。
|
||||
|
||||
## 原状态机(归档)
|
||||
|
||||
| 状态 | 含义 | 泳道 |
|
||||
|------|------|------|
|
||||
| `pending` | 待接收 | 待接收 |
|
||||
| `processing` | 处理中 | 处理中 |
|
||||
| `confirm` | 待确认 | 待确认 |
|
||||
| `overdue` | 已超期(SLA 剩余小时为负且未完成) | 已超期 |
|
||||
| `done` | 已完成 | 已完成 |
|
||||
|
||||
## 可见范围(归档)
|
||||
|
||||
| 角色 | 流转列表 |
|
||||
|------|----------|
|
||||
| 执行部门角色 | 本角色 `roleIds` 包含 或 `dept` 匹配 |
|
||||
| 总经理 | 高风险任务 + 含 `gm` 的任务 |
|
||||
|
||||
## 数据源
|
||||
|
||||
本地种子 `data/tasks.ts` 仍保留 `flow` 字段与 `flowTasksForRole`(供待办/详情复用);**主区流转看板未接真实工单 API,且已下线**。
|
||||
|
||||
## 代码路径(历史)
|
||||
|
||||
- ~~`components/TaskFlowBoard.tsx`~~(已不再挂载)
|
||||
- `components/TaskDetailDrawer.tsx`
|
||||
- `components/TodoPanel.tsx`
|
||||
- `data/tasks.ts`
|
||||
110
src/prototypes/oneos-web-workbench-new/.spec/todo-biz-admin.md
Normal file
110
src/prototypes/oneos-web-workbench-new/.spec/todo-biz-admin.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# 我的待办 · 业务管理部条线规则
|
||||
|
||||
> 实现:`data/todo-lines.ts` · `data/tasks.ts` · `components/TodoPanel.tsx` · `components/TodoLineTable.tsx`
|
||||
> 数据:本地种子,**未接真实 API**
|
||||
|
||||
## 1. 交互结构
|
||||
|
||||
- 「我的待办」标题**右上角**为筛选控件(非条线标签徽标):紧急程度、业务类型均为自定义下拉;业务类型支持搜索多选,紧急程度为单选且无搜索。
|
||||
- **全部紧急程度**:可切换「全部 / 正常 / 逾期」,仅显示对应紧急程度待办;下拉面板右侧与触发器右侧对齐。
|
||||
- **全部业务类型**:默认未选(等价全部);可多选业务类型,仅显示所选类型并集;仅选中 **1** 条线时列表列按脑图字段渲染,未选或选中多条时用紧凑通用列;下拉面板右侧与触发器右侧对齐。
|
||||
|
||||
### 1.1 紧急程度判定(优先级)
|
||||
|
||||
| 顺序 | 条件 | 紧急程度 |
|
||||
|------|------|----------|
|
||||
| 1 | `flowStatus === 'overdue'` 或 `slaHoursLeft < 0` | 逾期 |
|
||||
| 2 | 其余未完成待办 | 正常 |
|
||||
|
||||
前置:仅对 `isTodoIncomplete` 为真的任务参与筛选;角色切换后紧急程度复位为「全部」、业务类型清空(全部)。
|
||||
|
||||
### 1.2 业务类型筛选(判定顺序)
|
||||
|
||||
| 顺序 | 条件 | 结果 |
|
||||
|------|------|------|
|
||||
| 1 | 已选业务类型集合为空 | 不过滤条线(全部) |
|
||||
| 2 | 已选 ≥ 1 项 | 仅保留 `resolveTaskTodoLine(task)` ∈ 已选集 |
|
||||
| 3 | 已选恰好 1 项 | `TodoLineTable` 用该条线专用列 |
|
||||
| 4 | 未选或已选 ≥ 2 项 | `TodoLineTable` 用紧凑通用列 |
|
||||
|
||||
交互:触发器展示摘要(1–2 项显示名称,≥3 显示「已选 N 项」);下拉内可输入关键词模糊过滤选项标签;勾选多选,可一键清空。
|
||||
|
||||
### 1.3 业务类型选项
|
||||
|
||||
选项集合来自 `ROLE_TODO_LINES[roleId]`。业务管理部五条线顺序:
|
||||
|
||||
| 标签 | todoLine |
|
||||
|------|----------|
|
||||
| 租赁合同 | `lease-contract` |
|
||||
| 提车应收款 | `pickup-receivable` |
|
||||
| 租赁账单 | `lease-bill` |
|
||||
| 还车应结款(部门) | `return-settle-dept` |
|
||||
| 还车应结款 | `return-settle-final` |
|
||||
|
||||
其它角色暂按平台条线(租赁/能源/运维等)展示通用列表;后续可按各自脑图扩展。
|
||||
|
||||
代码路径:`components/TodoUrgencySelect.tsx` · `components/TodoLineMultiSelect.tsx` · `components/TodoPanel.tsx`
|
||||
|
||||
## 2. 生成与闭环(判定顺序)
|
||||
|
||||
### 2.1 租赁合同
|
||||
|
||||
| 步骤 | 规则 |
|
||||
|------|------|
|
||||
| 生成 | 审批中心产生待审合同后,同步进入「我的待办」 |
|
||||
| 展示 | 项目名称(可点开合同查询卡)、甲方、乙方、发起人、合同类型(标准/非标) |
|
||||
| 闭环 | 审批完成(`flowStatus = done`)后移出未完成 |
|
||||
|
||||
### 2.2 提车应收款
|
||||
|
||||
| 步骤 | 规则 |
|
||||
|------|------|
|
||||
| 生成 | 提车应收款单据创建后自动生成待办 |
|
||||
| 关联 | 关联收款记录;实收累加 |
|
||||
| 闭环 | **实收金额 ≥ 应收金额** 时关闭;未收齐则一直保留 |
|
||||
| 展示 | 项目名称(可点开查询卡)、交车时间、收款截止日(= 交车日前 2 天,旁注剩余/超期天数)、应收/实收/未收 |
|
||||
|
||||
### 2.3 租赁账单
|
||||
|
||||
| 步骤 | 规则 |
|
||||
|------|------|
|
||||
| 生成 | 租赁账单创建后自动生成待办 |
|
||||
| 闭环 | **实收金额 ≥ 账单应收** 时关闭;部分收款仍挂起 |
|
||||
| 展示 | 项目名称、账单生成日、收款截止日、账单周期、应收/实收/未收 |
|
||||
|
||||
### 2.4 还车应结款(部门)
|
||||
|
||||
| 步骤 | 规则 |
|
||||
|------|------|
|
||||
| 生成 | 还车成功并生成应结款后,若**业务管理部尚未提交**本部门结算则进入待办 |
|
||||
| 闭环 | 业务管理部提交完成后关闭 |
|
||||
| 展示 | 项目名称、车牌号(可点开还车记录)、还车时间、最晚提交时间 |
|
||||
|
||||
### 2.5 还车应结款(最终)
|
||||
|
||||
| 步骤 | 规则 |
|
||||
|------|------|
|
||||
| 生成 | 还车成功后,**业管 / 能源组 / 运维 / 安全**四部门均已提交结算后才生成 |
|
||||
| 展示 | 项目名称、车牌号(可点开结算查询卡)、四部门结算金额、最终结算(**应补 ¥x** 或 **应退 ¥x**) |
|
||||
| 闭环 | 最终结算确认完成(`flowStatus = done`)后移出 |
|
||||
|
||||
## 3. 未完成徽标口径(`isTodoIncomplete`)
|
||||
|
||||
优先级(命中即返回):
|
||||
|
||||
1. `flowStatus === done` → 不算未完成
|
||||
2. `pickup-receivable` / `lease-bill` → `received < receivable` 才算未完成
|
||||
3. `return-settle-dept` → `submitted === false` 才算未完成
|
||||
4. 其它带 `todoDetail` 的条线 → 未 done 即未完成
|
||||
5. 无 `todoDetail` 的兜底任务 → 未 done 即未完成
|
||||
|
||||
## 4. 角色可见性
|
||||
|
||||
- 待办列表仍按 `tasks.roleIds` 过滤。
|
||||
- 业务类型多选选项来自 `ROLE_TODO_LINES[roleId]`。
|
||||
- 总经理可看到业管五条线及其它高风险相关条线(种子中带 `gm` 的任务)。
|
||||
|
||||
## 5. 原型边界
|
||||
|
||||
- 生成/关联收款/四部门提交等为**种子状态演示**,点击「去处理」以跳转目标原型为主,不模拟完整收款流水。
|
||||
- 收款截止「剩余/超期」按浏览器本地日期相对 `dueAt` 计算。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user