Bladeren bron

免密登录

xsh_1997 3 dagen geleden
bovenliggende
commit
c6bbe20125

+ 1 - 1
doc/大屏/免密登录/大屏免密登录技术方案.md

@@ -177,7 +177,7 @@ async function screenAutoLogin(apiBase) {
177
 - [x] `ScreenLoginService` + `ScreenLoginRsaSupport` + `ScreenLoginProperties`
177
 - [x] `ScreenLoginService` + `ScreenLoginRsaSupport` + `ScreenLoginProperties`
178
 - [x] `application.yml` → `bigscreen.login.*`
178
 - [x] `application.yml` → `bigscreen.login.*`
179
 - [x] 单元测试:`ScreenLoginRsaSupportTest`、`ScreenLoginServiceTest`、`ScreenLoginControllerApiTest`
179
 - [x] 单元测试:`ScreenLoginRsaSupportTest`、`ScreenLoginServiceTest`、`ScreenLoginControllerApiTest`
180
-- [ ] `ruoyi-screen` 对接自动登录(按本方案调用公钥 + 登录接口)
180
+- [x] `ruoyi-screen` 对接自动登录(按本方案调用公钥 + 登录接口)
181
 
181
 
182
 ---
182
 ---
183
 
183
 

+ 3 - 0
ruoyi-screen/.env.development

@@ -14,3 +14,6 @@ VITE_PROXY_TARGET=http://192.168.1.6:8010
14
 VITE_WEATHER_APP_ID=36793262
14
 VITE_WEATHER_APP_ID=36793262
15
 VITE_WEATHER_APP_SECRET=t6a6z0H8
15
 VITE_WEATHER_APP_SECRET=t6a6z0H8
16
 VITE_WEATHER_CITY=巴青
16
 VITE_WEATHER_CITY=巴青
17
+
18
+# 大屏免密登录(RSA 加密 UUID,见 doc/大屏/免密登录/大屏免密登录技术方案.md)
19
+VITE_SCREEN_AUTO_LOGIN=true

+ 3 - 0
ruoyi-screen/.env.production

@@ -15,3 +15,6 @@ VITE_WEATHER_APP_ID=36793262
15
 VITE_WEATHER_APP_SECRET=t6a6z0H8
15
 VITE_WEATHER_APP_SECRET=t6a6z0H8
16
 VITE_WEATHER_CITY=巴青
16
 VITE_WEATHER_CITY=巴青
17
 
17
 
18
+# 大屏免密登录(生产需后端 bigscreen.login.enabled=true 且配置 RSA 密钥)
19
+VITE_SCREEN_AUTO_LOGIN=true
20
+

+ 11 - 0
ruoyi-screen/package-lock.json

@@ -11,6 +11,7 @@
11
         "axios": "^1.16.1",
11
         "axios": "^1.16.1",
12
         "echarts": "^5.5.1",
12
         "echarts": "^5.5.1",
13
         "echarts-wordcloud": "^2.1.0",
13
         "echarts-wordcloud": "^2.1.0",
14
+        "jsencrypt": "^3.0.0-rc.1",
14
         "vue": "^3.5.13",
15
         "vue": "^3.5.13",
15
         "vue-router": "^4.6.4"
16
         "vue-router": "^4.6.4"
16
       },
17
       },
@@ -883,6 +884,11 @@
883
         "node": ">= 6"
884
         "node": ">= 6"
884
       }
885
       }
885
     },
886
     },
887
+    "node_modules/jsencrypt": {
888
+      "version": "3.0.0-rc.1",
889
+      "resolved": "https://registry.npmmirror.com/jsencrypt/-/jsencrypt-3.0.0-rc.1.tgz",
890
+      "integrity": "sha512-gcvGaqerlUJy1Kq6tNgPYteVEoWNemu+9hBe2CdsCIz4rVcwjoTQ72iD1W76/PRMlnkzG0yVh7nwOOMOOUfKmg=="
891
+    },
886
     "node_modules/magic-string": {
892
     "node_modules/magic-string": {
887
       "version": "0.30.21",
893
       "version": "0.30.21",
888
       "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
894
       "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@@ -1639,6 +1645,11 @@
1639
         "debug": "4"
1645
         "debug": "4"
1640
       }
1646
       }
1641
     },
