allocate.vue 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930
  1. <template>
  2. <div class="allocate-popup-container">
  3. <van-popup
  4. class="allocate-popup"
  5. v-model:show="state.dialog.isShowDialog"
  6. position="right"
  7. :style="{ width: '100%' }"
  8. closeable
  9. :close-on-popstate="false"
  10. @close="onCancel"
  11. >
  12. <header class="popup-header">
  13. <div class="popup-title">资源分配</div>
  14. <div class="popup-controls">
  15. <div
  16. class="group-selector"
  17. @click="groupPickerVisible = true"
  18. >
  19. <span class="label">资源组</span>
  20. <van-field
  21. :model-value="groupLabel"
  22. readonly
  23. is-link
  24. input-align="right"
  25. placeholder="全部资源组"
  26. border
  27. />
  28. </div>
  29. <van-popup
  30. v-model:show="groupPickerVisible"
  31. round
  32. position="bottom"
  33. >
  34. <van-picker
  35. title="选择资源组"
  36. show-toolbar
  37. :columns="groupOptions"
  38. @cancel="groupPickerVisible = false"
  39. @confirm="onGroupConfirm"
  40. />
  41. </van-popup>
  42. <van-radio-group
  43. v-model="resClass"
  44. direction="horizontal"
  45. class="type-radio-group"
  46. @change="filterOutList"
  47. >
  48. <van-radio name="">全部</van-radio>
  49. <van-radio
  50. v-for="item in cellType"
  51. :key="item.dictValue"
  52. :name="item.dictValue"
  53. >
  54. {{ item.dictLabel }}
  55. </van-radio>
  56. </van-radio-group>
  57. </div>
  58. </header>
  59. <div class="popup-body">
  60. <section class="card confirm-card">
  61. <div class="info-banner warning">待分配队列(已确认{{ comfirmTotal }}人)</div>
  62. <van-empty
  63. v-if="!state.confirmList.length"
  64. image="default"
  65. description="暂无队列数据"
  66. />
  67. <ul v-else>
  68. <li
  69. v-for="(item, index) in state.confirmList"
  70. :key="index"
  71. :class="{ active: index === state.active }"
  72. @click="state.active = index"
  73. >
  74. <div class="text">
  75. <p>
  76. 分配房间:
  77. <span class="assign-room">{{ item.resName || '未分配' }}</span>
  78. </p>
  79. <p>
  80. {{ item.memberName }}
  81. 【{{ formatDate(new Date(item.appointStartDate), 'YYYY-mm-dd') }}~{{
  82. formatDate(new Date(item.appointEndDate), 'mm-dd')
  83. }}】
  84. </p>
  85. <p>
  86. 【{{ getDictLabel(cellType, item.cellSourceType) }}】{{
  87. formatDate(new Date(item.queueTime), 'YYYY-mm-dd HH:MM:SS')
  88. }}
  89. </p>
  90. </div>
  91. <div class="btns">
  92. <button
  93. v-if="item.resId"
  94. class="link-btn danger"
  95. type="button"
  96. @click.stop="cancelAllocate(item)"
  97. >
  98. 取消
  99. </button>
  100. <button
  101. class="link-btn"
  102. type="button"
  103. @click.stop="onDetail(item)"
  104. >
  105. 查看
  106. </button>
  107. </div>
  108. </li>
  109. </ul>
  110. </section>
  111. <section class="card resource-card">
  112. <div class="info-banner warning">资源列表(共{{ state.resourceList.length }}个)</div>
  113. <van-empty
  114. v-if="!state.resourceList.length"
  115. image="search"
  116. description="暂无资源数据"
  117. />
  118. <div
  119. v-else
  120. class="resource-grid"
  121. >
  122. <div
  123. v-for="item in state.resourceList"
  124. :key="item.id"
  125. class="resource-item"
  126. :class="{ active: curSelectedResourceId === item.id }"
  127. @click="onSelectResource(item)"
  128. >
  129. <div class="resource-header">
  130. <span class="resource-name">{{ item.resName }}</span>
  131. <van-tag
  132. type="primary"
  133. plain
  134. >
  135. {{ getDictLabel(cellType, item.resClass) }}
  136. </van-tag>
  137. </div>
  138. <p class="resource-location">{{ item.resLocation }}</p>
  139. <div class="resource-footer">
  140. <span class="resource-usage">{{ item.usingSource || 0 }}/{{ item.maxNum }}</span>
  141. <ul class="usage-dots">
  142. <li
  143. v-for="(p, index) in item.usedList"
  144. :key="index"
  145. :class="getUsedClass(p)"
  146. ></li>
  147. </ul>
  148. </div>
  149. </div>
  150. </div>
  151. </section>
  152. <section class="card usage-card">
  153. <div class="info-banner warning">房间使用情况</div>
  154. <div class="usage-list">
  155. <ul class="position-choose">
  156. <li
  157. v-for="(p, index) in allocationSituationList"
  158. :key="index"
  159. >
  160. <i :class="getUsedClass(p)"></i>
  161. <div class="txt-flex">
  162. <span
  163. v-if="p.userObj"
  164. :title="p.userObj.memberName"
  165. >
  166. {{ p.userObj.memberName }}
  167. </span>
  168. <span
  169. v-else-if="p.userName"
  170. :title="p.userName"
  171. >
  172. {{ p.userName }}
  173. </span>
  174. <span v-else>空闲</span>
  175. </div>
  176. <van-button
  177. v-if="!p.userObj && (p.assignStatus == '10' || p.assignStatus == '40')"
  178. type="primary"
  179. size="mini"
  180. plain
  181. @click.stop="onAllocate(p)"
  182. >
  183. 分配
  184. </van-button>
  185. </li>
  186. </ul>
  187. </div>
  188. </section>
  189. </div>
  190. <div class="popup-footer">
  191. <van-button
  192. type="primary"
  193. plain
  194. block
  195. @click="onCancel"
  196. >
  197. 取 消
  198. </van-button>
  199. <van-button
  200. type="primary"
  201. block
  202. @click="onSubmit('20')"
  203. >
  204. 确 定
  205. </van-button>
  206. </div>
  207. </van-popup>
  208. <DetailsDialog ref="detailsDialog" />
  209. </div>
  210. </template>
  211. <script setup lang="ts" name="entryAllocate">
  212. import to from 'await-to-js'
  213. import { reactive, ref, defineAsyncComponent, computed } from 'vue'
  214. import { showToast, showConfirmDialog } from 'vant'
  215. import { getDictLabel } from '/@/utils/other'
  216. import { usePlatformApi } from '/@/api/platform/home'
  217. import { useSystemApi } from '/@/api/platform/system'
  218. import { useUserInfo } from '/@/stores/userInfo'
  219. import { storeToRefs } from 'pinia'
  220. import { useDictApi } from '/@/api/base/system/dict'
  221. import { useCellAssignApi } from '/@/api/platform/home/assign'
  222. import { formatDate } from '/@/utils/formatTime'
  223. const DetailsDialog = defineAsyncComponent(() => import('/@/view/entry/components/details.vue'))
  224. const stores = useUserInfo()
  225. const { userInfos } = storeToRefs(stores)
  226. // 定义子组件向父组件传值/事件
  227. const emit = defineEmits(['refresh'])
  228. // 定义变量内容
  229. const systemApi = useSystemApi()
  230. const platformApi = usePlatformApi()
  231. const dictApi = useDictApi()
  232. const userTypeList = ref<RowDicDataType[]>([])
  233. const projectGroupList = ref<any[]>([])
  234. const cellType = ref<RowDicDataType[]>([])
  235. const originalResourceList = ref<any[]>([])
  236. const resourceList = ref<any[]>([])
  237. const molecularGroupList = ref<any[]>([])
  238. const cellAssignApi = useCellAssignApi()
  239. const state = reactive({
  240. form: {
  241. id: 0,
  242. memberId: 0,
  243. memberName: '',
  244. memberPhone: '',
  245. memberSex: '',
  246. memberNo: '',
  247. memberIden: '',
  248. startDate: '',
  249. endDate: '',
  250. memberType: '',
  251. deptId: 0,
  252. deptName: '',
  253. workPlace: '',
  254. mentorId: 0,
  255. mentorName: '',
  256. mentorPhone: '',
  257. mentorDeptId: 0,
  258. mentorDeptName: '',
  259. platformId: 0,
  260. platformName: '',
  261. platformType: '',
  262. platformTime: 0,
  263. other: '',
  264. isTemporary: '',
  265. },
  266. confirmList: [] as any[],
  267. resourceList: [] as any[],
  268. active: -1,
  269. dialog: {
  270. isShowDialog: false,
  271. type: '',
  272. title: '',
  273. submitTxt: '',
  274. },
  275. })
  276. const comfirmTotal = computed(() => {
  277. return state.confirmList.reduce((pre: number, cur: any) => {
  278. if (cur.resId) return pre + 1
  279. return pre
  280. }, 0)
  281. })
  282. const originalConfirmList = ref<any[]>([])
  283. const confirmList = ref<any[]>([])
  284. const belongOrgOption = ref<any[]>([])
  285. const userList = ref<any[]>([])
  286. const platformList = ref()
  287. const resClass = ref('')
  288. const groupId = ref('') // 资源组ID
  289. const groupPickerVisible = ref(false)
  290. const detailsDialog = ref()
  291. const curSelectedResourceId = ref(0) // 当前选中的资源ID
  292. const allocationSituationList = ref<any[]>([]) // 资源分配情况列表
  293. const groupOptions = computed(() => {
  294. const options = molecularGroupList.value.map((item: any) => ({
  295. text: item.groupName,
  296. value: String(item.id),
  297. }))
  298. return [{ text: '全部资源组', value: '' }, ...options]
  299. })
  300. const groupLabel = computed(() => {
  301. const option = groupOptions.value.find((item) => item.value === groupId.value)
  302. return option ? option.text : '全部资源组'
  303. })
  304. const onGroupConfirm = (value: any, detail?: any) => {
  305. let option: any = null
  306. if (value && typeof value === 'object' && 'selectedOptions' in value) {
  307. option = value.selectedOptions?.[0]
  308. } else if (detail && typeof detail === 'object' && 'selectedOptions' in detail) {
  309. option = detail.selectedOptions?.[0]
  310. } else if (Array.isArray(value)) {
  311. const target = value[0]
  312. option = groupOptions.value.find((item) => item.value === target)
  313. } else if (value && typeof value === 'object') {
  314. option = value
  315. }
  316. groupId.value = option?.value ?? ''
  317. groupPickerVisible.value = false
  318. filterOutListByGroupId(groupId.value)
  319. }
  320. const getDicts = () => {
  321. Promise.all([
  322. systemApi.getDeptTree(),
  323. systemApi.getUserList({ noPage: true }),
  324. platformApi.getAllPlatformList({ noPage: true }),
  325. dictApi.getDictDataByType('sys_user_type'),
  326. systemApi.getProjectGroupListForApp({ noPage: true }),
  327. userInfos.value.platformId != 0 ? platformApi.getResourceTypeDict({ id: userInfos.value.platformId }) : '',
  328. ]).then(([dept, user, plat, type, pjt, cell]) => {
  329. belongOrgOption.value = dept?.data || []
  330. userList.value = user?.data?.list || []
  331. platformList.value = plat?.data?.list || []
  332. userTypeList.value = type?.data?.values || []
  333. projectGroupList.value = pjt?.data?.list || []
  334. cellType.value = cell?.data || []
  335. })
  336. }
  337. const getMolecularGroupList = async () => {
  338. const [err, res]: ToResponse = await to(platformApi.getMolecularGroupList({ id: userInfos.value.platformId }))
  339. if (err) return
  340. molecularGroupList.value = res?.data || []
  341. }
  342. const getResourceList = async () => {
  343. const [err, res]: ToResponse = await to(
  344. platformApi.getResourceList({ platformId: userInfos.value.platformId, resStatus: '10' }),
  345. )
  346. if (err) return
  347. originalResourceList.value = JSON.parse(JSON.stringify(res?.data?.list || []))
  348. resourceList.value = JSON.parse(JSON.stringify(originalResourceList.value))
  349. state.resourceList = JSON.parse(JSON.stringify(resourceList.value))
  350. }
  351. // 待分配列表
  352. const getConfirmList = async () => {
  353. const [err, res]: ToResponse = await to(cellAssignApi.assignQueue({ platformId: userInfos.value.platformId }))
  354. if (err) return
  355. originalConfirmList.value = JSON.parse(JSON.stringify(res?.data || []))
  356. confirmList.value = JSON.parse(JSON.stringify(originalConfirmList.value))
  357. state.confirmList = JSON.parse(JSON.stringify(confirmList.value))
  358. }
  359. // 打开弹窗
  360. const openDialog = async (type: string, row: number) => {
  361. getDicts()
  362. getResourceList()
  363. getConfirmList()
  364. getMolecularGroupList()
  365. state.dialog.type = type
  366. state.dialog.title = '入室资源分配'
  367. state.dialog.isShowDialog = true
  368. }
  369. // 前端筛选类型
  370. const filterOutList = (val: string) => {
  371. if (val) {
  372. state.resourceList = resourceList.value.filter((item: any) => item.resClass == val)
  373. state.confirmList = confirmList.value.filter((item: any) => item.cellSourceType == val)
  374. } else {
  375. state.resourceList = [...resourceList.value]
  376. state.confirmList = [...confirmList.value]
  377. }
  378. }
  379. // 前端筛选资源组
  380. const filterOutListByGroupId = (val: string) => {
  381. if (val) {
  382. state.resourceList = resourceList.value.filter((item: any) => String(item.groupId) === val)
  383. } else {
  384. state.resourceList = [...resourceList.value]
  385. }
  386. }
  387. // 选择资源房间
  388. const onSelectResource = (row: any) => {
  389. curSelectedResourceId.value = row.id
  390. allocationSituationList.value = row.usedList
  391. }
  392. // 确认
  393. const onConfirm = (row: any) => {
  394. // 验证同类型 下个月出室人员 = 已确认人数 提示无法确认
  395. }
  396. // 关闭弹窗
  397. const resetDialogState = () => {
  398. confirmList.value = JSON.parse(JSON.stringify(originalConfirmList.value))
  399. state.confirmList = JSON.parse(JSON.stringify(confirmList.value))
  400. resourceList.value = JSON.parse(JSON.stringify(originalResourceList.value))
  401. state.resourceList = JSON.parse(JSON.stringify(resourceList.value))
  402. allocationSituationList.value = []
  403. curSelectedResourceId.value = 0
  404. resClass.value = ''
  405. groupId.value = ''
  406. state.active = -1
  407. }
  408. const closeDialog = () => {
  409. resetDialogState()
  410. state.dialog.isShowDialog = false
  411. }
  412. // 取消
  413. const onCancel = () => {
  414. closeDialog()
  415. }
  416. const handelBatchAssign = async () => {
  417. const checkedUser = state.confirmList.filter((item: any) => item.checked)
  418. console.log(checkedUser)
  419. if (!checkedUser.length) {
  420. showToast({ message: '请选择待分配队列人员', type: 'text' })
  421. return
  422. }
  423. showConfirmDialog({
  424. title: '提示',
  425. message: '是否将选中队列人员一键分配资源?',
  426. confirmButtonText: '确认',
  427. cancelButtonText: '取消',
  428. })
  429. .then(async () => {
  430. const [err]: ToResponse = await to(
  431. platformApi.batchAssign({ appointIds: checkedUser.map((item: any) => item.id) }),
  432. )
  433. if (err) return
  434. showToast({ message: '一键分配成功', type: 'success' })
  435. getResourceList()
  436. getConfirmList()
  437. })
  438. .catch(() => {})
  439. }
  440. // 提交
  441. const onSubmit = async (type: string) => {
  442. const arr = confirmList.value.filter((item: any) => item.resId)
  443. if (!arr.length) {
  444. showToast({ message: '请分配资源', type: 'text' })
  445. return
  446. }
  447. const [err]: ToResponse = await to(
  448. cellAssignApi.create({
  449. assignList: arr.map((item: any) => {
  450. return {
  451. appointId: item.id,
  452. resId: item.resId,
  453. location: item.location,
  454. replaceId: item.replaceId,
  455. mainId: item.id || 0,
  456. platformType: item.platformType || '10',
  457. }
  458. }),
  459. }),
  460. )
  461. if (err) return
  462. showToast({ message: '操作成功', type: 'success' })
  463. emit('refresh')
  464. closeDialog()
  465. }
  466. const onDetail = (val: number) => {
  467. detailsDialog.value.openDialog('get', val)
  468. }
  469. // 分配
  470. const onAllocate = (pos: any) => {
  471. if (state.active < 0) {
  472. showToast({ message: '请先选择一个待分配人员', type: 'text' })
  473. return
  474. }
  475. const activeObj = state.confirmList[state.active]
  476. if (activeObj.resId) {
  477. showToast({ message: '该人员已分配房间,如需调整请先取消当前分配', type: 'text' })
  478. return
  479. }
  480. const room = state.resourceList.find((item: any) => item.id == curSelectedResourceId.value)
  481. if (!room) {
  482. showToast({ message: '请选择资源房间', type: 'text' })
  483. return
  484. }
  485. if (room.resClass != activeObj.cellSourceType) {
  486. showToast({ message: '请选择正确的资源', type: 'text' })
  487. return
  488. }
  489. activeObj.resId = room.id
  490. activeObj.resName = room.resName
  491. activeObj.location = pos.location
  492. activeObj.replaceId = pos.id
  493. const user = confirmList.value.find((item: any) => item.id == activeObj.id)
  494. user.resId = room.id
  495. user.resName = room.resName
  496. user.location = pos.location
  497. user.replaceId = pos.id
  498. pos.userObj = { ...activeObj }
  499. const resource = resourceList.value.find((item: any) => item.id == room.id)
  500. const used = resource.usedList.find((item: any) => item.location == pos.location)
  501. used.userObj = { ...activeObj }
  502. }
  503. // 取消分配
  504. const cancelAllocate = (obj: any) => {
  505. if (!obj?.resId || !obj?.location) return
  506. const { resId, location } = obj
  507. const resetRoomUsage = (list: any[]) => {
  508. const room = list.find((item: any) => item.id === resId)
  509. if (!room?.usedList) return
  510. const used = room.usedList.find((i: any) => i.location === location)
  511. if (used) used.userObj = null
  512. }
  513. resetRoomUsage(resourceList.value)
  514. resetRoomUsage(state.resourceList)
  515. if (Array.isArray(allocationSituationList.value) && allocationSituationList.value.length) {
  516. const usage = allocationSituationList.value.find((item: any) => item.location === location)
  517. if (usage) usage.userObj = null
  518. }
  519. const user = confirmList.value.find((item) => item.id === obj.id)
  520. if (user) {
  521. user.resId = null
  522. user.resName = null
  523. user.location = null
  524. user.replaceId = null
  525. }
  526. obj.resId = null
  527. obj.resName = null
  528. obj.location = null
  529. obj.replaceId = null
  530. filterOutList(resClass.value)
  531. }
  532. const getUsedClass = (row: any) => {
  533. if (row.assignStatus == '10') {
  534. // 空
  535. if (row.userObj) return 'empty-allocate'
  536. return 'empty'
  537. } else if (row.assignStatus == '40') {
  538. // 次月离室
  539. if (row.userObj) return 'leave-allocate'
  540. return 'leave'
  541. } else {
  542. // 占用20 已分配45
  543. return 'used'
  544. }
  545. }
  546. // 暴露变量
  547. defineExpose({
  548. openDialog,
  549. })
  550. </script>
  551. <style lang="scss" scoped>
  552. .allocate-popup-container {
  553. .allocate-popup {
  554. display: flex;
  555. flex-direction: column;
  556. height: 100%;
  557. padding: 16px;
  558. box-sizing: border-box;
  559. }
  560. .popup-header {
  561. display: flex;
  562. justify-content: space-between;
  563. align-items: flex-start;
  564. flex-wrap: wrap;
  565. gap: 16px;
  566. margin-bottom: 16px;
  567. .popup-title {
  568. font-size: 20px;
  569. font-weight: 600;
  570. color: #111827;
  571. }
  572. .popup-controls {
  573. display: flex;
  574. align-items: center;
  575. gap: 12px;
  576. flex-wrap: wrap;
  577. .group-selector {
  578. display: flex;
  579. align-items: center;
  580. gap: 10px;
  581. padding: 6px 12px;
  582. background: #ffffff;
  583. border-radius: 999px;
  584. border: 1px solid #e5e7ef;
  585. box-shadow: 0 2px 6px rgba(148, 163, 184, 0.15);
  586. cursor: pointer;
  587. flex: 1;
  588. .label {
  589. width: 100px;
  590. font-size: 13px;
  591. color: #4b5563;
  592. font-weight: 500;
  593. }
  594. :deep(.van-field) {
  595. padding: 0;
  596. min-width: 120px;
  597. .van-field__control {
  598. font-size: 13px;
  599. color: #1f2937;
  600. }
  601. .van-field__control--right {
  602. text-align: right;
  603. }
  604. .van-field__right-icon {
  605. color: #2563eb;
  606. }
  607. }
  608. }
  609. .type-radio-group {
  610. display: flex;
  611. align-items: center;
  612. gap: 8px;
  613. padding: 6px 10px;
  614. background: #eef2ff;
  615. border-radius: 999px;
  616. .van-radio__icon {
  617. display: none;
  618. }
  619. .van-radio__label {
  620. color: #374151;
  621. font-size: 13px;
  622. }
  623. .van-radio--horizontal {
  624. padding: 0 6px;
  625. }
  626. .van-radio--horizontal.van-radio--checked .van-radio__label {
  627. color: #2563eb;
  628. font-weight: 600;
  629. }
  630. }
  631. }
  632. }
  633. .popup-body {
  634. flex: 1;
  635. display: flex;
  636. flex-direction: column;
  637. gap: 16px;
  638. overflow-y: auto;
  639. padding-bottom: 16px;
  640. }
  641. .card {
  642. background: #fff;
  643. border-radius: 12px;
  644. padding: 16px;
  645. box-shadow: 0 6px 18px rgba(15, 23, 42, 0.08);
  646. display: flex;
  647. flex-direction: column;
  648. gap: 12px;
  649. .info-banner {
  650. padding: 10px 14px;
  651. border-radius: 8px;
  652. font-size: 15px;
  653. font-weight: 600;
  654. &.warning {
  655. background: linear-gradient(90deg, rgba(252, 211, 77, 0.2), rgba(253, 230, 138, 0.5));
  656. color: #b45309;
  657. }
  658. }
  659. }
  660. .confirm-card {
  661. ul {
  662. max-height: 420px;
  663. overflow-y: auto;
  664. padding-right: 4px;
  665. &::-webkit-scrollbar {
  666. width: 6px;
  667. }
  668. &::-webkit-scrollbar-thumb {
  669. background: rgba(156, 163, 175, 0.4);
  670. border-radius: 999px;
  671. }
  672. li {
  673. display: flex;
  674. align-items: flex-start;
  675. justify-content: space-between;
  676. gap: 12px;
  677. padding: 12px;
  678. border: 1px solid #e5e7eb;
  679. border-radius: 10px;
  680. transition: border-color 0.2s ease, box-shadow 0.2s ease;
  681. cursor: pointer;
  682. & + li {
  683. margin-top: 10px;
  684. }
  685. &.active {
  686. border-color: #93c5fd;
  687. box-shadow: 0 10px 20px rgba(59, 130, 246, 0.08);
  688. }
  689. .text {
  690. flex: 1;
  691. display: flex;
  692. flex-direction: column;
  693. gap: 6px;
  694. p {
  695. margin: 0;
  696. color: #374151;
  697. font-size: 14px;
  698. line-height: 1.4;
  699. white-space: nowrap;
  700. overflow: hidden;
  701. text-overflow: ellipsis;
  702. }
  703. .assign-room {
  704. color: #2563eb;
  705. font-weight: 600;
  706. }
  707. }
  708. .btns {
  709. display: flex;
  710. flex-wrap: wrap;
  711. gap: 8px;
  712. justify-content: flex-end;
  713. .link-btn {
  714. padding: 6px 12px;
  715. border-radius: 999px;
  716. border: 1px solid transparent;
  717. background: #eff6ff;
  718. font-size: 13px;
  719. font-weight: 500;
  720. color: #1d4ed8;
  721. cursor: pointer;
  722. transition: all 0.2s ease;
  723. line-height: 1;
  724. &:hover {
  725. background: #dbeafe;
  726. color: #1e40af;
  727. box-shadow: 0 4px 12px rgba(29, 78, 216, 0.25);
  728. }
  729. &.danger {
  730. background: #fee2e2;
  731. color: #dc2626;
  732. &:hover {
  733. background: #fecaca;
  734. color: #b91c1c;
  735. box-shadow: 0 4px 12px rgba(220, 38, 38, 0.25);
  736. }
  737. }
  738. }
  739. }
  740. }
  741. }
  742. }
  743. .resource-card {
  744. .van-empty {
  745. padding: 24px 0;
  746. .van-empty__description {
  747. color: #6b7280;
  748. font-size: 14px;
  749. }
  750. }
  751. .resource-grid {
  752. display: grid;
  753. grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  754. gap: 12px;
  755. }
  756. .resource-item {
  757. background: #f9fafb;
  758. border-radius: 10px;
  759. padding: 16px;
  760. border: 1px solid transparent;
  761. display: flex;
  762. flex-direction: column;
  763. gap: 10px;
  764. cursor: pointer;
  765. transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
  766. &:hover {
  767. transform: translateY(-2px);
  768. box-shadow: 0 10px 20px rgba(15, 23, 42, 0.12);
  769. }
  770. &.active {
  771. border-color: #60a5fa;
  772. box-shadow: 0 8px 18px rgba(59, 130, 246, 0.16);
  773. }
  774. .resource-header {
  775. display: flex;
  776. justify-content: space-between;
  777. align-items: center;
  778. gap: 8px;
  779. .resource-name {
  780. font-size: 16px;
  781. font-weight: 600;
  782. color: #111827;
  783. }
  784. }
  785. .resource-location {
  786. color: #6b7280;
  787. font-size: 13px;
  788. }
  789. .resource-footer {
  790. display: flex;
  791. align-items: center;
  792. justify-content: space-between;
  793. .resource-usage {
  794. font-size: 14px;
  795. font-weight: 600;
  796. color: #111827;
  797. }
  798. .usage-dots {
  799. display: flex;
  800. flex-wrap: wrap;
  801. gap: 6px;
  802. row-gap: 6px;
  803. list-style: none;
  804. padding: 0;
  805. margin: 0;
  806. max-width: 120px;
  807. justify-content: flex-end;
  808. li {
  809. width: 10px;
  810. height: 10px;
  811. border-radius: 50%;
  812. background: #86efac;
  813. &.used {
  814. background: #f87171;
  815. }
  816. &.empty-allocate {
  817. background: linear-gradient(90deg, #86efac 0%, #86efac 50%, #2563eb 50%, #2563eb 100%);
  818. }
  819. &.leave {
  820. background: #fb923c;
  821. }
  822. &.leave-allocate {
  823. background: linear-gradient(90deg, #fb923c 0%, #fb923c 50%, #2563eb 50%, #2563eb 100%);
  824. }
  825. }
  826. }
  827. }
  828. }
  829. }
  830. .usage-card {
  831. .usage-list {
  832. max-height: 360px;
  833. overflow-y: auto;
  834. padding-right: 4px;
  835. &::-webkit-scrollbar {
  836. width: 6px;
  837. }
  838. &::-webkit-scrollbar-thumb {
  839. background: rgba(156, 163, 175, 0.4);
  840. border-radius: 999px;
  841. }
  842. .position-choose {
  843. list-style: none;
  844. padding: 0;
  845. margin: 0;
  846. display: flex;
  847. flex-direction: column;
  848. gap: 8px;
  849. li {
  850. display: flex;
  851. align-items: center;
  852. gap: 10px;
  853. padding: 8px 10px;
  854. border: 1px solid #e5e7eb;
  855. border-radius: 8px;
  856. background: #f9fafb;
  857. i {
  858. width: 10px;
  859. height: 10px;
  860. border-radius: 50%;
  861. background: #86efac;
  862. &.used {
  863. background: #f87171;
  864. }
  865. &.empty-allocate {
  866. background: linear-gradient(90deg, #86efac 0%, #86efac 50%, #2563eb 50%, #2563eb 100%);
  867. }
  868. &.leave {
  869. background: #fb923c;
  870. }
  871. &.leave-allocate {
  872. background: linear-gradient(90deg, #fb923c 0%, #fb923c 50%, #2563eb 50%, #2563eb 100%);
  873. }
  874. }
  875. .txt-flex {
  876. flex: 1;
  877. white-space: nowrap;
  878. overflow: hidden;
  879. text-overflow: ellipsis;
  880. color: #374151;
  881. font-size: 14px;
  882. }
  883. .van-button {
  884. min-width: 64px;
  885. }
  886. }
  887. }
  888. }
  889. }
  890. .popup-footer {
  891. display: flex;
  892. gap: 12px;
  893. margin-top: 16px;
  894. .van-button {
  895. height: 44px;
  896. font-size: 16px;
  897. font-weight: 600;
  898. }
  899. }
  900. }
  901. @media (min-width: 1024px) {
  902. .allocate-popup-container {
  903. .popup-body {
  904. flex-direction: row;
  905. .confirm-card {
  906. flex: 0 0 300px;
  907. }
  908. .resource-card {
  909. flex: 1;
  910. }
  911. .usage-card {
  912. flex: 0 0 280px;
  913. }
  914. }
  915. }
  916. }
  917. </style>