App.vue 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392
  1. <template>
  2. <div :class="['dashboard', themeClass]">
  3. <LoginModal
  4. :visible="showLoginModal"
  5. @success="handleLoginSuccess"
  6. @cancel="handleLoginCancel"
  7. />
  8. <header class="header">
  9. <h1 class="title">
  10. {{ systemTitle }}
  11. <span class="title-meta">库位 <span class="title-meta-value">{{ availableLocationsCount }}/{{ locations.length }}</span></span>
  12. <span class="title-fill-rate">
  13. <span
  14. class="title-meta title-fill-rate-item"
  15. @mouseenter="handleFillRateMouseEnter($event, overallFillRateDetail)"
  16. @mousemove="handleFillRateMouseMove"
  17. @mouseleave="handleFillRateMouseLeave"
  18. >
  19. 库满度 <span class="title-meta-value">{{ overallFillRate }}</span>
  20. </span>
  21. <span
  22. :class="[
  23. 'title-meta',
  24. 'title-meta-a',
  25. 'title-fill-rate-item',
  26. 'title-fill-rate-toggle',
  27. { 'title-meta-inactive': !categoryColorVisibility.A }
  28. ]"
  29. @mouseenter="handleFillRateMouseEnter($event, categoryFillRateDetailMap.A)"
  30. @mousemove="handleFillRateMouseMove"
  31. @mouseleave="handleFillRateMouseLeave"
  32. @click="toggleCategoryColorVisibility('A')"
  33. >
  34. A <span class="title-meta-value">{{ categoryFillRateMap.A }}</span>
  35. </span>
  36. <span
  37. :class="[
  38. 'title-meta',
  39. 'title-meta-b',
  40. 'title-fill-rate-item',
  41. 'title-fill-rate-toggle',
  42. { 'title-meta-inactive': !categoryColorVisibility.B }
  43. ]"
  44. @mouseenter="handleFillRateMouseEnter($event, categoryFillRateDetailMap.B)"
  45. @mousemove="handleFillRateMouseMove"
  46. @mouseleave="handleFillRateMouseLeave"
  47. @click="toggleCategoryColorVisibility('B')"
  48. >
  49. B <span class="title-meta-value">{{ categoryFillRateMap.B }}</span>
  50. </span>
  51. <span
  52. :class="[
  53. 'title-meta',
  54. 'title-meta-c',
  55. 'title-fill-rate-item',
  56. 'title-fill-rate-toggle',
  57. { 'title-meta-inactive': !categoryColorVisibility.C }
  58. ]"
  59. @mouseenter="handleFillRateMouseEnter($event, categoryFillRateDetailMap.C)"
  60. @mousemove="handleFillRateMouseMove"
  61. @mouseleave="handleFillRateMouseLeave"
  62. @click="toggleCategoryColorVisibility('C')"
  63. >
  64. C <span class="title-meta-value">{{ categoryFillRateMap.C }}</span>
  65. </span>
  66. </span>
  67. </h1>
  68. <div class="controls">
  69. <label class="filter-item">
  70. <select
  71. v-model="selectedCategory"
  72. :class="['level-select', { 'level-select-placeholder': !selectedCategory }]"
  73. >
  74. <option value="">库位类型</option>
  75. <option
  76. v-for="category in categoryOptions"
  77. :key="category"
  78. :value="category"
  79. >
  80. {{ category }}
  81. </option>
  82. </select>
  83. </label>
  84. <label class="filter-item">
  85. <select
  86. v-model="selectedLocationAttribute"
  87. :class="['level-select', { 'level-select-placeholder': !selectedLocationAttribute }]"
  88. >
  89. <option value="">库位属性</option>
  90. <option
  91. v-for="attribute in locationAttributeOptions"
  92. :key="attribute"
  93. :value="attribute"
  94. >
  95. {{ getLocationAttributeLabel(attribute) }}
  96. </option>
  97. </select>
  98. </label>
  99. <label class="filter-item">
  100. <select
  101. v-model="selectedHasContainer"
  102. :class="['level-select', { 'level-select-placeholder': !selectedHasContainer }]"
  103. >
  104. <option value="">容器</option>
  105. <option value="Y">有容器</option>
  106. <option value="N">无容器</option>
  107. </select>
  108. </label>
  109. <label class="filter-item filter-input-item">
  110. <span class="filter-input-wrap">
  111. <input
  112. v-model="locGroupKeywordInput"
  113. class="filter-input"
  114. type="text"
  115. placeholder="库位组"
  116. @keydown.enter="applyLocGroupFilter"
  117. >
  118. <button
  119. v-if="locGroupKeywordInput"
  120. class="filter-clear-btn"
  121. type="button"
  122. @click="clearLocGroupFilter"
  123. >
  124. <svg
  125. viewBox="0 0 16 16"
  126. aria-hidden="true"
  127. class="filter-action-icon"
  128. >
  129. <path
  130. 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"
  131. fill="currentColor"
  132. />
  133. </svg>
  134. </button>
  135. <button
  136. class="filter-confirm-btn"
  137. type="button"
  138. @click="applyLocGroupFilter"
  139. >
  140. <svg
  141. viewBox="0 0 16 16"
  142. aria-hidden="true"
  143. class="filter-action-icon"
  144. >
  145. <path
  146. 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"
  147. fill="currentColor"
  148. />
  149. </svg>
  150. </button>
  151. </span>
  152. </label>
  153. <label class="filter-item filter-input-item">
  154. <span class="filter-input-wrap">
  155. <input
  156. v-model="locationIdKeywordInput"
  157. class="filter-input"
  158. type="text"
  159. placeholder="库位号"
  160. @keydown.enter="applyLocationIdFilter"
  161. >
  162. <button
  163. v-if="locationIdKeywordInput"
  164. class="filter-clear-btn"
  165. type="button"
  166. @click="clearLocationIdFilter"
  167. >
  168. <svg
  169. viewBox="0 0 16 16"
  170. aria-hidden="true"
  171. class="filter-action-icon"
  172. >
  173. <path
  174. 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"
  175. fill="currentColor"
  176. />
  177. </svg>
  178. </button>
  179. <button
  180. class="filter-confirm-btn"
  181. type="button"
  182. @click="applyLocationIdFilter"
  183. >
  184. <svg
  185. viewBox="0 0 16 16"
  186. aria-hidden="true"
  187. class="filter-action-icon"
  188. >
  189. <path
  190. 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"
  191. fill="currentColor"
  192. />
  193. </svg>
  194. </button>
  195. </span>
  196. </label>
  197. <label class="filter-item">
  198. <select
  199. v-model="selectedZoneId"
  200. :class="['level-select', { 'level-select-placeholder': !selectedZoneId }]"
  201. >
  202. <option value="">库区</option>
  203. <option
  204. v-for="zoneId in zoneOptions"
  205. :key="zoneId"
  206. :value="zoneId"
  207. >
  208. {{ zoneId }}
  209. </option>
  210. </select>
  211. </label>
  212. <select
  213. v-model.number="currentLevel"
  214. class="level-select level-select-floor"
  215. @change="handleLevelChange"
  216. >
  217. <option
  218. v-for="level in levelRange"
  219. :key="level"
  220. :value="level"
  221. >
  222. {{ level }}层
  223. </option>
  224. </select>
  225. <div
  226. ref="refreshControlRef"
  227. class="refresh-control"
  228. >
  229. <button
  230. class="refresh-btn"
  231. :disabled="loading || refreshing"
  232. @click="handleManualRefresh"
  233. @contextmenu.prevent="handleRefreshContextMenu"
  234. >
  235. {{ refreshCountdownText }}
  236. </button>
  237. <div
  238. v-if="showRefreshPopover"
  239. class="refresh-popover"
  240. @click.stop
  241. >
  242. <input
  243. ref="refreshIntervalInputRef"
  244. v-model="refreshIntervalInput"
  245. class="refresh-popover-input"
  246. type="text"
  247. inputmode="numeric"
  248. @keydown.enter="applyRefreshInterval"
  249. >
  250. <button
  251. class="refresh-popover-confirm"
  252. type="button"
  253. @click="applyRefreshInterval"
  254. >
  255. <svg
  256. viewBox="0 0 16 16"
  257. aria-hidden="true"
  258. class="filter-action-icon"
  259. >
  260. <path
  261. 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"
  262. fill="currentColor"
  263. />
  264. </svg>
  265. </button>
  266. </div>
  267. </div>
  268. <div class="toggle-group">
  269. <label class="toggle-item">
  270. <span class="toggle-hint">组框</span>
  271. <input
  272. :checked="showGroupBorder"
  273. class="toggle-input"
  274. type="checkbox"
  275. @change="handleGroupBorderToggle"
  276. >
  277. <span class="toggle-track">
  278. <span class="toggle-thumb" />
  279. </span>
  280. </label>
  281. <label class="toggle-item">
  282. <span class="toggle-hint">卡片</span>
  283. <input
  284. v-model="showTooltip"
  285. class="toggle-input"
  286. type="checkbox"
  287. >
  288. <span class="toggle-track">
  289. <span class="toggle-thumb" />
  290. </span>
  291. </label>
  292. <label class="toggle-item theme-toggle">
  293. <span class="toggle-hint">主题</span>
  294. <input
  295. v-model="isLightTheme"
  296. class="toggle-input"
  297. type="checkbox"
  298. >
  299. <span class="toggle-track">
  300. <span class="toggle-thumb" />
  301. </span>
  302. </label>
  303. </div>
  304. <button
  305. class="logout-btn"
  306. @click="handleLogout"
  307. >
  308. 退出
  309. </button>
  310. </div>
  311. </header>
  312. <div
  313. v-if="fillRateTooltipVisible && fillRateTooltipText"
  314. class="fill-rate-tooltip"
  315. :style="fillRateTooltipStyle"
  316. >
  317. <div class="fill-rate-tooltip-line">
  318. {{ fillRateTooltipText }}
  319. </div>
  320. </div>
  321. <main class="main-content">
  322. <div
  323. v-if="loading"
  324. class="loading"
  325. >
  326. 加载中...
  327. </div>
  328. <div
  329. v-else-if="error"
  330. class="error"
  331. >
  332. {{ error }}
  333. </div>
  334. <div
  335. v-else
  336. class="map-container"
  337. >
  338. <WarehouseMap
  339. :locations="locations"
  340. :current-level="currentLevel"
  341. :selected-category="selectedCategory"
  342. :selected-location-attribute="selectedLocationAttribute"
  343. :selected-has-container="selectedHasContainer"
  344. :selected-zone-id="selectedZoneId"
  345. :loc-group-keyword="appliedLocGroupKeyword"
  346. :location-id-keyword="appliedLocationIdKeyword"
  347. :show-group-border="showGroupBorder"
  348. :show-tooltip="showTooltip"
  349. :category-color-visibility="categoryColorVisibility"
  350. :theme-mode="isLightTheme ? 'light' : 'dark'"
  351. @select-loc-group="handleSelectLocGroup"
  352. @select-location-id="handleSelectLocationId"
  353. />
  354. </div>
  355. </main>
  356. </div>
  357. </template>
  358. <script setup lang="ts">
  359. import {
  360. ref,
  361. onMounted,
  362. computed,
  363. onBeforeUnmount,
  364. nextTick,
  365. watchEffect,
  366. watch,
  367. type CSSProperties
  368. } from 'vue'
  369. import { fetchLocationData } from './api/location'
  370. import type { LocationAttributeCode, LocationResourceDataVO } from './types'
  371. import WarehouseMap from './components/WarehouseMap.vue'
  372. import LoginModal from './components/LoginModal.vue'
  373. import { config } from './config'
  374. import { getApiEnvironment, isAuthenticated, removeToken } from './utils/auth'
  375. const LEVEL_STORAGE_KEY = 'warehouse-map.current-level'
  376. const REFRESH_INTERVAL_STORAGE_KEY = 'warehouse-map.refresh-interval-ms'
  377. const getInitialLevel = () => {
  378. const savedLevel = window.localStorage.getItem(LEVEL_STORAGE_KEY)
  379. const parsedLevel = savedLevel ? Number(savedLevel) : NaN
  380. if (
  381. Number.isInteger(parsedLevel) &&
  382. parsedLevel >= config.minLevel &&
  383. parsedLevel <= config.maxLevel
  384. ) {
  385. return parsedLevel
  386. }
  387. return config.minLevel
  388. }
  389. const getInitialRefreshInterval = () => {
  390. const savedInterval = window.localStorage.getItem(REFRESH_INTERVAL_STORAGE_KEY)
  391. const parsedInterval = savedInterval ? Number(savedInterval) : NaN
  392. if (Number.isInteger(parsedInterval) && parsedInterval > 0) {
  393. return parsedInterval
  394. }
  395. return config.refreshInterval
  396. }
  397. const currentLevel = ref(getInitialLevel())
  398. const locations = ref<LocationResourceDataVO[]>([])
  399. const loading = ref(false)
  400. const refreshing = ref(false)
  401. const hasLoadedOnce = ref(false)
  402. const error = ref('')
  403. const showLoginModal = ref(false)
  404. const THEME_STORAGE_KEY = 'warehouse-map.theme'
  405. const getInitialTheme = () => {
  406. const saved = window.localStorage.getItem(THEME_STORAGE_KEY)
  407. if (saved === 'dark' || saved === 'light') {
  408. return saved
  409. }
  410. return 'light'
  411. }
  412. const isLightTheme = ref(getInitialTheme() === 'light')
  413. const selectedCategory = ref('')
  414. const selectedLocationAttribute = ref<LocationAttributeCode | ''>('')
  415. const selectedHasContainer = ref<'Y' | 'N' | ''>('')
  416. const selectedZoneId = ref('')
  417. const categoryColorVisibility = ref<Record<'A' | 'B' | 'C', boolean>>({
  418. A: true,
  419. B: true,
  420. C: true
  421. })
  422. const locGroupKeywordInput = ref('')
  423. const appliedLocGroupKeyword = ref('')
  424. const locationIdKeywordInput = ref('')
  425. const appliedLocationIdKeyword = ref('')
  426. const showGroupBorder = ref(false)
  427. const showTooltip = ref(true)
  428. const refreshIntervalMs = ref(getInitialRefreshInterval())
  429. const showRefreshPopover = ref(false)
  430. const refreshIntervalInput = ref(String(Math.max(Math.floor(refreshIntervalMs.value / 1000), 1)))
  431. const now = ref(Date.now())
  432. const nextRefreshAt = ref(Date.now() + refreshIntervalMs.value)
  433. const refreshControlRef = ref<HTMLElement | null>(null)
  434. const refreshIntervalInputRef = ref<HTMLInputElement | null>(null)
  435. const fillRateTooltipVisible = ref(false)
  436. const fillRateTooltipText = ref('')
  437. const fillRateTooltipPosition = ref({
  438. x: 0,
  439. y: 0
  440. })
  441. let refreshTimer: number | null = null
  442. let countdownTimer: number | null = null
  443. const LOCATION_ATTRIBUTE_LABEL_MAP: Record<LocationAttributeCode, string> = {
  444. OK: '正常',
  445. FI: '禁入',
  446. HD: '封存',
  447. SC: '管控'
  448. }
  449. const themeClass = computed(() => (isLightTheme.value ? 'theme-light' : 'theme-dark'))
  450. const levelRange = computed(() => {
  451. const levels = []
  452. for (let i = config.minLevel; i <= config.maxLevel; i++) {
  453. levels.push(i)
  454. }
  455. return levels
  456. })
  457. const currentEnvironment = ref(getApiEnvironment())
  458. const systemTitle = computed(() => {
  459. return currentEnvironment.value === 'prod' ? '宝时立库生产' : '宝时立库测试'
  460. })
  461. const categoryOptions = computed(() => {
  462. return [...new Set(locations.value.map((loc) => loc.category).filter(Boolean))].sort()
  463. })
  464. const hasContainer = (containerCode: string | null) => {
  465. return Boolean(containerCode && containerCode.trim())
  466. }
  467. const formatFillRate = (total: number, occupied: number) => {
  468. if (total <= 0) {
  469. return '0.0%'
  470. }
  471. return `${((occupied / total) * 100).toFixed(1)}%`
  472. }
  473. const formatFillRateDetail = (occupied: number, total: number) => {
  474. return `${occupied}/${total}`
  475. }
  476. const availableLocationsCount = computed(() => {
  477. return locations.value.filter((loc) => loc.locationAttribute === 'OK').length
  478. })
  479. const overallFillRate = computed(() => {
  480. const occupiedCount = locations.value.filter((loc) => hasContainer(loc.containerCode)).length
  481. return formatFillRate(locations.value.length, occupiedCount)
  482. })
  483. const overallFillRateDetail = computed(() => {
  484. const occupiedCount = locations.value.filter((loc) => hasContainer(loc.containerCode)).length
  485. return formatFillRateDetail(occupiedCount, locations.value.length)
  486. })
  487. const categoryFillRateMap = computed<Record<'A' | 'B' | 'C', string>>(() => {
  488. const categories: Array<'A' | 'B' | 'C'> = ['A', 'B', 'C']
  489. return categories.reduce(
  490. (result, category) => {
  491. const categoryLocations = locations.value.filter((loc) => loc.category === category)
  492. const occupiedCount = categoryLocations.filter((loc) =>
  493. hasContainer(loc.containerCode)
  494. ).length
  495. result[category] = formatFillRate(categoryLocations.length, occupiedCount)
  496. return result
  497. },
  498. {
  499. A: '0.0%',
  500. B: '0.0%',
  501. C: '0.0%'
  502. }
  503. )
  504. })
  505. const categoryFillRateDetailMap = computed<Record<'A' | 'B' | 'C', string>>(() => {
  506. const categories: Array<'A' | 'B' | 'C'> = ['A', 'B', 'C']
  507. return categories.reduce(
  508. (result, category) => {
  509. const categoryLocations = locations.value.filter((loc) => loc.category === category)
  510. const occupiedCount = categoryLocations.filter((loc) =>
  511. hasContainer(loc.containerCode)
  512. ).length
  513. result[category] = formatFillRateDetail(occupiedCount, categoryLocations.length)
  514. return result
  515. },
  516. {
  517. A: '0/0',
  518. B: '0/0',
  519. C: '0/0'
  520. }
  521. )
  522. })
  523. const fillRateTooltipStyle = computed<CSSProperties>(() => ({
  524. left: `${fillRateTooltipPosition.value.x}px`,
  525. top: `${fillRateTooltipPosition.value.y}px`
  526. }))
  527. const locationAttributeOptions = computed<LocationAttributeCode[]>(() => {
  528. return [
  529. ...new Set(locations.value.map((loc) => loc.locationAttribute).filter(Boolean))
  530. ] as LocationAttributeCode[]
  531. })
  532. const zoneOptions = computed(() => {
  533. return [...new Set(locations.value.map((loc) => loc.zoneId).filter(Boolean))].sort()
  534. })
  535. const getLocationAttributeLabel = (attribute: LocationAttributeCode) => {
  536. return LOCATION_ATTRIBUTE_LABEL_MAP[attribute] || attribute
  537. }
  538. const toggleCategoryColorVisibility = (category: 'A' | 'B' | 'C') => {
  539. categoryColorVisibility.value = {
  540. ...categoryColorVisibility.value,
  541. [category]: !categoryColorVisibility.value[category]
  542. }
  543. }
  544. const updateFillRateTooltipPosition = (event: MouseEvent) => {
  545. const tooltipWidth = 120
  546. const tooltipHeight = 42
  547. const offset = 14
  548. const maxX = window.innerWidth - tooltipWidth - 12
  549. const maxY = window.innerHeight - tooltipHeight - 12
  550. fillRateTooltipPosition.value = {
  551. x: Math.max(12, Math.min(event.clientX + offset, maxX)),
  552. y: Math.max(12, Math.min(event.clientY + offset, maxY))
  553. }
  554. }
  555. const handleFillRateMouseEnter = (event: MouseEvent, text: string) => {
  556. fillRateTooltipText.value = text
  557. fillRateTooltipVisible.value = true
  558. updateFillRateTooltipPosition(event)
  559. }
  560. const handleFillRateMouseMove = (event: MouseEvent) => {
  561. if (!fillRateTooltipVisible.value) {
  562. return
  563. }
  564. updateFillRateTooltipPosition(event)
  565. }
  566. const handleFillRateMouseLeave = () => {
  567. fillRateTooltipVisible.value = false
  568. fillRateTooltipText.value = ''
  569. }
  570. const copyText = async (text: string) => {
  571. if (!text) return
  572. try {
  573. await navigator.clipboard.writeText(text)
  574. return
  575. } catch (error) {
  576. console.warn('Clipboard API copy failed, fallback to execCommand.', error)
  577. }
  578. const textarea = document.createElement('textarea')
  579. textarea.value = text
  580. textarea.setAttribute('readonly', 'true')
  581. textarea.style.position = 'fixed'
  582. textarea.style.top = '-9999px'
  583. document.body.appendChild(textarea)
  584. textarea.select()
  585. document.execCommand('copy')
  586. document.body.removeChild(textarea)
  587. }
  588. const applyLocGroupFilter = () => {
  589. appliedLocGroupKeyword.value = locGroupKeywordInput.value.trim()
  590. }
  591. const applyLocationIdFilter = () => {
  592. appliedLocationIdKeyword.value = locationIdKeywordInput.value.trim()
  593. }
  594. const clearLocGroupFilter = () => {
  595. locGroupKeywordInput.value = ''
  596. appliedLocGroupKeyword.value = ''
  597. }
  598. const clearLocationIdFilter = () => {
  599. locationIdKeywordInput.value = ''
  600. appliedLocationIdKeyword.value = ''
  601. }
  602. const handleGroupBorderToggle = (event: Event) => {
  603. showGroupBorder.value = (event.target as HTMLInputElement).checked
  604. }
  605. const refreshCountdownText = computed(() => {
  606. const remainMs = Math.max(nextRefreshAt.value - now.value, 0)
  607. const totalSeconds = Math.floor(remainMs / 1000)
  608. const minutes = Math.floor(totalSeconds / 60)
  609. const seconds = totalSeconds % 60
  610. return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
  611. })
  612. const loadLocationData = async (options: { silent?: boolean } = {}) => {
  613. const { silent = false } = options
  614. if (!isAuthenticated()) {
  615. showLoginModal.value = true
  616. return
  617. }
  618. const shouldUseSilentRefresh = silent && hasLoadedOnce.value
  619. if (shouldUseSilentRefresh) {
  620. refreshing.value = true
  621. } else {
  622. loading.value = true
  623. error.value = ''
  624. }
  625. try {
  626. const data = await fetchLocationData({
  627. warehouse: config.warehouse,
  628. locLevel: currentLevel.value
  629. })
  630. locations.value = data
  631. hasLoadedOnce.value = true
  632. } catch (err: unknown) {
  633. if (!shouldUseSilentRefresh) {
  634. error.value = err instanceof Error ? err.message : '加载数据失败,请检查接口连接'
  635. }
  636. console.error('Failed to load location data:', err)
  637. } finally {
  638. if (shouldUseSilentRefresh) {
  639. refreshing.value = false
  640. } else {
  641. loading.value = false
  642. }
  643. }
  644. }
  645. const scheduleNextRefresh = () => {
  646. if (refreshTimer !== null) {
  647. window.clearTimeout(refreshTimer)
  648. }
  649. nextRefreshAt.value = Date.now() + refreshIntervalMs.value
  650. refreshTimer = window.setTimeout(async () => {
  651. await loadLocationData({ silent: true })
  652. scheduleNextRefresh()
  653. }, refreshIntervalMs.value)
  654. }
  655. const handleLevelChange = () => {
  656. window.localStorage.setItem(LEVEL_STORAGE_KEY, String(currentLevel.value))
  657. loadLocationData()
  658. scheduleNextRefresh()
  659. }
  660. const handleManualRefresh = () => {
  661. loadLocationData({ silent: true })
  662. scheduleNextRefresh()
  663. }
  664. const applyRefreshInterval = () => {
  665. const nextSeconds = Number(refreshIntervalInput.value.trim())
  666. if (!Number.isInteger(nextSeconds) || nextSeconds <= 0) {
  667. return
  668. }
  669. refreshIntervalMs.value = nextSeconds * 1000
  670. refreshIntervalInput.value = String(nextSeconds)
  671. window.localStorage.setItem(REFRESH_INTERVAL_STORAGE_KEY, String(refreshIntervalMs.value))
  672. showRefreshPopover.value = false
  673. scheduleNextRefresh()
  674. }
  675. const handleRefreshContextMenu = async () => {
  676. refreshIntervalInput.value = String(Math.max(Math.floor(refreshIntervalMs.value / 1000), 1))
  677. showRefreshPopover.value = true
  678. await nextTick()
  679. refreshIntervalInputRef.value?.focus()
  680. refreshIntervalInputRef.value?.select()
  681. }
  682. const handleDocumentClick = (event: MouseEvent) => {
  683. if (!showRefreshPopover.value) {
  684. return
  685. }
  686. const target = event.target as Node | null
  687. if (target && refreshControlRef.value?.contains(target)) {
  688. return
  689. }
  690. showRefreshPopover.value = false
  691. }
  692. const handleLoginSuccess = () => {
  693. currentEnvironment.value = getApiEnvironment()
  694. showLoginModal.value = false
  695. loadLocationData()
  696. scheduleNextRefresh()
  697. }
  698. const handleSelectLocGroup = async (locGroup1: string) => {
  699. await copyText(locGroup1)
  700. if (appliedLocGroupKeyword.value === locGroup1) {
  701. locGroupKeywordInput.value = ''
  702. appliedLocGroupKeyword.value = ''
  703. return
  704. }
  705. locGroupKeywordInput.value = locGroup1
  706. appliedLocGroupKeyword.value = locGroup1
  707. locationIdKeywordInput.value = ''
  708. appliedLocationIdKeyword.value = ''
  709. }
  710. const handleSelectLocationId = async (locationId: string) => {
  711. await copyText(locationId)
  712. if (appliedLocationIdKeyword.value === locationId) {
  713. locationIdKeywordInput.value = ''
  714. appliedLocationIdKeyword.value = ''
  715. return
  716. }
  717. locationIdKeywordInput.value = locationId
  718. appliedLocationIdKeyword.value = locationId
  719. locGroupKeywordInput.value = ''
  720. appliedLocGroupKeyword.value = ''
  721. }
  722. const handleLoginCancel = () => {
  723. // 不允许取消登录,必须登录才能使用
  724. }
  725. const handleLogout = () => {
  726. if (refreshTimer !== null) {
  727. window.clearTimeout(refreshTimer)
  728. refreshTimer = null
  729. }
  730. removeToken()
  731. locations.value = []
  732. hasLoadedOnce.value = false
  733. loading.value = false
  734. refreshing.value = false
  735. showLoginModal.value = true
  736. }
  737. onMounted(() => {
  738. countdownTimer = window.setInterval(() => {
  739. now.value = Date.now()
  740. }, 1000)
  741. document.addEventListener('mousedown', handleDocumentClick)
  742. if (!isAuthenticated()) {
  743. showLoginModal.value = true
  744. } else {
  745. loadLocationData()
  746. scheduleNextRefresh()
  747. }
  748. })
  749. watchEffect(() => {
  750. document.title = systemTitle.value
  751. })
  752. watch(isLightTheme, (isLight) => {
  753. window.localStorage.setItem(THEME_STORAGE_KEY, isLight ? 'light' : 'dark')
  754. })
  755. onBeforeUnmount(() => {
  756. if (refreshTimer !== null) {
  757. window.clearTimeout(refreshTimer)
  758. }
  759. if (countdownTimer !== null) {
  760. window.clearInterval(countdownTimer)
  761. }
  762. document.removeEventListener('mousedown', handleDocumentClick)
  763. })
  764. </script>
  765. <style scoped>
  766. .dashboard {
  767. width: 100%;
  768. height: 100vh;
  769. display: flex;
  770. flex-direction: column;
  771. background: var(--bg);
  772. color: var(--text);
  773. --bg: #000000;
  774. --header-bg: linear-gradient(180deg, #050505 0%, #000000 100%);
  775. --header-border: #1c1c1c;
  776. --text: #f2f2f2;
  777. --text-muted: rgba(255, 255, 255, 0.6);
  778. --text-dim: #d8d8d8;
  779. --label-text: #8e8e8e;
  780. --rate-a: rgba(201, 242, 215, 0.88);
  781. --rate-b: rgba(203, 220, 255, 0.9);
  782. --rate-c: rgba(240, 220, 156, 0.9);
  783. --rate-inactive: var(--text-muted);
  784. --panel-bg: rgba(0, 0, 0, 0.96);
  785. --panel-border: #1f1f1f;
  786. --panel-shadow: 0 12px 28px rgba(0, 0, 0, 0.32);
  787. --input-bg: rgba(255, 255, 255, 0.03);
  788. --input-border: #252525;
  789. --input-text: #f2f2f2;
  790. --input-placeholder: rgba(255, 255, 255, 0.28);
  791. --input-hover-bg: rgba(255, 255, 255, 0.05);
  792. --input-hover-border: #3a3a3a;
  793. --icon-border: #202020;
  794. --toggle-track-bg: #111111;
  795. --toggle-track-border: #2c2c2c;
  796. --toggle-thumb-bg: #f2f2f2;
  797. --toggle-track-checked-bg: #2b2b2b;
  798. --toggle-track-checked-border: #5a5a5a;
  799. --refresh-bg: #101010;
  800. --refresh-border: #282828;
  801. --refresh-text: #e8e8e8;
  802. --refresh-hover-bg: #181818;
  803. --refresh-hover-border: #3a3a3a;
  804. --popover-bg: rgba(4, 4, 4, 0.98);
  805. --popover-border: #242424;
  806. --btn-neutral-bg: rgba(255, 255, 255, 0.03);
  807. --btn-neutral-border: #252525;
  808. --btn-neutral-text: #cfcfcf;
  809. --btn-neutral-hover-bg: rgba(255, 255, 255, 0.06);
  810. --btn-neutral-hover-border: #3a3a3a;
  811. --danger-bg: rgba(210, 92, 92, 0.08);
  812. --danger-border: rgba(210, 92, 92, 0.45);
  813. --danger-text: #f2bcbc;
  814. --danger-hover-bg: rgba(210, 92, 92, 0.16);
  815. --danger-hover-border: rgba(210, 92, 92, 0.7);
  816. --danger-hover-text: #ffd4d4;
  817. --error-text: #ff4444;
  818. --select-arrow: #6d6d6d;
  819. }
  820. .dashboard.theme-light {
  821. --bg: #f6f7f9;
  822. --header-bg: linear-gradient(180deg, #ffffff 0%, #f0f2f5 100%);
  823. --header-border: #d9dee6;
  824. --text: #1f1f1f;
  825. --text-muted: rgba(0, 0, 0, 0.55);
  826. --text-dim: #3f3f3f;
  827. --label-text: #5f6368;
  828. --rate-a: #1f7a3f;
  829. --rate-b: #2f5fd7;
  830. --rate-c: #b8921f;
  831. --rate-inactive: var(--label-text);
  832. --panel-bg: rgba(255, 255, 255, 0.96);
  833. --panel-border: #d7dce3;
  834. --panel-shadow: 0 12px 24px rgba(31, 35, 40, 0.12);
  835. --input-bg: #ffffff;
  836. --input-border: #cfd6df;
  837. --input-text: #1f1f1f;
  838. --input-placeholder: rgba(0, 0, 0, 0.35);
  839. --input-hover-bg: #f7f9fb;
  840. --input-hover-border: #aeb7c2;
  841. --icon-border: #cfd6df;
  842. --toggle-track-bg: #e4e7ec;
  843. --toggle-track-border: #c6ccd5;
  844. --toggle-thumb-bg: #ffffff;
  845. --toggle-track-checked-bg: #cfd6df;
  846. --toggle-track-checked-border: #aeb7c2;
  847. --refresh-bg: #ffffff;
  848. --refresh-border: #cfd6df;
  849. --refresh-text: #2a2f36;
  850. --refresh-hover-bg: #f1f4f8;
  851. --refresh-hover-border: #aeb7c2;
  852. --popover-bg: #ffffff;
  853. --popover-border: #d7dce3;
  854. --btn-neutral-bg: #ffffff;
  855. --btn-neutral-border: #cfd6df;
  856. --btn-neutral-text: #2a2f36;
  857. --btn-neutral-hover-bg: #f1f4f8;
  858. --btn-neutral-hover-border: #aeb7c2;
  859. --danger-bg: rgba(210, 92, 92, 0.1);
  860. --danger-border: rgba(210, 92, 92, 0.45);
  861. --danger-text: #a83b3b;
  862. --danger-hover-bg: rgba(210, 92, 92, 0.16);
  863. --danger-hover-border: rgba(210, 92, 92, 0.7);
  864. --danger-hover-text: #8f2f2f;
  865. --error-text: #c73939;
  866. --select-arrow: #6b7280;
  867. }
  868. .header {
  869. padding: 10px 18px;
  870. background: var(--header-bg);
  871. border-bottom: 1px solid var(--header-border);
  872. display: flex;
  873. justify-content: space-between;
  874. align-items: center;
  875. }
  876. .title {
  877. display: flex;
  878. align-items: baseline;
  879. gap: 6px;
  880. font-size: 18px;
  881. font-weight: bold;
  882. line-height: 1.1;
  883. color: var(--text);
  884. text-shadow: 0 0 8px rgba(255, 255, 255, 0.08);
  885. }
  886. .title-meta {
  887. font-size: 11px;
  888. font-weight: normal;
  889. color: var(--text-muted);
  890. }
  891. .title-meta-value {
  892. font-weight: 700;
  893. }
  894. .title-legend {
  895. display: inline-flex;
  896. align-items: center;
  897. gap: 4px;
  898. margin-left: 2px;
  899. }
  900. .title-fill-rate {
  901. display: inline-flex;
  902. align-items: center;
  903. gap: 6px;
  904. margin-left: 2px;
  905. }
  906. .title-fill-rate-item {
  907. cursor: help;
  908. }
  909. .fill-rate-tooltip {
  910. position: fixed;
  911. z-index: 1000;
  912. max-width: 160px;
  913. padding: 10px 12px;
  914. border: 1px solid var(--panel-border);
  915. border-radius: 8px;
  916. background: var(--panel-bg);
  917. box-shadow: var(--panel-shadow);
  918. color: var(--text);
  919. font-size: 12px;
  920. line-height: 1.45;
  921. pointer-events: none;
  922. backdrop-filter: blur(6px);
  923. }
  924. .fill-rate-tooltip-line + .fill-rate-tooltip-line {
  925. margin-top: 3px;
  926. }
  927. .title-meta-a,
  928. .title-meta-b,
  929. .title-meta-c {
  930. color: var(--text-muted);
  931. }
  932. .title-meta-a {
  933. color: var(--rate-a);
  934. }
  935. .title-meta-b {
  936. color: var(--rate-b);
  937. }
  938. .title-meta-c {
  939. color: var(--rate-c);
  940. }
  941. .title-meta-inactive {
  942. color: var(--rate-inactive);
  943. }
  944. .legend-chip {
  945. display: inline-flex;
  946. align-items: center;
  947. justify-content: center;
  948. min-width: 18px;
  949. height: 14px;
  950. padding: 0 4px;
  951. border-radius: 999px;
  952. font-size: 9px;
  953. font-weight: 600;
  954. line-height: 1;
  955. color: #f4f7fa;
  956. border: none;
  957. cursor: pointer;
  958. transition:
  959. transform 0.2s ease,
  960. opacity 0.2s ease,
  961. box-shadow 0.2s ease;
  962. }
  963. .legend-chip:hover {
  964. transform: translateY(-1px);
  965. box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.14);
  966. }
  967. .legend-chip-inactive {
  968. opacity: 0.42;
  969. box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
  970. }
  971. .legend-chip-a {
  972. background: #1f7a3f;
  973. color: #f3fff7;
  974. }
  975. .legend-chip-b {
  976. background: #2f5fd7;
  977. color: #f5f8ff;
  978. }
  979. .legend-chip-c {
  980. background: #b8921f;
  981. color: #161200;
  982. }
  983. .controls {
  984. display: flex;
  985. align-items: center;
  986. gap: 6px;
  987. flex-wrap: wrap;
  988. row-gap: 6px;
  989. }
  990. .level-selector {
  991. display: flex;
  992. align-items: center;
  993. gap: 6px;
  994. color: var(--text-dim);
  995. }
  996. .filter-item {
  997. display: flex;
  998. align-items: center;
  999. gap: 4px;
  1000. color: var(--text-dim);
  1001. }
  1002. .filter-input-item {
  1003. min-width: auto;
  1004. }
  1005. .toggle-item {
  1006. display: flex;
  1007. align-items: center;
  1008. gap: 4px;
  1009. color: var(--text-dim);
  1010. }
  1011. .toggle-group {
  1012. display: inline-flex;
  1013. align-items: center;
  1014. gap: 8px;
  1015. flex-wrap: nowrap;
  1016. }
  1017. .selector-label {
  1018. font-size: 11px;
  1019. color: var(--label-text);
  1020. }
  1021. .toggle-hint {
  1022. font-size: 11px;
  1023. color: var(--label-text);
  1024. white-space: nowrap;
  1025. }
  1026. .level-select {
  1027. min-width: 76px;
  1028. padding: 6px 24px 6px 8px;
  1029. background: var(--input-bg);
  1030. border: 1px solid var(--input-border);
  1031. color: var(--input-text);
  1032. cursor: pointer;
  1033. font-size: 12px;
  1034. transition: all 0.3s;
  1035. border-radius: 4px;
  1036. outline: none;
  1037. appearance: none;
  1038. background-image:
  1039. linear-gradient(45deg, transparent 50%, var(--select-arrow) 50%),
  1040. linear-gradient(135deg, var(--select-arrow) 50%, transparent 50%);
  1041. background-position:
  1042. calc(100% - 13px) calc(50% - 2px),
  1043. calc(100% - 8px) calc(50% - 2px);
  1044. background-size:
  1045. 5px 5px,
  1046. 5px 5px;
  1047. background-repeat: no-repeat;
  1048. line-height: 1;
  1049. }
  1050. .level-select-placeholder {
  1051. color: var(--label-text);
  1052. }
  1053. .level-select-floor {
  1054. min-width: 64px;
  1055. }
  1056. .filter-input-wrap {
  1057. position: relative;
  1058. display: inline-flex;
  1059. width: 115px;
  1060. }
  1061. .filter-input {
  1062. width: 100%;
  1063. padding: 6px 8px;
  1064. padding-right: 50px;
  1065. background: var(--input-bg);
  1066. border: 1px solid var(--input-border);
  1067. color: var(--input-text);
  1068. font-size: 12px;
  1069. border-radius: 4px;
  1070. outline: none;
  1071. line-height: 1;
  1072. }
  1073. .filter-input::placeholder {
  1074. color: var(--input-placeholder);
  1075. }
  1076. .filter-input:hover,
  1077. .filter-input:focus {
  1078. background: var(--input-hover-bg);
  1079. border-color: var(--input-hover-border);
  1080. }
  1081. .filter-confirm-btn {
  1082. position: absolute;
  1083. top: 1px;
  1084. right: 1px;
  1085. width: 24px;
  1086. height: calc(100% - 2px);
  1087. display: inline-flex;
  1088. align-items: center;
  1089. justify-content: center;
  1090. border: none;
  1091. border-left: 1px solid var(--icon-border);
  1092. background: transparent;
  1093. color: var(--btn-neutral-text);
  1094. cursor: pointer;
  1095. border-radius: 0 3px 3px 0;
  1096. }
  1097. .filter-clear-btn {
  1098. position: absolute;
  1099. top: 1px;
  1100. right: 25px;
  1101. width: 24px;
  1102. height: calc(100% - 2px);
  1103. display: inline-flex;
  1104. align-items: center;
  1105. justify-content: center;
  1106. border: none;
  1107. border-left: 1px solid var(--icon-border);
  1108. background: transparent;
  1109. color: var(--btn-neutral-text);
  1110. cursor: pointer;
  1111. }
  1112. .filter-clear-btn:hover,
  1113. .filter-confirm-btn:hover {
  1114. background: var(--btn-neutral-hover-bg);
  1115. }
  1116. .filter-action-icon {
  1117. width: 12px;
  1118. height: 12px;
  1119. }
  1120. .toggle-input {
  1121. position: absolute;
  1122. opacity: 0;
  1123. pointer-events: none;
  1124. }
  1125. .toggle-track {
  1126. position: relative;
  1127. width: 34px;
  1128. height: 18px;
  1129. border-radius: 999px;
  1130. background: var(--toggle-track-bg);
  1131. border: 1px solid var(--toggle-track-border);
  1132. transition: all 0.2s;
  1133. cursor: pointer;
  1134. }
  1135. .toggle-thumb {
  1136. position: absolute;
  1137. top: 1px;
  1138. left: 1px;
  1139. width: 14px;
  1140. height: 14px;
  1141. border-radius: 50%;
  1142. background: var(--toggle-thumb-bg);
  1143. transition: transform 0.2s;
  1144. }
  1145. .toggle-input:checked + .toggle-track {
  1146. background: var(--toggle-track-checked-bg);
  1147. border-color: var(--toggle-track-checked-border);
  1148. }
  1149. .toggle-input:checked + .toggle-track .toggle-thumb {
  1150. transform: translateX(16px);
  1151. }
  1152. .level-select:hover,
  1153. .level-select:focus {
  1154. background: var(--input-hover-bg);
  1155. border-color: var(--input-hover-border);
  1156. }
  1157. .refresh-btn {
  1158. min-width: 58px;
  1159. padding: 6px 8px;
  1160. background: var(--refresh-bg);
  1161. border: 1px solid var(--refresh-border);
  1162. color: var(--refresh-text);
  1163. cursor: pointer;
  1164. font-size: 11px;
  1165. transition: all 0.3s;
  1166. border-radius: 4px;
  1167. line-height: 1;
  1168. }
  1169. .refresh-control {
  1170. position: relative;
  1171. }
  1172. .refresh-popover {
  1173. position: absolute;
  1174. top: calc(100% + 6px);
  1175. right: 0;
  1176. display: inline-flex;
  1177. align-items: center;
  1178. gap: 4px;
  1179. padding: 4px;
  1180. background: var(--popover-bg);
  1181. border: 1px solid var(--popover-border);
  1182. border-radius: 4px;
  1183. box-shadow: var(--panel-shadow);
  1184. z-index: 20;
  1185. }
  1186. .refresh-popover-input {
  1187. width: 56px;
  1188. padding: 6px 8px;
  1189. background: var(--input-bg);
  1190. border: 1px solid var(--input-border);
  1191. color: var(--input-text);
  1192. font-size: 12px;
  1193. border-radius: 4px;
  1194. outline: none;
  1195. line-height: 1;
  1196. }
  1197. .refresh-popover-input:focus {
  1198. background: var(--input-hover-bg);
  1199. border-color: var(--input-hover-border);
  1200. }
  1201. .refresh-popover-confirm {
  1202. width: 24px;
  1203. height: 28px;
  1204. display: inline-flex;
  1205. align-items: center;
  1206. justify-content: center;
  1207. border: 1px solid var(--btn-neutral-border);
  1208. background: var(--btn-neutral-bg);
  1209. color: var(--btn-neutral-text);
  1210. border-radius: 4px;
  1211. cursor: pointer;
  1212. }
  1213. .refresh-popover-confirm:hover {
  1214. background: var(--btn-neutral-hover-bg);
  1215. border-color: var(--btn-neutral-hover-border);
  1216. }
  1217. .refresh-btn:hover:not(:disabled) {
  1218. background: var(--refresh-hover-bg);
  1219. border-color: var(--refresh-hover-border);
  1220. }
  1221. .refresh-btn:disabled {
  1222. opacity: 0.7;
  1223. cursor: wait;
  1224. }
  1225. .logout-btn {
  1226. padding: 6px 10px;
  1227. background: var(--danger-bg);
  1228. border: 1px solid var(--danger-border);
  1229. color: var(--danger-text);
  1230. cursor: pointer;
  1231. font-size: 11px;
  1232. transition: all 0.3s;
  1233. border-radius: 4px;
  1234. line-height: 1;
  1235. }
  1236. .logout-btn:hover {
  1237. background: var(--danger-hover-bg);
  1238. border-color: var(--danger-hover-border);
  1239. color: var(--danger-hover-text);
  1240. }
  1241. .main-content {
  1242. flex: 1;
  1243. padding: 0 12px 12px;
  1244. overflow: auto;
  1245. background: var(--bg);
  1246. }
  1247. .loading,
  1248. .error {
  1249. display: flex;
  1250. justify-content: center;
  1251. align-items: center;
  1252. height: 100%;
  1253. font-size: 18px;
  1254. color: var(--text);
  1255. }
  1256. .error {
  1257. color: var(--error-text);
  1258. }
  1259. .map-container {
  1260. width: 100%;
  1261. height: 100%;
  1262. }
  1263. </style>