1647
     },
1648
+    "jsencrypt": {
1649
+      "version": "3.0.0-rc.1",
1650
+      "resolved": "https://registry.npmmirror.com/jsencrypt/-/jsencrypt-3.0.0-rc.1.tgz",
1651
+      "integrity": "sha512-gcvGaqerlUJy1Kq6tNgPYteVEoWNemu+9hBe2CdsCIz4rVcwjoTQ72iD1W76/PRMlnkzG0yVh7nwOOMOOUfKmg=="
1652
+    },
1642
     "magic-string": {
1653
     "magic-string": {
1643
       "version": "0.30.21",
1654
       "version": "0.30.21",
1644
       "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
1655
       "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",

+ 1 - 0
ruoyi-screen/package.json

@@ -15,6 +15,7 @@
15
     "axios": "^1.16.1",
15
     "axios": "^1.16.1",
16
     "echarts": "^5.5.1",
16
     "echarts": "^5.5.1",
17
     "echarts-wordcloud": "^2.1.0",
17
     "echarts-wordcloud": "^2.1.0",
18
+    "jsencrypt": "^3.0.0-rc.1",
18
     "vue": "^3.5.13",
19
     "vue": "^3.5.13",
19
     "vue-router": "^4.6.4"
20
     "vue-router": "^4.6.4"
20
   },
21
   },

+ 19 - 0
ruoyi-screen/src/api/login.js

@@ -27,3 +27,22 @@ export function logout() {
27
     method: 'post'
27
     method: 'post'
28
   })
28
   })
29
 }
29
 }
30
+
31
+/** 大屏免密登录:获取 RSA 公钥 */
32
+export function getScreenPublicKey() {
33
+  return request({
34
+    url: '/bigScreen/publicKey',
35
+    method: 'get',
36
+    headers: { isToken: false }
37
+  })
38
+}
39
+
40
+/** 大屏免密登录:提交 RSA 密文换取 JWT */
41
+export function screenAutoLogin(encryptedToken) {
42
+  return request({
43
+    url: '/bigScreen/login',
44
+    method: 'post',
45
+    headers: { isToken: false },
46
+    data: { token: encryptedToken }
47
+  })
48
+}

+ 2 - 0
ruoyi-screen/src/components/ScreenNavBar.vue

@@ -46,6 +46,7 @@ import { computed } from "vue";
46
 import { useRoute, useRouter } from "vue-router";
46
 import { useRoute, useRouter } from "vue-router";
47
 import { logout } from "@/api/login";
47
 import { logout } from "@/api/login";
48
 import { removeToken } from "@/utils/auth";
48
 import { removeToken } from "@/utils/auth";
49
+import { resetScreenAutoLoginState } from "@/utils/screenAutoLogin";
49
 
50
 
50
 const router = useRouter();
51
 const router = useRouter();
51
 const route = useRoute();
52
 const route = useRoute();
