Browse Source

🎨 协议填写增加审核以及显示优化

快乐的梦鱼 2 weeks ago
parent
commit
0a9dec5c6f

+ 3 - 1
.claude/settings.local.json

@@ -6,7 +6,9 @@
       "Bash(find . -name \"useMemorizeVar*\")",
       "Bash(find . -name \"useMemorizeVar*\")",
       "mcp__chrome-devtools__navigate_page",
       "mcp__chrome-devtools__navigate_page",
       "mcp__chrome-devtools__take_screenshot",
       "mcp__chrome-devtools__take_screenshot",
-      "mcp__chrome-devtools__evaluate_script"
+      "mcp__chrome-devtools__evaluate_script",
+      "mcp__chrome-devtools__new_page",
+      "Bash(npx vue-tsc *)"
     ]
     ]
   }
   }
 }
 }

+ 1 - 1
index.html

@@ -4,7 +4,7 @@
     <meta charset="UTF-8">
     <meta charset="UTF-8">
     <link rel="icon" href="/favicon.ico">
     <link rel="icon" href="/favicon.ico">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
     <meta name="viewport" content="width=device-width, initial-scale=1.0">
-    <title>闽南文化生态保护区(厦门市)</title>
+    <title>闽南文化数字资源采集平台</title>
   </head>
   </head>
   <body>
   <body>
     <div id="app"></div>
     <div id="app"></div>

+ 41 - 1
src/api/collect/AssessmentContent.ts

