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 +++++++++++++
 apps/jnpf-web-apps-main/src/views/x/tms/task/constants.ts                   |   71 
 apps/jnpf-web-apps-main/src/views/x/tms/task/types.ts                       |   82 +
 apps/jnpf-web-apps-main/src/views/x/tms/paper/Form.vue                      |  676 +++++++++
 apps/jnpf-web-apps-main/src/views/x/tms/paper/types.ts                      |   53 
 apps/jnpf-web-apps-main/src/views/x/tms/personTask/constants.ts             |   39 
 apps/jnpf-web-apps-main/src/views/x/tms/paper/components/QuestionPicker.vue |  229 +++
 apps/jnpf-web-apps-main/src/views/x/tms/task/Detail.vue                     |  192 ++
 apps/jnpf-web-apps-main/src/views/x/tms/personTask/Learn.vue                |  410 +++++
 apps/jnpf-web-apps-main/src/router/routes/modules/tmsPaper.ts               |   20 
 apps/jnpf-web-apps-main/src/views/x/tms/personTask/types.ts                 |   69 
 apps/jnpf-web-apps-main/src/api/x/tms/personTask.ts                         |   31 
 apps/jnpf-web-apps-main/src/views/x/tms/personTask/index.vue                |  200 ++
 apps/jnpf-web-apps-main/src/views/x/tms/task/index.vue                      |  325 ++++
 apps/jnpf-web-apps-main/src/router/routes/modules/tmsTask.ts                |   20 
 apps/jnpf-web-apps-main/src/router/routes/basic.ts                          |  142 +
 apps/jnpf-web-apps-main/src/api/x/tms/paper.ts                              |   42 
 apps/jnpf-web-apps-main/src/api/x/tms/place.ts                              |   24 
 apps/jnpf-web-apps-main/src/views/x/tms/paper/Detail.vue                    |  163 ++
 apps/jnpf-web-apps-main/src/views/x/tms/paper/constants.ts                  |   49 
 apps/jnpf-web-apps-main/src/views/x/tms/paper/index.vue                     |  256 +++
 apps/jnpf-web-apps-main/src/views/x/tms/personTask/Detail.vue               |  205 ++
 apps/jnpf-web-apps-main/src/api/x/tms/task.ts                               |   52 
 23 files changed, 4,309 insertions(+), 0 deletions(-)

diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/paper.ts b/apps/jnpf-web-apps-main/src/api/x/tms/paper.ts
new file mode 100644
index 0000000..1f2de58
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/paper.ts
@@ -0,0 +1,42 @@
+import type { PaperEntity, PaperPageQuery } from '#/views/x/tms/paper/types';
+
+import { defHttp } from '#/api/request';
+
+/** 姝e紡璺緞锛�/api/tms/paper/** */
+const prefix = '/api/tms/paper';
+
+async function unwrapData<T>(promise: Promise<any>): Promise<T> {
+  const res = await promise;
+  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
+    return res.data as T;
+  }
+  return res as T;
+}
+
+export function getPaperList(params: PaperPageQuery) {
+  return unwrapData<{ list: PaperEntity[]; pagination: Record<string, any> }>(
+    defHttp.get({ url: `${prefix}/list`, params }),
+  );
+}
+
+export function getPaperInfo(id: string) {
+  return unwrapData<PaperEntity>(defHttp.get({ url: `${prefix}/${id}` }));
+}
+
+export function createPaper(data: PaperEntity) {
+  return unwrapData<string>(defHttp.post({ url: prefix, data }));
+}
+
+export function updatePaper(data: PaperEntity) {
+  return unwrapData(defHttp.put({ url: `${prefix}/${data.id}`, data }));
+}
+
+/** 搴熷純 = biz_status=invalid */
+export function invalidatePaper(id: string) {
+  return unwrapData(defHttp.put({ url: `${prefix}/${id}/invalidate` }));
+}
+
+/** 鍚敤/鍋滅敤锛歰pen | closed */
+export function setPaperStatus(id: string, bizStatus: 'open' | 'closed') {
+  return unwrapData(defHttp.put({ url: `${prefix}/${id}/status`, data: { bizStatus } }));
+}
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/personTask.ts b/apps/jnpf-web-apps-main/src/api/x/tms/personTask.ts
new file mode 100644
index 0000000..0c792ac
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/personTask.ts
@@ -0,0 +1,31 @@
+import type { PersonTaskItem, PersonTaskLearnResult, PersonTaskPageQuery, PersonTaskSignResult } from '#/views/x/tms/personTask/types';
+
+import { defHttp } from '#/api/request';
+
+const prefix = '/api/tms/person-task';
+
+async function unwrapData<T>(promise: Promise<any>): Promise<T> {
+  const res = await promise;
+  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
+    return res.data as T;
+  }
+  return res as T;
+}
+
+export function getMyPersonTaskList(params: PersonTaskPageQuery) {
+  return unwrapData<{ list: PersonTaskItem[]; pagination: Record<string, any> }>(
+    defHttp.get({ url: `${prefix}/mine`, params }),
+  );
+}
+
+export function getPersonTaskInfo(id: string) {
+  return unwrapData<PersonTaskItem>(defHttp.get({ url: `${prefix}/${id}` }));
+}
+
+export function markPersonTaskLearned(id: string, seconds: number) {
+  return unwrapData<PersonTaskLearnResult>(defHttp.post({ url: `${prefix}/${id}/learn`, data: { seconds } }));
+}
+
+export function signPersonTask(id: string) {
+  return unwrapData<PersonTaskSignResult>(defHttp.post({ url: `${prefix}/${id}/sign` }));
+}
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/place.ts b/apps/jnpf-web-apps-main/src/api/x/tms/place.ts
new file mode 100644
index 0000000..5b9c45e
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/place.ts
@@ -0,0 +1,24 @@
+import { defHttp } from '#/api/request';
+
+/** 姝e紡璺緞锛�/api/tms/place/** */
+const prefix = '/api/tms/place';
+
+export interface PlaceOption {
+  id: string;
+  placeNo?: string;
+  fullName: string;
+  placeGroup?: string;
+}
+
+async function unwrapData<T>(promise: Promise<any>): Promise<T> {
+  const res = await promise;
+  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
+    return res.data as T;
+  }
+  return res as T;
+}
+
+/** 鍚敤涓殑鍩硅鍦扮偣锛堟潵鑷煿璁湴鐐圭鐞� / tms_place锛� */
+export function getPlaceOptions() {
+  return unwrapData<PlaceOption[]>(defHttp.get({ url: `${prefix}/options` }));
+}
diff --git a/apps/jnpf-web-apps-main/src/api/x/tms/task.ts b/apps/jnpf-web-apps-main/src/api/x/tms/task.ts
new file mode 100644
index 0000000..a458474
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/api/x/tms/task.ts
@@ -0,0 +1,52 @@
+import type { TaskEntity, TaskPageQuery } from '#/views/x/tms/task/types';
+
+import { defHttp } from '#/api/request';
+
+/** 姝e紡璺緞锛�/api/tms/task/** */
+const prefix = '/api/tms/task';
+
+async function unwrapData<T>(promise: Promise<any>): Promise<T> {
+  const res = await promise;
+  if (res && typeof res === 'object' && 'data' in res && ('code' in res || 'msg' in res)) {
+    return res.data as T;
+  }
+  return res as T;
+}
+
+export function getTaskList(params: TaskPageQuery) {
+  return unwrapData<{ list: TaskEntity[]; pagination: Record<string, any> }>(
+    defHttp.get({ url: `${prefix}/list`, params }),
+  );
+}
+
+export function getTaskInfo(id: string) {
+  return unwrapData<TaskEntity>(defHttp.get({ url: `${prefix}/${id}` }));
+}
+
+export function createTask(data: Partial<TaskEntity>) {
+  return unwrapData<string>(defHttp.post({ url: prefix, data }));
+}
+
+export function updateTask(data: Partial<TaskEntity> & { id: string }) {
+  return unwrapData(defHttp.put({ url: `${prefix}/${data.id}`, data }));
+}
+
+export function deleteTask(id: string) {
+  return unwrapData(defHttp.delete({ url: `${prefix}/${id}` }));
+}
+
+export function publishTask(id: string) {
+  return unwrapData(defHttp.put({ url: `${prefix}/${id}/publish` }));
+}
+
+export function cancelTask(id: string) {
+  return unwrapData(defHttp.put({ url: `${prefix}/${id}/cancel` }));
+}
+
+export function cancelTaskBatch(ids: string[]) {
+  return unwrapData(defHttp.put({ url: `${prefix}/cancelBatch`, data: { ids } }));
+}
+
+export function restoreTask(id: string) {
+  return unwrapData(defHttp.put({ url: `${prefix}/${id}/restore` }));
+}
diff --git a/apps/jnpf-web-apps-main/src/router/routes/basic.ts b/apps/jnpf-web-apps-main/src/router/routes/basic.ts
index 8f14ec8..7b65553 100644
--- a/apps/jnpf-web-apps-main/src/router/routes/basic.ts
+++ b/apps/jnpf-web-apps-main/src/router/routes/basic.ts
@@ -102,6 +102,39 @@
         },
       },
       {
+        path: '/tms/paper/create',
+        name: 'TmsPaperCreate',
+        component: () => import('#/views/x/tms/paper/Form.vue'),
+        meta: {
+          title: '鏂板璇曞嵎',
+          hideInMenu: true,
+          ignoreAccess: true,
+          currentActiveMenu: '/tms/paper',
+        },
+      },
+      {
+        path: '/tms/paper/edit/:id',
+        name: 'TmsPaperEdit',
+        component: () => import('#/views/x/tms/paper/Form.vue'),
+        meta: {
+          title: '缂栬緫璇曞嵎',
+          hideInMenu: true,
+          ignoreAccess: true,
+          currentActiveMenu: '/tms/paper',
+        },
+      },
+      {
+        path: '/tms/paper/detail/:id',
+        name: 'TmsPaperDetail',
+        component: () => import('#/views/x/tms/paper/Detail.vue'),
+        meta: {
+          title: '璇曞嵎璇︽儏',
+          hideInMenu: true,
+          ignoreAccess: true,
+          currentActiveMenu: '/tms/paper',
+        },
+      },
+      {
         path: '/tms/question/create',
         name: 'TmsQuestionCreate',
         component: () => import('#/views/x/tms/question/Form.vue'),
@@ -124,11 +157,44 @@
         },
       },
       {
+        path: '/tms/question/detail/:id',
+        name: 'TmsQuestionDetail',
+        component: () => import('#/views/x/tms/question/Detail.vue'),
+        meta: {
+          title: '璇曢璇︽儏',
+          hideInMenu: true,
+          ignoreAccess: true,
+          currentActiveMenu: '/tms/question',
+        },
+      },
+      {
         path: '/tms/selfTest/exam',
         name: 'TmsSelfTestExam',
         component: () => import('#/views/x/tms/selfTest/exam.vue'),
         meta: {
           title: '鑷垜妫�娴嬬瓟棰�',
+          hideInMenu: true,
+          ignoreAccess: true,
+          currentActiveMenu: '/tms/selfTest',
+        },
+      },
+      {
+        path: '/tms/selfTest/records',
+        name: 'TmsSelfTestRecords',
+        component: () => import('#/views/x/tms/selfTest/records.vue'),
+        meta: {
+          title: '鎴戠殑妫�娴嬭褰�',
+          hideInMenu: true,
+          ignoreAccess: true,
+          currentActiveMenu: '/tms/selfTest',
+        },
+      },
+      {
+        path: '/tms/selfTest/detail/:id',
+        name: 'TmsSelfTestDetail',
+        component: () => import('#/views/x/tms/selfTest/Detail.vue'),
+        meta: {
+          title: '妫�娴嬭鎯�',
           hideInMenu: true,
           ignoreAccess: true,
           currentActiveMenu: '/tms/selfTest',
@@ -151,6 +217,17 @@
         component: () => import('#/views/x/tms/onlineExam/Detail.vue'),
         meta: {
           title: '鑰冭瘯璇︽儏',
+          hideInMenu: true,
+          ignoreAccess: true,
+          currentActiveMenu: '/tms/onlineExam',
+        },
+      },
+      {
+        path: '/tms/onlineExam/review/:id',
+        name: 'TmsOnlineExamReview',
+        component: () => import('#/views/x/tms/onlineExam/Wrong.vue'),
+        meta: {
+          title: '鑰冭瘯鍥為【',
           hideInMenu: true,
           ignoreAccess: true,
           currentActiveMenu: '/tms/onlineExam',
@@ -336,6 +413,71 @@
           ignoreAccess: true,
         },
       },
+      {
+        path: '/tms/personTask',
+        name: 'TmsPersonTaskBasic',
+        component: () => import('#/views/x/tms/personTask/index.vue'),
+        meta: {
+          title: '涓汉鍩硅浠诲姟',
+          hideInMenu: true,
+          ignoreAccess: true,
+        },
+      },
+      {
+        path: '/tms/personTask/detail/:id',
+        name: 'TmsPersonTaskDetail',
+        component: () => import('#/views/x/tms/personTask/Detail.vue'),
+        meta: {
+          title: '涓汉鍩硅浠诲姟璇︽儏',
+          hideInMenu: true,
+          ignoreAccess: true,
+          currentActiveMenu: '/tms/personTask',
+        },
+      },
+      {
+        path: '/tms/personTask/learn/:id',
+        name: 'TmsPersonTaskLearn',
+        component: () => import('#/views/x/tms/personTask/Learn.vue'),
+        meta: {
+          title: '鍩硅瀛︿範',
+          hideInMenu: true,
+          ignoreAccess: true,
+          currentActiveMenu: '/tms/personTask',
+        },
+      },
+      {
+        path: '/tms/task/create',
+        name: 'TmsTaskCreate',
+        component: () => import('#/views/x/tms/task/Form.vue'),
+        meta: {
+          title: '鏂板鍩硅浠诲姟',
+          hideInMenu: true,
+          ignoreAccess: true,
+          currentActiveMenu: '/tms/task',
+        },
+      },
+      {
+        path: '/tms/task/edit/:id',
+        name: 'TmsTaskEdit',
+        component: () => import('#/views/x/tms/task/Form.vue'),
+        meta: {
+          title: '缂栬緫鍩硅浠诲姟',
+          hideInMenu: true,
+          ignoreAccess: true,
+          currentActiveMenu: '/tms/task',
+        },
+      },
+      {
+        path: '/tms/task/detail/:id',
+        name: 'TmsTaskDetail',
+        component: () => import('#/views/x/tms/task/Detail.vue'),
+        meta: {
+          title: '鍩硅浠诲姟璇︽儏',
+          hideInMenu: true,
+          ignoreAccess: true,
+          currentActiveMenu: '/tms/task',
+        },
+      },
     ],
   },
   {
diff --git a/apps/jnpf-web-apps-main/src/router/routes/modules/tmsPaper.ts b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsPaper.ts
new file mode 100644
index 0000000..41471a9
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsPaper.ts
@@ -0,0 +1,20 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+/**
+ * 鍒楄〃鍙敱鍚庡彴鑿滃崟鎸傝浇锛歱ageAddress = x/tms/paper/index锛岃矾鐢� /tms/paper
+ * 鍒涘缓/缂栬緫/璇︽儏宸叉寕鍒� basicRoutes锛堜笉渚濊禆鑿滃崟锛�
+ */
+const tmsPaperRoutes: RouteRecordRaw[] = [
+  {
+    path: '/tms/paper',
+    name: 'TmsPaper',
+    component: () => import('#/views/x/tms/paper/index.vue'),
+    meta: {
+      title: '璇曞嵎绠$悊',
+      hideInMenu: true,
+      ignoreAccess: true,
+    },
+  },
+];
+
+export default tmsPaperRoutes;
diff --git a/apps/jnpf-web-apps-main/src/router/routes/modules/tmsTask.ts b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsTask.ts
new file mode 100644
index 0000000..a77f0f7
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/router/routes/modules/tmsTask.ts
@@ -0,0 +1,20 @@
+import type { RouteRecordRaw } from 'vue-router';
+
+/**
+ * 鍒楄〃鍙敱鍚庡彴鑿滃崟鎸傝浇锛歱ageAddress = x/tms/task/index锛岃矾鐢� /tms/task
+ * 鍒涘缓/缂栬緫/璇︽儏宸叉寕鍒� basicRoutes锛堜笉渚濊禆鑿滃崟锛�
+ */
+const tmsTaskRoutes: RouteRecordRaw[] = [
+  {
+    path: '/tms/task',
+    name: 'TmsTask',
+    component: () => import('#/views/x/tms/task/index.vue'),
+    meta: {
+      title: '鍩硅浠诲姟绠$悊',
+      hideInMenu: true,
+      ignoreAccess: true,
+    },
+  },
+];
+
+export default tmsTaskRoutes;
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/paper/Detail.vue b/apps/jnpf-web-apps-main/src/views/x/tms/paper/Detail.vue
new file mode 100644
index 0000000..8f316dd
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/paper/Detail.vue
@@ -0,0 +1,163 @@
+<script lang="ts" setup>
+import type { PaperEntity } from './types';
+
+import { onMounted, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+
+import { useMessage } from '@jnpf/hooks';
+import {
+  Descriptions as ADescriptions,
+  DescriptionsItem as ADescriptionsItem,
+} from 'ant-design-vue';
+
+import { getPaperInfo } from '#/api/x/tms/paper';
+
+import {
+  labelOfSortMode,
+  labelOfStatus,
+  labelOfType,
+  loadPaperDics,
+} from './constants';
+
+defineOptions({ name: 'TmsPaperDetail' });
+
+const route = useRoute();
+const router = useRouter();
+const { createMessage } = useMessage();
+
+const loading = ref(false);
+const detail = ref<PaperEntity | null>(null);
+
+onMounted(async () => {
+  await loadPaperDics();
+  await loadData();
+});
+
+async function loadData() {
+  const id = String(route.params.id || '');
+  if (!id) {
+    router.replace('/tms/paper');
+    return;
+  }
+  loading.value = true;
+  try {
+    detail.value = await getPaperInfo(id);
+  } catch (e: any) {
+    createMessage.error(e?.message || '鍔犺浇璇曞嵎澶辫触');
+    router.replace('/tms/paper');
+  } finally {
+    loading.value = false;
+  }
+}
+
+function goBack() {
+  router.push('/tms/paper');
+}
+
+function goEdit() {
+  if (!detail.value?.id || detail.value.bizStatus !== 'closed') return;
+  router.push(`/tms/paper/edit/${detail.value.id}`);
+}
+
+function stripHtml(html?: string) {
+  if (!html) return '';
+  return html.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim();
+}
+</script>
+
+<template>
+  <div class="jnpf-content-wrapper tms-paper-detail-page" v-loading="loading">
+    <div class="jnpf-content-wrapper-center">
+      <div class="jnpf-content-wrapper-content tms-paper-detail-wrap" v-if="detail">
+        <a-card title="璇曞嵎璇︽儏" :bordered="false">
+          <template #extra>
+            <a-space>
+              <a-button @click="goBack">杩斿洖</a-button>
+              <a-button
+                type="primary"
+                :disabled="detail.bizStatus !== 'closed'"
+                @click="goEdit"
+              >
+                缂栬緫
+              </a-button>
+            </a-space>
+          </template>
+
+          <ADescriptions :column="2" bordered size="small">
+            <ADescriptionsItem label="璇曞嵎缂栧彿">{{ detail.paperNo || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="璇曞嵎鍚嶇О">{{ detail.paperName }}</ADescriptionsItem>
+            <ADescriptionsItem label="鐘舵��">{{ labelOfStatus(detail.bizStatus) }}</ADescriptionsItem>
+            <ADescriptionsItem label="鑰冭瘯鏃堕暱">{{ detail.durationMin ?? '-' }} 鍒嗛挓</ADescriptionsItem>
+            <ADescriptionsItem label="璇曢鎺掑簭">{{ labelOfSortMode(detail.sortMode) }}</ADescriptionsItem>
+            <ADescriptionsItem label="寮�鑰冩椂闂�">{{ detail.examStart || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="缁撴潫鏃堕棿">{{ detail.examEnd || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="鎴愮哗鍏竷鏃堕棿">{{ detail.scorePublishTime || '绔嬪嵆鍏竷' }}</ADescriptionsItem>
+            <ADescriptionsItem label="璇曞嵎鎬诲垎">{{ detail.totalScore ?? '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="鍚堟牸鍒嗘暟">{{ detail.passScore ?? '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="鍚富瑙傞">{{ detail.hasSubjective === '1' ? '鏄�' : '鍚�' }}</ADescriptionsItem>
+            <ADescriptionsItem label="鍒涘缓鏃堕棿">{{ detail.creatorTime || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="澶囨敞" :span="2">{{ detail.remark || '-' }}</ADescriptionsItem>
+          </ADescriptions>
+        </a-card>
+
+        <a-card title="缁勫嵎鍐呭" :bordered="false" style="margin-top: 12px">
+          <div v-for="sec in detail.sections || []" :key="sec.id" class="section-block">
+            <div class="section-title">
+              {{ sec.sectionName }}
+              <span class="muted">锛坽{ labelOfType(sec.questionType) }} 路 {{ (sec.questions || []).length }} 棰橈級</span>
+            </div>
+            <a-table
+              size="small"
+              row-key="questionId"
+              :pagination="false"
+              :data-source="sec.questions || []"
+              :columns="[
+                { title: '搴忓彿', width: 60, customRender: ({ index }: any) => index + 1 },
+                { title: '缂栧彿', dataIndex: 'questionNo', width: 90 },
+                {
+                  title: '棰樺共',
+                  dataIndex: 'stem',
+                  customRender: ({ record }: any) => stripHtml(record.stem),
+                },
+                { title: '棰樺簱', dataIndex: 'bankName', width: 140 },
+                { title: '鍒嗗��', dataIndex: 'score', width: 80 },
+              ]"
+            />
+          </div>
+          <a-empty v-if="!(detail.sections || []).length" description="鏆傛棤缁勫嵎鍐呭" />
+        </a-card>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.tms-paper-detail-page {
+  height: 100%;
+  min-height: 0;
+}
+
+.tms-paper-detail-wrap {
+  height: 100%;
+  min-height: 0;
+  overflow-x: hidden;
+  overflow-y: auto !important;
+  background: #fff;
+  padding: 16px;
+  box-sizing: border-box;
+}
+
+.section-block {
+  margin-bottom: 16px;
+}
+.section-title {
+  margin-bottom: 8px;
+  font-weight: 600;
+}
+.muted {
+  margin-left: 8px;
+  color: #999;
+  font-weight: 400;
+  font-size: 13px;
+}
+</style>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/paper/Form.vue b/apps/jnpf-web-apps-main/src/views/x/tms/paper/Form.vue
new file mode 100644
index 0000000..1f98478
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/paper/Form.vue
@@ -0,0 +1,676 @@
+<script lang="ts" setup>
+import type { PaperEntity, PaperQuestionItem, PaperSectionItem } from './types';
+import type { QuestionEntity } from '#/views/x/tms/question/types';
+
+import { computed, nextTick, onMounted, reactive, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+
+import { useMessage } from '@jnpf/hooks';
+import { useModal } from '@jnpf/ui/modal';
+
+import { Modal } from 'ant-design-vue';
+import dayjs from 'dayjs';
+
+import { createPaper, getPaperInfo, updatePaper } from '#/api/x/tms/paper';
+import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
+import { loadQuestionDics } from '#/views/x/tms/question/constants';
+
+import QuestionPicker from './components/QuestionPicker.vue';
+import {
+  QUESTION_TYPE_OPTIONS,
+  SORT_MODE_OPTIONS,
+  STATUS_OPTIONS,
+  labelOfType,
+  loadPaperDics,
+  nextTmpId,
+} from './constants';
+
+defineOptions({ name: 'TmsPaperForm' });
+
+const DT_FMT = 'YYYY-MM-DD HH:mm:ss';
+
+const route = useRoute();
+const router = useRouter();
+const { createMessage } = useMessage();
+
+const loading = ref(false);
+const submitting = ref(false);
+const formRef = ref();
+const activeSectionId = ref('');
+
+const isEdit = computed(() => !!route.params.id && route.params.id !== 'create');
+const pageTitle = computed(() => (isEdit.value ? '缂栬緫璇曞嵎' : '鏂板璇曞嵎'));
+
+const dataForm = reactive<PaperEntity>({
+  paperName: '',
+  bizStatus: 'open',
+  durationMin: 60,
+  examStart: undefined,
+  examEnd: undefined,
+  scorePublishTime: undefined,
+  passScore: undefined,
+  totalScore: 0,
+  sortMode: 'paper',
+  remark: '',
+  sections: [],
+});
+
+const statusTip = computed(
+  () => STATUS_OPTIONS.value.find((x) => (x.enCode || x.id) === dataForm.bizStatus)?.tip,
+);
+const statusTipColor = computed(
+  () => STATUS_OPTIONS.value.find((x) => (x.enCode || x.id) === dataForm.bizStatus)?.tipColor,
+);
+const editableStatusOptions = computed(() =>
+  STATUS_OPTIONS.value.filter((x) => (x.enCode || x.id) !== 'invalid'),
+);
+
+const computedTotal = computed(() => {
+  let sum = 0;
+  for (const sec of dataForm.sections || []) {
+    for (const q of sec.questions || []) {
+      sum += Number(q.score || 0);
+    }
+  }
+  return Math.round(sum * 10) / 10;
+});
+
+function toDayjs(v: any) {
+  if (v == null || v === '') return null;
+  if (typeof v === 'number') {
+    const d = dayjs(v);
+    return d.isValid() ? d : null;
+  }
+  const text = String(v).trim();
+  if (!text) return null;
+  if (/^\d+$/.test(text)) {
+    const n = Number(text);
+    const d = dayjs(text.length === 10 ? n * 1000 : n);
+    return d.isValid() ? d : null;
+  }
+  const d = dayjs(text);
+  return d.isValid() ? d : null;
+}
+
+function formatDateTime(v: any): string | undefined {
+  const d = toDayjs(v);
+  return d ? d.format(DT_FMT) : undefined;
+}
+
+const validateExamEnd = async (_rule: any, value: any) => {
+  const end = toDayjs(value);
+  const start = toDayjs(dataForm.examStart);
+  if (!end && !start) return;
+  if (start && !end) {
+    return Promise.reject('璇烽�夋嫨缁撴潫鏃堕棿');
+  }
+  if (!start && end) {
+    return Promise.reject('璇峰厛閫夋嫨寮�鑰冩椂闂�');
+  }
+  if (start && end && !end.isAfter(start)) {
+    return Promise.reject('缁撴潫鏃堕棿蹇呴』鏅氫簬寮�鑰冩椂闂�');
+  }
+  const duration = Number(dataForm.durationMin || 0);
+  if (start && end && duration > 0) {
+    const spanMin = end.diff(start, 'minute');
+    if (duration > spanMin) {
+      return Promise.reject(`鑰冭瘯鏃堕暱涓嶈兘瓒呰繃寮�鑰冭嚦缁撴潫鐨勯棿闅旓紙${spanMin} 鍒嗛挓锛塦);
+    }
+  }
+};
+
+const validateExamStart = async (_rule: any, value: any) => {
+  const start = toDayjs(value);
+  const end = toDayjs(dataForm.examEnd);
+  if (!start && end) {
+    return Promise.reject('璇烽�夋嫨寮�鑰冩椂闂�');
+  }
+  if (start && end && !end.isAfter(start)) {
+    return Promise.reject('寮�鑰冩椂闂村繀椤绘棭浜庣粨鏉熸椂闂�');
+  }
+};
+
+const validatePublishTime = async (_rule: any, value: any) => {
+  const publish = toDayjs(value);
+  if (!publish) return;
+  const end = toDayjs(dataForm.examEnd);
+  const start = toDayjs(dataForm.examStart);
+  if (end && publish.isBefore(end)) {
+    return Promise.reject('鎴愮哗鍏竷鏃堕棿涓嶈兘鏃╀簬缁撴潫鏃堕棿');
+  }
+  if (!end && start && publish.isBefore(start)) {
+    return Promise.reject('鎴愮哗鍏竷鏃堕棿涓嶈兘鏃╀簬寮�鑰冩椂闂�');
+  }
+};
+
+const validateDuration = async (_rule: any, value: any) => {
+  if (value == null || value === '') return;
+  const n = Number(value);
+  if (Number.isNaN(n) || n < 0) {
+    return Promise.reject('鑰冭瘯鏃堕暱涓嶈兘涓鸿礋鏁�');
+  }
+  const start = toDayjs(dataForm.examStart);
+  const end = toDayjs(dataForm.examEnd);
+  if (start && end && n > 0) {
+    const spanMin = end.diff(start, 'minute');
+    if (n > spanMin) {
+      return Promise.reject(`鑰冭瘯鏃堕暱涓嶈兘瓒呰繃寮�鑰冭嚦缁撴潫鐨勯棿闅旓紙${spanMin} 鍒嗛挓锛塦);
+    }
+  }
+};
+
+const validatePassScore = async (_rule: any, value: any) => {
+  if (value == null || value === '') return;
+  const pass = Number(value);
+  if (Number.isNaN(pass) || pass < 0) {
+    return Promise.reject('鍚堟牸鍒嗘暟涓嶈兘涓鸿礋鏁�');
+  }
+  const total = computedTotal.value;
+  if (pass > total) {
+    return Promise.reject(`鍚堟牸鍒嗘暟涓嶈兘澶т簬璇曞嵎鎬诲垎锛堝綋鍓嶆�诲垎 ${total}锛塦);
+  }
+};
+
+const rules = {
+  paperName: [{ required: true, message: '璇峰~鍐欒瘯鍗峰悕绉�', trigger: 'blur' }],
+  bizStatus: [{ required: true, message: '璇烽�夋嫨鐘舵��', trigger: 'change' }],
+  sortMode: [{ required: true, message: '璇烽�夋嫨璇曢鎺掑簭', trigger: 'change' }],
+  durationMin: [{ validator: validateDuration, trigger: 'change' }],
+  examStart: [{ validator: validateExamStart, trigger: 'change' }],
+  examEnd: [{ validator: validateExamEnd, trigger: 'change' }],
+  scorePublishTime: [{ validator: validatePublishTime, trigger: 'change' }],
+  passScore: [{ validator: validatePassScore, trigger: 'change' }],
+};
+
+function revalidateTimes() {
+  formRef.value?.validateFields?.(['examStart', 'examEnd', 'scorePublishTime', 'durationMin']).catch(() => undefined);
+}
+
+function revalidatePassScore() {
+  formRef.value?.validateFields?.(['passScore']).catch(() => undefined);
+}
+
+function toTimestamp(v: any): number | undefined {
+  const d = toDayjs(v);
+  return d ? d.valueOf() : undefined;
+}
+
+const [registerPicker, { openModal: openPicker }] = useModal();
+
+onMounted(async () => {
+  await Promise.all([loadPaperDics(), loadQuestionDics()]);
+  if (isEdit.value) {
+    await loadDetail(String(route.params.id));
+  }
+});
+
+async function loadDetail(id: string) {
+  loading.value = true;
+  try {
+    const info = await getPaperInfo(id);
+    Object.assign(dataForm, {
+      ...info,
+      sections: (info.sections || []).map((s) => ({
+        ...s,
+        id: s.id || nextTmpId('sec'),
+        questions: (s.questions || []).map((q) => ({ ...q })),
+      })),
+    });
+    if (dataForm.sections?.length) {
+      activeSectionId.value = dataForm.sections[0]!.id;
+    }
+  } catch (e: any) {
+    createMessage.error(e?.message || '鍔犺浇澶辫触');
+  } finally {
+    loading.value = false;
+  }
+}
+
+function goList() {
+  router.push('/tms/paper');
+}
+
+function handleCancel() {
+  Modal.confirm({
+    title: '纭鍙栨秷',
+    content: '纭畾鍙栨秷缂栬緫鍚楋紵鏈繚瀛樼殑鍐呭灏嗕涪澶便��',
+    okText: '纭畾',
+    cancelText: '缁х画缂栬緫',
+    onOk: () => goList(),
+  });
+}
+
+function addSection() {
+  const sec: PaperSectionItem = {
+    id: nextTmpId('sec'),
+    sectionName: '鍗曢�夐',
+    questionType: 'single',
+    sortNo: (dataForm.sections?.length || 0) + 1,
+    questions: [],
+  };
+  if (!dataForm.sections) dataForm.sections = [];
+  dataForm.sections.push(sec);
+  activeSectionId.value = sec.id;
+  nextTick(() => {
+    document.getElementById(`paper-sec-${sec.id}`)?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+  });
+}
+
+function removeSection(secId: string) {
+  dataForm.sections = (dataForm.sections || []).filter((s) => s.id !== secId);
+  if (activeSectionId.value === secId) {
+    activeSectionId.value = dataForm.sections[0]?.id || '';
+  }
+}
+
+function onSectionTypeChange(sec: PaperSectionItem) {
+  if (sec.questionType === 'single') sec.sectionName = '鍗曢�夐';
+  else if (sec.questionType === 'multi') sec.sectionName = '澶氶�夐';
+  else if (sec.questionType === 'judge') sec.sectionName = '鍒ゆ柇棰�';
+  else if (sec.questionType === 'blank') sec.sectionName = '濉┖棰�';
+  else if (sec.questionType === 'essay') sec.sectionName = '闂瓟棰�';
+}
+
+function openPick(sec: PaperSectionItem) {
+  if (!sec.questionType) {
+    createMessage.warning('璇峰厛閫夋嫨绔犺妭棰樺瀷');
+    return;
+  }
+  activeSectionId.value = sec.id;
+  const excludeIds = (dataForm.sections || [])
+    .flatMap((s) => s.questions || [])
+    .map((q) => q.questionId)
+    .filter(Boolean);
+  openPicker(true, { questionType: sec.questionType, excludeIds });
+}
+
+function onPickConfirm(rows: QuestionEntity[]) {
+  const sec = (dataForm.sections || []).find((s) => s.id === activeSectionId.value);
+  if (!sec) return;
+  const start = sec.questions.length;
+  for (let i = 0; i < rows.length; i++) {
+    const row = rows[i]!;
+    if (!row.id) continue;
+    if (sec.questionType && row.questionType && row.questionType !== sec.questionType) {
+      createMessage.warning(`璇曢 ${row.questionNo} 涓庣珷鑺傞鍨嬩笉涓�鑷达紝宸茶烦杩嘸);
+      continue;
+    }
+    sec.questions.push({
+      questionId: row.id,
+      questionNo: row.questionNo,
+      stem: row.stem,
+      questionType: row.questionType,
+      bankName: row.bankName,
+      score: 1,
+      sortNo: start + i + 1,
+    } as PaperQuestionItem);
+  }
+  syncTotal();
+}
+
+function removeQuestion(sec: PaperSectionItem, idx: number) {
+  sec.questions.splice(idx, 1);
+  sec.questions.forEach((q, i) => {
+    q.sortNo = i + 1;
+  });
+  syncTotal();
+}
+
+function moveQuestion(sec: PaperSectionItem, idx: number, delta: number) {
+  const target = idx + delta;
+  if (target < 0 || target >= sec.questions.length) return;
+  const list = sec.questions;
+  const tmp = list[idx]!;
+  list[idx] = list[target]!;
+  list[target] = tmp;
+  list.forEach((q, i) => {
+    q.sortNo = i + 1;
+  });
+}
+
+function syncTotal() {
+  dataForm.totalScore = computedTotal.value;
+  revalidatePassScore();
+}
+
+function stripHtml(html?: string) {
+  if (!html) return '';
+  const text = html.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim();
+  return text.length > 80 ? `${text.slice(0, 80)}鈥 : text;
+}
+
+async function handleSubmit() {
+  try {
+    await formRef.value?.validate();
+  } catch {
+    return;
+  }
+  if (!(dataForm.sections || []).some((s) => (s.questions || []).length)) {
+    createMessage.warning('璇疯嚦灏戞坊鍔犱竴閬撹瘯棰�');
+    return;
+  }
+  for (const sec of dataForm.sections || []) {
+    for (const q of sec.questions || []) {
+      if (q.score == null || Number(q.score) <= 0) {
+        createMessage.warning(`璇曢 ${q.questionNo || ''} 鍒嗗�煎繀椤诲ぇ浜� 0`);
+        return;
+      }
+    }
+  }
+  if (dataForm.passScore != null && Number(dataForm.passScore) > computedTotal.value) {
+    createMessage.warning(`鍚堟牸鍒嗘暟涓嶈兘澶т簬璇曞嵎鎬诲垎锛堝綋鍓嶆�诲垎 ${computedTotal.value}锛塦);
+    return;
+  }
+  syncTotal();
+  submitting.value = true;
+  try {
+    const payload: PaperEntity = {
+      ...dataForm,
+      examStart: formatDateTime(dataForm.examStart) ?? null,
+      examEnd: formatDateTime(dataForm.examEnd) ?? null,
+      scorePublishTime: formatDateTime(dataForm.scorePublishTime) ?? null,
+      sections: (dataForm.sections || []).map((s, si) => ({
+        ...s,
+        sortNo: si + 1,
+        questions: (s.questions || []).map((q, qi) => ({
+          questionId: q.questionId,
+          score: q.score ?? 0,
+          sortNo: qi + 1,
+        })),
+      })),
+    };
+    if (isEdit.value) {
+      await updatePaper(payload);
+      createMessage.success('鏇存柊鎴愬姛');
+    } else {
+      await createPaper(payload);
+      createMessage.success('鍒涘缓鎴愬姛');
+    }
+    goList();
+  } catch (e: any) {
+    createMessage.error(e?.message || '淇濆瓨澶辫触');
+  } finally {
+    submitting.value = false;
+  }
+}
+</script>
+
+<template>
+  <div class="jnpf-content-wrapper tms-paper-form-page" v-loading="loading">
+    <div class="jnpf-content-wrapper-center">
+      <div class="jnpf-content-wrapper-content tms-paper-form-wrap">
+        <div class="page-toolbar">
+          <div class="page-title">{{ pageTitle }}</div>
+        </div>
+
+        <div class="tms-paper-form-body">
+          <a-card title="鍩烘湰淇℃伅" :bordered="false" class="form-card">
+            <a-form ref="formRef" :model="dataForm" :rules="rules" :label-col="{ style: { width: '120px' } }">
+              <a-row :gutter="16">
+                <a-col v-if="dataForm.id" :span="12">
+                  <a-form-item label="璇曞嵎缂栧彿">
+                    <a-input :value="dataForm.paperNo || '-'" disabled />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item label="璇曞嵎鍚嶇О" name="paperName">
+                    <a-input v-model:value="dataForm.paperName" placeholder="璇疯緭鍏ヨ瘯鍗峰悕绉�" :maxlength="200" />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item label="璇曞嵎鐘舵��" name="bizStatus">
+                    <a-select
+                      v-model:value="dataForm.bizStatus"
+                      :options="editableStatusOptions"
+                      :field-names="TMS_DIC_FIELD_NAMES"
+                      style="width: 160px"
+                    />
+                    <span v-if="statusTip" class="status-tip" :style="{ color: statusTipColor }">{{ statusTip }}</span>
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item label="鑰冭瘯鏃堕暱" name="durationMin">
+                    <a-input-number
+                      v-model:value="dataForm.durationMin"
+                      :min="0"
+                      :precision="0"
+                      style="width: 160px"
+                      @change="revalidateTimes"
+                    />
+                    <span class="field-hint">鍒嗛挓锛堜笉搴旇秴杩囪缃殑鏃堕棿闂撮殧锛�</span>
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                <a-form-item label="璇曢鎺掑簭" name="sortMode">
+                  <a-radio-group v-model:value="dataForm.sortMode">
+                    <a-radio v-for="opt in SORT_MODE_OPTIONS" :key="opt.enCode || opt.id" :value="opt.enCode || opt.id">
+                      {{ opt.fullName }}
+                    </a-radio>
+                  </a-radio-group>
+                </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item label="寮�鑰冩椂闂�" name="examStart">
+                    <jnpf-date-picker
+                      v-model:value="dataForm.examStart"
+                      format="YYYY-MM-DD HH:mm:ss"
+                      placeholder="璇烽�夋嫨寮�鑰冩椂闂�"
+                      style="width: 100%"
+                      :end-time="toTimestamp(dataForm.examEnd)"
+                      @change="revalidateTimes"
+                    />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item label="缁撴潫鏃堕棿" name="examEnd">
+                    <jnpf-date-picker
+                      v-model:value="dataForm.examEnd"
+                      format="YYYY-MM-DD HH:mm:ss"
+                      placeholder="璇烽�夋嫨缁撴潫鏃堕棿"
+                      style="width: 100%"
+                      :start-time="toTimestamp(dataForm.examStart)"
+                      @change="revalidateTimes"
+                    />
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item label="鎴愮哗鍏竷鏃堕棿" name="scorePublishTime">
+                    <jnpf-date-picker
+                      v-model:value="dataForm.scorePublishTime"
+                      format="YYYY-MM-DD HH:mm:ss"
+                      placeholder="绔嬪嵆鍏竷璇风暀绌�"
+                      style="width: 100%"
+                      :start-time="toTimestamp(dataForm.examEnd || dataForm.examStart)"
+                      @change="revalidateTimes"
+                    />
+                    <div class="field-hint block">璁剧疆鎴愮哗鍏竷鏃堕棿锛岀珛鍗冲叕甯冭鐣欑┖</div>
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item label="璇曞嵎鎬诲垎">
+                    <a-input-number :value="computedTotal" disabled style="width: 160px" />
+                    <span class="field-hint">鐢辩粍鍗峰垎鍊艰嚜鍔ㄦ眹鎬�</span>
+                  </a-form-item>
+                </a-col>
+                <a-col :span="12">
+                  <a-form-item label="鍚堟牸鍒嗘暟" name="passScore">
+                    <a-input-number
+                      v-model:value="dataForm.passScore"
+                      :min="0"
+                      :precision="1"
+                      style="width: 160px"
+                      @change="revalidatePassScore"
+                    />
+                    <span class="field-hint">涓嶈兘澶т簬璇曞嵎鎬诲垎</span>
+                  </a-form-item>
+                </a-col>
+                <a-col :span="24">
+                  <a-form-item label="澶囨敞">
+                    <a-textarea v-model:value="dataForm.remark" :rows="3" placeholder="璇疯緭鍏ュ娉�" />
+                  </a-form-item>
+                </a-col>
+              </a-row>
+            </a-form>
+          </a-card>
+
+          <a-card title="缁勫嵎" :bordered="false" class="form-card">
+            <template #extra>
+              <a-button type="primary" @click="addSection">娣诲姞绔犺妭</a-button>
+            </template>
+
+            <a-empty v-if="!(dataForm.sections || []).length" description="璇锋坊鍔犵珷鑺傚苟浠庤瘯棰樼鐞嗛�夐缁勫嵎" />
+
+            <div
+              v-for="sec in dataForm.sections"
+              :id="`paper-sec-${sec.id}`"
+              :key="sec.id"
+              class="section-block"
+            >
+              <div class="section-head">
+                <a-input v-model:value="sec.sectionName" placeholder="绔犺妭鍚嶇О" style="width: 200px" />
+                <a-select
+                  v-model:value="sec.questionType"
+                  placeholder="棰樺瀷"
+                  style="width: 140px; margin-left: 8px"
+                  :options="QUESTION_TYPE_OPTIONS"
+                  :field-names="TMS_DIC_FIELD_NAMES"
+                  @change="onSectionTypeChange(sec)"
+                />
+                <a-space style="margin-left: auto">
+                  <a-button type="link" @click="openPick(sec)">閫夐</a-button>
+                  <a-button type="link" danger @click="removeSection(sec.id)">鍒犻櫎绔犺妭</a-button>
+                </a-space>
+              </div>
+
+              <a-table
+                size="small"
+                row-key="questionId"
+                :pagination="false"
+                :data-source="sec.questions"
+                :columns="[
+                  { title: '搴忓彿', width: 60, key: 'idx' },
+                  { title: '缂栧彿', dataIndex: 'questionNo', width: 90 },
+                  { title: '棰樺瀷', width: 80, key: 'qtype' },
+                  { title: '棰樺共', dataIndex: 'stem', ellipsis: true, key: 'stem' },
+                  { title: '棰樺簱', dataIndex: 'bankName', width: 140, ellipsis: true },
+                  { title: '鍒嗗��', width: 110, key: 'score' },
+                  { title: '鎿嶄綔', width: 160, key: 'action' },
+                ]"
+              >
+                <template #bodyCell="{ column, record, index }">
+                  <template v-if="column.key === 'idx'">{{ index + 1 }}</template>
+                  <template v-else-if="column.key === 'qtype'">{{ labelOfType(record.questionType) }}</template>
+                  <template v-else-if="column.key === 'stem'">{{ stripHtml(record.stem) }}</template>
+                  <template v-else-if="column.key === 'score'">
+                    <a-input-number
+                      v-model:value="record.score"
+                      :min="0"
+                      :precision="1"
+                      size="small"
+                      style="width: 90px"
+                      @change="syncTotal"
+                    />
+                  </template>
+                  <template v-else-if="column.key === 'action'">
+                    <a-space>
+                      <a @click="moveQuestion(sec, index, -1)">涓婄Щ</a>
+                      <a @click="moveQuestion(sec, index, 1)">涓嬬Щ</a>
+                      <a class="danger" @click="removeQuestion(sec, index)">绉婚櫎</a>
+                    </a-space>
+                  </template>
+                </template>
+              </a-table>
+            </div>
+          </a-card>
+
+          <div class="form-footer">
+            <a-space>
+              <a-button @click="handleCancel">鍙栨秷</a-button>
+              <a-button type="primary" :loading="submitting" @click="handleSubmit">淇濆瓨</a-button>
+            </a-space>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <QuestionPicker @register="registerPicker" @confirm="onPickConfirm" />
+  </div>
+</template>
+
+<style scoped>
+.tms-paper-form-page {
+  height: 100%;
+  min-height: 0;
+}
+
+.tms-paper-form-wrap {
+  display: flex;
+  flex-direction: column;
+  height: 100%;
+  min-height: 0;
+  overflow: hidden;
+  background: #fff;
+  padding: 16px;
+}
+
+.page-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  flex-shrink: 0;
+  margin-bottom: 12px;
+  padding-bottom: 12px;
+  border-bottom: 1px solid #f0f0f0;
+}
+
+.page-title {
+  font-size: 16px;
+  font-weight: 600;
+}
+
+.tms-paper-form-body {
+  flex: 1;
+  min-height: 0;
+  overflow-x: hidden;
+  overflow-y: auto;
+}
+
+.form-card {
+  margin-bottom: 12px;
+}
+
+.status-tip,
+.field-hint {
+  margin-left: 8px;
+  color: #999;
+  font-size: 12px;
+}
+
+.field-hint.block {
+  display: block;
+  margin: 4px 0 0;
+}
+
+.section-block {
+  margin-bottom: 16px;
+  padding: 12px;
+  background: #fafafa;
+  border: 1px solid #f0f0f0;
+  border-radius: 4px;
+}
+
+.section-head {
+  display: flex;
+  align-items: center;
+  margin-bottom: 8px;
+}
+
+.form-footer {
+  padding: 16px 0 24px;
+  text-align: center;
+}
+
+.danger {
+  color: #ff4d4f;
+}
+</style>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/paper/components/QuestionPicker.vue b/apps/jnpf-web-apps-main/src/views/x/tms/paper/components/QuestionPicker.vue
new file mode 100644
index 0000000..8a3f316
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/paper/components/QuestionPicker.vue
@@ -0,0 +1,229 @@
+<script lang="ts" setup>
+import type { QuestionBankOption, QuestionEntity } from '#/views/x/tms/question/types';
+
+import { computed, reactive, ref } from 'vue';
+
+import { useMessage } from '@jnpf/hooks';
+import { BasicModal, useModalInner } from '@jnpf/ui/modal';
+
+import { getQuestionBanks, getQuestionList } from '#/api/x/tms/question';
+import { labelOfType } from '#/views/x/tms/question/constants';
+
+defineOptions({ name: 'TmsPaperQuestionPicker' });
+
+const emit = defineEmits<{
+  confirm: [QuestionEntity[]];
+}>();
+
+const { createMessage } = useMessage();
+
+const banks = ref<QuestionBankOption[]>([]);
+const loading = ref(false);
+const list = ref<QuestionEntity[]>([]);
+const selectedKeys = ref<string[]>([]);
+const selectedRows = ref<QuestionEntity[]>([]);
+const excludeIds = ref<Set<string>>(new Set());
+/** 绔犺妭宸插畾棰樺瀷锛氶�夐鏃堕攣瀹氾紝鍙渶鍐嶉�夐搴� */
+const lockedType = ref<string | undefined>();
+
+const query = reactive({
+  bankId: undefined as string | undefined,
+  keyword: '',
+  currentPage: 1,
+  pageSize: 10,
+  total: 0,
+});
+
+const columns = [
+  { title: '缂栧彿', dataIndex: 'questionNo', width: 90 },
+  { title: '棰樺簱', dataIndex: 'bankName', width: 140, ellipsis: true },
+  { title: '棰樺瀷', dataIndex: 'questionType', width: 80, key: 'questionType' },
+  { title: '棰樺共', dataIndex: 'stem', ellipsis: true, key: 'stem' },
+];
+
+const typeLabel = computed(() => (lockedType.value ? labelOfType(lockedType.value) : ''));
+
+const [registerModal, { closeModal }] = useModalInner(async (data: any) => {
+  excludeIds.value = new Set(Array.isArray(data?.excludeIds) ? data.excludeIds : []);
+  lockedType.value = data?.questionType || undefined;
+  query.bankId = undefined;
+  query.keyword = '';
+  query.currentPage = 1;
+  query.total = 0;
+  list.value = [];
+  selectedKeys.value = [];
+  selectedRows.value = [];
+  if (!banks.value.length) {
+    banks.value = (await getQuestionBanks()) || [];
+  }
+});
+
+async function loadList() {
+  if (!query.bankId) {
+    list.value = [];
+    query.total = 0;
+    return;
+  }
+  loading.value = true;
+  try {
+    const page = await getQuestionList({
+      bankId: query.bankId,
+      questionType: lockedType.value || '',
+      bizStatus: 'open',
+      keyword: query.keyword || undefined,
+      currentPage: query.currentPage,
+      pageSize: query.pageSize,
+    });
+    list.value = Array.isArray(page?.list) ? page.list : [];
+    query.total = Number(page?.pagination?.total || 0);
+  } catch (e: any) {
+    createMessage.error(e?.message || '鍔犺浇璇曢澶辫触');
+    list.value = [];
+  } finally {
+    loading.value = false;
+  }
+}
+
+function onSelectChange(keys: (string | number)[], rows: QuestionEntity[]) {
+  selectedKeys.value = keys.map(String);
+  selectedRows.value = rows;
+}
+
+function rowDisabled(record: QuestionEntity) {
+  return !!(record.id && excludeIds.value.has(record.id));
+}
+
+function stripHtml(html?: string) {
+  if (!html) return '';
+  const text = html.replace(/<[^>]+>/g, '').replace(/&nbsp;/g, ' ').trim();
+  return text.length > 60 ? `${text.slice(0, 60)}鈥 : text;
+}
+
+function onBankChange() {
+  query.currentPage = 1;
+  selectedKeys.value = [];
+  selectedRows.value = [];
+  loadList();
+}
+
+function handleSearch() {
+  if (!query.bankId) {
+    createMessage.warning('璇峰厛閫夋嫨棰樺簱');
+    return;
+  }
+  query.currentPage = 1;
+  loadList();
+}
+
+function handlePageChange(page: number, pageSize: number) {
+  query.currentPage = page;
+  query.pageSize = pageSize;
+  loadList();
+}
+
+function handleOk() {
+  if (!query.bankId) {
+    createMessage.warning('璇峰厛閫夋嫨棰樺簱');
+    return;
+  }
+  const rows = selectedRows.value.filter((r) => r.id && !excludeIds.value.has(r.id));
+  if (!rows.length) {
+    createMessage.warning('璇烽�夋嫨璇曢');
+    return;
+  }
+  emit('confirm', rows);
+  closeModal();
+}
+</script>
+
+<template>
+  <BasicModal
+    v-bind="$attrs"
+    title="閫夋嫨璇曢"
+    width="900px"
+    destroy-on-close
+    @register="registerModal"
+    @ok="handleOk"
+  >
+    <div v-if="lockedType" class="picker-tip">
+      褰撳墠绔犺妭棰樺瀷锛�<b>{{ typeLabel }}</b>锛岃閫夋嫨棰樺簱鍚庡嬀閫夎瘯棰�
+    </div>
+
+    <a-form layout="inline" class="picker-query" @submit.prevent>
+      <a-form-item label="棰樺簱" required>
+        <a-select
+          v-model:value="query.bankId"
+          allow-clear
+          show-search
+          placeholder="璇烽�夋嫨棰樺簱"
+          style="width: 220px"
+          :options="banks"
+          :field-names="{ label: 'fullName', value: 'id' }"
+          option-filter-prop="fullName"
+          @change="onBankChange"
+        />
+      </a-form-item>
+      <a-form-item label="鍏抽敭璇�">
+        <a-input
+          v-model:value="query.keyword"
+          allow-clear
+          placeholder="缂栧彿/棰樺共"
+          style="width: 180px"
+          :disabled="!query.bankId"
+          @press-enter="handleSearch"
+        />
+      </a-form-item>
+      <a-form-item>
+        <a-button type="primary" :disabled="!query.bankId" @click="handleSearch">鏌ヨ</a-button>
+      </a-form-item>
+    </a-form>
+
+    <a-empty v-if="!query.bankId" description="璇峰厛閫夋嫨棰樺簱" />
+
+    <a-table
+      v-else
+      size="small"
+      row-key="id"
+      :loading="loading"
+      :data-source="list"
+      :columns="columns"
+      :pagination="{
+        current: query.currentPage,
+        pageSize: query.pageSize,
+        total: query.total,
+        showSizeChanger: true,
+        onChange: handlePageChange,
+      }"
+      :row-selection="{
+        selectedRowKeys: selectedKeys,
+        onChange: onSelectChange,
+        getCheckboxProps: (record: QuestionEntity) => ({ disabled: rowDisabled(record) }),
+      }"
+    >
+      <template #bodyCell="{ column, record }">
+        <template v-if="column.key === 'questionType'">
+          {{ labelOfType(record.questionType) }}
+        </template>
+        <template v-else-if="column.key === 'stem'">
+          {{ stripHtml(record.stem) }}
+          <span v-if="rowDisabled(record)" class="picked">锛堝凡鍏ュ嵎锛�</span>
+        </template>
+      </template>
+    </a-table>
+  </BasicModal>
+</template>
+
+<style scoped>
+.picker-tip {
+  margin-bottom: 12px;
+  color: #666;
+  font-size: 13px;
+}
+.picker-query {
+  margin-bottom: 12px;
+}
+.picked {
+  color: #999;
+  margin-left: 4px;
+}
+</style>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/paper/constants.ts b/apps/jnpf-web-apps-main/src/views/x/tms/paper/constants.ts
new file mode 100644
index 0000000..29928ee
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/paper/constants.ts
@@ -0,0 +1,49 @@
+import { ref } from 'vue';
+
+import { useBaseStore } from '#/store';
+import { TMS_DIC, labelOfDic, loadTmsDic, type TmsDicOpt } from '#/views/x/tms/shared/dic';
+
+type StatusOpt = TmsDicOpt & { tip?: string; tipColor?: string };
+
+function withStatusTip(list: TmsDicOpt[]): StatusOpt[] {
+  return list.map((x) => {
+    const code = x.enCode || x.id;
+    if (code === 'open') return { ...x, tip: '瀛﹀憳鍙綔绛�', tipColor: 'green' };
+    if (code === 'closed') return { ...x, tip: '瀛﹀憳涓嶅彲浣滅瓟', tipColor: 'red' };
+    return x;
+  });
+}
+
+export const STATUS_OPTIONS = ref<StatusOpt[]>([]);
+export const SORT_MODE_OPTIONS = ref<TmsDicOpt[]>([]);
+export const QUESTION_TYPE_OPTIONS = ref<TmsDicOpt[]>([]);
+
+export async function loadPaperDics() {
+  const baseStore = useBaseStore();
+  const [status, sortMode, questionType] = await Promise.all([
+    loadTmsDic(baseStore, TMS_DIC.openClosedInvalid),
+    loadTmsDic(baseStore, TMS_DIC.paperSortMode),
+    loadTmsDic(baseStore, TMS_DIC.questionType),
+  ]);
+  STATUS_OPTIONS.value = withStatusTip(status);
+  SORT_MODE_OPTIONS.value = sortMode;
+  QUESTION_TYPE_OPTIONS.value = questionType;
+}
+
+export function labelOfStatus(v?: string) {
+  return labelOfDic(STATUS_OPTIONS.value, v);
+}
+
+export function labelOfSortMode(v?: string) {
+  return labelOfDic(SORT_MODE_OPTIONS.value, v);
+}
+
+export function labelOfType(v?: string) {
+  return labelOfDic(QUESTION_TYPE_OPTIONS.value, v);
+}
+
+let tmpSeq = 0;
+export function nextTmpId(prefix = 'tmp') {
+  tmpSeq += 1;
+  return `${prefix}_${Date.now()}_${tmpSeq}`;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/paper/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/paper/index.vue
new file mode 100644
index 0000000..971f7bd
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/paper/index.vue
@@ -0,0 +1,256 @@
+<script lang="ts" setup>
+import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable';
+
+import type { PaperEntity } from './types';
+
+import { computed, onMounted, ref } from 'vue';
+import { useRouter } from 'vue-router';
+
+import { useMessage } from '@jnpf/hooks';
+import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
+
+import { getPaperList, invalidatePaper, setPaperStatus } from '#/api/x/tms/paper';
+import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
+
+import { STATUS_OPTIONS, labelOfSortMode, labelOfStatus, loadPaperDics } from './constants';
+
+defineOptions({ name: 'TmsPaperList' });
+
+const router = useRouter();
+const { createMessage } = useMessage();
+const invalidMode = ref(false);
+
+const activeStatusOptions = computed(() =>
+  STATUS_OPTIONS.value.filter((x) => (x.enCode || x.id) !== 'invalid'),
+);
+
+const columns: BasicColumn[] = [
+  { title: '璇曞嵎缂栧彿', dataIndex: 'paperNo', width: 120 },
+  { title: '璇曞嵎鍚嶇О', dataIndex: 'paperName', minWidth: 200 },
+  {
+    title: '鐘舵��',
+    dataIndex: 'bizStatus',
+    width: 100,
+    align: 'center',
+    slots: { default: 'bizStatus' },
+  },
+  {
+    title: '鏃堕暱(鍒嗛挓)',
+    dataIndex: 'durationMin',
+    width: 100,
+    align: 'center',
+  },
+  {
+    title: '鎬诲垎',
+    dataIndex: 'totalScore',
+    width: 90,
+    align: 'center',
+  },
+  {
+    title: '鍚堟牸鍒�',
+    dataIndex: 'passScore',
+    width: 90,
+    align: 'center',
+  },
+  {
+    title: '棰橀噺',
+    dataIndex: 'questionCount',
+    width: 80,
+    align: 'center',
+  },
+  {
+    title: '璇曢鎺掑簭',
+    dataIndex: 'sortMode',
+    width: 110,
+    customRender: ({ record }) => labelOfSortMode((record as PaperEntity).sortMode),
+  },
+  { title: '鍒涘缓鏃堕棿', dataIndex: 'creatorTime', width: 170 },
+];
+
+const [registerTable, { reload, getForm }] = useVxeTable({
+  api: fetchList,
+  columns,
+  immediate: false,
+  rowKey: 'id',
+  useSearchForm: true,
+  formConfig: {
+    baseColProps: { span: 6 },
+    compact: true,
+    schemas: [
+      {
+        field: 'keyword',
+        label: '鍏抽敭璇�',
+        component: 'Input',
+        componentProps: { placeholder: '缂栧彿/鍚嶇О', submitOnPressEnter: true },
+      },
+      {
+        field: 'bizStatus',
+        label: '鐘舵��',
+        component: 'Select',
+        componentProps: {
+          allowClear: true,
+          placeholder: '璇烽�夋嫨',
+          options: STATUS_OPTIONS.value,
+          fieldNames: TMS_DIC_FIELD_NAMES,
+        },
+      },
+    ],
+  },
+  actionColumn: {
+    width: 180,
+    title: '鎿嶄綔',
+    dataIndex: 'action',
+    fixed: 'right',
+  },
+});
+
+onMounted(async () => {
+  await loadPaperDics();
+  getForm()?.updateSchema?.([
+    {
+      field: 'bizStatus',
+      componentProps: {
+        allowClear: true,
+        placeholder: '璇烽�夋嫨',
+        options: activeStatusOptions.value,
+        fieldNames: TMS_DIC_FIELD_NAMES,
+      },
+    },
+  ]);
+  reload();
+});
+
+async function fetchList(params: Record<string, any>) {
+  const query = { ...params };
+  if (invalidMode.value) {
+    query.bizStatus = 'invalid';
+  }
+  const page = await getPaperList(query);
+  return {
+    data: {
+      list: Array.isArray(page?.list) ? page.list : [],
+      pagination: page?.pagination || { total: 0 },
+    },
+  };
+}
+
+function openInvalidList() {
+  invalidMode.value = true;
+  getForm()?.setFieldsValue?.({ bizStatus: undefined });
+  getForm()?.updateSchema?.([{ field: 'bizStatus', ifShow: false }]);
+  reload();
+}
+
+function backToNormal() {
+  invalidMode.value = false;
+  getForm()?.updateSchema?.([{ field: 'bizStatus', ifShow: true }]);
+  reload();
+}
+
+function statusColor(status?: string) {
+  if (status === 'open') return '#52c41a';
+  if (status === 'closed') return '#ff4d4f';
+  if (status === 'invalid') return '#999';
+  return undefined;
+}
+
+function handleCreate() {
+  router.push('/tms/paper/create');
+}
+
+function handleEdit(record: PaperEntity) {
+  if (record.bizStatus === 'open') {
+    createMessage.warning('璇峰厛鍋滅敤鍚庡啀缂栬緫');
+    return;
+  }
+  if (record.bizStatus === 'invalid') {
+    createMessage.warning('宸插簾寮冭瘯鍗蜂笉鍙紪杈�');
+    return;
+  }
+  router.push(`/tms/paper/edit/${record.id}`);
+}
+
+function handleDetail(record: PaperEntity) {
+  router.push(`/tms/paper/detail/${record.id}`);
+}
+
+async function handleInvalidate(record: PaperEntity) {
+  await invalidatePaper(record.id!);
+  createMessage.success('搴熷純鎴愬姛');
+  reload();
+}
+
+async function handleEnable(record: PaperEntity) {
+  await setPaperStatus(record.id!, 'open');
+  createMessage.success('宸插惎鐢�');
+  reload();
+}
+
+async function handleDisable(record: PaperEntity) {
+  await setPaperStatus(record.id!, 'closed');
+  createMessage.success('宸插仠鐢�');
+  reload();
+}
+
+function getTableActions(record: PaperEntity): ActionItem[] {
+  if (record.bizStatus === 'open') {
+    return [
+      {
+        label: '鍋滅敤',
+        modelConfirm: {
+          content: `纭畾鍋滅敤璇曞嵎銆�${record.paperName}銆嶅悧锛熷仠鐢ㄥ悗涓嶅彲鐢ㄤ簬鍩硅浠诲姟鍦ㄧ嚎鑰冭瘯銆俙,
+          onOk: handleDisable.bind(null, record),
+        },
+      },
+      { label: '璇︽儏', onClick: handleDetail.bind(null, record) },
+    ];
+  }
+  if (record.bizStatus === 'closed') {
+    return [
+      {
+        label: '鍚敤',
+        modelConfirm: {
+          content: `纭畾鍚敤璇曞嵎銆�${record.paperName}銆嶅悧锛熷惎鐢ㄥ悗鍙敤浜庡煿璁换鍔″湪绾胯�冭瘯銆俙,
+          onOk: handleEnable.bind(null, record),
+        },
+      },
+      { label: '缂栬緫', onClick: handleEdit.bind(null, record) },
+      {
+        label: '搴熷純',
+        color: 'error',
+        modelConfirm: {
+          content: `纭畾搴熷純璇曞嵎銆�${record.paperName}銆嶅悧锛熷簾寮冨悗涓嶅彲鍐嶅惎鐢紝涓斾笉鍙敤浜庡煿璁换鍔″湪绾胯�冭瘯銆俙,
+          onOk: handleInvalidate.bind(null, record),
+        },
+      },
+    ];
+  }
+  return [{ label: '璇︽儏', onClick: handleDetail.bind(null, record) }];
+}
+</script>
+
+<template>
+  <div class="jnpf-content-wrapper">
+    <div class="jnpf-content-wrapper-center">
+      <div class="jnpf-content-wrapper-content">
+        <BasicVxeTable @register="registerTable">
+          <template #tableTitle>
+            <a-space>
+              <template v-if="!invalidMode">
+                <a-button type="primary" pre-icon="icon-ym icon-ym-btn-add" @click="handleCreate">鏂板</a-button>
+                <a-button @click="openInvalidList">搴熷純鍒楄〃</a-button>
+              </template>
+              <a-button v-else @click="backToNormal">杩斿洖</a-button>
+            </a-space>
+          </template>
+          <template #bizStatus="{ record }">
+            <span :style="{ color: statusColor(record.bizStatus) }">{{ labelOfStatus(record.bizStatus) }}</span>
+          </template>
+          <template #action="{ record }">
+            <TableAction :actions="getTableActions(record)" />
+          </template>
+        </BasicVxeTable>
+      </div>
+    </div>
+  </div>
+</template>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/paper/types.ts b/apps/jnpf-web-apps-main/src/views/x/tms/paper/types.ts
new file mode 100644
index 0000000..398a49f
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/paper/types.ts
@@ -0,0 +1,53 @@
+/** 璇曞嵎鐘舵�侊紙瀛楀吀 tmsOpenClosedInvalid锛� */
+export type PaperStatus = 'open' | 'closed' | 'invalid';
+
+/** 璇曢鎺掑簭锛堝瓧鍏� tmsPaperSortMode锛� */
+export type PaperSortMode = 'paper' | 'random';
+
+export interface PaperQuestionItem {
+  id?: string;
+  sectionId?: string;
+  questionId: string;
+  questionNo?: string;
+  stem?: string;
+  questionType?: string;
+  bankName?: string;
+  score?: number | null;
+  sortNo?: number;
+}
+
+export interface PaperSectionItem {
+  /** 鍓嶇涓存椂 key 鎴栧凡淇濆瓨 ID */
+  id: string;
+  sectionName: string;
+  questionType?: string;
+  sortNo?: number;
+  questions: PaperQuestionItem[];
+}
+
+export interface PaperEntity {
+  id?: string;
+  /** 璇曞嵎缂栧彿锛屽 260001 */
+  paperNo?: string;
+  paperName: string;
+  bizStatus: PaperStatus;
+  durationMin?: number | null;
+  examStart?: string | number | null;
+  examEnd?: string | number | null;
+  scorePublishTime?: string | number | null;
+  passScore?: number | null;
+  totalScore?: number | null;
+  sortMode: PaperSortMode;
+  hasSubjective?: string;
+  remark?: string;
+  creatorTime?: string;
+  questionCount?: number;
+  sections?: PaperSectionItem[];
+}
+
+export interface PaperPageQuery {
+  currentPage?: number;
+  pageSize?: number;
+  bizStatus?: PaperStatus | '';
+  keyword?: string;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/personTask/Detail.vue b/apps/jnpf-web-apps-main/src/views/x/tms/personTask/Detail.vue
new file mode 100644
index 0000000..d3b72ec
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/personTask/Detail.vue
@@ -0,0 +1,205 @@
+<script lang="ts" setup>
+import type { PersonTaskItem } from './types';
+
+import { computed, onMounted, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+
+import { useMessage } from '@jnpf/hooks';
+import { Descriptions as ADescriptions, DescriptionsItem as ADescriptionsItem } from 'ant-design-vue';
+
+import { getPersonTaskInfo, signPersonTask } from '#/api/x/tms/personTask';
+import { TMS_BTN } from '#/views/x/tms/shared/ui';
+
+import {
+  labelOfCategory,
+  labelOfEvalMode,
+  labelOfPersonStatus,
+  labelOfTrainMode,
+  loadPersonTaskDics,
+} from './constants';
+
+import '#/views/x/tms/shared/page.css';
+
+defineOptions({ name: 'TmsPersonTaskDetail' });
+
+const route = useRoute();
+const router = useRouter();
+const { createMessage } = useMessage();
+
+const loading = ref(false);
+const signing = ref(false);
+const detail = ref<PersonTaskItem | null>(null);
+
+const progressText = computed(() => {
+  const learned = detail.value?.learnedSeconds || 0;
+  const required = detail.value?.requiredSeconds || 0;
+  return `${formatDuration(learned)} / ${required > 0 ? formatDuration(required) : '鏈厤缃�'}`;
+});
+
+const isClosed = computed(() => detail.value?.bizStatus === 'cancelled' || detail.value?.bizStatus === 'expired');
+const canLearn = computed(() => Boolean(detail.value?.id) && !isClosed.value);
+const showExam = computed(() => Boolean(detail.value?.canExam && detail.value?.paperId));
+const tipMessage = computed(() => {
+  if (!detail.value) return '';
+  if (detail.value.bizStatus === 'cancelled') return '浠诲姟宸插彇娑堬紝鏃犳硶缁х画瀛︿範鎴栬�冭瘯銆�';
+  if (detail.value.bizStatus === 'expired') return '浠诲姟宸茶繃鏈燂紝鏃犳硶缁х画瀛︿範鎴栬�冭瘯銆�';
+  if (detail.value.examTip && !detail.value.canExam) return detail.value.examTip;
+  if (detail.value.signTip && detail.value.canSign) return detail.value.signTip;
+  return '';
+});
+
+onMounted(async () => {
+  await loadPersonTaskDics();
+  await loadData();
+});
+
+async function loadData() {
+  const id = String(route.params.id || '');
+  if (!id) {
+    router.replace('/tms/personTask');
+    return;
+  }
+  loading.value = true;
+  try {
+    detail.value = await getPersonTaskInfo(id);
+  } catch (e: any) {
+    createMessage.error(e?.message || '鍔犺浇澶辫触');
+    router.replace('/tms/personTask');
+  } finally {
+    loading.value = false;
+  }
+}
+
+function goBack() {
+  router.push('/tms/personTask');
+}
+
+function goLearn() {
+  if (!detail.value?.id) return;
+  if (isClosed.value) {
+    createMessage.warning(tipMessage.value || '褰撳墠浠诲姟涓嶅彲瀛︿範');
+    return;
+  }
+  router.push(`/tms/personTask/learn/${detail.value.id}`);
+}
+
+function goExam() {
+  if (!detail.value?.id || !showExam.value) {
+    createMessage.info(detail.value?.examTip || '褰撳墠鏆備笉鍙�冭瘯');
+    return;
+  }
+  router.push(`/tms/personTask/learn/${detail.value.id}`);
+}
+
+async function handleSign() {
+  if (!detail.value?.id || !detail.value.canSign || signing.value) return;
+  signing.value = true;
+  try {
+    const result = await signPersonTask(detail.value.id);
+    detail.value.signTime = result.signTime;
+    detail.value.signed = result.signed ?? true;
+    detail.value.canSign = result.canSign ?? false;
+    detail.value.signTip = result.signTip || '宸茬鍒�';
+    detail.value.bizStatus = result.bizStatus || detail.value.bizStatus;
+    detail.value.canExam = result.canExam;
+    detail.value.examTip = result.examTip;
+    createMessage.success(result.message || '绛惧埌鎴愬姛');
+  } catch (e: any) {
+    createMessage.error(e?.message || '绛惧埌澶辫触');
+  } finally {
+    signing.value = false;
+  }
+}
+
+function formatDuration(seconds: number) {
+  const h = Math.floor(seconds / 3600);
+  const m = Math.floor((seconds % 3600) / 60);
+  const s = seconds % 60;
+  if (h > 0) return `${h}灏忔椂${m}鍒哷;
+  if (m > 0) return `${m}鍒�${s}绉抈;
+  return `${s}绉抈;
+}
+</script>
+
+<template>
+  <div class="jnpf-content-wrapper tms-person-task-page" v-loading="loading">
+    <div class="jnpf-content-wrapper-center">
+      <div class="jnpf-content-wrapper-content tms-person-task-wrap" v-if="detail">
+        <div class="tms-page-header">
+          <div>
+            <div class="tms-page-header__title">涓汉鍩硅浠诲姟</div>
+            <div class="tms-page-header__sub">{{ detail.taskNo || '-' }} 路 {{ detail.subject || '-' }}</div>
+          </div>
+          <div class="tms-page-header__actions">
+            <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
+            <a-button
+              v-if="detail.canSign"
+              type="primary"
+              :ghost="showExam"
+              :loading="signing"
+              @click="handleSign"
+            >
+              {{ TMS_BTN.sign }}
+            </a-button>
+            <a-button v-if="canLearn" type="primary" ghost @click="goLearn">{{ TMS_BTN.enterLearn }}</a-button>
+            <a-button v-if="showExam" type="primary" @click="goExam">
+              {{ detail.passFlag === '0' ? TMS_BTN.retake : TMS_BTN.enterExam }}
+            </a-button>
+          </div>
+        </div>
+
+        <a-alert v-if="tipMessage" class="tms-page-tip" type="warning" show-icon :message="tipMessage" />
+
+        <ADescriptions bordered :column="2" size="small">
+          <ADescriptionsItem label="鍩硅缂栧彿">{{ detail.taskNo || '-' }}</ADescriptionsItem>
+          <ADescriptionsItem label="鐘舵��">{{ labelOfPersonStatus(detail.bizStatus) }}</ADescriptionsItem>
+          <ADescriptionsItem label="鍩硅涓婚" :span="2">{{ detail.subject || '-' }}</ADescriptionsItem>
+          <ADescriptionsItem label="鍩硅鍒嗙被">{{ labelOfCategory(detail.category) }}</ADescriptionsItem>
+          <ADescriptionsItem label="鍩硅鏂瑰紡">{{ labelOfTrainMode(detail.trainMode) }}</ADescriptionsItem>
+          <ADescriptionsItem label="鑰冩牳鏂瑰紡">{{ labelOfEvalMode(detail.evalMode) }}</ADescriptionsItem>
+          <ADescriptionsItem label="绛惧埌鐘舵��">
+            {{
+              detail.signed || detail.signTime
+                ? `宸茬鍒�${detail.signTime ? `锛�${detail.signTime}锛塦 : ''}`
+                : detail.signTip || '鏈鍒�'
+            }}
+          </ADescriptionsItem>
+          <ADescriptionsItem label="鏄惁鍙��">
+            {{ detail.canExam ? '鏄�' : '鍚�' }}
+            <span v-if="!detail.canExam && detail.examTip" class="tip-inline">锛坽{ detail.examTip }}锛�</span>
+          </ADescriptionsItem>
+          <ADescriptionsItem label="瀛︿範杩涘害">{{ detail.learnStatus || '-' }}</ADescriptionsItem>
+          <ADescriptionsItem label="瑕佹眰璇炬椂">{{ detail.requiredHours ?? '-' }} 灏忔椂</ADescriptionsItem>
+          <ADescriptionsItem label="宸插/闇�瀛�">{{ progressText }}</ADescriptionsItem>
+          <ADescriptionsItem label="寮�濮嬫椂闂�">{{ detail.startTime || '-' }}</ADescriptionsItem>
+          <ADescriptionsItem label="缁撴潫鏃堕棿">{{ detail.endTime || '-' }}</ADescriptionsItem>
+          <ADescriptionsItem label="鍏抽棴鏃堕棿">{{ detail.closeTime || '-' }}</ADescriptionsItem>
+          <ADescriptionsItem label="鍦扮偣">{{ detail.placeName || '-' }}</ADescriptionsItem>
+          <ADescriptionsItem label="璇曞嵎">{{ detail.paperName || '-' }}</ADescriptionsItem>
+          <ADescriptionsItem label="鎴愮哗">{{ detail.examScore ?? '-' }}</ADescriptionsItem>
+          <ADescriptionsItem label="鍩硅瑕佺偣" :span="2">{{ detail.keyPoints || '-' }}</ADescriptionsItem>
+        </ADescriptions>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.tms-person-task-page,
+.tms-person-task-wrap {
+  height: 100%;
+  min-height: 0;
+}
+
+.tms-person-task-wrap {
+  overflow: auto;
+  background: #fff;
+  padding: 16px 20px 24px;
+  box-sizing: border-box;
+}
+
+.tip-inline {
+  color: #fa8c16;
+  margin-left: 4px;
+}
+</style>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/personTask/Learn.vue b/apps/jnpf-web-apps-main/src/views/x/tms/personTask/Learn.vue
new file mode 100644
index 0000000..eebe494
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/personTask/Learn.vue
@@ -0,0 +1,410 @@
+<script lang="ts" setup>
+import type { PersonTaskFile, PersonTaskItem } from './types';
+
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+
+import { useMessage } from '@jnpf/hooks';
+
+import { startOnlineExam } from '#/api/x/tms/onlineExam';
+import { getPersonTaskInfo, markPersonTaskLearned, signPersonTask } from '#/api/x/tms/personTask';
+import { getAuthMediaUrl } from '#/utils/jnpf';
+import { TMS_BTN } from '#/views/x/tms/shared/ui';
+
+import '#/views/x/tms/shared/page.css';
+
+defineOptions({ name: 'TmsPersonTaskLearn' });
+
+const route = useRoute();
+const router = useRouter();
+const { createMessage } = useMessage();
+
+const loading = ref(false);
+const acting = ref(false);
+const signing = ref(false);
+const detail = ref<PersonTaskItem | null>(null);
+const activeFileId = ref<string>();
+const sessionSeconds = ref(0);
+const notifiedFull = ref(false);
+
+let learnTimer: ReturnType<typeof setInterval> | null = null;
+let flushTimer: ReturnType<typeof setInterval> | null = null;
+let pendingSeconds = 0;
+
+const files = computed(() => detail.value?.files || []);
+const activeFile = computed(() => files.value.find((f) => f.id === activeFileId.value) || files.value[0]);
+const previewUrl = computed(() => toFullUrl(activeFile.value?.url));
+const progressText = computed(() => {
+  const learned = detail.value?.learnedSeconds || 0;
+  const required = detail.value?.requiredSeconds || 0;
+  return `${formatDuration(learned)} / ${required > 0 ? formatDuration(required) : '鏈厤缃�'}`;
+});
+const progressPercent = computed(() => {
+  const required = detail.value?.requiredSeconds || 0;
+  if (required <= 0) return 0;
+  return Math.min(100, Math.round(((detail.value?.learnedSeconds || 0) / required) * 100));
+});
+const signStatusText = computed(() => {
+  if (detail.value?.signed || detail.value?.signTime) {
+    return `宸茬鍒�${detail.value.signTime ? `锛�${detail.value.signTime}锛塦 : ''}`;
+  }
+  return detail.value?.signTip || '鏈鍒�';
+});
+const showSignButton = computed(() => Boolean(detail.value?.canSign));
+
+onMounted(async () => {
+  await loadData();
+  startTimer();
+});
+
+onBeforeUnmount(() => {
+  stopTimer(true);
+});
+
+watch(
+  () => files.value,
+  (list) => {
+    if (!list.length) return;
+    if (!activeFileId.value || !list.some((f) => f.id === activeFileId.value)) {
+      activeFileId.value = list[0]?.id;
+    }
+  },
+  { immediate: true },
+);
+
+async function loadData() {
+  const id = String(route.params.id || '');
+  if (!id) {
+    router.replace('/tms/personTask');
+    return;
+  }
+  loading.value = true;
+  try {
+    detail.value = await getPersonTaskInfo(id);
+    if ((detail.value.learnedSeconds || 0) >= (detail.value.requiredSeconds || 0) && (detail.value.requiredSeconds || 0) > 0) {
+      notifiedFull.value = true;
+    }
+  } catch (e: any) {
+    createMessage.error(e?.message || '鍔犺浇澶辫触');
+    router.replace('/tms/personTask');
+  } finally {
+    loading.value = false;
+  }
+}
+
+function goBack() {
+  stopTimer(true).finally(() => {
+    router.push('/tms/personTask');
+  });
+}
+
+function selectFile(file: PersonTaskFile) {
+  activeFileId.value = file.id;
+}
+
+function toFullUrl(url?: string) {
+  if (!url) return '';
+  const apiIndex = url.indexOf('/api/');
+  const path = /^https?:\/\//i.test(url) && apiIndex >= 0 ? url.slice(apiIndex) : url;
+  // t=t 璁╂枃浠舵湇鍔$洿鎺ヨ緭鍑哄唴瀹癸紝鍚﹀垯浼� 302锛宨mg/video 鎷夸笉鍒版枃浠�
+  return getAuthMediaUrl(path, false);
+}
+
+function formatDuration(seconds: number) {
+  const h = Math.floor(seconds / 3600);
+  const m = Math.floor((seconds % 3600) / 60);
+  const s = seconds % 60;
+  if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
+  return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
+}
+
+function startTimer() {
+  if (learnTimer) return;
+  learnTimer = setInterval(() => {
+    sessionSeconds.value += 1;
+    pendingSeconds += 1;
+    if (!detail.value) return;
+    detail.value.learnedSeconds = (detail.value.learnedSeconds || 0) + 1;
+    const required = detail.value.requiredSeconds || 0;
+    const learned = detail.value.learnedSeconds || 0;
+    detail.value.learnStatus = required > 0 && learned >= required ? '宸插婊�' : learned > 0 ? '瀛︿範涓�' : '鏈涔�';
+  }, 1000);
+  flushTimer = setInterval(() => {
+    flushLearn();
+  }, 8000);
+}
+
+async function flushLearn() {
+  if (!detail.value?.id || pendingSeconds <= 0) return;
+  const seconds = pendingSeconds;
+  pendingSeconds = 0;
+  try {
+    const result = await markPersonTaskLearned(detail.value.id, seconds);
+    detail.value.learnedSeconds = result.learnedSeconds ?? detail.value.learnedSeconds;
+    detail.value.requiredSeconds = result.requiredSeconds ?? detail.value.requiredSeconds;
+    detail.value.learnStatus = result.learnStatus || detail.value.learnStatus;
+    detail.value.bizStatus = result.bizStatus || detail.value.bizStatus;
+    detail.value.canExam = result.canExam;
+    detail.value.examTip = result.examTip;
+    if (result.message && (!notifiedFull.value || result.autoCompleted)) {
+      notifiedFull.value = true;
+      if (result.autoCompleted) {
+        createMessage.success(result.message);
+      } else {
+        createMessage.info(result.message);
+      }
+    }
+  } catch {
+    pendingSeconds += seconds;
+  }
+}
+
+async function stopTimer(flush = false) {
+  if (learnTimer) {
+    clearInterval(learnTimer);
+    learnTimer = null;
+  }
+  if (flushTimer) {
+    clearInterval(flushTimer);
+    flushTimer = null;
+  }
+  if (flush) {
+    await flushLearn();
+  }
+}
+
+async function handleSign() {
+  if (!detail.value?.id || !detail.value.canSign || signing.value) return;
+  signing.value = true;
+  try {
+    const result = await signPersonTask(detail.value.id);
+    detail.value.signTime = result.signTime;
+    detail.value.signed = result.signed ?? true;
+    detail.value.canSign = result.canSign ?? false;
+    detail.value.signTip = result.signTip || '宸茬鍒�';
+    detail.value.bizStatus = result.bizStatus || detail.value.bizStatus;
+    detail.value.canExam = result.canExam;
+    detail.value.examTip = result.examTip;
+    createMessage.success(result.message || '绛惧埌鎴愬姛');
+  } catch (e: any) {
+    createMessage.error(e?.message || '绛惧埌澶辫触');
+  } finally {
+    signing.value = false;
+  }
+}
+
+async function handleExam() {
+  if (!detail.value?.paperId || !detail.value.canExam || acting.value) return;
+  await stopTimer(true);
+  acting.value = true;
+  try {
+    const paper = await startOnlineExam(detail.value.paperId);
+    if (paper?.autoSubmitted) {
+      createMessage.warning('鑰冭瘯鏃堕棿宸插埌锛屽凡鑷姩浜ゅ嵎');
+      await loadData();
+      startTimer();
+      return;
+    }
+    sessionStorage.setItem('tms_online_exam_paper', JSON.stringify(paper));
+    sessionStorage.setItem('tms_online_exam_myPaperId', detail.value.paperId);
+    router.push('/tms/onlineExam/exam');
+  } catch (e: any) {
+    const msg = e?.message || '杩涘叆鑰冭瘯澶辫触';
+    if (String(msg).includes('鑷姩浜ゅ嵎')) {
+      createMessage.warning(msg);
+      await loadData();
+    } else {
+      createMessage.error(msg);
+    }
+    startTimer();
+  } finally {
+    acting.value = false;
+  }
+}
+</script>
+
+<template>
+  <div class="jnpf-content-wrapper tms-learn-page" v-loading="loading">
+    <div class="jnpf-content-wrapper-center">
+      <div class="jnpf-content-wrapper-content tms-learn-wrap" v-if="detail">
+        <div class="tms-page-header">
+          <div>
+            <div class="tms-page-header__title">{{ detail.subject || '瀛︿範' }}</div>
+            <div class="tms-page-header__sub">
+              鏈満 {{ formatDuration(sessionSeconds) }} 路 绱 {{ progressText }} 路 {{ signStatusText }}
+            </div>
+          </div>
+          <div class="tms-page-header__actions">
+            <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
+            <a-button
+              v-if="showSignButton"
+              type="primary"
+              :ghost="Boolean(detail.canExam)"
+              :loading="signing"
+              @click="handleSign"
+            >
+              {{ TMS_BTN.sign }}
+            </a-button>
+            <a-button v-if="detail.canExam" type="primary" :loading="acting" @click="handleExam">
+              {{ detail.passFlag === '0' ? TMS_BTN.retake : TMS_BTN.enterExam }}
+            </a-button>
+          </div>
+        </div>
+
+        <a-progress :percent="progressPercent" :show-info="true" class="learn-progress" />
+
+        <a-alert
+          v-if="detail.examTip && !detail.canExam"
+          class="tms-page-tip"
+          type="info"
+          show-icon
+          :message="detail.examTip"
+        />
+
+        <div class="learn-body">
+          <div class="file-side">
+            <div class="side-title">鏁欐潗鍒楄〃</div>
+            <div
+              v-for="file in files"
+              :key="file.id"
+              class="file-row"
+              :class="{ active: file.id === activeFile?.id }"
+              @click="selectFile(file)"
+            >
+              <div class="name">{{ file.name }}</div>
+              <div class="ext">{{ (file.previewType || 'other').toUpperCase() }}</div>
+            </div>
+            <div v-if="!files.length" class="empty">鏆傛棤鏁欐潗</div>
+          </div>
+
+          <div class="preview-side">
+            <template v-if="activeFile && previewUrl">
+              <img v-if="activeFile.previewType === 'image'" :src="previewUrl" class="preview-media" alt="" />
+              <video
+                v-else-if="activeFile.previewType === 'video'"
+                :src="previewUrl"
+                class="preview-media"
+                controls
+                controlslist="nodownload"
+              />
+              <audio
+                v-else-if="activeFile.previewType === 'audio'"
+                :src="previewUrl"
+                class="preview-audio"
+                controls
+              />
+              <iframe
+                v-else-if="activeFile.previewType === 'pdf'"
+                :src="previewUrl"
+                class="preview-frame"
+                title="pdf-preview"
+              />
+              <div v-else class="preview-fallback">
+                <div class="mb-2">褰撳墠鏂囦欢绫诲瀷锛坽{ activeFile.fileExt || activeFile.previewType }}锛夋殏涓嶆敮鎸侀〉鍐呴瑙堛��</div>
+              </div>
+            </template>
+            <div v-else class="preview-fallback">璇烽�夋嫨宸︿晶鏁欐潗寮�濮嬪涔�</div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.tms-learn-page,
+.tms-learn-wrap {
+  height: 100%;
+  min-height: 0;
+}
+
+.tms-learn-wrap {
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  background: #fff;
+  padding: 12px 16px 16px;
+  box-sizing: border-box;
+}
+
+.learn-progress {
+  margin-bottom: 12px;
+}
+
+.learn-body {
+  flex: 1;
+  min-height: 0;
+  display: grid;
+  grid-template-columns: 260px 1fr;
+  gap: 12px;
+}
+
+.file-side {
+  border: 1px solid #f0f0f0;
+  border-radius: 6px;
+  overflow: auto;
+  padding: 8px;
+}
+
+.side-title {
+  font-weight: 600;
+  margin-bottom: 8px;
+}
+
+.file-row {
+  padding: 8px 10px;
+  border-radius: 6px;
+  cursor: pointer;
+}
+
+.file-row:hover,
+.file-row.active {
+  background: #e6f4ff;
+}
+
+.file-row .name {
+  line-height: 1.4;
+  word-break: break-all;
+}
+
+.file-row .ext {
+  margin-top: 2px;
+  color: #8c8c8c;
+  font-size: 12px;
+}
+
+.preview-side {
+  border: 1px solid #f0f0f0;
+  border-radius: 6px;
+  min-height: 0;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: #fafafa;
+  overflow: hidden;
+}
+
+.preview-media {
+  max-width: 100%;
+  max-height: 100%;
+  object-fit: contain;
+}
+
+.preview-audio {
+  width: 80%;
+}
+
+.preview-frame {
+  width: 100%;
+  height: 100%;
+  border: 0;
+  background: #fff;
+}
+
+.preview-fallback,
+.empty {
+  color: #8c8c8c;
+  padding: 24px;
+  text-align: center;
+}
+</style>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/personTask/constants.ts b/apps/jnpf-web-apps-main/src/views/x/tms/personTask/constants.ts
new file mode 100644
index 0000000..47aaae1
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/personTask/constants.ts
@@ -0,0 +1,39 @@
+import { ref } from 'vue';
+
+import { useBaseStore } from '#/store';
+import { TMS_DIC, labelOfDic, loadTmsDic, type TmsDicOpt } from '#/views/x/tms/shared/dic';
+
+export const PERSON_STATUS_OPTIONS = ref<TmsDicOpt[]>([]);
+export const TRAIN_MODE_OPTIONS = ref<TmsDicOpt[]>([]);
+export const EVAL_MODE_OPTIONS = ref<TmsDicOpt[]>([]);
+export const CATEGORY_OPTIONS = ref<TmsDicOpt[]>([]);
+
+export async function loadPersonTaskDics() {
+  const baseStore = useBaseStore();
+  const [status, trainMode, evalMode, category] = await Promise.all([
+    loadTmsDic(baseStore, TMS_DIC.personTaskStatus),
+    loadTmsDic(baseStore, TMS_DIC.trainMode),
+    loadTmsDic(baseStore, TMS_DIC.evalMode),
+    loadTmsDic(baseStore, TMS_DIC.taskCategory),
+  ]);
+  PERSON_STATUS_OPTIONS.value = status;
+  TRAIN_MODE_OPTIONS.value = trainMode;
+  EVAL_MODE_OPTIONS.value = evalMode;
+  CATEGORY_OPTIONS.value = category;
+}
+
+export function labelOfPersonStatus(v?: string) {
+  return labelOfDic(PERSON_STATUS_OPTIONS.value, v);
+}
+
+export function labelOfTrainMode(v?: string) {
+  return labelOfDic(TRAIN_MODE_OPTIONS.value, v);
+}
+
+export function labelOfEvalMode(v?: string) {
+  return labelOfDic(EVAL_MODE_OPTIONS.value, v);
+}
+
+export function labelOfCategory(v?: string) {
+  return labelOfDic(CATEGORY_OPTIONS.value, v);
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/personTask/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/personTask/index.vue
new file mode 100644
index 0000000..9931798
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/personTask/index.vue
@@ -0,0 +1,200 @@
+<script lang="ts" setup>
+import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable';
+
+import type { PersonTaskItem } from './types';
+
+import { onMounted } from 'vue';
+import { useRouter } from 'vue-router';
+
+import { useMessage } from '@jnpf/hooks';
+import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
+
+import { getMyPersonTaskList } from '#/api/x/tms/personTask';
+import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
+import { TMS_BTN } from '#/views/x/tms/shared/ui';
+
+import {
+  PERSON_STATUS_OPTIONS,
+  labelOfEvalMode,
+  labelOfPersonStatus,
+  labelOfTrainMode,
+  loadPersonTaskDics,
+} from './constants';
+
+import '#/views/x/tms/shared/page.css';
+
+defineOptions({ name: 'TmsPersonTask' });
+
+const router = useRouter();
+const { createMessage } = useMessage();
+
+const columns: BasicColumn[] = [
+  { title: '鍩硅缂栧彿', dataIndex: 'taskNo', width: 140 },
+  { title: '鍩硅涓婚', dataIndex: 'subject', minWidth: 200 },
+  {
+    title: '鍩硅鏂瑰紡',
+    dataIndex: 'trainMode',
+    width: 110,
+    customRender: ({ record }) => labelOfTrainMode((record as PersonTaskItem).trainMode),
+  },
+  { title: '寮�濮嬫椂闂�', dataIndex: 'startTime', width: 170 },
+  { title: '缁撴潫鏃堕棿', dataIndex: 'endTime', width: 170 },
+  {
+    title: '鐘舵��',
+    dataIndex: 'bizStatus',
+    width: 100,
+    customRender: ({ record }) => labelOfPersonStatus((record as PersonTaskItem).bizStatus),
+  },
+  { title: '瀛︿範杩涘害', dataIndex: 'learnStatus', width: 100 },
+  {
+    title: '鏄惁鍙��',
+    dataIndex: 'canExam',
+    width: 100,
+    customRender: ({ record }) => {
+      const row = record as PersonTaskItem;
+      if (row.canExam) return '鏄�';
+      return row.examTip ? '鍚�' : '鍚�';
+    },
+  },
+  {
+    title: '鑰冩牳鏂瑰紡',
+    dataIndex: 'evalMode',
+    width: 110,
+    customRender: ({ record }) => labelOfEvalMode((record as PersonTaskItem).evalMode),
+  },
+];
+
+const [registerTable, { reload, getForm }] = useVxeTable({
+  api: fetchList,
+  columns,
+  immediate: false,
+  rowKey: 'id',
+  useSearchForm: true,
+  formConfig: {
+    baseColProps: { span: 6 },
+    compact: true,
+    showAdvancedButton: true,
+    alwaysShowLines: 1,
+    autoAdvancedLine: 1,
+    schemas: [
+      {
+        field: 'keyword',
+        label: '鍏抽敭璇�',
+        component: 'Input',
+        componentProps: { placeholder: '缂栧彿/涓婚', submitOnPressEnter: true },
+      },
+      {
+        field: 'bizStatus',
+        label: '鐘舵��',
+        component: 'Select',
+        componentProps: {
+          allowClear: true,
+          placeholder: '璇烽�夋嫨',
+          options: PERSON_STATUS_OPTIONS.value,
+          fieldNames: TMS_DIC_FIELD_NAMES,
+        },
+      },
+    ],
+  },
+  actionColumn: {
+    width: 160,
+    title: '鎿嶄綔',
+    dataIndex: 'action',
+    fixed: 'right',
+  },
+});
+
+onMounted(async () => {
+  await loadPersonTaskDics();
+  getForm()?.updateSchema?.({
+    field: 'bizStatus',
+    componentProps: {
+      allowClear: true,
+      placeholder: '璇烽�夋嫨',
+      options: PERSON_STATUS_OPTIONS.value,
+      fieldNames: TMS_DIC_FIELD_NAMES,
+    },
+  });
+  reload();
+});
+
+async function fetchList(params: Record<string, any>) {
+  const page = await getMyPersonTaskList(params);
+  return {
+    data: {
+      list: Array.isArray(page?.list) ? page.list : [],
+      pagination: page?.pagination || { total: 0 },
+    },
+  };
+}
+
+function isClosedStatus(status?: string) {
+  return status === 'cancelled' || status === 'expired';
+}
+
+function canEnterLearn(record: PersonTaskItem) {
+  return !isClosedStatus(record.bizStatus);
+}
+
+function handleView(record: PersonTaskItem) {
+  router.push(`/tms/personTask/detail/${record.id}`);
+}
+
+function handleLearn(record: PersonTaskItem) {
+  if (!canEnterLearn(record)) {
+    createMessage.warning(
+      record.bizStatus === 'cancelled' ? '浠诲姟宸插彇娑堬紝鏃犳硶瀛︿範' : '浠诲姟宸茶繃鏈燂紝鏃犳硶瀛︿範',
+    );
+    return;
+  }
+  router.push(`/tms/personTask/learn/${record.id}`);
+}
+
+function handleExamHint(record: PersonTaskItem) {
+  if (record.canExam) {
+    router.push(`/tms/personTask/learn/${record.id}`);
+    return;
+  }
+  createMessage.info(record.examTip || record.signTip || '褰撳墠鏆備笉鍙�冭瘯');
+}
+
+function getTableActions(record: PersonTaskItem): ActionItem[] {
+  const actions: ActionItem[] = [{ label: TMS_BTN.detail, onClick: handleView.bind(null, record) }];
+  if (canEnterLearn(record)) {
+    actions.push({ label: TMS_BTN.learn, onClick: handleLearn.bind(null, record) });
+  }
+  if (record.canExam) {
+    actions.push({
+      label: record.passFlag === '0' ? TMS_BTN.retake : TMS_BTN.exam,
+      onClick: handleExamHint.bind(null, record),
+    });
+  } else if (canEnterLearn(record) && record.examTip) {
+    actions.push({
+      label: TMS_BTN.exam,
+      disabled: true,
+      tooltip: record.examTip,
+    });
+  }
+  return actions;
+}
+</script>
+
+<template>
+  <div class="jnpf-content-wrapper">
+    <div class="jnpf-content-wrapper-center">
+      <div class="jnpf-content-wrapper-content">
+        <BasicVxeTable @register="registerTable">
+          <template #tableTitle>
+            <div>
+              <div class="tms-page-header__title">涓汉鍩硅浠诲姟</div>
+              <div class="tms-page-header__sub">瀛︿範鏁欐潗骞跺畬鎴愯�冩牳锛涘彲鑰冩椂鎿嶄綔鍒楃洿鎺ヨ繘鍏ヨ�冭瘯銆�</div>
+            </div>
+          </template>
+          <template #action="{ record }">
+            <TableAction :actions="getTableActions(record)" />
+          </template>
+        </BasicVxeTable>
+      </div>
+    </div>
+  </div>
+</template>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/personTask/types.ts b/apps/jnpf-web-apps-main/src/views/x/tms/personTask/types.ts
new file mode 100644
index 0000000..5601fc5
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/personTask/types.ts
@@ -0,0 +1,69 @@
+export interface PersonTaskFile {
+  id?: string;
+  name?: string;
+  url?: string;
+  fileExt?: string;
+  previewType?: 'image' | 'video' | 'audio' | 'pdf' | 'office' | 'other' | string;
+}
+
+export interface PersonTaskItem {
+  id: string;
+  taskId?: string;
+  taskNo?: string;
+  subject?: string;
+  category?: string;
+  trainMode?: string;
+  evalMode?: string;
+  startTime?: string;
+  endTime?: string;
+  closeTime?: string;
+  placeName?: string;
+  keyPoints?: string;
+  bizStatus?: string;
+  signTime?: string;
+  signed?: boolean;
+  canSign?: boolean;
+  signTip?: string;
+  learnSeconds?: number;
+  learnedSeconds?: number;
+  requiredHours?: number;
+  requiredSeconds?: number;
+  learnStatus?: string;
+  examScore?: number;
+  passFlag?: string;
+  paperId?: string;
+  paperName?: string;
+  canExam?: boolean;
+  examTip?: string;
+  files?: PersonTaskFile[];
+}
+
+export interface PersonTaskLearnResult {
+  learnedSeconds?: number;
+  requiredSeconds?: number;
+  learnStatus?: string;
+  bizStatus?: string;
+  canExam?: boolean;
+  examTip?: string;
+  autoCompleted?: boolean;
+  message?: string;
+}
+
+export interface PersonTaskSignResult {
+  signTime?: string;
+  bizStatus?: string;
+  signed?: boolean;
+  canSign?: boolean;
+  signTip?: string;
+  canExam?: boolean;
+  examTip?: string;
+  message?: string;
+}
+
+export interface PersonTaskPageQuery {
+  keyword?: string;
+  bizStatus?: string;
+  currentPage?: number;
+  pageSize?: number;
+  [key: string]: any;
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/task/Detail.vue b/apps/jnpf-web-apps-main/src/views/x/tms/task/Detail.vue
new file mode 100644
index 0000000..3336f36
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/task/Detail.vue
@@ -0,0 +1,192 @@
+<script lang="ts" setup>
+import type { TaskEntity } from './types';
+
+import { onMounted, ref } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+
+import { useMessage } from '@jnpf/hooks';
+import {
+  Descriptions as ADescriptions,
+  DescriptionsItem as ADescriptionsItem,
+} from 'ant-design-vue';
+
+import { getUserInfoList } from '#/api/permission/user';
+import { getTaskInfo } from '#/api/x/tms/task';
+import { TMS_BTN } from '#/views/x/tms/shared/ui';
+
+import {
+  labelOfCategory,
+  labelOfEvalMode,
+  labelOfPersonStatus,
+  labelOfPublishStatus,
+  labelOfSourceType,
+  labelOfTaskKind,
+  labelOfTrainMode,
+  loadTaskDics,
+  publishStatusColor,
+} from './constants';
+
+import '#/views/x/tms/shared/page.css';
+
+defineOptions({ name: 'TmsTaskDetail' });
+
+const route = useRoute();
+const router = useRouter();
+const { createMessage } = useMessage();
+
+const loading = ref(false);
+const detail = ref<TaskEntity | null>(null);
+
+onMounted(async () => {
+  await loadTaskDics();
+  await loadData();
+});
+
+async function loadData() {
+  const id = String(route.params.id || '');
+  if (!id) {
+    router.replace('/tms/task');
+    return;
+  }
+  loading.value = true;
+  try {
+    const info = await getTaskInfo(id);
+    await fillUserNames(info);
+    detail.value = info;
+  } catch (e: any) {
+    createMessage.error(e?.message || '鍔犺浇澶辫触');
+    router.replace('/tms/task');
+  } finally {
+    loading.value = false;
+  }
+}
+
+async function fillUserNames(info: TaskEntity) {
+  const ids = [
+    info.teacherId,
+    ...(info.personTasks || []).map((item) => item.userId),
+  ].filter((id): id is string => !!id);
+  const uniqueIds = [...new Set(ids)];
+  if (!uniqueIds.length) return;
+  const res = await getUserInfoList(uniqueIds);
+  const list = res?.data?.list || [];
+  const nameOf = new Map<string, string>();
+  for (const user of list) {
+    const name = user.realName || String(user.fullName || '').split('/')[0] || user.account;
+    if (user.id && name) nameOf.set(user.id, name);
+  }
+  info.teacherName = nameOf.get(info.teacherId || '') || info.teacherId;
+  for (const item of info.personTasks || []) {
+    item.userName = nameOf.get(item.userId || '') || item.userId;
+  }
+}
+
+function goBack() {
+  router.push('/tms/task');
+}
+
+function goEdit() {
+  if (!detail.value?.id || detail.value.publishStatus !== 'draft') return;
+  router.push(`/tms/task/edit/${detail.value.id}`);
+}
+</script>
+
+<template>
+  <div class="jnpf-content-wrapper tms-task-detail-page" v-loading="loading">
+    <div class="jnpf-content-wrapper-center">
+      <div class="jnpf-content-wrapper-content tms-task-detail-wrap" v-if="detail">
+        <a-card title="鍩硅浠诲姟璇︽儏" :bordered="false">
+          <template #extra>
+            <a-space>
+              <a-button @click="goBack">{{ TMS_BTN.back }}</a-button>
+              <a-button
+                type="primary"
+                :disabled="detail.publishStatus !== 'draft'"
+                @click="goEdit"
+              >
+                {{ TMS_BTN.edit }}
+              </a-button>
+            </a-space>
+          </template>
+
+          <ADescriptions :column="2" bordered size="small">
+            <ADescriptionsItem label="鍩硅缂栧彿">{{ detail.taskNo || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="鐘舵��">
+              <a-tag :color="publishStatusColor(detail.publishStatus)">
+                {{ labelOfPublishStatus(detail.publishStatus) }}
+              </a-tag>
+            </ADescriptionsItem>
+            <ADescriptionsItem label="鍩硅涓婚" :span="2">{{ detail.subject }}</ADescriptionsItem>
+            <ADescriptionsItem label="鍩硅鍒嗙被">{{ labelOfCategory(detail.category) }}</ADescriptionsItem>
+            <ADescriptionsItem label="鏉ユ簮绫诲瀷">{{ labelOfSourceType(detail.sourceType) }}</ADescriptionsItem>
+            <ADescriptionsItem label="鍩硅鏂瑰紡">{{ labelOfTrainMode(detail.trainMode) }}</ADescriptionsItem>
+            <ADescriptionsItem label="鑰冩牳鏂瑰紡">{{ labelOfEvalMode(detail.evalMode) }}</ADescriptionsItem>
+            <ADescriptionsItem label="浠诲姟绫诲瀷">{{ labelOfTaskKind(detail.taskKind) }}</ADescriptionsItem>
+            <ADescriptionsItem label="涓婃浠诲姟缂栧彿">{{ detail.prevTaskNo || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="寮�濮嬫椂闂�">{{ detail.startTime || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="缁撴潫鏃堕棿">{{ detail.endTime || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="鍏抽棴鏃堕棿">{{ detail.closeTime || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="鍙戝竷鏃堕棿">{{ detail.publishTime || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="鍦扮偣">{{ detail.placeName || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="鏁欐潗缂栧彿">{{ detail.materialNo || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="闄勪欢" :span="2">
+              {{ detail.files?.map((item) => item.fileName).filter(Boolean).join('銆�') || '-' }}
+            </ADescriptionsItem>
+            <ADescriptionsItem label="璇曞嵎">{{ detail.paperName || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="琛ヨ�冩鏁�">{{ detail.retakeLimit ?? 1 }}</ADescriptionsItem>
+            <ADescriptionsItem label="鍚堟牸鍒�">{{ detail.passScore ?? '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="鑰冭瘯寮�濮�">{{ detail.examStart || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="鑰冭瘯缁撴潫">{{ detail.examEnd || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="鍩硅甯�">{{ detail.teacherName || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="鍩硅瀵硅薄鏁�">
+              {{ detail.personCount ?? detail.traineeIds?.length ?? '-' }}
+            </ADescriptionsItem>
+            <ADescriptionsItem label="鍩硅瑕佺偣" :span="2">{{ detail.keyPoints || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="澶囨敞" :span="2">{{ detail.remark || '-' }}</ADescriptionsItem>
+            <ADescriptionsItem label="鍒涘缓鏃堕棿">{{ detail.creatorTime || '-' }}</ADescriptionsItem>
+          </ADescriptions>
+
+          <a-divider>涓汉浠诲姟</a-divider>
+          <a-table
+            size="small"
+            :pagination="false"
+            row-key="id"
+            :data-source="detail.personTasks || []"
+            :columns="[
+              { title: '鐢ㄦ埛鍚嶇О', dataIndex: 'userName', width: 160 },
+              { title: '鐘舵��', dataIndex: 'bizStatus', width: 100 },
+              { title: '绛惧埌鏃堕棿', dataIndex: 'signTime', width: 170 },
+              { title: '鎴愮哗', dataIndex: 'examScore', width: 90 },
+              { title: '鍚堟牸', dataIndex: 'passFlag', width: 90 },
+            ]"
+          >
+            <template #bodyCell="{ column, record }">
+              <template v-if="column.dataIndex === 'bizStatus'">
+                {{ labelOfPersonStatus(record.bizStatus) }}
+              </template>
+              <template v-else-if="column.dataIndex === 'passFlag'">
+                {{ record.passFlag === '1' ? '鍚堟牸' : record.passFlag === '0' ? '涓嶅悎鏍�' : '-' }}
+              </template>
+            </template>
+          </a-table>
+        </a-card>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.tms-task-detail-page {
+  height: 100%;
+  min-height: 0;
+}
+
+.tms-task-detail-wrap {
+  height: 100%;
+  min-height: 0;
+  overflow-x: hidden;
+  overflow-y: auto !important;
+  background: #fff;
+  box-sizing: border-box;
+}
+</style>
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>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/task/constants.ts b/apps/jnpf-web-apps-main/src/views/x/tms/task/constants.ts
new file mode 100644
index 0000000..f042d72
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/task/constants.ts
@@ -0,0 +1,71 @@
+import type { DicOpt } from './types';
+
+import { ref } from 'vue';
+
+import { useBaseStore } from '#/store';
+import { TMS_DIC, labelOfDic, loadTmsDic } from '#/views/x/tms/shared/dic';
+
+export const PUBLISH_STATUS_OPTIONS = ref<DicOpt[]>([]);
+export const TASK_CATEGORY_OPTIONS = ref<DicOpt[]>([]);
+export const TASK_KIND_OPTIONS = ref<DicOpt[]>([]);
+export const SOURCE_TYPE_OPTIONS = ref<DicOpt[]>([]);
+export const TRAIN_MODE_OPTIONS = ref<DicOpt[]>([]);
+export const EVAL_MODE_OPTIONS = ref<DicOpt[]>([]);
+export const PERSON_STATUS_OPTIONS = ref<DicOpt[]>([]);
+
+export async function loadTaskDics() {
+  const baseStore = useBaseStore();
+  const [publish, category, kind, source, train, evalMode, person] = await Promise.all([
+    loadTmsDic(baseStore, TMS_DIC.taskPublishStatus),
+    loadTmsDic(baseStore, TMS_DIC.taskCategory),
+    loadTmsDic(baseStore, TMS_DIC.taskKind),
+    loadTmsDic(baseStore, TMS_DIC.taskSourceType),
+    loadTmsDic(baseStore, TMS_DIC.trainMode),
+    loadTmsDic(baseStore, TMS_DIC.evalMode),
+    loadTmsDic(baseStore, TMS_DIC.personTaskStatus),
+  ]);
+  PUBLISH_STATUS_OPTIONS.value = publish;
+  TASK_CATEGORY_OPTIONS.value = category;
+  TASK_KIND_OPTIONS.value = kind;
+  SOURCE_TYPE_OPTIONS.value = source;
+  TRAIN_MODE_OPTIONS.value = train;
+  EVAL_MODE_OPTIONS.value = evalMode;
+  PERSON_STATUS_OPTIONS.value = person;
+}
+
+export function labelOfPublishStatus(v?: string) {
+  return labelOfDic(PUBLISH_STATUS_OPTIONS.value, v);
+}
+export function labelOfCategory(v?: string) {
+  return labelOfDic(TASK_CATEGORY_OPTIONS.value, v);
+}
+export function labelOfSourceType(v?: string) {
+  return labelOfDic(SOURCE_TYPE_OPTIONS.value, v);
+}
+export function labelOfTrainMode(v?: string) {
+  return labelOfDic(TRAIN_MODE_OPTIONS.value, v);
+}
+export function labelOfEvalMode(v?: string) {
+  return labelOfDic(EVAL_MODE_OPTIONS.value, v);
+}
+export function labelOfTaskKind(v?: string) {
+  return labelOfDic(TASK_KIND_OPTIONS.value, v);
+}
+export function labelOfPersonStatus(v?: string) {
+  return labelOfDic(PERSON_STATUS_OPTIONS.value, v);
+}
+
+export function publishStatusColor(status?: string) {
+  switch (status) {
+    case 'draft':
+      return 'default';
+    case 'published':
+      return 'processing';
+    case 'done':
+      return 'success';
+    case 'cancelled':
+      return 'error';
+    default:
+      return 'default';
+  }
+}
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/task/index.vue b/apps/jnpf-web-apps-main/src/views/x/tms/task/index.vue
new file mode 100644
index 0000000..042086f
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/task/index.vue
@@ -0,0 +1,325 @@
+<script lang="ts" setup>
+import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable';
+
+import type { TaskEntity } from './types';
+
+import { onMounted } from 'vue';
+import { useRouter } from 'vue-router';
+
+import { useMessage } from '@jnpf/hooks';
+import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
+
+import { Modal } from 'ant-design-vue';
+
+import {
+  cancelTask,
+  cancelTaskBatch,
+  deleteTask,
+  getTaskList,
+  publishTask,
+  restoreTask,
+} from '#/api/x/tms/task';
+import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
+import { TMS_BTN } from '#/views/x/tms/shared/ui';
+
+import {
+  PUBLISH_STATUS_OPTIONS,
+  SOURCE_TYPE_OPTIONS,
+  TRAIN_MODE_OPTIONS,
+  labelOfPublishStatus,
+  labelOfSourceType,
+  labelOfTrainMode,
+  loadTaskDics,
+  publishStatusColor,
+} from './constants';
+
+defineOptions({ name: 'TmsTaskList' });
+
+const router = useRouter();
+const { createMessage } = useMessage();
+
+const columns: BasicColumn[] = [
+  { title: '鍩硅缂栧彿', dataIndex: 'taskNo', width: 140 },
+  { title: '鍩硅涓婚', dataIndex: 'subject', minWidth: 200 },
+  {
+    title: '鏉ユ簮绫诲瀷',
+    dataIndex: 'sourceType',
+    width: 110,
+    customRender: ({ record }) => labelOfSourceType((record as TaskEntity).sourceType),
+  },
+  {
+    title: '鍩硅鏂瑰紡',
+    dataIndex: 'trainMode',
+    width: 110,
+    customRender: ({ record }) => labelOfTrainMode((record as TaskEntity).trainMode),
+  },
+  { title: '寮�濮嬫椂闂�', dataIndex: 'startTime', width: 170 },
+  { title: '缁撴潫鏃堕棿', dataIndex: 'endTime', width: 170 },
+  { title: '鍦扮偣', dataIndex: 'placeName', width: 140 },
+  {
+    title: '鐘舵��',
+    dataIndex: 'publishStatus',
+    width: 100,
+    align: 'center',
+    slots: { default: 'publishStatus' },
+  },
+  { title: '鍒涘缓鏃堕棿', dataIndex: 'creatorTime', width: 170 },
+];
+
+const [registerTable, { reload, getForm, getSelectRows }] = useVxeTable({
+  api: fetchList,
+  columns,
+  immediate: false,
+  rowKey: 'id',
+  useSearchForm: true,
+  formConfig: {
+    baseColProps: { span: 6 },
+    compact: true,
+    schemas: [
+      {
+        field: 'keyword',
+        label: '鍏抽敭璇�',
+        component: 'Input',
+        componentProps: { placeholder: '缂栧彿/涓婚', submitOnPressEnter: true },
+      },
+      {
+        field: 'publishStatus',
+        label: '鐘舵��',
+        component: 'Select',
+        componentProps: {
+          allowClear: true,
+          placeholder: '璇烽�夋嫨',
+          options: PUBLISH_STATUS_OPTIONS.value,
+          fieldNames: TMS_DIC_FIELD_NAMES,
+        },
+      },
+      {
+        field: 'trainMode',
+        label: '鍩硅鏂瑰紡',
+        component: 'Select',
+        componentProps: {
+          allowClear: true,
+          placeholder: '璇烽�夋嫨',
+          options: TRAIN_MODE_OPTIONS.value,
+          fieldNames: TMS_DIC_FIELD_NAMES,
+        },
+      },
+      {
+        field: 'sourceType',
+        label: '鏉ユ簮绫诲瀷',
+        component: 'Select',
+        componentProps: {
+          allowClear: true,
+          placeholder: '璇烽�夋嫨',
+          options: SOURCE_TYPE_OPTIONS.value,
+          fieldNames: TMS_DIC_FIELD_NAMES,
+        },
+      },
+      {
+        field: 'startTimeRange',
+        label: '寮�濮嬫椂闂�',
+        component: 'DateRange',
+        componentProps: {
+          format: 'YYYY-MM-DD',
+          placeholder: ['寮�濮�', '缁撴潫'],
+        },
+      },
+    ],
+  },
+  rowSelection: { type: 'checkbox' },
+  actionColumn: {
+    width: 260,
+    title: '鎿嶄綔',
+    dataIndex: 'action',
+    fixed: 'right',
+  },
+});
+
+onMounted(async () => {
+  await loadTaskDics();
+  getForm()?.updateSchema?.([
+    {
+      field: 'publishStatus',
+      componentProps: {
+        allowClear: true,
+        placeholder: '璇烽�夋嫨',
+        options: PUBLISH_STATUS_OPTIONS.value,
+        fieldNames: TMS_DIC_FIELD_NAMES,
+      },
+    },
+    {
+      field: 'trainMode',
+      componentProps: {
+        allowClear: true,
+        placeholder: '璇烽�夋嫨',
+        options: TRAIN_MODE_OPTIONS.value,
+        fieldNames: TMS_DIC_FIELD_NAMES,
+      },
+    },
+    {
+      field: 'sourceType',
+      componentProps: {
+        allowClear: true,
+        placeholder: '璇烽�夋嫨',
+        options: SOURCE_TYPE_OPTIONS.value,
+        fieldNames: TMS_DIC_FIELD_NAMES,
+      },
+    },
+  ]);
+  reload();
+});
+
+async function fetchList(params: Record<string, any>) {
+  const query = { ...params };
+  const range = query.startTimeRange;
+  if (Array.isArray(range) && range.length === 2) {
+    query.startTimeFrom = range[0];
+    query.startTimeTo = range[1];
+  }
+  delete query.startTimeRange;
+  const page = await getTaskList(query);
+  return {
+    data: {
+      list: Array.isArray(page?.list) ? page.list : [],
+      pagination: page?.pagination || { total: 0 },
+    },
+  };
+}
+
+function handleCreate() {
+  router.push('/tms/task/create');
+}
+
+function handleEdit(record: TaskEntity) {
+  if (record.publishStatus !== 'draft') {
+    createMessage.warning('浠呮湭鍙戝竷浠诲姟鍙淮鎶�');
+    return;
+  }
+  router.push(`/tms/task/edit/${record.id}`);
+}
+
+function handleDetail(record: TaskEntity) {
+  router.push(`/tms/task/detail/${record.id}`);
+}
+
+async function handlePublish(record: TaskEntity) {
+  await publishTask(record.id!);
+  createMessage.success('鍙戝竷鎴愬姛');
+  reload();
+}
+
+async function handleCancel(record: TaskEntity) {
+  await cancelTask(record.id!);
+  createMessage.success('宸插彇娑�');
+  reload();
+}
+
+async function handleRestore(record: TaskEntity) {
+  await restoreTask(record.id!);
+  createMessage.success('宸叉仮澶嶄负鑽夌');
+  reload();
+}
+
+async function handleDelete(record: TaskEntity) {
+  await deleteTask(record.id!);
+  createMessage.success('鍒犻櫎鎴愬姛');
+  reload();
+}
+
+async function handleBatchCancel() {
+  const rows = (getSelectRows?.() || []) as TaskEntity[];
+  const ids = rows.filter((r) => r.publishStatus === 'published').map((r) => r.id!).filter(Boolean);
+  if (!ids.length) {
+    createMessage.warning('璇峰嬀閫夊凡鍙戝竷鐨勪换鍔�');
+    return;
+  }
+  Modal.confirm({
+    title: '鎵归噺鍙栨秷',
+    content: `纭畾鍙栨秷閫変腑鐨� ${ids.length} 涓凡鍙戝竷浠诲姟锛熷凡鏈夌鍒颁汉鍛樼殑浠诲姟灏嗗け璐ャ�俙,
+    onOk: async () => {
+      await cancelTaskBatch(ids);
+      createMessage.success('鎵归噺鍙栨秷鎴愬姛');
+      reload();
+    },
+  });
+}
+
+function getTableActions(record: TaskEntity): ActionItem[] {
+  const actions: ActionItem[] = [{ label: TMS_BTN.detail, onClick: handleDetail.bind(null, record) }];
+  if (record.publishStatus === 'draft') {
+    actions.unshift(
+      {
+        label: TMS_BTN.publish,
+        modelConfirm: {
+          content: `纭畾鍙戝竷銆�${record.subject}銆嶏紵灏嗙敓鎴愪釜浜轰换鍔′笌鍩硅璁板綍銆俙,
+          onOk: handlePublish.bind(null, record),
+        },
+      },
+      {
+        label: TMS_BTN.maintain,
+        onClick: handleEdit.bind(null, record),
+      },
+      {
+        label: TMS_BTN.delete,
+        color: 'error',
+        modelConfirm: {
+          content: `纭畾鍒犻櫎銆�${record.subject}銆嶅悧锛焋,
+          onOk: handleDelete.bind(null, record),
+        },
+      },
+    );
+  } else if (record.publishStatus === 'published') {
+    actions.unshift({
+      label: TMS_BTN.cancel,
+      color: 'error',
+      modelConfirm: {
+        content: `纭畾鍙栨秷銆�${record.subject}銆嶏紵宸叉湁绛惧埌浜哄憳鏃朵笉鍙彇娑堛�俙,
+        onOk: handleCancel.bind(null, record),
+      },
+    });
+  } else if (record.publishStatus === 'cancelled') {
+    actions.unshift({
+      label: TMS_BTN.restore,
+      modelConfirm: {
+        content: `纭畾灏嗐��${record.subject}銆嶆仮澶嶄负鑽夌锛焋,
+        onOk: handleRestore.bind(null, record),
+      },
+    });
+  }
+  return actions;
+}
+</script>
+
+<template>
+  <div class="jnpf-content-wrapper">
+    <div class="jnpf-content-wrapper-center">
+      <div class="jnpf-content-wrapper-content">
+        <BasicVxeTable @register="registerTable">
+          <template #tableTitle>
+            <a-space>
+              <a-button type="primary" pre-icon="icon-ym icon-ym-btn-add" @click="handleCreate">
+                {{ TMS_BTN.add }}涓存椂鍩硅
+              </a-button>
+              <a-button danger @click="handleBatchCancel">鎵归噺{{ TMS_BTN.cancel }}</a-button>
+            </a-space>
+          </template>
+          <template #publishStatus="{ record }">
+            <a-tag :color="publishStatusColor(record.publishStatus)">
+              {{ labelOfPublishStatus(record.publishStatus) }}
+            </a-tag>
+          </template>
+          <template #action="{ record }">
+            <TableAction :actions="getTableActions(record)" />
+          </template>
+        </BasicVxeTable>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped>
+.tms-task-list-hint {
+  color: #999;
+  font-size: 12px;
+}
+</style>
diff --git a/apps/jnpf-web-apps-main/src/views/x/tms/task/types.ts b/apps/jnpf-web-apps-main/src/views/x/tms/task/types.ts
new file mode 100644
index 0000000..27244cf
--- /dev/null
+++ b/apps/jnpf-web-apps-main/src/views/x/tms/task/types.ts
@@ -0,0 +1,82 @@
+import type { TmsDicOpt } from '#/views/x/tms/shared/dic';
+
+export interface TaskEntity {
+  id?: string;
+  taskNo?: string;
+  category?: string;
+  trainType?: string;
+  sourceType?: string;
+  sourcePlanNo?: string;
+  sourceId?: string;
+  sourceItemId?: string;
+  subject?: string;
+  keyPoints?: string;
+  materialId?: string;
+  materialNo?: string;
+  placeId?: string;
+  placeNo?: string;
+  placeName?: string;
+  startTime?: string;
+  endTime?: string;
+  closeTime?: string;
+  paperId?: string;
+  paperName?: string;
+  examStart?: string;
+  examEnd?: string;
+  teacherId?: string;
+  teacherName?: string;
+  trainMode?: string;
+  evalMode?: string;
+  traineeIds?: string[];
+  trainees?: string;
+  taskKind?: string;
+  prevTaskNo?: string;
+  publishStatus?: string;
+  publishTime?: string;
+  retakeLimit?: number;
+  passScore?: number;
+  quizRatio?: number;
+  overdueRemind?: string;
+  inviteFlag?: string;
+  remark?: string;
+  creatorTime?: string;
+  creatorUserId?: string;
+  personCount?: number;
+  personTasks?: PersonTaskItem[];
+  files?: TaskFile[];
+}
+
+export interface PersonTaskItem {
+  id?: string;
+  userId?: string;
+  userName?: string;
+  deptId?: string;
+  bizStatus?: string;
+  signTime?: string;
+  examScore?: number;
+  passFlag?: string;
+  guestFlag?: string;
+}
+
+export interface TaskFile {
+  id?: string;
+  fileNo?: string;
+  fileName?: string;
+  materialId?: string;
+  fileJson?: string;
+  canDownload?: string;
+}
+
+export interface TaskPageQuery {
+  keyword?: string;
+  publishStatus?: string;
+  trainMode?: string;
+  sourceType?: string;
+  startTimeFrom?: string;
+  startTimeTo?: string;
+  currentPage?: number;
+  pageSize?: number;
+  [key: string]: any;
+}
+
+export type DicOpt = TmsDicOpt;

--
Gitblit v1.8.0