Переглянути джерело

📦 传承协议与自查表签名

快乐的梦鱼 3 тижнів тому
батько
коміт
6b9fa1b2df
58 змінених файлів з 4078 додано та 514 видалено
  1. 150 0
      .cursor/rules/dynamic-form.mdc
  2. 29 0
      .cursor/rules/powershell-pwsh.mdc
  3. 14 0
      .cursor/rules/web-scraping-devtools-first.mdc
  4. 0 121
      Untitled-1.txt
  5. 740 113
      package-lock.json
  6. 5 2
      package.json
  7. 0 3
      src/App.vue
  8. 1 1
      src/api/CommonContent.ts
  9. 13 23
      src/api/RequestModules.ts
  10. 585 0
      src/api/collect/AssessmentContent.ts
  11. 1 0
      src/assets/images/icon/AgreementSign.svg
  12. 1 0
      src/assets/images/icon/EvaluationForm.svg
  13. 2 2
      src/common/config/ApiCofig.ts
  14. 2 2
      src/common/upload/AliOssUploadCo.ts
  15. 1 1
      src/common/upload/ImageUploadCo.ts
  16. 2 2
      src/components/Footer.vue
  17. 142 0
      src/components/content/CommonCatalog.vue
  18. 17 16
      src/components/content/CommonListBlock.vue
  19. 63 0
      src/components/content/ImageGrid.vue
  20. 0 0
      src/components/content/ShowValueOrNull.vue
  21. 81 0
      src/components/content/SimplePageContentLoader.vue
  22. 146 0
      src/components/content/SimpleRichHtml.vue
  23. 1 1
      src/components/content/TagBar.vue
  24. 3 2
      src/components/parts/EmptyToRecord.vue
  25. 1 1
      src/components/parts/TitleDescBlock.vue
  26. 11 0
      src/composeables/LoaderCommon.ts
  27. 132 0
      src/composeables/SimplePagerDataLoader.ts
  28. 19 0
      src/composeables/useMemorizeVar.ts
  29. 40 0
      src/composeables/useSimpleDataLoader.ts
  30. 19 0
      src/composeables/useWindowOnUnLoadConfirm.ts
  31. 7 3
      src/main.ts
  32. 6 5
      src/pages/admin.vue
  33. 4 4
      src/pages/admin/seminar.vue
  34. 2 2
      src/pages/admin/works.vue
  35. 2 2
      src/pages/change-password.vue
  36. 333 0
      src/pages/collect/assessment/argeement-sign.vue
  37. 21 0
      src/pages/collect/assessment/components/AgreementBody.vue
  38. 119 0
      src/pages/collect/assessment/components/AgreementBodyMunicipal.vue
  39. 127 0
      src/pages/collect/assessment/components/AgreementBodyNational.vue
  40. 126 0
      src/pages/collect/assessment/components/AgreementBodyProvincial.vue
  41. 79 0
      src/pages/collect/assessment/components/AgreementDateWriteBlock.vue
  42. 75 0
      src/pages/collect/assessment/components/AgreementPrefillInline.vue
  43. 341 0
      src/pages/collect/assessment/components/EvaluationFormBlock.vue
  44. 359 0
      src/pages/collect/assessment/evaluation-form.vue
  45. 2 2
      src/pages/components/AdminItemState.vue
  46. 0 13
      src/pages/editor-test.vue
  47. 14 14
      src/pages/forms/form.vue
  48. 45 45
      src/pages/forms/ich.vue
  49. 59 59
      src/pages/forms/inheritor.vue
  50. 2 2
      src/pages/forms/plans.vue
  51. 47 35
      src/pages/forms/seminar.vue
  52. 35 24
      src/pages/forms/works.vue
  53. 32 9
      src/pages/inheritor.vue
  54. 1 2
      src/pages/login.vue
  55. 8 3
      src/router/index.ts
  56. 6 0
      src/tailwind.css
  57. 3 0
      src/vite-env.d.ts
  58. 2 0
      vite.config.ts

+ 150 - 0
.cursor/rules/dynamic-form.mdc

@@ -0,0 +1,150 @@
+---
+description: DynamicForm 动态表单定义、挂载与 dig/forms 数据工厂约定
+globs: "**/*.vue"
+alwaysApply: false
+---
+
+# 动态表单(DynamicForm)使用约定
+
+本项目采用的是Vue3 web技术栈,其中主要的功能点为表单提交,因此本项目采用了动态表单库 vue-dynamic-form,
+关于动态表单库的使用文档可参考https://docs.imengyu.top/vue-dynamic-form-docs/guide/about.html
+
+基于 `DynamicForm.vue`、`DynamicForm.ts`(`IDynamicFormOptions` / `IDynamicFormItem`)及 `pages/dig/forms/data/*.ts`(如 `overview.ts`)的实际用法。
+
+## 组件侧
+
+- 使用 `<DynamicForm :options="..." :model="..." :name="..." :globalParams="..." />`。`model` 与表单双向绑定;`globalParams` 可在条目回调的 `formGlobalParams` 中读取。
+- 通过 `ref` 拿到 `IDynamicFormRef`:`validate` / `submit`、`setValueByPath` / `getValueByPath`(路径为点分字符串或数组转点分)、`getFormRef`、`getFormItemControlRef`、`dispatchMessage` / `dispatchReload`(常量 `MESSAGE_RELOAD`)、`initDefaultValuesToModel`、`getVisibleFormNames`。
+- 顶层校验:在 `options.formRules`(async-validator `Rules`)或单项 `rules`(`RuleItem[]`)。可选 `formAdditionaProps`、`disabled`、`readonly`、`suppressRootError`、`suppressEmptyError`、`nestObjectMargin`、`emptyText`。
+
+## 选项结构 `IDynamicFormOptions`
+
+- **必填**:`formItems: IDynamicFormItem[]`。
+- 全局默认:可改 `defaultDynamicFormOptions` 或调用 `configDefaultDynamicFormOptions`(勿传入 `formItems`),与单次 `options` 合并。
+
+## 表单项 `IDynamicFormItem`
+
+- **必填**:`name`(字段路径名)。**常用**:`label`、`type`(内置控件类型字符串)、`additionalProps`(传给具体控件)、`formProps`(传给 `Field`)、`defaultValue`、`rules`、`children`(嵌套对象/数组项)。
+- **动态字段**:`label`、`additionalProps`、部分控件字段、`show`、`disabled` 等可写成 **`IDynamicFormItemCallback<T>`**:`{ callback: (model, rawModel, parentModel, params) => T }`。关联远端字段时优先用 **`rawModel`**;列表场景用 **`parentModel`**。`params` 含 `item`、`parent`、`form`(`IDynamicFormRef`)、`formGlobalParams`、`formRules`、`isFirst` / `isLast`。
+- **生命周期 / 联动**:`mounted` / `beforeUnmount` / `watch`;跨项赋值在拿到表单 ref 时用 **`form.setValueByPath('field', value)`**(参见 `overview` 中 `select-city` 的 `onSelectedTownship`)。
+- **布局容器**:`flat-group` 用于横向/分组排版(可配合 `childrenColProps`、`rowProps`);子项仍占用模型上的 `name`。嵌套对象用带 `children` 的条目(由渲染层按 `type` 区分对象/数组等)。
+- **数组子项**:可选 `newChildrenObject`、`deleteChildrenCallback` 控制增删;子层可用 `childrenColProps` / `colProps` / `nestObjectMargin`。
+- **默认值**:`defaultValue` 可为字面量或 **`() => value`**(惰性初始化);`initDefaultValuesToModel` 只对仍为 `undefined`/`null` 的路径写入。
+- **消息**:条目可实现 `message`,根上 `dispatchMessage` 分发;预置 `MESSAGE_RELOAD` 用于刷新类逻辑。
+
+## 可用组件
+
+在 \src\components\dynamicf\index.ts 中的 registerAllFormComponents 函数中有注册了本项目中可用的动态表单组件,可以在这里查阅。
+register的一个参数小写名字为组件的唯一标识,在配置中使用。
+本项目大部分使用 ant-design-vue 的组件,少部分组件为二次封装组件,放在项目的 \src\components\dynamicf 文件夹下。
+
+可用组件简表:
+text:单行文本框
+password:密码输入框
+number:数字输入框
+text-area:多行文本框
+switch:开关
+check-box:boolean类型的勾选框
+check-box-int:0,1数字类型的勾选框
+rate:评星
+select:静态数据下拉选择框
+select-value:静态数据下拉选择框,额外参数的options可配置选项数据,格式{text: string,value: unknown}[]
+select-id: 动态数据据下拉选择框,额外参数的loadData为回调:
+  loadData: (searchText: string | null) => Promise<DropdownValues<T>[]>;
+  DropdownValues<T> {
+    label: string,
+    value: number,
+    raw: T;
+  }
+  可用来加载选项数据。
+date:日期选择.
+time:时间选择。
+date-time:日期+时间选择
+date-range:日期范围选择。
+time-range:时间范围选择。
+date-time-range:日期+时间范围选择。
+uploader:单一图片上传。
+uploader:多图上传。
+
+formOptions 格式如下:{
+  formLabelCol: { span: 6 }, //表单标签栅格宽度
+  formWrapperCol: { span: 24 }, //表单容器栅格宽度
+  formAdditionaProps: { //指定ant desgin form 的其他参数
+    layout: 'vertical'
+  },
+  formItems: [//表单项目
+    {
+      label: '传承人姓名', //标签名称
+      name: 'name', //字段名称
+      type: 'text', //组件名称
+      additionalProps: { //组件的额外参数
+        placeholder: '请输入姓名'
+      },
+    },
+    { 
+      label: '证件照',
+      name: 'idPhoto',
+      type: 'uploader',
+      additionalProps: {
+
+      },
+    },
+    {
+      label: '类型',
+      name: 'type',
+      type: 'select-id',//动态下拉加载
+      additionalProps: {
+        placeholder: '请选择类型',
+        //如有动态加载数据,请为我生成类似代码
+        loadData: async () =>
+          (await CommonContent.getCategoryList(4)).map(p => ({
+            label: p.title,
+            value: p.id,
+            raw: p
+          }))
+      } as IdAsValueDropdownProps<DataModel>,
+    },
+    {
+      label: '性别',
+      name: 'gender',
+      type: 'select',
+      additionalProps: {
+        options: [
+          { text: '男', value: '男' },
+          { text: '女', value: '女' },
+        ]
+      },
+    },
+    {
+      label: '生日',
+      name: 'birthday',
+      type: 'date',
+      additionalProps: {
+        placeholder: '请输入出生日期' 
+      }
+    },
+    {
+      label: '说明',
+      name: 'jobTitle',
+      type: 'text-area',
+      additionalProps: {
+        placeholder: '请输入说明' 
+      }
+    },
+  ],
+  formRules: { //表单验证项,格式与 async-validator 一致
+    name: [
+      { required: true, message: '请输入姓名' },
+      { min: 2, max: 5, message: '长度在 2 到 5 个字符' }
+    ],
+    ichName: [
+      { required: true, message: '请输入项目名称' }
+    ],
+    //....
+  },
+});
+
+## 建议避免
+
+- 勿猜测未注册的 `type`;新增类型需在动态表单渲染链路中已有对应组件映射。
+- 回调里访问兄弟节点不要用错层级:`model` 是当前项值,`rawModel` 是整表根数据(跨深层字段时尤其要用根路径或 `setValueByPath`)。

+ 29 - 0
.cursor/rules/powershell-pwsh.mdc

@@ -0,0 +1,29 @@
+---
+description: Windows 终端优先使用 pwsh(PowerShell 7+),避免旧版 powershell.exe
+alwaysApply: true
+---
+
+# PowerShell:优先 pwsh
+
+在 **Windows** 上需要跑 PowerShell 命令或脚本时,**优先使用 `pwsh`**(PowerShell 7+ 可执行文件),不要用旧版 **`powershell.exe`**(Windows PowerShell 5.1),除非用户明确要求兼容 5.1。
+
+## 执行方式
+
+- 直接开 PowerShell 会话或让用户在本机执行时:写 **`pwsh`**,不要写 **`powershell`**。
+- 单次内联执行:可用 `pwsh -NoProfile -Command "..."` 或 `pwsh -File script.ps1`。
+- 若环境未安装 `pwsh`,再回退到 `powershell`,并在回复里说明依赖 5.1 或建议安装 [PowerShell](https://github.com/PowerShell/PowerShell/releases)。
+
+## 语法提示
+
+- `pwsh` 支持 `&&`、`||` 等操作符;与 5.1 行为不一致时以 **7+** 为准编写命令。
+- 设置工作目录优先用:`Set-Location '路径'` 或 `cd '路径'`;不要用 CMD 的 `cd /d`。
+
+## 反例 / 正例
+
+```powershell
+# ❌ 默认指向旧版
+powershell -Command "Get-ChildItem"
+
+# ✅ 优先
+pwsh -NoProfile -Command "Get-ChildItem"
+```

+ 14 - 0
.cursor/rules/web-scraping-devtools-first.mdc

@@ -0,0 +1,14 @@
+---
+description: 网页抓取/页面检查优先用 Browser DevTools MCP
+alwaysApply: true
+---
+
+# 网页抓取优先使用 Browser DevTools MCP
+
+当任务需要**打开网页、抓取页面内容、定位元素、复现交互、查看网络请求**时,优先使用 Browser DevTools MCP,而不是直接用普通网页抓取/猜测选择器。
+
+- **结构优先**:导航到页面后,先用 ARIA 快照理解页面结构与可交互元素(必要时再用 AX tree)。
+- **交互前必快照**:任何点击/输入/选择/滚动等交互之前,先获取快照拿到真实 ref/selector,禁止凭截图或臆测 CSS 选择器。
+- **按需取内容**:需要正文/表格/接口参数时,再获取 HTML(可按 selector 范围化);只有在需要验证视觉效果时才截图。
+- **批量执行**:多步操作(填表+提交+等待+再抓取)尽量用批量执行能力合并成一次调用,减少往返与不确定性。
+

+ 0 - 121
Untitled-1.txt

@@ -1,121 +0,0 @@
-你将专门为我生成我的项目中的动态表单配置
-本项目采用的是Vue3 web技术栈,其中主要的功能点为表单提交,因此本项目采用了动态表单库 vue-dynamic-form,
-关于动态表单库的使用文档可参考https://docs.imengyu.top/vue-dynamic-form-docs/guide/about.html
-
-可用组件:在 \src\components\dynamicf\index.ts 中的 registerAllFormComponents 函数中有注册了本项目中可用的动态表单组件,可以在这里查阅。
-register的一个参数小写名字为组件的唯一标识,在配置中使用。
-本项目大部分使用 ant-design-vue 的组件,少部分组件为二次封装组件,放在项目的 \src\components\dynamicf 文件夹下。
-
-可用组件简表:
-text:单行文本框
-password:密码输入框
-number:数字输入框
-text-area:多行文本框
-switch:开关
-check-box:boolean类型的勾选框
-check-box-int:0,1数字类型的勾选框
-rate:评星
-select:静态数据下拉选择框
-select-value:静态数据下拉选择框,额外参数的options可配置选项数据,格式{text: string,value: unknown}[]
-select-id: 动态数据据下拉选择框,额外参数的loadData为回调:
-  loadData: (searchText: string | null) => Promise<DropdownValues<T>[]>;
-  DropdownValues<T> {
-    label: string,
-    value: number,
-    raw: T;
-  }
-  可用来加载选项数据。
-date:日期选择.
-time:时间选择。
-date-time:日期+时间选择
-date-range:日期范围选择。
-time-range:时间范围选择。
-date-time-range:日期+时间范围选择。
-single-image:单一图片上传。
-mulit-image:多图上传。
-
-formOptions 格式如下:{
-  formLabelCol: { span: 6 }, //表单标签栅格宽度
-  formWrapperCol: { span: 24 }, //表单容器栅格宽度
-  formAdditionaProps: { //指定ant desgin form 的其他参数
-    layout: 'vertical'
-  },
-  formItems: [//表单项目
-    {
-      label: '传承人姓名', //标签名称
-      name: 'name', //字段名称
-      type: 'text', //组件名称
-      additionalProps: { //组件的额外参数
-        placeholder: '请输入姓名'
-      },
-    },
-    { 
-      label: '证件照',
-      name: 'idPhoto',
-      type: 'single-image',
-      additionalProps: {
-
-      },
-    },
-    {
-      label: '类型',
-      name: 'type',
-      type: 'select-id',//动态下拉加载
-      additionalProps: {
-        placeholder: '请选择类型',
-        //如有动态加载数据,请为我生成类似代码
-        loadData: async () =>
-          (await CommonContent.getCategoryList(4)).map(p => ({
-            label: p.title,
-            value: p.id,
-            raw: p
-          }))
-      } as IdAsValueDropdownProps<DataModel>,
-    },
-    {
-      label: '性别',
-      name: 'gender',
-      type: 'select',
-      additionalProps: {
-        options: [
-          { text: '男', value: '男' },
-          { text: '女', value: '女' },
-        ]
-      },
-    },
-    {
-      label: '生日',
-      name: 'birthday',
-      type: 'date',
-      additionalProps: {
-        placeholder: '请输入出生日期' 
-      }
-    },
-    {
-      label: '说明',
-      name: 'jobTitle',
-      type: 'text-area',
-      additionalProps: {
-        placeholder: '请输入说明' 
-      }
-    },
-  ],
-  formRules: { //表单验证项,格式与 async-validator 一致
-    name: [
-      { required: true, message: '请输入姓名' },
-      { min: 2, max: 5, message: '长度在 2 到 5 个字符' }
-    ],
-    ichName: [
-      { required: true, message: '请输入项目名称' }
-    ],
-    //....
-  },
-});
-
-你的主要任务是,为我生成重复的枯燥的表单配置:
-我会输入后端需要提交的字段列表,这包含:参数名、必选、类型、说明,
-为我选择最适合展现的表单组件,依据 vue-dynamic-form 动态表单库的配置与功能,
-为我攥写一个完整可用的表单配置,并写入页面的变量中:
-变量已经写好, formModel为表单数据,已经写好。formOptions为表单配置,含有以下字段:formItems表单项目,formRules表单验证项。
-注:后端字段名称为下划线小写,前端需要修改为小驼峰写法。
-

Різницю між файлами не показано, бо вона завелика
+ 740 - 113
package-lock.json


+ 5 - 2
package.json