@@ -157,6 +157,7 @@ export class UserAgreementListRow extends DataModel<UserAgreementListRow> {
       course: { clientSide: 'number', serverSide: 'number' },
       course: { clientSide: 'number', serverSide: 'number' },
       progress: { clientSide: 'number', serverSide: 'number' },
       progress: { clientSide: 'number', serverSide: 'number' },
       level: { clientSide: 'number', serverSide: 'string' },
       level: { clientSide: 'number', serverSide: 'string' },
+      rejectType: { clientSide: 'number', serverSide: 'number' },
     };
     };
     this._convertKeyType = (key) => {
     this._convertKeyType = (key) => {
       if (key.endsWith('Text') || key.endsWith('_text')) {
       if (key.endsWith('Text') || key.endsWith('_text')) {
@@ -194,8 +195,16 @@ export class UserAgreementListRow extends DataModel<UserAgreementListRow> {
   ich = '' as string|null;
   ich = '' as string|null;
   partyASign = '' as string|null;
   partyASign = '' as string|null;
   partyBSign = '' as string|null;
   partyBSign = '' as string|null;
-  /** -1=未提交,0=草稿,1=已自评,2=审核完成 */
+  /** 进度:0=草稿,
+1=已自评,
+2=项目保护单位审核完成,
+3=县(区)文旅部门审核完成,
+4=设区市文旅部门、省非遗中心审核完成,
+5=省文化和旅游厅审核完成 */
   progress = null as number|null;
   progress = null as number|null;
+  /** 退回状态: 0=无,1=自评阶段退回,2=项目保护单位退回,3=县(区)文旅部门退回,4=设区市文旅部门/省非遗中心退回,5=省文化和旅游厅退回 */
+  rejectType = null as number|null;
+  rejectReason = '' as string|null;
   createtime = '' as string|null;
   createtime = '' as string|null;
   updatetime = '' as string|null;
   updatetime = '' as string|null;
   deletetime = '' as string|null;
   deletetime = '' as string|null;
@@ -568,6 +577,12 @@ export class AgreementDetail extends DataModel<AgreementDetail> {
   activity = 0 as number;
   activity = 0 as number;
   course = 0 as number;
   course = 0 as number;
   mobile = '' as string|null;
   mobile = '' as string|null;
+  /** 进度: 0=草稿,1=已自评,2=项目保护单位审核完成,3=县(区)文旅部门审核完成,4=设区市文旅部门、省非遗中心审核完成,5=省文化和旅游厅审核完成 */
+  progress: number = 0;
+  /** 退回状态: 0=无,1=自评阶段退回,2=项目保护单位退回,3=县(区)文旅部门退回,4=设区市文旅部门、省非遗中心退回,5=省文化和旅游厅退回 */
+  rejectType: number = 0;
+  /** 退回原因 */
+  rejectReason?: string;
   partyAMobile = '' as string|null;
   partyAMobile = '' as string|null;
   idCard = '' as string|null;
   idCard = '' as string|null;
   health = '' as string|null;
   health = '' as string|null;
@@ -597,6 +612,18 @@ export function getCheckAnnexType(mimetype: string) {
 
 
 export type CheckAnnexTypeValue = (typeof CheckAnnexType)[keyof typeof CheckAnnexType];
 export type CheckAnnexTypeValue = (typeof CheckAnnexType)[keyof typeof CheckAnnexType];
 
 
+/** 传承协议审核提交(POST /ich/check/reviewAgreememt) */
+export interface AgreementReviewPayload {
+  /** 传承协议 ID */
+  id: number;
+  /** 进度: 0=草稿,1=已自评,2=项目保护单位审核完成,3=县(区)文旅部门审核完成,4=设区市文旅部门、省非遗中心审核完成,5=省文化和旅游厅审核完成 */
+  progress: number;
+  /** 退回状态: 0=无,1=自评阶段退回,2=项目保护单位退回,3=县(区)文旅部门退回,4=设区市文旅部门、省非遗中心退回,5=省文化和旅游厅退回 */
+  rejectType?: number;
+  /** 退回原因 */
+  rejectReason?: string;
+}
+
 /** 自查表审核提交(POST /ich/check/review) */
 /** 自查表审核提交(POST /ich/check/review) */
 export interface IchCheckReviewPayload {
 export interface IchCheckReviewPayload {
   /** 自查表 ID */
   /** 自查表 ID */
@@ -858,6 +885,19 @@ export class AssessmentContentApi extends AppServerRequestModule<DataModel> {
     return this.post('/ich/check/saveAgreement', '传承协议保存', dataModel.toServerSide());
     return this.post('/ich/check/saveAgreement', '传承协议保存', dataModel.toServerSide());
   }
   }
 
 
+  /**
+   * 传承协议审核
+   * POST `/ich/check/reviewAgreememt`
+   */
+  async reviewAgreement(payload: AgreementReviewPayload) {
+    return this.post('/ich/check/reviewAgreememt', '传承协议审核', {
+      id: payload.id,
+      progress: payload.progress,
+      reject_type: payload.rejectType || 0,
+      reject_reason: payload.rejectReason,
+    });
+  }
+
   async saveSelfAssessment(dataModel: SelfAssessmentDetail, progress?: number) {
   async saveSelfAssessment(dataModel: SelfAssessmentDetail, progress?: number) {
     const data = dataModel.toServerSide();
     const data = dataModel.toServerSide();
     if (progress !== undefined)
     if (progress !== undefined)

+ 4 - 5
src/components/NavBar.vue

@@ -39,7 +39,6 @@
         <img class="main-clickable" src="@/assets/images/LogoIcon.png" @click="goIndex" />
         <img class="main-clickable" src="@/assets/images/LogoIcon.png" @click="goIndex" />
         <div>
         <div>
           <p class="large">{{ TITLE }}</p>
           <p class="large">{{ TITLE }}</p>
-          <p>闽南文化生态保护区<span>(厦门市)</span></p>
         </div>
         </div>
       </div>
       </div>
     </div>
     </div>
@@ -304,11 +303,11 @@ nav.main {
         p {
         p {
           font-size: 0.7rem;
           font-size: 0.7rem;
           height: 20px;
           height: 20px;
+          max-width: 30vw;
+          text-overflow: ellipsis;
+          overflow: hidden;
+          white-space: nowrap;
 
 
-          span {
-            font-size: 0.7rem;
-            margin-left: 10px;
-          }
           &.large {
           &.large {
             height: 23px;
             height: 23px;
             font-size: 1.3rem;
             font-size: 1.3rem;

+ 2 - 76
src/pages/admin.vue

@@ -123,32 +123,7 @@
             <a-empty description="暂无数据" />
             <a-empty description="暂无数据" />
           </a-tab-pane> -->
           </a-tab-pane> -->
           <a-tab-pane key="9" tab="传承协议签署">
           <a-tab-pane key="9" tab="传承协议签署">
-            <CommonListBlock
-              ref="listRef"
-              :show-total="true"
-              :row-count="1"
-              :row-type="5"
-              :page-size="10"
-              :drop-down-names="[
-                {
-                  options: agreementProgressOptions,
-                  label: '状态',
-                  defaultSelectedValue: lastAgreementProgress,
-                },
-                {
-                  options: selfAssessmentLevelOptions,
-                  label: '等级',
-                  defaultSelectedValue: lastAgreementLevel,
-                },
-              ]"
-              :load="(page: number, pageSize: number, _tag: number, searchText: string, drop: number[]) => loadAgreementSignAdminList(page, pageSize, searchText, drop)"
-              :show-detail="(item) => router.push({ name: 'CollectAgreementSign', query: { id: item.agreementId && item.agreementId > 0 ? item.agreementId : 0, userId: item.userId ?? 0 } })"
-            >
-              <template #itemRight="{ item }">
-                <span class="mr-3 text-sm text-gray-600">{{ agreementProgressLabel(item.progress) }}</span>
-                <a-button type="link" @click.stop="router.push({ name: 'CollectAgreementSign', query: { id: item.agreementId && item.agreementId > 0 ? item.agreementId : 0, userId: item.userId ?? 0 } })">编辑</a-button>
-              </template>
-            </CommonListBlock>
+            <AgreementSignList ref="listRef" />
           </a-tab-pane>
           </a-tab-pane>
           <a-tab-pane key="8" tab="自查评估表">
           <a-tab-pane key="8" tab="自查评估表">
             <EvaluationFormList ref="listRef" />
             <EvaluationFormList ref="listRef" />
@@ -193,9 +168,9 @@ import useClipboard from 'vue-clipboard3';
 import CommonContent, { GetContentListParams } from '@/api/CommonContent';
 import CommonContent, { GetContentListParams } from '@/api/CommonContent';
 import CommonListBlock, { type DropdownCommonItem } from '@/components/content/CommonListBlock.vue';
 import CommonListBlock, { type DropdownCommonItem } from '@/components/content/CommonListBlock.vue';
 import InheritorContent from '@/api/inheritor/InheritorContent';
 import InheritorContent from '@/api/inheritor/InheritorContent';
-import AssessmentContentApi from '@/api/collect/AssessmentContent';
 import AdminItemState from './components/AdminItemState.vue';
 import AdminItemState from './components/AdminItemState.vue';
 import EvaluationFormList from './collect/assessment/evaluation-form-list.vue';
 import EvaluationFormList from './collect/assessment/evaluation-form-list.vue';
+import AgreementSignList from './collect/assessment/argeement-sign-list.vue';
 import { InfoCircleOutlined } from '@ant-design/icons-vue';
 import { InfoCircleOutlined } from '@ant-design/icons-vue';
 import { useSimpleDataLoader } from '@/composeables/useSimpleDataLoader';
 import { useSimpleDataLoader } from '@/composeables/useSimpleDataLoader';
 import { useMemorizeVar } from '@/composeables/useMemorizeVar';
 import { useMemorizeVar } from '@/composeables/useMemorizeVar';
@@ -229,7 +204,6 @@ watch(() => authStore.userId, (newV) => {
 
 
 const { variable: lastValueCategory } = useMemorizeVar('categoryLastSelectValue', 0);
 const { variable: lastValueCategory } = useMemorizeVar('categoryLastSelectValue', 0);
 const { variable: lastValueStatus } = useMemorizeVar('statusLastSelectValue', -10);
 const { variable: lastValueStatus } = useMemorizeVar('statusLastSelectValue', -10);
-const { variable: lastAgreementProgress } = useMemorizeVar('adminUserAgreementProgress', -100);
 const { variable: lastAgreementLevel } = useMemorizeVar('adminUserAgreementLevel', 0);
 const { variable: lastAgreementLevel } = useMemorizeVar('adminUserAgreementLevel', 0);
 
 
 const selfAssessmentLevelOptions: DropdownCommonItem[] = [
 const selfAssessmentLevelOptions: DropdownCommonItem[] = [
@@ -239,22 +213,6 @@ const selfAssessmentLevelOptions: DropdownCommonItem[] = [
   { id: 25, name: '市级' },
   { id: 25, name: '市级' },
 ];
 ];
 
 
-/** 传承人传承协议列表:进度筛选(与 ich/check/getUserAgreement 一致) */
-const agreementProgressOptions: DropdownCommonItem[] = [
-  { id: -100, name: '全部状态' },
-  { id: -1, name: '未提交' },
-  { id: 0, name: '草稿' },
-  { id: 1, name: '已提交审核' },
-  { id: 2, name: '审核完成' },
-];
-
-function agreementProgressLabel(progress: number | null | undefined) {
-  if (progress === null || progress === undefined)
-    return '未填写';
-  const hit = agreementProgressOptions.find((o) => o.id === progress);
-  return hit?.name ?? `进度 ${progress}`;
-}
-
 const computedCategoryOptions = computed<DropdownCommonItem[]>(() => {
 const computedCategoryOptions = computed<DropdownCommonItem[]>(() => {
   if (authStore.isReviewer) {
   if (authStore.isReviewer) {
     return [
     return [
@@ -400,38 +358,6 @@ async function loadAreaData(page: number, pageSize: number, dropDownValues: numb
   }
   }
 }
 }
 
 
-/** 管理员:传承人传承协议分页(ich/check/getUserAgreement) */
-async function loadAgreementSignAdminList(page: number, pageSize: number, searchText: string, dropDownValues: number[]) {
-  const pv = dropDownValues?.[0];
-  const lv = dropDownValues?.[1];
-  lastAgreementProgress.value = pv ?? -100;
-  lastAgreementLevel.value = lv ?? 0;
-  const progress = pv != null && pv > -50 ? pv : undefined;
-  const level = lv != null && lv > 0 ? lv : undefined;
-  const list = await AssessmentContentApi.getUserAgreementList({
-    year: new Date().getFullYear(),
-    page,
-    pageSize,
-    keywords: searchText?.trim() || undefined,
-    progress,
-    level,
-  });
-  console.log(list);
-  
-  return {
-    page,
-    total: list.total,
-    data: list.data.map((row) => ({
-      ...row,
-      id: row.id,
-      agreementId: row.agreementId,
-      userId: row.userId,
-      title: row.title,
-      desc: [row.mobile, row.unit, row.ichTitle].filter(Boolean).join(' · ') || '—',
-    })),
-  };
-}
-
 async function handleCopyAccount(item: GetContentListItem) {
 async function handleCopyAccount(item: GetContentListItem) {
   let result;
   let result;
   try {
   try {

+ 154 - 0
src/pages/collect/assessment/argeement-sign-list.vue

@@ -0,0 +1,154 @@
+<template>
+  <CommonListBlock
+    ref="listRef"
+    :show-total="true"
+    :row-count="1"
+    :row-type="5"
+    :page-size="10"
+    :drop-down-names="[
+      {
+        options: agreementProgressOptions,
+        label: '状态',
+        defaultSelectedValue: lastAgreementProgress,
+      },
+      {
+        options: agreementLevelOptions,
+        label: '等级',
+        defaultSelectedValue: lastAgreementLevel,
+      },
+    ]"
+    :load="(page: number, pageSize: number, _tag: number, searchText: string, drop: number[]) => loadAgreementSignAdminList(page, pageSize, searchText, drop)"
+    :show-detail="handleShowDetail"
+  >
+    <template #itemRight="{ item }">
+      <a-popover v-if="item.rejectType && item.rejectType > 0" title="退回原因" trigger="hover">
+        <template #content>
+          <div style="max-width: 300px">{{ item.rejectReason || '无' }}</div>
+        </template>
+        <span class="mr-3 text-sm" style="color: #f5222d; cursor: pointer;">
+          <ExclamationCircleOutlined /> 已退回
+        </span>
+      </a-popover>
+      <span v-else class="mr-3 text-sm text-gray-600">{{ agreementProgressLabel(item.progress) }}</span>
+      <a-button type="link" @click.stop="handleEdit(item)">编辑</a-button>
+      <a-button
+        type="primary"
+        v-if="canReview(item)"
+        @click.stop="handleReview(item)"
+      >
+        审核
+      </a-button>
+    </template>
+  </CommonListBlock>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import { useRouter } from 'vue-router';
+import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
+import CommonListBlock, { type DropdownCommonItem } from '@/components/content/CommonListBlock.vue';
+import AssessmentContentApi, { type UserAgreementListRow } from '@/api/collect/AssessmentContent';
+import { useMemorizeVar } from '@/composeables/useMemorizeVar';
+import { useAuthStore } from '@/stores/auth';
+import { GROUP_TO_REVIEW_PROGRESS } from './composeables/GroupData';
+
+const router = useRouter();
+const authStore = useAuthStore();
+const listRef = ref<any>(null);
+
+const { variable: lastAgreementProgress } = useMemorizeVar('adminUserAgreementProgress', -100);
+const { variable: lastAgreementLevel } = useMemorizeVar('adminUserAgreementLevel', 0);
+
+const agreementLevelOptions: DropdownCommonItem[] = [
+  { id: 0, name: '全部等级' },
+  { id: 23, name: '国家级' },
+  { id: 24, name: '省级' },
+  { id: 25, name: '市级' },
+];
+
+const agreementProgressOptions: DropdownCommonItem[] = [
+  { id: -100, name: '全部状态' },
+  { id: 0, name: '未提交' },
+  { id: 1, name: '已提交待审核' },
+  { id: 2, name: '项目保护单位审核完成' },
+  { id: 3, name: '县(区)文旅部门审核完成' },
+  { id: 4, name: '设区市文旅部门、省非遗中心审核完成' },
+  { id: 5, name: '省文化和旅游厅审核完成' },
+];
+
+const currentUserGroups = computed(() => authStore.userInfo?.adminGroup || []);
+
+function canReview(item: UserAgreementListRow) {
+  const currentUserGroup = currentUserGroups.value.find((group) => GROUP_TO_REVIEW_PROGRESS[group.id]);
+  const currentUserReviewProgress = GROUP_TO_REVIEW_PROGRESS[currentUserGroup?.id ?? 0] ?? 0;
+  return item.agreementId
+    && item.progress != null && item.progress >= 1 && item.progress < 5
+    && item.progress < currentUserReviewProgress;
+}
+
+function agreementProgressLabel(progress: number | null | undefined) {
+  if (progress === null || progress === undefined)
+    return '未填写';
+  const hit = agreementProgressOptions.find((o) => o.id === progress);
+  return hit?.name ?? `进度 ${progress}`;
+}
+
+async function loadAgreementSignAdminList(page: number, pageSize: number, searchText: string, dropDownValues: number[]) {
+  const pv = dropDownValues?.[0];
+  const lv = dropDownValues?.[1];
+  lastAgreementProgress.value = pv ?? -100;
+  lastAgreementLevel.value = lv ?? 0;
+  const progress = pv != null && pv > -50 ? pv : undefined;
+  const level = lv != null && lv > 0 ? lv : undefined;
+  const list = await AssessmentContentApi.getUserAgreementList({
+    year: new Date().getFullYear(),
+    page,
+    pageSize,
+    keywords: searchText?.trim() || undefined,
+    progress,
+    level,
+  });
+  return {
+    page,
+    total: list.total,
+    data: list.data.map((row) => ({
+      ...row,
+      id: row.id,
+      agreementId: row.agreementId,
+      userId: row.userId,
+      title: row.title,
+      desc: [row.mobile, row.basicUnit, row.ichTitle].filter(Boolean).join(' · ') || '—',
+    })),
+  };
+}
+
+function handleShowDetail(item: any) {
+  router.push({
+    name: 'CollectAgreementSign',
+    query: { id: item.agreementId && item.agreementId > 0 ? item.agreementId : 0, userId: item.userId ?? 0 },
+  });
+}
+
+function handleEdit(item: any) {
+  router.push({
+    name: 'CollectAgreementSign',
+    query: { id: item.agreementId && item.agreementId > 0 ? item.agreementId : 0, userId: item.userId ?? 0 },
+  });
+}
+
+function handleReview(item: any) {
+  if (!item.agreementId || !item.userId) return;
+  router.push({
+    name: 'CollectAgreementSignReview',
+    query: {
+      id: String(item.agreementId),
+      userId: String(item.userId),
+      ...(item.progress != null ? { progress: String(item.progress) } : {}),
+    },
+  });
+}
+
+defineExpose({
+  reload: () => listRef.value?.reload(),
+});
+</script>

+ 267 - 0
src/pages/collect/assessment/argeement-sign-review.vue

@@ -0,0 +1,267 @@
+<template>
+  <div class="about main-background main-background-type0">
+    <div v-if="!isInMiniProgram" class="nav-placeholder" />
+    <section class="main-section large">
+      <div class="content">
+        <div class="title left-right">
+          <a-button :icon="h(ArrowLeftOutlined)" @click="router.back()">返回</a-button>
+          <h2>传承协议审核</h2>
+          <div class="w-20" />
+        </div>
+        <a-spin :spinning="loader.loading.value">
+          <div 
+            v-if="loader.loading.value" class="h-50" 
+          />
+          <a-result
+            v-else-if="!currentAgreement"
+            status="warning"
+            title="无法加载传承协议"
+            sub-title="请从管理员列表进入,并确认传承协议 ID 与传承人用户 ID 正确。"
+          />
+          <div v-else>
+            <a-alert
+              type="info"
+              show-icon
+              class="mb-4"
+              :message="progressHint"
+            />
+            <AgreementFormDisplay
+              ref="agreementFormRef"
+              :currentAgreement="(currentAgreement as AgreementDetail)"
+              isReviewer
+            />
+
+            <a-divider />
+
+            <a-card title="审核提交" size="small" class="review-submit-card" :class="{ collapsed: reviewCardCollapsed }">
+              <template #extra>
+                <a-button
+                  size="small"
+                  type="text"
+                  :icon="h(reviewCardCollapsed ? UpOutlined : DownOutlined)"
+                  @click="reviewCardCollapsed = !reviewCardCollapsed"
+                >
+                  {{ reviewCardCollapsed ? '展开' : '收起' }}
+                </a-button>
+              </template>
+              <a-alert
+                v-if="!canSubmitReview"
+                type="warning"
+                show-icon
+                class="mb-4"
+                message="当前账号用户组无权在此环节审核"
+              />
+              <a-form layout="vertical" size="middle">
+                <div class="flex flex-col md:flex-row lg:flex-row w-full gap-3">
+                  <div class="flex flex-col flex-1">
+                    <a-form-item required :label="`审核通过:${reviewLevelLabel}`">
+                      <a-button
+                        block
+                        type="primary"
+                        :loading="submitLoading"
+                        :disabled="!canSubmitReview"
+                        @click="submitReview"
+                      >
+                        通过审核
+                      </a-button>
+                    </a-form-item>
+                  </div>
+                  <div class="flex flex-col flex-1">
+                    <a-form-item required label="不通过回退:回退原因">
+                      <a-textarea
+                        v-model:value="rejectReason"
+                        allow-clear
+                        placeholder="请输入回退原因"
+                        rows="3"
+                        maxlength="500"
+                        show-count
+                      />
+                    </a-form-item>
+                    <a-button
+                      block
+                      danger
+                      :loading="rejectLoading"
+                      :disabled="!canSubmitReview"
+                      @click="submitReject"
+                    >
+                      回退
+                    </a-button>
+                  </div>
+                </div>
+              </a-form>
+            </a-card>
+          </div>
+        </a-spin>
+      </div>
+    </section>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, h, onMounted, ref, watch } from 'vue';
+import { useRoute, useRouter } from 'vue-router';
+import { message, Modal } from 'ant-design-vue';
+import { RequestApiError } from '@imengyu/imengyu-utils';
+import { ArrowLeftOutlined, UpOutlined, DownOutlined } from '@ant-design/icons-vue';
+import AssessmentContentApi, { AgreementDetail } from '@/api/collect/AssessmentContent';
+import AgreementFormDisplay from './components/AgreementFormDisplay.vue';
+import type { AgreementYmdParts } from './components/AgreementDateWriteBlock.vue';
+import { useSimpleDataLoader } from '@/composeables/useSimpleDataLoader';
+import { useAuthStore } from '@/stores/auth';
+import { useMemorizeVar } from '@/composeables/useMemorizeVar';
+import { GROUP_TO_REVIEW_PROGRESS } from './composeables/GroupData';
+import { isInMiniProgram } from '@/composeables/MiniProgramIng.ts';
+
+function formatErr(e: unknown): string {
+  if (e instanceof RequestApiError)
+    return e.errorMessage;
+  if (e instanceof Error)
+    return e.message;
+  return String(e);
+}
+
+const router = useRouter();
+const route = useRoute();
+const authStore = useAuthStore();
+
+const agreementFormRef = ref<InstanceType<typeof AgreementFormDisplay> | null>(null);
+
+const currentUserGroups = computed(() => authStore.userInfo?.adminGroup || []);
+
+const queryFormId = computed(() => Number(route.query.id) || 0);
+const queryUserId = computed(() => Number(route.query.userId) || 0);
+const queryProgress = computed(() => {
+  const p = Number(route.query.progress);
+  return Number.isFinite(p) ? p : 0;
+});
+
+const currentAgreement = ref<AgreementDetail | null>(null);
+const { variable: reviewCardCollapsed } = useMemorizeVar<boolean>('argeement-sign-review-card-collapsed', false);
+
+const submitLoading = ref(false);
+const rejectLoading = ref(false);
+const rejectReason = ref<string | null>(null);
+
+const reviewProgress = computed(() => {
+  const currentUserGroup = currentUserGroups.value.find((group) => GROUP_TO_REVIEW_PROGRESS[group.id]);
+  return GROUP_TO_REVIEW_PROGRESS[currentUserGroup?.id ?? 0] ?? 0;
+});
+
+const canSubmitReview = computed(() => reviewProgress.value >= 2 && reviewProgress.value <= 5);
+
+const reviewLevelLabel = computed(() => {
+  switch (reviewProgress.value) {
+    case 2: return '项目保护单位';
+    case 3: return '县(区)文旅部门';
+    case 4: return '设区市文旅部门、省非遗中心';
+    case 5: return '省文化和旅游厅';
+    default: return '';
+  }
+});
+
+const progressHint = computed(() => {
+  const roleText = canSubmitReview.value
+    ? `当前账号审核环节:${reviewLevelLabel.value}`
+    : `当前用户组 ${currentUserGroups.value.map((group) => group.name).join(',')} 无对应审核环节`;
+  return `${roleText}。`;
+});
+
+const loader = useSimpleDataLoader(async () => {
+  const id = queryFormId.value;
+  const uid = queryUserId.value;
+  if (!id || !uid) {
+    currentAgreement.value = null;
+    return null;
+  }
+  const detail = await AssessmentContentApi.getAgreementDetail(id, uid);
+  currentAgreement.value = detail;
+  return detail;
+}, { immediate: false });
+
+watch(
+  () => [queryFormId.value, queryUserId.value] as const,
+  () => { loader.load(); },
+);
+
+onMounted(() => { loader.load(); });
+
+async function submitReview() {
+  const d = currentAgreement.value;
+  if (!d?.id) {
+    message.warning('缺少传承协议 ID');
+    return;
+  }
+  if (!canSubmitReview.value) {
+    message.warning('当前账号用户组无权提交审核');
+    return;
+  }
+  try {
+    await agreementFormRef.value?.validate();
+  } catch {
+    message.warning('请填写完整信息');
+    return;
+  }
+  try {
+    submitLoading.value = true;
+    await AssessmentContentApi.reviewAgreement({
+      id: d.id,
+      progress: reviewProgress.value,
+    });
+    message.success('审核通过');
+    router.back();
+  } catch (e) {
+    Modal.error({ title: '审核提交失败', content: formatErr(e) });
+  } finally {
+    submitLoading.value = false;
+  }
+}
+
+async function submitReject() {
+  const d = currentAgreement.value;
+  if (!d?.id) {
+    message.warning('缺少传承协议 ID');
+    return;
+  }
+  if (!canSubmitReview.value) {
+    message.warning('当前账号用户组无权回退');
+    return;
+  }
+  const reason = (rejectReason.value ?? '').trim();
+  if (!reason) {
+    message.warning('请输入回退原因');
+    return;
+  }
+  try {
+    rejectLoading.value = true;
+    await AssessmentContentApi.reviewAgreement({
+      id: d.id,
+      progress: queryProgress.value,
+      rejectType: reviewProgress.value,
+      rejectReason: reason,
+    });
+    message.success('已回退');
+    router.back();
+  } catch (e) {
+    Modal.error({ title: '回退失败', content: formatErr(e) });
+  } finally {
+    rejectLoading.value = false;
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.review-submit-card {
+  position: sticky;
+  bottom: 0;
+  z-index: 10;
+  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
+
+  :deep(.ant-form-item) {
+    margin-bottom: 0!important;
+  }
+
+  &.collapsed :deep(.ant-card-body) {
+    display: none;
+  }
+}
+</style>

+ 99 - 126
src/pages/collect/assessment/argeement-sign.vue

@@ -22,46 +22,36 @@
             </a-result>
             </a-result>
             <div v-else>
             <div v-else>
               <a-alert type="info" message="请仔细阅读传承协议,确保您已理解内容,并签署传承协议。" class="mb-4" show-icon />
               <a-alert type="info" message="请仔细阅读传承协议,确保您已理解内容,并签署传承协议。" class="mb-4" show-icon />
-              <a-form
-                v-if="currentAgreement"
-                ref="formRef"
-                :model="currentAgreement"
-                :rules="formRules"
-                layout="vertical"
-              >
-                <a-typography-title class="mt-5!" :level="4">{{ agreementTitle }}</a-typography-title>
+              
+              <AgreementFormDisplay
+                ref="agreementFormRef"
+                :currentAgreement="(currentAgreement as AgreementDetail)"
+              />
 
 
-                <AgreementBodyNational
-                  v-if="currentAgreement?.level === 23"
-                  :detail="(currentAgreement as AgreementDetail)"
-                  :agreement-year="agreementYear"
-                  :party-a-stamp-date="partyAStampDate"
-                  :party-b-sign-date="partyBSignDate"
-                  @update:party-a-stamp-date="partyAStampDate = $event"
-                  @update:party-b-sign-date="partyBSignDate = $event"
-                />
-                <AgreementBodyProvincial
-                  v-else-if="currentAgreement?.level === 24"
-                  :detail="(currentAgreement as AgreementDetail)"
-                  :agreement-year="agreementYear"
-                  :party-a-stamp-date="partyAStampDate"
-                  :party-b-sign-date="partyBSignDate"
-                  @update:party-a-stamp-date="partyAStampDate = $event"
-                  @update:party-b-sign-date="partyBSignDate = $event"
-                />
-                <AgreementBodyMunicipal
-                  v-else-if="currentAgreement?.level === 25"
-                  :detail="(currentAgreement as AgreementDetail)"
-                  :agreement-year="agreementYear"
-                  :party-a-stamp-date="partyAStampDate"
-                  :party-b-sign-date="partyBSignDate"
-                  @update:party-a-stamp-date="partyAStampDate = $event"
-                  @update:party-b-sign-date="partyBSignDate = $event"
-                />
-              </a-form>
+              <a-alert
+                v-if="currentAgreement.rejectType > 0"
+                type="error"
+                show-icon
+                class="mt-4"
+              >
+                <template #message>
+                  审核回退:{{ rejectTypeLabel }}
+                </template>
+                <template v-if="currentAgreement.rejectReason" #description>
+                  回退原因:{{ currentAgreement.rejectReason }}
+                </template>
+              </a-alert>
+              <a-alert
+                v-else-if="currentAgreement.progress >= 1"
+                type="success"
+                show-icon
+                class="mt-4"
+                :message="progressLabel"
+              />
 
 
               <a-space direction="vertical" class="w-full mt-4" size="middle">
               <a-space direction="vertical" class="w-full mt-4" size="middle">
                 <a-button type="primary" block :loading="submitLoading" @click="saveAgreement">保存传承协议</a-button>
                 <a-button type="primary" block :loading="submitLoading" @click="saveAgreement">保存传承协议</a-button>
+                <a-button v-if="!authStore.isAdmin && currentAgreement?.progress === 0" type="primary" block :loading="submitLoading" @click="submitForm">提交审核</a-button>
                 <a-button block :loading="submitLoading" @click="downloadAgreement">下载协议 PDF</a-button>
                 <a-button block :loading="submitLoading" @click="downloadAgreement">下载协议 PDF</a-button>
               </a-space>
               </a-space>
             </div>
             </div>
@@ -76,18 +66,13 @@
 import { computed, h, onMounted, ref, watch } from 'vue';
 import { computed, h, onMounted, ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import { useRoute, useRouter } from 'vue-router';
 import { message, Modal } from 'ant-design-vue';
 import { message, Modal } from 'ant-design-vue';
-import type { FormInstance } from 'ant-design-vue';
-import type { Rules } from 'async-validator';
-import { RequestApiError } from '@imengyu/imengyu-utils';
+import { RequestApiError, waitTimeOut } from '@imengyu/imengyu-utils';
 import { useAuthStore } from '@/stores/auth';
 import { useAuthStore } from '@/stores/auth';
 import AssessmentContentApi, { AgreementDetail } from '@/api/collect/AssessmentContent';
 import AssessmentContentApi, { AgreementDetail } from '@/api/collect/AssessmentContent';
-import AgreementBodyNational from './components/AgreementBodyNational.vue';
-import AgreementBodyProvincial from './components/AgreementBodyProvincial.vue';
-import AgreementBodyMunicipal from './components/AgreementBodyMunicipal.vue';
-import type { AgreementYmdParts } from './components/AgreementDateWriteBlock.vue';
 import { useSimpleDataLoader } from '@/composeables/useSimpleDataLoader';
 import { useSimpleDataLoader } from '@/composeables/useSimpleDataLoader';
 import { ArrowLeftOutlined } from '@ant-design/icons-vue';
 import { ArrowLeftOutlined } from '@ant-design/icons-vue';
 import { injectAppConfiguration } from '@/api/system/useAppConfiguration';
 import { injectAppConfiguration } from '@/api/system/useAppConfiguration';
+import AgreementFormDisplay from './components/AgreementFormDisplay.vue';
 
 
 function formatErr(e: unknown): string {
 function formatErr(e: unknown): string {
   if (e instanceof RequestApiError)
   if (e instanceof RequestApiError)
@@ -105,60 +90,7 @@ const queryId = computed(() => Number(route.query.id) || 0);
 const queryUserId = computed(() => Number(route.query.userId) || 0);
 const queryUserId = computed(() => Number(route.query.userId) || 0);
 
 
 const currentAgreement = ref<AgreementDetail | null>(null);
 const currentAgreement = ref<AgreementDetail | null>(null);
-const partyAStampDate = ref<AgreementYmdParts>({ year: '', month: '', day: '' });
-const partyBSignDate = ref<AgreementYmdParts>({ year: '', month: '', day: '' });
-const formRef = ref<FormInstance | null>(null);
-
-const CN_MOBILE_RE = /^1\d{10}$/;
-const CN_ID_RE = /^(?:\d{15}|\d{17}[\dXx])$/;
-
-const formRules = computed<Rules>(() => {
-  const rules: Rules = {
-    partyB: [{ required: true, message: '请填写乙方(传承人)姓名' }],
-    apprentice: [
-      { required: true, message: '请填写本年度带徒人数' },
-      { type: 'integer', min: 0, message: '须为不小于 0 的整数' },
-    ],
-    activity: [
-      { required: true, message: '请填写本年度宣传活动场次' },
-      { type: 'integer', min: 0, message: '须为不小于 0 的整数' },
-    ],
-    partyBSign: [
-      {
-        validator(_rule, value, callback) {
-          const s = typeof value === 'string' ? value.trim() : '';
-          if (!s)
-            callback(new Error('请完成乙方签名'));
-          else
-            callback();
-        },
-      },
-    ],
-    idCard: [
-      { required: true, message: '请填写身份证号' },
-    ],
-    ich: [{ required: true, message: '请填写非遗项目名称' }],
-    health: [{ required: true, message: '请填写身体状况' }],
-    mobile: [
-      { required: true, message: '请填写乙方联系电话' },
-      {
-        validator(_rule, value, callback) {
-          const s = value != null ? String(value).trim() : '';
-          if (!CN_MOBILE_RE.test(s))
-            callback(new Error('请输入正确的手机号'));
-          else
-            callback();
-        },
-      },
-    ],
-  };
-  if (currentAgreement.value?.level !== 25)
-    rules.course = [
-      { required: true, message: '请填写本年度研修班场次' },
-      { type: 'integer', min: 0, message: '须为不小于 0 的整数' },
-    ];
-  return rules;
-});
+const agreementFormRef = ref<InstanceType<typeof AgreementFormDisplay> | null>(null);
 
 
 async function loadBasicInfo() {
 async function loadBasicInfo() {
   const uid = authStore.userInfo?.id ?? authStore.userId;
   const uid = authStore.userInfo?.id ?? authStore.userId;
@@ -173,22 +105,11 @@ async function loadBasicInfo() {
   d.level = basicInfo.level;
   d.level = basicInfo.level;
 }
 }
 
 
-const agreementYear = computed(() => currentAgreement.value?.year ?? 2027);
-const agreementTitle = computed(
-  () => `${agreementYear.value} 年度${levelTitle.value}非物质文化遗产代表性传承人传承协议`,
-);
-const levelTitle = computed(() => {
-  if (currentAgreement.value?.level === 23) return '国家级';
-  if (currentAgreement.value?.level === 24) return '省级';
-  return '市级';
-});
-
 const loader = useSimpleDataLoader(async () => {
 const loader = useSimpleDataLoader(async () => {
   if (queryId.value > 0) {
   if (queryId.value > 0) {
+    const now = new Date();
     const detail = await AssessmentContentApi.getAgreementDetail(queryId.value, queryUserId.value || undefined);
     const detail = await AssessmentContentApi.getAgreementDetail(queryId.value, queryUserId.value || undefined);
     currentAgreement.value = detail;
     currentAgreement.value = detail;
-    partyAStampDate.value = { year: '', month: '', day: '' };
-    partyBSignDate.value = { year: '', month: '', day: '' };
     return currentAgreement.value;
     return currentAgreement.value;
   }
   }
   const uid = authStore.userInfo?.id ?? authStore.userId;
   const uid = authStore.userInfo?.id ?? authStore.userId;
@@ -199,12 +120,6 @@ const loader = useSimpleDataLoader(async () => {
       uid,
       uid,
     );
     );
     currentAgreement.value = detail;
     currentAgreement.value = detail;
-    partyAStampDate.value = { year: '', month: '', day: '' };
-    partyBSignDate.value = {
-      year: detail.updatetime.getFullYear().toString(),
-      month: (detail.updatetime.getMonth() + 1).toString(),
-      day: detail.updatetime.getDate().toString(),
-    };
   } else {
   } else {
     currentAgreement.value = null;
     currentAgreement.value = null;
   }
   }
@@ -227,6 +142,28 @@ watch(
 const submitLoading = ref(false);
 const submitLoading = ref(false);
 const appConfiguration = injectAppConfiguration();
 const appConfiguration = injectAppConfiguration();
 
 
+const rejectTypeLabel = computed(() => {
+  switch (currentAgreement.value?.rejectType) {
+    case 1: return '自评阶段退回';
+    case 2: return '项目保护单位退回';
+    case 3: return '县(区)文旅部门退回';
+    case 4: return '设区市文旅部门、省非遗中心退回';
+    case 5: return '省文化和旅游厅退回';
+    default: return '';
+  }
+});
+
+const progressLabel = computed(() => {
+  switch (currentAgreement.value?.progress) {
+    case 1: return '已提交,等待审核';
+    case 2: return '项目保护单位审核通过';
+    case 3: return '县(区)文旅部门审核通过';
+    case 4: return '设区市文旅部门、省非遗中心审核通过';
+    case 5: return '省文化和旅游厅审核通过';
+    default: return '';
+  }
+});
+
 async function createAgreement() {
 async function createAgreement() {
   const now = new Date();
   const now = new Date();
   const u = authStore.userInfo;
   const u = authStore.userInfo;
@@ -245,23 +182,13 @@ async function createAgreement() {
     detail.partyA = '福建省文化和旅游厅';
     detail.partyA = '福建省文化和旅游厅';
   }
   }
   detail.partyB = nick;
   detail.partyB = nick;
-  partyAStampDate.value = {
-    year: now.getFullYear().toString(),
-    month: (now.getMonth() + 1).toString(),
-    day: now.getDate().toString(),
-  };
-  partyBSignDate.value = {
-    year: now.getFullYear().toString(),
-    month: (now.getMonth() + 1).toString(),
-    day: now.getDate().toString(),
-  };
   currentAgreement.value = detail;
   currentAgreement.value = detail;
   await loadBasicInfo();
   await loadBasicInfo();
 }
 }
 
 
 async function saveAgreement() {
 async function saveAgreement() {
   try {
   try {
-    await formRef.value?.validate();
+    await agreementFormRef.value?.validate();
   } catch {
   } catch {
     message.warning('请填写完整信息');
     message.warning('请填写完整信息');
     return;
     return;
@@ -284,6 +211,52 @@ async function saveAgreement() {
   submitLoading.value = false;
   submitLoading.value = false;
 }
 }
 
 
+async function submitForm() {
+  try {
+    await agreementFormRef.value?.validate();
+  } catch (e: unknown) {
+    if (e && typeof e === 'object' && 'errorFields' in (e as object))
+      message.warning('请填写完整信息');
+    else if (e instanceof Error)
+      message.warning(e.message);
+    else
+      message.warning('请填写完整信息');
+    return;
+  }
+  const confirmed = await new Promise<boolean>((resolve) => {
+    Modal.confirm({
+      title: '提示',
+      content: '您确认要提交审核吗?请确认各项信息填写无误。',
+      okText: '确认提交',
+      cancelText: '取消',
+      onOk: () => resolve(true),
+      onCancel: () => resolve(false),
+    });
+  });
+  if (!confirmed)
+    return;
+  submitLoading.value = true;
+  const d = currentAgreement.value;
+  if (!d) {
+    submitLoading.value = false;
+    return;
+  }
+  try {
+    d.progress = 1;
+    await AssessmentContentApi.saveAgreement(d as AgreementDetail);
+    message.success('提交审核成功');
+    await waitTimeOut(500);
+    await loader.load();
+  } catch (error) {
+    Modal.error({
+      title: '提交审核失败',
+      content: formatErr(error),
+    });
+  }
+  submitLoading.value = false;
+}
+
+
 async function downloadAgreement() {
 async function downloadAgreement() {
   if (!currentAgreement.value?.id) {
   if (!currentAgreement.value?.id) {
     message.warning('请先保存传承协议后再下载 PDF');
     message.warning('请先保存传承协议后再下载 PDF');

+ 4 - 52
src/pages/collect/assessment/components/AgreementBodyMunicipal.vue

@@ -5,6 +5,7 @@
       <AgreementPrefillInline
       <AgreementPrefillInline
         v-model="detail.partyB"
         v-model="detail.partyB"
         placeholder="请填写乙方(传承人)姓名"
         placeholder="请填写乙方(传承人)姓名"
+        :disabled="isReviewer"
       />
       />
     </a-form-item>
     </a-form-item>
 
 
@@ -27,6 +28,7 @@
         number-mode
         number-mode
         placeholder="人数"
         placeholder="人数"
         suffix="人。"
         suffix="人。"
+        :disabled="isReviewer"
       />
       />
     </a-typography-paragraph>
     </a-typography-paragraph>
     <a-typography-paragraph :style="paragraphStyle">
     <a-typography-paragraph :style="paragraphStyle">
@@ -42,6 +44,7 @@
         number-mode
         number-mode
         placeholder="场次"
         placeholder="场次"
         suffix="场。"
         suffix="场。"
+        :disabled="isReviewer"
       />
       />
     </a-typography-paragraph>
     </a-typography-paragraph>
     <a-typography-paragraph :style="paragraphStyle">
     <a-typography-paragraph :style="paragraphStyle">
@@ -51,68 +54,17 @@
       十七、乙方应积极参与非物质文化遗产相关理论和实践研究、发表(出版)论文、专著等研究。
       十七、乙方应积极参与非物质文化遗产相关理论和实践研究、发表(出版)论文、专著等研究。
     </a-typography-paragraph>
     </a-typography-paragraph>
   </AgreementBody>
   </AgreementBody>
-
-  <a-divider />
-
-  <AgreementBody>
-    <a-form-item label="甲方" name="partyA">
-      <a-input v-model:value="detail.partyA" placeholder="设区市文化和旅游局全称" />
-    </a-form-item>
-    <a-form-item label="负责人(代表人)">
-      <a-input v-model:value="detail.partyASign" placeholder="" />
-    </a-form-item>
-    <a-form-item label="甲方电话" name="partyAMobile">
-      <a-input v-model:value="detail.partyAMobile" placeholder="" />
-    </a-form-item>
-    <AgreementDateWriteBlock
-      :model-value="partyAStampDate"
-      hint="(以实际盖章日期为准)"
-      @update:model-value="emit('update:partyAStampDate', $event)"
-    />
-
-    <a-divider />
-
-    <a-typography-paragraph strong>乙方:{{ detail.partyB }}(签名)</a-typography-paragraph>
-    <a-form-item label="乙方签名" name="partyBSign">
-      <Sign :model-value="detail.partyBSign ?? ''" @update:model-value="(v) => { detail.partyBSign = v }" />
-    </a-form-item>
-    <a-form-item label="身份证号" name="idCard">
-      <a-input v-model:value="detail.idCard" placeholder="请填写身份证号" />
-    </a-form-item>
-    <a-form-item label="项目名称" name="ich">
-      <a-input v-model:value="detail.ich" placeholder="非遗项目名称" />
-    </a-form-item>
-    <a-form-item label="身体状况" name="health">
-      <a-input v-model:value="detail.health" placeholder="请简要填写" />
-    </a-form-item>
-    <a-form-item label="乙方电话" name="mobile">
-      <a-input v-model:value="detail.mobile" placeholder="请填写联系电话" />
-    </a-form-item>
-    <AgreementDateWriteBlock
-      :model-value="partyBSignDate"
-      hint="(以实际签署日期为准)"
-      @update:model-value="emit('update:partyBSignDate', $event)"
-    />
-  </AgreementBody>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
 import type { AgreementDetail } from '@/api/collect/AssessmentContent';
 import type { AgreementDetail } from '@/api/collect/AssessmentContent';
 import AgreementBody from './AgreementBody.vue';
 import AgreementBody from './AgreementBody.vue';
 import AgreementPrefillInline from './AgreementPrefillInline.vue';
 import AgreementPrefillInline from './AgreementPrefillInline.vue';
-import AgreementDateWriteBlock, { type AgreementYmdParts } from './AgreementDateWriteBlock.vue';
-import { Sign } from '@imengyu/vue-dynamic-form-rich';
 
 
 defineProps<{
 defineProps<{
   detail: AgreementDetail;
   detail: AgreementDetail;
   agreementYear: number;
   agreementYear: number;
-  partyAStampDate: AgreementYmdParts;
-  partyBSignDate: AgreementYmdParts;
-}>();
-
-const emit = defineEmits<{
-  (e: 'update:partyAStampDate', v: AgreementYmdParts): void;
-  (e: 'update:partyBSignDate', v: AgreementYmdParts): void;
+  isReviewer?: boolean;
 }>();
 }>();
 
 
 const paragraphStyle = { lineHeight: 1.75, marginBottom: '8px' };
 const paragraphStyle = { lineHeight: 1.75, marginBottom: '8px' };

+ 6 - 51
src/pages/collect/assessment/components/AgreementBodyNational.vue

@@ -1,10 +1,11 @@
 <template>
 <template>
   <AgreementBody>
   <AgreementBody>
-    <a-typography-paragraph>甲方:福建省文化和旅游厅</a-typography-paragraph>
+    <a-typography-paragraph>甲方:{{ detail.partyA }}</a-typography-paragraph>
     <a-form-item label="乙方(传承人)" name="partyB">
     <a-form-item label="乙方(传承人)" name="partyB">
       <AgreementPrefillInline
       <AgreementPrefillInline
         v-model="detail.partyB"
         v-model="detail.partyB"
         placeholder="请填写乙方(传承人)姓名"
         placeholder="请填写乙方(传承人)姓名"
+        :disabled="isReviewer"
       />
       />
     </a-form-item>
     </a-form-item>
 
 
@@ -28,6 +29,7 @@
         number-mode
         number-mode
         placeholder="人数"
         placeholder="人数"
         suffix="人。"
         suffix="人。"
+        :disabled="isReviewer"
       />
       />
     </a-typography-paragraph>
     </a-typography-paragraph>
     <a-typography-paragraph :style="paragraphStyle">
     <a-typography-paragraph :style="paragraphStyle">
@@ -43,6 +45,7 @@
         number-mode
         number-mode
         placeholder="场次"
         placeholder="场次"
         suffix="场。"
         suffix="场。"
+        :disabled="isReviewer"
       />
       />
     </a-typography-paragraph>
     </a-typography-paragraph>
     <a-typography-paragraph :style="paragraphStyle">
     <a-typography-paragraph :style="paragraphStyle">
@@ -55,72 +58,24 @@
         number-mode
         number-mode
         placeholder="场次"
         placeholder="场次"
         suffix="场。"
         suffix="场。"
+        :disabled="isReviewer"
       />
       />
     </a-typography-paragraph>
     </a-typography-paragraph>
     <a-typography-paragraph :style="paragraphStyle">
     <a-typography-paragraph :style="paragraphStyle">
       十、乙方应积极参与非物质文化遗产相关理论和实践研究、发表(出版)论文、专著等研究。
       十、乙方应积极参与非物质文化遗产相关理论和实践研究、发表(出版)论文、专著等研究。
     </a-typography-paragraph>
     </a-typography-paragraph>
   </AgreementBody>
   </AgreementBody>
-
-  <a-divider />
-
-  <AgreementBody>
-    <a-typography-paragraph strong>甲方:福建省文化和旅游厅</a-typography-paragraph>
-    <a-form-item label="负责人(代表人)">
-      <a-input v-model:value="detail.partyASign" placeholder="" />
-    </a-form-item>
-    <a-form-item label="甲方电话" name="partyAMobile">
-      <a-input v-model:value="detail.partyAMobile" placeholder="" />
-    </a-form-item>
-    <AgreementDateWriteBlock
-      :model-value="partyAStampDate"
-      hint="(以实际盖章日期为准)"
-      @update:model-value="emit('update:partyAStampDate', $event)"
-    />
-
-    <a-divider />
-
-    <a-typography-paragraph strong>乙方:{{ detail.partyB }}(签名)</a-typography-paragraph>
-    <a-form-item label="乙方签名" name="partyBSign">
-      <Sign :model-value="detail.partyBSign ?? ''" @update:model-value="(v) => { detail.partyBSign = v }" />
-    </a-form-item>
-    <a-form-item label="身份证号" name="idCard">
-      <a-input v-model:value="detail.idCard" placeholder="请填写身份证号" />
-    </a-form-item>
-    <a-form-item label="项目名称" name="ich">
-      <a-input v-model:value="detail.ich" placeholder="非遗项目名称" />
-    </a-form-item>
-    <a-form-item label="身体状况" name="health">
-      <a-input v-model:value="detail.health" placeholder="请简要填写" />
-    </a-form-item>
-    <a-form-item label="乙方电话" name="mobile">
-      <a-input v-model:value="detail.mobile" placeholder="请填写联系电话" />
-    </a-form-item>
-    <AgreementDateWriteBlock
-      :model-value="partyBSignDate"
-      hint="(以实际签署日期为准)"
-      @update:model-value="emit('update:partyBSignDate', $event)"
-    />
-  </AgreementBody>
 </template>
 </template>
 
 
 <script setup lang="ts">
 <script setup lang="ts">
 import type { AgreementDetail } from '@/api/collect/AssessmentContent';
 import type { AgreementDetail } from '@/api/collect/AssessmentContent';
 import AgreementBody from './AgreementBody.vue';
 import AgreementBody from './AgreementBody.vue';
 import AgreementPrefillInline from './AgreementPrefillInline.vue';
 import AgreementPrefillInline from './AgreementPrefillInline.vue';
-import AgreementDateWriteBlock, { type AgreementYmdParts } from './AgreementDateWriteBlock.vue';
-import { Sign } from '@imengyu/vue-dynamic-form-rich';
 
 
 defineProps<{
 defineProps<{
   detail: AgreementDetail;
   detail: AgreementDetail;
   agreementYear: number;
   agreementYear: number;
-  partyAStampDate: AgreementYmdParts;
-  partyBSignDate: AgreementYmdParts;
-}>();
-
-const emit = defineEmits<{
-  (e: 'update:partyAStampDate', v: AgreementYmdParts): void;
-  (e: 'update:partyBSignDate', v: AgreementYmdParts): void;
+  isReviewer?: boolean;
 }>();
 }>();
 
 
 const paragraphStyle = { lineHeight: 1.75, marginBottom: '8px' };
 const paragraphStyle = { lineHeight: 1.75, marginBottom: '8px' };

+ 6 - 52
src/pages/collect/assessment/components/AgreementBodyProvincial.vue

@@ -1,10 +1,11 @@
 <template>
 <template>
   <AgreementBody>
   <AgreementBody>
-    <a-typography-paragraph>甲方:厦门市文化和旅游局</a-typography-paragraph>
+    <a-typography-paragraph>甲方:{{ detail.partyA }}</a-typography-paragraph>
     <a-form-item label="乙方(传承人)" name="partyB">
     <a-form-item label="乙方(传承人)" name="partyB">
       <AgreementPrefillInline
       <AgreementPrefillInline
         v-model="detail.partyB"
         v-model="detail.partyB"
         placeholder="请填写乙方(传承人)姓名"
         placeholder="请填写乙方(传承人)姓名"
+        :disabled="isReviewer"
       />
       />
     </a-form-item>
     </a-form-item>
 
 
@@ -27,6 +28,7 @@
         number-mode
         number-mode
         placeholder="人数"
         placeholder="人数"
         suffix="人。"
         suffix="人。"
+        :disabled="isReviewer"
       />
       />
     </a-typography-paragraph>
     </a-typography-paragraph>
     <a-typography-paragraph :style="paragraphStyle">
     <a-typography-paragraph :style="paragraphStyle">
@@ -42,6 +44,7 @@
         number-mode
         number-mode
         placeholder="场次"
         placeholder="场次"
         suffix="场。"
         suffix="场。"
+        :disabled="isReviewer"
       />
       />
     </a-typography-paragraph>
     </a-typography-paragraph>
     <a-typography-paragraph :style="paragraphStyle">
     <a-typography-paragraph :style="paragraphStyle">
@@ -60,66 +63,17 @@
       十、乙方应积极参与非物质文化遗产相关理论和实践研究、发表(出版)论文、专著等研究。
       十、乙方应积极参与非物质文化遗产相关理论和实践研究、发表(出版)论文、专著等研究。
     </a-typography-paragraph>
     </a-typography-paragraph>
   </AgreementBody>
   </AgreementBody>
-
-  <a-divider />
-
-  <AgreementBody>
-    <a-typography-paragraph strong>甲方:厦门市文化和旅游局</a-typography-paragraph>
-    <a-form-item label="负责人(代表人)">
-      <a-input v-model:value="detail.partyASign" placeholder="" />
-    </a-form-item>
-    <a-form-item label="甲方电话" name="partyAMobile">
-      <a-input v-model:value="detail.partyAMobile" placeholder="" />
-    </a-form-item>
-    <AgreementDateWriteBlock
-      :model-value="partyAStampDate"
-      hint="(以实际盖章日期为准)"
-      @update:model-value="emit('update:partyAStampDate', $event)"
-    />
-
-    <a-divider />
-
-    <a-typography-paragraph strong>乙方:{{ detail.partyB }}(签名)</a-typography-paragraph>
-    <a-form-item label="乙方签名" name="partyBSign">
-      <Sign :model-value="detail.partyBSign ?? ''" @update:model-value="(v) => { detail.partyBSign = v }" />
-    </a-form-item>
-    <a-form-item label="身份证号" name="idCard">
-      <a-input v-model:value="detail.idCard" placeholder="请填写身份证号" />
-    </a-form-item>
-    <a-form-item label="项目名称" name="ich">
-      <a-input v-model:value="detail.ich" placeholder="非遗项目名称" />
-    </a-form-item>
-    <a-form-item label="身体状况" name="health">
-      <a-input v-model:value="detail.health" placeholder="请简要填写" />
-    </a-form-item>
-    <a-form-item label="乙方电话" name="mobile">
-      <a-input v-model:value="detail.mobile" placeholder="请填写联系电话" />
-    </a-form-item>
-    <AgreementDateWriteBlock
-      :model-value="partyBSignDate"
-      hint="(以实际签署日期为准)"
-      @update:model-value="emit('update:partyBSignDate', $event)"
-    />
-  </AgreementBody>
 </template>
 </template>
 
 
-<script setup lang="ts">  
+<script setup lang="ts">
 import type { AgreementDetail } from '@/api/collect/AssessmentContent';
 import type { AgreementDetail } from '@/api/collect/AssessmentContent';
 import AgreementBody from './AgreementBody.vue';
 import AgreementBody from './AgreementBody.vue';
 import AgreementPrefillInline from './AgreementPrefillInline.vue';
 import AgreementPrefillInline from './AgreementPrefillInline.vue';
-import AgreementDateWriteBlock, { type AgreementYmdParts } from './AgreementDateWriteBlock.vue';
-import { Sign } from '@imengyu/vue-dynamic-form-rich';
 
 
 defineProps<{
 defineProps<{
   detail: AgreementDetail;
   detail: AgreementDetail;
   agreementYear: number;
   agreementYear: number;
-  partyAStampDate: AgreementYmdParts;
-  partyBSignDate: AgreementYmdParts;
-}>();
-
-const emit = defineEmits<{
-  (e: 'update:partyAStampDate', v: AgreementYmdParts): void;
-  (e: 'update:partyBSignDate', v: AgreementYmdParts): void;
+  isReviewer?: boolean;
 }>();
 }>();
 
 
 const paragraphStyle = { lineHeight: 1.75, marginBottom: '8px' };
 const paragraphStyle = { lineHeight: 1.75, marginBottom: '8px' };

+ 18 - 15
src/pages/collect/assessment/components/AgreementDateWriteBlock.vue

@@ -2,27 +2,30 @@
   <div class="ymd-block">
   <div class="ymd-block">
     <a-space wrap align="center">
     <a-space wrap align="center">
       <span v-if="prefix" class="text-secondary">{{ prefix }}</span>
       <span v-if="prefix" class="text-secondary">{{ prefix }}</span>
-      <AgreementPrefillInline
-        :model-value="modelValue.year"
-        placeholder="YYYY"
-        suffix="年"
+      <a-input-number
+        :value="modelValue.year"
         :max-length="4"
         :max-length="4"
-        @update:model-value="(v) => patch('year', v)"
+        size="small"
+        class="w-12!"
+        @update:value="(v: any) => patch('year', v)"
       />
       />
-      <AgreementPrefillInline
-        :model-value="modelValue.month"
-        placeholder="MM"
-        suffix="月"
+      <span class="text-secondary">年</span>
+      <a-input-number
+        :value="modelValue.month"
         :max-length="2"
         :max-length="2"
-        @update:model-value="(v) => patch('month', v)"
+        size="small"
+        class="w-12!"
+        @update:value="(v: any) => patch('month', v)"
       />
       />
-      <AgreementPrefillInline
-        :model-value="modelValue.day"
-        placeholder="DD"
-        suffix="日"
+      <span class="text-secondary">月</span>
+      <a-input-number
+        :value="modelValue.day"
         :max-length="2"
         :max-length="2"
-        @update:model-value="(v) => patch('day', v)"
+        size="small"
+        class="w-12!"
+        @update:value="(v: any) => patch('day', v)"
       />
       />
+      <span class="text-secondary">日</span>
     </a-space>
     </a-space>
     <div v-if="hint" class="text-secondary hint">{{ hint }}</div>
     <div v-if="hint" class="text-secondary hint">{{ hint }}</div>
   </div>
   </div>

+ 206 - 0
src/pages/collect/assessment/components/AgreementFormDisplay.vue

@@ -0,0 +1,206 @@
+<template>
+  <div>
+    <a-form
+      v-if="currentAgreement"
+      ref="formRef"
+      :model="currentAgreement"
+      :rules="formRules"
+      layout="vertical"
+    >
+      <a-typography-title class="mt-5!" :level="4">{{ agreementTitle }}</a-typography-title>
+
+      <AgreementBodyNational
+        v-if="currentAgreement?.level === 23"
+        :detail="(currentAgreement as AgreementDetail)"
+        :agreement-year="agreementYear"
+        :isReviewer="isReviewer"
+      />
+      <AgreementBodyProvincial
+        v-else-if="currentAgreement?.level === 24"
+        :detail="(currentAgreement as AgreementDetail)"
+        :agreement-year="agreementYear"
+        :isReviewer="isReviewer"
+      />
+      <AgreementBodyMunicipal
+        v-else-if="currentAgreement?.level === 25"
+        :detail="(currentAgreement as AgreementDetail)"
+        :agreement-year="agreementYear"
+        :isReviewer="isReviewer"
+      />
+
+      <a-divider />
+
+      <AgreementBody>
+        <a-form-item label="甲方" name="partyA">
+          <a-input v-model:value="currentAgreement.partyA" :readonly="!isReviewer" />
+        </a-form-item>
+        <a-form-item label="负责人(代表人)签名" name="partyASign">
+          <Sign 
+            v-if="isReviewer || currentAgreement.partyASign" :model-value="currentAgreement.partyASign ?? ''" 
+            :disabled="!isReviewer"
+            @update:model-value="(v) => { currentAgreement.partyASign = v }" 
+          />
+          <span v-else>待审核人员签名</span>
+        </a-form-item>
+        <a-form-item label="甲方电话" name="partyAMobile">
+          <a-input v-model:value="currentAgreement.partyAMobile" :placeholder="isReviewer ? '请填写联系电话' : '待审核人员填写'" :disabled="!isReviewer" />
+        </a-form-item>
+
+        <a-divider />
+
+        <a-typography-paragraph strong>乙方:{{ currentAgreement.partyB }}(签名)</a-typography-paragraph>
+        <a-form-item label="乙方签名" name="partyBSign">
+          <Sign :model-value="currentAgreement.partyBSign ?? ''" :readonly="isReviewer" @update:model-value="(v) => { currentAgreement.partyBSign = v }" />
+        </a-form-item>
+        <a-form-item label="身份证号" name="idCard">
+          <a-input v-model:value="currentAgreement.idCard" :readonly="isReviewer" placeholder="请填写身份证号" />
+        </a-form-item>
+        <a-form-item label="项目名称" name="ich">
+          <a-input v-model:value="currentAgreement.ich" :readonly="isReviewer" placeholder="非遗项目名称" />
+        </a-form-item>
+        <a-form-item label="身体状况" name="health">
+          <a-input v-model:value="currentAgreement.health" :readonly="isReviewer" placeholder="请简要填写" />
+        </a-form-item>
+        <a-form-item label="乙方电话" name="mobile">
+          <a-input v-model:value="currentAgreement.mobile" :readonly="isReviewer" placeholder="请填写联系电话" />
+        </a-form-item>
+        <a-form-item label="填报人联系方式" name="contactMobile">
+          <a-input v-model:value="currentAgreement.contactMobile" :readonly="isReviewer" placeholder="请填写手机号" />
+          <template v-if="!isReviewer" #extra>
+            用于消息通知,若有审核回退或通过,会以短信方式通知您
+          </template>
+        </a-form-item>
+      </AgreementBody>
+    </a-form>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { AgreementDetail } from '@/api/collect/AssessmentContent';
+import AgreementBodyNational from './AgreementBodyNational.vue';
+import AgreementBodyProvincial from './AgreementBodyProvincial.vue';
+import AgreementBodyMunicipal from './AgreementBodyMunicipal.vue';
+import AgreementBody from './AgreementBody.vue';
+import { Sign } from '@imengyu/vue-dynamic-form-rich';
+import { computed, ref } from 'vue';
+import type { Rules } from 'async-validator';
+import type { FormInstance } from 'ant-design-vue';
+
+const props = withDefaults(defineProps<{
+  currentAgreement: AgreementDetail;
+  isReviewer?: boolean;
+}>(), {
+  isReviewer: false,
+});
+
+const agreementYear = computed(() => props.currentAgreement?.year ?? 2027);
+const agreementTitle = computed(
+  () => `${agreementYear.value} 年度${levelTitle.value}非物质文化遗产代表性传承人传承协议`,
+);
+const levelTitle = computed(() => {
+  if (props.currentAgreement?.level === 23) return '国家级';
+  if (props.currentAgreement?.level === 24) return '省级';
+  return '市级';
+});
+
+const formRef = ref<FormInstance | null>(null);
+
+const CN_PHONE = /^1[3-9]\d{9}$/;
+const CN_PHONE_RE = /^(1\d{10}|0\d{2,3}-\d{7,8})$/;
+
+const formRules = computed<Rules>(() => {
+  const rules: Rules = {
+    partyA: [
+      { required: props.isReviewer, message: '请填写甲方负责人(代表人)姓名' }
+    ],
+    partyB: [
+      { required: true, message: '请填写乙方(传承人)姓名' }
+    ],
+    partyAMobile: [
+      {
+        required: props.isReviewer,
+        validator(_rule, value, callback) {
+          const s = value != null ? String(value).trim() : '';
+          if (props.isReviewer && !CN_PHONE_RE.test(s))
+            callback(new Error('请输入正确的手机号或座机号'));
+          else
+            callback();
+        },
+      },
+    ],
+    apprentice: [
+      { required: true, message: '请填写本年度带徒人数' },
+      { type: 'integer', min: 0, message: '须为不小于 0 的整数' },
+    ],
+    activity: [
+      { required: true, message: '请填写本年度宣传活动场次' },
+      { type: 'integer', min: 0, message: '须为不小于 0 的整数' },
+    ],
+    partyASign: [
+      {
+        required: props.isReviewer,
+        validator(_rule, value, callback) {
+          const s = typeof value === 'string' ? value.trim() : '';
+          if (!s && props.isReviewer)
+            callback(new Error('请完成甲方签名'));
+          else
+            callback();
+        },
+      },
+    ],
+    partyBSign: [
+      {
+        validator(_rule, value, callback) {
+          const s = typeof value === 'string' ? value.trim() : '';
+          if (!s)
+            callback(new Error('请完成乙方签名'));
+          else
+            callback();
+        },
+      },
+    ],
+    idCard: [
+      { required: true, message: '请填写身份证号' },
+    ],
+    ich: [{ required: true, message: '请填写非遗项目名称' }],
+    health: [{ required: true, message: '请填写身体状况' }],
+    mobile: [
+      { required: true, message: '请填写乙方联系电话' },
+      {
+        validator(_rule, value, callback) {
+          const s = value != null ? String(value).trim() : '';
+          if (!props.isReviewer && !CN_PHONE_RE.test(s))
+            callback(new Error('请输入正确的手机号或座机号'));
+          else
+            callback();
+        },
+      },
+    ],
+    contactMobile: [
+      {
+        required: false,
+        validator(_rule, value, callback) {
+          const s = value != null ? String(value).trim() : '';
+          if (!props.isReviewer && !CN_PHONE.test(s))
+            callback(new Error('请输入正确的手机号'));
+          else
+            callback();
+        },
+      },
+    ],
+  };
+  if (props.currentAgreement?.level !== 25)
+    rules.course = [
+      { required: true, message: '请填写本年度研修班场次' },
+      { type: 'integer', min: 0, message: '须为不小于 0 的整数' },
+    ];
+  return rules;
+});
+
+defineExpose({
+  validate: async () => await formRef.value?.validate(),
+});
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 3 - 0
src/pages/collect/assessment/components/AgreementPrefillInline.vue

@@ -6,6 +6,7 @@
       :type="numberMode ? 'number' : 'text'"
       :type="numberMode ? 'number' : 'text'"
       :placeholder="placeholder"
       :placeholder="placeholder"
       :maxlength="maxLength"
       :maxlength="maxLength"
+      :disabled="disabled"
       class="prefill-input"
       class="prefill-input"
       @update:value="onUpdate"
       @update:value="onUpdate"
     />
     />
@@ -24,10 +25,12 @@ const props = withDefaults(
     label?: string;
     label?: string;
     suffix?: string;
     suffix?: string;
     maxLength?: number;
     maxLength?: number;
+    disabled?: boolean;
   }>(),
   }>(),
   {
   {
     modelValue: '',
     modelValue: '',
     numberMode: false,
     numberMode: false,
+    disabled: false,
     placeholder: '请填写',
     placeholder: '请填写',
     label: '',
     label: '',
     suffix: '',
     suffix: '',

+ 4 - 1
src/pages/collect/assessment/evaluation-form-review.vue

@@ -9,8 +9,11 @@
           <div class="w-20" />
           <div class="w-20" />
         </div>
         </div>
         <a-spin :spinning="loader.loading.value">
         <a-spin :spinning="loader.loading.value">
+          <div 
+            v-if="loader.loading.value" class="h-50" 
+          />
           <a-result
           <a-result
-            v-if="!currentForm"
+            v-else-if="!currentForm"
             status="warning"
             status="warning"
             title="无法加载评估表"
             title="无法加载评估表"
             sub-title="请从管理员列表进入,并确认自查表 ID 与传承人用户 ID 正确。"
             sub-title="请从管理员列表进入,并确认自查表 ID 与传承人用户 ID 正确。"

+ 5 - 0
src/router/index.ts

@@ -90,6 +90,11 @@ const router = createRouter({
       component: () => import('@/pages/collect/assessment/argeement-sign.vue'),
       component: () => import('@/pages/collect/assessment/argeement-sign.vue'),
     },
     },
     {
     {
+      path: '/collect/assessment/argeement-sign-review',
+      name: 'CollectAgreementSignReview',
+      component: () => import('@/pages/collect/assessment/argeement-sign-review.vue'),
+    },
+    {
       path: '/:pathMatch(.*)*',
       path: '/:pathMatch(.*)*',
       name: 'NotFound',
       name: 'NotFound',
       component: NotFound
       component: NotFound