1202 lines
48 KiB
TypeScript
Raw Normal View History

2025-06-14 16:53:18 +08:00
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';
2024-07-10 12:55:22 +00:00
import './App.css';
2025-06-14 16:53:18 +08:00
// 步骤数据结构定义
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;
}
// 常量定义
2025-06-27 19:56:50 +08:00
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();
2025-06-14 16:53:18 +08:00
const PROCESS_NAME_FIELD_NAME = '流程名称';
const PROCESS_ORDER_FIELD_NAME = '流程顺序';
2025-06-27 19:56:50 +08:00
const POS_LINK_FIELD_NAME_IN_PROCESS_TABLE = 'POS表recordid';
const PROCESS_NODE_FIELD_NAME = '流程节点';
const FIELD_ID_FIELD_NAME = '字段ID';
2025-06-14 16:53:18 +08:00
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>
);
};
2024-07-10 12:55:22 +00:00
export default function App() {
2025-06-14 16:53:18 +08:00
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;
}
2025-06-27 19:56:50 +08:00
const processTable = await bitable.base.getTableByName(PROCESS_TABLE_NAME);
2025-06-14 16:53:18 +08:00
// 优化批量获取所有字段元数据减少API调用
const allFields = await processTable.getFieldMetaList();
const fieldMap = new Map(allFields.map(field => [field.id, field]));
setFieldMetadataCache(fieldMap);
// 验证字段是否存在 - 使用优化后的字段映射
2025-06-27 19:56:50 +08:00
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
2025-06-14 16:53:18 +08:00
];
// 检查必需字段是否存在
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[] = [];
2025-06-27 19:56:50 +08:00
// 获取字段对象
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);
2025-06-14 16:53:18 +08:00
for (const record of allRecords) {
const fields = record.fields;
const recordId = record.recordId;
2025-06-27 19:56:50 +08:00
// 处理POS链接字段
2025-06-14 16:53:18 +08:00
let posLinkText = '';
2025-06-27 19:56:50 +08:00
const posLinkFieldValue = fields[posLinkField?.id ?? ''];
2025-06-14 16:53:18 +08:00
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];
2025-06-27 19:56:50 +08:00
processName = Array.isArray(fieldValue)
? (fieldValue[0] as IOpenSegment)?.text?.trim() || ''
: (fieldValue as IOpenSegment)?.text?.trim() || '';
2025-06-14 16:53:18 +08:00
}
// 处理顺序字段
const processOrderField = allFields.find(f => f.name === PROCESS_ORDER_FIELD_NAME);
2025-06-27 19:56:50 +08:00
const order = processOrderField
? (fields[processOrderField.id] as number | undefined) || 0
: 0;
2025-06-14 16:53:18 +08:00
// 处理流程节点字段
2025-06-27 19:56:50 +08:00
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() || '')
: '';
2025-06-14 16:53:18 +08:00
// 处理时间字段ID字段
2025-06-27 19:56:50 +08:00
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() || '')
: '';
2025-06-14 16:53:18 +08:00
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);
2024-07-10 12:55:22 +00:00
}
2025-06-14 16:53:18 +08:00
}
useEffect(() => {
fetchAllProcessConfigs();
2024-07-10 12:55:22 +00:00
}, []);
2025-06-14 16:53:18 +08:00
// 初始化日期字段
2024-07-10 12:55:22 +00:00
useEffect(() => {
2025-06-14 16:53:18 +08:00
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();
2024-07-10 12:55:22 +00:00
}, []);
2025-06-14 16:53:18 +08:00
// 优化:初始化步骤时处理并行组
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 {
2025-06-27 19:56:50 +08:00
const posTable = await bitable.base.getTableByName(POS_TABLE_NAME);
2025-06-14 16:53:18 +08:00
// 从缓存中快速筛选当前记录的配置
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)!;
2025-06-14 16:53:18 +08:00
// 根据节点类型设置相应的时间字段
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;
2024-07-10 12:55:22 +00:00
}
2025-06-14 16:53:18 +08:00
// 父并行步骤的名称
// 如果并行块仅由一个原始步骤构成例如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>
);
}