@@ -16,9 +16,10 @@
   },
   "dependencies": {
     "@imengyu/imengyu-utils": "^0.0.17",
-    "@imengyu/imengyu-web-shared": "^0.0.1",
     "@imengyu/js-request-transform": "^0.3.7",
-    "@imengyu/vue-dynamic-form": "^0.1.3",
+    "@imengyu/vue-dynamic-form": "^0.1.9",
+    "@imengyu/vue-dynamic-form-ant": "^0.0.8",
+    "@imengyu/vue-dynamic-form-rich": "^0.0.2",
     "@imengyu/vue-scroll-rect": "^0.1.3",
     "@tinymce/tinymce-vue": "^6.3.0",
     "@vuemap/vue-amap": "^2.1.12",
@@ -45,6 +46,7 @@
   },
   "devDependencies": {
     "@inquirer/prompts": "^7.8.4",
+    "@tailwindcss/vite": "^4.3.0",
     "@tsconfig/node22": "^22.0.2",
     "@types/ali-oss": "^6.16.11",
     "@types/node": "^22.16.5",
@@ -59,6 +61,7 @@
     "commander": "^14.0.0",
     "npm-run-all2": "^8.0.4",
     "sass": "^1.87.0",
+    "tailwindcss": "^4.3.0",
     "typescript": "~5.8.0",
     "vite": "^7.0.6",
     "vite-plugin-vue-devtools": "^8.0.0",

+ 0 - 3
src/App.vue

@@ -50,9 +50,6 @@ watch(route, () => {
 </script>
 
 <style>
-@import "bootstrap/dist/css/bootstrap.css";
-@import "bootstrap/dist/css/bootstrap-grid.css";
-@import "bootstrap/dist/css/bootstrap-utilities.css";
 @import "./assets/scss/main.scss";
 @import "vue3-carousel/carousel.css";
 @import "@vuemap/vue-amap/dist/style.css";

+ 1 - 1
src/api/CommonContent.ts

@@ -1,6 +1,6 @@
 import { DataModel, transformArrayDataModel, type NewDataModel } from '@imengyu/js-request-transform';
 import { AppServerRequestModule } from './RequestModules';
-import type { QueryParams } from "@imengyu/imengyu-utils/dist/request";
+import type { QueryParams } from "@imengyu/imengyu-utils";
 import { transformSomeToArray } from '@/api/Utils';
 
 export class GetColumListParams extends DataModel<GetColumListParams> {

+ 13 - 23
src/api/RequestModules.ts

@@ -8,17 +8,16 @@
 
 import AppCofig from "@/common/config/AppCofig";
 import ApiCofig from "@/common/config/ApiCofig";
-import fetchImplementer from "@imengyu/imengyu-utils/dist/request/implementer/WebFetch";
 import { 
   RequestApiConfig,
   RequestApiError, RequestApiResult, type RequestApiErrorType, 
   RequestCoreInstance, RequestOptions, 
   defaultResponseDataGetErrorInfo, defaultResponseDataHandlerCatch, 
-  RequestResponse
-} from "@imengyu/imengyu-utils/dist/request";
-import { logError } from "@imengyu/imengyu-web-shared";
+  RequestResponse,
+  WebFetchImplementer,
+  appendGetUrlParams, appendPostParams
+} from "@imengyu/imengyu-utils";
 import type { DataModel, KeyValue, NewDataModel } from "@imengyu/js-request-transform";
-import { appendGetUrlParams, appendPostParams } from "@imengyu/imengyu-utils/dist/request/utils/Utils";
 import { useAuthStore } from "@/stores/auth";
 import { Modal } from "ant-design-vue";
 import { StringUtils } from "@imengyu/imengyu-utils";
@@ -176,24 +175,15 @@ function responseErrReoprtInceptor<T extends DataModel>(instance: RequestCoreIns
 //错误报告处理
 export function reportError<T extends DataModel>(instance: RequestCoreInstance<T>, response: RequestApiError | Error) {
   if (import.meta.env.DEV) {
+    console.log(response);
     if (response instanceof RequestApiError) {
-      logError({
-        message: `请求错误 ${response.apiName} : ${response.errorMessage}`,
-        detail: response.toString() +
-          '\r\n请求接口:' + response.apiName +
-          '\r\n请求地址:' + response.apiUrl +
-          '\r\n请求参数:' + JSON.stringify(response.rawRequest) +
-          '\r\n返回参数:' + JSON.stringify(response.rawData) +
-          '\r\n状态码:' + response.code +
-          '\r\n信息:' + response.errorCodeMessage,
-        type: 'error',
-      });
-    } else {
-      logError({
-        message: '错误报告 代码错误',
-        detail: response?.stack || ('' + response),
-        type: 'error',
-      });
+      console.log(response.apiName);
+      console.log(response.errorMessage);
+      console.log(response.apiUrl);
+      console.log(response.rawRequest);
+      console.log(response.rawData);
+      console.log(response.code);
+      console.log(response.errorCodeMessage);
     }
   } else {    
     let errMsg = '';
@@ -234,7 +224,7 @@ function responseErrorHandler<T extends DataModel>(err: Error, instance: Request
  */
 export class AppServerRequestModule<T extends DataModel> extends RequestCoreInstance<T> {
   constructor() {
-    super(fetchImplementer);
+    super(WebFetchImplementer);
     this.config.baseUrl = ApiCofig.serverProd;
     this.config.errCodes = []; //
     this.config.requestInceptor = requestInceptor;

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

@@ -0,0 +1,585 @@
+import { DataModel, transformArrayDataModel, transformDataModel, type KeyValue } from '@imengyu/js-request-transform';
+import { AppServerRequestModule } from '../RequestModules';
+import { transformSomeToArray } from '../Utils';
+import ApiCofig from '@/common/config/ApiCofig';
+import { appendGetUrlParams } from '@imengyu/imengyu-utils';
+import { useAuthStore } from '@/stores/auth';
+
+/**
+ * 自查评估 / 传承协议相关接口(ShowDoc)
+ * @see https://www.showdoc.com.cn/minnanCE/11559060626966335 列表
+ * @see https://www.showdoc.com.cn/minnanCE/11559060626887140 传承协议编辑
+ * @see https://www.showdoc.com.cn/minnanCE/11559060626966336 评估表编辑/新增
+ * @see https://www.showdoc.com.cn/minnanCE/11559060626966337 详情
+ */
+
+/** 计分点项(getCheckItems) */
+export class CheckItemInfo extends DataModel<CheckItemInfo> {
+  constructor() {
+    super(CheckItemInfo, '自查计分项目');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number' },
+      pid: { clientSide: 'number', serverSide: 'number' },
+      level: { clientSide: 'number', serverSide: 'number' },
+      points: { clientSide: 'number', serverSide: 'number' },
+      isTitle: { clientSide: 'boolean', serverSide: 'number' },
+      isMulitCheck: { clientSide: 'boolean', serverSide: 'number' },
+    };
+  }
+
+  id = 0 as number;
+  pid = 0 as number;
+  name = '' as string;
+  level = 0 as number;
+  points = 0 as number;
+  isTitle = false;
+  isMulitCheck = false;
+  /** 1=单选,2=多选,3=次数 */
+  checkType = 0 as number;
+  children: CheckItemInfo[] = [];
+}
+
+/** 自查评估表列表行 */
+export class SelfAssessmentListRow extends DataModel<SelfAssessmentListRow> {
+  constructor() {
+    super(SelfAssessmentListRow, '自查评估表列表项');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { 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' },
+      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;
+  userId = 0 as number;
+  year = 0 as number;
+  inheritor = '' as string|null;
+  unit = '' as string|null;
+  ichName = '' as string|null;
+  mobile = '' as string|null;
+  idCard = '' as string|null;
+  level = null as number|string|null;
+  address = '' as string|null;
+  weigh = 0 as number;
+  deductPoints = 0 as number;
+  points = 0 as number;
+  self = null as number|null;
+  ichUnit = null as number|null;
+  unitPoints = 0 as number;
+  county = null as number|null;
+  countyPoints = 0 as number;
+  district = null as number|null;
+  districtPoints = 0 as number;
+  province = null as number|null;
+  provincePoints = 0 as number;
+  createtime = '' as string;
+  updatetime = '' as string;
+  selfText = '' as string;
+}
+
+/** 传承协议列表行 */
+export class AgreementListRow extends DataModel<AgreementListRow> {
+  constructor() {
+    super(AgreementListRow, '传承协议列表项');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { 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' },
+      level: { clientSide: 'number', serverSide: 'number' },
+    };
+  }
+
+  id = 0 as number;
+  userId = 0 as number;
+  level = null as number|null;
+  year = 0 as number;
+  partyA = '' as string|null;
+  partyB = '' as string|null;
+  apprentice = 0 as number;
+  activity = 0 as number;
+  course = 0 as number;
+  mobile = '' as string|null;
+  partyAMobile = '' as string|null;
+  idCard = '' as string|null;
+  health = '' as string|null;
+  ich = '' as string|null;
+  partyASign = '' as string|null;
+  partyBSign = '' as string|null;
+  createtime = '' as string;
+  updatetime = '' as string;
+  deletetime = '' as string|null;
+}
+
+/** 传承人基础信息(ich/check/basic) */
+export class InheritorCheckBasicInfo extends DataModel<InheritorCheckBasicInfo> {
+  constructor() {
+    super(InheritorCheckBasicInfo, '传承人自查基础信息');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      userId: { clientSide: 'number', serverSide: 'number' },
+      level: { clientSide: 'number', serverSide: 'number' },
+      batch: { clientSide: 'number', serverSide: 'number' },
+    };
+    this._convertKeyType = (key) => {
+      if (key.endsWith('Text') || key.endsWith('_text')) {
+        return { clientSide: 'string', serverSide: 'undefined' };
+      }
+      return undefined;
+    };
+  }
+
+  userId = 0 as number;
+  name = '' as string;
+  region = '' as string;
+  gender = '' as string;
+  education = '' as string|null;
+  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;
+  educationText = '' as string;
+  levelText = '' as string;
+  regionText = '' as string;
+  /** 已填写的传承协议ID */
+  agreementId = 0 as number;
+  /** 已填写的自查表ID */
+  checkId = 0 as number;
+}
+
+/** 详情中已选计分项 */
+export class SelfAssessmentCheckItemAnswer extends DataModel<SelfAssessmentCheckItemAnswer> {
+  constructor() {
+    super(SelfAssessmentCheckItemAnswer, '自查计分选项');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number' },
+      points: { clientSide: 'number', serverSide: 'number' },
+      count: { clientSide: 'number', serverSide: 'number' },
+    };
+    this._afterSolveServer = (data) => {
+      if (data.itemId)
+        data.id = data.itemId;
+    }
+  }
+
+  id = 0 as number;
+  points = 0 as number;
+  count = 0 as number;
+}
+
+/** 自查评估表详情 */
+export class SelfAssessmentDetail extends DataModel<SelfAssessmentDetail> {
+  constructor() {
+    super(SelfAssessmentDetail, '自查评估表详情');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { 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' },
+      level: { clientSide: 'number', serverSide: 'string' },
+      content: {
+        customToClientFn: (value) => {
+          try {
+            return JSON.parse(value as string);
+          } catch {
+            return {};
+          }
+        },
+        customToServerFn: (value) => {
+          return value;
+        },
+
+      },
+      awardTime: {
+        clientSide: 'dayjs',
+        clientSideDateFormat: 'YYYY-MM-DD',
+        serverSide: 'string',
+        serverSideDateFormat: 'YYYY-MM-DD',
+      },
+      checkItems: {
+        customToClientFn: (value) => {
+          return transformArrayDataModel(SelfAssessmentCheckItemAnswer, transformSomeToArray(value), 'data');
+        },
+        customToServerFn: (value) => {
+          return (value as SelfAssessmentCheckItemAnswer[])
+            .filter(item => item.count > 0)
+            .map((item) => {
+              return {
+                id: item.id,
+                count: item.count,
+              };
+            });
+        },
+      },
+    };
+    this._blackList.toServer = [
+      'createtime',
+      'updatetime',
+      'deletetime',
+    ]
+    this._convertKeyType = (key) => {
+      if (key.endsWith('Text') || key.endsWith('_text')) {
+        return { clientSide: 'string', serverSide: 'undefined' };
+      }
+      return undefined;
+    };
+    this._beforeSolveClient = (data) => {
+      if (data.id == 0)
+        delete data.id;
+      return data;
+    }
+  }
+
+  id = 0 as number;
+  userId = 0 as number;
+  year = 0 as number;
+  inheritor = '' as string|null;
+  unit = '' as string|null;
+  ichName = '' as string|null;
+  mobile = '' as string|null;
+  idCard = '' as string|null;
+  level = null as number|string|null;
+  address = '' as string|null;
+  content : Record<string, any>|null = null;
+  weigh = 0 as number;
+  awardTime = new Date();
+  deductContent = '' as string|null;
+  deductPoints = 0 as number;
+  points = 0 as number;
+  self = null as number|null;
+  sign = '' as string|null;
+  ichUnit = null as number|null;
+  unitPoints = 0 as number;
+  county = null as number|null;
+  countyPoints = 0 as number;
+  district = null as number|null;
+  districtPoints = 0 as number;
+  province = null as number|null;
+  provincePoints = 0 as number;
+  createtime = '' as string;
+  updatetime = '' as string;
+  deletetime = '' as string|null;
+  selfText = '' as string;
+  checkItems :  SelfAssessmentCheckItemAnswer[] = [];
+}
+
+/** 传承协议详情 */
+export class AgreementDetail extends DataModel<AgreementDetail> {
+  constructor() {
+    super(AgreementDetail, '传承协议详情');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { 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' },
+      level: { clientSide: 'number', serverSide: 'number' },
+      updatetime: { clientSide: 'date', serverSide: 'undefined' },
+    };
+    this._nameMapperClient = {
+      partyA: 'party_a',
+      partyB: 'party_b',
+      partyASign: 'party_a_sign',
+      partyBSign: 'party_b_sign',
+      partyAMobile: 'party_a_mobile',
+    }
+    this._blackList.toServer = [
+      'createtime',
+      'updatetime',
+      'deletetime',
+    ]
+    this._beforeSolveClient = (data) => {
+      if (data.id == 0)
+        delete data.id;
+      return data;
+    }
+  }
+
+  id = 0 as number;
+  userId = 0 as number;
+  level = null as number|null;
+  year = 0 as number;
+  partyA = '' as string|null;
+  partyB = '' as string|null;
+  apprentice = 0 as number;
+  activity = 0 as number;
+  course = 0 as number;
+  mobile = '' as string|null;
+  partyAMobile = '' as string|null;
+  idCard = '' as string|null;
+  health = '' as string|null;
+  ich = '' as string|null;
+  partyASign = '' as string|null;
+  partyBSign = '' as string|null;
+  updatetime = new Date();
+}
+
+/** 证明材料附件类型(saveAnnex `type`) */
+export const CheckAnnexType = {
+  Image: 1,
+  Video: 2,
+  Audio: 3,
+  Document: 4,
+  Other: 5,
+  ExternalLink: 6,
+} as const;
+
+export function getCheckAnnexType(mimetype: string) {
+  if (mimetype.startsWith('image/')) return CheckAnnexType.Image;
+  if (mimetype.startsWith('video/')) return CheckAnnexType.Video;
+  if (mimetype.startsWith('audio/')) return CheckAnnexType.Audio;
+  if (mimetype.startsWith('application/pdf')) return CheckAnnexType.Document;
+  return CheckAnnexType.Other;
+}
+
+export type CheckAnnexTypeValue = (typeof CheckAnnexType)[keyof typeof CheckAnnexType];
+
+/** 证明材料修改与新增请求体(POST /ich/check/saveAnnex) */
+export interface SaveCheckAnnexPayload {
+  id?: number;
+  name: string;
+  formId: number;
+  url: string;
+  type: CheckAnnexTypeValue | number;
+  desc?: string;
+  mimetype?: string;
+  attachId?: number;
+  fileSize?: number;
+}
+
+/** 证明材料列表项(getAnnexList) */
+export class CheckAnnexListItem extends DataModel<CheckAnnexListItem> {
+  constructor() {
+    super(CheckAnnexListItem, '证明材料列表项');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number' },
+      formId: { clientSide: 'number', serverSide: 'number' },
+      type: { clientSide: 'number', serverSide: 'number' },
+      attachId: { clientSide: 'number', serverSide: 'number' },
+      fileSize: { clientSide: 'number', serverSide: 'number' },
+    };
+  }
+
+  id = 0 as number;
+  name = '' as string;
+  formId = 0 as number;
+  url = '' as string;
+  type = 0 as number;
+  desc = '' as string|null;
+  mimetype = '' as string|null;
+  attachId = null as number|null;
+  fileSize = null as number|null;
+  createtime = '' as string|null;
+  updatetime = '' as string|null;
+}
+
+export type IchCheckPaginated<T> = {
+  total: number;
+  perPage: number;
+  currentPage: number;
+  lastPage: number;
+  data: T[];
+};
+
+function normalizePaginated<T extends DataModel>(rowClass: new () => T, raw: KeyValue): IchCheckPaginated<T> {
+  return {
+    total: Number(raw.total ?? 0),
+    perPage: Number(raw.per_page ?? 0),
+    currentPage: Number(raw.current_page ?? 0),
+    lastPage: Number(raw.last_page ?? 0),
+    data: transformArrayDataModel(rowClass, transformSomeToArray(raw.data), 'data'),
+  };
+}
+
+function buildPdfUrl(baseUrl: string, path: string, id: number) {
+  const auth = useAuthStore();
+  let url = `${baseUrl}${path}?id=${encodeURIComponent(String(id))}`;
+  url = appendGetUrlParams(url, 'main_body_id', ApiCofig.mainBodyId);
+  url = appendGetUrlParams(url, 'token', auth.token);
+  return url;
+}
+
+async function downloadPdfBlob(path: string, id: number, baseUrl: string, defaultFilename: string) {
+  const url = buildPdfUrl(baseUrl, path, id);
+  const auth = useAuthStore();
+  const res = await fetch(url, {
+    method: 'GET',
+    headers: {
+      token: auth.token,
+      __token__: auth.token,
+    },
+  });
+  if (!res.ok)
+    throw new Error('下载失败,状态码:' + res.status);
+  const blob = await res.blob();
+  const objectUrl = URL.createObjectURL(blob);
+  const a = document.createElement('a');
+  a.href = objectUrl;
+  a.download = defaultFilename;
+  a.rel = 'noopener';
+  document.body.appendChild(a);
+  a.click();
+  document.body.removeChild(a);
+  URL.revokeObjectURL(objectUrl);
+}
+
+export class AssessmentContentApi extends AppServerRequestModule<DataModel> {
+
+  constructor() {
+    super();
+  }
+
+  async getCheckItems(level: number) {
+    const res = await this.post('/ich/check/getCheckItems', { level }, '自查计分项目');
+    const list = transformSomeToArray(res.data) as KeyValue[];
+    const items = transformArrayDataModel<CheckItemInfo>(CheckItemInfo, list, 'data') as CheckItemInfo[];
+    const map = new Map<number, CheckItemInfo>();
+    for (const item of items)
+      map.set(item.id, item);
+    const top = items.filter((item) => item.pid === 0);
+    for (const item of items)
+      item.children = items.filter((i) => i.pid === item.id);
+    return {
+      top,
+      map,
+    };
+  }
+
+  async getSelfAssessmentList(data: {
+    userId?: number;
+    level?: number;
+    year?: number;
+    keywords?: string;
+    page?: number;
+    pageSize?: number;
+  }) {
+    const res = await this.post('/ich/check/getList', {
+      user_id: data.userId,
+      level: data.level,
+      year: data.year,
+      keywords: data.keywords,
+      page: data.page,
+      pageSize: data.pageSize,
+    }, '评估表列表');
+    return normalizePaginated<SelfAssessmentListRow>(SelfAssessmentListRow, res.data as KeyValue);
+  }
+
+  async getAgreementList(data: {
+    userId?: number;
+    level?: number;
+    year?: number;
+    keywords?: string;
+    page?: number;
+    pageSize?: number;
+  }) {
+    const res = await this.post('/ich/check/getAgreementList', {
+      user_id: data.userId,
+      level: data.level,
+      year: data.year,
+      keywords: data.keywords,
+      page: data.page,
+      pageSize: data.pageSize,
+    }, '传承协议列表');
+    return normalizePaginated<AgreementListRow>(AgreementListRow, res.data as KeyValue);
+  }
+
+  async saveAgreement(dataModel: AgreementDetail) {
+    return this.post('/ich/check/saveAgreement', dataModel.toServerSide(), '传承协议保存');
+  }
+
+  async saveSelfAssessment(dataModel: SelfAssessmentDetail) {
+    return this.post('/ich/check/save', dataModel.toServerSide(), '自查评估表保存');
+  }
+
+  async downloadSelfAssessmentPdf(id: number) {
+    await downloadPdfBlob('/pdf/create', id, this.config.baseUrl, `self-assessment-${id}.pdf`);
+  }
+
+  async downloadAgreementPdf(id: number) {
+    await downloadPdfBlob('/pdf/nationalContract', id, this.config.baseUrl, `agreement-${id}.pdf`);
+  }
+
+  async saveAnnex(payload: SaveCheckAnnexPayload) {
+    return this.post('/ich/check/saveAnnex', {
+      id: payload.id,
+      name: payload.name,
+      form_id: payload.formId,
+      url: payload.url,
+      type: payload.type,
+      desc: payload.desc,
+      mimetype: payload.mimetype,
+      attach_id: payload.attachId,
+      filesize: payload.fileSize,
+    }, '证明材料保存');
+  }
+
+  async getAnnexList(formId: number) {
+    const res = await this.post('/ich/check/getAnnexList', {
+      form_id: formId,
+    }, '证明材料列表');
+    return normalizePaginated<CheckAnnexListItem>(CheckAnnexListItem, res.data as KeyValue);
+  }
+
+  async getInheritorBasic(userId?: number) {
+    const res = await this.post('/ich/check/basic', {
+      user_id: userId,
+    }, '传承人自查基础信息');
+    return transformDataModel<InheritorCheckBasicInfo>(InheritorCheckBasicInfo, res.data as KeyValue);
+  }
+
+  async getSelfAssessmentDetail(id: number, userId?: number) {
+    const res = await this.post('/ich/check/detail', { id, user_id: userId }, '评估表详情');
+    return transformDataModel<SelfAssessmentDetail>(SelfAssessmentDetail, res.data as KeyValue);
+  }
+
+  async getAgreementDetail(id: number, userId?: number) {
+    const res = await this.post('/ich/check/agreementDetail', { id, user_id: userId }, '传承协议详情');
+    return transformDataModel<AgreementDetail>(AgreementDetail, res.data as KeyValue);
+  }
+}
+
+export default new AssessmentContentApi();

Різницю між файлами не показано, бо вона завелика
+ 1 - 0
src/assets/images/icon/AgreementSign.svg


Різницю між файлами не показано, бо вона завелика
+ 1 - 0
src/assets/images/icon/EvaluationForm.svg


+ 2 - 2
src/common/config/ApiCofig.ts

@@ -3,7 +3,7 @@
  * 说明:后端接口配置
  */
 export default {
-  serverDev: 'https://mn.wenlvti.net/api',
-  serverProd: 'https://mn.wenlvti.net/api',
+  serverDev: 'https://mnwh.wenlvti.net/api',
+  serverProd: 'https://mnwh.wenlvti.net/api',
   mainBodyId: 1,
 }

+ 2 - 2
src/common/upload/AliOssUploadCo.ts

@@ -1,5 +1,5 @@
-import type { AntUploadRequestOption, UploadCoInterface } from "@/components/dynamicf/UploadImageFormItem";
 import { RandomUtils, StringUtils } from "@imengyu/imengyu-utils";
+import type { AntUploadRequestOption, UploadCoInterface } from "@imengyu/vue-dynamic-form-ant";
 import OSS from 'ali-oss';
 
 const client = new OSS({
@@ -27,7 +27,7 @@ export function useAliOssUploadCo(subPath: string) : UploadCoInterface {
       } else {
         client.multipartUpload(uploadPath, requestOption.file, {
           progress(percentage) {
-            requestOption.onProgress({ percent: percentage * 100 })
+            requestOption.onProgress?.({ percent: percentage * 100 })
           },
         }).then((res) => {
           requestOption.onSuccess?.({

+ 1 - 1
src/common/upload/ImageUploadCo.ts

@@ -1,5 +1,5 @@
 import CommonContent from "@/api/CommonContent";
-import type { AntUploadRequestOption, UploadCoInterface } from "@/components/dynamicf/UploadImageFormItem";
+import type { AntUploadRequestOption, UploadCoInterface } from "@imengyu/vue-dynamic-form-ant";
 
 export function useImageSimpleUploadCo(additionData?: Record<string, any>) : UploadCoInterface {
 

+ 2 - 2
src/components/Footer.vue

@@ -9,7 +9,7 @@
           </div>
         </div>
         <div class="col-sm-12 col-md-6">
-          <div class="d-block links text-md-end">
+          <div class="block links md:text-end">
             <span>友情链接:</span>
             <a href="https://minnan.wenlvti.net/">闽南文化生态保护区 (厦门市)</a>
             <a href="#">厦门市文化馆</a>
@@ -18,7 +18,7 @@
           </div>
         </div>
       </div>
-      <div class="row mt-3 mt-md-0">
+      <div class="row mt-4 md:mt-0">
         <div class="links">
           <a href="#">
             <img src="@/assets/images/footer/GonganLogo.png" />

+ 142 - 0
src/components/content/CommonCatalog.vue

@@ -0,0 +1,142 @@
+<script setup lang="ts">
+import { ScrollRect } from '@imengyu/vue-scroll-rect';
+import { ref, watch, type PropType } from 'vue';
+
+export interface CatalogItem {
+  title: string,
+  level: number,
+  scrollPos: number, 
+  anchor: string,
+}
+
+const emit = defineEmits([	
+  "goToItem"	
+])
+const props = defineProps({	
+  items: {
+    type: Object as PropType<CatalogItem[]>,
+    default: () => []
+  },
+  scrollContainer: {
+    type: Object as PropType<HTMLElement|null>,
+    default: () => null,
+  },
+})
+
+const activeIndex = ref(-1);
+
+function handlerContainerScroll(e: Event) {
+  const container = e.target as HTMLElement;
+  const scrollTop = container.scrollTop;
+
+  activeIndex.value = 0;
+  for (let i = props.items.length - 1; i >= 0; i--) {
+    const item = props.items[i];
+    if (item && scrollTop >= item.scrollPos) {
+      activeIndex.value = i;
+      break;
+    }
+  }
+}
+function handlerItemClick(item: CatalogItem) {
+  if (item.anchor) {
+    const el = document.getElementById(item.anchor);
+    if (el) {
+      el.scrollIntoView({ behavior: 'smooth' });
+    }
+  }
+  emit('goToItem', item);
+}
+
+watch(() => props.scrollContainer, (newVal, oldVal) => {
+  if (oldVal && oldVal instanceof HTMLElement)
+    oldVal.removeEventListener('scroll', handlerContainerScroll);
+  if (newVal && newVal instanceof HTMLElement)
+    newVal.addEventListener('scroll', handlerContainerScroll);
+}, { immediate: true });
+
+</script>
+
+<template>
+  <ScrollRect class="nana-catalog" scroll="vertical">
+    <div>
+      <div 
+        v-for="(item, index) in props.items"
+        :key="index"
+        :class="[
+          'nana-catalog-item',
+          `level-${item.level}`,
+          activeIndex === index ? 'active' : '',
+        ]"
+        @click="handlerItemClick(item)"
+      >
+        {{ item.title }}
+      </div>
+    </div>
+  </ScrollRect>
+</template>
+
+<style lang="scss">
+.nana-catalog {
+  position: relative; 
+  margin-left: 0.5rem;
+
+  > div {
+    display: flex;
+    flex-direction: column;
+  }
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    left: 0;
+    width: 1px;
+    background-color: var(--nana-text-6);
+  }
+
+  .nana-catalog-item {
+    position: relative;
+    padding: 0.4rem 0.8rem;
+    font-size: 1rem;
+    color: var(--nana-text-6);
+    user-select: none;
+    cursor: pointer;
+
+    &.active {
+      font-weight: bold; 
+      color: var(--nana-text-1);
+
+      &::after {
+        content: '';
+        position: absolute;
+        top: calc(50% - 6px);
+        left: 0;
+        border: 8px solid transparent;
+        border-left: 8px solid var(--nana-text-1);
+      }
+    }
+    &.level-1 {
+      font-size: 1.2rem;
+      padding-left: 1rem;
+    }
+    &.level-3,
+    &.level-4,
+    &.level-5 {
+      font-size: 0.8rem;
+      padding-left: 1.2rem;
+      
+      &::before {
+        content: '·';
+        display: inline-block;
+        padding-right: 0.6rem;
+      }
+    }
+    &.level-6 {
+      font-size: 0.7rem;
+      padding-left: 1.6rem;
+    }
+  }
+}
+</style>

+ 17 - 16
src/components/content/CommonListBlock.vue

@@ -3,7 +3,7 @@
   <div v-show="show" >
     <div class="content mb-2">
       <!-- 搜素栏 -->
-      <div class="row mt-3 align-items-center">
+      <div class="row mt-4 items-center">
         <!-- 左栏 -->
         <div class="col-sm-12 col-md-6 col-lg-6">
           <!-- 分类 -->
@@ -24,7 +24,7 @@
           <slot name="headLeft"></slot>
         </div>
         <!-- 右栏 -->
-        <div class="col-sm-12 col-md-6 col-lg-6 d-flex flex-row justify-content-end align-items-start flex-wrap" style="gap:5px">
+        <div class="col-sm-12 col-md-6 col-lg-6 flex flex-row justify-end items-start flex-wrap gap-[5px]">
           <Dropdown
             v-for="(drop, k) in dropDownNames" :key="k" 
             :selectedValue="dropDownValues[k]"
@@ -40,7 +40,7 @@
                 class="search-icon"
                 src="@/assets/images/news/IconSearch.png"
                 alt="搜索" 
-                @click="newsLoader.loadData(undefined, true)"
+                @click="newsLoader.load(true)"
               />
             </template>
           </SimpleInput>
@@ -81,11 +81,11 @@
           <div 
             v-for="(item, k) in newsLoader.list.value"
             :key="item.id"
-            :class="'item user-select-none main-clickable row-type'+rowType"
+            :class="'item select-none main-clickable row-type'+rowType"
             :style="{ width: rowWidth }"
             @click="handleShowDetail(item)"
           >
-            <a class="d-none" :href="router.resolve({ path: props.detailsPage, query: { id: item.id }}).href" />
+            <a class="hidden" :href="router.resolve({ path: props.detailsPage, query: { id: item.id }}).href" />
             <img
               :src="item.image || defaultImage" alt="新闻图片" 
             />
@@ -98,16 +98,16 @@
                   <div
                     v-for="(tag, k) in item.bottomTags"
                     :key="k"
-                    :class="tag ? '' : 'd-none'"
+                    :class="tag ? '' : 'hidden'"
                   >{{ tag }}</div>
                 </div>
                 <div v-if="item.addItems" class="extra">
                   <div 
                     v-for="(addItem, k) in item.addItems" 
                     :key="k" 
-                    class="d-flex flex-row align-items-center"
+                    class="flex flex-row items-center"
                     :class="[
-                      addItem.text ? '' : 'd-none',
+                      addItem.text ? '' : 'hidden',
                     ]"
                   >
                     <span class="desc">{{ addItem.name }}:</span>
@@ -140,13 +140,14 @@
 <script setup lang="ts">
 import { computed, onMounted, ref, watch, type PropType } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
-import { useSimplePagerDataLoader, SimplePageContentLoader } from '@imengyu/imengyu-web-shared';
 import TagBar from '../content/TagBar.vue';
 import Dropdown from '../controls/Dropdown.vue';
 import SimpleInput from '../controls/SimpleInput.vue';
 import Pagination from '../controls/Pagination.vue';
 import TitleDescBlock from '../parts/TitleDescBlock.vue';
 import IconSearch from '../icons/IconSearch.vue';
+import SimplePageContentLoader from './SimplePageContentLoader.vue';
+import { useSimplePagerDataLoader } from '@/composeables/SimplePagerDataLoader';
 
 export interface DropdownCommonItem {
   id: number; 
@@ -307,11 +308,11 @@ const searchText = ref(props.startSearchText);
 const dropDownValues = ref<any>([]);
 
 function handleSearch() {
-  newsLoader.loadData(undefined, true);
+  newsLoader.load(true);
 }
 function handleChangeDropDownValue(index: number, value: number) {
   dropDownValues.value[index] = value;
-  newsLoader.loadData(undefined, true);
+  newsLoader.load(true);
 }
 function handleShowDetail(item: any) {
   if (props.showDetail)
@@ -348,11 +349,11 @@ watch(() => props.dropDownNames, () => {
   loadDropValues();
 })
 watch(selectedTag, () => {
-  newsLoader.loadData(undefined, true);
+  newsLoader.load(true);
 })
 watch(tableListShow, (v) => {
   pageSize.value = v ? 100 : props.pageSize;
-  newsLoader.loadData(undefined, true);
+  newsLoader.load(true);
 })
 
 function loadDropValues() {
@@ -363,7 +364,7 @@ function loadDropValues() {
       dropDownValues.value.push(element.defaultSelectedValue);
   if(isEqual(oldDropDownValues, dropDownValues.value))
     return;
-  newsLoader.loadData();
+  newsLoader.load();
 }
 function isEqual(arr1 : Array<unknown>, arr2 : Array<unknown>) : boolean {
   if(!arr1 && !arr2)
@@ -383,7 +384,7 @@ function isEqual(arr1 : Array<unknown>, arr2 : Array<unknown>) : boolean {
 onMounted(() => {
   setTimeout(() => {
     loadDropValues();
-    newsLoader.loadData(undefined, true);
+    newsLoader.load(true);
   }, 600);
 })
 watch(route, () => {
@@ -392,7 +393,7 @@ watch(route, () => {
 
 defineExpose({
   reload() {
-    newsLoader.loadData(undefined, true);
+    newsLoader.load(true);
   }
 })
 </script>

+ 63 - 0
src/components/content/ImageGrid.vue

@@ -0,0 +1,63 @@
+<template>
+  <div class="w-100 d-flex flex-row flex-wrap" :style="{ gap: gap }">
+    <slot 
+      name="item"
+      v-for="(v, k) in data"
+      :key="k"
+      :item="v"
+      :index="k"
+      :width="`calc(${100 / rowCount}% - ${gap})`"
+      :height="imageHeight"
+      :url="imagekey ? v[imagekey] : v" 
+    >
+      <a-image
+        :src="imagekey ? v[imagekey] : v" 
+        :style="{ 
+          width: `calc(${100 / rowCount}% - ${gap})`,
+          height: imageHeight,
+          borderRadius: '5px',
+          objectFit: 'cover',
+        }"
+        @click="()=>emit('itemClick', v)"
+      />
+    </slot>
+    <slot name="empty" v-if="!data || data.length === 0">
+      <div class="w-100 text-center">
+        <ExclamationCircleOutlined />
+        <div>暂无数据</div>
+      </div>
+    </slot>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { PropType } from 'vue';
+import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
+
+defineProps({	
+  rowCount : {
+    type: Number,
+    default: 3,
+  },
+  imagekey : {
+    type: String,
+    default: undefined,
+  },
+  imageHeight : {
+    type: String,
+    default: undefined,
+  },
+  gap: {
+    type: String,
+    default: '10px',
+  },
+  data: {
+    type: Object as PropType<any[]>,
+    default: null,
+  },
+})
+
+const emit = defineEmits([	
+  "itemClick"	
+])
+</script>

+ 0 - 0
src/components/content/ShowValueOrNull.vue


+ 81 - 0
src/components/content/SimplePageContentLoader.vue

@@ -0,0 +1,81 @@
+<template>
+  <div
+    v-if="loader?.status.value == 'loading'"
+    style="min-height: 200rpx;display: flex;justify-content: center;align-items: center;"
+  >
+    <Spin tip="加载中" />
+  </div>
+  <div
+    v-else-if="loader?.status.value == 'error'"
+    style="min-height: 200rpx"
+  >
+    <Empty :description="loader.error.value" >
+      <Button  @click="handleRetry">重试</Button>
+    </Empty>
+  </div>
+  <template v-else-if="loader?.status.value == 'finished' || loader?.status.value == 'nomore'">
+    <slot />
+  </template>
+  <div
+    v-if="showEmpty || loader?.status.value == 'nomore'"
+    style="min-height: 200rpx"
+    class="empty"
+  >
+    <Empty :description="emptyView?.text ?? '暂无数据'">
+      <Button
+        v-if="emptyView?.button"
+        @click="emptyView?.buttonClick ?? handleRetry"
+      >
+        {{emptyView?.buttonText ?? '刷新'}}
+      </Button>
+    </Empty>
+  </div>
+</template>
+
+<script setup lang="ts">
+import type { ILoaderCommon } from '@/composeables/LoaderCommon';
+import { Button, Empty, Spin } from 'ant-design-vue';
+import { onMounted, ref, type PropType } from 'vue';
+
+const props = defineProps({	
+  loader: {
+    type: Object as PropType<ILoaderCommon<any>>,
+    default: null,
+  },
+  autoLoad: {
+    type: Boolean,
+    default: false, 
+  },
+  showEmpty: {
+    type: Boolean,
+    default: false, 
+  },
+  emptyView: {
+    type: Object as PropType<{
+      text: string,
+      buttonText: string,
+      button: boolean,
+      buttonClick: () => void,
+    }>,
+    default: null,
+  },
+})
+
+const loaded = ref(false);
+
+onMounted(() => {
+  loaded.value = false;
+  if (props.autoLoad)
+    handleLoad(); 
+});
+
+function handleRetry() {
+  props.loader.load();
+}
+function handleLoad() {
+  if (loaded.value) 
+    return;
+  loaded.value = true;
+  props.loader.load();
+}
+</script>

+ 146 - 0
src/components/content/SimpleRichHtml.vue

@@ -0,0 +1,146 @@
+<template>
+  <div v-show="show" ref="scrollContainer" class="nana-rich-html-container">
+    <div class="rich-html">
+      <slot name="prepend" />
+      <template 
+        v-for="(content, i) in contents"
+        :key="i"
+      >
+        <div 
+          v-if="content && content != 'null'"
+          :data-r-id="id"
+          class="content"
+          v-html="content"
+        />
+      </template>
+      <div v-if="!contents || contents.length === 0 || (contents.length === 1 && !contents[0])" class="w-100 text-center">
+        <ExclamationCircleOutlined />
+        <div>暂无数据</div>
+      </div>
+      <slot name="append" />
+    </div>
+    <div class="rich-html-catalog" v-if="catalog && catalogItems.length > 0">
+      <CommonCatalog
+        :items="catalogItems"
+        :scrollContainer="scrollContainer"
+      />
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { RandomUtils } from '@imengyu/imengyu-utils';
+import CommonCatalog, { type CatalogItem } from './CommonCatalog.vue';
+import { onBeforeUnmount, onMounted, ref, watch, type PropType } from 'vue';
+import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
+
+const props = defineProps({	
+  contents: {
+    type: Array as PropType<string[]>,
+    default: () => ([]),
+  },
+  tagStyle: {
+    type: Object as PropType<Record<string, string>>,
+    default: () => ({}),
+  },
+  catalog: {
+    type: Boolean,
+    default: true,
+  },
+  noStyle: {
+    type: Boolean,
+    default: false,
+  },
+  show: {
+    type: Boolean,
+    default: true,
+  },
+})
+
+const id = RandomUtils.genNonDuplicateIDHEX(12);
+const catalogItems = ref<CatalogItem[]>([]);
+const scrollContainer = ref<HTMLElement|null>(null);
+let lastStyleTag : HTMLElement|null = null;
+
+function genTagCss() {
+  if (Object.keys(props.tagStyle).length > 0) {
+    const style = document.createElement('style');
+    let css = '';
+    for (const key in props.tagStyle) {
+      css += `.rich-html div[data-r-id="${id}"] ${key} { ${props.tagStyle[key]} } `
+    }
+    style.innerHTML = css;
+    document.body.appendChild(style);
+    lastStyleTag = style;
+  }
+}
+function generateCatalog() {
+  catalogItems.value = [];
+
+  if (!props.catalog) 
+    return;
+
+  let anchrId = 0;
+  for (let i = 1; i <= 6; i++) {
+    const heades = document.querySelectorAll(`.rich-html div[data-r-id="${id}"] h${i}`);
+    for (const header of heades) {
+      anchrId++;
+      if (header instanceof HTMLHeadingElement) {
+        if (header.id == '')
+          header.id = 'header' + anchrId + 'a' + RandomUtils.genNonDuplicateIDHEX(12);
+        catalogItems.value.push({
+          title: header.textContent || '',
+          scrollPos: header.offsetTop,
+          level: i,
+          anchor: header.id,
+        });
+      }
+    }
+  }
+  catalogItems.value.sort((a, b) => {
+    return a.scrollPos - b.scrollPos;
+  })
+}
+
+watch(() => props.contents, () => {
+  setTimeout(() => {
+    generateCatalog();
+  }, 200);
+}, { immediate: true })
+
+onBeforeUnmount(() => {
+  if (lastStyleTag) {
+    lastStyleTag.parentElement?.removeChild(lastStyleTag);
+    lastStyleTag = null;
+  }
+})
+onMounted(() => {
+  genTagCss()
+});
+</script>
+
+<style lang="scss">
+.nana-rich-html-container {
+  position: relative;
+  display: flex;
+  flex-direction: row;
+
+  .rich-html {
+    flex: 1 1 100%;
+  }
+  .rich-html-catalog {
+    position: fixed;
+    top: 140px;
+    right: 0;
+    bottom: 0px;
+    width: 15rem;
+  }
+}
+
+
+@media (max-width: 1648px) {
+  .rich-html-catalog {
+    display: none;
+  }
+}
+</style>

+ 1 - 1
src/components/content/TagBar.vue

@@ -1,6 +1,6 @@
 <template>
   <!-- 单选标签选择按钮条,可显示一行标签,然后高亮选中项 -->
-  <div class="d-flex flex-row flex-wrap">
+  <div class="flex flex-row flex-wrap">
     <div
       :class="[
         'tag-button',

+ 3 - 2
src/components/parts/EmptyToRecord.vue

@@ -1,11 +1,12 @@
 <script setup lang="ts">
 import type { PropType } from 'vue';
-import { type ISimpleDataLoader, SimplePageContentLoader } from '@imengyu/imengyu-web-shared';
+import SimplePageContentLoader from '../content/SimplePageContentLoader.vue';
+import type { ILoaderCommon } from '@/composeables/LoaderCommon';
 
 const emit = defineEmits([ 'edit' ]);
 defineProps({
   loader: {
-    type: Object as PropType<ISimpleDataLoader<any, any>>,
+    type: Object as PropType<ILoaderCommon<any>>,
     default: undefined
   },
   title: {

+ 1 - 1
src/components/parts/TitleDescBlock.vue

@@ -22,7 +22,7 @@
 
 <script setup lang="ts">
 import { ref } from 'vue';
-import { SimpleRichHtml } from '@imengyu/imengyu-web-shared';
+import SimpleRichHtml from '../content/SimpleRichHtml.vue';
 
 const props = defineProps({	
   title : {

+ 11 - 0
src/composeables/LoaderCommon.ts

@@ -0,0 +1,11 @@
+import type { Ref } from "vue";
+
+export type LoaderLoadType = 'loading' | 'finished' | 'nomore' | 'error';
+
+export interface ILoaderCommon<T> {
+  error: Ref<string>;
+  loading: Ref<boolean>;
+  status: Ref<LoaderLoadType>;
+  content: Ref<T>;
+  load: (refresh?: boolean) => Promise<void>;
+}

+ 132 - 0
src/composeables/SimplePagerDataLoader.ts

@@ -0,0 +1,132 @@
+import { watch, ref, computed, type Ref } from "vue"
+import type { ILoaderCommon, LoaderLoadType } from "./LoaderCommon";
+
+export interface ISimplePageListLoader<T, P> extends ILoaderCommon<T> {
+  list: Ref<T[]>;
+  page: Ref<number>;
+  next: () => Promise<void>;
+  prev: () => Promise<void>;
+  total: Ref<number>;
+  totalPages: Ref<number>;
+}
+
+/**
+ * 简单分页数据封装。
+ * 
+ * 该封装了分页数据的加载、分页、上一页、下一页等功能。当页码发生变化时,会自动调用加载函数。
+ * 简单分页同时只能显示一页数据,重新加载会覆盖之前的数据。
+ * 
+ * 使用示例:
+ * ```ts
+ * const { data, page, total, loading } = useSimplePagerDataLoader(10, async (page, pageSize) => {
+ *   const res = await fetch(`/api/data?page=${page}&pageSize=${pageSize}`);
+ *   const data = await res.json();  
+ *   return {
+ *     data,
+ *     page: res.page,
+ *     total: res.total,
+ *   };
+ * });
+ * ```
+ *
+ * @param pageSize 一页的数量
+ * @param loader 加载函数
+ * @returns 
+ */
+export function useSimplePagerDataLoader<T, P = any>(
+  pageSize: number|Ref<number>, 
+  loader: (page: number, pageSize: number, params?: P) => Promise<{
+    data: T[],
+    total: number,
+  }>)  : ISimplePageListLoader<T, P>
+{
+  const page = ref(0);
+  const list = ref<T[]>([]) as Ref<T[]>;
+  const total = ref(0);
+  const totalPages = computed(() => Math.ceil(total.value / getPageSize()));
+  const status = ref<LoaderLoadType>('loading');
+  const error = ref('');
+  const loadError = ref('');
+
+  function getPageSize() {
+    return typeof pageSize == 'object'? pageSize.value : pageSize;
+  }
+  
+  watch(page, async () => {
+    await loadData(false);
+  });
+
+  let lastParams: P | undefined;
+  const loading = ref(false);
+
+  async function loadData(refresh: boolean = false) {
+    if (loading.value) 
+      return;
+    if (refresh) {
+      page.value = 1;
+    }
+    list.value = []; 
+    status.value = 'loading';
+    loading.value = true;
+
+    try {
+      const res = (await loader(page.value, getPageSize(), lastParams));
+      list.value = list.value.concat(res.data);
+      total.value = res.total;
+      status.value = res.data.length > 0 ? 'finished' : 'nomore';
+      error.value = '';
+      loading.value = false;
+    } catch(e) {
+      error.value = '' + e;
+      status.value = 'error';
+      loading.value = false;
+    }
+  }
+  /**
+   * 下一页
+   */
+  async function next() {
+    if (page.value > total.value)
+      return;
+    page.value++;
+    await loadData(false);
+  }
+  /**
+   * 上一页
+   */
+  async function prev() {
+    if (page.value <= 1)
+      return;   
+    page.value--;
+    await loadData(false);
+  }
+
+  return {
+    load: loadData,
+    next,
+    prev,
+    /**
+     * 数据
+     */
+    list,
+    /**
+     * 内容
+     */
+    content: list as any,
+    /**
+     * 当前页码
+     */
+    page,
+    /**
+     * 总数据条数
+     */
+    total,
+    /**
+     * 总页数
+     */
+    totalPages,
+    loading,
+    error,
+    status,
+  }
+}

+ 19 - 0
src/composeables/useMemorizeVar.ts

@@ -0,0 +1,19 @@
+import { ref, watch, onMounted } from "vue";
+
+export function useMemorizeVar<T>(key: string, initialValue: T) {
+  const variable = ref<T>(initialValue);
+
+  watch(variable, (newValue) => {
+    localStorage.setItem(key, JSON.stringify(newValue));
+  })
+  onMounted(() => {
+    const storedValue = localStorage.getItem(key);
+    if (storedValue) {
+      variable.value = JSON.parse(storedValue);
+    }
+  })
+
+  return {
+    variable,
+  }
+}

+ 40 - 0
src/composeables/useSimpleDataLoader.ts

@@ -0,0 +1,40 @@
+import { onMounted, ref } from "vue"
+import type { ILoaderCommon, LoaderLoadType } from "./LoaderCommon"
+
+export function useSimpleDataLoader<T>(
+  loader: () => Promise<T>, 
+  options?: { immediate?: boolean }
+) : ILoaderCommon<any> {
+  const content = ref<T>()
+  const loading = ref(false)
+  const error = ref('')
+  const status = ref<LoaderLoadType>('loading')
+
+  function load(refresh?: boolean) {
+    status.value = 'loading'
+    loading.value = true
+    return loader().then((res) => {
+      content.value = res
+      status.value = 'finished'
+      loading.value = false
+    }).catch((err) => {
+      error.value = err
+      status.value = 'error'
+      loading.value = false
+    })
+  }
+
+  if (options?.immediate !== false) {
+    onMounted(() => {
+      load()
+    })
+  }
+
+  return {
+    content,
+    loading,
+    error,
+    status,
+    load,
+  }
+}

+ 19 - 0
src/composeables/useWindowOnUnLoadConfirm.ts

@@ -0,0 +1,19 @@
+import { onMounted, onUnmounted } from "vue";
+
+export function useWindowOnUnLoadConfirm() {
+
+  function onUnLoad(e: BeforeUnloadEvent) {
+    const msg = '你还有未保存的内容,确定要离开吗?';
+    e.returnValue = msg;
+    return msg;
+  }
+
+  onMounted(() => {
+    window.addEventListener('beforeunload', onUnLoad);
+    window.addEventListener('popstate', onUnLoad);
+  })
+  onUnmounted(() => {
+    window.removeEventListener('beforeunload', onUnLoad);
+    window.removeEventListener('popstate', onUnLoad);
+  })
+}

+ 7 - 3
src/main.ts

@@ -1,5 +1,7 @@
+import './tailwind.css'
 import 'vue3-carousel/carousel.css'
-import '@imengyu/imengyu-web-shared/lib/imengyu-web-shared.css'
+import '@imengyu/vue-dynamic-form-ant/lib/vue-dynamic-form-ant.css'
+import '@imengyu/vue-dynamic-form-rich/lib/vue-dynamic-form-rich.css'
 import '@vueup/vue-quill/dist/vue-quill.snow.css';
 import 'tinymce/tinymce';
 import 'tinymce/themes/silver/theme';
@@ -11,7 +13,8 @@ import { createPinia } from 'pinia'
 import App from './App.vue'
 import router from './router'
 import NProgress from 'nprogress';
-import ImengyuCommon from '@imengyu/imengyu-web-shared';
+import VueDynamicFormAnt from '@imengyu/vue-dynamic-form-ant';
+import VueDynamicFormRich from '@imengyu/vue-dynamic-form-rich';
 import { registryConvert } from '@/common/ConvertRgeistry'
 import { initAMapApiLoader } from '@vuemap/vue-amap';
 import { QuillEditor } from '@vueup/vue-quill'
@@ -27,7 +30,8 @@ const app = createApp(App)
 
 app.use(createPinia())
 app.use(router)
-app.use(ImengyuCommon, {})
+app.use(VueDynamicFormAnt, {})
+app.use(VueDynamicFormRich, {})
 app.component('QuillEditor', QuillEditor);
 app.mount('#app').$nextTick(() => {
   configDynamicForm();

+ 6 - 5
src/pages/admin.vue

@@ -77,7 +77,7 @@
             </CommonListBlock>
           </a-tab-pane>
           <a-tab-pane key="3" tab="传习所列表">
-            <div v-if="false" class="d-flex justify-content-end">
+            <div v-if="false" class="flex justify-end">
               <a-button type="primary" @click="router.push({ name: 'FormSeminar' })">+ 新增</a-button>
             </div>
 
@@ -107,7 +107,7 @@
             <a-empty description="暂无数据" />
           </a-tab-pane>
           <a-tab-pane v-if="false" key="4" tab="重点区域">
-            <div class="d-flex justify-content-end">
+            <div class="flex justify-end">
               <a-button type="primary" :disabled="true" @click="router.push({ name: 'FormWork' })">+ 新增</a-button>
             </div>
             <CommonListBlock 
@@ -139,7 +139,6 @@
 import { computed, h, ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import { useAuthStore } from '@/stores/auth';
-import { useSimpleDataLoader, memorizeVar } from '@imengyu/imengyu-web-shared';
 import { message, Modal } from 'ant-design-vue';
 import type { GetContentListItem } from '@/api/CommonContent';
 import useClipboard from 'vue-clipboard3';
@@ -148,6 +147,8 @@ import CommonListBlock, { type DropdownCommonItem } from '@/components/content/C
 import InheritorContent from '@/api/inheritor/InheritorContent';
 import AdminItemState from './components/AdminItemState.vue';
 import { InfoCircleOutlined } from '@ant-design/icons-vue';
+import { useSimpleDataLoader } from '@/composeables/useSimpleDataLoader';
+import { useMemorizeVar } from '@/composeables/useMemorizeVar';
 
 const { toClipboard } = useClipboard();
 const router = useRouter();
@@ -156,8 +157,8 @@ const authStore = useAuthStore();
 const activeKey = ref(route.query.tab as string || '1');
 const inheritorData = ref<GetContentListItem[]>([]);
 
-const { variable: lastValueCategory } = memorizeVar('categoryLastSelectValue', 0);
-const { variable: lastValueStatus } = memorizeVar('statusLastSelectValue', -10);
+const { variable: lastValueCategory } = useMemorizeVar('categoryLastSelectValue', 0);
+const { variable: lastValueStatus } = useMemorizeVar('statusLastSelectValue', -10);
 
 const computedCategoryOptions = computed<DropdownCommonItem[]>(() => {
   if (authStore.isReviewer) {

+ 4 - 4
src/pages/admin/seminar.vue

@@ -11,9 +11,9 @@
         </div>
 
         <EmptyToRecord title="传习所" :loader="worksData" :showEdited="false" :showAdd="false">
-          <div class="d-flex justify-content-between p-2">
+          <div class="flex justify-between p-2">
             <a-button @click="router.back" :icon="h(ArrowLeftOutlined)">返回</a-button>
-            <div class="d-flex flex-row align-items-center">
+            <div class="flex flex-row items-center">
               <SimpleInput v-model="searchText" placeholder="请输入关键词" @search="handleSearch">
                 <template #suffix>
                   <IconSearch
@@ -24,7 +24,7 @@
                   />
                 </template>
               </SimpleInput>
-              <a-button class="ms-3" type="primary" @click="handleNewSeminar">+ 新增</a-button>
+              <a-button class="ms-4" type="primary" @click="handleNewSeminar">+ 新增</a-button>
             </div>
           </div>
           <a-list class="light-round" item-layout="horizontal" :data-source="worksData?.content.value || []">
@@ -52,13 +52,13 @@
 
 <script setup lang="ts">
 import { useRoute, useRouter } from 'vue-router';
-import { useSimpleDataLoader } from '@imengyu/imengyu-web-shared';
 import EmptyToRecord from '@/components/parts/EmptyToRecord.vue';
 import InheritorContent, { InheritorWorkInfo } from '@/api/inheritor/InheritorContent';
 import { ArrowLeftOutlined } from '@ant-design/icons-vue';
 import { h, ref } from 'vue';
 import SimpleInput from '@/components/controls/SimpleInput.vue';
 import IconSearch from '@/components/icons/IconSearch.vue';
+import { useSimpleDataLoader } from '@/composeables/useSimpleDataLoader';
 
 const router = useRouter();
 const route = useRoute();

+ 2 - 2
src/pages/admin/works.vue

@@ -11,7 +11,7 @@
         </div>
 
         <EmptyToRecord title="作品" :loader="worksData" :showEdited="false" :showAdd="false">
-          <div class="d-flex justify-content-between p-2">
+          <div class="flex justify-between p-2">
             <a-button @click="router.back" :icon="h(ArrowLeftOutlined)">返回</a-button>
             <div></div>
           </div>
@@ -40,11 +40,11 @@
 
 <script setup lang="ts">
 import { useRoute, useRouter } from 'vue-router';
-import { useSimpleDataLoader } from '@imengyu/imengyu-web-shared';
 import EmptyToRecord from '@/components/parts/EmptyToRecord.vue';
 import { ArrowLeftOutlined } from '@ant-design/icons-vue';
 import { h } from 'vue';
 import InheritorContent, { InheritorWorkInfo } from '@/api/inheritor/InheritorContent';
+import { useSimpleDataLoader } from '@/composeables/useSimpleDataLoader';
 
 
 const router = useRouter();

+ 2 - 2
src/pages/change-password.vue

@@ -16,7 +16,7 @@
               sub-title="您可以使用新密码登录"
             >
               <template #extra>
-                <a-button class="mt-3" block @click="router.back()">返回</a-button>
+                <a-button class="mt-4" block @click="router.back()">返回</a-button>
               </template>
             </a-result>
           </template>
@@ -27,7 +27,7 @@
               :options="formOptions"
             />
             <a-button type="primary" block :loading="isSubmiting" @click="handleSubmit">确认修改</a-button>
-            <a-button class="mt-3" block @click="router.back()">返回</a-button>
+            <a-button class="mt-4" block @click="router.back()">返回</a-button>
           </template>
         </div>
       </div>

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

@@ -0,0 +1,333 @@
+<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>
+        </div>
+        <a-spin :spinning="loader.loading.value">
+          <template v-if="!loader.loading.value">
+            <a-result
+              v-if="!currentAgreement"
+              status="info"
+              title="您还未签署传承协议"
+              sub-title="请先签署传承协议"
+            >
+              <template #extra>
+                <a-button type="primary" @click="createAgreement">去签署传承协议</a-button>
+              </template>
+            </a-result>
+            <div v-else>
+              <a-alert type="info" message="请仔细阅读传承协议,确保您已理解内容,并签署传承协议。" class="mb-4" show-icon />
+              <a-form
+                v-if="currentAgreement"
+                ref="formRef"
+                :model="currentAgreement"
+                :rules="formRules"
+                layout="vertical"
+              >
+                <a-typography-title class="mt-5!" :level="4">{{ agreementTitle }}</a-typography-title>
+
+                <AgreementBodyNational
+                  v-if="agreementLevel === 23"
+                  :detail="(currentAgreement as AgreementDetail)"
+                  :agreement-year="agreementYear"
+                  :party-a-stamp-date="partyAStampDate"
+                  :party-b-sign-date="partyBSignDate"
+                  @update:party-a-stamp-date="partyAStampDate = $event"
+                  @update:party-b-sign-date="partyBSignDate = $event"
+                />
+                <AgreementBodyProvincial
+                  v-else-if="agreementLevel === 24"
+                  :detail="(currentAgreement as AgreementDetail)"
+                  :agreement-year="agreementYear"
+                  :party-a-stamp-date="partyAStampDate"
+                  :party-b-sign-date="partyBSignDate"
+                  @update:party-a-stamp-date="partyAStampDate = $event"
+                  @update:party-b-sign-date="partyBSignDate = $event"
+                />
+                <AgreementBodyMunicipal
+                  v-else
+                  :detail="(currentAgreement as AgreementDetail)"
+                  :agreement-year="agreementYear"
+                  :party-a-stamp-date="partyAStampDate"
+                  :party-b-sign-date="partyBSignDate"
+                  @update:party-a-stamp-date="partyAStampDate = $event"
+                  @update:party-b-sign-date="partyBSignDate = $event"
+                />
+              </a-form>
+
+              <a-space direction="vertical" class="w-full mt-4" size="middle">
+                <a-button type="primary" block :loading="submitLoading" @click="saveAgreement">保存传承协议</a-button>
+                <a-button block :loading="submitLoading" @click="downloadAgreement">下载协议 PDF</a-button>
+              </a-space>
+            </div>
+          </template>
+        </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 type { FormInstance } from 'ant-design-vue';
+import type { Rules } from 'async-validator';
+import { RequestApiError } from '@imengyu/imengyu-utils';
+import { useAuthStore } from '@/stores/auth';
+import AssessmentContentApi, { AgreementDetail } from '@/api/collect/AssessmentContent';
+import AgreementBodyNational from './components/AgreementBodyNational.vue';
+import AgreementBodyProvincial from './components/AgreementBodyProvincial.vue';
+import AgreementBodyMunicipal from './components/AgreementBodyMunicipal.vue';
+import type { AgreementYmdParts } from './components/AgreementDateWriteBlock.vue';
+import { useSimpleDataLoader } from '@/composeables/useSimpleDataLoader';
+import { ArrowLeftOutlined } from '@ant-design/icons-vue';
+
+function formatErr(e: unknown): string {
+  if (e instanceof RequestApiError)
+    return e.errorMessage;
+  if (e instanceof Error)
+    return e.message;
+  return String(e);
+}
+
+const router = useRouter();
+const route = useRoute();
+const authStore = useAuthStore();
+
+const queryId = computed(() => Number(route.query.id) || 0);
+const queryUserId = computed(() => Number(route.query.userId) || 0);
+
+const currentAgreement = ref<AgreementDetail | null>(null);
+const partyAStampDate = ref<AgreementYmdParts>({ year: '', month: '', day: '' });
+const partyBSignDate = ref<AgreementYmdParts>({ year: '', month: '', day: '' });
+const formRef = ref<FormInstance | null>(null);
+
+const CN_MOBILE_RE = /^1\d{10}$/;
+const CN_ID_RE = /^(?:\d{15}|\d{17}[\dXx])$/;
+
+const agreementLevel = computed(() => {
+  const v = currentAgreement.value?.level;
+  if (v === 24) return 24;
+  if (v === 25) return 25;
+  return 23;
+});
+
+const formRules = computed<Rules>(() => {
+  const rules: Rules = {
+    partyB: [{ required: true, message: '请填写乙方(传承人)姓名' }],
+    apprentice: [
+      { required: true, message: '请填写本年度带徒人数' },
+      { type: 'integer', min: 0, message: '须为不小于 0 的整数' },
+    ],
+    activity: [
+      { required: true, message: '请填写本年度宣传活动场次' },
+      { type: 'integer', min: 0, message: '须为不小于 0 的整数' },
+    ],
+    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();
+        },
+      },
+    ],
+  };
+  if (agreementLevel.value === 25)
+    rules.partyA = [{ required: true, message: '请填写甲方单位全称' }];
+  else
+    rules.course = [
+      { required: true, message: '请填写本年度研修班场次' },
+      { type: 'integer', min: 0, message: '须为不小于 0 的整数' },
+    ];
+  return rules;
+});
+
+async function loadBasicInfo() {
+  const uid = authStore.userInfo?.id ?? authStore.userId;
+  const basicInfo = await AssessmentContentApi.getInheritorBasic(uid);
+  const d = currentAgreement.value;
+  if (!d)
+    return;
+  d.partyB = basicInfo.name;
+  d.mobile = basicInfo.mobile;
+  d.idCard = basicInfo.idCard;
+  d.ich = basicInfo.ichName;
+}
+
+const agreementYear = computed(() => currentAgreement.value?.year ?? new Date().getFullYear());
+const agreementTitle = computed(
+  () => `${agreementYear.value} 年度${levelTitle.value}非物质文化遗产代表性传承人传承协议`,
+);
+const levelTitle = computed(() => {
+  if (agreementLevel.value === 23) return '国家级';
+  if (agreementLevel.value === 24) return '省级';
+  return '市级';
+});
+
+const loader = useSimpleDataLoader(async () => {
+  if (queryId.value > 0) {
+    const detail = await AssessmentContentApi.getAgreementDetail(queryId.value, queryUserId.value || undefined);
+    currentAgreement.value = detail;
+    partyAStampDate.value = { year: '', month: '', day: '' };
+    partyBSignDate.value = { year: '', month: '', day: '' };
+    return currentAgreement.value;
+  }
+  const uid = authStore.userInfo?.id ?? authStore.userId;
+  const basicInfo = await AssessmentContentApi.getInheritorBasic(uid);
+  if (basicInfo.agreementId > 0) {
+    const detail = await AssessmentContentApi.getAgreementDetail(
+      basicInfo.agreementId,
+      uid,
+    );
+    currentAgreement.value = detail;
+    partyAStampDate.value = { year: '', month: '', day: '' };
+    partyBSignDate.value = {
+      year: detail.updatetime.getFullYear().toString(),
+      month: (detail.updatetime.getMonth() + 1).toString(),
+      day: detail.updatetime.getDate().toString(),
+    };
+  } else {
+    currentAgreement.value = null;
+  }
+  return currentAgreement.value;
+}, {
+  immediate: false,
+});
+
+onMounted(() => {
+  loader.load();
+});
+
+watch(
+  () => [queryId.value, queryUserId.value],
+  () => {
+    loader.load();
+  },
+);
+
+const submitLoading = ref(false);
+
+async function createAgreement() {
+  const now = new Date();
+  const u = authStore.userInfo;
+  const nick = u?.nickname || u?.username || '';
+  const uid = authStore.userInfo?.id ?? authStore.userId;
+  const basicInfo = await AssessmentContentApi.getInheritorBasic(uid);
+  const detail = new AgreementDetail();
+  detail.userId = uid;
+  detail.year = new Date().getFullYear();
+  detail.level = basicInfo.level;
+  if (basicInfo.level === 24) {
+    detail.partyA = '厦门市文化和旅游局';
+  } else if (basicInfo.level === 25) {
+    detail.partyA = '';
+  } else {
+    detail.partyA = '福建省文化和旅游厅';
+  }
+  detail.partyB = nick;
+  partyAStampDate.value = {
+    year: now.getFullYear().toString(),
+    month: (now.getMonth() + 1).toString(),
+    day: now.getDate().toString(),
+  };
+  partyBSignDate.value = {
+    year: now.getFullYear().toString(),
+    month: (now.getMonth() + 1).toString(),
+    day: now.getDate().toString(),
+  };
+  currentAgreement.value = detail;
+  await loadBasicInfo();
+}
+
+async function saveAgreement() {
+  try {
+    await formRef.value?.validate();
+  } catch {
+    message.warning('请填写完整信息');
+    return;
+  }
+  submitLoading.value = true;
+  try {
+    const d = currentAgreement.value;
+    if (!d) {
+      submitLoading.value = false;
+      return;
+    }
+    await AssessmentContentApi.saveAgreement(d as AgreementDetail);
+    message.success('保存传承协议成功');
+  } catch (error) {
+    Modal.error({
+      title: '保存传承协议失败',
+      content: formatErr(error),
+    });
+  }
+  submitLoading.value = false;
+}
+
+async function downloadAgreement() {
+  if (!currentAgreement.value?.id) {
+    message.warning('请先保存传承协议后再下载 PDF');
+    return;
+  }
+  try {
+    await AssessmentContentApi.downloadAgreementPdf(currentAgreement.value.id);
+    message.success('已开始下载');
+  } catch (error) {
+    Modal.error({
+      title: '下载失败',
+      content: formatErr(error),
+    });
+  }
+}
+</script>
+

+ 21 - 0
src/pages/collect/assessment/components/AgreementBody.vue

@@ -0,0 +1,21 @@
+<template>
+  <div class="agreement-body-wrap">
+    <div>
+      <slot />
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.agreement-body-wrap {
+  padding: 46px 18px;
+  border-radius: 8px;
+  background: #fafafa;
+  border: 1px solid #eee;
+
+  > div {
+    max-width: 800px;
+    margin: 0 auto;
+  }
+}
+</style>

+ 119 - 0
src/pages/collect/assessment/components/AgreementBodyMunicipal.vue

@@ -0,0 +1,119 @@
+<template>
+  <AgreementBody>
+    <a-typography-paragraph>甲方:(设文化和旅游局)</a-typography-paragraph>
+    <a-form-item label="乙方(传承人)" name="partyB">
+      <AgreementPrefillInline
+        v-model="detail.partyB"
+        placeholder="请填写乙方(传承人)姓名"
+      />
+    </a-form-item>
+
+    <a-typography-paragraph :style="paragraphStyle">
+      为传承弘扬中华优秀传统文化,有效保护和传承非物质文化遗产,鼓励和支持市级非物质文化遗产代表性传承人开展传承活动,根据《福建省非物质文化遗产条例》《厦门市市级非物质文化遗产代表性传承人认定与管理办法》等有关法律法规,制定协议,并按照下列各项条款签署,甲、乙双方共同遵守。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      九、甲乙双方应当以习近平新时代中国特色社会主义思想为指导,坚持以人民为中心,弘扬社会主义核心价值观,共同保护传承非物质文化遗产,推动中华优秀传统文化创造性转化、创新性发展。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      十、甲方按照《厦门市市级非物质文化遗产代表性传承人认定与管理办法》的要求,支持市级非物质文化遗产代表性传承人开展传承、传播活动。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      十一、甲方按照《福建省非物质文化遗产保护与传承专项资金管理办法》的要求,落实市文化和旅游局给予的代表性传承人的传承补助。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      十二、乙方应积极开展传承活动,培养后继人才,制定传承计划,{{ agreementYear }} 年度带徒
+      <AgreementPrefillInline
+        v-model="detail.apprentice"
+        number-mode
+        placeholder="人数"
+        suffix="人。"
+      />
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      十三、乙方应妥善保存相关实物、资料情况。主动保存、提供与该项非遗项目有关的原始资料、实物,配合记录工作。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      十四、乙方应主动、及时配合非遗调查,主动向文化和旅游主管部门、非遗保护中心反映非遗项目保护、传承情况和总结材料,并完成文化和旅游主管部门临时交办的非遗工作任务,提出保护的意见、建议。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      十五、乙方应积极、主动参加各级政府组织的非物质文化遗产公益性宣传活动,{{ agreementYear }} 年度完成
+      <AgreementPrefillInline
+        v-model="detail.activity"
+        number-mode
+        placeholder="场次"
+        suffix="场。"
+      />
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      十六、乙方应合理使用市级非物质文化遗产代表性传承人补助经费,用于开展非遗项目的传习活动,做好传承补助经费使用记录、支出范围和绩效评价等,不得用于生活补助。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      十七、乙方应积极参与非物质文化遗产相关理论和实践研究、发表(出版)论文、专著等研究。
+    </a-typography-paragraph>
+  </AgreementBody>
+
+  <a-divider />
+
+  <AgreementBody>
+    <a-form-item label="甲方" name="partyA">
+      <a-input v-model:value="detail.partyA" placeholder="设区市文化和旅游局全称" />
+    </a-form-item>
+    <a-form-item label="负责人(代表人)">
+      <a-input v-model:value="detail.partyASign" disabled placeholder="(待正式打印填写)" />
+    </a-form-item>
+    <a-form-item label="甲方电话" name="partyAMobile">
+      <a-input v-model:value="detail.partyAMobile" disabled placeholder="(待正式打印填写)" />
+    </a-form-item>
+    <AgreementDateWriteBlock
+      :model-value="partyAStampDate"
+      hint="(以实际盖章日期为准)"
+      @update:model-value="emit('update:partyAStampDate', $event)"
+    />
+
+    <a-divider />
+
+    <a-typography-paragraph strong>乙方:{{ detail.partyB }}(签名)</a-typography-paragraph>
+    <a-form-item label="乙方签名" name="partyBSign">
+      <Sign :model-value="detail.partyBSign ?? ''" @update:model-value="(v) => { detail.partyBSign = v }" />
+    </a-form-item>
+    <a-form-item label="身份证号" name="idCard">
+      <a-input v-model:value="detail.idCard" placeholder="请填写身份证号" />
+    </a-form-item>
+    <a-form-item label="项目名称" name="ich">
+      <a-input v-model:value="detail.ich" placeholder="非遗项目名称" />
+    </a-form-item>
+    <a-form-item label="身体状况" name="health">
+      <a-input v-model:value="detail.health" placeholder="请简要填写" />
+    </a-form-item>
+    <a-form-item label="乙方电话" name="mobile">
+      <a-input v-model:value="detail.mobile" placeholder="请填写联系电话" />
+    </a-form-item>
+    <AgreementDateWriteBlock
+      :model-value="partyBSignDate"
+      hint="(以实际签署日期为准)"
+      @update:model-value="emit('update:partyBSignDate', $event)"
+    />
+  </AgreementBody>
+</template>
+
+<script setup lang="ts">
+import type { AgreementDetail } from '@/api/collect/AssessmentContent';
+import AgreementBody from './AgreementBody.vue';
+import AgreementPrefillInline from './AgreementPrefillInline.vue';
+import AgreementDateWriteBlock, { type AgreementYmdParts } from './AgreementDateWriteBlock.vue';
+import { Sign } from '@imengyu/vue-dynamic-form-rich';
+
+defineProps<{
+  detail: AgreementDetail;
+  agreementYear: number;
+  partyAStampDate: AgreementYmdParts;
+  partyBSignDate: AgreementYmdParts;
+}>();
+
+const emit = defineEmits<{
+  (e: 'update:partyAStampDate', v: AgreementYmdParts): void;
+  (e: 'update:partyBSignDate', v: AgreementYmdParts): void;
+}>();
+
+const paragraphStyle = { lineHeight: 1.75, marginBottom: '8px' };
+</script>

+ 127 - 0
src/pages/collect/assessment/components/AgreementBodyNational.vue

@@ -0,0 +1,127 @@
+<template>
+  <AgreementBody>
+    <a-typography-paragraph>甲方:福建省文化和旅游厅</a-typography-paragraph>
+    <a-form-item label="乙方(传承人)" name="partyB">
+      <AgreementPrefillInline
+        v-model="detail.partyB"
+        placeholder="请填写乙方(传承人)姓名"
+      />
+    </a-form-item>
+
+    <a-typography-paragraph :style="paragraphStyle">
+      为传承弘扬中华优秀传统文化,有效保护和传承非物质文化遗产,鼓励和支持国家级非物质文化遗产代表性传承人开展传承活动,根据《中华人民共和国非物质文化遗产法》《国家级非物质文化遗产代表性传承人认定与管理办法》等有关法律法规,制定协议,并按照下列各项条款签署,甲、乙双方共同遵守。
+    </a-typography-paragraph>
+
+    <a-typography-paragraph :style="paragraphStyle">
+      一、甲乙双方应当以习近平新时代中国特色社会主义思想为指导,坚持以人民为中心,弘扬社会主义核心价值观,共同保护传承非物质文化遗产,推动中华优秀传统文化创造性转化、创新性发展。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      二、甲方按照《国家级非物质文化遗产代表性传承人认定与管理办法》的要求,支持国家级非物质文化遗产代表性传承人开展传承、传播活动。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      三、甲方按照《国家级非物质文化遗产保护专项资金管理办法》的要求,落实国家给予的代表性传承人的传承补助。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      四、乙方应积极开展传承活动,培养后继人才,制定传承计划,{{ agreementYear }} 年度带徒
+      <AgreementPrefillInline
+        v-model="detail.apprentice"
+        number-mode
+        placeholder="人数"
+        suffix="人。"
+      />
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      五、乙方应妥善保存相关实物、资料情况。主动保存、提供与该项非遗项目有关的原始资料、实物,配合记录工作。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      六、乙方应主动、及时配合非遗调查,主动向文化和旅游主管部门、非遗保护中心反映非遗项目保护、传承情况和总结材料,并完成文化和旅游主管部门临时交办的非遗工作任务,提出保护的意见、建议。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      七、乙方应积极、主动参加各级政府组织的非物质文化遗产公益性宣传活动,{{ agreementYear }} 年度完成
+      <AgreementPrefillInline
+        v-model="detail.activity"
+        number-mode
+        placeholder="场次"
+        suffix="场。"
+      />
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      八、乙方应合理使用国家级非物质文化遗产代表性传承人补助经费,用于开展非遗项目的传习活动,做好传承补助经费使用记录、支出范围和绩效评价等,不得用于生活补助。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      九、乙方应积极、主动参加文化和旅游部组织的非物质文化遗产代表性传承人研修班,{{ agreementYear }} 年度完成
+      <AgreementPrefillInline
+        v-model="detail.course"
+        number-mode
+        placeholder="场次"
+        suffix="场。"
+      />
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      十、乙方应积极参与非物质文化遗产相关理论和实践研究、发表(出版)论文、专著等研究。
+    </a-typography-paragraph>
+  </AgreementBody>
+
+  <a-divider />
+
+  <AgreementBody>
+    <a-typography-paragraph strong>甲方:福建省文化和旅游厅</a-typography-paragraph>
+    <a-form-item label="负责人(代表人)">
+      <a-input v-model:value="detail.partyASign" disabled placeholder="(待正式打印填写)" />
+    </a-form-item>
+    <a-form-item label="甲方电话" name="partyAMobile">
+      <a-input v-model:value="detail.partyAMobile" disabled placeholder="(待正式打印填写)" />
+    </a-form-item>
+    <AgreementDateWriteBlock
+      :model-value="partyAStampDate"
+      hint="(以实际盖章日期为准)"
+      @update:model-value="emit('update:partyAStampDate', $event)"
+    />
+
+    <a-divider />
+
+    <a-typography-paragraph strong>乙方:{{ detail.partyB }}(签名)</a-typography-paragraph>
+    <a-form-item label="乙方签名" name="partyBSign">
+      <Sign :model-value="detail.partyBSign ?? ''" @update:model-value="(v) => { detail.partyBSign = v }" />
+    </a-form-item>
+    <a-form-item label="身份证号" name="idCard">
+      <a-input v-model:value="detail.idCard" placeholder="请填写身份证号" />
+    </a-form-item>
+    <a-form-item label="项目名称" name="ich">
+      <a-input v-model:value="detail.ich" placeholder="非遗项目名称" />
+    </a-form-item>
+    <a-form-item label="身体状况" name="health">
+      <a-input v-model:value="detail.health" placeholder="请简要填写" />
+    </a-form-item>
+    <a-form-item label="乙方电话" name="mobile">
+      <a-input v-model:value="detail.mobile" placeholder="请填写联系电话" />
+    </a-form-item>
+    <AgreementDateWriteBlock
+      :model-value="partyBSignDate"
+      hint="(以实际签署日期为准)"
+      @update:model-value="emit('update:partyBSignDate', $event)"
+    />
+  </AgreementBody>
+</template>
+
+<script setup lang="ts">
+import type { AgreementDetail } from '@/api/collect/AssessmentContent';
+import AgreementBody from './AgreementBody.vue';
+import AgreementPrefillInline from './AgreementPrefillInline.vue';
+import AgreementDateWriteBlock, { type AgreementYmdParts } from './AgreementDateWriteBlock.vue';
+import { Sign } from '@imengyu/vue-dynamic-form-rich';
+
+defineProps<{
+  detail: AgreementDetail;
+  agreementYear: number;
+  partyAStampDate: AgreementYmdParts;
+  partyBSignDate: AgreementYmdParts;
+}>();
+
+const emit = defineEmits<{
+  (e: 'update:partyAStampDate', v: AgreementYmdParts): void;
+  (e: 'update:partyBSignDate', v: AgreementYmdParts): void;
+}>();
+
+const paragraphStyle = { lineHeight: 1.75, marginBottom: '8px' };
+</script>

+ 126 - 0
src/pages/collect/assessment/components/AgreementBodyProvincial.vue

@@ -0,0 +1,126 @@
+<template>
+  <AgreementBody>
+    <a-typography-paragraph>甲方:厦门市文化和旅游局</a-typography-paragraph>
+    <a-form-item label="乙方(传承人)" name="partyB">
+      <AgreementPrefillInline
+        v-model="detail.partyB"
+        placeholder="请填写乙方(传承人)姓名"
+      />
+    </a-form-item>
+
+    <a-typography-paragraph :style="paragraphStyle">
+      为传承弘扬中华优秀传统文化,有效保护和传承非物质文化遗产,鼓励和支持省级非物质文化遗产代表性传承人开展传承活动,根据《福建省非物质文化遗产条例》《福建省非物质文化遗产代表性传承人认定与管理办法》等有关法律法规,制定协议,并按照下列各项条款签署,甲、乙双方共同遵守。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      一、甲乙双方应当以习近平新时代中国特色社会主义思想为指导,坚持以人民为中心,弘扬社会主义核心价值观,共同保护传承非物质文化遗产,推动中华优秀传统文化创造性转化、创新性发展。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      二、甲方按照《福建省非物质文化遗产代表性传承人认定与管理办法》的要求,支持省级非物质文化遗产代表性传承人开展传承、传播活动。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      三、甲方按照《福建省非物质文化遗产保护与传承专项资金管理办法》的要求,落实省文化和旅游厅给予的代表性传承人的传承补助。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      四、乙方应积极开展传承活动,培养后继人才,制定传承计划,{{ agreementYear }} 年度带徒
+      <AgreementPrefillInline
+        v-model="detail.apprentice"
+        number-mode
+        placeholder="人数"
+        suffix="人。"
+      />
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      五、乙方应妥善保存相关实物、资料情况。主动保存、提供与该项非遗项目有关的原始资料、实物,配合记录工作。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      六、乙方应主动、及时配合非遗调查,主动向文化和旅游主管部门、非遗保护中心反映非遗项目保护、传承情况和总结材料,并完成文化和旅游主管部门临时交办的非遗工作任务,提出保护的意见、建议。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      七、乙方应积极、主动参加各级政府组织的非物质文化遗产公益性宣传活动,{{ agreementYear }} 年度完成
+      <AgreementPrefillInline
+        v-model="detail.activity"
+        number-mode
+        placeholder="场次"
+        suffix="场。"
+      />
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      八、乙方应合理使用省级非物质文化遗产代表性传承人补助经费,用于开展非遗项目的传习活动,做好传承补助经费使用记录、支出范围和绩效评价等,不得用于生活补助。
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      九、乙方应积极、主动参加文化和旅游部组织的非物质文化遗产代表性传承人研修班,{{ agreementYear }} 年度完成
+      <AgreementPrefillInline
+        v-model="detail.course"
+        number-mode
+        placeholder="场次"
+        suffix="场。"
+      />
+    </a-typography-paragraph>
+    <a-typography-paragraph :style="paragraphStyle">
+      十、乙方应积极参与非物质文化遗产相关理论和实践研究、发表(出版)论文、专著等研究。
+    </a-typography-paragraph>
+  </AgreementBody>
+
+  <a-divider />
+
+  <AgreementBody>
+    <a-typography-paragraph strong>甲方:厦门市文化和旅游局</a-typography-paragraph>
+    <a-form-item label="负责人(代表人)">
+      <a-input v-model:value="detail.partyASign" disabled placeholder="(待正式打印填写)" />
+    </a-form-item>
+    <a-form-item label="甲方电话" name="partyAMobile">
+      <a-input v-model:value="detail.partyAMobile" disabled placeholder="(待正式打印填写)" />
+    </a-form-item>
+    <AgreementDateWriteBlock
+      :model-value="partyAStampDate"
+      hint="(以实际盖章日期为准)"
+      @update:model-value="emit('update:partyAStampDate', $event)"
+    />
+
+    <a-divider />
+
+    <a-typography-paragraph strong>乙方:{{ detail.partyB }}(签名)</a-typography-paragraph>
+    <a-form-item label="乙方签名" name="partyBSign">
+      <Sign :model-value="detail.partyBSign ?? ''" @update:model-value="(v) => { detail.partyBSign = v }" />
+    </a-form-item>
+    <a-form-item label="身份证号" name="idCard">
+      <a-input v-model:value="detail.idCard" placeholder="请填写身份证号" />
+    </a-form-item>
+    <a-form-item label="项目名称" name="ich">
+      <a-input v-model:value="detail.ich" placeholder="非遗项目名称" />
+    </a-form-item>
+    <a-form-item label="身体状况" name="health">
+      <a-input v-model:value="detail.health" placeholder="请简要填写" />
+    </a-form-item>
+    <a-form-item label="乙方电话" name="mobile">
+      <a-input v-model:value="detail.mobile" placeholder="请填写联系电话" />
+    </a-form-item>
+    <AgreementDateWriteBlock
+      :model-value="partyBSignDate"
+      hint="(以实际签署日期为准)"
+      @update:model-value="emit('update:partyBSignDate', $event)"
+    />
+  </AgreementBody>
+</template>
+
+<script setup lang="ts">  
+import type { AgreementDetail } from '@/api/collect/AssessmentContent';
+import AgreementBody from './AgreementBody.vue';
+import AgreementPrefillInline from './AgreementPrefillInline.vue';
+import AgreementDateWriteBlock, { type AgreementYmdParts } from './AgreementDateWriteBlock.vue';
+import { Sign } from '@imengyu/vue-dynamic-form-rich';
+
+defineProps<{
+  detail: AgreementDetail;
+  agreementYear: number;
+  partyAStampDate: AgreementYmdParts;
+  partyBSignDate: AgreementYmdParts;
+}>();
+
+const emit = defineEmits<{
+  (e: 'update:partyAStampDate', v: AgreementYmdParts): void;
+  (e: 'update:partyBSignDate', v: AgreementYmdParts): void;
+}>();
+
+const paragraphStyle = { lineHeight: 1.75, marginBottom: '8px' };
+</script>

+ 79 - 0
src/pages/collect/assessment/components/AgreementDateWriteBlock.vue

@@ -0,0 +1,79 @@
+<template>
+  <div class="ymd-block">
+    <a-space wrap align="center">
+      <span v-if="prefix" class="text-secondary">{{ prefix }}</span>
+      <AgreementPrefillInline
+        :model-value="modelValue.year"
+        placeholder="YYYY"
+        suffix="年"
+        :max-length="4"
+        @update:model-value="(v) => patch('year', v)"
+      />
+      <AgreementPrefillInline
+        :model-value="modelValue.month"
+        placeholder="MM"
+        suffix="月"
+        :max-length="2"
+        @update:model-value="(v) => patch('month', v)"
+      />
+      <AgreementPrefillInline
+        :model-value="modelValue.day"
+        placeholder="DD"
+        suffix="日"
+        :max-length="2"
+        @update:model-value="(v) => patch('day', v)"
+      />
+    </a-space>
+    <div v-if="hint" class="text-secondary hint">{{ hint }}</div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import AgreementPrefillInline from './AgreementPrefillInline.vue';
+
+export type AgreementYmdParts = {
+  year: string;
+  month: string;
+  day: string;
+};
+
+const props = withDefaults(
+  defineProps<{
+    modelValue: AgreementYmdParts;
+    prefix?: string;
+    hint?: string;
+  }>(),
+  {
+    prefix: '时间:',
+    hint: '',
+  },
+);
+
+const emit = defineEmits<{
+  'update:modelValue': [value: AgreementYmdParts];
+}>();
+
+function digitsOnly(v: string | number, maxLen: number) {
+  const str = String(v ?? '').replace(/\D/g, '');
+  return str.slice(0, maxLen);
+}
+
+function patch(key: keyof AgreementYmdParts, v: string | number) {
+  const max = key === 'year' ? 4 : 2;
+  const next = digitsOnly(v, max);
+  emit('update:modelValue', {
+    ...props.modelValue,
+    [key]: next,
+  });
+}
+</script>
+
+<style scoped>
+.ymd-block {
+  margin-top: 8px;
+}
+.hint {
+  margin-top: 6px;
+  font-size: 13px;
+}
+</style>

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

@@ -0,0 +1,75 @@
+<template>
+  <span class="prefill-wrap">
+    <span v-if="label" class="prefill-label">{{ label }}</span>
+    <a-input
+      :value="textValue"
+      :type="numberMode ? 'number' : 'text'"
+      :placeholder="placeholder"
+      :maxlength="maxLength"
+      class="prefill-input"
+      @update:value="onUpdate"
+    />
+    <span v-if="suffix" class="prefill-suffix">{{ suffix }}</span>
+  </span>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+
+const props = withDefaults(
+  defineProps<{
+    modelValue?: string | number | null;
+    numberMode?: boolean;
+    placeholder?: string;
+    label?: string;
+    suffix?: string;
+    maxLength?: number;
+  }>(),
+  {
+    modelValue: '',
+    numberMode: false,
+    placeholder: '请填写',
+    label: '',
+    suffix: '',
+    maxLength: 20,
+  },
+);
+
+const emit = defineEmits<{
+  'update:modelValue': [value: string | number];
+}>();
+
+const textValue = computed(() =>
+  props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue),
+);
+
+function onUpdate(raw: string) {
+  if (props.numberMode) {
+    const n = parseInt(String(raw).replace(/\D/g, ''), 10);
+    emit('update:modelValue', Number.isFinite(n) ? n : 0);
+  } else {
+    emit('update:modelValue', raw);
+  }
+}
+</script>
+
+<style scoped>
+.prefill-wrap {
+  display: inline-flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 4px;
+  vertical-align: middle;
+}
+.prefill-label {
+  white-space: nowrap;
+}
+.prefill-input {
+  width: 5rem;
+  min-width: 4rem;
+  text-align: center;
+}
+.prefill-suffix {
+  white-space: nowrap;
+}
+</style>

+ 341 - 0
src/pages/collect/assessment/components/EvaluationFormBlock.vue

@@ -0,0 +1,341 @@
+<template>
+  <div>
+    <DynamicForm
+      ref="mainFormRef"
+      :model="currentForm"
+      :options="mainFormOptions"
+    />
+    <a-divider />
+    <a-typography-title :level="4">自评报告</a-typography-title>
+    <a-typography-paragraph type="secondary">
+      {{ currentForm.content?.title }}
+    </a-typography-paragraph>
+    <a-form class="assessment-content-fields w-full" layout="vertical">
+      <a-form-item
+        v-for="(label, idx) in contentItemLabels"
+        :key="idx"
+        :label="label"
+        :required="true"
+      >
+        <a-textarea
+          v-model:value="currentForm.content![`item${idx}`]"
+          :maxlength="1000"
+          show-count
+          :rows="4"
+          placeholder="请填写"
+        />
+      </a-form-item>
+    </a-form>
+    <a-divider />
+    <a-typography-title :level="4">自查项目选择</a-typography-title>
+    <div v-for="(item, index) in checkItemList" :key="item.id" class="check-item-block">
+      <div class="check-item-head">
+        <span>{{ index + 1 }}. {{ item.name }}</span>
+        <a-tag>{{ getCheckModeText(item.checkType) }}</a-tag>
+      </div>
+      <template v-if="item.checkType == 3">
+        <div v-for="child in item.children" :key="child.id" class="check-child-row">
+          <a-checkbox
+            :checked="hasCheckedItem(child.id)"
+            @change="(e: any) => setCheckedItem(item, child, e.target.checked)"
+          >
+            {{ child.name }} ({{ child.points }}分)
+          </a-checkbox>
+          <a-input-number
+            v-if="hasCheckedItem(child.id)"
+            :min="0"
+            :max="20"
+            :value="getCheckedItemCount(child.id) ?? 0"
+            addon-after="次"
+            @update:value="(v: number | null) => setCheckedItem(item, child, v ?? 0)"
+          />
+        </div>
+      </template>
+      <template v-else>
+        <a-checkbox
+          v-for="child in item.children"
+          :key="child.id"
+          :checked="hasCheckedItem(child.id)"
+          @change="(e: any) => setCheckedItem(item, child, e.target.checked)"
+        >
+          {{ child.name }} ({{ child.points }}分)
+        </a-checkbox>
+      </template>
+    </div>
+    <a-divider />
+    <DynamicForm
+      ref="tailFormRef"
+      :model="currentForm"
+      :options="tailFormOptions"
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { 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';
+import {
+  SelfAssessmentCheckItemAnswer,
+  type CheckItemInfo,
+  type SelfAssessmentDetail,
+} from '@/api/collect/AssessmentContent';
+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<{
+  currentForm: SelfAssessmentDetail;
+  checkItemList: any[];
+  currentFormCheckItems: any[];
+}>();
+
+const mainFormRef = ref<IDynamicFormRef | null>(null);
+const tailFormRef = ref<IDynamicFormRef | null>(null);
+
+const contentItemLabels = [
+  '(一)开展传承活动,培养后继人才情况;',
+  '(二)妥善保存相关实物、资料情况;',
+  '(三)配合进行非物质文化遗产调查情况;',
+  '(四)参加非物质文化遗产公益性宣传情况;',
+  '(五)补助经费使用情况;',
+  '(六)参加培训情况',
+  '(七)其他相关情况;',
+  '(八)存在的问题及原因分析。',
+] as const;
+
+const signUploadCo = useImageSimpleUploadCo();
+
+const mainFormOptions: IDynamicFormOptions = {
+  formAdditionaProps: {
+    layout: 'vertical',
+    scrollToFirstError: true,
+  },
+  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 },
+            ],
+          } as SelectIdProps<any>,
+        },
+        {
+          label: '家庭住址',
+          name: 'address',
+          type: 'text',
+          additionalProps: { placeholder: '请输入家庭住址' },
+        },
+        {
+          label: '获评时间',
+          name: 'awardTime',
+          type: 'date',
+          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: '请输入家庭住址' }],
+    awardTime: [{ required: true, message: '请选择获评时间' }],
+  },
+};
+
+const tailFormOptions: IDynamicFormOptions = {
+  formAdditionaProps: {
+    layout: 'vertical',
+    scrollToFirstError: true,
+  },
+  formItems: [
+    {
+      type: 'flat-group',
+      label: '传承人自查评估',
+      name: 'selfAssessmentGroup2',
+      childrenColProps: { span: 24 },
+      children: [
+        {
+          label: '其他相关情况(扣分内容)',
+          name: 'deductContent',
+          type: 'text',
+          additionalProps: {
+            maxlength: 260,
+            placeholder: '请输入其他相关情况(扣分内容)',
+          },
+        },
+        {
+          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 RadioValueFormItemProps,
+        },
+        {
+          label: '传承人签名',
+          name: 'inheritorSign',
+          type: 'sign',
+          additionalProps: {
+            upload: signUploadCo,
+          } as SignProps,
+        },
+      ],
+    },
+  ],
+  formRules: {
+    self: [{ required: true, message: '请选择自我评估' }],
+    sign: [{ required: true, message: '请传承人签名' }],
+  },
+};
+
+function getCheckModeText(checkMode: number) {
+  switch (checkMode) {
+    case 1:
+      return '单选';
+    case 2:
+      return '多选';
+    case 3:
+      return '可多次';
+    default:
+      return '';
+  }
+}
+
+function hasCheckedItem(id: number) {
+  return props.currentFormCheckItems.some(item => item.id === id);
+}
+
+function getCheckedItemCount(id: number) {
+  return props.currentFormCheckItems.find(item => item.id === id)?.count;
+}
+
+function setCheckedItem(checkItem: CheckItemInfo, childItem: CheckItemInfo, count: number | boolean) {
+  let c = count;
+  if (typeof c === 'boolean')
+    c = c ? 1 : 0;
+  let item = props.currentFormCheckItems.find(i => i.id === childItem.id);
+  if (!item) {
+    item = new SelfAssessmentCheckItemAnswer();
+    props.currentFormCheckItems.push(item);
+  }
+  if (item.count === c)
+    return;
+  item.id = childItem.id;
+  item.points = childItem.points;
+  item.count = c;
+  switch (checkItem.checkType) {
+    case 1: {
+      const allChildren = checkItem.children.map(child => child.id);
+      props.currentFormCheckItems.forEach(i => {
+        if (allChildren.includes(i.id) && i.id !== childItem.id)
+          i.count = 0;
+      });
+      for (let i = props.currentFormCheckItems.length - 1; i >= 0; i--) {
+        if (props.currentFormCheckItems[i].count === 0)
+          props.currentFormCheckItems.splice(i, 1);
+      }
+      break;
+    }
+  }
+  if (item.count === 0)
+    ArrayUtils.remove(props.currentFormCheckItems, item);
+}
+
+async function validate() {
+  await (mainFormRef.value?.getFormRef() as FormInstance)?.validate();
+  for (let i = 0; i < contentItemLabels.length; i++) {
+    const v = props.currentForm.content?.[`item${i}`];
+    if (v == null || String(v).trim() === '')
+      throw new Error(`请填写:${contentItemLabels[i]}`);
+  }
+  await (tailFormRef.value?.getFormRef() as FormInstance)?.validate();
+}
+
+defineExpose({ validate });
+</script>
+
+<style scoped>
+.check-item-block {
+  margin-bottom: 16px;
+}
+.check-item-head {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 8px;
+  font-weight: 500;
+}
+.check-child-row {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 8px;
+}
+</style>

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

@@ -0,0 +1,359 @@
+<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>
+        </div>
+        <a-spin :spinning="loader.loading.value">
+          <a-result
+            v-if="!currentForm"
+            status="info"
+            title="您还未填写评估表"
+          >
+            <template #extra>
+              <a-button type="primary" @click="createForm">去填写评估表</a-button>
+            </template>
+          </a-result>
+          <div v-else>
+            <EvaluationFormBlock
+              ref="blockRef"
+              :current-form="(currentForm as SelfAssessmentDetail)"
+              :check-item-list="checkItemList"
+              :current-form-check-items="currentFormCheckItems"
+            />
+            <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>
+              <a-input v-model:value="sec.suggestion" :disabled="sec.disabled" placeholder="(待终审填写)" class="mt-2" />
+              <a-space class="mt-2" wrap>
+                <a-checkbox v-for="(label, i) in externalReviewScoreRow1" :key="`r1-${i}`" disabled :checked="false">{{ label }}</a-checkbox>
+              </a-space>
+              <a-space class="mt-2" wrap>
+                <a-checkbox v-for="(label, i) in externalReviewScoreRow2" :key="`r2-${i}`" disabled :checked="false">{{ label }}</a-checkbox>
+              </a-space>
+              <div class="text-end text-secondary mt-2 text-sm">填写单位(盖章) 年 月 日</div>
+            </div>
+            <a-divider />
+            <a-typography-title :level="4">佐证资料上传</a-typography-title>
+            <a-alert
+              v-if="!currentForm?.id"
+              type="info"
+              message="请先保存评估表后再上传佐证资料"
+              class="mb-4"
+              show-icon
+            />
+            <a-upload
+              v-else
+              v-model:file-list="annexFileList"
+              :multiple="true"
+              :custom-request="annexCustomRequest"
+              :before-upload="beforeAnnexUpload"
+            >
+              <a-button type="default">选择文件上传</a-button>
+            </a-upload>
+            <a-divider />
+            <a-space direction="vertical" class="w-full" size="middle">
+              <a-button type="primary" block :loading="submitLoading" @click="saveForm">保存评估表</a-button>
+              <a-button block :loading="submitLoading" @click="downloadForm">下载评估表 PDF</a-button>
+            </a-space>
+          </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 type { UploadProps } from 'ant-design-vue';
+import { waitTimeOut } from '@imengyu/imengyu-utils';
+import { RequestApiError } from '@imengyu/imengyu-utils';
+import { ArrowLeftOutlined } from '@ant-design/icons-vue';
+import { useAuthStore } from '@/stores/auth';
+import { useAliOssUploadCo } from '@/common/upload/AliOssUploadCo';
+import AssessmentContentApi, {
+  SelfAssessmentDetail,
+  CheckItemInfo,
+  SelfAssessmentCheckItemAnswer,
+  getCheckAnnexType,
+} from '@/api/collect/AssessmentContent';
+import EvaluationFormBlock from './components/EvaluationFormBlock.vue';
+import type { AntUploadRequestOption } from '@imengyu/vue-dynamic-form-ant';
+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 authStore = useAuthStore();
+
+const queryId = computed(() => Number(route.query.id) || 0);
+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 submitLoading = ref(false);
+
+const externalReviewSectionTitles = ref([
+  { title: '1. 项目保护单位意见', suggestion: '', disabled: false },
+  { title: '2. 县(区)文旅部门审核意见', suggestion: '', disabled: true },
+  { title: '3. 设区市文旅部门、省非遗中心审核意见', suggestion: '', disabled: true },
+]);
+const externalReviewScoreRow1 = ['优秀', '合格', '不合格'] as const;
+const externalReviewScoreRow2 = ['丧失传承能力', '取消资格'] as const;
+
+const currentYear = new Date().getFullYear();
+
+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 '省级';
+  if (currentForm.value?.level === 25) return '市级';
+  return '国家级';
+});
+
+function loadEditorContent() {
+  if (!currentForm.value)
+    return;
+  if (typeof currentForm.value.content !== 'object' || currentForm.value.content === null)
+    currentForm.value.content = {};
+  currentForm.value.content.title = `传承人填写${currentYear}年1月1日至${currentYear}年12月31日${levelTitle.value}非遗传承人义务履行和传承补助经费使用情况等,不超过1000字,如未履行职责请进行说明。参考提纲如下:`;
+  for (let i = 0; i < 8; i++) {
+    if (typeof currentForm.value.content[`item${i}`] !== 'string')
+      currentForm.value.content[`item${i}`] = '';
+  }
+}
+
+async function loadBasicInfo() {
+  const uid = authStore.userInfo?.id ?? authStore.userId;
+  const basicInfo = await AssessmentContentApi.getInheritorBasic(uid);
+  const f = currentForm.value;
+  if (!f)
+    return;
+  f.inheritor = basicInfo.name;
+  f.unit = basicInfo.unit;
+  f.ichName = basicInfo.ichName;
+  f.mobile = basicInfo.mobile;
+  f.level = basicInfo.level;
+  f.idCard = basicInfo.idCard;
+  f.address = basicInfo.address;
+  
+}
+
+async function loadCheckItems() {
+  const f = currentForm.value;
+  if (!f)
+    return;
+  const { top, map } = await AssessmentContentApi.getCheckItems(Number(f.level));
+  checkItemList.value = top as CheckItemInfo[];
+  checkItemMap = map;
+  currentFormCheckItems.value = [...f.checkItems] as SelfAssessmentCheckItemAnswer[];
+}
+
+async function loadAnnexList() {
+  if (!currentForm.value?.id)
+    return;
+  const annexList = await AssessmentContentApi.getAnnexList(currentForm.value.id);
+  annexFileList.value = annexList.data.map((item) => ({
+    uid: String(item.id),
+    name: item.name,
+    status: 'done' as const,
+    url: item.url,
+    response: item,
+  }));
+}
+
+async function createForm() {
+  const uid = authStore.userInfo?.id ?? authStore.userId;
+  const detail = new SelfAssessmentDetail();
+  detail.userId = uid;
+  detail.year = new Date().getFullYear();
+  detail.checkItems = [];
+  currentForm.value = detail;
+  loadEditorContent();
+  await loadBasicInfo();
+  await loadCheckItems();
+  annexFileList.value = [];
+}
+
+async function saveForm() {
+  try {
+    await blockRef.value?.validate();
+  } catch (e: unknown) {
+    if (e && typeof e === 'object' && 'errorFields' in (e as object))
+      message.warning('请填写完整信息');
+    else if (e instanceof Error)
+      message.warning(e.message);
+    else
+      message.warning('请填写完整信息');
+    return;
+  }
+  submitLoading.value = true;
+  const cf = currentForm.value;
+  if (!cf) {
+    submitLoading.value = false;
+    return;
+  }
+  cf.checkItems = currentFormCheckItems.value;
+  try {
+    await AssessmentContentApi.saveSelfAssessment(cf as SelfAssessmentDetail);
+    message.success('保存评估表成功');
+    await waitTimeOut(500);
+    await loader.load();
+  } catch (error) {
+    Modal.error({
+      title: '保存评估表失败',
+      content: formatErr(error),
+    });
+  }
+  submitLoading.value = false;
+}
+
+async function downloadForm() {
+  if (!currentForm.value?.id) {
+    message.warning('请先保存评估表后再下载 PDF');
+    return;
+  }
+  try {
+    await AssessmentContentApi.downloadSelfAssessmentPdf(currentForm.value.id);
+    message.success('已开始下载');
+  } catch (error) {
+    Modal.error({
+      title: '下载评估表失败',
+      content: formatErr(error),
+    });
+  }
+}
+
+const beforeAnnexUpload: UploadProps['beforeUpload'] = () => {
+  if (!currentForm.value?.id) {
+    message.warning('请先保存评估表');
+    return false;
+  }
+  return true;
+};
+
+const annexCustomRequest: UploadProps['customRequest'] = async (options) => {
+  const { file, onSuccess, onError } = options;
+  try {
+    const cf = currentForm.value;
+    if (!cf)
+      return;
+    const raw = file as File;
+    const mimetype = raw.type || 'application/octet-stream';
+    let uploadedUrl = '';
+    await new Promise<void>((resolve, reject) => {
+      annexUploadCo.uploadRequest({
+        action: 'upload',
+        filename: raw.name,
+        data: {},
+        headers: {},
+        file: raw,
+        withCredentials: false,
+        method: 'POST',
+        onProgress: (progress) => {},
+        onSuccess: (body: unknown) => {
+          uploadedUrl = annexUploadCo.getUrlByUploadResponse?.(body) ?? '';
+          resolve();
+        },
+        onError: (err) => reject(err),
+      });
+    });
+    await AssessmentContentApi.saveAnnex({
+      name: raw.name,
+      formId: cf.id,
+      url: uploadedUrl,
+      type: getCheckAnnexType(mimetype),
+      mimetype,
+      fileSize: raw.size ? Math.max(1, Math.ceil(raw.size / 1024)) : undefined,
+    });
+    onSuccess?.({ url: uploadedUrl }, raw as never);
+    await loadAnnexList();
+  } catch (err) {
+    onError?.(err as Error);
+    message.error(formatErr(err));
+  }
+};
+
+const loader = useSimpleDataLoader(async () => {
+  if (queryId.value > 0) {
+    const detail = await AssessmentContentApi.getSelfAssessmentDetail(queryId.value, queryUserId.value || undefined);
+    currentForm.value = detail;
+    loadEditorContent();
+    await loadCheckItems();
+    await loadAnnexList();
+    return currentForm.value;
+  }
+  const uid = authStore.userInfo?.id ?? authStore.userId;
+  const basicInfo = await AssessmentContentApi.getInheritorBasic(uid);
+  if (basicInfo.checkId > 0) {
+    const detail = await AssessmentContentApi.getSelfAssessmentDetail(basicInfo.checkId, uid);
+    currentForm.value = detail;
+    console.log(currentForm.value);
+    loadEditorContent();
+    await loadAnnexList();
+    await loadCheckItems();
+  } else {
+    currentForm.value = null;
+  }
+  return currentForm.value;
+}, {
+  immediate: false,
+});
+
+onMounted(() => {
+  loader.load();
+});
+
+watch(
+  () => [queryId.value, queryUserId.value],
+  () => {
+    loader.load();
+  },
+);
+</script>
+
+<style scoped>
+.total-points {
+  font-size: 1.75rem;
+  color: #315816;
+  font-weight: 600;
+}
+</style>

+ 2 - 2
src/pages/components/AdminItemState.vue

@@ -1,7 +1,7 @@
 <template>
-  <div class="d-flex flex-row align-items-center gap-3">
+  <div class="flex flex-row items-center gap-4">
 
-    <div class="d-flex flex-column">
+    <div class="flex flex-col">
       <a-badge v-if="item.progress === -1" status="error" text="已退回" />
       <a-badge v-else-if="item.progress === 0" status="processing" text="待初审" />
       <a-badge v-else-if="item.progress === 1" status="processing" text="初审通过待专家审核" />

Різницю між файлами не показано, бо вона завелика
+ 0 - 13
src/pages/editor-test.vue


+ 14 - 14
src/pages/forms/form.vue

@@ -10,7 +10,7 @@
           <h2>{{ title }}</h2>
           <div class="button-placeholder"></div>
         </div>
-        <a-spin v-if="loadingData" class="w-100 h-100" />
+        <a-spin v-if="loadingData" class="w-full h-full" />
         <template v-else>   
           <a-tabs centered>
             <a-tab-pane key="1" :tab="basicTabText">
@@ -25,15 +25,15 @@
                 :model="(formModel as any)" 
                 :options="finalFormOptions"
               />
-              <div class="d-flex flex-column mt-3">
-                <div class="d-flex flex-row w-100 align-items-center justify-content-between">
+              <div class="flex flex-col mt-4">
+                <div class="flex flex-row w-full items-center justify-between">
                   <span>
                     <ExclamationCircleOutlined class="me-2" />
                     提示:上传文件时请勿离开页面防止上传失败,离开之前请保存您的修改以防丢失。
                   </span>
                   <a-button size="small" type="primary" @click="showHistory = true">历史版本</a-button>
                 </div>
-                <div class="d-flex flex-row w-100 align-items-center justify-content-end mt-3">
+                <div class="flex flex-row w-full items-center justify-end mt-4">
                   <a-popover
                     v-if="!isReviewer && !isAdmin"
                     title="保存提示"
@@ -42,7 +42,7 @@
                     <a-button
                       block 
                       :loading="loading"
-                      class="me-3" 
+                      class="me-4" 
                       @click="handleSubmitBase(false)"
                     >
                       保存
@@ -68,7 +68,7 @@
               <a-button 
                 type="primary"
                 block 
-                :loading="loading" class="mt-3" 
+                :loading="loading" class="mt-4" 
                 @click="handleSubmitExtend"
               >
                 提交
@@ -86,14 +86,14 @@
       :width="showHistoryModel ? (isMobile ? '100%' : '60%') : (isMobile ? '80%' : '50%')"
     >
       <div v-if="showHistoryModel">
-        <div class="d-flex flex-row justify-content-between">
+        <div class="flex flex-row justify-between">
           <a-button :icon="h(ArrowLeftOutlined)" @click="showHistoryModel = null">返回</a-button>
           <span>您正在查看 {{ showHistoryModel.desc }} 保存的版本</span>
         </div>
         
         <div class="main-section small-h">
           <div class="content">
-              <a-spin v-if="showHistoryLoading" class="w-100 h-100" />
+              <a-spin v-if="showHistoryLoading" class="w-full h-full" />
               <DynamicForm
                 v-else
                 :model="(showHistoryModel as any)" 
@@ -124,9 +124,8 @@
 </template>
 
 <script setup lang="ts" generic="T extends DataModel, U extends DataModel">
-import { onMounted, ref, toRefs, type PropType, h, watch, computed } from 'vue';
+import { onMounted, ref, toRefs, type PropType, h, computed } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
-import { useWindowOnUnLoadConfirm } from '@imengyu/imengyu-web-shared';
 import { DynamicForm, type IDynamicFormOptions, type IDynamicFormRef } from '@imengyu/vue-dynamic-form';
 import { message, Modal, type FormInstance } from 'ant-design-vue';
 import { ArrowLeftOutlined, ExclamationCircleOutlined } from '@ant-design/icons-vue';
@@ -136,6 +135,7 @@ import InheritorContent, { InheritorWorkInfo } from '@/api/inheritor/InheritorCo
 import CommonListBlock from '@/components/content/CommonListBlock.vue';
 import { waitTimeOut } from '@imengyu/imengyu-utils';
 import { useImageSimpleUploadCo } from '@/common/upload/ImageUploadCo';
+import { useWindowOnUnLoadConfirm } from '@/composeables/useWindowOnUnLoadConfirm';
 
 const isMobile = computed(() => {
   return window.innerWidth < 768;
@@ -210,7 +210,7 @@ const finalFormOptions = computed(() => {
       ...formOptions.value.formItems,
       ...(props.pushExamine ? [
         {
-          type: 'group-flat', label: '审核', name: 'ichInfo',
+          type: 'flat-group', label: '审核', name: 'ichInfo',
           childrenColProps: { span: 24 },
           children: [
             { 
@@ -289,7 +289,7 @@ const finalFormOptions = computed(() => {
               }
             },
             { 
-              label: '审核意见', name: 'comment', type: 'text-area',
+              label: '审核意见', name: 'comment', type: 'textarea',
               disabled: { callback: (_: any, model: any) => !isAdmin.value },
               additionalProps: {
                 placeholder: { callback: (_: any, model: any) => (isAdmin.value || isReviewer.value) ? '若审核不通过,请输入审核意见' : '暂无审核意见' },
@@ -299,7 +299,7 @@ const finalFormOptions = computed(() => {
               label: '审核签名', name: 'sign', type: 'sign',
               hidden: { callback: (_: any, model: any) => !isReviewer.value },
               additionalProps: {
-                uploadCo: useImageSimpleUploadCo({}),
+                upload: useImageSimpleUploadCo({}),
               }
             },
           ]
@@ -311,7 +311,7 @@ const finalFormOptions = computed(() => {
       sign: [{ required: true, message: '请审核签名', trigger: ['blur'] }],
     },
     disabled: readonly.value,
-  }
+  } as IDynamicFormOptions;
 });
 
 

+ 45 - 45
src/pages/forms/ich.vue

@@ -19,13 +19,12 @@ import Form from './form.vue';
 import InheritorContent, { IchExpandInfo, IchInfo } from '@/api/inheritor/InheritorContent';
 import CommonContent from '@/api/CommonContent';
 import type { IDynamicFormOptions, IDynamicFormRef } from '@imengyu/vue-dynamic-form';
-import { 
-  useBeforeUploadImageChecker, useBeforeUploadVideoChecker, 
-  type UploadImageFormItemProps, type AddressItem
-} from '@imengyu/imengyu-web-shared';
 import { useAuthStore } from '@/stores/auth';
 import { useAliOssUploadCo } from '@/common/upload/AliOssUploadCo';
 import { ExclamationCircleOutlined } from '@ant-design/icons-vue';
+import { useBeforeUploadImageChecker, useBeforeUploadVideoChecker, type UploaderFormItemProps } from '@imengyu/vue-dynamic-form-ant';
+import type { MapPointPickerProps } from '@imengyu/vue-dynamic-form-rich/lib/components/dynamicf/Map/MapPointPicker.vue';
+import type { AddressItem } from '@imengyu/vue-dynamic-form-rich';
 
 const authStore = useAuthStore();
 const formRef = ref();
@@ -40,7 +39,7 @@ const formOptions = ref<IDynamicFormOptions>({
   formNestNameGenerateType: 'array',
   formItems: [
     {
-      type: 'group-flat', label: '非遗基础档案', name: 'ichInfo',
+      type: 'flat-group', label: '非遗基础档案', name: 'ichInfo',
       childrenColProps: { span: 24 },
       children: [
         { 
@@ -53,21 +52,21 @@ const formOptions = ref<IDynamicFormOptions>({
           label: '级别', name: 'level', type: 'select-id',
           additionalProps: {
             placeholder: '请选择级别',
-            loadData: async () => (await CommonContent.getCategoryList(2)).map(p => ({ label: p.title, value: p.id, raw: p }))
+            loadData: async () => (await CommonContent.getCategoryList(2)).map(p => ({ text: p.title, value: p.id, raw: p }))
           },  
         },
         { 
           label: '非遗分类', name: 'ichType', type: 'select-id',
           additionalProps: {
             placeholder: '请选择非遗类型',
-            loadData: async () => (await CommonContent.getCategoryList(4)).map(p => ({ label: p.title, value: p.id, raw: p }))
+            loadData: async () => (await CommonContent.getCategoryList(4)).map(p => ({ text: p.title, value: p.id, raw: p }))
           },
         },
         { 
           label: '批次', name: 'batch', type: 'select-id',
           additionalProps: {
             placeholder: '请选择批次',
-            loadData: async () => (await CommonContent.getCategoryList(289)).map(p => ({ label: p.title, value: p.id, raw: p }))
+            loadData: async () => (await CommonContent.getCategoryList(289)).map(p => ({ text: p.title, value: p.id, raw: p }))
           },
         },
         { label: '申报区域', name: 'regionText', type: 'text', additionalProps: { placeholder: '请输入申报地区' } },
@@ -77,7 +76,7 @@ const formOptions = ref<IDynamicFormOptions>({
           label: '传承谱系', name: 'pedigree', type: 'richtext', 
           additionalProps: { placeholder: '请输入传承谱系' },
           formProps: {
-            extra: h('div', { class: 'd-flex flex-row align-items-start mt-2' }, [
+            extra: h('div', { class: 'flex flex-row items-start mt-2' }, [
               h(ExclamationCircleOutlined),
               h('pre', { class: 'ms-2' }, `请按传承脉络顺序填写:
 1、传承人:姓名(附简短介绍,如技艺特长/代表成就)
@@ -91,24 +90,25 @@ const formOptions = ref<IDynamicFormOptions>({
         //{ label: '传承值', name: 'heritage', type: 'text', additionalProps: { placeholder: '请输入传承值' } },
         
         { 
-          label: '保护单位地址', name: 'address', type: 'address-sercher', 
+          label: '保护单位地址', name: 'address', type: 'text', 
           additionalProps: { placeholder: '请输入地址' },
-          additionalEvents: {
-            choosedAddress: (address: AddressItem) => {
-              ((formRef.value?.getFormRef() as IDynamicFormRef).getFormItemControlRef('lonlat') as any).moveTo([
-                address.lng, address.lat
-              ], 20)
-            },
-          }
         },
         { 
-          label: '地图坐标', name: 'lonlat', type: 'map-pick-point',
+          label: '地图坐标', name: 'lonlat', type: 'select-lonlat',
           formProps: {
             extra: h('div', {}, [
               h(ExclamationCircleOutlined),
               h('span', { class: 'ms-2' }, '输入模糊地址后可以点击搜索跳转到指定位置,如果地图位置不正确,可以手动拖拽调整位置'),
             ]),
           },
+          additionalProps: {
+            showSearch: true,
+          } as MapPointPickerProps,
+          additionalEvents: {
+            choosedAddress: (address: AddressItem) => {
+              ((formRef.value?.getFormRef() as IDynamicFormRef).setValueByPath('address', address.address))
+            },
+          }
         },
         
         /* {
@@ -124,7 +124,7 @@ const formOptions = ref<IDynamicFormOptions>({
         //{ label: '流行地区', name: 'popularRegion', type: 'text', additionalProps: { placeholder: '请输入流行地区' } },
         //{ label: '批准时间', name: 'approveTime', type: 'text', additionalProps: { placeholder: '请输入批准时间' } },     
         { 
-          label: '非遗项目相关图片', name: 'images', type: 'mulit-image',
+          label: '非遗项目相关图片', name: 'images', type: 'uploader',
           //hidden: { callback: (_, model) => (model as IchInfo).type !== 4 },
           formProps: {
             extra: '建议分辨率:1920*1080以上',
@@ -135,26 +135,26 @@ const formOptions = ref<IDynamicFormOptions>({
             name: 'file',
             accept: 'image/*',
             beforeUpload: useBeforeUploadImageChecker(),
-            uploadCo: useAliOssUploadCo('ich/images'),
-          } as UploadImageFormItemProps,
+            upload: useAliOssUploadCo('ich/images'),
+          } as UploaderFormItemProps,
         },
         { 
-          label: '相关视频', name: 'video', type: 'single-video',
+          label: '相关视频', name: 'video', type: 'uploader',
           //hidden: { callback: (_, model) => (model as IchInfo).type !== 3 },
           additionalProps: {
             placeholder: '请上传视频',
             accept: 'video/*',
             name: 'file',
             beforeUpload: useBeforeUploadVideoChecker(),
-            uploadCo: useAliOssUploadCo('ich/video'),
-          } as UploadImageFormItemProps,  
+            upload: useAliOssUploadCo('ich/video'),
+          } as UploaderFormItemProps,  
         },
         { 
-          label: '其他附件', name: 'annex', type: 'mulit-video',
+          label: '其他附件', name: 'annex', type: 'uploader',
           //hidden: { callback: (_, model) => (model as IchInfo).type !== 3 },
           formProps: {
             extra: h('div', {
-              class: 'd-flex flex-row align-items-center mt-2'
+              class: 'flex flex-row items-center mt-2'
             }, [
               h(ExclamationCircleOutlined),
               h('span', { class: 'ms-2' }, '可以上传多个视频文件'),
@@ -164,13 +164,13 @@ const formOptions = ref<IDynamicFormOptions>({
             placeholder: '请上传视频',
             name: 'file',
             beforeUpload: useBeforeUploadVideoChecker(),
-            uploadCo: useAliOssUploadCo('ich/video'),
-          } as UploadImageFormItemProps,  
+            upload: useAliOssUploadCo('ich/video'),
+          } as UploaderFormItemProps,  
         },
       ]
     },
     /* {
-      type: 'group-flat', label: '通用信息', name: 'commonInfo',
+      type: 'flat-group', label: '通用信息', name: 'commonInfo',
       childrenColProps: { span: 24 },
       children: [
         { 
@@ -198,33 +198,33 @@ const formOptions = ref<IDynamicFormOptions>({
           additionalProps: { placeholder: '请输入来源' },
         },
         { 
-          label: '组图', name: 'images', type: 'mulit-image',
+          label: '组图', name: 'images', type: 'uploader',
           hidden: { callback: (_, model) => (model as IchInfo).type !== 4 },
           additionalProps: {
             placeholder: '请上传图片',
             maxCount: 20,
             name: 'file',
-            uploadCo: useImageSimpleUploadCo(),
-          } as UploadImageFormItemProps,
+            upload: useImageSimpleUploadCo(),
+          } as UploaderFormItemProps,
         },
         { 
-          label: '音频', name: 'audio', type: 'single-image',
+          label: '音频', name: 'audio', type: 'uploader',
           hidden: { callback: (_, model) => (model as IchInfo).type !== 2 },
           additionalProps: {
             placeholder: '请上传音频',
             name: 'file',
-            uploadCo: useImageSimpleUploadCo()
-          } as UploadImageFormItemProps,
+            upload: useImageSimpleUploadCo()
+          } as UploaderFormItemProps,
         },
         { 
-          label: '数字档案', name: 'archives', type: 'mulit-image',
+          label: '数字档案', name: 'archives', type: 'uploader',
           hidden: { callback: (_, model) => (model as IchInfo).type !== 5 },
           additionalProps: {
             placeholder: '请上传数字档案',
             maxCount: 20,
             name: 'file',
-            uploadCo: useImageSimpleUploadCo()
-          } as UploadImageFormItemProps,
+            upload: useImageSimpleUploadCo()
+          } as UploaderFormItemProps,
         },
         { 
           label: '标志', name: 'flag', type: 'select',
@@ -247,7 +247,7 @@ const formOptions = ref<IDynamicFormOptions>({
           } as SelectProps,
         },
         { 
-          label: '描述', name: 'desc', type: 'text-area',
+          label: '描述', name: 'desc', type: 'textarea',
           additionalProps: { placeholder: '请输入描述' },
         },
         { 
@@ -255,7 +255,7 @@ const formOptions = ref<IDynamicFormOptions>({
           additionalProps: { placeholder: '请输入TAG' },
         },
         { 
-          label: '备注', name: 'memo', type: 'text-area',
+          label: '备注', name: 'memo', type: 'textarea',
           additionalProps: { placeholder: '请输入备注' },
         },
       ]
@@ -286,7 +286,7 @@ const formExtendOptions = ref<IDynamicFormOptions>({
       type: 'select-id', label: '保护级别', name: 'protectLevel', 
       additionalProps: { 
         placeholder: '请选择保护级别', 
-        loadData: async () => (await CommonContent.getCategoryList(171)).map(p => ({ label: p.title, value: p.id, raw: p })) 
+        loadData: async () => (await CommonContent.getCategoryList(171)).map(p => ({ text: p.title, value: p.id, raw: p })) 
       } 
     },
     { type: 'text', label: '其他名称', name: 'otherNames', additionalProps: { placeholder: '请输入其他名称' } },
@@ -302,7 +302,7 @@ const formExtendOptions = ref<IDynamicFormOptions>({
     { type: 'text', label: '代表作品', name: 'works', additionalProps: { placeholder: '请输入代表作品' } },
     { type: 'text', label: '保护单位', name: 'unit', additionalProps: { placeholder: '请输入保护单位' } },
     { type: 'text', label: '保护情况', name: 'protect', additionalProps: { placeholder: '请输入保护情况' } },
-    { type: 'text-area', label: '综合概述', name: 'overview', additionalProps: { placeholder: '请输入综合概述' } },
+    { type: 'textarea', label: '综合概述', name: 'overview', additionalProps: { placeholder: '请输入综合概述' } },
     { type: 'text', label: '代表人物', name: 'figures', additionalProps: { placeholder: '请输入代表人物' } },
     { type: 'text', label: '传承群体', name: 'group', additionalProps: { placeholder: '请输入传承群体' } },
     { type: 'text', label: '传承方式', name: 'inherit', additionalProps: { placeholder: '请输入传承方式' } },
@@ -317,13 +317,13 @@ const formExtendOptions = ref<IDynamicFormOptions>({
     { type: 'text', label: '价值', name: 'meaning', additionalProps: { placeholder: '请输入价值' } },
     { type: 'text', label: '相关实物', name: 'prop', additionalProps: { placeholder: '请输入相关实物' } },
     { type: 'text', label: '其他基本情况', name: 'other', additionalProps: { placeholder: '请输入其他基本情况' } },
-    { type: 'mulit-image', label: '相关图片', name: 'images', additionalProps: { placeholder: '请上传相关图片', uploadCo: useImageSimpleUploadCo() } },
+    { type: 'uploader', label: '相关图片', name: 'images', additionalProps: { placeholder: '请上传相关图片', upload: useImageSimpleUploadCo() } },
     { type: 'text', label: '相关习俗', name: 'folkCulture', additionalProps: { placeholder: '请输入相关习俗' } },
     { type: 'text', label: '文物古迹', name: 'culturalRelic', additionalProps: { placeholder: '请输入文物古迹' } },
     { type: 'text', label: '文献资料', name: 'literature', additionalProps: { placeholder: '请输入文献资料' } },
     { type: 'text', label: '组织机构', name: 'organization', additionalProps: { placeholder: '请输入组织机构' } },
-    { type: 'text-area', label: '项目描述', name: 'description', additionalProps: { placeholder: '请输入项目描述' } },
-    { type: 'text-area', label: '简介', name: 'desc', additionalProps: { placeholder: '请输入简介' } },
+    { type: 'textarea', label: '项目描述', name: 'description', additionalProps: { placeholder: '请输入项目描述' } },
+    { type: 'textarea', label: '简介', name: 'desc', additionalProps: { placeholder: '请输入简介' } },
     { type: 'text', label: '申报地区', name: 'declarationRegion', additionalProps: { placeholder: '请输入申报地区' } },
     { type: 'text', label: '流行地区', name: 'popularRegion', additionalProps: { placeholder: '请输入流行地区' } },
   ],

+ 59 - 59
src/pages/forms/inheritor.vue

@@ -16,9 +16,9 @@ import Form from './form.vue';
 import InheritorContent, { InheritorExpandInfo, InheritorInfo } from '@/api/inheritor/InheritorContent';
 import CommonContent from '@/api/CommonContent';
 import type { IDynamicFormOptions } from '@imengyu/vue-dynamic-form';
-import { useBeforeUploadImageChecker, useBeforeUploadVideoChecker, type UploadImageFormItemProps } from '@imengyu/imengyu-web-shared';
 import { useAuthStore } from '@/stores/auth';
 import { useAliOssUploadCo } from '@/common/upload/AliOssUploadCo';
+import { useBeforeUploadImageChecker, useBeforeUploadVideoChecker, type UploaderFormItemProps } from '@imengyu/vue-dynamic-form-ant';
 
 const authStore = useAuthStore();
 const formModel = ref(new InheritorInfo()) as Ref<InheritorInfo>;
@@ -32,7 +32,7 @@ const formOptions = ref<IDynamicFormOptions>({
   formNestNameGenerateType: 'array',
   formItems: [
     {
-      type: 'group-flat', label: '传承人基础档案', name: 'ichInfo',
+      type: 'flat-group', label: '传承人基础档案', name: 'ichInfo',
       childrenColProps: { span: 24 },
       children: [
         { 
@@ -54,14 +54,14 @@ const formOptions = ref<IDynamicFormOptions>({
           label: '传承人等级', name: 'level', type: 'select-id',
           additionalProps: {
             placeholder: '请选择传承人等级',
-            loadData: async () => (await CommonContent.getCategoryList(2)).map(p => ({ label: p.title, value: p.id, raw: p }))
+            loadData: async () => (await CommonContent.getCategoryList(2)).map(p => ({ text: p.title, value: p.id, raw: p }))
           },
         },
         { 
           label: '传承人批次', name: 'batch', type: 'select-id',
           additionalProps: {
             placeholder: '请选择传承人批次',
-            loadData: async () => (await CommonContent.getCategoryList(289)).map(p => ({ label: p.title, value: p.id, raw: p }))
+            loadData: async () => (await CommonContent.getCategoryList(289)).map(p => ({ text: p.title, value: p.id, raw: p }))
           },
         },
         //{ label: '别称', name: 'alsoName', type: 'text', additionalProps: { placeholder: '请输入别称' } },
@@ -77,7 +77,7 @@ const formOptions = ref<IDynamicFormOptions>({
         { label: '奖项/成就', name: 'prize', type: 'richtext', additionalProps: { placeholder: '请输入奖项-成就' } },
         { label: '传承人谱系', name: 'pedigree', type: 'richtext', additionalProps: { placeholder: '请输入传承谱系' } },
         { 
-          label: '传承人照片', name: 'images', type: 'mulit-image',
+          label: '传承人照片', name: 'images', type: 'uploader',
           //hidden: { callback: (_, model) => (model as IchInfo).type !== 4 },
           formProps: {
             extra: '建议分辨率:1920*1080以上,请上传传承人证件照、工作照、生活照、实践活动照',
@@ -88,8 +88,8 @@ const formOptions = ref<IDynamicFormOptions>({
             name: 'file',
             accept: 'image/*',
             beforeUpload: useBeforeUploadImageChecker(),
-            uploadCo: useAliOssUploadCo('inheritor/images'),
-          } as UploadImageFormItemProps,
+            upload: useAliOssUploadCo('inheritor/images'),
+          } as UploaderFormItemProps,
         },
         /* { 
           type: 'array-object', label: '传承人照片(建议分辨率:1920*1080以上,请上传传承人证件照、工作照、生活照、实践活动照)', 
@@ -111,30 +111,30 @@ const formOptions = ref<IDynamicFormOptions>({
             //{ type: 'text', label: '联系方式', name: 'mobile', additionalProps: { placeholder: '请输入联系方式' } },
             { type: 'text', label: '照片说明', name: 'desc', additionalProps: { placeholder: '请输入说明' } },
             { 
-              label: '照片', name: 'url', type: 'single-image',
+              label: '照片', name: 'url', type: 'uploader',
               additionalProps: {
                 name: 'file',
                 placeholder: '请上传图片',
-                uploadCo: useImageSimpleUploadCo(),
-              } as UploadImageFormItemProps,
+                upload: useImageSimpleUploadCo(),
+              } as UploaderFormItemProps,
             },
           ]
         }, */
         { 
-          label: '传承人相关视频', name: 'video', type: 'single-video',
+          label: '传承人相关视频', name: 'video', type: 'uploader',
           //hidden: { callback: (_, model) => (model as InheritorInfo).type !== 3 },
           additionalProps: {
             placeholder: '请上传视频',
             name: 'file',
             accept: 'video/*',
             beforeUpload: useBeforeUploadVideoChecker(),
-            uploadCo: useAliOssUploadCo('inheritor/video'),
-          } as UploadImageFormItemProps,  
+            upload: useAliOssUploadCo('inheritor/video'),
+          } as UploaderFormItemProps,  
         },
       ]
     },
     /* {
-      type: 'group-flat', label: '通用信息', name: 'commonInfo',
+      type: 'flat-group', label: '通用信息', name: 'commonInfo',
       childrenColProps: { span: 24 },
       children: [
         { 
@@ -157,14 +157,14 @@ const formOptions = ref<IDynamicFormOptions>({
             },  
         },
         { 
-          label: '图片', name: 'image', type: 'single-image',
+          label: '图片', name: 'image', type: 'uploader',
           additionalProps: {
             placeholder: '请上传图片',
             name: 'file',
             accept: 'image/*',
             beforeUpload: useBeforeUploadImageChecker(),
-            uploadCo: useAliOssUploadCo('inheritor/images'),
-          } as UploadImageFormItemProps,
+            upload: useAliOssUploadCo('inheritor/images'),
+          } as UploaderFormItemProps,
         },
         { 
           label: '图片说明', name: 'imageDesc', type: 'text',
@@ -175,7 +175,7 @@ const formOptions = ref<IDynamicFormOptions>({
           additionalProps: { placeholder: '请输入来源' },
         },
         { 
-          label: '组图', name: 'images', type: 'mulit-image',
+          label: '组图', name: 'images', type: 'uploader',
           hidden: { callback: (_, model) => (model as InheritorInfo).type !== 4 },
           additionalProps: {
             placeholder: '请上传图片',
@@ -183,29 +183,29 @@ const formOptions = ref<IDynamicFormOptions>({
             name: 'file',
             accept: 'image/*',
             beforeUpload: useBeforeUploadImageChecker(),
-            uploadCo: useAliOssUploadCo('inheritor/images'),
-          } as UploadImageFormItemProps,
+            upload: useAliOssUploadCo('inheritor/images'),
+          } as UploaderFormItemProps,
         },
         { 
-          label: '音频', name: 'audio', type: 'single-image',
+          label: '音频', name: 'audio', type: 'uploader',
           hidden: { callback: (_, model) => (model as InheritorInfo).type !== 2 },
           additionalProps: {
             placeholder: '请上传音频',
             name: 'file',
             accept: 'audio/*',
             beforeUpload: useBeforeUploadAudioChecker(),
-            uploadCo: useAliOssUploadCo('inheritor/audios'),
-          } as UploadImageFormItemProps,
+            upload: useAliOssUploadCo('inheritor/audios'),
+          } as UploaderFormItemProps,
         },
         { 
-          label: '数字档案', name: 'archives', type: 'mulit-image',
+          label: '数字档案', name: 'archives', type: 'uploader',
           hidden: { callback: (_, model) => (model as InheritorInfo).type !== 5 },
           additionalProps: {
             placeholder: '请上传数字档案',
             maxCount: 20,
             name: 'file',
-            uploadCo: useAliOssUploadCo('inheritor/archives'),
-          } as UploadImageFormItemProps,
+            upload: useAliOssUploadCo('inheritor/archives'),
+          } as UploaderFormItemProps,
         },
         { 
           label: '标志', name: 'flag', type: 'select',
@@ -228,7 +228,7 @@ const formOptions = ref<IDynamicFormOptions>({
           } as SelectProps,
         },
         { 
-          label: '描述', name: 'desc', type: 'text-area',
+          label: '描述', name: 'desc', type: 'textarea',
           additionalProps: { placeholder: '请输入描述' },
         },
         { 
@@ -236,7 +236,7 @@ const formOptions = ref<IDynamicFormOptions>({
           additionalProps: { placeholder: '请输入TAG' },
         },
         { 
-          label: '备注', name: 'memo', type: 'text-area',
+          label: '备注', name: 'memo', type: 'textarea',
           additionalProps: { placeholder: '请输入备注' },
         },
       ]
@@ -286,13 +286,13 @@ const formExtendOptions = ref<IDynamicFormOptions>({
     {
       label: '证件照',
       name: 'idPhoto',
-      type: 'single-image',
+      type: 'uploader',
       additionalProps: {
         placeholder: '请上传证件照',
         accept: 'image/*',
         beforeUpload: useBeforeUploadImageChecker(),
-        uploadCo: useAliOssUploadCo('inheritor/idcards'),
-      } as UploadImageFormItemProps
+        upload: useAliOssUploadCo('inheritor/idcards'),
+      } as UploaderFormItemProps
     },
     {
       label: '民族',
@@ -330,7 +330,7 @@ const formExtendOptions = ref<IDynamicFormOptions>({
       type: 'select-id',
       additionalProps: {
         placeholder: '请选择文化程度',
-        loadData: async () => (await CommonContent.getCategoryList(10)).map(p => ({ label: p.title, value: p.id, raw: p }))
+        loadData: async () => (await CommonContent.getCategoryList(10)).map(p => ({ text: p.title, value: p.id, raw: p }))
       }
     },
     {
@@ -339,7 +339,7 @@ const formExtendOptions = ref<IDynamicFormOptions>({
       type: 'select-id',
       additionalProps: {
         placeholder: '请选择传承人级别',
-        loadData: async () => (await CommonContent.getCategoryList(2)).map(p => ({ label: p.title, value: p.id, raw: p }))
+        loadData: async () => (await CommonContent.getCategoryList(2)).map(p => ({ text: p.title, value: p.id, raw: p }))
       }
     },
     {
@@ -348,7 +348,7 @@ const formExtendOptions = ref<IDynamicFormOptions>({
       type: 'select-id',
       additionalProps: {
         placeholder: '请选择类型',
-        loadData: async () => (await CommonContent.getCategoryList(4)).map(p => ({ label: p.title, value: p.id, raw: p }))
+        loadData: async () => (await CommonContent.getCategoryList(4)).map(p => ({ text: p.title, value: p.id, raw: p }))
       }
     },
     {
@@ -357,7 +357,7 @@ const formExtendOptions = ref<IDynamicFormOptions>({
       type: 'select-id',
       additionalProps: {
         placeholder: '请选择批次',
-        loadData: async () => (await CommonContent.getCategoryList(289)).map(p => ({ label: p.title, value: p.id, raw: p }))
+        loadData: async () => (await CommonContent.getCategoryList(289)).map(p => ({ text: p.title, value: p.id, raw: p }))
       }
     },
     {
@@ -366,7 +366,7 @@ const formExtendOptions = ref<IDynamicFormOptions>({
       type: 'select-id',
       additionalProps: {
         placeholder: '请选择地区',
-        loadData: async () => (await CommonContent.getCategoryList(1)).map(p => ({ label: p.title, value: p.id, raw: p }))
+        loadData: async () => (await CommonContent.getCategoryList(1)).map(p => ({ text: p.title, value: p.id, raw: p }))
       }
     },
     {
@@ -438,19 +438,19 @@ const formExtendOptions = ref<IDynamicFormOptions>({
     {
       label: '学习与实践经历',
       name: 'experience',
-      type: 'text-area',
+      type: 'textarea',
       additionalProps: { placeholder: '请输入学习与实践经历' }
     },
     {
       label: '技艺特点',
       name: 'feature',
-      type: 'text-area',
+      type: 'textarea',
       additionalProps: { placeholder: '请输入技艺特点' }
     },
     {
       label: '代表作品及介绍',
       name: 'workDesc',
-      type: 'text-area',
+      type: 'textarea',
       additionalProps: { placeholder: '请输入代表作品及介绍' }
     },
     {
@@ -462,55 +462,55 @@ const formExtendOptions = ref<IDynamicFormOptions>({
     {
       label: '成就和影响',
       name: 'achievement',
-      type: 'text-area',
+      type: 'textarea',
       additionalProps: { placeholder: '请输入成就和影响' }
     },
     {
       label: '重要影响人物和事件',
       name: 'influence',
-      type: 'text-area',
+      type: 'textarea',
       additionalProps: { placeholder: '请输入重要影响人物和事件' }
     },
     {
       label: '传承方式',
       name: 'method',
-      type: 'text-area',
+      type: 'textarea',
       additionalProps: { placeholder: '请输入传承方式' }
     },
     {
       label: '传承谱系',
       name: 'pedigree',
-      type: 'text-area',
+      type: 'textarea',
       additionalProps: { placeholder: '请输入传承谱系' }
     },
     {
       label: '授徒传艺情况',
       name: 'teach',
-      type: 'text-area',
+      type: 'textarea',
       additionalProps: { placeholder: '请输入授徒传艺情况' }
     },
     {
       label: '对项目的认识、评价和建议',
       name: 'recognize',
-      type: 'text-area',
+      type: 'textarea',
       additionalProps: { placeholder: '请输入对项目的认识、评价和建议' }
     },
     {
       label: '其他需说明的情况',
       name: 'other',
-      type: 'text-area',
+      type: 'textarea',
       additionalProps: { placeholder: '请输入其他需说明的情况' }
     },
     {
       label: '图片资源',
       name: 'images',
-      type: 'mulit-image',
+      type: 'uploader',
       additionalProps: {
         placeholder: '请上传图片资源',
         accept: 'image/*',
         beforeUpload: useBeforeUploadImageChecker(),
-        uploadCo: useAliOssUploadCo('inheritor/images'),
-      }
+        upload: useAliOssUploadCo('inheritor/images'),
+      } as UploaderFormItemProps
     },
     {
       label: '荣誉称号',
@@ -545,25 +545,25 @@ const formExtendOptions = ref<IDynamicFormOptions>({
     {
       label: '个人简历',
       name: 'personalCv',
-      type: 'text-area',
+      type: 'textarea',
       additionalProps: { placeholder: '请输入个人简历' }
     },
     {
       label: '参与社会公益活动情况',
       name: 'activity',
-      type: 'text-area',
+      type: 'textarea',
       additionalProps: { placeholder: '请输入参与社会公益活动情况(展演,宣传,讲座等)' }
     },
     {
       label: '持有该项目的相关实物、资料情况',
       name: 'information',
-      type: 'text-area',
+      type: 'textarea',
       additionalProps: { placeholder: '请输入持有该项目的相关实物、资料情况' }
     },
     {
       label: '为该项目保护传承所做的其他贡献',
       name: 'contribute',
-      type: 'text-area',
+      type: 'textarea',
       additionalProps: { placeholder: '请输入为该项目保护传承所做的其他贡献' }
     },
     { 
@@ -585,26 +585,26 @@ const formExtendOptions = ref<IDynamicFormOptions>({
         { type: 'text', label: '手机号', name: 'mobile', additionalProps: { placeholder: '请输入手机号' } },
         { type: 'text', label: '照片说明', name: 'desc', additionalProps: { placeholder: '请输入照片说明' } },
         { 
-          label: '照片', name: 'url', type: 'single-image',
+          label: '照片', name: 'url', type: 'uploader',
           additionalProps: {
             name: 'file',
             placeholder: '请上传图片',
             beforeUpload: useBeforeUploadImageChecker(),
-            uploadCo: useAliOssUploadCo('inheritor/images'),
-          } as UploadImageFormItemProps,
+            upload: useAliOssUploadCo('inheritor/images'),
+          } as UploaderFormItemProps,
         },
       ]
     },
     {
       label: '被推荐人身份证复印件',
       name: 'idCardImages',
-      type: 'single-image',
+      type: 'uploader',
       additionalProps: {
         placeholder: '请上传被推荐人身份证复印件',
         accept: 'image/*',
         beforeUpload: useBeforeUploadImageChecker(),
-        uploadCo: useAliOssUploadCo('inheritor/idcards'),
-      } as UploadImageFormItemProps
+        upload: useAliOssUploadCo('inheritor/idcards'),
+      } as UploaderFormItemProps
     },
     
   ],

+ 2 - 2
src/pages/forms/plans.vue

@@ -29,8 +29,8 @@ const formOptions = ref<IDynamicFormOptions>({
   formItems: [
       { label: '预算项目名称', name: 'name', type: 'text', additionalProps: { placeholder: '请输入预算项目名称' } },
       { label: '经费投入(万元)', name: 'investment', type: 'number', additionalProps: { placeholder: '请输入经费投入(万元)', min: 0, precision: 2 } },
-      { label: '依据说明', name: 'desc', type: 'text-area', additionalProps: { placeholder: '请输入依据说明' } },
-      { label: '预期目标', name: 'target', type: 'text-area', additionalProps: { placeholder: '请输入预期目标' } },
+      { label: '依据说明', name: 'desc', type: 'textarea', additionalProps: { placeholder: '请输入依据说明' } },
+      { label: '预期目标', name: 'target', type: 'textarea', additionalProps: { placeholder: '请输入预期目标' } },
       { label: '保护单位自筹资金', name: 'unit', type: 'number', additionalProps: { placeholder: '请输入保护单位自筹资金', min: 0, precision: 2 } },
       { label: '地方(部门)投入资金', name: 'department', type: 'number', additionalProps: { placeholder: '请输入地方(部门)投入资金', min: 0, precision: 2 } },
   ],

+ 47 - 35
src/pages/forms/seminar.vue

@@ -19,8 +19,10 @@ import CommonContent from '@/api/CommonContent';
 import type { IDynamicFormOptions, IDynamicFormRef } from '@imengyu/vue-dynamic-form';
 import { useAuthStore } from '@/stores/auth';
 import { useAliOssUploadCo } from '@/common/upload/AliOssUploadCo';
-import { useBeforeUploadImageChecker, type UploadImageFormItemProps, type AddressItem } from '@imengyu/imengyu-web-shared';
 import { useRoute } from 'vue-router';
+import { useBeforeUploadImageChecker, type UploaderFormItemProps } from '@imengyu/vue-dynamic-form-ant';
+import type { MapPointPickerProps } from '@imengyu/vue-dynamic-form-rich/lib/components/dynamicf/Map/MapPointPicker.vue';
+import type { AddressItem } from '@imengyu/vue-dynamic-form-rich';
 
 const authStore = useAuthStore();
 const formRef = ref();
@@ -35,7 +37,7 @@ const formOptions = ref<IDynamicFormOptions>({
   formNestNameGenerateType: 'array',
   formItems: [
     {
-      type: 'group-flat', label: '传习所/保护单位信息', name: 'seminarInfo',
+      type: 'flat-group', label: '传习所/保护单位信息', name: 'seminarInfo',
       childrenColProps: { span: 24 },
       children: [
         { 
@@ -46,38 +48,42 @@ const formOptions = ref<IDynamicFormOptions>({
           label: '批次', name: 'batch', type: 'select-id',
           additionalProps: {
             placeholder: '请选择批次',
-            loadData: async () => (await CommonContent.getCategoryList(289)).map(p => ({ label: p.title, value: p.id, raw: p }))
+            loadData: async () => (await CommonContent.getCategoryList(289)).map(p => ({ text: p.title, value: p.id, raw: p }))
           },
         },
         { 
           label: '传习所级别', name: 'level', type: 'select-id',
           additionalProps: {
             placeholder: '请选择传习所级别',
-            loadData: async () => (await CommonContent.getCategoryList(2)).map(p => ({ label: p.title, value: p.id, raw: p }))
+            loadData: async () => (await CommonContent.getCategoryList(2)).map(p => ({ text: p.title, value: p.id, raw: p }))
           },
         },{ 
-          label: '图片', name: 'image', type: 'single-image',
+          label: '图片', name: 'image', type: 'uploader',
           additionalProps: {
             placeholder: '请上传图片',
             name: 'file',
             accept: 'image/*',
             beforeUpload: useBeforeUploadImageChecker(),
-            uploadCo: useAliOssUploadCo('seminar/images')
-          } as UploadImageFormItemProps,
+            upload: useAliOssUploadCo('seminar/images')
+          } as UploaderFormItemProps,
         },
         { label: '传习所介绍', name: 'content', type: 'richtext', additionalProps: { placeholder: '请输入内容' } },
         
-        { label: '传习所地址', name: 'address', type: 'address-sercher', 
+        { 
+          label: '传习所地址', name: 'address', type: 'text', 
           additionalProps: { placeholder: '请输入地址' },
+        },
+        { 
+          label: '地图坐标', name: 'lonlat', type: 'select-lonlat',
+          additionalProps: {
+            showSearch: true,
+          } as MapPointPickerProps,
           additionalEvents: {
             choosedAddress: (address: AddressItem) => {
-              ((formRef.value?.getFormRef() as IDynamicFormRef).getFormItemControlRef('lonlat') as any).moveTo([
-                address.lng, address.lat
-              ], 20)
+              ((formRef.value?.getFormRef() as IDynamicFormRef).setValueByPath('address', address.address))
             },
           }
         },
-        { label: '地图坐标', name: 'lonlat', type: 'map-pick-point' },
         
         /* {
           type: 'simple-flat', label: '', name: 'map',
@@ -134,7 +140,7 @@ const formOptions = ref<IDynamicFormOptions>({
     },
 
     /* {
-      type: 'group-flat', label: '通用信息', name: 'commonInfo',
+      type: 'flat-group', label: '通用信息', name: 'commonInfo',
       childrenColProps: { span: 24 },
       children: [
         { 
@@ -166,7 +172,7 @@ const formOptions = ref<IDynamicFormOptions>({
           additionalProps: { placeholder: '请输入来源' },
         },
         { 
-          label: '组图', name: 'images', type: 'mulit-image',
+          label: '组图', name: 'images', type: 'uploader',
           hidden: { callback: (_, model) => (model as SeminarInfo).type !== 4 },
           additionalProps: {
             placeholder: '请上传图片',
@@ -174,40 +180,40 @@ const formOptions = ref<IDynamicFormOptions>({
             name: 'file',
             accept: 'image/*',
             beforeUpload: useBeforeUploadImageChecker(),
-            uploadCo: useAliOssUploadCo('seminar/images'),
-          } as UploadImageFormItemProps,
+            upload: useAliOssUploadCo('seminar/images'),
+          } as UploaderFormItemProps,
         },
         { 
-          label: '音频', name: 'audio', type: 'single-image',
+          label: '音频', name: 'audio', type: 'uploader',
           hidden: { callback: (_, model) => (model as SeminarInfo).type !== 2 },
           additionalProps: {
             placeholder: '请上传音频',
             name: 'file',
             accept: 'audio/*',
             beforeUpload: useBeforeUploadAudioChecker(),
-            uploadCo: useAliOssUploadCo('seminar/audios')
-          } as UploadImageFormItemProps,
+            upload: useAliOssUploadCo('seminar/audios')
+          } as UploaderFormItemProps,
         },
         { 
-          label: '相关视频', name: 'video', type: 'single-video',
+          label: '相关视频', name: 'video', type: 'uploader',
           hidden: { callback: (_, model) => (model as SeminarInfo).type !== 3 },
           additionalProps: {
             placeholder: '请上传视频',
             name: 'file',
             accept: 'video/*',
             beforeUpload: useBeforeUploadVideoChecker(),
-            uploadCo: useAliOssUploadCo('seminar/videos')
-          } as UploadImageFormItemProps,  
+            upload: useAliOssUploadCo('seminar/videos')
+          } as UploaderFormItemProps,  
         },
         { 
-          label: '数字档案', name: 'archives', type: 'mulit-image',
+          label: '数字档案', name: 'archives', type: 'uploader',
           hidden: { callback: (_, model) => (model as SeminarInfo).type !== 5 },
           additionalProps: {
             placeholder: '请上传数字档案',
             maxCount: 20,
             name: 'file',
-            uploadCo: useAliOssUploadCo('seminar/archives')
-          } as UploadImageFormItemProps,
+            upload: useAliOssUploadCo('seminar/archives')
+          } as UploaderFormItemProps,
         },
         { 
           label: '标志', name: 'flag', type: 'select',
@@ -230,7 +236,7 @@ const formOptions = ref<IDynamicFormOptions>({
           } as SelectProps,
         },
         { 
-          label: '描述', name: 'desc', type: 'text-area',
+          label: '描述', name: 'desc', type: 'textarea',
           additionalProps: { placeholder: '请输入描述' },
         },
         { 
@@ -238,7 +244,7 @@ const formOptions = ref<IDynamicFormOptions>({
           additionalProps: { placeholder: '请输入TAG' },
         },
         { 
-          label: '备注', name: 'memo', type: 'text-area',
+          label: '备注', name: 'memo', type: 'textarea',
           additionalProps: { placeholder: '请输入备注' },
         },
       ]
@@ -269,22 +275,28 @@ const formExtendOptions = ref<IDynamicFormOptions>({
   formNestNameGenerateType: 'array',
   formItems: [
     { label: '持有者', name: 'holders', type: 'text', additionalProps: { placeholder: '请输入持有者' } },
-    { label: '地区', name: 'region', type: 'select-id', additionalProps: { placeholder: '请选择地区', loadData: async () => (await CommonContent.getCategoryList(1)).map(p => ({ label: p.title, value: p.id, raw: p })) } },
-    { label: '地图坐标', name: 'lonlat', type: 'map-pick-point' },
+    { 
+      label: '地区', name: 'region', type: 'select-id', 
+      additionalProps: { 
+        placeholder: '请选择地区',
+        loadData: async () => (await CommonContent.getCategoryList(1)).map(p => ({ text: p.title, value: p.id, raw: p })) 
+      } 
+    },
+    { label: '地图坐标', name: 'lonlat', type: 'select-lonlat' },
     {
-      type: 'simple-flat', label: '', name: 'map',
+      type: 'flat-simple', label: '', name: 'map',
       childrenColProps: { span: 12 },
       children: [
         { label: '平面坐标X', name: 'mapX', type: 'number', additionalProps: { placeholder: '请输入平面坐标X' } },
         { label: '平面坐标Y', name: 'mapY', type: 'number', additionalProps: { placeholder: '请输入平面坐标Y' } },
       ]
     },
-    { label: '成立时间', name: 'openTime', type: 'date-time', additionalProps: { placeholder: '请选择成立时间' } },
-    { label: '图片', name: 'image', type: 'single-image', additionalProps: { placeholder: '请上传图片', uploadCo: useAliOssUploadCo('seminar/images') } },
-    { label: '相关图片', name: 'images', type: 'mulit-image', additionalProps: { placeholder: '请上传相关图片', uploadCo: useAliOssUploadCo('seminar/images') } },
+    { label: '成立时间', name: 'openTime', type: 'datetime', additionalProps: { placeholder: '请选择成立时间' } },
+    { label: '图片', name: 'image', type: 'uploader', additionalProps: { placeholder: '请上传图片', upload: useAliOssUploadCo('seminar/images') } },
+    { label: '相关图片', name: 'images', type: 'uploader', additionalProps: { placeholder: '请上传相关图片', upload: useAliOssUploadCo('seminar/images') } },
     { label: '地址', name: 'address', type: 'text', additionalProps: { placeholder: '请输入地址' } },
-    { label: '描述', name: 'intro', type: 'text-area', additionalProps: { placeholder: '请输入描述' } },
-    { label: '简介', name: 'desc', type: 'text-area', additionalProps: { placeholder: '请输入简介' } }
+    { label: '描述', name: 'intro', type: 'textarea', additionalProps: { placeholder: '请输入描述' } },
+    { label: '简介', name: 'desc', type: 'textarea', additionalProps: { placeholder: '请输入简介' } }
   ],
   formRules: {
     region: [{ required: true, message: '请选择地区' }]

+ 35 - 24
src/pages/forms/works.vue

@@ -17,13 +17,10 @@ import InheritorContent, { InheritorWorkInfo } from '@/api/inheritor/InheritorCo
 import CommonContent from '@/api/CommonContent';
 import type { IDynamicFormOptions } from '@imengyu/vue-dynamic-form';
 import type { SelectProps } from 'ant-design-vue';
-import { 
-  useBeforeUploadAudioChecker, useBeforeUploadImageChecker,
-  useBeforeUploadVideoChecker, type UploadImageFormItemProps 
-} from '@imengyu/imengyu-web-shared';
 import { useRoute } from 'vue-router';
 import { useAuthStore } from '@/stores/auth';
 import { useAliOssUploadCo } from '@/common/upload/AliOssUploadCo';
+import { useBeforeUploadAudioChecker, useBeforeUploadImageChecker, useBeforeUploadVideoChecker, type UploaderFormItemProps } from '@imengyu/vue-dynamic-form-ant';
 
 const authStore = useAuthStore();
 const formModel = ref(new InheritorWorkInfo()) as Ref<InheritorWorkInfo>;
@@ -37,12 +34,19 @@ const formOptions = ref<IDynamicFormOptions>({
   formNestNameGenerateType: 'array',
   formItems: [
       {
-        type: 'group-flat', label: '作品/产品信息', name: 'baseInfo',
+        type: 'flat-group', label: '作品/产品信息', name: 'baseInfo',
         childrenColProps: { span: 24 },
         children: [
           { label: '作品/产品名称', name: 'title', type: 'text', additionalProps: { placeholder: '请输入标题' } },
-          { label: '所属区域', name: 'region', type: 'select-id', additionalProps: { placeholder: '请选择地区', loadData: async () => (await CommonContent.getCategoryList(1)).map(p => ({ label: p.title, value: p.id, raw: p })) } },
-          { label: '类型', name: 'type', type: 'select', 
+          { 
+            label: '所属区域', name: 'region', type: 'select-id', 
+            additionalProps: { 
+              placeholder: '请选择地区', 
+              loadData: async () => (await CommonContent.getCategoryList(1)).map(p => ({ text: p.title, value: p.id, raw: p })) 
+            } 
+          },
+          { 
+            label: '类型', name: 'type', type: 'select', 
             additionalProps: { 
               placeholder: '请选择类型', 
               options: [
@@ -52,49 +56,56 @@ const formOptions = ref<IDynamicFormOptions>({
                 { text: '相册', value: 4 }, 
                 { text: '其他类型', value: 5 }
               ] 
-            } as SelectProps 
+            }
+          },
+          { label: '缩略图', name: 'image', type: 'uploader', 
+            additionalProps: { 
+              placeholder: '请上传图片', 
+              upload: useAliOssUploadCo('inheritor/images'), 
+              name: 'file', 
+              accept: 'image/*' 
+            } as UploaderFormItemProps 
           },
-          { label: '缩略图', name: 'image', type: 'single-image', additionalProps: { placeholder: '请上传图片', uploadCo: useAliOssUploadCo('inheritor/images'), name: 'file', accept: 'image/*' } as UploadImageFormItemProps },
           { label: '图片说明', name: 'imageDesc', type: 'text', additionalProps: { placeholder: '请输入图片说明' } },
           { 
-            label: '组图', name: 'images', type: 'mulit-image', 
+            label: '组图', name: 'images', type: 'uploader', 
             hidden: { callback: (_, model) => (model as InheritorWorkInfo).type !== 4 },
             additionalProps: { 
               placeholder: '请上传组图', 
               accept: 'image/*',
               beforeUpload: useBeforeUploadImageChecker(),
-              uploadCo: useAliOssUploadCo('inheritor/images'), name: 'file', maxCount: 20 
-            } as UploadImageFormItemProps 
+              upload: useAliOssUploadCo('inheritor/images'), name: 'file', maxCount: 20 
+            } as UploaderFormItemProps 
           },
           { label: '作品/产品介绍', name: 'content', type: 'richtext', additionalProps: { placeholder: '请输入内容介绍' } },
           { 
-            label: '音频', name: 'audio', type: 'single-video', 
+            label: '音频', name: 'audio', type: 'uploader', 
             hidden: { callback: (_, model) => (model as InheritorWorkInfo).type !== 2 },
             additionalProps: { 
               placeholder: '请上传音频', 
               accept: 'audio/*',
               beforeUpload: useBeforeUploadAudioChecker(),
-              uploadCo: useAliOssUploadCo('inheritor/audios'), 
+              upload: useAliOssUploadCo('inheritor/audios'), 
               name: 'file' 
-            } as UploadImageFormItemProps 
+            } as UploaderFormItemProps 
           },
           { 
-            label: '相关视频', name: 'video', type: 'single-video', 
+            label: '相关视频', name: 'video', type: 'uploader', 
             hidden: { callback: (_, model) => (model as InheritorWorkInfo).type !== 3 },
             additionalProps: { 
               beforeUpload: useBeforeUploadVideoChecker(),
-              placeholder: '请上传视频', uploadCo: useAliOssUploadCo('inheritor/videos'), name: 'file' 
-            } as UploadImageFormItemProps 
+              placeholder: '请上传视频', upload: useAliOssUploadCo('inheritor/videos'), name: 'file' 
+            } as UploaderFormItemProps 
           },
           { 
-            label: '数字档案', name: 'archives', type: 'mulit-image', 
+            label: '数字档案', name: 'archives', type: 'uploader', 
             hidden: { callback: (_, model) => (model as InheritorWorkInfo).type !== 5 },
             additionalProps: { 
               placeholder: '请上传数字档案', 
-              uploadCo: useAliOssUploadCo('inheritor/archives'), 
+              upload: useAliOssUploadCo('inheritor/archives'), 
               name: 'file', 
               maxCount: 100 
-            } as UploadImageFormItemProps 
+            } as UploaderFormItemProps 
           },
           { 
             label: '审核人员', name: 'text1', type: 'static-text', 
@@ -119,12 +130,12 @@ const formOptions = ref<IDynamicFormOptions>({
         ]
       },
       /* {
-        type: 'group-flat', label: '扩展信息', name: 'extendInfo',
+        type: 'flat-group', label: '扩展信息', name: 'extendInfo',
         childrenColProps: { span: 24 },
         children: [
           { label: '转自', name: 'from', type: 'text', additionalProps: { placeholder: '请输入转自' } },
           { label: '关键字', name: 'keywords', type: 'select', additionalProps: { mode: 'tags', options: [], placeholder: '请输入关键字,回车添加' } as SelectProps },
-          { label: '描述', name: 'desc', type: 'text-area', additionalProps: { placeholder: '请输入描述' } },
+          { label: '描述', name: 'desc', type: 'textarea', additionalProps: { placeholder: '请输入描述' } },
           { label: 'TAG', name: 'tags', type: 'text', additionalProps: { placeholder: '请输入TAG' } },
           { label: '分类', name: 'category', type: 'text', additionalProps: { placeholder: '请输入分类' } },
           { label: '特点', name: 'feature', type: 'text', additionalProps: { placeholder: '请输入特点' } },
@@ -136,7 +147,7 @@ const formOptions = ref<IDynamicFormOptions>({
           { label: '形成或记录年代', name: 'creationEra', type: 'text', additionalProps: { placeholder: '请输入形成或记录年代' } },
           { label: '主要演述人', name: 'mainPerformer', type: 'text', additionalProps: { placeholder: '请输入主要演述人' } },
           { label: '其他代表人物', name: 'otherPerformers', type: 'text', additionalProps: { placeholder: '请输入其他代表人物' } },
-          { label: '作品全文', name: 'fullString', type: 'text-area', additionalProps: { placeholder: '请输入作品全文' } },
+          { label: '作品全文', name: 'fullString', type: 'textarea', additionalProps: { placeholder: '请输入作品全文' } },
           { label: '曲调', name: 'tune', type: 'text', additionalProps: { placeholder: '请输入曲调' } },
           { label: '发展演变', name: 'development', type: 'text', additionalProps: { placeholder: '请输入发展演变' } },
           { label: '流传情况', name: 'spread', type: 'text', additionalProps: { placeholder: '请输入流传情况' } },

+ 32 - 9
src/pages/inheritor.vue

@@ -15,12 +15,12 @@
         <a-tabs v-model:activeKey="activeKey" centered>
           <a-tab-pane key="1" tab="非遗项目">
             <EmptyToRecord title="非遗项目" :loader="ichData" @edit="router.push({ name: 'FormIch' })">
-              <a-alert v-if="ichData.content.value!.progress == -1" message="提交的信息被退回,您可以去修改" type="error" class="mt-3" showIcon>
+              <a-alert v-if="ichData.content.value!.progress == -1" message="提交的信息被退回,您可以去修改" type="error" class="mt-4" showIcon>
                 <template #action>
                   <a-button size="small" type="primary" @click="router.push({ name: 'FormIch' })">去修改</a-button>
                 </template>
               </a-alert>
-              <a-descriptions class="mt-3 light" title="非遗项目信息" v-if="ichData.content.value" bordered :column="{ xs: 1, sm: 1, md: 1, lg: 2 }">
+              <a-descriptions class="mt-4 light" title="非遗项目信息" v-if="ichData.content.value" bordered :column="{ xs: 1, sm: 1, md: 1, lg: 2 }">
                 <a-descriptions-item label="非遗项目名称"><ShowValueOrNull :value="ichData.content.value.title" /></a-descriptions-item>
                 <a-descriptions-item label="非遗分类"><ShowValueOrNull :value="ichData.content.value.ichTypeText" /></a-descriptions-item>
                 <a-descriptions-item label="申报区域"><ShowValueOrNull :value="ichData.content.value.regionText" /></a-descriptions-item>
@@ -46,12 +46,12 @@
           </a-tab-pane>
           <a-tab-pane key="2" tab="传承人">
             <EmptyToRecord title="传承人" :loader="inheritorData" @edit="router.push({ name: 'FormInheritor' })">
-              <a-alert v-if="inheritorData.content.value!.progress == -1" message="提交的信息被退回,您可以去修改" type="error" class="mt-3" showIcon>
+              <a-alert v-if="inheritorData.content.value!.progress == -1" message="提交的信息被退回,您可以去修改" type="error" class="mt-4" showIcon>
                 <template #action>
                   <a-button size="small" type="primary" @click="router.push({ name: 'FormInheritor' })">去修改</a-button>
                 </template>
               </a-alert>
-              <a-descriptions class="mt-3 light" title="传承人信息" v-if="inheritorData.content.value" bordered :column="{ xs: 1, sm: 1, md: 1, lg: 2 }">
+              <a-descriptions class="mt-4 light" title="传承人信息" v-if="inheritorData.content.value" bordered :column="{ xs: 1, sm: 1, md: 1, lg: 2 }">
                 <a-descriptions-item label="名字"><ShowValueOrNull :value="inheritorData.content.value.title" /></a-descriptions-item>
                 <a-descriptions-item label="传承人照片">
                   <ImageGrid :data="inheritorData.content.value.images" />
@@ -84,7 +84,7 @@
           <a-tab-pane key="3" tab="传习所">
             <EmptyToRecord title="传习所" :loader="seminarData" @edit="router.push({ name: 'FormSeminar' })">
               <a-alert v-if="seminarData.content.value?.progress == -1" message="提交的信息被退回,您可以去修改" type="warning" showIcon></a-alert>
-              <a-descriptions class="mt-3 light" title="传习所信息" v-if="seminarData.content.value" bordered :column="{ xs: 1, sm: 1, md: 1, lg: 2 }">
+              <a-descriptions class="mt-4 light" title="传习所信息" v-if="seminarData.content.value" bordered :column="{ xs: 1, sm: 1, md: 1, lg: 2 }">
                 <a-descriptions-item label="传习所名称"><ShowValueOrNull :value="seminarData.content.value.title" /></a-descriptions-item>
                 <a-descriptions-item label="传习所级别"><ShowValueOrNull :value="seminarData.content.value.levelText" /></a-descriptions-item>
                 <a-descriptions-item label="批次"><ShowValueOrNull :value="seminarData.content.value.batchText" /></a-descriptions-item>
@@ -109,7 +109,7 @@
           </a-tab-pane>
           <a-tab-pane key="4" tab="作品">
             <EmptyToRecord title="作品" buttonText="新增作品" :loader="ichData" :showEdited="false" @edit="router.push({ name: 'FormWork' })">
-              <div class="d-flex justify-content-end me-2 mb-2">
+              <div class="flex justify-end me-2 mb-2">
                 <a-button type="primary" size="small" @click="router.push({ name: 'FormWork' })">+ 新增</a-button>
               </div>
               <a-list class="light-round" item-layout="horizontal" :data-source="worksData || []">
@@ -133,7 +133,7 @@
           </a-tab-pane>
           <!-- <a-tab-pane key="5" tab="五年计划">
             <EmptyToRecord title="五年计划" :model="planData" :showEdited="false" @edit="router.push({ name: 'FormPlan' })">
-              <div class="d-flex justify-content-end me-2 mb-2">
+              <div class="flex justify-end me-2 mb-2">
                 <a-button type="primary" size="small" @click="router.push({ name: 'FormPlan' })">+ 新增</a-button>
               </div>
               <a-list item-layout="horizontal" :data-source="planData">
@@ -154,6 +154,27 @@
               </a-list>
             </EmptyToRecord>
           </a-tab-pane> -->
+          <a-tab-pane key="6">
+            <template #tab>
+              <a-badge count="NEW">
+                <span class="pr-5">自查评估</span>
+              </a-badge>
+            </template>
+            <div class="flex flex-row mt-4 mb-4 gap-4">
+              <a-button class="flex-1 h-[240px]!" type="default" @click="router.push({ name: 'CollectEvaluationForm' })">
+                <div class="flex flex-col items-center">
+                  <img src="@/assets/images/icon/EvaluationForm.svg" class="w-[100px] h-[100px]" alt="自查评估表" />
+                  <span>自查评估表</span>
+                </div>
+              </a-button>
+              <a-button class="flex-1 h-[240px]!" type="default" @click="router.push({ name: 'CollectAgreementSign' })">
+                <div class="flex flex-col items-center">
+                  <img src="@/assets/images/icon/AgreementSign.svg" class="w-[100px] h-[100px]" alt="传承协议签名" />
+                  <span>传承协议签名</span>
+                </div>
+              </a-button>
+            </div>
+          </a-tab-pane>
         </a-tabs>
       </div>
     </section>
@@ -172,12 +193,14 @@
 import { ref, watch } from 'vue';
 import { useRoute, useRouter } from 'vue-router';
 import { SettingsUtils } from '@imengyu/imengyu-utils';
-import { RequestApiError } from '@imengyu/imengyu-utils/dist/request';
+import { RequestApiError } from '@imengyu/imengyu-utils';
 import type { InheritorInfo, InheritorWorkInfo, PlanInfo, SeminarInfo } from '@/api/inheritor/InheritorContent';
-import { ImageGrid, SimpleRichHtml, ShowValueOrNull, useSimpleDataLoader } from '@imengyu/imengyu-web-shared';
 import InheritorContent from '@/api/inheritor/InheritorContent';
 import SimplePointedMap from '@/components/content/SimplePointedMap.vue';
 import EmptyToRecord from '@/components/parts/EmptyToRecord.vue';
+import { useSimpleDataLoader } from '@/composeables/useSimpleDataLoader';
+import SimpleRichHtml from '@/components/content/SimpleRichHtml.vue';
+import ImageGrid from '@/components/content/ImageGrid.vue';
 
 const router = useRouter();
 const route = useRoute();

+ 1 - 2
src/pages/login.vue

@@ -22,7 +22,6 @@
 </template>
 
 <script setup lang="ts">
-import type { SimpleSelectFormItemProps } from '@imengyu/imengyu-web-shared';
 import { useAuthStore } from '@/stores/auth';
 import { waitTimeOut } from '@imengyu/imengyu-utils';
 import { DynamicForm, type IDynamicFormOptions, type IDynamicFormRef } from '@imengyu/vue-dynamic-form';
@@ -86,7 +85,7 @@ const formOptions = ref<IDynamicFormOptions>({
             value: 1,
           },
         ],
-      } as SimpleSelectFormItemProps
+      }
     },
   ],
   formRules: {

+ 8 - 3
src/router/index.ts

@@ -69,9 +69,14 @@ const router = createRouter({
       component: () => import('@/pages/inheritor.vue'),
     },
     {
-      path: '/editor-test',
-      name: 'EditorTest',
-      component: () => import('@/pages/editor-test.vue'),
+      path: '/collect/assessment/evaluation-form',
+      name: 'CollectEvaluationForm',
+      component: () => import('@/pages/collect/assessment/evaluation-form.vue'),
+    },
+    {
+      path: '/collect/assessment/argeement-sign',
+      name: 'CollectAgreementSign',
+      component: () => import('@/pages/collect/assessment/argeement-sign.vue'),
     },
     {
       path: '/:pathMatch(.*)*',

+ 6 - 0
src/tailwind.css

@@ -0,0 +1,6 @@
+@import "tailwindcss";
+
+@theme {
+  /* 与 Bootstrap 5 `text-secondary` 接近,供迁移后的类名使用 */
+  --color-secondary: #6c757d;
+}

+ 3 - 0
src/vite-env.d.ts

@@ -0,0 +1,3 @@
+/// <reference types="vite/client" />
+/// <reference types="@imengyu/vue-dynamic-form-ant/index.d.ts" />
+/// <reference types="@imengyu/vue-dynamic-form-rich/index.d.ts" />

+ 2 - 0
vite.config.ts

@@ -4,10 +4,12 @@ import { defineConfig } from 'vite'
 import vue from '@vitejs/plugin-vue'
 import vueJsx from '@vitejs/plugin-vue-jsx'
 import vueDevTools from 'vite-plugin-vue-devtools'
+import tailwindcss from '@tailwindcss/vite'
 
 // https://vite.dev/config/
 export default defineConfig({
   plugins: [
+    tailwindcss(),
     vue(),
     vueJsx(),
     vueDevTools(),