新增 AutoRDO 需求清洗工作台与消息中枢,迭代 OneOS V2 设计规范及租赁合同/工作台/车辆等原型,同步云效技能与导航注册;并归档一批 legacy 原型快照。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
221
src/common/ds-theme-lab/ThemeSwitcher.tsx
Normal file
221
src/common/ds-theme-lab/ThemeSwitcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
src/common/ds-theme-lab/index.ts
Normal file
16
src/common/ds-theme-lab/index.ts
Normal 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';
|
||||
264
src/common/ds-theme-lab/theme-switcher.css
Normal file
264
src/common/ds-theme-lab/theme-switcher.css
Normal 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;
|
||||
}
|
||||
}
|
||||
544
src/common/ds-theme-lab/themeLab.ts
Normal file
544
src/common/ds-theme-lab/themeLab.ts
Normal 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();
|
||||
}
|
||||
6
src/common/message-hub/index.ts
Normal file
6
src/common/message-hub/index.ts
Normal 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';
|
||||
97
src/common/message-hub/resolve.test.ts
Normal file
97
src/common/message-hub/resolve.test.ts
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
52
src/common/message-hub/resolve.ts
Normal file
52
src/common/message-hub/resolve.ts
Normal 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: '当前端暂无可跳转目标' };
|
||||
}
|
||||
169
src/common/message-hub/routes.ts
Normal file
169
src/common/message-hub/routes.ts
Normal 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'),
|
||||
];
|
||||
303
src/common/message-hub/seed.ts
Normal file
303
src/common/message-hub/seed.ts
Normal 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 },
|
||||
),
|
||||
];
|
||||
13
src/common/message-hub/shell-adapter.test.ts
Normal file
13
src/common/message-hub/shell-adapter.test.ts
Normal 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('交车任务');
|
||||
});
|
||||
});
|
||||
30
src/common/message-hub/shell-adapter.ts
Normal file
30
src/common/message-hub/shell-adapter.ts
Normal 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);
|
||||
}
|
||||
143
src/common/message-hub/store.test.ts
Normal file
143
src/common/message-hub/store.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
100
src/common/message-hub/store.ts
Normal file
100
src/common/message-hub/store.ts
Normal 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));
|
||||
}
|
||||
56
src/common/message-hub/types.ts
Normal file
56
src/common/message-hub/types.ts
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
351
src/common/oneos-app-shell/RuoYiSettingsDrawer.tsx
Normal file
351
src/common/oneos-app-shell/RuoYiSettingsDrawer.tsx
Normal 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>自定义偏好设置 & 实时预览</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} />
|
||||
清空缓存 & 还原配置
|
||||
</button>
|
||||
</footer>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
173
src/common/oneos-v2-form/DatePicker.tsx
Normal file
173
src/common/oneos-v2-form/DatePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
src/common/oneos-v2-form/DateRangePicker.tsx
Normal file
180
src/common/oneos-v2-form/DateRangePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
src/common/oneos-v2-form/Field.tsx
Normal file
38
src/common/oneos-v2-form/Field.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/common/oneos-v2-form/Input.tsx
Normal file
62
src/common/oneos-v2-form/Input.tsx
Normal 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>
|
||||
);
|
||||
});
|
||||
252
src/common/oneos-v2-form/MultiSelect.tsx
Normal file
252
src/common/oneos-v2-form/MultiSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
37
src/common/oneos-v2-form/README.md
Normal file
37
src/common/oneos-v2-form/README.md
Normal 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`
|
||||
219
src/common/oneos-v2-form/Select.tsx
Normal file
219
src/common/oneos-v2-form/Select.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
250
src/common/oneos-v2-form/TimePicker.tsx
Normal file
250
src/common/oneos-v2-form/TimePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
src/common/oneos-v2-form/calendar/CalendarPanel.tsx
Normal file
219
src/common/oneos-v2-form/calendar/CalendarPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
src/common/oneos-v2-form/calendar/dateUtils.ts
Normal file
145
src/common/oneos-v2-form/calendar/dateUtils.ts
Normal 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 };
|
||||
}
|
||||
45
src/common/oneos-v2-form/hooks/useDismissOnFocusOutside.ts
Normal file
45
src/common/oneos-v2-form/hooks/useDismissOnFocusOutside.ts
Normal 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]);
|
||||
}
|
||||
28
src/common/oneos-v2-form/index.ts
Normal file
28
src/common/oneos-v2-form/index.ts
Normal 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';
|
||||
549
src/common/oneos-v2-form/oneos-v2-form.css
Normal file
549
src/common/oneos-v2-form/oneos-v2-form.css
Normal 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;
|
||||
}
|
||||
28
src/common/oneos-v2-form/types.ts
Normal file
28
src/common/oneos-v2-form/types.ts
Normal 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=筛选 32px,md=表单 36px(默认) */
|
||||
size?: O2ControlSize;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
};
|
||||
|
||||
export type DateRangeValue = {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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 `|` 分隔操作链接
|
||||
|
||||
@@ -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',
|
||||
|
||||
6
src/common/vehicle-fault/index.ts
Normal file
6
src/common/vehicle-fault/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './types';
|
||||
export * from './sla';
|
||||
export * from './notify';
|
||||
export * from './seed';
|
||||
export * from './storage';
|
||||
export * from './stats';
|
||||
88
src/common/vehicle-fault/notify.ts
Normal file
88
src/common/vehicle-fault/notify.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
}
|
||||
225
src/common/vehicle-fault/seed.ts
Normal file
225
src/common/vehicle-fault/seed.ts
Normal 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',
|
||||
},
|
||||
];
|
||||
}
|
||||
68
src/common/vehicle-fault/sla.ts
Normal file
68
src/common/vehicle-fault/sla.ts
Normal 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;
|
||||
}
|
||||
|
||||
/** 未归档且剩余天数 0~7(含) */
|
||||
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';
|
||||
}
|
||||
52
src/common/vehicle-fault/stats.ts
Normal file
52
src/common/vehicle-fault/stats.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
58
src/common/vehicle-fault/storage.ts
Normal file
58
src/common/vehicle-fault/storage.ts
Normal 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;
|
||||
}
|
||||
83
src/common/vehicle-fault/types.ts
Normal file
83
src/common/vehicle-fault/types.ts
Normal 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 = '刘洋';
|
||||
118
src/common/vehicle-purchase/acceptance.ts
Normal file
118
src/common/vehicle-purchase/acceptance.ts
Normal 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');
|
||||
}
|
||||
21
src/common/vehicle-purchase/approvalRules.ts
Normal file
21
src/common/vehicle-purchase/approvalRules.ts
Normal 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 ['部门负责人', '分管领导'];
|
||||
}
|
||||
7
src/common/vehicle-purchase/index.ts
Normal file
7
src/common/vehicle-purchase/index.ts
Normal 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';
|
||||
441
src/common/vehicle-purchase/seed.ts
Normal file
441
src/common/vehicle-purchase/seed.ts
Normal 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 };
|
||||
53
src/common/vehicle-purchase/stockBridge.ts
Normal file
53
src/common/vehicle-purchase/stockBridge.ts
Normal 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);
|
||||
}
|
||||
122
src/common/vehicle-purchase/storage.ts
Normal file
122
src/common/vehicle-purchase/storage.ts
Normal 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);
|
||||
}
|
||||
188
src/common/vehicle-purchase/types.ts
Normal file
188
src/common/vehicle-purchase/types.ts
Normal 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;
|
||||
}
|
||||
34
src/common/vehicle-purchase/vin.ts
Normal file
34
src/common/vehicle-purchase/vin.ts
Normal 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];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
152
src/prototypes/autorodo-web/.spec/confirm-flow.md
Normal file
152
src/prototypes/autorodo-web/.spec/confirm-flow.md
Normal 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 | `@姓名`(2–4 汉字) |
|
||||
| 提交人 | 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。
|
||||
245
src/prototypes/autorodo-web/.spec/requirements-prd.md
Normal file
245
src/prototypes/autorodo-web/.spec/requirements-prd.md
Normal 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 |
|
||||
|
||||
**外部依赖(产品口径)**
|
||||
|
||||
| 依赖方 | 交互方式 | 产品要求 |
|
||||
|--------|----------|----------|
|
||||
| AutoRDO(Cursor Skill) | 用户粘贴清洗提示词后执行 | 产出含标题、描述、「待确认」的 Markdown 初稿 |
|
||||
| YunxiaoPMapp(Cursor 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 落盘。)
|
||||
81
src/prototypes/autorodo-web/AutorodoWebApp.tsx
Normal file
81
src/prototypes/autorodo-web/AutorodoWebApp.tsx
Normal 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;
|
||||
197
src/prototypes/autorodo-web/annotation-source.json
Normal file
197
src/prototypes/autorodo-web/annotation-source.json
Normal file
File diff suppressed because one or more lines are too long
405
src/prototypes/autorodo-web/components/StepConfirm.tsx
Normal file
405
src/prototypes/autorodo-web/components/StepConfirm.tsx
Normal 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;
|
||||
185
src/prototypes/autorodo-web/components/StepExport.tsx
Normal file
185
src/prototypes/autorodo-web/components/StepExport.tsx
Normal 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;
|
||||
174
src/prototypes/autorodo-web/components/StepPaste.tsx
Normal file
174
src/prototypes/autorodo-web/components/StepPaste.tsx
Normal 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;
|
||||
37
src/prototypes/autorodo-web/index.tsx
Normal file
37
src/prototypes/autorodo-web/index.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
12
src/prototypes/autorodo-web/lib/agentAdapter.ts
Normal file
12
src/prototypes/autorodo-web/lib/agentAdapter.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
69
src/prototypes/autorodo-web/lib/applyAnswer.test.ts
Normal file
69
src/prototypes/autorodo-web/lib/applyAnswer.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
40
src/prototypes/autorodo-web/lib/applyAnswer.ts
Normal file
40
src/prototypes/autorodo-web/lib/applyAnswer.ts
Normal 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 };
|
||||
}
|
||||
18
src/prototypes/autorodo-web/lib/buildCleanPrompt.ts
Normal file
18
src/prototypes/autorodo-web/lib/buildCleanPrompt.ts
Normal 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');
|
||||
}
|
||||
37
src/prototypes/autorodo-web/lib/buildRecordPrompt.test.ts
Normal file
37
src/prototypes/autorodo-web/lib/buildRecordPrompt.test.ts
Normal 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(/提交人/);
|
||||
});
|
||||
});
|
||||
26
src/prototypes/autorodo-web/lib/buildRecordPrompt.ts
Normal file
26
src/prototypes/autorodo-web/lib/buildRecordPrompt.ts
Normal 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');
|
||||
}
|
||||
67
src/prototypes/autorodo-web/lib/clipboard.ts
Normal file
67
src/prototypes/autorodo-web/lib/clipboard.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
78
src/prototypes/autorodo-web/lib/inferMeta.test.ts
Normal file
78
src/prototypes/autorodo-web/lib/inferMeta.test.ts
Normal 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('张三');
|
||||
});
|
||||
});
|
||||
135
src/prototypes/autorodo-web/lib/inferMeta.ts
Normal file
135
src/prototypes/autorodo-web/lib/inferMeta.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
96
src/prototypes/autorodo-web/lib/parseDraft.test.ts
Normal file
96
src/prototypes/autorodo-web/lib/parseDraft.test.ts
Normal 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('紧急');
|
||||
});
|
||||
});
|
||||
101
src/prototypes/autorodo-web/lib/parseDraft.ts
Normal file
101
src/prototypes/autorodo-web/lib/parseDraft.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
30
src/prototypes/autorodo-web/lib/pendingQuestions.ts
Normal file
30
src/prototypes/autorodo-web/lib/pendingQuestions.ts
Normal 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 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
29
src/prototypes/autorodo-web/lib/types.ts
Normal file
29
src/prototypes/autorodo-web/lib/types.ts
Normal 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;
|
||||
836
src/prototypes/autorodo-web/style.css
Normal file
836
src/prototypes/autorodo-web/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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` | 渲染「业务名 ← 来源」 |
|
||||
8
src/prototypes/business-dept-ledger-legacy/LEGACY.md
Normal file
8
src/prototypes/business-dept-ledger-legacy/LEGACY.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# 【旧版】项目盈亏情况
|
||||
|
||||
本目录为设计规范 v2 套用前的**冻结对照副本**(2026-07-22)。
|
||||
|
||||
- 源原型:`src/prototypes/business-dept-ledger/`
|
||||
- 请勿在此目录继续改业务;正式改造只在源目录进行
|
||||
- 已跳过复制:`.spec/acp`、`.spec/prototype-comment-assets`(批注大图/会话)
|
||||
- 交叉引用已尽量改写为同套 `*-legacy`,避免引用被改版后的正式样式
|
||||
@@ -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": "主表 · 业务条线数据来源"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
10052
src/prototypes/business-dept-ledger-legacy/data/excel-2026-seed.json
Normal file
10052
src/prototypes/business-dept-ledger-legacy/data/excel-2026-seed.json
Normal file
File diff suppressed because it is too large
Load Diff
31
src/prototypes/business-dept-ledger-legacy/index.tsx
Normal file
31
src/prototypes/business-dept-ledger-legacy/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1181
src/prototypes/business-dept-ledger-legacy/styles/index.css
Normal file
1181
src/prototypes/business-dept-ledger-legacy/styles/index.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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` 且阻止离开编辑页 |
|
||||
@@ -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` 72–77 行);`LIST_TABLE_SCROLL_X` 合计约 1332px(含操作列 300px,`ContractTemplate.jsx`)。右侧还有 `@axhub/annotation` 工具栏占用宽度。
|
||||
- 影响:13 寸笔记本或分屏预览时,用户需频繁横向拖动才能看到「操作」列;筛选结果核对与批量处理效率下降。
|
||||
- 修复方向:≥1280px 保持全列;`<1200px` 将「启用状态」与次要时间列折叠为 expandable 摘要,或操作列收进「更多」菜单;与 Linear inverse 表格「密度可伸缩」原则对齐。
|
||||
|
||||
### P2 - 操作列 4~5 个并列链接,视觉密度与扫读成本高
|
||||
|
||||
- 证据:已发布行同时展示「停用/启用」「版本日志」「编辑」「删除」(`getListColumns` 操作列宽 300px);多行换行时(`Space wrap`)行高不齐。
|
||||
- 影响:法务需逐行扫描多个同色链接,难以快速区分主操作与危险操作;与 DESIGN 中 button-primary / button-secondary 层级未充分映射到表格场景。
|
||||
- 修复方向:主操作保留 1~2 个(如「编辑」);其余收入「更多」`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 报告 11–20px 多档字号、比例约 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
@@ -0,0 +1,8 @@
|
||||
# 【旧版】合同模板管理
|
||||
|
||||
本目录为设计规范 v2 套用前的**冻结对照副本**(2026-07-22)。
|
||||
|
||||
- 源原型:`src/prototypes/contract-template-management/`
|
||||
- 请勿在此目录继续改业务;正式改造只在源目录进行
|
||||
- 已跳过复制:`.spec/acp`、`.spec/prototype-comment-assets`(批注大图/会话)
|
||||
- 交叉引用已尽量改写为同套 `*-legacy`,避免引用被改版后的正式样式
|
||||
@@ -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
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"type": "excalidraw",
|
||||
"version": 2,
|
||||
"source": "axhub-make",
|
||||
"elements": [],
|
||||
"appState": {
|
||||
"gridSize": 20,
|
||||
"viewBackgroundColor": "#ffffff"
|
||||
},
|
||||
"files": {}
|
||||
}
|
||||
75
src/prototypes/contract-template-management-legacy/contract-template-catalog.js
vendored
Normal file
75
src/prototypes/contract-template-management-legacy/contract-template-catalog.js
vendored
Normal 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 || '—';
|
||||
}
|
||||
32
src/prototypes/contract-template-management-legacy/contract-template-docx-fidelity.js
vendored
Normal file
32
src/prototypes/contract-template-management-legacy/contract-template-docx-fidelity.js
vendored
Normal 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 || {})
|
||||
);
|
||||
}
|
||||
116
src/prototypes/contract-template-management-legacy/contract-template-section-filter.js
vendored
Normal file
116
src/prototypes/contract-template-management-legacy/contract-template-section-filter.js
vendored
Normal 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('');
|
||||
}
|
||||
173
src/prototypes/contract-template-management-legacy/contract-template-section-headings.js
vendored
Normal file
173
src/prototypes/contract-template-management-legacy/contract-template-section-headings.js
vendored
Normal 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;
|
||||
}
|
||||
102
src/prototypes/contract-template-management-legacy/contract-template-section-html.js
vendored
Normal file
102
src/prototypes/contract-template-management-legacy/contract-template-section-html.js
vendored
Normal 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;
|
||||
}
|
||||
170
src/prototypes/contract-template-management-legacy/contract-template-seed.js
vendored
Normal file
170
src/prototypes/contract-template-management-legacy/contract-template-seed.js
vendored
Normal 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吨车型试用协议;含完整安全附件',
|
||||
}),
|
||||
];
|
||||
}
|
||||
218
src/prototypes/contract-template-management-legacy/contract-template-store.js
vendored
Normal file
218
src/prototypes/contract-template-management-legacy/contract-template-store.js
vendored
Normal file
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* 合同模板跨原型共享状态(合同模板管理 ↔ 租赁合同创建)
|
||||
* 原型级 store;localStorage 优先持久化,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;
|
||||
}
|
||||
78
src/prototypes/contract-template-management-legacy/contract-template-types.js
vendored
Normal file
78
src/prototypes/contract-template-management-legacy/contract-template-types.js
vendored
Normal 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 };
|
||||
});
|
||||
}
|
||||
374
src/prototypes/contract-template-management-legacy/contract-template-vars.js
vendored
Normal file
374
src/prototypes/contract-template-management-legacy/contract-template-vars.js
vendored
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 客户表证照预览图(原型用 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 || '',
|
||||
};
|
||||
}
|
||||
341
src/prototypes/contract-template-management-legacy/contract-template-word-import.js
vendored
Normal file
341
src/prototypes/contract-template-management-legacy/contract-template-word-import.js
vendored
Normal 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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
122
src/prototypes/contract-template-management-legacy/index.tsx
Normal file
122
src/prototypes/contract-template-management-legacy/index.tsx
Normal 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
Reference in New Issue
Block a user