Переглянути джерело

feat(app): 添加标题复制Token按钮及复制反馈toast

handy 3 днів тому
батько
коміт
1e45cc170a
2 змінених файлів з 160 додано та 5 видалено
  1. 158 2
      src/App.vue
  2. 2 3
      src/components/warehouse-layout-special-cells.ts

+ 158 - 2
src/App.vue

@@ -12,7 +12,14 @@
       class="header"
     >
       <h1 class="title">
-        {{ systemTitle }}
+        <button
+          class="title-copy-btn"
+          type="button"
+          title="点击复制当前登录 Token"
+          @click="handleTitleTokenCopy"
+        >
+          {{ systemTitle }}
+        </button>
         <span class="title-meta">库位
           <span class="title-meta-value">{{ filteredOccupiedLocationsCount }}/{{ filteredLocationsCount }}</span></span>
         <span class="title-fill-rate">
@@ -315,6 +322,25 @@
       </div>
     </div>
 
+    <Transition name="toast-fade">
+      <div
+        v-if="toastVisible"
+        class="copy-toast"
+      >
+        <div class="copy-toast-badge">
+          Token
+        </div>
+        <div class="copy-toast-content">
+          <div class="copy-toast-title">
+            {{ toastTitle }}
+          </div>
+          <div class="copy-toast-text">
+            {{ toastText }}
+          </div>
+        </div>
+      </div>
+    </Transition>
+
     <CreateLocationSqlModal
       :visible="createLocationSqlModalVisible"
       :floor="createLocationSqlPayload.floor"
@@ -389,7 +415,13 @@ import LoginModal from './components/LoginModal.vue'
 import WorkingHighlight from './components/WorkingHighlight.vue'
 import CreateLocationSqlModal from './components/CreateLocationSqlModal.vue'
 import { config } from './config'
-import { AUTH_INVALID_EVENT, getApiEnvironment, isAuthenticated, removeToken } from './utils/auth'
+import {
+  AUTH_INVALID_EVENT,
+  getApiEnvironment,
+  getToken,
+  isAuthenticated,
+  removeToken
+} from './utils/auth'
 
 const LEVEL_STORAGE_KEY = 'warehouse-map.current-level'
 const REFRESH_INTERVAL_STORAGE_KEY = 'warehouse-map.refresh-interval-ms'
@@ -480,8 +512,12 @@ const fillRateTooltipPosition = ref({
   x: 0,
   y: 0
 })
+const toastVisible = ref(false)
+const toastTitle = ref('')
+const toastText = ref('')
 let refreshTimer: number | null = null
 let countdownTimer: number | null = null
