2025-06-27 19:56:50 +08:00

1202 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, useMemo } from 'react';
import { bitable, FieldType, IDateTimeFieldMeta, IDateTimeField, ITable, ITextField, ISelectionField, INumberField, IOpenSegment, IRecord } from '@lark-base-open/js-sdk';
import { Steps, Button, Typography, Form, Toast, Spin, Empty } from '@douyinfe/semi-ui';
import './App.css';
// 步骤数据结构定义
interface StepItem {
name: string;
fieldId: string; // 原始的fieldId代表流程名称本身
finished: boolean;
parallelSteps?: StepItem[]; // 并行子步骤数组
requireAllSubSteps?: boolean; // 是否所有子步骤都完成才标记主步骤完成
expectTimeFieldId?: string;
expectTimeValue?: number;
actualTimeFieldId?: string;
actualTimeValue?: number;
startTimeFieldId?: string;
startTimeValue?: number;
processNodeValue?: string;
order?: number; // 流程顺序,相同顺序的步骤视为并行
parallelGroup?: number; // 并行组标识用于UI分组显示
isMainStep?: boolean; // 是否为主步骤(整数顺序)
}
// 流程配置项结构
interface ProcessConfigItem {
processName: string;
order: number;
processNodeValue: string;
timeFieldId: string;
originalRecordId: string;
posLink: string;
}
// 常量定义
const POS_TABLE_NAME = "pos_订单基础信息明细"; // 替换为你的实际POS表名
const PROCESS_TABLE_NAME = "订单流程表"; // 替换为你的实际流程配置表名
let POS_TABLE_ID = "";
let PROCESS_TABLE_ID = "";
async function initTableIds() {
const posTable = await bitable.base.getTableByName(POS_TABLE_NAME);
POS_TABLE_ID = posTable.id;
const processTable = await bitable.base.getTableByName(PROCESS_TABLE_NAME);
PROCESS_TABLE_ID = processTable.id;
}
// 在应用初始化或需要用到表ID前调用
initTableIds();
const PROCESS_NAME_FIELD_NAME = '流程名称';
const PROCESS_ORDER_FIELD_NAME = '流程顺序';
const POS_LINK_FIELD_NAME_IN_PROCESS_TABLE = 'POS表recordid';
const PROCESS_NODE_FIELD_NAME = '流程节点';
const FIELD_ID_FIELD_NAME = '字段ID';
const PROCESS_NODE_EXPECT_TIME = '预计完成时间';
const PROCESS_NODE_ACTUAL_TIME = '实际完成时间';
const PROCESS_NODE_START_TIME = '实际开始时间';
// 格式化时间戳为可读格式
const formatTimestamp = (timestamp: number | undefined): string => {
if (timestamp === undefined || timestamp === null) return '-';
return new Date(timestamp).toLocaleString('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
};
// 步骤管理服务类
class StepService {
// 保存步骤完成时间
static async saveStepCompletionTime(
posTable: ITable,
selectedRecordId: string,
stepItem: StepItem,
isSubStep: boolean = false
): Promise<number | null> {
if (!selectedRecordId || !stepItem.actualTimeFieldId) return null;
try {
const timeField = await posTable.getFieldById<IDateTimeField>(stepItem.actualTimeFieldId);
const now = Date.now();
await timeField.setValue(selectedRecordId, now);
stepItem.actualTimeValue = now;
stepItem.finished = true;
console.log(`${isSubStep ? '子步骤' : '步骤'} "${stepItem.name}" 完成时间已保存到字段 ${stepItem.actualTimeFieldId}`);
return now;
} catch (e) {
console.error(`Error saving ${isSubStep ? 'sub-step' : 'step'} completion time:`, e);
throw e;
}
}
// 更新后续步骤的开始时间
static async updateSubsequentStepsStartTimes(
posTable: ITable,
selectedRecordId: string,
steps: StepItem[],
stepIndex: number,
actualTimeValue: number
) {
if (!selectedRecordId) return;
try {
// 从当前步骤的下一个步骤开始更新
for (let i = stepIndex + 1; i < steps.length; i++) {
const nextStep = steps[i];
// 如果下一步骤已有开始时间,且早于当前完成时间,则不更新
if (nextStep.startTimeValue && nextStep.startTimeValue <= actualTimeValue) {
continue;
}
// 如果下一步骤有开始时间字段ID更新该字段
if (nextStep.startTimeFieldId) {
const startTimeField = await posTable.getFieldById<IDateTimeField>(nextStep.startTimeFieldId);
await startTimeField.setValue(selectedRecordId, actualTimeValue);
nextStep.startTimeValue = actualTimeValue;
console.log(`Updated step ${i} (${nextStep.name}) start time to ${new Date(actualTimeValue).toLocaleString()}`);
}
// 如果下一步骤有并行子步骤,也更新它们的开始时间
if (nextStep.parallelSteps) {
for (const subStep of nextStep.parallelSteps) {
if (subStep.startTimeFieldId) {
const subStartTimeField = await posTable.getFieldById<IDateTimeField>(subStep.startTimeFieldId);
await subStartTimeField.setValue(selectedRecordId, actualTimeValue);
subStep.startTimeValue = actualTimeValue;
console.log(`Updated sub-step ${subStep.name} start time to ${new Date(actualTimeValue).toLocaleString()}`);
}
}
}
}
} catch (e) {
console.error("Error updating subsequent steps start times:", e);
Toast.warning("更新后续步骤开始时间失败");
}
}
// 处理子步骤完成的逻辑
static async handleSubStepCompletion(
newSteps: StepItem[],
stepIndex: number,
subStepIndex: number,
posTable: ITable,
selectedRecordId: string,
refreshSteps: (value: boolean) => void
) {
const parentStep = newSteps[stepIndex];
const subStep = parentStep.parallelSteps![subStepIndex];
// 如果父步骤要求所有子步骤完成才标记为完成
if (parentStep.requireAllSubSteps && parentStep.parallelSteps?.every(ps => ps.finished)) {
parentStep.finished = true;
// 如果父步骤也有完成时间字段,记录完成时间
if (parentStep.actualTimeFieldId) {
try {
const completionTime = await StepService.saveStepCompletionTime(posTable, selectedRecordId, parentStep);
if (completionTime) {
// 更新父步骤后的步骤开始时间
await StepService.updateSubsequentStepsStartTimes(
posTable, selectedRecordId, newSteps, stepIndex, completionTime
);
// 强制刷新步骤数据
refreshSteps(true);
}
} catch (e) {
console.error("Error saving parent step completion time:", e);
}
} else {
// 如果没有完成时间字段,也需要更新后续步骤
refreshSteps(true);
}
} else if (!parentStep.requireAllSubSteps && parentStep.parallelSteps?.some(ps => ps.finished)) {
// 任一子步骤完成即可推进主步骤
parentStep.finished = true;
// 如果父步骤有完成时间字段,记录完成时间
if (parentStep.actualTimeFieldId) {
try {
const completionTime = await StepService.saveStepCompletionTime(posTable, selectedRecordId, parentStep);
if (completionTime) {
// 更新父步骤后的步骤开始时间
await StepService.updateSubsequentStepsStartTimes(
posTable, selectedRecordId, newSteps, stepIndex, completionTime
);
// 强制刷新步骤数据
refreshSteps(true);
}
} catch (e) {
console.error("Error saving parent step completion time:", e);
}
} else {
// 如果没有完成时间字段,也需要更新后续步骤
refreshSteps(true);
}
}
}
// 查找上一个主步骤的完成时间
static findPreviousMainStepFinishTime(steps: StepItem[], currentIndex: number): number | undefined {
for (let i = currentIndex - 1; i >= 0; i--) {
const step = steps[i];
// 如果是主步骤(整数顺序)且已完成
if (step.isMainStep && step.finished && step.actualTimeValue) {
return step.actualTimeValue;
}
}
return undefined;
}
// 更新步骤的开始时间为上一个主步骤的完成时间
static async updateStepStartTimeFromPreviousMainStep(
posTable: ITable,
selectedRecordId: string,
steps: StepItem[],
stepIndex: number
) {
if (!selectedRecordId) return;
const currentStep = steps[stepIndex];
if (!currentStep.startTimeFieldId) return;
try {
// 查找上一个主步骤的完成时间
const previousFinishTime = StepService.findPreviousMainStepFinishTime(steps, stepIndex);
if (previousFinishTime) {
// 更新当前步骤的开始时间
const startTimeField = await posTable.getFieldById<IDateTimeField>(currentStep.startTimeFieldId);
await startTimeField.setValue(selectedRecordId, previousFinishTime);
currentStep.startTimeValue = previousFinishTime;
console.log(`Updated step ${stepIndex} (${currentStep.name}) start time to previous main step's finish time: ${new Date(previousFinishTime).toLocaleString()}`);
// 如果当前步骤有并行子步骤,也更新它们的开始时间
if (currentStep.parallelSteps) {
for (const subStep of currentStep.parallelSteps) {
if (subStep.startTimeFieldId) {
const subStartTimeField = await posTable.getFieldById<IDateTimeField>(subStep.startTimeFieldId);
await subStartTimeField.setValue(selectedRecordId, previousFinishTime);
subStep.startTimeValue = previousFinishTime;
console.log(`Updated sub-step ${subStep.name} start time to previous main step's finish time: ${new Date(previousFinishTime).toLocaleString()}`);
}
}
}
}
} catch (e) {
console.error(`Error updating step ${stepIndex} start time from previous main step:`, e);
Toast.warning("更新步骤开始时间失败");
}
}
}
// 判断并行步骤是否完成
const isParallelFinished = (parallelSteps: StepItem[], requireAll: boolean = true) => {
if (!parallelSteps || parallelSteps.length === 0) return true;
if (requireAll) {
return parallelSteps.every(step => step.finished);
} else {
return parallelSteps.some(step => step.finished);
}
};
// 步骤UI组件
const StepCard = ({
step,
idx,
currentStep,
loading,
onFinishStep,
formatTimestamp,
isCurrentStep,
isActiveParallelGroup,
isAnySubStepFinished, // 判断并行组中是否有子步骤已完成
canAccessSubSteps // 判断是否可以访问子步骤
}: {
step: StepItem;
idx: number;
currentStep: number;
loading: boolean;
onFinishStep: (stepIndex: number, subStepIndex?: number) => void;
formatTimestamp: (timestamp: number | undefined) => string;
isCurrentStep: boolean;
isActiveParallelGroup: boolean;
isAnySubStepFinished: boolean;
canAccessSubSteps: boolean;
}) => {
return (
<div className="step-content-container" style={{ minWidth: 300, marginTop: step.parallelSteps ? 10 : 0 }}>
{/* 并行步骤组标题 */}
{step.parallelSteps && (
<div className="parallel-group-header" style={{
padding: '10px 16px',
backgroundColor: '#f5f7fa',
borderRadius: '4px',
marginBottom: '16px',
borderLeft: '4px solid #165DFF'
}}>
<Typography.Text strong style={{ fontSize: 16, color: '#165DFF' }}>
: {step.name}
</Typography.Text>
<Typography.Text style={{ fontSize: 14, color: '#86909c', marginTop: 4, display: 'block' }}>
{step.requireAllSubSteps ? '所有子步骤完成后自动推进' : '任一子步骤完成后自动推进'}
</Typography.Text>
</div>
)}
{/* 普通步骤的时间信息展示 */}
{!step.parallelSteps && (
<div className="step-time-info" style={{
margin: '16px 0 8px 0',
padding: '12px',
border: '1px solid #eee',
borderRadius: '4px'
}}>
<Typography.Text strong style={{color: '#000', fontSize: 14, marginBottom: 8, display: 'block'}}>
{formatTimestamp(step.expectTimeValue)}
</Typography.Text>
<Typography.Text strong style={{color: '#000', fontSize: 14, marginBottom: 8, display: 'block'}}>
{formatTimestamp(step.startTimeValue)}
</Typography.Text>
<Typography.Text strong style={{color: '#000', fontSize: 14, marginBottom: 8, display: 'block'}}>
{formatTimestamp(step.actualTimeValue)}
</Typography.Text>
<Typography.Text strong style={{
color: step.actualTimeValue && step.expectTimeValue && step.actualTimeValue > step.expectTimeValue ? '#f5222d' :
(step.actualTimeValue && step.expectTimeValue && step.actualTimeValue <= step.expectTimeValue ? '#52c41a' : '#000'),
fontSize: 14, marginBottom: 0, display: 'block'
}}>
{step.startTimeValue && step.actualTimeValue ?
((step.actualTimeValue - step.startTimeValue) > 0 ?
((step.actualTimeValue - step.startTimeValue) / 1000 / 60 / 60).toFixed(2) + ' 小时' : '0 小时') : '-'}
</Typography.Text>
</div>
)}
{/* 并行子步骤 */}
{step.parallelSteps && (
<div className="parallel-steps-container" style={{
display: 'flex',
flexDirection: 'row',
gap: 16,
marginTop: 10,
flexWrap: 'wrap'
}}>
{step.parallelSteps.map((sub, subIdx) => (
<div
key={subIdx}
className="sub-step-card"
style={{
flex: 1,
minWidth: 280,
border: '1px solid #eee',
borderRadius: 4,
padding: 12,
backgroundColor: '#fafafa',
transition: 'all 0.3s',
position: 'relative'
}}
>
{/* 完成状态标识 */}
{sub.finished && (
<div className="sub-step-finished-badge" style={{
position: 'absolute',
top: '8px',
right: '8px',
backgroundColor: '#52c41a',
color: '#fff',
fontSize: '12px',
padding: '2px 6px',
borderRadius: '4px'
}}>
</div>
)}
<Typography.Text strong className="sub-step-title" style={{ fontSize: 15 }}>{sub.name}</Typography.Text>
<div className="sub-step-time-info" style={{ margin: '10px 0 8px 0' }}>
<Typography.Text strong style={{color: '#000', fontSize: 13, marginBottom: 8, display: 'block'}}>
{formatTimestamp(sub.expectTimeValue || step.expectTimeValue)}
</Typography.Text>
<Typography.Text strong style={{color: '#000', fontSize: 13, marginBottom: 8, display: 'block'}}>
{formatTimestamp(sub.startTimeValue || step.startTimeValue)}
</Typography.Text>
<Typography.Text strong style={{color: '#000', fontSize: 13, marginBottom: 8, display: 'block'}}>
{formatTimestamp(sub.actualTimeValue)}
</Typography.Text>
<Typography.Text strong style={{
color: sub.actualTimeValue && (sub.expectTimeValue || step.expectTimeValue) &&
sub.actualTimeValue > (sub.expectTimeValue || step.expectTimeValue) ? '#f5222d' :
(sub.actualTimeValue && (sub.expectTimeValue || step.expectTimeValue) &&
sub.actualTimeValue <= (sub.expectTimeValue || step.expectTimeValue) ? '#52c41a' : '#000'),
fontSize: 13, marginBottom: 0, display: 'block'
}}>
{sub.startTimeValue && sub.actualTimeValue ?
((sub.actualTimeValue - sub.startTimeValue) > 0 ?
((sub.actualTimeValue - sub.startTimeValue) / 1000 / 60 / 60).toFixed(2) + ' 小时' : '0 小时') :
(step.startTimeValue && sub.actualTimeValue ?
((sub.actualTimeValue - step.startTimeValue) / 1000 / 60 / 60).toFixed(2) + ' 小时' : '-')}
</Typography.Text>
</div>
{/* 允许活动并行组中的子步骤被点击,或并行组中已有子步骤完成 */}
{!sub.finished && (isCurrentStep || isActiveParallelGroup || isAnySubStepFinished || canAccessSubSteps) && (
<Button
theme="solid"
size="small"
loading={loading}
onClick={() => onFinishStep(idx, subIdx)}
style={{ marginTop: 8 }}
>
</Button>
)}
{/* 只有当父步骤未完成且不在当前活动状态时才显示等待提示 */}
{!sub.finished && !(isCurrentStep || isActiveParallelGroup || isAnySubStepFinished || canAccessSubSteps) && (
<div style={{ marginTop: 8, padding: '6px 12px', backgroundColor: '#f2f3f5', borderRadius: 4, textAlign: 'center' }}>
<Typography.Text style={{ fontSize: 13, color: '#86909c' }}>
</Typography.Text>
</div>
)}
</div>
))}
</div>
)}
{/* 当前步骤的完成按钮 */}
{idx === currentStep && !step.finished && !step.parallelSteps && (
<Button
theme="solid"
size="default"
loading={loading}
onClick={() => onFinishStep(idx)}
style={{ marginTop: 16 }}
>
</Button>
)}
</div>
);
};
export default function App() {
const [dateFields, setDateFields] = useState<IDateTimeFieldMeta[]>([]);
const [steps, setSteps] = useState<StepItem[]>([]);
const [currentStep, setCurrentStep] = useState(0);
const [loading, setLoading] = useState(false);
const [selectedRecordId, setSelectedRecordId] = useState<string | undefined>();
const [refreshSteps, setRefreshSteps] = useState<boolean>(false);
const [isChangingRecord, setIsChangingRecord] = useState(false); // 记录是否正在切换记录
const [hasNoProcess, setHasNoProcess] = useState(false); // 记录是否没有配置流程
// 缓存所有流程配置数据
const [allProcessConfigs, setAllProcessConfigs] = useState<ProcessConfigItem[]>([]);
const [processConfigLoading, setProcessConfigLoading] = useState(true);
// 缓存字段元数据,避免重复获取
const [fieldMetadataCache, setFieldMetadataCache] = useState<Map<string, any>>(new Map());
// 缓存按POS记录ID分组的配置
const [configsByPosLink, setConfigsByPosLink] = useState<Map<string, ProcessConfigItem[]>>(new Map());
// 预加载所有流程配置数据
async function fetchAllProcessConfigs() {
setProcessConfigLoading(true);
try {
// 检查缓存中是否已有配置数据
if (allProcessConfigs.length > 0) {
console.log('Using cached process configs');
return;
}
const processTable = await bitable.base.getTableByName(PROCESS_TABLE_NAME);
// 优化批量获取所有字段元数据减少API调用
const allFields = await processTable.getFieldMetaList();
const fieldMap = new Map(allFields.map(field => [field.id, field]));
setFieldMetadataCache(fieldMap);
// 验证字段是否存在 - 使用优化后的字段映射
const requiredFieldNames = [
POS_LINK_FIELD_NAME_IN_PROCESS_TABLE,
PROCESS_NODE_FIELD_NAME,
FIELD_ID_FIELD_NAME,
PROCESS_NAME_FIELD_NAME,
PROCESS_ORDER_FIELD_NAME
];
// 检查必需字段是否存在
const missingFields: string[] = [];
requiredFieldNames.forEach(name => {
const field = allFields.find(f => f.name === name);
if (!field) {
missingFields.push(`字段名: ${name}`);
}
});
if (missingFields.length > 0) {
const errorMsg = `订单流程表中缺少关键字段: ${missingFields.join(', ')}`;
console.error(errorMsg);
Toast.error(errorMsg);
setProcessConfigLoading(false);
return;
}
// 使用更高效的分页获取记录
const allRecords: IRecord[] = [];
let pageToken: string | undefined;
do {
const response = await processTable.getRecords({
pageSize: 5000, // 最大页大小
pageToken
});
allRecords.push(...response.records);
pageToken = response.pageToken;
} while (pageToken);
console.log(`Fetched ${allRecords.length} process config records`);
const allConfigs: ProcessConfigItem[] = [];
// 获取字段对象
const posLinkField = allFields.find(f => f.name === POS_LINK_FIELD_NAME_IN_PROCESS_TABLE);
const processNodeField = allFields.find(f => f.name === PROCESS_NODE_FIELD_NAME);
const fieldIdField = allFields.find(f => f.name === FIELD_ID_FIELD_NAME);
for (const record of allRecords) {
const fields = record.fields;
const recordId = record.recordId;
// 处理POS链接字段
let posLinkText = '';
const posLinkFieldValue = fields[posLinkField?.id ?? ''];
if (Array.isArray(posLinkFieldValue)) {
posLinkText = (posLinkFieldValue[0] as IOpenSegment)?.text?.trim() || '';
} else if (typeof posLinkFieldValue === 'object' && posLinkFieldValue) {
posLinkText = (posLinkFieldValue as IOpenSegment).text?.trim() || '';
}
// 处理流程名称字段 - 使用字段ID直接访问
const processNameField = allFields.find(f => f.name === PROCESS_NAME_FIELD_NAME);
let processName = '';
if (processNameField) {
const fieldValue = fields[processNameField.id];
processName = Array.isArray(fieldValue)
? (fieldValue[0] as IOpenSegment)?.text?.trim() || ''
: (fieldValue as IOpenSegment)?.text?.trim() || '';
}
// 处理顺序字段
const processOrderField = allFields.find(f => f.name === PROCESS_ORDER_FIELD_NAME);
const order = processOrderField
? (fields[processOrderField.id] as number | undefined) || 0
: 0;
// 处理流程节点字段
const processNodeValue = fields[processNodeField?.id ?? '']
? (Array.isArray(fields[processNodeField.id])
? (fields[processNodeField.id][0] as IOpenSegment)?.text?.trim() || ''
: (fields[processNodeField.id] as IOpenSegment)?.text?.trim() || '')
: '';
// 处理时间字段ID字段
const timeFieldId = fields[fieldIdField?.id ?? '']
? (Array.isArray(fields[fieldIdField.id])
? (fields[fieldIdField.id][0] as IOpenSegment)?.text?.trim() || ''
: (fields[fieldIdField.id] as IOpenSegment)?.text?.trim() || '')
: '';
allConfigs.push({
processName,
order,
processNodeValue,
timeFieldId,
originalRecordId: recordId,
posLink: posLinkText
});
}
// 按POS记录ID分组配置便于快速查找
const configsByPosMap = new Map<string, ProcessConfigItem[]>();
allConfigs.forEach(config => {
if (!configsByPosMap.has(config.posLink)) {
configsByPosMap.set(config.posLink, []);
}
configsByPosMap.get(config.posLink)?.push(config);
});
setAllProcessConfigs(allConfigs);
setConfigsByPosLink(configsByPosMap);
console.log('Fetched all process configs:', allConfigs.length, 'items');
} catch (error) {
console.error('Failed to fetch all process configs:', error);
Toast.error('加载流程配置数据失败,请检查网络连接或表格权限');
} finally {
setProcessConfigLoading(false);
}
}
useEffect(() => {
fetchAllProcessConfigs();
}, []);
// 初始化日期字段
useEffect(() => {
async function fetchDateFieldsForUI() {
try {
const activeTable = await bitable.base.getActiveTable();
const fields = await activeTable.getFieldMetaListByType<IDateTimeFieldMeta>(FieldType.DateTime);
setDateFields(fields);
} catch (e) {
console.error("Failed to fetch date fields for UI: ", e);
Toast.error("获取可选日期字段列表失败");
}
}
fetchDateFieldsForUI();
}, []);
// 优化:初始化步骤时处理并行组
const initializeStepsWithParallel = (configs: ProcessConfigItem[]) => {
if (configs.length === 0) return [];
// 使用Map按order分组提高查询效率
const stepsByOrder = new Map<number, StepItem[]>();
// 初始化步骤
configs.forEach(config => {
const step: StepItem = {
name: config.processName,
fieldId: config.timeFieldId,
finished: false,
order: config.order,
processNodeValue: config.processNodeValue,
isMainStep: config.order === Math.floor(config.order) // 标记是否为主步骤(整数顺序)
};
if (!stepsByOrder.has(config.order)) {
stepsByOrder.set(config.order, []);
}
stepsByOrder.get(config.order)?.push(step);
});
// 处理并行组
const processedSteps: StepItem[] = [];
stepsByOrder.forEach((groupSteps, order) => {
if (groupSteps.length === 1) {
processedSteps.push(groupSteps[0]);
} else {
const parallelGroupStep: StepItem = {
name: `并行组${order}`,
fieldId: `parallel_group_${order}`,
finished: false,
order,
parallelSteps: groupSteps,
requireAllSubSteps: true,
parallelGroup: order,
isMainStep: true // 并行组作为一个整体视为主步骤
};
// 继承第一个子步骤的时间字段
if (groupSteps.length > 0) {
const firstSubStep = groupSteps[0];
parallelGroupStep.expectTimeFieldId = firstSubStep.expectTimeFieldId;
parallelGroupStep.expectTimeValue = firstSubStep.expectTimeValue;
parallelGroupStep.startTimeFieldId = firstSubStep.startTimeFieldId;
parallelGroupStep.startTimeValue = firstSubStep.startTimeValue;
}
processedSteps.push(parallelGroupStep);
}
});
return processedSteps.sort((a, b) => (a.order || 0) - (b.order || 0));
};
// 刷新步骤数据
const refreshProcessSteps = async () => {
if (selectedRecordId) {
await loadProcessSteps(selectedRecordId);
}
};
// 从缓存中加载流程步骤
const loadProcessSteps = async (currentPosRecordId: string) => {
console.log('loadProcessSteps triggered with currentPosRecordId:', currentPosRecordId);
if (!currentPosRecordId) {
setSteps([]);
setCurrentStep(0);
setHasNoProcess(false);
console.log('No POS record selected, steps cleared.');
return;
}
setLoading(true);
setIsChangingRecord(true);
setHasNoProcess(false); // 重置状态
console.log('Loading process steps from cached configs...');
try {
const posTable = await bitable.base.getTableByName(POS_TABLE_NAME);
// 从缓存中快速筛选当前记录的配置
const filteredConfigs = configsByPosLink.get(currentPosRecordId) ||
allProcessConfigs.filter(config => config.posLink === currentPosRecordId);
console.log('Filtered configs for current record:', filteredConfigs.length);
if (filteredConfigs.length === 0) {
console.log('No configs found for current record');
setSteps([]);
setCurrentStep(0);
setHasNoProcess(true); // 设置为没有配置流程
setLoading(false);
setIsChangingRecord(false);
return;
}
// 按流程顺序分组配置
const stepsByOrder = new Map<number, StepItem[]>();
const tempStepsMap = new Map<string, StepItem>();
// 首先处理每个步骤的基本信息
for (const config of filteredConfigs) {
const stepName = config.processName;
const order = config.order;
// 如果该步骤不存在,创建新步骤
if (!tempStepsMap.has(stepName)) {
tempStepsMap.set(stepName, {
name: stepName,
fieldId: stepName,
finished: false,
order,
parallelSteps: undefined,
requireAllSubSteps: true, // 默认需要所有子步骤完成
isMainStep: order === Math.floor(order) // 标记是否为主步骤
});
} else {
// 如果步骤已存在使用最小的order值
const existingStep = tempStepsMap.get(stepName)!;
existingStep.order = Math.min(existingStep.order || 0, order);
// 重新计算是否为主步骤
existingStep.isMainStep = existingStep.order === Math.floor(existingStep.order);
}
const stepItem = tempStepsMap.get(stepName)!;
// 根据节点类型设置相应的时间字段
if (config.timeFieldId) {
try {
const posTimeField = await posTable.getFieldById<IDateTimeField>(config.timeFieldId);
const timeValue = await posTimeField.getValue(currentPosRecordId) as number | null;
if (config.processNodeValue?.includes('预计')) {
stepItem.expectTimeFieldId = config.timeFieldId;
stepItem.expectTimeValue = timeValue || undefined;
} else if (config.processNodeValue?.includes('实际') && !config.processNodeValue?.includes('开始')) {
stepItem.actualTimeFieldId = config.timeFieldId;
stepItem.actualTimeValue = timeValue || undefined;
if (timeValue) stepItem.finished = true;
} else if (config.processNodeValue === PROCESS_NODE_START_TIME || config.processNodeValue?.includes('实际开始')) {
stepItem.startTimeFieldId = config.timeFieldId;
stepItem.startTimeValue = timeValue || undefined;
}
} catch (e) {
console.warn(`Error processing time field ${config.timeFieldId} for step ${stepName} (node: ${config.processNodeValue}):`, e);
Toast.warning(`处理步骤 ${stepName} 的时间字段 ${config.timeFieldId} 出错`);
}
}
}
// 按顺序分组步骤
const loadedSteps = Array.from(tempStepsMap.values()).sort((a, b) => (a.order || 0) - (b.order || 0));
// 处理并行组 - 相同整数顺序的步骤形成并行组
const parallelGroups = new Map<number, StepItem[]>();
loadedSteps.forEach(step => {
const mainOrder = Math.floor(step.order || 0); // 使用order的整数部分作为分组键
if (!parallelGroups.has(mainOrder)) {
parallelGroups.set(mainOrder, []);
}
parallelGroups.get(mainOrder)?.push(step);
});
// 重构步骤数组,将并行组封装为主步骤
const revisedSteps: StepItem[] = [];
parallelGroups.forEach((groupSteps, mainOrder) => {
// 条件组内多于一个步骤或者组内只有一个步骤但其原始order包含小数
const shouldFormParallelBlock = groupSteps.length > 1 ||
(groupSteps.length === 1 && groupSteps[0].order !== Math.floor(groupSteps[0].order || 0));
if (!shouldFormParallelBlock && groupSteps.length === 1) {
// 单个普通步骤order为整数且不成组直接添加
revisedSteps.push(groupSteps[0]);
} else {
// 形成并行组 (包含单个小数order的步骤如16.1自己也形成一个"并行组")
let requiresAll = true; // 默认需要所有子步骤完成
// 检查组内是否有任何一个步骤的order以.2结尾
if (groupSteps.some(s => s.order && s.order.toString().endsWith('.2'))) {
requiresAll = false;
}
// 父并行步骤的名称
// 如果并行块仅由一个原始步骤构成例如order为16.1的步骤单独形成一个并行块),则父步骤名称可以就是该原始步骤的名称。
// 否则,组合所有子步骤名称。
const parallelStepName = groupSteps.length === 1
? groupSteps[0].name
: `并行处理组 ${mainOrder}`;
revisedSteps.push({
name: parallelStepName,
fieldId: `parallel_${mainOrder}`,
finished: requiresAll
? groupSteps.every(s => s.finished)
: groupSteps.some(s => s.finished), // 完成状态根据requireAllSubSteps判断
order: mainOrder, // 父并行步骤使用整数order进行排序
parallelSteps: groupSteps.sort((a,b) => (a.order || 0) - (b.order || 0)), // 子步骤按原始order排序
requireAllSubSteps: requiresAll,
// 尝试从第一个子步骤或具有整数order的子步骤继承时间字段
expectTimeFieldId: groupSteps.find(s => s.order === mainOrder)?.expectTimeFieldId || groupSteps[0]?.expectTimeFieldId,
expectTimeValue: groupSteps.find(s => s.order === mainOrder)?.expectTimeValue || groupSteps[0]?.expectTimeValue,
startTimeFieldId: groupSteps.find(s => s.order === mainOrder)?.startTimeFieldId || groupSteps[0]?.startTimeFieldId,
startTimeValue: groupSteps.find(s => s.order === mainOrder)?.startTimeValue || groupSteps[0]?.startTimeValue,
parallelGroup: mainOrder, // 用于UI分组显示的标识
isMainStep: true // 并行组作为一个整体视为主步骤
});
}
});
// 按顺序排序最终的步骤列表 (并行组和普通步骤)
const sortedSteps = revisedSteps.sort((a, b) => (a.order || 0) - (b.order || 0));
// 处理步骤间的开始时间关联
for (let i = 0; i < sortedSteps.length; i++) {
const currentStep = sortedSteps[i];
// 第一个步骤的开始时间不依赖于前一步骤
if (i === 0) continue;
// 查找上一个已完成的主步骤(包括并行组)
let previousMainStepIndex = i - 1;
while (previousMainStepIndex >= 0 && !sortedSteps[previousMainStepIndex].isMainStep) {
previousMainStepIndex--;
}
// 如果找到了上一个主步骤
if (previousMainStepIndex >= 0) {
const previousMainStep = sortedSteps[previousMainStepIndex];
// 如果前一个主步骤已完成,且当前步骤没有开始时间,设置当前步骤的开始时间为前一步骤的完成时间
if (previousMainStep.finished && previousMainStep.actualTimeValue &&
(!currentStep.startTimeValue || currentStep.startTimeValue < previousMainStep.actualTimeValue)) {
currentStep.startTimeValue = previousMainStep.actualTimeValue;
// 如果当前步骤有开始时间字段ID也更新该字段的值
if (currentStep.startTimeFieldId && selectedRecordId) {
try {
const startTimeField = await posTable.getFieldById<IDateTimeField>(currentStep.startTimeFieldId);
await startTimeField.setValue(selectedRecordId, previousMainStep.actualTimeValue);
console.log(`Set step ${i} (${currentStep.name}) start time to previous main step's finish time: ${new Date(previousMainStep.actualTimeValue).toLocaleString()}`);
} catch (e) {
console.warn(`Failed to update start time field for step ${i} (${currentStep.name}):`, e);
}
}
}
}
}
console.log('Loaded steps:', JSON.stringify(sortedSteps, null, 2));
setSteps(sortedSteps);
// 确定当前步骤
const firstUnfinished = sortedSteps.findIndex(s => !s.finished);
setCurrentStep(firstUnfinished === -1 ? sortedSteps.length : firstUnfinished);
} catch (error) {
console.error('Failed to load process steps:', error);
Toast.error('加载流程步骤失败,请检查控制台获取更多信息。');
setSteps([]);
} finally {
setLoading(false);
setIsChangingRecord(false);
console.log('Finished loading process steps.');
}
};
// 监听表格选择变化
useEffect(() => {
const off = bitable.base.onSelectionChange(async (event) => {
if (event.data && event.data.tableId === POS_TABLE_ID && event.data.recordId) {
setSelectedRecordId(event.data.recordId);
// 只在配置数据加载完成后才加载步骤
if (!processConfigLoading) {
await loadProcessSteps(event.data.recordId);
}
} else if (!event.data.recordId) {
setSelectedRecordId(undefined);
setSteps([]);
setCurrentStep(0);
setHasNoProcess(false);
}
});
return () => {
off();
};
}, [POS_TABLE_ID, processConfigLoading]);
// 监听刷新标志
useEffect(() => {
if (refreshSteps && selectedRecordId) {
refreshProcessSteps();
setRefreshSteps(false);
}
}, [refreshSteps, selectedRecordId]);
// 处理步骤完成
const handleFinishStep = async (stepIndex: number, subStepIndex?: number) => {
if (!selectedRecordId) {
Toast.error('没有选中的POS记录');
return;
}
const newSteps = [...steps];
let stepToComplete: StepItem;
let isSubStep = typeof subStepIndex === 'number';
// 确定要完成的步骤
if (isSubStep && newSteps[stepIndex] && newSteps[stepIndex].parallelSteps) {
stepToComplete = newSteps[stepIndex].parallelSteps![subStepIndex!];
} else {
stepToComplete = newSteps[stepIndex];
}
// 检查步骤是否存在
if (!stepToComplete) {
Toast.error('找不到要完成的步骤');
return;
}
// 检查步骤是否已经完成
if (stepToComplete.finished && stepToComplete.actualTimeValue) {
Toast.info(`步骤 "${stepToComplete.name}" 已于 ${formatTimestamp(stepToComplete.actualTimeValue)} 完成`);
return;
}
setLoading(true);
try {
const posTable = await bitable.base.getTableById(POS_TABLE_ID);
// 如果没有配置实际完成时间字段,仅标记为完成
if (!stepToComplete.actualTimeFieldId) {
Toast.warning(`步骤 "${stepToComplete.name}" 未配置实际完成时间字段ID无法自动记录时间。将仅标记为完成。`);
stepToComplete.finished = true;
} else {
// 保存步骤完成时间
const completionTime = await StepService.saveStepCompletionTime(
posTable, selectedRecordId, stepToComplete, isSubStep
);
Toast.success(`步骤 "${stepToComplete.name}" 时间已记录`);
// 如果是主步骤完成,更新后续步骤的开始时间
if (!isSubStep && completionTime) {
await StepService.updateSubsequentStepsStartTimes(
posTable, selectedRecordId, newSteps, stepIndex, completionTime
);
// 强制刷新步骤数据
setRefreshSteps(true);
}
}
// 处理子步骤完成的特殊逻辑
if (isSubStep) {
const parentStep = newSteps[stepIndex];
// 如果父步骤要求所有子步骤完成才标记为完成
if (parentStep.requireAllSubSteps && parentStep.parallelSteps?.every(ps => ps.finished)) {
parentStep.finished = true;
// 如果父步骤也有完成时间字段,记录完成时间
if (parentStep.actualTimeFieldId) {
try {
const completionTime = await StepService.saveStepCompletionTime(posTable, selectedRecordId, parentStep);
if (completionTime) {
// 更新父步骤后的步骤开始时间
await StepService.updateSubsequentStepsStartTimes(
posTable, selectedRecordId, newSteps, stepIndex, completionTime
);
// 强制刷新步骤数据
setRefreshSteps(true);
}
} catch (e) {
console.error("Error saving parent step completion time:", e);
}
} else {
// 如果没有完成时间字段,也需要更新后续步骤
setRefreshSteps(true);
}
} else if (!parentStep.requireAllSubSteps && parentStep.parallelSteps?.some(ps => ps.finished)) {
// 任一子步骤完成即可推进主步骤
parentStep.finished = true;
// 如果父步骤有完成时间字段,记录完成时间
if (parentStep.actualTimeFieldId) {
try {
const completionTime = await StepService.saveStepCompletionTime(posTable, selectedRecordId, parentStep);
if (completionTime) {
// 更新父步骤后的步骤开始时间
await StepService.updateSubsequentStepsStartTimes(
posTable, selectedRecordId, newSteps, stepIndex, completionTime
);
// 强制刷新步骤数据
setRefreshSteps(true);
}
} catch (e) {
console.error("Error saving parent step completion time:", e);
}
} else {
// 如果没有完成时间字段,也需要更新后续步骤
setRefreshSteps(true);
}
}
}
// 更新步骤状态和当前步骤
setSteps(newSteps);
const firstUnfinished = newSteps.findIndex(s => !s.finished);
setCurrentStep(firstUnfinished === -1 ? newSteps.length : firstUnfinished);
} catch (e) {
console.error("Error finishing step:", e);
Toast.error(`完成步骤 "${stepToComplete.name}" 失败`);
} finally {
setLoading(false);
}
};
// 确定当前并行组是否为活动组
const isActiveParallelGroup = (step: StepItem): boolean => {
// 如果当前步骤是并行组,则该并行组是活动的
if (step.parallelSteps && currentStep === steps.findIndex(s => s === step)) {
return true;
}
// 如果当前步骤是并行组中的子步骤,则其父并行组是活动的活动的
if (currentStep < steps.length && steps[currentStep].parallelSteps) {
const currentParallelStep = steps[currentStep];
// 检查当前步骤的并行组ID是否与参数步骤的并行组ID相同
return currentParallelStep.parallelGroup === step.parallelGroup;
}
return false;
};
// 判断并行组中是否有任何子步骤已完成
const isAnySubStepFinished = (step: StepItem): boolean => {
if (!step.parallelSteps) return false;
return step.parallelSteps.some(subStep => subStep.finished);
};
// 判断是否可以访问子步骤
const canAccessSubSteps = (step: StepItem): boolean => {
// 如果是当前步骤或活动并行组,当然可以访问
if (isActiveParallelGroup(step)) return true;
// 如果是已完成的并行组,也可以访问
if (step.finished) return true;
// 如果并行组中已有任何子步骤完成,也可以访问
if (isAnySubStepFinished(step)) return true;
// 如果并行组的前置步骤(主步骤)已完成,也可以访问
const stepIndex = steps.findIndex(s => s === step);
if (stepIndex > 0) {
// 查找上一个主步骤
let previousMainStepIndex = stepIndex - 1;
while (previousMainStepIndex >= 0 && !steps[previousMainStepIndex].isMainStep) {
previousMainStepIndex--;
}
// 如果找到了上一个主步骤且它已完成
if (previousMainStepIndex >= 0 && steps[previousMainStepIndex].finished) {
return true;
}
}
return false;
};
// 渲染加载状态
if (processConfigLoading) {
return (
<div className="app-container" style={{ padding: 24, minWidth: 410 }}>
<Typography.Title heading={4}></Typography.Title>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '50vh' }}>
<Spin size="large" tip="加载中..." />
</div>
</div>
);
}
// 渲染无流程记录提示
if (hasNoProcess && selectedRecordId) {
return (
<div className="app-container" style={{ padding: 24, minWidth: 410 }}>
<Typography.Title heading={4}></Typography.Title>
<div style={{ marginTop: 24, textAlign: 'center' }}>
<Empty
description={
<Typography.Text style={{ fontSize: 16, color: '#4e5969' }}>
</Typography.Text>
}
/>
<Button
onClick={async () => { // 修改为 async 函数
setHasNoProcess(false);
await fetchAllProcessConfigs(); // 首先重新获取所有流程配置
if (selectedRecordId) {
await loadProcessSteps(selectedRecordId); // 然后使用新的配置加载步骤
}
}}
style={{ marginTop: 20 }}
loading={processConfigLoading || loading} // 当任一加载进行中时显示loading
>
</Button>
</div>
</div>
);
}
// 渲染切换记录时的加载状态
if (isChangingRecord) {
return (
<div className="app-container" style={{ padding: 24, minWidth: 410 }}>
<Typography.Title heading={4}></Typography.Title>
<div style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '50vh',
backgroundColor: '#fafafa',
borderRadius: 8,
border: '1px solid #e5e6eb'
}}>
<Spin
size="large"
style={{ marginBottom: 20 }}
/>
<Typography.Title
heading={5}
style={{
fontSize: 18,
fontWeight: 500,
color: '#4e5969',
textAlign: 'center'
}}
>
...
</Typography.Title>
<Typography.Text
style={{
marginTop: 8,
color: '#86909c',
fontSize: 14
}}
>
</Typography.Text>
</div>
</div>
);
}
// 渲染主界面
return (
<div className="app-container" style={{ padding: 24, minWidth: 410 }}>
<Typography.Title heading={4}></Typography.Title>
<Steps direction="vertical" type="basic" current={currentStep} style={{ margin: '28px 0' }}>
{steps.map((step, idx) => (
<Steps.Step
key={step.name}
title={step.name}
status={step.finished ? 'finish' : idx === currentStep ? 'process' : 'wait'}
description={
<StepCard
step={step}
idx={idx}
currentStep={currentStep}
loading={loading}
onFinishStep={handleFinishStep}
formatTimestamp={formatTimestamp}
isCurrentStep={idx === currentStep}
isActiveParallelGroup={isActiveParallelGroup(step)}
isAnySubStepFinished={isAnySubStepFinished(step)}
canAccessSubSteps={canAccessSubSteps(step)}
/>
}
/>
))}
</Steps>
</div>
);
}