@@ -101,6 +102,7 @@ function goNav(item) {
101
 function onLogout() {
102
 function onLogout() {
102
   logout().catch(() => {});
103
   logout().catch(() => {});
103
   removeToken();
104
   removeToken();
105
+  resetScreenAutoLoginState();
104
   router.replace({ path: "/login" });
106
   router.replace({ path: "/login" });
105
 }
107
 }
106
 </script>
108
 </script>

+ 25 - 4
ruoyi-screen/src/router/index.js

@@ -1,6 +1,7 @@
1
 import { createRouter, createWebHistory } from "vue-router"
1
 import { createRouter, createWebHistory } from "vue-router"
2
 import ScreenLayout from "../layout/ScreenLayout.vue"
2
 import ScreenLayout from "../layout/ScreenLayout.vue"
3
 import { getToken } from "../utils/auth"
3
 import { getToken } from "../utils/auth"
4
+import { ensureScreenAutoLogin, isScreenAutoLoginEnabled } from "../utils/screenAutoLogin"
4
 
5
 
5
 /**
6
 /**
6
  * 大屏路由表(路径与后端 /bigScreen/* 业务对应,便于联调)
7
  * 大屏路由表(路径与后端 /bigScreen/* 业务对应,便于联调)
@@ -87,15 +88,35 @@ const router = createRouter({
87
   routes
88
   routes
88
 })
89
 })
89
 
90
 
90
-/** 未登录跳转登录页;已登录访问 /login 则进首页 */
91
-router.beforeEach((to, _from, next) => {
91
+/** 未登录跳转登录页;已登录访问 /login 则进首页;启用免密时先尝试自动登录 */
92
+router.beforeEach(async (to, _from, next) => {
92
   const token = getToken()
93
   const token = getToken()
93
   if (to.path === "/login") {
94
   if (to.path === "/login") {
94
-    next(token ? { path: "/home" } : undefined)
95
+    if (token) {
96
+      next({ path: "/home" })
97
+      return
98
+    }
99
+    if (isScreenAutoLoginEnabled()) {
100
+      const ok = await ensureScreenAutoLogin()
101
+      if (ok) {
102
+        const redirect =
103
+          typeof to.query.redirect === "string" ? to.query.redirect : "/home"
104
+        next({ path: redirect })
105
+        return
106
+      }
107
+    }
108
+    next()
95
     return
109
     return
96
   }
110
   }
97
   const needAuth = to.matched.some((r) => r.meta.requiresAuth)
111
   const needAuth = to.matched.some((r) => r.meta.requiresAuth)
98
-  if (needAuth && !token) {
112
+  if (needAuth && !getToken()) {
113
+    if (isScreenAutoLoginEnabled()) {
114
+      const ok = await ensureScreenAutoLogin()
115
+      if (ok) {
116
+        next()
117
+        return
118
+      }
119
+    }
99
     next({ path: "/login", query: { redirect: to.fullPath } })
120
     next({ path: "/login", query: { redirect: to.fullPath } })
100
     return
121
     return
101
   }
122
   }

+ 2 - 0
ruoyi-screen/src/utils/request.js

@@ -1,6 +1,7 @@
1
 import axios from 'axios'
1
 import axios from 'axios'
2
 import router from '../router'
2
 import router from '../router'
3
 import { getToken, removeToken } from './auth'
3
 import { getToken, removeToken } from './auth'
4
+import { resetScreenAutoLoginState } from './screenAutoLogin'
4
 import { tansParams } from './ruoyi'
5
 import { tansParams } from './ruoyi'
5
 
6
 
6
 /** 避免多个接口同时 401 重复跳转登录 */
7
 /** 避免多个接口同时 401 重复跳转登录 */
@@ -11,6 +12,7 @@ function redirectToLogin() {
11
   if (router.currentRoute.value.path === '/login') return
12
   if (router.currentRoute.value.path === '/login') return
12
   isRedirectingLogin = true
13
   isRedirectingLogin = true
13
   removeToken()
14
   removeToken()
15
+  resetScreenAutoLoginState()
14
   const redirect = router.currentRoute.value.fullPath
16
   const redirect = router.currentRoute.value.fullPath
15
   router
17
   router
16
     .replace({
18
     .replace({

+ 78 - 0
ruoyi-screen/src/utils/screenAutoLogin.js

@@ -0,0 +1,78 @@
1
+import JSEncrypt from 'jsencrypt/bin/jsencrypt.min'
2
+import { getScreenPublicKey, screenAutoLogin } from '@/api/login'
3
+import { getToken, setToken } from '@/utils/auth'
4
+
5
+/** 同一会话内仅尝试一次免密登录,避免反复请求 */
6
+let autoLoginState = null
7
+let autoLoginPromise = null
8
+
9
+export function isScreenAutoLoginEnabled() {
10
+  return import.meta.env.VITE_SCREEN_AUTO_LOGIN === 'true'
11
+}
12
+
13
+export function resetScreenAutoLoginState() {
14
+  autoLoginState = null
15
+  autoLoginPromise = null
16
+}
17
+
18
+/**
19
+ * RSA 加密 UUID 换取 JWT(见 doc/大屏/免密登录/大屏免密登录技术方案.md)
20
+ * @returns {Promise<string>} JWT
21
+ */
22
+export async function tryScreenAutoLogin() {
23
+  const keyRes = await getScreenPublicKey()
24
+  const publicKey = keyRes.data?.publicKey
25
+  if (!publicKey) {
26
+    throw new Error('未获取到 RSA 公钥')
27
+  }
28
+
29
+  const uuid = crypto.randomUUID()
30
+  const encryptor = new JSEncrypt()
31
+  encryptor.setPublicKey(publicKey)
32
+  const cipher = encryptor.encrypt(uuid)
33
+  if (!cipher) {
34
+    throw new Error('RSA 加密失败')
35
+  }
36
+
37
+  const loginRes = await screenAutoLogin(cipher)
38
+  if (!loginRes.token) {
39
+    throw new Error('免密登录未返回 token')
40
+  }
41
+  setToken(loginRes.token)
42
+  return loginRes.token
43
+}
44
+
45
+/**
46
+ * 路由守卫用:已登录直接通过;未启用免密则 false;否则尝试一次免密登录
47
+ * @returns {Promise<boolean>}
48
+ */
49
+export async function ensureScreenAutoLogin() {
50
+  if (getToken()) {
51
+    return true
52
+  }
53
+  if (!isScreenAutoLoginEnabled()) {
54
+    return false
55
+  }
56
+  if (autoLoginState === 'fail') {
57
+    return false
58
+  }
59
+  if (autoLoginState === 'ok') {
60
+    return !!getToken()
61
+  }
62
+  if (autoLoginState === 'pending' && autoLoginPromise) {
63
+    return autoLoginPromise
64
+  }
65
+
66
+  autoLoginState = 'pending'
67
+  autoLoginPromise = tryScreenAutoLogin()
68
+    .then(() => {
69
+      autoLoginState = 'ok'
70
+      return true
71
+    })
72
+    .catch(() => {
73
+      autoLoginState = 'fail'
74
+      return false
75
+    })
76
+
77
+  return autoLoginPromise
78
+}

+ 2 - 2
ruoyi-ui/src/layout/components/Sidebar/Logo.vue

@@ -6,7 +6,7 @@
6
         <h1 v-else class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' && navType !== 3 ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1>
6
         <h1 v-else class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' && navType !== 3 ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1>
7
       </router-link>
7
       </router-link>
8
       <router-link v-else key="expand" class="sidebar-logo-link" to="/">
8
       <router-link v-else key="expand" class="sidebar-logo-link" to="/">
9
-        <img v-if="logo" :src="logo" class="sidebar-logo" />
9
+        <!-- <img v-if="logo" :src="logo" class="sidebar-logo" /> -->
10
         <h1 class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' && navType !== 3 ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1>
10
         <h1 class="sidebar-title" :style="{ color: sideTheme === 'theme-dark' && navType !== 3 ? variables.logoTitleColor : variables.logoLightTitleColor }">{{ title }} </h1>
11
       </router-link>
11
       </router-link>
12
     </transition>
12
     </transition>
@@ -80,7 +80,7 @@ export default {
80
       color: #fff;
80
       color: #fff;
81
       font-weight: 600;
81
       font-weight: 600;
82
       line-height: 50px;
82
       line-height: 50px;
83
-      font-size: 14px;
83
+      font-size: 11px;
84
       font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
84
       font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
85
       vertical-align: middle;
85
       vertical-align: middle;
86
     }
86
     }

+ 58 - 11
ruoyi-ui/src/views/diseaseTreatment/medicalResource/index.vue

@@ -207,7 +207,7 @@
207
               :placeholder="dtT('formServiceStart')"
207
               :placeholder="dtT('formServiceStart')"
208
               :picker-options="timePickerOptions"
208
               :picker-options="timePickerOptions"
209
               style="width: 160px"
209
               style="width: 160px"
210
-              @change="validateFormField('serviceStartTime')"
210
+              @change="handleServiceStartTimeChange"
211
             />
211
             />
212
             <span class="time-sep">{{ dtCommon("timeSeparator") }}</span>
212
             <span class="time-sep">{{ dtCommon("timeSeparator") }}</span>
213
             <el-time-select
213
             <el-time-select
@@ -215,7 +215,7 @@
215
               :placeholder="dtT('formServiceEnd')"
215
               :placeholder="dtT('formServiceEnd')"
216
               :picker-options="timePickerOptionsEnd"
216
               :picker-options="timePickerOptionsEnd"
217
               style="width: 160px"
217
               style="width: 160px"
218
-              @change="validateFormField('serviceStartTime')"
218
+              @change="handleServiceEndTimeChange"
219
             />
219
             />
220
           </el-form-item>
220
           </el-form-item>
221
           <el-form-item prop="serviceWeekdaysList" :rules="formRules.serviceWeekdaysList">
221
           <el-form-item prop="serviceWeekdaysList" :rules="formRules.serviceWeekdaysList">
@@ -282,7 +282,7 @@
282
               :placeholder="dtT('formServiceStart')"
282
               :placeholder="dtT('formServiceStart')"
283
               :picker-options="timePickerOptions"
283
               :picker-options="timePickerOptions"
284
               style="width: 160px"
284
               style="width: 160px"
285
-              @change="validateFormField('serviceStartTime')"
285
+              @change="handleServiceStartTimeChange"
286
             />
286
             />
287
             <span class="time-sep">{{ dtCommon("timeSeparator") }}</span>
287
             <span class="time-sep">{{ dtCommon("timeSeparator") }}</span>
288
             <el-time-select
288
             <el-time-select
@@ -290,7 +290,7 @@
290
               :placeholder="dtT('formServiceEnd')"
290
               :placeholder="dtT('formServiceEnd')"
291
               :picker-options="timePickerOptionsEnd"
291
               :picker-options="timePickerOptionsEnd"
292
               style="width: 160px"
292
               style="width: 160px"
293
-              @change="validateFormField('serviceStartTime')"
293
+              @change="handleServiceEndTimeChange"
294
             />
294
             />
295
           </el-form-item>
295
           </el-form-item>
296
           <el-form-item prop="serviceWeekdaysList" :rules="formRules.serviceWeekdaysList">
296
           <el-form-item prop="serviceWeekdaysList" :rules="formRules.serviceWeekdaysList">
@@ -526,12 +526,11 @@ export default {
526
       return !!(this.form.photoFilePath || this.form.photoFileUrl)
526
       return !!(this.form.photoFilePath || this.form.photoFileUrl)
527
     },
527
     },
528
     timePickerOptionsEnd() {
528
     timePickerOptionsEnd() {
529
-      const start = this.form.serviceStartTime
530
       return {
529
       return {
531
-        start: start || "00:00",
530
+        start: "00:00",
532
         step: "00:15",
531
         step: "00:15",
533
         end: "23:45",
532
         end: "23:45",
534
-        minTime: start
533
+        minTime: this.form.serviceStartTime || "00:00"
535
       }
534
       }
536
     },
535
     },
537
     formRules() {
536
     formRules() {
@@ -831,8 +830,47 @@ export default {
831
     allConsultModeValues() {
830
     allConsultModeValues() {
832
       return this.consultModeOptions.map((item) => item.value)
831
       return this.consultModeOptions.map((item) => item.value)
833
     },
832
     },
833
+    hasServiceScheduleType(resourceType) {
834
+      const t = this.normalizeResourceType(resourceType)
835
+      return this.isVetType(t) || this.isOrgType(t)
836
+    },
837
+    defaultServiceStartTime() {
838
+      return "08:00"
839
+    },
840
+    defaultServiceEndTime() {
841
+      return "18:00"
842
+    },
843
+    defaultServiceWeekdaysList() {
844
+      return [1, 2, 3, 4, 5]
845
+    },
846
+    applyDefaultServiceSchedule(resourceType) {
847
+      if (!this.hasServiceScheduleType(resourceType)) {
848
+        return
849
+      }
850
+      this.form.serviceStartTime = this.defaultServiceStartTime()
851
+      this.form.serviceWeekdaysList = this.defaultServiceWeekdaysList()
852
+      this.$nextTick(() => {
853
+        this.form.serviceEndTime = this.defaultServiceEndTime()
854
+      })
855
+    },
856
+    handleServiceStartTimeChange() {
857
+      const start = this.form.serviceStartTime
858
+      const end = this.form.serviceEndTime
859
+      if (start && (!end || end <= start)) {
860
+        this.$nextTick(() => {
861
+          this.form.serviceEndTime = this.defaultServiceEndTime()
862
+          this.validateFormField("serviceStartTime")
863
+        })
864
+        return
865
+      }
866
+      this.validateFormField("serviceStartTime")
867
+    },
868
+    handleServiceEndTimeChange() {
869
+      this.validateFormField("serviceStartTime")
870
+    },
834
     resetFormModel() {
871
     resetFormModel() {
835
       const resourceType = TAB_TYPE_MAP[this.activeTab]
872
       const resourceType = TAB_TYPE_MAP[this.activeTab]
873
+      const hasServiceSchedule = this.hasServiceScheduleType(resourceType)
836
       this.form = {
874
       this.form = {
837
         id: undefined,
875
         id: undefined,
838
         resourceType,
876
         resourceType,
@@ -846,9 +884,9 @@ export default {
846
         consultModesList: this.isVetType(resourceType) ? this.allConsultModeValues() : [],
884
         consultModesList: this.isVetType(resourceType) ? this.allConsultModeValues() : [],
847
         serviceArea: undefined,
885
         serviceArea: undefined,
848
         feeStandard: undefined,
886
         feeStandard: undefined,
849
-        serviceStartTime: undefined,
850
-        serviceEndTime: undefined,
851
-        serviceWeekdaysList: [],
887
+        serviceStartTime: hasServiceSchedule ? this.defaultServiceStartTime() : undefined,
888
+        serviceEndTime: hasServiceSchedule ? this.defaultServiceEndTime() : undefined,
889
+        serviceWeekdaysList: hasServiceSchedule ? this.defaultServiceWeekdaysList() : [],
852
         maxDailyAppointments: this.isOrgType(resourceType) ? 30 : undefined,
890
         maxDailyAppointments: this.isOrgType(resourceType) ? 30 : undefined,
853
         establishDate: undefined,
891
         establishDate: undefined,
854
         teamSize: undefined,
892
         teamSize: undefined,
@@ -863,7 +901,14 @@ export default {
863
       this.resetFormModel()
901
       this.resetFormModel()
864
       this.dialogEdit = false
902
       this.dialogEdit = false
865
       this.open = true
903
       this.open = true
866
-      this.$nextTick(() => this.resetForm("form"))
904
+      this.$nextTick(() => {
905
+        if (this.hasServiceScheduleType(this.form.resourceType)) {
906
+          this.form.serviceEndTime = this.defaultServiceEndTime()
907
+        }
908
+        if (this.$refs.form) {
909
+          this.$refs.form.clearValidate()
910
+        }
911
+      })
867
     },
912
     },
868
     fillFormFromApi(data) {
913
     fillFormFromApi(data) {
869
       const row = this.normalizeMedicalResourceRow(data)
914
       const row = this.normalizeMedicalResourceRow(data)
@@ -988,6 +1033,8 @@ export default {
988
         this.form.serviceStartTime = undefined
1033
         this.form.serviceStartTime = undefined
989
         this.form.serviceEndTime = undefined
1034
         this.form.serviceEndTime = undefined
990
         this.form.serviceWeekdaysList = []
1035
         this.form.serviceWeekdaysList = []
1036
+      } else {
1037
+        this.applyDefaultServiceSchedule(t)
991
       }
1038
       }
992
       if (!this.isVetType(t) && !this.isTeamType(t) && !this.isEquipmentType(t)) {
1039
       if (!this.isVetType(t) && !this.isTeamType(t) && !this.isEquipmentType(t)) {
993
         this.form.affiliatedUnit = undefined
1040
         this.form.affiliatedUnit = undefined

+ 67 - 11
ruoyi-ui/src/views/techService/techResource/index.vue

@@ -271,7 +271,7 @@
271
               :placeholder="tsT('formServiceStart')"
271
               :placeholder="tsT('formServiceStart')"
272
               :picker-options="timePickerOptions"
272
               :picker-options="timePickerOptions"
273
               style="width: 160px"
273
               style="width: 160px"
274
-              @change="validateFormField('serviceStartTime')"
274
+              @change="handleServiceStartTimeChange"
275
             />
275
             />
276
             <span class="time-sep">{{ tsCommon("timeSeparator") }}</span>
276
             <span class="time-sep">{{ tsCommon("timeSeparator") }}</span>
277
             <el-time-select
277
             <el-time-select
@@ -279,7 +279,7 @@
279
               :placeholder="tsT('formServiceEnd')"
279
               :placeholder="tsT('formServiceEnd')"
280
               :picker-options="timePickerOptionsEnd"
280
               :picker-options="timePickerOptionsEnd"
281
               style="width: 160px"
281
               style="width: 160px"
282
-              @change="validateFormField('serviceStartTime')"
282
+              @change="handleServiceEndTimeChange"
283
             />
283
             />
284
           </el-form-item>
284
           </el-form-item>
285
           <el-form-item prop="serviceWeekdaysList" :rules="formRules.serviceWeekdaysList">
285
           <el-form-item prop="serviceWeekdaysList" :rules="formRules.serviceWeekdaysList">
@@ -329,6 +329,7 @@
329
               :placeholder="tsT('formServiceStart')"
329
               :placeholder="tsT('formServiceStart')"
330
               :picker-options="timePickerOptions"
330
               :picker-options="timePickerOptions"
331
               style="width: 160px"
331
               style="width: 160px"
332
+              @change="handleServiceStartTimeChange"
332
             />
333
             />
333
             <span class="time-sep">{{ tsCommon("timeSeparator") }}</span>
334
             <span class="time-sep">{{ tsCommon("timeSeparator") }}</span>
334
             <el-time-select
335
             <el-time-select
@@ -336,11 +337,12 @@
336
               :placeholder="tsT('formServiceEnd')"
337
               :placeholder="tsT('formServiceEnd')"
337
               :picker-options="timePickerOptionsEnd"
338
               :picker-options="timePickerOptionsEnd"
338
               style="width: 160px"
339
               style="width: 160px"
340
+              @change="handleServiceEndTimeChange"
339
             />
341
             />
340
           </el-form-item>
342
           </el-form-item>
341
           <el-form-item prop="serviceWeekdaysList" :rules="formRules.serviceWeekdaysList">
343
           <el-form-item prop="serviceWeekdaysList" :rules="formRules.serviceWeekdaysList">
342
             <template slot="label">{{ tsT("formServiceWeekdays") }}</template>
344
             <template slot="label">{{ tsT("formServiceWeekdays") }}</template>
343
-            <el-checkbox-group v-model="form.serviceWeekdaysList">
345
+            <el-checkbox-group v-model="form.serviceWeekdaysList" @change="validateFormField('serviceWeekdaysList')">
344
               <el-checkbox v-for="item in weekdayOptions" :key="item.value" :label="item.value">{{ item.label }}</el-checkbox>
346
               <el-checkbox v-for="item in weekdayOptions" :key="item.value" :label="item.value">{{ item.label }}</el-checkbox>
345
             </el-checkbox-group>
347
             </el-checkbox-group>
346
           </el-form-item>
348
           </el-form-item>
@@ -598,12 +600,11 @@ export default {
598
       return !!(this.form.coverFilePath || this.form.coverFileUrl)
600
       return !!(this.form.coverFilePath || this.form.coverFileUrl)
599
     },
601
     },
600
     timePickerOptionsEnd() {
602
     timePickerOptionsEnd() {
601
-      const start = this.form.serviceStartTime
602
       return {
603
       return {
603
-        start: start || "00:00",
604
+        start: "00:00",
604
         step: "00:15",
605
         step: "00:15",
605
         end: "23:45",
606
         end: "23:45",
606
-        minTime: start
607
+        minTime: this.form.serviceStartTime || "00:00"
607
       }
608
       }
608
     },
609
     },
609
     formRules() {
610
     formRules() {
@@ -871,9 +872,11 @@ export default {
871
       this.handleQuery()
872
       this.handleQuery()
872
     },
873
     },
873
     resetFormModel() {
874
     resetFormModel() {
875
+      const resourceType = TAB_TYPE_MAP[this.activeTab]
876
+      const hasServiceSchedule = this.hasServiceScheduleType(resourceType)
874
       this.form = {
877
       this.form = {
875
         id: undefined,
878
         id: undefined,
876
-        resourceType: TAB_TYPE_MAP[this.activeTab],
879
+        resourceType,
877
         resourceName: undefined,
880
         resourceName: undefined,
878
         photoFileUrl: undefined,
881
         photoFileUrl: undefined,
879
         photoFilePath: undefined,
882
         photoFilePath: undefined,
@@ -893,9 +896,9 @@ export default {
893
         serviceArea: undefined,
896
         serviceArea: undefined,
894
         feeStandard: undefined,
897
         feeStandard: undefined,
895
         borrowFee: undefined,
898
         borrowFee: undefined,
896
-        serviceStartTime: undefined,
897
-        serviceEndTime: undefined,
898
-        serviceWeekdaysList: [],
899
+        serviceStartTime: hasServiceSchedule ? this.defaultServiceStartTime() : undefined,
900
+        serviceEndTime: hasServiceSchedule ? this.defaultServiceEndTime() : undefined,
901
+        serviceWeekdaysList: hasServiceSchedule ? this.defaultServiceWeekdaysList() : [],
899
         instrumentModel: undefined,
902
         instrumentModel: undefined,
900
         storageLocation: undefined,
903
         storageLocation: undefined,
901
         reservationProcess: undefined,
904
         reservationProcess: undefined,
@@ -909,7 +912,14 @@ export default {
909
       this.resetFormModel()
912
       this.resetFormModel()
910
       this.dialogEdit = false
913
       this.dialogEdit = false
911
       this.open = true
914
       this.open = true
912
-      this.$nextTick(() => this.resetForm("form"))
915
+      this.$nextTick(() => {
916
+        if (this.hasServiceScheduleType(this.form.resourceType)) {
917
+          this.form.serviceEndTime = this.defaultServiceEndTime()
918
+        }
919
+        if (this.$refs.form) {
920
+          this.$refs.form.clearValidate()
921
+        }
922
+      })
913
     },
923
     },
914
     fillFormFromApi(data) {
924
     fillFormFromApi(data) {
915
       this.form = {
925
       this.form = {
@@ -1022,12 +1032,58 @@ export default {
1022
         .catch(() => {})
1032
         .catch(() => {})
1023
     },
1033
     },
1024
     handleFormTypeChange() {
1034
     handleFormTypeChange() {
1035
+      const t = this.normalizeResourceType(this.form.resourceType)
1036
+      if (this.hasServiceScheduleType(t)) {
1037
+        this.applyDefaultServiceSchedule(t)
1038
+      } else {
1039
+        this.form.serviceStartTime = undefined
1040
+        this.form.serviceEndTime = undefined
1041
+        this.form.serviceWeekdaysList = []
1042
+      }
1025
       this.$nextTick(() => {
1043
       this.$nextTick(() => {
1026
         if (this.$refs.form) {
1044
         if (this.$refs.form) {
1027
           this.$refs.form.clearValidate()
1045
           this.$refs.form.clearValidate()
1028
         }
1046
         }
1029
       })
1047
       })
1030
     },
1048
     },
1049
+    hasServiceScheduleType(resourceType) {
1050
+      const t = this.normalizeResourceType(resourceType)
1051
+      return this.isExpertType(t) || this.isInstrumentType(t)
1052
+    },
1053
+    defaultServiceStartTime() {
1054
+      return "08:00"
1055
+    },
1056
+    defaultServiceEndTime() {
1057
+      return "18:00"
1058
+    },
1059
+    defaultServiceWeekdaysList() {
1060
+      return [1, 2, 3, 4, 5]
1061
+    },
1062
+    applyDefaultServiceSchedule(resourceType) {
1063
+      if (!this.hasServiceScheduleType(resourceType)) {
1064
+        return
1065
+      }
1066
+      this.form.serviceStartTime = this.defaultServiceStartTime()
1067
+      this.form.serviceWeekdaysList = this.defaultServiceWeekdaysList()
1068
+      this.$nextTick(() => {
1069
+        this.form.serviceEndTime = this.defaultServiceEndTime()
1070
+      })
1071
+    },
1072
+    handleServiceStartTimeChange() {
1073
+      const start = this.form.serviceStartTime
1074
+      const end = this.form.serviceEndTime
1075
+      if (start && (!end || end <= start)) {
1076
+        this.$nextTick(() => {
1077
+          this.form.serviceEndTime = this.defaultServiceEndTime()
1078
+          this.validateFormField("serviceStartTime")
1079
+        })
1080
+        return
1081
+      }
1082
+      this.validateFormField("serviceStartTime")
1083
+    },
1084
+    handleServiceEndTimeChange() {
1085
+      this.validateFormField("serviceStartTime")
1086
+    },
1031
     extOf(fileName) {
1087
     extOf(fileName) {
1032
       if (!fileName || fileName.lastIndexOf(".") < 0) {
1088
       if (!fileName || fileName.lastIndexOf(".") < 0) {
1033
         return ""
1089
         return ""