瀏覽代碼

登录接口

handy 1 月之前
父節點
當前提交
be37bbdc1a
共有 8 個文件被更改,包括 469 次插入65 次删除
  1. 61 26
      src/App.vue
  2. 22 0
      src/api/auth.ts
  3. 30 0
      src/api/location.ts
  4. 304 0
      src/components/LoginModal.vue
  5. 9 39
      src/components/WarehouseMap.vue
  6. 3 0
      src/config/index.ts
  7. 23 0
      src/types/index.ts
  8. 17 0
      src/utils/auth.ts

+ 61 - 26
src/App.vue

@@ -1,9 +1,13 @@
 <template>
   <div class="dashboard">
+    <LoginModal
+      :visible="showLoginModal"
+      @success="handleLoginSuccess"
+      @cancel="handleLoginCancel"
+    />
+
     <header class="header">
-      <h1 class="title">
-        库位平面图实时监控大屏
-      </h1>
+      <h1 class="title">库位平面图实时监控大屏</h1>
       <div class="controls">
         <span class="warehouse-label">仓库: {{ config.warehouse }}</span>
         <div class="level-selector">
@@ -16,30 +20,17 @@
             {{ level }}层
           </button>
         </div>
+        <button class="logout-btn" @click="handleLogout">退出</button>
       </div>
     </header>
 
     <main class="main-content">
-      <div
-        v-if="loading"
-        class="loading"
-      >
-        加载中...
-      </div>
-      <div
-        v-else-if="error"
-        class="error"
-      >
+      <div v-if="loading" class="loading">加载中...</div>
+      <div v-else-if="error" class="error">
         {{ error }}
       </div>
-      <div
-        v-else
-        class="map-container"
-      >
-        <WarehouseMap
-          :locations="locations"
-          :current-level="currentLevel"
-        />
+      <div v-else class="map-container">
+        <WarehouseMap :locations="locations" :current-level="currentLevel" />
       </div>
     </main>
 
@@ -71,12 +62,15 @@ import { ref, onMounted, computed } from 'vue'
 import { fetchLocationData } from './api/location'
 import type { LocationResourceDataVO } from './types'
 import WarehouseMap from './components/WarehouseMap.vue'
+import LoginModal from './components/LoginModal.vue'
 import { config } from './config'
+import { isAuthenticated, removeToken } from './utils/auth'
 
 const currentLevel = ref(1)
 const locations = ref<LocationResourceDataVO[]>([])
 const loading = ref(false)
 const error = ref('')
