Explorar o código

📦 传承人自评估表

快乐的梦鱼 hai 4 semanas
pai
achega
8d94c219a7

+ 43 - 126
src/api/collect/AssessmentContent.ts

@@ -20,10 +20,8 @@ export class CheckItemInfo extends DataModel<CheckItemInfo> {
       pid: { clientSide: 'number', serverSide: 'number' },
       level: { clientSide: 'number', serverSide: 'number' },
       points: { clientSide: 'number', serverSide: 'number' },
-      isTitle: { clientSide: 'number', serverSide: 'number' },
-      haschild: { clientSide: 'number', serverSide: 'number' },
-      checkType: { clientSide: 'number', serverSide: 'number' },
-      weigh: { clientSide: 'number', serverSide: 'number' },
+      isTitle: { clientSide: 'boolean', serverSide: 'number' },
+      isMulitCheck: { clientSide: 'boolean', serverSide: 'number' },
     };
   }
 
@@ -32,11 +30,11 @@ export class CheckItemInfo extends DataModel<CheckItemInfo> {
   name = '' as string;
   level = 0 as number;
   points = 0 as number;
-  isTitle = 0 as number;
-  spacer = '' as string;
-  haschild = 0 as number;
+  isTitle = false;
+  isMulitCheck = false;
+  /** 1=单选,2=多选,3=次数 */
   checkType = 0 as number;
-  weigh = 0 as number;
+  children: CheckItemInfo[] = [];
 }
 
 /** 自查评估表列表行 */
@@ -160,6 +158,8 @@ export class InheritorCheckBasicInfo extends DataModel<InheritorCheckBasicInfo>
   ichName = '' as string;
   unit = '' as string;
   level = 0 as number;
+  idCard = '' as string;
+  address = '' as string;
   batch = 0 as number;
   mobile = '' as string;
   genderText = '' as string;
@@ -175,21 +175,17 @@ export class SelfAssessmentCheckItemAnswer extends DataModel<SelfAssessmentCheck
     this.setNameMapperCase('Camel', 'Snake');
     this._convertTable = {
       id: { clientSide: 'number', serverSide: 'number' },
-      itemId: { clientSide: 'number', serverSide: 'number' },
-      formId: { clientSide: 'number', serverSide: 'number' },
       points: { clientSide: 'number', serverSide: 'number' },
       count: { clientSide: 'number', serverSide: 'number' },
-      isTitle: { clientSide: 'number', serverSide: 'number' },
     };
   }
 
+  /**
+   * 选项ID
+   */
   id = 0 as number;
-  itemId = 0 as number;
-  formId = 0 as number;
-  answerText = '' as string;
   points = 0 as number;
   count = 0 as number;
-  isTitle = 0 as number;
 }
 
 /** 自查评估表详情 */
