useTaskForm.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655
  1. import { ref } from 'vue'
  2. import dayjs from 'dayjs'
  3. import { getTaskContentConfigByTaskTypeIdApi } from '@/services/modules/task/taskFrom'
  4. import type {
  5. TaskContentConfig,
  6. TaskContentConfigDeriveFrom,
  7. TaskFieldConfigItem,
  8. TaskFieldDictMap,
  9. TaskTypeItem,
  10. } from '@/services/modules/task/taskFrom/type'
  11. import { useUserStore } from '@/stores/modules/user'
  12. import { normalizeTaskFieldType } from './useTaskFieldType'
  13. export type FieldValue = string
  14. /**
  15. * 日期字段展示格式。
  16. */
  17. export const TASK_DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm'
  18. export const HIDE_FIELD_DEPT_ID = ['1611890693', '1611890565']
  19. /**
  20. * 这些 taskTypeId 的 datetime 字段不自动初始化当前时间。
  21. */
  22. const EXCLUDED_AUTO_DATETIME_BASE_TYPE_IDS = new Set(['81', '85', '86', '87'])
  23. const ON_SITE_VISIT_TASK_TYPE_ID = '85'
  24. const ON_SITE_VISIT_TYPE = '现场拜访'
  25. const ON_SITE_VISIT_READONLY_FIELD_ALIASES = new Set(['level', 'visitType'])
  26. export const MULTIPLE_SELECT_DRUG_TASK_TYPE_IDS = new Set([
  27. '1',
  28. '2',
  29. '3',
  30. '4',
  31. '19',
  32. '64',
  33. '75',
  34. '76',
  35. '81',
  36. '85',
  37. ])
  38. /**
  39. * 单选、多选组件统一使用的选项结构。
  40. *
  41. * label、value 以及其他扩展属性统一转换为字符串。
  42. */
  43. export interface TaskFieldOption {
  44. label: string
  45. value: string
  46. [key: string]: string
  47. }
  48. export type TaskFieldConfigViewItem = Omit<TaskFieldConfigItem, 'id'> & {
  49. id: string
  50. originalTaskFiledType: string
  51. show: boolean
  52. readonly: boolean
  53. options: TaskFieldOption[] | null
  54. }
  55. export interface DynamicFormFieldValue {
  56. /**
  57. * 页面展示值。
  58. *
  59. * datetime 字段使用 YYYY-MM-DD HH:mm。
  60. */
  61. label: FieldValue
  62. /**
  63. * 实际提交值。
  64. *
  65. * datetime 字段使用 YYYY-MM-DD HH:mm。
  66. */
  67. value: FieldValue
  68. }
  69. export interface DynamicFormState {
  70. value: Record<string, DynamicFormFieldValue>
  71. }
  72. export interface DerivedFieldChangeEvent {
  73. previousValue: FieldValue
  74. value: string
  75. label: string
  76. }
  77. export interface SingleSelectChangeEvent extends DerivedFieldChangeEvent {
  78. selectedItem: TaskFieldOption
  79. }
  80. interface RegisteredTaskForm {
  81. fields: TaskFieldConfigViewItem[]
  82. taskTypes: TaskTypeItem[]
  83. }
  84. const createEmptyFieldValue = (): DynamicFormFieldValue => ({
  85. label: '',
  86. value: '',
  87. })
  88. const createEmptyFormState = (): DynamicFormState => ({
  89. value: {},
  90. })
  91. /**
  92. * 将日期统一截断到分钟。
  93. *
  94. * 避免 label 只展示到分钟,但 value 仍包含秒和毫秒。
  95. */
  96. const normalizeDateTimeToMinute = (date: ReturnType<typeof dayjs>): ReturnType<typeof dayjs> => {
  97. return date.second(0).millisecond(0)
  98. }
  99. /**
  100. * 判断当前 datetime 字段是否需要自动初始化当前时间。
  101. */
  102. const shouldInitializeCurrentDateTime = (field: TaskFieldConfigViewItem): boolean => {
  103. const fieldType = String(field.taskFiledType ?? '').trim()
  104. const taskTypeId = String(field.taskTypeId ?? '').trim()
  105. return fieldType === 'datetime' && !EXCLUDED_AUTO_DATETIME_BASE_TYPE_IDS.has(taskTypeId)
  106. }
  107. const isOnSiteVisitField = (
  108. field: Pick<TaskFieldConfigItem, 'taskTypeId' | 'alias'>,
  109. alias: string
  110. ): boolean => {
  111. return (
  112. String(field.taskTypeId).trim() === ON_SITE_VISIT_TASK_TYPE_ID && field.alias?.trim() === alias
  113. )
  114. }
  115. const shouldReadonlyField = (field: TaskFieldConfigItem): boolean => {
  116. return (
  117. String(field.taskTypeId).trim() === ON_SITE_VISIT_TASK_TYPE_ID &&
  118. ON_SITE_VISIT_READONLY_FIELD_ALIASES.has(field.alias?.trim() ?? '')
  119. )
  120. }
  121. const shouldShowField = (field: TaskFieldConfigItem, deptId: string): boolean => {
  122. // 旧页面仅在 longtext 分支读取 show,因此这里显式保留原有隐藏范围。
  123. const hidesLongTextField =
  124. String(field.taskTypeId).trim() === '18' &&
  125. field.taskFiledType.trim() === 'longtext' &&
  126. HIDE_FIELD_DEPT_ID.includes(deptId)
  127. return !hidesLongTextField
  128. }
  129. export const isMultipleSelectDrugField = (
  130. field: Pick<TaskFieldConfigItem, 'taskTypeId' | 'alias'>
  131. ): boolean => {
  132. return (
  133. MULTIPLE_SELECT_DRUG_TASK_TYPE_IDS.has(String(field.taskTypeId).trim()) &&
  134. field.alias?.trim() === 'drugs'
  135. )
  136. }
  137. const serializeTaskFieldType = (field: TaskFieldConfigItem): string => {
  138. if (isMultipleSelectDrugField(field)) return 'multiple_select'
  139. return normalizeTaskFieldType(field.taskFiledType)
  140. }
  141. /**
  142. * 创建字段初始值。
  143. *
  144. * 符合旧业务规则的 datetime 字段:
  145. * - label 为 YYYY-MM-DD HH:mm
  146. * - value 为 YYYY-MM-DD HH:mm
  147. *
  148. * 其他字段初始化为空。
  149. */
  150. const createInitialFieldValue = (
  151. field: TaskFieldConfigViewItem,
  152. initialDateTime: ReturnType<typeof dayjs>
  153. ): DynamicFormFieldValue => {
  154. if (isOnSiteVisitField(field, 'visitType')) {
  155. return {
  156. label: ON_SITE_VISIT_TYPE,
  157. value: ON_SITE_VISIT_TYPE,
  158. }
  159. }
  160. if (!shouldInitializeCurrentDateTime(field)) {
  161. return createEmptyFieldValue()
  162. }
  163. const normalizedDateTime = normalizeDateTimeToMinute(initialDateTime)
  164. return {
  165. label: normalizedDateTime.format(TASK_DATE_TIME_FORMAT),
  166. value: normalizedDateTime.format(TASK_DATE_TIME_FORMAT),
  167. }
  168. }
  169. /**
  170. * 将字典或特殊接口返回的数据转换为统一选项格式。
  171. *
  172. * 可以在页面的 beforeOpen 中复用。
  173. */
  174. export const serializeTaskFieldOptions = (source: unknown): TaskFieldOption[] => {
  175. if (!Array.isArray(source)) {
  176. return []
  177. }
  178. return source.reduce<TaskFieldOption[]>((result, item) => {
  179. if (!item || typeof item !== 'object') {
  180. return result
  181. }
  182. const rawItem = item as Record<string, unknown>
  183. const normalizedItem = Object.entries(rawItem).reduce<Record<string, string>>(
  184. (record, [key, value]) => {
  185. record[key] = value === undefined || value === null ? '' : String(value)
  186. return record
  187. },
  188. {}
  189. )
  190. if (!normalizedItem.label || !normalizedItem.value) {
  191. return result
  192. }
  193. result.push({
  194. ...normalizedItem,
  195. label: normalizedItem.label,
  196. value: normalizedItem.value,
  197. })
  198. return result
  199. }, [])
  200. }
  201. /**
  202. * 获取字段已有的字典内容。
  203. *
  204. * 以下情况返回 null:
  205. * 1. 字段没有 dictGroupName
  206. * 2. 返回字典中不存在该分组
  207. * 3. 对应字典不是数组
  208. * 4. 对应字典没有有效选项
  209. */
  210. const getFieldDictOptions = (
  211. field: TaskFieldConfigItem,
  212. dict: TaskFieldDictMap
  213. ): TaskFieldOption[] | null => {
  214. const dictName = field.dictGroupName?.trim()
  215. if (!dictName) {
  216. return null
  217. }
  218. const source = dict[dictName]
  219. if (!Array.isArray(source)) {
  220. return null
  221. }
  222. const options = serializeTaskFieldOptions(source)
  223. return options.length > 0 ? options : null
  224. }
  225. /**
  226. * 将接口原始字段配置转换成页面字段配置。
  227. *
  228. * 不处理任何特殊接口或特殊业务判断。
  229. */
  230. const serializeTaskFieldConfig = (
  231. config: TaskFieldConfigItem[],
  232. dict: TaskFieldDictMap,
  233. deptId: string
  234. ): TaskFieldConfigViewItem[] => {
  235. return config.map((field) => ({
  236. ...field,
  237. id: String(field.id),
  238. originalTaskFiledType: normalizeTaskFieldType(field.taskFiledType),
  239. taskFiledType: serializeTaskFieldType(field),
  240. show: shouldShowField(field, deptId),
  241. readonly: shouldReadonlyField(field),
  242. options: getFieldDictOptions(field, dict),
  243. }))
  244. }
  245. const isRecord = (source: unknown): source is Record<string, unknown> => {
  246. return Boolean(source) && typeof source === 'object' && !Array.isArray(source)
  247. }
  248. const isTaskFieldConfigItem = (source: unknown): source is TaskFieldConfigItem => {
  249. if (!isRecord(source)) return false
  250. const id = source.id
  251. return (
  252. (typeof id === 'string' || typeof id === 'number') &&
  253. String(id).trim().length > 0 &&
  254. typeof source.taskTypeId === 'string' &&
  255. typeof source.taskFiledKey === 'string' &&
  256. typeof source.taskFiledValue === 'string' &&
  257. source.taskFiledValue.trim().length > 0 &&
  258. typeof source.taskFiledType === 'string' &&
  259. source.taskFiledType.trim().length > 0 &&
  260. (source.alias === null || typeof source.alias === 'string') &&
  261. (source.dictGroupName === null || typeof source.dictGroupName === 'string')
  262. )
  263. }
  264. const serializeTaskTypes = (source: unknown): TaskTypeItem[] => {
  265. if (!Array.isArray(source)) {
  266. return []
  267. }
  268. return source.reduce<TaskTypeItem[]>((result, item) => {
  269. if (!isRecord(item)) {
  270. return result
  271. }
  272. const id = item.id
  273. const taskScoreEnd = Number(item.taskScoreEnd)
  274. if (
  275. (typeof id === 'string' || typeof id === 'number') &&
  276. String(id).trim() &&
  277. Number.isFinite(taskScoreEnd)
  278. ) {
  279. result.push({ ...item, id: String(id), taskScoreEnd } as unknown as TaskTypeItem)
  280. }
  281. return result
  282. }, [])
  283. }
  284. const serializeTaskContentConfigResponse = (source: unknown): TaskContentConfig | null => {
  285. if (!isRecord(source)) {
  286. return null
  287. }
  288. if (
  289. !Array.isArray(source.config) ||
  290. source.config.length === 0 ||
  291. !source.config.every(isTaskFieldConfigItem)
  292. ) {
  293. return null
  294. }
  295. return {
  296. config: source.config,
  297. dict: isRecord(source.dict) ? (source.dict as TaskFieldDictMap) : {},
  298. taskTypes: serializeTaskTypes(source.taskTypes),
  299. }
  300. }
  301. const serializeDeriveFrom = (source: unknown): TaskContentConfigDeriveFrom | null => {
  302. if (!isRecord(source) || typeof source.field !== 'string' || !isRecord(source.valueMapping)) {
  303. return null
  304. }
  305. const field = source.field.trim()
  306. const valueMapping = Object.entries(source.valueMapping).reduce<Record<string, string>>(
  307. (result, [value, taskTypeId]) => {
  308. const normalizedTaskTypeId =
  309. typeof taskTypeId === 'string' || typeof taskTypeId === 'number'
  310. ? String(taskTypeId).trim()
  311. : ''
  312. if (normalizedTaskTypeId) {
  313. result[String(value)] = normalizedTaskTypeId
  314. }
  315. return result
  316. },
  317. {}
  318. )
  319. if (!field || !Object.keys(valueMapping).length) {
  320. return null
  321. }
  322. return {
  323. field,
  324. valueMapping,
  325. }
  326. }
  327. /**
  328. * 负责:
  329. * 1. 获取动态表单配置
  330. * 2. 将字段 id 转换为字符串
  331. * 3. 挂载已有字典 options
  332. * 4. 初始化动态表单值
  333. * 5. 自动初始化符合条件的 datetime 字段
  334. *
  335. * 特殊字段的接口请求不在这里处理,
  336. * 由页面的 beforeOpen 自行请求并覆盖 field.options。
  337. */
  338. export const useTaskForm = () => {
  339. const userStore = useUserStore()
  340. const form = ref<DynamicFormState>(createEmptyFormState())
  341. const taskFieldConfigList = ref<TaskFieldConfigViewItem[]>([])
  342. const activeTaskTypeId = ref('')
  343. const taskTypes = ref<TaskTypeItem[]>([])
  344. const taskFormConfigMap = new Map<string, RegisteredTaskForm>()
  345. let deriveFrom: TaskContentConfigDeriveFrom | null = null
  346. let loadRequestVersion = 0
  347. const createFormState = (config: TaskFieldConfigViewItem[]): DynamicFormState => {
  348. const formValue: DynamicFormState['value'] = {}
  349. /**
  350. * 同一次初始化使用同一个时间点,
  351. * 避免多个 datetime 字段出现分钟边界差异。
  352. */
  353. const initialDateTime = dayjs()
  354. for (const field of config) {
  355. formValue[field.id] = createInitialFieldValue(field, initialDateTime)
  356. }
  357. return {
  358. value: formValue,
  359. }
  360. }
  361. const clearTaskFormState = (): void => {
  362. form.value = createEmptyFormState()
  363. taskFieldConfigList.value = []
  364. activeTaskTypeId.value = ''
  365. taskTypes.value = []
  366. taskFormConfigMap.clear()
  367. deriveFrom = null
  368. }
  369. const resetTaskForm = (): void => {
  370. loadRequestVersion += 1
  371. clearTaskFormState()
  372. }
  373. const activateTaskForm = (
  374. taskTypeId: string,
  375. derivedFieldValue?: DynamicFormFieldValue
  376. ): boolean => {
  377. const normalizedTaskTypeId = taskTypeId.trim()
  378. const targetTaskForm = taskFormConfigMap.get(normalizedTaskTypeId)
  379. if (!targetTaskForm) {
  380. return false
  381. }
  382. const { fields: targetConfig, taskTypes: targetTaskTypes } = targetTaskForm
  383. /**
  384. * 每次切换都重新创建目标表单状态:
  385. * - 不保留当前表单已经输入的内容
  386. * - 不恢复目标表单上一次输入的内容
  387. * - datetime 等字段继续使用原有初始化规则
  388. */
  389. const targetFormState = createFormState(targetConfig)
  390. if (deriveFrom && derivedFieldValue) {
  391. const derivedFieldName = deriveFrom.field
  392. const targetDerivedField = targetConfig.find(
  393. (field) => field.taskFiledValue.trim() === derivedFieldName
  394. )
  395. if (targetDerivedField) {
  396. targetFormState.value[targetDerivedField.id] = {
  397. label: derivedFieldValue.label,
  398. value: derivedFieldValue.value,
  399. }
  400. }
  401. }
  402. activeTaskTypeId.value = normalizedTaskTypeId
  403. taskFieldConfigList.value = targetConfig
  404. taskTypes.value = targetTaskTypes
  405. form.value = targetFormState
  406. return true
  407. }
  408. const registerTaskForm = (taskTypeId: string, taskContentConfig: TaskContentConfig): void => {
  409. const normalizedTaskTypeId = taskTypeId.trim()
  410. if (!normalizedTaskTypeId) {
  411. return
  412. }
  413. const config = serializeTaskFieldConfig(
  414. taskContentConfig.config,
  415. taskContentConfig.dict,
  416. String(userStore.currentUserInfo?.deptId ?? '')
  417. )
  418. const fieldIds = new Set(config.map((field) => field.id))
  419. const submitKeys = new Set(config.map((field) => field.taskFiledValue))
  420. if (fieldIds.size !== config.length || submitKeys.size !== config.length) {
  421. throw new Error(`任务 ${normalizedTaskTypeId} 的字段 ID 或提交键重复`)
  422. }
  423. taskFormConfigMap.set(normalizedTaskTypeId, {
  424. fields: config,
  425. taskTypes: taskContentConfig.taskTypes ?? [],
  426. })
  427. }
  428. const loadSingleTaskForm = (taskTypeId: string, taskContentConfig: TaskContentConfig): void => {
  429. registerTaskForm(taskTypeId, taskContentConfig)
  430. activateTaskForm(taskTypeId)
  431. }
  432. const loadMultipleTaskForms = (
  433. source: Record<string, unknown>,
  434. requestedTaskTypeId: string
  435. ): boolean => {
  436. deriveFrom = serializeDeriveFrom(source.deriveFrom)
  437. if (!deriveFrom) {
  438. return false
  439. }
  440. for (const [taskTypeId, taskContentConfig] of Object.entries(source)) {
  441. const serializedTaskContentConfig = serializeTaskContentConfigResponse(taskContentConfig)
  442. if (taskTypeId !== 'deriveFrom' && serializedTaskContentConfig) {
  443. registerTaskForm(taskTypeId, serializedTaskContentConfig)
  444. }
  445. }
  446. const mappedTaskTypeIds = Object.values(deriveFrom.valueMapping)
  447. const initialTaskTypeId =
  448. (taskFormConfigMap.has(requestedTaskTypeId) ? requestedTaskTypeId : undefined) ??
  449. mappedTaskTypeIds.find((taskTypeId) =>
  450. taskFormConfigMap
  451. .get(taskTypeId)
  452. ?.fields.some((field) => field.taskFiledValue.trim() === deriveFrom?.field)
  453. ) ??
  454. mappedTaskTypeIds.find((taskTypeId) => taskFormConfigMap.has(taskTypeId)) ??
  455. taskFormConfigMap.keys().next().value
  456. return typeof initialTaskTypeId === 'string' && activateTaskForm(initialTaskTypeId)
  457. }
  458. const restoreDerivedFieldValue = (
  459. field: TaskFieldConfigViewItem,
  460. previousValue: FieldValue
  461. ): void => {
  462. const currentFieldValue = form.value.value[field.id]
  463. if (!currentFieldValue) {
  464. return
  465. }
  466. const previousValueString =
  467. previousValue === undefined || previousValue === '' ? '' : String(previousValue)
  468. const previousOption = field.options?.find((option) => option.value === previousValueString)
  469. currentFieldValue.value = previousValueString
  470. currentFieldValue.label = previousOption?.label ?? ''
  471. }
  472. /**
  473. * 多表单模式下,根据 deriveFrom 指定字段的值切换实际表单。
  474. *
  475. * 切换时重新初始化目标表单,仅把派生字段当前选择同步到目标表单。
  476. */
  477. const handleDerivedFieldChange = (
  478. field: TaskFieldConfigViewItem,
  479. event: DerivedFieldChangeEvent
  480. ): void => {
  481. if (!deriveFrom || field.taskFiledValue.trim() !== deriveFrom.field) {
  482. return
  483. }
  484. const targetTaskTypeId = deriveFrom.valueMapping[event.value]
  485. if (
  486. !targetTaskTypeId ||
  487. !activateTaskForm(targetTaskTypeId, {
  488. label: event.label,
  489. value: event.value,
  490. })
  491. ) {
  492. restoreDerivedFieldValue(field, event.previousValue)
  493. console.warn(`[useTaskForm] 未找到派生值 ${event.value} 对应的表单配置`)
  494. uni.showToast({
  495. title: '未找到对应的表单配置',
  496. icon: 'none',
  497. })
  498. }
  499. }
  500. const loadTaskForm = async (taskTypeId: string): Promise<void> => {
  501. const normalizedTaskTypeId = taskTypeId.trim()
  502. if (!normalizedTaskTypeId) {
  503. throw new Error('缺少 taskTypeId')
  504. }
  505. const requestVersion = ++loadRequestVersion
  506. try {
  507. const res = await getTaskContentConfigByTaskTypeIdApi(normalizedTaskTypeId)
  508. if (requestVersion !== loadRequestVersion) {
  509. return
  510. }
  511. const responseData = res.data
  512. const singleTaskContentConfig = serializeTaskContentConfigResponse(responseData)
  513. clearTaskFormState()
  514. if (singleTaskContentConfig) {
  515. loadSingleTaskForm(normalizedTaskTypeId, singleTaskContentConfig)
  516. return
  517. }
  518. if (!isRecord(responseData) || !loadMultipleTaskForms(responseData, normalizedTaskTypeId)) {
  519. throw new Error('动态表单配置格式无效')
  520. }
  521. } catch (error) {
  522. if (requestVersion !== loadRequestVersion) {
  523. return
  524. }
  525. clearTaskFormState()
  526. throw error
  527. }
  528. }
  529. return {
  530. form,
  531. taskFieldConfigList,
  532. activeTaskTypeId,
  533. taskTypes,
  534. loadTaskForm,
  535. resetTaskForm,
  536. handleDerivedFieldChange,
  537. }
  538. }