+const showLoginModal = ref(false)
 
 const levelRange = computed(() => {
   const levels = []
@@ -87,6 +81,11 @@ const levelRange = computed(() => {
 })
 
 const loadLocationData = async () => {
+  if (!isAuthenticated()) {
+    showLoginModal.value = true
+    return
+  }
+
   loading.value = true
   error.value = ''
   try {
@@ -95,8 +94,8 @@ const loadLocationData = async () => {
       locLevel: currentLevel.value
     })
     locations.value = data
-  } catch (err) {
-    error.value = '加载数据失败,请检查接口连接'
+  } catch (err: any) {
+    error.value = err.message || '加载数据失败,请检查接口连接'
     console.error('Failed to load location data:', err)
   } finally {
     loading.value = false
@@ -112,10 +111,29 @@ const getCategoryCount = (category: string) => {
   return computed(() => locations.value.filter((loc) => loc.category === category).length)
 }
 
-onMounted(() => {
+const handleLoginSuccess = () => {
+  showLoginModal.value = false
   loadLocationData()
-  // 自动刷新数据
-  setInterval(loadLocationData, config.refreshInterval)
+}
+
+const handleLoginCancel = () => {
+  // 不允许取消登录,必须登录才能使用
+}
+
+const handleLogout = () => {
+  removeToken()
+  locations.value = []
+  showLoginModal.value = true
+}
+
+onMounted(() => {
+  if (!isAuthenticated()) {
+    showLoginModal.value = true
+  } else {
+    loadLocationData()
+    // 自动刷新数据
+    setInterval(loadLocationData, config.refreshInterval)
+  }
 })
 </script>
 
@@ -188,6 +206,23 @@ onMounted(() => {
   font-weight: bold;
 }
 
+.logout-btn {
+  padding: 10px 20px;
+  background: rgba(255, 68, 68, 0.1);
+  border: 1px solid #ff4444;
+  color: #ff4444;
+  cursor: pointer;
+  font-size: 16px;
+  transition: all 0.3s;
+  border-radius: 4px;
+}
+
+.logout-btn:hover {
+  background: rgba(255, 68, 68, 0.2);
+  border-color: #ff6666;
+  color: #ff6666;
+}
+
 .main-content {
   flex: 1;
   padding: 20px;

+ 22 - 0
src/api/auth.ts

@@ -0,0 +1,22 @@
+import axios from 'axios'
+import type { ApiResponse, LoginRequest, LoginResponse } from '../types'
+import { config } from '../config'
+
+const authClient = axios.create({
+  baseURL: config.apiBaseUrl,
+  timeout: 10000,
+  headers: {
+    'Content-Type': 'application/json',
+    Source: 'web'
+  }
+})
+
+export const login = async (params: LoginRequest): Promise<LoginResponse> => {
+  const response = await authClient.post<ApiResponse<LoginResponse>>(config.loginApi, params)
+
+  if (response.data.code !== 200) {
+    throw new Error(response.data.message || '登录失败')
+  }
+
+  return response.data.data
+}

+ 30 - 0
src/api/location.ts

@@ -1,6 +1,7 @@
 import axios from 'axios'
 import type { ApiResponse, LocationRequest, LocationResourceDataVO } from '../types'
 import { config } from '../config'
+import { getToken } from '../utils/auth'
 
 const apiClient = axios.create({
   baseURL: config.apiBaseUrl,
@@ -10,6 +11,35 @@ const apiClient = axios.create({
   }
 })
 
+// 请求拦截器:添加 token 和 Source
+apiClient.interceptors.request.use(
+  (config) => {
+    const token = getToken()
+    if (token) {
+      config.headers.Authorization = token
+    }
+    config.headers.Source = 'web'
+    return config
+  },
+  (error) => {
+    return Promise.reject(error)
+  }
+)
+
+// 响应拦截器:处理业务错误
+apiClient.interceptors.response.use(
+  (response) => {
+    const { code, message } = response.data
+    if (code !== 200) {
+      return Promise.reject(new Error(message || '请求失败'))
+    }
+    return response
+  },
+  (error) => {
+    return Promise.reject(error)
+  }
+)
+
 export const fetchLocationData = async (
   params: LocationRequest
 ): Promise<LocationResourceDataVO[]> => {

+ 304 - 0
src/components/LoginModal.vue

@@ -0,0 +1,304 @@
+<template>
+  <div v-if="visible" class="modal-overlay" @click.self="handleCancel">
+    <div class="modal-content">
+      <div class="modal-header">
+        <h2>登录</h2>
+      </div>
+      <div class="modal-body">
+        <div v-if="error" class="error-message">{{ error }}</div>
+        <form @submit.prevent="handleSubmit">
+          <div class="form-group">
+            <label for="username">账号</label>
+            <input
+              id="username"
+              v-model="formData.username"
+              type="text"
+              placeholder="请输入账号"
+              required
+              autocomplete="username"
+            />
+          </div>
+          <div class="form-group">
+            <label for="password">密码</label>
+            <div class="password-input-wrapper">
+              <input
+                id="password"
+                v-model="formData.password"
+                :type="showPassword ? 'text' : 'password'"
+                placeholder="请输入密码"
+                required
+                autocomplete="current-password"
+              />
+              <button
+                type="button"
+                class="password-toggle"
+                @click="togglePasswordVisibility"
+                :title="showPassword ? '隐藏密码' : '显示密码'"
+              >
+                <svg
+                  v-if="!showPassword"
+                  xmlns="http://www.w3.org/2000/svg"
+                  width="20"
+                  height="20"
+                  viewBox="0 0 24 24"
+                  fill="none"
+                  stroke="currentColor"
+                  stroke-width="2"
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                >
+                  <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
+                  <circle cx="12" cy="12" r="3"></circle>
+                </svg>
+                <svg
+                  v-else
+                  xmlns="http://www.w3.org/2000/svg"
+                  width="20"
+                  height="20"
+                  viewBox="0 0 24 24"
+                  fill="none"
+                  stroke="currentColor"
+                  stroke-width="2"
+                  stroke-linecap="round"
+                  stroke-linejoin="round"
+                >
+                  <path
+                    d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
+                  ></path>
+                  <line x1="1" y1="1" x2="23" y2="23"></line>
+                </svg>
+              </button>
+            </div>
+          </div>
+          <div class="form-actions">
+            <button type="submit" class="btn-primary" :disabled="loading">
+              {{ loading ? '登录中...' : '登录' }}
+            </button>
+          </div>
+        </form>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive } from 'vue'
+import { login } from '../api/auth'
+import { setToken } from '../utils/auth'
+
+interface Props {
+  visible: boolean
+}
+
+interface Emits {
+  (e: 'success'): void
+  (e: 'cancel'): void
+}
+
+defineProps<Props>()
+const emit = defineEmits<Emits>()
+
+const formData = reactive({
+  username: '',
+  password: ''
+})
+
+const loading = ref(false)
+const error = ref('')
+const showPassword = ref(false)
+
+const togglePasswordVisibility = () => {
+  showPassword.value = !showPassword.value
+}
+
+const handleSubmit = async () => {
+  if (!formData.username || !formData.password) {
+    error.value = '请输入账号和密码'
+    return
+  }
+
+  loading.value = true
+  error.value = ''
+
+  try {
+    const response = await login({
+      username: formData.username,
+      password: formData.password
+    })
+
+    setToken(response.token)
+    emit('success')
+  } catch (err: any) {
+    error.value = err.message || '登录失败,请稍后重试'
+    console.error('Login failed:', err)
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleCancel = () => {
+  if (!loading.value) {
+    emit('cancel')
+  }
+}
+</script>
+
+<style scoped>
+.modal-overlay {
+  position: fixed;
+  top: 0;
+  left: 0;
+  right: 0;
+  bottom: 0;
+  background: rgba(0, 0, 0, 0.9);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  z-index: 1000;
+}
+
+.modal-content {
+  background: #1a1a1a;
+  border: 2px solid #00ff88;
+  border-radius: 8px;
+  width: 90%;
+  max-width: 400px;
+  box-shadow: 0 0 30px rgba(0, 255, 136, 0.3);
+}
+
+.modal-header {
+  padding: 20px;
+  border-bottom: 1px solid #333;
+}
+
+.modal-header h2 {
+  margin: 0;
+  color: #00ff88;
+  font-size: 24px;
+  text-align: center;
+}
+
+.modal-body {
+  padding: 30px 20px;
+}
+
+.error-message {
+  background: rgba(255, 68, 68, 0.15);
+  border: 1px solid #ff4444;
+  color: #ff6666;
+  padding: 12px 16px;
+  border-radius: 6px;
+  margin-bottom: 20px;
+  text-align: center;
+  font-size: 14px;
+  animation: shake 0.5s;
+  box-shadow: 0 0 15px rgba(255, 68, 68, 0.2);
+}
+
+@keyframes shake {
+  0%,
+  100% {
+    transform: translateX(0);
+  }
+  10%,
+  30%,
+  50%,
+  70%,
+  90% {
+    transform: translateX(-5px);
+  }
+  20%,
+  40%,
+  60%,
+  80% {
+    transform: translateX(5px);
+  }
+}
+
+.form-group {
+  margin-bottom: 20px;
+}
+
+.form-group label {
+  display: block;
+  color: #00ff88;
+  margin-bottom: 8px;
+  font-size: 14px;
+}
+
+.form-group input {
+  width: 100%;
+  padding: 12px;
+  background: rgba(255, 255, 255, 0.05);
+  border: 1px solid #333;
+  border-radius: 4px;
+  color: #fff;
+  font-size: 14px;
+  transition: all 0.3s;
+}
+
+.password-input-wrapper {
+  position: relative;
+}
+
+.password-input-wrapper input {
+  padding-right: 45px;
+}
+
+.password-toggle {
+  position: absolute;
+  right: 10px;
+  top: 50%;
+  transform: translateY(-50%);
+  background: none;
+  border: none;
+  color: #666;
+  cursor: pointer;
+  padding: 5px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  transition: color 0.3s;
+}
+
+.password-toggle:hover {
+  color: #00ff88;
+}
+
+.form-group input:focus {
+  outline: none;
+  border-color: #00ff88;
+  background: rgba(0, 255, 136, 0.05);
+}
+
+.form-group input::placeholder {
+  color: #666;
+}
+
+.form-actions {
+  margin-top: 30px;
+}
+
+.btn-primary {
+  width: 100%;
+  padding: 12px;
+  background: #00ff88;
+  border: none;
+  border-radius: 4px;
+  color: #000;
+  font-size: 16px;
+  font-weight: bold;
+  cursor: pointer;
+  transition: all 0.3s;
+}
+
+.btn-primary:hover:not(:disabled) {
+  background: #00dd77;
+  box-shadow: 0 0 20px rgba(0, 255, 136, 0.5);
+}
+
+.btn-primary:disabled {
+  opacity: 0.6;
+  cursor: not-allowed;
+}
+</style>

+ 9 - 39
src/components/WarehouseMap.vue

@@ -1,39 +1,21 @@
 <template>
   <div class="warehouse-map">
-    <div
-      v-if="gridData.length === 0"
-      class="no-data"
-    >
-      暂无数据
-    </div>
-    <div
-      v-else
-      class="map-grid"
-      :style="gridStyle"
-    >
+    <div v-if="gridData.length === 0" class="no-data">暂无数据</div>
+    <div v-else class="map-grid" :style="gridStyle">
       <div
         v-for="(cell, index) in gridData"
         :key="index"
         :class="['grid-cell', getCellClass(cell)]"
         :title="getCellTitle(cell)"
       >
-        <div
-          v-if="cell"
-          class="cell-content"
-        >
+        <div v-if="cell" class="cell-content">
           <div class="location-id">
             {{ cell.locationId }}
           </div>
-          <div
-            class="category-badge"
-            :style="getCategoryStyle(cell.category)"
-          >
+          <div class="category-badge" :style="getCategoryStyle(cell.category)">
             {{ cell.category }}
           </div>
-          <div
-            v-if="cell.containerCode"
-            class="container-code"
-          >
+          <div v-if="cell.containerCode" class="container-code">
             {{ cell.containerCode }}
           </div>
         </div>
@@ -42,31 +24,19 @@
 
     <div class="legend">
       <div class="legend-item">
-        <span
-          class="legend-color"
-          style="background: #00ff88"
-        />
+        <span class="legend-color" style="background: #00ff88" />
         <span>A类库位</span>
       </div>
       <div class="legend-item">
-        <span
-          class="legend-color"
-          style="background: #00aaff"
-        />
+        <span class="legend-color" style="background: #00aaff" />
         <span>B类库位</span>
       </div>
       <div class="legend-item">
-        <span
-          class="legend-color"
-          style="background: #ffaa00"
-        />
+        <span class="legend-color" style="background: #ffaa00" />
         <span>C类库位</span>
       </div>
       <div class="legend-item">
-        <span
-          class="legend-color"
-          style="background: #1a1a1a"
-        />
+        <span class="legend-color" style="background: #1a1a1a" />
         <span>过道</span>
       </div>
     </div>

+ 3 - 0
src/config/index.ts

@@ -3,6 +3,9 @@ export const config = {
   // API 基础地址
   apiBaseUrl: 'http://localhost:8114',
 
+  // 登录接口
+  loginApi: '/api/user/login',
+
   // 仓库代码
   warehouse: 'WH01',
 

+ 23 - 0
src/types/index.ts

@@ -14,9 +14,32 @@ export interface LocationResourceDataVO {
 export interface ApiResponse<T> {
   code: number
   data: T
+  message?: string
+  traceId?: string
 }
 
 export interface LocationRequest {
   warehouse: string
   locLevel: number
 }
+
+export interface LoginRequest {
+  username: string
+  password: string
+}
+
+export interface LoginResponse {
+  token: string
+  expirationTime: number
+  activate: boolean
+  userVO: {
+    name: string
+    avatar: string | null
+    id: number
+    temporary: boolean
+    certified: boolean
+    tags: string
+    needUpdatePassword: boolean
+  }
+  version: string
+}

+ 17 - 0
src/utils/auth.ts

@@ -0,0 +1,17 @@
+const TOKEN_KEY = 'auth_token'
+
+export const getToken = (): string | null => {
+  return localStorage.getItem(TOKEN_KEY)
+}
+
+export const setToken = (token: string): void => {
+  localStorage.setItem(TOKEN_KEY, token)
+}
+
+export const removeToken = (): void => {
+  localStorage.removeItem(TOKEN_KEY)
+}
+
+export const isAuthenticated = (): boolean => {
+  return !!getToken()
+}