feat: Phase 3 - API layer + Pinia stores + app integration
- HTTP client (axios interceptors, token mgmt, typed APIs) - Pinia stores: token, user (login/logout), app (dark mode, sidebar) - globalConfig TS types + Window augmentation - Vue Router (hash history, auth guard) - Login/Home/Mine pages (Vant UI) - Vant integration + globalConfig dev script - Build passes (vue-tsc + vite)
This commit is contained in:
19
src/App.vue
19
src/App.vue
@@ -1,7 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import HelloWorld from './components/HelloWorld.vue'
|
||||
/**
|
||||
* 应用根组件
|
||||
*
|
||||
* 使用 Vant ConfigProvider 包裹路由视图,
|
||||
* 提供全局主题配置和路由出口。
|
||||
*/
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HelloWorld />
|
||||
<van-config-provider>
|
||||
<router-view />
|
||||
</van-config-provider>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* App-level 全局样式调整 */
|
||||
#app {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
|
||||
139
src/api/common.ts
Normal file
139
src/api/common.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 通用 API
|
||||
*
|
||||
* 包含文件上传、字典查询、验证码获取、系统配置等公共接口。
|
||||
*
|
||||
* @module api/common
|
||||
*/
|
||||
|
||||
import { post, get } from '@/utils/http'
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 请求参数类型
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** 文件上传响应 */
|
||||
export interface UploadResult {
|
||||
/** 文件在服务器上的存储名称 */
|
||||
fileName: string
|
||||
/** 文件可访问的完整 URL */
|
||||
url: string
|
||||
}
|
||||
|
||||
/** 字典项 */
|
||||
export interface DictItem {
|
||||
/** 字典标签(显示用) */
|
||||
dictLabel: string
|
||||
/** 字典值(存储用) */
|
||||
dictValue: string
|
||||
/** 字典类型 */
|
||||
dictType?: string
|
||||
/** CSS 类名(用于表格回显样式) */
|
||||
cssClass?: string
|
||||
/** 列表样式 */
|
||||
listClass?: string
|
||||
/** 是否默认 */
|
||||
isDefault?: string
|
||||
/** 排序号 */
|
||||
dictSort?: number
|
||||
}
|
||||
|
||||
/** 验证码图片响应 */
|
||||
export interface CaptchaResult {
|
||||
/** 验证码唯一标识,登录时需要回传 */
|
||||
uuid: string
|
||||
/** Base64 编码的验证码图片 */
|
||||
img: string
|
||||
/** 是否开启验证码 */
|
||||
captchaEnabled: boolean
|
||||
}
|
||||
|
||||
/** 系统配置项 */
|
||||
export interface ConfigItem {
|
||||
/** 参数键名 */
|
||||
configKey: string
|
||||
/** 参数值 */
|
||||
configValue: string
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// API 方法
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 上传文件
|
||||
*
|
||||
* 支持图片、文档等文件上传,使用 FormData 格式。
|
||||
*
|
||||
* @param file - 要上传的文件对象
|
||||
* @returns 上传结果,包含文件名和访问 URL
|
||||
*/
|
||||
export function uploadFile(file: File): Promise<UploadResult> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
return post<UploadResult>('/common/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量上传文件
|
||||
* @param files - 文件对象数组
|
||||
* @returns 上传结果数组
|
||||
*/
|
||||
export async function uploadFiles(files: File[]): Promise<UploadResult[]> {
|
||||
const results: UploadResult[] = []
|
||||
for (const file of files) {
|
||||
const result = await uploadFile(file)
|
||||
results.push(result)
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取字典数据
|
||||
*
|
||||
* 根据字典类型查询对应的字典项列表,用于下拉框、单选框等组件的数据源。
|
||||
*
|
||||
* @param dictType - 字典类型标识,如 'sys_user_sex'
|
||||
* @returns 字典项数组
|
||||
*/
|
||||
export function getDictData(dictType: string): Promise<DictItem[]> {
|
||||
return get<DictItem[]>('/system/dict/data/type', { dictType })
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据字典类型获取字典数据(RESTful 风格)
|
||||
* @param dictType - 字典类型标识
|
||||
* @returns 字典项数组
|
||||
*/
|
||||
export function getDictDataByType(dictType: string): Promise<DictItem[]> {
|
||||
return get<DictItem[]>(`/system/dict/data/type/${dictType}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取验证码图片
|
||||
* @returns 验证码信息(uuid 和 base64 图片)
|
||||
*/
|
||||
export function getCaptcha(): Promise<CaptchaResult> {
|
||||
return get<CaptchaResult>('/captchaImage')
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据配置键获取系统配置值
|
||||
* @param configKey - 配置键名
|
||||
* @returns 配置项的值
|
||||
*/
|
||||
export function getConfigValue(configKey: string): Promise<string> {
|
||||
return get<string>(`/system/config/configKey/${configKey}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取系统配置项详情
|
||||
* @param configKey - 配置键名
|
||||
* @returns 配置项完整信息
|
||||
*/
|
||||
export function getConfigItem(configKey: string): Promise<ConfigItem> {
|
||||
return get<ConfigItem>(`/system/config/configKey/${configKey}`)
|
||||
}
|
||||
43
src/api/index.ts
Normal file
43
src/api/index.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* API 模块聚合入口
|
||||
*
|
||||
* 统一导出所有 API 子模块的方法和类型,业务代码只需
|
||||
* `import { login, getUserInfo } from '@/api'` 即可使用。
|
||||
*
|
||||
* @module api
|
||||
*/
|
||||
|
||||
// ── 用户模块 ──
|
||||
export {
|
||||
login,
|
||||
getUserInfo,
|
||||
logout,
|
||||
resetPassword,
|
||||
} from './user'
|
||||
export type {
|
||||
LoginParams,
|
||||
LoginResult,
|
||||
ResetPasswordParams,
|
||||
UserInfo,
|
||||
} from './user'
|
||||
|
||||
// ── 通用模块 ──
|
||||
export {
|
||||
uploadFile,
|
||||
uploadFiles,
|
||||
getDictData,
|
||||
getDictDataByType,
|
||||
getCaptcha,
|
||||
getConfigValue,
|
||||
getConfigItem,
|
||||
} from './common'
|
||||
export type {
|
||||
UploadResult,
|
||||
DictItem,
|
||||
CaptchaResult,
|
||||
ConfigItem,
|
||||
} from './common'
|
||||
|
||||
// ── HTTP 工具(便捷导出) ──
|
||||
export { getToken, setToken, removeToken } from '@/utils/http'
|
||||
export type { ApiResponse } from '@/utils/http'
|
||||
105
src/api/user.ts
Normal file
105
src/api/user.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 用户相关 API
|
||||
*
|
||||
* 包含登录、获取用户信息、退出登录、重置密码等用户模块接口。
|
||||
*
|
||||
* @module api/user
|
||||
*/
|
||||
|
||||
import { post, get } from '@/utils/http'
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 请求参数类型
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** 登录请求参数 */
|
||||
export interface LoginParams {
|
||||
/** 用户名 */
|
||||
username: string
|
||||
/** 密码 */
|
||||
password: string
|
||||
/** 验证码 */
|
||||
code?: string
|
||||
/** 验证码唯一标识 */
|
||||
uuid?: string
|
||||
}
|
||||
|
||||
/** 重置密码请求参数 */
|
||||
export interface ResetPasswordParams {
|
||||
/** 旧密码 */
|
||||
oldPassword: string
|
||||
/** 新密码 */
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 响应数据类型
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** 登录返回结果 */
|
||||
export interface LoginResult {
|
||||
/** 认证 Token */
|
||||
token: string
|
||||
}
|
||||
|
||||
/** 用户信息 */
|
||||
export interface UserInfo {
|
||||
/** 用户 ID */
|
||||
userId: string | number
|
||||
/** 用户名 */
|
||||
userName: string
|
||||
/** 昵称 */
|
||||
nickName: string
|
||||
/** 头像地址 */
|
||||
avatar: string
|
||||
/** 手机号 */
|
||||
phonenumber?: string
|
||||
/** 邮箱 */
|
||||
email?: string
|
||||
/** 性别(0-男 1-女 2-未知) */
|
||||
sex?: string
|
||||
/** 部门名称 */
|
||||
deptName?: string
|
||||
/** 角色列表 */
|
||||
roles?: string[]
|
||||
/** 权限列表 */
|
||||
permissions?: string[]
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// API 方法
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
* @param params - 登录参数(用户名、密码、验证码等)
|
||||
* @returns 返回包含 Token 的登录结果
|
||||
*/
|
||||
export function login(params: LoginParams): Promise<LoginResult> {
|
||||
return post<LoginResult>('/login', params)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息
|
||||
* @returns 用户详细信息
|
||||
*/
|
||||
export function getUserInfo(): Promise<UserInfo> {
|
||||
return get<UserInfo>('/getInfo')
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export function logout(): Promise<void> {
|
||||
return post<void>('/logout')
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置密码
|
||||
* @param params - 旧密码和新密码
|
||||
* @returns Promise<void>
|
||||
*/
|
||||
export function resetPassword(params: ResetPasswordParams): Promise<void> {
|
||||
return post<void>('/system/user/profile/resetPwd', params)
|
||||
}
|
||||
167
src/config/types.ts
Normal file
167
src/config/types.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* 全局配置类型声明
|
||||
*
|
||||
* 声明 window.globalConfig 的完整 TypeScript 类型,
|
||||
* 该配置由 public/config/globalConfig.js 在页面加载时注入。
|
||||
* 同时扩展 Window 接口,使得 TypeScript 能够正确识别
|
||||
* window.globalConfig 及其子属性。
|
||||
*
|
||||
* @module config/types
|
||||
*/
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 全局配置子模块类型
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** 系统基础配置 */
|
||||
export interface SystemConfig {
|
||||
/** 系统名称(登录页、系统左上角展示) */
|
||||
name: string
|
||||
/** 系统编码(在集成管控平台中的子系统编码) */
|
||||
id: string
|
||||
/** Bucket 名称(用于图片等静态资源存储) */
|
||||
buckName: string
|
||||
/** 系统版本号 */
|
||||
version: string
|
||||
/** 系统 Logo 地址 */
|
||||
logo?: string
|
||||
/** 系统首页标题 */
|
||||
homeTitle?: string
|
||||
/** 系统描述 */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** API 接口配置 */
|
||||
export interface ApiConfig {
|
||||
/** API 后端基础地址 */
|
||||
baseURL: string
|
||||
/** API 主机地址(图片上传下载等场景) */
|
||||
host: string
|
||||
/** WebSocket 连接地址 */
|
||||
wsURL?: string
|
||||
/** 请求超时时间(毫秒) */
|
||||
timeout?: number
|
||||
}
|
||||
|
||||
/** 地图服务配置 */
|
||||
export interface MapConfig {
|
||||
/** 地图类型(如 'tdt' 天地图、'amap' 高德、'baidu' 百度) */
|
||||
type: string
|
||||
/** 地图服务地址 */
|
||||
url: string
|
||||
/** 地图默认中心点坐标 [经度, 纬度] */
|
||||
center?: [number, number]
|
||||
/** 地图默认缩放级别 */
|
||||
zoom?: number
|
||||
/** 地图最大缩放级别 */
|
||||
maxZoom?: number
|
||||
/** 地图最小缩放级别 */
|
||||
minZoom?: number
|
||||
/** 地图瓦片地址 */
|
||||
tileUrl?: string
|
||||
/** 天地图 Key(type 为 tdt 时使用) */
|
||||
tdtKey?: string
|
||||
/** 地图样式地址 */
|
||||
styleUrl?: string
|
||||
/** 是否开启地图注记 */
|
||||
label?: boolean
|
||||
}
|
||||
|
||||
/** 管网配置 */
|
||||
export interface PipeConfig {
|
||||
/** 管网数据服务地址 */
|
||||
url?: string
|
||||
/** 管网默认图层 */
|
||||
defaultLayer?: string
|
||||
/** 管网高亮颜色 */
|
||||
highlightColor?: string
|
||||
/** 管网选中颜色 */
|
||||
selectedColor?: string
|
||||
/** 管道直径范围 */
|
||||
diameterRange?: [number, number]
|
||||
/** 是否展示管网标注 */
|
||||
showLabel?: boolean
|
||||
/** 管网数据版本 */
|
||||
version?: string
|
||||
}
|
||||
|
||||
/** 打卡配置 */
|
||||
export interface ClockInConfig {
|
||||
/** 打卡范围半径(米) */
|
||||
radius?: number
|
||||
/** 打卡地点列表 */
|
||||
locations?: Array<{
|
||||
/** 地点名称 */
|
||||
name: string
|
||||
/** 经度 */
|
||||
lng: number
|
||||
/** 纬度 */
|
||||
lat: number
|
||||
/** 有效范围半径(米) */
|
||||
radius?: number
|
||||
}>
|
||||
/** 是否开启拍照打卡 */
|
||||
needPhoto?: boolean
|
||||
/** 是否开启定位验证 */
|
||||
needLocation?: boolean
|
||||
/** 打卡时间限制(如 '09:00-18:00') */
|
||||
timeRange?: string
|
||||
}
|
||||
|
||||
/** 新简易运维系统配置 */
|
||||
export interface XjyhSysConfig {
|
||||
/** 是否启用新简易运维 */
|
||||
enabled?: boolean
|
||||
/** 运维系统地址 */
|
||||
url?: string
|
||||
/** 运维系统 AppId */
|
||||
appId?: string
|
||||
/** 运维系统密钥 */
|
||||
secret?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局配置对象类型
|
||||
*
|
||||
* 由 public/config/globalConfig.js 在页面加载时注入到
|
||||
* window.globalConfig,并在应用启动时通过接口获取远程配置
|
||||
* 进行合并。
|
||||
*/
|
||||
export interface GlobalConfig {
|
||||
/** 系统基础配置 */
|
||||
system: SystemConfig
|
||||
/** API 接口配置 */
|
||||
api: ApiConfig
|
||||
/** 地图服务配置 */
|
||||
map: MapConfig
|
||||
/** 管网配置 */
|
||||
pipe: PipeConfig
|
||||
/** 打卡配置 */
|
||||
clockIn: ClockInConfig
|
||||
/** 新简易运维系统配置 */
|
||||
xjyhSys: XjyhSysConfig
|
||||
/** 设备类型配置(key 为类型编码,value 为类型名称) */
|
||||
sbType: Record<string, string>
|
||||
/** 地图加载类型配置 */
|
||||
loadMapType: string
|
||||
/** 其他扩展配置(预留字段) */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Window 接口扩展
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
/**
|
||||
* 运行时全局配置对象
|
||||
*
|
||||
* 由 public/config/globalConfig.js 在 index.html 中通过
|
||||
* `<script>` 标签注入,前端启动时合并远程配置后使用。
|
||||
*/
|
||||
globalConfig: GlobalConfig
|
||||
}
|
||||
}
|
||||
|
||||
export {}
|
||||
116
src/main.ts
116
src/main.ts
@@ -1,5 +1,119 @@
|
||||
/**
|
||||
* 应用入口 — Yuto Water H5
|
||||
*
|
||||
* 初始化 Vue 应用,集成 Vant 4 UI、Pinia 状态管理、
|
||||
* Vue Router 路由、全局样式等核心模块。
|
||||
*
|
||||
* @module main
|
||||
*/
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
// ── Vant 4 组件按需注册 ──
|
||||
import {
|
||||
ConfigProvider,
|
||||
NavBar,
|
||||
Tabbar,
|
||||
TabbarItem,
|
||||
Button,
|
||||
Form,
|
||||
Field,
|
||||
Cell,
|
||||
CellGroup,
|
||||
Popup,
|
||||
Uploader,
|
||||
} from 'vant'
|
||||
|
||||
// ── Vant 样式(组件样式按需引入) ──
|
||||
import 'vant/lib/index.css'
|
||||
|
||||
// ── 全局样式 ──
|
||||
import './styles/index.scss'
|
||||
|
||||
// ── 路由 ──
|
||||
import router from './router'
|
||||
|
||||
// ── 根组件 ──
|
||||
import App from './App.vue'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
// ══════════════════════════════════════════════
|
||||
// 创建应用
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
// ── 注册 Pinia ──
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
// ── 注册 Vant 组件(组件类) ──
|
||||
// Toast / Dialog / ImagePreview 为函数式 API,
|
||||
// 直接从 vant 导入即可使用,无需 app.use 注册。
|
||||
const vantComponents = [
|
||||
ConfigProvider,
|
||||
NavBar,
|
||||
Tabbar,
|
||||
TabbarItem,
|
||||
Button,
|
||||
Form,
|
||||
Field,
|
||||
Cell,
|
||||
CellGroup,
|
||||
Popup,
|
||||
Uploader,
|
||||
]
|
||||
|
||||
for (const component of vantComponents) {
|
||||
app.use(component)
|
||||
}
|
||||
|
||||
// ── 注册路由 ──
|
||||
app.use(router)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 挂载应用(带错误处理)
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
try {
|
||||
app.mount('#app')
|
||||
} catch (err) {
|
||||
console.error('[Yuto Water H5] 应用挂载失败:', err)
|
||||
// 在 DOM 中展示降级错误信息
|
||||
const root = document.getElementById('app')
|
||||
if (root) {
|
||||
root.innerHTML = `
|
||||
<div style="
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
color: #323233;
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
">
|
||||
<h1 style="font-size: 20px; margin-bottom: 8px;">应用加载失败</h1>
|
||||
<p style="font-size: 14px; color: #969799;">
|
||||
${err instanceof Error ? err.message : '未知错误'}
|
||||
</p>
|
||||
<button
|
||||
onclick="location.reload()"
|
||||
style="
|
||||
margin-top: 16px;
|
||||
padding: 8px 24px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: #2196F3;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
122
src/router/index.ts
Normal file
122
src/router/index.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* 路由配置
|
||||
*
|
||||
* 基于 Vue Router 4 的 Hash 模式路由,
|
||||
* 包含登录、首页、个人中心等核心页面路由定义。
|
||||
* 通过全局前置守卫实现 Token 认证拦截。
|
||||
*
|
||||
* @module router
|
||||
*/
|
||||
|
||||
import {
|
||||
createRouter,
|
||||
createWebHashHistory,
|
||||
type RouteRecordRaw,
|
||||
type NavigationGuardNext,
|
||||
type RouteLocationNormalized,
|
||||
} from 'vue-router'
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 常量
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** 白名单路由 — 无需 Token 即可访问 */
|
||||
const WHITE_LIST: string[] = ['/login']
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 路由表
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 动态路由(懒加载)
|
||||
*
|
||||
* 使用 import() 实现路由级别的代码分割,
|
||||
* 配合 Vite 自动生成独立的 chunk 文件。
|
||||
*/
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/home',
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: {
|
||||
title: '登录',
|
||||
noAuth: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/home',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/home/index.vue'),
|
||||
meta: {
|
||||
title: '首页',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/mine',
|
||||
name: 'Mine',
|
||||
component: () => import('@/views/mine/index.vue'),
|
||||
meta: {
|
||||
title: '我的',
|
||||
},
|
||||
},
|
||||
// 404 兜底
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
redirect: '/home',
|
||||
},
|
||||
]
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 创建路由实例
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
const router = createRouter({
|
||||
// Hash 模式 — 兼容移动端 WebView 和微信内嵌浏览器
|
||||
history: createWebHashHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 全局前置守卫 — 认证拦截
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
router.beforeEach(
|
||||
(
|
||||
to: RouteLocationNormalized,
|
||||
_from: RouteLocationNormalized,
|
||||
next: NavigationGuardNext,
|
||||
) => {
|
||||
// 更新页面标题
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - 舆图智慧水务`
|
||||
}
|
||||
|
||||
// 白名单路由直接放行
|
||||
if (WHITE_LIST.includes(to.path) || to.meta.noAuth) {
|
||||
return next()
|
||||
}
|
||||
|
||||
// 检查 Token 是否存在
|
||||
const token = localStorage.getItem('Authorization')
|
||||
|
||||
if (token && token !== '') {
|
||||
// 已登录 → 放行
|
||||
return next()
|
||||
}
|
||||
|
||||
// 未登录 → 重定向到登录页,保存原目标路径
|
||||
next({
|
||||
path: '/login',
|
||||
query: {
|
||||
redirect: to.fullPath,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export default router
|
||||
299
src/stores/app.ts
Normal file
299
src/stores/app.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* 应用全局状态管理
|
||||
*
|
||||
* 基于 Pinia 的应用级 Store,管理全局配置、侧边栏、
|
||||
* 暗黑模式、缓存页面等应用维度的状态。
|
||||
*
|
||||
* @module stores/app
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { GlobalConfig } from '@/config/types'
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 常量
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** 暗黑模式 localStorage 键名 */
|
||||
const DARK_MODE_KEY = 'app-dark-mode'
|
||||
|
||||
/** 侧边栏状态 localStorage 键名 */
|
||||
const SIDEBAR_KEY = 'app-sidebar-collapsed'
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 内部工具函数
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** 从 localStorage 读取布尔值 */
|
||||
function readBoolFromStorage(key: string, fallback: boolean): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem(key)
|
||||
if (stored === null) return fallback
|
||||
return stored === 'true'
|
||||
} catch {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
/** 将布尔值写入 localStorage */
|
||||
function writeBoolToStorage(key: string, value: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(key, String(value))
|
||||
} catch {
|
||||
// localStorage 不可用时静默失败
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 类型定义
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** 侧边栏状态 */
|
||||
export interface SidebarState {
|
||||
/** 是否折叠 */
|
||||
collapsed: boolean
|
||||
/** 是否在移动端展开(不考虑折叠状态) */
|
||||
opened: boolean
|
||||
}
|
||||
|
||||
/** 应用尺寸类型 */
|
||||
export type DeviceSize = 'mobile' | 'tablet' | 'desktop'
|
||||
|
||||
/**
|
||||
* 应用全局 Store
|
||||
*
|
||||
* 管理应用的全局状态,包括:
|
||||
* - globalConfig:运行时全局配置
|
||||
* - sidebar:侧边栏折叠/展开状态(持久化)
|
||||
* - darkMode:暗黑模式开关(持久化)
|
||||
* - cachedViews:需要缓存的页面视图名称列表
|
||||
* - device:当前设备类型
|
||||
*/
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
// ══════════════════════════════════════════════
|
||||
// State
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 运行时全局配置
|
||||
*
|
||||
* 应用启动时从 window.globalConfig 读取初始值,
|
||||
* 后续可通过 loadGlobalConfig 从远程接口更新。
|
||||
*/
|
||||
const globalConfig = ref<GlobalConfig | null>(
|
||||
typeof window !== 'undefined' ? window.globalConfig ?? null : null,
|
||||
)
|
||||
|
||||
/**
|
||||
* 侧边栏状态
|
||||
*
|
||||
* collapsed 会持久化到 localStorage,刷新后保持。
|
||||
*/
|
||||
const sidebar = ref<SidebarState>({
|
||||
collapsed: readBoolFromStorage(SIDEBAR_KEY, false),
|
||||
opened: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* 暗黑模式
|
||||
*
|
||||
* 切换时自动在 <html> 元素上添加/移除 'dark' 类名,
|
||||
* 并持久化到 localStorage。
|
||||
*/
|
||||
const darkMode = ref<boolean>(readBoolFromStorage(DARK_MODE_KEY, false))
|
||||
|
||||
/**
|
||||
* 缓存的视图组件名称列表
|
||||
*
|
||||
* 用于 <keep-alive> 的 include 属性,
|
||||
* 存储需要保持状态不销毁的路由组件 name。
|
||||
*/
|
||||
const cachedViews = ref<string[]>([])
|
||||
|
||||
/**
|
||||
* 当前设备类型
|
||||
*
|
||||
* 根据窗口宽度自动判断。
|
||||
*/
|
||||
const device = ref<DeviceSize>(getDeviceSize())
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 暗黑模式初始化
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
// 监听 darkMode 变化,同步到 DOM 和 localStorage
|
||||
watch(darkMode, (val: boolean) => {
|
||||
writeBoolToStorage(DARK_MODE_KEY, val)
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.classList.toggle('dark', val)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Getters
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 当前是否为移动端设备
|
||||
* @returns 设备宽度 < 768px 返回 true
|
||||
*/
|
||||
const isMobile = computed<boolean>(() => device.value === 'mobile')
|
||||
|
||||
/**
|
||||
* 当前是否为桌面端设备
|
||||
* @returns 设备宽度 >= 992px 返回 true
|
||||
*/
|
||||
const isDesktop = computed<boolean>(() => device.value === 'desktop')
|
||||
|
||||
/**
|
||||
* 当前是否为平板设备
|
||||
* @returns 设备宽度在 768px ~ 991px 返回 true
|
||||
*/
|
||||
const isTablet = computed<boolean>(() => device.value === 'tablet')
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Actions
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 设置全局配置
|
||||
*
|
||||
* 可用于应用启动时从远程接口加载配置后合并更新。
|
||||
*
|
||||
* @param config - 完整的全局配置对象或部分配置
|
||||
*/
|
||||
function setGlobalConfig(config: Partial<GlobalConfig>): void {
|
||||
if (globalConfig.value) {
|
||||
Object.assign(globalConfig.value, config)
|
||||
} else {
|
||||
globalConfig.value = config as GlobalConfig
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换侧边栏折叠状态
|
||||
*
|
||||
* 桌面端切换 collapsed 状态,移动端切换 opened 状态。
|
||||
*/
|
||||
function toggleSidebar(): void {
|
||||
if (isMobile.value) {
|
||||
sidebar.value.opened = !sidebar.value.opened
|
||||
} else {
|
||||
sidebar.value.collapsed = !sidebar.value.collapsed
|
||||
writeBoolToStorage(SIDEBAR_KEY, sidebar.value.collapsed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭侧边栏
|
||||
*
|
||||
* 通常用于移动端点击遮罩层或路由跳转后自动关闭。
|
||||
*/
|
||||
function closeSidebar(): void {
|
||||
sidebar.value.opened = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换暗黑模式
|
||||
*
|
||||
* 反转当前的暗黑模式状态,变化会自动同步到
|
||||
* localStorage 和 DOM。
|
||||
*/
|
||||
function toggleDarkMode(): void {
|
||||
darkMode.value = !darkMode.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置暗黑模式
|
||||
*
|
||||
* @param value - true 开启暗黑模式,false 关闭
|
||||
*/
|
||||
function setDarkMode(value: boolean): void {
|
||||
darkMode.value = value
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加缓存视图
|
||||
*
|
||||
* 将指定视图名称加入缓存列表,用于 keep-alive。
|
||||
* 重复添加同名视图不会产生副作用。
|
||||
*
|
||||
* @param viewName - 要缓存的视图组件名称
|
||||
*/
|
||||
function addCachedView(viewName: string): void {
|
||||
if (!cachedViews.value.includes(viewName)) {
|
||||
cachedViews.value.push(viewName)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除缓存视图
|
||||
*
|
||||
* 将指定视图名称从缓存列表中移除。
|
||||
*
|
||||
* @param viewName - 要移除缓存的视图组件名称
|
||||
*/
|
||||
function removeCachedView(viewName: string): void {
|
||||
const index = cachedViews.value.indexOf(viewName)
|
||||
if (index !== -1) {
|
||||
cachedViews.value.splice(index, 1)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有缓存视图
|
||||
*/
|
||||
function clearCachedViews(): void {
|
||||
cachedViews.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新设备类型
|
||||
*
|
||||
* 根据当前窗口宽度重新计算设备类型。
|
||||
* 通常在窗口 resize 事件中调用。
|
||||
*/
|
||||
function updateDevice(): void {
|
||||
device.value = getDeviceSize()
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
globalConfig,
|
||||
sidebar,
|
||||
darkMode,
|
||||
cachedViews,
|
||||
device,
|
||||
// Getters
|
||||
isMobile,
|
||||
isDesktop,
|
||||
isTablet,
|
||||
// Actions
|
||||
setGlobalConfig,
|
||||
toggleSidebar,
|
||||
closeSidebar,
|
||||
toggleDarkMode,
|
||||
setDarkMode,
|
||||
addCachedView,
|
||||
removeCachedView,
|
||||
clearCachedViews,
|
||||
updateDevice,
|
||||
}
|
||||
})
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 辅助函数
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 根据窗口宽度判断当前设备类型
|
||||
* @returns 设备类型标识
|
||||
*/
|
||||
function getDeviceSize(): DeviceSize {
|
||||
if (typeof window === 'undefined') return 'desktop'
|
||||
const width = window.innerWidth
|
||||
if (width < 768) return 'mobile'
|
||||
if (width < 992) return 'tablet'
|
||||
return 'desktop'
|
||||
}
|
||||
98
src/stores/token.ts
Normal file
98
src/stores/token.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Token 状态管理
|
||||
*
|
||||
* 基于 Pinia 的 Token 持久化 Store,
|
||||
* 封装 localStorage 中 Token 的读取、写入、清除操作。
|
||||
* 配合 axios 请求拦截器实现自动附加 Token。
|
||||
*
|
||||
* @module stores/token
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
/** localStorage 中存储 Token 的键名 */
|
||||
const TOKEN_KEY = 'Authorization'
|
||||
|
||||
/**
|
||||
* Token Store
|
||||
*
|
||||
* 管理登录凭证 Token 的持久化存储。
|
||||
* - token:当前 Token 值(响应式)
|
||||
* - hasToken:是否已存在 Token(计算属性)
|
||||
* - setToken:存储 Token 到 localStorage 并同步到 Store
|
||||
* - getToken:从 localStorage 读取 Token 并同步到 Store
|
||||
* - clearToken:清除 localStorage 和 Store 中的 Token
|
||||
*/
|
||||
export const useTokenStore = defineStore('token', () => {
|
||||
// ══════════════════════════════════════════════
|
||||
// State
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** 当前存储的 Token 值 */
|
||||
const token = ref<string | null>(localStorage.getItem(TOKEN_KEY))
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Getters
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 是否已存在有效的 Token
|
||||
* @returns 如果 Token 存在且非空返回 true
|
||||
*/
|
||||
const hasToken = computed<boolean>(() => {
|
||||
return token.value !== null && token.value !== ''
|
||||
})
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Actions
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 设置 Token
|
||||
*
|
||||
* 将 Token 写入 localStorage 并同步更新 Store 中的响应式状态。
|
||||
*
|
||||
* @param value - Token 字符串
|
||||
*/
|
||||
function setToken(value: string): void {
|
||||
token.value = value
|
||||
localStorage.setItem(TOKEN_KEY, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Token
|
||||
*
|
||||
* 从 localStorage 读取 Token 并同步到 Store 状态。
|
||||
* 通常在应用初始化时调用。
|
||||
*
|
||||
* @returns 当前的 Token 值,不存在时返回 null
|
||||
*/
|
||||
function getToken(): string | null {
|
||||
const stored = localStorage.getItem(TOKEN_KEY)
|
||||
token.value = stored
|
||||
return stored
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 Token
|
||||
*
|
||||
* 移除 localStorage 中存储的 Token,并将 Store 状态置为 null。
|
||||
* 通常在退出登录或 Token 过期时调用。
|
||||
*/
|
||||
function clearToken(): void {
|
||||
token.value = null
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
token,
|
||||
// Getters
|
||||
hasToken,
|
||||
// Actions
|
||||
setToken,
|
||||
getToken,
|
||||
clearToken,
|
||||
}
|
||||
})
|
||||
193
src/stores/user.ts
Normal file
193
src/stores/user.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 用户状态管理
|
||||
*
|
||||
* 基于 Pinia 的用户信息 Store,管理当前登录用户的
|
||||
* 个人信息、登录/退出逻辑以及权限相关数据。
|
||||
*
|
||||
* @module stores/user
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { useTokenStore } from './token'
|
||||
import {
|
||||
login as loginApi,
|
||||
logout as logoutApi,
|
||||
getUserInfo as getUserInfoApi,
|
||||
type LoginParams,
|
||||
type UserInfo,
|
||||
} from '@/api'
|
||||
|
||||
/**
|
||||
* 用户 Store
|
||||
*
|
||||
* 管理当前登录用户的完整生命周期:
|
||||
* - userInfo:用户基本信息(响应式)
|
||||
* - roles:角色列表
|
||||
* - permissions:权限标识列表
|
||||
* - hasUserInfo:是否已加载用户信息(计算属性)
|
||||
* - login:调用登录接口并存储 Token
|
||||
* - logout:调用退出接口并清除状态
|
||||
* - getUserInfo:获取当前用户详细信息
|
||||
*/
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// ══════════════════════════════════════════════
|
||||
// State
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** 当前登录用户信息 */
|
||||
const userInfo = ref<UserInfo | null>(null)
|
||||
|
||||
/** 用户角色列表 */
|
||||
const roles = ref<string[]>([])
|
||||
|
||||
/** 用户权限标识列表 */
|
||||
const permissions = ref<string[]>([])
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Getters
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 是否已加载用户信息
|
||||
* @returns 用户信息对象存在则返回 true
|
||||
*/
|
||||
const hasUserInfo = computed<boolean>(() => {
|
||||
return userInfo.value !== null
|
||||
})
|
||||
|
||||
/**
|
||||
* 当前用户名
|
||||
* @returns 用户名称,未登录时返回空字符串
|
||||
*/
|
||||
const userName = computed<string>(() => {
|
||||
return userInfo.value?.userName ?? ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 当前用户昵称
|
||||
* @returns 用户昵称,未登录时返回空字符串
|
||||
*/
|
||||
const nickName = computed<string>(() => {
|
||||
return userInfo.value?.nickName ?? ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 当前用户头像
|
||||
* @returns 头像地址,未登录时返回空字符串
|
||||
*/
|
||||
const avatar = computed<string>(() => {
|
||||
return userInfo.value?.avatar ?? ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 当前用户 ID
|
||||
* @returns 用户 ID,未登录时返回空字符串
|
||||
*/
|
||||
const userId = computed<string | number>(() => {
|
||||
return userInfo.value?.userId ?? ''
|
||||
})
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Actions
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*
|
||||
* 调用登录接口获取 Token,并写入 Token Store。
|
||||
* 登录成功后自动调用 getUserInfo 获取用户详细信息。
|
||||
*
|
||||
* @param params - 登录参数(用户名、密码、验证码等)
|
||||
* @returns 登录成功后返回的 Token
|
||||
*/
|
||||
async function login(params: LoginParams): Promise<string> {
|
||||
const tokenStore = useTokenStore()
|
||||
const res = await loginApi(params)
|
||||
tokenStore.setToken(res.token)
|
||||
await getUserInfo()
|
||||
return res.token
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前登录用户信息
|
||||
*
|
||||
* 调用后端接口获取用户详细信息,同时更新
|
||||
* roles 和 permissions 列表。
|
||||
*/
|
||||
async function getUserInfo(): Promise<void> {
|
||||
const info = await getUserInfoApi()
|
||||
userInfo.value = info
|
||||
roles.value = info.roles ?? []
|
||||
permissions.value = info.permissions ?? []
|
||||
}
|
||||
|
||||
/**
|
||||
* 退出登录
|
||||
*
|
||||
* 调用后端退出接口,清除 Token Store 中的 Token,
|
||||
* 并将当前 Store 状态重置为初始值。
|
||||
*/
|
||||
async function logout(): Promise<void> {
|
||||
const tokenStore = useTokenStore()
|
||||
try {
|
||||
await logoutApi()
|
||||
} finally {
|
||||
// 无论接口调用成功与否,前端都必须清除登录状态
|
||||
tokenStore.clearToken()
|
||||
$reset()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前用户是否拥有指定权限
|
||||
*
|
||||
* @param permission - 权限标识字符串
|
||||
* @returns 拥有该权限返回 true,否则返回 false
|
||||
*/
|
||||
function hasPermission(permission: string): boolean {
|
||||
return permissions.value.includes(permission)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断当前用户是否拥有指定角色
|
||||
*
|
||||
* @param role - 角色标识字符串
|
||||
* @returns 拥有该角色返回 true,否则返回 false
|
||||
*/
|
||||
function hasRole(role: string): boolean {
|
||||
return roles.value.includes(role)
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置 Store 状态
|
||||
*
|
||||
* 将 userInfo、roles、permissions 全部恢复为初始值。
|
||||
* 通过 Pinia 的 $reset 方法可调用此逻辑。
|
||||
*/
|
||||
function $reset(): void {
|
||||
userInfo.value = null
|
||||
roles.value = []
|
||||
permissions.value = []
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
userInfo,
|
||||
roles,
|
||||
permissions,
|
||||
// Getters
|
||||
hasUserInfo,
|
||||
userName,
|
||||
nickName,
|
||||
avatar,
|
||||
userId,
|
||||
// Actions
|
||||
login,
|
||||
getUserInfo,
|
||||
logout,
|
||||
hasPermission,
|
||||
hasRole,
|
||||
$reset,
|
||||
}
|
||||
})
|
||||
12
src/types/global.d.ts
vendored
Normal file
12
src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 全局类型声明
|
||||
*
|
||||
* 扩展 Window 接口,声明运行时注入的全局配置对象。
|
||||
* globalConfig 的完整类型定义见 @/config/types。
|
||||
*
|
||||
* @module types/global
|
||||
*/
|
||||
|
||||
// Window.globalConfig 的完整类型声明已迁移至 src/config/types.ts,
|
||||
// 本文件保留作为全局类型声明的入口。
|
||||
export {}
|
||||
237
src/utils/http/index.ts
Normal file
237
src/utils/http/index.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* HTTP 客户端封装
|
||||
*
|
||||
* 基于 Axios 封装的 HTTP 请求模块,提供统一的请求/响应拦截、
|
||||
* Token 管理以及类型安全的请求方法。
|
||||
*
|
||||
* - baseURL 从 window.globalConfig.api.baseURL 读取,回退到 '/dev-api'
|
||||
* - 请求拦截器自动附加 Authorization Token
|
||||
* - 响应拦截器自动解包数据并统一错误提示
|
||||
*
|
||||
* @module utils/http
|
||||
*/
|
||||
|
||||
import axios, {
|
||||
type AxiosInstance,
|
||||
type AxiosResponse,
|
||||
type AxiosRequestConfig,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from 'axios'
|
||||
import { showFailToast } from 'vant'
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 类型定义
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** 后端统一响应结构 */
|
||||
export interface ApiResponse<T = unknown> {
|
||||
/** 状态码,200 表示成功 */
|
||||
code: number
|
||||
/** 响应数据 */
|
||||
data: T
|
||||
/** 响应消息 */
|
||||
msg: string
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Token 管理
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/** localStorage 中存储 Token 的键名 */
|
||||
const TOKEN_KEY = 'Authorization'
|
||||
|
||||
/**
|
||||
* 获取本地存储的 Token
|
||||
* @returns Token 字符串,不存在时返回 null
|
||||
*/
|
||||
export function getToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 Token 到本地存储
|
||||
* @param token - Token 字符串
|
||||
*/
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除本地存储的 Token
|
||||
*/
|
||||
export function removeToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Axios 实例
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Axios 实例
|
||||
*
|
||||
* - baseURL:优先读取 window.globalConfig.api.baseURL,不存在时使用 '/dev-api'
|
||||
* - timeout:30 秒超时
|
||||
*/
|
||||
const http: AxiosInstance = axios.create({
|
||||
baseURL:
|
||||
(typeof window !== 'undefined' && window.globalConfig?.api?.baseURL) ||
|
||||
'/dev-api',
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 请求拦截器
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 请求拦截器
|
||||
*
|
||||
* 在每次请求发出前,自动从 localStorage 读取 Token
|
||||
* 并附加到请求头的 Authorization 字段中。
|
||||
*/
|
||||
http.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
const token = getToken()
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error: unknown) => {
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 响应拦截器
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 响应拦截器
|
||||
*
|
||||
* - 成功(code === 200):解包返回 response.data.data
|
||||
* - 业务异常:弹出错误提示并 reject
|
||||
* - 网络异常:弹出错误提示并 reject
|
||||
*/
|
||||
http.interceptors.response.use(
|
||||
(response): AxiosResponse | Promise<AxiosResponse> => {
|
||||
const res = response.data as ApiResponse
|
||||
|
||||
// 文件下载等特殊响应(非 JSON)直接返回
|
||||
if (response.config.responseType === 'blob') {
|
||||
return response
|
||||
}
|
||||
|
||||
if (res.code === 200) {
|
||||
// 解包:将 response.data 替换为业务数据
|
||||
response.data = res.data
|
||||
return response
|
||||
}
|
||||
|
||||
// 业务错误码处理
|
||||
const errMsg = res.msg || '请求失败'
|
||||
showFailToast(errMsg)
|
||||
|
||||
// Token 过期等特殊错误码可在此扩展处理
|
||||
// if (res.code === 401) { removeToken(); /* 跳转登录 */ }
|
||||
|
||||
return Promise.reject(new Error(errMsg))
|
||||
},
|
||||
(error: unknown) => {
|
||||
let msg = '网络错误,请稍后重试'
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const resData = error.response?.data as ApiResponse | undefined
|
||||
if (resData?.msg) {
|
||||
msg = resData.msg
|
||||
} else if (error.message) {
|
||||
msg = error.message
|
||||
}
|
||||
|
||||
// HTTP 状态码特殊处理
|
||||
const status = error.response?.status
|
||||
if (status === 401) {
|
||||
msg = '登录已过期,请重新登录'
|
||||
removeToken()
|
||||
// TODO: 跳转登录页
|
||||
} else if (status === 403) {
|
||||
msg = '没有操作权限'
|
||||
} else if (status === 404) {
|
||||
msg = '请求的资源不存在'
|
||||
} else if (status === 500) {
|
||||
msg = '服务器内部错误'
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
msg = error.message
|
||||
}
|
||||
|
||||
showFailToast(msg)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// 类型安全的请求方法
|
||||
// ══════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET 请求
|
||||
* @param url - 请求地址
|
||||
* @param params - 查询参数
|
||||
* @param config - Axios 请求配置
|
||||
* @returns Promise<T>
|
||||
*/
|
||||
export async function get<T>(
|
||||
url: string,
|
||||
params?: Record<string, unknown>,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
return http.get(url, { params, ...config }) as Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* POST 请求
|
||||
* @param url - 请求地址
|
||||
* @param data - 请求体数据
|
||||
* @param config - Axios 请求配置
|
||||
* @returns Promise<T>
|
||||
*/
|
||||
export async function post<T>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
return http.post(url, data, config) as Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 请求
|
||||
* @param url - 请求地址
|
||||
* @param data - 请求体数据
|
||||
* @param config - Axios 请求配置
|
||||
* @returns Promise<T>
|
||||
*/
|
||||
export async function put<T>(
|
||||
url: string,
|
||||
data?: unknown,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
return http.put(url, data, config) as Promise<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE 请求
|
||||
* @param url - 请求地址
|
||||
* @param config - Axios 请求配置
|
||||
* @returns Promise<T>
|
||||
*/
|
||||
export async function del<T>(
|
||||
url: string,
|
||||
config?: AxiosRequestConfig,
|
||||
): Promise<T> {
|
||||
return http.delete(url, config) as Promise<T>
|
||||
}
|
||||
|
||||
/** 导出原始的 Axios 实例,供特殊场景(如文件上传进度监听)使用 */
|
||||
export default http
|
||||
125
src/views/home/index.vue
Normal file
125
src/views/home/index.vue
Normal file
@@ -0,0 +1,125 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 首页 (placeholder)
|
||||
*
|
||||
* 使用 Vant Tabbar 实现底部导航切换,
|
||||
* 当前仅展示占位内容,后续集成地图、管网等功能模块。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
/** 当前激活的 Tab */
|
||||
const active = ref(0)
|
||||
|
||||
/**
|
||||
* Tab 切换处理
|
||||
*/
|
||||
function onTabChange(index: number): void {
|
||||
active.value = index
|
||||
if (index === 0) {
|
||||
router.replace('/home')
|
||||
} else if (index === 1) {
|
||||
router.replace('/mine')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home-page">
|
||||
<!-- 顶部导航栏 -->
|
||||
<van-nav-bar title="舆图智慧水务" fixed placeholder />
|
||||
|
||||
<!-- 页面主体区域 -->
|
||||
<div class="home-content">
|
||||
<div class="welcome-card">
|
||||
<h2>欢迎使用智慧水务</h2>
|
||||
<p>移动端管理平台</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-grid">
|
||||
<div class="feature-item" v-for="i in 4" :key="i">
|
||||
<div class="feature-icon"></div>
|
||||
<span class="feature-label">功能模块 {{ i }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部导航栏 -->
|
||||
<van-tabbar v-model="active" :fixed="true" :placeholder="true" @change="onTabChange">
|
||||
<van-tabbar-item icon="home-o" name="首页">首页</van-tabbar-item>
|
||||
<van-tabbar-item icon="user-o" name="我的">我的</van-tabbar-item>
|
||||
</van-tabbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-page);
|
||||
|
||||
:deep(.van-nav-bar) {
|
||||
background: var(--color-primary);
|
||||
--van-nav-bar-title-text-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.home-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: 12px;
|
||||
padding: 32px 24px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: 16px;
|
||||
|
||||
h2 {
|
||||
font-size: 20px;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
background: var(--color-bg-card);
|
||||
border-radius: 12px;
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
cursor: pointer;
|
||||
transition: background-color var(--transition-fast);
|
||||
|
||||
&:active {
|
||||
background: var(--color-border-light);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: var(--color-primary-bg);
|
||||
margin: 0 auto 8px;
|
||||
}
|
||||
|
||||
.feature-label {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-regular);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
153
src/views/login/index.vue
Normal file
153
src/views/login/index.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 登录页面 (placeholder)
|
||||
*
|
||||
* 包含用户名、密码输入表单,调用 userStore.login 完成登录。
|
||||
* 登录成功后跳转至首页(或 redirect 参数指定的页面)。
|
||||
*/
|
||||
import { ref, reactive } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { showSuccessToast, showFailToast } from 'vant'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
/** 表单加载状态 */
|
||||
const loading = ref(false)
|
||||
|
||||
/** 登录表单数据 */
|
||||
const loginForm = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
/** 表单校验规则 */
|
||||
const rules = {
|
||||
username: [{ required: true, message: '请输入用户名' }],
|
||||
password: [{ required: true, message: '请输入密码' }],
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理登录提交
|
||||
*/
|
||||
async function handleSubmit(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login({
|
||||
username: loginForm.username,
|
||||
password: loginForm.password,
|
||||
})
|
||||
showSuccessToast('登录成功')
|
||||
// 跳转到 redirect 指定的页面或首页
|
||||
const redirect = (route.query.redirect as string) || '/home'
|
||||
router.replace(redirect)
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '登录失败,请重试'
|
||||
showFailToast(msg)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<!-- 头部 Logo 区域 -->
|
||||
<div class="login-header">
|
||||
<h1 class="login-title">舆图智慧水务</h1>
|
||||
<p class="login-subtitle">移动端管理平台</p>
|
||||
</div>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<div class="login-form-wrapper">
|
||||
<van-form @submit="handleSubmit">
|
||||
<van-field
|
||||
v-model="loginForm.username"
|
||||
name="username"
|
||||
label="用户名"
|
||||
placeholder="请输入用户名"
|
||||
:rules="rules.username"
|
||||
clearable
|
||||
left-icon="user-o"
|
||||
/>
|
||||
<van-field
|
||||
v-model="loginForm.password"
|
||||
name="password"
|
||||
label="密码"
|
||||
placeholder="请输入密码"
|
||||
type="password"
|
||||
:rules="rules.password"
|
||||
left-icon="lock"
|
||||
/>
|
||||
<div class="login-button-wrapper">
|
||||
<van-button
|
||||
round
|
||||
block
|
||||
type="primary"
|
||||
native-type="submit"
|
||||
:loading="loading"
|
||||
loading-text="登录中..."
|
||||
>
|
||||
登 录
|
||||
</van-button>
|
||||
</div>
|
||||
</van-form>
|
||||
</div>
|
||||
|
||||
<!-- 底部版权 -->
|
||||
<div class="login-footer">
|
||||
<span>v2.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-page);
|
||||
padding: 0 24px;
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 48px;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: var(--color-primary);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.login-form-wrapper {
|
||||
width: 100%;
|
||||
background: var(--color-bg-card);
|
||||
border-radius: 12px;
|
||||
padding: 24px 16px;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.login-button-wrapper {
|
||||
margin-top: 24px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
position: fixed;
|
||||
bottom: 32px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-placeholder);
|
||||
}
|
||||
</style>
|
||||
77
src/views/mine/index.vue
Normal file
77
src/views/mine/index.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 个人中心页面 (placeholder)
|
||||
*
|
||||
* 展示用户基本信息,提供退出登录等功能入口。
|
||||
*/
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mine-page">
|
||||
<van-nav-bar title="我的" fixed placeholder />
|
||||
|
||||
<div class="mine-content">
|
||||
<div class="user-card">
|
||||
<div class="avatar-placeholder"></div>
|
||||
<div class="user-info">
|
||||
<span class="user-name">未登录</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<van-cell-group inset>
|
||||
<van-cell title="个人信息" is-link />
|
||||
<van-cell title="系统设置" is-link />
|
||||
<van-cell title="关于我们" is-link />
|
||||
</van-cell-group>
|
||||
|
||||
<div class="logout-wrapper">
|
||||
<van-button round block type="danger" @click="router.replace('/login')">
|
||||
退出登录
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mine-page {
|
||||
min-height: 100vh;
|
||||
background: var(--color-bg-page);
|
||||
}
|
||||
|
||||
.mine-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--color-bg-card);
|
||||
border-radius: 12px;
|
||||
padding: 24px 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary-bg);
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.logout-wrapper {
|
||||
margin-top: 24px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user