@@ -214,6 +210,20 @@ export class SelfAssessmentDetail extends DataModel<SelfAssessmentDetail> {
       province: { clientSide: 'number', serverSide: 'number' },
       provincePoints: { clientSide: 'number', serverSide: 'number' },
       level: { clientSide: 'number', serverSide: 'string' },
+      checkItems: {
+        clientSide: 'array',
+        clientSideChildDataModel: SelfAssessmentCheckItemAnswer,
+        customToServerFn: (value) => {
+          return (value as SelfAssessmentCheckItemAnswer[])
+            .filter(item => item.count > 0)
+            .map((item) => {
+              return {
+                id: item.id,
+                count: item.count,
+              };
+            });
+        },
+      },
     };
     this._convertKeyType = (key) => {
       if (key.endsWith('Text') || key.endsWith('_text')) {
@@ -251,8 +261,7 @@ export class SelfAssessmentDetail extends DataModel<SelfAssessmentDetail> {
   updatetime = '' as string;
   deletetime = '' as string|null;
   selfText = '' as string;
-  /** 接口为按 key 分组的对象,已转为各值的 DataModel */
-  checkItems = {} as Record<string, SelfAssessmentCheckItemAnswer>;
+  checkItems :  SelfAssessmentCheckItemAnswer[] = [];
 }
 
 /** 传承协议详情 */
@@ -292,91 +301,6 @@ export class AgreementDetail extends DataModel<AgreementDetail> {
   deletetime = '' as string|null;
 }
 
-/** 提交自查评估表(ich/check/save) */
-export class SelfAssessmentSubmit extends DataModel<SelfAssessmentSubmit> {
-  constructor() {
-    super(SelfAssessmentSubmit, '自查评估表提交');
-    this.setNameMapperCase('Camel', 'Snake');
-    this._convertTable = {
-      id: { clientSide: 'number', serverSide: 'number' },
-      userId: { clientSide: 'number', serverSide: 'number' },
-      year: { clientSide: 'number', serverSide: 'number' },
-      level: { clientSide: 'number', serverSide: 'number' },
-      deductPoints: { clientSide: 'number', serverSide: 'number' },
-      points: { clientSide: 'number', serverSide: 'number' },
-      self: { clientSide: 'number', serverSide: 'number' },
-      checkItems: {
-        clientSide: 'array',
-        clientSideChildDataModel: SelfAssessmentSubmitCheckItem,
-        serverSide: 'array',
-      },
-    };
-  }
-
-  id = null as number|null;
-  userId = 0 as number;
-  year = 0 as number;
-  inheritor = '' as string;
-  unit = '' as string;
-  ichName = '' as string;
-  mobile = '' as string;
-  idCard = '' as string;
-  level = 0 as number;
-  address = '' as string;
-  content = '' as string;
-  deductContent = '' as string;
-  deductPoints = 0 as number;
-  points = 0 as number;
-  self = 0 as number;
-  checkItems = [] as SelfAssessmentSubmitCheckItem[];
-}
-
-export class SelfAssessmentSubmitCheckItem extends DataModel<SelfAssessmentSubmitCheckItem> {
-  constructor() {
-    super(SelfAssessmentSubmitCheckItem, '自查计分选项提交');
-    this.setNameMapperCase('Camel', 'Snake');
-    this._convertTable = {
-      id: { clientSide: 'number', serverSide: 'number' },
-      count: { clientSide: 'number', serverSide: 'number' },
-    };
-  }
-
-  id = 0 as number;
-  count = 1 as number;
-}
-
-/** 提交传承协议(文档路径名为 saveAgreememt) */
-export class AgreementSubmit extends DataModel<AgreementSubmit> {
-  constructor() {
-    super(AgreementSubmit, '传承协议提交');
-    this.setNameMapperCase('Camel', 'Snake');
-    this._convertTable = {
-      id: { clientSide: 'number', serverSide: 'number' },
-      year: { clientSide: 'number', serverSide: 'number' },
-      level: { clientSide: 'number', serverSide: 'number' },
-      apprentice: { clientSide: 'number', serverSide: 'number' },
-      activity: { clientSide: 'number', serverSide: 'number' },
-      course: { clientSide: 'number', serverSide: 'number' },
-    };
-  }
-
-  id = null as number|null;
-  year = 0 as number;
-  level = 0 as number;
-  partyA = '' as string;
-  partyB = '' as string;
-  apprentice = 0 as number;
-  activity = 0 as number;
-  course = 0 as number;
-  mobile = '' as string;
-  partyAMobile = '' as string;
-  idCard = '' as string;
-  health = '' as string;
-  ich = '' as string;
-  partyASign = '' as string;
-  partyBSign = '' as string;
-}
-
 export type IchCheckPaginated<T> = {
   total: number;
   perPage: number;
@@ -409,7 +333,11 @@ export class AssessmentContentApi extends AppServerRequestModule<DataModel> {
   async getCheckItems(level: number) {
     const res = await this.post('/ich/check/getCheckItems', '自查计分项目', { level });
     const list = transformSomeToArray(res.data) as KeyValue[];
-    return transformArrayDataModel(CheckItemInfo, list, 'data');
+    const items = transformArrayDataModel<CheckItemInfo>(CheckItemInfo, list, 'data');
+    const top = items.filter((item) => item.pid === 0);
+    for (const item of items) 
+      item.children = items.filter((i) => i.pid === item.id);
+    return top;
   }
 
   /**
@@ -424,7 +352,7 @@ export class AssessmentContentApi extends AppServerRequestModule<DataModel> {
     page?: number;
     pageSize?: number;
   }) {
-    const res = await this.post('/ich/check/getList', '评估表列表', {
+    const res = await this.post<KeyValue>('/ich/check/getList', '评估表列表', {
       user_id: data.userId,
       level: data.level,
       year: data.year,
@@ -432,7 +360,7 @@ export class AssessmentContentApi extends AppServerRequestModule<DataModel> {
       page: data.page,
       pageSize: data.pageSize,
     });
-    return normalizePaginated(SelfAssessmentListRow, (res.data ?? {}) as KeyValue);
+    return normalizePaginated<SelfAssessmentListRow>(SelfAssessmentListRow, res.requireData());
   }
 
   /**
@@ -446,7 +374,7 @@ export class AssessmentContentApi extends AppServerRequestModule<DataModel> {
     page?: number;
     pageSize?: number;
   }) {
-    const res = await this.post('/ich/check/getAgreementList', '传承协议列表', {
+    const res = await this.post<KeyValue>('/ich/check/getAgreementList', '传承协议列表', {
       user_id: data.userId,
       level: data.level,
       year: data.year,
@@ -454,20 +382,20 @@ export class AssessmentContentApi extends AppServerRequestModule<DataModel> {
       page: data.page,
       pageSize: data.pageSize,
     });
-    return normalizePaginated(AgreementListRow, (res.data ?? {}) as KeyValue);
+    return normalizePaginated<AgreementListRow>(AgreementListRow, res.requireData());
   }
 
   /**
    * 保存传承协议(ShowDoc 文档 URL 为 saveAgreememt)
    */
-  async saveAgreement(dataModel: AgreementSubmit) {
+  async saveAgreement(dataModel: AgreementDetail) {
     return this.post('/ich/check/saveAgreememt', '传承协议保存', dataModel.toServerSide());
   }
 
   /**
    * 保存自查评估表
    */
-  async saveSelfAssessment(dataModel: SelfAssessmentSubmit) {
+  async saveSelfAssessment(dataModel: SelfAssessmentDetail) {
     return this.post('/ich/check/save', '自查评估表保存', dataModel.toServerSide());
   }
 
@@ -475,37 +403,26 @@ export class AssessmentContentApi extends AppServerRequestModule<DataModel> {
    * 传承人基础信息(默认当前用户;管理员可传 userId)
    */
   async getInheritorBasic(userId?: number) {
-    const res = await this.post('/ich/check/basic', '传承人自查基础信息', {
+    const res = await this.post<KeyValue>('/ich/check/basic', '传承人自查基础信息', {
       user_id: userId,
     });
-    return transformDataModel(InheritorCheckBasicInfo, (res.data ?? {}) as KeyValue);
+    return transformDataModel<InheritorCheckBasicInfo>(InheritorCheckBasicInfo, res.requireData());
   }
 
   /**
    * 自查评估表详情
    */
   async getSelfAssessmentDetail(id: number) {
-    const res = await this.post('/ich/check/detail', '评估表详情', { id });
-    const body = (res.data ?? {}) as KeyValue;
-    const checkItemsRaw = body.check_items as Record<string, KeyValue> | undefined;
-    const { check_items: _ci, ...rest } = body;
-    const m = transformDataModel(SelfAssessmentDetail, rest as KeyValue);
-    const out: Record<string, SelfAssessmentCheckItemAnswer> = {};
-    if (checkItemsRaw && typeof checkItemsRaw === 'object') {
-      for (const k of Object.keys(checkItemsRaw)) {
-        out[k] = transformDataModel(SelfAssessmentCheckItemAnswer, checkItemsRaw[k]!);
-      }
-    }
-    m.checkItems = out;
-    return m;
+    const res = await this.post<KeyValue>('/ich/check/detail', '评估表详情', { id });
+    return transformDataModel<SelfAssessmentDetail>(SelfAssessmentDetail, res.requireData());
   }
 
   /**
    * 传承协议详情
    */
   async getAgreementDetail(id: number) {
-    const res = await this.post('/ich/check/agreementDetail', '传承协议详情', { id });
-    return transformDataModel<AgreementDetail>(AgreementDetail, (res.data ?? {}) as KeyValue);
+    const res = await this.post<KeyValue>('/ich/check/agreementDetail', '传承协议详情', { id });
+    return transformDataModel<AgreementDetail>(AgreementDetail, res.requireData());
   }
 }
 

+ 11 - 0
src/components/dynamic/DynamicFormControl.vue

@@ -222,6 +222,16 @@
           />
         </view>
       </template>
+      <template v-else-if="item.type === 'sign'">
+        <view>
+          <SignatureField
+            ref="itemRef"
+            :modelValue="model"
+            v-bind="params"
+            @update:modelValue="(e: any) => onValueChanged(e)"
+          />
+        </view>
+      </template>
       <template v-else-if="item.type === 'button'">
         <Button
           ref="itemRef"
@@ -288,6 +298,7 @@ import Alert from '../feedback/Alert.vue';
 import Image from '../basic/Image.vue';
 import CheckBoxTreeList, { type CheckBoxTreeListProps } from './wrappers/CheckBoxTreeList.vue';
 import { useInjectFormContext, useInjectFormItemContext } from '../form/FormContext';
+import SignatureField from '../form/SignatureField.vue';
 
 export interface FormCeilProps {
   model: unknown,

+ 4 - 2
src/components/typography/B.vue

@@ -1,9 +1,11 @@
 <template>
-  <Text bold v-bind="$props">
+  <Text v-bind="props" bold>
     <slot />
   </Text>
 </template>
 
 <script setup lang="ts">
-import Text from '../basic/Text.vue';
+import Text, { type TextProps } from '../basic/Text.vue';
+
+const props = defineProps<Partial<TextProps>>()
 </script>

+ 4 - 2
src/components/typography/H1.vue

@@ -1,9 +1,11 @@
 <template>
-  <Text fontConfig="h1" v-bind="$props">
+  <Text v-bind="props" fontConfig="h1">
     <slot />
   </Text>
 </template>
 
 <script setup lang="ts">
-import Text from '../basic/Text.vue';
+import Text, { type TextProps } from '../basic/Text.vue';
+
+const props = defineProps<Partial<TextProps>>()
 </script>

+ 4 - 2
src/components/typography/H2.vue

@@ -1,9 +1,11 @@
 <template>
-  <Text fontConfig="h2" v-bind="$props">
+  <Text v-bind="props" fontConfig="h2">
     <slot />
   </Text>
 </template>
 
 <script setup lang="ts">
-import Text from '../basic/Text.vue';
+import Text, { type TextProps } from '../basic/Text.vue';
+
+const props = defineProps<Partial<TextProps>>()
 </script>

+ 4 - 2
src/components/typography/H3.vue

@@ -1,9 +1,11 @@
 <template>
-  <Text fontConfig="h3" v-bind="$props">
+  <Text v-bind="props" fontConfig="h3">
     <slot />
   </Text>
 </template>
 
 <script setup lang="ts">
-import Text from '../basic/Text.vue';
+import Text, { type TextProps } from '../basic/Text.vue';
+
+const props = defineProps<Partial<TextProps>>()
 </script>

+ 4 - 2
src/components/typography/H4.vue

@@ -1,9 +1,11 @@
 <template>
-  <Text fontConfig="h4" v-bind="$props">
+  <Text v-bind="props" fontConfig="h4">
     <slot />
   </Text>
 </template>
 
 <script setup lang="ts">
-import Text from '../basic/Text.vue';
+import Text, { type TextProps } from '../basic/Text.vue';
+
+const props = defineProps<Partial<TextProps>>()
 </script>

+ 4 - 2
src/components/typography/H5.vue

@@ -1,9 +1,11 @@
 <template>
-  <Text fontConfig="h5" v-bind="$props">
+  <Text v-bind="props" fontConfig="h5">
     <slot />
   </Text>
 </template>
 
 <script setup lang="ts">
-import Text from '../basic/Text.vue';
+import Text, { type TextProps } from '../basic/Text.vue';
+
+const props = defineProps<Partial<TextProps>>()
 </script>

+ 4 - 2
src/components/typography/H6.vue

@@ -1,9 +1,11 @@
 <template>
-  <Text fontConfig="h6" v-bind="$props">
+  <Text v-bind="props" fontConfig="h6">
     <slot />
   </Text>
 </template>
 
 <script setup lang="ts">
-import Text from '../basic/Text.vue';
+import Text, { type TextProps } from '../basic/Text.vue';
+
+const props = defineProps<Partial<TextProps>>()
 </script>

+ 4 - 2
src/components/typography/I.vue

@@ -1,9 +1,11 @@
 <template>
-  <Text italic v-bind="$props">
+  <Text v-bind="props" italic>
     <slot />
   </Text>
 </template>
 
 <script setup lang="ts">
-import Text from '../basic/Text.vue';
+import Text, { type TextProps } from '../basic/Text.vue';
+
+const props = defineProps<Partial<TextProps>>()
 </script>

+ 4 - 2
src/components/typography/P.vue

@@ -1,9 +1,11 @@
 <template>
-  <Text fontConfig="p" v-bind="$props">
+  <Text v-bind="props" fontConfig="p">
     <slot />
   </Text>
 </template>
 
 <script setup lang="ts">
-import Text from '../basic/Text.vue';
+import Text, { type TextProps } from '../basic/Text.vue';
+
+const props = defineProps<Partial<TextProps>>()
 </script>

+ 4 - 2
src/components/typography/S.vue

@@ -1,9 +1,11 @@
 <template>
-  <Text lineThrough v-bind="$props">
+  <Text v-bind="props" lineThrough>
     <slot />
   </Text>
 </template>
 
 <script setup lang="ts">
-import Text from '../basic/Text.vue';
+import Text, { type TextProps } from '../basic/Text.vue';
+
+const props = defineProps<Partial<TextProps>>()
 </script>

+ 4 - 2
src/components/typography/U.vue

@@ -1,9 +1,11 @@
 <template>
-  <Text underline v-bind="$props">
+  <Text v-bind="props" underline>
     <slot />
   </Text>
 </template>
 
 <script setup lang="ts">
-import Text from '../basic/Text.vue';
+import Text, { type TextProps } from '../basic/Text.vue';
+
+const props = defineProps<Partial<TextProps>>()
 </script>

+ 21 - 0
src/pages.json

@@ -242,6 +242,13 @@
           }
         },
         {
+          "path": "admin",
+          "style": {
+            "navigationBarTitleText": "非遗数字化资源信息校对管理员",
+            "enablePullDownRefresh": false
+          }
+        },
+        {
           "path": "user/change-password",
           "style": {
             "navigationBarTitleText": "修改密码",
@@ -298,11 +305,25 @@
           }
         },
         {
+          "path": "assessment/argeement-sign-list",
+          "style": {
+            "navigationBarTitleText": "传承协议签名列表",
+            "enablePullDownRefresh": false
+          }
+        },
+        {
           "path": "assessment/evaluation-form",
           "style": {
             "navigationBarTitleText": "自查评估表",
             "enablePullDownRefresh": false
           }
+        },
+        {
+          "path": "assessment/evaluation-list",
+          "style": {
+            "navigationBarTitleText": "自查评估表列表",
+            "enablePullDownRefresh": false
+          }
         }
       ]
     }

+ 75 - 0
src/pages/collect/admin.vue

@@ -0,0 +1,75 @@
+<template>
+  <FlexCol :innerStyle="{
+    backgroundImage: 'url(https://xy.wenlvti.net/app_static/images/mine/TopBanner.png)',
+    backgroundSize: '100% auto',
+    backgroundRepeat: 'no-repeat',
+    backgroundPosition: 'top center',
+    minHeight: '100vh',
+  }">
+    <StatusBarSpace />
+    <NavBar leftButton="back" />
+    <FlexCol gap="gap.xl" padding="space.lg">
+      <FlexCol center>
+        <Text fontConfig="h2">非遗数字化资源信息校对管理员</Text>
+        <FlexRow>
+          <Text fontConfig="subText">技术支持:18649931391</Text>
+        </FlexRow>
+      </FlexCol>
+      <FlexRow justify="space-between">
+        <FlexRow align="center" gap="gap.md">
+          <Avatar 
+            randomColor
+            :src="authStore.userInfo?.avatar" 
+            defaultAvatar="https://mncdn.wenlvti.net/app_static/minnan/logo.png"
+          />
+          <Text fontConfig="h4">{{ authStore.userInfo?.nickname }}</Text>
+        </FlexRow>
+        <FlexRow>
+          <WxButton openType="contact">
+            <Button type="text" icon="wechat">在线客服</Button>
+          </WxButton>
+          <Button type="text" icon="lock" @click="navTo('user/change-password')">修改密码</Button>
+        </FlexRow>
+      </FlexRow>
+      <CellGroup round>
+      <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/9fb29e8bdb66490034145c90f892773a.png" title="传承协议签名表" showArrow touchable @click="navTo('assessment/argeement-sign-list')" />
+      <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/66d4665b1da5075e60148312469b2630.png" title="自查评估表" showArrow touchable @click="navTo('assessment/evaluation-list')" />
+      </CellGroup>
+      <CellGroup round>
+        <Cell icon="https://mncdn.wenlvti.net/app_static/minnan/logo.png" title="返回闽南文化" showArrow touchable @click="back()" />
+        <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/cbc47d0b9cad7891e6154359952858c6.png" title="退出登录" showArrow touchable @click="onLogout" />
+      </CellGroup>
+    </FlexCol>
+    <XBarSpace />
+  </FlexCol>
+</template>
+
+<script setup lang="ts">
+import { useAuthStore } from '@/store/auth';
+import CellGroup from '@/components/basic/CellGroup.vue';
+import Cell from '@/components/basic/Cell.vue';
+import Text from '@/components/basic/Text.vue';
+import FlexCol from '@/components/layout/FlexCol.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+import WxButton from '@/components/basic/WxButton.vue';
+import XBarSpace from '@/components/layout/space/XBarSpace.vue';
+import StatusBarSpace from '@/components/layout/space/StatusBarSpace.vue';
+import NavBar from '@/components/nav/NavBar.vue';
+import Avatar from '@/components/display/Avatar.vue';
+import Button from '@/components/basic/Button.vue';
+import { back, navTo } from '@/components/utils/PageAction';
+import { confirm } from '@/components/utils/DialogAction';
+
+const authStore = useAuthStore();
+
+function onLogout() {
+  confirm({
+    content: '您确定要退出登录吗?',
+  }).then((res) => {
+    if (res) {
+      authStore.logout();
+      back();
+    }
+  });
+}
+</script>

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

@@ -0,0 +1,50 @@
+<template>
+  <FlexCol padding="space.lg">
+    <SearchBar v-model="search" @search="loader.reload()" />
+    <SimplePageContentLoader :loader="loader">
+      <template v-if="loader.isFinished.value">
+        <FlexRow 
+          v-for="item in loader.list.value" :key="item.id"
+          direction="row"
+          justify="space-between"
+          @click="navTo('assessment/argeement-sign', { id: item.id })"
+        >
+          <FlexCol>
+            <Text :text="item.inheritor ?? '?'" />
+            <Text :text="item.mobile || item.unit || '?'" />
+          </FlexCol>
+          <Icon name="arrow-right-bold" />
+        </FlexRow>
+      </template>
+    </SimplePageContentLoader>
+    <XBarSpace />
+  </FlexCol>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { useSimplePageListLoader } from '@/components/composeabe/loader/SimplePageListLoader';
+import AssessmentContentApi from '@/api/collect/AssessmentContent';
+import FlexCol from '@/components/layout/FlexCol.vue';
+import SimplePageContentLoader from '@/components/loader/SimplePageContentLoader.vue';
+import XBarSpace from '@/components/layout/space/XBarSpace.vue';
+import Text from '@/components/basic/Text.vue';
+import Icon from '@/components/basic/Icon.vue';
+import SearchBar from '@/components/form/SearchBar.vue';
+import Touchable from '@/components/feedback/Touchable.vue';
+import { navTo } from '@/components/utils/PageAction';
+import FlexRow from '@/components/layout/FlexRow.vue';
+
+const search = ref('');
+const loader = useSimplePageListLoader(10, async (page, pageSize) => {
+  const list = await AssessmentContentApi.getSelfAssessmentList({
+    year: new Date().getFullYear(),
+    page,
+    pageSize,
+  });
+  return {
+    list: list.data,
+    total: list.total,
+  };
+});
+</script>

+ 297 - 153
src/pages/collect/assessment/argeement-sign.vue

@@ -1,184 +1,203 @@
 <template>
-  <FlexCol padding="space.lg">
-    <SimplePageContentLoader :loader="loader">
-      <template v-if="loader.isFinished.value">
-        <Result
-          v-if="!currentAgreement"
-          status="info"
-          title="您还未签署传承协议"
-          description="请先签署传承协议"
-        >
-          <Height :height="30" />
-          <Button type="primary" @click="createAgreement">签署传承协议</Button>
-        </Result>
-        <FlexCol v-else :gap="'lg'">
-          <Alert type="info" message="请仔细阅读传承协议,确保您已理解内容,并签署传承协议。" />
-
-          <FlexCol
-            :gap="'md'"
-            :innerStyle="articleWrapStyle"
+  <CommonRoot>
+    <FlexCol padding="space.lg">
+      <SimplePageContentLoader :loader="loader">
+        <template v-if="loader.isFinished.value">
+          <Result
+            v-if="!currentAgreement"
+            status="info"
+            title="您还未签署传承协议"
+            description="请先签署传承协议"
           >
-            <H3>{{ agreementTitle }}</H3>
+            <Height :height="30" />
+            <Button type="primary" @click="createAgreement">去签署传承协议</Button>
+          </Result>
+          <FlexCol v-else gap="gap.md">
+            <Alert type="info" message="请仔细阅读传承协议,确保您已理解内容,并签署传承协议。" />
 
-            <FlexCol :gap="'sm'">
-              <P>甲方:福建省文化和旅游厅</P>
-              <FlexRow align="center" wrap :gap="'sm'">
-                <Text font-config="p" color="text.content">乙方:</Text>
-                <AgreementPrefillInline
-                  v-model="currentAgreement.partyA"
-                  placeholder="请填写乙方(传承人)姓名"
-                />
-              </FlexRow>
-            </FlexCol>
+            <FlexCol
+              :gap="'md'"
+              :innerStyle="articleWrapStyle"
+            >
+              <Form ref="formRef" :model="currentAgreement" :rules="formRules">
+                <H3>{{ agreementTitle }}</H3>
 
-            <Height :height="8" />
+                <FlexCol :gap="'sm'">
+                  <P>甲方:福建省文化和旅游厅</P>
+                  <FlexRow align="center" wrap :gap="'sm'">
+                    <Text font-config="p" color="text.content">乙方:</Text>
+                    <AgreementPrefillInline
+                      v-model="currentAgreement.partyB"
+                      name="partyB"
+                      placeholder="请填写乙方(传承人)姓名"
+                    />
+                  </FlexRow>
+                </FlexCol>
 
-            <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
-              为传承弘扬中华优秀传统文化,有效保护和传承非物质文化遗产,鼓励和支持国家级非物质文化遗产代表性传承人开展传承活动,根据《中华人民共和国非物质文化遗产法》《国家级非物质文化遗产代表性传承人认定与管理办法》等有关法律法规,制定协议,并按照下列各项条款签署,甲、乙双方共同遵守。
-            </Text>
+                <Height :height="8" />
 
-            <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
-              一、甲乙双方应当以习近平新时代中国特色社会主义思想为指导,坚持以人民为中心,弘扬社会主义核心价值观,共同保护传承非物质文化遗产,推动中华优秀传统文化创造性转化、创新性发展。
-            </Text>
+                <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
+                  为传承弘扬中华优秀传统文化,有效保护和传承非物质文化遗产,鼓励和支持国家级非物质文化遗产代表性传承人开展传承活动,根据《中华人民共和国非物质文化遗产法》《国家级非物质文化遗产代表性传承人认定与管理办法》等有关法律法规,制定协议,并按照下列各项条款签署,甲、乙双方共同遵守
+                </Text>
 
-            <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
-              二、甲方按照《国家级非物质文化遗产代表性传承人认定与管理办法》的要求,支持国家级非物质文化遗产代表性传承人开展传承、传播活动
-            </Text>
+                <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
+                  一、甲乙双方应当以习近平新时代中国特色社会主义思想为指导,坚持以人民为中心,弘扬社会主义核心价值观,共同保护传承非物质文化遗产,推动中华优秀传统文化创造性转化、创新性发展
+                </Text>
 
-            <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
-              三、甲方按照《国家级非物质文化遗产保护专项资金管理办法》的要求,落实国家给予的代表性传承人的传承补助
-            </Text>
+                <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
+                  二、甲方按照《国家级非物质文化遗产代表性传承人认定与管理办法》的要求,支持国家级非物质文化遗产代表性传承人开展传承、传播活动
+                </Text>
 
-            <FlexRow align="center" wrap :gap="'sm'" :inner-style="paragraphStyle">
-              <Text font-config="p" color="text.content">四、乙方应积极开展传承活动,培养后继人才,制定传承计划,{{ agreementYear }} 年度带徒</Text>
-              <AgreementPrefillInline
-                v-model="currentAgreement.apprentice"
-                number-mode
-                placeholder="人数"
-                suffix="人。"
-              />
-            </FlexRow>
+                <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
+                  三、甲方按照《国家级非物质文化遗产保护专项资金管理办法》的要求,落实国家给予的代表性传承人的传承补助。
+                </Text>
 
-            <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
-              五、乙方应妥善保存相关实物、资料情况。主动保存、提供与该项非遗项目有关的原始资料、实物,配合记录工作。
-            </Text>
+                <FlexRow align="center" wrap :gap="'sm'" :inner-style="paragraphStyle">
+                  <Text font-config="p" color="text.content">四、乙方应积极开展传承活动,培养后继人才,制定传承计划,{{ agreementYear }} 年度带徒</Text>
+                  <AgreementPrefillInline
+                    v-model="currentAgreement.apprentice"
+                    name="apprentice"
+                    number-mode
+                    placeholder="人数"
+                    suffix="人。"
+                  />
+                </FlexRow>
 
-            <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
-              六、乙方应主动、及时配合非遗调查,主动向文化和旅游主管部门、非遗保护中心反映非遗项目保护、传承情况和总结材料,并完成文化和旅游主管部门临时交办的非遗工作任务,提出保护的意见、建议。
-            </Text>
+                <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
+                  五、乙方应妥善保存相关实物、资料情况。主动保存、提供与该项非遗项目有关的原始资料、实物,配合记录工作
+                </Text>
 
-            <FlexRow align="center" wrap :gap="'sm'" :inner-style="paragraphStyle">
-              <Text font-config="p" color="text.content">七、乙方应积极、主动参加各级政府组织的非物质文化遗产公益性宣传活动,{{ agreementYear }} 年度完成</Text>
-              <AgreementPrefillInline
-                v-model="currentAgreement.activity"
-                number-mode
-                placeholder="场次"
-                suffix="场。"
-              />
-            </FlexRow>
+                <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
+                  六、乙方应主动、及时配合非遗调查,主动向文化和旅游主管部门、非遗保护中心反映非遗项目保护、传承情况和总结材料,并完成文化和旅游主管部门临时交办的非遗工作任务,提出保护的意见、建议。
+                </Text>
 
-            <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
-              八、乙方应合理使用国家级非物质文化遗产代表性传承人补助经费,用于开展非遗项目的传习活动,做好传承补助经费使用记录、支出范围和绩效评价等,不得用于生活补助。
-            </Text>
+                <FlexRow align="center" wrap :gap="'sm'" :inner-style="paragraphStyle">
+                  <Text font-config="p" color="text.content">七、乙方应积极、主动参加各级政府组织的非物质文化遗产公益性宣传活动,{{ agreementYear }} 年度完成</Text>
+                  <AgreementPrefillInline
+                    v-model="currentAgreement.activity"
+                    name="activity"
+                    number-mode
+                    placeholder="场次"
+                    suffix="场。"
+                  />
+                </FlexRow>
 
-            <FlexRow align="center" wrap :gap="'sm'" :inner-style="paragraphStyle">
-              <Text font-config="p" color="text.content">九、乙方应积极、主动参加文化和旅游部组织的非物质文化遗产代表性传承人研修班,{{ agreementYear }} 年度完成</Text>
-              <AgreementPrefillInline
-                v-model="currentAgreement.course"
-                number-mode
-                placeholder="场次"
-                suffix="场。"
-              />
-            </FlexRow>
+                <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
+                  八、乙方应合理使用国家级非物质文化遗产代表性传承人补助经费,用于开展非遗项目的传习活动,做好传承补助经费使用记录、支出范围和绩效评价等,不得用于生活补助。
+                </Text>
 
-            <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
-              十、乙方应积极参与非物质文化遗产相关理论和实践研究、发表(出版)论文、专著等研究。
-            </Text>
+                <FlexRow align="center" wrap :gap="'sm'" :inner-style="paragraphStyle">
+                  <Text font-config="p" color="text.content">九、乙方应积极、主动参加文化和旅游部组织的非物质文化遗产代表性传承人研修班,{{ agreementYear }} 年度完成</Text>
+                  <AgreementPrefillInline
+                    v-model="currentAgreement.course"
+                    name="course"
+                    number-mode
+                    placeholder="场次"
+                    suffix="场。"
+                  />
+                </FlexRow>
 
-            <Height :height="16" />
+                <Text font-config="p" color="text.content" :inner-style="paragraphStyle">
+                  十、乙方应积极参与非物质文化遗产相关理论和实践研究、发表(出版)论文、专著等研究。
+                </Text>
 
-            <FlexCol :gap="'md'" :inner-style="signBlockStyle">
-              <FlexRow padding="space.sm">
-                <Text font-config="p" color="text.content" bold>甲方:福建省文化和旅游厅</Text>
-              </FlexRow>
-              <Field
-                label="负责人(代表人)"
-                label-position="top"
-                v-model="currentAgreement.partyASign"
-                placeholder="选填"
-                :show-bottom-border="true"
-              />
-              <Field
-                label="甲方电话"
-                label-position="top"
-                type="tel"
-                v-model="currentAgreement.partyAMobile"
-                placeholder="请填写甲方联系电话"
-              />
-              <AgreementDateWriteBlock
-                v-model="partyAStampDate"
-                hint="(以实际盖章日期为准)"
-              />
+                <Height :height="16" />
 
-              <Height :height="24" />
+                <FlexCol :gap="'md'" :inner-style="signBlockStyle">
+                  <FlexRow padding="space.sm">
+                    <Text font-config="p" color="text.content" bold>甲方:福建省文化和旅游厅</Text>
+                  </FlexRow>
+                  <Field
+                    label="负责人(代表人)"
+                    label-position="top"
+                    v-model="currentAgreement.partyASign"
+                    placeholder="选填"
+                    :show-bottom-border="true"
+                  />
+                  <Field
+                    label="甲方电话"
+                    label-position="top"
+                    name="partyAMobile"
+                    type="tel"
+                    v-model="currentAgreement.partyAMobile"
+                    placeholder="请填写甲方联系电话"
+                  />
+                  <AgreementDateWriteBlock
+                    v-model="partyAStampDate"
+                    hint="(以实际盖章日期为准)"
+                  />
 
-              <FlexRow padding="space.sm">
-                <Text font-config="p" color="text.content" bold>乙方:{{ currentAgreement.partyB }}(签名)</Text>
-              </FlexRow>
-              <Field
-                label="乙方签名 / 说明"
-                label-position="top"
-                showRightArrow
-              >
-                <Text>点击签名</Text>
-                <template #extra>
-                  <SignatureField
-                    v-model="currentAgreement.partyBSign"
-                    placeholder=""
+                  <Height :height="24" />
+
+                  <FlexRow padding="space.sm">
+                    <Text font-config="p" color="text.content" bold>乙方:{{ currentAgreement.partyB }}(签名)</Text>
+                  </FlexRow>
+                  <Field
+                    label="乙方签名 / 说明"
+                    label-position="top"
+                    name="partyBSign"
+                    showRightArrow
+                  >
+                    <Text>点击签名</Text>
+                    <template #extra>
+                      <SignatureField
+                        v-model="currentAgreement.partyBSign"
+                        placeholder=""
+                      />
+                    </template>
+                  </Field>
+                  <Field
+                    label="身份证号"
+                    label-position="top"
+                    name="idCard"
+                    v-model="currentAgreement.idCard"
+                    placeholder="请填写身份证号"
+                  />
+                  <Field
+                    label="项目名称"
+                    label-position="top"
+                    name="ich"
+                    v-model="currentAgreement.ich"
+                    placeholder="非遗项目名称"
+                  />
+                  <Field
+                    label="身体状况"
+                    label-position="top"
+                    name="health"
+                    v-model="currentAgreement.health"
+                    placeholder="请简要填写"
+                  />
+                  <Field
+                    label="乙方电话"
+                    label-position="top"
+                    name="mobile"
+                    type="tel"
+                    v-model="currentAgreement.mobile"
+                    placeholder="请填写联系电话"
                   />
-                </template>
-              </Field>
-              <Field
-                label="身份证号"
-                label-position="top"
-                v-model="currentAgreement.idCard"
-                placeholder="请填写身份证号"
-              />
-              <Field
-                label="项目名称"
-                label-position="top"
-                v-model="currentAgreement.ich"
-                placeholder="非遗项目名称"
-              />
-              <Field
-                label="身体状况"
-                label-position="top"
-                v-model="currentAgreement.health"
-                placeholder="请简要填写"
-              />
-              <Field
-                label="乙方电话"
-                label-position="top"
-                type="tel"
-                v-model="currentAgreement.mobile"
-                placeholder="请填写联系电话"
-              />
-              <AgreementDateWriteBlock
-                v-model="partyBSignDate"
-                hint="(以实际签署日期为准)"
-              />
+                  <AgreementDateWriteBlock
+                    v-model="partyBSignDate"
+                    hint="(以实际签署日期为准)"
+                  />
+                </FlexCol>
+              </Form>
             </FlexCol>
+
+            <Button type="primary" block :loading="submitLoading" @click="saveAgreement">保存传承协议</Button>
+            <Button :loading="submitLoading" @click="downloadAgreement">下载协议PDF</Button>
           </FlexCol>
-        </FlexCol>
-      </template>
-    </SimplePageContentLoader>
-  </FlexCol>
+        </template>
+      </SimplePageContentLoader>
+    </FlexCol>
+  </CommonRoot>
 </template>
 
 <script setup lang="ts">
 import { computed, ref } from 'vue';
 import { useAuthStore } from '@/store/auth';
 import { useSimpleDataLoader } from '@/components/composeabe/loader/SimpleDataLoader';
+import { assertNotNull, formatError } from '@imengyu/imengyu-utils';
+import { toast, alert } from '@/components/dialog/CommonRoot';
 import AssessmentContentApi, { AgreementDetail } from '@/api/collect/AssessmentContent';
 import FlexCol from '@/components/layout/FlexCol.vue';
 import Result from '@/components/feedback/Result.vue';
@@ -194,6 +213,16 @@ import Text from '@/components/basic/Text.vue';
 import AgreementPrefillInline from './components/AgreementPrefillInline.vue';
 import AgreementDateWriteBlock, { type AgreementYmdParts } from './components/AgreementDateWriteBlock.vue';
 import SignatureField from '@/components/form/SignatureField.vue';
+import Form, { type FormInstance } from '@/components/form/Form.vue';
+import type { Rules } from 'async-validator';
+import { useLoadQuerys } from '@/common/composeabe/LoadQuerys';
+import CommonRoot from '@/components/dialog/CommonRoot.vue';
+
+const { querys } = useLoadQuerys({
+  id: 0,
+}, () => {
+  loader.load();
+});
 
 const authStore = useAuthStore();
 const currentAgreement = ref<AgreementDetail | null>(null);
@@ -202,6 +231,79 @@ 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: 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 的整数' },
+  ],
+  course: [
+    { required: true, message: '请填写本年度研修班场次' },
+    { type: 'integer', min: 0, message: '须为不小于 0 的整数' },
+  ],
+  partyAMobile: [
+    {
+      validator(_rule, value, callback) {
+        const s = value != null && value !== undefined ? String(value).trim() : '';
+        if (!s) {
+          callback();
+          return;
+        }
+        if (!CN_MOBILE_RE.test(s))
+          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: '请填写身份证号' },
+    {
+      validator(_rule, value, callback) {
+        const s = value != null ? String(value).trim() : '';
+        if (!CN_ID_RE.test(s))
+          callback(new Error('请输入正确的身份证号'));
+        else
+          callback();
+      },
+    },
+  ],
+  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();
+      },
+    },
+  ],
+};
+
 const articleWrapStyle = {
   padding: '24rpx 28rpx',
   borderRadius: '16rpx',
@@ -231,6 +333,13 @@ const agreementTitle = computed(
   () => `${agreementYear.value} 年度${levelTitle.value}非物质文化遗产代表性传承人传承协议`,
 );
 const loader = useSimpleDataLoader(async () => {
+  if (querys.value.id > 0) {
+    const detail = await AssessmentContentApi.getAgreementDetail(querys.value.id);
+    currentAgreement.value = detail;
+    partyAStampDate.value = { year: '', month: '', day: '' };
+    partyBSignDate.value = { year: '', month: '', day: '' };
+    return currentAgreement.value;
+  }
   const list = await AssessmentContentApi.getAgreementList({
     userId: authStore.userInfo?.id,
     year: new Date().getFullYear(),
@@ -246,6 +355,8 @@ const loader = useSimpleDataLoader(async () => {
   return currentAgreement.value;
 });
 
+const submitLoading = ref(false);
+
 function createAgreement() {
   const now = new Date();
   const u = authStore.userInfo;
@@ -260,4 +371,37 @@ function createAgreement() {
   partyBSignDate.value = { year: now.getFullYear().toString(), month: (now.getMonth() + 1).toString(), day: now.getDate().toString() };
   currentAgreement.value = detail;
 }
+async function saveAgreement() {
+  const detail = currentAgreement.value;
+  try {
+    await formRef.value?.validate();
+  } catch (error) {
+    toast('请填写完整信息');
+    return;
+  }
+  submitLoading.value = true;
+  try {
+    assertNotNull(detail, 'currentAgreement is null');
+    await AssessmentContentApi.saveAgreement(detail as AgreementDetail);
+    toast('保存传承协议成功');
+  } catch (error) {
+    alert({
+      title: '保存传承协议失败',
+      content: formatError(error),
+    });
+  }
+  submitLoading.value = false;
+}
+async function downloadAgreement() {
+  const detail = currentAgreement.value;
+  try {
+    assertNotNull(detail, 'currentAgreement is null');
+    throw new Error('没这个接口');
+  } catch (error) {
+    alert({
+      title: '保存传承协议失败',
+      content: formatError(error),
+    });
+  }
+}
 </script>

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

@@ -14,6 +14,7 @@
       {{ label }}
     </Text>
     <Field
+      :name="name"
       :model-value="textValue"
       :type="numberMode ? 'number' : 'text'"
       :show-label="false"
@@ -50,6 +51,8 @@ const props = withDefaults(
     /** 输入框右侧固定文案,如「人」「场」 */
     suffix?: string;
     maxLength?: number;
+    /** 与 Form 的 `rules` 键一致,用于联表校验 */
+    name?: string;
     /** 主题间距键,默认 sm */
     gap?: string;
     fieldStyle?: ViewStyle;
@@ -65,6 +68,7 @@ const props = withDefaults(
     gap: 'sm',
     fieldStyle: () => ({}),
     inputStyle: () => ({}),
+    name: undefined,
   },
 );
 

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

@@ -1,6 +1,389 @@
 <template>
+  <CommonRoot>
+    <FlexCol padding="space.lg">
+      <SimplePageContentLoader :loader="loader">
+        <template v-if="loader.isFinished.value">
+          <Result
+            v-if="!currentForm"
+            status="info"
+            title="您还未填写评估表"
+          >
+            <Height :height="30" />
+            <Button type="primary" @click="createForm">去填写评估表</Button>
+          </Result>
+          <FlexCol v-else gap="gap.md">
+            <DynamicForm
+              ref="form1Ref"
+              :model="currentForm"
+              :options="formOptions"
+            />
+            <H3>自查项目选择</H3>
+            <FlexCol gap="gap.md">
+              <FlexCol v-for="(item, index) in checkItemList" :key="item.id" gap="gap.md">
+                <Text fontConfig="subTitleText" :text="`${index + 1}. ${item.name}`" />
+                <template v-if="item.checkType == 2">
+                  <FlexCol gap="gap.sm">
+                    <FlexRow v-for="child in item.children" :key="child.id" justify="space-between">
+                      <CheckBox
+                        :text="`${child.name} (${child.points}分)`"
+                        :modelValue="hasCheckedItem(child.id)" 
+                        @update:modelValue="setCheckedItem(item as CheckItemInfo, child as CheckItemInfo, $event)" 
+                      />
+                      <Stepper 
+                        v-if="hasCheckedItem(child.id)"
+                        :min="0"
+                        :max="20"
+                        :step="1"
+                        :modelValue="getCheckedItemCount(child.id) ?? 0" 
+                        @update:modelValue="setCheckedItem(item as CheckItemInfo, child as CheckItemInfo, $event)" 
+                      />
+                      <view v-else></view>
+                    </FlexRow>
+                  </FlexCol>
+                </template>
+                <template v-else>
+                  <FlexCol gap="gap.sm">
+                    <CheckBox 
+                      v-for="child in item.children" :key="child.id"
+                      :text="`${child.name} (${child.points}分)`"
+                      :modelValue="hasCheckedItem(child.id)" 
+                      @update:modelValue="setCheckedItem(item as CheckItemInfo, child as CheckItemInfo, $event)" 
+                    />
+                  </FlexCol>
+                </template>
+              </FlexCol>
+            </FlexCol>
+            <DynamicForm
+              ref="form3Ref"
+              :model="currentForm"
+              :options="formOptionsEnd"
+            />
+            <FlexRow align="center" justify="space-between">
+              <H3>自评总分</H3>
+              <Text fontSize="44rpx" fontFamily="HUNdin1451" :text="`${totalPoints}分`" />
+            </FlexRow>
+            <Button type="primary" block :loading="submitLoading" @click="saveForm">保存评估表</Button>
+            <Button :loading="submitLoading" @click="downloadForm">下载评估表PDF</Button>
+          </FlexCol>
+        </template>
+      </SimplePageContentLoader>
+      <XBarSpace />
+    </FlexCol>
+  </CommonRoot>
 </template>
 
 <script setup lang="ts">
+import { computed, ref } from 'vue';
+import { useSimpleDataLoader } from '@/components/composeabe/loader/SimpleDataLoader';
+import { useAuthStore } from '@/store/auth';
+import { useLoadQuerys } from '@/components/composeabe/LoadQuerys';
+import { ArrayUtils, assertNotNull, formatError } from '@imengyu/imengyu-utils';
+import { toast, alert } from '@/components/dialog/CommonRoot';
+import AssessmentContentApi, {
+  SelfAssessmentDetail,
+  CheckItemInfo,
+  SelfAssessmentCheckItemAnswer,
+} from '@/api/collect/AssessmentContent';
+import CommonRoot from '@/components/dialog/CommonRoot.vue';
+import Button from '@/components/basic/Button.vue';
+import Result from '@/components/feedback/Result.vue';
 import FlexCol from '@/components/layout/FlexCol.vue';
-</script>
+import Height from '@/components/layout/space/Height.vue';
+import SimplePageContentLoader from '@/components/loader/SimplePageContentLoader.vue';
+import DynamicForm from '@/components/dynamic/DynamicForm.vue';
+import H3 from '@/components/typography/H3.vue';
+import FlexRow from '@/components/layout/FlexRow.vue';
+import Text from '@/components/basic/Text.vue';
+import XBarSpace from '@/components/layout/space/XBarSpace.vue';
+import CheckBox from '@/components/form/CheckBox.vue';
+import Stepper from '@/components/form/Stepper.vue';
+import type { IDynamicFormOptions, IDynamicFormRef } from '@/components/dynamic';
+import type { RadioValueProps } from '@/components/dynamic/wrappers/RadioValue';
+import type { FieldProps } from '@/components/form/Field.vue';
+
+const { querys } = useLoadQuerys({
+  id: 0,
+}, () => {
+  loader.load();
+});
+
+const currentForm = ref<SelfAssessmentDetail | null>(null);
+const authStore = useAuthStore();
+
+const form1Ref = ref<IDynamicFormRef | null>(null);
+const form3Ref = ref<IDynamicFormRef | null>(null);
+
+const formOptions = ref<IDynamicFormOptions>({
+  formAdditionaProps: {
+    labelFlex: 4,
+    inputFlex: 8,
+  },
+  formItems: [
+    {
+      type: 'flat-group',
+      label: '传承人自查评估',
+      name: 'selfAssessmentGroup',
+      childrenColProps: { span: 24 },
+      children: [
+        {
+          label: '传承人名称',
+          name: 'inheritor',
+          type: 'text',
+          additionalProps: { placeholder: '请输入传承人名称' },
+        },
+        {
+          label: '项目保护单位',
+          name: 'unit',
+          type: 'text',
+          additionalProps: { placeholder: '请输入项目保护单位' },
+        },
+        {
+          label: '项目名称',
+          name: 'ichName',
+          type: 'text',
+          additionalProps: { placeholder: '请输入项目名称' },
+        },
+        {
+          label: '联系电话',
+          name: 'mobile',
+          type: 'text',
+          additionalProps: { placeholder: '请输入联系电话' },
+        },
+        {
+          label: '身份证号',
+          name: 'idCard',
+          type: 'text',
+          additionalProps: { placeholder: '请输入身份证号' },
+        },
+        {
+          label: '级别',
+          name: 'level',
+          type: 'select-id',
+          additionalProps: {
+            placeholder: '请选择级别',
+            loadData: async () => [
+              { text: '国家级', value: 23 },
+              { text: '省级', value: 24 },
+              { text: '市级', value: 25 },
+            ],
+          },
+        },
+        {
+          label: '家庭住址',
+          name: 'address',
+          type: 'text',
+          additionalProps: { placeholder: '请输入家庭住址' },
+        },
+        {
+          label: '自评报告',
+          name: 'content',
+          type: 'richtext',
+          additionalProps: { placeholder: '请填写自评报告' },
+        },
+      ],
+    },
+  ],
+  formRules: {
+    inheritor: [{ required: true, message: '请输入传承人名称' }],
+    unit: [{ required: true, message: '请输入项目保护单位' }],
+    ichName: [{ required: true, message: '请输入项目名称' }],
+    mobile: [{ required: true, message: '请输入联系电话' }],
+    idCard: [{ required: true, message: '请输入身份证号' }],
+    level: [{ required: true, message: '请选择级别' }],
+    address: [{ required: true, message: '请输入家庭住址' }],
+    content: [{ required: true, message: '请填写自评报告' }],
+    self: [{ required: true, message: '请选择自我评估' }],
+  },
+});
+const formOptionsEnd = ref<IDynamicFormOptions>({
+  formAdditionaProps: {
+    labelPosition: 'top',
+  },
+  formItems: [
+    {
+      type: 'flat-group',
+      label: '传承人自查评估',
+      name: 'selfAssessmentGroup',
+      childrenColProps: { span: 24 },
+      children: [
+        {
+          label: '其他相关情况(扣分内容)',
+          name: 'deductContent',
+          type: 'text',
+          additionalProps: {
+            showWordLimit: true,
+            maxlength: 260,
+            placeholder: '请输入其他相关情况(扣分内容)',
+          } as FieldProps,
+        },
+        {
+          label: '其他相关情况(扣分分值)',
+          name: 'deductPoints',
+          type: 'number',
+          additionalProps: {
+            placeholder: '请输入其他相关情况(扣分分值)',
+            min: 0,
+            max: 100,
+          },
+        },
+        {
+          label: '自我评估',
+          name: 'self',
+          type: 'radio-value',
+          additionalProps: {
+            options: [
+              { text: '优秀', value: 1 },
+              { text: '合格', value: 2 },
+              { text: '不合格', value: 3 },
+              { text: '丧失传承能力', value: 4 },
+              { text: '取消资格', value: 5 },
+            ],
+            vertical: true,
+          } as RadioValueProps,
+        },
+        {
+          label: '传承人签名',
+          name: 'sign',
+          type: 'sign',
+          additionalProps: {
+            placeholder: '请签名',
+          } as FieldProps,
+        }
+      ],
+    },
+  ],
+  formRules: {
+    self: [{ required: true, message: '请选择自我评估' }],
+    sign: [{ required: true, message: '请传承人签名' }],
+  },
+});
+const checkItemList = ref<CheckItemInfo[]>([]);
+
+const totalPoints = computed(() => {
+  if (!currentForm.value) 
+    return 0;
+    console.log(currentForm.value?.checkItems);
+  return Object.values(currentForm.value.checkItems).reduce((acc, item) => acc + (item.count * item.points), 0);
+});
+
+async function loadBasicInfo() {
+  const basicInfo = await AssessmentContentApi.getInheritorBasic(authStore.userInfo?.id);
+  assertNotNull(currentForm.value, 'currentForm is null');
+  currentForm.value.inheritor = basicInfo.name;
+  currentForm.value.unit = basicInfo.unit;
+  currentForm.value.ichName = basicInfo.ichName;
+  currentForm.value.mobile = basicInfo.mobile;
+  currentForm.value.level = basicInfo.level;
+  currentForm.value.idCard = basicInfo.idCard;
+  currentForm.value.address = basicInfo.address;
+
+}
+async function loadCheckItems() {
+  assertNotNull(currentForm.value, 'currentForm is null');
+  checkItemList.value = await AssessmentContentApi.getCheckItems(Number(currentForm.value.level));
+}
+function hasCheckedItem(id: number) {
+  return currentForm.value?.checkItems.some(item => item.id === id);
+}
+function getCheckedItemCount(id: number) {
+  return currentForm.value?.checkItems.find(item => item.id === id)?.count;
+}
+function setCheckedItem(checkItem: CheckItemInfo, childItem: CheckItemInfo, count: number|boolean) {
+  if (!currentForm.value)
+    return;
+  if (typeof count === 'boolean') {
+    count = count ? 1 : 0;
+  }
+  let item = currentForm.value.checkItems.find(item => item.id === childItem.id);
+  if (!item) {
+    item = new SelfAssessmentCheckItemAnswer();
+    currentForm.value.checkItems.push(item);
+  }
+  item.id = childItem.id;
+  item.points = childItem.points;
+  item.count = count;
+  switch (checkItem.checkType) {
+    case 1: {
+      /** 单选,清除其他选项 */
+      const allChildren = checkItem.children.map(child => child.id);
+      currentForm.value?.checkItems.forEach(item => {
+        if (allChildren.includes(item.id) && item.id !== childItem.id)
+          item.count = 0;
+      });
+      break;
+    }
+  }
+  if (item.count === 0)
+    ArrayUtils.remove(currentForm.value.checkItems, item);
+}
+
+const submitLoading = ref(false);
+
+async function createForm() {
+  const detail = new SelfAssessmentDetail();
+  detail.userId = authStore.userInfo!.id;
+  detail.year = new Date().getFullYear();
+  detail.checkItems = [];
+  currentForm.value = detail;
+  await loadBasicInfo();
+  await loadCheckItems();
+}
+async function saveForm() {
+  const detail = currentForm.value;
+
+  try {
+    await form1Ref.value?.validate();
+    await form3Ref.value?.validate();
+  } catch (error) {
+    toast('请填写完整信息');
+    return;
+  }
+
+  submitLoading.value = true;
+
+  try {
+    assertNotNull(detail, 'currentForm is null');
+    await AssessmentContentApi.saveSelfAssessment(detail as SelfAssessmentDetail);
+    toast('保存评估表成功');
+  } catch (error) {
+    alert({
+      title: '保存评估表失败',
+      content: formatError(error),
+    });
+  }
+  submitLoading.value = false;
+}
+async function downloadForm() {
+  try {
+    assertNotNull(currentForm.value, 'currentForm is null');
+    throw new Error('没这个接口');
+  } catch (error) {
+    alert({
+      title: '下载评估表失败',
+      content: formatError(error),
+    });
+  }
+}
+
+const loader = useSimpleDataLoader(async () => {
+  if (querys.value.id > 0) {
+    const detail = await AssessmentContentApi.getSelfAssessmentDetail(querys.value.id);
+    currentForm.value = detail;
+    await loadCheckItems();
+    return;
+  }
+  const list = await AssessmentContentApi.getSelfAssessmentList({
+    userId: authStore.userInfo?.id,
+    year: new Date().getFullYear(),
+  });
+  if (list.data.length > 0) {
+    const detail = await AssessmentContentApi.getSelfAssessmentDetail(list.data[0].id);
+    currentForm.value = detail;
+    await loadCheckItems();
+  } else {
+    currentForm.value = null;
+    createForm();
+  }
+  return currentForm.value;
+}, false);
+</script>

+ 49 - 0
src/pages/collect/assessment/evaluation-list.vue

@@ -0,0 +1,49 @@
+<template>
+  <FlexCol padding="space.lg">
+    <SearchBar v-model="search" @search="loader.reload()" />
+    <SimplePageContentLoader :loader="loader">
+      <template v-if="loader.isFinished.value">
+        <Touchable 
+          v-for="item in loader.list.value" :key="item.id"
+          direction="row"
+          justify="space-between"
+          @click="navTo('assessment/evaluation-form', { id: item.id })"
+        >
+          <FlexCol>
+            <Text :text="item.inheritor ?? '?'" />
+            <Text :text="item.mobile || item.unit || '?'" />
+          </FlexCol>
+          <Icon name="arrow-right-bold" />
+        </Touchable>
+      </template>
+    </SimplePageContentLoader>
+    <XBarSpace />
+  </FlexCol>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { useSimplePageListLoader } from '@/components/composeabe/loader/SimplePageListLoader';
+import AssessmentContentApi from '@/api/collect/AssessmentContent';
+import FlexCol from '@/components/layout/FlexCol.vue';
+import SimplePageContentLoader from '@/components/loader/SimplePageContentLoader.vue';
+import XBarSpace from '@/components/layout/space/XBarSpace.vue';
+import Text from '@/components/basic/Text.vue';
+import Icon from '@/components/basic/Icon.vue';
+import SearchBar from '@/components/form/SearchBar.vue';
+import Touchable from '@/components/feedback/Touchable.vue';
+import { navTo } from '@/components/utils/PageAction';
+
+const search = ref('');
+const loader = useSimplePageListLoader(10, async (page, pageSize) => {
+  const list = await AssessmentContentApi.getSelfAssessmentList({
+    year: new Date().getFullYear(),
+    page,
+    pageSize,
+  });
+  return {
+    list: list.data,
+    total: list.total,
+  };
+});
+</script>

+ 1 - 1
src/pages/collect/forms/form.vue

@@ -73,7 +73,7 @@
 </template>
 
 <script setup lang="ts" generic="T extends DataModel, U extends DataModel">
-import { onMounted, ref, toRefs, type PropType, h, computed } from 'vue';
+import { onMounted, ref, toRefs, type PropType, computed } from 'vue';
 import { useAuthStore } from '@/store/auth';
 import { useImageSimpleUploadCo } from '@/common/upload/ImageUploadCo';
 import { alert, confirm, toast } from '@/components/utils/DialogAction';

+ 6 - 6
src/pages/collect/inheritor.vue

@@ -34,12 +34,12 @@
       <CellGroup round>
         <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/9fb29e8bdb66490034145c90f892773a.png" title="自查评估" showArrow touchable @click="navTo('assessment/index')" />
       </CellGroup>
-      <CellGroup round>
-        <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/1366973c061bf98594036e42c0344593.png" title="非遗项目" showArrow touchable @click="navTo('forms/ich')" />
-        <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/042236758da5aaed21c1010e5b9440ce.png" title="传承人" showArrow touchable @click="navTo('forms/inheritor')" />
-        <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/d2e9010323d098aa51e268fc32f14d3d.png" title="传习所" showArrow touchable @click="navTo('forms/seminar')" />
-        <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/66d4665b1da5075e60148312469b2630.png" title="作品" showArrow touchable @click="navTo('works')" />
-      </CellGroup>
+      <!-- <CellGroup round>
+        <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/1366973c061bf98594036e42c0344593.png" title="非遗项目资料采集" showArrow touchable @click="navTo('forms/ich')" />
+        <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/042236758da5aaed21c1010e5b9440ce.png" title="传承人信息采集" showArrow touchable @click="navTo('forms/inheritor')" />
+        <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/d2e9010323d098aa51e268fc32f14d3d.png" title="传习所资料采集" showArrow touchable @click="navTo('forms/seminar')" />
+        <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/66d4665b1da5075e60148312469b2630.png" title="传承人作品上传。" showArrow touchable @click="navTo('works')" />
+      </CellGroup> -->
       <CellGroup round>
         <Cell icon="https://mncdn.wenlvti.net/app_static/minnan/logo.png" title="返回闽南文化" showArrow touchable @click="back()" />
         <Cell icon="https://mncdn.wenlvti.net/uploads/20250313/cbc47d0b9cad7891e6154359952858c6.png" title="退出登录" showArrow touchable @click="onLogout" />

+ 4 - 2
src/pages/collect/login.vue

@@ -93,7 +93,7 @@ const formDefine: IDynamicFormOptions = {
         placeholder: '请输入密码',
       },
     },
-    /* {
+    {
       label: '登录类型',
       name: 'type',
       type: 'radio-value',
@@ -104,7 +104,7 @@ const formDefine: IDynamicFormOptions = {
           { text: '管理员', value: 1 },
         ],
       } as RadioValueProps,
-    }, */
+    },
   ],
 };
 
@@ -141,6 +141,8 @@ async function handleSubmit() {
     await new Promise((r) => setTimeout(r, 200));
     if (authStore.loginType === 0) {
       uni.redirectTo({ url: '/pages/collect/inheritor' });
+    } else if (authStore.loginType === 1) {
+      uni.redirectTo({ url: '/pages/collect/admin' });
     } else {
       uni.switchTab({ url: '/pages/user/index' });
     }