From 8534025c45b4736975730678b9ccf23570a75f95 Mon Sep 17 00:00:00 2001
From: liuyu <yu.liu@cqgbkj.com>
Date: 星期二, 22 九月 2026 09:25:48 +0800
Subject: [PATCH] feat(tms): 新增试卷、培训任务、个人任务页面

---
 apps/jnpf-web-apps-main/src/views/x/tms/task/Form.vue |  959 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 files changed, 959 insertions(+), 0 deletions(-)

diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/task/Form.vue b/apps/jnpf-web-apps-main/src/views/x/tms/task/Form.vue
new file mode 100644
index 0000000..863885a
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/task/Form.vue
@@ -0,0 +1,959 @@
+<script lang="ts" setup>
+/**
+ * 鍩硅浠诲姟琛ㄥ崟锛堝榻愯鏄庝功 6.2.18锛�
+ * - 绌虹櫧鏂板 = 涓存椂鍩硅锛氬唴瀹瑰瓧娈靛彲濉�
+ * - 璁″垝甯﹀嚭浠诲姟锛堝勾璁″垝/宀楄鍒掔瓑锛夛細缂栧彿/鏉ユ簮鍙锛屽叾浣欏唴瀹逛笌鎵ц淇℃伅鍧囧彲缁存姢鍚庡彂甯�
+ */
+import type { TaskEntity, TaskFile } from './types';
+
+import { computed, h, onMounted, reactive, ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+
+import { useMessage } from '@jnpf/hooks';
+import { BasicForm, useForm } from '@jnpf/ui/form';
+
+import { Alert as AAlert, Steps as ASteps } from 'ant-design-vue';
+import dayjs from 'dayjs';
+
+import { getCoursePaperOptions } from '#/api/x/tms/course';
+import { getPlaceOptions } from '#/api/x/tms/place';
+import { createTask, getTaskInfo, publishTask, updateTask } from '#/api/x/tms/task';
+import { JnpfPopupSelect } from '#/components/Jnpf/popupSelect';
+import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
+import { TMS_BTN } from '#/views/x/tms/shared/ui';
+
+import { EVAL_MODE_OPTIONS, loadTaskDics, SOURCE_TYPE_OPTIONS, TASK_CATEGORY_OPTIONS, TASK_KIND_OPTIONS, TRAIN_MODE_OPTIONS } from './constants';
+
+defineOptions({ name: 'TmsTaskForm' });
+
+const DT_FMT = 'YYYY-MM-DD HH:mm:ss';
+
+/** 鏃ユ湡缁勪欢鏍¢獙绫诲瀷鏄� number锛屽瓧绗︿覆浼氳褰撴垚绌哄�� */
+function toTimeNumber(value?: null | number | string) {
+  if (value == null || value === '') return undefined;
+  if (typeof value === 'number' && !Number.isNaN(value)) return value;
+  const text = String(value).trim();
+  if (/^\d{10,13}$/.test(text)) {
+    const n = Number(text);
+    return text.length === 10 ? n * 1000 : n;
+  }
+  const date = dayjs(text);
+  return date.isValid() ? date.valueOf() : undefined;
+}
+
+function formatTaskTime(value?: null | number | string) {
+  if (value == null || value === '') return undefined;
+  const date = dayjs(value);
+  return date.isValid() ? date.format(DT_FMT) : undefined;
+}
+/** 鍙敱绌虹櫧鏂板鐨勬潵婧愶紙璇存槑涔︼細涓存椂锛涙枃浠剁瓑鐢辩郴缁熷甫鍑猴級 */
+const BLANK_CREATE_SOURCES = new Set(['temp']);
+
+const route = useRoute();
+const router = useRouter();
+const { createMessage } = useMessage();
+
+const loading = ref(false);
+const submitting = ref(false);
+const paperOptions = ref<{ fullName: string; id: string }[]>([]);
+const placeOptions = ref<{ fullName: string; id: string; placeNo?: string }[]>([]);
+/** 闈炶崏绋跨瓑鍦烘櫙閿佸唴瀹癸紱璁″垝甯﹀嚭榛樿鍙敼 */
+const contentLocked = ref(false);
+const taskFiles = ref<TaskFile[]>([]);
+const uploadFiles = ref<any[]>([]);
+
+/** 鏉ヨ嚜璁″垝/绯荤粺甯﹀嚭锛氫富棰樼瓑鍐呭鍙 */
+const fromPlan = ref(false);
+/** 1=鍩硅鍐呭 2=鎵ц瀹夋帓 */
+const currentStep = ref(1);
+const state = reactive({ id: '', publishStatus: 'draft' as string });
+
+const isEdit = computed(() => !!route.params.id && route.params.id !== 'create');
+const pageTitle = computed(() => {
+  if (!isEdit.value) return '鏂板涓存椂鍩硅浠诲姟';
+  return fromPlan.value ? '缁存姢鍩硅浠诲姟锛堣鍒掑甫鍑猴級' : '缂栬緫涓存椂鍩硅浠诲姟';
+});
+const tipText = computed(() => {
+  if (currentStep.value === 1) {
+    if (fromPlan.value) {
+      return '璁″垝宸插甫鍑轰富棰樸�佹柟寮忋�佸璞$瓑锛涘彲缁х画琛ュ叏鎴栦慨鏀瑰悗杩涘叆涓嬩竴姝ャ�傜紪鍙蜂笌鏉ユ簮涓嶅彲鏀广��';
+    }
+    return '鍏堝~鍐欏煿璁唴瀹癸紝涓嬩竴姝ュ啀瀹夋帓鏃堕棿銆佸湴鐐广�佽瘯鍗风瓑鎵ц淇℃伅銆�';
+  }
+  if (fromPlan.value) {
+    return '璇疯ˉ鍏ㄦ墽琛屽畨鎺掑悗淇濆瓨鎴栧彂甯冦�傚彲杩斿洖涓婁竴姝ヤ慨鏀瑰煿璁唴瀹广��';
+  }
+  return '璇峰~鍐欐墽琛屽畨鎺掞紱鍙繑鍥炰笂涓�姝ヤ慨鏀瑰煿璁唴瀹广��';
+});
+const stepItems = computed(() => [{ title: fromPlan.value ? '璁″垝鍐呭锛堝彲缁存姢锛�' : '鍩硅鍐呭' }, { title: fromPlan.value ? '鎵ц瀹夋帓锛堝彂甯冿級' : '鎵ц瀹夋帓' }]);
+
+const [registerContentForm, contentFormApi] = useForm({
+  labelWidth: 120,
+  baseColProps: { span: 12 },
+  schemas: [
+    {
+      field: 'taskNo',
+      label: '鍩硅缂栧彿',
+      component: 'Input',
+      componentProps: { disabled: true, placeholder: '鑽夌鍙蜂繚瀛樼敓鎴愶紝鍙戝竷鍚庢寮忕紪鍙�' },
+    },
+    {
+      field: 'publishStatusLabel',
+      label: '鍙戝竷鐘舵��',
+      component: 'Input',
+      componentProps: { disabled: true },
+      ifShow: () => isEdit.value,
+    },
+    {
+      field: 'sourceType',
+      label: '鏉ユ簮绫诲瀷',
+      component: 'Select',
+      defaultValue: 'temp',
+      componentProps: {
+        disabled: true,
+        options: [],
+        fieldNames: TMS_DIC_FIELD_NAMES,
+      },
+    },
+    {
+      field: 'category',
+      label: '鍩硅鍒嗙被',
+      component: 'Select',
+      defaultValue: 'temp',
+      componentProps: {
+        disabled: true,
+        options: [],
+        fieldNames: TMS_DIC_FIELD_NAMES,
+      },
+    },
+    {
+      field: 'sourcePlanNo',
+      label: '鏉ユ簮璁″垝鍙�',
+      component: 'Input',
+      componentProps: { disabled: true, placeholder: '璁″垝甯﹀嚭' },
+      ifShow: () => fromPlan.value,
+    },
+    {
+      field: 'subject',
+      label: '鍩硅涓婚',
+      component: 'Input',
+      componentProps: { placeholder: '璇疯緭鍏ュ煿璁富棰�', maxlength: 200 },
+      rules: [{ required: true, message: '蹇呭~', trigger: 'blur' }],
+    },
+    {
+      field: 'trainMode',
+      label: '鍩硅鏂瑰紡',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        placeholder: '璇烽�夋嫨',
+        options: [],
+        fieldNames: TMS_DIC_FIELD_NAMES,
+        onChange: (val: string) => syncModeRules(val),
+      },
+      rules: [{ required: true, message: '蹇呭~', trigger: 'change' }],
+    },
+    {
+      field: 'evalMode',
+      label: '鑰冩牳鏂瑰紡',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        placeholder: '璇烽�夋嫨',
+        options: [],
+        fieldNames: TMS_DIC_FIELD_NAMES,
+        onChange: (val: string) => syncEvalRules(val),
+      },
+      rules: [{ required: true, message: '蹇呭~', trigger: 'change' }],
+    },
+    {
+      field: 'teacherId',
+      label: '鍩硅甯�',
+      component: 'UserSelect',
+      componentProps: { placeholder: '璇烽�夋嫨鍩硅甯�' },
+    },
+    {
+      field: 'traineeIds',
+      label: '鍩硅瀵硅薄',
+      component: 'UserSelect',
+      componentProps: { placeholder: '璇烽�夋嫨鍩硅瀵硅薄', multiple: true },
+      rules: [{ required: true, type: 'array', min: 1, message: '蹇呭~', trigger: 'change' }],
+    },
+    {
+      field: 'materialId',
+      label: '鍩硅鏁欐潗',
+      component: 'Input',
+      render: ({ model }) =>
+        h(JnpfPopupSelect, {
+          columnOptions: [
+            { label: '鏁欐潗缂栧彿', value: 'materialno' },
+            { label: '鏁欐潗鍚嶇О', value: 'fullname' },
+            {
+              label: '鏂囦欢绫诲瀷',
+              value: 'filetype',
+              jnpfKey: 'select',
+              props: { label: 'fullName', value: 'enCode' },
+              __config__: {
+                jnpfKey: 'select',
+                dataType: 'dictionary',
+                dictionaryType: 'tmsdt008',
+              },
+            },
+          ],
+          disabled: contentLocked.value,
+          hasPage: true,
+          pageSize: 20,
+          interfaceId: '871690100000000001',
+          placeholder: '璇烽�夋嫨鍩硅鏁欐潗',
+          popupTitle: '閫夋嫨鏁版嵁',
+          popupType: 'popover',
+          popupWidth: '800px',
+          propsValue: 'id',
+          relationField: 'fullname',
+          value: model.materialId || undefined,
+          'onUpdate:value': (id?: string) => {
+            contentFormApi.setFieldsValue({ materialId: id || undefined });
+            contentFormApi.clearValidate(['materialId']);
+            if (!id) return;
+          },
+          onChange: (_id: string, row: any) => {
+            const data = row || {};
+            contentFormApi.setFieldsValue({
+              materialNo: data.materialno || undefined,
+            });
+            ensureMainFile(data);
+          },
+        }),
+    },
+    {
+      field: 'taskKind',
+      label: '浠诲姟绫诲瀷',
+      component: 'Select',
+      defaultValue: 'new',
+      componentProps: {
+        options: [],
+        fieldNames: TMS_DIC_FIELD_NAMES,
+      },
+    },
+    {
+      field: 'prevTaskNo',
+      label: '涓婃浠诲姟缂栧彿',
+      component: 'Input',
+      componentProps: { placeholder: '鍐嶇淮鎶ゆ椂鐢辩郴缁熷甫鍑�', disabled: true },
+      ifShow: ({ values }) => values.taskKind === 'continue',
+    },
+    {
+      field: 'keyPoints',
+      label: '鍩硅瑕佺偣',
+      component: 'Textarea',
+      colProps: { span: 24 },
+      componentProps: { rows: 2, placeholder: '閫夊~', maxlength: 2000 },
+    },
+  ],
+});
+
+const [registerExecForm, execFormApi] = useForm({
+  labelWidth: 120,
+  baseColProps: { span: 12 },
+  schemas: [
+    {
+      field: 'startTime',
+      label: '寮�濮嬫椂闂�',
+      component: 'DatePicker',
+      componentProps: {
+        showTime: true,
+        format: DT_FMT,
+        valueFormat: DT_FMT,
+        style: { width: '100%' },
+      },
+      rules: [{ required: true, message: '蹇呭~', trigger: 'change' }],
+    },
+    {
+      field: 'endTime',
+      label: '缁撴潫鏃堕棿',
+      component: 'DatePicker',
+      componentProps: {
+        showTime: true,
+        format: DT_FMT,
+        valueFormat: DT_FMT,
+        style: { width: '100%' },
+      },
+      rules: [{ required: true, message: '蹇呭~', trigger: 'change' }],
+    },
+    {
+      field: 'closeTime',
+      label: '鍏抽棴鏃堕棿',
+      component: 'DatePicker',
+      componentProps: {
+        showTime: true,
+        format: DT_FMT,
+        valueFormat: DT_FMT,
+        style: { width: '100%' },
+      },
+      rules: [{ required: true, message: '蹇呭~', trigger: 'change' }],
+    },
+    {
+      field: 'placeId',
+      label: '鍩硅鍦扮偣',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        showSearch: true,
+        placeholder: '闆嗕腑/鎿嶄綔鎺堣蹇呴��',
+        options: [],
+        optionFilterProp: 'fullName',
+      },
+    },
+    {
+      field: 'paperId',
+      label: '鑰冩牳璇曞嵎',
+      component: 'Select',
+      componentProps: {
+        allowClear: true,
+        showSearch: true,
+        placeholder: '鍦ㄧ嚎鑰冭瘯鏃跺繀閫�',
+        options: [],
+        optionFilterProp: 'fullName',
+      },
+    },
+    {
+      field: 'evalMode',
+      label: '鑰冩牳鏂瑰紡',
+      component: 'Input',
+      ifShow: () => false,
+    },
+    {
+      field: 'examStart',
+      label: '鑰冭瘯寮�濮�',
+      component: 'DatePicker',
+      componentProps: {
+        showTime: true,
+        format: DT_FMT,
+        valueFormat: DT_FMT,
+        style: { width: '100%' },
+      },
+      ifShow: ({ values }) => values.evalMode === 'exam',
+    },
+    {
+      field: 'examEnd',
+      label: '鑰冭瘯缁撴潫',
+      component: 'DatePicker',
+      componentProps: {
+        showTime: true,
+        format: DT_FMT,
+        valueFormat: DT_FMT,
+        style: { width: '100%' },
+      },
+      ifShow: ({ values }) => values.evalMode === 'exam',
+    },
+    {
+      field: 'retakeLimit',
+      label: '琛ヨ�冩鏁�',
+      component: 'InputNumber',
+      defaultValue: 1,
+      componentProps: { min: 0, max: 99, precision: 0, style: { width: '100%' } },
+      helpMessage: '涓嶅悎鏍煎彲琛ヨ�冩鏁帮紝榛樿 1',
+    },
+    {
+      field: 'passScore',
+      label: '鍚堟牸鍒�',
+      component: 'InputNumber',
+      componentProps: { min: 0, max: 999, precision: 1, style: { width: '100%' } },
+    },
+    {
+      field: 'overdueRemind',
+      label: '閫炬湡鎻愰啋',
+      component: 'Select',
+      defaultValue: '0',
+      componentProps: {
+        options: [
+          { id: '1', enCode: '1', fullName: '鏄�' },
+          { id: '0', enCode: '0', fullName: '鍚�' },
+        ],
+        fieldNames: TMS_DIC_FIELD_NAMES,
+      },
+    },
+    {
+      field: 'inviteFlag',
+      label: '鍙個璇锋梺鍚�',
+      component: 'Select',
+      defaultValue: '0',
+      componentProps: {
+        options: [
+          { id: '1', enCode: '1', fullName: '鏄�' },
+          { id: '0', enCode: '0', fullName: '鍚�' },
+        ],
+        fieldNames: TMS_DIC_FIELD_NAMES,
+      },
+    },
+    {
+      field: 'remark',
+      label: '澶囨敞',
+      component: 'Textarea',
+      colProps: { span: 24 },
+      componentProps: { rows: 2, placeholder: '閫夊~', maxlength: 500 },
+    },
+  ],
+});
+
+onMounted(async () => {
+  loading.value = true;
+  try {
+    await loadTaskDics();
+    await Promise.all([loadPapers(), loadPlaces()]);
+    refreshOptions();
+    if (isEdit.value) {
+      await loadDetail(String(route.params.id));
+    } else {
+      fromPlan.value = false;
+      contentFormApi.resetFields();
+      execFormApi.resetFields();
+      contentFormApi.setFieldsValue({
+        sourceType: 'temp',
+        category: 'temp',
+        taskKind: 'new',
+      });
+      execFormApi.setFieldsValue({
+        retakeLimit: 1,
+        overdueRemind: '0',
+        inviteFlag: '0',
+        startTime: dayjs().valueOf(),
+        endTime: dayjs().add(2, 'hour').valueOf(),
+        closeTime: dayjs().add(1, 'day').valueOf(),
+      });
+      applyContentEditable(true);
+      currentStep.value = 1;
+    }
+  } finally {
+    loading.value = false;
+  }
+});
+
+watch(
+  () => route.params.id,
+  async (id) => {
+    if (!id || id === 'create') return;
+    loading.value = true;
+    try {
+      await loadDetail(String(id));
+    } finally {
+      loading.value = false;
+    }
+  },
+);
+
+function isPlanSourced(info: TaskEntity) {
+  if (info.sourceId || info.sourceItemId || info.sourcePlanNo) return true;
+  const st = info.sourceType || '';
+  return !!st && !BLANK_CREATE_SOURCES.has(st);
+}
+
+function applyContentEditable(editable: boolean) {
+  contentLocked.value = !editable;
+  const disabled = !editable;
+  contentFormApi.updateSchema([
+    { field: 'subject', componentProps: { disabled, placeholder: disabled ? '' : '璇疯緭鍏ュ煿璁富棰�', maxlength: 200 } },
+    {
+      field: 'trainMode',
+      componentProps: {
+        disabled,
+        allowClear: !disabled,
+        options: TRAIN_MODE_OPTIONS.value,
+        fieldNames: TMS_DIC_FIELD_NAMES,
+        onChange: (val: string) => syncModeRules(val),
+      },
+    },
+    {
+      field: 'evalMode',
+      componentProps: {
+        disabled,
+        allowClear: !disabled,
+        options: EVAL_MODE_OPTIONS.value,
+        fieldNames: TMS_DIC_FIELD_NAMES,
+        onChange: (val: string) => {
+          syncEvalRules(val);
+        },
+      },
+    },
+    { field: 'teacherId', componentProps: { disabled, placeholder: '璇烽�夋嫨鍩硅甯�' } },
+    { field: 'traineeIds', componentProps: { disabled, placeholder: '璇烽�夋嫨鍩硅瀵硅薄', multiple: true } },
+    {
+      field: 'taskKind',
+      componentProps: {
+        disabled,
+        options: TASK_KIND_OPTIONS.value,
+        fieldNames: TMS_DIC_FIELD_NAMES,
+      },
+    },
+    { field: 'keyPoints', componentProps: { disabled, rows: 2, placeholder: disabled ? '' : '閫夊~', maxlength: 2000 } },
+  ]);
+}
+
+function refreshOptions() {
+  contentFormApi.updateSchema([
+    {
+      field: 'sourceType',
+      componentProps: {
+        disabled: true,
+        options: SOURCE_TYPE_OPTIONS.value,
+        fieldNames: TMS_DIC_FIELD_NAMES,
+      },
+    },
+    {
+      field: 'category',
+      componentProps: {
+        disabled: true,
+        options: TASK_CATEGORY_OPTIONS.value,
+        fieldNames: TMS_DIC_FIELD_NAMES,
+      },
+    },
+    {
+      field: 'trainMode',
+      componentProps: {
+        allowClear: true,
+        options: TRAIN_MODE_OPTIONS.value,
+        fieldNames: TMS_DIC_FIELD_NAMES,
+        onChange: (val: string) => syncModeRules(val),
+      },
+    },
+    {
+      field: 'evalMode',
+      componentProps: {
+        allowClear: true,
+        options: EVAL_MODE_OPTIONS.value,
+        fieldNames: TMS_DIC_FIELD_NAMES,
+        onChange: (val: string) => {
+          syncEvalRules(val);
+        },
+      },
+    },
+    {
+      field: 'taskKind',
+      componentProps: {
+        options: TASK_KIND_OPTIONS.value,
+        fieldNames: TMS_DIC_FIELD_NAMES,
+      },
+    },
+  ]);
+  execFormApi.updateSchema([
+    {
+      field: 'placeId',
+      componentProps: {
+        allowClear: true,
+        showSearch: true,
+        options: placeOptions.value,
+        optionFilterProp: 'fullName',
+      },
+    },
+    {
+      field: 'paperId',
+      componentProps: {
+        allowClear: true,
+        showSearch: true,
+        options: paperOptions.value,
+        optionFilterProp: 'fullName',
+      },
+    },
+  ]);
+}
+
+function syncModeRules(mode?: string) {
+  const needPlace = mode === 'onsite' || mode === 'practice';
+  const needMaterial = mode === 'online';
+  execFormApi.updateSchema([
+    {
+      field: 'placeId',
+      rules: needPlace ? [{ required: true, message: '鐜板満绫婚』閫夊湴鐐�', trigger: 'change' }] : [],
+    },
+  ]);
+  contentFormApi.updateSchema([
+    {
+      field: 'materialId',
+      rules: needMaterial ? [{ required: true, message: '鍦ㄧ嚎瀛︿範椤婚�夋暀鏉�', trigger: 'change' }] : [],
+    },
+  ]);
+  execFormApi.clearValidate(['placeId']);
+  contentFormApi.clearValidate(['materialId']);
+}
+
+function ensureMainFile(row: any) {
+  const materialId = row?.id;
+  const fileName = row?.fullname;
+  if (!materialId || !fileName) return;
+  if (taskFiles.value.some((item) => item.materialId === materialId)) return;
+  taskFiles.value = [
+    {
+      materialId,
+      fileNo: row.materialno,
+      fileName,
+      canDownload: '0',
+    },
+    ...taskFiles.value,
+  ];
+}
+
+function removeTaskFile(index: number) {
+  const removed = taskFiles.value[index];
+  taskFiles.value = taskFiles.value.filter((_, i) => i !== index);
+  if (!removed?.fileJson) return;
+  try {
+    const file = JSON.parse(removed.fileJson);
+    uploadFiles.value = uploadFiles.value.filter((item) => item.fileId !== file.fileId);
+  } catch {
+    // 闈炰笂浼犻檮浠�
+  }
+}
+
+function onUploadChange(list: any[]) {
+  const files = Array.isArray(list) ? list : [];
+  uploadFiles.value = files;
+  for (const file of files) {
+    if (!file?.name) continue;
+    const existed = taskFiles.value.some((row) => {
+      if (!row.fileJson) return false;
+      try {
+        return JSON.parse(row.fileJson).fileId === file.fileId;
+      } catch {
+        return false;
+      }
+    });
+    if (existed) continue;
+    taskFiles.value = [
+      ...taskFiles.value,
+      {
+        fileNo: file.fileId,
+        fileName: file.name,
+        fileJson: JSON.stringify(file),
+        canDownload: '1',
+      },
+    ];
+  }
+}
+
+function syncEvalRules(evalMode?: string) {
+  execFormApi.updateSchema([
+    {
+      field: 'paperId',
+      rules: evalMode === 'exam' ? [{ required: true, message: '鍦ㄧ嚎鑰冭瘯椤婚�夎瘯鍗�', trigger: 'change' }] : [],
+    },
+  ]);
+  execFormApi.setFieldsValue({ evalMode });
+  execFormApi.clearValidate(['paperId']);
+}
+
+async function loadPapers() {
+  try {
+    const list = await getCoursePaperOptions();
+    paperOptions.value = (Array.isArray(list) ? list : []).map((x: any) => ({
+      id: x.id,
+      fullName: x.fullName || x.paperName || x.id,
+    }));
+  } catch {
+    paperOptions.value = [];
+  }
+}
+
+async function loadPlaces() {
+  try {
+    const list = await getPlaceOptions();
+    placeOptions.value = (Array.isArray(list) ? list : []).map((x: any) => ({
+      id: x.id,
+      fullName: x.fullName || x.placeName || x.id,
+      placeNo: x.placeNo,
+    }));
+  } catch {
+    placeOptions.value = [];
+  }
+}
+
+async function loadDetail(id: string) {
+  const info = await getTaskInfo(id);
+  if (info.publishStatus && info.publishStatus !== 'draft') {
+    createMessage.warning('浠呮湭鍙戝竷浠诲姟鍙紪杈戯紝宸茶烦杞鎯�');
+    router.replace(`/tms/task/detail/${id}`);
+    return;
+  }
+  state.id = info.id || id;
+  state.publishStatus = info.publishStatus || 'draft';
+  fromPlan.value = isPlanSourced(info);
+  // 璁″垝甯﹀嚭涔熶粠鍐呭姝ュ紑濮嬶紝渚夸簬琛ュ叏鍩硅甯�/鏁欐潗绛�
+  currentStep.value = 1;
+
+  const traineeIds = Array.isArray(info.traineeIds)
+    ? info.traineeIds
+    : String(info.trainees || '')
+        .split(/[,;锛岋紱\s]+/)
+        .filter(Boolean);
+
+  const statusMap: Record<string, string> = {
+    draft: '鏈彂甯�',
+    published: '宸插彂甯�',
+    cancelled: '宸插彇娑�',
+    done: '宸插畬鎴�',
+  };
+
+  contentFormApi.setFieldsValue({
+    ...info,
+    traineeIds,
+    materialId: String(info.materialId || '').split(',')[0] || undefined,
+    publishStatusLabel: statusMap[info.publishStatus || 'draft'] || info.publishStatus,
+  });
+  taskFiles.value = Array.isArray(info.files) ? info.files : [];
+  uploadFiles.value = taskFiles.value
+    .map((row) => {
+      if (!row.fileJson) return null;
+      try {
+        return JSON.parse(row.fileJson);
+      } catch {
+        return null;
+      }
+    })
+    .filter(Boolean);
+  execFormApi.setFieldsValue({
+    ...info,
+    evalMode: info.evalMode,
+    startTime: toTimeNumber(info.startTime),
+    endTime: toTimeNumber(info.endTime),
+    closeTime: toTimeNumber(info.closeTime),
+    examStart: toTimeNumber(info.examStart),
+    examEnd: toTimeNumber(info.examEnd),
+  });
+  refreshOptions();
+  // 璁″垝甯﹀嚭锛氫粎缂栧彿/鏉ユ簮绫诲彧璇伙紝涓氬姟鍐呭鍙淮鎶�
+  applyContentEditable(true);
+  syncModeRules(info.trainMode);
+  syncEvalRules(info.evalMode);
+}
+
+async function goNext() {
+  try {
+    await contentFormApi.validate();
+    const content = contentFormApi.getFieldsValue() as TaskEntity;
+    syncModeRules(content.trainMode);
+    syncEvalRules(content.evalMode);
+    currentStep.value = 2;
+  } catch {
+    // 鏍¢獙澶辫触鐢辫〃鍗曟彁绀�
+  }
+}
+
+function goPrev() {
+  currentStep.value = 1;
+}
+
+async function handleSubmit(andPublish = false) {
+  const [content, exec] = await Promise.all([contentFormApi.validate(), execFormApi.validate()]);
+  submitting.value = true;
+  try {
+    let traineeIds = (content as TaskEntity).traineeIds as string | string[] | undefined;
+    if (typeof traineeIds === 'string') {
+      traineeIds = traineeIds.split(/[,;锛岋紱\s]+/).filter(Boolean);
+    }
+    const teacherRaw = (content as TaskEntity).teacherId as any;
+    const materialRaw = (content as TaskEntity).materialId;
+    const materialId =
+      String(materialRaw || '')
+        .split(/[,;锛岋紱]+/)[0]
+        ?.trim() || undefined;
+    const contentData = content as TaskEntity;
+    const execData = exec as TaskEntity;
+    // 鎵ц琛ㄥ崟鍔犺浇鏃跺啓鍏ヤ簡鏁存潯浠诲姟锛屽悗鍐欏叆浼氱洊鎺夌涓�姝ユ敼杩囩殑鍩硅鏂瑰紡/鑰冩牳鏂瑰紡
+    const payload: Partial<TaskEntity> = {
+      ...execData,
+      ...contentData,
+      sourceType: fromPlan.value ? contentData.sourceType : 'temp',
+      category: fromPlan.value ? contentData.category : 'temp',
+      traineeIds: Array.isArray(traineeIds) ? traineeIds : [],
+      teacherId: Array.isArray(teacherRaw) ? teacherRaw[0] : teacherRaw,
+      materialId,
+      trainMode: contentData.trainMode,
+      evalMode: contentData.evalMode,
+      files: taskFiles.value.filter((row) => row.fileName),
+      startTime: formatTaskTime(execData.startTime),
+      endTime: formatTaskTime(execData.endTime),
+      closeTime: formatTaskTime(execData.closeTime),
+      examStart: formatTaskTime(execData.examStart),
+      examEnd: formatTaskTime(execData.examEnd),
+      placeId: execData.placeId,
+      placeName: execData.placeName,
+      paperId: execData.paperId,
+      retakeLimit: execData.retakeLimit,
+      passScore: execData.passScore,
+      overdueRemind: execData.overdueRemind,
+      inviteFlag: execData.inviteFlag,
+      remark: execData.remark,
+    };
+    delete (payload as any).publishStatusLabel;
+
+    let id = state.id;
+    if (isEdit.value && id) {
+      await updateTask({ ...payload, id });
+      createMessage.success('鏇存柊鎴愬姛');
+    } else {
+      id = await createTask(payload);
+      state.id = id;
+      createMessage.success('鍒涘缓鎴愬姛');
+    }
+    if (andPublish && id) {
+      await publishTask(id);
+      createMessage.success('鍙戝竷鎴愬姛');
+    }
+    router.push('/tms/task');
+  } catch (error: any) {
+    if (error?.errorFields) return;
+    createMessage.error(error?.message || '淇濆瓨澶辫触');
+  } finally {
+    submitting.value = false;
+  }
+}
+
+function handleCancel() {
+  router.push('/tms/task');
+}
+</script>
+
+<template>
+  <div class="jnpf-content-wrapper tms-task-form-page" v-loading="loading">
+    <div class="jnpf-content-wrapper-center">
+      <div class="jnpf-content-wrapper-content tms-task-form-wrap">
+        <div class="tms-task-form-scroll">
+          <div class="tms-task-page-title">{{ pageTitle }}</div>
+          <ASteps class="tms-task-steps" size="small" :current="currentStep - 1" :items="stepItems" />
+          <AAlert class="tms-task-tip" type="info" show-icon :message="tipText" />
+
+          <a-card v-show="currentStep === 1" :title="fromPlan ? '璁″垝甯﹀嚭锛堝彲缁存姢锛�' : '鍩硅鍐呭'" :bordered="false" class="tms-task-section">
+            <BasicForm @register="registerContentForm" />
+            <div class="tms-task-files">
+              <div class="tms-task-files-head">
+                <span>鏁欐潗/闄勪欢</span>
+                <JnpfUploadFile
+                  v-if="!contentLocked"
+                  v-model:value="uploadFiles"
+                  accept="*"
+                  button-text="涓婁紶闄勪欢"
+                  :file-size="0"
+                  :limit="0"
+                  :show-all-download="false"
+                  :show-upload-list="false"
+                  tip-text="鏀寔鏂囨。鍜岃棰�"
+                  show-tip
+                  @change="onUploadChange" />
+              </div>
+              <a-table
+                size="small"
+                :pagination="false"
+                row-key="materialId"
+                :data-source="taskFiles"
+                :columns="[
+                  { title: '缂栧彿', dataIndex: 'fileNo', width: 160 },
+                  { title: '鏂囦欢鍚嶇О', dataIndex: 'fileName' },
+                  { title: '鍙笅杞�', dataIndex: 'canDownload', width: 90 },
+                  { title: '鎿嶄綔', dataIndex: 'action', width: 80 },
+                ]">
+                <template #bodyCell="{ column, record, index }">
+                  <template v-if="column.dataIndex === 'fileName'">
+                    <a-input v-model:value="record.fileName" :disabled="contentLocked" maxlength="300" />
+                  </template>
+                  <template v-else-if="column.dataIndex === 'canDownload'">
+                    <a-switch
+                      :checked="record.canDownload === '1'"
+                      :disabled="contentLocked"
+                      checked-children="鏄�"
+                      un-checked-children="鍚�"
+                      @change="(checked) => (record.canDownload = checked ? '1' : '0')" />
+                  </template>
+                  <template v-else-if="column.dataIndex === 'action'">
+                    <a-button type="link" danger :disabled="contentLocked" @click="removeTaskFile(index)">鍒犻櫎</a-button>
+                  </template>
+                </template>
+              </a-table>
+            </div>
+          </a-card>
+
+          <a-card v-show="currentStep === 2" :title="fromPlan ? '鎵ц瀹夋帓锛堣ˉ鍏ㄥ悗鍙戝竷锛�' : '鎵ц瀹夋帓'" :bordered="false" class="tms-task-section">
+            <BasicForm @register="registerExecForm" />
+          </a-card>
+        </div>
+
+        <div class="tms-task-form-footer">
+          <a-space>
+            <a-button @click="handleCancel">{{ TMS_BTN.cancel }}</a-button>
+            <a-button v-if="currentStep === 2" @click="goPrev">涓婁竴姝�</a-button>
+            <a-button v-if="currentStep === 1" type="primary" @click="goNext">涓嬩竴姝�</a-button>
+            <template v-if="currentStep === 2">
+              <a-button type="primary" ghost :loading="submitting" @click="handleSubmit(false)">
+                {{ TMS_BTN.saveDraft }}
+              </a-button>
+              <a-button type="primary" :loading="submitting" @click="handleSubmit(true)">
+                {{ TMS_BTN.savePublish }}
+              </a-button>
+            </template>
+          </a-space>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.tms-task-form-page {
+  height: 100%;
+  min-height: 0;
+}
+
+.tms-task-form-wrap {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
+  overflow: hidden;
+  background: #fff;
+}
+
+.tms-task-form-scroll {
+  flex: 1;
+  min-height: 0;
+  overflow-x: hidden;
+  overflow-y: auto;
+  padding: 16px 16px 8px;
+}
+
+.tms-task-page-title {
+  margin-bottom: 12px;
+  font-size: 16px;
+  font-weight: 600;
+}
+
+.tms-task-steps {
+  max-width: 480px;
+  margin-bottom: 12px;
+}
+
+.tms-task-tip {
+  margin-bottom: 12px;
+}
+
+.tms-task-section {
+  margin-bottom: 12px;
+}
+
+.tms-task-files {
+  margin-top: 8px;
+}
+
+.tms-task-files-head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 8px;
+  font-weight: 600;
+}
+
+.tms-task-form-footer {
+  flex-shrink: 0;
+  padding: 12px 16px;
+  border-top: 1px solid #f0f0f0;
+  text-align: right;
+  background: #fff;
+}
+</style>

--
Gitblit v1.8.0