소스 검색

feature(仪器预约):新增仪器预约、预约日历功能

wanglj 1 년 전
부모
커밋
38183464b3
53개의 변경된 파일2448개의 추가작업 그리고 235개의 파일을 삭제
  1. 7 0
      components.d.ts
  2. 2 1
      package.json
  3. 11 4
      pnpm-lock.yaml
  4. 35 0
      src/api/instr/document.ts
  5. 35 0
      src/api/instr/notice.ts
  6. 71 0
      src/api/instr/useAppoint.ts
  7. 58 0
      src/api/instr/usedRecord.ts
  8. 2 2
      src/api/login/index.ts
  9. 6 2
      src/api/project/index.ts
  10. 51 0
      src/api/system/config.ts
  11. 2 2
      src/api/technical/index.js
  12. 4 1
      src/api/training/index.ts
  13. 167 0
      src/components/CustomForm.vue
  14. 40 0
      src/directive/authDirective.ts
  15. 178 0
      src/directive/customDirective.ts
  16. 18 0
      src/directive/index.ts
  17. 1 1
      src/layout/entry.vue
  18. 25 46
      src/layout/index.vue
  19. 19 0
      src/layout/training.vue
  20. 3 2
      src/main.ts
  21. 87 19
      src/router.ts
  22. 11 5
      src/stores/userInfo.ts
  23. 4 3
      src/theme/index.scss
  24. 5 1
      src/types/index.d.ts
  25. 88 0
      src/utils/arrayOperation.ts
  26. 0 2
      src/utils/formatTime.ts
  27. 1 1
      src/utils/micro_request.ts
  28. 1 1
      src/utils/request.ts
  29. 20 0
      src/view/entry/appoint.vue
  30. 48 41
      src/view/entry/index.vue
  31. 20 0
      src/view/entry/mine.vue
  32. 3 3
      src/view/exam/cover.vue
  33. 8 8
      src/view/home/index.vue
  34. 394 0
      src/view/instr/appoint.vue
  35. 2 2
      src/view/instr/appoint/index.vue
  36. 3 5
      src/view/instr/appointList/inProgress/index.vue
  37. 20 6
      src/view/instr/appointList/index.vue
  38. 1 3
      src/view/instr/appointList/myAppoint/index.vue
  39. 2 3
      src/view/instr/appointList/soonGeton/index.vue
  40. 304 0
      src/view/instr/calendar.vue
  41. 540 0
      src/view/instr/detail.vue
  42. 11 0
      src/view/instr/list-follow.vue
  43. 37 18
      src/view/instr/list.vue
  44. 6 7
      src/view/login/index.vue
  45. 1 1
      src/view/notice/index.vue
  46. 1 3
      src/view/service/index.vue
  47. 2 2
      src/view/todo/index.vue
  48. 19 0
      src/view/training/done.vue
  49. 4 2
      src/view/training/enroll.vue
  50. 61 25
      src/view/training/index.vue
  51. 4 3
      src/view/user/edit.vue
  52. 4 9
      src/view/user/index.vue
  53. 1 1
      tsconfig.json

+ 7 - 0
components.d.ts

