App.vue 26 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039
  1. <template>
  2. <div class="dashboard">
  3. <LoginModal
  4. :visible="showLoginModal"
  5. @success="handleLoginSuccess"
  6. @cancel="handleLoginCancel"
  7. />
  8. <header class="header">
  9. <h1 class="title">
  10. 宝时立库
  11. <span class="title-meta">· 总库位 {{ locations.length }}</span>
  12. <span class="title-meta">· 可用库位 {{ availableLocationsCount }}</span>
  13. <span class="title-legend">
  14. <button
  15. type="button"
  16. :class="[
  17. 'legend-chip',
  18. 'legend-chip-a',
  19. { 'legend-chip-inactive': !categoryColorVisibility.A }
  20. ]"
  21. @click="toggleCategoryColorVisibility('A')"
  22. >
  23. A
  24. </button>
  25. <button
  26. type="button"
  27. :class="[
  28. 'legend-chip',
  29. 'legend-chip-b',
  30. { 'legend-chip-inactive': !categoryColorVisibility.B }
  31. ]"
  32. @click="toggleCategoryColorVisibility('B')"
  33. >
  34. B
  35. </button>
  36. <button
  37. type="button"
  38. :class="[
  39. 'legend-chip',
  40. 'legend-chip-c',
  41. { 'legend-chip-inactive': !categoryColorVisibility.C }
  42. ]"
  43. @click="toggleCategoryColorVisibility('C')"
  44. >
  45. C
  46. </button>
  47. </span>
  48. </h1>
  49. <div class="controls">
  50. <label class="filter-item">
  51. <span class="selector-label">库位类型</span>
  52. <select
  53. v-model="selectedCategory"
  54. class="level-select"
  55. >
  56. <option value="">全部</option>
  57. <option
  58. v-for="category in categoryOptions"
  59. :key="category"
  60. :value="category"
  61. >
  62. {{ category }}
  63. </option>
  64. </select>
  65. </label>
  66. <label class="filter-item">
  67. <span class="selector-label">库位属性</span>
  68. <select
  69. v-model="selectedLocationAttribute"
  70. class="level-select"
  71. >
  72. <option value="">全部</option>
  73. <option
  74. v-for="attribute in locationAttributeOptions"
  75. :key="attribute"
  76. :value="attribute"
  77. >
  78. {{ getLocationAttributeLabel(attribute) }}
  79. </option>
  80. </select>
  81. </label>
  82. <label class="filter-item">
  83. <span class="selector-label">容器</span>
  84. <select
  85. v-model="selectedHasContainer"
  86. class="level-select"
  87. >
  88. <option value="">全部</option>
  89. <option value="Y">有容器</option>
  90. <option value="N">无容器</option>
  91. </select>
  92. </label>
  93. <label class="filter-item filter-input-item">
  94. <span class="selector-label">库位组</span>
  95. <span class="filter-input-wrap">
  96. <input
  97. v-model="locGroupKeywordInput"
  98. class="filter-input"
  99. type="text"
  100. placeholder="输入库位组"
  101. @keydown.enter="applyLocGroupFilter"
  102. >
  103. <button
  104. v-if="locGroupKeywordInput"
  105. class="filter-clear-btn"
  106. type="button"
  107. @click="clearLocGroupFilter"
  108. >
  109. <svg
  110. viewBox="0 0 16 16"
  111. aria-hidden="true"
  112. class="filter-action-icon"
  113. >
  114. <path
  115. d="M4.22 4.22a.75.75 0 0 1 1.06 0L8 6.94l2.72-2.72a.75.75 0 1 1 1.06 1.06L9.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L8 9.06l-2.72 2.72a.75.75 0 1 1-1.06-1.06L6.94 8 4.22 5.28a.75.75 0 0 1 0-1.06z"
  116. fill="currentColor"
  117. />
  118. </svg>
  119. </button>
  120. <button
  121. class="filter-confirm-btn"
  122. type="button"
  123. @click="applyLocGroupFilter"
  124. >
  125. <svg
  126. viewBox="0 0 16 16"
  127. aria-hidden="true"
  128. class="filter-action-icon"
  129. >
  130. <path
  131. d="M6.5 2.5a4 4 0 1 0 2.47 7.15l2.69 2.68 1.06-1.06-2.68-2.69A4 4 0 0 0 6.5 2.5zm0 1.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5z"
  132. fill="currentColor"
  133. />
  134. </svg>
  135. </button>
  136. </span>
  137. </label>
  138. <label class="filter-item filter-input-item">
  139. <span class="selector-label">库位号</span>
  140. <span class="filter-input-wrap">
  141. <input
  142. v-model="locationIdKeywordInput"
  143. class="filter-input"
  144. type="text"
  145. placeholder="输入库位号"
  146. @keydown.enter="applyLocationIdFilter"
  147. >
  148. <button
  149. v-if="locationIdKeywordInput"
  150. class="filter-clear-btn"
  151. type="button"
  152. @click="clearLocationIdFilter"
  153. >
  154. <svg
  155. viewBox="0 0 16 16"
  156. aria-hidden="true"
  157. class="filter-action-icon"
  158. >
  159. <path
  160. d="M4.22 4.22a.75.75 0 0 1 1.06 0L8 6.94l2.72-2.72a.75.75 0 1 1 1.06 1.06L9.06 8l2.72 2.72a.75.75 0 1 1-1.06 1.06L8 9.06l-2.72 2.72a.75.75 0 1 1-1.06-1.06L6.94 8 4.22 5.28a.75.75 0 0 1 0-1.06z"
  161. fill="currentColor"
  162. />
  163. </svg>
  164. </button>
  165. <button
  166. class="filter-confirm-btn"
  167. type="button"
  168. @click="applyLocationIdFilter"
  169. >
  170. <svg
  171. viewBox="0 0 16 16"
  172. aria-hidden="true"
  173. class="filter-action-icon"
  174. >
  175. <path
  176. d="M6.5 2.5a4 4 0 1 0 2.47 7.15l2.69 2.68 1.06-1.06-2.68-2.69A4 4 0 0 0 6.5 2.5zm0 1.5a2.5 2.5 0 1 1 0 5 2.5 2.5 0 0 1 0-5z"
  177. fill="currentColor"
  178. />
  179. </svg>
  180. </button>
  181. </span>
  182. </label>
  183. <label class="toggle-item">
  184. <span class="selector-label">库位组边框</span>
  185. <input
  186. :checked="showGroupBorder"
  187. class="toggle-input"
  188. type="checkbox"
  189. @change="handleGroupBorderToggle"
  190. >
  191. <span class="toggle-track">
  192. <span class="toggle-thumb" />
  193. </span>
  194. </label>
  195. <label class="filter-item">
  196. <span class="selector-label">库区</span>
  197. <select
  198. v-model="selectedZoneId"
  199. class="level-select"
  200. >
  201. <option value="">全部</option>
  202. <option
  203. v-for="zoneId in zoneOptions"
  204. :key="zoneId"
  205. :value="zoneId"
  206. >
  207. {{ zoneId }}
  208. </option>
  209. </select>
  210. </label>
  211. <label class="toggle-item">
  212. <span class="selector-label">卡片</span>
  213. <input
  214. v-model="showTooltip"
  215. class="toggle-input"
  216. type="checkbox"
  217. >
  218. <span class="toggle-track">
  219. <span class="toggle-thumb" />
  220. </span>
  221. </label>
  222. <select
  223. v-model.number="currentLevel"
  224. class="level-select level-select-floor"
  225. @change="handleLevelChange"
  226. >
  227. <option
  228. v-for="level in levelRange"
  229. :key="level"
  230. :value="level"
  231. >
  232. {{ level }}层
  233. </option>
  234. </select>
  235. <div
  236. ref="refreshControlRef"
  237. class="refresh-control"
  238. >
  239. <button
  240. class="refresh-btn"
  241. :disabled="loading || refreshing"
  242. @click="handleManualRefresh"
  243. @contextmenu.prevent="handleRefreshContextMenu"
  244. >
  245. {{ refreshCountdownText }}
  246. </button>
  247. <div
  248. v-if="showRefreshPopover"
  249. class="refresh-popover"
  250. @click.stop
  251. >
  252. <input
  253. ref="refreshIntervalInputRef"
  254. v-model="refreshIntervalInput"
  255. class="refresh-popover-input"
  256. type="text"
  257. inputmode="numeric"
  258. @keydown.enter="applyRefreshInterval"
  259. >
  260. <button
  261. class="refresh-popover-confirm"
  262. type="button"
  263. @click="applyRefreshInterval"
  264. >
  265. <svg
  266. viewBox="0 0 16 16"
  267. aria-hidden="true"
  268. class="filter-action-icon"
  269. >
  270. <path
  271. d="M6.46 11.03 3.43 8a.75.75 0 1 1 1.06-1.06l1.97 1.96 5.05-5.04A.75.75 0 0 1 12.57 4.9l-5.58 5.59a.75.75 0 0 1-1.06 0z"
  272. fill="currentColor"
  273. />
  274. </svg>
  275. </button>
  276. </div>
  277. </div>
  278. <button
  279. class="logout-btn"
  280. @click="handleLogout"
  281. >
  282. 退出
  283. </button>
  284. </div>
  285. </header>
  286. <main class="main-content">
  287. <div
  288. v-if="loading"
  289. class="loading"
  290. >
  291. 加载中...
  292. </div>
  293. <div
  294. v-else-if="error"
  295. class="error"
  296. >
  297. {{ error }}
  298. </div>
  299. <div
  300. v-else
  301. class="map-container"
  302. >
  303. <WarehouseMap
  304. :locations="locations"
  305. :current-level="currentLevel"
  306. :selected-category="selectedCategory"
  307. :selected-location-attribute="selectedLocationAttribute"
  308. :selected-has-container="selectedHasContainer"
  309. :selected-zone-id="selectedZoneId"
  310. :loc-group-keyword="appliedLocGroupKeyword"
  311. :location-id-keyword="appliedLocationIdKeyword"
  312. :show-group-border="showGroupBorder"
  313. :show-tooltip="showTooltip"
  314. :category-color-visibility="categoryColorVisibility"
  315. @select-loc-group="handleSelectLocGroup"
  316. @select-location-id="handleSelectLocationId"
  317. />
  318. </div>
  319. </main>
  320. </div>
  321. </template>
  322. <script setup lang="ts">
  323. import { ref, onMounted, computed, onBeforeUnmount, nextTick } from 'vue'
  324. import { fetchLocationData } from './api/location'
  325. import type { LocationAttributeCode, LocationResourceDataVO } from './types'
  326. import WarehouseMap from './components/WarehouseMap.vue'
  327. import LoginModal from './components/LoginModal.vue'
  328. import { config } from './config'
  329. import { isAuthenticated, removeToken } from './utils/auth'
  330. const LEVEL_STORAGE_KEY = 'warehouse-map.current-level'
  331. const REFRESH_INTERVAL_STORAGE_KEY = 'warehouse-map.refresh-interval-ms'
  332. const getInitialLevel = () => {
  333. const savedLevel = window.localStorage.getItem(LEVEL_STORAGE_KEY)
  334. const parsedLevel = savedLevel ? Number(savedLevel) : NaN
  335. if (
  336. Number.isInteger(parsedLevel) &&
  337. parsedLevel >= config.minLevel &&
  338. parsedLevel <= config.maxLevel
  339. ) {
  340. return parsedLevel
  341. }
  342. return config.minLevel
  343. }
  344. const getInitialRefreshInterval = () => {
  345. const savedInterval = window.localStorage.getItem(REFRESH_INTERVAL_STORAGE_KEY)
  346. const parsedInterval = savedInterval ? Number(savedInterval) : NaN
  347. if (Number.isInteger(parsedInterval) && parsedInterval > 0) {
  348. return parsedInterval
  349. }
  350. return config.refreshInterval
  351. }
  352. const currentLevel = ref(getInitialLevel())
  353. const locations = ref<LocationResourceDataVO[]>([])
  354. const loading = ref(false)
  355. const refreshing = ref(false)
  356. const hasLoadedOnce = ref(false)
  357. const error = ref('')
  358. const showLoginModal = ref(false)
  359. const selectedCategory = ref('')
  360. const selectedLocationAttribute = ref<LocationAttributeCode | ''>('')
  361. const selectedHasContainer = ref<'Y' | 'N' | ''>('')
  362. const selectedZoneId = ref('')
  363. const categoryColorVisibility = ref<Record<'A' | 'B' | 'C', boolean>>({
  364. A: true,
  365. B: true,
  366. C: true
  367. })
  368. const locGroupKeywordInput = ref('')
  369. const appliedLocGroupKeyword = ref('')
  370. const locationIdKeywordInput = ref('')
  371. const appliedLocationIdKeyword = ref('')
  372. const showGroupBorder = ref(false)
  373. const showTooltip = ref(true)
  374. const refreshIntervalMs = ref(getInitialRefreshInterval())
  375. const showRefreshPopover = ref(false)
  376. const refreshIntervalInput = ref(String(Math.max(Math.floor(refreshIntervalMs.value / 1000), 1)))
  377. const now = ref(Date.now())
  378. const nextRefreshAt = ref(Date.now() + refreshIntervalMs.value)
  379. const refreshControlRef = ref<HTMLElement | null>(null)
  380. const refreshIntervalInputRef = ref<HTMLInputElement | null>(null)
  381. let refreshTimer: number | null = null
  382. let countdownTimer: number | null = null
  383. const LOCATION_ATTRIBUTE_LABEL_MAP: Record<LocationAttributeCode, string> = {
  384. OK: '正常',
  385. FI: '禁入',
  386. HD: '封存',
  387. SC: '管控'
  388. }
  389. const levelRange = computed(() => {
  390. const levels = []
  391. for (let i = config.minLevel; i <= config.maxLevel; i++) {
  392. levels.push(i)
  393. }
  394. return levels
  395. })
  396. const categoryOptions = computed(() => {
  397. return [...new Set(locations.value.map((loc) => loc.category).filter(Boolean))].sort()
  398. })
  399. const availableLocationsCount = computed(() => {
  400. return locations.value.filter((loc) => loc.locationAttribute === 'OK').length
  401. })
  402. const locationAttributeOptions = computed<LocationAttributeCode[]>(() => {
  403. return [
  404. ...new Set(locations.value.map((loc) => loc.locationAttribute).filter(Boolean))
  405. ] as LocationAttributeCode[]
  406. })
  407. const zoneOptions = computed(() => {
  408. return [...new Set(locations.value.map((loc) => loc.zoneId).filter(Boolean))].sort()
  409. })
  410. const getLocationAttributeLabel = (attribute: LocationAttributeCode) => {
  411. return LOCATION_ATTRIBUTE_LABEL_MAP[attribute] || attribute
  412. }
  413. const toggleCategoryColorVisibility = (category: 'A' | 'B' | 'C') => {
  414. categoryColorVisibility.value = {
  415. ...categoryColorVisibility.value,
  416. [category]: !categoryColorVisibility.value[category]
  417. }
  418. }
  419. const copyText = async (text: string) => {
  420. if (!text) return
  421. try {
  422. await navigator.clipboard.writeText(text)
  423. return
  424. } catch (error) {
  425. console.warn('Clipboard API copy failed, fallback to execCommand.', error)
  426. }
  427. const textarea = document.createElement('textarea')
  428. textarea.value = text
  429. textarea.setAttribute('readonly', 'true')
  430. textarea.style.position = 'fixed'
  431. textarea.style.top = '-9999px'
  432. document.body.appendChild(textarea)
  433. textarea.select()
  434. document.execCommand('copy')
  435. document.body.removeChild(textarea)
  436. }
  437. const applyLocGroupFilter = () => {
  438. appliedLocGroupKeyword.value = locGroupKeywordInput.value.trim()
  439. }
  440. const applyLocationIdFilter = () => {
  441. appliedLocationIdKeyword.value = locationIdKeywordInput.value.trim()
  442. }
  443. const clearLocGroupFilter = () => {
  444. locGroupKeywordInput.value = ''
  445. appliedLocGroupKeyword.value = ''
  446. }
  447. const clearLocationIdFilter = () => {
  448. locationIdKeywordInput.value = ''
  449. appliedLocationIdKeyword.value = ''
  450. }
  451. const handleGroupBorderToggle = (event: Event) => {
  452. showGroupBorder.value = (event.target as HTMLInputElement).checked
  453. }
  454. const refreshCountdownText = computed(() => {
  455. const remainMs = Math.max(nextRefreshAt.value - now.value, 0)
  456. const totalSeconds = Math.floor(remainMs / 1000)
  457. const minutes = Math.floor(totalSeconds / 60)
  458. const seconds = totalSeconds % 60
  459. return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
  460. })
  461. const loadLocationData = async (options: { silent?: boolean } = {}) => {
  462. const { silent = false } = options
  463. if (!isAuthenticated()) {
  464. showLoginModal.value = true
  465. return
  466. }
  467. const shouldUseSilentRefresh = silent && hasLoadedOnce.value
  468. if (shouldUseSilentRefresh) {
  469. refreshing.value = true
  470. } else {
  471. loading.value = true
  472. error.value = ''
  473. }
  474. try {
  475. const data = await fetchLocationData({
  476. warehouse: config.warehouse,
  477. locLevel: currentLevel.value
  478. })
  479. locations.value = data
  480. hasLoadedOnce.value = true
  481. } catch (err: unknown) {
  482. if (!shouldUseSilentRefresh) {
  483. error.value = err instanceof Error ? err.message : '加载数据失败,请检查接口连接'
  484. }
  485. console.error('Failed to load location data:', err)
  486. } finally {
  487. if (shouldUseSilentRefresh) {
  488. refreshing.value = false
  489. } else {
  490. loading.value = false
  491. }
  492. }
  493. }
  494. const scheduleNextRefresh = () => {
  495. if (refreshTimer !== null) {
  496. window.clearTimeout(refreshTimer)
  497. }
  498. nextRefreshAt.value = Date.now() + refreshIntervalMs.value
  499. refreshTimer = window.setTimeout(async () => {
  500. await loadLocationData({ silent: true })
  501. scheduleNextRefresh()
  502. }, refreshIntervalMs.value)
  503. }
  504. const handleLevelChange = () => {
  505. window.localStorage.setItem(LEVEL_STORAGE_KEY, String(currentLevel.value))
  506. loadLocationData()
  507. scheduleNextRefresh()
  508. }
  509. const handleManualRefresh = () => {
  510. loadLocationData({ silent: true })
  511. scheduleNextRefresh()
  512. }
  513. const applyRefreshInterval = () => {
  514. const nextSeconds = Number(refreshIntervalInput.value.trim())
  515. if (!Number.isInteger(nextSeconds) || nextSeconds <= 0) {
  516. return
  517. }
  518. refreshIntervalMs.value = nextSeconds * 1000
  519. refreshIntervalInput.value = String(nextSeconds)
  520. window.localStorage.setItem(REFRESH_INTERVAL_STORAGE_KEY, String(refreshIntervalMs.value))
  521. showRefreshPopover.value = false
  522. scheduleNextRefresh()
  523. }
  524. const handleRefreshContextMenu = async () => {
  525. refreshIntervalInput.value = String(Math.max(Math.floor(refreshIntervalMs.value / 1000), 1))
  526. showRefreshPopover.value = true
  527. await nextTick()
  528. refreshIntervalInputRef.value?.focus()
  529. refreshIntervalInputRef.value?.select()
  530. }
  531. const handleDocumentClick = (event: MouseEvent) => {
  532. if (!showRefreshPopover.value) {
  533. return
  534. }
  535. const target = event.target as Node | null
  536. if (target && refreshControlRef.value?.contains(target)) {
  537. return
  538. }
  539. showRefreshPopover.value = false
  540. }
  541. const handleLoginSuccess = () => {
  542. showLoginModal.value = false
  543. loadLocationData()
  544. scheduleNextRefresh()
  545. }
  546. const handleSelectLocGroup = async (locGroup1: string) => {
  547. await copyText(locGroup1)
  548. if (appliedLocGroupKeyword.value === locGroup1) {
  549. locGroupKeywordInput.value = ''
  550. appliedLocGroupKeyword.value = ''
  551. return
  552. }
  553. locGroupKeywordInput.value = locGroup1
  554. appliedLocGroupKeyword.value = locGroup1
  555. locationIdKeywordInput.value = ''
  556. appliedLocationIdKeyword.value = ''
  557. }
  558. const handleSelectLocationId = async (locationId: string) => {
  559. await copyText(locationId)
  560. if (appliedLocationIdKeyword.value === locationId) {
  561. locationIdKeywordInput.value = ''
  562. appliedLocationIdKeyword.value = ''
  563. return
  564. }
  565. locationIdKeywordInput.value = locationId
  566. appliedLocationIdKeyword.value = locationId
  567. locGroupKeywordInput.value = ''
  568. appliedLocGroupKeyword.value = ''
  569. }
  570. const handleLoginCancel = () => {
  571. // 不允许取消登录,必须登录才能使用
  572. }
  573. const handleLogout = () => {
  574. if (refreshTimer !== null) {
  575. window.clearTimeout(refreshTimer)
  576. refreshTimer = null
  577. }
  578. removeToken()
  579. locations.value = []
  580. hasLoadedOnce.value = false
  581. loading.value = false
  582. refreshing.value = false
  583. showLoginModal.value = true
  584. }
  585. onMounted(() => {
  586. countdownTimer = window.setInterval(() => {
  587. now.value = Date.now()
  588. }, 1000)
  589. document.addEventListener('mousedown', handleDocumentClick)
  590. if (!isAuthenticated()) {
  591. showLoginModal.value = true
  592. } else {
  593. loadLocationData()
  594. scheduleNextRefresh()
  595. }
  596. })
  597. onBeforeUnmount(() => {
  598. if (refreshTimer !== null) {
  599. window.clearTimeout(refreshTimer)
  600. }
  601. if (countdownTimer !== null) {
  602. window.clearInterval(countdownTimer)
  603. }
  604. document.removeEventListener('mousedown', handleDocumentClick)
  605. })
  606. </script>
  607. <style scoped>
  608. .dashboard {
  609. width: 100%;
  610. height: 100vh;
  611. display: flex;
  612. flex-direction: column;
  613. background: #000000;
  614. }
  615. .header {
  616. padding: 10px 18px;
  617. background: linear-gradient(180deg, #050505 0%, #000000 100%);
  618. border-bottom: 1px solid #1c1c1c;
  619. display: flex;
  620. justify-content: space-between;
  621. align-items: center;
  622. }
  623. .title {
  624. display: flex;
  625. align-items: baseline;
  626. gap: 6px;
  627. font-size: 18px;
  628. font-weight: bold;
  629. line-height: 1.1;
  630. color: #f2f2f2;
  631. text-shadow: 0 0 8px rgba(255, 255, 255, 0.08);
  632. }
  633. .title-meta {
  634. font-size: 11px;
  635. font-weight: normal;
  636. color: rgba(255, 255, 255, 0.6);
  637. }
  638. .title-legend {
  639. display: inline-flex;
  640. align-items: center;
  641. gap: 4px;
  642. margin-left: 2px;
  643. }
  644. .legend-chip {
  645. display: inline-flex;
  646. align-items: center;
  647. justify-content: center;
  648. min-width: 18px;
  649. height: 14px;
  650. padding: 0 4px;
  651. border-radius: 999px;
  652. font-size: 9px;
  653. font-weight: 600;
  654. line-height: 1;
  655. color: #f4f7fa;
  656. border: none;
  657. cursor: pointer;
  658. transition:
  659. transform 0.2s ease,
  660. opacity 0.2s ease,
  661. box-shadow 0.2s ease;
  662. }
  663. .legend-chip:hover {
  664. transform: translateY(-1px);
  665. box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.14);
  666. }
  667. .legend-chip-inactive {
  668. opacity: 0.42;
  669. box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
  670. }
  671. .legend-chip-a {
  672. background: #008000;
  673. }
  674. .legend-chip-b {
  675. background: #0000ff;
  676. color: #f4f8ff;
  677. }
  678. .legend-chip-c {
  679. background: #ffff00;
  680. color: #5a5200;
  681. }
  682. .controls {
  683. display: flex;
  684. align-items: center;
  685. gap: 6px;
  686. }
  687. .level-selector {
  688. display: flex;
  689. align-items: center;
  690. gap: 6px;
  691. color: #d8d8d8;
  692. }
  693. .filter-item {
  694. display: flex;
  695. align-items: center;
  696. gap: 4px;
  697. color: #d8d8d8;
  698. }
  699. .filter-input-item {
  700. min-width: auto;
  701. }
  702. .toggle-item {
  703. display: flex;
  704. align-items: center;
  705. gap: 4px;
  706. color: #d8d8d8;
  707. }
  708. .selector-label {
  709. font-size: 11px;
  710. color: #8e8e8e;
  711. }
  712. .level-select {
  713. min-width: 76px;
  714. padding: 6px 24px 6px 8px;
  715. background: rgba(255, 255, 255, 0.03);
  716. border: 1px solid #252525;
  717. color: #f2f2f2;
  718. cursor: pointer;
  719. font-size: 12px;
  720. transition: all 0.3s;
  721. border-radius: 4px;
  722. outline: none;
  723. appearance: none;
  724. background-image:
  725. linear-gradient(45deg, transparent 50%, #6d6d6d 50%),
  726. linear-gradient(135deg, #6d6d6d 50%, transparent 50%);
  727. background-position:
  728. calc(100% - 13px) calc(50% - 2px),
  729. calc(100% - 8px) calc(50% - 2px);
  730. background-size:
  731. 5px 5px,
  732. 5px 5px;
  733. background-repeat: no-repeat;
  734. line-height: 1;
  735. }
  736. .level-select-floor {
  737. min-width: 64px;
  738. }
  739. .filter-input-wrap {
  740. position: relative;
  741. display: inline-flex;
  742. width: 136px;
  743. }
  744. .filter-input {
  745. width: 100%;
  746. padding: 6px 8px;
  747. padding-right: 50px;
  748. background: rgba(255, 255, 255, 0.03);
  749. border: 1px solid #252525;
  750. color: #f2f2f2;
  751. font-size: 12px;
  752. border-radius: 4px;
  753. outline: none;
  754. line-height: 1;
  755. }
  756. .filter-input::placeholder {
  757. color: rgba(255, 255, 255, 0.28);
  758. }
  759. .filter-input:hover,
  760. .filter-input:focus {
  761. background: rgba(255, 255, 255, 0.05);
  762. border-color: #3a3a3a;
  763. }
  764. .filter-confirm-btn {
  765. position: absolute;
  766. top: 1px;
  767. right: 1px;
  768. width: 24px;
  769. height: calc(100% - 2px);
  770. display: inline-flex;
  771. align-items: center;
  772. justify-content: center;
  773. border: none;
  774. border-left: 1px solid #202020;
  775. background: transparent;
  776. color: #cfcfcf;
  777. cursor: pointer;
  778. border-radius: 0 3px 3px 0;
  779. }
  780. .filter-clear-btn {
  781. position: absolute;
  782. top: 1px;
  783. right: 25px;
  784. width: 24px;
  785. height: calc(100% - 2px);
  786. display: inline-flex;
  787. align-items: center;
  788. justify-content: center;
  789. border: none;
  790. border-left: 1px solid #202020;
  791. background: transparent;
  792. color: #cfcfcf;
  793. cursor: pointer;
  794. }
  795. .filter-clear-btn:hover,
  796. .filter-confirm-btn:hover {
  797. background: rgba(255, 255, 255, 0.06);
  798. }
  799. .filter-action-icon {
  800. width: 12px;
  801. height: 12px;
  802. }
  803. .toggle-input {
  804. position: absolute;
  805. opacity: 0;
  806. pointer-events: none;
  807. }
  808. .toggle-track {
  809. position: relative;
  810. width: 34px;
  811. height: 18px;
  812. border-radius: 999px;
  813. background: #111111;
  814. border: 1px solid #2c2c2c;
  815. transition: all 0.2s;
  816. cursor: pointer;
  817. }
  818. .toggle-thumb {
  819. position: absolute;
  820. top: 1px;
  821. left: 1px;
  822. width: 14px;
  823. height: 14px;
  824. border-radius: 50%;
  825. background: #f2f2f2;
  826. transition: transform 0.2s;
  827. }
  828. .toggle-input:checked + .toggle-track {
  829. background: #2b2b2b;
  830. border-color: #5a5a5a;
  831. }
  832. .toggle-input:checked + .toggle-track .toggle-thumb {
  833. transform: translateX(16px);
  834. }
  835. .level-select:hover,
  836. .level-select:focus {
  837. background: rgba(255, 255, 255, 0.05);
  838. border-color: #3a3a3a;
  839. }
  840. .refresh-btn {
  841. min-width: 58px;
  842. padding: 6px 8px;
  843. background: #101010;
  844. border: 1px solid #282828;
  845. color: #e8e8e8;
  846. cursor: pointer;
  847. font-size: 11px;
  848. transition: all 0.3s;
  849. border-radius: 4px;
  850. line-height: 1;
  851. }
  852. .refresh-control {
  853. position: relative;
  854. }
  855. .refresh-popover {
  856. position: absolute;
  857. top: calc(100% + 6px);
  858. right: 0;
  859. display: inline-flex;
  860. align-items: center;
  861. gap: 4px;
  862. padding: 4px;
  863. background: rgba(4, 4, 4, 0.98);
  864. border: 1px solid #242424;
  865. border-radius: 4px;
  866. box-shadow: 0 8px 18px rgba(0, 0, 0, 0.24);
  867. z-index: 20;
  868. }
  869. .refresh-popover-input {
  870. width: 56px;
  871. padding: 6px 8px;
  872. background: rgba(255, 255, 255, 0.03);
  873. border: 1px solid #252525;
  874. color: #f2f2f2;
  875. font-size: 12px;
  876. border-radius: 4px;
  877. outline: none;
  878. line-height: 1;
  879. }
  880. .refresh-popover-input:focus {
  881. background: rgba(255, 255, 255, 0.05);
  882. border-color: #3a3a3a;
  883. }
  884. .refresh-popover-confirm {
  885. width: 24px;
  886. height: 28px;
  887. display: inline-flex;
  888. align-items: center;
  889. justify-content: center;
  890. border: 1px solid #252525;
  891. background: rgba(255, 255, 255, 0.03);
  892. color: #cfcfcf;
  893. border-radius: 4px;
  894. cursor: pointer;
  895. }
  896. .refresh-popover-confirm:hover {
  897. background: rgba(255, 255, 255, 0.06);
  898. border-color: #3a3a3a;
  899. }
  900. .refresh-btn:hover:not(:disabled) {
  901. background: #181818;
  902. border-color: #3a3a3a;
  903. }
  904. .refresh-btn:disabled {
  905. opacity: 0.7;
  906. cursor: wait;
  907. }
  908. .logout-btn {
  909. padding: 6px 10px;
  910. background: rgba(210, 92, 92, 0.08);
  911. border: 1px solid rgba(210, 92, 92, 0.45);
  912. color: #f2bcbc;
  913. cursor: pointer;
  914. font-size: 11px;
  915. transition: all 0.3s;
  916. border-radius: 4px;
  917. line-height: 1;
  918. }
  919. .logout-btn:hover {
  920. background: rgba(210, 92, 92, 0.16);
  921. border-color: rgba(210, 92, 92, 0.7);
  922. color: #ffd4d4;
  923. }
  924. .main-content {
  925. flex: 1;
  926. padding: 0 12px 12px;
  927. overflow: auto;
  928. background: #000000;
  929. }
  930. .loading,
  931. .error {
  932. display: flex;
  933. justify-content: center;
  934. align-items: center;
  935. height: 100%;
  936. font-size: 18px;
  937. color: #f2f2f2;
  938. }
  939. .error {
  940. color: #ff4444;
  941. }
  942. .map-container {
  943. width: 100%;
  944. height: 100%;
  945. }
  946. </style>