feat: add approval center module with split layout and nav registration

This commit is contained in:
hsu06
2026-07-15 08:55:49 +08:00
parent aa6b9a7683
commit 6971529cd7
19 changed files with 3046 additions and 764 deletions

View File

@@ -0,0 +1,57 @@
import '../oneosWebLegacy/legacyGlobals';
import '../../prototypes/vehicle-management/style.css';import './styles/index.css';
import React from 'react';
import { ConfigProvider } from 'antd';
import type { ApprovalTabKey } from './types';
import { ApprovalCenterPage } from './pages/ApprovalCenterPage';
const approvalTheme = {
token: {
colorPrimary: '#165dff',
borderRadius: 8,
fontFamily:
'Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif',
colorBgLayout: '#f5f7fa',
},
components: {
Tabs: {
inkBarColor: '#165dff',
itemSelectedColor: '#165dff',
itemHoverColor: '#165dff',
},
Button: {
controlHeight: 32,
},
Select: {
controlHeight: 32,
},
Input: {
controlHeight: 32,
},
},
};
export interface ApprovalAppProps {
tabKey?: ApprovalTabKey;
pageTitle?: string;
showTabs?: boolean;
}
export function ApprovalApp({
tabKey,
pageTitle = '审批中心',
showTabs = false,
}: ApprovalAppProps) {
return (
<ConfigProvider theme={approvalTheme}>
<ApprovalCenterPage
tabKey={tabKey}
pageTitle={pageTitle}
showTabs={showTabs}
/>
</ConfigProvider>
);
}
export default ApprovalApp;

View File

@@ -0,0 +1,96 @@
import React from 'react';
import { RightOutlined } from '@ant-design/icons';
import type { ApprovalCardItem } from '../types';
import {
formatStatusLabel,
getApprovalTypeColor,
getApprovalTypeLabel,
} from '../config/approvalTypeConfig';
export interface ApprovalCardProps {
item: ApprovalCardItem;
selected?: boolean;
onClick?: (item: ApprovalCardItem) => void;
}
function statusClassName(status: ApprovalCardItem['status']): string {
if (status === 'approved') return 'ap-status ap-status--approved';
if (status === 'rejected') return 'ap-status ap-status--rejected';
return 'ap-status ap-status--pending';
}
export function ApprovalCard({ item, selected = false, onClick }: ApprovalCardProps) {
const typeColor = getApprovalTypeColor(item.type);
const statusLabel = formatStatusLabel(item);
return (
<article
className={`ap-card${selected ? ' ap-card--selected' : ''}`}
role="button"
tabIndex={0}
onClick={() => onClick?.(item)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick?.(item);
}
}}
>
<div className="ap-card__header">
<span
className="ap-card__type"
style={{ backgroundColor: `${typeColor}14`, color: typeColor, borderColor: `${typeColor}33` }}
>
{item.typeLabel ?? getApprovalTypeLabel(item.type)}
</span>
<span className={statusClassName(item.status)}>{statusLabel}</span>
</div>
<div className="ap-card__body">
<h3 className="ap-card__title">{item.title}</h3>
{item.subtitle ? <p className="ap-card__subtitle">{item.subtitle}</p> : null}
{item.keyFacts && item.keyFacts.length > 0 ? (
<dl className="ap-card__facts">
{item.keyFacts.map((fact) => (
<div key={`${item.id}-${fact.label}`} className="ap-card__fact">
<dt>{fact.label}</dt>
<dd className={fact.emphasis ? 'ap-card__fact-value--emphasis' : undefined}>
{fact.value}
</dd>
</div>
))}
</dl>
) : null}
{item.risks && item.risks.length > 0 ? (
<div className="ap-card__risks">
{item.risks.map((risk) => (
<span
key={`${item.id}-${risk.label}`}
className={`ap-card__risk ap-card__risk--${risk.level ?? 'default'}`}
>
{risk.label}
</span>
))}
</div>
) : null}
</div>
<div className="ap-card__footer">
<div className="ap-card__footer-meta">
{item.showInitiatorInFooter ? (
<span className="ap-card__footer-text"> {item.initiatedBy}</span>
) : null}
<span className="ap-card__footer-text">
{item.footerText ?? `发起时间 ${item.initiatedAt}`}
</span>
</div>
<span className="ap-card__action">
<RightOutlined />
</span>
</div>
</article>
);
}

View File

