新增 AutoRDO 需求清洗工作台与消息中枢,迭代 OneOS V2 设计规范及租赁合同/工作台/车辆等原型,同步云效技能与导航注册;并归档一批 legacy 原型快照。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
王冕
2026-07-28 15:50:35 +08:00
parent b14425da5a
commit 5d51a6bf7a
1393 changed files with 488042 additions and 10046 deletions

View File

@@ -1,6 +1,5 @@
import React, { useMemo, useState } from 'react';
import { Dropdown } from 'antd';
import type { MenuProps } from 'antd';
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
ArrowRightLeft,
Car,
@@ -17,6 +16,7 @@ import {
Undo2,
Upload,
Users,
Wrench,
type LucideIcon,
} from 'lucide-react';
@@ -46,18 +46,21 @@ const MORE_ACTION_ICONS: Record<string, LucideIcon> = {
uploadStamped: Upload,
manage: Settings2,
preview: Eye,
view: Eye,
viewRecords: Eye,
history: FileText,
download: Download,
failDetail: FileText,
process: Wrench,
};
function renderMenuItemLabel(item: OperationActionItem) {
const Icon = item.icon ?? MORE_ACTION_ICONS[item.key];
return (
<span
className={[
'vm-op-menu-item',
item.danger ? 'vm-op-menu-item--danger' : '',
].filter(Boolean).join(' ')}
className={['vm-op-menu-item', item.danger ? 'vm-op-menu-item--danger' : '']
.filter(Boolean)
.join(' ')}
>
{Icon ? (
<Icon className="vm-op-menu-item__icon" aria-hidden strokeWidth={1.75} />
@@ -77,133 +80,267 @@ export type OperationPrimaryAction = {
};
export type OperationActionsProps = {
/** 详情:固定外显主操作 */
view?: OperationPrimaryAction;
/**
* @deprecated 编辑已收入「更多」下拉,请放入 more 数组
* 编辑:常用操作,固定外显(与 process 合计最多 2 个)。
*/
edit?: OperationPrimaryAction;
/** 其余操作(含编辑)收入「更多」下拉 */
/**
* 处理 / 处置:常用操作,固定外显。
*/
process?: OperationPrimaryAction;
/**
* 查看 / 详情 / 查看记录:低频,默认收入「更多」。
* 仅当无 edit/process 可外显时,作为唯一主操作外显(只读列表兼容)。
*/
view?: OperationPrimaryAction;
/** 其余低频 / 危险操作收入「更多」下拉 */
more?: OperationActionItem[];
/** 更多按钮文案,默认「更多」 */
/** 更多按钮文案(仅 aria,默认「更多」 */
moreLabel?: string;
className?: string;
annotationId?: string;
};
function ViewIcon() {
return <Eye className="vm-op-action__icon" aria-hidden />;
}
function MoreIcon() {
return <MoreHorizontal className="vm-op-more-btn__icon" aria-hidden strokeWidth={1.75} />;
}
type PrimaryKind = 'edit' | 'process' | 'view';
const PrimaryActionButton = React.forwardRef<
HTMLButtonElement,
{
icon: 'view' | 'more';
kind: PrimaryKind | 'more';
label: string;
onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
disabled?: boolean;
ariaLabel: string;
ariaHasPopup?: boolean;
ariaExpanded?: boolean;
className?: string;
}
>(function PrimaryActionButton(
{
icon,
label,
onClick,
disabled,
ariaLabel,
ariaHasPopup,
className,
},
{ kind, label, onClick, disabled, ariaLabel, ariaHasPopup, ariaExpanded, className },
ref,
) {
const isMore = icon === 'more';
const isMore = kind === 'more';
const Icon =
kind === 'edit' ? Pencil : kind === 'process' ? Wrench : kind === 'view' ? Eye : MoreHorizontal;
return (
<button
ref={ref}
type="button"
className={[
isMore ? 'vm-op-more-btn' : 'vm-op-action',
className,
].filter(Boolean).join(' ')}
className={[isMore ? 'vm-op-more-btn' : 'vm-op-action', className].filter(Boolean).join(' ')}
aria-label={ariaLabel}
aria-haspopup={ariaHasPopup ? 'menu' : undefined}
aria-expanded={ariaExpanded}
disabled={disabled}
onClick={onClick}
>
{isMore ? <MoreIcon /> : <ViewIcon />}
{isMore ? null : <span className="vm-op-action__label">{label}</span>}
{isMore ? (
<MoreHorizontal className="vm-op-more-btn__icon" aria-hidden strokeWidth={1.75} />
) : (
<>
<Icon className="vm-op-action__icon" aria-hidden strokeWidth={1.75} />
<span className="vm-op-action__label">{label}</span>
</>
)}
</button>
);
});
function mergeMoreItems(
more: OperationActionItem[],
edit?: OperationPrimaryAction,
): OperationActionItem[] {
const visibleMore = more.filter((item) => !item.hidden);
if (!edit || edit.hidden) return visibleMore;
const hasEdit = visibleMore.some((item) => item.key === 'edit' || item.label === '编辑');
if (hasEdit) return visibleMore;
return [
{
key: 'edit',
label: edit.label || '编辑',
onClick: edit.onClick,
disabled: edit.disabled,
},
...visibleMore,
];
function toMoreItem(
key: string,
primary: OperationPrimaryAction,
defaultLabel: string,
): OperationActionItem {
return {
key,
label: primary.label || defaultLabel,
onClick: primary.onClick,
disabled: primary.disabled,
};
}
type MenuEntry =
| { type: 'divider'; key: string }
| { type: 'item'; item: OperationActionItem };
/**
* 列表操作列(强制):常用「编辑 / 处理」外显,低频「查看记录」等进更多。
* 不依赖 antd保证 oneos-v2 等轻量工作区可加载。
*/
export function OperationActions({
view,
edit,
process,
view,
more = [],
moreLabel = '更多',
className,
annotationId,
}: OperationActionsProps) {
const [moreOpen, setMoreOpen] = useState(false);
const visibleMore = useMemo(
() => mergeMoreItems(more, edit),
[more, edit],
);
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
const moreBtnRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const menuItems: MenuProps['items'] = useMemo(() => {
const items: MenuProps['items'] = [];
visibleMore.forEach((item, index) => {
if (item.danger && index > 0 && !visibleMore[index - 1]?.danger) {
items.push({ type: 'divider' });
}
items.push({
key: item.key,
label: renderMenuItemLabel(item),
danger: item.danger,
disabled: item.disabled,
className: item.danger ? 'vm-op-dropdown-menu-item--danger' : undefined,
const visibleEdit = edit && !edit.hidden ? edit : undefined;
const visibleProcess = process && !process.hidden ? process : undefined;
const visibleView = view && !view.hidden ? view : undefined;
const hasWorkPrimary = !!(visibleEdit || visibleProcess);
/** 有编辑/处理时:查看收入更多;仅查看时:查看外显 */
const viewInMore = !!(visibleView && hasWorkPrimary);
const viewOutside = !!(visibleView && !hasWorkPrimary);
const outsidePrimaries = useMemo(() => {
const list: Array<{ kind: PrimaryKind; action: OperationPrimaryAction; defaultLabel: string }> =
[];
if (visibleEdit) list.push({ kind: 'edit', action: visibleEdit, defaultLabel: '编辑' });
if (visibleProcess) {
list.push({
kind: 'process',
action: visibleProcess,
defaultLabel: visibleProcess.label || '处理',
});
}
if (viewOutside && visibleView) {
list.push({
kind: 'view',
action: visibleView,
defaultLabel: visibleView.label || '详情',
});
}
return list.slice(0, 2);
}, [visibleEdit, visibleProcess, viewOutside, visibleView]);
const visibleMore = useMemo(() => {
const items: OperationActionItem[] = [];
if (viewInMore && visibleView) {
items.push(
toMoreItem('view', visibleView, visibleView.label || '查看记录'),
);
}
more.forEach((item) => {
if (item.hidden) return;
if (visibleEdit && (item.key === 'edit' || item.label === '编辑' || item.label === '编辑合同')) {
return;
}
if (
visibleProcess &&
(item.key === 'process' ||
item.label === '处理' ||
item.label === '处置' ||
item.label === (visibleProcess.label || ''))
) {
return;
}
if (viewInMore && (item.key === 'view' || item.key === 'viewRecords')) return;
items.push(item);
});
return items;
}, [more, viewInMore, visibleView, visibleEdit, visibleProcess]);
const menuEntries = useMemo(() => {
const entries: MenuEntry[] = [];
visibleMore.forEach((item, index) => {
if (item.danger && index > 0 && !visibleMore[index - 1]?.danger) {
entries.push({ type: 'divider', key: `divider-${item.key}` });
}
entries.push({ type: 'item', item });
});
return entries;
}, [visibleMore]);
const showView = !!(view && !view.hidden);
const showMore = visibleMore.length > 0;
if (!showView && !showMore) {
useLayoutEffect(() => {
if (!moreOpen || !moreBtnRef.current) {
setMenuPos(null);
return;
}
const rect = moreBtnRef.current.getBoundingClientRect();
const menuWidth = 176;
const left = Math.min(
Math.max(8, rect.right - menuWidth),
window.innerWidth - menuWidth - 8,
);
setMenuPos({ top: rect.bottom + 4, left });
}, [moreOpen]);
useEffect(() => {
if (!moreOpen) return;
const onPointerDown = (event: MouseEvent | TouchEvent) => {
const target = event.target as Node;
if (moreBtnRef.current?.contains(target)) return;
if (menuRef.current?.contains(target)) return;
setMoreOpen(false);
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setMoreOpen(false);
};
const onScroll = () => setMoreOpen(false);
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('touchstart', onPointerDown);
document.addEventListener('keydown', onKeyDown);
window.addEventListener('scroll', onScroll, true);
window.addEventListener('resize', onScroll);
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('touchstart', onPointerDown);
document.removeEventListener('keydown', onKeyDown);
window.removeEventListener('scroll', onScroll, true);
window.removeEventListener('resize', onScroll);
};
}, [moreOpen]);
if (outsidePrimaries.length === 0 && !showMore) {
return <span className="vm-operation-actions vm-operation-actions--empty">-</span>;
}
const handleMenuClick: MenuProps['onClick'] = ({ key, domEvent }) => {
domEvent.stopPropagation();
const target = visibleMore.find((item) => item.key === key);
if (target && !target.disabled) target.onClick();
};
const menu =
moreOpen && menuPos && typeof document !== 'undefined'
? createPortal(
<div
ref={menuRef}
className="vm-op-dropdown"
style={{ top: menuPos.top, left: menuPos.left }}
role="presentation"
>
<div className="vm-op-dropdown-menu" role="menu" aria-label="更多操作">
{menuEntries.map((entry) => {
if (entry.type === 'divider') {
return <div key={entry.key} className="vm-op-dropdown-divider" role="separator" />;
}
const { item } = entry;
return (
<button
key={item.key}
type="button"
role="menuitem"
className={[
'vm-op-dropdown-item',
item.danger ? 'vm-op-dropdown-item--danger' : '',
item.disabled ? 'vm-op-dropdown-item--disabled' : '',
]
.filter(Boolean)
.join(' ')}
disabled={item.disabled}
onClick={(event) => {
event.stopPropagation();
if (item.disabled) return;
setMoreOpen(false);
item.onClick();
}}
>
{renderMenuItemLabel(item)}
</button>
);
})}
</div>
</div>,
document.body,
)
: null;
return (
<div
@@ -211,61 +348,97 @@ export function OperationActions({
data-annotation-id={annotationId}
onClick={(event) => event.stopPropagation()}
>
{showView ? (
{outsidePrimaries.map(({ kind, action, defaultLabel }) => (
<PrimaryActionButton
icon="view"
label={view?.label || '详情'}
ariaLabel={view?.label || '详情'}
disabled={view?.disabled}
key={kind}
kind={kind}
label={action.label || defaultLabel}
ariaLabel={action.label || defaultLabel}
disabled={action.disabled}
onClick={(event) => {
event.stopPropagation();
if (!view?.disabled) view!.onClick();
if (!action.disabled) action.onClick();
}}
/>
) : null}
))}
{showMore ? (
<Dropdown
menu={{
items: menuItems,
onClick: handleMenuClick,
className: 'vm-op-dropdown-menu',
}}
trigger={['click']}
placement="bottomRight"
getPopupContainer={() => document.body}
overlayClassName="vm-op-dropdown"
open={moreOpen}
onOpenChange={setMoreOpen}
align={{ offset: [0, 4] }}
>
<>
<PrimaryActionButton
icon="more"
ref={moreBtnRef}
kind="more"
label={moreLabel}
ariaLabel="更多操作"
ariaHasPopup
ariaExpanded={moreOpen}
className={moreOpen ? 'vm-op-more-btn--open' : undefined}
onClick={(event) => {
event.stopPropagation();
setMoreOpen((open) => !open);
}}
/>
</Dropdown>
{menu}
</>
) : null}
</div>
);
}
const VIEW_LABELS = new Set(['详情', '查看', '查看详情', '查看记录', '操作记录']);
const EDIT_LABELS = new Set(['编辑', '编辑合同']);
const PROCESS_LABELS = new Set(['处理', '处置', '还车', '办理']);
/**
* 从扁平操作列表拆分出 view / more编辑收入 more
* 从扁平操作列表拆分:编辑/处理外显,查看记录等进更多
*/
export function splitOperationActions(items: OperationActionItem[]): {
edit?: OperationPrimaryAction;
process?: OperationPrimaryAction;
view?: OperationPrimaryAction;
more: OperationActionItem[];
} {
const visible = items.filter((item) => !item.hidden);
const viewItem = visible.find((item) => item.label === '详情' || item.label === '查看' || item.key === 'view');
const more = visible.filter((item) => item !== viewItem);
const editItem = visible.find(
(item) => item.key === 'edit' || EDIT_LABELS.has(item.label),
);
const processItem = visible.find(
(item) =>
item.key === 'process' ||
item.key === 'handle' ||
PROCESS_LABELS.has(item.label),
);
const viewItem = visible.find(
(item) =>
item.key === 'view' ||
item.key === 'viewRecords' ||
item.key === 'history' ||
VIEW_LABELS.has(item.label),
);
const more = visible.filter(
(item) => item !== editItem && item !== processItem && item !== viewItem,
);
return {
edit: editItem
? {
onClick: editItem.onClick,
label: editItem.label,
disabled: editItem.disabled,
}
: undefined,
process: processItem
? {
onClick: processItem.onClick,
label: processItem.label,
disabled: processItem.disabled,
}
: undefined,
view: viewItem
? {
onClick: viewItem.onClick,
label: viewItem.label === '查看' ? '详情' : viewItem.label,
label:
viewItem.label === '查看' || viewItem.label === '详情'
? '查看记录'
: viewItem.label,
disabled: viewItem.disabled,
}
: undefined,

View File

@@ -0,0 +1,221 @@
import React, { useEffect, useId, useMemo, useState } from 'react';
import { ChevronDown, ChevronUp, Moon, Sun, SwatchBook } from 'lucide-react';
import {
THEME_VARIANTS,
OFFICIAL_THEME,
applyThemeSkin,
clearThemeSkin,
buildAntThemeConfig,
buildOfficialAntTheme,
getThemeById,
loadThemeLabState,
saveThemeLabState,
type ThemeId,
type ThemeLabState,
type ThemeMode,
} from './themeLab';
import './theme-switcher.css';
export type ThemeLabChange = {
themeId: ThemeId;
mode: ThemeMode;
antTheme: ReturnType<typeof buildOfficialAntTheme>;
};
type Props = {
onChange: (next: ThemeLabChange) => void;
};
function resolveAnt(themeId: ThemeId, mode: ThemeMode) {
if (themeId === 'official') {
return buildOfficialAntTheme();
}
const theme = getThemeById(themeId);
if (!theme) return buildOfficialAntTheme();
return buildAntThemeConfig(theme, mode);
}
function applyDom(themeId: ThemeId, mode: ThemeMode) {
if (themeId === 'official') {
clearThemeSkin();
document.documentElement.dataset.dsMode = 'light';
document.documentElement.dataset.dsTheme = 'official';
return;
}
const theme = getThemeById(themeId);
if (!theme) {
clearThemeSkin();
return;
}
applyThemeSkin(theme, mode);
}
export function ThemeSwitcher({ onChange }: Props) {
const titleId = useId();
const [state, setState] = useState<ThemeLabState>(() =>
typeof window === 'undefined'
? { themeId: 'official', mode: 'light', collapsed: false }
: loadThemeLabState(),
);
const activeTheme = useMemo(
() => (state.themeId === 'official' ? null : getThemeById(state.themeId)),
[state.themeId],
);
const accentPreview =
state.themeId === 'official'
? OFFICIAL_THEME.primary
: state.themeId === 'E-intercom' && state.mode === 'light'
? '#ff5600'
: activeTheme?.accent || OFFICIAL_THEME.primary;
useEffect(() => {
applyDom(state.themeId, state.mode);
onChange({
themeId: state.themeId,
mode: state.mode,
antTheme: resolveAnt(state.themeId, state.mode),
});
saveThemeLabState(state);
}, [state.themeId, state.mode]); // eslint-disable-line react-hooks/exhaustive-deps -- onChange identity ignored
const setTheme = (themeId: ThemeId) => {
setState((prev) => ({
...prev,
themeId,
mode: themeId === 'official' ? 'light' : prev.mode,
}));
};
const setMode = (mode: ThemeMode) => {
setState((prev) => {
if (prev.themeId === 'official' && mode === 'dark') {
return { ...prev, themeId: 'A-linear', mode: 'dark' };
}
return { ...prev, mode };
});
};
if (state.collapsed) {
return (
<div className="ds-theme-lab ds-theme-lab--collapsed" role="region" aria-label="主题实验室">
<button
type="button"
className="ds-theme-lab__fab"
onClick={() => setState((p) => ({ ...p, collapsed: false }))}
aria-expanded={false}
aria-controls={titleId}
>
<SwatchBook size={18} strokeWidth={2} aria-hidden />
<span></span>
<span className="ds-theme-lab__fab-swatch" style={{ background: accentPreview }} aria-hidden />
</button>
</div>
);
}
return (
<div
className="ds-theme-lab"
role="region"
aria-labelledby={titleId}
data-annotation-id="lc-theme-lab"
>
<header className="ds-theme-lab__head">
<div className="ds-theme-lab__title-wrap">
<SwatchBook size={16} strokeWidth={2.25} aria-hidden />
<h2 id={titleId} className="ds-theme-lab__title">
</h2>
<span className="ds-theme-lab__badge">稿</span>
</div>
<button
type="button"
className="ds-theme-lab__icon-btn"
onClick={() => setState((p) => ({ ...p, collapsed: true }))}
aria-label="收起主题实验室"
>
<ChevronDown size={18} aria-hidden />
</button>
</header>
<div className="ds-theme-lab__mode" role="group" aria-label="浅色或暗色">
<button
type="button"
className={`ds-theme-lab__mode-btn${state.mode === 'light' ? ' is-active' : ''}`}
onClick={() => setMode('light')}
aria-pressed={state.mode === 'light'}
>
<Sun size={14} aria-hidden />
</button>
<button
type="button"
className={`ds-theme-lab__mode-btn${state.mode === 'dark' ? ' is-active' : ''}`}
onClick={() => setMode('dark')}
aria-pressed={state.mode === 'dark'}
disabled={state.themeId === 'official'}
title={state.themeId === 'official' ? '官方规范暂仅浅色;切暗色将改用 Linear' : undefined}
>
<Moon size={14} aria-hidden />
</button>
</div>
<div className="ds-theme-lab__themes" role="listbox" aria-label="主题套装">
<button
type="button"
role="option"
aria-selected={state.themeId === 'official'}
className={`ds-theme-lab__theme${state.themeId === 'official' ? ' is-active' : ''}`}
onClick={() => setTheme('official')}
>
<span className="ds-theme-lab__swatch" style={{ background: OFFICIAL_THEME.primary }} />
<span className="ds-theme-lab__theme-meta">
<span className="ds-theme-lab__theme-name"></span>
<span className="ds-theme-lab__theme-cat"></span>
</span>
</button>
{THEME_VARIANTS.map((t) => {
const swatch =
t.id === 'E-intercom' && state.mode === 'light' ? t.ctaAccent || t.accent : t.accent;
return (
<button
key={t.id}
type="button"
role="option"
aria-selected={state.themeId === t.id}
className={`ds-theme-lab__theme${state.themeId === t.id ? ' is-active' : ''}`}
onClick={() => setTheme(t.id as ThemeId)}
title={t.feel}
>
<span className="ds-theme-lab__swatch" style={{ background: swatch }} />
<span className="ds-theme-lab__theme-meta">
<span className="ds-theme-lab__theme-name">{t.name}</span>
<span className="ds-theme-lab__theme-cat">{t.category}</span>
</span>
</button>
);
})}
</div>
<p className="ds-theme-lab__hint">
{state.themeId === 'official'
? 'Linear · 若依默认蓝'
: `${activeTheme?.name || state.themeId} · ${state.mode === 'dark' ? '暗色' : '浅色'}`}
10
</p>
<button
type="button"
className="ds-theme-lab__expand-hint"
onClick={() => setState((p) => ({ ...p, collapsed: true }))}
>
<ChevronUp size={14} aria-hidden />
</button>
</div>
);
}

View File

@@ -0,0 +1,16 @@
export { ThemeSwitcher } from './ThemeSwitcher';
export type { ThemeLabChange } from './ThemeSwitcher';
export {
THEME_VARIANTS,
OFFICIAL_THEME,
THEME_CATALOG,
applyThemeSkin,
clearThemeSkin,
buildSkinCss,
buildAntThemeConfig,
buildOfficialAntTheme,
getThemeById,
loadThemeLabState,
saveThemeLabState,
} from './themeLab';
export type { ThemeId, ThemeMode, ThemeLabState, ThemeVariant } from './themeLab';

View File

@@ -0,0 +1,264 @@
.ds-theme-lab,
.ds-theme-lab--collapsed {
display: none !important;
position: fixed;
left: 20px;
bottom: 20px;
z-index: 9400;
width: min(360px, calc(100vw - 32px));
padding: 12px 14px 10px;
border-radius: 14px;
border: 1px solid rgba(24, 24, 27, 0.12);
background: rgba(255, 255, 255, 0.94);
box-shadow:
0 12px 40px rgba(15, 23, 42, 0.16),
0 0 0 1px rgba(255, 255, 255, 0.4) inset;
backdrop-filter: blur(12px);
color: #18181b;
font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
}
html[data-ds-mode="dark"] .ds-theme-lab {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(24, 24, 27, 0.92);
color: #f4f4f5;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.ds-theme-lab--collapsed {
width: auto;
padding: 0;
border: none;
background: transparent;
box-shadow: none;
backdrop-filter: none;
}
.ds-theme-lab__fab {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 0 14px;
border-radius: 999px;
border: 1px solid rgba(24, 24, 27, 0.12);
background: rgba(255, 255, 255, 0.96);
color: #18181b;
font-size: 13px;
font-weight: 600;
cursor: pointer;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.14);
}
html[data-ds-mode="dark"] .ds-theme-lab__fab {
border-color: rgba(255, 255, 255, 0.14);
background: rgba(24, 24, 27, 0.94);
color: #f4f4f5;
}
.ds-theme-lab__fab:focus-visible,
.ds-theme-lab__icon-btn:focus-visible,
.ds-theme-lab__mode-btn:focus-visible,
.ds-theme-lab__theme:focus-visible {
outline: 2px solid #5e6ad2;
outline-offset: 2px;
}
.ds-theme-lab__fab-swatch {
width: 12px;
height: 12px;
border-radius: 999px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
}
.ds-theme-lab__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 10px;
}
.ds-theme-lab__title-wrap {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.ds-theme-lab__title {
margin: 0;
font-size: 13px;
font-weight: 650;
letter-spacing: -0.01em;
}
.ds-theme-lab__badge {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 999px;
background: rgba(94, 106, 210, 0.12);
color: #5e6ad2;
}
html[data-ds-mode="dark"] .ds-theme-lab__badge {
background: rgba(130, 143, 255, 0.18);
color: #a5b0ff;
}
.ds-theme-lab__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: transparent;
color: inherit;
cursor: pointer;
}
.ds-theme-lab__icon-btn:hover {
background: rgba(24, 24, 27, 0.06);
}
html[data-ds-mode="dark"] .ds-theme-lab__icon-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.ds-theme-lab__mode {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 10px;
}
.ds-theme-lab__mode-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 36px;
border-radius: 8px;
border: 1px solid rgba(24, 24, 27, 0.1);
background: rgba(24, 24, 27, 0.03);
color: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
html[data-ds-mode="dark"] .ds-theme-lab__mode-btn {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.04);
}
.ds-theme-lab__mode-btn.is-active {
border-color: transparent;
background: #18181b;
color: #fff;
}
html[data-ds-mode="dark"] .ds-theme-lab__mode-btn.is-active {
background: #f4f4f5;
color: #18181b;
}
.ds-theme-lab__mode-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ds-theme-lab__themes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
max-height: 220px;
overflow: auto;
}
.ds-theme-lab__theme {
display: flex;
align-items: center;
gap: 8px;
min-height: 44px;
padding: 6px 8px;
border-radius: 10px;
border: 1px solid rgba(24, 24, 27, 0.1);
background: rgba(24, 24, 27, 0.02);
color: inherit;
text-align: left;
cursor: pointer;
}
html[data-ds-mode="dark"] .ds-theme-lab__theme {
border-color: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
}
.ds-theme-lab__theme.is-active {
border-color: currentColor;
box-shadow: 0 0 0 1px currentColor;
}
.ds-theme-lab__swatch {
flex: 0 0 auto;
width: 16px;
height: 16px;
border-radius: 5px;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.ds-theme-lab__theme-meta {
display: flex;
flex-direction: column;
min-width: 0;
line-height: 1.2;
}
.ds-theme-lab__theme-name {
font-size: 12px;
font-weight: 650;
}
.ds-theme-lab__theme-cat {
font-size: 10px;
opacity: 0.65;
}
.ds-theme-lab__hint {
margin: 10px 0 6px;
font-size: 11px;
line-height: 1.45;
opacity: 0.72;
}
.ds-theme-lab__expand-hint {
display: inline-flex;
align-items: center;
gap: 4px;
width: 100%;
justify-content: center;
min-height: 32px;
border: none;
background: transparent;
color: inherit;
font-size: 11px;
opacity: 0.7;
cursor: pointer;
}
.ds-theme-lab__expand-hint:hover {
opacity: 1;
}
@media (prefers-reduced-motion: reduce) {
.ds-theme-lab,
.ds-theme-lab__fab {
transition: none;
}
}

View File

@@ -0,0 +1,544 @@
import catalog from '../../resources/design-system/theme-variants.json';
export type ThemeMode = 'light' | 'dark';
export type ThemeId = (typeof catalog.themes)[number]['id'] | 'official';
export type ModePalette = {
ink: string;
body: string;
muted: string;
mutedSoft: string;
canvas: string;
surface: string;
soft: string;
hairline: string;
hairlineStrong: string;
controlBg: string;
tagNeutralBg: string;
tagNeutralFg: string;
onPrimary: string;
primaryOverride?: string;
primaryHoverOverride?: string;
primaryFocusOverride?: string;
};
export type ThemeVariant = (typeof catalog.themes)[number];
export const THEME_CATALOG = catalog;
export const OFFICIAL_THEME = catalog.officialDefault;
export const THEME_VARIANTS = catalog.themes as ThemeVariant[];
export const STORAGE_KEY = 'oneos-lc-theme-lab-v1';
export type ThemeLabState = {
themeId: ThemeId;
mode: ThemeMode;
collapsed?: boolean;
};
export function loadThemeLabState(): ThemeLabState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { themeId: 'official', mode: 'light', collapsed: false };
const parsed = JSON.parse(raw) as Partial<ThemeLabState>;
const themeId = (parsed.themeId as ThemeId) || 'official';
const mode = parsed.mode === 'dark' ? 'dark' : 'light';
return {
themeId,
mode,
collapsed: !!parsed.collapsed,
};
} catch {
return { themeId: 'official', mode: 'light', collapsed: false };
}
}
export function saveThemeLabState(state: ThemeLabState) {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
/* ignore */
}
}
export function getThemeById(themeId: string): ThemeVariant | null {
return THEME_VARIANTS.find((t) => t.id === themeId) || null;
}
function soft(hex: string, alpha = 0.12) {
const h = hex.replace('#', '');
const n = parseInt(h.length === 3 ? h.split('').map((c) => c + c).join('') : h, 16);
const r = (n >> 16) & 255;
const g = (n >> 8) & 255;
const b = n & 255;
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}
export function resolvePrimary(theme: ThemeVariant, mode: ThemeMode) {
const palette = theme.modes[mode] as ModePalette;
if (palette.primaryOverride) {
return {
primary: palette.primaryOverride,
primaryHover: palette.primaryHoverOverride || theme.accentHover,
primaryFocus: palette.primaryFocusOverride || theme.accentFocus,
};
}
if (theme.id === 'E-intercom' && mode === 'light' && theme.ctaAccent) {
return {
primary: theme.ctaAccent,
primaryHover: (theme as { ctaAccentHover?: string }).ctaAccentHover || '#fe4c02',
primaryFocus: (theme as { ctaAccentFocus?: string }).ctaAccentFocus || '#e04d00',
};
}
return {
primary: theme.accent,
primaryHover: theme.accentHover,
primaryFocus: theme.accentFocus,
};
}
export function resolvePalette(theme: ThemeVariant, mode: ThemeMode): ModePalette & { isDark: boolean } {
return { ...(theme.modes[mode] as ModePalette), isDark: mode === 'dark' };
}
export function buildOfficialAntTheme() {
const o = OFFICIAL_THEME;
return {
token: {
colorPrimary: o.primary,
colorLink: o.primary,
colorLinkHover: o.primaryHover,
colorSuccess: '#27a644',
colorWarning: '#d97706',
colorError: '#e5484d',
colorInfo: '#2563eb',
borderRadius: 8,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif',
fontSize: 14,
colorText: '#18181b',
colorTextSecondary: '#52525b',
colorBorder: '#e5e7eb',
colorBgContainer: '#ffffff',
colorBgLayout: '#f5f6f6',
},
components: {
Table: {
headerBg: '#f6f7f7',
headerColor: '#71717a',
rowHoverBg: '#f6f7f7',
borderColor: '#e5e7eb',
cellPaddingBlock: 6,
cellPaddingInline: 10,
},
Card: { borderRadiusLG: 12 },
Button: { controlHeight: 36, borderRadius: 8 },
Input: { controlHeight: 32, borderRadius: 8 },
InputNumber: { handleVisible: true },
DatePicker: {
cellActiveWithRangeBg: 'rgba(50, 160, 110, 0.12)',
cellHoverWithRangeBg: 'rgba(50, 160, 110, 0.08)',
cellRangeBorderColor: o.primary,
activeBorderColor: o.primary,
hoverBorderColor: o.primaryHover,
},
},
};
}
export function buildAntThemeConfig(theme: ThemeVariant, mode: ThemeMode) {
const c = resolvePalette(theme, mode);
const { primary, primaryHover } = resolvePrimary(theme, mode);
const radius = parseInt(String(theme.radiusControl || '8').replace('px', ''), 10) || 8;
const radiusCard = parseInt(String(theme.radiusCard || '12').replace('px', ''), 10) || 12;
return {
token: {
colorPrimary: primary,
colorLink: primary,
colorLinkHover: primaryHover,
colorSuccess: '#27a644',
colorWarning: '#d97706',
colorError: '#e5484d',
colorInfo: '#2563eb',
borderRadius: radius,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif',
fontSize: 14,
colorText: c.ink,
colorTextSecondary: c.body,
colorBorder: c.hairline,
colorBgContainer: c.controlBg,
colorBgLayout: c.canvas,
colorBgElevated: c.surface,
},
components: {
Table: {
headerBg: c.soft,
headerColor: c.muted,
rowHoverBg: c.soft,
borderColor: c.hairline,
cellPaddingBlock: 6,
cellPaddingInline: 10,
},
Card: { borderRadiusLG: radiusCard },
Button: { controlHeight: 36, borderRadius: radius },
Input: { controlHeight: 32, borderRadius: radius },
InputNumber: { handleVisible: true },
DatePicker: {
cellActiveWithRangeBg: soft(primary, 0.12),
cellHoverWithRangeBg: soft(primary, 0.08),
cellRangeBorderColor: primary,
activeBorderColor: primary,
hoverBorderColor: primaryHover,
},
Select: {
colorBgContainer: c.controlBg,
optionSelectedBg: c.soft,
},
},
};
}
const STYLE_ID = 'oneos-ds-theme-lab-skin';
export function clearThemeSkin() {
if (typeof document === 'undefined') return;
document.getElementById(STYLE_ID)?.remove();
delete document.documentElement.dataset.dsMode;
delete document.documentElement.dataset.dsTheme;
}
export function applyThemeSkin(theme: ThemeVariant, mode: ThemeMode) {
if (typeof document === 'undefined') return;
document.documentElement.dataset.dsMode = mode;
document.documentElement.dataset.dsTheme = theme.id;
let el = document.getElementById(STYLE_ID) as HTMLStyleElement | null;
if (!el) {
el = document.createElement('style');
el.id = STYLE_ID;
document.head.appendChild(el);
}
el.textContent = buildSkinCss(theme, mode);
}
/** 皮肤 CSS与截图脚本同规则 */
export function buildSkinCss(theme: ThemeVariant, mode: ThemeMode): string {
const isDark = mode === 'dark';
const c = resolvePalette(theme, mode);
const { primary, primaryHover, primaryFocus } = resolvePrimary(theme, mode);
const primarySoft =
theme.accentSoft && !isDark ? theme.accentSoft : soft(primary, isDark ? 0.22 : 0.12);
const root = `html[data-ds-mode="${mode}"]`;
const page = `${root} .vm-page`;
const lc = `${page}.lc-page`;
const antVars = `
--ant-color-primary: ${primary};
--ant-color-primary-hover: ${primaryHover};
--ant-color-bg-container: ${c.controlBg};
--ant-color-bg-container-disabled: ${c.soft};
--ant-color-bg-elevated: ${c.surface};
--ant-color-bg-layout: ${c.canvas};
--ant-color-bg-spotlight: ${c.soft};
--ant-color-bg-mask: rgba(0,0,0,0.55);
--ant-color-text: ${c.ink};
--ant-color-text-secondary: ${c.body};
--ant-color-text-tertiary: ${c.muted};
--ant-color-text-quaternary: ${c.mutedSoft};
--ant-color-text-placeholder: ${c.mutedSoft};
--ant-color-border: ${c.hairline};
--ant-color-border-secondary: ${c.hairlineStrong};
--ant-color-fill: ${c.soft};
--ant-color-fill-secondary: ${c.soft};
--ant-color-fill-tertiary: ${c.soft};
--ant-color-fill-quaternary: ${c.controlBg};
--ant-color-fill-content: ${c.controlBg};
`;
return `
${root} {
color-scheme: ${isDark ? 'dark' : 'light'};
--ds-ink: ${c.ink}; --ds-body: ${c.body}; --ds-muted: ${c.muted}; --ds-muted-soft: ${c.mutedSoft};
--ds-canvas: ${c.canvas}; --ds-surface: ${c.surface}; --ds-soft: ${c.soft};
--ds-hairline: ${c.hairline}; --ds-hairline-strong: ${c.hairlineStrong};
--ds-control-bg: ${c.controlBg}; --ds-primary: ${primary}; --ds-primary-hover: ${primaryHover};
--ds-on-primary: ${c.onPrimary}; --ds-tag-neutral-bg: ${c.tagNeutralBg}; --ds-tag-neutral-fg: ${c.tagNeutralFg};
${antVars}
background: ${c.canvas} !important;
}
${root} body, ${root} #root, ${root} .ant-app, body, #root, .ant-app, .vm-page, .lc-page {
${antVars}
background-color: ${c.canvas} !important;
color: ${c.body} !important;
}
.vm-filter-card, .vm-table-card, .vm-kpi-card, .ldb-filter-card, .lc-fleet-summary, .ant-card, .ant-card-body {
background-color: ${c.surface} !important;
border-color: ${c.hairline} !important;
color: ${c.ink} !important;
}
.ant-select-selector, .ant-select-selection-search-input, .ant-input, .ant-input-affix-wrapper, .ant-picker, .ant-select-dropdown {
background-color: ${c.controlBg} !important;
border-color: ${c.hairlineStrong} !important;
color: ${c.ink} !important;
}
.ant-select-selection-item, .ant-select-selection-placeholder, .ant-input::placeholder, .ant-picker-input > input {
color: ${c.ink} !important;
}
.ant-table, .ant-table-container, .ant-table-content, .ant-table-wrapper {
background-color: ${c.surface} !important;
color: ${c.ink} !important;
}
.ant-table-thead > tr > th, .ant-table-thead .ant-table-cell {
background-color: ${c.soft} !important;
color: ${c.ink} !important;
border-bottom-color: ${c.hairline} !important;
}
.ant-table-tbody > tr > td, .ant-table-tbody .ant-table-cell {
background-color: ${c.surface} !important;
color: ${c.body} !important;
border-bottom-color: ${c.hairline} !important;
}
.ant-table-tbody > tr:hover > td, .ant-table-tbody > tr.ant-table-row-hover > td {
background-color: ${c.soft} !important;
}
.ds-theme-lab,
.ds-theme-lab--collapsed,
[data-annotation-id="lc-theme-lab"] {
display: none !important;
visibility: hidden !important;
pointer-events: none !important;
}
${root} .oneos-shell, .oneos-shell {
--oneos-primary: ${primary} !important;
--oneos-primary-soft: ${primaryHover} !important;
--oneos-ink: ${c.ink} !important;
--oneos-ink-soft: ${c.body} !important;
--oneos-muted: ${c.muted} !important;
--oneos-border: ${c.hairline} !important;
--oneos-bg: ${c.canvas} !important;
--oneos-surface: ${c.surface} !important;
--oneos-aside-bg: ${c.surface} !important;
background: ${c.canvas} !important;
color: ${c.ink} !important;
}
${root} .oneos-shell-aside, .oneos-shell-aside {
background: ${c.surface} !important;
border-right: 1px solid ${c.hairline} !important;
}
${root} .oneos-shell-brand, .oneos-shell-brand {
background: ${c.surface} !important;
border-bottom: 1px solid ${c.hairline} !important;
}
${root} .oneos-shell-brand__text, .oneos-shell-brand__text {
color: ${c.ink} !important;
background: none !important;
-webkit-text-fill-color: ${c.ink} !important;
}
${root} .oneos-shell-brand__mark rect, .oneos-shell-brand__mark rect {
fill: ${primary} !important;
}
${root} .oneos-shell-menu__btn, .oneos-shell-menu__btn {
color: ${c.body} !important;
}
${root} .oneos-shell-menu__btn:hover, .oneos-shell-menu__btn:hover {
background: ${c.soft} !important;
color: ${c.ink} !important;
}
${root} .oneos-shell-menu__btn.is-active, .oneos-shell-menu__btn.is-active {
background: ${isDark ? soft(primary, 0.22) : soft(primary, 0.12)} !important;
color: ${primary} !important;
font-weight: 600 !important;
}
${root} .oneos-shell-menu__btn.is-active .oneos-shell-menu__icon, .oneos-shell-menu__btn.is-active .oneos-shell-menu__icon {
color: ${primary} !important;
}
${root} .oneos-shell-chrome, ${root} .oneos-shell-header, .oneos-shell-chrome, .oneos-shell-header {
background: ${c.surface} !important;
border-bottom: 1px solid ${c.hairline} !important;
color: ${c.ink} !important;
}
${root} .oneos-shell-icon-btn, .oneos-shell-icon-btn {
color: ${c.muted} !important;
}
${root} .oneos-shell-icon-btn:hover, .oneos-shell-icon-btn:hover {
background: ${c.soft} !important;
color: ${c.ink} !important;
}
${root} .oneos-shell-breadcrumb, .oneos-shell-breadcrumb {
color: ${c.body} !important;
}
${root} .oneos-shell-breadcrumb .is-current, .oneos-shell-breadcrumb .is-current {
color: ${c.ink} !important;
}
${root} .oneos-shell-breadcrumb__sep, .oneos-shell-breadcrumb__sep {
color: ${c.mutedSoft} !important;
}
${root} .oneos-shell-search, .oneos-shell-search {
background: ${c.soft} !important;
border: 1px solid ${c.hairlineStrong} !important;
color: ${c.ink} !important;
}
${root} .oneos-shell-search input, .oneos-shell-search input {
color: ${c.ink} !important;
}
${root} .oneos-shell-search input::placeholder, .oneos-shell-search input::placeholder {
color: ${c.mutedSoft} !important;
}
${root} .oneos-shell-search kbd, .oneos-shell-search kbd {
background: ${c.controlBg} !important;
border-color: ${c.hairline} !important;
color: ${c.muted} !important;
}
${root} .oneos-shell-tabs, .oneos-shell-tabs {
background: ${c.soft} !important;
border-bottom: 1px solid ${c.hairline} !important;
}
${root} .oneos-shell-tab, .oneos-shell-tab {
background: transparent !important;
color: ${c.muted} !important;
border-color: ${c.hairline} !important;
}
${root} .oneos-shell-tab.is-active, .oneos-shell-tab.is-active {
background: ${c.surface} !important;
color: ${primary} !important;
border-top: 2px solid ${primary} !important;
font-weight: 600 !important;
}
${root} .oneos-shell-frame, .oneos-shell-frame {
background: ${c.canvas} !important;
}
${page} {
--ln-primary: ${primary} !important; --ln-primary-hover: ${primaryHover} !important;
--ln-primary-focus: ${primaryFocus} !important; --ln-primary-soft: ${primarySoft} !important;
--ln-ink: ${c.ink} !important; --ln-body: ${c.body} !important; --ln-muted: ${c.muted} !important;
--ln-muted-soft: ${c.mutedSoft} !important; --ln-canvas-parchment: ${c.canvas} !important;
--ln-surface-card: ${c.surface} !important; --ln-surface-strong: ${c.tagNeutralBg} !important;
--ln-canvas-soft: ${c.soft} !important; --ln-hairline: ${c.hairline} !important;
--ln-hairline-strong: ${c.hairlineStrong} !important; --ln-on-primary: ${c.onPrimary} !important;
--ln-link: ${primary} !important; --ln-radius-control: ${theme.radiusControl} !important;
--ln-radius-card: ${theme.radiusCard} !important;
${antVars}
background: ${c.canvas} !important; color: ${c.body} !important;
}
${root} .ant-select, ${root} .ant-select-css-var, ${page} .ant-select, ${page} .lc-filter-select {
--ant-color-bg-container: ${c.controlBg} !important;
--ant-color-text: ${c.ink} !important;
--ant-color-text-placeholder: ${c.mutedSoft} !important;
--ant-color-border: ${c.hairlineStrong} !important;
--ant-color-icon: ${c.muted} !important;
}
${page} .vm-filter-card, ${page} .vm-table-card, ${page} .vm-kpi-card, ${page} .lc-fleet-summary,
${page} .lc-create-topbar, ${page} .ct-editor-page, ${page} .ct-panel {
background: ${c.surface} !important; border-color: ${c.hairline} !important; color: ${c.body} !important;
}
${page} .vm-filter-title, ${page} .vm-kpi-val, ${page} .lc-create-topbar__title, ${page} h1, ${page} h2, ${page} h3 {
color: ${c.ink} !important;
}
${page} .vm-filter-field > span, ${page} .vm-kpi-label, ${page} .ant-form-item-label > label {
color: ${c.muted} !important;
}
${root} .ant-select, ${root} .ant-select-outlined, ${root} .ant-select .ant-select-selector,
${root} .ant-select .ant-select-content, ${root} .ant-input, ${root} .ant-input-affix-wrapper,
${root} .ant-picker, ${root} .ant-input-number, ${root} textarea.ant-input,
${page} .ant-select, ${page} .ant-select-outlined, ${page} .ant-select .ant-select-selector,
${page} .ant-select .ant-select-content, ${page} .ant-input, ${page} .ant-input-affix-wrapper,
${page} .ant-picker, ${page} .ant-input-number, ${page} .vm-filter-picker-control,
${page} .lc-filter-select, ${page} .lc-filter-select .ant-select-content,
${lc} .vm-filter-field .ant-select, ${lc} .vm-filter-field .lc-filter-select,
${lc} .vm-filter-field .ant-select .ant-select-content, ${lc} .vm-filter-field .vm-filter-picker-control {
background: ${c.controlBg} !important; background-color: ${c.controlBg} !important;
border-color: ${c.hairlineStrong} !important; color: ${c.ink} !important; box-shadow: none !important;
}
${root} .ant-select-disabled, ${page} .ant-select-disabled {
background: ${c.soft} !important; color: ${c.muted} !important;
}
${page} .ant-select-input, ${page} .ant-select-placeholder, ${page} .ant-select-content,
${page} .ant-picker-input > input {
color: ${c.ink} !important; background: transparent !important;
}
${page} .ant-select-placeholder, ${page} .ant-select-selection-placeholder,
${page} .ant-input::placeholder, ${page} .vm-filter-picker-input::placeholder,
${page} .vm-filter-picker-input.is-placeholder {
color: ${isDark ? '#a1a1aa' : c.mutedSoft} !important; opacity: 1 !important;
}
${page} .ant-select-suffix, ${page} .ant-select-arrow, ${page} .ant-picker-suffix { color: ${c.muted} !important; }
${page} .ant-select-selection-item {
background: ${c.soft} !important; border-color: ${c.hairline} !important; color: ${c.ink} !important;
}
${page} .ant-select-focused, ${page} .ant-select-focused.ant-select-outlined,
${page} .vm-filter-picker-control:focus-within, ${page} .ant-input:focus, ${page} .ant-picker-focused {
border-color: ${primary} !important; box-shadow: 0 0 0 2px ${primarySoft} !important;
}
${page} .ant-table, ${page} .ant-table-container, ${page} .ant-table-body, ${page} .ant-table-header {
background: ${c.surface} !important; color: ${c.ink} !important;
}
${page} .ant-table-thead > tr > th { background: ${c.soft} !important; color: ${c.muted} !important; border-bottom-color: ${c.hairline} !important; }
${page} .ant-table-tbody > tr > td { background: ${c.surface} !important; color: ${c.body} !important; border-bottom-color: ${c.hairline} !important; }
${page} .ant-table-tbody > tr:hover > td { background: ${c.soft} !important; }
${page} .vm-status-gray, ${page} .vm-tag-manual {
background: ${c.tagNeutralBg} !important; color: ${c.tagNeutralFg} !important;
}
${isDark ? `
${page} .vm-status, ${page} .ant-tag { background: ${c.tagNeutralBg} !important; color: ${c.tagNeutralFg} !important; }
${page} .vm-status-green, ${page} .ant-tag-success { background: rgba(39,166,68,.2) !important; color: #4ade80 !important; }
${page} .vm-status-amber, ${page} .ant-tag-warning { background: rgba(217,119,6,.22) !important; color: #fbbf24 !important; }
${page} .vm-status-red, ${page} .ant-tag-error { background: rgba(229,72,77,.22) !important; color: #f87171 !important; }
${page} .vm-status-blue { background: ${soft(primary, 0.22)} !important; color: ${primaryHover} !important; }
${page} .lc-fee-info-cell__side--hydrogen .lc-fee-info-cell__value { background: ${c.tagNeutralBg} !important; color: ${c.tagNeutralFg} !important; }
${page} .lc-fee-info-cell__side--payment .lc-fee-info-cell__value { background: ${soft(primary, 0.22)} !important; color: ${primaryHover} !important; }
${page} .lc-fee-info-cell__side--payment.is-postpay .lc-fee-info-cell__value { background: rgba(229,72,77,.22) !important; color: #f87171 !important; }
${page} .lc-fee-info-cell__side--period .lc-fee-info-cell__value { background: ${soft(primary, 0.22)} !important; color: ${primaryHover} !important; }
` : ''}
${page} .vm-btn-primary, ${page} .ant-btn-primary {
background: ${primary} !important; border-color: ${primary} !important; color: ${c.onPrimary} !important;
}
${page} .vm-btn-primary:hover, ${page} .ant-btn-primary:hover {
background: ${primaryHover} !important; border-color: ${primaryHover} !important;
}
${page} .vm-btn-ghost, ${page} .vm-btn-secondary, ${page} .ant-btn-default {
background: ${c.controlBg} !important; border-color: ${c.hairlineStrong} !important; color: ${c.ink} !important;
}
${page} .vm-btn-link, ${page} .ant-btn-link, ${page} a { color: ${primary} !important; }
${page} .vm-kpi-card.active { border-color: ${primary} !important; box-shadow: 0 0 0 1px ${primarySoft} !important; }
${page} .vm-pagination-page { background: ${c.controlBg} !important; border-color: ${c.hairline} !important; color: ${c.body} !important; }
${page} .vm-pagination-page.active { background: ${primary} !important; border-color: ${primary} !important; color: ${c.onPrimary} !important; }
${root} .ant-select-dropdown, ${root} .ant-picker-dropdown, ${root} .ant-dropdown, ${root} .ant-popover-inner, ${root} .ant-modal-content {
background: ${c.surface} !important; color: ${c.ink} !important; border: 1px solid ${c.hairline} !important;
}
${root} .ant-select-item-option-active, ${root} .ant-select-item-option-selected {
background: ${c.soft} !important; color: ${c.ink} !important;
}
`.trim();
}

View File

@@ -0,0 +1,6 @@
export * from './types';
export * from './resolve';
export { ROUTE_RULES } from './routes';
export { SEED_MESSAGES } from './seed';
export * from './store';
export * from './shell-adapter';

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { resolveMessageTarget } from './resolve';
import { ROUTE_RULES } from './routes';
import { SEED_MESSAGES } from './seed';
import type { HubMessage, RouteRule } from './types';
const baseMsg: HubMessage = {
id: 'm1',
sourceSystem: 'oneos',
bizType: 'approval.arrive',
bizId: 'AP-1',
title: '待审',
summary: '',
detail: '详情',
priority: 'normal',
createdAt: '2026-07-22T10:00:00+08:00',
audienceRoleIds: [],
channels: [],
bizTag: '租赁合同',
};
describe('resolveMessageTarget', () => {
it('精确匹配 web 规则', () => {
const rules: RouteRule[] = [
{
sourceSystem: 'oneos',
bizType: 'approval.arrive',
client: 'web',
targetTemplate: '/prototypes/oneos-web-approval-todo?id={bizId}',
label: '审批待办',
},
];
const r = resolveMessageTarget(baseMsg, 'web', rules);
expect(r.ok).toBe(true);
if (r.ok) expect(r.uri).toBe('/prototypes/oneos-web-approval-todo?id=AP-1');
});
it('harmony 可 fallback 到 android', () => {
const rules: RouteRule[] = [
{
sourceSystem: 'oneos',
bizType: 'approval.arrive',
client: 'android',
targetTemplate: 'oneos://approval/{bizId}',
label: '安卓审批',
fallbackClient: undefined,
},
{
sourceSystem: 'oneos',
bizType: 'approval.arrive',
client: 'harmony',
targetTemplate: '',
label: '鸿蒙走安卓模板',
fallbackClient: 'android',
},
];
const r = resolveMessageTarget(baseMsg, 'harmony', rules);
expect(r.ok).toBe(true);
if (r.ok) expect(r.uri).toBe('oneos://approval/AP-1');
});
it('无规则返回固定文案', () => {
const r = resolveMessageTarget(baseMsg, 'wechat_oa', []);
expect(r).toEqual({
ok: false,
reason: 'no_rule',
message: '当前端暂无可跳转目标',
});
});
it('种子 approval 在 web 可解析', () => {
const msg = SEED_MESSAGES.find((m) => m.bizType === 'approval.arrive');
expect(msg).toBeTruthy();
const r = resolveMessageTarget(msg!, 'web', ROUTE_RULES);
expect(r.ok).toBe(true);
});
it('带 externalApp', () => {
const msg = { ...baseMsg, sourceSystem: 'external' as const, bizType: 'order.action', bizId: 'O-9' };
const rules: RouteRule[] = [
{
sourceSystem: 'external',
bizType: 'order.action',
client: 'web',
targetTemplate: 'ext-app-a://order/{bizId}',
label: '外部 App A 订单',
externalApp: 'external-app-a',
},
];
const r = resolveMessageTarget(msg, 'web', rules);
expect(r.ok).toBe(true);
if (r.ok) {
expect(r.uri).toBe('ext-app-a://order/O-9');
expect(r.externalApp).toBe('external-app-a');
}
});
});

View File

@@ -0,0 +1,52 @@
import type { HubMessage, MessageClient, ResolveResult, RouteRule } from './types';
function fillTemplate(template: string, msg: HubMessage): string {
return template
.replace(/\{bizId\}/g, encodeURIComponent(msg.bizId))
.replace(/\{id\}/g, encodeURIComponent(msg.id));
}
function findRule(
rules: RouteRule[],
sourceSystem: HubMessage['sourceSystem'],
bizType: string,
client: MessageClient,
): RouteRule | undefined {
return rules.find(
(r) => r.sourceSystem === sourceSystem && r.bizType === bizType && r.client === client,
);
}
export function resolveMessageTarget(
msg: HubMessage,
client: MessageClient,
rules: RouteRule[],
): ResolveResult {
const primary = findRule(rules, msg.sourceSystem, msg.bizType, client);
if (primary) {
const useFallback =
(!primary.targetTemplate || primary.targetTemplate.trim() === '') && primary.fallbackClient;
if (useFallback && primary.fallbackClient) {
const fb = findRule(rules, msg.sourceSystem, msg.bizType, primary.fallbackClient);
if (fb?.targetTemplate?.trim()) {
return {
ok: true,
uri: fillTemplate(fb.targetTemplate.trim(), msg),
label: primary.label || fb.label,
externalApp: fb.externalApp ?? primary.externalApp,
rule: fb,
};
}
}
if (primary.targetTemplate?.trim()) {
return {
ok: true,
uri: fillTemplate(primary.targetTemplate, msg),
label: primary.label,
externalApp: primary.externalApp,
rule: primary,
};
}
}
return { ok: false, reason: 'no_rule', message: '当前端暂无可跳转目标' };
}

View File

@@ -0,0 +1,169 @@
import type { ExternalAppId, RouteRule, SourceSystem } from './types';
type OneosRule = {
bizType: string;
web: string;
mobilePath: string;
label: string;
pcOnlyWechat?: boolean;
};
function oneosRules(configs: OneosRule[]): RouteRule[] {
return configs.flatMap((c) => fiveClients('oneos', c.bizType, c.web, c.mobilePath, c.label, c.pcOnlyWechat));
}
function fiveClients(
sourceSystem: SourceSystem,
bizType: string,
web: string,
mobilePath: string,
label: string,
pcOnlyWechat = false,
externalApp?: ExternalAppId,
): RouteRule[] {
const wechat = pcOnlyWechat ? '' : `https://oa.example.com/h5/${mobilePath}?id={bizId}`;
return [
{ sourceSystem, bizType, client: 'web', targetTemplate: web, label, externalApp },
{
sourceSystem,
bizType,
client: 'ios',
targetTemplate: `oneos://${mobilePath}/{bizId}`,
label: `${label}iOS`,
externalApp,
},
{
sourceSystem,
bizType,
client: 'android',
targetTemplate: `oneos-android://${mobilePath}/{bizId}`,
label: `${label}(安卓)`,
externalApp,
},
{
sourceSystem,
bizType,
client: 'harmony',
targetTemplate: '',
label: `${label}(鸿蒙)`,
fallbackClient: 'android',
externalApp,
},
{
sourceSystem,
bizType,
client: 'wechat_oa',
targetTemplate: wechat,
label: `${label}(服务号)`,
externalApp,
},
];
}
function externalRules(
bizType: string,
template: string,
label: string,
externalApp: ExternalAppId,
): RouteRule[] {
const mobilePath = bizType.split('.')[0]!;
return fiveClients('external', bizType, template, mobilePath, label, true, externalApp).map((r) =>
r.client === 'ios' || r.client === 'android'
? { ...r, targetTemplate: template }
: r.client === 'harmony'
? { ...r, targetTemplate: '', fallbackClient: 'android' as const }
: r,
);
}
export const ROUTE_RULES: RouteRule[] = [
...oneosRules([
{
bizType: 'approval.arrive',
web: '/prototypes/oneos-web-approval-todo?id={bizId}',
mobilePath: 'approval',
label: '审批待办',
},
{
bizType: 'urge.remind',
web: '/prototypes/oneos-web-ops',
mobilePath: 'urge',
label: '催办提醒',
},
{
bizType: 'contract.expire',
web: '/prototypes/customer-management',
mobilePath: 'contract',
label: '合同到期',
},
{
bizType: 'license.expire',
web: '/prototypes/customer-management',
mobilePath: 'license',
label: '证照到期',
},
{
bizType: 'bill.ready',
web: '/prototypes/lease-business-ledger',
mobilePath: 'bill',
label: '账单生成',
pcOnlyWechat: true,
},
{
bizType: 'h2.balance',
web: '/prototypes/payment-records',
mobilePath: 'h2',
label: '氢费余额',
},
{
bizType: 'system.release',
web: '',
mobilePath: 'release',
label: '版本更新通知',
pcOnlyWechat: true,
},
{
bizType: 'payment.unlinked',
web: '/prototypes/payment-records',
mobilePath: 'payment',
label: '收款未关联账单',
pcOnlyWechat: true,
},
{
bizType: 'receivable.partial',
web: '/prototypes/vehicle-pickup-receivable',
mobilePath: 'receivable',
label: '提车应收款差额',
},
{
bizType: 'system.notice',
web: '',
mobilePath: 'notice',
label: '系统通知(仅详情)',
pcOnlyWechat: true,
},
{
bizType: 'overview.risk',
web: '/prototypes/lease-business-line-overview',
mobilePath: 'overview',
label: '经营高风险汇总',
},
]),
...fiveClients(
'vehicle-mid',
'fault.overdue',
'/prototypes/vehicle-fault-handling#page=detail&id={bizId}',
'fault',
'故障逾期',
),
...fiveClients(
'vehicle-mid',
'vehicle.status',
'/prototypes/vehicle-management',
'vehicle',
'车辆状态变更',
),
...fiveClients('other-system', 'generic.notice', '', 'notice', '外部系统通用通知', true),
...externalRules('order.action', 'ext-app-a://order/{bizId}', '外部 App A 订单操作', 'external-app-a'),
...externalRules('settle.action', 'ext-app-b://settle/{bizId}', '外部 App B 结算操作', 'external-app-b'),
];

View File

@@ -0,0 +1,303 @@
import type { ChannelDelivery, HubMessage, MessagePriority } from './types';
function toIso(time: string): string {
const [date, hm] = time.split(' ');
return `${date}T${hm}:00+08:00`;
}
function channels(
createdAt: string,
opts: { wechatSkipped?: boolean; androidPending?: boolean; harmonyPending?: boolean } = {},
): ChannelDelivery[] {
const { wechatSkipped = false, androidPending = false, harmonyPending = true } = opts;
return [
{ client: 'web', status: 'sent', updatedAt: createdAt },
{ client: 'ios', status: 'sent', updatedAt: createdAt },
{
client: 'android',
status: androidPending ? 'pending' : 'sent',
updatedAt: androidPending ? undefined : createdAt,
},
{
client: 'harmony',
status: harmonyPending ? 'pending' : 'sent',
updatedAt: harmonyPending ? undefined : createdAt,
},
{
client: 'wechat_oa',
status: wechatSkipped ? 'skipped' : 'sent',
updatedAt: wechatSkipped ? undefined : createdAt,
},
];
}
function fromNotice(
id: string,
sourceSystem: HubMessage['sourceSystem'],
bizType: string,
bizId: string,
title: string,
summary: string,
detail: string,
time: string,
read: boolean,
audienceRoleIds: string[],
bizTag: string,
priority: MessagePriority = 'normal',
channelOpts?: Parameters<typeof channels>[1],
): HubMessage {
const createdAt = toIso(time);
return {
id,
sourceSystem,
bizType,
bizId,
title,
summary,
detail,
priority,
createdAt,
readAt: read ? createdAt : undefined,
audienceRoleIds,
channels: channels(createdAt, channelOpts),
bizTag,
};
}
/** 与 workbench SEED_NOTICES 对齐,并补充多系统样例 */
export const SEED_MESSAGES: HubMessage[] = [
fromNotice(
'n-release-notes',
'oneos',
'system.release',
'release-v1',
'v1.2 版本更新',
'更新后首次进入可查看完整更新日志',
'OneOS 已发布 V1.2更新时间07月16日16:00。也可从顶栏「版本更新」图标或快速入口随时查阅并切换查看过往版本。',
'2026-07-16 16:00',
false,
[
'bizAdmin',
'bizEnergy',
'bizSales',
'ops',
'procurement',
'safety',
'finance',
'legal',
'gm',
],
'版本更新',
'normal',
{ wechatSkipped: true },
),
fromNotice(
'n-1',
'oneos',
'urge.remind',
't-ops-delivery',
'沪AD8821',
'',
'管理员 张明 对任务「执行交车 · 沪AD8821」发起了催办。请优先处理该待办完成后状态将同步更新。',
'2026-07-18 09:12',
false,
['bizAdmin', 'ops', 'gm'],
'交车任务',
'high',
),
fromNotice(
'n-2',
'oneos',
'bill.ready',
't-lease-bill-01',
'沪东物流 6 月',
'请于 7 日内完成核对',
'系统已生成沪东物流 2026 年 6 月租赁账单,应收 ¥186,000。请在 7 天内完成核对并提交财务。',
'2026-07-14 08:05',
false,
['bizAdmin'],
'租赁账单',
'normal',
{ wechatSkipped: true },
),
fromNotice(
'n-3',
'oneos',
'h2.balance',
't-h2-recharge',
'嘉定站余额不足预警',
'当前余额 ¥8,200低于安全线',
'嘉定站氢费账户余额已低于安全阈值,建议立即发起预付款充值,避免影响站端加注。',
'2026-07-18 09:00',
false,
['bizEnergy'],
'氢费账户',
),
fromNotice(
'n-4',
'oneos',
'approval.arrive',
't-legal-contract',
'待审 LC-2026-0888',
'业务部已提交,请法务审核',
'新桥冷链租赁合同已到达法务节点,当前已停留超过 24 小时,请尽快审核。',
'2026-07-16 18:01',
true,
['legal'],
'租赁合同',
),
fromNotice(
'n-5',
'oneos',
'license.expire',
't-legal-license',
'远航能源道路运输证临期',
'剩余 18 天,请跟进续期',
'客户「远航能源」道路运输证将于 18 天后到期。业务部需跟进客户续期材料,法务关注风险标签。',
'2026-07-15 08:10',
false,
['bizSales', 'legal'],
'证照管理',
),
fromNotice(
'n-6',
'oneos',
'receivable.partial',
'recv-lhc-202607',
'绿氢城配',
'到账金额小于应收',
'绿氢城配提车应收款已部分到账,差额 ¥12,000请财务确认并跟进补款。',
'2026-07-17 16:40',
false,
['finance'],
'提车应收款',
),
fromNotice(
'n-7',
'oneos',
'system.notice',
't-safety-accident',
'AC-2026-041 待复核',
'司机已上报,请安全部复核',
'车牌沪BE3310 发生一般事故,材料已上传,请安全部在 SLA 内完成复核。',
'2026-07-16 12:45',
false,
['safety'],
'事故复核',
'normal',
{ wechatSkipped: true },
),
fromNotice(
'n-8',
'oneos',
'overview.risk',
'weekly-risk-20260718',
'本周高风险事项汇总',
'共 11 项跨部门高风险待关注',
'系统汇总本周各部门高风险预警 11 项,涵盖交车超期、回款超期、加氢未核等,可在经营总览中查看详情。',
'2026-07-18 07:30',
false,
['gm'],
'经营总览',
),
fromNotice(
'n-9',
'oneos',
'contract.expire',
'contract-hc-f1902',
'到期待还车 · 沪CF1902',
'合同已到期 6 天',
'租赁合同已过期车辆沪CF1902 尚未完成还车流程,请运维跟进。',
'2026-07-17 08:00',
true,
['ops'],
'租赁合同',
),
fromNotice(
'n-10',
'oneos',
'payment.unlinked',
'pay-unlinked-batch',
'收款入账待关联账单',
'4 笔收款未关联金额0请业务尽快认领',
'系统按「到账金额 已认领金额」统计,当前有 4 笔收款仍有未关联金额(合计约 ¥5,800。请业务管理部 / 业务部人员在收款记录中完成账单关联。',
'2026-07-18 08:20',
false,
['bizAdmin', 'bizSales'],
'收款记录',
'normal',
{ wechatSkipped: true },
),
fromNotice(
'vm-fault-1',
'vehicle-mid',
'fault.overdue',
'VF-20260718-001',
'故障工单 SLA 超时',
'沪DK7720 制动系统故障待处理',
'车辆数据中台检测到故障工单 VF-20260718-001 已超过 SLA 24 小时未闭环,请运维优先处理。',
'2026-07-18 10:30',
false,
['ops'],
'故障处理',
'high',
),
fromNotice(
'vm-status-1',
'vehicle-mid',
'vehicle.status',
'veh-hc-f1902',
'车辆状态变更为「停运」',
'沪CF1902 因合同到期自动停运',
'车辆数据中台同步沪CF1902 状态由「在租」变更为「停运」,关联合同 contract-hc-f1902。',
'2026-07-17 08:05',
false,
['ops', 'bizAdmin'],
'车辆状态',
),
fromNotice(
'other-notice-1',
'other-system',
'generic.notice',
'ext-sys-alert-001',
'第三方能耗平台告警',
'嘉定站电表读数异常波动',
'外部能耗监控系统上报嘉定站电表读数异常,请相关同事在 OneOS 内核实后联系第三方平台确认。',
'2026-07-18 11:00',
false,
['bizEnergy', 'ops'],
'外部接入',
'normal',
{ wechatSkipped: true },
),
fromNotice(
'ext-order-1',
'external',
'order.action',
'O-20260718-009',
'外部 App A订单待确认',
'客户「绿氢城配」新订单待业务确认',
'外部 App A 推送:订单 O-20260718-009 已创建,请在 App A 内确认交期与价格。',
'2026-07-18 09:45',
false,
['bizSales'],
'外部订单',
'normal',
{ wechatSkipped: true, androidPending: true },
),
fromNotice(
'ext-settle-1',
'external',
'settle.action',
'S-20260717-003',
'外部 App B结算待签章',
'沪东物流 6 月结算单待财务签章',
'外部 App B 推送:结算单 S-20260717-003 已生成,请财务在 App B 内完成签章后回传 OneOS。',
'2026-07-17 14:20',
false,
['finance'],
'外部结算',
'normal',
{ wechatSkipped: true, androidPending: true },
),
];

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest';
import { SEED_MESSAGES } from './seed';
import { toShellNoticeItem } from './shell-adapter';
describe('toShellNoticeItem', () => {
it('maps urge.remind to shell type 催办提醒', () => {
const msg = SEED_MESSAGES.find((m) => m.bizType === 'urge.remind');
expect(msg).toBeTruthy();
const item = toShellNoticeItem(msg!);
expect(item.type).toBe('催办提醒');
expect(item.bizTag).toBe('交车任务');
});
});

View File

@@ -0,0 +1,30 @@
import type { ShellNoticeItem } from '../oneos-app-shell/notice-bridge';
import { resolveMessageTarget } from './resolve';
import { ROUTE_RULES } from './routes';
import type { HubMessage } from './types';
export function toShellNoticeItem(msg: HubMessage): ShellNoticeItem {
const web = resolveMessageTarget(msg, 'web', ROUTE_RULES);
return {
id: msg.id,
type:
msg.bizType === 'urge.remind' ||
(msg.priority === 'high' && msg.bizType.startsWith('urge'))
? '催办提醒'
: msg.bizTag,
bizTag: msg.bizTag,
title: msg.title,
summary: msg.summary,
detail: msg.detail,
time: msg.createdAt.includes('T')
? msg.createdAt.replace('T', ' ').slice(0, 16)
: msg.createdAt.slice(0, 16),
read: !!msg.readAt,
href: web.ok && web.uri.startsWith('/') ? web.uri : undefined,
taskId: msg.bizId,
};
}
export function toShellNoticeItems(messages: HubMessage[]): ShellNoticeItem[] {
return messages.map(toShellNoticeItem);
}

View File

@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { HubMessage } from './types';
import {
clearRead,
listForRole,
loadMessages,
markRead,
sortMessages,
unreadCount,
} from './store';
const STORAGE_KEY = 'oneos.message-hub.readAt.v1';
function installLocalStorageMock() {
const store = new Map<string, string>();
const mock = {
getItem: vi.fn((key: string) => (store.has(key) ? store.get(key)! : null)),
setItem: vi.fn((key: string, value: string) => {
store.set(key, value);
}),
removeItem: vi.fn((key: string) => {
store.delete(key);
}),
clear: vi.fn(() => {
store.clear();
}),
};
vi.stubGlobal('localStorage', mock);
vi.stubGlobal('window', { localStorage: mock });
return { store, mock };
}
function makeMsg(overrides: Partial<HubMessage> & Pick<HubMessage, 'id'>): HubMessage {
return {
sourceSystem: 'oneos',
bizType: 'system.notice',
bizId: 'biz-1',
title: '标题',
summary: '摘要',
detail: '详情',
priority: 'normal',
createdAt: '2026-07-18T09:00:00+08:00',
audienceRoleIds: [],
channels: [],
bizTag: '系统通知',
...overrides,
};
}
describe('message-hub store', () => {
let storage: ReturnType<typeof installLocalStorageMock>;
beforeEach(() => {
storage = installLocalStorageMock();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('listForRole includes empty audience or matching role', () => {
const messages = [
makeMsg({ id: 'all', audienceRoleIds: [] }),
makeMsg({ id: 'ops-only', audienceRoleIds: ['ops'] }),
makeMsg({ id: 'legal-only', audienceRoleIds: ['legal'] }),
];
const forOps = listForRole(messages, 'ops');
expect(forOps.map((m) => m.id)).toEqual(['all', 'ops-only']);
expect(listForRole(messages).map((m) => m.id)).toEqual(['all', 'ops-only', 'legal-only']);
});
it('sortMessages orders unread, then high priority, then createdAt desc', () => {
const messages = [
makeMsg({
id: 'read-normal-old',
readAt: '2026-07-18T10:00:00+08:00',
priority: 'normal',
createdAt: '2026-07-17T08:00:00+08:00',
}),
makeMsg({
id: 'unread-normal-new',
priority: 'normal',
createdAt: '2026-07-18T12:00:00+08:00',
}),
makeMsg({
id: 'unread-high',
priority: 'high',
bizType: 'urge.remind',
createdAt: '2026-07-18T09:00:00+08:00',
}),
makeMsg({
id: 'read-high',
readAt: '2026-07-18T11:00:00+08:00',
priority: 'high',
createdAt: '2026-07-18T13:00:00+08:00',
}),
];
expect(sortMessages(messages).map((m) => m.id)).toEqual([
'unread-high',
'unread-normal-new',
'read-high',
'read-normal-old',
]);
});
it('markRead persists readAt and loadMessages merges storage', () => {
const base = [
makeMsg({ id: 'test-ops', audienceRoleIds: ['ops'] }),
makeMsg({ id: 'test-legal', audienceRoleIds: ['legal'] }),
];
const updated = markRead('test-ops', base);
expect(updated.find((m) => m.id === 'test-ops')?.readAt).toBeTruthy();
expect(updated.find((m) => m.id === 'test-legal')?.readAt).toBeUndefined();
const persisted = JSON.parse(storage.mock.getItem(STORAGE_KEY) ?? '{}') as Record<string, string>;
expect(persisted['test-ops']).toBeTruthy();
storage.mock.setItem(STORAGE_KEY, JSON.stringify({ 'n-1': '2026-07-22T12:00:00.000Z' }));
const fromSeed = loadMessages().find((m) => m.id === 'n-1');
expect(fromSeed?.readAt).toBe('2026-07-22T12:00:00.000Z');
});
it('unreadCount respects role filter', () => {
const messages = [
makeMsg({ id: 'ops-unread', audienceRoleIds: ['ops'] }),
makeMsg({ id: 'legal-unread', audienceRoleIds: ['legal'] }),
makeMsg({ id: 'ops-read', audienceRoleIds: ['ops'], readAt: '2026-07-18T10:00:00+08:00' }),
];
expect(unreadCount(messages, 'ops')).toBe(1);
expect(unreadCount(messages, 'legal')).toBe(1);
expect(unreadCount(messages)).toBe(2);
});
it('clearRead removes persisted readAt and restores seed', () => {
markRead('n-1');
expect(loadMessages().find((m) => m.id === 'n-1')?.readAt).toBeTruthy();
clearRead('n-1');
const after = loadMessages().find((m) => m.id === 'n-1');
expect(after?.readAt).toBeUndefined();
const persisted = JSON.parse(storage.mock.getItem(STORAGE_KEY) ?? '{}') as Record<string, string>;
expect(persisted['n-1']).toBeUndefined();
});
});

View File

@@ -0,0 +1,100 @@
import { SEED_MESSAGES } from './seed';
import type { HubMessage, MessagePriority } from './types';
const STORAGE_KEY = 'oneos.message-hub.readAt.v1';
const PRIORITY_ORDER: Record<MessagePriority, number> = {
high: 0,
normal: 1,
};
function canUseStorage(): boolean {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined';
}
function loadReadAtMap(): Record<string, string> {
if (!canUseStorage()) return {};
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) return {};
const parsed: unknown = JSON.parse(raw);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, string>;
}
} catch {
// ignore corrupt storage
}
return {};
}
function persistReadAtMap(map: Record<string, string>): void {
if (!canUseStorage()) return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
} catch {
// ignore quota / private mode
}
}
/** Clone seed messages and merge persisted readAt from localStorage. */
export function loadMessages(): HubMessage[] {
const readAtMap = loadReadAtMap();
return SEED_MESSAGES.map((msg) => {
const persisted = readAtMap[msg.id];
if (persisted) {
return { ...msg, readAt: persisted };
}
return { ...msg };
});
}
export function markRead(id: string, messages?: HubMessage[]): HubMessage[] {
const now = new Date().toISOString();
const base = messages ?? loadMessages();
const readAtMap = loadReadAtMap();
readAtMap[id] = now;
persistReadAtMap(readAtMap);
return base.map((m) => (m.id === id ? { ...m, readAt: now } : { ...m }));
}
/** Clear persisted readAt for an id (demo reset / sync with release-seen). Seed readAt unchanged. */
export function clearRead(id: string, messages?: HubMessage[]): HubMessage[] {
const base = messages ?? loadMessages();
const readAtMap = loadReadAtMap();
delete readAtMap[id];
persistReadAtMap(readAtMap);
const seed = SEED_MESSAGES.find((m) => m.id === id);
return base.map((m) => {
if (m.id !== id) return { ...m };
return { ...m, readAt: seed?.readAt };
});
}
export function listForRole(messages: HubMessage[], roleId?: string): HubMessage[] {
if (!roleId) return messages;
return messages.filter(
(m) => !m.audienceRoleIds.length || m.audienceRoleIds.includes(roleId),
);
}
export function sortMessages(messages: HubMessage[]): HubMessage[] {
return [...messages].sort((a, b) => {
const aUnread = a.readAt ? 1 : 0;
const bUnread = b.readAt ? 1 : 0;
if (aUnread !== bUnread) return aUnread - bUnread;
const pa = PRIORITY_ORDER[a.priority];
const pb = PRIORITY_ORDER[b.priority];
if (pa !== pb) return pa - pb;
return b.createdAt.localeCompare(a.createdAt);
});
}
export function unreadCount(messages: HubMessage[], roleId?: string): number {
return listForRole(messages, roleId).filter((m) => !m.readAt).length;
}
export function getSortedMessages(roleId?: string): HubMessage[] {
return sortMessages(listForRole(loadMessages(), roleId));
}

View File

@@ -0,0 +1,56 @@
export type SourceSystem = 'oneos' | 'vehicle-mid' | 'other-system' | 'external';
export type MessageClient = 'web' | 'ios' | 'android' | 'harmony' | 'wechat_oa';
export type ChannelStatus = 'pending' | 'sent' | 'failed' | 'skipped';
export type MessagePriority = 'normal' | 'high';
export type ExternalAppId = 'external-app-a' | 'external-app-b';
export type ChannelDelivery = {
client: MessageClient;
status: ChannelStatus;
updatedAt?: string;
};
export type HubMessage = {
id: string;
sourceSystem: SourceSystem;
bizType: string;
bizId: string;
title: string;
summary: string;
detail: string;
priority: MessagePriority;
createdAt: string;
readAt?: string;
/** 角色占位;空数组表示全员可见 */
audienceRoleIds: string[];
channels: ChannelDelivery[];
/** 展示用业务标签,如「交车任务」 */
bizTag: string;
};
export type RouteRule = {
sourceSystem: SourceSystem;
bizType: string;
client: MessageClient;
targetTemplate: string;
label: string;
externalApp?: ExternalAppId;
/** 精确未命中时,再试该 client如 harmony → android */
fallbackClient?: MessageClient;
};
export type ResolveOk = {
ok: true;
uri: string;
label: string;
externalApp?: ExternalAppId;
rule: RouteRule;
};
export type ResolveFail = {
ok: false;
reason: 'no_rule';
message: '当前端暂无可跳转目标';
};
export type ResolveResult = ResolveOk | ResolveFail;

View File

@@ -14,6 +14,7 @@ import {
Maximize,
X,
Sparkles,
Settings,
type LucideIcon,
FolderOpen,
FileText,
@@ -24,7 +25,6 @@ import {
BatteryCharging,
Users,
Workflow,
Settings,
ClipboardList,
BarChart3,
Wrench,
@@ -38,10 +38,17 @@ import {
type ShellMenuItem,
} from './nav-from-prototypes';
import {
readStoredOneOsTheme,
setOneOsTheme,
applyRuoYiSettingsToDocument,
syncRuoYiSettingsToAllIframes,
type OneOsTheme,
} from './theme';
import {
RuoYiSettingsDrawer,
readStoredRuoYiSettings,
persistRuoYiSettings,
DEFAULT_RUOYI_SETTINGS,
type RuoYiSettings,
} from './RuoYiSettingsDrawer';
import { ShellNoticeCenter } from './ShellNoticeCenter';
import {
isNoticesSync,
@@ -117,13 +124,61 @@ export function OneOsAppShell({
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());
// RuoYi 偏好设置
const [settingsOpen, setSettingsOpen] = useState(false);
const [ruoyiSettings, setRuoyiSettings] = useState<RuoYiSettings>(() => readStoredRuoYiSettings());
const [shellNotices, setShellNotices] = useState<ShellNoticeItem[]>([]);
const [shellUnread, setShellUnread] = useState(0);
// 计算当前主题浅暗模式
const isDark = useMemo(() => {
if (ruoyiSettings.themeMode === 'dark') return true;
if (ruoyiSettings.themeMode === 'light') return false;
if (typeof window !== 'undefined') {
return window.matchMedia('(prefers-color-scheme: dark)').matches;
}
return false;
}, [ruoyiSettings.themeMode]);
const currentTheme: OneOsTheme = isDark ? 'dark' : 'light';
// 当设置更新时应用到父 DOM 并同步到所有 iframe
useEffect(() => {
setOneOsTheme(theme);
}, [theme]);
persistRuoYiSettings(ruoyiSettings);
applyRuoYiSettingsToDocument(document, ruoyiSettings);
syncRuoYiSettingsToAllIframes(ruoyiSettings);
}, [ruoyiSettings]);
// 监听新加载或切换的 iframe确保 onload 时样式被完整注入
useEffect(() => {
const syncIframes = () => {
const frames = document.querySelectorAll<HTMLIFrameElement>('.oneos-shell-frame, iframe');
frames.forEach((frame) => {
const handleLoad = () => {
try {
if (frame.contentDocument) {
applyRuoYiSettingsToDocument(frame.contentDocument, ruoyiSettings);
}
} catch {
/* ignore cross-origin error */
}
};
frame.removeEventListener('load', handleLoad);
frame.addEventListener('load', handleLoad);
if (frame.contentDocument && frame.contentDocument.readyState === 'complete') {
handleLoad();
}
});
};
syncIframes();
const timer = setTimeout(syncIframes, 300);
return () => clearTimeout(timer);
}, [activeHref, ruoyiSettings]);
useEffect(() => {
const onMsg = (e: MessageEvent) => {
@@ -186,12 +241,11 @@ export function OneOsAppShell({
}, [path]);
const toggleTheme = useCallback(() => {
setTheme((prev) => {
const next: OneOsTheme = prev === 'dark' ? 'light' : 'dark';
setOneOsTheme(next);
return next;
});
}, []);
setRuoyiSettings((prev) => ({
...prev,
themeMode: isDark ? 'light' : 'dark',
}));
}, [isDark]);
const crumbs = breadcrumb?.length
? breadcrumb
@@ -215,6 +269,14 @@ export function OneOsAppShell({
[onNavigateProp],
);
const onNoticeViewAll = useCallback(() => {
handleNavigate({
key: 'message-center',
label: '消息中心',
href: '/prototypes/message-center',
});
}, [handleNavigate]);
const toggleOpen = useCallback((key: string) => {
setOpenKeys((prev) => (prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]));
}, []);
@@ -257,8 +319,9 @@ export function OneOsAppShell({
return (
<div
className={`oneos-shell${collapsed ? ' oneos-shell--collapsed' : ''}`}
data-oneos-theme={theme}
className={`oneos-shell${collapsed ? ' oneos-shell--collapsed' : ''}${ruoyiSettings.darkSidebar ? ' oneos-shell--dark-sidebar' : ''}${ruoyiSettings.darkNavbar ? ' oneos-shell--dark-navbar' : ''}`}
data-oneos-theme={currentTheme}
data-ds-mode={currentTheme}
>
<aside className="oneos-shell-aside" aria-label="羚牛 OneOS 导航">
<div className="oneos-shell-brand">
@@ -267,8 +330,8 @@ export function OneOsAppShell({
<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" />
<stop offset="0%" stopColor="var(--oneos-primary, #533AFD)" />
<stop offset="100%" stopColor="var(--ln-primary-hover, #6346FF)" />
</linearGradient>
</defs>
<rect width="32" height="32" rx="9" fill="url(#oneosBrandGrad)" />
@@ -323,6 +386,18 @@ export function OneOsAppShell({
<input type="search" placeholder="搜索" aria-label="搜索" />
<kbd> K</kbd>
</label>
{/* ⚙️ 若依偏好设置按钮 */}
<button
type="button"
className="oneos-shell-icon-btn"
aria-label="偏好设置"
title="若依偏好设置"
onClick={() => setSettingsOpen(true)}
>
<Settings size={16} />
</button>
{onOpenReleaseNotes ? (
<button
type="button"
@@ -344,11 +419,11 @@ export function OneOsAppShell({
<button
type="button"
className="oneos-shell-icon-btn"
aria-label={theme === 'dark' ? '切换浅色模式' : '切换暗色模式'}
title={theme === 'dark' ? '浅色模式' : '暗色模式'}
aria-label={currentTheme === 'dark' ? '切换浅色模式' : '切换暗色模式'}
title={currentTheme === 'dark' ? '浅色模式' : '暗色模式'}
onClick={toggleTheme}
>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
{currentTheme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
<button type="button" className="oneos-shell-icon-btn" aria-label="全屏" title="全屏(演示)">
<Maximize size={16} />
@@ -359,6 +434,7 @@ export function OneOsAppShell({
onRead={onNoticeRead}
onOpen={onNoticeOpen}
onHandle={onNoticeHandle}
onViewAll={onNoticeViewAll}
/>
<button type="button" className="oneos-shell-avatar" aria-label="超级管理员">
<img src={AVATAR_URL} alt="" width={28} height={28} />
@@ -406,6 +482,15 @@ export function OneOsAppShell({
<div className="oneos-shell-content">{children}</div>
</div>
{/* 若依偏好设置 Drawer 面板 */}
<RuoYiSettingsDrawer
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
settings={ruoyiSettings}
onChange={setRuoyiSettings}
onReset={() => setRuoyiSettings(DEFAULT_RUOYI_SETTINGS)}
/>
</div>
);
}

View File

@@ -0,0 +1,351 @@
import React, { useCallback, useEffect, useState } from 'react';
import {
Check,
Copy,
Maximize2,
Minimize2,
Moon,
RefreshCw,
RotateCcw,
Settings,
Sun,
X,
Monitor,
Trash2,
} from 'lucide-react';
export interface RuoYiSettings {
themeMode: 'light' | 'dark' | 'system';
darkSidebar: boolean;
darkNavbar: boolean;
primaryColor: string;
borderRadius: number;
fontSize: number;
activeTab: 'appearance' | 'layout' | 'shortcuts' | 'general';
}
export const RUOYI_SETTINGS_STORAGE_KEY = 'ruoyi-oneos-settings-v2';
export const DEFAULT_RUOYI_SETTINGS: RuoYiSettings = {
themeMode: 'light',
darkSidebar: false,
darkNavbar: false,
primaryColor: '#533AFD',
borderRadius: 0.5,
fontSize: 14,
activeTab: 'appearance',
};
export const PRESET_COLORS = [
{ name: '默认', value: '#533AFD' },
{ name: '紫罗兰', value: '#722ED1' },
{ name: '樱花粉', value: '#EB2F96' },
{ name: '柠檬黄', value: '#FADB14' },
{ name: '天蓝色', value: '#409EFF' },
{ name: '浅绿色', value: '#13C2C2' },
{ name: '特色灰', value: '#595959' },
{ name: '深绿色', value: '#009688' },
{ name: '深蓝色', value: '#111936' },
{ name: '橙黄色', value: '#FA8C16' },
{ name: '玫瑰红', value: '#F5222D' },
{ name: '中性色', value: '#262626' },
{ name: '石板灰', value: '#1E293B' },
{ name: '中灰色', value: '#4B5563' },
];
export function readStoredRuoYiSettings(): RuoYiSettings {
if (typeof window === 'undefined') return DEFAULT_RUOYI_SETTINGS;
try {
const raw = window.localStorage.getItem(RUOYI_SETTINGS_STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
return { ...DEFAULT_RUOYI_SETTINGS, ...parsed };
}
} catch {
/* ignore parse error */
}
return DEFAULT_RUOYI_SETTINGS;
}
export function persistRuoYiSettings(settings: RuoYiSettings): void {
if (typeof window === 'undefined') return;
try {
window.localStorage.setItem(RUOYI_SETTINGS_STORAGE_KEY, JSON.stringify(settings));
} catch {
/* ignore */
}
}
export type RuoYiSettingsDrawerProps = {
open: boolean;
onClose: () => void;
settings: RuoYiSettings;
onChange: (next: RuoYiSettings) => void;
onReset: () => void;
};
export function RuoYiSettingsDrawer({
open,
onClose,
settings,
onChange,
onReset,
}: RuoYiSettingsDrawerProps) {
const [copied, setCopied] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && open) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [open, onClose]);
if (!open) return null;
const updateSetting = <K extends keyof RuoYiSettings>(key: K, value: RuoYiSettings[K]) => {
const next = { ...settings, [key]: value };
onChange(next);
};
const handleCopySettings = () => {
const jsonStr = JSON.stringify(settings, null, 2);
navigator.clipboard.writeText(jsonStr).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (
<div className="ruoyi-settings-overlay" onClick={onClose}>
<aside
className={`ruoyi-settings-drawer${isFullscreen ? ' is-fullscreen' : ''}`}
onClick={(e) => e.stopPropagation()}
aria-label="偏好设置"
>
{/* Header */}
<header className="ruoyi-settings-header">
<div className="ruoyi-settings-header__title">
<h3></h3>
<p> &amp; </p>
</div>
<div className="ruoyi-settings-header__actions">
<button
type="button"
className="ruoyi-settings-icon-btn"
title="重置偏好设置"
onClick={onReset}
>
<RotateCcw size={15} />
</button>
<button
type="button"
className="ruoyi-settings-icon-btn"
title={isFullscreen ? '还原' : '全屏'}
onClick={() => setIsFullscreen((v) => !v)}
>
{isFullscreen ? <Minimize2 size={15} /> : <Maximize2 size={15} />}
</button>
<button
type="button"
className="ruoyi-settings-icon-btn"
title="关闭"
onClick={onClose}
>
<X size={16} />
</button>
</div>
</header>
{/* Tabs */}
<nav className="ruoyi-settings-tabs">
{[
{ id: 'appearance', label: '外观' },
{ id: 'layout', label: '布局' },
{ id: 'shortcuts', label: '快捷键' },
{ id: 'general', label: '通用' },
].map((tab) => (
<button
key={tab.id}
type="button"
className={`ruoyi-settings-tab${settings.activeTab === tab.id ? ' is-active' : ''}`}
onClick={() => updateSetting('activeTab', tab.id as RuoYiSettings['activeTab'])}
>
{tab.label}
</button>
))}
</nav>
{/* Drawer Body */}
<div className="ruoyi-settings-body">
{settings.activeTab === 'appearance' && (
<div className="ruoyi-settings-group">
{/* 1. 主题模式 */}
<div className="ruoyi-settings-item">
<label className="ruoyi-settings-label"></label>
<div className="ruoyi-settings-options-segmented">
<button
type="button"
className={`ruoyi-segmented-btn${settings.themeMode === 'light' ? ' is-active' : ''}`}
onClick={() => updateSetting('themeMode', 'light')}
>
<Sun size={14} />
</button>
<button
type="button"
className={`ruoyi-segmented-btn${settings.themeMode === 'dark' ? ' is-active' : ''}`}
onClick={() => updateSetting('themeMode', 'dark')}
>
<Moon size={14} />
</button>
<button
type="button"
className={`ruoyi-segmented-btn${settings.themeMode === 'system' ? ' is-active' : ''}`}
onClick={() => updateSetting('themeMode', 'system')}
>
<Monitor size={14} />
</button>
</div>
</div>
{/* 2. 深色侧边栏 */}
<div className="ruoyi-settings-item ruoyi-settings-item--row">
<span className="ruoyi-settings-label"></span>
<label className="ruoyi-switch">
<input
type="checkbox"
checked={settings.darkSidebar}
onChange={(e) => updateSetting('darkSidebar', e.target.checked)}
/>
<span className="ruoyi-switch-slider" />
</label>
</div>
{/* 3. 深色顶栏 */}
<div className="ruoyi-settings-item ruoyi-settings-item--row">
<span className="ruoyi-settings-label"></span>
<label className="ruoyi-switch">
<input
type="checkbox"
checked={settings.darkNavbar}
onChange={(e) => updateSetting('darkNavbar', e.target.checked)}
/>
<span className="ruoyi-switch-slider" />
</label>
</div>
{/* 4. 内置主题推荐色盘 */}
<div className="ruoyi-settings-item">
<label className="ruoyi-settings-label"></label>
<div className="ruoyi-colors-grid">
{PRESET_COLORS.map((c) => {
const selected =
settings.primaryColor.toLowerCase() === c.value.toLowerCase();
return (
<button
key={c.value}
type="button"
className={`ruoyi-color-tile${selected ? ' is-selected' : ''}`}
onClick={() => updateSetting('primaryColor', c.value)}
title={`${c.name} (${c.value})`}
>
<span className="ruoyi-color-swatch" style={{ background: c.value }}>
{selected && <Check size={12} color="#fff" strokeWidth={3} />}
</span>
<span className="ruoyi-color-name">{c.name}</span>
</button>
);
})}
{/* 自定义颜色 */}
<label className="ruoyi-color-tile ruoyi-color-tile--custom" title="自定义颜色">
<span
className="ruoyi-color-swatch"
style={{ background: settings.primaryColor }}
>
<input
type="color"
value={settings.primaryColor}
onChange={(e) => updateSetting('primaryColor', e.target.value)}
className="ruoyi-color-picker-input"
/>
</span>
<span className="ruoyi-color-name"></span>
</label>
</div>
</div>
{/* 5. 圆角 Radius */}
<div className="ruoyi-settings-item">
<label className="ruoyi-settings-label"></label>
<div className="ruoyi-settings-options-segmented">
{[0, 0.25, 0.5, 0.75, 1].map((r) => (
<button
key={r}
type="button"
className={`ruoyi-segmented-btn${settings.borderRadius === r ? ' is-active' : ''}`}
onClick={() => updateSetting('borderRadius', r)}
>
{r}
</button>
))}
</div>
</div>
{/* 6. 字体大小 Font Size */}
<div className="ruoyi-settings-item">
<label className="ruoyi-settings-label"></label>
<div className="ruoyi-settings-options-segmented">
{[12, 13, 14, 15, 16].map((s) => (
<button
key={s}
type="button"
className={`ruoyi-segmented-btn${settings.fontSize === s ? ' is-active' : ''}`}
onClick={() => updateSetting('fontSize', s)}
>
{s}px
</button>
))}
</div>
</div>
</div>
)}
{settings.activeTab !== 'appearance' && (
<div className="ruoyi-settings-empty">
<p></p>
</div>
)}
</div>
{/* Footer */}
<footer className="ruoyi-settings-footer">
<button
type="button"
className="ruoyi-btn-copy"
onClick={handleCopySettings}
>
<Copy size={14} />
{copied ? '已复制偏好设置' : '复制偏好设置'}
</button>
<button
type="button"
className="ruoyi-btn-reset-cache"
onClick={() => {
onReset();
alert('已清空主题缓存并还原默认设置');
}}
>
<Trash2 size={13} />
&amp;
</button>
</footer>
</aside>
</div>
);
}

View File

@@ -1,8 +1,9 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Bell, BellOff, X } from 'lucide-react';
import { Bell, BellOff } from 'lucide-react';
import type { ShellNoticeItem } from './notice-bridge';
const MESSAGE_CENTER_HREF = '/prototypes/message-center';
/** 气泡/标签x条新通知请尽快处理阿拉伯数字 */
function formatNewMessageTip(count: number): string {
return `${count}条新通知,请尽快处理`;
@@ -15,7 +16,14 @@ function compareNoticePriority(a: ShellNoticeItem, b: ShellNoticeItem): number {
return b.time.localeCompare(a.time);
}
type AllTab = 'unread' | 'read';
function defaultNavigateToMessageCenter() {
if (typeof window === 'undefined') return;
if (window.parent !== window) {
window.parent.postMessage({ type: 'ONEOS_SHELL_NAV', href: MESSAGE_CENTER_HREF }, '*');
} else {
window.location.href = MESSAGE_CENTER_HREF;
}
}
type ShellNoticeCenterProps = {
notices: ShellNoticeItem[];
@@ -23,6 +31,8 @@ type ShellNoticeCenterProps = {
onRead: (notice: ShellNoticeItem) => void;
onOpen: (notice: ShellNoticeItem) => void;
onHandle: (notice: ShellNoticeItem) => void;
/** 「查看全部」:关闭面板后导航到消息中心;未传则默认跳转 /prototypes/message-center */
onViewAll?: () => void;
};
function NoticeListItem({
@@ -75,53 +85,31 @@ export function ShellNoticeCenter({
onRead,
onOpen: _onOpen,
onHandle,
onViewAll,
}: 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;
if (!open && !detailNotice) 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);
@@ -130,23 +118,12 @@ export function ShellNoticeCenter({
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]);
}, [open, detailNotice]);
const handleSelect = (notice: ShellNoticeItem) => {
onRead(notice);
setDetailNotice(notice);
setOpen(false);
setAllOpen(false);
};
const handleAction = (notice: ShellNoticeItem) => {
@@ -155,23 +132,28 @@ export function ShellNoticeCenter({
closeAll();
};
const handleViewAll = () => {
closeAll();
if (onViewAll) onViewAll();
else defaultNavigateToMessageCenter();
};
return (
<div className="oneos-shell-notify" ref={wrapRef} data-annotation-id="wb-notice">
{unreadCount > 0 && !open && !detailNotice && !allOpen ? (
{unreadCount > 0 && !open && !detailNotice ? (
<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' : ''}`}
className={`oneos-shell-icon-btn oneos-shell-icon-btn--notify${unreadCount > 0 ? ' has-unread' : ''}${open || detailNotice ? ' is-open' : ''}`}
aria-label={unreadCount > 0 ? formatNewMessageTip(unreadCount) : '通知'}
aria-expanded={open || !!detailNotice || allOpen}
aria-expanded={open || !!detailNotice}
aria-haspopup="dialog"
onClick={() => {
if (detailNotice || allOpen) {
if (detailNotice) {
setDetailNotice(null);
setAllOpen(false);
setOpen(false);
return;
}
@@ -181,7 +163,7 @@ export function ShellNoticeCenter({
<Bell size={16} />
</button>
{open && !detailNotice && !allOpen ? (
{open && !detailNotice ? (
<div className="oneos-shell-notify__panel" role="dialog" aria-label="通知中心">
<div className="oneos-shell-notify__header">
<h3 className="oneos-shell-notify__title">
@@ -190,15 +172,7 @@ export function ShellNoticeCenter({
<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 type="button" className="oneos-shell-notify__link" onClick={handleViewAll}>
</button>
</div>
@@ -236,88 +210,6 @@ export function ShellNoticeCenter({
</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">

View File

@@ -10,18 +10,18 @@
--oneos-aside-w-collapsed: 68px;
--oneos-header-h: 52px;
--oneos-tabs-h: 40px;
--oneos-primary: #32a06e;
--oneos-primary-soft: #3fb87c;
--oneos-ink: #1e293b;
--oneos-ink-soft: rgba(30, 41, 59, 0.88);
--oneos-muted: #64748b;
--oneos-border: #e2e8f0;
--oneos-bg: #f1f5f9;
--oneos-primary: #533afd;
--oneos-primary-soft: #e0e7ff;
--oneos-ink: #0a2540;
--oneos-ink-soft: #425466;
--oneos-muted: #627d98;
--oneos-border: #e3e8ee;
--oneos-bg: #f6f9fc;
--oneos-surface: #ffffff;
--oneos-aside-bg: linear-gradient(180deg, #ffffff 0%, #f8fafc 48%, #f1f5f9 100%);
--oneos-ease: cubic-bezier(0.22, 1, 0.36, 1);
--oneos-shadow-aside: 4px 0 24px rgba(15, 23, 42, 0.04);
--oneos-shadow-chrome: 0 1px 0 rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.03);
--oneos-shadow-aside: 4px 0 24px rgba(10, 37, 64, 0.04);
--oneos-shadow-chrome: 0 1px 0 rgba(10, 37, 64, 0.04), 0 8px 24px rgba(10, 37, 64, 0.03);
display: flex;
width: 100%;
@@ -43,29 +43,30 @@
-webkit-font-smoothing: antialiased;
}
/* 暗色:参照原型导航页VoltAgent 深色画布) */
/* 暗色:Stripe Dark Fintech SaaS 风格 */
.oneos-shell[data-oneos-theme='dark'],
html[data-oneos-theme='dark'] .oneos-shell {
--oneos-primary: #00d992;
--oneos-primary-soft: #2fd6a1;
--oneos-ink: #f2f2f2;
--oneos-ink-soft: rgba(242, 242, 242, 0.88);
--oneos-muted: #8b949e;
--oneos-border: #3d3a39;
--oneos-bg: #101010;
--oneos-surface: #1a1a1a;
--oneos-aside-bg: linear-gradient(180deg, #121212 0%, #101010 48%, #0c0c0c 100%);
--oneos-shadow-aside: 4px 0 24px rgba(0, 0, 0, 0.35);
--oneos-shadow-chrome: 0 1px 0 rgba(255, 255, 255, 0.04), 0 8px 24px rgba(0, 0, 0, 0.25);
html[data-oneos-theme='dark'] .oneos-shell,
html[data-ds-mode='dark'] .oneos-shell {
--oneos-primary: #533afd;
--oneos-primary-soft: rgba(83, 58, 253, 0.18);
--oneos-ink: #f7fafc;
--oneos-ink-soft: #a0aec0;
--oneos-muted: #718096;
--oneos-border: #23272f;
--oneos-bg: #0a0b0d;
--oneos-surface: #121418;
--oneos-aside-bg: linear-gradient(180deg, #121418 0%, #0e1014 48%, #0a0b0d 100%);
--oneos-shadow-aside: 4px 0 24px rgba(0, 0, 0, 0.4);
--oneos-shadow-chrome: 0 1px 0 rgba(255, 255, 255, 0.05), 0 8px 24px rgba(0, 0, 0, 0.3);
color-scheme: dark;
}
.oneos-shell[data-oneos-theme='dark'] .oneos-shell-brand {
background: linear-gradient(180deg, rgba(26, 26, 26, 0.95) 0%, rgba(16, 16, 16, 0.6) 100%);
background: linear-gradient(180deg, rgba(18, 20, 24, 0.95) 0%, rgba(10, 11, 13, 0.6) 100%);
}
.oneos-shell[data-oneos-theme='dark'] .oneos-shell-brand__text {
background: linear-gradient(135deg, #f2f2f2 0%, #00d992 55%, #2fd6a1 100%);
background: linear-gradient(135deg, #f7fafc 0%, #818cf8 55%, #533afd 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
@@ -221,7 +222,7 @@ html[data-oneos-theme='dark'] .oneos-shell {
font-weight: 700;
letter-spacing: -0.03em;
white-space: nowrap;
background: linear-gradient(135deg, #18181b 0%, #2b9260 55%, #32a06e 100%);
background: linear-gradient(135deg, #0a2540 0%, #533afd 55%, #6346ff 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
@@ -1519,6 +1520,452 @@ html[data-oneos-theme='dark'] .oneos-shell {
overflow: auto;
}
/* ——— 深色侧边栏 / 深色顶栏 特效重置 ——— */
.oneos-shell.oneos-shell--dark-sidebar .oneos-shell-aside {
background: linear-gradient(180deg, #121418 0%, #0e1014 48%, #0a0b0d 100%) !important;
color: #f7fafc !important;
border-right-color: #23272f !important;
}
.oneos-shell.oneos-shell--dark-sidebar .oneos-shell-brand {
background: rgba(18, 20, 24, 0.95) !important;
border-bottom-color: #23272f !important;
}
.oneos-shell.oneos-shell--dark-sidebar .oneos-shell-menu__btn {
color: #a0aec0 !important;
}
.oneos-shell.oneos-shell--dark-sidebar .oneos-shell-menu__btn:hover {
background: rgba(255, 255, 255, 0.06) !important;
color: #ffffff !important;
}
.oneos-shell.oneos-shell--dark-sidebar .oneos-shell-menu__btn.is-active {
background: rgba(83, 58, 253, 0.25) !important;
color: #ffffff !important;
}
.oneos-shell.oneos-shell--dark-navbar .oneos-shell-chrome {
background: #121418 !important;
border-bottom-color: #23272f !important;
color: #f7fafc !important;
}
.oneos-shell.oneos-shell--dark-navbar .oneos-shell-icon-btn {
color: #a0aec0 !important;
}
.oneos-shell.oneos-shell--dark-navbar .oneos-shell-icon-btn:hover {
background: rgba(255, 255, 255, 0.08) !important;
color: #ffffff !important;
}
.oneos-shell.oneos-shell--dark-navbar .oneos-shell-breadcrumb ol,
.oneos-shell.oneos-shell--dark-navbar .oneos-shell-breadcrumb .is-current {
color: #f7fafc !important;
}
/* ——— 若依 (RuoYi) 偏好设置 Drawer 抽屉面板 ——— */
.ruoyi-settings-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(10, 37, 64, 0.35);
backdrop-filter: blur(4px);
display: flex;
justify-content: flex-end;
animation: ruoyi-fade-in 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes ruoyi-fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.ruoyi-settings-drawer {
width: 380px;
max-width: 100vw;
height: 100%;
background: #ffffff;
box-shadow: -8px 0 32px rgba(10, 37, 64, 0.12);
display: flex;
flex-direction: column;
color: #0a2540;
font-size: 14px;
animation: ruoyi-slide-left 0.25s cubic-bezier(0.22, 1, 0.36, 1);
transition: width 0.25s ease, max-width 0.25s ease;
}
.ruoyi-settings-drawer.is-fullscreen {
width: 100vw;
max-width: 100vw;
}
html[data-oneos-theme="dark"] .ruoyi-settings-drawer,
html[data-ds-mode="dark"] .ruoyi-settings-drawer {
background: #121418;
color: #f7fafc;
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.5);
}
@keyframes ruoyi-slide-left {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.ruoyi-settings-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #e3e8ee;
background: #f8fafc;
}
html[data-oneos-theme="dark"] .ruoyi-settings-header,
html[data-ds-mode="dark"] .ruoyi-settings-header {
background: #16181f;
border-bottom-color: #23272f;
}
.ruoyi-settings-header__title h3 {
margin: 0;
font-size: 16px;
font-weight: 700;
color: inherit;
}
.ruoyi-settings-header__title p {
margin: 2px 0 0;
font-size: 12px;
color: #627d98;
}
.ruoyi-settings-header__actions {
display: flex;
align-items: center;
gap: 6px;
}
.ruoyi-settings-icon-btn {
display: inline-grid;
place-items: center;
width: 32px;
height: 32px;
border: none;
border-radius: 8px;
background: transparent;
color: #627d98;
cursor: pointer;
transition: all 0.15s ease;
}
.ruoyi-settings-icon-btn:hover {
background: #e0e7ff;
color: var(--oneos-primary, #533afd);
}
.ruoyi-settings-tabs {
display: flex;
align-items: center;
gap: 8px;
padding: 0 20px;
border-bottom: 1px solid #e3e8ee;
background: #ffffff;
}
html[data-oneos-theme="dark"] .ruoyi-settings-tabs,
html[data-ds-mode="dark"] .ruoyi-settings-tabs {
background: #121418;
border-bottom-color: #23272f;
}
.ruoyi-settings-tab {
padding: 12px 14px;
border: none;
border-bottom: 2px solid transparent;
background: transparent;
color: #627d98;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.ruoyi-settings-tab:hover {
color: #0a2540;
}
.ruoyi-settings-tab.is-active {
color: var(--oneos-primary, #533afd);
border-bottom-color: var(--oneos-primary, #533afd);
font-weight: 600;
}
.ruoyi-settings-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.ruoyi-settings-group {
display: flex;
flex-direction: column;
gap: 20px;
}
.ruoyi-settings-item {
display: flex;
flex-direction: column;
gap: 8px;
}
.ruoyi-settings-item--row {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.ruoyi-settings-label {
font-size: 13px;
font-weight: 600;
color: inherit;
}
/* 分段按钮 Segmented */
.ruoyi-settings-options-segmented {
display: flex;
align-items: center;
gap: 4px;
padding: 3px;
border-radius: 8px;
background: #f1f5f9;
border: 1px solid #e2e8f0;
}
html[data-oneos-theme="dark"] .ruoyi-settings-options-segmented,
html[data-ds-mode="dark"] .ruoyi-settings-options-segmented {
background: #1a1e26;
border-color: #2a2f3d;
}
.ruoyi-segmented-btn {
flex: 1;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 6px 10px;
border: none;
border-radius: 6px;
background: transparent;
color: #627d98;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.ruoyi-segmented-btn:hover {
color: #0a2540;
}
.ruoyi-segmented-btn.is-active {
background: #ffffff;
color: var(--oneos-primary, #533afd);
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
}
html[data-oneos-theme="dark"] .ruoyi-segmented-btn.is-active,
html[data-ds-mode="dark"] .ruoyi-segmented-btn.is-active {
background: #23272f;
color: #ffffff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
/* 开关 Switch */
.ruoyi-switch {
position: relative;
display: inline-block;
width: 44px;
height: 22px;
}
.ruoyi-switch input {
opacity: 0;
width: 0;
height: 0;
}
.ruoyi-switch-slider {
position: absolute;
cursor: pointer;
inset: 0;
background-color: #cbd5e1;
transition: 0.2s;
border-radius: 999px;
}
.ruoyi-switch-slider:before {
position: absolute;
content: "";
height: 18px;
width: 18px;
left: 2px;
bottom: 2px;
background-color: white;
transition: 0.2s;
border-radius: 50%;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
}
.ruoyi-switch input:checked + .ruoyi-switch-slider {
background-color: var(--oneos-primary, #533afd);
}
.ruoyi-switch input:checked + .ruoyi-switch-slider:before {
transform: translateX(22px);
}
/* 色盘 Grid */
.ruoyi-colors-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.ruoyi-color-tile {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid #e3e8ee;
border-radius: 8px;
background: #f8fafc;
cursor: pointer;
transition: all 0.15s ease;
outline: none;
}
html[data-oneos-theme="dark"] .ruoyi-color-tile,
html[data-ds-mode="dark"] .ruoyi-color-tile {
background: #16181f;
border-color: #23272f;
}
.ruoyi-color-tile:hover {
border-color: var(--oneos-primary, #533afd);
transform: translateY(-1px);
}
.ruoyi-color-tile.is-selected {
border-color: var(--oneos-primary, #533afd);
background: #ffffff;
box-shadow: 0 0 0 2px color-mix(in srgb, var(--oneos-primary, #533afd) 20%, transparent);
}
html[data-oneos-theme="dark"] .ruoyi-color-tile.is-selected,
html[data-ds-mode="dark"] .ruoyi-color-tile.is-selected {
background: #1e222d;
}
.ruoyi-color-swatch {
position: relative;
width: 22px;
height: 22px;
border-radius: 6px;
flex-shrink: 0;
display: grid;
place-items: center;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
}
.ruoyi-color-name {
font-size: 12px;
color: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ruoyi-color-tile--custom {
position: relative;
cursor: pointer;
}
.ruoyi-color-picker-input {
position: absolute;
inset: 0;
opacity: 0;
width: 100%;
height: 100%;
cursor: pointer;
}
.ruoyi-settings-empty {
padding: 40px 20px;
text-align: center;
color: #627d98;
}
.ruoyi-settings-footer {
display: flex;
flex-direction: column;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid #e3e8ee;
background: #f8fafc;
}
html[data-oneos-theme="dark"] .ruoyi-settings-footer,
html[data-ds-mode="dark"] .ruoyi-settings-footer {
background: #16181f;
border-top-color: #23272f;
}
.ruoyi-btn-copy {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
width: 100%;
height: 38px;
border: none;
border-radius: 8px;
background: #10b981;
color: #ffffff;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s ease;
}
.ruoyi-btn-copy:hover {
background: #059669;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.3);
}
.ruoyi-btn-reset-cache {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
width: 100%;
height: 32px;
border: none;
background: transparent;
color: #627d98;
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.ruoyi-btn-reset-cache:hover {
color: #ef4444;
}
@media (max-width: 900px) {
.oneos-shell-search {
min-width: 0;

View File

@@ -1,10 +1,16 @@
/**
* OneOS 浅色 / 暗色主题localStorage + 跨 iframe postMessage
* OneOS 浅色 / 暗色主题 & RuoYi 偏好设置localStorage + 跨 iframe postMessage
* 暗色参照原型导航页VoltAgent 深色画布)。
* 不对「旧 ONEOS」目录下原型生效。
*/
import './oneos-theme-content.css';
import {
type RuoYiSettings,
readStoredRuoYiSettings,
persistRuoYiSettings,
DEFAULT_RUOYI_SETTINGS,
} from './RuoYiSettingsDrawer';
export type OneOsTheme = 'light' | 'dark';
@@ -30,29 +36,101 @@ export function isLegacyOneOsPrototype(id?: string | null): boolean {
}
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 */
const settings = readStoredRuoYiSettings();
if (settings.themeMode === 'dark') return 'dark';
if (settings.themeMode === 'light') return 'light';
if (typeof window !== 'undefined') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
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 applyRuoYiSettingsToDocument(
doc: Document = document,
settings?: Partial<RuoYiSettings>,
) {
if (!doc || !doc.documentElement) return;
const currentSettings: RuoYiSettings = {
...readStoredRuoYiSettings(),
...settings,
};
const isDark =
currentSettings.themeMode === 'dark' ||
(currentSettings.themeMode === 'system' &&
typeof window !== 'undefined' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
const themeValue = isDark ? 'dark' : 'light';
doc.documentElement.setAttribute('data-oneos-theme', themeValue);
doc.documentElement.setAttribute('data-ds-mode', themeValue);
doc.documentElement.setAttribute('data-oneos-theme-mode', themeValue);
if (isDark) {
doc.documentElement.classList.add('dark');
if (doc.body) doc.body.classList.add('dark');
} else {
doc.documentElement.classList.remove('dark');
if (doc.body) doc.body.classList.remove('dark');
}
// 注入或更新主题 CSS 变量覆盖节点
let styleEl = doc.getElementById('oneos-ruoyi-theme-style-override') as HTMLStyleElement | null;
if (!styleEl) {
styleEl = doc.createElement('style');
styleEl.id = 'oneos-ruoyi-theme-style-override';
doc.head?.appendChild(styleEl);
}
const primary = currentSettings.primaryColor || '#533AFD';
const radius = `${Math.round((currentSettings.borderRadius ?? 0.5) * 16)}px`;
const fontSize = `${currentSettings.fontSize || 14}px`;
styleEl.textContent = `
:root, html, body, [data-oneos-theme], [data-ds-mode] {
--oneos-primary: ${primary} !important;
--ln-primary: ${primary} !important;
--el-color-primary: ${primary} !important;
--ln-primary-hover: ${primary} !important;
--ln-primary-focus: ${primary} !important;
--ruoyi-menu-active-text: ${primary} !important;
--oneos-radius: ${radius} !important;
--ln-radius: ${radius} !important;
font-size: ${fontSize} !important;
}
`;
}
export function persistOneOsTheme(theme: OneOsTheme) {
try {
window.localStorage.setItem(ONEOS_THEME_STORAGE_KEY, theme);
} catch {
/* ignore */
}
export function syncRuoYiSettingsToAllIframes(settings: RuoYiSettings) {
if (typeof document === 'undefined') return;
const frames = document.querySelectorAll('iframe');
frames.forEach((frame) => {
try {
if (frame.contentDocument) {
applyRuoYiSettingsToDocument(frame.contentDocument, settings);
}
} catch {
/* ignore cross-origin */
}
try {
const isDark =
settings.themeMode === 'dark' ||
(settings.themeMode === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
frame.contentWindow?.postMessage(
{
type: ONEOS_THEME_MESSAGE,
theme: isDark ? 'dark' : 'light',
settings,
},
'*',
);
} catch {
/* ignore */
}
});
}
export function broadcastOneOsTheme(theme: OneOsTheme) {
@@ -63,20 +141,24 @@ export function broadcastOneOsTheme(theme: OneOsTheme) {
} catch {
/* ignore */
}
const frames = document.querySelectorAll('iframe');
frames.forEach((frame) => {
try {
frame.contentWindow?.postMessage({ type: ONEOS_THEME_MESSAGE, theme }, '*');
} catch {
/* ignore */
}
});
const settings = readStoredRuoYiSettings();
syncRuoYiSettingsToAllIframes({ ...settings, themeMode: theme });
}
export function applyOneOsTheme(theme: OneOsTheme, root: HTMLElement = document.documentElement) {
const current = readStoredRuoYiSettings();
const nextSettings: RuoYiSettings = {
...current,
themeMode: theme,
};
persistRuoYiSettings(nextSettings);
applyRuoYiSettingsToDocument(root.ownerDocument || document, nextSettings);
}
export function setOneOsTheme(theme: OneOsTheme) {
applyOneOsTheme(theme);
persistOneOsTheme(theme);
broadcastOneOsTheme(theme);
const settings = readStoredRuoYiSettings();
syncRuoYiSettingsToAllIframes({ ...settings, themeMode: theme });
}
export function toggleOneOsTheme(current?: OneOsTheme): OneOsTheme {
@@ -87,7 +169,6 @@ export function toggleOneOsTheme(current?: OneOsTheme): OneOsTheme {
type BootstrapOptions = {
prototypeId?: string;
/** 强制跳过(如旧 ONEOS */
skip?: boolean;
};
@@ -98,25 +179,24 @@ 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);
const currentSettings = readStoredRuoYiSettings();
applyRuoYiSettingsToDocument(document, currentSettings);
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);
if (data.settings) {
applyRuoYiSettingsToDocument(document, data.settings);
} else if (data.theme === 'dark' || data.theme === 'light') {
applyRuoYiSettingsToDocument(document, { themeMode: data.theme });
}
});
window.addEventListener('storage', (event) => {
if (event.key !== ONEOS_THEME_STORAGE_KEY) return;
if (event.newValue === 'dark' || event.newValue === 'light') {
applyOneOsTheme(event.newValue);
if (event.key === RUOYI_SETTINGS_STORAGE_KEY) {
const nextSettings = readStoredRuoYiSettings();
applyRuoYiSettingsToDocument(document, nextSettings);
}
});
}

View File

@@ -0,0 +1,173 @@
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Calendar, X } from 'lucide-react';
import { O2Field } from './Field';
import { O2SingleCalendarPanel } from './calendar/CalendarPanel';
import { useDismissOnFocusOutside } from './hooks/useDismissOnFocusOutside';
import type { O2FieldProps } from './types';
export type O2DatePickerProps = O2FieldProps & {
value: string;
onChange: (value: string) => void;
placeholder?: string;
allowClear?: boolean;
ariaLabel?: string;
};
export function O2DatePicker({
label,
required,
help,
error,
className,
style,
size = 'md',
disabled,
id,
value,
onChange,
placeholder = 'YYYY-MM-DD',
allowClear = true,
ariaLabel,
}: O2DatePickerProps) {
const autoId = useId();
const fieldId = id || autoId;
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0, width: 280 });
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const dismiss = useCallback(() => setOpen(false), []);
const updatePos = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const width = Math.max(280, rect.width);
let left = rect.left;
if (left + width > window.innerWidth - 8) {
left = Math.max(8, window.innerWidth - 8 - width);
}
let top = rect.bottom + 6;
if (top + 340 > window.innerHeight - 8 && rect.top > 340) {
top = Math.max(8, rect.top - 340 - 6);
}
setPos({ top, left, width });
}, []);
useDismissOnFocusOutside(open, [anchorRef, popoverRef], dismiss);
useEffect(() => {
if (!open) return;
updatePos();
window.addEventListener('resize', updatePos);
window.addEventListener('scroll', updatePos, true);
return () => {
window.removeEventListener('resize', updatePos);
window.removeEventListener('scroll', updatePos, true);
};
}, [open, updatePos]);
const openPicker = () => {
if (disabled) return;
setOpen(true);
window.requestAnimationFrame(updatePos);
};
const controlClass = [
'o2-control',
'o2-control--picker',
`o2-control--${size}`,
open ? 'is-open' : '',
error ? 'o2-control--error' : '',
disabled ? 'is-disabled' : '',
]
.filter(Boolean)
.join(' ');
const popover =
open && typeof document !== 'undefined'
? createPortal(
<div
ref={popoverRef}
className="o2-popover o2-popover--portal o2-popover--calendar"
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
<O2SingleCalendarPanel
value={value}
onChange={onChange}
onComplete={dismiss}
/>
<div className="o2-cal__footer">
<button
type="button"
className="o2-cal__chip"
onClick={() => {
const d = new Date();
const iso = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
onChange(iso);
dismiss();
}}
>
</button>
</div>
</div>,
document.body,
)
: null;
return (
<O2Field
label={label}
required={required}
help={help}
error={error}
className={className}
style={style}
htmlFor={fieldId}
>
<div className="o2-picker" ref={anchorRef}>
<div className={controlClass} onClick={openPicker}>
<Calendar size={14} className="o2-control__leading" aria-hidden />
<input
id={fieldId}
type="text"
className="o2-control__input o2-control--tabular"
value={value}
placeholder={placeholder}
readOnly
disabled={disabled}
aria-expanded={open}
aria-invalid={error ? true : undefined}
aria-label={ariaLabel || (typeof label === 'string' ? label : '日期')}
onFocus={openPicker}
onKeyDown={(e) => {
if (e.key === 'Escape') dismiss();
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openPicker();
}
}}
/>
{allowClear && value && !disabled ? (
<button
type="button"
className="o2-control__icon-btn"
aria-label="清空"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
onChange('');
dismiss();
}}
>
<X size={14} aria-hidden />
</button>
) : null}
</div>
{popover}
</div>
</O2Field>
);
}

View File

@@ -0,0 +1,180 @@
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Calendar, X } from 'lucide-react';
import { O2Field } from './Field';
import { O2RangeCalendarPanel } from './calendar/CalendarPanel';
import { useDismissOnFocusOutside } from './hooks/useDismissOnFocusOutside';
import type { DateRangeValue, O2FieldProps } from './types';
export type { DateRangeValue };
export type O2DateRangePickerProps = O2FieldProps & {
startDate: string;
endDate: string;
onChange: (range: DateRangeValue) => void;
startPlaceholder?: string;
endPlaceholder?: string;
allowClear?: boolean;
ariaLabel?: string;
};
const POPOVER_MIN_WIDTH = 560;
export function O2DateRangePicker({
label,
required,
help,
error,
className,
style,
size = 'md',
disabled,
id,
startDate,
endDate,
onChange,
startPlaceholder = '开始日期',
endPlaceholder = '结束日期',
allowClear = true,
ariaLabel = '日期范围',
}: O2DateRangePickerProps) {
const autoId = useId();
const fieldId = id || autoId;
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0, width: POPOVER_MIN_WIDTH });
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const hasValue = Boolean(startDate || endDate);
const dismiss = useCallback(() => setOpen(false), []);
const updatePos = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const maxWidth = Math.max(160, window.innerWidth - 16);
const width = Math.min(Math.max(rect.width, POPOVER_MIN_WIDTH), maxWidth);
let left = rect.left;
if (left + width > window.innerWidth - 8) {
left = Math.max(8, window.innerWidth - 8 - width);
}
if (left < 8) left = 8;
let top = rect.bottom + 6;
if (top + 380 > window.innerHeight - 8 && rect.top > 380) {
top = Math.max(8, rect.top - 380 - 6);
}
setPos({ top, left, width });
}, []);
useDismissOnFocusOutside(open, [anchorRef, popoverRef], dismiss);
useEffect(() => {
if (!open) return;
updatePos();
window.addEventListener('resize', updatePos);
window.addEventListener('scroll', updatePos, true);
return () => {
window.removeEventListener('resize', updatePos);
window.removeEventListener('scroll', updatePos, true);
};
}, [open, updatePos]);
const openPicker = (which?: 'start' | 'end') => {
if (disabled) return;
setOpen(true);
window.requestAnimationFrame(updatePos);
void which;
};
const controlClass = [
'o2-control',
'o2-control--range',
`o2-control--${size}`,
open ? 'is-open' : '',
error ? 'o2-control--error' : '',
disabled ? 'is-disabled' : '',
]
.filter(Boolean)
.join(' ');
const popover =
open && typeof document !== 'undefined'
? createPortal(
<div
ref={popoverRef}
className="o2-popover o2-popover--portal o2-popover--calendar o2-popover--range"
style={{ top: pos.top, left: pos.left, width: pos.width }}
role="dialog"
aria-label={ariaLabel}
>
<O2RangeCalendarPanel
startDate={startDate}
endDate={endDate}
onChange={onChange}
onComplete={dismiss}
/>
</div>,
document.body,
)
: null;
return (
<O2Field
label={label}
required={required}
help={help}
error={error}
className={className}
style={style}
htmlFor={`${fieldId}-start`}
>
<div className="o2-picker o2-picker--range" ref={anchorRef}>
<div className={controlClass}>
<button
type="button"
className="o2-range__part"
id={`${fieldId}-start`}
disabled={disabled}
onClick={() => openPicker('start')}
aria-label={startPlaceholder}
>
<Calendar size={14} aria-hidden />
<span className={startDate ? 'o2-range__value' : 'o2-range__placeholder'}>
{startDate || startPlaceholder}
</span>
</button>
<span className="o2-range__sep"></span>
<button
type="button"
className="o2-range__part"
id={`${fieldId}-end`}
disabled={disabled}
onClick={() => openPicker('end')}
aria-label={endPlaceholder}
>
<Calendar size={14} aria-hidden />
<span className={endDate ? 'o2-range__value' : 'o2-range__placeholder'}>
{endDate || endPlaceholder}
</span>
</button>
{allowClear && hasValue && !disabled ? (
<button
type="button"
className="o2-control__icon-btn"
aria-label="清空"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
onChange({ startDate: '', endDate: '' });
dismiss();
}}
>
<X size={14} aria-hidden />
</button>
) : null}
</div>
{popover}
</div>
</O2Field>
);
}

View File

@@ -0,0 +1,38 @@
import React from 'react';
import type { O2FieldProps } from './types';
type Props = O2FieldProps & {
children: React.ReactNode;
htmlFor?: string;
};
export function O2Field({
label,
required,
help,
error,
className,
style,
children,
htmlFor,
}: Props) {
const classes = ['o2-field', className].filter(Boolean).join(' ');
return (
<div className={classes} style={style} data-invalid={error ? 'true' : undefined}>
{label ? (
<label className="o2-field__label" htmlFor={htmlFor}>
{label}
{required ? <span className="o2-field__required" aria-hidden>*</span> : null}
</label>
) : null}
{children}
{error ? (
<p className="o2-field__error" role="alert">
{error}
</p>
) : help ? (
<p className="o2-field__help">{help}</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,62 @@
import React, { forwardRef, useId } from 'react';
import { O2Field } from './Field';
import type { O2FieldProps } from './types';
export type O2InputProps = O2FieldProps &
Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size' | 'className' | 'style'> & {
tabularNums?: boolean;
inputClassName?: string;
};
export const O2Input = forwardRef<HTMLInputElement, O2InputProps>(function O2Input(
{
label,
required,
help,
error,
className,
style,
size = 'md',
disabled,
id,
tabularNums,
inputClassName,
...rest
},
ref,
) {
const autoId = useId();
const inputId = id || autoId;
const controlClass = [
'o2-control',
`o2-control--${size}`,
error ? 'o2-control--error' : '',
tabularNums ? 'o2-control--tabular' : '',
inputClassName,
]
.filter(Boolean)
.join(' ');
return (
<O2Field
label={label}
required={required}
help={help}
error={error}
className={className}
style={style}
htmlFor={inputId}
>
<div className={controlClass}>
<input
ref={ref}
id={inputId}
className="o2-control__input"
disabled={disabled}
aria-invalid={error ? true : undefined}
{...rest}
/>
</div>
</O2Field>
);
});

View File

@@ -0,0 +1,252 @@
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Check, ChevronDown, X } from 'lucide-react';
import { O2Field } from './Field';
import { useDismissOnFocusOutside } from './hooks/useDismissOnFocusOutside';
import type { O2FieldProps, O2SelectOption } from './types';
export type O2MultiSelectProps = O2FieldProps & {
value: string[];
options: O2SelectOption[] | string[];
onChange: (value: string[]) => void;
placeholder?: string;
searchable?: boolean;
ariaLabel?: string;
maxTagCount?: number;
};
function normalizeOptions(options: O2SelectOption[] | string[]): O2SelectOption[] {
return options.map((item) =>
typeof item === 'string' ? { value: item, label: item } : item,
);
}
export function O2MultiSelect({
label,
required,
help,
error,
className,
style,
size = 'md',
disabled,
id,
value,
options,
onChange,
placeholder = '请选择(可多选)',
searchable = true,
ariaLabel,
maxTagCount = 3,
}: O2MultiSelectProps) {
const autoId = useId();
const fieldId = id || autoId;
const opts = useMemo(() => normalizeOptions(options), [options]);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 });
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const selectedSet = useMemo(() => new Set(value), [value]);
const selectedLabels = useMemo(
() => opts.filter((o) => selectedSet.has(o.value)),
[opts, selectedSet],
);
const dismiss = useCallback(() => {
setOpen(false);
setQuery('');
}, []);
useDismissOnFocusOutside(open, [anchorRef, popoverRef], dismiss);
const updatePos = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setPos({ top: rect.bottom + 6, left: rect.left, width: Math.max(rect.width, 220) });
}, []);
useEffect(() => {
if (!open) return;
updatePos();
window.addEventListener('resize', updatePos);
window.addEventListener('scroll', updatePos, true);
return () => {
window.removeEventListener('resize', updatePos);
window.removeEventListener('scroll', updatePos, true);
};
}, [open, updatePos, query, value.length]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q || !searchable) return opts;
return opts.filter(
(o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q),
);
}, [opts, query, searchable]);
const openPicker = () => {
if (disabled) return;
setOpen(true);
window.requestAnimationFrame(() => {
updatePos();
inputRef.current?.focus();
});
};
const toggle = (next: string) => {
if (selectedSet.has(next)) {
onChange(value.filter((v) => v !== next));
} else {
onChange([...value, next]);
}
setQuery('');
};
const removeTag = (next: string, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
onChange(value.filter((v) => v !== next));
};
const clearAll = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
onChange([]);
};
const visibleTags = selectedLabels.slice(0, maxTagCount);
const overflow = selectedLabels.length - visibleTags.length;
const controlClass = [
'o2-control',
'o2-control--picker',
'o2-control--multi',
`o2-control--${size}`,
open ? 'is-open' : '',
error ? 'o2-control--error' : '',
disabled ? 'is-disabled' : '',
]
.filter(Boolean)
.join(' ');
const popover =
open && typeof document !== 'undefined'
? createPortal(
<div
ref={popoverRef}
className="o2-popover o2-popover--portal"
role="listbox"
aria-multiselectable
aria-label={ariaLabel || (typeof label === 'string' ? label : '多选')}
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
<div className="o2-popover__list">
{filtered.length === 0 ? (
<p className="o2-popover__empty"></p>
) : (
filtered.map((item) => {
const checked = selectedSet.has(item.value);
return (
<button
key={item.value}
type="button"
role="option"
aria-selected={checked}
disabled={item.disabled}
className={`o2-popover__option ${checked ? 'is-checked' : ''}`}
onClick={() => !item.disabled && toggle(item.value)}
>
<span className={`o2-check ${checked ? 'is-on' : ''}`} aria-hidden>
{checked ? <Check size={12} /> : null}
</span>
<span className="o2-popover__option-label">{item.label}</span>
</button>
);
})
)}
</div>
</div>,
document.body,
)
: null;
return (
<O2Field
label={label}
required={required}
help={help}
error={error}
className={className}
style={style}
htmlFor={fieldId}
>
<div className="o2-picker" ref={anchorRef}>
<div className={controlClass} onClick={openPicker}>
<div className="o2-multi-tags">
{visibleTags.map((tag) => (
<span key={tag.value} className="o2-tag">
{tag.label}
{!disabled ? (
<button
type="button"
className="o2-tag__remove"
aria-label={`移除${tag.label}`}
onClick={(e) => removeTag(tag.value, e)}
>
<X size={12} aria-hidden />
</button>
) : null}
</span>
))}
{overflow > 0 ? <span className="o2-tag o2-tag--more">+{overflow}</span> : null}
<input
ref={inputRef}
id={fieldId}
type="text"
className="o2-control__input o2-multi-input"
value={open && searchable ? query : ''}
placeholder={selectedLabels.length === 0 ? placeholder : ''}
readOnly={!searchable || !open}
disabled={disabled}
aria-expanded={open}
aria-invalid={error ? true : undefined}
onChange={(e) => {
if (!searchable) return;
setQuery(e.target.value);
if (!open) setOpen(true);
}}
onFocus={openPicker}
onKeyDown={(e) => {
if (e.key === 'Escape') dismiss();
if (e.key === 'Backspace' && !query && value.length > 0) {
onChange(value.slice(0, -1));
}
}}
/>
</div>
{value.length > 0 && !disabled ? (
<button
type="button"
className="o2-control__icon-btn"
aria-label="清空全部"
tabIndex={-1}
onClick={clearAll}
>
<X size={14} aria-hidden />
</button>
) : null}
<ChevronDown
size={14}
className={`o2-control__chevron ${open ? 'is-open' : ''}`}
aria-hidden
/>
</div>
{popover}
</div>
</O2Field>
);
}

View File

@@ -0,0 +1,37 @@
# OneOS V2 统一表单控件
路径:`src/common/oneos-v2-form/`
预览原型:`/prototypes/oneos-v2-form-kit`
## 导出
| 组件 | 文件 | 说明 |
|------|------|------|
| `O2Input` | `Input.tsx` | 单行输入 |
| `O2Select` | `Select.tsx` | 单选(可搜索) |
| `O2MultiSelect` | `MultiSelect.tsx` | 多选 Tag |
| `O2DatePicker` | `DatePicker.tsx` | 单日日历 |
| `O2DateRangePicker` | `DateRangePicker.tsx` | 开始「至」结束 · 双月 |
| `O2TimePicker` | `TimePicker.tsx` | `HH:mm` / `HH:mm:ss` |
样式:`oneos-v2-form.css`(依赖 `oneos-ds-tokens.css`
## 引用
```tsx
import '../../resources/design-system/oneos-ds-tokens.css';
import '../../common/oneos-v2-form/oneos-v2-form.css';
import {
O2Input,
O2Select,
O2MultiSelect,
O2DatePicker,
O2DateRangePicker,
O2TimePicker,
} from '../../common/oneos-v2-form';
```
主色 Stripe Violet `#533AFD`;高度 `md=36` / `sm=32`;圆角 8。
区间日期:开始框 +「至」+ 结束框 + 双月历(对标 `DateRangeFilterField`)。
预览:`/prototypes/oneos-v2-form-kit` · `/prototypes/oneos-v2?view=form-kit`

View File

@@ -0,0 +1,219 @@
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Check, ChevronDown, X } from 'lucide-react';
import { O2Field } from './Field';
import { useDismissOnFocusOutside } from './hooks/useDismissOnFocusOutside';
import type { O2FieldProps, O2SelectOption } from './types';
export type O2SelectProps = O2FieldProps & {
value: string;
options: O2SelectOption[] | string[];
onChange: (value: string) => void;
placeholder?: string;
searchable?: boolean;
allowClear?: boolean;
ariaLabel?: string;
};
function normalizeOptions(options: O2SelectOption[] | string[]): O2SelectOption[] {
return options.map((item) =>
typeof item === 'string' ? { value: item, label: item } : item,
);
}
export function O2Select({
label,
required,
help,
error,
className,
style,
size = 'md',
disabled,
id,
value,
options,
onChange,
placeholder = '请选择',
searchable = true,
allowClear = true,
ariaLabel,
}: O2SelectProps) {
const autoId = useId();
const fieldId = id || autoId;
const opts = useMemo(() => normalizeOptions(options), [options]);
const selected = opts.find((o) => o.value === value);
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 });
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const dismiss = useCallback(() => {
setOpen(false);
setQuery('');
}, []);
useDismissOnFocusOutside(open, [anchorRef, popoverRef], dismiss);
const updatePos = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setPos({ top: rect.bottom + 6, left: rect.left, width: rect.width });
}, []);
useEffect(() => {
if (!open) return;
updatePos();
window.addEventListener('resize', updatePos);
window.addEventListener('scroll', updatePos, true);
return () => {
window.removeEventListener('resize', updatePos);
window.removeEventListener('scroll', updatePos, true);
};
}, [open, updatePos, query]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q || !searchable) return opts;
return opts.filter(
(o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q),
);
}, [opts, query, searchable]);
const openPicker = () => {
if (disabled) return;
setOpen(true);
setQuery('');
window.requestAnimationFrame(() => {
updatePos();
if (searchable) inputRef.current?.focus();
});
};
const pick = (next: string) => {
onChange(next);
dismiss();
};
const clear = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
onChange('');
dismiss();
};
const display = open && searchable ? query : selected?.label || '';
const controlClass = [
'o2-control',
'o2-control--picker',
`o2-control--${size}`,
open ? 'is-open' : '',
error ? 'o2-control--error' : '',
disabled ? 'is-disabled' : '',
]
.filter(Boolean)
.join(' ');
const popover =
open && typeof document !== 'undefined'
? createPortal(
<div
ref={popoverRef}
className="o2-popover o2-popover--portal"
role="listbox"
aria-label={ariaLabel || (typeof label === 'string' ? label : '选择')}
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
<div className="o2-popover__list">
{filtered.length === 0 ? (
<p className="o2-popover__empty"></p>
) : (
filtered.map((item) => {
const checked = item.value === value;
return (
<button
key={item.value}
type="button"
role="option"
aria-selected={checked}
disabled={item.disabled}
className={`o2-popover__option ${checked ? 'is-checked' : ''}`}
onClick={() => !item.disabled && pick(item.value)}
>
<span className="o2-popover__option-label">{item.label}</span>
{checked ? <Check size={14} aria-hidden /> : null}
</button>
);
})
)}
</div>
</div>,
document.body,
)
: null;
return (
<O2Field
label={label}
required={required}
help={help}
error={error}
className={className}
style={style}
htmlFor={fieldId}
>
<div className="o2-picker" ref={anchorRef}>
<div className={controlClass} onClick={openPicker}>
<input
ref={inputRef}
id={fieldId}
type="text"
className="o2-control__input"
value={display}
placeholder={placeholder}
readOnly={!searchable || !open}
disabled={disabled}
aria-expanded={open}
aria-invalid={error ? true : undefined}
aria-label={ariaLabel || (typeof label === 'string' ? label : undefined)}
onChange={(e) => {
if (!searchable) return;
setQuery(e.target.value);
if (!open) setOpen(true);
}}
onFocus={openPicker}
onKeyDown={(e) => {
if (e.key === 'Escape') dismiss();
if (e.key === 'Enter' || e.key === ' ') {
if (!open) {
e.preventDefault();
openPicker();
}
}
}}
/>
{allowClear && value && !disabled ? (
<button
type="button"
className="o2-control__icon-btn"
aria-label="清空"
tabIndex={-1}
onClick={clear}
>
<X size={14} aria-hidden />
</button>
) : null}
<ChevronDown
size={14}
className={`o2-control__chevron ${open ? 'is-open' : ''}`}
aria-hidden
/>
</div>
{popover}
</div>
</O2Field>
);
}

View File

@@ -0,0 +1,250 @@
import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { Clock, X } from 'lucide-react';
import { O2Field } from './Field';
import { useDismissOnFocusOutside } from './hooks/useDismissOnFocusOutside';
import type { O2FieldProps } from './types';
export type O2TimePickerProps = O2FieldProps & {
value: string;
onChange: (value: string) => void;
/** HH:mm 或 HH:mm:ss */
format?: 'HH:mm' | 'HH:mm:ss';
placeholder?: string;
allowClear?: boolean;
ariaLabel?: string;
};
function pad(n: number) {
return String(n).padStart(2, '0');
}
function parseTime(value: string, withSeconds: boolean) {
const parts = value.split(':').map((p) => Number(p));
return {
h: Number.isFinite(parts[0]) ? parts[0] : 0,
m: Number.isFinite(parts[1]) ? parts[1] : 0,
s: withSeconds && Number.isFinite(parts[2]) ? parts[2] : 0,
};
}
function formatTime(h: number, m: number, s: number, withSeconds: boolean) {
return withSeconds ? `${pad(h)}:${pad(m)}:${pad(s)}` : `${pad(h)}:${pad(m)}`;
}
function Column({
values,
selected,
onSelect,
label,
}: {
values: number[];
selected: number;
onSelect: (n: number) => void;
label: string;
}) {
return (
<div className="o2-time__col" role="listbox" aria-label={label}>
{values.map((n) => (
<button
key={n}
type="button"
role="option"
aria-selected={n === selected}
className={`o2-time__item ${n === selected ? 'is-selected' : ''}`}
onClick={() => onSelect(n)}
>
{pad(n)}
</button>
))}
</div>
);
}
export function O2TimePicker({
label,
required,
help,
error,
className,
style,
size = 'md',
disabled,
id,
value,
onChange,
format = 'HH:mm',
placeholder,
allowClear = true,
ariaLabel,
}: O2TimePickerProps) {
const withSeconds = format === 'HH:mm:ss';
const autoId = useId();
const fieldId = id || autoId;
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ top: 0, left: 0, width: 200 });
const anchorRef = useRef<HTMLDivElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const draft = useMemo(() => parseTime(value || '00:00:00', withSeconds), [value, withSeconds]);
const dismiss = useCallback(() => setOpen(false), []);
const updatePos = useCallback(() => {
const el = anchorRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const width = Math.max(withSeconds ? 220 : 180, rect.width);
let left = rect.left;
if (left + width > window.innerWidth - 8) {
left = Math.max(8, window.innerWidth - 8 - width);
}
let top = rect.bottom + 6;
if (top + 280 > window.innerHeight - 8 && rect.top > 280) {
top = Math.max(8, rect.top - 280 - 6);
}
setPos({ top, left, width });
}, [withSeconds]);
useDismissOnFocusOutside(open, [anchorRef, popoverRef], dismiss);
useEffect(() => {
if (!open) return;
updatePos();
window.addEventListener('resize', updatePos);
window.addEventListener('scroll', updatePos, true);
return () => {
window.removeEventListener('resize', updatePos);
window.removeEventListener('scroll', updatePos, true);
};
}, [open, updatePos]);
const openPicker = () => {
if (disabled) return;
setOpen(true);
window.requestAnimationFrame(updatePos);
};
const commit = (h: number, m: number, s: number) => {
onChange(formatTime(h, m, s, withSeconds));
};
const hours = useMemo(() => Array.from({ length: 24 }, (_, i) => i), []);
const minutes = useMemo(() => Array.from({ length: 60 }, (_, i) => i), []);
const seconds = minutes;
const controlClass = [
'o2-control',
'o2-control--picker',
`o2-control--${size}`,
open ? 'is-open' : '',
error ? 'o2-control--error' : '',
disabled ? 'is-disabled' : '',
]
.filter(Boolean)
.join(' ');
const popover =
open && typeof document !== 'undefined'
? createPortal(
<div
ref={popoverRef}
className="o2-popover o2-popover--portal o2-popover--time"
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
<div className="o2-time">
<Column
label="时"
values={hours}
selected={draft.h}
onSelect={(h) => commit(h, draft.m, draft.s)}
/>
<Column
label="分"
values={minutes}
selected={draft.m}
onSelect={(m) => commit(draft.h, m, draft.s)}
/>
{withSeconds ? (
<Column
label="秒"
values={seconds}
selected={draft.s}
onSelect={(s) => commit(draft.h, draft.m, s)}
/>
) : null}
</div>
<div className="o2-cal__footer">
<button
type="button"
className="o2-cal__chip"
onClick={() => {
const now = new Date();
commit(now.getHours(), now.getMinutes(), now.getSeconds());
dismiss();
}}
>
</button>
<button type="button" className="o2-cal__chip o2-cal__chip--primary" onClick={dismiss}>
</button>
</div>
</div>,
document.body,
)
: null;
return (
<O2Field
label={label}
required={required}
help={help}
error={error}
className={className}
style={style}
htmlFor={fieldId}
>
<div className="o2-picker" ref={anchorRef}>
<div className={controlClass} onClick={openPicker}>
<Clock size={14} className="o2-control__leading" aria-hidden />
<input
id={fieldId}
type="text"
className="o2-control__input o2-control--tabular"
value={value}
placeholder={placeholder || format}
readOnly
disabled={disabled}
aria-expanded={open}
aria-invalid={error ? true : undefined}
aria-label={ariaLabel || (typeof label === 'string' ? label : '时间')}
onFocus={openPicker}
onKeyDown={(e) => {
if (e.key === 'Escape') dismiss();
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openPicker();
}
}}
/>
{allowClear && value && !disabled ? (
<button
type="button"
className="o2-control__icon-btn"
aria-label="清空"
tabIndex={-1}
onClick={(e) => {
e.stopPropagation();
onChange('');
dismiss();
}}
>
<X size={14} aria-hidden />
</button>
) : null}
</div>
{popover}
</div>
</O2Field>
);
}

View File

@@ -0,0 +1,219 @@
import React, { useMemo, useState } from 'react';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import {
addMonths,
buildMonthCells,
formatISODate,
formatMonthTitle,
isDateInRange,
isRangeEnd,
isRangeStart,
normalizeDateRange,
parseISODate,
sameCalendarDay,
startOfMonth,
WEEKDAY_LABELS,
type DateRangeValue,
} from './dateUtils';
function MonthPanel({
monthDate,
start,
end,
hover,
selected,
mode,
onDayClick,
onDayHover,
}: {
monthDate: Date;
start: Date | null;
end: Date | null;
hover: Date | null;
selected: Date | null;
mode: 'single' | 'range';
onDayClick: (date: Date) => void;
onDayHover: (date: Date | null) => void;
}) {
const cells = useMemo(() => buildMonthCells(monthDate), [monthDate]);
const today = useMemo(() => new Date(), []);
return (
<div className="o2-cal__month">
<div className="o2-cal__month-title">{formatMonthTitle(monthDate)}</div>
<div className="o2-cal__weekdays" aria-hidden>
{WEEKDAY_LABELS.map((label) => (
<span key={label} className="o2-cal__weekday">
{label}
</span>
))}
</div>
<div className="o2-cal__days" role="grid">
{cells.map((cell) => {
const key = formatISODate(cell.date);
const isToday = sameCalendarDay(cell.date, today);
let classNames = ['o2-cal__day'];
if (!cell.inMonth) classNames.push('is-outside');
if (isToday) classNames.push('is-today');
if (mode === 'single') {
if (selected && sameCalendarDay(cell.date, selected)) classNames.push('is-selected');
} else {
const selectedStart = isRangeStart(cell.date, start, end, hover);
const selectedEnd = isRangeEnd(cell.date, start, end, hover);
const inRange = isDateInRange(cell.date, start, end, hover);
if (inRange) classNames.push('is-in-range');
if (selectedStart) classNames.push('is-range-start');
if (selectedEnd) classNames.push('is-range-end');
}
return (
<button
key={key}
type="button"
role="gridcell"
className={classNames.join(' ')}
aria-label={key}
aria-pressed={
mode === 'single'
? Boolean(selected && sameCalendarDay(cell.date, selected))
: isRangeStart(cell.date, start, end, hover) ||
isRangeEnd(cell.date, start, end, hover)
}
onMouseEnter={() => onDayHover(cell.date)}
onMouseLeave={() => onDayHover(null)}
onClick={() => onDayClick(cell.date)}
>
<span>{cell.date.getDate()}</span>
</button>
);
})}
</div>
</div>
);
}
export function O2SingleCalendarPanel({
value,
onChange,
onComplete,
}: {
value: string;
onChange: (next: string) => void;
onComplete?: () => void;
}) {
const selected = parseISODate(value);
const [viewMonth, setViewMonth] = useState(() => startOfMonth(selected || new Date()));
return (
<div className="o2-cal o2-cal--single">
<div className="o2-cal__nav">
<button
type="button"
className="o2-cal__nav-btn"
aria-label="上一月"
onClick={() => setViewMonth((prev) => addMonths(prev, -1))}
>
<ChevronLeft size={16} aria-hidden />
</button>
<button
type="button"
className="o2-cal__nav-btn"
aria-label="下一月"
onClick={() => setViewMonth((prev) => addMonths(prev, 1))}
>
<ChevronRight size={16} aria-hidden />
</button>
</div>
<MonthPanel
monthDate={viewMonth}
start={null}
end={null}
hover={null}
selected={selected}
mode="single"
onDayHover={() => undefined}
onDayClick={(date) => {
onChange(formatISODate(date));
onComplete?.();
}}
/>
</div>
);
}
export function O2RangeCalendarPanel({
startDate,
endDate,
onChange,
onComplete,
}: {
startDate: string;
endDate: string;
onChange: (range: DateRangeValue) => void;
onComplete?: () => void;
}) {
const start = parseISODate(startDate);
const end = parseISODate(endDate);
const [viewMonth, setViewMonth] = useState(() => startOfMonth(start || end || new Date()));
const [hoverDate, setHoverDate] = useState<Date | null>(null);
const leftMonth = viewMonth;
const rightMonth = addMonths(viewMonth, 1);
const handleDayClick = (date: Date) => {
const iso = formatISODate(date);
if (!startDate || (startDate && endDate)) {
onChange({ startDate: iso, endDate: '' });
return;
}
const next = normalizeDateRange(startDate, iso);
onChange(next);
onComplete?.();
};
return (
<div className="o2-cal o2-cal--range">
<div className="o2-cal__nav">
<button
type="button"
className="o2-cal__nav-btn"
aria-label="上一月"
onClick={() => setViewMonth((prev) => addMonths(prev, -1))}
>
<ChevronLeft size={16} aria-hidden />
</button>
<button
type="button"
className="o2-cal__nav-btn"
aria-label="下一月"
onClick={() => setViewMonth((prev) => addMonths(prev, 1))}
>
<ChevronRight size={16} aria-hidden />
</button>
</div>
<div className="o2-cal__body">
<MonthPanel
monthDate={leftMonth}
start={start}
end={end}
hover={hoverDate}
selected={null}
mode="range"
onDayClick={handleDayClick}
onDayHover={setHoverDate}
/>
<MonthPanel
monthDate={rightMonth}
start={start}
end={end}
hover={hoverDate}
selected={null}
mode="range"
onDayClick={handleDayClick}
onDayHover={setHoverDate}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
export const WEEKDAY_LABELS = ['日', '一', '二', '三', '四', '五', '六'] as const;
export interface DateRangeValue {
startDate: string;
endDate: string;
}
export function parseISODate(value: string): Date | null {
if (!value) return null;
const parts = value.split('-').map(Number);
if (parts.length !== 3) return null;
const [year, month, day] = parts;
if (!year || !month || !day) return null;
return new Date(year, month - 1, day);
}
export function formatISODate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
export function sameCalendarDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
export function addMonths(date: Date, count: number): Date {
return new Date(date.getFullYear(), date.getMonth() + count, 1);
}
export function startOfMonth(date: Date): Date {
return new Date(date.getFullYear(), date.getMonth(), 1);
}
export function compareISODate(a: string, b: string): number {
if (a === b) return 0;
return a < b ? -1 : 1;
}
export function normalizeDateRange(startDate: string, endDate: string): DateRangeValue {
if (!startDate || !endDate) return { startDate, endDate };
if (compareISODate(startDate, endDate) <= 0) return { startDate, endDate };
return { startDate: endDate, endDate: startDate };
}
export function isDateInRange(
date: Date,
start: Date | null,
end: Date | null,
hover: Date | null,
): boolean {
const rangeEnd = end || hover;
if (!start || !rangeEnd) return false;
const time = date.getTime();
const startTime = start.getTime();
const endTime = rangeEnd.getTime();
const min = Math.min(startTime, endTime);
const max = Math.max(startTime, endTime);
return time >= min && time <= max;
}
export function isRangeStart(
date: Date,
start: Date | null,
end: Date | null,
hover: Date | null,
): boolean {
if (!start) return false;
const rangeEnd = end || hover;
if (!rangeEnd) return sameCalendarDay(date, start);
const min = start.getTime() <= rangeEnd.getTime() ? start : rangeEnd;
return sameCalendarDay(date, min);
}
export function isRangeEnd(
date: Date,
start: Date | null,
end: Date | null,
hover: Date | null,
): boolean {
if (!start) return false;
const rangeEnd = end || hover;
if (!rangeEnd) return false;
const max = start.getTime() >= rangeEnd.getTime() ? start : rangeEnd;
return sameCalendarDay(date, max);
}
export function buildMonthCells(monthDate: Date): Array<{ date: Date; inMonth: boolean }> {
const year = monthDate.getFullYear();
const month = monthDate.getMonth();
const firstWeekday = new Date(year, month, 1).getDay();
const cells: Array<{ date: Date; inMonth: boolean }> = [];
for (let i = 0; i < firstWeekday; i += 1) {
const date = new Date(year, month, i - firstWeekday + 1);
cells.push({ date, inMonth: false });
}
for (let day = 1; day <= daysInMonth(year, month); day += 1) {
cells.push({ date: new Date(year, month, day), inMonth: true });
}
while (cells.length < 42) {
const last = cells[cells.length - 1].date;
const next = new Date(last.getFullYear(), last.getMonth(), last.getDate() + 1);
cells.push({ date: next, inMonth: false });
}
return cells;
}
function daysInMonth(year: number, month: number): number {
return new Date(year, month + 1, 0).getDate();
}
export function formatMonthTitle(monthDate: Date): string {
return `${monthDate.getFullYear()}${monthDate.getMonth() + 1}`;
}
export function pad2(n: number): string {
return String(n).padStart(2, '0');
}
export function formatTimeValue(hours: number, minutes: number, seconds?: number): string {
const h = pad2(hours);
const m = pad2(minutes);
if (seconds === undefined) return `${h}:${m}`;
return `${h}:${m}:${pad2(seconds)}`;
}
export function parseTimeValue(value: string): { hours: number; minutes: number; seconds: number } | null {
if (!value) return null;
const parts = value.split(':').map(Number);
if (parts.length < 2 || parts.some((n) => Number.isNaN(n))) return null;
const [hours, minutes, seconds = 0] = parts;
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59 || seconds < 0 || seconds > 59) {
return null;
}
return { hours, minutes, seconds };
}

View File

@@ -0,0 +1,45 @@
import { useEffect, type RefObject } from 'react';
function isInsideContainers(
target: EventTarget | null,
containers: Array<RefObject<HTMLElement | null>>,
): boolean {
const node = target as Node | null;
if (!node) return false;
return containers.some((ref) => ref.current?.contains(node));
}
export function useDismissOnFocusOutside(
open: boolean,
containerRef: RefObject<HTMLElement | null> | Array<RefObject<HTMLElement | null>>,
onDismiss: () => void,
) {
useEffect(() => {
if (!open) return;
const containers = Array.isArray(containerRef) ? containerRef : [containerRef];
const closeIfOutside = (target: EventTarget | null) => {
if (!isInsideContainers(target, containers)) {
onDismiss();
}
};
const handlePointerDown = (event: MouseEvent) => {
closeIfOutside(event.target);
};
const handleFocusIn = () => {
window.requestAnimationFrame(() => {
closeIfOutside(document.activeElement);
});
};
document.addEventListener('mousedown', handlePointerDown);
document.addEventListener('focusin', handleFocusIn);
return () => {
document.removeEventListener('mousedown', handlePointerDown);
document.removeEventListener('focusin', handleFocusIn);
};
}, [open, onDismiss, containerRef]);
}

View File

@@ -0,0 +1,28 @@
/**
* OneOS V2 Form Kit
*
* 新 V2 / 迁入页统一表单控件Input / Select / MultiSelect / Date / DateRange / Time
* 旧 vm 页可继续使用 FilterPickerField / DateRangeFilterField。
*/
import '../../resources/design-system/oneos-ds-tokens.css';
import './oneos-v2-form.css';
export { O2Field } from './Field';
export { O2Input } from './Input';
export type { O2InputProps } from './Input';
export { O2Select } from './Select';
export type { O2SelectProps } from './Select';
export { O2MultiSelect } from './MultiSelect';
export type { O2MultiSelectProps } from './MultiSelect';
export { O2DatePicker } from './DatePicker';
export type { O2DatePickerProps } from './DatePicker';
export { O2DateRangePicker } from './DateRangePicker';
export type { O2DateRangePickerProps, DateRangeValue } from './DateRangePicker';
export { O2TimePicker } from './TimePicker';
export type { O2TimePickerProps } from './TimePicker';
export type { O2ControlSize, O2FieldProps, O2SelectOption } from './types';
export {
O2SingleCalendarPanel,
O2RangeCalendarPanel,
} from './calendar/CalendarPanel';

View File

@@ -0,0 +1,549 @@
/**
* OneOS V2 Form Controls — Stripe Violet skin
* Import with: import '../../resources/design-system/oneos-ds-tokens.css'
* import './oneos-v2-form.css'
*/
.o2-field {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.o2-field__label {
font-size: 12px;
font-weight: 600;
color: var(--ln-body, #425466);
line-height: 1.33;
}
.o2-field__required {
color: var(--ln-error, #ef4444);
margin-left: 2px;
}
.o2-field__help,
.o2-field__error {
margin: 0;
font-size: 12px;
line-height: 1.33;
}
.o2-field__help {
color: var(--ln-muted, #627d98);
}
.o2-field__error {
color: var(--ln-error, #ef4444);
}
.o2-control {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
box-sizing: border-box;
border: 1px solid var(--ln-hairline, #e3e8ee);
border-radius: 8px;
background: var(--ln-canvas-soft, #f8fafc);
color: var(--ln-ink, #0a2540);
font-size: 13px;
font-weight: 500;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.o2-control--md {
min-height: 36px;
padding: 7px 12px;
}
.o2-control--sm {
min-height: 32px;
padding: 5px 10px;
font-size: 12px;
}
.o2-control:hover:not(.is-disabled):not(:focus-within):not(.o2-control--error) {
border-color: var(--ln-hairline-strong, #d4d4d8);
}
.o2-control:focus-within:not(.o2-control--error),
.o2-control.is-open:not(.o2-control--error) {
border-color: var(--ln-primary, #533afd);
box-shadow: 0 0 0 3px rgba(83, 58, 253, 0.2);
background: var(--ln-surface-card, #ffffff);
}
.o2-control--error {
border-color: var(--ln-error, #ef4444);
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
}
.o2-control.is-disabled,
.o2-control:disabled {
background: var(--ln-surface-strong, #f1f5f9);
color: var(--ln-muted, #627d98);
cursor: not-allowed;
opacity: 0.85;
}
.o2-control--tabular,
.o2-control--tabular .o2-control__input,
.o2-range__value {
font-variant-numeric: tabular-nums;
font-family: 'JetBrains Mono', SFMono-Regular, Consolas, monospace;
}
.o2-control__input {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
color: inherit;
font: inherit;
padding: 0;
width: 100%;
}
.o2-control__input::placeholder {
color: var(--ln-muted-soft, #9fb3c8);
font-weight: 400;
font-family: inherit;
}
.o2-control__leading {
flex-shrink: 0;
color: var(--ln-muted, #627d98);
}
.o2-control__chevron,
.o2-control__trailing-icon {
flex-shrink: 0;
color: var(--ln-muted, #627d98);
transition: transform 0.15s ease;
pointer-events: none;
}
.o2-control__chevron.is-open {
transform: rotate(180deg);
color: var(--ln-primary, #533afd);
}
.o2-control__input--tabular {
font-variant-numeric: tabular-nums;
font-family: 'JetBrains Mono', SFMono-Regular, Consolas, monospace;
}
.o2-cal__body {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.o2-popover--cal,
.o2-popover--cal-range,
.o2-popover--calendar,
.o2-popover--range,
.o2-popover--time {
border-radius: 12px;
padding: 0;
}
.o2-tag--more {
background: var(--ln-surface-strong, #f1f5f9);
color: var(--ln-body, #425466);
}
.o2-control--multi {
min-height: 36px;
height: auto;
align-items: flex-start;
padding-top: 6px;
padding-bottom: 6px;
}
.o2-control--multi.o2-control--sm {
min-height: 32px;
}
.o2-control__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--ln-muted, #627d98);
cursor: pointer;
padding: 0;
flex-shrink: 0;
}
.o2-control__icon-btn:hover {
background: var(--ln-surface-strong, #f1f5f9);
color: var(--ln-ink, #0a2540);
}
.o2-control--picker {
cursor: pointer;
}
.o2-picker {
position: relative;
width: 100%;
}
.o2-control--range {
display: flex;
align-items: center;
gap: 8px;
padding-right: 8px;
}
.o2-range__part {
flex: 1;
min-width: 0;
display: inline-flex;
align-items: center;
gap: 6px;
border: none;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
padding: 0 4px;
text-align: left;
}
.o2-range__part:disabled {
cursor: not-allowed;
}
.o2-range__sep {
flex-shrink: 0;
font-size: 12px;
font-weight: 700;
color: var(--ln-body, #425466);
padding: 0 2px;
}
.o2-range__placeholder {
color: var(--ln-muted-soft, #9fb3c8);
font-weight: 400;
font-family: inherit;
}
.o2-range__value {
color: var(--ln-ink, #0a2540);
}
.o2-popover {
background: var(--ln-surface-card, #ffffff);
border: 1px solid var(--ln-hairline, #e3e8ee);
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
z-index: 1200;
overflow: hidden;
}
.o2-popover--portal {
position: fixed;
}
.o2-popover__list {
max-height: 260px;
overflow: auto;
padding: 6px;
}
.o2-popover__empty {
margin: 0;
padding: 12px;
font-size: 12px;
color: var(--ln-muted, #627d98);
text-align: center;
}
.o2-popover__option {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
border: none;
background: transparent;
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
color: var(--ln-ink, #0a2540);
cursor: pointer;
text-align: left;
}
.o2-popover__option:hover:not(:disabled) {
background: var(--ln-surface-strong, #f1f5f9);
}
.o2-popover__option.is-checked {
background: var(--ln-primary-soft, #e0e7ff);
color: var(--ln-primary, #533afd);
font-weight: 600;
}
.o2-popover__option:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.o2-tags,
.o2-multi-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1;
min-width: 0;
align-items: center;
}
.o2-multi-input {
min-width: 72px;
flex: 1;
}
.o2-check {
width: 16px;
height: 16px;
border-radius: 4px;
border: 1px solid var(--ln-hairline-strong, #d4d4d8);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: #fff;
}
.o2-check.is-on {
background: var(--ln-primary, #533afd);
border-color: var(--ln-primary, #533afd);
}
.o2-tag {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 100%;
padding: 2px 8px;
border-radius: 9999px;
background: var(--ln-primary-soft, #e0e7ff);
color: var(--ln-primary, #533afd);
font-size: 11px;
font-weight: 600;
line-height: 1.4;
}
.o2-tag__remove {
display: inline-flex;
border: none;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0;
line-height: 1;
}
.o2-cal {
padding: 12px;
}
.o2-cal--range .o2-cal__body {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.o2-cal__nav {
display: flex;
justify-content: space-between;
margin-bottom: 8px;
}
.o2-cal__nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid var(--ln-hairline, #e3e8ee);
border-radius: 6px;
background: var(--ln-surface-card, #ffffff);
color: var(--ln-body, #425466);
cursor: pointer;
}
.o2-cal__nav-btn:hover {
border-color: var(--ln-primary, #533afd);
color: var(--ln-primary, #533afd);
}
.o2-cal__month-title {
text-align: center;
font-size: 13px;
font-weight: 700;
color: var(--ln-ink, #0a2540);
margin-bottom: 8px;
}
.o2-cal__weekdays,
.o2-cal__days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.o2-cal__weekday {
text-align: center;
font-size: 11px;
color: var(--ln-muted, #627d98);
padding: 4px 0;
}
.o2-cal__day {
width: 100%;
aspect-ratio: 1;
max-width: 36px;
margin: 0 auto;
border: none;
border-radius: 6px;
background: transparent;
color: var(--ln-ink, #0a2540);
font-size: 12px;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.o2-cal__day.is-outside {
color: var(--ln-hairline-strong, #d4d4d8);
}
.o2-cal__day.is-today {
border: 1px solid var(--ln-primary, #533afd);
color: var(--ln-primary, #533afd);
font-weight: 700;
}
.o2-cal__day.is-selected,
.o2-cal__day.is-range-start,
.o2-cal__day.is-range-end {
background: var(--ln-primary, #533afd);
color: #ffffff;
font-weight: 700;
}
.o2-cal__day.is-in-range {
background: var(--ln-primary-soft, #e0e7ff);
color: var(--ln-primary, #533afd);
border-radius: 0;
}
.o2-cal__day:hover:not(.is-selected):not(.is-range-start):not(.is-range-end) {
background: var(--ln-surface-strong, #f1f5f9);
}
.o2-cal__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 8px 12px 12px;
border-top: 1px solid var(--ln-hairline, #e3e8ee);
}
.o2-cal__chip {
border: 1px solid var(--ln-hairline, #e3e8ee);
background: var(--ln-surface-card, #ffffff);
color: var(--ln-ink, #0a2540);
border-radius: 6px;
padding: 4px 10px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.o2-cal__chip--primary {
border: none;
background: var(--ln-primary, #533afd);
color: #ffffff;
}
.o2-time {
display: flex;
max-height: 220px;
overflow: hidden;
}
.o2-time__col {
flex: 1;
overflow-y: auto;
border-right: 1px solid var(--ln-hairline, #e3e8ee);
padding: 4px;
}
.o2-time__col:last-child {
border-right: none;
}
.o2-time__item {
width: 100%;
border: none;
background: transparent;
border-radius: 6px;
padding: 6px 0;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: var(--ln-ink, #0a2540);
cursor: pointer;
}
.o2-time__item:hover {
background: var(--ln-surface-strong, #f1f5f9);
}
.o2-time__item.is-selected {
background: var(--ln-primary-soft, #e0e7ff);
color: var(--ln-primary, #533afd);
font-weight: 700;
}
[data-ds-mode='dark'] .o2-control,
[data-oneos-theme='dark'] .o2-control {
background: #1a1d24;
border-color: var(--ln-hairline, #23272f);
color: var(--ln-ink, #f7fafc);
}
[data-ds-mode='dark'] .o2-control:focus-within,
[data-oneos-theme='dark'] .o2-control:focus-within,
[data-ds-mode='dark'] .o2-control.is-open,
[data-oneos-theme='dark'] .o2-control.is-open {
background: #121418;
}
[data-ds-mode='dark'] .o2-control.is-disabled,
[data-oneos-theme='dark'] .o2-control.is-disabled {
background: #16181f;
}
[data-ds-mode='dark'] .o2-popover,
[data-oneos-theme='dark'] .o2-popover {
background: #121418;
border-color: #23272f;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
[data-ds-mode='dark'] .o2-cal__day.is-outside,
[data-oneos-theme='dark'] .o2-cal__day.is-outside {
color: #4a5568;
}

View File

@@ -0,0 +1,28 @@
import type { CSSProperties, ReactNode } from 'react';
/** 筛选区 32px / 表单区 36px */
export type O2ControlSize = 'sm' | 'md';
export type O2SelectOption = {
value: string;
label: string;
disabled?: boolean;
};
export type O2FieldProps = {
label?: ReactNode;
required?: boolean;
help?: ReactNode;
error?: ReactNode;
className?: string;
style?: CSSProperties;
/** 控件尺寸sm=筛选 32pxmd=表单 36px默认 */
size?: O2ControlSize;
disabled?: boolean;
id?: string;
};
export type DateRangeValue = {
startDate: string;
endDate: string;
};

View File

@@ -2,6 +2,86 @@ import type { ApprovalCardItem } from '../types';
import { CURRENT_USER } from '../types';
export const MOCK_APPROVAL_CASES: ApprovalCardItem[] = [
{
id: 'ac-vp-normal',
type: 'vehicle_procurement',
typeLabel: '车辆采购',
status: 'processing',
listTab: 'todo',
title: 'HT-CG-2026-0102',
subtitle: '福田智蓝新能源 · 智蓝氢能轻卡 4.5T',
keyFacts: [
{ label: '审批类型', value: '正常审批' },
{ label: '采购数量', value: '8 辆' },
{ label: '合同金额', value: '¥3,360,000.00', emphasis: true },
{ label: '审批节点', value: '部门负责人 → 分管领导' },
],
extraTags: ['正常审批'],
currentApprover: '赵总',
showInitiatorInFooter: true,
initiatedBy: '李采购',
initiatedAt: '2026-04-08 10:20',
},
{
id: 'ac-vp-abnormal',
type: 'vehicle_procurement',
typeLabel: '车辆采购',
status: 'pending',
listTab: 'todo',
title: 'LNGD-25-091',
subtitle: '现代汽车氢燃料电池系统(广州)· 136 辆 4.5T',
keyFacts: [
{ label: '审批类型', value: '非正常审批' },
{ label: '采购数量', value: '136 辆' },
{ label: '合同金额', value: '¥95,880,000.00', emphasis: true },
{ label: '审批节点', value: '部门负责人 → 财务 → 法务 → 分管领导' },
],
risks: [{ label: '金额超过 500 万阈值(四期付款·增资绑定)', level: 'high' }],
extraTags: ['非正常审批'],
currentApprover: '财务负责人',
showInitiatorInFooter: true,
initiatedBy: '王采购',
initiatedAt: '2026-07-18 16:00',
},
{
id: 'ac-vp-done',
type: 'vehicle_procurement',
typeLabel: '车辆采购',
status: 'approved',
listTab: 'done',
title: 'HT-CG-2026-0088',
subtitle: '东风商用车 · 苏龙18T氢能重卡',
keyFacts: [
{ label: '审批类型', value: '正常审批' },
{ label: '采购数量', value: '5 辆' },
{ label: '合同金额', value: '¥3,400,000.00' },
{ label: '后续', value: '已生成验车任务 YC-2026-0001' },
],
extraTags: ['正常审批', '已生成验车'],
handledBy: '赵总',
showInitiatorInFooter: true,
initiatedBy: '李采购',
initiatedAt: '2026-03-18 09:40',
},
{
id: 'ac-vp-initiated',
type: 'vehicle_procurement',
typeLabel: '车辆采购',
status: 'processing',
listTab: 'initiated',
title: 'HT-CG-2026-0102',
subtitle: '福田智蓝新能源 · 智蓝氢能轻卡 4.5T',
keyFacts: [
{ label: '审批类型', value: '正常审批' },
{ label: '合同金额', value: '¥3,360,000.00', emphasis: true },
{ label: '当前节点', value: '分管领导' },
],
extraTags: ['正常审批'],
currentApprover: '赵总',
showInitiatorInFooter: true,
initiatedBy: CURRENT_USER,
initiatedAt: '2026-04-08 10:20',
},
{
id: 'ac-22',
type: 'lease',

View File

@@ -1,48 +1,53 @@
# OperationActions · 列表操作列规范
> 完整规范见 `src/prototypes/vm-shared/DESIGN.md` · 操作列 OperationActions
> 完整规范见 `src/prototypes/vm-shared/DESIGN.md` · 操作列 OperationActions
> OneOS V2 同步见 `src/resources/design-system/DESIGN.md` §3.16
## 组件
- 路径:`src/common/OperationActions.tsx`
- 样式:`src/common/vm-operation-actions.css`(经 `vehicle-management/style.css` 全局引入)
- 样式:`src/common/vm-operation-actions.css`(经 `vehicle-management/style.css` 全局引入,或 V2 页单独引入
## 布局
## 布局(定稿)
```
[👁 详情] [✏️ 编辑] [⋮]
[✏️ 编辑] [🔧 处理] [⋮]
```
- **详情 / 编辑**:外显,带 Lucide 图标 + 文案,最小触控 44×44px
- **编辑 / 处理(处置)**:常用操作,**外侧**展示,带 Lucide 图标 + 文案,合计最多 2 个,最小触控 44×44px
- **查看记录 / 详情 / 操作记录等**:低频,收入「更多」下拉
- **更多**:仅图标 `MoreHorizontal`,点击 Ant Dropdown 展开其余操作
- 无详情/编辑时省略对应按钮;无任何操作时显示 `-`
- **只读例外**:若无可外显的编辑/处理,允许「详情」单独外显
- 无任何操作时显示 `-`
## 迁移步骤
1. 确认页面已 `import '../vehicle-management/style.css'`
1. 确认页面已引入 `vm-operation-actions.css`(或 `vehicle-management/style.css`
2. `import { OperationActions, splitOperationActions } from '../../common/OperationActions'`
3. 将原 `getOperationButtons` / 内联 `Button link` 改为:
3. 将原内联按钮改为:
```tsx
<OperationActions
view={{ onClick: () => ... }}
edit={canEdit ? { onClick: () => ... } : undefined}
process={{ label: '处置', onClick: () => ... }}
view={{ label: '查看记录', onClick: () => ... }}
more={[...]}
/>
```
4. 操作列宽建议 `168``fixed: 'right'`
5. `more` 中移除已外显的「详情」「编辑」项
5. 勿把已外显的「编辑」「处理」再放进 `more`
## 扁平列表迁移
```tsx
const { view, edit, more } = splitOperationActions(allItems);
return <OperationActions view={view} edit={edit} more={more} />;
const { edit, process, view, more } = splitOperationActions(allItems);
return <OperationActions edit={edit} process={process} view={view} more={more} />;
```
## 禁止
- 横排超过 2 个 `Button type="link"` 主操作
- 常用「编辑 / 处理」藏进更多,低频「查看记录」放外侧
- 横排超过 2 个主操作
- 文字「更多」链接触发器
- pipe `|` 分隔操作链接

View File

@@ -32,10 +32,10 @@ export const VEHICLE_OPS = {
};
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 },
{ plateNo: '粤BH2001', brandModel: '佛山飞驰·35T', opsStatus: VEHICLE_OPS.inventory },
{ plateNo: '粤BH2002', brandModel: '佛山飞驰·35T', opsStatus: VEHICLE_OPS.self_operated },
{ plateNo: '粤BH2003', brandModel: '未势能源·49T', opsStatus: VEHICLE_OPS.inventory },
{ plateNo: '粤BH2008', brandModel: '未势能源·49T', opsStatus: VEHICLE_OPS.inventory },
];
export const SEED_DRIVERS = [
@@ -310,7 +310,7 @@ function seedDispatchTasks() {
routePricing: '线路计价',
unitPrice: 1800,
quantity: 1,
plateNo: '粤B·H2002',
plateNo: '粤BH2002',
driver: '陈志强',
phone: '13800138001',
status: 'assigned',

View File

@@ -0,0 +1,6 @@
export * from './types';
export * from './sla';
export * from './notify';
export * from './seed';
export * from './storage';
export * from './stats';

View File

@@ -0,0 +1,88 @@
import type { FaultRecord, NotifyChannel, NotifyKind } from './types';
import { FAULT_STATUS_LABEL, OPS_SUPERVISOR_NAME } from './types';
import { deadlineOf, remainingDays } from './sla';
export function resolveNotifyRecipient(record: FaultRecord, kind: NotifyKind): string {
if (kind === 'overdue') return OPS_SUPERVISOR_NAME;
if (record.assignee?.trim()) return record.assignee;
return OPS_SUPERVISOR_NAME;
}
export function renderSms(record: FaultRecord, kind: NotifyKind): string {
const vehicle = `${record.plateNo} ${record.brand}${record.model}`;
if (kind === 'due_soon') {
return `【OneOS故障】${record.code} ${vehicle} 将于 ${deadlineOf(record.reportedAt)} 到期剩7天仍未归档请尽快处置。详情见故障处置。`;
}
const left = remainingDays(record.reportedAt);
const overdue = Math.abs(Math.min(left, 0));
return `【OneOS故障·逾期】${record.code} 已超 30 天未归档(状态:${FAULT_STATUS_LABEL[record.status]},逾期 ${overdue} 天),请主管督促闭环。`;
}
export function renderEmailTitle(record: FaultRecord, kind: NotifyKind): string {
if (kind === 'due_soon') {
return `【故障临期提醒】${record.code} 距闭环截止还剩 7 天`;
}
return `【故障逾期升级】${record.code} 已超过 30 天未归档`;
}
export function renderEmailBody(record: FaultRecord, kind: NotifyKind): string {
const hang = record.hangHistory
.map((h) => `${h.at} ${h.by}${h.reason}`)
.join('') || '无';
const lastNotify = record.notifications[record.notifications.length - 1];
const lines = [
`故障号:${record.code}`,
`车辆:${record.plateNo} · ${record.brand} ${record.model}`,
`上报时间:${record.reportedAt}`,
`当前状态:${FAULT_STATUS_LABEL[record.status]}`,
`截止日:${deadlineOf(record.reportedAt)}`,
`处理人:${record.assignee || '(未接手)'}`,
`系统链接:/prototypes/vehicle-fault-handling#id=${record.id}`,
];
if (kind === 'overdue') {
lines.push(`逾期天数:${Math.abs(Math.min(remainingDays(record.reportedAt), 0))}`);
lines.push(`挂起原因摘要:${hang}`);
lines.push(`末次催办:${lastNotify?.sentAt || '无'}`);
}
return lines.join('\n');
}
export function buildNotifyPair(
record: FaultRecord,
kind: NotifyKind,
sentAt: string,
): Array<{
id: string;
kind: NotifyKind;
channel: NotifyChannel;
to: string;
title: string;
body: string;
sentAt: string;
demo: true;
}> {
const to = resolveNotifyRecipient(record, kind);
const base = `${record.id}-${kind}-${sentAt}`;
return [
{
id: `${base}-sms`,
kind,
channel: 'sms',
to,
title: kind === 'due_soon' ? '临期短信' : '逾期短信',
body: renderSms(record, kind),
sentAt,
demo: true,
},
{
id: `${base}-email`,
kind,
channel: 'email',
to,
title: renderEmailTitle(record, kind),
body: renderEmailBody(record, kind),
sentAt,
demo: true,
},
];
}

View File

@@ -0,0 +1,225 @@
import type { FaultRecord } from './types';
import { buildNotifyPair } from './notify';
function att(
id: string,
name: string,
kind: FaultRecord['aiAttachments'][0]['kind'],
source: 'ai' | 'evidence',
by: string,
at: string,
): FaultRecord['aiAttachments'][0] {
return { id, name, kind, source, uploadedBy: by, uploadedAt: at, previewNote: `${kind} 演示附件` };
}
/** 演示种子:以 DEMO_TODAY=2026-07-22 为今天 */
export function buildSeedFaults(): FaultRecord[] {
const dueSoonNotify = buildNotifyPair(
{
id: 'tmp',
code: 'GZ-20260629-003',
status: 'processing',
reportedAt: '2026-06-29T09:00:00',
chatSummary: '',
aiAttachments: [],
plateNo: '沪AD8821',
brand: '飞驰',
model: '氢燃料厢式',
region: '上海',
assignee: '周强',
hangHistory: [],
notifications: [],
evidence: [],
updatedAt: '2026-07-20T10:00:00',
},
'due_soon',
'2026-07-15T09:00:00',
);
const overdueNotify = buildNotifyPair(
{
id: 'tmp2',
code: 'GZ-20260610-014',
status: 'suspended',
reportedAt: '2026-06-10T14:20:00',
chatSummary: '',
aiAttachments: [],
plateNo: '苏B88K21',
brand: '上汽',
model: 'MAXUS V80',
region: '江苏',
assignee: '吴婷',
hangHistory: [],
notifications: [],
evidence: [],
updatedAt: '2026-07-18T11:00:00',
},
'overdue',
'2026-07-10T09:00:00',
);
const overdueWeekly = buildNotifyPair(
{
id: 'tmp2',
code: 'GZ-20260610-014',
status: 'suspended',
reportedAt: '2026-06-10T14:20:00',
chatSummary: '',
aiAttachments: [],
plateNo: '苏B88K21',
brand: '上汽',
model: 'MAXUS V80',
region: '江苏',
assignee: '吴婷',
hangHistory: [],
notifications: [],
evidence: [],
updatedAt: '2026-07-18T11:00:00',
},
'overdue',
'2026-07-17T09:00:00',
);
return [
{
id: 'f-pending-1',
code: 'GZ-20260720-001',
status: 'pending',
reportedAt: '2026-07-20T08:30:00',
chatSummary:
'用户:车在嘉定加氢站附近无法启动,仪表有动力系统报警。\n机器人已记录故障现象请上传仪表与车尾照片。\n用户[已上传 2 张图]',
aiAttachments: [
att('a1', '仪表报警.jpg', 'image', 'ai', 'AI机器人', '2026-07-20T08:32:00'),
att('a2', '现场短视频.mp4', 'video', 'ai', 'AI机器人', '2026-07-20T08:33:00'),
],
plateNo: '沪AG2201',
brand: '飞驰',
model: '氢燃料厢式',
region: '上海',
hangHistory: [],
notifications: [],
evidence: [],
updatedAt: '2026-07-20T08:33:00',
},
{
id: 'f-pending-2',
code: 'GZ-20260721-002',
status: 'pending',
reportedAt: '2026-07-21T16:10:00',
chatSummary: '用户:浙江仓车辆底盘异响,已短途挪至停车场。\n机器人请补充部位与是否可上路。',
aiAttachments: [att('a3', '底盘异响.mp4', 'video', 'ai', 'AI机器人', '2026-07-21T16:12:00')],
plateNo: '浙A9F312',
brand: '东风',
model: '轻卡氢燃料',
region: '浙江',
hangHistory: [],
notifications: [],
evidence: [],
updatedAt: '2026-07-21T16:12:00',
},
{
id: 'f-due-soon',
code: 'GZ-20260629-003',
status: 'processing',
reportedAt: '2026-06-29T09:00:00',
chatSummary: '用户:动力电池温度偏高报警,已停运。\n机器人已生成待处理记录。',
aiAttachments: [att('a4', '温度报警.jpg', 'image', 'ai', 'AI机器人', '2026-06-29T09:05:00')],
plateNo: '沪AD8821',
brand: '飞驰',
model: '氢燃料厢式',
region: '上海',
faultTime: '2026-06-29 08:40',
location: '上海市嘉定区加氢站旁',
part: '动力电池',
level: '紧急',
result: '已降温停运,待厂家远程诊断后更换模组',
remark: '客户要求书面说明',
assignee: '周强',
hangHistory: [],
notifications: dueSoonNotify,
evidence: [
att('e1', '现场检测记录.pdf', 'pdf', 'evidence', '周强', '2026-07-01T11:00:00'),
att('e2', '电池舱照片.jpg', 'image', 'evidence', '周强', '2026-07-01T11:05:00'),
],
updatedAt: '2026-07-20T10:00:00',
},
{
id: 'f-overdue',
code: 'GZ-20260610-014',
status: 'suspended',
reportedAt: '2026-06-10T14:20:00',
chatSummary: '用户:底盘异响加剧,疑似悬挂问题。',
aiAttachments: [att('a5', '底盘.jpg', 'image', 'ai', 'AI机器人', '2026-06-10T14:25:00')],
plateNo: '苏B88K21',
brand: '上汽',
model: 'MAXUS V80',
region: '江苏',
faultTime: '2026-06-10 13:50',
location: '江苏省苏州市园区物流园',
part: '底盘悬挂',
level: '一般',
result: '',
assignee: '吴婷',
hangHistory: [
{
id: 'h1',
reason: '等待整车厂备件到货,暂无法继续拆检',
at: '2026-06-28T10:00:00',
by: '吴婷',
},
],
notifications: [...overdueNotify, ...overdueWeekly],
evidence: [att('e3', '初检记录.docx', 'word', 'evidence', '吴婷', '2026-06-12T15:00:00')],
updatedAt: '2026-07-18T11:00:00',
},
{
id: 'f-processing',
code: 'GZ-20260705-008',
status: 'processing',
reportedAt: '2026-07-05T11:00:00',
chatSummary: '用户:气瓶阀体附近有异味,已靠边停靠。',
aiAttachments: [att('a6', '气瓶区域.jpg', 'image', 'ai', 'AI机器人', '2026-07-05T11:04:00')],
plateNo: '沪BE3310',
brand: '飞驰',
model: '氢燃料厢式',
region: '上海',
faultTime: '2026-07-05 10:30',
location: '上海市浦东新区外环辅路',
part: '供氢系统',
level: '特急',
result: '已隔离车辆,待压力检测',
assignee: '刘洋',
hangHistory: [],
notifications: [],
evidence: [],
updatedAt: '2026-07-08T09:20:00',
},
{
id: 'f-archived',
code: 'GZ-20260501-021',
status: 'archived',
reportedAt: '2026-05-01T10:00:00',
chatSummary: '用户:雨刮电机失效。\n机器人已记录。',
aiAttachments: [att('a7', '雨刮.jpg', 'image', 'ai', 'AI机器人', '2026-05-01T10:05:00')],
plateNo: '浙B2C901',
brand: '东风',
model: '轻卡氢燃料',
region: '浙江',
faultTime: '2026-05-01 09:40',
location: '浙江省杭州市余杭仓',
part: '雨刮电机',
level: '提示',
result: '更换雨刮电机总成,路试正常',
remark: '已闭环',
assignee: '赵鹏',
hangHistory: [],
notifications: [],
evidence: [
att('e4', '维修工单.pdf', 'pdf', 'evidence', '赵鹏', '2026-05-03T16:00:00'),
att('e5', '更换后照片.jpg', 'image', 'evidence', '赵鹏', '2026-05-03T16:10:00'),
],
archivedAt: '2026-05-04T10:00:00',
updatedAt: '2026-05-04T10:00:00',
},
];
}

View File

@@ -0,0 +1,68 @@
import type { FaultRecord } from './types';
export const SLA_DAYS = 30;
export const DUE_SOON_DAYS = 7;
/** 原型「今天」固定,便于演示临期/逾期 */
export const DEMO_TODAY = '2026-07-22';
function parseDay(iso: string): Date {
const d = new Date(`${iso.slice(0, 10)}T00:00:00`);
return d;
}
function addDays(iso: string, days: number): string {
const d = parseDay(iso);
d.setDate(d.getDate() + days);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function deadlineOf(reportedAt: string): string {
return addDays(reportedAt.slice(0, 10), SLA_DAYS);
}
/** 剩余自然日;负数表示已逾期天数的相反数(-3 = 逾期 3 天) */
export function remainingDays(reportedAt: string, today: string = DEMO_TODAY): number {
const end = parseDay(deadlineOf(reportedAt)).getTime();
const now = parseDay(today).getTime();
return Math.round((end - now) / 86400000);
}
export function isArchived(record: FaultRecord): boolean {
return record.status === 'archived';
}
export function isOverdue(record: FaultRecord, today: string = DEMO_TODAY): boolean {
if (isArchived(record)) return false;
return remainingDays(record.reportedAt, today) < 0;
}
/** 未归档且剩余天数 07 */
export function isDueSoon(record: FaultRecord, today: string = DEMO_TODAY): boolean {
if (isArchived(record)) return false;
const left = remainingDays(record.reportedAt, today);
return left >= 0 && left <= DUE_SOON_DAYS;
}
export interface ArchiveGap {
field: string;
label: string;
}
export function archiveGaps(record: FaultRecord): ArchiveGap[] {
const gaps: ArchiveGap[] = [];
if (!record.faultTime?.trim()) gaps.push({ field: 'faultTime', label: '故障时间' });
if (!record.location?.trim()) gaps.push({ field: 'location', label: '地点' });
if (!record.part?.trim()) gaps.push({ field: 'part', label: '部位' });
if (!record.level) gaps.push({ field: 'level', label: '等级' });
if (!record.result?.trim()) gaps.push({ field: 'result', label: '处置结果' });
if (!record.evidence?.length) gaps.push({ field: 'evidence', label: '证据附件(至少 1 个)' });
return gaps;
}
export function canArchive(record: FaultRecord): boolean {
return archiveGaps(record).length === 0 && record.status === 'processing';
}

View File

@@ -0,0 +1,52 @@
import type { FaultRecord } from './types';
import { isDueSoon, isOverdue } from './sla';
import { loadFaults } from './storage';
export function listDueSoon(records?: FaultRecord[]) {
const list = records ?? loadFaults();
return list.filter((r) => isDueSoon(r));
}
export function listOverdue(records?: FaultRecord[]) {
const list = records ?? loadFaults();
return list.filter((r) => isOverdue(r));
}
export function listPending(records?: FaultRecord[]) {
const list = records ?? loadFaults();
return list.filter((r) => r.status === 'pending');
}
export function faultStatsForScope(
region: string | null,
assignee: string | null,
records?: FaultRecord[],
): {
total: number;
closed: number;
rate: number;
suspended: number;
dueSoon: number;
label: string;
} {
let rows = records ?? loadFaults();
if (region) rows = rows.filter((r) => r.region === region);
if (assignee) rows = rows.filter((r) => r.assignee === assignee);
const total = rows.length;
const closed = rows.filter((r) => r.status === 'archived').length;
const suspended = rows.filter((r) => r.status === 'suspended').length;
const dueSoon = rows.filter((r) => isDueSoon(r)).length;
const label = assignee
? assignee
: region
? `${region} · 地区合计`
: '全部人员合计';
return {
total,
closed,
rate: total > 0 ? Math.round((closed / total) * 1000) / 10 : 0,
suspended,
dueSoon,
label,
};
}

View File

@@ -0,0 +1,58 @@
import type { FaultRecord } from './types';
import { buildSeedFaults } from './seed';
export const STORAGE_KEYS = {
schemaVersion: 'oneos.vf.schemaVersion',
faults: 'oneos.vf.faults',
} as const;
export const VF_SCHEMA_VERSION = 1;
function readJson<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
function writeJson(key: string, value: unknown) {
localStorage.setItem(key, JSON.stringify(value));
}
function ensureSchema() {
if (typeof localStorage === 'undefined') return;
const ver = Number(localStorage.getItem(STORAGE_KEYS.schemaVersion) || '0');
if (ver === VF_SCHEMA_VERSION) return;
localStorage.removeItem(STORAGE_KEYS.faults);
localStorage.setItem(STORAGE_KEYS.schemaVersion, String(VF_SCHEMA_VERSION));
}
export function loadFaults(): FaultRecord[] {
const seed = buildSeedFaults();
if (typeof localStorage === 'undefined') return seed.map((f) => ({ ...f }));
ensureSchema();
const stored = readJson<FaultRecord[] | null>(STORAGE_KEYS.faults, null);
if (!stored || !stored.length) {
writeJson(STORAGE_KEYS.faults, seed);
return seed.map((f) => ({ ...f }));
}
return stored;
}
export function saveFaults(list: FaultRecord[]) {
if (typeof localStorage === 'undefined') return;
ensureSchema();
writeJson(STORAGE_KEYS.faults, list);
}
export function upsertFault(list: FaultRecord[], next: FaultRecord): FaultRecord[] {
const idx = list.findIndex((f) => f.id === next.id);
const copy = list.slice();
if (idx >= 0) copy[idx] = next;
else copy.unshift(next);
saveFaults(copy);
return copy;
}

View File

@@ -0,0 +1,83 @@
/** 车辆运维 · 故障处置 · 共享类型 */
export type FaultStatus = 'pending' | 'processing' | 'suspended' | 'archived';
export type FaultLevel = '特急' | '紧急' | '一般' | '提示';
export type EvidenceKind = 'image' | 'video' | 'pdf' | 'word' | 'other';
export type NotifyChannel = 'sms' | 'email';
export type NotifyKind = 'due_soon' | 'overdue';
export interface FaultAttachment {
id: string;
name: string;
kind: EvidenceKind;
/** 原始记录媒体 vs 处置证据 */
source: 'ai' | 'evidence';
uploadedBy: string;
uploadedAt: string;
/** 演示用预览文案 / URL 占位 */
previewNote?: string;
}
export interface HangHistoryItem {
id: string;
reason: string;
at: string;
by: string;
resumedAt?: string;
}
export interface NotifyRecord {
id: string;
kind: NotifyKind;
channel: NotifyChannel;
to: string;
title: string;
body: string;
sentAt: string;
/** 原型演示标记 */
demo: true;
}
export interface FaultRecord {
id: string;
code: string;
status: FaultStatus;
reportedAt: string;
/** AI 原始 */
chatSummary: string;
aiAttachments: FaultAttachment[];
/** 车辆 */
plateNo: string;
brand: string;
model: string;
region: string;
/** 处置 */
faultTime?: string;
location?: string;
part?: string;
level?: FaultLevel;
result?: string;
remark?: string;
evidence: FaultAttachment[];
/** 处理人;待处理可为空 */
assignee?: string;
hangHistory: HangHistoryItem[];
notifications: NotifyRecord[];
archivedAt?: string;
updatedAt: string;
}
export const FAULT_STATUS_LABEL: Record<FaultStatus, string> = {
pending: '待处理',
processing: '处理中',
suspended: '挂起',
archived: '已归档',
};
export const FAULT_LEVELS: FaultLevel[] = ['特急', '紧急', '一般', '提示'];
export const OPS_SUPERVISOR_NAME = '刘洋';

View File

@@ -0,0 +1,118 @@
/** 对齐《附件4车辆交接验收表》现场验车单 */
/** 图例:√正常完好齐全 · N缺少 · ×损坏/开裂 · O变形 */
export type HandoverItemMark = 'ok' | 'missing' | 'damage' | 'deform' | 'na';
export const HANDOVER_ITEM_MARK_LABEL: Record<HandoverItemMark, string> = {
ok: '√ 正常',
missing: 'N 缺少',
damage: '× 损坏',
deform: 'O 变形',
na: '— 不适用',
};
/** 交接验收表 · 检验项目(按样表归并,原型可全选) */
export const HANDOVER_CHECK_GROUPS: { title: string; items: string[] }[] = [
{
title: '外观与标识',
items: [
'整车外观状况(与公告目录样车相同)',
'漆面状况',
'喇叭状况',
'车辆照片左前45°+右后45°',
'汽车标识LOGO',
'车辆底盘状况',
'整车配置表',
],
},
{
title: '动力与续航',
items: [
'冷却风扇转动状况',
'供氢量/续航电量≥95%',
'钥匙2把',
'动力舱状况',
'车辆启动状况',
'档位变换(前进档、倒档)',
],
},
{
title: '驾驶室与电器',
items: [
'车辆内饰状况',
'灯光状况',
'前后灯总成状况',
'风挡玻璃状况',
'仪表盘功能状况',
'雨刮器状况',
'座椅状况',
'安全带状况',
'左右后视镜状况',
'车内反光镜状况',
'空调状况',
'刹车系统状况',
'制动器助力性能状况',
'方向盘助力性能状况',
],
},
{
title: '随车工具与备件',
items: [
'灭火器1个',
'备胎1只',
'点烟器1个',
'反光衣',
'千斤顶',
'轮胎撬棍',
'车辆反光贴',
'三角警示牌',
'钳子/轮胎扳手',
'备胎扬撬',
'轴头保养扳手',
'挡车器',
'胎压检测值',
'标准量去离子水',
'标准量冷却液',
],
},
{
title: '文件资料',
items: ['用户操作手册', '质量保修手册', '保修IC卡'],
},
{
title: '车身结构',
items: [
'四轮轮胎状况',
'挡泥板状况',
'侧防护围杠/后部护围杠',
'左右中后门状况',
'货箱箱体状况',
],
},
];
export const DEFAULT_ACCEPTANCE_CHECK_ITEMS: string[] = HANDOVER_CHECK_GROUPS.flatMap(
(g) => g.items,
);
/** 现场验车主单据车辆交接验收表附件4 */
export const DEFAULT_SIGNOFF_DOC_NAME = '车辆交接验收表';
/** 合同侧《发送签收单》仍可作为商务签收补充说明 */
export const CONTRACT_SIGNOFF_DOC_NAME = '发送签收单';
export function buildSignoffEsignFileName(
taskCode: string,
seq: number,
docName = DEFAULT_SIGNOFF_DOC_NAME,
) {
return `${taskCode}-${String(seq).padStart(2, '0')}-${docName}-电子签章.pdf`;
}
export function allHandoverItemsOk(
marks: Partial<Record<string, HandoverItemMark>> | undefined,
items: string[] = DEFAULT_ACCEPTANCE_CHECK_ITEMS,
): boolean {
if (!marks) return false;
return items.every((item) => marks[item] === 'ok' || marks[item] === 'na');
}

View File

@@ -0,0 +1,21 @@
/** 采购合同审批规则:仅金额超阈值 → 非正常审批 */
import type { ApprovalKind } from './types';
/** 原型可配置阈值(元),默认 500 万 */
export const ABNORMAL_AMOUNT_THRESHOLD = 5_000_000;
export function resolveApprovalKind(totalAmount: number): ApprovalKind {
return Number(totalAmount) >= ABNORMAL_AMOUNT_THRESHOLD ? 'abnormal' : 'normal';
}
export function approvalKindLabel(kind: ApprovalKind): string {
return kind === 'abnormal' ? '非正常审批' : '正常审批';
}
export function approvalNodes(kind: ApprovalKind): string[] {
if (kind === 'abnormal') {
return ['部门负责人', '财务负责人', '法务/合规', '分管领导'];
}
return ['部门负责人', '分管领导'];
}

View File

@@ -0,0 +1,7 @@
export * from './types';
export * from './approvalRules';
export * from './vin';
export * from './storage';
export * from './seed';
export * from './stockBridge';
export * from './acceptance';

View File

@@ -0,0 +1,441 @@
import type {
PurchaseContract,
InspectionTask,
InspectionVehicleLine,
StockedVehicleRecord,
PaymentInstallment,
} from './types';
import { resolveApprovalKind } from './approvalRules';
import {
DEFAULT_ACCEPTANCE_CHECK_ITEMS,
DEFAULT_SIGNOFF_DOC_NAME,
buildSignoffEsignFileName,
} from './acceptance';
function nowStr() {
const d = new Date();
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function lngdPaymentSchedule(): PaymentInstallment[] {
return [
{
id: 'pay-1',
period: 1,
label: '第1期价款定金',
amount: 680_000,
unitAmount: 5_000,
trigger: '本合同签订之日起 7 个工作日内',
prerequisite: '无',
dueHint: '含税,人民币 0.5 万元/辆',
},
{
id: 'pay-2',
period: 2,
label: '第2期价款',
amount: 40_800_000,
unitAmount: 300_000,
trigger: '本项目车辆交付且乙方收到符合约定的发票后起 19 个工作日内',
prerequisite: '甲方已按增资协议向丙方按期足额支付全部增资款',
dueHint: '含税,人民币 30 万元/辆',
},
{
id: 'pay-3',
period: 3,
label: '第3期价款',
amount: 40_800_000,
unitAmount: 300_000,
trigger: '自本项目车辆交车日起满 1 年之日起 7 个工作日内',
prerequisite: '车辆已交付',
dueHint: '含税,人民币 30 万元/辆',
},
{
id: 'pay-4',
period: 4,
label: '第4期价款',
amount: 13_600_000,
unitAmount: 100_000,
trigger: '自本项目车辆交车日起满 2 年之日起 7 个工作日内',
prerequisite: '车辆已交付',
dueHint: '含税,人民币 10 万元/辆',
},
];
}
export function buildSeedContracts(): PurchaseContract[] {
const c1: PurchaseContract = {
id: 'pc-001',
code: 'HT-CG-2026-0088',
buyerName: '氢能科技(广东)有限公司',
sellerName: '东风商用车有限公司',
buyerRoleLabel: '买方',
sellerRoleLabel: '卖方(车企)',
vehicleModel: '苏龙18T氢能重卡',
powerType: '氢燃料电池',
configSummary: '18T 牵引车 · 燃料电池系统标配',
quantity: 5,
unitPrice: 680000,
totalAmount: 3400000,
priceScopeNote: '价格含运费;不含购置税、保险、上牌杂费',
inspectLocation: '东风襄阳工厂停车场',
inspectDate: '2026-08-15',
paymentSummary: '预付 30%,到货验收合格后 60%,质保期满 10%。',
paymentSchedule: [
{
id: 'c1-p1',
period: 1,
label: '预付款',
amount: 1_020_000,
trigger: '合同签订后 7 日内',
prerequisite: '无',
},
{
id: 'c1-p2',
period: 2,
label: '到货验收款',
amount: 2_040_000,
trigger: '验收合格交付后',
prerequisite: '验车通过并签署签收单',
},
{
id: 'c1-p3',
period: 3,
label: '质保金',
amount: 340_000,
trigger: '质保期满',
prerequisite: '无重大质量索赔',
},
],
warrantySummary: '整车质保按厂家手册;燃料电池系统另见附件。',
purchaserName: '李采购',
remark: '首批试运营车辆',
attachments: [{ uid: 'att-1', name: 'HT-CG-2026-0088-盖章合同.pdf', kind: 'contract' }],
status: 'inspection_created',
approvalKind: 'normal',
createdAt: '2026-03-15 10:00',
updatedAt: '2026-03-20 16:00',
inspectionTaskId: 'insp-001',
clauses: {
mileage: '采购车辆交付后首年每月单车行驶里程不低于 6,000 km未达标需说明原因。',
maintenance: '首保周期 2 万 km 或 24 个月(以先到为准);易损件由运维按合同标准执行。',
policy: '帕力安18T里程优惠政策月里程≥6000km 可享受次月减免,具体按采购附件执行。',
payment: '预付 30%,到货验收合格后 60%,质保期满 10%。',
penalty: '逾期交付按 5000 元/次承担违约金;质量不符按合同第八条处理。',
},
};
const c2: PurchaseContract = {
id: 'pc-002',
code: 'HT-CG-2026-0102',
buyerName: '羚牛新能源科技有限公司',
sellerName: '福田智蓝新能源',
vehicleModel: '智蓝氢能轻卡 4.5T',
powerType: '氢燃料电池',
configSummary: '4.5T 厢式 · 城市配送',
quantity: 8,
unitPrice: 420000,
totalAmount: 3360000,
inspectLocation: '福田智蓝长沙基地',
inspectDate: '2026-09-01',
paymentSummary: '货到票到 45 天内付款。',
paymentSchedule: [
{
id: 'c2-p1',
period: 1,
label: '货到票到款',
amount: 3_360_000,
trigger: '货到票到后 45 天内',
prerequisite: '验收合格',
},
],
warrantySummary: '3 年质保期内免费上门巡检 1 次/季。',
purchaserName: '李采购',
attachments: [{ uid: 'att-2', name: 'HT-CG-2026-0102-合同扫描件.pdf', kind: 'contract' }],
status: 'approved',
approvalKind: 'normal',
createdAt: '2026-04-02 11:00',
updatedAt: '2026-04-10 09:30',
clauses: {
mileage: '交付后 36 个月内累计里程不低于 15 万 km单车。',
maintenance: '按厂家保养手册执行;乙方提供 3 年质保期内免费上门巡检 1 次/季。',
policy: '享免政策帕力安4.5T里程优惠政策(详见附件)。',
payment: '货到票到 45 天内付款。',
penalty: '标准违约条款。',
},
};
const totalLngd = 95_880_000;
const c3: PurchaseContract = {
id: 'pc-003',
code: 'LNGD-25-091',
buyerName: '羚牛氢能科技(广东)有限公司',
sellerName: '现代汽车氢燃料电池系统(广州)有限公司',
thirdPartyName: '羚牛新能源科技(上海)有限公司',
buyerRoleLabel: '乙方(买方)',
sellerRoleLabel: '甲方(卖方/车企)',
thirdPartyRoleLabel: '丙方(乙方唯一股东 · 增资/担保相关)',
vehicleModel: '4.5吨燃料电池冷藏车',
productModelCode: 'XDQ5041XLCFCEV0',
powerType: '氢燃料电池',
configSummary: '按附件一《4.5吨燃料电池冷藏车配置表》;不作注明的按甲方标准',
originPlace: '中国',
manufacturer: '现代汽车氢燃料电池系统(广州)有限公司',
quantity: 136,
unitPrice: 705_000,
totalAmount: totalLngd,
priceScopeNote: '车辆价格不含购置税、保险、上牌杂费;价格含运费',
inspectLocation: '乙方指定地点(广州市辖区内)',
inspectDate: '2025-06-30',
deliveryPrerequisite:
'甲方按照增资协议向丙方缴纳全部增资款之日起约定工作日内,在乙方指定地点完成全部车辆交付',
relatedAgreementNote:
'2025年5月12日甲方与丙方及其股东签订《关于羚牛新能源科技上海有限公司增资协议》本合同价款与交付与增资履约挂钩',
warrantySummary:
'整车除易损件2年或10万公里以先到为准氢燃料电池系统、储氢系统、三电系统核心零部件质保期更长以用户手册及质保协议为准',
paymentSummary:
'四期定金68万 → 交车收票后4080万增资到位为前提→ 交车满1年4080万 → 交车满2年1360万',
paymentSchedule: lngdPaymentSchedule(),
purchaserName: '王采购',
remark: '样例对齐 LNGD-25-091三方主体 + 增资绑定 + 四期付款 + 发送签收单验收',
attachments: [
{
uid: 'att-3',
name: 'LNGD-25-091车辆购买合同136辆4.5T.pdf',
kind: 'contract',
},
{
uid: 'att-3b',
name: '附件一-4.5吨燃料电池冷藏车配置表.pdf',
kind: 'config',
},
{
uid: 'att-3c',
name: '附件二-担保车辆明细.pdf',
kind: 'guarantee',
},
],
status: 'draft',
approvalKind: resolveApprovalKind(totalLngd),
createdAt: '2025-05-12 15:20',
updatedAt: '2025-05-12 15:20',
clauses: {
payment:
'第1期定金68万第2期交车收票后4080万增资到位为前提第3/4期按交车满1年/2年支付。',
penalty: '逾期付款按未支付金额 0.05%/日计息;质量不符可拒收并要求重新交付。',
},
};
return [c1, c2, c3];
}
function emptyLine(seq: number, idPrefix: string): InspectionVehicleLine {
return {
id: `${idPrefix}-line-${seq}`,
seq,
vin: '',
status: 'awaiting',
evidences: [],
redeliveryCount: 0,
};
}
/** 大数量合同不预生成全部行,改为按到车追加 */
export function createInspectionTaskFromContract(contract: PurchaseContract): InspectionTask {
const id = `insp-${Date.now()}`;
const code = `YC-${new Date().getFullYear()}-${String(Date.now()).slice(-4)}`;
const initialCount = Math.min(contract.quantity, 3);
return {
id,
code,
contractId: contract.id,
contractCode: contract.code,
vehicleModel: contract.vehicleModel,
expectedQty: contract.quantity,
inspectLocation: contract.inspectLocation,
inspectDate: contract.inspectDate,
status: 'pending',
defaultDeliverUnit: contract.sellerName,
defaultReceiveUnit: contract.buyerName,
acceptanceCheckItems: [...DEFAULT_ACCEPTANCE_CHECK_ITEMS],
signoffDocName: DEFAULT_SIGNOFF_DOC_NAME,
vehicles: Array.from({ length: initialCount }, (_, i) => ({
...emptyLine(i + 1, id),
vehicleModel: contract.vehicleModel,
deliverUnit: contract.sellerName,
receiveUnit: contract.buyerName,
handoverDate: contract.inspectDate,
})),
createdAt: nowStr(),
updatedAt: nowStr(),
};
}
export function appendInspectionVehicleLine(task: InspectionTask): InspectionTask {
if (task.vehicles.length >= task.expectedQty) {
return task;
}
const seq = task.vehicles.length + 1;
return {
...task,
vehicles: [
...task.vehicles,
{
...emptyLine(seq, task.id),
vehicleModel: task.vehicleModel,
deliverUnit: task.defaultDeliverUnit,
receiveUnit: task.defaultReceiveUnit,
handoverDate: task.inspectDate,
},
],
status: task.status === 'pending' ? 'in_progress' : task.status,
updatedAt: nowStr(),
};
}
/** 拒收后发起重新交付:回到待登记,保留拒收历史 */
export function requestRedelivery(line: InspectionVehicleLine): InspectionVehicleLine {
return {
...line,
status: 'redelivery',
redeliveryCount: (line.redeliveryCount || 0) + 1,
lastRejectedAt: line.lastRejectedAt || nowStr(),
vin: '',
esignFileName: undefined,
signedAt: undefined,
checkItems: undefined,
evidences: [],
failReason: line.failReason,
};
}
export function startRedeliveryInspection(line: InspectionVehicleLine): InspectionVehicleLine {
return {
...line,
status: 'awaiting',
};
}
export function buildSeedInspections(): InspectionTask[] {
const allOk: Record<string, 'ok'> = {};
DEFAULT_ACCEPTANCE_CHECK_ITEMS.forEach((item) => {
allOk[item] = 'ok';
});
return [
{
id: 'insp-001',
code: 'YC-2026-0001',
contractId: 'pc-001',
contractCode: 'HT-CG-2026-0088',
vehicleModel: '苏龙18T氢能重卡',
expectedQty: 5,
inspectLocation: '东风襄阳工厂停车场',
inspectDate: '2026-08-15',
status: 'in_progress',
defaultDeliverUnit: '东风商用车有限公司',
defaultReceiveUnit: '氢能科技(广东)有限公司',
acceptanceCheckItems: [...DEFAULT_ACCEPTANCE_CHECK_ITEMS],
signoffDocName: DEFAULT_SIGNOFF_DOC_NAME,
vehicles: [
{
id: 'line-1',
seq: 1,
vin: 'LFV2BJCH8K3123456',
status: 'stocked',
vehicleModel: '苏龙18T氢能重卡',
deliverUnit: '东风商用车有限公司',
receiveUnit: '氢能科技(广东)有限公司',
handoverDate: '2026-08-15',
odometerKm: 12,
motorNo: 'TZ220X5-H0001',
checkItems: [...DEFAULT_ACCEPTANCE_CHECK_ITEMS],
handoverMarks: allOk,
evidences: [
{ uid: 'e1', name: '左前45度.jpg', kind: 'photo', photoAngle: 'left45' },
{ uid: 'e1b', name: '右后45度.jpg', kind: 'photo', photoAngle: 'right45' },
],
delivererSigned: true,
receiverSigned: true,
esignFileName: buildSignoffEsignFileName('YC-2026-0001', 1),
signedAt: '2026-08-15 11:20',
stockedAt: '2026-08-15 11:22',
stockedVehicleId: 'sv-001',
redeliveryCount: 0,
},
{
id: 'line-2',
seq: 2,
vin: 'LFV2BJCH8K3123457',
status: 'failed',
vehicleModel: '苏龙18T氢能重卡',
deliverUnit: '东风商用车有限公司',
receiveUnit: '氢能科技(广东)有限公司',
handoverDate: '2026-08-15',
odometerKm: 8,
failReason: '备胎/随车工具缺少,漆面破损,拒收并要求重新交付',
checkItems: ['整车外观状况(与公告目录样车相同)', '漆面状况'],
handoverMarks: {
'整车外观状况(与公告目录样车相同)': 'damage',
'漆面状况': 'damage',
'备胎1只': 'missing',
'千斤顶': 'missing',
},
evidences: [
{ uid: 'e2', name: '漆面破损.jpg', kind: 'photo' },
{ uid: 'e3', name: '工具缺失.jpg', kind: 'photo' },
],
lastRejectedAt: '2026-08-15 14:00',
redeliveryCount: 0,
},
{
...emptyLine(3, 'insp-001'),
vehicleModel: '苏龙18T氢能重卡',
deliverUnit: '东风商用车有限公司',
receiveUnit: '氢能科技(广东)有限公司',
handoverDate: '2026-08-15',
},
{
...emptyLine(4, 'insp-001'),
vehicleModel: '苏龙18T氢能重卡',
deliverUnit: '东风商用车有限公司',
receiveUnit: '氢能科技(广东)有限公司',
},
{
...emptyLine(5, 'insp-001'),
vehicleModel: '苏龙18T氢能重卡',
deliverUnit: '东风商用车有限公司',
receiveUnit: '氢能科技(广东)有限公司',
},
],
createdAt: '2026-03-20 16:05',
updatedAt: '2026-08-15 11:22',
},
];
}
export function stockVehicleFromLine(
task: InspectionTask,
line: InspectionVehicleLine,
): StockedVehicleRecord {
return {
id: `sv-${Date.now()}-${line.seq}`,
vin: line.vin,
brandModel: task.vehicleModel,
purchaseContractId: task.contractId,
purchaseContractCode: task.contractCode,
purchaseDate: nowStr().slice(0, 10),
status: '未备车',
sourceInspectionTaskId: task.id,
sourceLineId: line.id,
};
}
export function buildEsignFileName(taskCode: string, seq: number) {
return buildSignoffEsignFileName(taskCode, seq);
}
export function formatMoney(n: number) {
return `¥${Number(n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
export { nowStr as formatNow };

View File

@@ -0,0 +1,53 @@
import type { StockedVehicleRecord } from './types';
import { loadStocked } from './storage';
/** 将验车入库记录映射为车辆资产列表可用的最小字段集 */
export function mapStockedToVehicleAsset(s: StockedVehicleRecord): Record<string, string> {
const parts = String(s.brandModel || '').split(/\s+/);
const brand = parts[0] || '待补充';
const model = parts.slice(1).join(' ') || s.brandModel || '待补充';
return {
id: s.id,
plateNo: '待上牌',
vin: s.vin,
vehicleNo: '-',
color: '-',
year: '-',
purchaseDate: s.purchaseDate,
parking: '-',
ownership: '羚牛氢能科技(广东)有限公司',
scrapDate: '-',
ratingTime: '-',
operateCompany: '羚牛氢能科技(广东)有限公司',
vehicleSource: '采购入库',
leaseCompany: '-',
vehicleType: '-',
brand,
model,
customer: '-',
department: '运维部',
manager: '-',
contractNo: s.purchaseContractCode,
operateStatus: '自营',
vehicleStatus: s.status || '未备车',
licenseStatus: '待办理',
insuranceStatus: '待投保',
mileage: '0',
location: '-',
locationAddress: '-',
gpsTime: '-',
regDate: '-',
inspectExpire: '-',
lastDeliveryTime: '-',
lastDeliveryMile: '-',
lastReturnTime: '-',
lastReturnMile: '-',
outStatus: '-',
onlineStatus: '离线',
projectName: `采购合同 ${s.purchaseContractCode}`,
};
}
export function loadStockedAsVehicleAssets(): Record<string, string>[] {
return loadStocked().map(mapStockedToVehicleAsset);
}

View File

@@ -0,0 +1,122 @@
import type { PurchaseContract, InspectionTask, StockedVehicleRecord } from './types';
import { resolveApprovalKind } from './approvalRules';
export const STORAGE_KEYS = {
schemaVersion: 'oneos.vp.schemaVersion',
contracts: 'oneos.vp.contracts',
inspections: 'oneos.vp.inspections',
stocked: 'oneos.vp.stockedVehicles',
workOrders: 'oneos.vp.linkedWorkOrders',
workOrderPrefill: 'oneos.vp.workOrderPrefill',
} as const;
/** 字段模型升级时递增,强制用新种子覆盖旧 localStorage */
export const VP_SCHEMA_VERSION = 3;
function readJson<T>(key: string, fallback: T): T {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
return JSON.parse(raw) as T;
} catch {
return fallback;
}
}
function writeJson(key: string, value: unknown) {
localStorage.setItem(key, JSON.stringify(value));
}
function ensureSchema() {
const ver = Number(localStorage.getItem(STORAGE_KEYS.schemaVersion) || '0');
if (ver === VP_SCHEMA_VERSION) return;
localStorage.removeItem(STORAGE_KEYS.contracts);
localStorage.removeItem(STORAGE_KEYS.inspections);
localStorage.removeItem(STORAGE_KEYS.stocked);
localStorage.setItem(STORAGE_KEYS.schemaVersion, String(VP_SCHEMA_VERSION));
}
export function loadContracts(seed: PurchaseContract[]): PurchaseContract[] {
ensureSchema();
const stored = readJson<PurchaseContract[] | null>(STORAGE_KEYS.contracts, null);
if (!stored || !stored.length) {
writeJson(STORAGE_KEYS.contracts, seed);
return seed.map((c) => ({ ...c }));
}
return stored;
}
export function saveContracts(list: PurchaseContract[]) {
ensureSchema();
writeJson(STORAGE_KEYS.contracts, list);
}
export function loadInspections(seed: InspectionTask[]): InspectionTask[] {
ensureSchema();
const stored = readJson<InspectionTask[] | null>(STORAGE_KEYS.inspections, null);
if (!stored || !stored.length) {
writeJson(STORAGE_KEYS.inspections, seed);
return seed.map((t) => ({ ...t }));
}
return stored;
}
export function saveInspections(list: InspectionTask[]) {
ensureSchema();
writeJson(STORAGE_KEYS.inspections, list);
}
export function loadStocked(seed: StockedVehicleRecord[] = []): StockedVehicleRecord[] {
return readJson(STORAGE_KEYS.stocked, seed);
}
export function saveStocked(list: StockedVehicleRecord[]) {
writeJson(STORAGE_KEYS.stocked, list);
}
export function bumpContractApproval(
contract: PurchaseContract,
totalAmount = contract.totalAmount,
): PurchaseContract {
return {
...contract,
approvalKind: resolveApprovalKind(totalAmount),
totalAmount,
};
}
export function setWorkOrderPrefill(payload: unknown) {
sessionStorage.setItem(STORAGE_KEYS.workOrderPrefill, JSON.stringify(payload));
}
export function consumeWorkOrderPrefill<T = unknown>(): T | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEYS.workOrderPrefill);
if (!raw) return null;
sessionStorage.removeItem(STORAGE_KEYS.workOrderPrefill);
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export function peekWorkOrderPrefill<T = unknown>(): T | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEYS.workOrderPrefill);
if (!raw) return null;
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export function countLinkedWorkOrders(contractId: string): number {
const map = readJson<Record<string, number>>(STORAGE_KEYS.workOrders, {});
return map[contractId] || 0;
}
export function bumpLinkedWorkOrders(contractId: string, delta: number) {
const map = readJson<Record<string, number>>(STORAGE_KEYS.workOrders, {});
map[contractId] = (map[contractId] || 0) + delta;
writeJson(STORAGE_KEYS.workOrders, map);
}

View File

@@ -0,0 +1,188 @@
/** 车辆采购合同 / 验车 / 入库 · 共享类型 */
export type ContractStatus =
| 'draft'
| 'pending'
| 'processing'
| 'approved'
| 'rejected'
| 'inspection_created';
export type ApprovalKind = 'normal' | 'abnormal';
export type InspectionTaskStatus = 'pending' | 'in_progress' | 'completed';
/** awaiting 待登记passed 已签收待入库failed 拒收待重交redelivery 已发起重交stocked 已入库 */
export type VehicleInspectStatus =
| 'awaiting'
| 'passed'
| 'failed'
| 'redelivery'
| 'stocked';
export interface PurchaseAttachment {
uid: string;
name: string;
/** config=配置表附件一guarantee=担保附件contract=合同正文other */
kind?: 'contract' | 'config' | 'guarantee' | 'other';
}
/** 分期付款计划(对齐 LNGD 类合同多期价款) */
export interface PaymentInstallment {
id: string;
/** 第几期 */
period: number;
label: string;
amount: number;
/** 元/辆(可选) */
unitAmount?: number;
/** 触发条件说明 */
trigger: string;
/** 前提条件(如增资到位、收票) */
prerequisite?: string;
dueHint?: string;
}
export interface PurchaseContract {
id: string;
code: string;
/** 买方(合同乙方常见) */
buyerName: string;
/** 卖方(合同甲方常见,车企) */
sellerName: string;
/** 第三方(如股东/担保/增资方) */
thirdPartyName?: string;
buyerRoleLabel?: string;
sellerRoleLabel?: string;
thirdPartyRoleLabel?: string;
vehicleModel: string;
/** 产品型号编码,如 XDQ5041XLCFCEV0 */
productModelCode?: string;
powerType: string;
configSummary: string;
originPlace?: string;
manufacturer?: string;
quantity: number;
unitPrice: number;
totalAmount: number;
/** 价格口径说明(如不含购置税保险上牌) */
priceScopeNote?: string;
inspectLocation: string;
inspectDate: string;
/** 交付前提(如增资协议足额到位后 N 个工作日) */
deliveryPrerequisite?: string;
/** 关联协议说明(增资协议等) */
relatedAgreementNote?: string;
/** 质保摘要 */
warrantySummary?: string;
/** 自由文本付款摘要(兼容旧数据) */
paymentSummary: string;
/** 结构化分期 */
paymentSchedule?: PaymentInstallment[];
buyerContact?: string;
purchaserName: string;
remark?: string;
attachments: PurchaseAttachment[];
status: ContractStatus;
approvalKind: ApprovalKind;
createdAt: string;
updatedAt: string;
inspectionTaskId?: string;
clauses?: Partial<Record<'mileage' | 'maintenance' | 'policy' | 'payment' | 'penalty', string>>;
}
export interface InspectionEvidence {
uid: string;
name: string;
kind: 'photo' | 'video' | 'doc';
/** left45 / right45 / other */
photoAngle?: 'left45' | 'right45' | 'other';
}
export interface InspectionVehicleLine {
id: string;
seq: number;
/** 车架号(交接验收表字段名) */
vin: string;
status: VehicleInspectStatus;
failReason?: string;
evidences: InspectionEvidence[];
/** 勾选通过的检查项(兼容旧数据) */
checkItems?: string[];
/** 交接验收表:项目 → 交/接车状况标记 */
handoverMarks?: Partial<Record<string, import('./acceptance').HandoverItemMark>>;
/** 车辆型号(可与任务车型一致,现场可改) */
vehicleModel?: string;
/** 交车单位 */
deliverUnit?: string;
/** 接车单位 */
receiveUnit?: string;
/** 车辆交接日期 */
handoverDate?: string;
/** 交车时仪表里程(公里) */
odometerKm?: number;
/** 发动机号/电机号 */
motorNo?: string;
/** 备注说明状况 */
remark?: string;
/** 交车方已签 */
delivererSigned?: boolean;
/** 接车方已签 */
receiverSigned?: boolean;
/** 电子签章《车辆交接验收表》文件名 */
esignFileName?: string;
signedAt?: string;
stockedAt?: string;
stockedVehicleId?: string;
redeliveryCount?: number;
lastRejectedAt?: string;
}
export interface InspectionTask {
id: string;
code: string;
contractId: string;
contractCode: string;
vehicleModel: string;
expectedQty: number;
inspectLocation: string;
inspectDate: string;
status: InspectionTaskStatus;
/** 默认交车/接车单位(可带到每台) */
defaultDeliverUnit?: string;
defaultReceiveUnit?: string;
acceptanceCheckItems?: string[];
/** 默认《车辆交接验收表》 */
signoffDocName?: string;
vehicles: InspectionVehicleLine[];
createdAt: string;
updatedAt: string;
completedAt?: string;
}
export interface StockedVehicleRecord {
id: string;
vin: string;
brandModel: string;
purchaseContractId: string;
purchaseContractCode: string;
purchaseDate: string;
status: string;
sourceInspectionTaskId: string;
sourceLineId: string;
}
export interface WorkOrderPrefillRow {
clauseType: string;
title: string;
currentOwnerId: string;
accountableOwnerId: string;
requirement?: string;
}
export interface WorkOrderPrefillPayload {
contractId: string;
contractCode: string;
rows: WorkOrderPrefillRow[];
batch?: boolean;
}

View File

@@ -0,0 +1,34 @@
/** VIN 正则校验ISO 3779 常见约束,排除 I/O/Q */
const VIN_REGEX = /^[A-HJ-NPR-Z0-9]{17}$/;
export function normalizeVin(raw: string): string {
return String(raw || '')
.trim()
.toUpperCase()
.replace(/\s+/g, '');
}
export function isValidVin(raw: string): boolean {
return VIN_REGEX.test(normalizeVin(raw));
}
export function vinValidationMessage(raw: string): string | null {
const v = normalizeVin(raw);
if (!v) return '请填写 VIN 码';
if (v.length !== 17) return 'VIN 码须为 17 位';
if (!VIN_REGEX.test(v)) return 'VIN 码格式不正确(不可含 I/O/Q仅字母与数字';
return null;
}
/** OCR 模拟回填样例(未接真实 OCR */
export const OCR_SAMPLE_VINS = [
'LZZ1CLWB0PA123456',
'LZZ1CLWB0PA123457',
'LFNA1LSE5N1A88901',
'LZZ1CLSB5PA778899',
];
export function mockOcrVin(index = 0): string {
return OCR_SAMPLE_VINS[index % OCR_SAMPLE_VINS.length];
}

View File

@@ -1,6 +1,7 @@
/**
* 全局列表操作列 — OperationActions
* 规范vm-shared/DESIGN.md · 操作列
* 不依赖 antd Dropdown class
*/
.vm-operation-actions {
@@ -96,12 +97,13 @@
flex-shrink: 0;
}
/* 更多下拉:挂到 body使用 overlayClassName 命中 */
.vm-op-dropdown.ant-dropdown {
/* 更多下拉:portal 到 body */
.vm-op-dropdown {
position: fixed;
z-index: 1060;
}
.vm-op-dropdown .vm-op-dropdown-menu.ant-dropdown-menu {
.vm-op-dropdown-menu {
min-width: 176px;
padding: 6px;
border: 1px solid color-mix(in srgb, var(--ln-ink, #0f172a) 8%, transparent);
@@ -112,30 +114,33 @@
0 4px 10px -4px rgba(15, 23, 42, 0.08);
}
.vm-op-dropdown .ant-dropdown-menu-item {
.vm-op-dropdown-item {
display: flex;
align-items: center;
width: 100%;
min-height: 40px;
margin: 2px 0;
padding: 8px 10px;
border: none;
border-radius: 6px;
background: transparent;
font: inherit;
font-size: 14px;
line-height: 1.4;
color: var(--ln-ink, #0f172a);
text-align: left;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.vm-op-dropdown .ant-dropdown-menu-item .ant-dropdown-menu-title-content {
flex: 1;
min-width: 0;
}
.vm-op-dropdown .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled):hover,
.vm-op-dropdown .ant-dropdown-menu-item:not(.ant-dropdown-menu-item-disabled).ant-dropdown-menu-item-active {
.vm-op-dropdown-item:hover:not(:disabled),
.vm-op-dropdown-item:focus-visible:not(:disabled) {
background: var(--ln-canvas-soft, #f4f4f5);
outline: none;
}
.vm-op-dropdown .ant-dropdown-menu-item-divider {
.vm-op-dropdown-divider {
height: 1px;
margin: 4px 8px;
background: color-mix(in srgb, var(--ln-ink, #0f172a) 8%, transparent);
}
@@ -163,38 +168,32 @@
white-space: nowrap;
}
.vm-op-dropdown .vm-op-dropdown-menu-item--danger:not(.ant-dropdown-menu-item-disabled) .vm-op-menu-item__label,
.vm-op-dropdown .ant-dropdown-menu-item-danger:not(.ant-dropdown-menu-item-disabled) .vm-op-menu-item__label {
.vm-op-dropdown-item--danger .vm-op-menu-item__label,
.vm-op-menu-item--danger .vm-op-menu-item__label {
color: var(--ln-error, #e5484d);
font-weight: 500;
}
.vm-op-dropdown .vm-op-dropdown-menu-item--danger:not(.ant-dropdown-menu-item-disabled) .vm-op-menu-item__icon,
.vm-op-dropdown .ant-dropdown-menu-item-danger:not(.ant-dropdown-menu-item-disabled) .vm-op-menu-item__icon {
.vm-op-dropdown-item--danger .vm-op-menu-item__icon,
.vm-op-menu-item--danger .vm-op-menu-item__icon {
color: var(--ln-error, #e5484d);
}
.vm-op-dropdown .vm-op-dropdown-menu-item--danger:not(.ant-dropdown-menu-item-disabled):hover,
.vm-op-dropdown .vm-op-dropdown-menu-item--danger:not(.ant-dropdown-menu-item-disabled).ant-dropdown-menu-item-active,
.vm-op-dropdown .ant-dropdown-menu-item-danger:not(.ant-dropdown-menu-item-disabled):hover,
.vm-op-dropdown .ant-dropdown-menu-item-danger:not(.ant-dropdown-menu-item-disabled).ant-dropdown-menu-item-active {
.vm-op-dropdown-item--danger:hover:not(:disabled),
.vm-op-dropdown-item--danger:focus-visible:not(:disabled) {
background: color-mix(in srgb, var(--ln-error, #e5484d) 8%, transparent);
}
.vm-op-dropdown .ant-dropdown-menu-item-disabled {
.vm-op-dropdown-item--disabled,
.vm-op-dropdown-item:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.vm-operation-actions .ant-dropdown-menu-item-danger,
.vm-operation-actions .ant-dropdown-menu-item-danger > span {
color: var(--ln-error, #e5484d) !important;
}
@media (prefers-reduced-motion: reduce) {
.vm-op-action,
.vm-op-more-btn,
.vm-op-dropdown .ant-dropdown-menu-item {
.vm-op-dropdown-item {
transition: none;
}
}

View File

@@ -0,0 +1,152 @@
# AutoRDO 需求清洗工作台 · 粘回确认与上报门禁
> 实现:`lib/parseDraft.ts`、`lib/pendingQuestions.ts`、`lib/applyAnswer.ts`、`lib/inferMeta.ts`、`lib/buildRecordPrompt.ts`
> UI`components/StepConfirm.tsx`、`components/StepExport.tsx`
本规格覆盖步骤 2「确认待确认」的解析 / 答题 / 元数据补齐,以及进入步骤 3「上报云效」前的门禁判定。
**原型本地逻辑;未接真实云效 API、未接 AutoRDO 服务端。**
---
## 前置条件
| 场景 | 是否校验 / 是否执行 |
|------|---------------------|
| 粘回区为空就点「解析初稿」 | 拦截:提示「请先粘贴 AutoRDO 初稿」;不改草稿列表 |
| 粘回文不含 `**标题**` /「原始诉求AutoRDO」可识别块 | 拦截:提示未能解析;不改草稿列表 |
| 尚未解析出任何草稿 | 不上报;步骤 3 导航禁用(无 drafts步骤 2 出口提示「请先粘回并解析」 |
| 某条仍有未答「待确认」 | 该条 `confirmed=false`;不可进入上报 |
| 某条元数据不齐(类型 / 优先级 / 标签 / 提交部门 / 提交人) | `metaComplete=false`;不可进入上报 / 不可复制上报提示词 |
| 答题时选项与补充均为空 | 「确认并下一题」禁用;不调用 `applyPendingAnswer` |
| 补充文案非空 | **优先**用补充文案作为答案(覆盖单选) |
---
## 判定优先级顺序
### A. 解析初稿(`parseAutorodoDraft`
按块拆分后,对每个需求块提取字段;**命中即采用,不再用更弱规则覆盖已提取值**。
| 优先级 | 条件 | 结果 |
|--------|------|------|
| 1 | Markdown 能按 `### N` 切出多块且块内含「原始诉求AutoRDO」或 `**标题**` | 多条 `RequirementDraft` |
| 2 | 无法切块,但全文含 `**标题**` | 整篇视为 1 条草稿 |
| 3 | 以上皆否 | 返回空数组 → UI 报解析失败 |
| 4 | 块内匹配 `**标题**:…` | 写入 `title` |
| 5 | 块内匹配 `**描述**` 至「待确认」之前 | 写入 `description` |
| 6 | 「待确认:」后为「无」或空 | `pendings=[]``confirmed=true` |
| 7 | 「待确认:」后有列表行 | 每行一条 `PendingItem`(去列表前缀),`confirmed=false` |
解析成功后流水线(`StepConfirm.handleParse`
1. `parseAutorodoDraft` — 拆条,并读取块内结构化元数据行(`**类型**` / `**优先级**` / `**标签**` / `**提交部门**` / `**提交人**`
2. `enrichPendingsWithQuestions` — 为每条待确认生成题干与选项
3. `inferRequirementMeta(标题+描述)``mergeInferredMeta`**仅填补仍空的字段**(结构化行优先)
### A2. 结构化元数据行(`parseStructuredMeta`
| 字段 | 行格式示例 | 归一 |
|------|------------|------|
| 类型 | `**类型**:【优化】` | 含「新增」→新增;含「优化」→优化 |
| 优先级 | `**优先级**P1-高` / `高` | P0/紧急→紧急P1/高→高P2/中→中P3/低→低 |
| 标签 | `**标签**:交车管理, PC端` | 按 `,,、` 拆分 |
| 提交部门 | `**提交部门**:业务管理组` | 原文 trim |
| 提交人 | `**提交人**:杨凤娥` | 原文 trim |
支持 `### 1``### 1已确认` 分段。
### B. 待确认选项启发式(`buildOptionsForPending`
| 优先级 | 原文匹配 | 题干 / 选项 |
|--------|----------|-------------|
| 1 | 含「导出范围」或「范围」 | 「导出范围」指什么?→ 当前筛选结果 / 全部数据 / 仅勾选行 |
| 2 | 含「模块」或「归属」 | 归属哪个业务模块?→ 租赁合同管理 / 车辆管理 / 证照管理 / 还车应结款 |
| 3 | 其它 | 原文(补「?」)→ 确认按原文理解 / 暂不纳入本需求 / 需文字补充 |
### C. 答题写回描述(`applyPendingAnswer`
| 优先级 | 条件 | 描述写回 |
|--------|------|----------|
| 0 | 答案 trim 为空,或找不到 `pendingId` | 原样返回,不改 |
| 1 | 描述中存在 `{标签}:待确认` 占位 | 替换为 `{标签}{答案}` |
| 2 | 描述中含任意「待确认」 | 替换**首个**「待确认」为答案 |
| 3 | 以上皆否 | 在描述末尾追加 `{标签}{答案}` |
答完后:该 pending `resolved=true`;若全部 resolved → `confirmed=true`
### D. 元数据启发式(`inferRequirementMeta`
解析后预填;**已有值不覆盖**`mergeInferredMeta`)。用户可改。未识别则保持空,仍须手填后才能上报。
| 字段 | 优先级 | 匹配 |
|------|--------|------|
| 类型 | 1 | 标题/文中 `【新增】` / `【优化】` |
| 类型 | 2 | `类型:新增\|优化` |
| 类型 | 3 | 关键词:新功能/新建 → 新增;改进/改造/修复/增强/优化 → 优化 |
| 优先级 | 1 | `优先级:紧急\|高\|中\|低` |
| 优先级 | 2 | 紧急/加急/特急/asap → 紧急;高优/尽快 → 高;低优/可延后 → 低;中优 → 中 |
| 标签 | 1 | `标签a、b`(按顿号/逗号拆) |
| 标签 | 2 | 文中命中模块词表(租赁合同管理、车辆管理、证照管理、还车应结款、故障管理、工作台等),最多 3 个 |
| 提交部门 | 1 | `提交部门\|部门:…` |
| 提交部门 | 2 | 固定词:业务管理组 / 产品部 / 安全部 / 运营部 / 财务部 / 技术部 |
| 提交人 | 1 | `提交人\|反馈人\|提出人:…` |
| 提交人 | 2 | `@姓名`24 汉字) |
| 提交人 | 3 | `姓名反馈` |
### E. 进入上报门禁(`StepConfirm` + `metaComplete` + `buildYunxiaoRecordPrompt`
全部需求须同时满足:
| 优先级 | 条件 | 用户可见结果 |
|--------|------|--------------|
| 1 | `drafts.length > 0` | 否则提示先粘回解析 |
| 2 | 每条 `confirmed === true`(无未答待确认) | 「需求 N还有 K 项待确认」 |
| 3 | 每条 `metaComplete`类型、优先级、≥1 标签、提交部门、提交人 | 「需求 N缺少 …」 |
| 4 | 全部通过 | 显示「进入上报」;步骤 3 可复制 YunxiaoPMapp 多条记录提示词 |
`buildYunxiaoRecordPrompt` 在复制时再次校验;未通过则抛错,步骤 3 预览区展示错误与缺失清单。
---
## 数据源
| 来源 | 类型 | 说明 |
|------|------|------|
| 粘回 Markdown | 用户粘贴 | Cursor 跑完 AutoRDO 后的初稿;**本机剪贴板,非 API** |
| 本地启发式选项 | 代码常量 | `pendingQuestions.ts` / `inferMeta.ts` 正则与枚举;**未接字典服务** |
| 用户答题与元数据 | 会话内存 | 刷新丢失;**未持久化、未接云效** |
| 清洗 / 上报提示词 | 本地拼接 | `buildCleanPrompt` / `buildYunxiaoRecordPrompt`;用户自行粘到 Cursor 执行 Skill |
**明确非数据源**:云效 OpenAPI、AutoRDO 远程清洗、需求库回写。
---
## 用户可见结果
| 动作 | 通过 | 失败 / 拦截 |
|------|------|-------------|
| 解析初稿 | 出现需求分页胶囊;展示标题、实时描述、待确认卡、元数据区 | 红字错误提示;草稿不变 |
| 确认并下一题 | 描述实时更新;下一题或「本需求待确认项已全部答完」 | 按钮禁用(无答案) |
| 补齐元数据 | 分段按钮 / 标签芯片即时反映 | — |
| 进入上报 | 主按钮可点 → 步骤 3 | 「尚不能进入上报」+ 缺失列表 |
| 复制上报提示词 | Toast已复制请粘到 Cursor 跑 YunxiaoPMapp | 预览区错误 +「请返回步骤 2」 |
| 复制定稿 Markdown | Toast已复制定稿 Markdown | 无草稿时禁用 |
标题写入上报口令时格式:`【新增|优化】` + 标题(若标题已带同前缀则去重)。每条 `推进至=暂不推进`
---
## 代码路径
| 能力 | 路径 |
|------|------|
| 解析粘回稿 | `lib/parseDraft.ts``parseAutorodoDraft` |
| 待确认题干与选项 | `lib/pendingQuestions.ts``buildOptionsForPending` / `enrichPendingsWithQuestions` |
| 答题写回 | `lib/applyAnswer.ts``applyPendingAnswer` |
| 部门 / 人 / 类型 / 优先级 / 标签预填 | `lib/inferMeta.ts``inferRequirementMeta` / `mergeInferredMeta` |
| 元数据齐全 & 上报提示词 | `lib/buildRecordPrompt.ts``metaComplete` / `buildYunxiaoRecordPrompt` |
| 确认步 UI 与门禁 | `components/StepConfirm.tsx` |
| 上报步复制 | `components/StepExport.tsx` |
关联产品说明:见 [requirements-prd.md](./requirements-prd.md) §6 / §8。

View File

@@ -0,0 +1,245 @@
# AutoRDO 需求清洗工作台 · 产品需求说明(全模块)
## 1. 一句话与目标
**一句话**
给产品经理一个网页工作台:粘贴聊天/截图/语音素材 → 复制 AutoRDO 清洗提示词 → 粘回初稿确认待确认并补齐上报字段 → 复制 YunxiaoPMapp 多条「记录需求」提示词,在 Cursor 侧完成入库。
**要解决的问题**
- 碎片素材难直接写成可入库的标题与书面描述
- 多条诉求混在一起,容易漏拆或合并错
- 「待确认」项靠聊天来回补,缺少结构化答题与门禁
- 上报云效前缺少类型、优先级、标签、提交部门/人的齐套校验
- 不想在网页直连云效,只希望生成可粘贴执行的口令
**本期目标**
1. 三步工作台:粘贴素材 → 确认待确认 → 上报云效(复制口令)
2. 支持文本 + 图片/语音附件芯片(不上传;由 Cursor 多模态识读)
3. 一键复制 AutoRDO 清洗提示词;可选「粘贴后自动复制」
4. 粘回初稿后解析多条需求、分页答题、补齐元数据
5. 齐套后一键复制 YunxiaoPMapp 多条记录需求提示词(及定稿 Markdown
**非目标(本期不做)**
- 不直连云效 OpenAPI / 不在网页内建单
- 不在网页内跑 AutoRDO 模型清洗(只生成提示词)
- 不做账号登录、权限、草稿云端持久化
- 不替代 YunxiaoPMapp / AutoRDO Skill 本体规则
## 2. 模块边界(最重要)
```mermaid
flowchart LR
subgraph web [AutoRDO 网页工作台]
P[粘贴素材]
C[确认待确认]
E[上报口令]
end
Cursor[Cursor + AutoRDO / YunxiaoPMapp]
YX[云效需求]
P -->|复制清洗提示词| Cursor
Cursor -->|粘回初稿 Markdown| C
C --> E
E -->|复制记录需求提示词| Cursor
Cursor -->|Skill 执行| YX
```
| 业务线 / 子域 | 做什么 | 不做什么 |
|---------------|--------|----------|
| 素材采集 | 粘贴文本与图/语音文件名芯片,生成清洗提示词 | 不上传附件、不识读媒体二进制 |
| 确认补齐 | 解析初稿、答待确认、编辑标题/描述、补元数据 | 不自动推进云效状态 |
| 上报出口 | 预览并复制多条「记录需求」口令 / 定稿 Markdown | 不调用云效 API |
**外部依赖(产品口径)**
| 依赖方 | 交互方式 | 产品要求 |
|--------|----------|----------|
| AutoRDOCursor Skill | 用户粘贴清洗提示词后执行 | 产出含标题、描述、「待确认」的 Markdown 初稿 |
| YunxiaoPMappCursor Skill | 用户粘贴上报提示词后执行 | 按条独立记录需求;本工作台默认「暂不推进」 |
| 剪贴板 | 浏览器复制 / 粘贴 | 复制失败时明确提示,可重试 |
**关键约束**
- 一条上报口令可含多条「记录需求」,**禁止合并为一条**
- 未答完待确认或元数据不齐 → **禁止**进入可复制的上报态
- 标题类型仅「新增 / 优化」;写入口令时带 `【新增】` / `【优化】` 前缀
## 3. 用户与角色
| 角色 | 主要目标 |
|------|----------|
| 产品经理(如王冕) | 快速把碎片诉求洗成可入库描述,并复制口令交给 YunxiaoPMapp |
| 兼岗业管 / 业务接口人 | 粘贴业务反馈素材,确认模糊项后交产品入库 |
| 评审 / 培训 | 演示「清洗 → 确认 → 上报口令」闭环,不改真实云效 |
## 4. 用户故事与故事点(业务条线说明口径)
> 合计约 **13 SP**(可按团队基准调整)。
### Epic A · 粘贴与清洗提示词(约 4 SP
#### A1 · 多模态素材粘贴与复制清洗提示词
- **角色**:产品经理
- **起点**:手里有聊天记录、口述笔记、截图或语音文件名
- **怎么运作**
1. 在步骤 1 粘贴正文;粘贴图片/语音时显示附件芯片(可移除)
2. 点「确认并复制清洗提示词」,或开启「粘贴后自动复制」
3. 将提示词粘到 Cursor按 AutoRDO 规则清洗
- **关键结果**`可`无正文仅附件时仍复制;`禁止`正文与附件皆空时复制
- **闭环**:剪贴板拿到可执行的 AutoRDO 提示词Toast 提示去 Cursor 执行
- **排期**US-01 · M
### Epic B · 粘回确认与元数据(约 6 SP
#### B1 · 解析初稿并分页答待确认
- **角色**:产品经理
- **起点**Cursor 已产出 AutoRDO 初稿 Markdown
- **怎么运作**
1. 粘回步骤 2点「解析初稿」拆成多条需求分页
2. 对每条未确认项选题或填写补充,确认后描述实时写回
3. 可手改标题与描述
- **关键结果**`可`跳过已全部确认的需求;`禁止`空答案提交
- **闭环**:各需求「待确认」清零或标明无待确认
- **排期**US-02 · L
#### B2 · 补齐上报元数据并门禁放行
- **角色**:产品经理
- **起点**:待确认已处理或无需处理
- **怎么运作**
1. 选择或确认类型(新增/优化)、优先级;添加标签;确认提交部门与提交人(均可从内容自动预填,可改)
2. 系统列出缺失项;全部齐套后出现「进入上报」
- **关键结果**`禁止`缺项时进入可复制上报
- **闭环**:全部需求确认且元数据齐套,进入步骤 3
- **排期**US-03 · M
> 判定顺序、启发式选项与写回规则全文见 **[confirm-flow.md](./confirm-flow.md)**。
### Epic C · 云效上报口令(约 3 SP
#### C1 · 复制多条记录需求提示词
- **角色**:产品经理
- **起点**:步骤 2 已放行
- **怎么运作**
1. 在步骤 3 预览 YunxiaoPMapp 多条「记录需求」口令
2. 一键复制;可选复制定稿 Markdown 存档
3. 粘到 Cursor 执行 YunxiaoPMapp本页不直连云效
- **关键结果**`禁止`未齐套时复制上报口令;每条独立、暂不推进
- **闭环**:口令已复制,可在 Cursor 侧完成入库
- **排期**US-04 · M
## 5. 功能模块说明(正向 / 逆向)
### 5.1 粘贴素材
**正向**
1. 输入或粘贴文本;粘贴图/语音出现附件芯片
2. 确认复制清洗提示词;成功 Toast
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| 无正文且无附件 | 主按钮禁用 |
| 复制失败 | 错误提示,可重试 |
| 关闭自动复制 | 仅手动确认时复制 |
### 5.2 确认待确认
**正向**
1. 粘回初稿 → 解析 → 分页答题 → 补元数据 → 进入上报
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| 空粘回 / 无法解析 | 错误提示,不覆盖已有草稿(解析失败时) |
| 仍有待确认或缺元数据 | 「尚不能进入上报」+ 清单 |
| 补充文案与单选并存 | 以补充文案为准 |
### 5.3 上报云效(口令)
**正向**
1. 预览完整提示词 → 一键复制 → 去 Cursor 执行
2. 可另复制定稿 Markdown待确认
**逆向 / 边界**
| 情况 | 系统表现 |
|------|----------|
| 草稿未齐套 | 预览区错误 + 缺失列表;复制上报禁用 |
| 无草稿 | 提示先完成步骤 2 |
| 本页 | **不**调用云效 API |
## 6. 关键业务逻辑(必须对齐)
### 6.1 粘回确认与上报门禁
摘要(全文见 [confirm-flow.md](./confirm-flow.md)
| 优先级 | 规则 | 说明 |
|--------|------|------|
| 1 | 先能解析出 ≥1 条需求 | 否则无法确认/上报 |
| 2 | 每条待确认须答完或初稿标明「无」 | 否则不可上报 |
| 3 | 类型、优先级、标签、提交部门、提交人齐套 | 否则不可上报 |
| 4 | 上报口令按条独立 + 暂不推进 | 不合并、不在本页推进状态 |
**数据源(产品口径)**
- 粘回 Markdown用户从 Cursor 粘贴)
- 本地启发式 + **结构化元数据行**`**类型**` / `**优先级**` / `**标签**` / `**提交部门**` / `**提交人**`,含 `P1-高`)预填(原型本地,未接真 API
- 用户当场填写的答案与元数据(会话内)
**用户可见结果**
- 通过:进入上报并可复制口令
- 拦截:缺失清单文案;复制上报不可用
## 7. 总览流程图
```mermaid
flowchart TD
A[粘贴文本/图/语音芯片] --> B[复制 AutoRDO 清洗提示词]
B --> C[Cursor 执行 AutoRDO]
C --> D[粘回初稿 Markdown]
D --> E{解析成功?}
E -->|否| D
E -->|是| F[分页答待确认]
F --> G[补齐元数据]
G --> H{全部齐套?}
H -->|否| F
H -->|是| I[复制 YunxiaoPMapp 多条记录提示词]
I --> J[Cursor 执行入库]
J --> K[云效出现需求 · 本页结束]
```
## 8. 验收清单(产品 / 测试)
**粘贴与清洗**
- [ ] 可粘贴文本;粘贴图片/语音显示附件芯片且可移除
- [ ] 「确认并复制清洗提示词」生成含 AutoRDO 指令与素材区的提示词
- [ ] 「粘贴后自动复制」开启时,粘贴文本会自动复制
- [ ] 正文与附件皆空时不可复制
**确认与门禁**
- [ ] 粘回合法初稿可解析出多条并分页
- [ ] 待确认可选题或补充;描述实时更新
- [ ] 未答完或元数据不齐时不可进入上报,并列出缺失项
- [ ] 类型/优先级/标签/部门/人可从内容预填且可改;类型仅新增/优化
**上报口令**
- [ ] 齐套后可复制多条「记录需求」口令,含优先级/标签/部门/人,推进至=暂不推进
- [ ] 可复制定稿 Markdown待确认
- [ ] 页面无云效直连写单行为
**文档**
- [ ] 标注目录可打开「产品需求说明PRD」与「粘回确认与上报门禁」
- [ ] 规格与页面行为一致(见 confirm-flow.md
## 9. 交付口径
交付可运行的 **AutoRDO 需求清洗工作台** 原型:产品经理在网页完成「粘贴素材 → 复制清洗提示词 → 粘回确认待确认与元数据 → 复制 YunxiaoPMapp 多条记录需求提示词」;实际清洗与云效入库均在 Cursor Skill 侧完成,**网页不直连云效**。复杂确认与门禁规则以 `.spec/confirm-flow.md` 为准。
预览路径:`/prototypes/autorodo-web`;对象存储发布形态:`{baseUrl}/autorodo-web/index.html`
## 10. 功能变更记录
> 产品经理原型发版记录。仅记功能与业务逻辑变更;不含样式/UI/表结构。
> 由「需求定稿」关键字触发追加;日常改原型不强制每改必写。
(尚无定稿版本;首轮 AutoPRD 落盘。)

View File

@@ -0,0 +1,81 @@
import React, { useState } from 'react';
import type { RequirementDraft, WizardStep } from './lib/types';
import { StepPaste } from './components/StepPaste';
import { StepConfirm } from './components/StepConfirm';
import { StepExport } from './components/StepExport';
const STEPS: Array<{ id: WizardStep; title: string; desc: string }> = [
{ id: 1, title: '粘贴素材', desc: '复制清洗提示词' },
{ id: 2, title: '确认待确认', desc: '粘回初稿并答题' },
{ id: 3, title: '上报云效', desc: '复制记录需求口令' },
];
export function AutorodoWebApp() {
const [step, setStep] = useState<WizardStep>(1);
const [drafts, setDrafts] = useState<RequirementDraft[]>([]);
const [activeIndex, setActiveIndex] = useState(0);
const [pasteBack, setPasteBack] = useState('');
const canGoStep = (id: WizardStep): boolean => {
if (id === 1) return true;
// 步骤 2始终可进便于粘回初稿有 drafts 时同样可进
if (id === 2) return true;
// 步骤 3有草稿后可预览完整校验在 StepConfirm / StepExport
if (id === 3) return drafts.length > 0;
return false;
};
return (
<div className="arw-page" data-annotation-id="arw-page">
<aside className="arw-rail" aria-label="工作台步骤">
<div className="arw-rail-brand">
<h1>AutoRDO </h1>
<p> · · </p>
</div>
{STEPS.map((item) => {
const active = step === item.id;
const enabled = canGoStep(item.id);
return (
<button
key={item.id}
type="button"
className={`arw-step-btn${active ? ' is-active' : ''}${!enabled ? ' is-disabled' : ''}`}
aria-current={active ? 'step' : undefined}
aria-disabled={!enabled}
disabled={!enabled}
onClick={() => {
if (enabled) setStep(item.id);
}}
>
<span className="arw-step-idx" aria-hidden>
{item.id}
</span>
<span className="arw-step-meta">
<span className="arw-step-title">{item.title}</span>
<span className="arw-step-desc">{item.desc}</span>
</span>
</button>
);
})}
</aside>
<main className="arw-main">
{step === 1 && <StepPaste />}
{step === 2 && (
<StepConfirm
pasteBack={pasteBack}
onPasteBackChange={setPasteBack}
drafts={drafts}
onDraftsChange={setDrafts}
activeIndex={activeIndex}
onActiveIndexChange={setActiveIndex}
onGoExport={() => setStep(3)}
/>
)}
{step === 3 && <StepExport drafts={drafts} />}
</main>
</div>
);
}
export default AutorodoWebApp;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,405 @@
import React, { useMemo, useState } from 'react';
import type { Priority, RequirementDraft, RequirementMeta } from '../lib/types';
import { parseAutorodoDraft } from '../lib/parseDraft';
import { enrichPendingsWithQuestions } from '../lib/pendingQuestions';
import { applyPendingAnswer } from '../lib/applyAnswer';
import { inferRequirementMeta, mergeInferredMeta } from '../lib/inferMeta';
import { metaComplete } from '../lib/buildRecordPrompt';
const TITLE_KINDS: Array<NonNullable<RequirementMeta['titleKind']>> = ['新增', '优化'];
const PRIORITIES: Priority[] = ['紧急', '高', '中', '低'];
export type StepConfirmProps = {
pasteBack: string;
onPasteBackChange: (value: string) => void;
drafts: RequirementDraft[];
onDraftsChange: (drafts: RequirementDraft[]) => void;
activeIndex: number;
onActiveIndexChange: (index: number) => void;
onGoExport: () => void;
};
function patchDraft(
drafts: RequirementDraft[],
index: number,
patch: (d: RequirementDraft) => RequirementDraft,
): RequirementDraft[] {
return drafts.map((d, i) => (i === index ? patch(d) : d));
}
function missingHints(drafts: RequirementDraft[]): string[] {
if (drafts.length === 0) return ['请先粘回并解析 AutoRDO 初稿'];
const hints: string[] = [];
drafts.forEach((d, i) => {
const label = `需求 ${i + 1}`;
if (!d.confirmed) {
const left = d.pendings.filter((p) => !p.resolved).length;
hints.push(`${label}:还有 ${left} 项待确认`);
}
if (!metaComplete(d)) {
const m = d.meta;
const miss: string[] = [];
if (!m.titleKind) miss.push('类型');
if (!m.priority) miss.push('优先级');
if (m.tags.length === 0) miss.push('标签');
if (!m.submitDept.trim()) miss.push('提交部门');
if (!m.submitter.trim()) miss.push('提交人');
if (miss.length) hints.push(`${label}:缺少 ${miss.join('、')}`);
}
});
return hints;
}
export function StepConfirm({
pasteBack,
onPasteBackChange,
drafts,
onDraftsChange,
activeIndex,
onActiveIndexChange,
onGoExport,
}: StepConfirmProps) {
const [parseError, setParseError] = useState<string | null>(null);
const [selectedOption, setSelectedOption] = useState('');
const [supplement, setSupplement] = useState('');
const [tagInput, setTagInput] = useState('');
const safeIndex = drafts.length === 0 ? 0 : Math.min(activeIndex, drafts.length - 1);
const current = drafts[safeIndex] ?? null;
const pending = current?.pendings.find((p) => !p.resolved) ?? null;
const allReady = useMemo(
() => drafts.length > 0 && drafts.every((d) => d.confirmed && metaComplete(d)),
[drafts],
);
const hints = useMemo(() => missingHints(drafts), [drafts]);
const handleParse = () => {
const md = pasteBack.trim();
if (!md) {
setParseError('请先粘贴 AutoRDO 初稿');
return;
}
const parsed = parseAutorodoDraft(md);
if (parsed.length === 0) {
setParseError('未能解析出需求,请检查初稿格式(需含 **标题** / 原始诉求)');
return;
}
const next = parsed.map((draft) => {
const enriched = enrichPendingsWithQuestions(draft);
const inferred = inferRequirementMeta(`${enriched.title}\n${enriched.description}`);
return {
...enriched,
meta: mergeInferredMeta(enriched.meta, inferred),
};
});
setParseError(null);
setSelectedOption('');
setSupplement('');
setTagInput('');
onDraftsChange(next);
onActiveIndexChange(0);
};
const updateCurrent = (patch: (d: RequirementDraft) => RequirementDraft) => {
if (!current) return;
onDraftsChange(patchDraft(drafts, safeIndex, patch));
};
const updateMeta = (partial: Partial<RequirementMeta>) => {
updateCurrent((d) => ({ ...d, meta: { ...d.meta, ...partial } }));
};
const handleConfirmAnswer = () => {
if (!current || !pending) return;
const answer = supplement.trim() || selectedOption.trim();
if (!answer) return;
const next = applyPendingAnswer(current, pending.id, answer);
onDraftsChange(patchDraft(drafts, safeIndex, () => next));
setSelectedOption('');
setSupplement('');
};
const addTag = () => {
const tag = tagInput.trim();
if (!tag || !current) return;
if (current.meta.tags.includes(tag)) {
setTagInput('');
return;
}
updateMeta({ tags: [...current.meta.tags, tag] });
setTagInput('');
};
const removeTag = (tag: string) => {
if (!current) return;
updateMeta({ tags: current.meta.tags.filter((t) => t !== tag) });
};
const switchDraft = (index: number) => {
onActiveIndexChange(index);
setSelectedOption('');
setSupplement('');
setTagInput('');
};
return (
<section className="arw-panel arw-panel--wide" data-annotation-id="arw-step-confirm" aria-labelledby="arw-confirm-title">
<header className="arw-panel-header">
<h2 id="arw-confirm-title"></h2>
<p> AutoRDO 稿</p>
</header>
<div className="arw-field">
<label className="arw-label" htmlFor="arw-paste-back">
AutoRDO 稿
</label>
<textarea
id="arw-paste-back"
className="arw-textarea"
value={pasteBack}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onPasteBackChange(e.target.value)}
placeholder="将 Cursor 跑完 AutoRDO 后的初稿 Markdown 粘到此处…"
spellCheck={false}
/>
</div>
<div className="arw-actions">
<button type="button" className="arw-btn arw-btn--primary" onClick={handleParse}>
稿
</button>
</div>
{parseError ? (
<p className="arw-status is-error" role="alert">
{parseError}
</p>
) : null}
{drafts.length > 0 ? (
<>
<div className="arw-pills" role="tablist" aria-label="需求分页">
{drafts.map((d, i) => {
const active = i === safeIndex;
return (
<button
key={d.index}
type="button"
role="tab"
aria-selected={active}
className={`arw-pill${active ? ' is-active' : ''}${d.confirmed ? ' is-done' : ''}`}
onClick={() => switchDraft(i)}
>
{i + 1}/{drafts.length}
</button>
);
})}
</div>
{current ? (
<>
<div className="arw-confirm-grid">
<div className="arw-confirm-col">
<div className="arw-field">
<label className="arw-label" htmlFor="arw-draft-title">
</label>
<input
id="arw-draft-title"
className="arw-input"
type="text"
value={current.title}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
updateCurrent((d) => ({ ...d, title: e.target.value }))
}
/>
</div>
<div className="arw-field">
<span className="arw-label">稿</span>
<pre className="arw-desc-live">{current.description || '(空)'}</pre>
</div>
</div>
<div className="arw-confirm-col">
<div className="arw-field">
<span className="arw-label"></span>
{pending ? (
<div className="arw-pending-card">
<p className="arw-pending-q">{pending.question}</p>
<div className="arw-radio-list" role="radiogroup" aria-label="待确认选项">
{pending.options.map((opt) => (
<label key={opt} className="arw-radio">
<input
type="radio"
name={`arw-pending-${pending.id}`}
checked={selectedOption === opt}
onChange={() => setSelectedOption(opt)}
/>
<span>{opt}</span>
</label>
))}
</div>
<div className="arw-field" style={{ marginBottom: 0 }}>
<label className="arw-label" htmlFor="arw-pending-supplement">
</label>
<input
id="arw-pending-supplement"
className="arw-input"
type="text"
value={supplement}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSupplement(e.target.value)}
placeholder="可直接填写答案,将优先于单选"
/>
</div>
<div className="arw-actions">
<button
type="button"
className="arw-btn arw-btn--primary"
onClick={handleConfirmAnswer}
disabled={!supplement.trim() && !selectedOption.trim()}
>
</button>
</div>
</div>
) : (
<p className="arw-empty-hint"></p>
)}
</div>
</div>
</div>
<fieldset className="arw-meta">
<legend className="arw-label"></legend>
<p className="arw-hint">
稿/
</p>
<div className="arw-meta-row">
<span className="arw-label"></span>
<div className="arw-seg" role="group" aria-label="需求类型">
{TITLE_KINDS.map((kind) => (
<button
key={kind}
type="button"
className={`arw-seg-btn${current.meta.titleKind === kind ? ' is-active' : ''}`}
onClick={() => updateMeta({ titleKind: kind })}
>
{kind}
</button>
))}
</div>
</div>
<div className="arw-meta-row">
<span className="arw-label"></span>
<div className="arw-seg" role="group" aria-label="优先级">
{PRIORITIES.map((p) => (
<button
key={p}
type="button"
className={`arw-seg-btn${current.meta.priority === p ? ' is-active' : ''}`}
onClick={() => updateMeta({ priority: p })}
>
{p}
</button>
))}
</div>
</div>
<div className="arw-field">
<span className="arw-label"></span>
<div className="arw-chips">
{current.meta.tags.map((tag) => (
<span key={tag} className="arw-chip">
<span className="arw-chip-name">{tag}</span>
<button
type="button"
className="arw-chip-remove"
aria-label={`移除标签 ${tag}`}
onClick={() => removeTag(tag)}
>
×
</button>
</span>
))}
</div>
<div className="arw-tag-add">
<input
className="arw-input"
type="text"
value={tagInput}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTagInput(e.target.value)}
onKeyDown={(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault();
addTag();
}
}}
placeholder="输入标签后添加"
aria-label="新增标签"
/>
<button type="button" className="arw-btn" onClick={addTag} disabled={!tagInput.trim()}>
</button>
</div>
</div>
<div className="arw-meta-grid">
<div className="arw-field">
<label className="arw-label" htmlFor="arw-submit-dept">
</label>
<input
id="arw-submit-dept"
className="arw-input"
type="text"
value={current.meta.submitDept}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
updateMeta({ submitDept: e.target.value })
}
/>
</div>
<div className="arw-field">
<label className="arw-label" htmlFor="arw-submitter">
</label>
<input
id="arw-submitter"
className="arw-input"
type="text"
value={current.meta.submitter}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
updateMeta({ submitter: e.target.value })
}
/>
</div>
</div>
</fieldset>
</>
) : null}
</>
) : null}
<div className="arw-export-gate">
{allReady ? (
<button type="button" className="arw-btn arw-btn--primary" onClick={onGoExport}>
</button>
) : (
<div className="arw-missing" role="status">
<p className="arw-missing-title"></p>
<ul>
{hints.map((h) => (
<li key={h}>{h}</li>
))}
</ul>
</div>
)}
</div>
</section>
);
}
export default StepConfirm;

View File

@@ -0,0 +1,185 @@
import React, { useCallback, useMemo, useState } from 'react';
import type { RequirementDraft } from '../lib/types';
import { buildYunxiaoRecordPrompt, metaComplete } from '../lib/buildRecordPrompt';
import { copyText } from '../lib/clipboard';
const TOAST_MS = 2800;
export type StepExportProps = {
drafts: RequirementDraft[];
};
/** 定稿 Markdown多份已确认 ## 原始诉求AutoRDO待确认无 */
export function buildConfirmedMarkdown(drafts: RequirementDraft[]): string {
const blocks = drafts.map((d, i) =>
[
`### ${i + 1}`,
'## 原始诉求AutoRDO',
'',
`**标题**${d.title}`,
'',
'**描述**',
d.description,
'',
'待确认:',
'无',
].join('\n'),
);
return [`${drafts.length}`, '', blocks.join('\n\n')].join('\n');
}
function missingFieldHints(drafts: RequirementDraft[]): string[] {
if (drafts.length === 0) return ['暂无需求草稿,请先完成步骤 2'];
const hints: string[] = [];
drafts.forEach((d, i) => {
const label = `需求 ${i + 1}`;
if (!d.confirmed) {
const left = d.pendings.filter((p) => !p.resolved).length;
hints.push(`${label}:还有 ${left} 项待确认`);
}
if (!metaComplete(d)) {
const m = d.meta;
const miss: string[] = [];
if (!m.titleKind) miss.push('类型');
if (!m.priority) miss.push('优先级');
if (m.tags.length === 0) miss.push('标签');
if (!m.submitDept.trim()) miss.push('提交部门');
if (!m.submitter.trim()) miss.push('提交人');
if (miss.length) hints.push(`${label}:缺少 ${miss.join('、')}`);
}
});
return hints;
}
export function StepExport({ drafts }: StepExportProps) {
const [toast, setToast] = useState<string | null>(null);
const [status, setStatus] = useState<{ kind: 'ok' | 'error'; message: string } | null>(null);
const [copying, setCopying] = useState<'prompt' | 'markdown' | null>(null);
const preview = useMemo(() => {
try {
return { ok: true as const, text: buildYunxiaoRecordPrompt(drafts), hints: [] as string[] };
} catch (err) {
const message = err instanceof Error ? err.message : '生成上报提示词失败';
return { ok: false as const, text: '', message, hints: missingFieldHints(drafts) };
}
}, [drafts]);
const showToast = useCallback((message: string) => {
setToast(message);
window.setTimeout(() => {
setToast((cur: string | null) => (cur === message ? null : cur));
}, TOAST_MS);
}, []);
const handleCopyPrompt = useCallback(async () => {
if (!preview.ok) {
setStatus({ kind: 'error', message: preview.message });
return;
}
setCopying('prompt');
try {
await copyText(preview.text);
const message = '已复制上报提示词,请粘到 Cursor 跑 YunxiaoPMapp';
setStatus({ kind: 'ok', message });
showToast(message);
} catch {
setStatus({
kind: 'error',
message: '复制失败。可全选上方预览手动复制,或检查浏览器剪贴板权限后重试',
});
setToast(null);
} finally {
setCopying(null);
}
}, [preview, showToast]);
const handleCopyMarkdown = useCallback(async () => {
if (drafts.length === 0) {
setStatus({ kind: 'error', message: '暂无需求草稿可复制' });
return;
}
setCopying('markdown');
try {
await copyText(buildConfirmedMarkdown(drafts));
const message = '已复制定稿 Markdown';
setStatus({ kind: 'ok', message });
showToast(message);
} catch {
setStatus({
kind: 'error',
message: '复制失败。可全选上方预览手动复制,或检查浏览器剪贴板权限后重试',
});
setToast(null);
} finally {
setCopying(null);
}
}, [drafts, showToast]);
return (
<section className="arw-panel arw-panel--wide" data-annotation-id="arw-step-export" aria-labelledby="arw-export-title">
<header className="arw-panel-header">
<h2 id="arw-export-title"></h2>
<p> YunxiaoPMapp 稿 Markdown </p>
</header>
<div className="arw-field">
<span className="arw-label" id="arw-export-preview-label">
</span>
{preview.ok ? (
<pre className="arw-export-preview" aria-labelledby="arw-export-preview-label" tabIndex={0}>
{preview.text}
</pre>
) : (
<div className="arw-export-error" role="alert">
<p className="arw-export-error-title">{preview.message}</p>
{preview.hints.length > 0 ? (
<ul>
{preview.hints.map((h) => (
<li key={h}>{h}</li>
))}
</ul>
) : null}
<p className="arw-export-error-hint"> 2 </p>
</div>
)}
</div>
<div className="arw-actions">
<button
type="button"
className="arw-btn arw-btn--primary"
onClick={() => void handleCopyPrompt()}
disabled={copying !== null || !preview.ok}
>
{copying === 'prompt' ? '复制中…' : '一键复制上报提示词'}
</button>
<button
type="button"
className="arw-btn"
onClick={() => void handleCopyMarkdown()}
disabled={copying !== null || drafts.length === 0}
>
{copying === 'markdown' ? '复制中…' : '复制定稿 Markdown'}
</button>
</div>
{status ? (
<p className={`arw-status${status.kind === 'error' ? ' is-error' : ''}`} role="status">
{status.message}
</p>
) : (
<p className="arw-status" aria-live="polite" />
)}
{toast ? (
<div className="arw-toast" role="status" aria-live="polite">
{toast}
</div>
) : null}
</section>
);
}
export default StepExport;

View File

@@ -0,0 +1,174 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { buildCleanPrompt } from '../lib/buildCleanPrompt';
import { copyText } from '../lib/clipboard';
const TOAST_MS = 2800;
const MEDIA_RE = /^(image|audio)\//;
function collectMediaNames(fileList: FileList | null | undefined): string[] {
if (!fileList || fileList.length === 0) return [];
return Array.from(fileList)
.filter((f) => MEDIA_RE.test(f.type))
.map((f) => f.name)
.filter(Boolean);
}
export function StepPaste() {
const [text, setText] = useState('');
const [attachmentNames, setAttachmentNames] = useState<string[]>([]);
const [autoCopyOnPaste, setAutoCopyOnPaste] = useState(false);
const [status, setStatus] = useState<{ kind: 'ok' | 'error'; message: string } | null>(null);
const [toast, setToast] = useState<string | null>(null);
const [copying, setCopying] = useState(false);
const autoCopyRef = useRef(autoCopyOnPaste);
const attachmentNamesRef = useRef(attachmentNames);
useEffect(() => {
autoCopyRef.current = autoCopyOnPaste;
}, [autoCopyOnPaste]);
useEffect(() => {
attachmentNamesRef.current = attachmentNames;
}, [attachmentNames]);
const showCopiedFeedback = useCallback(() => {
const message = '已复制,请粘到 Cursor 跑 AutoRDO';
setStatus({ kind: 'ok', message });
setToast(message);
window.setTimeout(() => {
setToast((cur: string | null) => (cur === message ? null : cur));
}, TOAST_MS);
}, []);
const runCopy = useCallback(
async (payload: { text: string; attachmentNames: string[] }) => {
const prompt = buildCleanPrompt(payload);
setCopying(true);
try {
await copyText(prompt);
showCopiedFeedback();
} catch {
setStatus({
kind: 'error',
message: '复制失败。可手动全选提示词,或检查浏览器剪贴板权限后重试',
});
setToast(null);
} finally {
setCopying(false);
}
},
[showCopiedFeedback],
);
const handleConfirmCopy = useCallback(() => {
void runCopy({ text, attachmentNames });
}, [attachmentNames, runCopy, text]);
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const mediaNames = collectMediaNames(e.clipboardData.files);
let nextAttachments = attachmentNamesRef.current;
if (mediaNames.length > 0) {
nextAttachments = [...attachmentNamesRef.current];
for (const name of mediaNames) {
if (!nextAttachments.includes(name)) nextAttachments.push(name);
}
setAttachmentNames(nextAttachments);
}
const pastedText = e.clipboardData.getData('text/plain');
if (!autoCopyRef.current || !pastedText) return;
const el = e.currentTarget;
window.setTimeout(() => {
const nextText = el.value;
void runCopy({ text: nextText, attachmentNames: nextAttachments });
}, 0);
},
[runCopy],
);
const removeAttachment = useCallback((name: string) => {
setAttachmentNames((prev: string[]) => prev.filter((n: string) => n !== name));
}, []);
return (
<section className="arw-panel" data-annotation-id="arw-step-paste" aria-labelledby="arw-paste-title">
<header className="arw-panel-header">
<h2 id="arw-paste-title"></h2>
<p> AutoRDO </p>
</header>
<div className="arw-field">
<label className="arw-label" htmlFor="arw-paste-input">
</label>
<textarea
id="arw-paste-input"
className="arw-textarea"
value={text}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)}
onPaste={handlePaste}
placeholder="在此粘贴聊天记录、口述笔记、表格行等…也可粘贴图片/语音(将显示为附件芯片)"
spellCheck={false}
/>
</div>
{attachmentNames.length > 0 ? (
<div className="arw-field" aria-label="附件列表">
<span className="arw-label"></span>
<div className="arw-chips">
{attachmentNames.map((name: string) => (
<span key={name} className="arw-chip" title={name}>
<span className="arw-chip-name">{name}</span>
<button
type="button"
className="arw-chip-remove"
aria-label={`移除附件 ${name}`}
onClick={() => removeAttachment(name)}
>
×
</button>
</span>
))}
</div>
</div>
) : null}
<div className="arw-actions">
<button
type="button"
className="arw-btn arw-btn--primary"
onClick={handleConfirmCopy}
disabled={copying || (!text.trim() && attachmentNames.length === 0)}
>
{copying ? '复制中…' : '确认并复制清洗提示词'}
</button>
<label className="arw-check">
<input
type="checkbox"
checked={autoCopyOnPaste}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setAutoCopyOnPaste(e.target.checked)}
/>
</label>
</div>
{status ? (
<p className={`arw-status${status.kind === 'error' ? ' is-error' : ''}`} role="status">
{status.message}
</p>
) : (
<p className="arw-status" aria-live="polite" />
)}
{toast ? (
<div className="arw-toast" role="status" aria-live="polite">
{toast}
</div>
) : null}
</section>
);
}
export default StepPaste;

View File

@@ -0,0 +1,37 @@
/**
* @name AutoRDO 需求清洗工作台
* 粘贴素材 · 确认待确认 · 复制云效上报提示词
*/
import './style.css';
import React, { useMemo } from 'react';
import {
type AnnotationSourceDocument,
type AnnotationViewerOptions,
} from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import annotationSourceDocument from './annotation-source.json';
import { AutorodoWebApp } from './AutorodoWebApp';
export default function AutorodoWebEntry() {
const annotationOptions = useMemo<AnnotationViewerOptions>(
() => ({
showToolbar: true,
showThemeToggle: true,
showColorFilter: true,
emptyWhenNoData: false,
toolbarEdge: 'right',
currentPageId: 'main',
}),
[],
);
return (
<>
<AutorodoWebApp />
<PrototypeAnnotationHost
source={annotationSourceDocument as AnnotationSourceDocument}
options={annotationOptions}
/>
</>
);
}

View File

@@ -0,0 +1,12 @@
export type CleanRequest = { text: string; attachmentNames: string[] };
export type RecordRequest = { drafts: import('./types').RequirementDraft[] };
/** MVP未接组织 Agent后续替换实现即可 */
export const agentAdapter = {
async clean(_req: CleanRequest): Promise<null> {
return null;
},
async record(_req: RecordRequest): Promise<null> {
return null;
},
};

View File

@@ -0,0 +1,69 @@
import { describe, it, expect } from 'vitest';
import { applyPendingAnswer } from './applyAnswer';
import type { RequirementDraft } from './types';
const base: RequirementDraft = {
index: 0,
title: '合同台账批量导出',
description: '在租赁合同台账支持批量导出\n导出范围待确认',
pendings: [
{
id: 'r0-p0',
raw: '导出范围未说明',
question: '导出范围指什么?',
options: ['当前筛选结果', '全部合同', '仅勾选行'],
resolved: false,
},
],
meta: { priority: '', tags: [], submitDept: '', submitter: '', titleKind: '' },
confirmed: false,
};
describe('applyPendingAnswer', () => {
it('merges answer into description and marks pending resolved', () => {
const next = applyPendingAnswer(base, 'r0-p0', '当前筛选结果');
expect(next.pendings[0].resolved).toBe(true);
expect(next.pendings[0].answer).toBe('当前筛选结果');
expect(next.description).toContain('当前筛选结果');
expect(next.description).not.toMatch(/导出范围:待确认/);
expect(next.confirmed).toBe(true);
});
it('leaves draft unchanged when answer is empty', () => {
const next = applyPendingAnswer(base, 'r0-p0', ' ');
expect(next).toBe(base);
expect(next.pendings[0].resolved).toBe(false);
expect(next.description).toBe(base.description);
expect(next.confirmed).toBe(false);
});
it('replaces only the scoped placeholder when multiple 待确认 lines exist', () => {
const multi: RequirementDraft = {
...base,
description: '导出范围:待确认\n导出格式待确认',
pendings: [
{
id: 'r0-p0',
raw: '导出范围未说明',
question: '导出范围指什么?',
options: ['当前筛选结果', '全部合同', '仅勾选行'],
resolved: false,
},
{
id: 'r0-p1',
raw: '导出格式未说明',
question: '导出格式指什么?',
options: ['Excel', 'CSV', 'PDF'],
resolved: false,
},
],
confirmed: false,
};
const next = applyPendingAnswer(multi, 'r0-p0', '当前筛选结果');
expect(next.description).toBe('导出范围:当前筛选结果\n导出格式待确认');
expect(next.pendings[0].resolved).toBe(true);
expect(next.pendings[1].resolved).toBe(false);
expect(next.confirmed).toBe(false);
});
});

View File

@@ -0,0 +1,40 @@
import type { RequirementDraft } from './types';
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function applyPendingAnswer(
draft: RequirementDraft,
pendingId: string,
answer: string,
): RequirementDraft {
const trimmed = answer.trim();
if (!trimmed) {
return draft;
}
const hit = draft.pendings.find((p) => p.id === pendingId);
if (!hit) {
return draft;
}
const pendings = draft.pendings.map((p) =>
p.id === pendingId ? { ...p, resolved: true, answer: trimmed } : p,
);
const label = hit.raw.replace(/未说明|待确认|不清楚/g, '').trim() || hit.raw;
let description = draft.description;
const labelPlaceholder = new RegExp(`${escapeRegExp(label)}\\s*[:]\\s*待确认`);
if (labelPlaceholder.test(description)) {
description = description.replace(labelPlaceholder, `${label}${trimmed}`);
} else if (description.includes('待确认')) {
description = description.replace('待确认', trimmed);
} else {
description = `${description.replace(/\s+$/, '')}\n${label}${trimmed}`;
}
const confirmed = pendings.every((p) => p.resolved);
return { ...draft, description, pendings, confirmed };
}

View File

@@ -0,0 +1,18 @@
export function buildCleanPrompt(payload: {
text: string;
attachmentNames: string[];
}): string {
const files =
payload.attachmentNames.length > 0
? `附件:${payload.attachmentNames.join('、')}(请用多模态识读图/语音内容)`
: '附件:无';
return [
'AutoRDO',
'请按 AutoRDO 规则清洗下列素材:多条独立诉求拆成多份;提炼标题与书面描述;不确定处标「待确认」;描述无结尾句号。',
files,
'',
'—— 素材开始 ——',
payload.text.trim() || '(仅附件,无正文)',
'—— 素材结束 ——',
].join('\n');
}

View File

@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
import { buildYunxiaoRecordPrompt } from './buildRecordPrompt';
import type { RequirementDraft } from './types';
const one: RequirementDraft = {
index: 0,
title: '合同台账批量导出',
description: '在租赁合同台账支持按筛选结果批量导出 Excel',
pendings: [],
meta: {
priority: '中',
tags: ['租赁合同'],
submitDept: '产品部',
submitter: '王冕',
titleKind: '新增',
},
confirmed: true,
};
describe('buildYunxiaoRecordPrompt', () => {
it('includes priority tags dept submitter', () => {
const text = buildYunxiaoRecordPrompt([one]);
expect(text).toContain('YunxiaoPMapp');
expect(text).toContain('记录需求:【新增】合同台账批量导出');
expect(text).toContain('优先级=中');
expect(text).toContain('标签=租赁合同');
expect(text).toContain('提交部门=产品部');
expect(text).toContain('提交人=王冕');
expect(text).toContain('推进至=暂不推进');
});
it('throws when meta incomplete', () => {
expect(() =>
buildYunxiaoRecordPrompt([{ ...one, meta: { ...one.meta, submitter: '' } }]),
).toThrow(/提交人/);
});
});

View File

@@ -0,0 +1,26 @@
import type { RequirementDraft } from './types';
export function metaComplete(d: RequirementDraft): boolean {
const m = d.meta;
return Boolean(
m.priority && m.tags.length > 0 && m.submitDept.trim() && m.submitter.trim() && m.titleKind,
);
}
export function buildYunxiaoRecordPrompt(drafts: RequirementDraft[]): string {
for (const d of drafts) {
if (!d.confirmed) throw new Error(`需求 ${d.index + 1} 仍有未确认项`);
if (!metaComplete(d)) throw new Error(`需求 ${d.index + 1} 缺少优先级/标签/提交部门/提交人/类型`);
}
const lines = drafts.map((d) => {
const title = `${d.meta.titleKind}${d.title.replace(/^【(?:新增|优化)】/, '')}`;
const tags = d.meta.tags.join('、');
return `记录需求:${title};描述=${d.description.replace(/\n/g, ' ')};优先级=${d.meta.priority};标签=${tags};提交部门=${d.meta.submitDept};提交人=${d.meta.submitter};推进至=暂不推进`;
});
return [
'YunxiaoPMapp',
'请按下列条目依次记录需求;每条保持独立,不要合并。',
'',
...lines,
].join('\n');
}

View File

@@ -0,0 +1,67 @@
/**
* 复制文本到剪贴板。
* 优先 Clipboard API在 iframe / 权限受限环境下降级到 textarea + execCommand。
*/
export async function copyText(text: string): Promise<void> {
const value = String(text ?? '');
if (!value) {
throw new Error('没有可复制的内容');
}
const clipboard = typeof navigator !== 'undefined' ? navigator.clipboard : undefined;
if (clipboard?.writeText && typeof window !== 'undefined' && window.isSecureContext) {
try {
await clipboard.writeText(value);
return;
} catch {
// 继续降级(常见于嵌入宿主页、权限策略拦截)
}
}
copyTextViaTextarea(value);
}
function copyTextViaTextarea(text: string): void {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.setAttribute('aria-hidden', 'true');
textarea.style.position = 'fixed';
textarea.style.top = '0';
textarea.style.left = '0';
textarea.style.width = '1px';
textarea.style.height = '1px';
textarea.style.padding = '0';
textarea.style.border = 'none';
textarea.style.outline = 'none';
textarea.style.boxShadow = 'none';
textarea.style.background = 'transparent';
textarea.style.opacity = '0';
// iOS / 部分 WebView 需要在可视区内且可选中
textarea.style.zIndex = '-1';
document.body.appendChild(textarea);
const selection = document.getSelection();
const previousRange =
selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
let ok = false;
try {
ok = document.execCommand('copy');
} finally {
document.body.removeChild(textarea);
if (selection) {
selection.removeAllRanges();
if (previousRange) selection.addRange(previousRange);
}
}
if (!ok) {
throw new Error('execCommand copy failed');
}
}

View File

@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { inferRequirementMeta, inferSubmitMeta, mergeInferredMeta } from './inferMeta';
import type { RequirementMeta } from './types';
describe('inferRequirementMeta', () => {
it('detects dept and person from free text', () => {
const r = inferRequirementMeta('业务管理组张三反馈:合同导出不好用');
expect(r.submitDept).toMatch(/业务/);
expect(r.submitter).toContain('张三');
});
it('detects priority from labels and urgency words', () => {
expect(inferRequirementMeta('优先级:紧急,明天要上').priority).toBe('紧急');
expect(inferRequirementMeta('这个高优,尽快做').priority).toBe('高');
expect(inferRequirementMeta('低优,可延后').priority).toBe('低');
expect(inferRequirementMeta('**优先级**P1-高').priority).toBe('高');
});
it('detects titleKind from 【新增】/【优化】 and keywords', () => {
expect(inferRequirementMeta('【新增】合同台账导出').titleKind).toBe('新增');
expect(inferRequirementMeta('优化证照到期提醒口径').titleKind).toBe('优化');
expect(inferRequirementMeta('类型:优化\n改一下文案').titleKind).toBe('优化');
expect(inferRequirementMeta('**类型**:【优化】').titleKind).toBe('优化');
});
it('detects tags from explicit label or module catalog', () => {
expect(inferRequirementMeta('标签:故障管理、工作台').tags).toEqual(['故障管理', '工作台']);
expect(inferRequirementMeta('在租赁合同管理里加批量导出').tags).toContain('租赁合同管理');
});
it('inferSubmitMeta stays compatible', () => {
const r = inferSubmitMeta('产品部李四反馈:不好用');
expect(r.submitDept).toBe('产品部');
expect(r.submitter).toContain('李四');
});
});
describe('mergeInferredMeta', () => {
it('does not overwrite non-empty fields', () => {
const meta: RequirementMeta = {
titleKind: '优化',
priority: '中',
tags: ['工作台'],
submitDept: '产品部',
submitter: '王冕',
};
const merged = mergeInferredMeta(meta, {
titleKind: '新增',
priority: '紧急',
tags: ['故障管理'],
submitDept: '安全部',
submitter: '张三',
});
expect(merged).toEqual(meta);
});
it('fills empty fields from inference', () => {
const meta: RequirementMeta = {
titleKind: '',
priority: '',
tags: [],
submitDept: '',
submitter: '',
};
const merged = mergeInferredMeta(meta, {
titleKind: '新增',
priority: '高',
tags: ['证照管理'],
submitDept: '安全部',
submitter: '张三',
});
expect(merged.titleKind).toBe('新增');
expect(merged.priority).toBe('高');
expect(merged.tags).toEqual(['证照管理']);
expect(merged.submitDept).toBe('安全部');
expect(merged.submitter).toBe('张三');
});
});

View File

@@ -0,0 +1,135 @@
import type { Priority, RequirementMeta } from './types';
import { normalizePriority } from './parseDraft';
const DEPT_PATTERNS = [
/(?:\*\*)?(?:提交部门|部门)(?:\*\*)?[:]\s*([^\s,;\n]+)/,
/(业务管理组(?:-能源部)?|产品部|安全部|运营部|财务部|技术部|运维部|数智部)/,
];
const PERSON_PATTERNS = [
/(?:\*\*)?(?:提交人|反馈人|提出人)(?:\*\*)?[:]\s*([^\s,;\n]+)/,
/@([\u4e00-\u9fa5]{2,4})/,
/([\u4e00-\u9fa5]{2,4})反馈/,
];
/** 与云效模块标签 / 出题启发式对齐的可识别词表(命中则作为标签预填) */
const TAG_CATALOG = [
'租赁合同',
'租赁合同管理',
'租赁业务台账',
'提车应收款',
'车辆管理',
'车辆调拨',
'车辆氢费明细',
'证照管理',
'还车应结款',
'交车管理',
'还车管理',
'故障管理',
'违章管理',
'加氢站管理',
'工作台',
'系统全局',
'客户管理',
'供应商管理',
'保险采购',
'付款记录',
'PC端',
'小程序',
'采购',
'财务',
] as const;
export type InferredMeta = Pick<
RequirementMeta,
'titleKind' | 'priority' | 'tags' | 'submitDept' | 'submitter'
>;
function inferTitleKind(text: string): RequirementMeta['titleKind'] {
const labeledBracket = text.match(/(?:\*\*)?类型(?:\*\*)?[:]\s*【?(新增|优化)】?/);
if (labeledBracket?.[1]) return labeledBracket[1] as '新增' | '优化';
if (/【新增】|(?:新功能|新建|新增模块)/.test(text)) return '新增';
if (/【优化】|(?:改进|改造|修复|增强)/.test(text)) return '优化';
if (/(?:^|[^\u4e00-\u9fa5])新增(?:功能|需求)?/.test(text)) return '新增';
if (/(?:^|[^\u4e00-\u9fa5])优化/.test(text)) return '优化';
return '';
}
function inferPriority(text: string): Priority | '' {
const labeled = text.match(/(?:\*\*)?优先级(?:\*\*)?[:]\s*([^\n]+)/);
if (labeled?.[1]) {
const n = normalizePriority(labeled[1]);
if (n) return n;
}
if (/紧急|加急|特急|asap|P0/i.test(text)) return '紧急';
if (/P1|高优|尽快|很急/.test(text)) return '高';
if (/P3|低优|不急|可延后/.test(text)) return '低';
if (/P2|中优|一般优先级/.test(text)) return '中';
return '';
}
function inferTags(text: string): string[] {
const labeled = text.match(/(?:\*\*)?标签(?:\*\*)?[:]\s*([^\n;]+)/);
if (labeled?.[1]) {
return labeled[1]
.split(/[,,、/\s]+/)
.map((t) => t.trim())
.filter(Boolean);
}
const hits: string[] = [];
for (const tag of TAG_CATALOG) {
if (text.includes(tag) && !hits.includes(tag)) hits.push(tag);
}
// 归一:同时命中「租赁合同管理」与「租赁合同」时保留更长名
if (hits.includes('租赁合同管理') && hits.includes('租赁合同')) {
return hits.filter((t) => t !== '租赁合同');
}
return hits.slice(0, 3);
}
function inferDept(text: string): string {
for (const re of DEPT_PATTERNS) {
const m = text.match(re);
if (m?.[1]) return m[1];
}
return '';
}
function inferPerson(text: string): string {
for (const re of PERSON_PATTERNS) {
const m = text.match(re);
if (m?.[1]) return m[1];
}
return '';
}
/** 从标题+描述(及粘贴原文)启发式推断全部上报元数据;未识别字段为空 */
export function inferRequirementMeta(text: string): InferredMeta {
return {
titleKind: inferTitleKind(text),
priority: inferPriority(text),
tags: inferTags(text),
submitDept: inferDept(text),
submitter: inferPerson(text),
};
}
/** @deprecated 使用 inferRequirementMeta保留兼容旧调用 */
export function inferSubmitMeta(text: string): { submitDept: string; submitter: string } {
const m = inferRequirementMeta(text);
return { submitDept: m.submitDept, submitter: m.submitter };
}
/** 仅用推断结果填补草稿中仍为空的元数据字段(不覆盖用户已填) */
export function mergeInferredMeta(
meta: RequirementMeta,
inferred: InferredMeta,
): RequirementMeta {
return {
titleKind: meta.titleKind || inferred.titleKind,
priority: meta.priority || inferred.priority,
tags: meta.tags.length > 0 ? meta.tags : inferred.tags,
submitDept: meta.submitDept || inferred.submitDept,
submitter: meta.submitter || inferred.submitter,
};
}

View File

@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import { parseAutorodoDraft, normalizePriority } from './parseDraft';
const SAMPLE = `共 2 条
### 1
## 原始诉求AutoRDO
**标题**:合同台账批量导出
**描述**
在租赁合同台账支持批量导出
待确认:
- 导出范围未说明
### 2
## 原始诉求AutoRDO
**标题**:证照到期提醒
**描述**
调整证照到期提醒口径
待确认:
`;
const STRUCTURED = `### 1已确认
## 原始诉求AutoRDO
**标题**:交车管理:交车导入展示交车明细
**类型**:【优化】
**优先级**P1-高
**标签**:交车管理, PC端
**提交部门**:业务管理组
**提交人**:杨凤娥
**描述**
交、还车场景下,交车导入功能需展示交车明细
### 2已确认
## 原始诉求AutoRDO
**标题**:故障管理:选择车辆后展示车辆里程
**类型**:【优化】
**优先级**P2-中
**标签**:故障管理, PC端, 小程序
**提交部门**:运维部
**提交人**:王建功
**描述**
故障管理中,选定车辆后展示该车里程信息
`;
describe('parseAutorodoDraft', () => {
it('splits multiple requirements and pending items', () => {
const list = parseAutorodoDraft(SAMPLE);
expect(list).toHaveLength(2);
expect(list[0].title).toContain('合同台账');
expect(list[0].pendings).toHaveLength(1);
expect(list[0].pendings[0].raw).toContain('导出范围');
expect(list[1].pendings).toHaveLength(0);
expect(list[1].confirmed).toBe(true);
});
it('parses structured meta from confirmed AutoRDO blocks', () => {
const list = parseAutorodoDraft(STRUCTURED);
expect(list).toHaveLength(2);
expect(list[0].title).toContain('交车导入');
expect(list[0].meta).toEqual({
titleKind: '优化',
priority: '高',
tags: ['交车管理', 'PC端'],
submitDept: '业务管理组',
submitter: '杨凤娥',
});
expect(list[0].confirmed).toBe(true);
expect(list[1].meta.titleKind).toBe('优化');
expect(list[1].meta.priority).toBe('中');
expect(list[1].meta.tags).toEqual(['故障管理', 'PC端', '小程序']);
expect(list[1].meta.submitDept).toBe('运维部');
expect(list[1].meta.submitter).toBe('王建功');
});
});
describe('normalizePriority', () => {
it('maps P1-高 style to 云效四档', () => {
expect(normalizePriority('P1-高')).toBe('高');
expect(normalizePriority('P2-中')).toBe('中');
expect(normalizePriority('P3-低')).toBe('低');
expect(normalizePriority('紧急')).toBe('紧急');
});
});

View File

@@ -0,0 +1,101 @@
import type { Priority, RequirementDraft, PendingItem, RequirementMeta } from './types';
const emptyMeta = (): RequirementMeta => ({
priority: '',
tags: [],
submitDept: '',
submitter: '',
titleKind: '',
});
function fieldLine(block: string, name: string): string {
const re = new RegExp(`\\*\\*${name}\\*\\*[:]\\s*(.+)`, 'u');
return (block.match(re)?.[1] ?? '').trim();
}
function parseTitleKind(raw: string): RequirementMeta['titleKind'] {
if (/新增/.test(raw)) return '新增';
if (/优化/.test(raw)) return '优化';
return '';
}
/** 支持:高 / P1-高 / P1 / 紧急 等 → 云效四档 */
export function normalizePriority(raw: string): Priority | '' {
const t = raw.trim();
if (!t) return '';
if (/紧急|P\s*0/i.test(t)) return '紧急';
if (/P\s*1|高/i.test(t)) return '高';
if (/P\s*2|中/i.test(t)) return '中';
if (/P\s*3|低/i.test(t)) return '低';
return '';
}
function parseTags(raw: string): string[] {
if (!raw) return [];
return raw
.split(/[,,、]+/)
.map((t) => t.trim())
.filter(Boolean);
}
function parseStructuredMeta(block: string): RequirementMeta {
const typeRaw = fieldLine(block, '类型');
const priorityRaw = fieldLine(block, '优先级');
const tagsRaw = fieldLine(block, '标签');
const deptRaw = fieldLine(block, '提交部门');
const personRaw = fieldLine(block, '提交人');
return {
titleKind: parseTitleKind(typeRaw),
priority: normalizePriority(priorityRaw),
tags: parseTags(tagsRaw),
submitDept: deptRaw,
submitter: personRaw,
};
}
function parsePendings(block: string, reqIndex: number): PendingItem[] {
const m = block.match(/待确认[:]\s*([\s\S]*?)(?=\n###|\n##\s*原始诉求|$)/);
if (!m) return [];
const body = m[1].trim();
if (!body || body === '无') return [];
const lines = body
.split('\n')
.map((l) => l.replace(/^[-*•\d.、)\s]+/, '').trim())
.filter(Boolean);
return lines.map((raw, i) => ({
id: `r${reqIndex}-p${i}`,
raw,
question: raw,
options: [],
resolved: false,
}));
}
export function parseAutorodoDraft(md: string): RequirementDraft[] {
// 支持 ### 1 / ### 1已确认
const parts = md
.split(/(?=^###\s*\d+)/m)
.filter((p) => /原始诉求AutoRDO/.test(p) || /\*\*标题\*\*/.test(p));
const chunks =
parts.length > 0
? parts
: md.includes('**标题**')
? [md]
: [];
return chunks.map((chunk, index) => {
const title = fieldLine(chunk, '标题');
const descMatch = chunk.match(/\*\*描述\*\*[:]\s*\n?([\s\S]*?)(?=\n待确认|\n###\s*\d+|$)/);
const description = (descMatch?.[1] ?? '').trim();
const pendings = parsePendings(chunk, index);
const meta = parseStructuredMeta(chunk);
return {
index,
title,
description,
pendings,
meta,
confirmed: pendings.length === 0,
};
});
}

View File

@@ -0,0 +1,30 @@
import type { RequirementDraft } from './types';
export function buildOptionsForPending(raw: string): { question: string; options: string[] } {
if (/导出范围|范围/.test(raw)) {
return {
question: '「导出范围」指什么?',
options: ['当前筛选结果', '全部数据', '仅勾选行'],
};
}
if (/模块|归属/.test(raw)) {
return {
question: '归属哪个业务模块?',
options: ['租赁合同管理', '车辆管理', '证照管理', '还车应结款'],
};
}
return {
question: raw.endsWith('') || raw.endsWith('?') ? raw : `${raw}`,
options: ['确认按原文理解', '暂不纳入本需求', '需文字补充'],
};
}
export function enrichPendingsWithQuestions(draft: RequirementDraft): RequirementDraft {
return {
...draft,
pendings: draft.pendings.map((p) => {
const q = buildOptionsForPending(p.raw);
return { ...p, question: q.question, options: q.options };
}),
};
}

View File

@@ -0,0 +1,29 @@
export type Priority = '紧急' | '高' | '中' | '低';
export type RequirementMeta = {
priority: Priority | '';
tags: string[];
submitDept: string;
submitter: string;
titleKind: '新增' | '优化' | '';
};
export type PendingItem = {
id: string;
raw: string;
question: string;
options: string[];
resolved: boolean;
answer?: string;
};
export type RequirementDraft = {
index: number;
title: string;
description: string;
pendings: PendingItem[];
meta: RequirementMeta;
confirmed: boolean;
};
export type WizardStep = 1 | 2 | 3;

View File

@@ -0,0 +1,836 @@
/* AutoRDO 需求清洗工作台 — ONE-OS Linear green tokens (DESIGN.md) */
.arw-page {
--arw-primary: #32a06e;
--arw-primary-hover: #3fb87c;
--arw-primary-focus: #2b9260;
--arw-primary-soft: rgba(50, 160, 110, 0.1);
--arw-ink: #18181b;
--arw-body: #52525b;
--arw-muted: #71717a;
--arw-muted-soft: #a1a1aa;
--arw-canvas: #f5f6f6;
--arw-surface: #ffffff;
--arw-hairline: #e5e7eb;
--arw-hairline-strong: #d4d4d8;
--arw-success: #27a644;
--arw-on-primary: #ffffff;
--arw-radius-control: 8px;
--arw-radius-card: 12px;
--arw-font: Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC",
"Microsoft YaHei", sans-serif;
--arw-touch: 44px;
box-sizing: border-box;
min-height: 100vh;
margin: 0;
display: flex;
background: var(--arw-canvas);
color: var(--arw-ink);
font-family: var(--arw-font);
font-size: 14px;
line-height: 1.5;
}
.arw-page *,
.arw-page *::before,
.arw-page *::after {
box-sizing: border-box;
}
.arw-rail {
width: 220px;
flex-shrink: 0;
padding: 24px 16px;
border-right: 1px solid var(--arw-hairline);
background: var(--arw-surface);
display: flex;
flex-direction: column;
gap: 8px;
}
.arw-rail-brand {
margin: 0 0 16px;
padding: 0 8px;
}
.arw-rail-brand h1 {
margin: 0 0 4px;
font-size: 16px;
font-weight: 600;
color: var(--arw-ink);
}
.arw-rail-brand p {
margin: 0;
font-size: 12px;
color: var(--arw-muted);
}
.arw-step-btn {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
min-height: var(--arw-touch);
padding: 10px 12px;
border: 1px solid transparent;
border-radius: var(--arw-radius-control);
background: transparent;
color: var(--arw-body);
text-align: left;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.arw-step-btn:hover {
background: var(--arw-primary-soft);
}
.arw-step-btn:focus-visible {
outline: none;
border-color: var(--arw-primary-focus);
}
.arw-step-btn.is-active {
background: var(--arw-primary-soft);
border-color: var(--arw-primary);
color: var(--arw-primary-focus);
}
.arw-step-idx {
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 9999px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
font-variant-numeric: tabular-nums;
background: var(--arw-hairline);
color: var(--arw-muted);
}
.arw-step-btn.is-active .arw-step-idx {
background: var(--arw-primary);
color: var(--arw-on-primary);
}
.arw-step-meta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.arw-step-title {
font-size: 14px;
font-weight: 600;
color: inherit;
}
.arw-step-desc {
font-size: 12px;
color: var(--arw-muted);
}
.arw-main {
flex: 1;
min-width: 0;
padding: 24px 32px 32px;
}
.arw-panel {
max-width: 720px;
background: var(--arw-surface);
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-card);
padding: 24px;
box-shadow: 0 1px 2px rgba(24, 24, 27, 0.06);
}
.arw-panel--wide {
max-width: 960px;
}
.arw-step-btn.is-disabled,
.arw-step-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.arw-step-btn.is-disabled:hover,
.arw-step-btn:disabled:hover {
background: transparent;
}
.arw-input {
width: 100%;
min-height: var(--arw-touch);
padding: 0 14px;
border: 1px solid var(--arw-hairline-strong);
border-radius: var(--arw-radius-control);
background: var(--arw-surface);
color: var(--arw-ink);
font-family: inherit;
font-size: 14px;
line-height: 1.4;
}
.arw-input:focus,
.arw-input:focus-visible {
outline: none;
border-color: var(--arw-primary-focus);
}
.arw-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 20px 0 16px;
}
.arw-pill {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--arw-touch);
padding: 0 14px;
border: 1px solid var(--arw-hairline-strong);
border-radius: 9999px;
background: var(--arw-surface);
color: var(--arw-body);
font-family: inherit;
font-size: 13px;
font-weight: 500;
font-variant-numeric: tabular-nums;
cursor: pointer;
}
.arw-pill:hover {
background: var(--arw-primary-soft);
}
.arw-pill:focus-visible {
outline: none;
border-color: var(--arw-primary-focus);
}
.arw-pill.is-active {
border-color: var(--arw-primary);
background: var(--arw-primary-soft);
color: var(--arw-primary-focus);
}
.arw-pill.is-done:not(.is-active) {
border-color: rgba(39, 166, 68, 0.45);
color: var(--arw-success);
}
.arw-confirm-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 8px;
}
.arw-confirm-col {
min-width: 0;
}
.arw-desc-live {
margin: 0;
min-height: 120px;
max-height: 280px;
overflow: auto;
padding: 12px 14px;
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-control);
background: #f6f7f7;
color: var(--arw-ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.arw-pending-card {
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-control);
background: #fafafa;
}
.arw-pending-q {
margin: 0;
font-size: 14px;
font-weight: 600;
color: var(--arw-ink);
}
.arw-radio-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.arw-radio {
display: flex;
align-items: center;
gap: 10px;
min-height: var(--arw-touch);
padding: 0 4px;
color: var(--arw-body);
font-size: 14px;
cursor: pointer;
}
.arw-radio input {
width: 18px;
height: 18px;
accent-color: var(--arw-primary);
cursor: pointer;
}
.arw-empty-hint {
margin: 0;
padding: 16px;
border: 1px dashed var(--arw-hairline-strong);
border-radius: var(--arw-radius-control);
color: var(--arw-muted);
font-size: 14px;
text-align: center;
background: #f6f7f7;
}
.arw-meta {
margin: 16px 0 0;
padding: 16px;
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-control);
background: #fafafa;
}
.arw-meta legend {
padding: 0 4px;
}
.arw-hint {
margin: 0 0 12px;
font-size: 12px;
line-height: 1.5;
color: var(--arw-muted, #71717a);
}
.arw-meta-row {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 14px;
}
.arw-seg {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.arw-seg-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: var(--arw-touch);
min-width: var(--arw-touch);
padding: 0 14px;
border: 1px solid var(--arw-hairline-strong);
border-radius: var(--arw-radius-control);
background: var(--arw-surface);
color: var(--arw-body);
font-family: inherit;
font-size: 14px;
cursor: pointer;
}
.arw-seg-btn:hover {
background: var(--arw-primary-soft);
}
.arw-seg-btn:focus-visible {
outline: none;
border-color: var(--arw-primary-focus);
}
.arw-seg-btn.is-active {
border-color: var(--arw-primary);
background: var(--arw-primary-soft);
color: var(--arw-primary-focus);
font-weight: 600;
}
.arw-tag-add {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.arw-tag-add .arw-input {
flex: 1 1 160px;
}
.arw-meta-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px 16px;
}
.arw-meta-grid .arw-field {
margin-bottom: 0;
}
.arw-export-gate {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid var(--arw-hairline);
}
.arw-missing {
padding: 12px 14px;
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-control);
background: #f6f7f7;
color: var(--arw-body);
}
.arw-missing-title {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--arw-ink);
}
.arw-missing ul {
margin: 0;
padding-left: 18px;
}
.arw-missing li {
margin: 4px 0;
font-size: 13px;
}
.arw-panel-header {
margin-bottom: 20px;
}
.arw-panel-header h2 {
margin: 0 0 6px;
font-size: 18px;
font-weight: 600;
color: var(--arw-ink);
}
.arw-panel-header p {
margin: 0;
font-size: 14px;
color: var(--arw-body);
}
.arw-stub {
margin: 0;
padding: 24px;
color: var(--arw-muted);
font-size: 14px;
text-align: center;
border: 1px dashed var(--arw-hairline-strong);
border-radius: var(--arw-radius-control);
background: #f6f7f7;
}
.arw-export-preview {
margin: 0;
min-height: 160px;
max-height: 420px;
overflow: auto;
padding: 12px 14px;
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-control);
background: #f6f7f7;
color: var(--arw-ink);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
user-select: text;
-webkit-user-select: text;
cursor: text;
}
.arw-export-error {
padding: 14px 16px;
border: 1px solid rgba(229, 72, 77, 0.35);
border-radius: var(--arw-radius-control);
background: rgba(229, 72, 77, 0.06);
color: var(--arw-body);
}
.arw-export-error-title {
margin: 0 0 8px;
font-size: 14px;
font-weight: 600;
color: #e5484d;
}
.arw-export-error ul {
margin: 0 0 8px;
padding-left: 18px;
}
.arw-export-error li {
margin: 4px 0;
font-size: 14px;
}
.arw-export-error-hint {
margin: 0;
font-size: 13px;
color: var(--arw-muted);
}
.arw-field {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.arw-label {
font-size: 12px;
font-weight: 500;
color: var(--arw-muted);
}
.arw-textarea {
width: 100%;
min-height: 180px;
padding: 12px 14px;
border: 1px solid var(--arw-hairline-strong);
border-radius: var(--arw-radius-control);
background: var(--arw-surface);
color: var(--arw-ink);
font-family: inherit;
font-size: 14px;
line-height: 1.55;
resize: vertical;
}
.arw-textarea::placeholder {
color: var(--arw-muted-soft);
}
.arw-textarea:focus,
.arw-textarea:focus-visible {
outline: none;
border-color: var(--arw-primary-focus);
}
.arw-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
min-height: 0;
}
.arw-chip {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 6px 10px;
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-control);
background: #f6f7f7;
color: var(--arw-body);
font-size: 12px;
}
.arw-chip-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.arw-chip-remove {
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--arw-muted);
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.arw-chip-remove:hover {
color: var(--arw-ink);
background: var(--arw-hairline);
}
.arw-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px 16px;
margin-top: 8px;
}
.arw-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: var(--arw-touch);
padding: 0 18px;
border: 1px solid var(--arw-hairline-strong);
border-radius: var(--arw-radius-control);
background: var(--arw-surface);
color: var(--arw-ink);
font-family: inherit;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.arw-btn:hover {
background: #f6f7f7;
}
.arw-btn:focus-visible {
outline: none;
border-color: var(--arw-primary-focus);
}
.arw-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.arw-btn--primary {
border-color: var(--arw-primary);
background: var(--arw-primary);
color: var(--arw-on-primary);
}
.arw-btn--primary:hover:not(:disabled) {
background: var(--arw-primary-hover);
border-color: var(--arw-primary-hover);
}
.arw-check {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: var(--arw-touch);
font-size: 14px;
color: var(--arw-body);
cursor: pointer;
user-select: none;
}
.arw-check input {
width: 18px;
height: 18px;
accent-color: var(--arw-primary);
cursor: pointer;
}
.arw-status {
margin: 12px 0 0;
min-height: 20px;
font-size: 13px;
color: var(--arw-success);
}
.arw-status.is-error {
color: #e5484d;
}
.arw-toast {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 50;
display: inline-flex;
align-items: center;
gap: 8px;
min-height: var(--arw-touch);
padding: 10px 16px;
border: 1px solid var(--arw-hairline);
border-radius: var(--arw-radius-control);
background: var(--arw-surface);
color: var(--arw-ink);
box-shadow: 0 12px 32px rgba(24, 24, 27, 0.12);
font-size: 14px;
}
@media (max-width: 767px) {
.arw-page {
flex-direction: column;
font-size: 14px;
}
.arw-rail {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--arw-hairline);
padding: 12px 16px 16px;
flex-direction: row;
flex-wrap: wrap;
align-items: stretch;
gap: 8px;
order: -1;
}
.arw-rail-brand {
width: 100%;
margin: 0 0 4px;
padding: 0;
}
.arw-rail-brand h1 {
font-size: 16px;
}
.arw-rail-brand p {
font-size: 13px;
}
.arw-step-btn {
flex: 1 1 calc(33.33% - 8px);
min-width: 0;
min-height: var(--arw-touch);
padding: 10px 8px;
flex-direction: column;
align-items: center;
text-align: center;
gap: 6px;
}
.arw-step-meta {
align-items: center;
}
.arw-step-title {
font-size: 13px;
}
.arw-step-desc {
display: none;
}
.arw-main {
padding: 16px;
}
.arw-panel {
padding: 16px;
max-width: none;
}
.arw-panel-header h2 {
font-size: 17px;
}
.arw-panel-header p,
.arw-textarea,
.arw-input,
.arw-btn,
.arw-check,
.arw-radio,
.arw-pending-q,
.arw-empty-hint,
.arw-seg-btn,
.arw-pill,
.arw-desc-live,
.arw-export-preview,
.arw-export-error-title,
.arw-export-error li,
.arw-status,
.arw-toast {
font-size: 14px;
}
.arw-label {
font-size: 13px;
}
.arw-textarea,
.arw-input {
min-height: var(--arw-touch);
}
.arw-textarea {
min-height: 160px;
}
.arw-confirm-grid,
.arw-meta-grid,
.arw-tag-add {
grid-template-columns: 1fr;
flex-direction: column;
}
.arw-tag-add .arw-input,
.arw-tag-add .arw-btn {
width: 100%;
flex: none;
}
.arw-actions {
flex-direction: column;
align-items: stretch;
}
.arw-actions .arw-btn {
width: 100%;
}
.arw-chip-remove {
width: 28px;
height: 28px;
min-width: 28px;
min-height: 28px;
}
.arw-chip {
min-height: var(--arw-touch);
padding: 8px 10px;
font-size: 14px;
}
.arw-toast {
left: 16px;
right: 16px;
bottom: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.arw-step-btn,
.arw-btn,
.arw-pill,
.arw-seg-btn {
transition: none;
}
}

View File

@@ -0,0 +1,40 @@
# 业务部台账 · 产品需求说明PRD
> 原型路径:`/prototypes/business-dept-ledger`
> 实现页:`src/prototypes/oneos-web-data-analysis-legacy/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

View File

@@ -0,0 +1,60 @@
# 业务部台账 · 各业务条线数据统计来源
> 实现:`src/prototypes/oneos-web-data-analysis-legacy/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` | 渲染「业务名 ← 来源」 |

View File

@@ -0,0 +1,8 @@
# 【旧版】项目盈亏情况
本目录为设计规范 v2 套用前的**冻结对照副本**2026-07-22
- 源原型:`src/prototypes/business-dept-ledger/`
- 请勿在此目录继续改业务;正式改造只在源目录进行
- 已跳过复制:`.spec/acp``.spec/prototype-comment-assets`(批注大图/会话)
- 交叉引用已尽量改写为同套 `*-legacy`,避免引用被改版后的正式样式

View 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": "主表 · 业务条线数据来源"
}
]
}
]
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
/**
* @name 业务部台账
* 自 ONE-OS Web 端 / 数据分析 / 业务部台账 独立预览OneOS 菜单入口)
*/
import '../../common/oneosWebLegacy/legacyGlobals';
import '../vehicle-management-legacy/style.css';
import '../ledger-shared-legacy/styles/ledger-page.css';
import './styles/index.css';
import React, { useEffect } from 'react';
import * as antd from 'antd';
import dayjs from 'dayjs';
import { ConfigProvider } from 'antd';
import 'antd/dist/reset.css';
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
import BusinessDeptLedgerPage from '../oneos-web-data-analysis-legacy/pages/05-业务部台账.jsx';
window.React = React;
window.antd = antd;
window.dayjs = dayjs;
export default function BusinessDeptLedger() {
useEffect(() => {
clearHostPrototypeRouteInfo();
}, []);
return (
<ConfigProvider>
<BusinessDeptLedgerPage />
</ConfigProvider>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
# Prototype Review
- 审查目标:`src/prototypes/contract-template-management`
- 用户资料/参考资料:
- 本轮用户任务:对「合同模板管理」原型执行 Prototype Review / 需求评审
- `src/resources/contract-template-management/PRD.md`v1.2
- `src/prototypes/contract-template-management-legacy/index.tsx``ContractTemplate.jsx`
- `contract-template-store.js``contract-template-catalog.js``contract-template-section-html.js``contract-template-section-filter.js``contract-template-seed.js`
- 下游消费:`lease-contract-management/lease-contract-preview-build.js``LeaseContractCreate.jsx`
- `annotation-source.json``.spec/prototype-comments.json`
- 生成时间2026-07-07
## 总体点评
合同模板管理原型在**主业务链路上已基本成立**:列表筛选与嵌套章节子表、草稿/发布/启用停用、版本日志与快照查看、编辑页 9 章节 Tab、锁定/风控/条件条款工具、离开未保存确认,以及经 `contract-template-store` 向租赁合同创建贯通预览,均能在同浏览器 `localStorage` 会话内端到端演示。这与 PRD v1.2 对「Word 文档级模型 + 启用态控制 + 跨模块消费」的定位一致。
当前最影响「按真实法务作业验收」的缺口集中在 **Word 导入仍为文件名驱动的 Mock**,无法证明 PRD §9 对任意 `.doc/.docx` 的拆分策略;其次是 **标注/内嵌 PRD 与 v1.2 字段口径仍有残留不一致**,以及租赁预览侧 **条件条款存在模板 HTML 与硬编码注入双轨**。权限、存储失败、正式 API 与异常分支属原型已知边界,应在研发阶段补齐,但不阻断当前演示型原型价值。
## P0-P3 优先级问题
### P1 - Word 导入未实现真实解析与章节拆分
- 证据:`ContractTemplate.jsx``handleImportWord` 仅调用 `mockWordBlocks(file.name, categoryFilter)`,按文件名关键字返回内置 `v266LeaseBlocks` / 试用合同块或通用占位块,未读取上传文件内容,也未调用 PRD §9 所述 `sanitizeWordExportHtml` + 标记切分流程。
- 影响:法务无法在本原型中验证「上传任意 Word → 自动拆成 9 章」这一核心维护路径;验收标准 §16.2「导入 Word 后 9 章节正确拆分」仅对少数种子文件名成立,与 PRD 表述的业务成立条件存在明显差距。
- 修复方向:原型层至少接入 `.docx → HTML` 解析(或明确标注为演示 Mock 并下调 PRD 验收项);研发按 §9.2§9.4 实现服务端/前端拆分,并在导入失败时给出可恢复提示(重试、保留原稿、查看错误原因)。
### P2 - 标注与内嵌 PRD 仍使用「合同名称」,与 v1.2「合同类型」冲突
- 证据:`annotation-source.json` 列表筛选节点 `ct-list-filter``annotationText``markdownMap.ct-list-filter` 仍写「合同名称 / 选项来自模板名称」;而 `PRD.md` v1.2 §7.2、§7.4 与实现中筛选字段 `keyword`、列表列 `contractType`、编辑区「合同类型」均已统一为 **合同类型**
- 影响:评审、测试与业务方按标注操作时会对筛选对象产生误解,造成需求口径分裂;与 PRD §16.4「标注与 PRD 一致」未完全满足。
- 修复方向:运行或更新 `scripts/build-annotation-source.mjs`,将筛选说明改为「合同类型(可搜索下拉,精确匹配 `contractType`)」;同步目录内嵌 PRD 片段版本号至 v1.2。
### P2 - 租赁预览条件条款存在「模板 HTML」与硬编码注入双轨
- 证据:`lease-contract-preview-build.js` 在读取 store 合并 HTML 后仍调用 `injectPrototypeVehicleClauses`;该函数在 `lease-contract-vehicle-clause.js` 中按 `PROTOTYPE_VEHICLE_CLAUSE_DEFS` 与固定 `paragraphMarker` 包裹段落,注释写明「正式环境由模板管理产出」。模板管理侧条件条款则通过编辑区「条件」写入 `data-vehicle-clause` / `data-vehicle-bindings`
- 影响:法务在模板管理中新设或调整条件条款后,租赁预览对标准 V26.6 类合同可能仍部分依赖硬编码注入点;与 PRD「创建合同时按订单车辆过滤条件条款」的单一数据源目标不完全一致增加联调与回归风险。
- 修复方向:租赁预览改为**仅**解析模板 HTML 中的条件条款标记;硬编码注入降级为种子数据初始化脚本或 feature flag并在跨模块验收用例中覆盖「仅改模板、不改租赁代码」场景。
### P2 - 车型主数据未与车辆管理模块打通
- 证据PRD §11 要求品牌·型号与车辆管理一致;实现中 `ContractTemplate.jsx` / `VehicleBrandModelPicker.jsx` 使用 `LEASE_VEHICLE_BRAND_MODEL_CATALOG``lease-order-vars.js` 内置枚举),未读取 `vehicle-management` 主数据或接口。
- 影响:章节「适用车型」绑定与预览模拟车型的选项集可能与正式车辆台账漂移;绑定存的是字符串,正式环境若车型更名会导致历史模板命中失败。
- 修复方向:原型可保留内置枚举但需在 PRD/标注标明;研发对接车辆主数据 API并约定品牌型号唯一键与变更策略。
### P3 - 权限边界与存储异常未在原型中表达
- 证据PRD §13 建议法务可编辑/发布、客服只读等角色能力;原型无角色切换或操作拦截。`contract-template-store.js``writeToStorage``localStorage` 失败时静默忽略,无用户提示或降级策略。
- 影响:无法演示「启用中不可编辑」之外的组织级权限;极端环境下用户以为已保存但实际未持久化,造成跨页面数据丢失误解。
- 修复方向:原型可增加「法务 / 客服只读」演示开关;存储失败时 `message.error` 并阻止离开编辑页;正式环境对接权限服务与后端持久化。
## 完整性与项目对齐
| 维度 | 结论 |
|---|---|
| 核心用户 | 法务为主、客服/风控间接用户 — 与 PRD §2 一致 |
| 主流程 | 列表 → 新增/编辑 → 保存/发布 → 启用/停用 → 租赁创建选用 — **可演示** |
| 入口/出口 | `index.tsx` + `ContractTemplate.jsx` 列表/编辑双视图;`navigationRef` 与标注目录可切换 — 对齐 |
| 范围边界 | 仅 `lease` 分类业务闭环;`purchase`/`service` 代码预留未暴露 — 符合非目标 |
| 跨模块 | `contract-template-catalog.js` 冷启动种子、`getContractTemplateBaseHtml` 优先 store — 与 PRD §12、§16.3 对齐 |
| 资料冲突 | **待确认**`annotation-source.json` 筛选字段仍称「合同名称」,与 PRD v1.2 / 源码「合同类型」冲突(见 P2 |
| 项目 metadata | 路由 `/prototypes/contract-template-management`;与 `lease-contract-management` PRD 关联描述一致 |
**已覆盖且与 PRD 一致的主要能力(摘要)**:嵌套列表 9 章节子表及风控/条件条款计数;启用中锁定编辑删除、停用二次确认;发布递增版本号、已发布再发布写 `versionLogs` 且默认停用;合同主体非空与合同类型校验;未保存离开确认;版本快照只读查看;附件章节车型绑定与租赁侧 `applyAttachmentSectionVehicleFilter` 联动。
## 业务逻辑连贯性
- **状态迁移**:草稿(无版本号)→ 发布(有版本号、默认启用)→ 停用(可编辑)→ 再发布(版本递增、写快照、默认停用)— 与 PRD §6.2§6.3 一致,源码 `handleSaveTemplate` / `toggleTemplateEnabled` 可印证。
- **启用与下游**`getPublishedEnabledLeaseOptions` 过滤 `published && enabled`;停用后 `LeaseContractCreate` 订阅 store 更新选项 — 连贯。
- **版本号生成**`generateNextVersionNo``category === lease` 下全部已发布模板取最大 minor 递增 — 符合 PRD §6.1「同分类下递增」字面规则;若业务期望「每份 Word 文件独立版本序列」,需产品另行确认(当前无冲突证据,记为待确认项)。
- **条件条款命中**:模板编辑预览与租赁预览均遵循「订单车辆任意命中则展示」;模板管理预览未选模拟车型时隐藏条件段落,与 PRD §8.7.3 一致。
- **数据生命周期**`creator/updater/createTime/updateTime` 在保存时更新;删除仅草稿或已发布且停用 — 符合 §6.5。
## 状态、异常、边界与恢复
| 类别 | 审查结论 |
|---|---|
| 输入校验 | 保存/发布校验合同主体非空、合同类型必填;条件条款弹窗要求至少一个车型 — 已覆盖 |
| 空状态 | 列表无数据、筛选无结果、版本日志为空、编辑区无内容预览引导 — 已覆盖 |
| 操作确认 | 停用、删除、未保存离开 — 有 Modal 确认 |
| 权限失败 | 未实现角色权限 — 见 P3 |
| 导入失败 | 非真实 Word 解析,无格式错误/拆分失败分支 — 见 P1 |
| 存储失败 | `localStorage` 写入失败静默 — 见 P3 |
| 并发/重复点击 | 保存无 loading 锁,快速连点可能重复写入(原型风险低)— P3 |
| 跨页面恢复 | 刷新后 `localStorage` 恢复模板;清空存储后 `ensureContractTemplateStore` 重新种子 — 可恢复 |
| 下游一致性 | 停用模板后租赁侧选项实时收缩;预览读 store `sectionContents` — 已检查通过 |
## 证据与评估说明
- **用户资料优先级**:本轮无额外用户附件;以 PRD v1.2 为业务基线,与 `annotation-source.json` 冲突处已标「待确认」。
- **读取范围**
- 规范:`rules/prototype-review-guide.md`
- 资料:`src/resources/contract-template-management/PRD.md`
- 源码:`index.tsx``ContractTemplate.jsx`(列表/编辑/保存发布/导入/版本日志)、`contract-template-store.js``contract-template-catalog.js``contract-template-section-filter.js`
- 下游:`lease-contract-preview-build.js``lease-contract-vehicle-clause.js``LeaseContractCreate.jsx`
- 标注:`annotation-source.json`;未展开阅读 `canvas.excalidraw` 全量
- **独立评估**`degraded`(未使用子代理分拆「业务基线」与「原型证据」两轮独立评估,在同一审查中顺序完成)
---
## 修复记录2026-07-07 后续)
| 优先级 | 问题 | 状态 | 说明 |
|---|---|---|---|
| P1 | Word 导入 Mock | **已修复** | `contract-template-word-import.js` + mammoth 解析 `.docx`;失败回退演示模板并提示 |
| P2 | 标注「合同名称」口径 | **已修复** | `build-annotation-source.mjs` 同步 `annotationText` / `annotationsByElementId` / `markdownMap` |
| P2 | 租赁预览条件条款双轨 | **已修复** | `injectPrototypeVehicleClauses` 在 HTML 已有条件标记时跳过 |
| P2 | 车型主数据未打通 | **已修复(原型)** | `src/common/vehicle-brand-model-catalog.js` 读取车辆管理种子并合并演示车型 |
| P3 | 权限与存储异常 | **已修复(原型)** | `?role=cs` 只读演示;存储失败 `message.error` 且阻止离开编辑页 |

View File

@@ -0,0 +1,89 @@
# UI Review
- 审查目标:`src/prototypes/contract-template-management`
- 使用设计依据:`src/themes/linear/DESIGN.md`(实现侧通过 `vehicle-management/style.css` 声明为 Linear **light / inverse** 模式,主色覆写为 ONE-OS 翡翠绿 `--ln-primary: #32a06e`;与 Linear 原文档薰衣草色 `#5e6ad2` 存在有意的品牌偏移)
- 生成时间2026-06-29
## 总体点评
合同模板管理在整体视觉上与车辆管理等 ONE-OS 中后台模块保持一致:浅灰画布(`--ln-canvas-parchment`、白卡片、8px 圆角控件、Inter 字体与翡翠绿主色,表格/筛选区复用 `.vm-filter-card``.vm-table-card` 模式信息密度适合法务日常维护任务。编辑页双栏Word 编辑 + A4 预览)、章节 Tab、预览工具栏的 `aria-label` / `role` 标注较完整,禁用态操作按钮与 `focus-visible` 内描边也体现了一定的可访问性意识。
主要风险集中在**列表操作区触控尺寸**、**宽表横向滚动**以及**多操作并列带来的扫描成本**。Impeccable detector 另提示条件说明卡片的紫色左边框(`#7c3aed`)与全站绿色主色体系不一致,属于品牌表达层面的杂音。整体未达到 Linear 营销站那种极简留白,但作为 dense admin 工具界面,层次与组件复用是成立且可继续打磨的。
## P0-P3 优先级问题
### P1 - 列表行内操作按钮触控高度不足 44px
- 证据:`.ct-action-btn.ant-btn-link` 使用 `min-height: var(--vm-btn-height)`36px`vehicle-management/style.css`);对比同模块预览翻页按钮已设 `min-width/min-height: 44px``contract-template.css` 约 1563 行)。列表操作列含「启用/停用」「版本日志」「编辑」「删除」等链接按钮。
- 影响:在触控屏或高精度鼠标场景下,误触率上升;不符合常见 WCAG 2.5.5 目标尺寸建议,与 DESIGN 中间距/控件可点性预期不符。
- 修复方向:将行内操作 `min-height` 提升至 44px或改为「更多」下拉 + 主操作外露;保持 `cursor-pointer` 与禁用态对比度(当前禁用灰 `#94A3B8` 已处理)。
### P1 - 主表固定最小宽度导致中小屏强依赖横向滚动
- 证据:`.ct-data-table--nested``--ct-list-table-width: 1232px``min-width` 强制(`contract-template.css` 7277 行);`LIST_TABLE_SCROLL_X` 合计约 1332px含操作列 300px`ContractTemplate.jsx`)。右侧还有 `@axhub/annotation` 工具栏占用宽度。
- 影响13 寸笔记本或分屏预览时,用户需频繁横向拖动才能看到「操作」列;筛选结果核对与批量处理效率下降。
- 修复方向≥1280px 保持全列;`<1200px` 将「启用状态」与次要时间列折叠为 expandable 摘要,或操作列收进「更多」菜单;与 Linear inverse 表格「密度可伸缩」原则对齐。
### P2 - 操作列 45 个并列链接,视觉密度与扫读成本高
- 证据:已发布行同时展示「停用/启用」「版本日志」「编辑」「删除」(`getListColumns` 操作列宽 300px多行换行时`Space wrap`)行高不齐。
- 影响:法务需逐行扫描多个同色链接,难以快速区分主操作与危险操作;与 DESIGN 中 button-primary / button-secondary 层级未充分映射到表格场景。
- 修复方向:主操作保留 12 个(如「编辑」);其余收入「更多」`Dropdown`;危险操作「删除」保持 `danger` 但与次要操作拉开间距或使用图标+文字。
### P2 - 条件条款说明卡使用紫色左边框,偏离主色体系
- 证据detector 命中 `border-left: 3px solid #7c3aed``ContractTemplate.jsx` 内联样式与 `contract-template.css` 约 1390 行);全站主色为 `--ln-primary #32a06e`Linear DESIGN 强调单一 chromatic accent。
- 影响:编辑页唯一紫色强调与表格/按钮绿色系统冲突,削弱品牌一致性,易被识别为「拼贴式」装饰。
- 修复方向:改为 `border-left-color: var(--ln-primary)``var(--ln-primary-soft)` 背景 + 无侧栏;与 `.ct-main-clause-tip` 信息层级一致即可。
### P3 - 字号阶梯偏平,表头与正文层次不够清晰
- 证据Impeccable detector 报告 1120px 多档字号、比例约 1.8:1`flat-type-hierarchy`);表头 `0.75rem`12px/`--ln-muted`,正文 `0.8125rem`13px/`--ln-body`,差距仅 1px。
- 影响:长列表扫读时列标题与单元格内容对比偏弱;不影响完成任务,但略损专业感。
- 修复方向:收敛为 3 档:表头 12px medium + `--ln-muted-80`、正文 14px、辅助 12px确保表头对比度 ≥4.5:1。
## 核心元件
### 筛选区(`ct-list-filter`
- **符合**:四字段栅格(合同名称可搜索 Select、状态、创建人、启用状态+ 查询/重置,与 `vm-filter-card` 模式一致;字段带 `aria-label`
- **调整**:筛选项增至 4 个后,窄屏下建议 `@media (max-width: 992px)` 改为两列栅格(当前 filter grid 响应式依赖 vehicle-management 全局样式,需确认 4 字段是否换行整齐)。
### 列表表格(`ct-list-table`
- **符合**表头浅灰底、hairline 分隔、hover 行背景、展开行嵌套章节子表;状态/启用 Tag 使用 Ant Design semantic 色。
- **调整**:操作列与总宽度过宽(见 P1/P2禁用编辑/删除的 link 按钮样式已灰化,但缺少 `title` 提示「请先停用」可帮助理解禁用原因。
### 编辑页顶栏与章节 Tab`ct-editor-topbar` / `ct-section-tabs`
- **符合**:返回 + 保存/保存并发布 CTA 层级清晰;`ct-btn-cta` 使用主色实心按钮;章节 Tab 带 `role="tablist"``aria-selected`,「更多」章节有 `aria-expanded`
- **调整**:顶栏在窄屏未 sticky长章节名在「更多」Tab 内 ellipsis 至 140px`max-width: 992px`),可考虑 tooltip 展示全名。
### 编辑区 + 预览区(`ct-editor-panel` / `ct-preview-panel`
- **符合**左右分栏、A4 预览分页/连续模式、缩放与翻页控件具备 44px 触控与 `prefers-reduced-motion`;富文本工具栏 `focus-visible` 内描边符合 `--vm-focus-border-color`
- **保留亮点**:预览工具栏 `role="toolbar"` 与分组 `aria-label` 是全场最佳 a11y 实践,可复用到列表操作区。
- **调整**:编辑区 Word 画布 `210mm` 固定宽,小屏下横向滚动不可避免,需在容器上提示「可横向滑动查看版式」。
### 弹窗(删除 / 停用 / 未保存离开 / 版本日志)
- **符合**`ct-modal-wrap` 统一包裹;停用与离开编辑有明确二次确认文案。
- **调整**:版本日志 Modal 宽 720px、快照预览 900px小屏应验证 `max-width: calc(100vw - 32px)` 是否生效。
## 响应式与可访问性
| 维度 | 结论 |
|---|---|
| 桌面≥1280px | 编辑双栏与预览体验完整;列表需横向滚动但可接受 |
| 平板/窄屏≤992px | 编辑列 `max-height: none` 已放开;表格仍强制 min-width体验明显降级 |
| 键盘 | 章节 Tab、预览控件、部分按钮有 `focus-visible`;行内 link 操作依赖 Tab 顺序,密集时冗长 |
| 语义 | 筛选、章节、预览工具栏标注较好;表格操作按钮未统一 `aria-label`(仅靠文字) |
| 对比度 | 正文 `#52525b` on 白 generally OK表头 `#71717a` on `#f6f7f7` 建议复核 AA |
| 动效 | 多处 `@media (prefers-reduced-motion: reduce)` 已覆盖工具栏与章节 Tab |
## 证据与评估说明
- **浏览器/截图**:未启动本地预览与浏览器截图;结论来自源码、`contract-template.css``index.tsx` ConfigProvider token 及 detector 输出。
- **Scanner**:已运行 `impeccable/scripts/detect.mjs --json`exit 0含 side-tab、overused-font Inter、flat-type-hierarchy、em-dash-overuse 等 warning已筛入 P2/P3 相关项em-dash 主要来自合同正文 HTML 种子,非 UI chrome。
- **设计依据说明**:项目根 `DESIGN.md` 为 Vercel 营销规范,与本原型实现不一致;审查采用实现声明的 `src/themes/linear/DESIGN.md` + ONE-OS token 覆写作为对齐基准。
- **独立评估**`degraded`(单轮完成设计阅读 + 源码 + detector未拆分子代理未做浏览器实机检查

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,8 @@
# 【旧版】合同模板管理
本目录为设计规范 v2 套用前的**冻结对照副本**2026-07-22
- 源原型:`src/prototypes/contract-template-management/`
- 请勿在此目录继续改业务;正式改造只在源目录进行
- 已跳过复制:`.spec/acp``.spec/prototype-comment-assets`(批注大图/会话)
- 交叉引用已尽量改写为同套 `*-legacy`,避免引用被改版后的正式样式

View File

@@ -0,0 +1,352 @@
import React, { useState } from 'react';
import { Popover } from 'antd';
import {
LEASE_VEHICLE_BRAND_MODEL_CATALOG,
formatBrandModelPair,
} from '../lease-contract-management-legacy/lease-order-vars.js';
import {
VEHICLE_BINDING_ALL_BRAND,
isAllVehicleBindings,
vehicleBindingKey,
} from './vehicle-binding-utils.js';
function SearchIcon() {
return React.createElement('svg', {
width: 16,
height: 16,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
'aria-hidden': true,
},
React.createElement('circle', { cx: 11, cy: 11, r: 7 }),
React.createElement('path', { d: 'M20 20l-3-3' })
);
}
function CloseIcon() {
return React.createElement('svg', {
width: 14,
height: 14,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
'aria-hidden': true,
},
React.createElement('path', { d: 'M18 6L6 18M6 6l12 12' })
);
}
function ChevronDownIcon() {
return React.createElement('svg', {
width: 14,
height: 14,
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
'aria-hidden': true,
},
React.createElement('path', { d: 'M6 9l6 6 6-6' })
);
}
export default function VehicleBrandModelPicker(props) {
var value = props.value || [];
var onChange = props.onChange;
var openState = useState(false);
var activeBrandState = useState(VEHICLE_BINDING_ALL_BRAND);
var queryState = useState('');
var open = openState[0];
var setOpen = openState[1];
var activeBrand = activeBrandState[0];
var setActiveBrand = activeBrandState[1];
var query = queryState[0];
var setQuery = queryState[1];
var searchText = query.trim();
var isSearchMode = searchText.length > 0;
var isAllMode = isAllVehicleBindings(value);
var isBrowsingAllTab = !isSearchMode && activeBrand === VEHICLE_BINDING_ALL_BRAND;
var resolvedBrand = (activeBrand && activeBrand !== VEHICLE_BINDING_ALL_BRAND)
? activeBrand
: (value.length > 0
? value[0].brand
: LEASE_VEHICLE_BRAND_MODEL_CATALOG[0].brand);
var searchLower = searchText.toLowerCase();
var displayModels = isSearchMode
? LEASE_VEHICLE_BRAND_MODEL_CATALOG.reduce(function (list, item) {
return list.concat(item.models.map(function (model) {
return { brand: item.brand, model: model };
}));
}, []).filter(function (item) {
var label = formatBrandModelPair(item.brand, item.model);
return label.toLowerCase().indexOf(searchLower) >= 0
|| item.brand.toLowerCase().indexOf(searchLower) >= 0;
})
: (function () {
var group = LEASE_VEHICLE_BRAND_MODEL_CATALOG.find(function (node) {
return node.brand === resolvedBrand;
});
if (!group) return [];
return group.models.map(function (model) {
return { brand: group.brand, model: model };
});
})();
function handleOpenChange(nextOpen) {
setOpen(nextOpen);
if (nextOpen) {
if (isAllVehicleBindings(value)) {
setActiveBrand(VEHICLE_BINDING_ALL_BRAND);
} else if (value.length > 0) {
setActiveBrand(value[0].brand);
} else {
setActiveBrand(VEHICLE_BINDING_ALL_BRAND);
}
} else {
setQuery('');
}
}
function selectAllBrands() {
onChange([]);
setActiveBrand(VEHICLE_BINDING_ALL_BRAND);
setQuery('');
}
function isSelected(brand, model) {
var key = vehicleBindingKey({ brand: brand, model: model });
return value.some(function (item) {
return vehicleBindingKey(item) === key;
});
}
function togglePair(brand, model) {
var key = vehicleBindingKey({ brand: brand, model: model });
if (isSelected(brand, model)) {
var next = value.filter(function (item) {
return vehicleBindingKey(item) !== key;
});
onChange(next);
if (!next.length) {
setActiveBrand(VEHICLE_BINDING_ALL_BRAND);
}
return;
}
onChange(isAllMode ? [{ brand: brand, model: model }] : value.concat([{ brand: brand, model: model }]));
setActiveBrand(brand);
}
function removeBinding(binding) {
var key = vehicleBindingKey(binding);
var next = value.filter(function (item) {
return vehicleBindingKey(item) !== key;
});
onChange(next);
if (!next.length) {
setActiveBrand(VEHICLE_BINDING_ALL_BRAND);
}
}
function clearAll(event) {
event.preventDefault();
event.stopPropagation();
selectAllBrands();
}
var panelContent = React.createElement('div', {
className: 'vm-operate-city-edit-body vm-operate-city-picker-panel ct-vehicle-brand-model-panel',
},
React.createElement('label', { className: 'vm-operate-city-edit-search' },
React.createElement('span', { className: 'vm-operate-city-edit-search-icon', 'aria-hidden': true },
React.createElement(SearchIcon)
),
React.createElement('input', {
type: 'text',
className: 'vm-operate-city-edit-search-input',
value: query,
placeholder: '输入品牌或型号名称快速筛选',
'aria-label': '筛选品牌型号',
autoComplete: 'off',
onChange: function (e) { setQuery(e.target.value); },
}),
query
? React.createElement('button', {
type: 'button',
className: 'vm-operate-city-edit-search-clear',
onClick: function (e) {
e.preventDefault();
e.stopPropagation();
setQuery('');
},
'aria-label': '清空搜索',
}, React.createElement(CloseIcon))
: null
),
value.length > 0
? React.createElement('div', { className: 'vm-operate-city-edit-selected', 'aria-live': 'polite' },
React.createElement('span', { className: 'vm-operate-city-edit-selected-label' }, '已选'),
React.createElement('div', { className: 'ct-vehicle-brand-model-panel__selected-list' },
value.map(function (item) {
var label = formatBrandModelPair(item.brand, item.model);
return React.createElement('span', {
key: vehicleBindingKey(item),
className: 'ct-vehicle-brand-model-panel__selected-tag',
},
label,
React.createElement('button', {
type: 'button',
'aria-label': '移除' + label,
onMouseDown: function (e) { e.preventDefault(); },
onClick: function () { removeBinding(item); },
}, React.createElement(CloseIcon))
);
})
)
)
: isAllMode
? React.createElement('div', { className: 'vm-operate-city-edit-selected', 'aria-live': 'polite' },
React.createElement('span', { className: 'vm-operate-city-edit-selected-label' }, '已选'),
React.createElement('div', { className: 'ct-vehicle-brand-model-panel__selected-list' },
React.createElement('span', {
className: 'ct-vehicle-brand-model-panel__selected-tag ct-vehicle-brand-model-panel__selected-tag--all',
}, '全部')
)
)
: null,
React.createElement('div', {
className: 'vm-operate-city-edit-main' + (isSearchMode ? ' is-search' : ''),
},
!isSearchMode
? React.createElement('div', { className: 'vm-operate-city-edit-step vm-operate-city-edit-step--province' },
React.createElement('span', { className: 'vm-operate-city-step-label' }, '品牌'),
React.createElement('div', {
className: 'vm-operate-city-edit-chips',
role: 'tablist',
'aria-label': '品牌列表',
},
React.createElement('button', {
key: VEHICLE_BINDING_ALL_BRAND,
type: 'button',
role: 'tab',
'aria-selected': isBrowsingAllTab,
className: 'vm-operate-city-chip ct-vehicle-brand-model-picker__all-brand'
+ (isBrowsingAllTab ? ' active' : ''),
onClick: selectAllBrands,
}, '全部'),
LEASE_VEHICLE_BRAND_MODEL_CATALOG.map(function (item) {
var active = activeBrand === item.brand;
return React.createElement('button', {
key: item.brand,
type: 'button',
role: 'tab',
'aria-selected': active,
className: 'vm-operate-city-chip' + (active ? ' active' : ''),
onClick: function () {
setActiveBrand(item.brand);
setQuery('');
},
}, item.brand);
})
)
)
: null,
React.createElement('div', { className: 'vm-operate-city-edit-step vm-operate-city-edit-step--city' },
React.createElement('span', { className: 'vm-operate-city-step-label' },
isSearchMode ? ('搜索结果(' + displayModels.length + '') : '型号',
),
React.createElement('div', {
className: 'vm-operate-city-edit-chips',
role: 'listbox',
'aria-label': '型号列表',
'aria-multiselectable': true,
},
isBrowsingAllTab && isAllMode
? React.createElement('p', { className: 'vm-operate-city-empty ct-vehicle-brand-model-picker__all-hint' },
'当前章节适用于全部品牌、全部型号;如需限定车型,请先选择左侧品牌'
)
: displayModels.length === 0
? React.createElement('p', { className: 'vm-operate-city-empty' },
isSearchMode ? '未找到匹配型号,请换个关键词' : '暂无型号'
)
: displayModels.map(function (item) {
var checked = isSelected(item.brand, item.model);
return React.createElement('button', {
key: vehicleBindingKey(item),
type: 'button',
role: 'option',
'aria-selected': checked,
className: 'vm-operate-city-chip' + (checked ? ' active' : ''),
onClick: function () { togglePair(item.brand, item.model); },
}, item.model);
})
)
)
)
);
return React.createElement('div', { className: 'vm-operate-city-field ct-vehicle-brand-model-picker' },
React.createElement(Popover, {
trigger: 'click',
placement: 'bottomLeft',
open: open,
onOpenChange: handleOpenChange,
overlayClassName: 'ct-vehicle-brand-model-popover',
getPopupContainer: function () { return document.body; },
content: panelContent,
},
React.createElement('div', {
className: 'vm-filter-picker-control ct-vehicle-brand-model-picker__trigger' + (open ? ' open' : ''),
role: 'combobox',
'aria-label': '品牌型号',
'aria-expanded': open,
tabIndex: 0,
onKeyDown: function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setOpen(true);
}
},
},
isAllMode
? React.createElement('span', {
className: 'ct-vehicle-brand-model-picker__summary-tag ct-vehicle-brand-model-picker__summary-tag--all',
title: '全部品牌、全部型号',
}, '全部')
: value.length > 0
? React.createElement('div', { className: 'ct-vehicle-brand-model-picker__summary-tags' },
value.map(function (item) {
var label = formatBrandModelPair(item.brand, item.model);
return React.createElement('span', {
key: vehicleBindingKey(item),
className: 'ct-vehicle-brand-model-picker__summary-tag',
title: label,
},
React.createElement('span', { className: 'ct-vehicle-brand-model-picker__summary-tag-brand' }, item.brand),
React.createElement('span', { className: 'ct-vehicle-brand-model-picker__summary-tag-sep', 'aria-hidden': true }, '·'),
React.createElement('span', { className: 'ct-vehicle-brand-model-picker__summary-tag-model' }, item.model)
);
})
)
: React.createElement('span', {
className: 'vm-filter-picker-input ct-vehicle-brand-model-picker__summary is-placeholder',
}, '请选择品牌 / 型号'),
!isAllMode && value.length > 0
? React.createElement('button', {
type: 'button',
className: 'vm-filter-picker-clear',
onClick: clearAll,
'aria-label': '清空品牌型号',
tabIndex: -1,
}, React.createElement(CloseIcon))
: null,
React.createElement('span', { className: 'vm-filter-picker-chevron' },
React.createElement(ChevronDownIcon)
)
)
)
);
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,11 @@
{
"type": "excalidraw",
"version": 2,
"source": "axhub-make",
"elements": [],
"appState": {
"gridSize": 20,
"viewBackgroundColor": "#ffffff"
},
"files": {}
}

View File

@@ -0,0 +1,75 @@
import { V266_LEASE_DOCUMENT_HTML } from './v266-lease-document.js';
import { TRIAL_AGREEMENT_DOCUMENT_HTML } from './trial-agreement-document.js';
import { TRIAL_AGREEMENT_18T_DOCUMENT_HTML } from './trial-agreement-18t-document.js';
import { mergeMonolithicHtml } from './contract-template-section-html.js';
import { buildInitialWordTemplates } from './contract-template-seed.js';
import {
ensureContractTemplateStore,
getPublishedEnabledLeaseOptions,
getPublishedContractTypeOptions,
getTemplateDocumentById,
getDefaultTemplateIdForContractType,
subscribeContractTemplates,
mapTemplateToLeaseOption,
restoreContractTemplatesFromSession,
} from './contract-template-store.js';
export {
getContractTypeLabel,
normalizeContractType,
resolveContractType,
} from './contract-template-types.js';
restoreContractTemplatesFromSession();
ensureContractTemplateStore(buildInitialWordTemplates);
export function getPublishedContractTemplateOptions(category) {
return getPublishedEnabledLeaseOptions(category);
}
export function getPublishedContractTypeChoices(category) {
return getPublishedContractTypeOptions(category);
}
export function subscribePublishedContractTemplateOptions(listener) {
return subscribeContractTemplates(listener);
}
export function getContractTemplateOption(templateId) {
var doc = getTemplateDocumentById(templateId);
if (!doc || doc.status !== 'published' || doc.enabled === false) return null;
return mapTemplateToLeaseOption(doc);
}
function getStaticTemplateBaseHtml(templateId) {
switch (templateId) {
case 'doc-4':
return TRIAL_AGREEMENT_DOCUMENT_HTML || '';
case 'doc-5':
return TRIAL_AGREEMENT_18T_DOCUMENT_HTML || '';
case 'doc-1':
case 'doc-2':
default:
return V266_LEASE_DOCUMENT_HTML || '';
}
}
/** 优先读取 store 中模板的 sectionContents静态 HTML 仅作兜底 */
export function getContractTemplateBaseHtml(templateId) {
var doc = getTemplateDocumentById(templateId);
if (doc && doc.sectionContents) {
var merged = mergeMonolithicHtml(doc.sectionContents);
if (merged && String(merged).trim()) return merged;
}
return getStaticTemplateBaseHtml(templateId);
}
export function getDefaultContractTemplateId(contractType, category) {
return getDefaultTemplateIdForContractType(contractType, category);
}
export function getContractTemplatePreviewBadge(templateId) {
var option = getContractTemplateOption(templateId);
if (!option) return '请先选择合同模板';
return option.contractTypeLabel || option.title || option.kind || '—';
}

View File

@@ -0,0 +1,32 @@
/**
* docx-preview 高保真 Word 渲染(预览区)
* 使用动态 import避免阻塞原型入口模块加载。
*/
var DEFAULT_RENDER_OPTIONS = {
className: 'ct-docx-preview',
inWrapper: true,
ignoreWidth: false,
ignoreHeight: false,
ignoreFonts: false,
breakPages: true,
ignoreLastRenderedPageBreak: true,
trimXmlDeclaration: true,
useBase64URL: true,
};
export async function renderDocxPreview(container, arrayBuffer, options) {
if (!container || !arrayBuffer) return;
container.innerHTML = '';
var mod = await import('docx-preview');
var renderAsync = mod.renderAsync;
if (typeof renderAsync !== 'function') {
throw new Error('docx-preview 模块未正确加载');
}
await renderAsync(
arrayBuffer,
container,
null,
Object.assign({}, DEFAULT_RENDER_OPTIONS, options || {})
);
}

View File

@@ -0,0 +1,116 @@
/**
* 按章节适用车型绑定,从整份合同 HTML 中移除未命中的附件章节。
*/
import { sectionBindingsMatchOrder } from './vehicle-binding-utils.js';
var ATTACHMENT_SECTIONS = [
{ key: 'lease_order', markers: ['<b>1租赁订单 </b>', '附件 1租赁订单', '附件1租赁订单', '1租赁订单'] },
{ key: 'vehicle_cost', markers: ['附件2:车辆产生的费用', '附件2车辆产生的费用', '<b>附件2车辆产生的费用</b>'] },
{ key: 'safety_notice', markers: ['附件3:安全责任告知书', '附件3安全责任告知书', '<b>附件3安全责任告知书</b>'] },
{ key: 'dynamic_supervision', markers: ['附件4重型普通货运车辆租赁动态监管责任告知与确认书', '附件4:'] },
{ key: 'safety_commitment', markers: ['<b>附件5</b>', '<b>安全责任承诺书</b></p><p class="ct-word-body">为严格落实', '附件5', '附件5:'] },
{ key: 'vehicle_spec', markers: ['<b>附件6车辆使用规范和驾驶员要求</b>', '附件6车辆使用规范和驾驶员要求', '附件6:'] },
{ key: 'authorization', markers: ['font: 28.0px Times; color: #000000"><b>授权委托书</b>', '<b>授权委托书</b></p><p class="ct-word-body">致:', '<b>授权委托书</b><b></b></p><p class="ct-word-body">致:'] },
{ key: 'safety_agreement', markers: ['data-risk-redline="1"><b>安全责任协议</b></span></p>', '<b>安全责任协议</b></p><p class="ct-word-body"><br></p><p class="ct-word-body"><b>甲方(出租方):</b>', '<b>安全责任协议</b></p><p class="ct-word-body"><br></p>'] },
];
function isValidSectionMarkerMatch(source, index, marker) {
if (index < 0) return false;
var text = String(source || '');
var before = text.slice(Math.max(0, index - 8), index);
var after = text.slice(index, index + String(marker || '').length + 12);
if (/[(]$/.test(before)) return false;
if (/、$/.test(before)) return false;
if (/^《/.test(after.slice(String(marker).length))) return false;
var nearby = text.slice(Math.max(0, index - 24), index + String(marker).length + 24);
if (/详见.{0,20}附件/.test(nearby)) return false;
if (/以附件/.test(nearby)) return false;
if (String(marker).length <= 10 && !/<b>/.test(nearby)) return false;
return true;
}
function findFirstMarker(source, markers) {
var text = String(source || '');
var best = -1;
(markers || []).forEach(function (marker) {
var from = 0;
while (from < text.length) {
var idx = text.indexOf(marker, from);
if (idx < 0) break;
if (isValidSectionMarkerMatch(text, idx, marker)) {
if (best < 0 || idx < best) best = idx;
break;
}
from = idx + 1;
}
});
return best;
}
function collectSectionRanges(html) {
var source = String(html || '');
var points = ATTACHMENT_SECTIONS.map(function (section) {
return {
key: section.key,
index: findFirstMarker(source, section.markers),
};
}).filter(function (point) { return point.index >= 0; });
points.sort(function (a, b) { return a.index - b.index; });
return points.map(function (point, idx) {
var next = points[idx + 1];
return {
key: point.key,
start: point.index,
end: next ? next.index : source.length,
};
});
}
export function applyAttachmentSectionVehicleFilter(html, sectionVehicleBindings, vehicleKeys) {
var source = String(html || '');
if (!source) return source;
var bindingsMap = sectionVehicleBindings || {};
var ranges = collectSectionRanges(source);
if (!ranges.length) return source;
var removeMask = new Array(source.length);
ranges.forEach(function (range) {
var bindings = bindingsMap[range.key] || [];
if (sectionBindingsMatchOrder(bindings, vehicleKeys)) return;
for (var i = range.start; i < range.end; i++) {
removeMask[i] = true;
}
});
if (!removeMask.some(Boolean)) return source;
var out = '';
for (var pos = 0; pos < source.length; pos++) {
if (!removeMask[pos]) out += source.charAt(pos);
}
return out;
}
/** 附件章节删减后,按当前保留顺序重排「附件 N」序号 */
export function renumberAttachmentSections(html) {
var source = String(html || '');
var ranges = collectSectionRanges(source);
if (!ranges.length) return source;
var chunks = [];
var cursor = 0;
ranges.forEach(function (range, idx) {
var newNo = idx + 1;
chunks.push(source.slice(cursor, range.start));
var slice = source.slice(range.start, range.end);
slice = slice.replace(/附件\s*\d+\s*[:]/, '附件' + newNo + '');
slice = slice.replace(/<b>\s*(\d+):租赁订单\s*<\/b>/, '<b>' + newNo + ':租赁订单 </b>');
chunks.push(slice);
cursor = range.end;
});
chunks.push(source.slice(cursor));
return chunks.join('');
}

View File

@@ -0,0 +1,173 @@
/**
* 从 Word HTML 中按「标题」段落提取章节 Tab并拆分/合并章节内容。
*/
import { DOCUMENT_SECTION_TABS } from './contract-template-section-html.js';
var DEFAULT_MAIN_TAB = { key: 'main', label: '合同主体' };
var HEADING_TAGS = { H1: true, H2: true, H3: true, H4: true, H5: true, H6: true };
export function getDefaultSectionTabs() {
return DOCUMENT_SECTION_TABS.slice();
}
export function emptySectionMapForTabs(tabs) {
var map = {};
(tabs || []).forEach(function (tab) {
map[tab.key] = '';
});
return map;
}
function normalizeHeadingText(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
function slugifySectionKey(label, index) {
var slug = String(label || '')
.toLowerCase()
.replace(/[^\u4e00-\u9fa5a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 24);
return slug ? 'section_' + slug + '_' + index : 'section_' + index;
}
function isSectionTitleElement(node) {
if (!node || node.nodeType !== 1) return false;
if (!node.classList || !node.classList.contains('ct-word-section-title')) return false;
if (node.closest && node.closest('table')) return false;
var text = normalizeHeadingText(node.textContent);
return text.length > 0 && text.length <= 80;
}
function isHeadingElement(node) {
if (!node || node.nodeType !== 1) return false;
if (isSectionTitleElement(node)) return true;
if (!HEADING_TAGS[node.nodeName]) return false;
if (node.closest && node.closest('table')) return false;
var text = normalizeHeadingText(node.textContent);
return text.length > 0 && text.length <= 80;
}
function collectHeadingElements(root) {
var headings = [];
if (!root) return headings;
var seen = new Set();
function push(node) {
if (!node || seen.has(node)) return;
if (!isHeadingElement(node)) return;
seen.add(node);
headings.push(node);
}
root.querySelectorAll('.ct-word-section-title,h1,h2,h3,h4,h5,h6').forEach(push);
return headings;
}
/** 从 Word HTML 提取章节 Tab无标题时返回 null调用方回退默认 Tab */
export function extractSectionTabsFromHtml(html) {
if (typeof document === 'undefined') return null;
var source = String(html || '').trim();
if (!source) return null;
var div = document.createElement('div');
div.innerHTML = source;
var headings = collectHeadingElements(div);
if (!headings.length) return null;
var tabs = [Object.assign({}, DEFAULT_MAIN_TAB)];
var usedKeys = { main: true };
headings.forEach(function (heading, index) {
var label = normalizeHeadingText(heading.textContent);
if (!label) return;
var key = slugifySectionKey(label, index + 1);
while (usedKeys[key]) {
key = key + '_' + index;
}
usedKeys[key] = true;
tabs.push({ key: key, label: label, headingText: label });
});
return tabs.length > 1 ? tabs : null;
}
function isWordBlockNode(node) {
if (!node) return false;
if (node.nodeType === 3) return !!String(node.textContent || '').trim();
return node.nodeType === 1 && node.nodeName !== 'STYLE';
}
function detectDynamicSectionStartKey(node, tabs) {
if (!node || node.nodeType !== 1 || !tabs || !tabs.length) return null;
if (!isHeadingElement(node)) return null;
var text = normalizeHeadingText(node.textContent);
for (var i = 1; i < tabs.length; i++) {
if (tabs[i].label === text || tabs[i].headingText === text) {
return tabs[i].key;
}
}
return null;
}
/** 按章节 Tab 将整份 HTML 拆为 section map */
export function splitHtmlBySectionTabs(html, tabs) {
var sectionTabs = tabs && tabs.length ? tabs : getDefaultSectionTabs();
var source = String(html || '');
var result = emptySectionMapForTabs(sectionTabs);
if (!source.trim()) return result;
if (typeof document === 'undefined') {
return result;
}
var div = document.createElement('div');
div.innerHTML = source.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '');
var root = div.querySelector('.ct-word-doc') || div;
var children = [];
root.childNodes.forEach(function (node) {
if (isWordBlockNode(node)) children.push(node);
});
if (!children.length) {
result.main = source;
return result;
}
var holders = {};
var currentKey = 'main';
children.forEach(function (child) {
var startKey = detectDynamicSectionStartKey(child, sectionTabs);
if (startKey) currentKey = startKey;
if (!holders[currentKey]) holders[currentKey] = document.createElement('div');
holders[currentKey].appendChild(child.cloneNode(true));
});
sectionTabs.forEach(function (tab) {
result[tab.key] = holders[tab.key] ? holders[tab.key].innerHTML : '';
});
return result;
}
/** 将章节 map 合并为整份 HTML */
export function mergeHtmlBySectionTabs(sectionMap, tabs) {
var sectionTabs = tabs && tabs.length ? tabs : getDefaultSectionTabs();
var map = sectionMap || {};
var out = String(map.main || '');
sectionTabs.slice(1).forEach(function (tab) {
var chunk = map[tab.key];
if (chunk) out += String(chunk);
});
return out;
}
export function resolveSectionTabs(record) {
if (record && Array.isArray(record.sectionTabs) && record.sectionTabs.length) {
return record.sectionTabs;
}
return getDefaultSectionTabs();
}
export function getSectionTabLabel(tabs, key) {
var list = tabs || getDefaultSectionTabs();
var tab = list.find(function (item) { return item.key === key; });
return tab ? tab.label : key;
}

View File

@@ -0,0 +1,102 @@
/**
* 章节 HTML 合并 / 拆分(合同模板管理 ↔ 租赁合同预览共用)
*/
export var DOCUMENT_SECTION_TABS = [
{ key: 'main', label: '合同主体' },
{ key: 'lease_order', label: '租赁订单', markers: ['<b>1租赁订单 </b>', '附件 1租赁订单', '附件1租赁订单'] },
{ key: 'vehicle_cost', label: '车辆产生的费用', markers: ['附件2:车辆产生的费用', '附件2车辆产生的费用', '<b>附件2车辆产生的费用</b>'] },
{ key: 'safety_notice', label: '安全责任告知书', markers: ['附件3:安全责任告知书', '附件3安全责任告知书', '<b>附件3安全责任告知书</b>'] },
{ key: 'dynamic_supervision', label: '重型普通货运车辆租赁动态监管责任告知与确认书', markers: ['附件4重型普通货运车辆租赁动态监管责任告知与确认书', '附件4:'] },
{ key: 'safety_commitment', label: '安全责任承诺书', markers: ['<b>附件5</b>', '<b>安全责任承诺书</b></p><p class="ct-word-body">为严格落实', '附件5', '附件5:'] },
{ key: 'vehicle_spec', label: '车辆使用规范和驾驶员要求', markers: ['<b>附件6车辆使用规范和驾驶员要求</b>', '附件6车辆使用规范和驾驶员要求', '附件6:'] },
{ key: 'authorization', label: '授权委托书', markers: ['font: 28.0px Times; color: #000000"><b>授权委托书</b>', '<b>授权委托书</b></p><p class="ct-word-body">致:', '<b>授权委托书</b><b></b></p><p class="ct-word-body">致:'] },
{ key: 'safety_agreement', label: '安全责任协议', markers: ['data-risk-redline="1"><b>安全责任协议</b></span></p>', '<b>安全责任协议</b></p><p class="ct-word-body"><br></p><p class="ct-word-body"><b>甲方(出租方):</b>', '<b>安全责任协议</b></p><p class="ct-word-body"><br></p>'] },
];
export function emptySectionMap() {
var map = {};
DOCUMENT_SECTION_TABS.forEach(function (tab) { map[tab.key] = ''; });
return map;
}
export function mergeMonolithicHtml(sectionMap) {
var map = sectionMap || {};
var out = String(map.main || '');
DOCUMENT_SECTION_TABS.slice(1).forEach(function (tab) {
var chunk = map[tab.key];
if (chunk) out += String(chunk);
});
return out;
}
function isValidSectionMarkerMatch(source, index, marker) {
if (index < 0) return false;
var text = String(source || '');
var before = text.slice(Math.max(0, index - 8), index);
var after = text.slice(index, index + String(marker || '').length + 12);
if (/[(]$/.test(before)) return false;
if (/、$/.test(before)) return false;
if (/^《/.test(after.slice(String(marker).length))) return false;
var nearby = text.slice(Math.max(0, index - 24), index + String(marker).length + 24);
if (/详见.{0,20}附件/.test(nearby)) return false;
if (/以附件/.test(nearby)) return false;
if (String(marker).length <= 10 && !/<b>/.test(nearby)) return false;
return true;
}
function findFirstMarker(source, markers) {
var text = String(source || '');
var best = -1;
(markers || []).forEach(function (marker) {
var from = 0;
while (from < text.length) {
var idx = text.indexOf(marker, from);
if (idx < 0) break;
if (isValidSectionMarkerMatch(text, idx, marker)) {
if (best < 0 || idx < best) best = idx;
break;
}
from = idx + 1;
}
});
return best;
}
function findSafeHtmlSplitIndex(source, index) {
var text = String(source || '');
var candidates = ['</p>', '</table>', '</div>'];
var best = index;
candidates.forEach(function (tag) {
var idx = text.lastIndexOf(tag, index);
if (idx >= 0) {
var end = idx + tag.length;
if (end > best) best = end;
}
});
return best > 0 ? best : index;
}
/** 将整份 Word HTML 按附件标记拆为章节 map种子数据 / 导入用) */
export function splitMonolithicHtml(html) {
var source = String(html || '');
var result = emptySectionMap();
if (!source.trim()) return result;
var splitPoints = [];
DOCUMENT_SECTION_TABS.slice(1).forEach(function (tab) {
var idx = findFirstMarker(source, tab.markers || [tab.label]);
if (idx >= 0) splitPoints.push({ key: tab.key, index: idx });
});
splitPoints.sort(function (a, b) { return a.index - b.index; });
var deduped = [];
splitPoints.forEach(function (point) {
if (!deduped.length || deduped[deduped.length - 1].index !== point.index) deduped.push(point);
});
result.main = deduped.length ? source.slice(0, findSafeHtmlSplitIndex(source, deduped[0].index)) : source;
for (var i = 0; i < deduped.length; i++) {
var start = deduped[i].index;
var end = i + 1 < deduped.length ? findSafeHtmlSplitIndex(source, deduped[i + 1].index) : source.length;
result[deduped[i].key] = source.slice(start, end);
}
return result;
}

View File

@@ -0,0 +1,170 @@
import { V266_LEASE_DOCUMENT_HTML } from './v266-lease-document.js';
import { TRIAL_AGREEMENT_DOCUMENT_HTML } from './trial-agreement-document.js';
import { TRIAL_AGREEMENT_18T_DOCUMENT_HTML } from './trial-agreement-18t-document.js';
import { emptySectionMap, splitMonolithicHtml } from './contract-template-section-html.js';
function getLeaseDocumentSectionMap() {
if (typeof V266_LEASE_DOCUMENT_HTML !== 'undefined' && V266_LEASE_DOCUMENT_HTML) {
return splitMonolithicHtml(V266_LEASE_DOCUMENT_HTML);
}
return emptySectionMap();
}
function isTrialAgreementFileName(name) {
return /商用车试用协议/i.test(String(name || ''));
}
function isTrialAgreement18tFileName(name) {
return /商用车试用协议.*现代18吨|现代18吨.*商用车试用协议/i.test(String(name || ''));
}
function getTrialAgreementHtml(fileName) {
if (isTrialAgreement18tFileName(fileName)) {
return typeof TRIAL_AGREEMENT_18T_DOCUMENT_HTML !== 'undefined' ? TRIAL_AGREEMENT_18T_DOCUMENT_HTML : '';
}
if (isTrialAgreementFileName(fileName)) {
return typeof TRIAL_AGREEMENT_DOCUMENT_HTML !== 'undefined' ? TRIAL_AGREEMENT_DOCUMENT_HTML : '';
}
return '';
}
function getTrialAgreementSectionMap(fileName) {
var html = getTrialAgreementHtml(fileName);
if (!html) return emptySectionMap();
return splitMonolithicHtml(html);
}
function createWordDocument(params) {
return Object.assign({
category: 'lease',
contractName: '',
contractType: '合同',
fileName: '未命名合同.docx',
versionName: '未命名合同',
versionNo: null,
status: 'draft',
enabled: false,
versionLogs: [],
sectionContents: emptySectionMap(),
sectionVehicleBindings: {},
remark: '',
}, params || {});
}
/** 冷启动种子:未打开模板管理页时租赁创建等场景仍可列出已启用模板 */
export function buildInitialWordTemplates() {
var v266 = getLeaseDocumentSectionMap();
var trialBase = getTrialAgreementSectionMap('商用车试用协议.docx');
var trial18t = getTrialAgreementSectionMap('商用车试用协议-现代18吨.docx');
var model18t = [{ brand: '现代', model: '18吨氢燃料电池车' }];
var model45t = [{ brand: '现代', model: '帕力安牌4.5吨冷链车' }];
var model96 = [{ brand: '苏龙', model: '9.6米氢燃料电池车' }];
var fullSections = Object.assign(emptySectionMap(), v266);
return [
createWordDocument({
id: 'doc-1',
contractName: '现代18吨正式合同',
contractType: '合同',
fileName: '现代18吨车型-数智中心版.docx',
versionName: '现代18吨车型-数智中心版',
versionNo: 'v1.00',
status: 'published',
enabled: true,
versionLogs: [{
id: 'log-doc-1-v0',
versionNo: 'v0.99',
action: '发布变更',
operator: '法务-王静',
time: '2026-05-20 16:40',
remark: '数智中心版条款调整前的归档快照',
snapshot: {
fileName: '现代18吨车型-数智中心版.docx',
versionName: '现代18吨车型-数智中心版',
versionNo: 'v0.99',
sectionContents: Object.assign(emptySectionMap(), v266),
sectionVehicleBindings: { lease_order: model18t },
remark: '历史快照(原型)',
archivedAt: '2026-05-20 16:40',
},
}],
sectionContents: fullSections,
sectionVehicleBindings: {
lease_order: model18t,
dynamic_supervision: model96,
vehicle_spec: model45t,
},
creator: '法务-王静',
createTime: '2026-05-25 09:13',
updater: '法务-王静',
updateTime: '2026-05-25 09:13',
remark: 'V26.6 现代18吨数智中心版标准合同',
}),
createWordDocument({
id: 'doc-2',
contractName: '正式合同',
contractType: '合同',
fileName: '2026年标准商用车租赁合同.docx',
versionName: '2026年标准商用车租赁合同',
versionNo: 'v1.01',
status: 'published',
enabled: true,
sectionContents: fullSections,
sectionVehicleBindings: {},
creator: '法务-王静',
createTime: '2026-03-15 14:30',
updater: '法务-王静',
updateTime: '2026-03-15 14:30',
remark: '通用主体条款',
}),
createWordDocument({
id: 'doc-3',
contractName: '正式合同',
contractType: '合同',
fileName: '2026年二季度草案.docx',
versionName: '2026年二季度草案',
versionNo: null,
status: 'draft',
sectionContents: fullSections,
sectionVehicleBindings: {},
creator: '法务-李明',
createTime: '2026-06-01 09:15',
updater: '法务-李明',
updateTime: '2026-06-01 09:15',
remark: '待法务审核发布',
}),
createWordDocument({
id: 'doc-4',
contractName: '试用合同',
contractType: '合同',
fileName: '商用车试用协议.docx',
versionName: '商用车试用协议',
versionNo: 'v1.00',
status: 'published',
enabled: true,
sectionContents: Object.assign(emptySectionMap(), trialBase),
sectionVehicleBindings: {},
creator: '法务-王静',
createTime: '2026-06-20 10:00',
updater: '法务-王静',
updateTime: '2026-06-20 10:00',
remark: '通用商用车试用协议范本',
}),
createWordDocument({
id: 'doc-5',
contractName: '现代18吨试用合同',
contractType: '合同',
fileName: '商用车试用协议-现代18吨.docx',
versionName: '商用车试用协议-现代18吨',
versionNo: 'v1.00',
status: 'published',
enabled: false,
sectionContents: Object.assign(emptySectionMap(), trial18t),
sectionVehicleBindings: {},
creator: '法务-李明',
createTime: '2026-06-25 14:20',
updater: '法务-李明',
updateTime: '2026-06-25 14:20',
remark: '现代18吨车型试用协议含完整安全附件',
}),
];
}

View File

@@ -0,0 +1,218 @@
/**
* 合同模板跨原型共享状态(合同模板管理 ↔ 租赁合同创建)
* 原型级 storelocalStorage 优先持久化sessionStorage 作兼容回退。
*/
import {
getContractNameLabel,
normalizeContractType,
resolveContractName,
resolveContractType,
} from './contract-template-types.js';
var STORAGE_KEY = 'oneos_contract_templates_v1';
var templates = null;
var listeners = new Set();
var lastWriteSucceeded = true;
function readFromStorage() {
if (typeof localStorage !== 'undefined') {
try {
var localRaw = localStorage.getItem(STORAGE_KEY);
if (localRaw) {
var localParsed = JSON.parse(localRaw);
if (Array.isArray(localParsed) && localParsed.length) return localParsed;
}
} catch (err) {
/* 忽略 */
}
}
if (typeof sessionStorage === 'undefined') return null;
try {
var raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
var parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : null;
} catch (err) {
return null;
}
}
function writeToStorage() {
if (!templates) {
lastWriteSucceeded = true;
return true;
}
var payload = JSON.stringify(templates);
var localOk = true;
var sessionOk = true;
if (typeof localStorage !== 'undefined') {
try {
localStorage.setItem(STORAGE_KEY, payload);
} catch (err) {
localOk = false;
}
}
if (typeof sessionStorage !== 'undefined') {
try {
sessionStorage.setItem(STORAGE_KEY, payload);
} catch (err) {
sessionOk = false;
}
}
lastWriteSucceeded = localOk || sessionOk;
return lastWriteSucceeded;
}
export function wasLastContractTemplateWriteOk() {
return lastWriteSucceeded;
}
function notify() {
var snapshot = templates ? templates.slice() : [];
listeners.forEach(function (fn) {
try {
fn(snapshot);
} catch (err) {
console.error('[contract-template-store] listener error', err);
}
});
}
export function restoreContractTemplatesFromSession() {
if (templates) return templates;
var restored = readFromStorage();
if (restored) {
templates = restored;
notify();
}
return templates || [];
}
export function ensureContractTemplateStore(seedFactory) {
if (!templates) {
templates = readFromStorage();
}
if (!templates && typeof seedFactory === 'function') {
templates = seedFactory();
writeToStorage();
notify();
}
return templates || [];
}
export function getContractTemplates() {
return templates ? templates.slice() : [];
}
export function setContractTemplates(updater) {
var current = templates ? templates.slice() : [];
var next = typeof updater === 'function' ? updater(current) : updater;
templates = Array.isArray(next) ? next : current;
writeToStorage();
notify();
return templates;
}
export function subscribeContractTemplates(listener) {
if (typeof listener !== 'function') return function () {};
listeners.add(listener);
return function () {
listeners.delete(listener);
};
}
var VEHICLE_HINT_BY_ID = {
'doc-1': '现代 · 18吨氢燃料电池车',
'doc-2': '通用车型',
'doc-4': '通用车型',
'doc-5': '现代 · 18吨',
};
function inferKind(doc) {
return resolveContractName(doc);
}
function inferVehicleHint(doc) {
if (doc.id && VEHICLE_HINT_BY_ID[doc.id]) return VEHICLE_HINT_BY_ID[doc.id];
return '通用车型';
}
export function mapTemplateToLeaseOption(doc) {
var contractName = resolveContractName(doc);
return {
id: doc.id,
title: doc.versionName || stripExtension(doc.fileName) || doc.id,
fileName: doc.fileName || doc.versionName || '',
versionNo: doc.versionNo || '—',
contractType: contractName,
contractTypeLabel: getContractNameLabel(contractName),
kind: inferKind(doc),
vehicleHint: inferVehicleHint(doc),
};
}
function stripExtension(name) {
return String(name || '').replace(/\.[^.]+$/, '');
}
/** 已发布且启用、可供租赁合同创建选用的模板 */
export function getPublishedEnabledLeaseOptions(category) {
var cat = category || 'lease';
return (templates || [])
.filter(function (doc) {
return doc.category === cat
&& doc.status === 'published'
&& doc.enabled !== false;
})
.map(mapTemplateToLeaseOption)
.sort(function (a, b) {
return String(a.fileName).localeCompare(String(b.fileName), 'zh-CN');
});
}
/** 已发布且启用模板中去重后的合同类型(供租赁合同创建选用) */
export function getPublishedContractTypeOptions(category) {
var cat = category || 'lease';
var seen = {};
var types = [];
(templates || []).forEach(function (doc) {
if (doc.category !== cat || doc.status !== 'published' || doc.enabled === false) return;
var type = resolveContractType(doc);
if (!type || seen[type]) return;
seen[type] = true;
types.push({ value: type, label: type });
});
return types.sort(function (a, b) {
return a.label.localeCompare(b.label, 'zh-CN');
});
}
export function getTemplateDocumentById(templateId) {
if (!templateId || !templates) return null;
return templates.find(function (doc) { return doc.id === templateId; }) || null;
}
export function getTemplateSectionVehicleBindings(templateId) {
var doc = getTemplateDocumentById(templateId);
if (!doc || !doc.sectionVehicleBindings) return {};
return doc.sectionVehicleBindings;
}
/** 某合同类型下已发布且启用的模板(取最近更新的一条) */
export function getDefaultTemplateIdForContractType(contractType, category) {
var normalizedType = normalizeContractType(contractType);
if (!normalizedType) return '';
var cat = category || 'lease';
var matches = (templates || []).filter(function (doc) {
return doc.category === cat
&& doc.status === 'published'
&& doc.enabled !== false
&& resolveContractType(doc) === normalizedType;
});
if (!matches.length) return '';
matches.sort(function (a, b) {
return String(b.updateTime || '').localeCompare(String(a.updateTime || ''), 'zh-CN');
});
return matches[0].id;
}

View File

@@ -0,0 +1,78 @@
/**
* 合同模板字段口径:
* - contractName合同名称用户自定义文本
* - contractType类型枚举合同 / 授权委托书 / 转三方协议 / 其他)
*/
export var CONTRACT_TYPE_ENUM_OPTIONS = ['合同', '授权委托书', '转三方协议', '其他'];
export var DEFAULT_DOCUMENT_CONTRACT_TYPE = '合同';
export function isDocumentContractTypeEnum(value) {
return CONTRACT_TYPE_ENUM_OPTIONS.indexOf(String(value || '').trim()) >= 0;
}
export function normalizeContractName(value) {
return String(value || '').trim();
}
export function normalizeDocumentContractType(value) {
var text = String(value || '').trim();
return isDocumentContractTypeEnum(text) ? text : DEFAULT_DOCUMENT_CONTRACT_TYPE;
}
export function getContractNameLabel(value) {
var text = normalizeContractName(value);
return text || '—';
}
export function getDocumentContractTypeLabel(value) {
var text = normalizeDocumentContractType(value);
return text || '—';
}
/** 无 contractName 时,按文件名推断(兼容旧数据) */
export function inferContractNameFromDoc(doc) {
if (doc && doc.contractName) return normalizeContractName(doc.contractName);
var legacy = doc && doc.contractType;
if (legacy && !isDocumentContractTypeEnum(legacy)) {
return normalizeContractName(legacy);
}
var name = String((doc && (doc.fileName || doc.versionName)) || '');
return /试用/.test(name) ? '试用合同' : '正式合同';
}
export function resolveContractName(doc) {
return inferContractNameFromDoc(doc);
}
export function resolveDocumentContractType(doc) {
if (doc && doc.contractName != null && isDocumentContractTypeEnum(doc.contractType)) {
return normalizeDocumentContractType(doc.contractType);
}
if (doc && isDocumentContractTypeEnum(doc.contractType)) {
return normalizeDocumentContractType(doc.contractType);
}
return DEFAULT_DOCUMENT_CONTRACT_TYPE;
}
/** @deprecated 租赁合同等下游仍按「合同名称」消费,保留别名 */
export function normalizeContractType(value) {
return normalizeContractName(value);
}
/** @deprecated 保留别名,语义为合同名称 */
export function getContractTypeLabel(value) {
return getContractNameLabel(value);
}
/** @deprecated 保留别名,语义为合同名称 */
export function resolveContractType(doc) {
return resolveContractName(doc);
}
export function getDocumentContractTypeSelectOptions() {
return CONTRACT_TYPE_ENUM_OPTIONS.map(function (value) {
return { value: value, label: value };
});
}

View File

@@ -0,0 +1,374 @@
/**
* 合同模板占位符变量 — 供合同模板管理与租赁合同创建页共用
*/
export var DEFAULT_LESSOR_COMPANIES = [
{
id: 'jx',
shortName: '嘉兴羚牛',
enabled: true,
legalName: '浙江羚牛氢能科技有限公司',
creditCode: '91330481MA2JXXXXX',
address: '浙江省嘉兴市平湖市乍浦镇杭州湾大道1号',
contact: '法务部',
phone: '0573-88888888',
email: 'legal@lingniu-jx.com',
accountName: '浙江羚牛氢能科技有限公司',
bankName: '招商银行嘉兴分行',
bankAccount: '120924165110301',
hotline: '400-021-1773',
},
{
id: 'sh',
shortName: '上海羚牛',
enabled: true,
legalName: '羚牛氢能科技(上海)有限公司',
creditCode: '91310115MA1HXXXXX',
address: '上海市浦东新区张江高科技园区科苑路88号',
contact: '法务部',
phone: '021-88888888',
email: 'legal@lingniu-sh.com',
accountName: '羚牛氢能科技(上海)有限公司',
bankName: '招商银行上海张江支行',
bankAccount: '120924165110401',
hotline: '400-021-1773',
},
{
id: 'gd',
shortName: '广东羚牛',
enabled: true,
legalName: '羚牛氢能科技(广东)有限公司',
creditCode: '91440101MA5CXXXXX',
address: '广东省广州市黄埔区科学城开源大道11号',
contact: '法务部',
phone: '020-88888888',
email: 'legal@lingniu-gd.com',
accountName: '羚牛氢能科技(广东)有限公司',
bankName: '招商银行广州萝岗支行',
bankAccount: '120924165110201',
hotline: '400-021-1773',
},
];
export var MOCK_LEASE_CUSTOMERS = [
{
id: '1',
name: '嘉兴某某物流有限公司',
lastRatingTime: '2026-06-08',
riskLevel: 'A',
creditCode: '91330400MA2XXXXX1',
address: '浙江省嘉兴市南湖区科技大道1号',
contact: '张三',
phone: '13800138001',
email: 'zhangsan@example.com',
companyName: '嘉兴某某物流有限公司',
companyPhone: '0573-88888888',
mailingAddress: '浙江省嘉兴市南湖区科技大道1号',
bank: '中国工商银行嘉兴分行',
bankAccount: '6222021234567890123',
taxId: '91330400MA2XXXXX1',
attachments: {
businessLicense: { name: '嘉兴某某物流-营业执照.pdf', ocrVerified: true, expiryDate: '2028-06-30' },
legalIdFront: { name: '法人身份证-正面.jpg', ocrVerified: true, expiryDate: '2032-08-15' },
legalIdBack: { name: '法人身份证-反面.jpg', ocrVerified: true, expiryDate: '2032-08-15' },
roadTransportPermit: { name: '道路运输经营许可证.pdf', ocrVerified: true, expiryDate: '2027-12-31' },
},
},
{
id: '2',
name: '上海某某运输公司',
lastRatingTime: '2025-12-20',
riskLevel: 'C',
creditCode: '91310000MA2XXXXX2',
address: '上海市浦东新区张江高科技园区',
contact: '李四',
phone: '13800138002',
email: 'lisi@example.com',
companyName: '上海某某运输公司',
companyPhone: '021-66666666',
mailingAddress: '上海市浦东新区张江高科技园区碧波路88号',
bank: '中国建设银行上海分行',
bankAccount: '6217001234567890123',
taxId: '91310000MA2XXXXX2',
attachments: {
businessLicense: { name: '上海某某运输-营业执照.pdf', ocrVerified: true, expiryDate: '2027-03-01' },
legalIdFront: { name: '法人身份证-正面.jpg', ocrVerified: true, expiryDate: '2029-11-20' },
legalIdBack: { name: '法人身份证-反面.jpg', ocrVerified: true, expiryDate: '2029-11-20' },
roadTransportPermit: { name: '道路运输经营许可证.pdf', ocrVerified: true, expiryDate: '2026-01-10' },
},
},
{
id: '3',
name: '杭州某某租赁有限公司',
lastRatingTime: '2026-04-15',
riskLevel: 'B',
creditCode: '91330100MA2XXXXX3',
address: '浙江省杭州市余杭区未来科技城',
contact: '王五',
phone: '13800138003',
email: 'wangwu@example.com',
companyName: '杭州某某租赁有限公司',
companyPhone: '0571-99999999',
mailingAddress: '浙江省杭州市余杭区文一西路998号',
bank: '中国农业银行杭州分行',
bankAccount: '6228481234567890123',
taxId: '91330100MA2XXXXX3',
attachments: {
businessLicense: { name: '杭州某某租赁-营业执照.pdf', ocrVerified: true, expiryDate: '2026-09-20' },
legalIdFront: { name: '法人身份证-正面.jpg', ocrVerified: true, expiryDate: '2030-05-08' },
legalIdBack: { name: '法人身份证-反面.jpg', ocrVerified: true, expiryDate: '2030-05-08' },
roadTransportPermit: { name: '道路运输经营许可证.pdf', ocrVerified: true, expiryDate: '2026-02-01' },
},
},
];
/** 租赁合同乙方客户风控等级C/D 不可签约) */
export var LEASE_CUSTOMER_RISK_META = {
A: { label: 'A低风险', selectable: true },
B: { label: 'B中风险', selectable: true },
C: { label: 'C高风险', selectable: false },
D: { label: 'D禁入', selectable: false },
};
export var LEASE_CUSTOMER_RISK_FILTER_OPTIONS = [
{ value: 'A', label: 'A低风险' },
{ value: 'B', label: 'B中风险' },
{ value: 'C', label: 'C高风险' },
{ value: 'D', label: 'D禁入' },
];
export function getLeaseCustomerRiskLevelLabel(level) {
if (!level) return '—';
var meta = LEASE_CUSTOMER_RISK_META[level];
return meta ? meta.label : level;
}
export function isLeaseCustomerSelectable(customer) {
if (!customer) return false;
var meta = LEASE_CUSTOMER_RISK_META[customer.riskLevel];
return meta ? meta.selectable : false;
}
export var CUSTOMER_CREDENTIAL_ITEMS = [
{ key: 'businessLicense', label: '客户营业执照' },
{ key: 'legalIdFront', label: '法人身份证(正面)' },
{ key: 'legalIdBack', label: '法人身份证(反面)' },
{ key: 'roadTransportPermit', label: '道路运输许可证' },
];
var ATTACHMENT_PREVIEW_COLORS = {
businessLicense: { bg: '#ecfdf5', accent: '#32a06e' },
legalIdFront: { bg: '#eff6ff', accent: '#3b82f6' },
legalIdBack: { bg: '#eef2ff', accent: '#6366f1' },
roadTransportPermit: { bg: '#fff7ed', accent: '#ea580c' },
};
function escapeSvgText(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 客户表证照预览图(原型用 SVG 占位,模拟客户档案附件) */
export function getCustomerAttachmentPreviewUrl(customer, key, label) {
if (!customer) return '';
var file = customer.attachments && customer.attachments[key];
if (file && file.previewUrl) return file.previewUrl;
var colors = ATTACHMENT_PREVIEW_COLORS[key] || { bg: '#f8fafc', accent: '#64748b' };
var svg = '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="480" viewBox="0 0 640 480">'
+ '<rect width="640" height="480" fill="' + colors.bg + '"/>'
+ '<rect x="32" y="32" width="576" height="416" rx="12" fill="#ffffff" stroke="' + colors.accent + '" stroke-width="2"/>'
+ '<text x="320" y="232" text-anchor="middle" font-family="PingFang SC,Microsoft YaHei,sans-serif" font-size="22" fill="#18181b">'
+ escapeSvgText(label)
+ '</text>'
+ '<text x="320" y="272" text-anchor="middle" font-family="PingFang SC,Microsoft YaHei,sans-serif" font-size="14" fill="#64748b">'
+ escapeSvgText(customer.name || '')
+ '</text>'
+ '</svg>';
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
}
export var DEFAULT_MILEAGE_STANDARD = {
hasRequirement: false,
period: 'month',
targetKm: 6000,
reductionYuan: 2000,
validUntil: '2026-12-31',
};
export var MILEAGE_PERIOD_OPTIONS = [
{ value: 'month', labelBefore: '每月完成', labelAfter: '公里' },
{ value: 'quarter', labelBefore: '每季度完成', labelAfter: '公里' },
{ value: 'year', labelBefore: '每年度完成', labelAfter: '公里' },
];
export var CONTRACT_CODE_PREFIX = 'LNZLHT';
export var DEFAULT_FEE_INFO = {
paymentMethod: 'advance',
paymentPeriod: 1,
hydrogenPaymentMethod: 'self',
prepayAmount: null,
payAheadWorkdays: 5,
returnHydrogenDiffUnitPrice: null,
};
/** 新建合同时系统自动生成的编号后缀(不含 LNZLHT 前缀) */
export function generateAutoContractCodeSuffix() {
var now = new Date();
var y = now.getFullYear();
var m = String(now.getMonth() + 1).padStart(2, '0');
var d = String(now.getDate()).padStart(2, '0');
var seq = String(Math.floor(Math.random() * 9000) + 1000);
return y + m + d + seq;
}
export var PAYMENT_PERIOD_OPTIONS = [
{ value: 1, label: '月付' },
{ value: 3, label: '季付' },
{ value: 6, label: '半年付' },
{ value: 12, label: '年付' },
];
export var PAYMENT_METHOD_OPTIONS = [
{ value: 'advance', label: '先付后用' },
{ value: 'postpay', label: '先用后付' },
];
export var HYDROGEN_PAYMENT_METHOD_OPTIONS = [
{ value: 'self', label: '自行解决' },
{ value: 'prepay', label: '预付款' },
{ value: 'month', label: '按月结算' },
];
export function getLessorCompanies() {
if (typeof window !== 'undefined' && window.ONEOS_LESSOR_COMPANIES && window.ONEOS_LESSOR_COMPANIES.length) {
return window.ONEOS_LESSOR_COMPANIES;
}
return DEFAULT_LESSOR_COMPANIES;
}
export function getLessorCompanyById(id) {
if (!id) return null;
var list = getLessorCompanies();
return list.find(function (c) { return c.id === id; })
|| list.find(function (c) { return c.shortName === id; })
|| null;
}
export function getLeaseCustomerById(id) {
if (!id) return null;
return MOCK_LEASE_CUSTOMERS.find(function (c) { return c.id === id; }) || null;
}
export function buildLessorVars(company) {
if (!company) return {};
return {
lessorName: company.legalName || '',
lessorShortName: company.shortName || '',
lessorAddress: company.address || '',
lessorContact: company.contact || '',
lessorPhone: company.phone || '',
lessorEmail: company.email || '',
lessorAccountName: company.accountName || company.legalName || '',
lessorBankName: company.bankName || '',
lessorBankAccount: company.bankAccount || '',
lessorCreditCode: company.creditCode || '',
};
}
export function applyTemplateVars(html, vars) {
var out = String(html || '');
Object.keys(vars || {}).forEach(function (key) {
var val = vars[key] != null ? String(vars[key]) : '';
out = out.split('{{' + key + '}}').join(val);
});
return out;
}
/** 将左侧表单值映射为 V26.6 合同 HTML 占位符 */
export function buildLeasePreviewVars(form) {
var lessor = getLessorCompanyById(form && form.lessorId);
var customer = getLeaseCustomerById(form && form.customerId);
var vars = {
contractCode: (form && form.contractCode) ? String(form.contractCode) : '',
customerName: customer ? customer.name : '',
customerAddress: customer ? customer.address : '',
customerContact: customer ? customer.contact : '',
customerPhone: customer ? customer.phone : '',
creditCode: customer ? customer.creditCode : '',
};
return Object.assign(vars, buildLessorVars(lessor));
}
export function getCustomerInvoicePreview(customer) {
if (!customer) {
return {
companyName: '',
bank: '',
bankAccount: '',
taxId: '',
mailingAddress: '',
companyPhone: '',
};
}
return {
companyName: customer.companyName || customer.name || '',
bank: customer.bank || '',
bankAccount: customer.bankAccount || '',
taxId: customer.taxId || customer.creditCode || '',
mailingAddress: customer.mailingAddress || customer.address || '',
companyPhone: customer.companyPhone || '',
};
}
export function getLessorAccountPreview(company) {
if (!company) {
return {
accountName: '',
bankName: '',
bankAccount: '',
};
}
return {
accountName: company.accountName || company.legalName || '',
bankName: company.bankName || '',
bankAccount: company.bankAccount || '',
};
}
export function getLessorContactPreview(company) {
if (!company) {
return {
mailAddress: '',
contactName: '',
contactPhone: '',
email: '',
};
}
return {
mailAddress: company.address || '',
contactName: company.contact || '',
contactPhone: company.phone || '',
email: company.email || '',
};
}
export function getCustomerContactPreview(customer) {
if (!customer) {
return {
mailAddress: '',
contactName: '',
contactPhone: '',
email: '',
};
}
return {
mailAddress: customer.address || customer.mailingAddress || '',
contactName: customer.contact || '',
contactPhone: customer.phone || '',
email: customer.email || '',
};
}

View File

@@ -0,0 +1,341 @@
/**
* Word (.doc / .docx) → HTML 导入
*/
import mammoth from 'mammoth';
import JSZip from 'jszip';
var MAMMOTH_STYLE_MAP = [
"p[style-name='Title'] => h1.ct-word-doc-title:fresh",
"p[style-name='Heading 1'] => p.ct-word-section-title:fresh",
"p[style-name='Heading 2'] => p.ct-word-section-title.ct-word-section-title--2:fresh",
"p[style-name='Heading 3'] => p.ct-word-section-title.ct-word-section-title--3:fresh",
"p[style-name='标题'] => p.ct-word-section-title:fresh",
"p[style-name='标题 1'] => p.ct-word-section-title:fresh",
"p[style-name='标题1'] => p.ct-word-section-title:fresh",
"p[style-name='标题 2'] => p.ct-word-section-title.ct-word-section-title--2:fresh",
"p[style-name='标题2'] => p.ct-word-section-title.ct-word-section-title--2:fresh",
"p[style-name='标题 3'] => p.ct-word-section-title.ct-word-section-title--3:fresh",
"p[style-name='标题3'] => p.ct-word-section-title.ct-word-section-title--3:fresh",
"highlight => mark.ct-word-highlight",
"r[style-name='Highlight'] => mark.ct-word-highlight",
"r[style-name='高亮强调'] => mark.ct-word-highlight",
];
var OLE_METADATA_PATTERNS = [
/Root Entry/i,
/SummaryInformation/i,
/DocumentSummaryInformation/i,
/\bWordDocument\b/,
/WpsCustomData/i,
/MsoDataStore/i,
/PKSOProductBuildVer/i,
/KSOProductBuildVer/i,
/\bICV\b/,
/\bFormText\b/,
/MERGEFORMAT/i,
/OLE_LINK/i,
];
var LEGACY_DOC_JUNK_EXACT = {
'Root Entry': true,
'SummaryInformation': true,
'DocumentSummaryInformation': true,
'WordDocument': true,
'Word.Document': true,
'Data': true,
'1Table': true,
'WpsCustomData': true,
'MsoDataStore': true,
'Normal': true,
'Default Paragraph Font': true,
'Table Normal': true,
};
export var IMPORTED_WORD_STYLE_ELEMENT_ID = 'ct-imported-word-styles';
export function buildImportedWordBaselineCss() {
return [
'.ct-word-doc--imported{font-family:SimSun,"宋体","Songti SC",STSong,serif;font-size:11pt;line-height:1.15;color:#000;letter-spacing:normal;word-break:normal;overflow-wrap:break-word}',
'.ct-word-doc--imported p,.ct-word-doc--imported li{margin:0;text-align:inherit;text-indent:inherit}',
'.ct-word-doc--imported h1,.ct-word-doc--imported h2,.ct-word-doc--imported h3,.ct-word-doc--imported h4,.ct-word-doc--imported h5,.ct-word-doc--imported h6{margin:0;font-size:inherit;font-weight:inherit;line-height:inherit}',
'.ct-word-doc--imported .ct-word-section-title{font-weight:700;margin:0;text-align:justify}',
'.ct-word-doc--imported .ct-word-doc-title{text-align:center;font-weight:700;font-size:18pt;margin:0 0 6pt}',
'.ct-word-doc--imported mark.ct-word-highlight,.ct-word-doc--imported .ct-word-highlight{background-color:#ffff00;color:inherit;padding:0}',
'.ct-word-doc--imported table{border-collapse:collapse;width:auto;max-width:100%;table-layout:auto}',
'.ct-word-doc--imported td,.ct-word-doc--imported th{vertical-align:top}',
'.ct-word-doc--imported img{max-width:100%;height:auto}',
'.ct-word-doc--imported strong,.ct-word-doc--imported b{font-weight:700}',
].join('\n');
}
function scopeCssToImportedRoot(cssText) {
var scoped = String(cssText || '').trim();
if (!scoped) return '';
if (scoped.indexOf('.ct-word-doc--imported') >= 0) return scoped;
return scoped.replace(/(^|\})([^{@]+)\{/g, function (match, prefix, selector) {
var parts = selector.split(',').map(function (part) {
var trimmed = part.trim();
if (!trimmed || trimmed.indexOf('@') === 0) return trimmed;
return '.ct-word-doc--imported ' + trimmed;
});
return prefix + parts.join(',') + '{';
});
}
export function extractInlineStylesFromImportedHtml(html) {
var content = String(html || '');
var cssParts = [];
content = content.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, function (_, css) {
if (css && css.trim()) cssParts.push(css.trim());
return '';
});
var cssText = cssParts.map(scopeCssToImportedRoot).filter(Boolean).join('\n');
return { html: content.trim(), cssText: cssText };
}
export async function extractDocxThemeStyles(arrayBuffer) {
try {
var zip = await JSZip.loadAsync(arrayBuffer);
var stylesFile = zip.file('word/styles.xml');
if (!stylesFile) return '';
var stylesXml = await stylesFile.async('text');
var fontFamily = '宋体';
var fontSize = '11pt';
var docDefaultsMatch = stylesXml.match(/<w:docDefaults[\s\S]*?<\/w:docDefaults>/);
if (docDefaultsMatch) {
var block = docDefaultsMatch[0];
var eastAsia = block.match(/w:eastAsia="([^"]+)"/);
if (eastAsia && eastAsia[1]) fontFamily = eastAsia[1];
var ascii = block.match(/w:ascii="([^"]+)"/);
if (!eastAsia && ascii && ascii[1]) fontFamily = ascii[1];
var sz = block.match(/<w:sz[^>]*w:val="(\d+)"/);
if (sz && sz[1]) fontSize = (parseInt(sz[1], 10) / 2) + 'pt';
}
return '.ct-word-doc--imported{font-family:"' + fontFamily + '",SimSun,"宋体","Songti SC",serif;font-size:' + fontSize + ';line-height:1.15;color:#000}';
} catch (e) {
return '';
}
}
export function injectImportedWordStyles(cssText) {
if (typeof document === 'undefined') return;
var merged = [buildImportedWordBaselineCss(), String(cssText || '').trim()].filter(Boolean).join('\n');
var styleEl = document.getElementById(IMPORTED_WORD_STYLE_ELEMENT_ID);
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = IMPORTED_WORD_STYLE_ELEMENT_ID;
document.head.appendChild(styleEl);
}
if (styleEl.textContent !== merged) styleEl.textContent = merged;
}
export function prepareImportedWordHtml(html, extraCss) {
var source = sanitizeImportedWordHtml(String(html || ''));
if (!isImportedWordDocHtml(source)) return source;
var extracted = extractInlineStylesFromImportedHtml(source);
injectImportedWordStyles([extracted.cssText, extraCss].filter(Boolean).join('\n'));
var body = extracted.html.trim();
if (!body) return '';
if (body.indexOf('ct-word-doc--imported') >= 0) return body;
return wrapWordHtml(body);
}
function wrapWordHtml(html) {
var body = String(html || '').trim();
if (!body) return '';
if (body.indexOf('ct-word-doc') >= 0) return body;
return '<div class="ct-word-doc ct-word-doc--imported">' + body + '</div>';
}
function escapeHtml(text) {
return String(text || '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function detectWordFileFormat(arrayBuffer) {
var view = new Uint8Array(arrayBuffer || []);
if (view.length >= 4 && view[0] === 0x50 && view[1] === 0x4b) return 'docx';
if (view.length >= 8 && view[0] === 0xd0 && view[1] === 0xcf && view[2] === 0x11 && view[3] === 0xe0) return 'doc';
return 'unknown';
}
export function isImportedWordDocHtml(html) {
return /ct-word-doc--imported/.test(String(html || ''));
}
export function isCorruptWordImportHtml(html) {
var source = String(html || '');
if (!source.trim()) return true;
var plain = source.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '').replace(/<[^>]+>/g, '');
if (!plain.replace(/\s/g, '')) return true;
var hits = 0;
OLE_METADATA_PATTERNS.forEach(function (pattern) {
if (pattern.test(plain)) hits += 1;
});
if (hits >= 2) return true;
if (/[\u4e00-\u9fff]/.test(plain)) return false;
if (/合同|甲方|乙方|租赁|附件/.test(plain)) return false;
return hits >= 1 && plain.length < 500;
}
export function sanitizeImportedWordHtml(html) {
var content = String(html || '').trim();
if (!content) return '';
content = content
.replace(/<\/p>\s*n\s*<p/gi, '</p><p')
.replace(/<\/table>\s*n\s*<p/gi, '</table><p')
.replace(/<\/table>\s*n\s*<table/gi, '</table><table');
if (content.indexOf('ct-word-doc--imported') < 0 && content.indexOf('ct-word-doc') >= 0) {
content = content.replace('ct-word-doc', 'ct-word-doc ct-word-doc--imported');
}
return content;
}
function looksLikeHeadingLine(line) {
var text = String(line || '').trim();
if (!text || text.length > 80) return false;
if (/^附件\s*\d+/i.test(text)) return true;
if (/^第[一二三四五六七八九十\d]+[章节部分]/i.test(text)) return true;
if (/^[一二三四五六七八九十\d]+[、.:]/.test(text) && text.length <= 40) return true;
return text.length <= 24 && !/[。;,,;]/.test(text);
}
function isLegacyDocJunkLine(line) {
var text = String(line || '').trim();
if (!text) return true;
if (LEGACY_DOC_JUNK_EXACT[text]) return true;
if (/^Text\d+$/i.test(text)) return true;
if (/^P\s*\d+$/i.test(text)) return true;
if (/^[\d\s*桰漀]+$/.test(text)) return true;
if (text.length <= 28 && !/[\u4e00-\u9fff]/.test(text) && /^[\x00-\x7F\s]+$/.test(text)) return true;
if (OLE_METADATA_PATTERNS.some(function (pattern) { return pattern.test(text); })) return true;
return false;
}
function plainTextToHtml(text) {
var lines = String(text || '')
.replace(/\r/g, '\n')
.split('\n')
.map(function (line) { return line.trim(); })
.filter(function (line) { return line && !isLegacyDocJunkLine(line); });
if (!lines.length) return '';
return lines.map(function (line) {
if (looksLikeHeadingLine(line)) {
return '<p class="ct-word-section-title">' + escapeHtml(line) + '</p>';
}
return '<p class="ct-word-body">' + escapeHtml(line) + '</p>';
}).join('');
}
/** 从旧版 .doc 二进制中粗提取可读文本(原型层兜底) */
function extractTextFromLegacyDoc(arrayBuffer) {
var view = new Uint8Array(arrayBuffer);
var chunks = [];
var current = '';
function flush() {
var text = current.replace(/\s+/g, ' ').trim();
if (text.length >= 4 && !isLegacyDocJunkLine(text)) chunks.push(text);
current = '';
}
for (var i = 0; i < view.length - 1; i += 2) {
var code = view[i] | (view[i + 1] << 8);
if (
(code >= 0x20 && code <= 0x7e)
|| (code >= 0x4e00 && code <= 0x9fff)
|| code === 0x0a
|| code === 0x0d
|| code === 0x3001
|| code === 0x3002
|| code === 0xff1a
|| code === 0xff1b
) {
current += String.fromCharCode(code);
} else {
flush();
}
}
flush();
var merged = chunks.join('\n');
if (!merged.trim()) {
var latin = new TextDecoder('latin1').decode(view);
merged = latin.replace(/[^\x20-\x7E\u4e00-\u9fff\r\n]+/g, '\n');
}
return merged.replace(/\n{3,}/g, '\n\n').trim();
}
export async function convertDocxArrayBufferToHtml(arrayBuffer) {
if (detectWordFileFormat(arrayBuffer) !== 'docx') {
throw new Error('不是有效的 .docx 文件');
}
var themeCss = await extractDocxThemeStyles(arrayBuffer);
var result = await mammoth.convertToHtml(
{ arrayBuffer: arrayBuffer },
{
styleMap: MAMMOTH_STYLE_MAP,
includeDefaultStyleMap: true,
includeEmbeddedStyleMap: true,
ignoreEmptyParagraphs: false,
}
);
var html = String(result.value || '').trim();
if (!html || isCorruptWordImportHtml(html)) {
var messages = (result.messages || []).map(function (m) { return m.message; }).filter(Boolean);
throw new Error(messages[0] || 'Word 文件解析结果无效');
}
return prepareImportedWordHtml(wrapWordHtml(html), themeCss);
}
export async function convertDocxFileToHtml(file) {
if (!file) throw new Error('未选择文件');
var arrayBuffer = await file.arrayBuffer();
return convertDocxArrayBufferToHtml(arrayBuffer);
}
export async function convertLegacyDocFileToHtml(file) {
if (!file) throw new Error('未选择文件');
var arrayBuffer = await file.arrayBuffer();
if (detectWordFileFormat(arrayBuffer) !== 'doc') {
throw new Error('不是有效的 .doc 文件');
}
var text = extractTextFromLegacyDoc(arrayBuffer);
if (!text || !/[\u4e00-\u9fff]/.test(text)) {
throw new Error('未能从 .doc 文件中提取正文,请另存为 .docx 后重试');
}
var html = plainTextToHtml(text);
if (!html || isCorruptWordImportHtml(html)) {
throw new Error('未能从 .doc 文件中生成可用内容,请另存为 .docx 后重试');
}
return prepareImportedWordHtml(wrapWordHtml(html), '');
}
export async function convertWordFileToHtml(file) {
if (!file) throw new Error('未选择文件');
var arrayBuffer = await file.arrayBuffer();
var format = detectWordFileFormat(arrayBuffer);
var name = String(file.name || '').toLowerCase();
if (format === 'docx' || (/\.docx$/i.test(name) && format !== 'doc')) {
return convertDocxArrayBufferToHtml(arrayBuffer);
}
if (format === 'doc' || /\.doc$/i.test(name)) {
return convertLegacyDocFileToHtml(file);
}
throw new Error('无法识别 Word 文件格式,请使用 .doc 或 .docx');
}
export function detectWordFileFormatFromName(name, arrayBuffer) {
if (arrayBuffer) return detectWordFileFormat(arrayBuffer);
var lower = String(name || '').toLowerCase();
if (/\.docx$/i.test(lower)) return 'docx';
if (/\.doc$/i.test(lower)) return 'doc';
return 'unknown';
}
export function isDocxArrayBuffer(arrayBuffer) {
return detectWordFileFormat(arrayBuffer) === 'docx';
}

View File

@@ -0,0 +1,122 @@
/**
* @name 合同模板管理
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
import * as antd from 'antd';
import { ConfigProvider } from 'antd';
import {
type AnnotationDirectoryRouteNode,
type AnnotationSourceDocument,
type AnnotationViewerOptions
} from '@axhub/annotation';
import { PrototypeAnnotationHost } from '../../common/prototype-annotation-host';
import 'antd/dist/reset.css';
import '../vehicle-management-legacy/style.css';
import './styles/index.css';
import ContractTemplateApp from './ContractTemplate.jsx';
import annotationSourceDocument from './annotation-source.json';
type ContractPageId = 'list' | 'editor';
type ContractNavigationRef = {
openCreate?: () => void;
backToList?: () => void;
};
declare global {
interface Window {
React: typeof React;
antd: typeof antd;
}
}
window.React = React;
window.antd = antd;
/** 与 vehicle-management / Linear 主题一致 */
const vmTheme = {
token: {
colorPrimary: '#32a06e',
colorLink: '#32a06e',
colorLinkHover: '#3fb87c',
borderRadius: 8,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif',
fontSize: 14,
colorText: '#18181b',
colorTextSecondary: '#52525b',
colorBorder: '#e5e7eb',
colorBgContainer: '#ffffff',
},
components: {
Table: {
headerBg: '#f6f7f7',
headerColor: '#71717a',
rowHoverBg: '#f6f7f7',
borderColor: '#e5e7eb',
cellPaddingBlock: 8,
cellPaddingInline: 12,
},
Tabs: {
inkBarColor: '#32a06e',
itemActiveColor: '#32a06e',
itemSelectedColor: '#32a06e',
},
Card: {
borderRadiusLG: 12,
},
},
};
export function ContractTemplateManagementContent({ embedded = false }: { embedded?: boolean }) {
const [annotationPageId, setAnnotationPageId] = useState<ContractPageId>('list');
const navigationRef = useRef<ContractNavigationRef>({});
useEffect(() => {
if (!embedded) {
clearHostPrototypeRouteInfo();
}
}, [embedded]);
const handleDirectoryRoute = useCallback((node: AnnotationDirectoryRouteNode) => {
const payload = node.payload as { pageId?: string } | undefined;
const pageId: ContractPageId = payload?.pageId === 'editor' ? 'editor' : 'list';
if (pageId === 'editor') {
navigationRef.current.openCreate?.();
} else {
navigationRef.current.backToList?.();
}
setAnnotationPageId(pageId);
}, []);
const annotationOptions = useMemo<AnnotationViewerOptions>(
() => ({
showToolbar: true,
showThemeToggle: true,
showColorFilter: true,
emptyWhenNoData: false,
toolbarEdge: 'right',
currentPageId: annotationPageId,
onDirectoryRoute: handleDirectoryRoute,
}),
[annotationPageId, handleDirectoryRoute],
);
return (
<ConfigProvider theme={vmTheme}>
<ContractTemplateApp
navigationRef={navigationRef}
onPageViewChange={setAnnotationPageId}
/>
<PrototypeAnnotationHost
source={annotationSourceDocument as AnnotationSourceDocument}
options={annotationOptions}
/>
</ConfigProvider>
);
}
export default function ContractTemplateManagementPage() {
return <ContractTemplateManagementContent />;
}

Some files were not shown because too many files have changed in this diff Show More