ソースを参照

📦 列表修改与审核功能

快乐的梦鱼 5 時間 前
コミット
e9901fda46

+ 247 - 0
src/api/collect/AssessmentContent.ts

@@ -62,6 +62,7 @@ export class SelfAssessmentListRow extends DataModel<SelfAssessmentListRow> {
       province: { clientSide: 'number', serverSide: 'number' },
       provincePoints: { clientSide: 'number', serverSide: 'number' },
       level: { clientSide: 'number', serverSide: 'string' },
+      progress: { clientSide: 'number', serverSide: 'number' },
     };
     this._convertKeyType = (key) => {
       if (key.endsWith('Text') || key.endsWith('_text')) {
@@ -96,6 +97,8 @@ export class SelfAssessmentListRow extends DataModel<SelfAssessmentListRow> {
   createtime = '' as string;
   updatetime = '' as string;
   selfText = '' as string;
+  /** -1=未提交,0=草稿,1=已自评,2~5 各级审核完成 */
+  progress = null as number|null;
 }
 
 /** 传承协议列表行 */
@@ -135,6 +138,157 @@ export class AgreementListRow extends DataModel<AgreementListRow> {
   deletetime = '' as string|null;
 }
 
+/** 保护单位账号:传承人传承协议分页列表行(ich/check/getUserAgreement) */
+export class UserAgreementListRow extends DataModel<UserAgreementListRow> {
+  constructor() {
+    super(UserAgreementListRow, '传承人传承协议列表项');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number' },
+      sitesId: { clientSide: 'number', serverSide: 'number' },
+      batch: { clientSide: 'number', serverSide: 'number' },
+      ichId: { clientSide: 'number', serverSide: 'number' },
+      agreementId: { clientSide: 'number', serverSide: 'number' },
+      userId: { clientSide: 'number', serverSide: 'number' },
+      year: { clientSide: 'number', serverSide: 'number' },
+      apprentice: { clientSide: 'number', serverSide: 'number' },
+      activity: { clientSide: 'number', serverSide: 'number' },
+      course: { clientSide: 'number', serverSide: 'number' },
+      progress: { clientSide: 'number', serverSide: 'number' },
+      level: { clientSide: 'number', serverSide: 'string' },
+    };
+    this._convertKeyType = (key) => {
+      if (key.endsWith('Text') || key.endsWith('_text')) {
+        return { clientSide: 'string', serverSide: 'undefined' };
+      }
+      return undefined;
+    };
+  }
+
+  id = 0 as number;
+  title = '' as string;
+  associationId = '' as string;
+  sitesId = 0 as number;
+  level = null as number|string|null;
+  batch = 0 as number;
+  gender = '' as string;
+  basicMobile = '' as string|null;
+  basicUnit = '' as string|null;
+  ichId = 0 as number;
+  ichTitle = '' as string;
+  /** 已填写的传承协议记录 ID,未填写时为 null */
+  agreementId = null as number|null;
+  userId = null as number|null;
+  year = null as number|null;
+  partyA = '' as string|null;
+  partyB = '' as string|null;
+  apprentice = null as number|null;
+  activity = null as number|null;
+  course = null as number|null;
+  mobile = '' as string|null;
+  partyAMobile = '' as string|null;
+  partyAContact = '' as string|null;
+  idCard = '' as string|null;
+  health = '' as string|null;
+  ich = '' as string|null;
+  partyASign = '' as string|null;
+  partyBSign = '' as string|null;
+  /** -1=未提交,0=草稿,1=已自评,2=审核完成 */
+  progress = null as number|null;
+  createtime = '' as string|null;
+  updatetime = '' as string|null;
+  deletetime = '' as string|null;
+  batchText = '' as string;
+  ichSiteTypeText = '' as string;
+}
+
+/** 保护单位账号:传承人列表行(ich/check/getInheritorList) */
+export class InheritorCheckListRow extends DataModel<InheritorCheckListRow> {
+  constructor() {
+    super(InheritorCheckListRow, '传承人列表项');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number' },
+      sitesId: { clientSide: 'number', serverSide: 'number' },
+      batch: { clientSide: 'number', serverSide: 'number' },
+      ichId: { clientSide: 'number', serverSide: 'number' },
+      checkId: { clientSide: 'number', serverSide: 'number' },
+      userId: { clientSide: 'number', serverSide: 'number' },
+      year: { clientSide: 'number', serverSide: 'number' },
+      weigh: { clientSide: 'number', serverSide: 'number' },
+      deductPoints: { clientSide: 'number', serverSide: 'number' },
+      points: { clientSide: 'number', serverSide: 'number' },
+      self: { clientSide: 'number', serverSide: 'number' },
+      ichUnit: { clientSide: 'number', serverSide: 'number' },
+      unitPoints: { clientSide: 'number', serverSide: 'number' },
+      county: { clientSide: 'number', serverSide: 'number' },
+      countyPoints: { clientSide: 'number', serverSide: 'number' },
+      district: { clientSide: 'number', serverSide: 'number' },
+      districtPoints: { clientSide: 'number', serverSide: 'number' },
+      province: { clientSide: 'number', serverSide: 'number' },
+      provincePoints: { clientSide: 'number', serverSide: 'number' },
+      progress: { clientSide: 'number', serverSide: 'number' },
+      level: { clientSide: 'number', serverSide: 'string' },
+    };
+    this._convertKeyType = (key) => {
+      if (key.endsWith('Text') || key.endsWith('_text')) {
+        return { clientSide: 'string', serverSide: 'undefined' };
+      }
+      return undefined;
+    };
+  }
+
+  id = 0 as number;
+  title = '' as string;
+  /** 关联项目 ID(接口可能返回字符串数字) */
+  associationId = '' as string;
+  sitesId = 0 as number;
+  level = null as number|string|null;
+  batch = 0 as number;
+  gender = '' as string;
+  basicMobile = '' as string|null;
+  basicUnit = '' as string|null;
+  ichId = 0 as number;
+  ichTitle = '' as string;
+  checkId = null as number|null;
+  userId = null as number|null;
+  year = null as number|null;
+  inheritor = '' as string|null;
+  unit = '' as string|null;
+  ichName = '' as string|null;
+  mobile = '' as string|null;
+  idCard = '' as string|null;
+  address = '' as string|null;
+  weigh = null as number|null;
+  deductPoints = null as number|null;
+  points = null as number|null;
+  self = null as number|null;
+  ichUnit = null as number|null;
+  unitPoints = null as number|null;
+  county = null as number|null;
+  countyPoints = null as number|null;
+  district = null as number|null;
+  districtPoints = null as number|null;
+  province = null as number|null;
+  provincePoints = null as number|null;
+  createtime = '' as string|null;
+  updatetime = '' as string|null;
+  /** -1=未提交,0=草稿,1=已自评,2=项目保护单位审核完成,3=县(区)文旅部门审核完成,4=设区市文旅部门/省非遗中心审核完成,5=省文化和旅游厅审核完成 */
+  progress = null as number|null;
+  flagText = '' as string;
+  typeText = '' as string;
+  openStatusText = '' as string;
+  statusText = '' as string;
+  regionText = '' as string;
+  levelText = '' as string;
+  crTypeText = '' as string;
+  ichTypeText = '' as string;
+  claimStatusText = '' as string;
+  isMultipleClaimsText = '' as string;
+  batchText = '' as string;
+  ichSiteTypeText = '' as string;
+}
+
 /** 传承人基础信息(ich/check/basic) */
 export class InheritorCheckBasicInfo extends DataModel<InheritorCheckBasicInfo> {
   constructor() {
@@ -221,6 +375,8 @@ export class SelfAssessmentDetail extends DataModel<SelfAssessmentDetail> {
       content: {
         customToClientFn: (value) => {
           try {
+            if (typeof value === 'object') 
+              return value;
             return JSON.parse(value as string);
           } catch {
             return {};
@@ -377,6 +533,26 @@ export function getCheckAnnexType(mimetype: string) {
 
 export type CheckAnnexTypeValue = (typeof CheckAnnexType)[keyof typeof CheckAnnexType];
 
+/** 自查表审核提交(POST /ich/check/review) */
+export interface IchCheckReviewPayload {
+  /** 自查表 ID */
+  id: number;
+  /** 项目保护单位意见: 1=优秀,2=合格,3=不合格,4=丧失传承能力,5=取消资格 */
+  ichUnit?: number;
+  unitPoints?: number;
+  /** 县(区)文旅部门审核意见,取值同 ichUnit */
+  county?: number;
+  countyPoints?: number;
+  /** 设区市文旅部门、省非遗中心审核意见,取值同 ichUnit */
+  district?: number;
+  districtPoints?: number;
+  /** 省文化和旅游厅意见,取值同 ichUnit */
+  province?: number;
+  provincePoints?: number;
+  /** 0=草稿,1=已自评,2=项目保护单位审核完成,3=县(区)文旅部门审核完成,4=设区市文旅部门/省非遗中心审核完成,5=省文化和旅游厅审核完成 */
+  progress: number;
+}
+
 /** 证明材料修改与新增请求体(POST /ich/check/saveAnnex) */
 export interface SaveCheckAnnexPayload {
   id?: number;
@@ -491,6 +667,7 @@ export class AssessmentContentApi extends AppServerRequestModule<DataModel> {
 
   async getSelfAssessmentList(data: {
     userId?: number;
+    progress?: number;
     level?: number;
     year?: number;
     keywords?: string;
@@ -499,6 +676,7 @@ export class AssessmentContentApi extends AppServerRequestModule<DataModel> {
   }) {
     const res = await this.post('/ich/check/getList', {
       user_id: data.userId,
+      progress: data.progress,
       level: data.level,
       year: data.year,
       keywords: data.keywords,
@@ -527,6 +705,56 @@ export class AssessmentContentApi extends AppServerRequestModule<DataModel> {
     return normalizePaginated<AgreementListRow>(AgreementListRow, res.data as KeyValue);
   }
 
+  /**
+   * 保护单位账号:传承人传承协议分页列表
+   * @param data.progress -1=未提交,0=草稿,1=已自评,2=审核完成
+   */
+  async getUserAgreementList(data: {
+    userId?: number;
+    progress?: number;
+    level?: number;
+    year?: number;
+    keywords?: string;
+    page?: number;
+    pageSize?: number;
+  }) {
+    const res = await this.post('/ich/check/getUserAgreement', {
+      user_id: data.userId,
+      progress: data.progress,
+      level: data.level,
+      year: data.year,
+      keywords: data.keywords,
+      page: data.page,
+      pageSize: data.pageSize,
+    }, '传承人传承协议列表');
+    return normalizePaginated<UserAgreementListRow>(UserAgreementListRow, res.data as KeyValue);
+  }
+
+  /**
+   * 保护单位账号:传承人分页列表
+   * @param data.progress -1=未提交,0=草稿,1=已自评,2=项目保护单位审核完成,3=县(区)文旅部门审核完成,4=设区市文旅部门/省非遗中心审核完成,5=省文化和旅游厅审核完成
+   */
+  async getInheritorList(data: {
+    userId?: number;
+    progress?: number;
+    level?: number;
+    year?: number;
+    keywords?: string;
+    page?: number;
+    pageSize?: number;
+  }) {
+    const res = await this.post('/ich/check/getInheritorList', {
+      user_id: data.userId,
+      progress: data.progress,
+      level: data.level,
+      year: data.year,
+      keywords: data.keywords,
+      page: data.page,
+      pageSize: data.pageSize,
+    }, '传承人列表');
+    return normalizePaginated<InheritorCheckListRow>(InheritorCheckListRow, res.data as KeyValue);
+  }
+
   async saveAgreement(dataModel: AgreementDetail) {
     return this.post('/ich/check/saveAgreement', dataModel.toServerSide(), '传承协议保存');
   }
@@ -535,6 +763,25 @@ export class AssessmentContentApi extends AppServerRequestModule<DataModel> {
     return this.post('/ich/check/save', dataModel.toServerSide(), '自查评估表保存');
   }
 
+  /**
+   * 自查评估表审核
+   * POST `/ich/check/review`
+   */
+  async reviewSelfAssessment(payload: IchCheckReviewPayload) {
+    return this.post('/ich/check/review', {
+      id: payload.id,
+      ich_unit: payload.ichUnit,
+      unit_points: payload.unitPoints,
+      county: payload.county,
+      county_points: payload.countyPoints,
+      district: payload.district,
+      district_points: payload.districtPoints,
+      province: payload.province,
+      province_points: payload.provincePoints,
+      progress: payload.progress,
+    }, '自查评估表审核');
+  }
+
   async downloadSelfAssessmentPdf(id: number) {
     await downloadPdfBlob('/pdf/create', id, this.config.baseUrl, `self-assessment-${id}.pdf`);
   }

+ 105 - 18
src/pages/admin.vue

@@ -106,31 +106,52 @@
           <a-tab-pane key="7" tab="区级非遗传承人">
             <a-empty description="暂无数据" />
           </a-tab-pane> -->
-          <a-tab-pane key="8" tab="自查评估表">
+          <a-tab-pane key="9" tab="传承协议签署">
             <CommonListBlock
               :show-total="true"
               :row-count="1"
               :row-type="5"
               :page-size="10"
-              :load="(page: number, pageSize: number, _tag: number, searchText: string, _drop: number[]) => loadSelfAssessmentAdminList(page, pageSize, searchText)"
-              :show-detail="(item) => router.push({ name: 'CollectEvaluationForm', query: { id: item.id, userId: item.userId } })"
+              :drop-down-names="[
+                {
+                  options: agreementProgressOptions,
+                  label: '状态',
+                  defaultSelectedValue: lastAgreementProgress,
+                },
+              ]"
+              :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 }">
-                <a-button type="link" @click.stop="router.push({ name: 'CollectEvaluationForm', query: { id: item.id, userId: item.userId } })">编辑</a-button>
+                <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>
           </a-tab-pane>
-          <a-tab-pane key="9" tab="传承协议签署">
+          <a-tab-pane key="8" tab="自查评估表">
             <CommonListBlock
               :show-total="true"
               :row-count="1"
               :row-type="5"
               :page-size="10"
-              :load="(page: number, pageSize: number, _tag: number, searchText: string, _drop: number[]) => loadAgreementSignAdminList(page, pageSize, searchText)"
-              :show-detail="(item) => router.push({ name: 'CollectAgreementSign', query: { id: item.id, userId: item.userId } })"
+              :drop-down-names="[
+                {
+                  options: selfAssessmentProgressOptions,
+                  label: '状态',
+                  defaultSelectedValue: lastSelfAssessmentProgress,
+                },
+              ]"
+              :load="(page: number, pageSize: number, _tag: number, searchText: string, drop: number[]) => loadSelfAssessmentAdminList(page, pageSize, searchText, drop)"
+              :show-detail="(item) => router.push({ name: 'CollectEvaluationForm', query: { id: item.checkId ?? item.id, userId: item.userId } })"
             >
               <template #itemRight="{ item }">
-                <a-button type="link" @click.stop="router.push({ name: 'CollectAgreementSign', query: { id: item.id, userId: item.userId } })">编辑</a-button>
+                <span class="mr-3 text-sm text-gray-600">{{ selfAssessmentProgressLabel(item.progress) }}</span>
+                <a-button type="link" @click.stop="router.push({ name: 'CollectEvaluationForm', query: { id: item.checkId ?? item.id, userId: item.userId } })">编辑</a-button>
+                <a-button
+                  type="primary"
+                  v-if="item.checkId && item.progress != null && item.progress >= 1 && item.progress < 5"
+                  @click.stop="handleReviewSelfAssessment(item)"
+                >审核</a-button>             
               </template>
             </CommonListBlock>
           </a-tab-pane>
@@ -188,6 +209,43 @@ const inheritorData = ref<GetContentListItem[]>([]);
 
 const { variable: lastValueCategory } = useMemorizeVar('categoryLastSelectValue', 0);
 const { variable: lastValueStatus } = useMemorizeVar('statusLastSelectValue', -10);
+const { variable: lastSelfAssessmentProgress } = useMemorizeVar('adminSelfAssessmentProgress', -100);
+const { variable: lastAgreementProgress } = useMemorizeVar('adminUserAgreementProgress', -100);
+
+/** 自查评估表列表:进度筛选(与 ich/check/getList 一致) */
+const selfAssessmentProgressOptions: DropdownCommonItem[] = [
+  { id: -100, name: '全部状态' },
+  { id: -1, name: '未提交' },
+  { id: 0, name: '草稿' },
+  { id: 1, name: '已自评' },
+  { id: 2, name: '项目保护单位审核完成' },
+  { id: 3, name: '县(区)文旅部门审核完成' },
+  { id: 4, name: '设区市文旅部门/省非遗中心审核完成' },
+  { id: 5, 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}`;
+}
+
+function selfAssessmentProgressLabel(progress: number | null | undefined) {
+  if (progress === null || progress === undefined)
+    return '未填写';
+  const hit = selfAssessmentProgressOptions.find((o) => o.id === progress);
+  return hit?.name ?? `进度 ${progress}`;
+}
 
 const computedCategoryOptions = computed<DropdownCommonItem[]>(() => {
   if (authStore.isReviewer) {
@@ -332,13 +390,17 @@ async function loadAreaData(page: number, pageSize: number, dropDownValues: numb
   }
 }
 
-/** 管理员:自查评估表分页(与小程序 evaluation-list 一致,关键词走接口) */
-async function loadSelfAssessmentAdminList(page: number, pageSize: number, searchText: string) {
-  const list = await AssessmentContentApi.getSelfAssessmentList({
+/** 管理员:自查评估表分页(关键词、进度走接口) */
+async function loadSelfAssessmentAdminList(page: number, pageSize: number, searchText: string, dropDownValues: number[]) {
+  const pv = dropDownValues?.[0];
+  lastSelfAssessmentProgress.value = pv ?? -100;
+  const progress = pv != null && pv > -50 ? pv : undefined;
+  const list = await AssessmentContentApi.getInheritorList({
     year: new Date().getFullYear(),
     page,
     pageSize,
     keywords: searchText?.trim() || undefined,
+    progress,
   });
   return {
     page,
@@ -347,29 +409,36 @@ async function loadSelfAssessmentAdminList(page: number, pageSize: number, searc
       ...row,
       id: row.id,
       userId: row.userId,
-      title: row.inheritor ?? '?',
-      desc: [row.mobile, row.unit, row.ichName].filter(Boolean).join(' · ') || '—',
+      title: row.title ?? '?',
+      desc: [row.mobile, row.unit, row.ichTitle].filter(Boolean).join(' · ') || '—',
     })),
   };
 }
 
-/** 管理员:传承协议分页(小程序 argeement-sign-list 误用评估接口,此处使用 getAgreementList) */
-async function loadAgreementSignAdminList(page: number, pageSize: number, searchText: string) {
-  const list = await AssessmentContentApi.getAgreementList({
+/** 管理员:传承人传承协议分页(ich/check/getUserAgreement) */
+async function loadAgreementSignAdminList(page: number, pageSize: number, searchText: string, dropDownValues: number[]) {
+  const pv = dropDownValues?.[0];
+  lastAgreementProgress.value = pv ?? -100;
+  const progress = pv != null && pv > -50 ? pv : undefined;
+  const list = await AssessmentContentApi.getUserAgreementList({
     year: new Date().getFullYear(),
     page,
     pageSize,
     keywords: searchText?.trim() || undefined,
+    progress,
   });
+  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.partyB ?? row.partyA ?? '?',
-      desc: [row.mobile, row.ich].filter(Boolean).join(' · ') || '—',
+      title: row.title,
+      desc: [row.mobile, row.unit, row.ichTitle].filter(Boolean).join(' · ') || '—',
     })),
   };
 }
@@ -412,4 +481,22 @@ function handleGoWorks(item: GetContentListItem) {
     ichId: item.id,
   } })
 }
+
+/** 自查评估表:进入审核页(只读表单 + 提交 /ich/check/review) */
+function handleReviewSelfAssessment(item: { checkId: number | null; userId: number | null; progress?: number | null }) {
+  const cid = item.checkId;
+  const uid = item.userId;
+  if (!cid || !uid) {
+    message.warning('缺少自查表 ID 或传承人用户 ID');
+    return;
+  }
+  router.push({
+    name: 'CollectEvaluationFormReview',
+    query: {
+      id: String(cid),
+      userId: String(uid),
+      ...(item.progress != null && item.progress !== undefined ? { progress: String(item.progress) } : {}),
+    },
+  });
+}
 </script>

+ 35 - 7
src/pages/collect/assessment/components/EvaluationFormBlock.vue

@@ -3,7 +3,7 @@
     <DynamicForm
       ref="mainFormRef"
       :model="currentForm"
-      :options="mainFormOptions"
+      :options="mainFormOptionsComputed"
     />
     <a-divider />
     <a-typography-title :level="4">自评报告</a-typography-title>
@@ -23,6 +23,7 @@
           show-count
           :rows="4"
           placeholder="请填写"
+          :disabled="readonly"
         />
       </a-form-item>
     </a-form>
@@ -36,6 +37,7 @@
       <template v-if="item.checkType == 3">
         <div v-for="child in item.children" :key="child.id" class="check-child-row">
           <a-checkbox
+            :disabled="readonly"
             :checked="hasCheckedItem(child.id)"
             @change="(e: any) => setCheckedItem(item, child, e.target.checked)"
           >
@@ -43,6 +45,7 @@
           </a-checkbox>
           <a-input-number
             v-if="hasCheckedItem(child.id)"
+            :disabled="readonly"
             :min="0"
             :max="20"
             :value="getCheckedItemCount(child.id) ?? 0"
@@ -55,6 +58,7 @@
         <a-checkbox
           v-for="child in item.children"
           :key="child.id"
+          :disabled="readonly"
           :checked="hasCheckedItem(child.id)"
           @change="(e: any) => setCheckedItem(item, child, e.target.checked)"
         >
@@ -66,13 +70,13 @@
     <DynamicForm
       ref="tailFormRef"
       :model="currentForm"
-      :options="tailFormOptions"
+      :options="tailFormOptionsComputed"
     />
   </div>
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue';
+import { computed, ref } from 'vue';
 import { DynamicForm, type IDynamicFormOptions, type IDynamicFormRef } from '@imengyu/vue-dynamic-form';
 import { ArrayUtils } from '@imengyu/imengyu-utils';
 import type { FormInstance } from 'ant-design-vue';
@@ -85,11 +89,15 @@ import { useImageSimpleUploadCo } from '@/common/upload/ImageUploadCo';
 import type { RadioValueFormItemProps, SelectIdProps } from '@imengyu/vue-dynamic-form-ant';
 import type { SignProps } from '@imengyu/vue-dynamic-form-rich';
 
-const props = defineProps<{
+const props = withDefaults(defineProps<{
   currentForm: SelfAssessmentDetail;
   checkItemList: any[];
   currentFormCheckItems: any[];
-}>();
+  /** 为 true 时整表只读(管理员查看 / 审核页) */
+  readonly?: boolean;
+}>(), {
+  readonly: false,
+});
 
 const mainFormRef = ref<IDynamicFormRef | null>(null);
 const tailFormRef = ref<IDynamicFormRef | null>(null);
@@ -107,7 +115,7 @@ const contentItemLabels = [
 
 const signUploadCo = useImageSimpleUploadCo();
 
-const mainFormOptions: IDynamicFormOptions = {
+const mainFormOptionsBase: IDynamicFormOptions = {
   formAdditionaProps: {
     layout: 'vertical',
     scrollToFirstError: true,
@@ -191,7 +199,15 @@ const mainFormOptions: IDynamicFormOptions = {
   },
 };
 
-const tailFormOptions: IDynamicFormOptions = {
+const mainFormOptionsComputed = computed<IDynamicFormOptions>(() => ({
+  ...mainFormOptionsBase,
+  disabled: props.readonly,
+  formAdditionaProps: {
+    ...mainFormOptionsBase.formAdditionaProps,
+  },
+}));
+
+const tailFormOptionsBase: IDynamicFormOptions = {
   formAdditionaProps: {
     layout: 'vertical',
     scrollToFirstError: true,
@@ -254,6 +270,14 @@ const tailFormOptions: IDynamicFormOptions = {
   },
 };
 
+const tailFormOptionsComputed = computed<IDynamicFormOptions>(() => ({
+  ...tailFormOptionsBase,
+  disabled: props.readonly,
+  formAdditionaProps: {
+    ...tailFormOptionsBase.formAdditionaProps,
+  },
+}));
+
 function getCheckModeText(checkMode: number) {
   switch (checkMode) {
     case 1:
@@ -276,6 +300,8 @@ function getCheckedItemCount(id: number) {
 }
 
 function setCheckedItem(checkItem: CheckItemInfo, childItem: CheckItemInfo, count: number | boolean) {
+  if (props.readonly)
+    return;
   let c = count;
   if (typeof c === 'boolean')
     c = c ? 1 : 0;
@@ -308,6 +334,8 @@ function setCheckedItem(checkItem: CheckItemInfo, childItem: CheckItemInfo, coun
 }
 
 async function validate() {
+  if (props.readonly)
+    return;
   await (mainFormRef.value?.getFormRef() as FormInstance)?.validate();
   for (let i = 0; i < contentItemLabels.length; i++) {
     const v = props.currentForm.content?.[`item${i}`];

+ 68 - 0
src/pages/collect/assessment/components/SelfAssessmentFormDisplay.vue

@@ -0,0 +1,68 @@
+<template>
+  <div>
+    <EvaluationFormBlock
+      ref="blockRef"
+      :current-form="currentForm"
+      :check-item-list="checkItemList"
+      :current-form-check-items="currentFormCheckItems"
+      :readonly="readonly"
+    />
+    <a-divider />
+    <a-row justify="space-between" align="bottom" class="mb-2">
+      <a-typography-title :level="4" class="mb-0">自评总分</a-typography-title>
+      <span class="total-points">{{ totalPoints }} 分</span>
+    </a-row>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed, ref } from 'vue';
+import type { CheckItemInfo, SelfAssessmentCheckItemAnswer, SelfAssessmentDetail } from '@/api/collect/AssessmentContent';
+import EvaluationFormBlock from './EvaluationFormBlock.vue';
+
+const props = withDefaults(defineProps<{
+  currentForm: SelfAssessmentDetail;
+  checkItemList: CheckItemInfo[];
+  currentFormCheckItems: SelfAssessmentCheckItemAnswer[];
+  readonly?: boolean;
+}>(), {
+  readonly: false,
+});
+
+const blockRef = ref<InstanceType<typeof EvaluationFormBlock> | null>(null);
+
+const checkItemMap = computed(() => {
+  const m = new Map<number, CheckItemInfo>();
+  function walk(nodes: CheckItemInfo[]) {
+    for (const n of nodes) {
+      m.set(n.id, n);
+      if (n.children?.length)
+        walk(n.children);
+    }
+  }
+  walk(props.checkItemList);
+  return m;
+});
+
+const totalPoints = computed(() => {
+  return props.currentFormCheckItems
+    .filter((item) => {
+      const checkItem = checkItemMap.value.get(item.id);
+      return checkItem && !checkItem.isTitle;
+    })
+    .reduce((acc, item) => acc + (item.count * item.points), 0)
+    - (props.currentForm.deductPoints ?? 0);
+});
+
+defineExpose({
+  validate: () => blockRef.value?.validate(),
+});
+</script>
+
+<style scoped>
+.total-points {
+  font-size: 1.75rem;
+  color: #315816;
+  font-weight: 600;
+}
+</style>

+ 295 - 0
src/pages/collect/assessment/evaluation-form-review.vue

@@ -0,0 +1,295 @@
+<template>
+  <div class="about main-background main-background-type0">
+    <div 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">
+          <a-result
+            v-if="!currentForm"
+            status="warning"
+            title="无法加载评估表"
+            sub-title="请从管理员列表进入,并确认自查表 ID 与传承人用户 ID 正确。"
+          />
+          <div v-else>
+            <a-alert
+              type="info"
+              show-icon
+              class="mb-4"
+              :message="progressHint"
+            />
+            <SelfAssessmentFormDisplay
+              :current-form="currentForm"
+              :check-item-list="checkItemList"
+              :current-form-check-items="currentFormCheckItems"
+              :readonly="true"
+            />
+            <a-divider />
+            <a-typography-title :level="4">佐证资料</a-typography-title>
+            <a-empty v-if="!annexLinks.length" description="暂无佐证资料" />
+            <ul v-else class="annex-list pl-5">
+              <li v-for="a in annexLinks" :key="a.id">
+                <a :href="a.url" target="_blank" rel="noopener noreferrer">{{ a.name }}</a>
+              </li>
+            </ul>
+            <a-divider />
+            <a-card title="审核提交" size="small">
+              <a-form layout="vertical" class="max-w-xl">
+                <a-form-item :label="`审核环节:${reviewLevelLabel}`">
+                  <a-select
+                    v-model:value="reviewOpinion"
+                    allow-clear
+                    placeholder="请选择审核意见"
+                    :options="opinionSelectOptions"
+                  />
+                </a-form-item>
+                <a-form-item label="评分">
+                  <a-input-number
+                    v-model:value="reviewPoints"
+                    :min="0"
+                    :max="100"
+                    class="w-full"
+                    placeholder="可选"
+                  />
+                </a-form-item>
+                <a-form-item label="提交后进度">
+                  <a-select
+                    v-model:value="submitProgress"
+                    :options="progressSubmitOptions"
+                  />
+                </a-form-item>
+                <a-form-item>
+                  <a-button type="primary" :loading="submitLoading" @click="submitReview">
+                    提交审核
+                  </a-button>
+                </a-form-item>
+              </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 } from '@ant-design/icons-vue';
+import AssessmentContentApi, {
+  type CheckItemInfo,
+  type SelfAssessmentCheckItemAnswer,
+  SelfAssessmentDetail,
+} from '@/api/collect/AssessmentContent';
+import SelfAssessmentFormDisplay from './components/SelfAssessmentFormDisplay.vue';
+import { useSimpleDataLoader } from '@/composeables/useSimpleDataLoader';
+
+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 queryFormId = computed(() => Number(route.query.id) || 0);
+const queryUserId = computed(() => Number(route.query.userId) || 0);
+/** 列表当前进度(与管理员列表一致) */
+const listProgress = computed(() => {
+  const p = Number(route.query.progress);
+  return Number.isFinite(p) ? p : NaN;
+});
+
+const currentForm = ref<SelfAssessmentDetail | null>(null);
+const currentFormCheckItems = ref([] as SelfAssessmentCheckItemAnswer[]);
+const checkItemList = ref([] as CheckItemInfo[]);
+const annexLinks = ref<{ id: number; name: string; url: string }[]>([]);
+
+const submitLoading = ref(false);
+const reviewOpinion = ref<number | null>(null);
+const reviewPoints = ref<number | null>(null);
+const submitProgress = ref<number>(2);
+
+const opinionSelectOptions = [
+  { label: '优秀', value: 1 },
+  { label: '合格', value: 2 },
+  { label: '不合格', value: 3 },
+  { label: '丧失传承能力', value: 4 },
+  { label: '取消资格', value: 5 },
+];
+
+const progressSubmitOptions = [
+  { label: '2 — 项目保护单位审核完成', value: 2 },
+  { label: '3 — 县(区)文旅部门审核完成', value: 3 },
+  { label: '4 — 设区市文旅部门、省非遗中心审核完成', value: 4 },
+  { label: '5 — 省文化和旅游厅审核完成', value: 5 },
+];
+
+const targetProgressDefault = computed(() => {
+  const p = listProgress.value;
+  if (!Number.isFinite(p) || p < 1)
+    return 2;
+  if (p >= 5)
+    return 5;
+  return p + 1;
+});
+
+const reviewLevelLabel = computed(() => {
+  switch (submitProgress.value) {
+    case 2:
+      return '项目保护单位';
+    case 3:
+      return '县(区)文旅部门';
+    case 4:
+      return '设区市文旅部门、省非遗中心';
+    case 5:
+      return '省文化和旅游厅';
+    default:
+      return '';
+  }
+});
+
+const progressHint = computed(() => {
+  const id = queryFormId.value;
+  const uid = queryUserId.value;
+  const lp = listProgress.value;
+  const lpText = Number.isFinite(lp) ? `列表进度:${lp}` : '未传列表进度参数,已按默认环节填写';
+  return `自查表 ID ${id},传承人用户 ID ${uid}。${lpText}。提交后进度将更新为 ${submitProgress.value}。`;
+});
+
+const currentYear = new Date().getFullYear();
+
+function levelTitleFromForm(f: SelfAssessmentDetail) {
+  if (f.level === 23) return '国家级';
+  if (f.level === 24) return '省级';
+  if (f.level === 25) return '市级';
+  return '国家级';
+}
+
+function loadEditorContent(f: SelfAssessmentDetail) {
+  if (typeof f.content !== 'object' || f.content === null)
+    f.content = {};
+  f.content.title = `传承人填写${currentYear}年1月1日至${currentYear}年12月31日${levelTitleFromForm(f)}非遗传承人义务履行和传承补助经费使用情况等,不超过1000字,如未履行职责请进行说明。参考提纲如下:`;
+  for (let i = 0; i < 8; i++) {
+    if (typeof f.content[`item${i}`] !== 'string')
+      f.content[`item${i}`] = '';
+  }
+}
+
+async function loadCheckItems(f: SelfAssessmentDetail) {
+  const { top } = await AssessmentContentApi.getCheckItems(Number(f.level));
+  checkItemList.value = top as CheckItemInfo[];
+  currentFormCheckItems.value = [...f.checkItems] as SelfAssessmentCheckItemAnswer[];
+}
+
+async function loadAnnexLinks(formId: number) {
+  const annexList = await AssessmentContentApi.getAnnexList(formId);
+  annexLinks.value = annexList.data.map((item) => ({
+    id: item.id,
+    name: item.name,
+    url: item.url,
+  }));
+}
+
+function applyPrefillFromDetail(f: SelfAssessmentDetail) {
+  const t = submitProgress.value;
+  if (t === 2) {
+    reviewOpinion.value = f.ichUnit ?? null;
+    reviewPoints.value = f.unitPoints || null;
+  } else if (t === 3) {
+    reviewOpinion.value = f.county ?? null;
+    reviewPoints.value = f.countyPoints || null;
+  } else if (t === 4) {
+    reviewOpinion.value = f.district ?? null;
+    reviewPoints.value = f.districtPoints || null;
+  } else if (t === 5) {
+    reviewOpinion.value = f.province ?? null;
+    reviewPoints.value = f.provincePoints || null;
+  }
+}
+
+const loader = useSimpleDataLoader(async () => {
+  const id = queryFormId.value;
+  const uid = queryUserId.value;
+  if (!id || !uid) {
+    currentForm.value = null;
+    return null;
+  }
+  const detail = await AssessmentContentApi.getSelfAssessmentDetail(id, uid);
+  currentForm.value = detail;
+  loadEditorContent(detail);
+  await loadCheckItems(detail);
+  if (detail.id)
+    await loadAnnexLinks(detail.id);
+  else
+    annexLinks.value = [];
+  submitProgress.value = targetProgressDefault.value;
+  applyPrefillFromDetail(detail);
+  return detail;
+}, { immediate: false });
+
+watch(submitProgress, () => {
+  const f = currentForm.value;
+  if (f)
+    applyPrefillFromDetail(f);
+});
+
+watch(
+  () => [queryFormId.value, queryUserId.value, listProgress.value] as const,
+  () => {
+    loader.load();
+  },
+);
+
+onMounted(() => {
+  loader.load();
+});
+
+async function submitReview() {
+  const f = currentForm.value;
+  if (!f?.id) {
+    message.warning('缺少自查表 ID');
+    return;
+  }
+  if (reviewOpinion.value == null) {
+    message.warning('请选择审核意见');
+    return;
+  }
+  const t = submitProgress.value;
+  const base = { id: f.id, progress: t };
+  const points = reviewPoints.value != null && reviewPoints.value >= 0 ? reviewPoints.value : undefined;
+  const op = reviewOpinion.value;
+  try {
+    submitLoading.value = true;
+    if (t === 2)
+      await AssessmentContentApi.reviewSelfAssessment({ ...base, ichUnit: op, unitPoints: points });
+    else if (t === 3)
+      await AssessmentContentApi.reviewSelfAssessment({ ...base, county: op, countyPoints: points });
+    else if (t === 4)
+      await AssessmentContentApi.reviewSelfAssessment({ ...base, district: op, districtPoints: points });
+    else
+      await AssessmentContentApi.reviewSelfAssessment({ ...base, province: op, provincePoints: points });
+    message.success('审核提交成功');
+    router.back();
+  } catch (e) {
+    Modal.error({ title: '审核提交失败', content: formatErr(e) });
+  } finally {
+    submitLoading.value = false;
+  }
+}
+</script>
+
+<style scoped>
+.annex-list {
+  list-style: disc;
+}
+</style>

+ 7 - 26
src/pages/collect/assessment/evaluation-form.vue

@@ -19,18 +19,14 @@
             </template>
           </a-result>
           <div v-else>
-            <EvaluationFormBlock
+            <SelfAssessmentFormDisplay
               ref="blockRef"
               :current-form="(currentForm as SelfAssessmentDetail)"
-              :check-item-list="checkItemList"
-              :current-form-check-items="currentFormCheckItems"
+              :check-item-list="(checkItemList as CheckItemInfo[])"
+              :current-form-check-items="(currentFormCheckItems as SelfAssessmentCheckItemAnswer[])"
+              :readonly="false"
             />
             <a-divider />
-            <a-row justify="space-between" align="bottom" class="mb-2">
-              <a-typography-title :level="4" class="mb-0">自评总分</a-typography-title>
-              <span class="total-points">{{ totalPoints }} 分</span>
-            </a-row>
-            <a-divider />
             <a-typography-title :level="4">各级审核意见(待终审填写)</a-typography-title>
             <div v-for="(sec, secIdx) in externalReviewSectionTitles" :key="secIdx" class="mb-6">
               <a-typography-text strong>{{ sec.title }}</a-typography-text>
@@ -89,8 +85,7 @@ import AssessmentContentApi, {
   SelfAssessmentCheckItemAnswer,
   getCheckAnnexType,
 } from '@/api/collect/AssessmentContent';
-import EvaluationFormBlock from './components/EvaluationFormBlock.vue';
-import type { AntUploadRequestOption } from '@imengyu/vue-dynamic-form-ant';
+import SelfAssessmentFormDisplay from './components/SelfAssessmentFormDisplay.vue';
 import { useSimpleDataLoader } from '@/composeables/useSimpleDataLoader';
 
 function formatErr(e: unknown): string {
@@ -111,9 +106,8 @@ const queryUserId = computed(() => Number(route.query.userId) || 0);
 const currentForm = ref<SelfAssessmentDetail | null>(null);
 const currentFormCheckItems = ref([] as SelfAssessmentCheckItemAnswer[]);
 const checkItemList = ref([] as CheckItemInfo[]);
-let checkItemMap = new Map<number, CheckItemInfo>();
 
-const blockRef = ref<InstanceType<typeof EvaluationFormBlock> | null>(null);
+const blockRef = ref<InstanceType<typeof SelfAssessmentFormDisplay> | null>(null);
 const submitLoading = ref(false);
 
 const externalReviewSectionTitles = ref([
@@ -130,18 +124,6 @@ const annexFileList = ref<UploadProps['fileList']>([]);
 
 const annexUploadCo = useAliOssUploadCo('assessment/annex');
 
-const totalPoints = computed(() => {
-  if (!currentForm.value)
-    return 0;
-  return currentFormCheckItems.value
-    .filter((item) => {
-      const checkItem = checkItemMap.get(item.id);
-      return checkItem && !checkItem.isTitle;
-    })
-    .reduce((acc, item) => acc + (item.count * item.points), 0)
-    - (currentForm.value.deductPoints ?? 0);
-});
-
 const levelTitle = computed(() => {
   if (currentForm.value?.level === 23) return '国家级';
   if (currentForm.value?.level === 24) return '省级';
@@ -181,9 +163,8 @@ async function loadCheckItems() {
   const f = currentForm.value;
   if (!f)
     return;
-  const { top, map } = await AssessmentContentApi.getCheckItems(Number(f.level));
+  const { top } = await AssessmentContentApi.getCheckItems(Number(f.level));
   checkItemList.value = top as CheckItemInfo[];
-  checkItemMap = map;
   currentFormCheckItems.value = [...f.checkItems] as SelfAssessmentCheckItemAnswer[];
 }
 

+ 1 - 1
src/pages/login.vue

@@ -81,7 +81,7 @@ const formOptions = ref<IDynamicFormOptions>({
             value: 0,
           },
           {
-            text: '管理员',
+            text: '管理员/保护单位',
             value: 1,
           },
         ],

+ 5 - 0
src/router/index.ts

@@ -74,6 +74,11 @@ const router = createRouter({
       component: () => import('@/pages/collect/assessment/evaluation-form.vue'),
     },
     {
+      path: '/collect/assessment/evaluation-form-review',
+      name: 'CollectEvaluationFormReview',
+      component: () => import('@/pages/collect/assessment/evaluation-form-review.vue'),
+    },
+    {
       path: '/collect/assessment/argeement-sign',
       name: 'CollectAgreementSign',
       component: () => import('@/pages/collect/assessment/argeement-sign.vue'),