+let toastTimer: number | null = null
 const WORKING_HIGHLIGHT_REFRESH_EVENT = 'working-highlight-refresh'
 
 const LOCATION_ATTRIBUTE_LABEL_MAP: Record<LocationAttributeCode, string> = {
@@ -741,6 +777,21 @@ const handleFillRateMouseLeave = () => {
   fillRateTooltipText.value = ''
 }
 
+const showToast = (title: string, text: string) => {
+  toastTitle.value = title
+  toastText.value = text
+  toastVisible.value = true
+
+  if (toastTimer !== null) {
+    window.clearTimeout(toastTimer)
+  }
+
+  toastTimer = window.setTimeout(() => {
+    toastVisible.value = false
+    toastTimer = null
+  }, 2200)
+}
+
 const copyText = async (text: string) => {
   if (!text) return
 
@@ -762,6 +813,17 @@ const copyText = async (text: string) => {
   document.body.removeChild(textarea)
 }
 
+const handleTitleTokenCopy = async () => {
+  const token = getToken()
+  if (!token) {
+    showToast('未检测到 Token', '当前未登录,暂无可复制内容')
+    return
+  }
+
+  await copyText(token)
+  showToast('Token 已复制', '当前登录 Token 已复制到剪贴板')
+}
+
 const countHyphen = (text: string) => text.split('-').length - 1
 
 const applyLocationKeywordFilter = () => {
@@ -1037,6 +1099,9 @@ onBeforeUnmount(() => {
   if (countdownTimer !== null) {
     window.clearInterval(countdownTimer)
   }
+  if (toastTimer !== null) {
+    window.clearTimeout(toastTimer)
+  }
   document.removeEventListener('mousedown', handleDocumentClick)
   window.removeEventListener(AUTH_INVALID_EVENT, handleAuthInvalid)
   window.removeEventListener('resize', updateSelectWidths)
@@ -1168,6 +1233,33 @@ onBeforeUnmount(() => {
   text-shadow: 0 0 8px rgba(255, 255, 255, 0.08);
 }
 
+.title-copy-btn {
+  appearance: none;
+  border: none;
+  background: transparent;
+  padding: 0;
+  margin: 0;
+  font: inherit;
+  font-weight: inherit;
+  color: inherit;
+  cursor: pointer;
+  border-radius: 10px;
+  transition:
+    transform 0.18s ease,
+    opacity 0.18s ease,
+    color 0.18s ease;
+}
+
+.title-copy-btn:hover {
+  transform: translateY(-1px);
+  opacity: 0.92;
+}
+
+.title-copy-btn:focus-visible {
+  outline: 2px solid rgba(79, 140, 255, 0.55);
+  outline-offset: 4px;
+}
+
 .title-meta {
   font-size: 11px;
   font-weight: normal;
@@ -1216,6 +1308,70 @@ onBeforeUnmount(() => {
   margin-top: 3px;
 }
 
+.copy-toast {
+  position: fixed;
+  top: 18px;
+  right: 18px;
+  z-index: 1200;
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  min-width: 248px;
+  max-width: 360px;
+  padding: 12px 14px;
+  border: 1px solid var(--panel-border);
+  border-radius: 14px;
+  background:
+    linear-gradient(135deg, rgba(79, 140, 255, 0.16), rgba(38, 214, 171, 0.1)), var(--panel-bg);
+  box-shadow: var(--panel-shadow);
+  backdrop-filter: blur(10px);
+}
+
+.copy-toast-badge {
+  flex: 0 0 auto;
+  min-width: 48px;
+  height: 28px;
+  padding: 0 10px;
+  border-radius: 999px;
+  background: rgba(79, 140, 255, 0.14);
+  color: var(--text);
+  font-size: 11px;
+  font-weight: 700;
+  line-height: 28px;
+  text-align: center;
+}
+
+.copy-toast-content {
+  min-width: 0;
+}
+
+.copy-toast-title {
+  color: var(--text);
+  font-size: 13px;
+  font-weight: 700;
+  line-height: 1.2;
+}
+
+.copy-toast-text {
+  margin-top: 3px;
+  color: var(--text-muted);
+  font-size: 12px;
+  line-height: 1.35;
+}
+
+.toast-fade-enter-active,
+.toast-fade-leave-active {
+  transition:
+    opacity 0.2s ease,
+    transform 0.2s ease;
+}
+
+.toast-fade-enter-from,
+.toast-fade-leave-to {
+  opacity: 0;
+  transform: translateY(-8px) scale(0.98);
+}
+
 .title-meta-a,
 .title-meta-b,
 .title-meta-c {

+ 2 - 3
src/components/warehouse-layout-special-cells.ts

@@ -57,6 +57,8 @@ const SHARED_SPECIAL_CELLS: readonly WarehouseLayoutSpecialCell[] = [
   specialCell(8, 0, 'wall', '墙'),
   specialCell(19, 0, 'wall', '墙'),
   specialCell(19, 7, 'wall', '墙'),
+  specialCell(19, 15, 'wall', '墙'),
+  specialCell(19, 23, 'wall', '墙'),
   specialCell(29, 0, 'wall', '墙'),
   specialCell(29, 7, 'wall', '墙'),
   specialCell(29, 15, 'wall', '墙'),
@@ -66,9 +68,6 @@ const SHARED_SPECIAL_CELLS: readonly WarehouseLayoutSpecialCell[] = [
 ] as const
 
 const LEVEL_1_SPECIAL_CELLS: readonly WarehouseLayoutSpecialCell[] = [
-  // 一层补充墙体点位,按楼层库位坐标直接映射。
-  specialCell(19, 15, 'wall', '墙'),
-  specialCell(19, 23, 'wall', '墙'),
   // 一层作业点位按业务类型区分,便于前端统一配色和展示说明。
   specialCell(34, 1, 'workpoint', 'A1', 'inbound'),
   specialCell(34, 2, 'workpoint', 'A2', 'inbound'),