import { ref } from 'vue' import dayjs from 'dayjs' import { getTaskContentConfigByTaskTypeIdApi } from '@/services/modules/task/taskFrom' import type { TaskContentConfig, TaskContentConfigDeriveFrom, TaskFieldConfigItem, TaskFieldDictMap, TaskTypeItem, } from '@/services/modules/task/taskFrom/type' import { useUserStore } from '@/stores/modules/user' import { normalizeTaskFieldType } from './useTaskFieldType' export type FieldValue = string /** * 日期字段展示格式。 */ export const TASK_DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm' export const HIDE_FIELD_DEPT_ID = ['1611890693', '1611890565'] /** * 这些 taskTypeId 的 datetime 字段不自动初始化当前时间。 */ const EXCLUDED_AUTO_DATETIME_BASE_TYPE_IDS = new Set(['81', '85', '86', '87']) const ON_SITE_VISIT_TASK_TYPE_ID = '85' const ON_SITE_VISIT_TYPE = '现场拜访' const ON_SITE_VISIT_READONLY_FIELD_ALIASES = new Set(['level', 'visitType']) export const MULTIPLE_SELECT_DRUG_TASK_TYPE_IDS = new Set([ '1', '2', '3', '4', '19', '64', '75', '76', '81', '85', ]) /** * 单选、多选组件统一使用的选项结构。 * * label、value 以及其他扩展属性统一转换为字符串。 */ export interface TaskFieldOption { label: string value: string [key: string]: string } export type TaskFieldConfigViewItem = Omit & { id: string originalTaskFiledType: string show: boolean readonly: boolean options: TaskFieldOption[] | null } export interface DynamicFormFieldValue { /** * 页面展示值。 * * datetime 字段使用 YYYY-MM-DD HH:mm。 */ label: FieldValue /** * 实际提交值。 * * datetime 字段使用 YYYY-MM-DD HH:mm。 */ value: FieldValue } export interface DynamicFormState { value: Record } export interface DerivedFieldChangeEvent { previousValue: FieldValue value: string label: string } export interface SingleSelectChangeEvent extends DerivedFieldChangeEvent { selectedItem: TaskFieldOption } interface RegisteredTaskForm { fields: TaskFieldConfigViewItem[] taskTypes: TaskTypeItem[] } const createEmptyFieldValue = (): DynamicFormFieldValue => ({ label: '', value: '', }) const createEmptyFormState = (): DynamicFormState => ({ value: {}, }) /** * 将日期统一截断到分钟。 * * 避免 label 只展示到分钟,但 value 仍包含秒和毫秒。 */ const normalizeDateTimeToMinute = (date: ReturnType): ReturnType => { return date.second(0).millisecond(0) } /** * 判断当前 datetime 字段是否需要自动初始化当前时间。 */ const shouldInitializeCurrentDateTime = (field: TaskFieldConfigViewItem): boolean => { const fieldType = String(field.taskFiledType ?? '').trim() const taskTypeId = String(field.taskTypeId ?? '').trim() return fieldType === 'datetime' && !EXCLUDED_AUTO_DATETIME_BASE_TYPE_IDS.has(taskTypeId) } const isOnSiteVisitField = ( field: Pick, alias: string ): boolean => { return ( String(field.taskTypeId).trim() === ON_SITE_VISIT_TASK_TYPE_ID && field.alias?.trim() === alias ) } const shouldReadonlyField = (field: TaskFieldConfigItem): boolean => { return ( String(field.taskTypeId).trim() === ON_SITE_VISIT_TASK_TYPE_ID && ON_SITE_VISIT_READONLY_FIELD_ALIASES.has(field.alias?.trim() ?? '') ) } const shouldShowField = (field: TaskFieldConfigItem, deptId: string): boolean => { // 旧页面仅在 longtext 分支读取 show,因此这里显式保留原有隐藏范围。 const hidesLongTextField = String(field.taskTypeId).trim() === '18' && field.taskFiledType.trim() === 'longtext' && HIDE_FIELD_DEPT_ID.includes(deptId) return !hidesLongTextField } export const isMultipleSelectDrugField = ( field: Pick ): boolean => { return ( MULTIPLE_SELECT_DRUG_TASK_TYPE_IDS.has(String(field.taskTypeId).trim()) && field.alias?.trim() === 'drugs' ) } const serializeTaskFieldType = (field: TaskFieldConfigItem): string => { if (isMultipleSelectDrugField(field)) return 'multiple_select' return normalizeTaskFieldType(field.taskFiledType) } /** * 创建字段初始值。 * * 符合旧业务规则的 datetime 字段: * - label 为 YYYY-MM-DD HH:mm * - value 为 YYYY-MM-DD HH:mm * * 其他字段初始化为空。 */ const createInitialFieldValue = ( field: TaskFieldConfigViewItem, initialDateTime: ReturnType ): DynamicFormFieldValue => { if (isOnSiteVisitField(field, 'visitType')) { return { label: ON_SITE_VISIT_TYPE, value: ON_SITE_VISIT_TYPE, } } if (!shouldInitializeCurrentDateTime(field)) { return createEmptyFieldValue() } const normalizedDateTime = normalizeDateTimeToMinute(initialDateTime) return { label: normalizedDateTime.format(TASK_DATE_TIME_FORMAT), value: normalizedDateTime.format(TASK_DATE_TIME_FORMAT), } } /** * 将字典或特殊接口返回的数据转换为统一选项格式。 * * 可以在页面的 beforeOpen 中复用。 */ export const serializeTaskFieldOptions = (source: unknown): TaskFieldOption[] => { if (!Array.isArray(source)) { return [] } return source.reduce((result, item) => { if (!item || typeof item !== 'object') { return result } const rawItem = item as Record const normalizedItem = Object.entries(rawItem).reduce>( (record, [key, value]) => { record[key] = value === undefined || value === null ? '' : String(value) return record }, {} ) if (!normalizedItem.label || !normalizedItem.value) { return result } result.push({ ...normalizedItem, label: normalizedItem.label, value: normalizedItem.value, }) return result }, []) } /** * 获取字段已有的字典内容。 * * 以下情况返回 null: * 1. 字段没有 dictGroupName * 2. 返回字典中不存在该分组 * 3. 对应字典不是数组 * 4. 对应字典没有有效选项 */ const getFieldDictOptions = ( field: TaskFieldConfigItem, dict: TaskFieldDictMap ): TaskFieldOption[] | null => { const dictName = field.dictGroupName?.trim() if (!dictName) { return null } const source = dict[dictName] if (!Array.isArray(source)) { return null } const options = serializeTaskFieldOptions(source) return options.length > 0 ? options : null } /** * 将接口原始字段配置转换成页面字段配置。 * * 不处理任何特殊接口或特殊业务判断。 */ const serializeTaskFieldConfig = ( config: TaskFieldConfigItem[], dict: TaskFieldDictMap, deptId: string ): TaskFieldConfigViewItem[] => { return config.map((field) => ({ ...field, id: String(field.id), originalTaskFiledType: normalizeTaskFieldType(field.taskFiledType), taskFiledType: serializeTaskFieldType(field), show: shouldShowField(field, deptId), readonly: shouldReadonlyField(field), options: getFieldDictOptions(field, dict), })) } const isRecord = (source: unknown): source is Record => { return Boolean(source) && typeof source === 'object' && !Array.isArray(source) } const isTaskFieldConfigItem = (source: unknown): source is TaskFieldConfigItem => { if (!isRecord(source)) return false const id = source.id return ( (typeof id === 'string' || typeof id === 'number') && String(id).trim().length > 0 && typeof source.taskTypeId === 'string' && typeof source.taskFiledKey === 'string' && typeof source.taskFiledValue === 'string' && source.taskFiledValue.trim().length > 0 && typeof source.taskFiledType === 'string' && source.taskFiledType.trim().length > 0 && (source.alias === null || typeof source.alias === 'string') && (source.dictGroupName === null || typeof source.dictGroupName === 'string') ) } const serializeTaskTypes = (source: unknown): TaskTypeItem[] => { if (!Array.isArray(source)) { return [] } return source.reduce((result, item) => { if (!isRecord(item)) { return result } const id = item.id const taskScoreEnd = Number(item.taskScoreEnd) if ( (typeof id === 'string' || typeof id === 'number') && String(id).trim() && Number.isFinite(taskScoreEnd) ) { result.push({ ...item, id: String(id), taskScoreEnd } as unknown as TaskTypeItem) } return result }, []) } const serializeTaskContentConfigResponse = (source: unknown): TaskContentConfig | null => { if (!isRecord(source)) { return null } if ( !Array.isArray(source.config) || source.config.length === 0 || !source.config.every(isTaskFieldConfigItem) ) { return null } return { config: source.config, dict: isRecord(source.dict) ? (source.dict as TaskFieldDictMap) : {}, taskTypes: serializeTaskTypes(source.taskTypes), } } const serializeDeriveFrom = (source: unknown): TaskContentConfigDeriveFrom | null => { if (!isRecord(source) || typeof source.field !== 'string' || !isRecord(source.valueMapping)) { return null } const field = source.field.trim() const valueMapping = Object.entries(source.valueMapping).reduce>( (result, [value, taskTypeId]) => { const normalizedTaskTypeId = typeof taskTypeId === 'string' || typeof taskTypeId === 'number' ? String(taskTypeId).trim() : '' if (normalizedTaskTypeId) { result[String(value)] = normalizedTaskTypeId } return result }, {} ) if (!field || !Object.keys(valueMapping).length) { return null } return { field, valueMapping, } } /** * 负责: * 1. 获取动态表单配置 * 2. 将字段 id 转换为字符串 * 3. 挂载已有字典 options * 4. 初始化动态表单值 * 5. 自动初始化符合条件的 datetime 字段 * * 特殊字段的接口请求不在这里处理, * 由页面的 beforeOpen 自行请求并覆盖 field.options。 */ export const useTaskForm = () => { const userStore = useUserStore() const form = ref(createEmptyFormState()) const taskFieldConfigList = ref([]) const activeTaskTypeId = ref('') const taskTypes = ref([]) const taskFormConfigMap = new Map() let deriveFrom: TaskContentConfigDeriveFrom | null = null let loadRequestVersion = 0 const createFormState = (config: TaskFieldConfigViewItem[]): DynamicFormState => { const formValue: DynamicFormState['value'] = {} /** * 同一次初始化使用同一个时间点, * 避免多个 datetime 字段出现分钟边界差异。 */ const initialDateTime = dayjs() for (const field of config) { formValue[field.id] = createInitialFieldValue(field, initialDateTime) } return { value: formValue, } } const clearTaskFormState = (): void => { form.value = createEmptyFormState() taskFieldConfigList.value = [] activeTaskTypeId.value = '' taskTypes.value = [] taskFormConfigMap.clear() deriveFrom = null } const resetTaskForm = (): void => { loadRequestVersion += 1 clearTaskFormState() } const activateTaskForm = ( taskTypeId: string, derivedFieldValue?: DynamicFormFieldValue ): boolean => { const normalizedTaskTypeId = taskTypeId.trim() const targetTaskForm = taskFormConfigMap.get(normalizedTaskTypeId) if (!targetTaskForm) { return false } const { fields: targetConfig, taskTypes: targetTaskTypes } = targetTaskForm /** * 每次切换都重新创建目标表单状态: * - 不保留当前表单已经输入的内容 * - 不恢复目标表单上一次输入的内容 * - datetime 等字段继续使用原有初始化规则 */ const targetFormState = createFormState(targetConfig) if (deriveFrom && derivedFieldValue) { const derivedFieldName = deriveFrom.field const targetDerivedField = targetConfig.find( (field) => field.taskFiledValue.trim() === derivedFieldName ) if (targetDerivedField) { targetFormState.value[targetDerivedField.id] = { label: derivedFieldValue.label, value: derivedFieldValue.value, } } } activeTaskTypeId.value = normalizedTaskTypeId taskFieldConfigList.value = targetConfig taskTypes.value = targetTaskTypes form.value = targetFormState return true } const registerTaskForm = (taskTypeId: string, taskContentConfig: TaskContentConfig): void => { const normalizedTaskTypeId = taskTypeId.trim() if (!normalizedTaskTypeId) { return } const config = serializeTaskFieldConfig( taskContentConfig.config, taskContentConfig.dict, String(userStore.currentUserInfo?.deptId ?? '') ) const fieldIds = new Set(config.map((field) => field.id)) const submitKeys = new Set(config.map((field) => field.taskFiledValue)) if (fieldIds.size !== config.length || submitKeys.size !== config.length) { throw new Error(`任务 ${normalizedTaskTypeId} 的字段 ID 或提交键重复`) } taskFormConfigMap.set(normalizedTaskTypeId, { fields: config, taskTypes: taskContentConfig.taskTypes ?? [], }) } const loadSingleTaskForm = (taskTypeId: string, taskContentConfig: TaskContentConfig): void => { registerTaskForm(taskTypeId, taskContentConfig) activateTaskForm(taskTypeId) } const loadMultipleTaskForms = ( source: Record, requestedTaskTypeId: string ): boolean => { deriveFrom = serializeDeriveFrom(source.deriveFrom) if (!deriveFrom) { return false } for (const [taskTypeId, taskContentConfig] of Object.entries(source)) { const serializedTaskContentConfig = serializeTaskContentConfigResponse(taskContentConfig) if (taskTypeId !== 'deriveFrom' && serializedTaskContentConfig) { registerTaskForm(taskTypeId, serializedTaskContentConfig) } } const mappedTaskTypeIds = Object.values(deriveFrom.valueMapping) const initialTaskTypeId = (taskFormConfigMap.has(requestedTaskTypeId) ? requestedTaskTypeId : undefined) ?? mappedTaskTypeIds.find((taskTypeId) => taskFormConfigMap .get(taskTypeId) ?.fields.some((field) => field.taskFiledValue.trim() === deriveFrom?.field) ) ?? mappedTaskTypeIds.find((taskTypeId) => taskFormConfigMap.has(taskTypeId)) ?? taskFormConfigMap.keys().next().value return typeof initialTaskTypeId === 'string' && activateTaskForm(initialTaskTypeId) } const restoreDerivedFieldValue = ( field: TaskFieldConfigViewItem, previousValue: FieldValue ): void => { const currentFieldValue = form.value.value[field.id] if (!currentFieldValue) { return } const previousValueString = previousValue === undefined || previousValue === '' ? '' : String(previousValue) const previousOption = field.options?.find((option) => option.value === previousValueString) currentFieldValue.value = previousValueString currentFieldValue.label = previousOption?.label ?? '' } /** * 多表单模式下,根据 deriveFrom 指定字段的值切换实际表单。 * * 切换时重新初始化目标表单,仅把派生字段当前选择同步到目标表单。 */ const handleDerivedFieldChange = ( field: TaskFieldConfigViewItem, event: DerivedFieldChangeEvent ): void => { if (!deriveFrom || field.taskFiledValue.trim() !== deriveFrom.field) { return } const targetTaskTypeId = deriveFrom.valueMapping[event.value] if ( !targetTaskTypeId || !activateTaskForm(targetTaskTypeId, { label: event.label, value: event.value, }) ) { restoreDerivedFieldValue(field, event.previousValue) console.warn(`[useTaskForm] 未找到派生值 ${event.value} 对应的表单配置`) uni.showToast({ title: '未找到对应的表单配置', icon: 'none', }) } } const loadTaskForm = async (taskTypeId: string): Promise => { const normalizedTaskTypeId = taskTypeId.trim() if (!normalizedTaskTypeId) { throw new Error('缺少 taskTypeId') } const requestVersion = ++loadRequestVersion try { const res = await getTaskContentConfigByTaskTypeIdApi(normalizedTaskTypeId) if (requestVersion !== loadRequestVersion) { return } const responseData = res.data const singleTaskContentConfig = serializeTaskContentConfigResponse(responseData) clearTaskFormState() if (singleTaskContentConfig) { loadSingleTaskForm(normalizedTaskTypeId, singleTaskContentConfig) return } if (!isRecord(responseData) || !loadMultipleTaskForms(responseData, normalizedTaskTypeId)) { throw new Error('动态表单配置格式无效') } } catch (error) { if (requestVersion !== loadRequestVersion) { return } clearTaskFormState() throw error } } return { form, taskFieldConfigList, activeTaskTypeId, taskTypes, loadTaskForm, resetTaskForm, handleDerivedFieldChange, } }