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:
2026-06-15 20:56:05 +08:00
parent 74cc0df2b8
commit 9b68a2d275
19 changed files with 2080 additions and 8 deletions

View File

@@ -1,13 +1,15 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>yuto-water-h5</title> <title>舆图智慧水务</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<!-- 运行时全局配置 — 开发环境 -->
<script src="/config/globalConfig.dev.js"></script>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

24
package-lock.json generated
View File

@@ -20,7 +20,8 @@
"supercluster": "^8.0.1", "supercluster": "^8.0.1",
"uuid": "^14.0.0", "uuid": "^14.0.0",
"vant": "^4.9.24", "vant": "^4.9.24",
"vue": "^3.5.34" "vue": "^3.5.34",
"vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.12.3", "@types/node": "^24.12.3",
@@ -5035,6 +5036,27 @@
} }
} }
}, },
"node_modules/vue-router": {
"version": "4.6.4",
"resolved": "http://mirrors.tencentyun.com/npm/vue-router/-/vue-router-4.6.4.tgz",
"integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/vue-router/node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "http://mirrors.tencentyun.com/npm/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
"license": "MIT"
},
"node_modules/vue-tsc": { "node_modules/vue-tsc": {
"version": "3.3.5", "version": "3.3.5",
"dev": true, "dev": true,

View File

@@ -21,7 +21,8 @@
"supercluster": "^8.0.1", "supercluster": "^8.0.1",
"uuid": "^14.0.0", "uuid": "^14.0.0",
"vant": "^4.9.24", "vant": "^4.9.24",
"vue": "^3.5.34" "vue": "^3.5.34",
"vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.12.3", "@types/node": "^24.12.3",

View File

@@ -0,0 +1,148 @@
/**
* @Description: 全局配置(开发环境)
*
* 此文件在 index.html 中通过 <script> 标签加载,
* 将配置挂载到 window.globalConfig 供应用启动时读取。
*
* 生产环境部署时,由运维/后端将此文件替换为对应环境的配置。
*/
window.globalConfig = {
// ══════════════════════════════════════════════
// 系统基础配置
// ══════════════════════════════════════════════
system: {
/** 系统名称(登录页、导航栏展示) */
name: '舆图智慧水务',
/** 系统编码(集成管控平台中的子系统编码) */
id: '4b9ce59235794ff5b6785dd0bf4ddf9e',
/** 附件存储桶名称 */
buckName: 'swfile',
/** 符号库存储桶名称 */
buckNameMetadata: 'sw-metadata',
/** 附件存储桶地址 */
imgLoadUrl: 'http://10.10.10.189:81/icp-api',
/** 版本信息 */
version: 'v2.0.0',
/** 是否是微信小程序 */
isMiniProgram: false,
/** navbar 高度px */
navHeight: 76,
/** 轨迹记录时间间隔毫秒1分钟 */
trackTime: 1000 * 60,
/** 班组打卡轨迹记录距离间隔毫秒10分钟 */
groupsTrackTime: 1000 * 600,
/** 默认行政区划等级PROVINCE | CITY | COUNTY | TOWNSHIP */
regionLevel: 'COUNTY',
/** 系统首页标题 */
homeTitle: '智慧水务',
/** 系统描述 */
description: '舆图智慧水务移动端',
},
// ══════════════════════════════════════════════
// API 接口配置
// ══════════════════════════════════════════════
api: {
/** API 主机地址 */
host: 'http://10.10.10.189:81/',
/** API 基础路径 */
baseURL: 'http://10.10.10.189:81/icp-api',
// baseURL: '/dev-api', // 代理模式
/** 智慧水务服务路径 */
swPath: '/wisdomwater',
// swPath: '/wisdomwatersm', // 思明环境
/** 天地图接口服务 */
tdt: 'https://api.tianditu.gov.cn',
tdtToken: '72f4500be0826ec2443f0794b05bec0f',
/** 高德地图接口服务 */
amap: 'https://restapi.amap.com',
amapKey: 'c6c64f95ef9ca6f504d4b676a0a77c7d',
/** 台风数据接口服务 */
typhoon: 'http://183.252.1.27:8082',
/** 请求超时时间(毫秒) */
timeout: 30000,
},
// ══════════════════════════════════════════════
// 地图配置
// ══════════════════════════════════════════════
map: {
/** 地图中心点 [经度, 纬度] */
center: [118.734575, 31.990939],
// center: [118.112785, 24.486653],
/** 地图默认缩放级别 */
zoom: 10,
/** 地图最大缩放级别 */
maxZoom: 20,
/** 地图最小缩放级别 */
minZoom: 3,
/** Token 拦截配置 */
interceptors: [
{
urls: 'https://map.zygh.xm.gov.cn:6060',
params: {
token: '11eA5fuVv89GgFd2zJRJj44OQsDuHzCf-LuiYqjfqF3D3RlrV4hGzv9ev6ckf8eZbGGW2obvw9zHKU5g0XU-6w..',
},
},
],
/** 基础图层列表 */
baseLayers: [],
/** 地图类型 */
type: 'tdt',
},
// ══════════════════════════════════════════════
// 排水管网配置
// ══════════════════════════════════════════════
pipe: {
/** 检查井 ID */
checkId: '32',
},
// ══════════════════════════════════════════════
// 巡检养护配置
// ══════════════════════════════════════════════
xjyhSys: {
/** (防汛)养护单位父级 code */
fxyhdwParentCode: 'yhdw',
/** 巡检单位父级 code */
xjdwParentCode: 'swxj',
},
// ══════════════════════════════════════════════
// 打卡配置
// ══════════════════════════════════════════════
clockIn: {
/** 打卡距离(米) */
distance: 50,
},
// ══════════════════════════════════════════════
// 系统加载服务类型
// ══════════════════════════════════════════════
loadMapType: {
/** 基础服务 */
basic: 'base-map',
/** 专题服务 */
thematic: 'thematic-map',
},
/** 基础服务缓存 Key */
CACHE_KEY: 'loadMapListCacheSearch',
/** 所有基础服务缓存 Key */
CACHE_KEY_ALL: 'loadMapListCacheSearch_ALL',
/** 专题判断标识列表 */
specialFeature: [],
// ══════════════════════════════════════════════
// 设备类型配置(雨情/水情/工情)
// ══════════════════════════════════════════════
sbType: {
/** 雨情设备 */
yq: ['雨量计'],
/** 水情设备 */
sq: ['水位计', '河道水位', '电子水尺'],
/** 工情设备 */
gq: ['雨水泵站', '闸门', '截流井', '拍门'],
},
}

View File

@@ -1,7 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import HelloWorld from './components/HelloWorld.vue' /**
* 应用根组件
*
* 使用 Vant ConfigProvider 包裹路由视图,
* 提供全局主题配置和路由出口。
*/
</script> </script>
<template> <template>
<HelloWorld /> <van-config-provider>
<router-view />
</van-config-provider>
</template> </template>
<style>
/* App-level 全局样式调整 */
#app {
width: 100%;
min-height: 100vh;
}
</style>

139
src/api/common.ts Normal file
View 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
View 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
View 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
View 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
/** 天地图 Keytype 为 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 {}

View File

@@ -1,5 +1,119 @@
/**
* 应用入口 — Yuto Water H5
*
* 初始化 Vue 应用,集成 Vant 4 UI、Pinia 状态管理、
* Vue Router 路由、全局样式等核心模块。
*
* @module main
*/
import { createApp } from 'vue' 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 './styles/index.scss'
// ── 路由 ──
import router from './router'
// ── 根组件 ──
import App from './App.vue' 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
View 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
View 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
View 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
View 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
View 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
View 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'
* - timeout30 秒超时
*/
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
View 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
View 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
View 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>