@@ -0,0 +1,47 @@
import React from 'react';
import { Empty, Spin } from 'antd';
import type { ApprovalCardItem } from '../types';
import { ApprovalCard } from './ApprovalCard';
export interface ApprovalCardListProps {
items: ApprovalCardItem[];
loading?: boolean;
selectedId?: string | null;
onItemClick?: (item: ApprovalCardItem) => void;
}
export function ApprovalCardList({
items,
loading = false,
selectedId,
onItemClick,
}: ApprovalCardListProps) {
if (loading) {
return (
<div className="ap-list ap-list--loading">
<Spin tip="加载中..." />
</div>
);
}
if (items.length === 0) {
return (
<div className="ap-list ap-list--empty">
<Empty description="暂无审批事项" />
</div>
);
}
return (
<div className="ap-list">
{items.map((item) => (
<ApprovalCard
key={item.id}
item={item}
selected={item.id === selectedId}
onClick={onItemClick}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,193 @@
import React, { useState } from 'react';
import {
Avatar,
Button,
Empty,
Space,
Tabs,
Timeline,
Typography,
} from 'antd';
import {
CheckCircleOutlined,
ClockCircleOutlined,
CopyOutlined,
ReloadOutlined,
UserOutlined,
} from '@ant-design/icons';
import type { ApprovalCardItem } from '../types';
import {
formatStatusLabel,
getApprovalTypeLabel,
} from '../config/approvalTypeConfig';
import { InsuranceComparisonDetail } from './InsuranceComparisonDetail';
const { Text, Title } = Typography;
export interface ApprovalDetailPlaceholderProps {
item: ApprovalCardItem | null;
onRefresh?: () => void;
}
function statusBadgeClass(status: ApprovalCardItem['status']): string {
if (status === 'approved') return 'ap-detail__badge ap-detail__badge--approved';
if (status === 'rejected') return 'ap-detail__badge ap-detail__badge--rejected';
return 'ap-detail__badge ap-detail__badge--pending';
}
const MOCK_TIMELINE = [
{ role: '发起人', name: '提交申请', time: '', color: 'blue' as const },
{ role: '部门主管', name: '审批通过', time: '2026-07-08 10:30', color: 'green' as const },
{ role: '财务审核', name: '审批中', time: '', color: 'gray' as const },
];
export function ApprovalDetailPlaceholder({ item, onRefresh }: ApprovalDetailPlaceholderProps) {
const [activeTab, setActiveTab] = useState('detail');
if (!item) {
return (
<div className="ap-detail ap-detail--empty">
<Empty description="请从左侧选择一条审批事项" />
</div>
);
}
if (item.typeLabel === '保险比价采购') {
return <InsuranceComparisonDetail item={item} onRefresh={onRefresh} />;
}
const statusLabel = formatStatusLabel(item);
const typeLabel = getApprovalTypeLabel(item.type);
return (
<div className="ap-detail">
<div className="ap-detail__toolbar">
<Space size={8}>
<Text type="secondary"></Text>
<Text copyable={{ icon: <CopyOutlined />, text: item.id }}>{item.id}</Text>
</Space>
<Button type="text" icon={<ReloadOutlined />} onClick={onRefresh}>
</Button>
</div>
<div className="ap-detail__header">
<div className="ap-detail__header-main">
<Title level={4} className="ap-detail__title">
{item.title}
</Title>
<span className={statusBadgeClass(item.status)}>{statusLabel}</span>
</div>
<div className="ap-detail__meta">
<Space size={16} wrap>
<Space size={6}>
<Avatar size={24} icon={<UserOutlined />} />
<Text>{item.initiatedBy}</Text>
</Space>
<Space size={6}>
<Text type="secondary"></Text>
<Text>{typeLabel}</Text>
</Space>
<Space size={6}>
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
<Text type="secondary"></Text>
<Text>{item.initiatedAt}</Text>
</Space>
</Space>
</div>
</div>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
className="ap-detail__tabs"
items={[
{
key: 'detail',
label: '审批详情',
children: (
<div className="ap-detail__content">
{item.subtitle ? (
<p className="ap-detail__subtitle">{item.subtitle}</p>
) : null}
{item.keyFacts && item.keyFacts.length > 0 ? (
<dl className="ap-detail__facts">
{item.keyFacts.map((fact) => (
<div key={fact.label} className="ap-detail__fact">
<dt>{fact.label}</dt>
<dd className={fact.emphasis ? 'ap-detail__fact-value--emphasis' : undefined}>
{fact.value}
</dd>
</div>
))}
</dl>
) : null}
{item.risks && item.risks.length > 0 ? (
<div className="ap-detail__risks">
{item.risks.map((risk) => (
<span
key={risk.label}
className={`ap-card__risk ap-card__risk--${risk.level ?? 'default'}`}
>
{risk.label}
</span>
))}
</div>
) : null}
<div className="ap-detail__timeline-section">
<Text strong className="ap-detail__section-title">
</Text>
<Timeline
className="ap-detail__timeline"
items={MOCK_TIMELINE.map((step, index) => ({
color: step.color,
dot:
step.color === 'green' ? (
<CheckCircleOutlined style={{ fontSize: 14 }} />
) : undefined,
children: (
<div className="ap-detail__timeline-item">
<div className="ap-detail__timeline-head">
<Text strong>
{step.role}
{step.name ? ` · ${step.name}` : ''}
</Text>
{step.time ? (
<Text type="secondary" className="ap-detail__timeline-time">
{step.time}
</Text>
) : null}
</div>
{index === 0 ? (
<Text type="secondary">{item.initiatedBy}</Text>
) : null}
</div>
),
}))}
/>
</div>
</div>
),
},
{
key: 'flow',
label: '审批流程图',
children: (
<div className="ap-detail__flow-placeholder">
<Empty description="流程图占位,后续接入 BPM 流程引擎" />
</div>
),
},
]}
/>
<div className="ap-detail__actions">
<Button></Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,266 @@
import React, { useState } from 'react';
import {
Avatar,
Button,
Empty,
Space,
Table,
Tabs,
Tag,
Typography,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ClockCircleOutlined,
CopyOutlined,
ReloadOutlined,
UserOutlined,
} from '@ant-design/icons';
import type { ApprovalCardItem } from '../types';
import { formatStatusLabel } from '../config/approvalTypeConfig';
const { Text, Title } = Typography;
export interface InsuranceComparisonDetailProps {
item: ApprovalCardItem;
onRefresh?: () => void;
}
function getFact(item: ApprovalCardItem, label: string): string {
return item.keyFacts?.find((fact) => fact.label === label)?.value ?? '-';
}
interface VehicleRow {
key: string;
plateNo: string;
vin: string;
insuranceType: string;
latestPaymentDate: string;
finalQuote: string;
status: string;
approver: string;
remark: string;
}
interface QuoteRow {
key: string;
company: string;
amount: string;
isFinal: boolean;
remark: string;
}
function buildComparisonNo(item: ApprovalCardItem): string {
const datePart = item.initiatedAt.replace(/[-: ]/g, '').slice(0, 8);
const seq = item.id.replace(/\D/g, '').padStart(4, '0');
return `BXBJ${datePart}${seq}`;
}
function buildSummaryRows(item: ApprovalCardItem, comparisonNo: string) {
const finalQuote = getFact(item, '最终报价').replace('¥', '');
const insuranceType = getFact(item, '采购险种');
const companyCount = getFact(item, '保险公司数量');
return [
{ label: '比价单号', value: comparisonNo },
{ label: '采购状态', value: <Tag color="processing"></Tag> },
{ label: '创建人', value: item.initiatedBy },
{ label: '创建时间', value: item.initiatedAt },
{ label: '车辆数', value: '1' },
{ label: '险种数', value: '1' },
{ label: '已确认报价数', value: '1' },
{ label: '确认报价总额', value: finalQuote },
{ label: '当前审批人', value: item.currentApprover ?? '-' },
{ label: '保险公司数量', value: companyCount },
{ label: '采购险种', value: insuranceType },
{ label: '中选保险公司', value: item.subtitle?.replace(/^中选:/, '') ?? '-' },
];
}
function buildVehicleRows(item: ApprovalCardItem): VehicleRow[] {
const finalQuote = getFact(item, '最终报价').replace('¥', '');
return [
{
key: '1',
plateNo: item.title,
vin: '-',
insuranceType: getFact(item, '采购险种'),
latestPaymentDate: getFact(item, '最晚付费日'),
finalQuote,
status: '审批中',
approver: item.currentApprover ?? '-',
remark: item.subtitle ?? '-',
},
];
}
function buildQuoteRows(item: ApprovalCardItem): QuoteRow[] {
const winner = item.subtitle?.replace(/^中选:/, '') ?? '国元农业保险上海分公司';
const finalAmount = getFact(item, '最终报价').replace('¥', '');
return [
{ key: '1', company: '国任财产保险股份有限公司广东分公司', amount: '111.00', isFinal: false, remark: '-' },
{ key: '2', company: winner, amount: finalAmount, isFinal: true, remark: '-' },
{ key: '3', company: '中国平安财产保险股份有限公司上海分公司', amount: '888.00', isFinal: false, remark: '-' },
];
}
export function InsuranceComparisonDetail({ item, onRefresh }: InsuranceComparisonDetailProps) {
const [activeTab, setActiveTab] = useState('detail');
const comparisonNo = buildComparisonNo(item);
const statusLabel = formatStatusLabel(item);
const typeLabel = item.typeLabel ?? '保险比价采购';
const summaryRows = buildSummaryRows(item, comparisonNo);
const vehicleRows = buildVehicleRows(item);
const quoteRows = buildQuoteRows(item);
const vehicleColumns: ColumnsType<VehicleRow> = [
{ title: '车牌号', dataIndex: 'plateNo', width: 110 },
{ title: 'VIN', dataIndex: 'vin', width: 120 },
{ title: '险种', dataIndex: 'insuranceType', width: 90 },
{ title: '最晚付费日', dataIndex: 'latestPaymentDate', width: 120 },
{ title: '最终报价', dataIndex: 'finalQuote', width: 100 },
{
title: '采购状态',
dataIndex: 'status',
width: 90,
render: (value: string) => <Tag color="processing">{value}</Tag>,
},
{ title: '当前审批人', dataIndex: 'approver', width: 120 },
{ title: '备注', dataIndex: 'remark', ellipsis: true },
];
const quoteColumns: ColumnsType<QuoteRow> = [
{ title: '保险公司', dataIndex: 'company', ellipsis: true },
{ title: '报价金额', dataIndex: 'amount', width: 110 },
{
title: '最终',
dataIndex: 'isFinal',
width: 72,
render: (isFinal: boolean) =>
isFinal ? <Tag color="success"></Tag> : <Text type="secondary">-</Text>,
},
{ title: '备注', dataIndex: 'remark', width: 80 },
];
return (
<div className="ap-detail ap-detail--insurance">
<div className="ap-detail__toolbar">
<Space size={8}>
<Text type="secondary"></Text>
<Text copyable={{ icon: <CopyOutlined />, text: comparisonNo }}>{comparisonNo}</Text>
</Space>
<Button type="text" icon={<ReloadOutlined />} onClick={onRefresh}>
</Button>
</div>
<div className="ap-detail__header">
<div className="ap-detail__header-main">
<Title level={4} className="ap-detail__title">
{typeLabel}-{comparisonNo}
</Title>
<span className="ap-detail__badge ap-detail__badge--pending">{statusLabel}</span>
</div>
<div className="ap-detail__meta">
<Space size={16} wrap>
<Space size={6}>
<Avatar size={24} icon={<UserOutlined />} />
<Text>{item.initiatedBy}</Text>
</Space>
<Space size={6}>
<Text type="secondary"></Text>
<Text>{typeLabel}</Text>
</Space>
<Space size={6}>
<ClockCircleOutlined style={{ color: '#8c8c8c' }} />
<Text type="secondary"></Text>
<Text>{item.initiatedAt}</Text>
</Space>
</Space>
</div>
</div>
<Tabs
activeKey={activeTab}
onChange={setActiveTab}
className="ap-detail__tabs"
items={[
{
key: 'detail',
label: '审批详情',
children: (
<div className="ap-detail__content">
<table className="ap-insurance-summary">
<tbody>
{Array.from({ length: Math.ceil(summaryRows.length / 2) }).map((_, rowIndex) => {
const left = summaryRows[rowIndex * 2];
const right = summaryRows[rowIndex * 2 + 1];
return (
<tr key={left.label}>
<th>{left.label}</th>
<td>{left.value}</td>
{right ? (
<>
<th>{right.label}</th>
<td>{right.value}</td>
</>
) : (
<>
<th />
<td />
</>
)}
</tr>
);
})}
</tbody>
</table>
<div className="ap-insurance-table-section">
<Text strong className="ap-detail__section-title">
</Text>
<Table
className="ap-insurance-table"
columns={vehicleColumns}
dataSource={vehicleRows}
pagination={false}
size="small"
expandable={{
expandedRowRender: () => (
<Table
className="ap-insurance-quote-table"
columns={quoteColumns}
dataSource={quoteRows}
pagination={false}
size="small"
/>
),
defaultExpandedRowKeys: ['1'],
}}
scroll={{ x: 900 }}
/>
</div>
</div>
),
},
{
key: 'flow',
label: '审批流程图',
children: (
<div className="ap-detail__flow-placeholder">
<Empty description="流程图占位,后续接入 BPM 流程引擎" />
</div>
),
},
]}
/>
<div className="ap-detail__actions">
<Button></Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import type { ApprovalCardItem, ApprovalStatus, ApprovalTypeKey, StatusFilterValue } from '../types';
export interface ApprovalTypeOption {
value: ApprovalTypeKey;
label: string;
shortLabel: string;
color: string;
}
export const APPROVAL_TYPE_CONFIG: Record<ApprovalTypeKey, ApprovalTypeOption> = {
parking: { value: 'parking', label: '停车场', shortLabel: '停车场', color: '#6366f1' },
vehicle_procurement: { value: 'vehicle_procurement', label: '车辆采购', shortLabel: '采购', color: '#8b5cf6' },
lease: { value: 'lease', label: '租赁合同', shortLabel: '租赁', color: '#2563eb' },
charter: { value: 'charter', label: '包车合同', shortLabel: '包车', color: '#0ea5e9' },
transfer: { value: 'transfer', label: '调拨', shortLabel: '调拨', color: '#14b8a6' },
vehicle_change: { value: 'vehicle_change', label: '异动', shortLabel: '异动', color: '#f59e0b' },
replacement: { value: 'replacement', label: '替换车', shortLabel: '替换', color: '#f97316' },
delivery: { value: 'delivery', label: '交车', shortLabel: '交车', color: '#10b981' },
return_settlement: { value: 'return_settlement', label: '还车应结款', shortLabel: '还车结款', color: '#ef4444' },
pickup_receivable: { value: 'pickup_receivable', label: '提车应收款', shortLabel: '提车应收', color: '#ec4899' },
billing: { value: 'billing', label: '租赁账单', shortLabel: '账单', color: '#a855f7' },
insurance: { value: 'insurance', label: '保险采购', shortLabel: '保险', color: '#22c55e' },
supplier: { value: 'supplier', label: '供应商准入', shortLabel: '供应商', color: '#64748b' },
customer_risk: { value: 'customer_risk', label: '客户风险标签', shortLabel: '风险标签', color: '#dc2626' },
third_party_return: { value: 'third_party_return', label: '三方退租', shortLabel: '退租', color: '#d97706' },
contract_template: { value: 'contract_template', label: '合同模板', shortLabel: '模板', color: '#7c3aed' },
annual_inspection: { value: 'annual_inspection', label: '年审', shortLabel: '年审', color: '#0891b2' },
finance_payment: { value: 'finance_payment', label: '付款申请', shortLabel: '付款', color: '#be185d' },
clearing: { value: 'clearing', label: '清分结算', shortLabel: '清分', color: '#4f46e5' },
energy_account: { value: 'energy_account', label: '能源账户', shortLabel: '能源', color: '#059669' },
};
export const APPROVAL_TYPE_OPTIONS = Object.values(APPROVAL_TYPE_CONFIG);
export const STATUS_OPTIONS: { value: StatusFilterValue; label: string }[] = [
{ value: 'all', label: '全部' },
{ value: 'pending', label: '审批中' },
{ value: 'approved', label: '已通过' },
{ value: 'rejected', label: '已驳回' },
];
const STATUS_LABEL_MAP: Record<ApprovalStatus, string> = {
pending: '审批中',
processing: '审批中',
approved: '已通过',
rejected: '已驳回',
};
export function getApprovalTypeLabel(type: ApprovalTypeKey): string {
return APPROVAL_TYPE_CONFIG[type]?.label ?? type;
}
export function getApprovalTypeColor(type: ApprovalTypeKey): string {
return APPROVAL_TYPE_CONFIG[type]?.color ?? '#64748b';
}
export function formatStatusLabel(item: Pick<ApprovalCardItem, 'status' | 'currentApprover'>): string {
const base = STATUS_LABEL_MAP[item.status] ?? item.status;
if ((item.status === 'pending' || item.status === 'processing') && item.currentApprover) {
return `审批中:${item.currentApprover}`;
}
return base;
}
export function matchesStatusFilter(status: ApprovalStatus, filter: StatusFilterValue): boolean {
if (filter === 'all') return true;
if (filter === 'pending') return status === 'pending' || status === 'processing';
if (filter === 'approved') return status === 'approved';
if (filter === 'rejected') return status === 'rejected';
return true;
}

View File

@@ -0,0 +1,50 @@
import type { ApprovalCardItem } from '../types';
import { CURRENT_USER } from '../types';
export const MOCK_APPROVAL_CASES: ApprovalCardItem[] = [
{
id: 'ac-22',
type: 'lease',
typeLabel: '合同审批',
status: 'rejected',
listTab: 'initiated',
title: '杭州迅达运输有限公司',
subtitle: '杭州城配项目',
keyFacts: [
{ label: '租赁车辆数', value: '5 辆' },
{ label: '租金及服务费合计', value: '192000.00 元' },
{ label: '保证金总额', value: '40000.00 元' },
{ label: '氢气预付款金额', value: '20000.00 元' },
],
showInitiatorInFooter: true,
initiatedBy: CURRENT_USER,
initiatedAt: '2026-06-10 14:00',
},
{
id: 'ac-23',
type: 'insurance',
typeLabel: '保险比价采购',
status: 'processing',
listTab: 'initiated',
title: '沪A51676F',
subtitle: '中选:国元农业保险上海分公司',
keyFacts: [
{ label: '采购险种', value: '商业险' },
{ label: '最终报价', value: '¥1000.00' },
{ label: '保险公司数量', value: '3家' },
{ label: '最晚付费日', value: '2026-08-09' },
],
currentApprover: '财务部-赵总',
showInitiatorInFooter: true,
initiatedBy: CURRENT_USER,
initiatedAt: '2026-06-10 14:00',
},
];
export function filterCasesByTab(
cases: ApprovalCardItem[],
tabKey?: string,
): ApprovalCardItem[] {
if (!tabKey) return cases;
return cases.filter((item) => item.listTab === tabKey);
}

View File

@@ -0,0 +1,156 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Input, Select, Tabs } from 'antd';
import { SearchOutlined } from '@ant-design/icons';
import type { ApprovalCardItem, ApprovalListTab, ApprovalStatus } from '../types';
import { ApprovalCardList } from '../components/ApprovalCardList';
import { ApprovalDetailPlaceholder } from '../components/ApprovalDetailPlaceholder';
import {
filterCasesByTab,
MOCK_APPROVAL_CASES,
} from '../data/mockApprovalCases';
import {
APPROVAL_TYPE_OPTIONS,
formatStatusLabel,
} from '../config/approvalTypeConfig';
const TAB_ITEMS: { key: ApprovalListTab; label: string }[] = [
{ key: 'todo', label: '我的待办' },
{ key: 'initiated', label: '我发起的' },
{ key: 'done', label: '我的已办' },
{ key: 'cc', label: '我的抄送' },
];
const STATUS_OPTIONS: { value: ApprovalStatus | 'all'; label: string }[] = [
{ value: 'all', label: '全部状态' },
{ value: 'pending', label: '审批中' },
{ value: 'approved', label: '已通过' },
{ value: 'rejected', label: '已驳回' },
];
export interface ApprovalCenterPageProps {
tabKey?: ApprovalListTab;
pageTitle?: string;
showTabs?: boolean;
}
export function ApprovalCenterPage({
tabKey = 'todo',
pageTitle,
showTabs = false,
}: ApprovalCenterPageProps) {
const [activeTab, setActiveTab] = useState<ApprovalListTab>(tabKey);
const [statusFilter, setStatusFilter] = useState<ApprovalStatus | 'all'>('all');
const [typeFilter, setTypeFilter] = useState<string>('all');
const [keyword, setKeyword] = useState('');
const [selectedId, setSelectedId] = useState<string | null>(null);
useEffect(() => {
setActiveTab(tabKey);
}, [tabKey]);
const tabItems = useMemo(() => {
let list = filterCasesByTab(MOCK_APPROVAL_CASES, activeTab);
if (statusFilter !== 'all') {
list = list.filter((item) => item.status === statusFilter);
}
if (typeFilter !== 'all') {
list = list.filter((item) => item.type === typeFilter);
}
if (keyword.trim()) {
const q = keyword.trim().toLowerCase();
list = list.filter(
(item) =>
item.title.toLowerCase().includes(q) ||
(item.subtitle?.toLowerCase().includes(q) ?? false) ||
item.initiatedBy.toLowerCase().includes(q) ||
formatStatusLabel(item).toLowerCase().includes(q),
);
}
return list;
}, [activeTab, statusFilter, typeFilter, keyword]);
useEffect(() => {
if (tabItems.length === 0) {
setSelectedId(null);
return;
}
if (!selectedId || !tabItems.some((item) => item.id === selectedId)) {
setSelectedId(tabItems[0].id);
}
}, [tabItems, selectedId]);
const selectedItem = useMemo(
() => tabItems.find((item) => item.id === selectedId) ?? null,
[tabItems, selectedId],
);
const handleItemClick = (item: ApprovalCardItem) => {
setSelectedId(item.id);
};
const currentTabMeta = TAB_ITEMS.find((t) => t.key === activeTab);
const displayTitle = pageTitle ?? currentTabMeta?.label ?? '审批中心';
return (
<div className="ap-page">
{showTabs ? (
<div className="ap-page__tabs-bar">
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as ApprovalListTab)}
items={TAB_ITEMS.map((t) => ({ key: t.key, label: t.label }))}
/>
</div>
) : null}
<div className="ap-page__shell">
<aside className="ap-page__sidebar">
<div className="ap-sidebar__head">
<h1 className="ap-sidebar__title">{displayTitle}</h1>
<p className="ap-sidebar__desc"></p>
</div>
<div className="ap-filter ap-filter--sidebar">
<Select
className="ap-filter__control"
value={statusFilter}
onChange={setStatusFilter}
options={STATUS_OPTIONS}
placeholder="审批状态"
/>
<Select
className="ap-filter__control"
value={typeFilter}
onChange={setTypeFilter}
options={[{ value: 'all', label: '全部类型' }, ...APPROVAL_TYPE_OPTIONS]}
placeholder="流程类型"
/>
<Input
className="ap-filter__search"
allowClear
prefix={<SearchOutlined />}
placeholder="关键词搜索"
value={keyword}
onChange={(e) => setKeyword(e.target.value)}
/>
</div>
<div className="ap-sidebar__list">
<ApprovalCardList
items={tabItems}
selectedId={selectedId}
onItemClick={handleItemClick}
/>
</div>
<div className="ap-sidebar__foot"> {tabItems.length} </div>
</aside>
<main className="ap-page__detail">
<ApprovalDetailPlaceholder item={selectedItem} />
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,603 @@
.ap-page {
--ap-primary: #165dff;
--ap-primary-soft: rgba(22, 93, 255, 0.08);
--ap-border: #e5e7eb;
--ap-surface: #ffffff;
--ap-muted: #64748b;
--ap-text: #0f172a;
--ap-radius: 12px;
--ap-shadow: 0 1px 2px rgba(15, 23, 42, 0.06), 0 4px 16px rgba(15, 23, 42, 0.04);
--ap-sidebar-width: 400px;
display: flex;
flex-direction: column;
height: 100vh;
padding: 0;
background: var(--vm-bg, #f5f7fa);
overflow: hidden;
}
.ap-page__tabs-bar {
flex-shrink: 0;
padding: 12px 20px 0;
background: var(--ap-surface);
border-bottom: 1px solid var(--ap-border);
}
.ap-page__tabs-bar .ant-tabs-nav {
margin-bottom: 0;
}
.ap-page__shell {
display: flex;
flex: 1;
min-height: 0;
gap: 0;
}
.ap-page__sidebar {
display: flex;
flex-direction: column;
flex-shrink: 0;
width: var(--ap-sidebar-width);
background: var(--ap-surface);
border-right: 1px solid var(--ap-border);
min-height: 0;
}
.ap-sidebar__head {
flex-shrink: 0;
padding: 20px 16px 12px;
border-bottom: 1px solid #f1f5f9;
}
.ap-sidebar__title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--ap-text);
line-height: 1.4;
}
.ap-sidebar__desc {
margin: 4px 0 0;
font-size: 12px;
color: var(--ap-muted);
line-height: 1.5;
}
.ap-filter {
margin-bottom: 0;
}
.ap-filter--sidebar {
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 16px;
border-bottom: 1px solid #f1f5f9;
}
.ap-filter__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.ap-filter__item {
display: flex;
flex-direction: column;
gap: 8px;
}
.ap-filter__label {
font-size: 13px;
color: var(--ap-muted);
font-weight: 500;
}
.ap-filter__control {
width: 100%;
}
.ap-filter__search {
width: 100%;
}
.ap-sidebar__list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px;
}
.ap-sidebar__foot {
flex-shrink: 0;
padding: 10px 16px;
border-top: 1px solid #f1f5f9;
font-size: 12px;
color: var(--ap-muted);
text-align: center;
background: #fafbfc;
}
.ap-page__detail {
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--ap-surface);
}
.ap-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.ap-list--loading,
.ap-list--empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
background: transparent;
border: 1px dashed var(--ap-border);
border-radius: var(--ap-radius);
}
.ap-card {
display: flex;
flex-direction: column;
background: var(--ap-surface);
border: 1px solid var(--ap-border);
border-radius: var(--ap-radius);
box-shadow: var(--ap-shadow);
cursor: pointer;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
overflow: hidden;
position: relative;
}
.ap-card:hover,
.ap-card:focus-visible {
border-color: rgba(22, 93, 255, 0.35);
box-shadow: 0 4px 16px rgba(22, 93, 255, 0.08);
outline: none;
}
.ap-card--selected {
border-color: rgba(22, 93, 255, 0.5);
background: var(--ap-primary-soft);
box-shadow: 0 4px 16px rgba(22, 93, 255, 0.1);
}
.ap-card--selected::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--ap-primary);
border-radius: var(--ap-radius) 0 0 var(--ap-radius);
}
.ap-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 0;
}
.ap-card__type {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
border: 1px solid transparent;
font-size: 12px;
font-weight: 600;
line-height: 20px;
}
.ap-status {
font-size: 12px;
font-weight: 600;
line-height: 20px;
}
.ap-status--pending {
color: #d97706;
}
.ap-status--approved {
color: #16a34a;
}
.ap-status--rejected {
color: #dc2626;
}
.ap-card__body {
flex: 1;
padding: 12px 16px 0;
}
.ap-card__title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--ap-text);
line-height: 1.45;
}
.ap-card__subtitle {
margin: 6px 0 0;
font-size: 13px;
color: var(--ap-muted);
line-height: 1.5;
}
.ap-card__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 12px;
margin: 14px 0 0;
padding: 12px;
border-radius: 10px;
background: #f8fafc;
}
.ap-card--selected .ap-card__facts {
background: rgba(255, 255, 255, 0.7);
}
.ap-card__fact {
min-width: 0;
}
.ap-card__fact dt {
margin: 0;
font-size: 12px;
color: var(--ap-muted);
line-height: 1.4;
}
.ap-card__fact dd {
margin: 4px 0 0;
font-size: 13px;
color: var(--ap-text);
font-weight: 500;
line-height: 1.45;
word-break: break-word;
}
.ap-card__fact-value--emphasis {
color: var(--ap-primary);
font-weight: 700;
}
.ap-card__risks {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.ap-card__risk {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 500;
background: #fff7ed;
color: #c2410c;
border: 1px solid #fed7aa;
}
.ap-card__risk--high {
background: #fef2f2;
color: #b91c1c;
border-color: #fecaca;
}
.ap-card__risk--medium {
background: #fffbeb;
color: #b45309;
border-color: #fde68a;
}
.ap-card__footer {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 12px;
margin-top: 10px;
padding: 8px 16px 10px;
border-top: 1px solid #f1f5f9;
}
.ap-card__footer-meta {
display: flex;
flex-direction: column;
gap: 2px;
}
.ap-card__footer-text {
font-size: 12px;
color: var(--ap-muted);
line-height: 1.4;
}
.ap-card__action {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
color: var(--ap-primary);
line-height: 1.4;
flex-shrink: 0;
}
/* Detail panel */
.ap-detail {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.ap-detail--empty {
align-items: center;
justify-content: center;
}
.ap-detail__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-shrink: 0;
padding: 12px 24px;
border-bottom: 1px solid #f1f5f9;
}
.ap-detail__header {
flex-shrink: 0;
padding: 20px 24px 0;
}
.ap-detail__header-main {
display: flex;
align-items: flex-start;
gap: 12px;
flex-wrap: wrap;
}
.ap-detail__title {
margin: 0 !important;
flex: 1;
min-width: 0;
}
.ap-detail__badge {
flex-shrink: 0;
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 20px;
}
.ap-detail__badge--pending {
background: #fff7ed;
color: #d97706;
}
.ap-detail__badge--approved {
background: #f0fdf4;
color: #16a34a;
}
.ap-detail__badge--rejected {
background: #fef2f2;
color: #dc2626;
}
.ap-detail__meta {
margin-top: 12px;
padding-bottom: 4px;
}
.ap-detail__tabs {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 0 24px;
}
.ap-detail__tabs .ant-tabs-content-holder {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.ap-detail__tabs .ant-tabs-content {
height: 100%;
}
.ap-detail__content {
padding: 8px 0 24px;
}
.ap-detail__subtitle {
margin: 0 0 16px;
color: var(--ap-muted);
font-size: 14px;
}
.ap-detail__facts {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
margin: 0 0 16px;
padding: 16px;
border-radius: 10px;
background: #f8fafc;
}
.ap-detail__fact dt {
margin: 0;
font-size: 12px;
color: var(--ap-muted);
}
.ap-detail__fact dd {
margin: 4px 0 0;
font-size: 14px;
color: var(--ap-text);
font-weight: 500;
}
.ap-detail__fact-value--emphasis {
color: var(--ap-primary);
font-weight: 700;
}
.ap-detail__risks {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 20px;
}
.ap-detail__timeline-section {
margin-top: 8px;
}
.ap-detail__section-title {
display: block;
margin-bottom: 16px;
}
.ap-detail__timeline {
margin-top: 8px;
}
.ap-detail__timeline-item {
padding-bottom: 4px;
}
.ap-detail__timeline-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.ap-detail__timeline-time {
font-size: 12px;
}
.ap-detail__flow-placeholder {
display: flex;
align-items: center;
justify-content: center;
min-height: 280px;
}
.ap-detail__actions {
flex-shrink: 0;
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 24px 20px;
border-top: 1px solid #f1f5f9;
background: var(--ap-surface);
}
.ap-insurance-summary {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
border: 1px solid var(--ap-border);
border-radius: 8px;
overflow: hidden;
font-size: 13px;
}
.ap-insurance-summary th,
.ap-insurance-summary td {
padding: 10px 14px;
border: 1px solid #f1f5f9;
vertical-align: middle;
}
.ap-insurance-summary th {
width: 14%;
background: #fafbfc;
color: var(--ap-muted);
font-weight: 500;
text-align: left;
}
.ap-insurance-summary td {
width: 36%;
color: var(--ap-text);
}
.ap-insurance-table-section {
margin-top: 8px;
}
.ap-insurance-table .ant-table,
.ap-insurance-quote-table .ant-table {
font-size: 13px;
}
.ap-insurance-quote-table {
margin: 0;
background: #fafbfc;
}
.ap-insurance-quote-table .ant-table-thead > tr > th {
background: #f1f5f9;
}
@media (max-width: 960px) {
.ap-page {
height: auto;
min-height: 100vh;
overflow: auto;
}
.ap-page__shell {
flex-direction: column;
}
.ap-page__sidebar {
width: 100%;
max-height: 50vh;
border-right: none;
border-bottom: 1px solid var(--ap-border);
}
.ap-page__detail {
min-height: 50vh;
}
.ap-filter__grid {
grid-template-columns: 1fr;
}
.ap-detail__facts {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,73 @@
export type ApprovalStatus = 'pending' | 'processing' | 'approved' | 'rejected';
export type ApprovalTabKey = 'todo' | 'done' | 'initiated' | 'cc';
export type ApprovalTypeKey =
| 'parking'
| 'vehicle_procurement'
| 'lease'
| 'charter'
| 'transfer'
| 'vehicle_change'
| 'replacement'
| 'delivery'
| 'return_settlement'
| 'pickup_receivable'
| 'billing'
| 'insurance'
| 'supplier'
| 'customer_risk'
| 'third_party_return'
| 'contract_template'
| 'annual_inspection'
| 'finance_payment'
| 'clearing'
| 'energy_account';
export type ApprovalListTab = ApprovalTabKey;
export interface ApprovalKeyFact {
label: string;
value: string;
emphasis?: boolean;
}
export interface ApprovalRisk {
label: string;
level?: 'high' | 'medium' | 'low';
}
export interface ApprovalCardItem {
id: string;
type: ApprovalTypeKey;
status: ApprovalStatus;
listTab: ApprovalListTab;
title: string;
subtitle?: string;
keyFacts?: ApprovalKeyFact[];
risks?: ApprovalRisk[];
currentApprover?: string;
initiatedBy: string;
initiatedAt: string;
typeLabel?: string;
footerText?: string;
showInitiatorInFooter?: boolean;
ccUsers?: string[];
handledBy?: string;
}
export const CURRENT_USER = '张明辉';
export type StatusFilterValue = 'all' | 'pending' | 'approved' | 'rejected';
export interface ApprovalFilters {
status: StatusFilterValue;
type: ApprovalTypeKey | 'all';
keyword: string;
}
export const EMPTY_APPROVAL_FILTERS: ApprovalFilters = {
status: 'all',
type: 'all',
keyword: '',
};

View File

@@ -11,6 +11,37 @@
"title": "工作台",
"itemKey": "prototypes/oneos-web-workbench"
},
{
"id": "folder-oneos-approval",
"kind": "folder",
"title": "审批中心",
"children": [
{
"id": "item:prototypes:oneos-web-approval-initiated",
"kind": "item",
"title": "我发起的",
"itemKey": "prototypes/oneos-web-approval-initiated"
},
{
"id": "item:prototypes:oneos-web-approval-todo",
"kind": "item",
"title": "我的待办",
"itemKey": "prototypes/oneos-web-approval-todo"
},
{
"id": "item:prototypes:oneos-web-approval-done",
"kind": "item",
"title": "我的已办",
"itemKey": "prototypes/oneos-web-approval-done"
},
{
"id": "item:prototypes:oneos-web-approval-cc",
"kind": "item",
"title": "我的抄送",
"itemKey": "prototypes/oneos-web-approval-cc"
}
]
},
{
"id": "folder-1782874599304-roj8fd",
"kind": "folder",
@@ -78,6 +109,12 @@
"kind": "item",
"title": "站点信息",
"itemKey": "prototypes/oneos-web-h2-station-site"
},
{
"id": "item:prototypes:oneos-web-h2-station-weekly",
"kind": "item",
"title": "站点周报统计",
"itemKey": "prototypes/oneos-web-h2-station-weekly"
}
]
},
@@ -161,6 +198,25 @@
"itemKey": "prototypes/vehicle-return-settlement-v2"
}
]
},
{
"id": "folder-1783875880945-4r6tq1",
"kind": "folder",
"title": "任务工单",
"children": [
{
"id": "item:prototypes:task-work-order",
"kind": "item",
"title": "任务工单",
"itemKey": "prototypes/task-work-order"
}
]
},
{
"id": "item:prototypes:lease-business-line-overview",
"kind": "item",
"title": "业务条线说明",
"itemKey": "prototypes/lease-business-line-overview"
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,8 @@
/**
* @name 我的抄送
*/
import { ApprovalApp } from '../../common/oneos-web-approval/ApprovalApp';
export default function OneosWebApprovalCc() {
return <ApprovalApp tabKey="cc" pageTitle="我的抄送" />;
}

View File

@@ -0,0 +1,8 @@
/**
* @name 我的已办
*/
import { ApprovalApp } from '../../common/oneos-web-approval/ApprovalApp';
export default function OneosWebApprovalDone() {
return <ApprovalApp tabKey="done" pageTitle="我的已办" />;
}

View File

@@ -0,0 +1,8 @@
/**
* @name 我发起的
*/
import { ApprovalApp } from '../../common/oneos-web-approval/ApprovalApp';
export default function OneosWebApprovalInitiated() {
return <ApprovalApp tabKey="initiated" pageTitle="我发起的" />;
}

View File

@@ -0,0 +1,8 @@
/**
* @name 我的待办
*/
import { ApprovalApp } from '../../common/oneos-web-approval/ApprovalApp';
export default function OneosWebApprovalTodo() {
return <ApprovalApp tabKey="todo" pageTitle="我的待办" />;
}

View File

@@ -0,0 +1,8 @@
/**
* @name 审批中心
*/
import { ApprovalApp } from '../../common/oneos-web-approval/ApprovalApp';
export default function OneosWebApproval() {
return <ApprovalApp pageTitle="审批中心" showTabs />;
}