Procházet zdrojové kódy

feat(web-app): 接入登录 API、新增 Http 封装与 dev 代理,完善登录态与导航

ligb před 3 týdny
rodič
revize
60218b077f

+ 5 - 0
web-app/src/api/auth.js

@@ -0,0 +1,5 @@
+import { http } from '@/utils/http'
+
+export function login({ username, password }) {
+  return http.post('/api/auth/login', { username, password }, { auth: false })
+}

+ 21 - 21
web-app/src/components/SideNav.vue

@@ -24,19 +24,19 @@ const router = useRouter()
 const route = useRoute()
 
 const groups = [
+  // {
+  //   label: '主导航',
+  //   divider: true,
+  //   items: [
+  //     { key: 'dashboard', label: '工作台', icon: '◉', to: '/' },
+  //     { key: 'board', label: '看板广场', icon: '▦' },
+  //     { key: 'watch', label: '自选股', icon: '★', badge: 12 },
+  //     { key: 'alert', label: '预警中心', icon: '⏰', badge: 3 },
+  //     { key: 'download', label: '数据下载', icon: '⬇' },
+  //   ],
+  // },
   {
-    label: '主导航',
-    divider: true,
-    items: [
-      { key: 'dashboard', label: '工作台', icon: '◉', to: '/' },
-      { key: 'board', label: '看板广场', icon: '▦' },
-      { key: 'watch', label: '自选股', icon: '★', badge: 12 },
-      { key: 'alert', label: '预警中心', icon: '⏰', badge: 3 },
-      { key: 'download', label: '数据下载', icon: '⬇' },
-    ],
-  },
-  {
-    label: '基本面 · 7 大板块',
+    label: '基本面',
     divider: true,
     items: [
       { key: 'macro', label: '宏观总览', icon: '●', to: '/fundamentals', match: 'fundamentals' },
@@ -49,15 +49,15 @@ const groups = [
       { key: 'pmi', label: '景气度 (PMI/BCI)', icon: '●' },
     ],
   },
-  {
-    label: '模型工具',
-    divider: false,
-    items: [
-      { key: 'gdp-now', label: 'GDP 周频预测', icon: '⚙' },
-      { key: 'cpi-fcst', label: '通胀预测模型', icon: '⚙' },
-      { key: 'pmi-mix', label: 'PMI 合成', icon: '⚙' },
-    ],
-  },
+  // {
+  //   label: '模型工具',
+  //   divider: false,
+  //   items: [
+  //     { key: 'gdp-now', label: 'GDP 周频预测', icon: '⚙' },
+  //     { key: 'cpi-fcst', label: '通胀预测模型', icon: '⚙' },
+  //     { key: 'pmi-mix', label: 'PMI 合成', icon: '⚙' },
+  //   ],
+  // },
 ]
 
 function isActive(item) {

+ 5 - 6
web-app/src/components/TopNavBar.vue

@@ -6,15 +6,14 @@
     </div>
 
     <nav class="nav-main">
-      <router-link to="/">首页</router-link>
       <a class="on">基本面</a>
       <a>政策面</a>
       <a>资金面</a>
       <a>债市分析</a>
       <a>权益分析</a>
       <a>多资产</a>
-      <a>研报</a>
-      <a>模型工具</a>
+      <!-- <a>研报</a>
+      <a>模型工具</a> -->
     </nav>
 
     <div class="search-box">
@@ -43,9 +42,9 @@ defineEmits(['logout'])
 
 const { auth } = useAuth()
 
-const displayName = computed(() => auth.account || '栗国斌')
-const avatarText = computed(() => (auth.account ? auth.account.charAt(0) : '栗'))
-const roleText = '投顾'
+const displayName = computed(() => auth.account)
+const avatarText = computed(() => (auth.account ? auth.account.charAt(0) : '空'))
+const roleText = ''
 </script>
 
 <style scoped>

+ 24 - 1
web-app/src/components/login/LoginFormCard.vue

@@ -50,7 +50,11 @@
           </button>
         </div>
 
-        <button class="btn-primary" type="submit">登 录 投 研</button>
+        <button class="btn-primary" type="submit" :disabled="loading">
+          {{ loading ? '登 录 中 …' : '登 录 投 研' }}
+        </button>
+
+        <p v-if="error" class="form-card__error" role="alert">{{ error }}</p>
       </form>
 
       <div class="form-card__foot">
@@ -72,6 +76,8 @@ import SystemMark from './SystemMark.vue'
 defineProps({
   account: { type: String, default: '' },
   password: { type: String, default: '' },
+  loading: { type: Boolean, default: false },
+  error: { type: String, default: '' },
 })
 const emit = defineEmits(['update:account', 'update:password', 'submit'])
 const showPwd = ref(false)
@@ -228,6 +234,23 @@ const showPwd = ref(false)
 
 .btn-primary:active { transform: translateY(0); }
 
+.btn-primary:disabled {
+  cursor: not-allowed;
+  filter: grayscale(0.2);
+  opacity: 0.7;
+  transform: none;
+  box-shadow: var(--login-shadow-btn);
+}
+
+.form-card__error {
+  margin: 10px 0 0;
+  font-size: clamp(11px, 0.95vw, 12px);
+  color: #c0392b;
+  text-align: center;
+  letter-spacing: 1px;
+  min-height: 1em;
+}
+
 .form-card__foot {
   display: flex;
   flex-wrap: wrap;

+ 64 - 8
web-app/src/store/auth.js

@@ -24,14 +24,58 @@ function saveToStorage(payload) {
 
 const state = reactive({
   token: '',
+  tokenType: 'Bearer',
+  expiresIn: 0,
+  userId: null,
+  username: '',
+  nickname: '',
   account: '',
-  // 登录态由 token 是否存在判定;isLoggedIn 为派生只读值
+  roles: [],
+  permissions: [],
 })
 
 const persisted = loadFromStorage()
-if (persisted?.token) {
-  state.token = persisted.token
-  state.account = persisted.account || ''
+if (persisted && (persisted.token || persisted.username)) {
+  state.token = persisted.token || ''
+  state.tokenType = persisted.tokenType || 'Bearer'
+  state.expiresIn = persisted.expiresIn || 0
+  state.userId = persisted.userId ?? null
+  state.username = persisted.username || ''
+  state.nickname = persisted.nickname || ''
+  state.account = persisted.account || persisted.username || ''
+  state.roles = Array.isArray(persisted.roles) ? persisted.roles : []
+  state.permissions = Array.isArray(persisted.permissions) ? persisted.permissions : []
+}
+
+export function getToken() {
+  return state.token
+}
+
+function persist() {
+  saveToStorage({
+    token: state.token,
+    tokenType: state.tokenType,
+    expiresIn: state.expiresIn,
+    userId: state.userId,
+    username: state.username,
+    nickname: state.nickname,
+    account: state.account,
+    roles: state.roles,
+    permissions: state.permissions,
+  })
+}
+
+function applyLoginPayload(payload) {
+  if (!payload) return
+  state.token = payload.token || ''
+  state.tokenType = payload.tokenType || 'Bearer'
+  state.expiresIn = payload.expiresIn ?? 0
+  state.userId = payload.id ?? null
+  state.username = payload.username || ''
+  state.nickname = payload.nickname || ''
+  state.account = payload.username || ''
+  state.roles = Array.isArray(payload.roles) ? payload.roles : []
+  state.permissions = Array.isArray(payload.permissions) ? payload.permissions : []
 }
 
 export function useAuth() {
@@ -39,14 +83,26 @@ export function useAuth() {
     auth: readonly(state),
     isLoggedIn: () => Boolean(state.token),
     login(payload) {
-      state.token = payload.token
-      state.account = payload.account || ''
-      saveToStorage({ token: state.token, account: state.account })
+      applyLoginPayload(payload)
+      persist()
     },
     logout() {
       state.token = ''
+      state.tokenType = 'Bearer'
+      state.expiresIn = 0
+      state.userId = null
+      state.username = ''
+      state.nickname = ''
       state.account = ''
+      state.roles = []
+      state.permissions = []
       saveToStorage(null)
     },
+    hasRole(code) {
+      return state.roles.includes(code)
+    },
+    hasPermission(code) {
+      return state.permissions.includes(code)
+    },
   }
-}
+}

+ 88 - 0
web-app/src/utils/http.js

@@ -0,0 +1,88 @@
+import { getToken } from '@/store/auth'
+
+const DEFAULT_BASE = ''
+const DEFAULT_TIMEOUT = 15000
+
+export class HttpError extends Error {
+  constructor({ code = 'INTERNAL_ERROR', message = '请求失败', status = 0, payload = null }) {
+    super(message)
+    this.name = 'HttpError'
+    this.code = code
+    this.status = status
+    this.payload = payload
+  }
+}
+
+function resolveBaseURL() {
+  const envBase = import.meta.env.VITE_API_BASE
+  return (envBase ?? DEFAULT_BASE).replace(/\/+$/, '')
+}
+
+function buildURL(path, query) {
+  const base = resolveBaseURL()
+  const cleanPath = path.startsWith('/') ? path : `/${path}`
+  if (!query) return `${base}${cleanPath}`
+  const params = new URLSearchParams()
+  Object.entries(query).forEach(([k, v]) => {
+    if (v === undefined || v === null) return
+    params.append(k, String(v))
+  })
+  const qs = params.toString()
+  return qs ? `${base}${cleanPath}?${qs}` : `${base}${cleanPath}`
+}
+
+async function parseBody(response) {
+  const contentType = response.headers.get('content-type') || ''
+  if (contentType.includes('application/json')) {
+    try { return await response.json() } catch { return null }
+  }
+  try { return await response.text() } catch { return null }
+}
+
+export async function request(path, { method = 'GET', body, query, headers, timeout = DEFAULT_TIMEOUT, auth = true } = {}) {
+  const controller = new AbortController()
+  const timer = setTimeout(() => controller.abort(), timeout)
+
+  const finalHeaders = {
+    Accept: 'application/json',
+    ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
+    ...headers,
+  }
+  if (auth) {
+    const token = getToken()
+    if (token) finalHeaders.Authorization = `Bearer ${token}`
+  }
+
+  let response
+  try {
+    response = await fetch(buildURL(path, query), {
+      method,
+      headers: finalHeaders,
+      body: body !== undefined ? JSON.stringify(body) : undefined,
+      signal: controller.signal,
+    })
+  } catch (err) {
+    clearTimeout(timer)
+    if (err?.name === 'AbortError') {
+      throw new HttpError({ code: 'TIMEOUT', message: '请求超时,请稍后重试', status: 0 })
+    }
+    throw new HttpError({ code: 'NETWORK_ERROR', message: '网络异常,请检查连接', status: 0 })
+  }
+  clearTimeout(timer)
+
+  const data = await parseBody(response)
+  if (response.ok) {
+    return data
+  }
+
+  const errCode = (data && typeof data === 'object' && data.code) || `HTTP_${response.status}`
+  const errMsg = (data && typeof data === 'object' && data.message) || response.statusText || '请求失败'
+  throw new HttpError({ code: errCode, message: errMsg, status: response.status, payload: data })
+}
+
+export const http = {
+  get: (path, opts = {}) => request(path, { ...opts, method: 'GET' }),
+  post: (path, body, opts = {}) => request(path, { ...opts, method: 'POST', body }),
+  put: (path, body, opts = {}) => request(path, { ...opts, method: 'PUT', body }),
+  del: (path, opts = {}) => request(path, { ...opts, method: 'DELETE' }),
+}

+ 35 - 7
web-app/src/views/Login.vue

@@ -5,15 +5,19 @@
       class="login-page__form"
       v-model:account="form.account"
       v-model:password="form.password"
+      :loading="submitting"
+      :error="errorMsg"
       @submit="onSubmit"
     />
   </div>
 </template>
 
 <script setup>
-import { reactive } from 'vue'
+import { reactive, ref } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { useAuth } from '@/store/auth'
+import { login as loginApi } from '@/api/auth'
+import { HttpError } from '@/utils/http'
 import LoginBrandSide from '@/components/login/LoginBrandSide.vue'
 import LoginFormCard from '@/components/login/LoginFormCard.vue'
 import '@/assets/login/tokens.css'
@@ -27,11 +31,35 @@ const form = reactive({
   password: '',
 })
 
-function onSubmit() {
-  if (!form.account || !form.password) return
-  login({ token: `mock-${Date.now()}`, account: form.account })
-  const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
-  router.replace(redirect)
+const submitting = ref(false)
+const errorMsg = ref('')
+
+async function onSubmit() {
+  if (submitting.value) return
+  if (!form.account || !form.password) {
+    errorMsg.value = '请输入工号与密码'
+    return
+  }
+  errorMsg.value = ''
+  submitting.value = true
+  try {
+    const payload = await loginApi({ username: form.account, password: form.password })
+    if (!payload || payload.success === false || (payload.code !== undefined && payload.code !== 0 && payload.code !== 200)) {
+      errorMsg.value = payload?.message || '登录失败,请稍后重试'
+      return
+    }
+    login(payload)
+    const redirect = typeof route.query.redirect === 'string' ? route.query.redirect : '/'
+    router.replace(redirect)
+  } catch (err) {
+    if (err instanceof HttpError) {
+      errorMsg.value = err.message || '登录失败,请稍后重试'
+    } else {
+      errorMsg.value = '登录失败,请稍后重试'
+    }
+  } finally {
+    submitting.value = false
+  }
 }
 </script>
 
@@ -61,4 +89,4 @@ function onSubmit() {
   .login-page__brand,
   .login-page__form { flex: 1 0 100%; }
 }
-</style>
+</style>

+ 7 - 0
web-app/vite.config.js

@@ -31,6 +31,13 @@ export default defineConfig(({ mode }) => {
         // qiankun 通过 fetch 拉取子应用资源,须允许跨域
         'Access-Control-Allow-Origin': '*',
       },
+      proxy: {
+        // 开发环境将 /api 代理到后端,避免浏览器跨域
+        '/api': {
+          target: process.env.VITE_API_TARGET || 'http://localhost:8080',
+          changeOrigin: true,
+        },
+      },
     },
     build: {
       target: 'es2015',