@@ -7,8 +7,13 @@ export {}
 
 declare module 'vue' {
   export interface GlobalComponents {
+    CustomForm: typeof import('./src/components/CustomForm.vue')['default']
     RouterLink: typeof import('vue-router')['RouterLink']
     RouterView: typeof import('vue-router')['RouterView']
+    VanActionBar: typeof import('vant/es')['ActionBar']
+    VanActionBarButton: typeof import('vant/es')['ActionBarButton']
+    VanActionBarIcon: typeof import('vant/es')['ActionBarIcon']
+    VanBackTop: typeof import('vant/es')['BackTop']
     VanButton: typeof import('vant/es')['Button']
     VanCell: typeof import('vant/es')['Cell']
     VanCellGroup: typeof import('vant/es')['CellGroup']
@@ -22,6 +27,7 @@ declare module 'vue' {
     VanFloatingBubble: typeof import('vant/es')['FloatingBubble']
     VanForm: typeof import('vant/es')['Form']
     VanIcon: typeof import('vant/es')['Icon']
+    VanImage: typeof import('vant/es')['Image']
     VanList: typeof import('vant/es')['List']
     VanNotify: typeof import('vant/es')['Notify']
     VanPicker: typeof import('vant/es')['Picker']
@@ -41,6 +47,7 @@ declare module 'vue' {
     VanTabs: typeof import('vant/es')['Tabs']
     VanTag: typeof import('vant/es')['Tag']
     VanTextEllipsis: typeof import('vant/es')['TextEllipsis']
+    VanTimePicker: typeof import('vant/es')['TimePicker']
     VanUploader: typeof import('vant/es')['Uploader']
   }
 }

+ 2 - 1
package.json

@@ -13,7 +13,8 @@
     "await-to-js": "^3.0.0",
     "axios": "^1.8.2",
     "cropperjs": "1.5.13",
-    "moment": "^2.30.1",
+    "downloadjs": "^1.4.7",
+    "moment": "^2.29.4",
     "pinia": "^3.0.1",
     "postcss-px-to-viewport": "^1.1.1",
     "sm-crypto": "^0.3.13",

+ 11 - 4
pnpm-lock.yaml

@@ -17,9 +17,12 @@ dependencies:
   cropperjs:
     specifier: 1.5.13
     version: 1.5.13
+  downloadjs:
+    specifier: ^1.4.7
+    version: 1.4.7
   moment:
-    specifier: ^2.30.1
-    version: 2.30.1
+    specifier: ^2.29.4
+    version: 2.29.4
   pinia:
     specifier: ^3.0.1
     version: 3.0.1(typescript@4.9.5)(vue@3.5.13)
@@ -747,6 +750,10 @@ packages:
     requiresBuild: true
     optional: true
 
+  /downloadjs@1.4.7:
+    resolution: {integrity: sha512-LN1gO7+u9xjU5oEScGFKvXhYf7Y/empUIIEAGBs1LzUq/rg5duiDrkuH5A2lQGd5jfMOb9X9usDa2oVXwJ0U/Q==}
+    dev: false
+
   /dunder-proto@1.0.1:
     resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
     engines: {node: '>= 0.4'}
@@ -1023,8 +1030,8 @@ packages:
     resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
     dev: false
 
-  /moment@2.30.1:
-    resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
+  /moment@2.29.4:
+    resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
     dev: false
 
   /ms@2.1.3:

+ 35 - 0
src/api/instr/document.ts

@@ -0,0 +1,35 @@
+/*
+ * @Author: liuzhenlin 461480418@qq.ocm
+ * @Date: 2023-07-14 17:15:49
+ * @LastEditors: liuzhenlin
+ * @LastEditTime: 2023-08-14 14:21:15
+ * @Description: file content
+ * @FilePath: \frontend\packages\vue-next-admin-sub\src\api\instr\index.ts
+ */
+import request from '/@/utils/micro_request.js';
+const basePath = import.meta.env.VITE_INSTR_ADMIN;
+// 参数设置
+export function useInstDocApi() {
+	return {
+		// 新增
+		add(query?: object) {
+			return request.postRequest(basePath, 'TusInstrumentDocument', 'Create', query);
+		},
+		// 详情
+		detail(query?: object) {
+			return request.postRequest(basePath, 'TusInstrumentDocument', 'GetEntityById', query);
+		},
+    // 详情
+		list(query?: object) {
+			return request.postRequest(basePath, 'TusInstrumentDocument', 'GetList', query);
+		},
+		// 更新
+		update(query?: object) {
+			return request.postRequest(basePath, 'TusInstrumentDocument', 'UpdateById', query);
+		},
+		// 删除
+		del(query?: object) {
+			return request.postRequest(basePath, 'TusInstrumentDocument', 'DeleteByIds', query);
+		},
+	};
+}

+ 35 - 0
src/api/instr/notice.ts

@@ -0,0 +1,35 @@
+/*
+ * @Author: liuzhenlin 461480418@qq.ocm
+ * @Date: 2023-07-14 17:15:49
+ * @LastEditors: liuzhenlin
+ * @LastEditTime: 2023-08-16 11:14:42
+ * @Description: file content
+ * @FilePath: \frontend\packages\vue-next-admin-sub\src\api\inst\notice.ts
+ */
+import request from '/@/utils/micro_request.js';
+const basePath = import.meta.env.VITE_INSTR_ADMIN;
+// 参数设置
+export function useNoticeApi() {
+	return { 
+		// 新增
+		add(query?: object) {
+			return request.postRequest(basePath, 'TusInstrumentNotice', 'Create', query);
+		},
+		// 详情
+		detail(query?: object) {
+			return request.postRequest(basePath, 'TusInstrumentNotice', 'GetEntityById', query);
+		},
+    // 详情
+		list(query?: object) {
+			return request.postRequest(basePath, 'TusInstrumentNotice', 'GetList', query);
+		},
+		// 更新
+		update(query?: object) {
+			return request.postRequest(basePath, 'TusInstrumentNotice', 'UpdateById', query);
+		},
+		// 删除
+		del(query?: object) {
+			return request.postRequest(basePath, 'TusInstrumentNotice', 'DeleteByIds', query);
+		},
+	};
+}

+ 71 - 0
src/api/instr/useAppoint.ts

@@ -0,0 +1,71 @@
+/*
+ * @Author: liuzhenlin 461480418@qq.ocm
+ * @Date: 2023-08-10 18:20:32
+ * @LastEditors: liuzhenlin
+ * @LastEditTime: 2023-08-25 17:14:47
+ * @Description: file content
+ * @FilePath: \frontend\packages\vue-next-admin-sub\src\api\inst\useAppoint.ts
+ */
+import request from '/@/utils/micro_request.js';
+const basePath = import.meta.env.VITE_INSTR_ADMIN;
+// 参数设置
+export function useUseAppointApi() {
+  return {
+    //预约列表
+    list(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentAppointment', 'GetList', query);
+    },
+    //按权限查预约列表
+    getListByPermission(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentAppointment', 'GetListByInst', query);
+    },
+    // 导出预约列表
+    exportExcel(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentAppointment', 'ExportExcel', query);
+    },
+    // 预约
+    add(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentAppointment', 'Create', query);
+    },
+    // 编辑预约
+    update(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentAppointment', 'UpdateById', query);
+    },
+    // 取消预约
+    cancelAppoint(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentAppointment', 'AdminCancel', query);
+    },
+    // 取消预约
+    userCancelAppoint(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentAppointment', 'UserCancel', query);
+    },
+    // 获取全部预约情况
+    getAppointInfo(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentAppointment', 'AppointInfo', query);
+    },
+    // 获取设备预约情况
+    getInstrAppointInfo(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentAppointment', 'GetEntityById', query);
+    },
+    // 获取用户信息
+    getUserId(query?: object) {
+      return request.postRequest(basePath, 'MyAppointment', 'UserInfo', query);
+    },
+    // 获取用户是否㤇资质申请
+    getNeedGrant(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentAppointment', 'NeedGrantRequest', query);
+    },
+    // 统计预约记录
+    getAppointReportStatistic(query?: object) {
+      return request.postRequest(basePath, 'TusInstrument', 'Statistic', query);
+    },
+    // 统计预约记录
+    onStatisticExport(query?: object) {
+      return request.postRequest(basePath, 'TusInstrument', 'StatisticExport', query);
+    },
+    // 预约记录
+    getZunYiAppointRecord(query?: object) {
+      return request.postRequest(basePath, 'ZunyiCustom', 'GetAppointmentList', query);
+    },
+  };
+}

+ 58 - 0
src/api/instr/usedRecord.ts

@@ -0,0 +1,58 @@
+/*
+ * @Author: liuzhenlin 461480418@qq.ocm
+ * @Date: 2023-08-10 18:20:32
+ * @LastEditors: wanglj wanglijie@dashoo.cn
+ * @LastEditTime: 2025-03-25 11:45:13
+ * @Description: file content
+ * @FilePath: \frontend\packages\vue-next-admin-sub\src\api\inst\usedRecord.ts
+ */
+import request from '/@/utils/micro_request.js';
+const basePath = import.meta.env.VITE_INSTR_ADMIN;
+// 参数设置
+export function useUsedRecordApi() {
+  return {
+    //使用列表 
+    getList(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentUsedRecord', 'GetList', query);
+    },
+    //使用列表带权限 
+    getListByScoped(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentUsedRecord', 'GetList', query);
+    },
+    //预约列表 
+    list(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentUsedRecord', 'GetListByInst', query);
+    },
+    // 预约
+    create(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentUsedRecord', 'CreateFromAppoint', query);
+    },
+    // 删除
+    del(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentUsedRecord', 'DeleteByIds', query);
+    },
+    // 更新
+    update(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentUsedRecord', 'UpdateById', query);
+    },
+    // 获取使用列表
+    getUseList(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentUsedRecord', 'GetAppointmentWithUse', query);
+    },
+    getListByProj(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentUsedRecord', 'GetListByProj', query);
+    },
+    // 导出
+    exportFile(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentUsedRecord', 'ExportExcelByInst', query);
+    },
+    // 统计信息
+    getStatisticsInfo(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentUsedRecord', 'StatisticByInst', query);
+    },
+    // 按课题组查询使用记录 
+    getInstUseRecordByProj(query?: object) {
+      return request.postRequest(basePath, 'TusInstrumentUsedRecord', 'GetListByCurrentProj', query);
+    },
+  };
+}

+ 2 - 2
src/api/login/index.ts

@@ -13,8 +13,8 @@ export function useLoginApi() {
     signIn: (query?: object) => {
       return request.postRequestWithClientInfo(basePath, 'System', 'Login', query)
     },
-    weChatLoginOpenId: (query?: object) => {
-      return request.postRequestWithClientInfo(basePath, 'System', 'WeChatLoginOpenId', query)
+    weChatLoginUnionId: (query?: object) => {
+      return request.postRequestWithClientInfo(basePath, 'System', 'WeChatLoginUnionId', query)
     },
     weChatLogin: (query?: object) => {
       return request.postRequestWithClientInfo(basePath, 'System', 'WeChatLogin', query)

+ 6 - 2
src/api/project/index.ts

@@ -1,8 +1,8 @@
 /*
  * @Author: wanglj 471442253@qq.com
  * @Date: 2023-07-19 13:42:40
- * @LastEditors: wanglj
- * @LastEditTime: 2024-10-29 16:55:42
+ * @LastEditors: wanglj wanglijie@dashoo.cn
+ * @LastEditTime: 2025-03-24 14:52:11
  * @Description: file content
  * @FilePath: \labsop_backup\frontend\components\labsop-api\src\api\base\project\index.ts
  */
@@ -150,5 +150,9 @@ export function useProApi() {
 		getProjectGroupMemberBlackListById(query?: object) {
 			return request.postRequest(adminPath, 'ProjectGroup', 'GetProjectGroupMemberBlackListById', query);
 		},
+    // 列表
+		getFinanceAccountList(query?: object) {
+			return request.postRequest(basePath, 'Finance', 'GetFinanceAccountList', query);
+		},
 	};
 }

+ 51 - 0
src/api/system/config.ts

@@ -0,0 +1,51 @@
+/*
+ * @Author: wanglj 471442253@qq.com
+ * @Date: 2023-07-19 13:42:40
+ * @LastEditors: wanglj wanglijie@dashoo.cn
+ * @LastEditTime: 2025-03-25 16:00:58
+ * @Description: file content
+ * @FilePath: \labsop_meno\frontend\packages\base\src\api\system\config.ts
+ */
+import request from '/@/utils/micro_request.js'
+const basePath = import.meta.env.VITE_ADMIN
+// 参数设置
+export function useConfigApi() {
+  return {
+    // 创建参数
+    createConfig(query?: object) {
+      return request.postRequest(basePath,'Config','Create', query)
+    },
+    // 参数列表
+    getConfigList(query?: object) {
+      return request.postRequest(basePath,'Config','GetList', query)
+    },
+    // 删除参数
+    delConfig(query?: object) {
+      return request.postRequest(basePath,'Config','DeleteByIds', query)
+    },
+    // 参数详情
+    getConfigEntity(query?: object) {
+      return request.postRequest(basePath,'Config','GetEntityById', query)
+    },
+    // 编辑参数
+    updateConfig(query?: object) {
+      return request.postRequest(basePath,'Config','UpdateById', query)
+    },
+     // 保存系统参数
+     saveSysConfigParams(query?: object) {
+      return request.postRequest(basePath,'Config','SaveSysConfigParams', query)
+    },
+    // 查询系统参数
+    getEntityMapByKeys(query?: object) {
+      return request.postRequest(basePath,'Config','GetEntityMapByKeys', query)
+    },
+    // 查询系统参数单个
+    getEntityMapByKey(query?: object) {
+      return request.postRequest(basePath,'Config','GetEntityByKey', query)
+    },
+    getEntityByKey(query?: object) {
+      return request.postRequest(basePath,'Config','GetEntityByKey', query)
+    }
+  }
+}
+

+ 2 - 2
src/api/technical/index.js

@@ -6,8 +6,8 @@
  * @Description: file content
  * @FilePath: \frontend\packages\vue-next-admin-sub\src\api\inst\index.ts
  */
-import micro_request from '../../utils/micro_request'
-const basePath = process.uniEnv.VITE_INSTR_ADMIN
+import micro_request from '/@/utils/micro_request'
+const basePath = import.meta.env.VITE_INSTR_ADMIN
 // 技术服务
 export default {
   // 列表

+ 4 - 1
src/api/training/index.ts

@@ -2,7 +2,7 @@
  * @Author: wanglj 471442253@qq.com
  * @Date: 2023-07-19 13:42:40
  * @LastEditors: wanglj wanglijie@dashoo.cn
- * @LastEditTime: 2025-03-17 18:13:48
+ * @LastEditTime: 2025-03-27 09:19:03
  * @Description: file content
  * @FilePath: \labsop_meno\frontend\packages\instr\src\api\base\index.ts
  */
@@ -15,6 +15,9 @@ export function useTrainingApi() {
     getList(query?: object) {
       return request.postRequest(basePath, 'TeTrainingNotice', 'List', query)
     },
+    getListForUser(query?: object) {
+      return request.postRequest(basePath, 'TeTrainingNotice', 'ListForUser', query)
+    },
     // 详情
     getEntity(query?: object) {
       return request.postRequest(basePath, 'TeTrainingNotice', 'GetEntityById', query)

+ 167 - 0
src/components/CustomForm.vue

@@ -0,0 +1,167 @@
+<template>
+  <van-form ref="formRef" required="auto" v-for="(v, i) in formData" :key="i">
+    <h4>{{ v.formTitle }}</h4>
+    <van-cell-group class="mt10">
+      <template v-for="(element, index) in v.formItemList">
+        <!-- 单行文本 -->
+        <van-field
+          v-if="element.tagIcon == 'input'"
+          :label="element.label"
+          v-model="element.defaultValue"
+          placeholder="请输入"
+          :rules="[{ required: element.required, message: `${element.label}不能为空` }]"
+        ></van-field>
+        <!-- 多行文本 -->
+        <van-field
+          v-else-if="element.tagIcon == 'textarea'"
+          :label="element.label"
+          v-model="element.defaultValue"
+          placeholder="请输入"
+          rows="3"
+          autosize
+          type="textarea"
+          :rules="[{ required: element.required, message: `${element.label}不能为空` }]"
+        ></van-field>
+        <!-- 整数 -->
+        <van-field v-else-if="element.tagIcon == 'number'" :label="element.label" v-model="element.defaultValue" placeholder="请输入" type="digit" />
+        <!-- 浮点数 -->
+        <van-field v-else-if="element.tagIcon == 'float'" :label="element.label" v-model="element.defaultValue" placeholder="请输入" type="number" />
+        <!-- 日期、时间 -->
+        <van-field
+          v-else-if="element.tagIcon == 'time' || element.tagIcon == 'date'"
+          is-link
+          readonly
+          :label="element.label"
+          @click="onDateClick(element)"
+          :rules="[{ required: element.required, message: `${element.label}不能为空` }]"
+        >
+          <template #input>
+            <span v-if="!element.defaultValue?.length">-</span>
+            <span v-else>{{ element.tagIcon == 'time' ? element.defaultValue.join(':') : element.defaultValue.join('-') }}</span>
+          </template>
+        </van-field>
+        <van-field v-else :label="element.label" :rules="[{ required: element.required, message: `${element.label}不能为空` }]">
+          <template #input>
+            <!-- 多选 -->
+            <van-checkbox-group v-if="element.tagIcon == 'multiple-select'" v-model="element.defaultValue" direction="horizontal">
+              <van-checkbox v-for="(item, idx) in element.options" :name="item.value">{{ item.label }}</van-checkbox>
+            </van-checkbox-group>
+            <!-- 单选 -->
+            <van-radio-group v-else-if="element.tagIcon == 'select'" v-model="element.defaultValue" direction="horizontal">
+              <van-radio v-for="(item, idx) in element.options" :name="item.value">{{ item.label }}</van-radio>
+            </van-radio-group>
+          </template>
+        </van-field>
+      </template>
+    </van-cell-group>
+  </van-form>
+  <!-- 选择时间 -->
+  <van-popup v-model:show="showDatePicker" position="bottom">
+    <van-date-picker
+      v-if="currentNode.tagIcon == 'date'"
+      v-model="currentNode.defaultValue"
+      title="选择日期"
+      @confirm="pickDate"
+      @cancel="showDatePicker = false"
+    />
+    <van-time-picker
+      v-else-if="currentNode.tagIcon == 'time'"
+      v-model="currentNode.defaultValue"
+      title="选择时间"
+      @confirm="pickDate"
+      @cancel="showDatePicker = false"
+    />
+  </van-popup>
+</template>
+<script lang="ts" setup>
+  import { ref } from 'vue'
+  import { formatDate } from '../utils/formatTime'
+  import { showNotify } from 'vant'
+  const props = defineProps({
+    formData: {
+      type: Array as () => any[],
+      default: () => []
+    }
+  })
+  const showDatePicker = ref(false)
+  const currentNode = ref<any>({})
+  const onDateClick = (node: any) => {
+    const now = new Date()
+    if (node.tagIcon === 'date') {
+      node.defaultValue = node.defaultValue || formatDate(now, 'YYYY-mm-dd').split('-')
+    } else {
+      node.defaultValue = node.defaultValue || formatDate(now, 'HH:MM').split(':')
+    }
+    currentNode.value = node
+    showDatePicker.value = true
+  }
+  const pickDate = ({ selectedOptions }) => {
+    // for (const v of props.formData) {
+    //   for (const element of v.formItemList) {
+    //     if (element.Field === currentNode.value.Field) {
+    //       element.defaultValue = selectedOptions.map((item) => item.value)
+    //     }
+    //   }
+    // }
+    currentNode.value.defaultValue = selectedOptions.map((item) => item.value)
+    showDatePicker.value = false
+  }
+  const getFormData = () => {
+    const formData = props.formData
+    let hasEmpty = false
+    for (let i in formData) {
+      for (let item of formData[i].formItemList) {
+        if (item.tagIcon == 'time' && item.defaultValue?.length) {
+          item.defaultValue = item.defaultValue.join(':')
+        } else if (item.tagIcon == 'date' && item.defaultValue?.length) {
+          item.defaultValue = item.defaultValue.join('-')
+        }
+        if (item.tagIcon !== 'multiple-select' && item.required && !item.defaultValue) {
+          hasEmpty = true
+          showNotify({
+            message: `请填写${formData[i].formTitle}表单下的${item.label}`,
+            type: 'warning'
+          })
+          break
+        } else if (item.tagIcon == 'multiple-select' && item.required && item.defaultValue.length == 0) {
+          hasEmpty = true
+          showNotify({
+            message: `请填写${formData[i].formTitle}表单下的${item.label}`,
+            type: 'warning'
+          })
+          break
+        }
+      }
+      if (hasEmpty) {
+        break
+      }
+    }
+    const data = hasEmpty ? false : formData
+    return data
+  }
+  // 暴露变量
+  defineExpose({
+    getFormData
+  })
+</script>
+<style lang="scss" scoped>
+  h4 {
+    height: 18px;
+    line-height: 18px;
+    display: flex;
+    margin: 10px 0;
+    span {
+      font-weight: normal;
+      margin-left: auto;
+    }
+    &::before {
+      display: inline-block;
+      content: '';
+      width: 3px;
+      height: 18px;
+      background-color: #1c9bfd;
+      margin-right: 4px;
+      vertical-align: middle;
+    }
+  }
+</style>

+ 40 - 0
src/directive/authDirective.ts

@@ -0,0 +1,40 @@
+import type { App } from 'vue';
+import { useUserInfo } from '/@/stores/userInfo';
+import { judementSameArr } from '/@/utils/arrayOperation';
+
+/**
+ * 用户权限指令
+ * @directive 单个权限验证(v-auth="xxx")
+ * @directive 多个权限验证,满足一个则显示(v-auths="[xxx,xxx]")
+ * @directive 多个权限验证,全部满足则显示(v-auth-all="[xxx,xxx]")
+ */
+export function authDirective(app: App) {
+	// 单个权限验证(v-auth="xxx")
+	app.directive('auth', {
+		mounted(el, binding) {
+			const stores = useUserInfo();
+			if (!stores.userInfos.authBtnList.some((v: string) => v === binding.value)) el.parentNode.removeChild(el);
+		},
+	});
+	// 多个权限验证,满足一个则显示(v-auths="[xxx,xxx]")
+	app.directive('auths', {
+		mounted(el, binding) {
+			let flag = false;
+			const stores = useUserInfo();
+			stores.userInfos.authBtnList.map((val: string) => {
+				binding.value.map((v: string) => {
+					if (val === v) flag = true;
+				});
+			});
+			if (!flag) el.parentNode.removeChild(el);
+		},
+	});
+	// 多个权限验证,全部满足则显示(v-auth-all="[xxx,xxx]")
+	app.directive('auth-all', {
+		mounted(el, binding) {
+			const stores = useUserInfo();
+			const flag = judementSameArr(binding.value, stores.userInfos.authBtnList);
+			if (!flag) el.parentNode.removeChild(el);
+		},
+	});
+}

+ 178 - 0
src/directive/customDirective.ts

@@ -0,0 +1,178 @@
+import type { App } from 'vue';
+
+/**
+ * 按钮波浪指令
+ * @directive 默认方式:v-waves,如 `<div v-waves></div>`
+ * @directive 参数方式:v-waves=" |light|red|orange|purple|green|teal",如 `<div v-waves="'light'"></div>`
+ */
+export function wavesDirective(app: App) {
+	app.directive('waves', {
+		mounted(el, binding) {
+			el.classList.add('waves-effect');
+			binding.value && el.classList.add(`waves-${binding.value}`);
+			function setConvertStyle(obj: { [key: string]: unknown }) {
+				let style: string = '';
+				for (let i in obj) {
+					if (obj.hasOwnProperty(i)) style += `${i}:${obj[i]};`;
+				}
+				return style;
+			}
+			function onCurrentClick(e: { [key: string]: unknown }) {
+				let elDiv = document.createElement('div');
+				elDiv.classList.add('waves-ripple');
+				el.appendChild(elDiv);
+				let styles = {
+					left: `${e.layerX}px`,
+					top: `${e.layerY}px`,
+					opacity: 1,
+					transform: `scale(${(el.clientWidth / 100) * 10})`,
+					'transition-duration': `750ms`,
+					'transition-timing-function': `cubic-bezier(0.250, 0.460, 0.450, 0.940)`,
+				};
+				elDiv.setAttribute('style', setConvertStyle(styles));
+				setTimeout(() => {
+					elDiv.setAttribute(
+						'style',
+						setConvertStyle({
+							opacity: 0,
+							transform: styles.transform,
+							left: styles.left,
+							top: styles.top,
+						})
+					);
+					setTimeout(() => {
+						elDiv && el.removeChild(elDiv);
+					}, 750);
+				}, 450);
+			}
+			el.addEventListener('mousedown', onCurrentClick, false);
+		},
+		unmounted(el) {
+			el.addEventListener('mousedown', () => {});
+		},
+	});
+}
+
+/**
+ * 自定义拖动指令
+ * @description  使用方式:v-drag="[dragDom,dragHeader]",如 `<div v-drag="['.drag-container .el-dialog', '.drag-container .el-dialog__header']"></div>`
+ * @description dragDom 要拖动的元素,dragHeader 要拖动的 Header 位置
+ * @link 注意:https://github.com/element-plus/element-plus/issues/522
+ * @lick 参考:https://blog.csdn.net/weixin_46391323/article/details/105228020?utm_medium=distribute.pc_relevant.none-task-blog-baidujs_title-10&spm=1001.2101.3001.4242
+ */
+export function dragDirective(app: App) {
+	app.directive('drag', {
+		mounted(el, binding) {
+			if (!binding.value) return false;
+
+			const dragDom = document.querySelector(binding.value[0]) as HTMLElement;
+			const dragHeader = document.querySelector(binding.value[1]) as HTMLElement;
+
+			dragHeader.onmouseover = () => (dragHeader.style.cursor = `move`);
+
+			function down(e: any, type: string) {
+				// 鼠标按下,计算当前元素距离可视区的距离
+				const disX = type === 'pc' ? e.clientX - dragHeader.offsetLeft : e.touches[0].clientX - dragHeader.offsetLeft;
+				const disY = type === 'pc' ? e.clientY - dragHeader.offsetTop : e.touches[0].clientY - dragHeader.offsetTop;
+
+				// body当前宽度
+				const screenWidth = document.body.clientWidth;
+				// 可见区域高度(应为body高度,可某些环境下无法获取)
+				const screenHeight = document.documentElement.clientHeight;
+
+				// 对话框宽度
+				const dragDomWidth = dragDom.offsetWidth;
+				// 对话框高度
+				const dragDomheight = dragDom.offsetHeight;
+
+				const minDragDomLeft = dragDom.offsetLeft;
+				const maxDragDomLeft = screenWidth - dragDom.offsetLeft - dragDomWidth;
+
+				const minDragDomTop = dragDom.offsetTop;
+				const maxDragDomTop = screenHeight - dragDom.offsetTop - dragDomheight;
+
+				// 获取到的值带px 正则匹配替换
+				let styL: any = getComputedStyle(dragDom as Element).left;
+				let styT: any = getComputedStyle(dragDom as Element).top;
+
+				// 注意在ie中 第一次获取到的值为组件自带50% 移动之后赋值为px
+				if (styL.includes('%')) {
+					styL = +document.body.clientWidth * (+styL.replace(/\%/g, '') / 100);
+					styT = +document.body.clientHeight * (+styT.replace(/\%/g, '') / 100);
+				} else {
+					styL = +styL.replace(/\px/g, '');
+					styT = +styT.replace(/\px/g, '');
+				}
+
+				return {
+					disX,
+					disY,
+					minDragDomLeft,
+					maxDragDomLeft,
+					minDragDomTop,
+					maxDragDomTop,
+					styL,
+					styT,
+				};
+			}
+
+			function move(e: any, type: string, obj: any) {
+				let { disX, disY, minDragDomLeft, maxDragDomLeft, minDragDomTop, maxDragDomTop, styL, styT } = obj;
+
+				// 通过事件委托,计算移动的距离
+				let left = type === 'pc' ? e.clientX - disX : e.touches[0].clientX - disX;
+				let top = type === 'pc' ? e.clientY - disY : e.touches[0].clientY - disY;
+
+				// 边界处理
+				if (-left > minDragDomLeft) {
+					left = -minDragDomLeft;
+				} else if (left > maxDragDomLeft) {
+					left = maxDragDomLeft;
+				}
+
+				if (-top > minDragDomTop) {
+					top = -minDragDomTop;
+				} else if (top > maxDragDomTop) {
+					top = maxDragDomTop;
+				}
+
+				// 移动当前元素
+				dragDom.style.cssText += `;left:${left + styL}px;top:${top + styT}px;`;
+			}
+
+			/**
+			 * pc端
+			 * onmousedown 鼠标按下触发事件
+			 * onmousemove 鼠标按下时持续触发事件
+			 * onmouseup 鼠标抬起触发事件
+			 */
+			dragHeader.onmousedown = (e) => {
+				const obj = down(e, 'pc');
+				document.onmousemove = (e) => {
+					move(e, 'pc', obj);
+				};
+				document.onmouseup = () => {
+					document.onmousemove = null;
+					document.onmouseup = null;
+				};
+			};
+
+			/**
+			 * 移动端
+			 * ontouchstart 当按下手指时,触发ontouchstart
+			 * ontouchmove 当移动手指时,触发ontouchmove
+			 * ontouchend 当移走手指时,触发ontouchend
+			 */
+			dragHeader.ontouchstart = (e) => {
+				const obj = down(e, 'app');
+				document.ontouchmove = (e) => {
+					move(e, 'app', obj);
+				};
+				document.ontouchend = () => {
+					document.ontouchmove = null;
+					document.ontouchend = null;
+				};
+			};
+		},
+	});
+}

+ 18 - 0
src/directive/index.ts

@@ -0,0 +1,18 @@
+import type { App } from 'vue';
+import { authDirective } from '/@/directive/authDirective';
+import { wavesDirective, dragDirective } from '/@/directive/customDirective';
+
+/**
+ * 导出指令方法:v-xxx
+ * @methods authDirective 用户权限指令,用法:v-auth
+ * @methods wavesDirective 按钮波浪指令,用法:v-waves
+ * @methods dragDirective 自定义拖动指令,用法:v-drag
+ */
+export function directive(app: App) {
+	// 用户权限指令
+	authDirective(app);
+	// 按钮波浪指令
+	wavesDirective(app);
+	// 自定义拖动指令
+	dragDirective(app);
+}

+ 1 - 1
src/layout/entry.vue

@@ -8,7 +8,7 @@
 -->
 <template>
   <router-view></router-view>
-  <van-tabbar route>
+  <van-tabbar route :placeholder="true">
     <van-tabbar-item replace to="/entry" icon="send-gift-o">入室申请</van-tabbar-item>
     <van-tabbar-item replace to="/entry/mine" icon="coupon-o">我的入室</van-tabbar-item>
     <van-tabbar-item replace to="/entry/appoint" icon="cluster-o">入室预约</van-tabbar-item>

+ 25 - 46
src/layout/index.vue

@@ -2,66 +2,32 @@
  * @Author: wanglj wanglijie@dashoo.cn
  * @Date: 2025-03-13 09:07:55
  * @LastEditors: wanglj wanglijie@dashoo.cn
- * @LastEditTime: 2025-03-21 09:17:49
+ * @LastEditTime: 2025-03-26 18:32:41
  * @FilePath: \labsop-h5\src\layout\index.vue
  * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
 -->
 <template>
   <router-view></router-view>
-  <footer class="footer-nav">
-    <ul>
-      <li @click="onRouterPush('/home')" :class="{ active: route.path == '/home' }">
-        <!-- <div class="img-container">
-          <img src="../assets/img/home-icon.png" alt="" />
-        </div> -->
-        <van-icon name="wap-home-o" :size="24" />
-        首页
-      </li>
-      <li class="service" @click="onRouterPush('/todo')" :class="{ active: route.path == '/todo' }">
-        <!-- <div class="img-container">
-          <img src="../assets/img/service-icon.png" alt="" />
-        </div> -->
-        <van-icon name="cluster-o" :size="24" />
-        待办
-      </li>
-      <li class="center-icon" @click="scan">
+  <van-tabbar route :placeholder="true">
+    <van-tabbar-item replace to="/home" icon="wap-home-o">首页</van-tabbar-item>
+    <van-tabbar-item replace to="/todo" icon="cluster-o">待办</van-tabbar-item>
+    <van-tabbar-item replace class="center-icon" @click="scan">
+      <template #icon>
         <van-icon name="scan" :size="50" color="#fff" />
-      </li>
-      <li class="todo" @click="onRouterPush('/notice')" :class="{ active: route.path == '/notice' }">
-        <!-- <div class="img-container">
-          <img src="../assets/img/todo-icon.png" alt="" />
-        </div> -->
-        <van-icon name="todo-list-o" :size="24" />
-        通知
-      </li>
-      <li class="user" @click="onRouterPush('/user')" :class="{ active: route.path == '/user' }">
-        <!-- <div class="img-container">
-          <img src="../assets/img/user-icon.png" alt="" />
-        </div> -->
-        <van-icon name="user-o" :size="24" />
-        我的
-      </li>
-    </ul>
-  </footer>
+      </template>
+    </van-tabbar-item>
+    <van-tabbar-item replace to="/notice" icon="todo-list-o">通知</van-tabbar-item>
+    <van-tabbar-item replace to="/user" icon="user-o">我的</van-tabbar-item>
+  </van-tabbar>
+  
 </template>
 
 <script lang="ts" setup>
-  import to from 'await-to-js'
-  import { showDialog } from 'vant'
-  import { ref, watch } from 'vue'
   import { useRouter, useRoute } from 'vue-router'
   import { useUserInfo } from '/@/stores/userInfo'
-  import { Local } from '/@/utils/storage'
 
-  const active = ref(0)
   const router = useRouter()
   const route = useRoute()
-  watch(
-    () => route.path,
-    () => {
-      console.log(route.path)
-    }
-  )
 
   const onRouterPush = (val: string) => {
     router.push(val)
@@ -127,4 +93,17 @@
       }
     }
   }
+  .center-icon {
+    height: 60px;
+    flex: 0 0 60px;
+    padding: 10px;
+    align-self: flex-end;
+    position: relative;
+    z-index: 99999;
+    i {
+      padding: 10px;
+      background: #1d66dc;
+      border-radius: 50%;
+    }
+  }
 </style>

+ 19 - 0
src/layout/training.vue

@@ -0,0 +1,19 @@
+<!--
+ * @Author: wanglj wanglijie@dashoo.cn
+ * @Date: 2025-03-13 09:07:55
+ * @LastEditors: wanglj wanglijie@dashoo.cn
+ * @LastEditTime: 2025-03-27 09:02:11
+ * @FilePath: \labsop-h5\src\layout\index.vue
+ * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
+-->
+<template>
+  <router-view></router-view>
+  <van-tabbar route :placeholder="true">
+    <van-tabbar-item replace to="/training" icon="info-o">全部培训</van-tabbar-item>
+    <van-tabbar-item replace to="/training/done" icon="passed">我的培训</van-tabbar-item>
+  </van-tabbar>
+</template>
+
+<script lang="ts" setup></script>
+
+<style lang="scss" scoped></style>

+ 3 - 2
src/main.ts

@@ -2,7 +2,7 @@
  * @Author: wanglj wanglijie@dashoo.cn
  * @Date: 2025-03-10 11:40:15
  * @LastEditors: wanglj wanglijie@dashoo.cn
- * @LastEditTime: 2025-03-12 13:52:08
+ * @LastEditTime: 2025-03-26 15:44:05
  * @FilePath: \vue3-ts\src\main.ts
  * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
  */
@@ -14,8 +14,9 @@ import router from './router'
 import './theme/index.scss'
 import './theme/vant.scss'
 import pinia from '/@/stores/index';
-
+import { directive } from '/@/directive/index';
 const app = createApp(App)
+directive(app)
 app.use(pinia)
 app.use(router)
 app.mount('#app')

+ 87 - 19
src/router.ts

@@ -2,7 +2,7 @@
  * @Author: wanglj wanglijie@dashoo.cn
  * @Date: 2025-03-10 11:40:15
  * @LastEditors: wanglj wanglijie@dashoo.cn
- * @LastEditTime: 2025-03-21 14:49:28
+ * @LastEditTime: 2025-03-27 17:12:32
  * @FilePath: \vue3-ts\src\router.ts
  * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
  */
@@ -42,6 +42,38 @@ const routes = [
       title: '仪器列表'
     }
   },
+  {
+    name: 'instrFollow',
+    path: '/instr-follow',
+    component: () => import('/@/view/instr/list-follow.vue'),
+    meta: {
+      title: '关注仪器'
+    }
+  },
+  {
+    name: 'instrDetail',
+    path: '/instr-detail',
+    component: () => import('/@/view/instr/detail.vue'),
+    meta: {
+      title: '仪器详情'
+    }
+  },
+  {
+    name: 'instrAppoint',
+    path: '/instr-appoint',
+    component: () => import('/@/view/instr/appoint.vue'),
+    meta: {
+      title: '仪器预约'
+    }
+  },
+  {
+    name: 'instrCalendar',
+    path: '/instr-calendar',
+    component: () => import('/@/view/instr/calendar.vue'),
+    meta: {
+      title: '预约日历'
+    }
+  },
   {
     name: 'appointInfo',
     path: '/instr-appoint-record',
@@ -144,22 +176,6 @@ const routes = [
           title: '修改密码'
         }
       },
-      {
-        name: 'training',
-        path: '/training',
-        component: () => import('/@/view/training/index.vue'),
-        meta: {
-          title: '培训列表'
-        }
-      },
-      {
-        name: 'trainingEnroll',
-        path: '/training/enroll',
-        component: () => import('/@/view/training/enroll.vue'),
-        meta: {
-          title: '培训报名'
-        }
-      },
       {
         name: 'examCover',
         path: '/exam-cover',
@@ -191,6 +207,22 @@ const routes = [
           title: '入室申请'
         }
       },
+      {
+        name: 'entryMine',
+        path: '/entry/mine',
+        component: () => import('/@/view/entry/mine.vue'),
+        meta: {
+          title: '我的入室'
+        }
+      },
+      {
+        name: 'entryAppoint',
+        path: '/entry/appoint',
+        component: () => import('/@/view/entry/appoint.vue'),
+        meta: {
+          title: '入室预约'
+        }
+      },
       {
         name: 'entryAdd',
         path: '/entry/add',
@@ -200,6 +232,37 @@ const routes = [
         }
       }
     ]
+  },
+  {
+    path: '/',
+    redirect: '/login',
+    component: () => import('/@/layout/training.vue'),
+    children: [
+      {
+        name: 'training',
+        path: '/training',
+        component: () => import('/@/view/training/index.vue'),
+        meta: {
+          title: '全部培训'
+        }
+      },
+      {
+        name: 'trainingDone',
+        path: '/training/done',
+        component: () => import('/@/view/training/done.vue'),
+        meta: {
+          title: '我的培训'
+        }
+      },
+      {
+        name: 'trainingEnroll',
+        path: '/training/enroll',
+        component: () => import('/@/view/training/enroll.vue'),
+        meta: {
+          title: '培训报名'
+        }
+      },
+    ]
   }
 ]
 
@@ -208,7 +271,7 @@ const router = createRouter({
   history: createWebHistory()
 })
 const whiteList = ['/login', '/register', '/training', '/training/enroll']
-router.beforeEach((to, from, next) => {
+router.beforeEach(async (to, from, next) => {
   const storesUseUserInfo = useUserInfo()
   // // 微信授权码获取openId
   // const code = to.query.code
@@ -232,7 +295,12 @@ router.beforeEach((to, from, next) => {
       })
     } else {
       if (!storesUseUserInfo.userInfos.id) {
-        storesUseUserInfo.setUserInfos()
+        await storesUseUserInfo.setUserInfos()
+      }
+      if(to.path == '/login') {
+        const code = to.query.code
+        storesUseUserInfo.setOpenId(code as string)
+        next('/home')
       }
       next()
     }

+ 11 - 5
src/stores/userInfo.ts

@@ -2,7 +2,7 @@
  * @Author: wanglj wanglijie@dashoo.cn
  * @Date: 2025-03-17 14:46:02
  * @LastEditors: wanglj wanglijie@dashoo.cn
- * @LastEditTime: 2025-03-21 11:19:34
+ * @LastEditTime: 2025-03-27 16:38:39
  * @FilePath: \labsop-h5\src\view\stores\userInfo.ts
  * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
  */
@@ -40,6 +40,7 @@ export const useUserInfo = defineStore('userInfo', {
       pgName: ''
     },
     openId: '',
+    unionId: '',
     openIdFlag: false,
     sdkConfig: {
       app_id: '',
@@ -51,6 +52,7 @@ export const useUserInfo = defineStore('userInfo', {
   actions: {
     async setUserInfos() {
       // 存储用户信息到浏览器缓存
+      console.log('存储用户信息到浏览器缓存');
       const userInfos = <UserInfos>await this.getApiUserInfo()
       this.userInfos = userInfos
     },
@@ -110,9 +112,11 @@ export const useUserInfo = defineStore('userInfo', {
     },
     async setOpenId(code: string) {
       if(this.openIdFlag || this.openId) return
-      const local = localStorage.getItem('openId')
-      if(local) {
-        this.openId = local
+      const openId = localStorage.getItem('openId')
+      const unionId = localStorage.getItem('unionId')
+      if(openId && unionId) {
+        this.openId = openId
+        this.unionId = unionId
         return
       }
       this.openIdFlag = true
@@ -121,8 +125,10 @@ export const useUserInfo = defineStore('userInfo', {
         this.openIdFlag = false 
         return
       }
-      this.openId = res?.data?.unionid || ''
+      this.openId = res?.data?.openid || ''
+      this.unionId = res?.data?.unionid || ''
       localStorage.setItem('openId', this.openId)
+      localStorage.setItem('unionId', this.unionId)
       this.openIdFlag = false 
     },
     async getSdkConfig() {

+ 4 - 3
src/theme/index.scss

@@ -18,15 +18,16 @@ body,
 #app {
   display: flex;
   flex-direction: column;
-
   .app-container {
-    height: calc(100vh - 48px);
+    height: 100%;
+    flex: 1;
     padding: 0 10px;
     overflow-y: auto;
     background-color: #f7f8fa;
   }
   .entry-container {
-    height: calc(100vh - 48px);
+    height: 100%;
+    flex: 1;
     overflow-y: auto;
     background-color: #f7f8fa;
   }

+ 5 - 1
src/types/index.d.ts

@@ -2,7 +2,7 @@
  * @Author: wanglj wanglijie@dashoo.cn
  * @Date: 2025-03-18 20:02:42
  * @LastEditors: wanglj wanglijie@dashoo.cn
- * @LastEditTime: 2025-03-19 14:03:45
+ * @LastEditTime: 2025-03-27 16:34:11
  * @FilePath: \labsop_h5\src\types\index.d.ts
  * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
  */
@@ -43,6 +43,7 @@ declare interface UserInfos<T = any> {
 declare interface UserInfosState {
   userInfos: UserInfos
   openId: string
+  unionId: string
   openIdFlag: boolean
   sdkConfig: {
     app_id: string
@@ -57,4 +58,7 @@ declare interface RowTraningApplyType {
   endTime: string
   title: string
   type: string
+  createdName: string
+  createdTime: string
+  applyStatus: string
 }

+ 88 - 0
src/utils/arrayOperation.ts

@@ -0,0 +1,88 @@
+/*
+ * @Author: wanglj 471442253@qq.com
+ * @Date: 2023-07-11 14:13:56
+ * @LastEditors: wanglj
+ * @LastEditTime: 2023-07-20 16:22:10
+ * @Description: file content
+ * @FilePath: \labsop_meno\frontend\packages\vue-next-admin\src\utils\arrayOperation.ts
+ */
+/**
+ * 判断两数组字符串是否相同(用于按钮权限验证),数组字符串中存在相同时会自动去重(按钮权限标识不会重复)
+ * @param news 新数据
+ * @param old 源数据
+ * @returns 两数组相同返回 `true`,反之则反
+ */
+export function judementSameArr(newArr: unknown[] | string[], oldArr: string[]): boolean {
+	const news = removeDuplicate(newArr);
+	const olds = removeDuplicate(oldArr);
+	let count = 0;
+	const leng = news.length;
+	for (let i in olds) {
+		for (let j in news) {
+			if (olds[i] === news[j]) count++;
+		}
+	}
+	return count === leng ? true : false;
+}
+
+/**
+ * 判断两个对象是否相同
+ * @param a 要比较的对象一
+ * @param b 要比较的对象二
+ * @returns 相同返回 true,反之则反
+ */
+export function isObjectValueEqual<T>(a: T, b: T): boolean {
+	if (!a || !b) return false;
+	let aProps = Object.getOwnPropertyNames(a);
+	let bProps = Object.getOwnPropertyNames(b);
+	if (aProps.length != bProps.length) return false;
+	for (let i = 0; i < aProps.length; i++) {
+		let propName = aProps[i];
+		let propA = a[propName];
+		let propB = b[propName];
+		if (!b.hasOwnProperty(propName)) return false;
+		if (propA instanceof Object) {
+			if (!isObjectValueEqual(propA, propB)) return false;
+		} else if (propA !== propB) {
+			return false;
+		}
+	}
+	return true;
+}
+
+/**
+ * 数组、数组对象去重
+ * @param arr 数组内容
+ * @param attr 需要去重的键值(数组对象)
+ * @returns
+ */
+export function removeDuplicate(arr: EmptyArrayType, attr?: string) {
+	if (!Object.keys(arr).length) {
+		return arr;
+	} else {
+		if (attr) {
+			const obj: EmptyObjectType = {};
+			return arr.reduce((cur: EmptyArrayType[], item: EmptyArrayType) => {
+				obj[item[attr]] ? '' : (obj[item[attr]] = true && item[attr] && cur.push(item));
+				return cur;
+			}, []);
+		} else {
+			return [...new Set(arr)];
+		}
+	}
+}
+export function handleTree(data:[], id = 'id', parentId = 'parentId', children = 'children', rootId = 0) {
+	//对源数据深度克隆
+	const cloneData = JSON.parse(JSON.stringify(data))
+	//循环所有项
+	const treeData =  cloneData.filter(father => {
+    let branchArr = cloneData.filter(child => {
+    //返回每一项的子级数组
+    return father[id] === child[parentId]
+    });
+    branchArr.length > 0 ? father[children] = branchArr : '';
+    //返回第一层
+    return father[parentId] == rootId;
+  });
+	return treeData != '' ? treeData : data;
+}

+ 0 - 2
src/utils/formatTime.ts

@@ -10,8 +10,6 @@
  * @returns 返回拼接后的时间字符串
  */
 export function formatDate(date: Date, format: string): string {
-  console.log(date);
-
   let we = date.getDay(); // 星期
   let z = getWeek(date); // 周
   let qut = Math.floor((date.getMonth() + 3) / 3).toString(); // 季度

+ 1 - 1
src/utils/micro_request.ts

@@ -156,7 +156,7 @@ function processResponse(res) {
       // / 清除缓存/token等
       Session.clear();
       Local.remove('token')
-      location.reload()
+      window.location.reload()
     })
   } else if (code === 500) {
     showNotify({

+ 1 - 1
src/utils/request.ts

@@ -50,7 +50,7 @@ service.interceptors.response.use(
           Session.clear()
           Local.remove('token')
           // 使用 reload 时,不需要调用 resetRoute() 重置路由
-          location.reload()
+          window.location.reload()
         })
       } else if (res.code === 500) {
         showNotify({

+ 20 - 0
src/view/entry/appoint.vue

@@ -0,0 +1,20 @@
+<!--
+ * @Author: wanglj wanglijie@dashoo.cn
+ * @Date: 2025-03-11 18:02:10
+ * @LastEditors: wanglj wanglijie@dashoo.cn
+ * @LastEditTime: 2025-03-20 17:51:51
+ * @FilePath: \vant-demo-master\vant\vue3-ts\src\view\login\index.vue
+ * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
+-->
+<template>
+  <div class="entry-container">
+    
+  </div>
+</template>
+
+<script name="entryAppoint" lang="ts" setup>
+  
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 48 - 41
src/view/entry/index.vue

@@ -2,7 +2,7 @@
  * @Author: wanglj wanglijie@dashoo.cn
  * @Date: 2025-03-11 18:02:10
  * @LastEditors: wanglj wanglijie@dashoo.cn
- * @LastEditTime: 2025-03-20 17:51:51
+ * @LastEditTime: 2025-03-26 18:13:14
  * @FilePath: \vant-demo-master\vant\vue3-ts\src\view\login\index.vue
  * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
 -->
@@ -13,41 +13,47 @@
       <van-tab title="已通过" name="30"></van-tab>
       <van-tab title="全部申请" name="0"></van-tab>
     </van-tabs>
-    <van-list v-model:loading="state.loading" :finished="state.finished" finished-text="没有更多了" @load="onLoad">
-      <van-cell v-for="item in state.list" :key="item" @click="toDetail(item.id)">
-        <template #default>
-          <div class="list">
-            <header class="flex justify-between">
-              <strong class="title">{{ `${item.memberName}的${item.platformName}入室申请` }}</strong>
-              <van-tag v-if="item.approveStatus == 10" type="warning">待提交</van-tag>
-              <van-tag v-else-if="item.approveStatus == 20" type="primary">审批中</van-tag>
-              <van-tag v-else-if="item.approveStatus == 30" type="success">审批通过</van-tag>
-              <van-tag v-else-if="item.approveStatus == 40" type="danger">审批退回</van-tag>
-            </header>
-            <p class="inst-title">
-              <span>课题名称</span>
-              <span class="title ml8">{{ item.pgName }}</span>
-            </p>
-            <p class="inst-title">
-              <span>申请平台</span>
-              <span class="title ml8">{{ item.platformName }}</span>
-            </p>
-            <p class="inst-title">
-              <span>申请时长</span>
-              <span class="title ml8">{{ item.platformTime }}个月</span>
-            </p>
-            <p class="inst-title">
-              <span>入室周期</span>
-              <span class="title ml8">{{ item.appointEndDate ? `${formatDate(new Date(item.appointEndDate), 'YYYY-mm-dd')}~${formatDate(new Date(item.appointEndDate), 'YYYY-mm-dd')}` : '-' }}</span>
-            </p>
-            <footer class="flex justify-between mt4">
-              <span class="title">{{ item.memberName }}</span>
-              <span class="time">{{ formatDate(new Date(item.createdTime), 'YYYY-mm-dd') }}</span>
-            </footer>
-          </div>
-        </template>
-      </van-cell>
-    </van-list>
+    <div class="list-container">
+      <van-list v-model:loading="state.loading" :finished="state.finished" finished-text="没有更多了" @load="onLoad">
+        <van-cell v-for="item in state.list" :key="item" @click="toDetail(item.id)">
+          <template #default>
+            <div class="list">
+              <header class="flex justify-between">
+                <strong class="title">{{ `${item.memberName}的${item.platformName}入室申请` }}</strong>
+                <van-tag v-if="item.approveStatus == 10" type="warning">待提交</van-tag>
+                <van-tag v-else-if="item.approveStatus == 20" type="primary">审批中</van-tag>
+                <van-tag v-else-if="item.approveStatus == 30" type="success">审批通过</van-tag>
+                <van-tag v-else-if="item.approveStatus == 40" type="danger">审批退回</van-tag>
+              </header>
+              <p class="inst-title">
+                <span>课题名称</span>
+                <span class="title ml8">{{ item.pgName }}</span>
+              </p>
+              <p class="inst-title">
+                <span>申请平台</span>
+                <span class="title ml8">{{ item.platformName }}</span>
+              </p>
+              <p class="inst-title">
+                <span>申请时长</span>
+                <span class="title ml8">{{ item.platformTime }}个月</span>
+              </p>
+              <p class="inst-title">
+                <span>入室周期</span>
+                <span class="title ml8">{{
+                  item.appointEndDate
+                    ? `${formatDate(new Date(item.appointEndDate), 'YYYY-mm-dd')}~${formatDate(new Date(item.appointEndDate), 'YYYY-mm-dd')}`
+                    : '-'
+                }}</span>
+              </p>
+              <footer class="flex justify-between mt4">
+                <span class="title">{{ item.memberName }}</span>
+                <span class="time">{{ formatDate(new Date(item.createdTime), 'YYYY-mm-dd') }}</span>
+              </footer>
+            </div>
+          </template>
+        </van-cell>
+      </van-list>
+    </div>
     <van-floating-bubble v-model:offset="offset" icon="plus" @click="onClick" axis="y" />
   </div>
 </template>
@@ -61,7 +67,7 @@
   const platformAppointApi = usePlatformAppointApi()
   const router = useRouter()
   const route = useRoute()
-  const offset = ref({ x: -80, y: 400 });
+  const offset = ref({ x: -80, y: 400 })
   const state = reactive({
     queryParams: {
       approveStatus: '10,20',
@@ -85,7 +91,7 @@
     }
     state.loading = false
     state.queryParams.pageNum++
-    if (state.list.length < state.queryParams.pageSize) {
+    if (list.length < state.queryParams.pageSize) {
       state.finished = true
     }
   }
@@ -114,12 +120,13 @@
     position: relative;
     display: flex;
     flex-direction: column;
-    .van-list {
-      height: calc(100% - 62px);
+    .list-container {
+      overflow-y: auto;
       padding: 10px;
       border-radius: 4px;
       flex: 1;
-      overflow-y: auto;
+    }
+    .van-list {
       .van-cell {
         background-color: #fff;
         + .van-cell {

+ 20 - 0
src/view/entry/mine.vue

@@ -0,0 +1,20 @@
+<!--
+ * @Author: wanglj wanglijie@dashoo.cn
+ * @Date: 2025-03-11 18:02:10
+ * @LastEditors: wanglj wanglijie@dashoo.cn
+ * @LastEditTime: 2025-03-20 17:51:51
+ * @FilePath: \vant-demo-master\vant\vue3-ts\src\view\login\index.vue
+ * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
+-->
+<template>
+  <div class="entry-container">
+    
+  </div>
+</template>
+
+<script name="entryMine" lang="ts" setup>
+  
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 3 - 3
src/view/exam/cover.vue

@@ -2,7 +2,7 @@
  * @Author: wanglj wanglijie@dashoo.cn
  * @Date: 2025-03-11 18:02:10
  * @LastEditors: wanglj wanglijie@dashoo.cn
- * @LastEditTime: 2025-03-19 19:44:54
+ * @LastEditTime: 2025-03-27 17:02:21
  * @FilePath: \vant-demo-master\vant\vue3-ts\src\view\login\index.vue
  * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
 -->
@@ -37,7 +37,7 @@
   const route = useRoute()
   const trainingApi = useTrainingApi()
   const storesUseUserInfo = useUserInfo()
-  const { userInfos, openId } = storeToRefs(storesUseUserInfo)
+  const { userInfos, openId, unionId } = storeToRefs(storesUseUserInfo)
   const loginApi = useLoginApi()
   const state = reactive({
     form: {
@@ -90,7 +90,7 @@
     })
   }
   const openIdLogin = async () => {
-    const [err, res]: ToResponse = await to(loginApi.weChatLoginOpenId({ openId: openId.value }))
+    const [err, res]: ToResponse = await to(loginApi.weChatLoginUnionId({ openId: openId.value, unionId: unionId.value }))
     if (err) return
     Local.set('token', res?.data.token)
   }

+ 8 - 8
src/view/home/index.vue

@@ -2,7 +2,7 @@
  * @Author: wanglj wanglijie@dashoo.cn
  * @Date: 2025-03-11 18:02:10
  * @LastEditors: wanglj wanglijie@dashoo.cn
- * @LastEditTime: 2025-03-21 16:47:22
+ * @LastEditTime: 2025-03-26 17:50:22
  * @FilePath: \vant-demo-master\vant\vue3-ts\src\view\login\index.vue
  * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
 -->
@@ -23,11 +23,11 @@
     <div class="card">
       <h4>常用功能</h4>
       <ul class="nav">
-        <li @click="onRouterPush('/instr-list')">
+        <li @click="onRouterPush('/instr-list')" v-auth="'h5-home-instr'">
           <img src="../../assets/img/仪器预约.png" alt="" />
           <p>仪器预约</p>
         </li>
-        <li @click="onRouterPush('/entry')">
+        <li @click="onRouterPush('/entry')" v-auth="'h5-home-entry'">
           <img src="../../assets/img/入室申请.png" alt="" />
           <p>入室管理</p>
         </li>
@@ -35,23 +35,23 @@
           <img src="../../assets/img/入室预约.png" alt="" />
           <p>入室预约</p>
         </li> -->
-        <li>
+        <li v-auth="'h5-home-tech'">
           <img src="../../assets/img/技术委托.png" alt="" />
           <p>技术委托</p>
         </li>
-        <li @click="onRouterPush('/training')">
+        <li @click="onRouterPush('/training')" v-auth="'h5-home-training'">
           <img src="../../assets/img/培训考试.png" alt="" />
           <p>培训考试</p>
         </li>
-        <li @click="onRouterPush('/exam-cover')">
+        <li @click="onRouterPush('/exam-cover')" v-auth="'h5-home-animal'">
           <img src="../../assets/img/动物笼位.png" alt="" />
           <p>动物笼位</p>
         </li>
-        <li>
+        <li v-auth="'h5-home-reagent'">
           <img src="../../assets/img/试剂耗材.png" alt="" />
           <p>试剂耗材</p>
         </li>
-        <li>
+        <li v-auth="'h5-home-more'">
           <img src="../../assets/img/更多应用.png" alt="" />
           <p>更多应用</p>
         </li>

+ 394 - 0
src/view/instr/appoint.vue

@@ -0,0 +1,394 @@
+<!--
+ * @Author: wanglj wanglijie@dashoo.cn
+ * @Date: 2025-03-24 09:17:15
+ * @LastEditors: wanglj wanglijie@dashoo.cn
+ * @LastEditTime: 2025-03-26 16:39:21
+ * @FilePath: \labsop_h5\src\view\instr\detail.vue
+ * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
+-->
+<template>
+  <div class="container">
+    <van-form ref="formRef" required="auto">
+      <van-cell-group>
+        <van-field label="预约仪器" v-model="state.form.instName" readonly :rules="[{ required: true }]" />
+      </van-cell-group>
+      <h4>预约时间</h4>
+      <van-cell-group class="mt10">
+        <van-field
+          v-model="state.form.startTime"
+          is-link
+          readonly
+          label="开始时间"
+          placeholder="开始时间"
+          @click="onRouterPush('/instr-calendar', { id: state.form.instId })"
+          :rules="[{ required: true, message: '开始时间不能为空' }]"
+        />
+        <van-field
+          v-model="state.form.endTime"
+          is-link
+          readonly
+          label="结束时间"
+          placeholder="结束时间"
+          @click="onRouterPush('/instr-calendar', { id: state.form.instId })"
+          :rules="[{ required: true, message: '结束时间不能为空' }]"
+        />
+      </van-cell-group>
+      <h4>申请明细</h4>
+      <template v-if="state.appointId == 0">
+        <van-cell-group class="mt10" v-if="state.isActiveService">
+          <!-- <van-field name="radio" label="课题/服务" :rules="[{ required: true }]">
+            <template #input>
+              <van-radio-group v-model="state.form.projectType" direction="horizontal" @change="changeProjectType">
+                <van-radio style="margin-right: 20px" name="project">课题</van-radio>
+                <van-radio name="service">服务</van-radio>
+              </van-radio-group>
+            </template>
+          </van-field> -->
+          <van-field
+            v-if="state.form.projectType == 'project'"
+            label="课题组"
+            placeholder="课题组"
+            @click="state.showProject = true"
+            v-model="state.form.projectName"
+            :rules="[{ required: true, message: '课题不能为空' }]"
+          >
+          </van-field>
+          <van-field
+            v-if="state.form.projectType == 'service'"
+            label="服务"
+            placeholder="服务"
+            @click="state.shwoService = true"
+            v-model="state.form.serviceName"
+            :rules="[{ required: true, message: '服务不能为空' }]"
+          >
+          </van-field>
+          <!-- <van-field
+            v-if="state.form.projectType == 'project'"
+            label="经费卡"
+            placeholder="经费卡"
+            is-link
+            readonly
+            @click="state.showExpenseCard = true"
+            v-model="state.form.expenseCardName"
+          ></van-field> -->
+          <van-field
+            label="预约人"
+            placeholder="预约人"
+            is-link
+            readonly
+            @click="openSelectUser"
+            v-model="state.form.nickName"
+            :rules="[{ required: true, message: '预约人不能为空' }]"
+          ></van-field>
+          <van-field
+            label="联系电话"
+            placeholder="联系电话"
+            v-model="state.form.userContact"
+            :rules="[{ required: true, message: '联系电话不能为空' }]"
+          ></van-field>
+          <van-field label="备注" placeholder="备注" v-model="state.form.remark" rows="2" autosize type="textarea" maxlength="300" show-word-limit></van-field>
+        </van-cell-group>
+      </template>
+      <CustomForm ref="customFormRef" :formData="state.form.createForm"></CustomForm>
+    </van-form>
+  </div>
+  <van-action-bar>
+    <van-action-bar-button class="w100" type="primary" text="提交" @click="onClickButton" />
+  </van-action-bar>
+  <!-- 选择服务 -->
+  <van-popup v-model:show="state.shwoService" position="bottom">
+    <van-picker :columns="serviceList" :columns-field-names="{ text: 'name', value: 'id' }" @confirm="pickService" @cancel="state.shwoService = false" />
+  </van-popup>
+  <!-- 选择课题 -->
+  <van-popup v-model:show="state.showProject" position="bottom">
+    <van-picker
+      :columns="projectList"
+      :columns-field-names="{ text: 'projectName', value: 'projectId' }"
+      @confirm="pickProject"
+      @cancel="state.showProject = false"
+    />
+  </van-popup>
+  <!-- 选择经费卡 -->
+  <van-popup v-model:show="state.showExpenseCard" position="bottom">
+    <van-picker
+      :columns="fundsList"
+      :columns-field-names="{ text: 'finAccount', value: 'id' }"
+      @confirm="pickExpenseCard"
+      @cancel="state.showExpenseCard = false"
+    />
+  </van-popup>
+  <!-- 选择预约人 -->
+  <van-popup v-model:show="state.showAppointUser" position="bottom">
+    <van-picker
+      :columns="userList"
+      :columns-field-names="{ text: 'nickName', value: 'id' }"
+      @confirm="pickAppointUser"
+      @cancel="state.showAppointUser = false"
+    />
+  </van-popup>
+  <AppointDialog ref="appointDialogRef" />
+</template>
+
+<script lang="ts" setup>
+  import to from 'await-to-js'
+  import { useRoute, useRouter } from 'vue-router'
+  import { useInstrApi } from '/@/api/instr'
+  import { useInstDocApi } from '/@/api/instr/document'
+  import { defineAsyncComponent, onMounted, reactive, ref } from 'vue'
+  import { formatDate } from '/@/utils/formatTime'
+  import { showNotify } from 'vant'
+  import download from 'downloadjs'
+  import { useNoticeApi } from '/@/api/instr/notice'
+  import { useProApi } from '/@/api/project'
+  import technicalApi from '/@/api/technical/index'
+  import { Local } from '/@/utils/storage'
+  import { useUserApi } from '/@/api/system/user'
+  import { useUserInfo } from '/@/stores/userInfo'
+  import { storeToRefs } from 'pinia'
+  import instAppoint from '/@/api/instr/instAppoint'
+  import { useConfigApi } from '/@/api/system/config'
+  const CustomForm = defineAsyncComponent(() => import('/@/components/CustomForm.vue'))
+  const storesUseUserInfo = useUserInfo()
+  const { userInfos } = storeToRefs(storesUseUserInfo)
+  const route = useRoute()
+  const router = useRouter()
+  const projApi = useProApi()
+  const instApi = useInstrApi()
+  const userApi = useUserApi()
+  const configApi = useConfigApi()
+  const serviceList = ref([])
+  const projectList = ref([])
+  const fundsList = ref([])
+  const userList = ref([])
+  const appointDialogRef = ref()
+  const formRef = ref()
+  const customFormRef = ref()
+  const state = reactive({
+    loading: false,
+    appointId: 0,
+    isActiveService: false,
+    showProject: false,
+    shwoService: false,
+    showExpenseCard: false,
+    showAppointUser: false,
+    isInstrHead: false,
+    instDetail: {} as any,
+    form: {
+      instId: 0,
+      instName: '',
+      startTime: '',
+      endTime: null,
+      projectName: null,
+      projectId: null,
+      serviceId: null,
+      serviceName: null,
+      expenseCardId: 0,
+      expenseCardName: '',
+      userContact: '',
+      userId: 0,
+      nickName: '',
+      projectType: '',
+      assistEnable: false,
+      createForm: [],
+      remark: ''
+    }
+  })
+
+  // 选择课题还是服务
+  const changeProjectType = () => {
+    state.form.serviceId = 0
+    state.form.serviceName = ''
+    state.form.projectId = 0
+    state.form.projectName = ''
+    state.form.expenseCardId = 0
+    state.form.expenseCardName = ''
+    fundsList.value = []
+  }
+  const pickProject = ({ selectedOptions }) => {
+    state.form.projectId = selectedOptions[0].projectId
+    state.form.projectName = selectedOptions[0].projectName
+    state.showProject = false
+    getFundsData()
+  }
+  // 选择服务
+  const pickService = ({ selectedOptions }) => {
+    state.form.serviceId = selectedOptions[0].id
+    state.form.serviceName = selectedOptions[0].name
+    state.shwoService = false
+  }
+  // 经费卡选择
+  const pickExpenseCard = ({ selectedOptions }) => {
+    state.form.expenseCardId = selectedOptions[0].id
+    state.form.expenseCardName = selectedOptions[0].finAccount
+    state.showExpenseCard = false
+  }
+  const getFundsData = async () => {
+    const [err, res]: ToResponse = await to(projApi.getFinanceAccountList({ projId: state.form.projectId }))
+    if (err) return
+    fundsList.value = res?.data.list ? [res?.data.list] : []
+    // if (fundsList.value && fundsList.value.length > 0 && fundsList.value[0].length > 0) {
+    //   state.form.expenseCardId = fundsList.value[0][0].id
+    //   state.form.expenseCardName = fundsList.value[0][0].finAccount
+    // }
+  }
+  // 预约人选择
+  const pickAppointUser = ({ selectedOptions }) => {
+    state.form.nickName = selectedOptions[0].nickName
+    state.form.userId = selectedOptions[0].id
+    state.form.userContact = selectedOptions[0].phone
+    state.showAppointUser = false
+    state.form.serviceId = 0
+    state.form.serviceName = ''
+    state.form.projectId = 0
+    state.form.projectName = ''
+    state.form.expenseCardId = 0
+    state.form.expenseCardName = ''
+    fundsList.value = []
+    getMyProjectInfo(state.form.userId)
+  }
+  // 选择预约人
+  const openSelectUser = () => {
+    if (!state.isInstrHead) return
+    state.showAppointUser = true
+  }
+  const init = async () => {
+    //延长预约会传一个预约id 有预约id 获取预约详情
+    const [err, res]: ToResponse = await to(configApi.getEntityMapByKey({ configKey: 'instr_is_activate_service' }))
+    if (err) return
+    state.isActiveService = res.data?.configValue == '10' ? true : false
+    state.form.projectType = 'project'
+    state.form.userId = userInfos.value.id || 0
+    state.form.nickName = userInfos.value.nickName || ''
+    state.form.userContact = userInfos.value.phone || ''
+    getMyProjectInfo()
+    getUserService()
+    getUserList()
+    getInstrDetails()
+    getAppointConfig()
+  }
+  const getInstrDetails = async () => {
+    const [err, res]: ToResponse = await to(instApi.getDetail({ id: state.form.instId }))
+    if (err) return
+    if (res?.code === 200) {
+      state.instDetail = res.data
+      state.form.instName = state.instDetail.instName
+      const userInfo = storesUseUserInfo.userInfos
+      state.isInstrHead = userInfo.id ? res.data.instHeadId.split(',').includes('' + userInfo?.id) : false
+    }
+  }
+  // 获取用户下的服务
+  const getUserService = async () => {
+    const [err, res]: ToResponse = await to(technicalApi.getList({ noPage: true }))
+    if (err) return
+    serviceList.value = [res?.data.list]
+    if (state.form.projectType == 'service') {
+      state.form.serviceId = res?.data.list[0].id || 0
+      state.form.serviceName = res?.data.list[0].name || ''
+    }
+  }
+  // 获取用户相关的课题组
+  const getMyProjectInfo = async (id?: number) => {
+    let params = {}
+    if (id) {
+      params = { id }
+    } else {
+      params = {}
+    }
+    const [err, res]: ToResponse = await to(projApi.getMySelfProjectGroup(params))
+    if (err) return
+    // state.form.projectName = res?.data.pgName || ''
+    // state.form.projectId = res?.data.id || null
+    projectList.value = [{ projectName: res?.data.pgName || '', projectId: res?.data.id || null }]
+    if (state.form.projectType == 'project') {
+      state.form.projectId = res?.data.id || null
+      state.form.projectName = res?.data.pgName || ''
+      getFundsData()
+    }
+  }
+  const getUserList = async () => {
+    const [err, res]: ToResponse = await to(userApi.getUserList({ noPage: true }))
+    if (err) return
+    userList.value = [res?.data.list]
+  }
+  // 预约配置信息
+  const getAppointConfig = async () => {
+    const params = {
+      instId: state.form.instId,
+      code: 'InstCfgAppoint'
+    }
+    const [err, res]: ToResponse = await to(instApi.getSettingDetail({ ...params }))
+    if (err) return
+    state.form.createForm = res?.data?.config.createForm ? JSON.parse(res.data.config.createForm) : []
+  }
+  const onRouterPush = (val: string, params?: any) => {
+    router.push({
+      path: val,
+      query: { ...params }
+    })
+  }
+  const onClickButton = async () => {
+    state.loading = true
+    const [errValid] = await to(formRef.value.validate())
+    const customForm = customFormRef.value.getFormData()
+    if (errValid || (state.form.createForm.length && !customForm)) {
+      state.loading = false
+      return
+    }
+    const params = JSON.parse(JSON.stringify(state.form))
+    params.userName = params.nickName
+    params.sampleForm = JSON.stringify(customForm)
+    delete params.createForm
+    const [err]: ToResponse = await to(instAppoint.add(params))
+    if (err) {
+      state.loading = false
+      return
+    }
+    showNotify({
+      type: 'success',
+      message: '预约成功'
+    })
+    router.push({
+      path: '/instr-detail',
+      query: {
+        id: params.instId
+      }
+    })
+  }
+  onMounted(() => {
+    const id = route.query.id ? +route.query.id : 0
+    const startTime = route.query.startTime ? formatDate(new Date(+route.query.startTime), 'YYYY-mm-dd HH:MM') : ''
+    const endTime = route.query.endTime ? formatDate(new Date(+route.query.endTime), 'YYYY-mm-dd HH:MM') : ''
+    state.form.instId = id
+    state.form.startTime = startTime
+    state.form.endTime = endTime
+    init()
+  })
+</script>
+
+<style lang="scss" scoped>
+  .container {
+    height: calc(100% - 70px);
+    padding: 10px;
+    background-color: #f9f9f9;
+    overflow-y: auto;
+    h4 {
+      height: 18px;
+      line-height: 18px;
+      display: flex;
+      margin: 10px 0;
+      span {
+        font-weight: normal;
+        margin-left: auto;
+      }
+      &::before {
+        display: inline-block;
+        content: '';
+        width: 3px;
+        height: 18px;
+        background-color: #1c9bfd;
+        margin-right: 4px;
+        vertical-align: middle;
+      }
+    }
+  }
+</style>

+ 2 - 2
src/view/instr/appoint/index.vue

@@ -1,8 +1,8 @@
 <!--
  * @Author: liuzhenlin 461480418@qq.ocm
  * @Date: 2023-04-12 11:16:26
- * @LastEditors: liuzhenlin
- * @LastEditTime: 2023-09-21 16:39:27
+ * @LastEditors: wanglj wanglijie@dashoo.cn
+ * @LastEditTime: 2025-03-25 09:45:06
  * @Description: file content
  * @FilePath: \labsop小程序\pages\message\appoint.vue
 -->

+ 3 - 5
src/view/instr/appointList/inProgress/index.vue

@@ -1,8 +1,8 @@
 <!--
  * @Author: liuzhenlin 461480418@qq.ocm
  * @Date: 2023-01-12 11:57:48
- * @LastEditors: liuzhenlin
- * @LastEditTime: 2023-09-22 14:38:38
+ * @LastEditors: wanglj wanglijie@dashoo.cn
+ * @LastEditTime: 2025-03-26 17:59:17
  * @Description: file content
  * @FilePath: \labsop小程序\pages\schedule\myAppoint\index.vue
 -->
@@ -228,14 +228,12 @@
   .panel-wrap {
     height: 100%;
     .data-list {
-      height: 100%;
-      padding: 20px;
-      overflow: auto;
       .inst-item {
         border-radius: 10px;
         padding: 15px;
         box-shadow: -2px 0px 9px rgba(0, 0, 0, 0.12);
         margin-bottom: 20px;
+        background-color: #fff;
         .equ-tit {
           width: 74px;
         }

+ 20 - 6
src/view/instr/appointList/index.vue

@@ -2,7 +2,7 @@
  * @Author: liuzhenlin 461480418@qq.ocm
  * @Date: 2023-01-12 11:57:48
  * @LastEditors: wanglj wanglijie@dashoo.cn
- * @LastEditTime: 2025-03-21 17:11:05
+ * @LastEditTime: 2025-03-26 18:00:04
  * @Description: file content
  * @FilePath: \opms\pages\schedule\index.vue
 -->
@@ -10,20 +10,27 @@
   <!-- 页面内容 -->
   <div class="home">
     <!-- <van-pull-refresh v-model="loading" @refresh="onRefresh"> -->
-    <van-tabs v-model:active="active" type="card" @click-tab="onClickTab">
+    <van-tabs v-model:active="active" @click-tab="onClickTab">
       <van-tab title="即将上机">
-        <soon-geton v-if="active === 0" ref="soonGetonRef" />
+        <div class="list-container">
+          <soon-geton v-if="active === 0" ref="soonGetonRef" />
+        </div>
       </van-tab>
       <van-tab title="正在上机">
-        <in-progress v-if="active === 1" ref="inProgressRef" />
+        <div class="list-container">
+          <in-progress v-if="active === 1" ref="inProgressRef" />
+        </div>
       </van-tab>
       <van-tab title="等待审核">
-        <my-appoint v-if="active === 2" ref="myAppointRef" />
+        <div class="list-container">
+          <my-appoint v-if="active === 2" ref="myAppointRef" />
+        </div>
       </van-tab>
     </van-tabs>
     <!-- </van-pull-refresh> -->
     <van-tabbar route :placeholder="true">
-      <van-tabbar-item replace to="/instr-list" icon="printer">仪器列表</van-tabbar-item>
+      <van-tabbar-item replace to="/instr-follow" icon="star">收藏仪器</van-tabbar-item>
+      <van-tabbar-item replace to="/instr-list" icon="printer">全部仪器</van-tabbar-item>
       <van-tabbar-item replace to="/instr-appoint-record" icon="label">我的预约</van-tabbar-item>
     </van-tabbar>
   </div>
@@ -121,6 +128,13 @@
     display: flex;
     flex-direction: column;
     padding-top: 10px;
+    .list-container {
+      height: 100%;
+      background-color: #f7f8fa;
+      padding: 10px;
+      overflow-y: auto;
+      flex: 1;
+    }
     .van-tabs {
       flex: 1;
       height: 0;

+ 1 - 3
src/view/instr/appointList/myAppoint/index.vue

@@ -152,14 +152,12 @@
   .panel-wrap {
     height: 100%;
     .data-list {
-      height: 100%;
-      padding: 20px;
-      overflow: auto;
       .inst-item {
         border-radius: 10px;
         padding: 15px;
         box-shadow: -2px 0px 9px rgba(0, 0, 0, 0.12);
         margin-bottom: 20px;
+        background-color: #fff;
         .equ-tit {
           width: 74px;
         }

+ 2 - 3
src/view/instr/appointList/soonGeton/index.vue

@@ -177,6 +177,7 @@
         } else if (row.controlMode == '30') {
           // 蓝牙
           // this.$refs.bluetoothRef.initBlue('open', row)
+          await useUserInfo().scanCode()
         }
       },
       /**
@@ -271,14 +272,12 @@
   .panel-wrap {
     height: 100%;
     .data-list {
-      height: 100%;
-      padding: 20px;
-      overflow: auto;
       .inst-item {
         border-radius: 10px;
         padding: 15px;
         box-shadow: -2px 0px 9px rgba(0, 0, 0, 0.12);
         margin-bottom: 20px;
+        background-color: #fff;
         .equ-tit {
           width: 74px;
         }

+ 304 - 0
src/view/instr/calendar.vue

@@ -0,0 +1,304 @@
+<!--
+ * @Author: wanglj wanglijie@dashoo.cn
+ * @Date: 2025-03-24 16:28:47
+ * @LastEditors: wanglj wanglijie@dashoo.cn
+ * @LastEditTime: 2025-03-25 14:51:00
+ * @FilePath: \labsop_h5\src\view\instr\components\appoint-dialog.vue
+ * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
+-->
+<template>
+  <div class="calendar-container">
+    <header>
+      <p>
+        <strong><van-icon name="calendar-o" :size="16" />预约时间:</strong>
+        <span v-if="state.selected.length === 0">请选择开始时间段</span>
+        <span v-else-if="state.selected.length === 1">{{ formatDate(new Date(state.selected[0].startStamp), 'mm-dd HH:MM') }}~{{ formatDate(new Date(state.selected[0].endStamp), 'mm-dd HH:MM') }}</span>
+        <span v-else-if="state.selected.length === 2">{{ formatDate(new Date(state.selected[0].startStamp), 'mm-dd HH:MM') }}~{{ formatDate(new Date(state.selected[1].endStamp), 'mm-dd HH:MM') }}</span>
+      </p>
+      <p>
+        <strong><van-icon name="clock-o" :size="16" />预约时长:</strong>
+        <span>{{ getAppointTime() }}</span>
+      </p>
+    </header>
+    <div class="main">
+      <template v-for="(item, index) in state.calendar">
+        <h4 v-if="item.startTime == '00:00'">{{ formatDate(new Date(item.startStamp), 'YYYY-mm-dd/WWW') }}</h4>
+        <div class="tag" :class="{ disabled: item.disabled, selected: item.selected }" @click="onTagClick(item, index)">
+          {{ item.startTime }}-{{ item.endTime }}
+        </div>
+      </template>
+    </div>
+  </div>
+  <van-action-bar>
+    <van-action-bar-button class="w100" type="primary" text="提交" @click="onClickButton" />
+  </van-action-bar>
+</template>
+<script lang="ts" setup>
+  import { onMounted, reactive, ref } from 'vue'
+  import { formatDate } from '/@/utils/formatTime'
+  import { showNotify } from 'vant'
+  import { useRoute, useRouter } from 'vue-router'
+  import moment from 'moment'
+  import to from 'await-to-js'
+  import { useInstrApi } from '/@/api/instr'
+  const route = useRoute()
+  const router = useRouter()
+  const instId = ref(0)
+  const instApi = useInstrApi()
+  const state = reactive({
+    instInfo: {} as any,
+    intervalTime: 30,
+    furtherLimit: '',
+    instrBusinessTime: '',
+    begin_at: '00:00:00',
+    ent_at: '23:50:00',
+    currentWeekAppointList: [],
+    selected: [],
+    calendar: []
+  })
+  // 获取系统设置时间间隔
+  const getTimeSplit = async () => {
+    const [err, res]: ToResponse = await to(
+      instApi.getSettingDetail({
+        instId: Number(instId.value),
+        code: 'InstCfgAppoint'
+      })
+    )
+    if (err) return
+    if (res.code == 200) {
+      state.intervalTime = res.data?.config?.timeSplit
+      state.begin_at = res.data?.config?.timeRange?.[0].start
+      state.ent_at = res.data?.config?.timeRange?.[0].end
+      appointTimeInfo()
+    }
+  }
+  const appointTimeInfo = async () => {
+    const currentDate = formatDate(new Date(), 'YYYY-mm-dd')
+    const nextWeek = formatDate(new Date(new Date().getTime() + 604800000), 'YYYY-mm-dd')
+    let params = {
+      instId: instId.value,
+      date: currentDate,
+      dateType: 'week'
+    }
+    await Promise.all([
+      instApi.getAppointInfo({ ...params }),    
+      instApi.getAppointInfo({...params, date: nextWeek})
+    ]).then(([now, next]) => {
+      const { appoint, unavailable, furtherLimit } = now.data
+      const { appoint: appointNext, unavailable: unavailableNext } = next.data
+      let allDate = [...(appoint || []), ...(unavailable || []), ...(appointNext || []), ...(unavailableNext || [])].map((item) => ({
+        ...item,
+        start: item.startTime ? item.startTime : item.start,
+        startStamp: new Date(item.startTime ? item.startTime : item.start).getTime(),
+        end: item.endTime ? item.endTime : item.end,
+        endStamp: new Date(item.endTime ? item.endTime : item.end).getTime(),
+      }))
+      state.currentWeekAppointList = allDate
+      state.furtherLimit = furtherLimit ? nearFurtherLimit(furtherLimit, state.intervalTime) : ''
+      // 设备的工作时间
+      state.instrBusinessTime = `${state.begin_at}-${state.ent_at}`
+      initCalendar()
+    })
+  }
+  const nearFurtherLimit = (time, split) => {
+    // 将目标时间转换为Moment对象
+    const targetMoment = moment(time)
+
+    // 计算距离目标时间最近的能够被时间间隔整除的时间
+    const remainder = targetMoment.minute() % split
+    const divisibleTime = targetMoment.clone().subtract(remainder, 'minutes')
+    const nearestAvailableTime = divisibleTime.format('YYYY/MM/DD HH:mm') + ':00'
+    return nearestAvailableTime
+  }
+  const initCalendar = () => {
+    const split = state.intervalTime
+    const now = new Date().getTime()
+    const start = formatDate(new Date(now), 'YYYY-mm-dd 00:00:00')
+    const end = formatDate(new Date(now + 1000 * 60 * 60 * 24 * 7), 'YYYY-mm-dd 23:59:59')
+    let stamp = new Date(start).getTime()
+    while (stamp <= new Date(end).getTime()) {
+      const obj = {
+        startStamp: stamp,
+        endStamp: stamp + 1000 * split * 60,
+        startTime: formatDate(new Date(stamp), 'HH:MM'),
+        endTime: formatDate(new Date(stamp + 1000 * split * 60), 'HH:MM'),
+        disabled: stamp < now,
+        selected: false
+      }
+      // 禁用不可预约时间段和已预约时间段
+      for(const item of state.currentWeekAppointList) {
+        if(obj.startStamp >= item.startStamp && obj.endStamp <= item.endStamp) {
+          obj.disabled = true
+          continue
+        }
+      }
+      // 最早预约时间
+      if(state.furtherLimit && obj.startStamp >= new Date(state.furtherLimit).getTime()) {
+        obj.disabled = true
+      }
+      state.calendar.push(obj)
+      stamp += 1000 * split * 60
+    }
+  }
+  const onTagClick = (item: any, idx: number) => {
+    if (item.disabled) return
+    if (item.selected && state.selected[0].startStamp === item.startStamp) {
+      return
+    }
+
+    if (state.selected.length === 2) {
+      state.selected = []
+      for (const item of state.calendar) {
+        item.selected = false
+      }
+    } else if (state.selected.length === 1) {
+      // 验证选中时间段是否有占用的
+      const selected = JSON.parse(JSON.stringify(state.selected))
+      selected.push(item)
+      selected.sort((a, b) => {
+        return a.startStamp - b.startStamp
+      })
+      let flag = false
+      for (const item of state.calendar) {
+        if (item.startStamp >= selected[0].startStamp && item.endStamp <= selected[1].endStamp && item.disabled) {
+          flag = true
+          break
+        }
+      }
+      if (flag) {
+        showNotify({
+          message: '选中时间段已被占用,请重新选择',
+          type: 'warning'
+        })
+        return
+      }
+    }
+    state.selected.push(item)
+    item.selected = true
+    state.selected.sort((a, b) => {
+      return a.startStamp - b.startStamp
+    })
+    if (state.selected.length == 2) {
+      for (const item of state.calendar) {
+        if (item.startStamp >= state.selected[0].startStamp && item.endStamp <= state.selected[1].endStamp) {
+          item.selected = true
+        }
+      }
+    }
+  }
+  const getAppointTime = () => {
+    let startDate: Date = new Date()
+    let endDate: Date = new Date()
+    if(state.selected.length === 1) {
+      startDate = new Date(state.selected[0].startStamp)
+      endDate = new Date(state.selected[0].endStamp)
+    } else if(state.selected.length === 2) {
+      startDate = new Date(state.selected[0].startStamp)
+      endDate = new Date(state.selected[1].endStamp)
+    } else {
+      return '-'
+    }
+    // 计算两个日期之间的时间差(以毫秒为单位)
+    const timeDifference = endDate.getTime() - startDate.getTime()
+    // 计算天数
+    const days = Math.floor(timeDifference / (1000 * 60 * 60 * 24))
+    // 计算剩余的毫秒数
+    const remainingMilliseconds = timeDifference % (1000 * 60 * 60 * 24)
+    // 计算小时数
+    const hours = Math.floor(remainingMilliseconds / (1000 * 60 * 60))
+    // 计算剩余的毫秒数
+    const remainingMillisecondsAfterHours = remainingMilliseconds % (1000 * 60 * 60)
+    // 计算分钟数
+    const minutes = Math.floor(remainingMillisecondsAfterHours / (1000 * 60))
+    return `${days}天${hours}小时${minutes}分`
+  }
+  const onClickButton = () => {
+    if(!state.selected.length) {
+      showNotify({
+        message: '请选择预约时间',
+        type: 'warning'
+      })
+      return
+    }
+    let startTime = 0
+    let endTime = 0
+    if(state.selected.length === 1) {
+      startTime = state.selected[0].startStamp
+      endTime = state.selected[0].endStamp
+    } else {
+      startTime = state.selected[0].startStamp
+      endTime = state.selected[1].endStamp
+    }
+    router.push({
+      path: '/instr-appoint',
+      query: {
+        id: instId.value,
+        startTime,
+        endTime
+      }
+    })
+  }
+  onMounted(() => {
+    instId.value = route.query.id ? +route.query.id : 0
+    getTimeSplit()
+  })
+</script>
+<style lang="scss" scoped>
+  .calendar-container {
+    height: calc(100% - 50px);
+    display: flex;
+    flex-direction: column;
+    header {
+      padding: 10px;
+      border-bottom: 1px solid #dcdfe6;
+      p {
+        padding: 4px;
+        i {
+          margin-right: 4px;
+        }
+      }
+    }
+    .main {
+      flex: 1;
+      overflow-y: auto;
+      padding: 10px 6px;
+      display: flex;
+      flex-wrap: wrap;
+      color: #323233;
+      h4 {
+        flex: 0 0 100%;
+        margin: 4px 0 0 4px;
+        height: 18px;
+        line-height: 18px;
+        display: flex;
+        &::before {
+          display: inline-block;
+          content: '';
+          width: 3px;
+          height: 18px;
+          background-color: #1c9bfd;
+          margin-right: 4px;
+          vertical-align: middle;
+        }
+      }
+      .tag {
+        font-size: 12px;
+        text-align: center;
+        flex: 0 0 calc(25% - 14px);
+        padding: 4px;
+        border: 1px solid #dcdfe6;
+        border-radius: 4px;
+        margin-left: 4px;
+        margin-top: 4px;
+        &.disabled {
+          background-color: #dcdfe6;
+        }
+        &.selected {
+          background-color: #1989fa;
+          border-color: #1989fa;
+          color: #fff;
+        }
+      }
+    }
+  }
+</style>

+ 540 - 0
src/view/instr/detail.vue

@@ -0,0 +1,540 @@
+<!--
+ * @Author: wanglj wanglijie@dashoo.cn
+ * @Date: 2025-03-24 09:17:15
+ * @LastEditors: wanglj wanglijie@dashoo.cn
+ * @LastEditTime: 2025-03-26 15:28:13
+ * @FilePath: \labsop_h5\src\view\instr\detail.vue
+ * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
+-->
+<template>
+  <div class="instr-detail">
+    <van-swipe v-if="noticeInfo.noticeTitle" class="my-swipe" :autoplay="5000" :show-indicators="false" vertical height="30">
+      <van-swipe-item @click="state.popupShow = true">
+        <div class="flex">
+          <van-icon name="volume-o" class="mr4" :size="20" />
+          {{ noticeInfo.noticeTitle }}
+        </div>
+      </van-swipe-item>
+    </van-swipe>
+    <header class="flex">
+      <div class="h100">
+        <!-- <img :showLoading="true" :src="state.instDetail.instPicture" width="80px" height="80px" /> -->
+        <van-image width="80px" height="80px" :src="state.instDetail.instPicture" />
+      </div>
+      <div class="i-right ml10">
+        <div class="h100 flex flex-top flex-column flex-between">
+          <div class="flex flex-top mb4 ml2">
+            <div class="detailTxt name">{{ state.instDetail.instName }}({{ state.instDetail.instCode }})</div>
+          </div>
+          <footer>
+            <div class="flex flex-top mb4 mt-auto">
+              <img class="i-r-icon" src="../../assets/img/user.png" v-if="state.instDetail.instHeadName" />
+              <div class="detailTxt">{{ state.instDetail.instHeadName }}</div>
+            </div>
+            <div class="flex flex-top">
+              <img class="i-r-icon" src="../../assets/img/address.png" v-if="state.instDetail.placeAddress" />
+              <div class="detailTxt">{{ state.instDetail.placeAddress + setLaboratoryName(state.instDetail.laboratoryName) }}</div>
+            </div>
+          </footer>
+        </div>
+      </div>
+    </header>
+    <van-tabs v-model:active="active" @change="tabChange">
+      <van-tab title="仪器信息" name="info"></van-tab>
+      <van-tab title="待审核" name="approval"></van-tab>
+      <van-tab title="历史申请" name="history"></van-tab>
+    </van-tabs>
+    <div v-if="active === 'info'" class="content">
+      <div class="card">
+        <h4>仪器信息</h4>
+        <ul>
+          <li>
+            <label>名称</label>
+            <span>{{ state.instDetail.instName }}</span>
+          </li>
+          <li>
+            <label>编号</label>
+            <span>{{ state.instDetail.instCode }}</span>
+          </li>
+          <li>
+            <label>仪器型号</label>
+            <span>{{ state.instDetail.instNameEn }}</span>
+          </li>
+          <li>
+            <label>当前状态</label>
+            <span>{{ state.instStatus[state.instDetail.instStatus] }}</span>
+          </li>
+          <li>
+            <label>所属组织</label>
+            <span>{{ state.instDetail.belongOrgName }}</span>
+          </li>
+          <li>
+            <label>位置</label>
+            <span>{{ state.instDetail.placeAddress }}</span>
+          </li>
+          <li>
+            <label>负责人</label>
+            <span>{{ state.instDetail.instHeadName }}</span>
+          </li>
+          <li>
+            <label>联系方式</label>
+            <span>{{ state.instDetail.instHeadTel }}</span>
+          </li>
+        </ul>
+      </div>
+      <div class="card">
+        <h4>申请须知</h4>
+        <div class="text">{{ state.instDetail.applicationNotes }}</div>
+      </div>
+      <div class="card">
+        <h4>主要功能</h4>
+        <div class="text">{{ state.instDetail.instFunctFeat }}</div>
+      </div>
+      <!-- <div class="card">
+            <h4>相关附件</h4>
+            <template v-for="item in state.instFiles">
+              <div class="file-item">
+                <a href="javascript: void(0);" @click="realDown(item.docName, item.docUrl)">{{ item.docName }}</a>
+              </div>
+            </template>
+          </div> -->
+    </div>
+    <van-list v-else v-model:loading="state.loading" :finished="state.finished" finished-text="没有更多了" @load="onLoad">
+      <van-cell v-for="item in state.list">
+        <template #default>
+          <div class="list">
+            <header class="flex justify-between">
+              <strong class="title">{{ item.userName }}的预约</strong>
+              <van-tag v-if="item.appointStatus == '10'" type="default">待审核</van-tag>
+              <van-tag v-else-if="item.appointStatus == '11'" type="warning">已退回</van-tag>
+              <van-tag v-else-if="item.appointStatus == '20'" type="success">已通过</van-tag>
+              <van-tag v-else-if="item.appointStatus == '30'" type="danger">已驳回</van-tag>
+              <van-tag v-else-if="item.appointStatus == '40'" type="warning">已取消</van-tag>
+              <van-tag v-else-if="item.appointStatus == '50'" type="default">已上机</van-tag>
+              <van-tag v-else-if="item.appointStatus == '60'" type="primary">已完成</van-tag>
+              <van-tag v-else-if="item.appointStatus == '70'" type="warning">审核超时</van-tag>
+              <van-tag v-else-if="item.appointStatus == '80'" type="danger">超时取消</van-tag>
+              <van-tag v-else-if="item.appointStatus == '90'" type="danger">超时未上机</van-tag>
+            </header>
+            <p class="inst-title">
+              <span>预约仪器</span>
+              <span class="title ml8">{{ item.instName }}({{ item.instCode }})</span>
+            </p>
+            <p class="inst-title">
+              <span>预约时间</span>
+              <span class="title ml8">{{ formatDate(new Date(item.startTime), 'mm-dd HH:MM') }}~{{ formatDate(new Date(item.endTime), 'mm-dd HH:MM') }}</span>
+            </p>
+            <p class="inst-title">
+              <span>预约时长</span>
+              <span class="title ml8">{{ getAppointTime(item) }}</span>
+            </p>
+            <p class="inst-title">
+              <span>违约情况</span>
+              <span class="title ml8">{{ getBreachTypes(item) }}</span>
+            </p>
+            <p class="inst-title">
+              <span>扣分明细</span>
+              <span class="title ml8">{{ item.breachScore }}分</span>
+            </p>
+            <p class="inst-title">
+              <span>备注</span>
+              <span class="title ml8">{{ item.remark }}</span>
+            </p>
+            <footer class="flex justify-between mt4">
+              <span class="title">{{ item.userName }}</span>
+              <span class="time">{{ formatDate(new Date(item.createdTime), 'mm-dd HH:MM') }}</span>
+            </footer>
+          </div>
+        </template>
+      </van-cell>
+    </van-list>
+    <van-back-top target=".instr-detail" bottom="10vh" />
+  </div>
+  <van-action-bar>
+    <van-action-bar-icon icon="wap-home-o" text="首页" @click="onRouterPush('/home')" />
+    <van-action-bar-icon icon="calendar-o" text="周视图" />
+    <van-action-bar-icon :icon="state.instDetail.following ? 'star' : 'star-o'" :class="{ follow: state.instDetail.following }" :text="state.instDetail.following ? '取消收藏' : '收藏'" @click="handleFollowInst" />
+    <van-action-bar-button type="primary" text="立即预约" @click="onAppoint" />
+  </van-action-bar>
+  <!-- 通知 -->
+  <van-popup v-model:show="state.popupShow" round :closeable="true" position="top" :style="{ padding: '20px' }">
+    <h4>{{ noticeInfo.noticeTitle }}</h4>
+    <div class="notice-container" v-html="noticeInfo.noticeContent"></div>
+  </van-popup>
+  <!-- 申请须知 -->
+  <van-popup v-model:show="state.needToKnowShow" round :closeable="true" position="bottom" :style="{ height: '90vh' }">
+    <div class="need-to-know">
+      <h4 class="mt8 mb8">申请须知</h4>
+      <p>{{ state.instDetail.applicationNotes }}</p>
+      <footer>
+        <van-button class="w100" type="primary" round @click="confirmAppoint">我知道了</van-button>
+      </footer>
+    </div>
+  </van-popup>
+</template>
+
+<script lang="ts" setup>
+  import to from 'await-to-js'
+  import { useRoute, useRouter } from 'vue-router'
+  import { useInstrApi } from '/@/api/instr'
+  import { useInstDocApi } from '/@/api/instr/document'
+  import { onMounted, reactive, ref } from 'vue'
+  import { formatDate } from '/@/utils/formatTime'
+  import { showNotify } from 'vant'
+  import download from 'downloadjs'
+  import { useNoticeApi } from '/@/api/instr/notice'
+  import { useUseAppointApi } from '/@/api/instr/useAppoint'
+  import { useBlackApi } from '/@/api/blacklist'
+  const route = useRoute()
+  const router = useRouter()
+  const instApi = useInstrApi()
+  const instDocApi = useInstDocApi()
+  const noticeApi = useNoticeApi()
+  const useAppointApi = useUseAppointApi()
+  const blacklistApi = useBlackApi()
+  const active = ref('info')
+  const state = reactive({
+    detailsLoading: false,
+    instStatus: {
+      10: '正常',
+      20: '故障',
+      30: '报废'
+    },
+    instDetail: {} as any,
+    instFiles: [] as any[],
+    loading: false,
+    finished: false,
+    queryParams: {
+      pageNum: 1,
+      pageSize: 10,
+      instId: 0,
+      appointStatus: []
+    },
+    list: [] as any[],
+    popupShow: false,
+    needToKnowShow: false
+  })
+  const noticeInfo = ref({ noticeTitle: '', noticeContent: '' })
+  // 获取仪器详情
+  const getDetail = async (id: number) => {
+    state.detailsLoading = true
+    const [err, res]: ToResponse = await to(instApi.getDetail({ id }))
+    state.detailsLoading = false
+    if (err) return
+    if (res?.code === 200) {
+      state.instDetail = res.data
+      getDocs()
+      getNotice()
+    }
+  }
+  const getNotice = async () => {
+    const param = {
+      pageNum: 1,
+      pageSize: 1,
+      instId: state.instDetail.instId
+    }
+    const [err, res]: ToResponse = await to(noticeApi.list({ ...param }))
+    if (err) return
+    noticeInfo.value = res?.data?.list.length > 0 ? res?.data?.list[0] : {}
+  }
+  // 附件列表
+  const getDocs = async () => {
+    const [err, res]: ToResponse = await to(instDocApi.list({ noPage: true, instId: state.instDetail.instId, docType: '' }))
+    if (err) return
+    state.instFiles = res?.data.list || []
+  }
+  const realDown = (filename: string, fileurl: string) => {
+    let ua = navigator.userAgent.toLowerCase()
+    if (ua.includes('mac')) {
+      //iOS 将文件url转换为文件流 在下载
+      downloadFun(fileurl + '?response-content-type=application/octet-stream', filename)
+    } else {
+      //android 直接用插件的方法下载即可
+      download(fileurl, filename)
+    }
+  }
+  // 创建a标签 实现下载
+  const downloadFun = async (blobFile, fileName) => {
+    let blob = new Blob([blobFile], {
+      type: 'application/pdf;charset=UTF-8'
+    })
+    // @ts-ignore
+    if (window.navigator.msSaveOrOpenBlob) {
+      // @ts-ignore
+      navigator.msSaveBlob(blob, fileName)
+    } else {
+      let link = document.createElement('a')
+      link.href = window.URL.createObjectURL(blob)
+      link.download = fileName
+      link.click()
+      window.URL.revokeObjectURL(link.href) //释放内存
+    }
+  }
+  const setLaboratoryName = (name) => {
+    return name ? `(${name})` : ''
+  }
+  const tabChange = (name: string) => {
+    if (name === 'history' || name === 'approval') {
+      state.finished = false
+      state.list = []
+      state.queryParams = {
+        pageNum: 1,
+        pageSize: 10,
+        instId: state.instDetail.id,
+        appointStatus: name === 'approval' ? ['10'] : []
+      }
+      onLoad()
+    }
+  }
+  const onLoad = async () => {
+    state.loading = true
+    const [err, res]: ToResponse = await to(useAppointApi.getListByPermission(state.queryParams))
+    if (err) return
+    const list = res?.data?.list || []
+    for (const item of list) {
+      state.list.push(item)
+    }
+    state.loading = false
+    state.queryParams.pageNum++
+    if (list.length < state.queryParams.pageSize) {
+      state.finished = true
+    }
+  }
+  const getBreachTypes = (row: any) => {
+    let breachTypes = <string[]>[]
+    if (row.isLate) breachTypes.push('迟到')
+    if (row.isOvertime) breachTypes.push('超时')
+    if (row.isLeaveEarly) breachTypes.push('早退')
+    if (row.isAbsence) breachTypes.push('爽约')
+    return breachTypes.join('、') || '-'
+  }
+  const getAppointTime = (row: any) => {
+    const startDate = new Date(row.startTime)
+    const endDate = new Date(row.endTime)
+    // 计算两个日期之间的时间差(以毫秒为单位)
+    const timeDifference = endDate.getTime() - startDate.getTime()
+    // 计算天数
+    const days = Math.floor(timeDifference / (1000 * 60 * 60 * 24))
+    // 计算剩余的毫秒数
+    const remainingMilliseconds = timeDifference % (1000 * 60 * 60 * 24)
+    // 计算小时数
+    const hours = Math.floor(remainingMilliseconds / (1000 * 60 * 60))
+    // 计算剩余的毫秒数
+    const remainingMillisecondsAfterHours = remainingMilliseconds % (1000 * 60 * 60)
+    // 计算分钟数
+    const minutes = Math.floor(remainingMillisecondsAfterHours / (1000 * 60))
+    return `${days}天${hours}小时${minutes}分`
+  }
+  // 关注/取关
+  const handleFollowInst = async () => {
+    const [err] = state.instDetail.following
+      ? await to(instApi.unfollow({ ids: [state.instDetail.id] }))
+      : await to(instApi.follow({ ids: [state.instDetail.id] }))
+    if (err) return
+    showNotify({ type: 'success', message: !state.instDetail.following ? '收藏成功' : '已取消收藏' })
+    getDetail(state.instDetail.id)
+  }
+  const onAppoint = async () => {
+    state.needToKnowShow = true
+  }
+  const confirmAppoint = async () => {
+    const [err, res]: ToResponse = await to(blacklistApi.checkInBlacklist())
+    if (err) return
+    if (res.data) {
+      showNotify({ type: 'danger', message: '您已被拉入黑名单,无法预约,请联系管理员' })
+      return
+    }
+    onRouterPush('/instr-appoint', { id: state.instDetail.id })
+  }
+  const onRouterPush = (val: string, params?: any) => {
+    router.push({
+      path: val,
+      query: { ...params }
+    })
+  }
+  onMounted(() => {
+    const id = route.query.id ? +route.query.id : 0
+    getDetail(id)
+  })
+</script>
+
+<style lang="scss" scoped>
+  .instr-detail {
+    height: calc(100% - 50px);
+    overflow-y: auto;
+    background-color: #f7f8fa;
+    .my-swipe {
+      background-color: #fff;
+      height: 30px !important;
+      line-height: 30px !important;
+      :deep(.flex) {
+        height: 30px;
+        overflow: hidden;
+        padding: 0 12px;
+        span {
+          display: inline-block;
+          height: 30px;
+          line-height: 30px;
+        }
+        span:first-child {
+          flex: 1;
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+        }
+      }
+    }
+    > header {
+      height: 80px;
+      background-color: #fff;
+      padding: 12px;
+    }
+    .inst-info {
+      display: flex;
+    }
+    .i-right {
+      flex: 1;
+      font-size: 14px;
+      height: 80px;
+      .i-r-icon {
+        width: 15px;
+        height: 15px;
+        margin-right: 10px;
+      }
+    }
+    .detailTxt {
+      font-size: 12px;
+      color: #333333;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      &.name {
+        font-weight: bold;
+        font-size: 16px;
+      }
+    }
+    .content {
+      padding: 10px;
+    }
+    .card {
+      border-radius: 4px;
+      background-color: #fff;
+      padding: 10px;
+      box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.12);
+      & + .card {
+        margin-top: 10px;
+      }
+      h4 {
+        height: 18px;
+        line-height: 18px;
+        display: flex;
+        margin-bottom: 10px;
+        span {
+          font-weight: normal;
+          margin-left: auto;
+        }
+        &::before {
+          display: inline-block;
+          content: '';
+          width: 3px;
+          height: 18px;
+          background-color: #1c9bfd;
+          margin-right: 4px;
+          vertical-align: middle;
+        }
+      }
+      > ul {
+        li {
+          display: flex;
+          padding: 6px 0;
+          label {
+            width: 80px;
+            min-width: 80px;
+            color: #969799;
+          }
+          span {
+            word-break: break-all;
+          }
+        }
+      }
+      .text {
+        white-space: pre-wrap;
+      }
+    }
+    .van-list {
+      padding: 10px;
+      border-radius: 4px;
+      flex: 1;
+      .van-cell {
+        background-color: #fff;
+        + .van-cell {
+          margin-top: 10px;
+        }
+        header,
+        footer {
+          color: #333;
+        }
+        .title {
+          flex: 1;
+          white-space: nowrap;
+          overflow: hidden;
+          text-overflow: ellipsis;
+          text-align: left;
+        }
+        .inst-title {
+          color: #333;
+          text-align: left;
+          flex: 1;
+          overflow: hidden;
+          white-space: nowrap;
+          text-overflow: ellipsis;
+          margin-top: 4px;
+          span:first-child {
+            display: inline-block;
+            width: 80px;
+            min-width: 80px;
+            color: rgb(120, 120, 120);
+          }
+        }
+        .time {
+          color: #f69a4d;
+        }
+      }
+    }
+  }
+  .btns {
+    flex: 1;
+    display: flex;
+    li {
+      display: flex;
+      flex-direction: column;
+      align-items: center;
+      justify-content: center;
+      padding: 0 8px;
+      font-size: 12px;
+      i {
+        margin-bottom: 4px;
+      }
+    }
+  }
+  :deep(.follow .van-icon) {
+    color: #fdc33e;
+  }
+  .need-to-know {
+    height: calc(100% - 20px);
+    overflow: hidden;
+    display: flex;
+    flex-direction: column;
+    padding: 10px 20px;
+    white-space: pre-wrap;
+    p {
+      flex: 1;
+      overflow-y: auto;
+    }
+    footer {
+      flex: 0 0 45px;
+      margin-top: 4px;
+      border-top: 1px solid #f7f8fa;
+    }
+  }
+</style>

+ 11 - 0
src/view/instr/list-follow.vue

@@ -0,0 +1,11 @@
+<template>
+  <List following="20" />
+</template>
+
+<script lang="ts" setup>
+  import { defineAsyncComponent } from 'vue'
+  const List = defineAsyncComponent(() => import('./list.vue'))
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 37 - 18
src/view/instr/list.vue

@@ -28,21 +28,25 @@
           <div class="inst-item mb40" v-for="(v, index) in state.instList" :key="index">
             <div class="inst-info mt4 mb4">
               <div class="i-left" @click="openDetail(v)">
-                <img :showLoading="true" :src="v.instPicture" width="100px" height="80px" />
+                <!-- <img :showLoading="true" :src="v.instPicture" width="100px" height="100%" /> -->
+                <van-image width="100px" height="100px" :src="v.instPicture" />
               </div>
               <div class="i-right ml10">
-                <div @click="openDetail(v)">
+                <div class="h100 flex flex-top flex-column flex-between" @click="openDetail(v)">
+                  <!-- <van-button @click="openAppoint(v)">预约</van-button> -->
                   <div class="flex flex-top mb4 ml2">
-                    <div class="detailTxt name">{{ v.instCode }}</div>
-                  </div>
-                  <div class="flex flex-top mb4 mt-auto">
-                    <img class="i-r-icon" src="../../assets/img/user.png" v-if="v.instHeadName" />
-                    <div class="detailTxt">{{ v.instHeadName }}</div>
-                  </div>
-                  <div class="flex flex-top">
-                    <img class="i-r-icon" src="../../assets/img/address.png" v-if="v.placeAddress" />
-                    <div class="detailTxt">{{ v.placeAddress + setLaboratoryName(v.laboratoryName) }}</div>
+                    <div class="detailTxt name">{{ v.instName }}</div>
                   </div>
+                  <footer>
+                    <div class="flex flex-top mb4 mt-auto">
+                      <img class="i-r-icon" src="../../assets/img/user.png" v-if="v.instHeadName" />
+                      <div class="detailTxt">{{ v.instHeadName }}</div>
+                    </div>
+                    <div class="flex flex-top">
+                      <img class="i-r-icon" src="../../assets/img/address.png" v-if="v.placeAddress" />
+                      <div class="detailTxt">{{ v.placeAddress + setLaboratoryName(v.laboratoryName) }}</div>
+                    </div>
+                  </footer>
                 </div>
               </div>
             </div>
@@ -51,7 +55,7 @@
       </div>
     </div>
     <van-tabbar route :placeholder="true">
-      <van-tabbar-item replace to="/instr-list" icon="printer">关注仪器</van-tabbar-item>
+      <van-tabbar-item replace to="/instr-follow" icon="star">收藏仪器</van-tabbar-item>
       <van-tabbar-item replace to="/instr-list" icon="printer">全部仪器</van-tabbar-item>
       <van-tabbar-item replace to="/instr-appoint-record" icon="label">我的预约</van-tabbar-item>
     </van-tabbar>
@@ -121,7 +125,12 @@
   import { useBlackApi } from '/@/api/blacklist'
   import { usePositionApi } from '/@/api/instr/position'
   import { showNotify } from 'vant'
-
+  const props = defineProps({
+    following: {
+      type: String,
+      default: '10'
+    }
+  })
   const router = useRouter()
   const instApi = useInstrApi()
   const blacklistApi = useBlackApi()
@@ -186,8 +195,14 @@
   }
   // 设备详情
   const openDetail = (v) => {
-    state.popupShow = true
-    getDetail(v.id)
+    router.push({
+      path: '/instr-detail',
+      query: {
+        id: v.id
+      }
+    })
+    // state.popupShow = true
+    // getDetail(v.id)
   }
   const onLoad = () => {
     state.queryForm.pageNum++
@@ -196,7 +211,9 @@
   // 查询列表
   const getInstList = async () => {
     state.loading = true
-    const [err, res]: ToResponse = await to(instApi.getList(state.queryForm))
+    const params = JSON.parse(JSON.stringify(state.queryForm))
+    params.following = props.following
+    const [err, res]: ToResponse = await to(instApi.getList(params))
     state.loading = false
     if (err) return
     if (res?.code === 200) {
@@ -289,17 +306,19 @@
     .inst-list {
       flex: 1;
       height: 0;
+      background-color: #f7f8fa;
       // padding-bottom: 20px;
       // padding: 0 15px;
       // height: calc(100% - 60px);
       .inst-wrap {
         height: 100%;
         overflow: auto;
-        padding: 20px 20px 0 20px;
+        padding: 10px 10px 0 10px;
         // padding: 10px 10px 0 10px;
       }
       .inst-item {
-        padding: 4px;
+        background-color: #fff;
+        padding: 8px;
         box-shadow: -2px 0px 9px rgba(0, 0, 0, 0.12);
         margin-bottom: 14px;
         border-radius: 6px;

+ 6 - 7
src/view/login/index.vue

@@ -2,7 +2,7 @@
  * @Author: wanglj wanglijie@dashoo.cn
  * @Date: 2025-03-11 18:02:10
  * @LastEditors: wanglj wanglijie@dashoo.cn
- * @LastEditTime: 2025-03-21 10:12:40
+ * @LastEditTime: 2025-03-27 17:18:32
  * @FilePath: \vant-demo-master\vant\vue3-ts\src\view\login\index.vue
  * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
 -->
@@ -44,7 +44,7 @@
   const sm3 = crypto.sm3
   const loginApi = useLoginApi()
   const storesUseUserInfo = useUserInfo()
-  const { userInfos, openId } = storeToRefs(storesUseUserInfo)
+  const { userInfos, openId, unionId } = storeToRefs(storesUseUserInfo)
   const state = reactive({
     loading: {
       signIn: false
@@ -59,10 +59,9 @@
     const params = JSON.parse(JSON.stringify(state.form))
     params.password = sm3(params.password)
     params.openId = openId.value
+    params.unionId = unionId.value
     // params.password = (params.password)
     // sm3
-    console.log(openId.value, 'openIdddddddddddd');
-    
     const post = params.openId ? loginApi.weChatLogin : loginApi.signIn
     const [err, res]: ToResponse = await to(post(params))
     if (err) {
@@ -70,11 +69,11 @@
       return
     }
     // 存储 token 到浏览器缓存
-    Local.set('token', res?.data.token)
+    Local.set('token', res?.data?.token)
     router.push('/home')
   }
   const openIdLogin = async () => {
-    const [err, res]: ToResponse = await to(loginApi.weChatLoginOpenId({ openId: openId.value }))
+    const [err, res]: ToResponse = await to(loginApi.weChatLoginUnionId({ openId: openId.value, unionId: unionId.value }))
     if (err) return
     Local.set('token', res?.data.token)
     router.push('/home')
@@ -121,8 +120,8 @@
   }
   onMounted(async () => {
     const code: string = route.query.code ? route.query.code.toString() : ''
+    await storesUseUserInfo.setOpenId(code)
     if(code) {
-      await storesUseUserInfo.setOpenId(code)
       openIdLogin()
     }
   })

+ 1 - 1
src/view/notice/index.vue

@@ -61,7 +61,7 @@
     }
     state.loading = false
     state.queryParams.pageNum++
-    if (state.list.length < state.queryParams.pageSize) {
+    if (list.length < state.queryParams.pageSize) {
       state.finished = true
     }
   }

+ 1 - 3
src/view/service/index.vue

@@ -118,7 +118,7 @@
     }
     state.loading = false
     state.queryParams.pageNum++
-    if (state.list.length < state.queryParams.pageSize) {
+    if (list.length < state.queryParams.pageSize) {
       state.finished = true
     }
   }
@@ -145,12 +145,10 @@
       }
     }
     .van-list {
-      height: calc(100vh - 48px);
       background-color: #fff;
       margin: 10px 0;
       padding: 0 10px;
       border-radius: 4px;
-      overflow-y: auto;
       .van-cell {
         background-color: #f9ffff;
         margin-top: 10px;

+ 2 - 2
src/view/todo/index.vue

@@ -2,7 +2,7 @@
  * @Author: wanglj wanglijie@dashoo.cn
  * @Date: 2025-03-11 18:02:10
  * @LastEditors: wanglj wanglijie@dashoo.cn
- * @LastEditTime: 2025-03-21 09:38:30
+ * @LastEditTime: 2025-03-24 14:16:13
  * @FilePath: \vant-demo-master\vant\vue3-ts\src\view\login\index.vue
  * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
 -->
@@ -70,7 +70,7 @@
     }
     state.loading = false
     state.queryParams.pageNum++
-    if (state.list.length < state.queryParams.pageSize) {
+    if (list.length < state.queryParams.pageSize) {
       state.finished = true
     }
   }

+ 19 - 0
src/view/training/done.vue

@@ -0,0 +1,19 @@
+<!--
+ * @Author: wanglj wanglijie@dashoo.cn
+ * @Date: 2025-03-17 13:36:58
+ * @LastEditors: wanglj wanglijie@dashoo.cn
+ * @LastEditTime: 2025-03-26 17:28:32
+ * @FilePath: \labsop-h5\src\view\training\index.vue
+ * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
+-->
+<template>
+  <Training isAll="20" />
+</template>
+
+<script lang="ts" setup>
+  import { defineAsyncComponent } from 'vue'
+  const Training = defineAsyncComponent(() => import('./index.vue'))
+</script>
+
+<style lang="scss" scoped>
+</style>

+ 4 - 2
src/view/training/enroll.vue

@@ -46,7 +46,7 @@
   const sysApi = useSystemApi()
   const trainingApi = useTrainingApi()
   const storesUseUserInfo = useUserInfo()
-  const { userInfos, openId } = storeToRefs(storesUseUserInfo)
+  const { userInfos, openId, unionId } = storeToRefs(storesUseUserInfo)
   const router = useRouter()
   const route = useRoute()
   const formRef = ref()
@@ -66,7 +66,8 @@
       projectGroup: '',
       projectGroupName: '',
       telephone: '',
-      wechatOpenId: ''
+      wechatOpenId: '',
+      wechatUnionId: ''
     }
   })
   const getDicts = () => {
@@ -104,6 +105,7 @@
     const [errValid] = await to(formRef.value.validate())
     if (errValid) return
     state.form.wechatOpenId = openId.value
+    state.form.wechatUnionId = unionId.value
     const [err]: ToResponse = await to(trainingApi.addTrainingApply(state.form))
     if (err) return
     showNotify({

+ 61 - 25
src/view/training/index.vue

@@ -2,7 +2,7 @@
  * @Author: wanglj wanglijie@dashoo.cn
  * @Date: 2025-03-17 13:36:58
  * @LastEditors: wanglj wanglijie@dashoo.cn
- * @LastEditTime: 2025-03-19 11:37:11
+ * @LastEditTime: 2025-03-27 09:26:41
  * @FilePath: \labsop-h5\src\view\training\index.vue
  * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
 -->
@@ -11,9 +11,18 @@
     <van-list v-model:loading="state.loading" :finished="state.finished" finished-text="没有更多了" @load="onLoad" class="mt10">
       <van-cell v-for="(item, index) in state.list" :key="index" :center="true">
         <template #title>
-          <van-text-ellipsis :content="`${item.title}`" />
+          <div class="list" @click="onEnroll(item)">
+            <header class="flex justify-between">
+              <van-text-ellipsis class="title" :content="item.title" />
+              <van-tag v-if="item.applyStatus === '10'" type="warning">未报名</van-tag>
+              <van-tag v-else-if="item.applyStatus === '20'" type="primary">已报名</van-tag>
+            </header>
+            <footer class="flex justify-between">
+              <van-text-ellipsis :content="item.createdName" />
+              <span>{{ formatDate(new Date(item.createdTime), 'YYYY-mm-dd') }}</span>
+            </footer>
+          </div>
         </template>
-        <template #value><van-button type="primary" size="small" @click="onEnroll(item)">报名</van-button></template>
       </van-cell>
     </van-list>
   </div>
@@ -24,20 +33,34 @@
   import { onMounted, reactive } from 'vue'
   import { useRouter } from 'vue-router'
   import { useTrainingApi } from '/@/api/training'
+  import { formatDate } from '/@/utils/formatTime'
+  import { useUserInfo } from '/@/stores/userInfo'
+import { showNotify } from 'vant'
+  const props = defineProps({
+    isAll: {
+      type: String,
+      default: '10'
+    }
+  })
   const trainingApi = useTrainingApi()
   const router = useRouter()
   const state = reactive({
     queryParams: {
       pageNum: 1,
-      pageSize: 10
+      pageSize: 10,
+      isAll: false
     },
     list: [] as RowTraningApplyType[],
     loading: false,
     finished: false
   })
   const onLoad = async () => {
+    if (state.loading) return
     state.loading = true
-    const [err, res]: ToResponse = await to(trainingApi.getList(state.queryParams))
+    const params = JSON.parse(JSON.stringify(state.queryParams))
+    params.isAll = props.isAll
+    params.userId = useUserInfo().userInfos.id
+    const [err, res]: ToResponse = await to(trainingApi.getListForUser(params))
     if (err) {
       state.loading = false
       return
@@ -53,6 +76,13 @@
     state.loading = false
   }
   const onEnroll = (row: any) => {
+    if(row.applyStatus === '20') {
+      showNotify({
+        type: 'warning',
+        message: '您已报名该培训,请勿重复报名'
+      })
+      return
+    }
     router.push({
       path: '/training/enroll',
       query: {
@@ -65,27 +95,33 @@
   })
 </script>
 
-<style lang="scss">
-  .user {
-    &-poster {
-      width: 100%;
-      height: 53vw;
-      display: block;
-    }
-
-    &-group {
-      margin-bottom: 15px;
-    }
-
-    &-links {
-      padding: 15px 0;
-      font-size: 12px;
-      text-align: center;
+<style lang="scss" scoped>
+  .app-container {
+    .van-cell {
       background-color: #fff;
-
-      .van-icon {
-        display: block;
-        font-size: 24px;
+      margin-top: 10px;
+      header {
+        color: #333;
+        font-size: 16px;
+      }
+      footer {
+        color: #969799;
+        margin-top: 4px;
+      }
+      .title {
+        font-weight: bold;
+      }
+      .inst-title {
+        color: #333;
+        text-align: left;
+        flex: 1;
+        overflow: hidden;
+        white-space: nowrap;
+        text-overflow: ellipsis;
+        margin-top: 4px;
+      }
+      .time {
+        color: #f69a4d;
       }
     }
   }

+ 4 - 3
src/view/user/edit.vue

@@ -18,7 +18,8 @@
       <van-cell-group>
         <van-cell class="flex" is-link size="normal" title="头像" @click="editAvatar">
           <template #value>
-            <img class="avatar" width="30" height="30" :src="userInfos.avatar" alt="" />
+            <!-- <img class="avatar" width="30" height="30" :src="userInfos.avatar" alt="" /> -->
+            <van-image width="30px" height="30px" :src="userInfos.avatar" />
           </template>
         </van-cell>
         <van-field v-model="state.form.nickName" label="用户昵称" placeholder="用户昵称" :rules="[{ required: true, message: '请输入用户昵称' }]" />
@@ -117,7 +118,6 @@
   }
   const afterRead = (res: any) => {
     const rawFile = res.file
-    console.log(rawFile, rawFile.type, 'rawFileeeeeeeeeee')
     if (
       rawFile.type !== 'image/jpeg' &&
       rawFile.type !== 'image/jpg' &&
@@ -229,7 +229,8 @@
             closeDialog()
           }
         }
-      }).catch(() => {
+      })
+      .catch(() => {
         showNotify({
           type: 'warning',
           message: '上传失败'

+ 4 - 9
src/view/user/index.vue

@@ -1,7 +1,8 @@
 <template>
   <div class="app-container">
     <header>
-      <img :src="userInfos.avatar" alt="" />
+      <!-- <img :src="userInfos.avatar" alt="" /> -->
+      <van-image class="mr10" width="100px" height="100px" round :src="userInfos.avatar" />
       <div class="content">
         <p class="bold">
           <van-text-ellipsis :content="`${userInfos.nickName}(信用分:${userInfos.creditScore})`" />
@@ -71,8 +72,8 @@
     showConfirmDialog({
       message: '确认切换账号?'
     }).then(() => {
-      Local.clear()
-      router.push('/login')
+      Local.remove('token')
+      window.location.reload()
     })
   }
 </script>
@@ -88,12 +89,6 @@
       border-radius: 8px;
       margin-top: 10px;
       display: flex;
-      img {
-        width: 100px;
-        height: 100px;
-        border-radius: 50%;
-        margin-right: 10px;
-      }
       .content {
         flex: 1;
         display: flex;

+ 1 - 1
tsconfig.json

@@ -21,6 +21,6 @@
       ]
     }
   },
-  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "src/utils/errorCode.js", "src/utils/micro_request.ts", "src/types/*.d.ts", "src/types/**/*.ts", "src/api/appoint/index.ts"],
+  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue", "src/utils/errorCode.js", "src/utils/micro_request.ts", "src/types/*.d.ts", "src/types/**/*.ts", "src/api/appoint/index.ts", "src/view/instr/appoint-calendervue"],
   "references": [{ "path": "./tsconfig.node.json" }]
 }