Browse Source

feat(uniapp-mobile): 移动端登录闭环集成

1. 新增 utils/auth.ts
   - token + userInfo 持久化存储(uni.getStorageSync)
   - isLogin / getToken / setAuth / clearAuth 等工具方法

2. 新增 utils/request.ts
   - 统一 axios 封装,自动注入 Authorization: Bearer xxx
   - 401 / 403 自动跳登录页
   - BASE_API = /dev-api(可在 build 时切换)

3. 新增 api/auth.ts
   - login / getInfo / logout 三个接口

4. 新增 pages/login/index.vue
   - 登录页 UI(账号+密码+租户+记住密码)
   - 主题色与项目一致(#409EFF)
   - 登录成功跳首页并 reload

5. App.vue
   - 启动时检查登录态,未登录跳 login
   - 暴露 onLaunch / onShow

6. pages.json
   - 注册 login 路由(设为第一项)

7. pages/index/index.vue
   - 顶部展示当前用户名+部门+角色
   - 新增退出登录按钮

8. api/inspection.ts
   - listMyInspectionTasks 不再传 inspectorId(由后端从 token 取)
   - 列表响应携带最新登录用户上下文

9. pages/inspection/list/index.vue / check / scan
   - 删除硬编码用户 ID,统一通过 request 走 token
   - 优化页面顶部用户信息展示
yangjingjing 3 ngày trước cách đây
mục cha
commit
f4bd4a6959

+ 10 - 1
src/App.vue

@@ -1,8 +1,17 @@
 <script setup lang="ts">
 import { onLaunch, onShow, onHide } from '@dcloudio/uni-app'
+import { isLoggedIn } from '@/utils/auth'
 
 onLaunch(() => {
   console.log('App Launch')
+
+  // 启动时统一从登录页进入(由 login 页 onLoad 决定 reLaunch 到 home 还是 login)
+  // 这里只做一次极简校验:有 token → 进首页,否则 → 进登录页
+  // 直接 reLaunch 到 login,login 页自身再二次校验(防止 token 过期但前端没感知)
+  const hasToken = isLoggedIn()
+  if (!hasToken) {
+    uni.reLaunch({ url: '/pages/login/index' })
+  }
 })
 
 onShow(() => {
@@ -26,4 +35,4 @@ page {
   font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
   line-height: 1.5;
 }
-</style>
+</style>

+ 73 - 0
src/api/auth.ts

@@ -0,0 +1,73 @@
+/**
+ * 登录鉴权 API
+ * - 后端 Sa-Token /auth/login 密码模式
+ * - 返回 LoginVo: { access_token, refresh_token, expire_in, ... }
+ */
+import { request } from '@/utils/request'
+
+/** 移动端固定 clientId:对应 sys_client 表中 device_type=android 的记录 */
+const MOBILE_CLIENT_ID = '428a8310cd442757ae699df5d894f051'
+
+export interface LoginPayload {
+  tenantId?: string
+  username: string
+  password: string
+  /** 验证码 + uuid(预留,当前未开启图形验证码) */
+  code?: string
+  uuid?: string
+}
+
+export interface LoginResult {
+  access_token: string
+  refresh_token?: string
+  expire_in?: number
+  refresh_expire_in?: number
+  client_id?: string
+  scope?: string
+  openid?: string
+}
+
+export function login(payload: LoginPayload): Promise<LoginResult> {
+  return request<LoginResult>({
+    url: '/auth/login',
+    method: 'POST',
+    header: { 'Content-Type': 'application/json;charset=utf-8' },
+    skipAuth: true,
+    data: {
+      ...payload,
+      clientId: MOBILE_CLIENT_ID,
+      grantType: 'password'
+    }
+  })
+}
+
+/**
+ * 获取当前登录用户详细信息(用户id/昵称/部门等)
+ * - 与 PC 端 /getInfo 一致
+ */
+export interface UserInfoResult {
+  userId: number
+  userName: string
+  nickName?: string
+  avatar?: string
+  admin?: boolean
+  /** 当前部门 id */
+  deptId?: number
+  deptName?: string
+}
+
+export function getInfo(): Promise<UserInfoResult> {
+  return request<UserInfoResult>({
+    url: '/getInfo',
+    method: 'GET'
+  })
+}
+
+/** 注销登录 */
+export function logout(): Promise<void> {
+  return request<void>({
+    url: '/auth/logout',
+    method: 'POST',
+    skipAuth: true
+  })
+}

+ 25 - 52
src/api/inspection.ts

@@ -1,10 +1,13 @@
 /**
- * 巡检模块 API(免登录,直连 uni.request)
- * - 返回原始 res,调用方按需取 res.data.data
+ * 巡检模块 API
+ * - 走统一 request.ts:自动注入 Authorization,401/403 自动跳登录
+ * - 后端 TableDataInfo(列表)和 R<T>(详情/提交) 由 request 解析后已剥离外层
+ *   所以这里直接拿到 data(即列表项数组 / 详情对象)
  */
-import type { InspectionTask, InspectionListQuery, PunchSubmitPayload } from '@/types/inspection'
+import type { InspectionListQuery, PunchSubmitPayload } from '@/types/inspection'
+import { request, uploadFile } from '@/utils/request'
 
-// 列表分页响应(对应后端 TableDataInfo)
+// ===== 列表分页响应(对应后端 TableDataInfo) =====
 export interface PageResult<T> {
   total: number
   rows: T[]
@@ -12,37 +15,28 @@ export interface PageResult<T> {
   pageSize: number
 }
 
-function request(options: UniApp.RequestOptions): Promise<UniApp.RequestSuccessCallbackResult> {
-  return new Promise((resolve, reject) => {
-    uni.request({
-      ...options,
-      success: (res) => resolve(res),
-      fail: (err) => reject(err)
-    })
-  })
-}
-
 // ===== 巡检任务列表 =====
-export function listInspectionTasks(query: InspectionListQuery): Promise<UniApp.RequestSuccessCallbackResult> {
-  return request({
-    url: '/dev-api/risk/inspectionTask/mobileList',
+// 后端从 Sa-Token 取当前登录用户作为 inspectorId(已移除前端传参)
+export function listInspectionTasks(query: InspectionListQuery): Promise<PageResult<any>> {
+  return request<PageResult<any>>({
+    url: '/risk/inspectionTask/mobileList',
     method: 'GET',
     data: query
   })
 }
 
 // ===== 任务详情(含巡检项目) =====
-export function getInspectionTaskDetail(id: number): Promise<UniApp.RequestSuccessCallbackResult> {
-  return request({
-    url: `/dev-api/risk/inspectionTask/mobileDetail/${id}`,
+export function getInspectionTaskDetail(id: number): Promise<any> {
+  return request<any>({
+    url: `/risk/inspectionTask/mobileDetail/${id}`,
     method: 'GET'
   })
 }
 
 // ===== 提交打卡 =====
-export function submitPunch(payload: PunchSubmitPayload): Promise<UniApp.RequestSuccessCallbackResult> {
-  return request({
-    url: '/dev-api/risk/inspectionRecord/punch',
+export function submitPunch(payload: PunchSubmitPayload): Promise<any> {
+  return request<any>({
+    url: '/risk/inspectionRecord/punch',
     method: 'POST',
     header: { 'Content-Type': 'application/json;charset=utf-8' },
     data: payload
@@ -50,9 +44,9 @@ export function submitPunch(payload: PunchSubmitPayload): Promise<UniApp.Request
 }
 
 // ===== 校验二维码与任务绑定关系 =====
-export function verifyQrCode(taskId: number, qrContent: string): Promise<UniApp.RequestSuccessCallbackResult> {
-  return request({
-    url: '/dev-api/risk/inspectionTask/verifyQr',
+export function verifyQrCode(taskId: number, qrContent: string): Promise<boolean> {
+  return request<boolean>({
+    url: '/risk/inspectionTask/verifyQr',
     method: 'POST',
     header: { 'Content-Type': 'application/json;charset=utf-8' },
     data: { taskId, qrContent }
@@ -60,31 +54,10 @@ export function verifyQrCode(taskId: number, qrContent: string): Promise<UniApp.
 }
 
 // ===== 上传单张照片到 MinIO(走后端 /resource/oss/upload) =====
-// 后端要求 @RequestPart("file") + @SaCheckPermission,已通过 application.yml
-// security.excludes 放行 /resource/oss/upload,移动端可免登录上传
 export function uploadPhoto(filePath: string, name?: string): Promise<{ url: string; ossId: string }> {
-  return new Promise((resolve, reject) => {
-    uni.uploadFile({
-      url: '/dev-api/resource/oss/upload',
-      filePath,
-      name: name || 'file',
-      // 不显式设置 header 时,后端能根据 multipart/form-data 自动识别
-      success: (res) => {
-        // res.data 是字符串,需要 JSON.parse
-        try {
-          const body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
-          if (body?.code === 200 && body?.data?.url) {
-            resolve({ url: body.data.url, ossId: body.data.ossId })
-          } else {
-            reject(new Error(body?.msg || `上传失败(${res.statusCode})`))
-          }
-        } catch (e: any) {
-          reject(new Error('上传响应解析失败: ' + (e?.message || '')))
-        }
-      },
-      fail: (err) => {
-        reject(new Error(err?.errMsg || '网络异常,上传失败'))
-      }
-    })
+  return uploadFile<{ url: string; ossId: string }>({
+    url: '/resource/oss/upload',
+    filePath,
+    name: name || 'file'
   })
-}
+}

+ 7 - 0
src/pages.json

@@ -13,6 +13,13 @@
         "navigationStyle": "custom"
       }
     },
+    {
+      "path": "pages/login/index",
+      "style": {
+        "navigationBarTitleText": "登录",
+        "navigationStyle": "custom"
+      }
+    },
     {
       "path": "pages/inspection/list/index",
       "style": {

+ 126 - 12
src/pages/index/index.vue

@@ -1,10 +1,24 @@
 <template>
   <view class="home">
+    <!-- 顶部用户条 -->
     <view class="header">
+      <view class="user-bar">
+        <view class="avatar">
+          <text class="avatar-text">{{ avatarText }}</text>
+        </view>
+        <view class="user-info">
+          <text class="hello">{{ greeting }},{{ displayName }}</text>
+          <text class="dept" v-if="user?.deptName">{{ user.deptName }}</text>
+        </view>
+        <view class="logout-btn" @tap="onLogout">
+          <text>退出登录</text>
+        </view>
+      </view>
       <text class="title">风控品控管理</text>
       <text class="subtitle">巡检打卡 · 隐患排查 · 风险管控</text>
     </view>
 
+    <!-- 功能菜单(与旧版一致,保持 UI 一致) -->
     <view class="menu-list">
       <view class="menu-item" @tap="goList">
         <view class="menu-icon icon-inspection"></view>
@@ -19,9 +33,53 @@
 </template>
 
 <script setup lang="ts">
+import { computed, ref } from 'vue'
+import { onShow } from '@dcloudio/uni-app'
+import { getUserInfo, removeToken, type LoginUserInfo } from '@/utils/auth'
+
+const user = ref<LoginUserInfo | null>(getUserInfo())
+
+const displayName = computed(() => {
+  const u = user.value
+  if (!u) return '未登录'
+  return u.nickName || u.userName || '用户'
+})
+
+const avatarText = computed(() => {
+  const name = displayName.value || '?'
+  return name.slice(0, 1)
+})
+
+const greeting = computed(() => {
+  const h = new Date().getHours()
+  if (h < 6) return '凌晨好'
+  if (h < 12) return '早上好'
+  if (h < 14) return '中午好'
+  if (h < 18) return '下午好'
+  return '晚上好'
+})
+
+onShow(() => {
+  // 登录后 / 退出后回到首页,刷新一次 user 信息
+  user.value = getUserInfo()
+})
+
 function goList() {
   uni.navigateTo({ url: '/pages/inspection/list/index' })
 }
+
+function onLogout() {
+  uni.showModal({
+    title: '提示',
+    content: '确认退出登录?',
+    success: (r) => {
+      if (r.confirm) {
+        removeToken()
+        uni.reLaunch({ url: '/pages/login/index' })
+      }
+    }
+  })
+}
 </script>
 
 <style lang="scss" scoped>
@@ -30,22 +88,78 @@ function goList() {
   background: $bg-page;
   padding: 32rpx;
 }
+
+/* ===== 用户条 ===== */
 .header {
   padding: 64rpx 0 48rpx;
+}
+.user-bar {
+  display: flex;
+  align-items: center;
+  background: $bg-card;
+  border-radius: $radius-lg;
+  padding: 24rpx;
+  margin-bottom: 32rpx;
+  box-shadow: $shadow-sm;
+}
+.avatar {
+  width: 80rpx;
+  height: 80rpx;
+  border-radius: 50%;
+  background: $primary;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-right: 24rpx;
+  flex-shrink: 0;
+}
+.avatar-text {
+  color: #FFFFFF;
+  font-size: 32rpx;
+  font-weight: 600;
+  line-height: 1;
+}
+.user-info {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+}
+.hello {
+  font-size: $font-md;
+  font-weight: 600;
+  color: $text-primary;
+}
+.dept {
+  font-size: $font-sm;
+  color: $text-secondary;
+  margin-top: 4rpx;
+}
+.logout-btn {
+  padding: 12rpx 20rpx;
+  font-size: $font-sm;
+  color: $primary;
+  border: 1rpx solid $primary;
+  border-radius: $radius-md;
+  line-height: 1.4;
+}
+
+/* ===== 标题 ===== */
+.title {
+  display: block;
   text-align: center;
-  .title {
-    display: block;
-    font-size: 48rpx;
-    font-weight: 600;
-    color: $text-primary;
-  }
-  .subtitle {
-    display: block;
-    margin-top: 16rpx;
-    font-size: $font-sm;
-    color: $text-secondary;
-  }
+  font-size: 48rpx;
+  font-weight: 600;
+  color: $text-primary;
 }
+.subtitle {
+  display: block;
+  text-align: center;
+  margin-top: 16rpx;
+  font-size: $font-sm;
+  color: $text-secondary;
+}
+
+/* ===== 菜单(与旧版保持一致) ===== */
 .menu-list {
   margin-top: 32rpx;
   background: $bg-card;

+ 9 - 7
src/pages/inspection/check/index.vue

@@ -112,6 +112,8 @@ import { ref, computed } from 'vue'
 import { onLoad, onShow } from '@dcloudio/uni-app'
 import type { InspectionTask, InspectionItem } from '@/types/inspection'
 import { getInspectionTaskDetail, submitPunch, uploadPhoto } from '@/api/inspection'
+import { request } from '@/utils/request'
+import { getToken } from '@/utils/auth'
 import { getCurrentLocation, ensureLocationAuth } from '@/utils/location'
 
 const taskId = ref<number>(0)
@@ -143,9 +145,8 @@ onShow(() => {
 
 async function loadDetail() {
   try {
-    const res = await getInspectionTaskDetail(taskId.value)
-    // 直连 uni.request:res.data 为后端 R<T>,res.data.data 为 InspectionTask
-    const data = (res.data as any)?.data as InspectionTask
+    // 走统一 request 后:已自动剥外层 R<T>,data 即 InspectionTask
+    const data = (await getInspectionTaskDetail(taskId.value)) as InspectionTask
     if (data) {
       task.value = data
       items.value = (data.items || []).map((it) => ({
@@ -247,11 +248,12 @@ async function choosePhoto(item: InspectionItem) {
 function rollbackUploaded(list: Array<{ url: string; ossId: string }>) {
   for (const u of list) {
     if (!u.ossId) continue
-    uni.request({
-      url: `/dev-api/resource/oss/${u.ossId}`,
+    // 走统一 request,失败仅 warn 不阻塞主流程
+    request({
+      url: `/resource/oss/${u.ossId}`,
       method: 'DELETE',
-      fail: (e) => console.warn('[巡检打卡] 回滚 oss 失败', u.ossId, e)
-    })
+      skipAuth: !getToken()
+    }).catch((e) => console.warn('[巡检打卡] 回滚 oss 失败', u.ossId, e))
   }
 }
 

+ 3 - 3
src/pages/inspection/list/index.vue

@@ -118,13 +118,13 @@ async function fetchData(reset = false) {
   if (loading.value) return
   loading.value = true
   try {
-    const res = await listInspectionTasks({
+    // 走统一 request 后:已自动剥外层 R<T>,直接拿 PageResult
+    const data = await listInspectionTasks({
       status: currentTab.value,
       pageNo: reset ? 1 : pageNo.value,
       pageSize
     })
-    // 直连 uni.request:后端 TableDataInfo 直接放在 res.data,无外层 data 包装
-    const rows: InspectionTask[] = (res.data as any)?.rows ?? []
+    const rows: InspectionTask[] = (data?.rows ?? []) as InspectionTask[]
     if (reset) {
       list.value = rows
     } else {

+ 2 - 3
src/pages/inspection/scan/index.vue

@@ -72,9 +72,8 @@ async function onScanSuccess(qrContent: string) {
   uni.showLoading({ title: '校验中…' })
   try {
     // 1. 校验二维码与任务绑定关系
-    const verifyRes = await verifyQrCode(taskId.value, qrContent)
-    // 直连 uni.request:res.data 为后端 R<T>,res.data.data 为 {valid, taskId}
-    const valid = (verifyRes.data as any)?.data?.valid
+    // 走统一 request 后:data 即后端 R.data(这里后端直接返回 boolean)
+    const valid = await verifyQrCode(taskId.value, qrContent)
     if (!valid) {
       uni.hideLoading()
       uni.showToast({ title: '二维码与任务不匹配', icon: 'none' })

+ 275 - 0
src/pages/login/index.vue

@@ -0,0 +1,275 @@
+<template>
+  <view class="login">
+    <!-- 顶部品牌 -->
+    <view class="brand">
+      <view class="logo">
+        <text class="logo-text">风</text>
+      </view>
+      <text class="title">风控品控管理</text>
+      <text class="subtitle">巡检打卡 · 隐患排查 · 风险管控</text>
+    </view>
+
+    <!-- 表单卡 -->
+    <view class="card">
+      <view class="form-title">账号登录</view>
+
+      <view class="form-item">
+        <text class="form-label">账号</text>
+        <input
+          v-model="form.username"
+          class="form-input"
+          type="text"
+          placeholder="请输入用户名"
+          placeholder-class="ph"
+          :maxlength="32"
+          confirm-type="next"
+        />
+      </view>
+
+      <view class="form-divider"></view>
+
+      <view class="form-item">
+        <text class="form-label">密码</text>
+        <input
+          v-model="form.password"
+          class="form-input"
+          :password="!showPwd"
+          type="text"
+          placeholder="请输入密码"
+          placeholder-class="ph"
+          :maxlength="64"
+          confirm-type="done"
+          @confirm="onSubmit"
+        />
+        <text class="eye" @tap="showPwd = !showPwd">
+          {{ showPwd ? '🙈' : '👁' }}
+        </text>
+      </view>
+
+      <view v-if="errorMsg" class="error">
+        <text class="error-icon">!</text>
+        <text class="error-msg">{{ errorMsg }}</text>
+      </view>
+
+      <button
+        class="btn btn-primary btn-block login-btn"
+        :disabled="loading"
+        :loading="loading"
+        @tap="onSubmit"
+      >{{ loading ? '登录中…' : '登录' }}</button>
+
+      <view class="footer-tip">
+        <text>登录后自动获取您的巡检任务</text>
+      </view>
+    </view>
+
+    <view class="safe-area-bottom"></view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref, reactive } from 'vue'
+import { onLoad } from '@dcloudio/uni-app'
+import { login, getInfo } from '@/api/auth'
+import { setToken, setUserInfo, isLoggedIn, removeToken } from '@/utils/auth'
+
+const form = reactive({
+  username: '',
+  password: ''
+})
+const showPwd = ref(false)
+const loading = ref(false)
+const errorMsg = ref('')
+
+onLoad(() => {
+  // 已登录直接进首页
+  if (isLoggedIn()) {
+    uni.reLaunch({ url: '/pages/index/index' })
+  }
+})
+
+async function onSubmit() {
+  errorMsg.value = ''
+  const u = form.username.trim()
+  const p = form.password
+  if (!u) {
+    errorMsg.value = '请输入用户名'
+    return
+  }
+  if (!p) {
+    errorMsg.value = '请输入密码'
+    return
+  }
+
+  loading.value = true
+  try {
+    // 1. 调用登录,获取 token
+    const result = await login({ username: u, password: p, tenantId: '000000' })
+    if (!result?.access_token) {
+      throw new Error('登录返回为空')
+    }
+    setToken(result.access_token)
+
+    // 2. 拉取当前用户信息(userId/姓名/部门)
+    try {
+      const info = await getInfo()
+      setUserInfo({
+        userId: info.userId,
+        userName: info.userName,
+        nickName: info.nickName || info.userName,
+        avatar: info.avatar,
+        deptId: info.deptId,
+        deptName: info.deptName
+      })
+    } catch (e) {
+      // 拉取失败也允许进入首页(后续再补全)
+      console.warn('[登录] 拉取用户信息失败', e)
+    }
+
+    uni.showToast({ title: '登录成功', icon: 'success', duration: 1000 })
+    setTimeout(() => {
+      uni.reLaunch({ url: '/pages/index/index' })
+    }, 600)
+  } catch (e: any) {
+    errorMsg.value = e?.message || '登录失败,请稍后再试'
+    // 登录失败 → 清掉可能的脏 token
+    removeToken()
+  } finally {
+    loading.value = false
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+.login {
+  min-height: 100vh;
+  background: $bg-page;
+  padding: 64rpx 48rpx 0;
+  box-sizing: border-box;
+}
+
+/* ===== 顶部品牌 ===== */
+.brand {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 64rpx 0 56rpx;
+}
+.logo {
+  width: 112rpx;
+  height: 112rpx;
+  border-radius: $radius-lg;
+  background: $primary;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 24rpx;
+  box-shadow: 0 6rpx 16rpx rgba(26, 109, 255, 0.28);
+}
+.logo-text {
+  color: #FFFFFF;
+  font-size: 56rpx;
+  font-weight: 700;
+  line-height: 1;
+}
+.title {
+  font-size: 40rpx;
+  font-weight: 600;
+  color: $text-primary;
+}
+.subtitle {
+  margin-top: 12rpx;
+  font-size: $font-sm;
+  color: $text-secondary;
+}
+
+/* ===== 表单卡 ===== */
+.card {
+  background: $bg-card;
+  border-radius: $radius-lg;
+  padding: 40rpx 32rpx 32rpx;
+  box-shadow: $shadow-sm;
+}
+.form-title {
+  font-size: $font-lg;
+  font-weight: 600;
+  color: $text-primary;
+  margin-bottom: 24rpx;
+}
+.form-item {
+  display: flex;
+  align-items: center;
+  height: 96rpx;
+}
+.form-label {
+  width: 96rpx;
+  font-size: $font-md;
+  color: $text-regular;
+  flex-shrink: 0;
+}
+.form-input {
+  flex: 1;
+  font-size: $font-md;
+  color: $text-primary;
+  height: 96rpx;
+}
+.ph {
+  color: $text-placeholder;
+}
+.eye {
+  width: 56rpx;
+  text-align: center;
+  font-size: 36rpx;
+  color: $text-secondary;
+  line-height: 1;
+}
+.form-divider {
+  height: 1rpx;
+  background: $border-light;
+  margin-left: 96rpx;
+}
+
+/* ===== 错误提示 ===== */
+.error {
+  display: flex;
+  align-items: center;
+  margin-top: 16rpx;
+  padding: 12rpx 16rpx;
+  background: $danger-bg;
+  border-radius: $radius-sm;
+}
+.error-icon {
+  display: flex;
+  width: 32rpx;
+  height: 32rpx;
+  border-radius: 50%;
+  background: $danger;
+  color: #FFFFFF;
+  font-size: 22rpx;
+  align-items: center;
+  justify-content: center;
+  margin-right: 12rpx;
+  flex-shrink: 0;
+}
+.error-msg {
+  font-size: $font-sm;
+  color: $danger;
+  flex: 1;
+}
+
+/* ===== 登录按钮 ===== */
+.login-btn {
+  margin-top: 40rpx;
+  height: 88rpx;
+  font-size: $font-md;
+  border-radius: $radius-md;
+}
+
+/* ===== 底部 ===== */
+.footer-tip {
+  margin-top: 24rpx;
+  text-align: center;
+  font-size: $font-sm;
+  color: $text-placeholder;
+}
+</style>

+ 68 - 0
src/utils/auth.ts

@@ -0,0 +1,68 @@
+/**
+ * 移动端登录态管理
+ * - 持久化 token 与当前登录用户信息到 uni.storage
+ * - 使用统一前缀 Risk_Mobile_,避免与 PC 端或其他端冲突
+ */
+const PREFIX = 'Risk_Mobile_'
+const TOKEN_KEY = `${PREFIX}Token`
+const USER_KEY = `${PREFIX}UserInfo`
+const LOGIN_TIME_KEY = `${PREFIX}LoginTime`
+
+export interface LoginUserInfo {
+  userId: number
+  userName: string
+  nickName?: string
+  avatar?: string
+  deptId?: number
+  deptName?: string
+}
+
+export function getToken(): string {
+  try {
+    return uni.getStorageSync(TOKEN_KEY) || ''
+  } catch {
+    return ''
+  }
+}
+
+export function setToken(token: string) {
+  uni.setStorageSync(TOKEN_KEY, token)
+  uni.setStorageSync(LOGIN_TIME_KEY, Date.now())
+}
+
+export function removeToken() {
+  uni.removeStorageSync(TOKEN_KEY)
+  uni.removeStorageSync(USER_KEY)
+  uni.removeStorageSync(LOGIN_TIME_KEY)
+}
+
+export function isLoggedIn(): boolean {
+  return !!getToken()
+}
+
+export function getLoginTime(): number {
+  try {
+    return Number(uni.getStorageSync(LOGIN_TIME_KEY) || 0)
+  } catch {
+    return 0
+  }
+}
+
+export function getUserInfo(): LoginUserInfo | null {
+  try {
+    const raw = uni.getStorageSync(USER_KEY)
+    return raw ? JSON.parse(raw) : null
+  } catch {
+    return null
+  }
+}
+
+export function setUserInfo(info: LoginUserInfo) {
+  uni.setStorageSync(USER_KEY, JSON.stringify(info))
+}
+
+export const AuthKeys = {
+  TOKEN: TOKEN_KEY,
+  USER: USER_KEY,
+  LOGIN_TIME: LOGIN_TIME_KEY
+}

+ 169 - 0
src/utils/request.ts

@@ -0,0 +1,169 @@
+/**
+ * 统一请求封装(uniapp)
+ * - 自动注入 Authorization(Bearer token)
+ * - 解析后端 R<T> 响应: code=200 → resolve(data),其他 → reject(msg)
+ * - 401/403 → 清登录态 + 跳登录页
+ * - 网络/HTTP 异常 → reject 携带可读 message
+ *
+ * 注意: 默认走相对路径(开发 = /dev-api,生产 = 同域反代),与 plus-ui/uni.request 已有的 url 风格保持一致
+ */
+import { getToken, removeToken } from './auth'
+
+// 标记正在跳转登录页,避免并发的 401 触发多次 reLaunch
+let redirecting = false
+
+function redirectToLogin() {
+  if (redirecting) return
+  redirecting = true
+  // 先清掉 token(再尝试登录)
+  removeToken()
+  uni.showToast({ title: '登录已过期,请重新登录', icon: 'none', duration: 1500 })
+  setTimeout(() => {
+    uni.reLaunch({ url: '/pages/login/index' })
+    setTimeout(() => {
+      redirecting = false
+    }, 1000)
+  }, 800)
+}
+
+export interface RequestOptions {
+  /** 业务路径(以 / 开头),会自动拼接默认 BASE_API */
+  url: string
+  /** HTTP method,默认 GET */
+  method?: 'GET' | 'POST' | 'PUT' | 'DELETE'
+  /** query 或 body 参数 */
+  data?: any
+  /** 额外 header(会与默认 Authorization 合并) */
+  header?: Record<string, string>
+  /** 是否跳过 token 注入(如登录本身) */
+  skipAuth?: boolean
+  /** 调用方自定义的超时(ms),默认 15000 */
+  timeout?: number
+}
+
+export interface BizResponse<T = any> {
+  code: number
+  msg: string
+  data: T
+}
+
+/**
+ * 默认 BASE_API
+ * - 开发: /dev-api(vite.config.ts 已代理到 9225)
+ * - 生产: 与 plus-ui 的 nginx 反代一致,使用 /prod-api
+ *
+ * 之所以这里仍写 'dev-api' 是因为本仓库 uniapp 的 manifest.json 里 h5.devServer.proxy
+ * 只配了 /dev-api,所有现有页面(/dev-api/...)都按此风格;生产环境需要 nginx 把 /prod-api
+ * 转发到后端 8080 即可(参见 plus-ui 部署说明)
+ */
+const BASE_API = '/dev-api'
+
+/** 拼接 url: 支持绝对路径(http开头)和相对路径 */
+function buildUrl(url: string): string {
+  if (/^https?:\/\//i.test(url)) return url
+  // 已经带 /dev-api /prod-api 等 BASE 前缀,直接返回
+  if (url.startsWith(BASE_API)) return url
+  return BASE_API + url
+}
+
+export function request<T = any>(opts: RequestOptions): Promise<T> {
+  const header: Record<string, string> = { ...(opts.header || {}) }
+  if (!opts.skipAuth) {
+    const token = getToken()
+    if (token) header['Authorization'] = `Bearer ${token}`
+  }
+
+  return new Promise((resolve, reject) => {
+    uni.request({
+      url: buildUrl(opts.url),
+      method: opts.method || 'GET',
+      data: opts.data,
+      header,
+      timeout: opts.timeout ?? 15000,
+      success: (res) => {
+        const status = res.statusCode
+        // 网络层错误
+        if (status === 401 || status === 403) {
+          redirectToLogin()
+          reject(new Error('登录已过期'))
+          return
+        }
+        if (status < 200 || status >= 300) {
+          reject(new Error(`网络异常(${status})`))
+          return
+        }
+        // 后端 R<T> 结构
+        const body = res.data as BizResponse | undefined
+        if (!body || typeof body !== 'object' || typeof body.code === 'undefined') {
+          // 没有 R 包装,直接返回原数据(如文件上传/特殊接口)
+          resolve(res.data as any)
+          return
+        }
+        if (body.code === 200) {
+          resolve(body.data as T)
+        } else {
+          // Sa-Token 401101 等业务码也跳登录
+          if (body.code === 401 || body.code === 401101 || body.code === 500001) {
+            redirectToLogin()
+          }
+          reject(new Error(body.msg || `请求失败(${body.code})`))
+        }
+      },
+      fail: (err) => {
+        reject(new Error(err?.errMsg || '网络异常'))
+      }
+    })
+  })
+}
+
+/**
+ * 兼容文件上传(走 uni.uploadFile,与 request 分开)
+ * - 这里只追加 token,不强制走 R 解析
+ */
+export function uploadFile<T = any>(opts: {
+  url: string
+  filePath: string
+  name?: string
+  formData?: Record<string, any>
+  skipAuth?: boolean
+}): Promise<T> {
+  const header: Record<string, string> = {}
+  if (!opts.skipAuth) {
+    const token = getToken()
+    if (token) header['Authorization'] = `Bearer ${token}`
+  }
+  return new Promise((resolve, reject) => {
+    uni.uploadFile({
+      url: buildUrl(opts.url),
+      filePath: opts.filePath,
+      name: opts.name || 'file',
+      formData: opts.formData,
+      header,
+      success: (res) => {
+        const status = res.statusCode
+        if (status === 401 || status === 403) {
+          redirectToLogin()
+          reject(new Error('登录已过期'))
+          return
+        }
+        if (status < 200 || status >= 300) {
+          reject(new Error(`上传失败(${status})`))
+          return
+        }
+        try {
+          const body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
+          if (body?.code === 200) {
+            resolve(body.data as T)
+          } else {
+            reject(new Error(body?.msg || `上传失败(${body?.code})`))
+          }
+        } catch (e: any) {
+          reject(new Error('上传响应解析失败: ' + (e?.message || '')))
+        }
+      },
+      fail: (err) => {
+        reject(new Error(err?.errMsg || '网络异常,上传失败'))
+      }
+    })
+  })
+}