面板
This commit is contained in:
parent
1935a60c92
commit
086cf94ae5
22
.env
Normal file
22
.env
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# 【通用】环境变量
|
||||||
|
|
||||||
|
# 版本号
|
||||||
|
VITE_VERSION = 2.4.2.4
|
||||||
|
# 端口号
|
||||||
|
VITE_PORT = 3006
|
||||||
|
|
||||||
|
# 网站地址前缀
|
||||||
|
VITE_BASE_URL = /
|
||||||
|
|
||||||
|
# API 地址前缀
|
||||||
|
VITE_API_URL = http://192.168.110.7:25928
|
||||||
|
# VITE_API_URL = https://bsh.yw.server.hnzhwlkj.cn
|
||||||
|
|
||||||
|
# 权限模式( frontend | backend )
|
||||||
|
VITE_ACCESS_MODE = frontend
|
||||||
|
|
||||||
|
# 是否打开路由信息
|
||||||
|
VITE_OPEN_ROUTE_INFO = false
|
||||||
|
|
||||||
|
# 锁屏加密密钥
|
||||||
|
VITE_LOCK_ENCRYPT_KEY = jfsfjk1938jfj
|
||||||
BIN
.env.development
Normal file
BIN
.env.development
Normal file
Binary file not shown.
BIN
.env.production
Normal file
BIN
.env.production
Normal file
Binary file not shown.
56
dist/api/data.json
vendored
Normal file
56
dist/api/data.json
vendored
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"label": "销售数据",
|
||||||
|
"cards": [
|
||||||
|
"BFS渠道Q3销售进度",
|
||||||
|
"华北西门子Q3重点品销售进度",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"新品/套系销售情况"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "CX/供应链看板",
|
||||||
|
"cards": [
|
||||||
|
"供应链Q3销售进度",
|
||||||
|
"供应链Q3销售进度",
|
||||||
|
"供应链Q3销售进度",
|
||||||
|
"供应链Q3销售进度",
|
||||||
|
"供应链Q3销售进度",
|
||||||
|
"供应链Q3销售进度",
|
||||||
|
"库存分析",
|
||||||
|
"交付情况"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "产品内容",
|
||||||
|
"cards": [
|
||||||
|
"产品详情",
|
||||||
|
"产品详情",
|
||||||
|
"产品详情",
|
||||||
|
"产品详情",
|
||||||
|
"产品详情",
|
||||||
|
"产品详情",
|
||||||
|
"产品详情",
|
||||||
|
"新产品规划"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "战略地图",
|
||||||
|
"cards": [
|
||||||
|
"战略目标",
|
||||||
|
"战略目标",
|
||||||
|
"战略目标",
|
||||||
|
"战略目标",
|
||||||
|
"战略目标",
|
||||||
|
"战略目标",
|
||||||
|
"战略目标",
|
||||||
|
"市场布局"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
4
dist/api/login.json
vendored
Normal file
4
dist/api/login.json
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"code": 200,
|
||||||
|
"token": "mocked-jwt-token"
|
||||||
|
}
|
||||||
66
dist/api/menus.json
vendored
Normal file
66
dist/api/menus.json
vendored
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "sales",
|
||||||
|
"name": "销售数据",
|
||||||
|
"cards": [
|
||||||
|
"BFS渠道Q3销售进度",
|
||||||
|
"华北西门子Q3重点品销售进度",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"新品/套系销售情况"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "cx",
|
||||||
|
"name": "CX/供应链看板",
|
||||||
|
"cards": [
|
||||||
|
"BFS渠道Q3销售进度",
|
||||||
|
"华北西门子Q3重点品销售进度",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"新品/套系销售情况"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "product",
|
||||||
|
"name": "产品内容",
|
||||||
|
"cards": [
|
||||||
|
"BFS渠道Q3销售进度",
|
||||||
|
"华北西门子Q3重点品销售进度",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"新品/套系销售情况"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "map",
|
||||||
|
"name": "战略地图",
|
||||||
|
"cards": [
|
||||||
|
"BFS渠道Q3销售进度",
|
||||||
|
"华北西门子Q3重点品销售进度",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"2025年销售数据看板",
|
||||||
|
"新品/套系销售情况"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
4
dist/api/user.json
vendored
Normal file
4
dist/api/user.json
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"name": "Admin",
|
||||||
|
"avatar": "https://i.pravatar.cc/100"
|
||||||
|
}
|
||||||
BIN
dist/assets/Rectangle-Cj9dDHwH.png
vendored
Normal file
BIN
dist/assets/Rectangle-Cj9dDHwH.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
BIN
dist/assets/iconfont-CIM5cTsr.ttf
vendored
Normal file
BIN
dist/assets/iconfont-CIM5cTsr.ttf
vendored
Normal file
Binary file not shown.
BIN
dist/assets/iconfont-DO81I5ZO.woff
vendored
Normal file
BIN
dist/assets/iconfont-DO81I5ZO.woff
vendored
Normal file
Binary file not shown.
BIN
dist/assets/iconfont-Do8FRWo6.woff2
vendored
Normal file
BIN
dist/assets/iconfont-Do8FRWo6.woff2
vendored
Normal file
Binary file not shown.
63
dist/assets/index-DFqZsSAt.js
vendored
Normal file
63
dist/assets/index-DFqZsSAt.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-DFqZsSAt.js.map
vendored
Normal file
1
dist/assets/index-DFqZsSAt.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-DagmUA4t.css
vendored
Normal file
1
dist/assets/index-DagmUA4t.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
dist/assets/logo-DFGcQTBE.png
vendored
Normal file
BIN
dist/assets/logo-DFGcQTBE.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
13
dist/index.html
vendored
Normal file
13
dist/index.html
vendored
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>数据看板</title>
|
||||||
|
<script type="module" crossorigin src="/assets/index-DFqZsSAt.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-DagmUA4t.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>Vue App</title>
|
<title>数据看板</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
947
package-lock.json
generated
947
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@ -3,19 +3,23 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite --mode development",
|
||||||
"build": "vite build",
|
"dev:prod": "vite --mode production",
|
||||||
|
"build": "vite build --mode production",
|
||||||
|
"build:dev": "vite build --mode development",
|
||||||
"serve": "vite preview",
|
"serve": "vite preview",
|
||||||
"mock": "node mock-server.js"
|
"mock": "node mock-server.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.2",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
"element-plus": "^2.11.5",
|
||||||
"vue": "^3.4.0",
|
"vue": "^3.4.0",
|
||||||
"vue-router": "^4.2.0"
|
"vue-router": "^4.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitejs/plugin-vue": "^4.5.0",
|
"@vitejs/plugin-vue": "^4.5.0",
|
||||||
"vite": "^5.0.0",
|
"express": "^4.18.2",
|
||||||
"express": "^4.18.2"
|
"vite": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,13 +1,42 @@
|
|||||||
import request from './request'
|
import request from './request'
|
||||||
|
|
||||||
export function apiLogin(payload){
|
export function apiLogin(payload){
|
||||||
return request.post('/api/login', payload)
|
return request.post('/common/auth/login', payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function apiqrcode(){
|
||||||
|
return request.get('/qrcode/url')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMenus(){
|
export function getMenus(){
|
||||||
return request.get('/api/menus.json')
|
return request.get('/common/nav/categories/complete')
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getUser(){
|
export function getUser(){
|
||||||
return request.get('/api/user.json')
|
return request.get('/common/user/info')
|
||||||
|
}
|
||||||
|
export function apiRefreshToken(payload){
|
||||||
|
return request.post('/common/auth/refresh/token', payload)
|
||||||
|
}
|
||||||
|
//获取微信用户信息
|
||||||
|
export function getWechatUserInfo(params){
|
||||||
|
// 通过 code / state 向后端换取微信用户信息
|
||||||
|
return request.get('/user/info', { params })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绑定微信到系统账户(姓名/手机号/编号/密码)
|
||||||
|
export function bindWechatAccount(payload){
|
||||||
|
return request.post('/bind/user', payload)
|
||||||
|
}
|
||||||
|
//更新用户信息
|
||||||
|
export function updateUserInfo(payload){
|
||||||
|
return request.put('/admin/user/edit', payload)
|
||||||
|
}
|
||||||
|
//重置密码
|
||||||
|
export function resetPassword(payload){
|
||||||
|
return request.put('/common/user/update/password', payload)
|
||||||
|
}
|
||||||
|
//图片上传
|
||||||
|
export function uploadImage(payload){
|
||||||
|
return request.post('/upload/image', payload)
|
||||||
}
|
}
|
||||||
@ -1,16 +1,131 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
const service = axios.create({
|
const service = axios.create({
|
||||||
baseURL: '', // use relative so vite proxy/public works
|
baseURL: import.meta.env.VITE_API_URL || '', // 使用环境变量配置API地址
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
})
|
})
|
||||||
|
|
||||||
service.interceptors.request.use(cfg => {
|
// --- Token 管理 ---
|
||||||
const token = localStorage.getItem('token')
|
const ACCESS_TOKEN_KEY = 'token'
|
||||||
if (token) cfg.headers['Authorization'] = `Bearer ${token}`
|
const REFRESH_TOKEN_KEY = 'refresh_token'
|
||||||
|
const EXPIRES_AT_KEY = 'token_expires_at' // ms 时间戳
|
||||||
|
const EXPIRY_BUFFER_MS = 60 * 1000 // 提前 60s 刷新
|
||||||
|
|
||||||
|
function getStoredTokens(){
|
||||||
|
return {
|
||||||
|
accessToken: localStorage.getItem(ACCESS_TOKEN_KEY) || '',
|
||||||
|
refreshToken: localStorage.getItem(REFRESH_TOKEN_KEY) || '',
|
||||||
|
expiresAt: Number(localStorage.getItem(EXPIRES_AT_KEY) || 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function storeTokens({ accessToken, refreshToken, expiresIn }){
|
||||||
|
if (accessToken) localStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
|
||||||
|
if (refreshToken) localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
|
||||||
|
if (typeof expiresIn === 'number') {
|
||||||
|
const expiresAt = Date.now() + Math.max(0, expiresIn * 1000 - EXPIRY_BUFFER_MS)
|
||||||
|
localStorage.setItem(EXPIRES_AT_KEY, String(expiresAt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAccessTokenExpiredSoon(){
|
||||||
|
const { expiresAt } = getStoredTokens()
|
||||||
|
if (!expiresAt) return false
|
||||||
|
return Date.now() >= expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
let isRefreshing = false
|
||||||
|
let refreshPromise = null
|
||||||
|
let requestQueue = []
|
||||||
|
|
||||||
|
async function refreshAccessToken(){
|
||||||
|
const { refreshToken } = getStoredTokens()
|
||||||
|
if (!refreshToken) throw new Error('No refresh token')
|
||||||
|
|
||||||
|
// 使用裸 axios 避免拦截器递归
|
||||||
|
const resp = await axios.post(
|
||||||
|
`${import.meta.env.VITE_API_URL || ''}/common/auth/refresh/token`,
|
||||||
|
{ refresh_token: refreshToken },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Fdesk-Language': 'zh',
|
||||||
|
'Fdesk-System-Code': 'board',
|
||||||
|
'Fdesk-Login-Type': 'upwd'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const data = resp.data?.data || resp.data || {}
|
||||||
|
const accessToken = data.access_token || data.accessToken
|
||||||
|
const newRefreshToken = data.refresh_token || refreshToken
|
||||||
|
const expiresIn = Number(data.expires_in || data.expiresIn || 0)
|
||||||
|
if (!accessToken) throw new Error('Refresh failed: no accessToken')
|
||||||
|
storeTokens({ accessToken, refreshToken: newRefreshToken, expiresIn })
|
||||||
|
return accessToken
|
||||||
|
}
|
||||||
|
|
||||||
|
// 请求拦截:附加通用头,并在过期时刷新 Token
|
||||||
|
service.interceptors.request.use(async (cfg) => {
|
||||||
|
// 通用 Header
|
||||||
|
cfg.headers['Fdesk-Language'] = 'zh'
|
||||||
|
cfg.headers['Fdesk-System-Code'] = 'board'
|
||||||
|
cfg.headers['Fdesk-Login-Type'] = 'upwd'
|
||||||
|
|
||||||
|
// 刷新逻辑
|
||||||
|
if (isAccessTokenExpiredSoon()) {
|
||||||
|
if (!isRefreshing) {
|
||||||
|
isRefreshing = true
|
||||||
|
refreshPromise = refreshAccessToken().finally(() => { isRefreshing = false })
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await refreshPromise
|
||||||
|
// 唤醒队列
|
||||||
|
requestQueue.forEach(res => res())
|
||||||
|
requestQueue = []
|
||||||
|
} catch (e) {
|
||||||
|
requestQueue = []
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
} else if (isRefreshing) {
|
||||||
|
// 等待当前刷新完成
|
||||||
|
await new Promise((resolve) => requestQueue.push(resolve))
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessToken } = getStoredTokens()
|
||||||
|
if (accessToken) cfg.headers['Fdesk-Auth-Token'] = `Bearer ${accessToken}`
|
||||||
return cfg
|
return cfg
|
||||||
}, err => Promise.reject(err))
|
}, err => Promise.reject(err))
|
||||||
|
|
||||||
service.interceptors.response.use(res => res.data, err => Promise.reject(err))
|
// 响应拦截:直出 data;401 时尝试一次刷新后重试
|
||||||
|
service.interceptors.response.use(
|
||||||
|
(res) => res.data,
|
||||||
|
async (error) => {
|
||||||
|
const original = error?.config
|
||||||
|
const status = error?.response?.status
|
||||||
|
if (status === 401 && !original?._retried) {
|
||||||
|
try {
|
||||||
|
if (!isRefreshing) {
|
||||||
|
isRefreshing = true
|
||||||
|
refreshPromise = refreshAccessToken().finally(() => { isRefreshing = false })
|
||||||
|
}
|
||||||
|
await refreshPromise
|
||||||
|
original._retried = true
|
||||||
|
const { accessToken } = getStoredTokens()
|
||||||
|
if (accessToken) original.headers['Fdesk-Auth-Token'] = `Bearer ${accessToken}`
|
||||||
|
return service(original)
|
||||||
|
} catch (e) {
|
||||||
|
// 刷新失败,清理本地存储并重定向到登录页
|
||||||
|
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(EXPIRES_AT_KEY)
|
||||||
|
|
||||||
|
// 如果是在浏览器环境中,重定向到登录页
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
export default service
|
export default service
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 395 B After Width: | Height: | Size: 1.0 KiB |
2663
src/assets/icons/system/iconfont.css
Normal file
2663
src/assets/icons/system/iconfont.css
Normal file
File diff suppressed because it is too large
Load Diff
67
src/assets/icons/system/iconfont.js
Normal file
67
src/assets/icons/system/iconfont.js
Normal file
File diff suppressed because one or more lines are too long
4643
src/assets/icons/system/iconfont.json
Normal file
4643
src/assets/icons/system/iconfont.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
src/assets/icons/system/iconfont.ttf
Normal file
BIN
src/assets/icons/system/iconfont.ttf
Normal file
Binary file not shown.
BIN
src/assets/icons/system/iconfont.woff
Normal file
BIN
src/assets/icons/system/iconfont.woff
Normal file
Binary file not shown.
BIN
src/assets/icons/system/iconfont.woff2
Normal file
BIN
src/assets/icons/system/iconfont.woff2
Normal file
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 0 B After Width: | Height: | Size: 11 KiB |
15
src/main.js
15
src/main.js
@ -1,6 +1,19 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||||
import './assets/style.css'
|
import './assets/style.css'
|
||||||
|
import './assets/icons/system/iconfont.css'
|
||||||
|
|
||||||
createApp(App).use(router).mount('#app')
|
const app = createApp(App)
|
||||||
|
|
||||||
|
// 注册所有图标
|
||||||
|
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||||
|
app.component(key, component)
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus)
|
||||||
|
app.mount('#app')
|
||||||
|
|||||||
@ -1,11 +1,15 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import Login from '../views/Login.vue'
|
import Login from '../views/Login.vue'
|
||||||
import Dashboard from '../views/Dashboard.vue'
|
import Dashboard from '../views/Dashboard.vue'
|
||||||
|
import WxCallback from '../views/wx_callback.vue'
|
||||||
|
import { isLoggedIn, clearTokens } from '../utils/auth'
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ path: '/', redirect: '/login' },
|
{ path: '/', redirect: '/login' },
|
||||||
{ path: '/login', component: Login },
|
{ path: '/login', component: Login, meta: { public: true } },
|
||||||
{ path: '/dashboard', component: Dashboard }
|
{ path: '/dashboard', component: Dashboard },
|
||||||
|
{ path: '/wx_callback', component: WxCallback, meta: { public: true } }
|
||||||
]
|
]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
@ -13,4 +17,27 @@ const router = createRouter({
|
|||||||
routes
|
routes
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 路由守卫:无效或缺失 token 时跳转登录
|
||||||
|
router.beforeEach((to, from, next) => {
|
||||||
|
// 标记为 public 的路由直接放行
|
||||||
|
if (to.meta && to.meta.public) return next()
|
||||||
|
|
||||||
|
if (to.path === '/login') {
|
||||||
|
// 已登录且未过期,访问登录页则跳转首页
|
||||||
|
if (isLoggedIn()) {
|
||||||
|
ElMessage.info('您已登录,正在跳转到首页...')
|
||||||
|
return next('/dashboard')
|
||||||
|
}
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他受保护路由:检查登录状态
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
ElMessage.warning('请先登录')
|
||||||
|
return next('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return next()
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
84
src/utils/auth.js
Normal file
84
src/utils/auth.js
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
// Token 管理常量
|
||||||
|
const ACCESS_TOKEN_KEY = 'token'
|
||||||
|
const REFRESH_TOKEN_KEY = 'refresh_token'
|
||||||
|
const EXPIRES_AT_KEY = 'token_expires_at'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否已登录
|
||||||
|
* @returns {boolean} 是否已登录
|
||||||
|
*/
|
||||||
|
export function isLoggedIn() {
|
||||||
|
const token = localStorage.getItem(ACCESS_TOKEN_KEY)
|
||||||
|
const expiresAt = Number(localStorage.getItem(EXPIRES_AT_KEY) || 0)
|
||||||
|
|
||||||
|
if (!token) return false
|
||||||
|
|
||||||
|
// 检查 token 是否过期
|
||||||
|
if (expiresAt && Date.now() >= expiresAt) {
|
||||||
|
// Token 已过期,清理本地存储
|
||||||
|
clearTokens()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理所有 token 相关数据
|
||||||
|
*/
|
||||||
|
export function clearTokens() {
|
||||||
|
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(EXPIRES_AT_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存登录信息
|
||||||
|
* @param {Object} data - 登录响应数据
|
||||||
|
*/
|
||||||
|
export function saveLoginData(data) {
|
||||||
|
const { access_token, refresh_token, expires_in } = data
|
||||||
|
|
||||||
|
if (access_token) {
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refresh_token) {
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, refresh_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof expires_in === 'number') {
|
||||||
|
const expiresAt = Date.now() + expires_in * 1000
|
||||||
|
localStorage.setItem(EXPIRES_AT_KEY, String(expiresAt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理登录状态检查和重定向
|
||||||
|
* @param {Object} router - Vue Router 实例
|
||||||
|
* @param {string} loginPath - 登录页面路径,默认为 '/login'
|
||||||
|
*/
|
||||||
|
export function handleAuthRedirect(router, loginPath = '/login') {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
ElMessage.warning('登录已过期,请重新登录')
|
||||||
|
router.replace(loginPath)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登出处理
|
||||||
|
* @param {Object} router - Vue Router 实例
|
||||||
|
* @param {string} loginPath - 登录页面路径,默认为 '/login'
|
||||||
|
*/
|
||||||
|
export function logout(router, loginPath = '/login') {
|
||||||
|
clearTokens()
|
||||||
|
ElMessage.success('已退出登录')
|
||||||
|
router.replace(loginPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1,24 +0,0 @@
|
|||||||
import axios from "axios"
|
|
||||||
|
|
||||||
const service = axios.create({
|
|
||||||
baseURL: "http://localhost:5173",
|
|
||||||
timeout: 5000
|
|
||||||
})
|
|
||||||
|
|
||||||
service.interceptors.request.use(
|
|
||||||
(config) => {
|
|
||||||
const token = localStorage.getItem("token")
|
|
||||||
if (token) {
|
|
||||||
config.headers["Authorization"] = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
return config
|
|
||||||
},
|
|
||||||
(error) => Promise.reject(error)
|
|
||||||
)
|
|
||||||
|
|
||||||
service.interceptors.response.use(
|
|
||||||
(response) => response.data,
|
|
||||||
(error) => Promise.reject(error)
|
|
||||||
)
|
|
||||||
|
|
||||||
export default service
|
|
||||||
774
src/views/Dashboard copy 2.vue
Normal file
774
src/views/Dashboard copy 2.vue
Normal file
@ -0,0 +1,774 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<header class="topbar">
|
||||||
|
<button class="menu-btn" @click="sidebarOpen = !sidebarOpen">☰</button>
|
||||||
|
<div class="brand">
|
||||||
|
<img src="/src/assets/logo.png" alt="logo" />
|
||||||
|
<!-- <span>销售数据平台</span> -->
|
||||||
|
</div>
|
||||||
|
<div class="user-dropdown" @click.stop="toggleUserMenu">
|
||||||
|
<img :src="userInfo.avatar_pull" alt="avatar" class="avatar" />
|
||||||
|
<i class="iconfont-sys iconsys-xiala" v-if="false"></i>
|
||||||
|
<div v-if="showUserMenu" class="dropdown-menu" @click.stop>
|
||||||
|
<div class="item" @click="openProfile">个人中心</div>
|
||||||
|
<div class="item danger" @click="confirmLogout">退出登录</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 左侧导航 -->
|
||||||
|
<aside :class="['sidebar', { open: sidebarOpen }]">
|
||||||
|
<ul>
|
||||||
|
<!-- <li
|
||||||
|
v-for="(item, index) in menus"
|
||||||
|
:key="item.id"
|
||||||
|
@click="onNav(item.id, index)"
|
||||||
|
:class="{ active: activeMenu === index }"
|
||||||
|
>
|
||||||
|
<i class="iconfont-sys" v-html="item.icon" style="margin-right: 10px;"></i>{{ item.name }}
|
||||||
|
</li> -->
|
||||||
|
<template v-for="(item, index) in menus" :key="item.id">
|
||||||
|
<li
|
||||||
|
@click="onNav(item.id, index)"
|
||||||
|
:class="{ active: activeMenu === index }"
|
||||||
|
>
|
||||||
|
<i class="iconfont-sys" v-html="item.icon" style="margin-right: 10px;"></i>{{ item.name }}
|
||||||
|
</li>
|
||||||
|
<!-- 子菜单 -->
|
||||||
|
<li
|
||||||
|
v-for="child in item.child"
|
||||||
|
:key="child.id"
|
||||||
|
@click="onNav(child.id, -1)"
|
||||||
|
class="submenu-item"
|
||||||
|
>
|
||||||
|
{{ child.name }}
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
<div v-if="sidebarOpen" class="drawer-overlay" @click="sidebarOpen = false"></div>
|
||||||
|
|
||||||
|
<!-- 右侧内容 -->
|
||||||
|
<main class="content">
|
||||||
|
<section v-for="m in menus" :key="m.id" :id="m.id" class="section">
|
||||||
|
<h3>{{ m.name }}</h3>
|
||||||
|
<div class="title-block"></div>
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="card" v-for="(item, index) in m.cards" :key="index" @click="onCardClick(item)">
|
||||||
|
<div class="icon">
|
||||||
|
<img src="/src/assets/card-icon.png" alt="icon" />
|
||||||
|
</div>
|
||||||
|
<div class="info">
|
||||||
|
<div class="title">{{ item.title }}</div>
|
||||||
|
<div class="desc">{{ item.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 修改资料弹窗 -->
|
||||||
|
<div v-if="showProfile" class="modal-overlay" @click.self="showProfile = false">
|
||||||
|
<div class="modal profile-modal">
|
||||||
|
<!-- 顶部用户信息 -->
|
||||||
|
<div class="profile-header">
|
||||||
|
<div class="profile-avatar">
|
||||||
|
<img :src="userInfo.avatar_pull" alt="avatar" />
|
||||||
|
</div>
|
||||||
|
<div class="profile-meta">
|
||||||
|
<div class="profile-name">{{ userInfo.name }}</div>
|
||||||
|
<div class="profile-sub">联系电话:{{ userInfo.mobile || '-' }}</div>
|
||||||
|
<div class="profile-sub">编号:{{ userInfo.sn || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 基本设置 -->
|
||||||
|
<div class="profile-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>基本设置</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col label">头像</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="avatar-edit">
|
||||||
|
<img :src="userInfo.avatar_pull" alt="avatar" class="avatar-lg" />
|
||||||
|
<input v-if="basicEditing" type="file" @change="uploadAvatar" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col label">昵称</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" :disabled="!basicEditing" v-model="userInfo.nickname" placeholder="请输入昵称" />
|
||||||
|
</div>
|
||||||
|
<div class="col label">联系方式</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" :disabled="!basicEditing" v-model="userInfo.mobile" placeholder="请输入联系方式" />
|
||||||
|
</div>
|
||||||
|
<div class="col label">编号</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" disabled v-model="userInfo.sn" placeholder="请输入编号" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="edit-btn" @click="saveProfile">{{ basicEditing ? '保存' : '编辑' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 更改密码 -->
|
||||||
|
<div class="profile-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>更改密码</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col label">当前密码</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" :type="pwdEditing ? 'password' : 'password'" :disabled="!pwdEditing" v-model="pwdForm.password" placeholder="请输入原始密码" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col label">新密码</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" :type="pwdEditing ? 'password' : 'password'" :disabled="!pwdEditing" v-model="pwdForm.password_new" placeholder="请输入新密码" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col label">确认新密码</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" :type="pwdEditing ? 'password' : 'password'" :disabled="!pwdEditing" v-model="pwdForm.confirm" placeholder="请输入确认密码" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="edit-btn" @click="onResetPassword">{{ pwdEditing ? '保存' : '编辑' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn ghost" @click="showProfile = false">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 退出登录确认 -->
|
||||||
|
<div v-if="showLogoutConfirm" class="modal-overlay" @click.self="showLogoutConfirm = false">
|
||||||
|
<div class="modal confirm-modal">
|
||||||
|
<h3>确认退出登录?</h3>
|
||||||
|
<p style="color:#666;margin-top:6px;">退出后需要重新登录才能进入系统。</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn ghost" @click="showLogoutConfirm = false">取消</button>
|
||||||
|
<button class="btn primary" @click="doLogout">确认退出</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { getMenus, getUser, updateUserInfo, resetPassword, uploadImage } from "../api";
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
const menus = ref([]);
|
||||||
|
const profile = ref({ username: "", password: "" });
|
||||||
|
const showProfile = ref(false);
|
||||||
|
const sidebarOpen = ref(false);
|
||||||
|
const basicEditing = ref(false);
|
||||||
|
const pwdEditing = ref(false);
|
||||||
|
const pwdForm = ref({ password: "", password_new: "", confirm: "" });
|
||||||
|
const showUserMenu = ref(false);
|
||||||
|
const showLogoutConfirm = ref(false);
|
||||||
|
const userInfo = ref({});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
// const m = await getMenus();
|
||||||
|
const m =[
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"parent_id": 0,
|
||||||
|
"name": "销售数据",
|
||||||
|
"icon": "",
|
||||||
|
"child":[
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"parent_id": 2,
|
||||||
|
"name": "销售数据child1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"parent_id": 2,
|
||||||
|
"name": "销售数据child2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"parent_id": 2,
|
||||||
|
"name": "销售数据child3",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nav_item": [
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"category_id": 2,
|
||||||
|
"type": "file",
|
||||||
|
"title": "2025年销售数据看板",
|
||||||
|
"description": "每日更新",
|
||||||
|
"url": "bsh_datax/classify/20251022/2925f02de6b9f25efe5772f70fdd0126_68f892e4c3caa.pdf",
|
||||||
|
"sort": 1,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-14T06:00:07.000000Z",
|
||||||
|
"updated_at": "2025-10-22T08:16:39.000000Z",
|
||||||
|
"url_pull": "http://static.hnzhwlkj.cn/bsh_datax/classify/20251022/2925f02de6b9f25efe5772f70fdd0126_68f892e4c3caa.pdf?Expires=1763435290&OSSAccessKeyId=LTAI5tAGiH7bkVkmrniXnfkA&Signature=j6r9ZEJt2NUikBdjnW6jmZS9IKI%3D"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"category_id": 2,
|
||||||
|
"type": "file",
|
||||||
|
"title": "BFS渠道Q3销售进度",
|
||||||
|
"description": "每日12次更新",
|
||||||
|
"url": "bsh_datax/classify/20251022/1da0a229d5e8bbd89ef0a89938a75127_68f895f291285.xlsx",
|
||||||
|
"sort": 1,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-15T08:53:51.000000Z",
|
||||||
|
"updated_at": "2025-10-22T08:29:40.000000Z",
|
||||||
|
"url_pull": "http://static.hnzhwlkj.cn/bsh_datax/classify/20251022/1da0a229d5e8bbd89ef0a89938a75127_68f895f291285.xlsx?Expires=1763435290&OSSAccessKeyId=LTAI5tAGiH7bkVkmrniXnfkA&Signature=KQiXvIt9bJtpB3eCLRzzYHkiPhg%3D"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"parent_id": 0,
|
||||||
|
"name": "CX/供应链看板",
|
||||||
|
"icon": "",
|
||||||
|
"child":[
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"parent_id": 3,
|
||||||
|
"name": "供应链看板child1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 15,
|
||||||
|
"parent_id":3,
|
||||||
|
"name": "供应链看板",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 16,
|
||||||
|
"parent_id": 3,
|
||||||
|
"name": "供应链看板child3",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"nav_item": [
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"category_id": 3,
|
||||||
|
"type": "link",
|
||||||
|
"title": "BFS渠道Q3销售进度",
|
||||||
|
"description": "每日12次更新",
|
||||||
|
"url": "",
|
||||||
|
"sort": 0,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-16T06:18:55.000000Z",
|
||||||
|
"updated_at": "2025-10-16T06:18:55.000000Z",
|
||||||
|
"url_pull": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"category_id": 3,
|
||||||
|
"type": "link",
|
||||||
|
"title": "华北西门子Q3重品销售进度",
|
||||||
|
"description": "销售代表3季度KPI",
|
||||||
|
"url": "",
|
||||||
|
"sort": 0,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-16T06:19:09.000000Z",
|
||||||
|
"updated_at": "2025-10-16T06:19:09.000000Z",
|
||||||
|
"url_pull": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 13,
|
||||||
|
"category_id": 3,
|
||||||
|
"type": "file",
|
||||||
|
"title": "2025年西门子冰箱+洗衣机产品规划-9月",
|
||||||
|
"description": "",
|
||||||
|
"url": "bsh_datax/classify/20251022/eb728831e28f5e2a5e3760e5bc7e3da5_68f8906559822.xlsx",
|
||||||
|
"sort": 0,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-22T08:05:59.000000Z",
|
||||||
|
"updated_at": "2025-10-22T08:25:34.000000Z",
|
||||||
|
"url_pull": "http://static.hnzhwlkj.cn/bsh_datax/classify/20251022/eb728831e28f5e2a5e3760e5bc7e3da5_68f8906559822.xlsx?Expires=1763435290&OSSAccessKeyId=LTAI5tAGiH7bkVkmrniXnfkA&Signature=oitV4sQQn0crmJbT9TonaauPFDA%3D"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 14,
|
||||||
|
"category_id": 3,
|
||||||
|
"type": "file",
|
||||||
|
"title": "看板222",
|
||||||
|
"description": "",
|
||||||
|
"url": "bsh_datax/classify/20251022/063ab7207ed6f0b5f51ad67f06ca17de_68f891e33004b.xlsx",
|
||||||
|
"sort": 0,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-22T08:12:26.000000Z",
|
||||||
|
"updated_at": "2025-10-22T08:12:26.000000Z",
|
||||||
|
"url_pull": "http://static.hnzhwlkj.cn/bsh_datax/classify/20251022/063ab7207ed6f0b5f51ad67f06ca17de_68f891e33004b.xlsx?Expires=1763435290&OSSAccessKeyId=LTAI5tAGiH7bkVkmrniXnfkA&Signature=uV8rB3YNovpiuuFT%2FaCVgfzd6hI%3D"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"parent_id": 0,
|
||||||
|
"name": "产品内容",
|
||||||
|
"icon": "",
|
||||||
|
"child":[],
|
||||||
|
"nav_item": [
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"category_id": 4,
|
||||||
|
"type": "link",
|
||||||
|
"title": "华北西门子Q3重品销售进度",
|
||||||
|
"description": "每日12次更新",
|
||||||
|
"url": "",
|
||||||
|
"sort": 0,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-16T06:19:26.000000Z",
|
||||||
|
"updated_at": "2025-10-16T06:19:26.000000Z",
|
||||||
|
"url_pull": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"category_id": 4,
|
||||||
|
"type": "file",
|
||||||
|
"title": "使用手册",
|
||||||
|
"description": "",
|
||||||
|
"url": "https://bshdataboard.oss-cn-beijing.aliyuncs.com/bsh_datax/classify/20251017/76734144ac2137556fd192d140ed4e3d_68f1dd20c92c7.png?Expires=1760684848&OSSAccessKeyId=LTAI5tAGiH7bkVkmrniXnfkA&Signature=eTV%2FN7wEopq%2BT7%2BjzDaGrCSP6G0%3D",
|
||||||
|
"sort": 0,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-17T03:02:10.000000Z",
|
||||||
|
"updated_at": "2025-10-17T06:07:36.000000Z",
|
||||||
|
"url_pull": "http://static.hnzhwlkj.cn/https%3A//bshdataboard.oss-cn-beijing.aliyuncs.com/bsh_datax/classify/20251017/76734144ac2137556fd192d140ed4e3d_68f1dd20c92c7.png%3FExpires%3D1760684848%26OSSAccessKeyId%3DLTAI5tAGiH7bkVkmrniXnfkA%26Signature%3DeTV%252FN7wEopq%252BT7%252BjzDaGrCSP6G0%253D?Expires=1763435290&OSSAccessKeyId=LTAI5tAGiH7bkVkmrniXnfkA&Signature=JWrXGUSgQMgE%2FmE%2BxOQtw5COwl8%3D"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"parent_id": 0,
|
||||||
|
"name": "看板分类",
|
||||||
|
"icon": "",
|
||||||
|
"child":[],
|
||||||
|
"nav_item": [
|
||||||
|
{
|
||||||
|
"id": 12,
|
||||||
|
"category_id": 5,
|
||||||
|
"type": "link",
|
||||||
|
"title": "看板内容",
|
||||||
|
"description": "",
|
||||||
|
"url": "#",
|
||||||
|
"sort": 0,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2025-10-20T06:02:58.000000Z",
|
||||||
|
"updated_at": "2025-10-20T06:02:58.000000Z",
|
||||||
|
"url_pull": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
// const res = m.data;
|
||||||
|
const res = m;
|
||||||
|
// 模拟数据结构:[{ id, name, cards: [{ title, desc }] }]
|
||||||
|
menus.value = res.map((section) => ({
|
||||||
|
...section,
|
||||||
|
cards: section.nav_item.map((c) =>
|
||||||
|
typeof c === "string"
|
||||||
|
? { title: c, description: "" }
|
||||||
|
: c
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
const {data} = await getUser();
|
||||||
|
userInfo.value = data.user;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 下载文件
|
||||||
|
async function onCardClick(item){
|
||||||
|
if (!item) return;
|
||||||
|
const url = item.url_pull;
|
||||||
|
const type = item.type;
|
||||||
|
if (!url) return;
|
||||||
|
if (type === 'link') {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'file') {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// // 对所有文件类型都使用 fetch 下载,确保真正的下载
|
||||||
|
// const response = await fetch(url, {
|
||||||
|
// method: 'GET',
|
||||||
|
// headers: {
|
||||||
|
// Accept: '*/*'
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
|
||||||
|
// if (!response.ok) {
|
||||||
|
// throw new Error(`下载失败: ${response.status} ${response.statusText}`)
|
||||||
|
// }
|
||||||
|
// const blob = await response.blob()
|
||||||
|
// const url = window.URL.createObjectURL(blob)
|
||||||
|
// const link = document.createElement('a')
|
||||||
|
// link.href = url
|
||||||
|
// link.download = file.name || file.file_name || 'download'
|
||||||
|
// link.style.display = 'none'
|
||||||
|
// document.body.appendChild(link)
|
||||||
|
// link.click()
|
||||||
|
// document.body.removeChild(link)
|
||||||
|
// window.URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
// ElMessage.success('下载成功')
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error('下载失败:', error)
|
||||||
|
// const errorMessage = error instanceof Error ? error.message : '未知错误'
|
||||||
|
// ElMessage.error(`下载失败: ${errorMessage}`)
|
||||||
|
// }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMenu = ref(0);
|
||||||
|
function onNav(id, index) {
|
||||||
|
activeMenu.value = index;
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) {
|
||||||
|
const topbarHeight = 56;
|
||||||
|
const mobileHeight = 44;
|
||||||
|
const offset = window.innerWidth <= 768 ? mobileHeight : topbarHeight;
|
||||||
|
const y = el.getBoundingClientRect().top + window.scrollY - offset;
|
||||||
|
window.scrollTo({ top: y, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
sidebarOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAvatar(e){
|
||||||
|
const file = e.target.files[0];
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
formData.append('scene', 'user');
|
||||||
|
const { data } = await uploadImage(formData);
|
||||||
|
if (data) {
|
||||||
|
userInfo.value.avatar = data.href;
|
||||||
|
userInfo.value.avatar_pull = data.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function saveProfile(){
|
||||||
|
basicEditing.value = !basicEditing.value;
|
||||||
|
if(basicEditing.value) return;
|
||||||
|
try {
|
||||||
|
const { data } = await updateUserInfo(userInfo.value);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存用户信息失败', e);
|
||||||
|
} finally {
|
||||||
|
basicEditing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function onResetPassword(){
|
||||||
|
pwdEditing.value = !pwdEditing.value;
|
||||||
|
if(pwdEditing.value) return;
|
||||||
|
if(pwdForm.value.password_new !== pwdForm.value.confirm){
|
||||||
|
alert('新密码和确认密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { message } = await resetPassword(pwdForm.value);
|
||||||
|
alert(message);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e?.response?.data?.message || '重置密码失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function toggleUserMenu(){
|
||||||
|
showUserMenu.value = !showUserMenu.value;
|
||||||
|
}
|
||||||
|
async function openProfile(){
|
||||||
|
showUserMenu.value = false;
|
||||||
|
try {
|
||||||
|
const { data } = await getUser();
|
||||||
|
if (data.user) {
|
||||||
|
userInfo.value = data.user;
|
||||||
|
profile.value.nickname = data.user.nickname || data.user.name || '';
|
||||||
|
profile.value.phone = data.user.phone || '';
|
||||||
|
profile.value.code = data.user.code || data.user.id || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取用户信息失败', e);
|
||||||
|
} finally {
|
||||||
|
showProfile.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function confirmLogout(){
|
||||||
|
showUserMenu.value = false;
|
||||||
|
showLogoutConfirm.value = true;
|
||||||
|
}
|
||||||
|
function doLogout(){
|
||||||
|
// 清空本地缓存并跳转登录
|
||||||
|
localStorage.clear();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('click', () => { showUserMenu.value = false; });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部导航 */
|
||||||
|
.topbar {
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: #fff;
|
||||||
|
/* color: #fff; */
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.brand img {
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
.menu-btn {
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.user-dropdown img.avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.user-dropdown{ position:relative; }
|
||||||
|
.user-dropdown .dropdown-menu{
|
||||||
|
position:absolute; right:0; top:46px; width:140px; background:#fff; border:1px solid #e5e7eb; border-radius:8px; box-shadow:0 10px 30px rgba(0,0,0,.08); z-index:120;
|
||||||
|
}
|
||||||
|
.user-dropdown .dropdown-menu .item{ padding:10px 12px; cursor:pointer; font-size:14px; color:#333; }
|
||||||
|
.user-dropdown .dropdown-menu .item:hover{ background:#f6fbfb; color:#009999; }
|
||||||
|
.user-dropdown .dropdown-menu .item.danger{ color:#e35d5d; }
|
||||||
|
|
||||||
|
/* 左侧导航 */
|
||||||
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px 0;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 56px;
|
||||||
|
bottom: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.sidebar ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sidebar li {
|
||||||
|
padding: 10px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.sidebar li:hover {
|
||||||
|
background: #eef6f6;
|
||||||
|
color: #009999;
|
||||||
|
}
|
||||||
|
.sidebar li.active {
|
||||||
|
background: rgba(0, 153, 153, 0.09);
|
||||||
|
border-left: 3px solid #009999;
|
||||||
|
color: #009999;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.sidebar .submenu-item {
|
||||||
|
padding: 8px 18px 8px 36px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #666;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容区 */
|
||||||
|
.content {
|
||||||
|
margin-left: 220px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f5f6f9;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* section 标题 */
|
||||||
|
.section h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
margin: 20px 0 16px;
|
||||||
|
/* border-left: 4px solid #0f7c72; */
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-block {
|
||||||
|
width: 40px;
|
||||||
|
height: 8px;
|
||||||
|
background: #14C9C9;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
margin-top: -25px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片网格 */
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 10px rgba(14, 42, 51, 0.06);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: 0 6px 16px rgba(14, 42, 51, 0.12);
|
||||||
|
}
|
||||||
|
.card .icon img {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.card .info .title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
.card .info .desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹窗 */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: #fff;
|
||||||
|
padding: 18px;
|
||||||
|
width: 340px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.profile-modal{ width: 920px; max-width: 96vw; }
|
||||||
|
.profile-header{ display:flex; align-items:center; gap:16px; padding:14px; background:linear-gradient(90deg,#f3fbfb,#ffffff); border-radius:8px; margin-bottom:16px; }
|
||||||
|
.profile-avatar img{ width:56px; height:56px; border-radius:50%; }
|
||||||
|
.profile-meta{ display:flex; flex-direction:column; gap:4px; }
|
||||||
|
.profile-name{ font-size:16px; font-weight:700; color:#222; }
|
||||||
|
.profile-sub{ font-size:12px; color:#666; }
|
||||||
|
|
||||||
|
.profile-section{ background:#fff; border:1px solid #eef2f2; border-radius:8px; padding:12px 14px; margin:16px 0; }
|
||||||
|
.section-title{ display:flex; align-items:center;margin-bottom:10px; }
|
||||||
|
.section-title .dot{ width:6px; height:16px; background:#14C9C9; border-radius:3px; display:inline-block; margin-right:8px; }
|
||||||
|
.section-title > span:first-child{ margin-right:8px; }
|
||||||
|
.edit-btn{ border:1px solid #14C9C9; color:#14C9C9; background:#ecfafa; border-radius:6px; padding:6px 12px; cursor:pointer; }
|
||||||
|
.section-body{ padding:4px 0; }
|
||||||
|
.row{ display:grid; grid-template-columns: 80px 1fr 80px 1fr 80px 1fr; gap:12px; align-items:center; padding:8px 0; }
|
||||||
|
.row + .row{ border-top:1px dashed #eef2f2; }
|
||||||
|
.col.label{ color:#666; font-size:13px; }
|
||||||
|
.input{ width:100%; height:36px; border:1px solid #e5e7eb; border-radius:6px; padding:0 10px; background:#fafafa; }
|
||||||
|
.avatar-lg{ width:64px; height:64px; border-radius:50%; }
|
||||||
|
.avatar-edit{ display:flex; align-items:center; gap:10px; }
|
||||||
|
|
||||||
|
.modal-actions{ display:flex; justify-content:flex-end; gap:8px; margin-top:8px; }
|
||||||
|
.btn{ padding:8px 14px; border-radius:8px; border:1px solid transparent; cursor:pointer; }
|
||||||
|
.btn.ghost{ background:#fff; border-color:#e5e7eb; }
|
||||||
|
.btn.primary{ background:#14C9C9; color:#fff; }
|
||||||
|
.modal label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
.drawer-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.menu-btn {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
left: -260px;
|
||||||
|
transition: left 0.28s;
|
||||||
|
top: 44px;
|
||||||
|
width: 200px;
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
.sidebar.open {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.drawer-overlay {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 44px 0 0 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 16px;
|
||||||
|
margin-top: 56px;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
.brand span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
632
src/views/Dashboard copy.vue
Normal file
632
src/views/Dashboard copy.vue
Normal file
@ -0,0 +1,632 @@
|
|||||||
|
<template>
|
||||||
|
<div class="dashboard">
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<header class="topbar">
|
||||||
|
<button class="menu-btn" @click="sidebarOpen = !sidebarOpen">☰</button>
|
||||||
|
<div class="brand">
|
||||||
|
<img src="/src/assets/logo.png" alt="logo" />
|
||||||
|
<!-- <span>销售数据平台</span> -->
|
||||||
|
</div>
|
||||||
|
<div class="user-dropdown" @click.stop="toggleUserMenu">
|
||||||
|
<img :src="userInfo.avatar_pull" alt="avatar" class="avatar" />
|
||||||
|
<i class="iconfont-sys iconsys-xiala" v-if="false"></i>
|
||||||
|
<div v-if="showUserMenu" class="dropdown-menu" @click.stop>
|
||||||
|
<div class="item" @click="openProfile">个人中心</div>
|
||||||
|
<div class="item danger" @click="confirmLogout">退出登录</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 左侧导航 -->
|
||||||
|
<aside :class="['sidebar', { open: sidebarOpen }]">
|
||||||
|
<ul>
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in menus"
|
||||||
|
:key="item.id"
|
||||||
|
@click="onNav(item.id, index)"
|
||||||
|
:class="{ active: activeMenu === index }"
|
||||||
|
>
|
||||||
|
<i class="iconfont-sys" v-html="item.icon" style="margin-right: 10px;"></i>{{ item.name }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
<div v-if="sidebarOpen" class="drawer-overlay" @click="sidebarOpen = false"></div>
|
||||||
|
|
||||||
|
<!-- 右侧内容 -->
|
||||||
|
<main class="content">
|
||||||
|
<section v-for="m in menus" :key="m.id" :id="m.id" class="section">
|
||||||
|
<h3>{{ m.name }}</h3>
|
||||||
|
<div class="title-block"></div>
|
||||||
|
<div class="card-grid">
|
||||||
|
<div class="card" v-for="(item, index) in m.cards" :key="index" @click="onCardClick(item)">
|
||||||
|
<div class="icon">
|
||||||
|
<img src="/src/assets/card-icon.png" alt="icon" />
|
||||||
|
</div>
|
||||||
|
<div class="info">
|
||||||
|
<div class="title">{{ item.title }}</div>
|
||||||
|
<div class="desc">{{ item.description }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 修改资料弹窗 -->
|
||||||
|
<div v-if="showProfile" class="modal-overlay" @click.self="showProfile = false">
|
||||||
|
<div class="modal profile-modal">
|
||||||
|
<!-- 顶部用户信息 -->
|
||||||
|
<div class="profile-header">
|
||||||
|
<div class="profile-avatar">
|
||||||
|
<img :src="userInfo.avatar_pull" alt="avatar" />
|
||||||
|
</div>
|
||||||
|
<div class="profile-meta">
|
||||||
|
<div class="profile-name">{{ userInfo.name }}</div>
|
||||||
|
<div class="profile-sub">联系电话:{{ userInfo.mobile || '-' }}</div>
|
||||||
|
<div class="profile-sub">编号:{{ userInfo.sn || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 基本设置 -->
|
||||||
|
<div class="profile-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>基本设置</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col label">头像</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="avatar-edit">
|
||||||
|
<img :src="userInfo.avatar_pull" alt="avatar" class="avatar-lg" />
|
||||||
|
<input v-if="basicEditing" type="file" @change="uploadAvatar" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col label">昵称</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" :disabled="!basicEditing" v-model="userInfo.nickname" placeholder="请输入昵称" />
|
||||||
|
</div>
|
||||||
|
<div class="col label">联系方式</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" :disabled="!basicEditing" v-model="userInfo.mobile" placeholder="请输入联系方式" />
|
||||||
|
</div>
|
||||||
|
<div class="col label">编号</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" disabled v-model="userInfo.sn" placeholder="请输入编号" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="edit-btn" @click="saveProfile">{{ basicEditing ? '保存' : '编辑' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 更改密码 -->
|
||||||
|
<div class="profile-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>更改密码</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col label">当前密码</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" :type="pwdEditing ? 'password' : 'password'" :disabled="!pwdEditing" v-model="pwdForm.password" placeholder="请输入原始密码" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col label">新密码</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" :type="pwdEditing ? 'password' : 'password'" :disabled="!pwdEditing" v-model="pwdForm.password_new" placeholder="请输入新密码" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col label">确认新密码</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" :type="pwdEditing ? 'password' : 'password'" :disabled="!pwdEditing" v-model="pwdForm.confirm" placeholder="请输入确认密码" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="edit-btn" @click="onResetPassword">{{ pwdEditing ? '保存' : '编辑' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn ghost" @click="showProfile = false">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 退出登录确认 -->
|
||||||
|
<div v-if="showLogoutConfirm" class="modal-overlay" @click.self="showLogoutConfirm = false">
|
||||||
|
<div class="modal confirm-modal">
|
||||||
|
<h3>确认退出登录?</h3>
|
||||||
|
<p style="color:#666;margin-top:6px;">退出后需要重新登录才能进入系统。</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn ghost" @click="showLogoutConfirm = false">取消</button>
|
||||||
|
<button class="btn primary" @click="doLogout">确认退出</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { getMenus, getUser, updateUserInfo, resetPassword, uploadImage } from "../api";
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
const menus = ref([]);
|
||||||
|
const profile = ref({ username: "", password: "" });
|
||||||
|
const showProfile = ref(false);
|
||||||
|
const sidebarOpen = ref(false);
|
||||||
|
const basicEditing = ref(false);
|
||||||
|
const pwdEditing = ref(false);
|
||||||
|
const pwdForm = ref({ password: "", password_new: "", confirm: "" });
|
||||||
|
const showUserMenu = ref(false);
|
||||||
|
const showLogoutConfirm = ref(false);
|
||||||
|
const userInfo = ref({});
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
const m = await getMenus();
|
||||||
|
const res = m.data;
|
||||||
|
// 模拟数据结构:[{ id, name, cards: [{ title, desc }] }]
|
||||||
|
menus.value = res.map((section) => ({
|
||||||
|
...section,
|
||||||
|
cards: section.nav_item.map((c) =>
|
||||||
|
typeof c === "string"
|
||||||
|
? { title: c, description: "" }
|
||||||
|
: c
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
const {data} = await getUser();
|
||||||
|
userInfo.value = data.user;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 下载文件
|
||||||
|
async function onCardClick(item){
|
||||||
|
if (!item) return;
|
||||||
|
const url = item.url_pull;
|
||||||
|
const type = item.type;
|
||||||
|
if (!url) return;
|
||||||
|
if (type === 'link') {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'file') {
|
||||||
|
try {
|
||||||
|
// 规范化 URL,处理相对路径与空格/中文
|
||||||
|
const normalized = (() => {
|
||||||
|
try {
|
||||||
|
const raw = typeof url === 'string' ? url.trim() : ''
|
||||||
|
if (!raw) return ''
|
||||||
|
// 先粗略编码空格,避免 new URL 抛错
|
||||||
|
const pre = raw.replace(/\s/g, '%20')
|
||||||
|
const u = new URL(pre, window.location.href)
|
||||||
|
// 对仅路径部分进行 encodeURI,保留查询参数
|
||||||
|
const path = encodeURI(u.pathname)
|
||||||
|
const q = u.search
|
||||||
|
const h = u.hash
|
||||||
|
return `${u.origin}${path}${q}${h}`
|
||||||
|
} catch (_) {
|
||||||
|
return typeof url === 'string' ? encodeURI(url) : ''
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
throw new Error('无效的下载地址')
|
||||||
|
}
|
||||||
|
// 对所有文件类型都使用 fetch 下载,确保真正的下载
|
||||||
|
const response = await fetch(normalized, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Accept: '*/*'
|
||||||
|
},
|
||||||
|
// 若后端依赖 Cookie 鉴权,需要携带凭证
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`下载失败: ${response.status} ${response.statusText}`)
|
||||||
|
}
|
||||||
|
const blob = await response.blob()
|
||||||
|
|
||||||
|
// 从响应头或链接推断文件名
|
||||||
|
const contentDisposition = response.headers.get('content-disposition') || ''
|
||||||
|
let filename = ''
|
||||||
|
const match = contentDisposition.match(/filename\*?=([^;]+)/i)
|
||||||
|
if (match) {
|
||||||
|
const raw = match[1].trim().replace(/^UTF-8''/i, '').replace(/^"|"$/g, '')
|
||||||
|
try {
|
||||||
|
filename = decodeURIComponent(raw)
|
||||||
|
} catch (_) {
|
||||||
|
filename = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!filename) {
|
||||||
|
const urlObj = new URL(normalized, window.location.href)
|
||||||
|
filename = urlObj.pathname.split('/').filter(Boolean).pop() || ''
|
||||||
|
}
|
||||||
|
if (!filename) {
|
||||||
|
filename = item.file_name || item.name || 'download'
|
||||||
|
}
|
||||||
|
|
||||||
|
const blobUrl = window.URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = blobUrl
|
||||||
|
link.download = filename
|
||||||
|
link.style.display = 'none'
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
window.URL.revokeObjectURL(blobUrl)
|
||||||
|
|
||||||
|
ElMessage.success('下载成功')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('下载失败:', error)
|
||||||
|
const errorMessage = error instanceof Error ? error.message : '未知错误'
|
||||||
|
// CORS/网络级失败时,降级为隐藏 iframe 直连触发下载(不打开新标签)
|
||||||
|
try {
|
||||||
|
const raw = typeof url === 'string' ? url.trim() : ''
|
||||||
|
if (raw) {
|
||||||
|
const fallbackUrl = (() => {
|
||||||
|
try {
|
||||||
|
const u = new URL(raw.replace(/\s/g, '%20'), window.location.href)
|
||||||
|
const path = encodeURI(u.pathname)
|
||||||
|
return `${u.origin}${path}${u.search}${u.hash}`
|
||||||
|
} catch (_) {
|
||||||
|
return encodeURI(raw)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
const iframe = document.createElement('iframe')
|
||||||
|
iframe.style.display = 'none'
|
||||||
|
iframe.src = fallbackUrl
|
||||||
|
document.body.appendChild(iframe)
|
||||||
|
// 一段时间后移除 iframe,避免内存泄漏
|
||||||
|
setTimeout(() => {
|
||||||
|
try { document.body.removeChild(iframe) } catch (_) {}
|
||||||
|
}, 15000)
|
||||||
|
ElMessage.info('正在请求下载...')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// 忽略降级异常
|
||||||
|
}
|
||||||
|
ElMessage.error(`下载失败: ${errorMessage}`)
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeMenu = ref(0);
|
||||||
|
function onNav(id, index) {
|
||||||
|
activeMenu.value = index;
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) {
|
||||||
|
const topbarHeight = 56;
|
||||||
|
const mobileHeight = 44;
|
||||||
|
const offset = window.innerWidth <= 768 ? mobileHeight : topbarHeight;
|
||||||
|
const y = el.getBoundingClientRect().top + window.scrollY - offset;
|
||||||
|
window.scrollTo({ top: y, behavior: "smooth" });
|
||||||
|
}
|
||||||
|
sidebarOpen.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAvatar(e){
|
||||||
|
const file = e.target.files[0];
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
formData.append('scene', 'user');
|
||||||
|
const { data } = await uploadImage(formData);
|
||||||
|
if (data) {
|
||||||
|
userInfo.value.avatar = data.href;
|
||||||
|
userInfo.value.avatar_pull = data.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function saveProfile(){
|
||||||
|
basicEditing.value = !basicEditing.value;
|
||||||
|
if(basicEditing.value) return;
|
||||||
|
try {
|
||||||
|
const { data } = await updateUserInfo(userInfo.value);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存用户信息失败', e);
|
||||||
|
} finally {
|
||||||
|
basicEditing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function onResetPassword(){
|
||||||
|
pwdEditing.value = !pwdEditing.value;
|
||||||
|
if(pwdEditing.value) return;
|
||||||
|
if(pwdForm.value.password_new !== pwdForm.value.confirm){
|
||||||
|
alert('新密码和确认密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { message } = await resetPassword(pwdForm.value);
|
||||||
|
alert(message);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e?.response?.data?.message || '重置密码失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function toggleUserMenu(){
|
||||||
|
showUserMenu.value = !showUserMenu.value;
|
||||||
|
}
|
||||||
|
async function openProfile(){
|
||||||
|
showUserMenu.value = false;
|
||||||
|
try {
|
||||||
|
const { data } = await getUser();
|
||||||
|
if (data.user) {
|
||||||
|
userInfo.value = data.user;
|
||||||
|
profile.value.nickname = data.user.nickname || data.user.name || '';
|
||||||
|
profile.value.phone = data.user.phone || '';
|
||||||
|
profile.value.code = data.user.code || data.user.id || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取用户信息失败', e);
|
||||||
|
} finally {
|
||||||
|
showProfile.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function confirmLogout(){
|
||||||
|
showUserMenu.value = false;
|
||||||
|
showLogoutConfirm.value = true;
|
||||||
|
}
|
||||||
|
function doLogout(){
|
||||||
|
// 清空本地缓存并跳转登录
|
||||||
|
localStorage.clear();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('click', () => { showUserMenu.value = false; });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dashboard {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 顶部导航 */
|
||||||
|
.topbar {
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 16px;
|
||||||
|
background: #fff;
|
||||||
|
/* color: #fff; */
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.brand img {
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
.menu-btn {
|
||||||
|
display: none;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.user-dropdown img.avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.user-dropdown{ position:relative; }
|
||||||
|
.user-dropdown .dropdown-menu{
|
||||||
|
position:absolute; right:0; top:46px; width:140px; background:#fff; border:1px solid #e5e7eb; border-radius:8px; box-shadow:0 10px 30px rgba(0,0,0,.08); z-index:120;
|
||||||
|
}
|
||||||
|
.user-dropdown .dropdown-menu .item{ padding:10px 12px; cursor:pointer; font-size:14px; color:#333; }
|
||||||
|
.user-dropdown .dropdown-menu .item:hover{ background:#f6fbfb; color:#009999; }
|
||||||
|
.user-dropdown .dropdown-menu .item.danger{ color:#e35d5d; }
|
||||||
|
|
||||||
|
/* 左侧导航 */
|
||||||
|
.sidebar {
|
||||||
|
width: 220px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 12px 0;
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 56px;
|
||||||
|
bottom: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-right: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.sidebar ul {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.sidebar li {
|
||||||
|
padding: 10px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #333;
|
||||||
|
font-size: 14px;
|
||||||
|
border-left: 3px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.sidebar li:hover {
|
||||||
|
background: #eef6f6;
|
||||||
|
color: #009999;
|
||||||
|
}
|
||||||
|
.sidebar li.active {
|
||||||
|
background: rgba(0, 153, 153, 0.09);
|
||||||
|
border-left: 3px solid #009999;
|
||||||
|
color: #009999;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容区 */
|
||||||
|
.content {
|
||||||
|
margin-left: 220px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #f5f6f9;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* section 标题 */
|
||||||
|
.section h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
margin: 20px 0 16px;
|
||||||
|
/* border-left: 4px solid #0f7c72; */
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-block {
|
||||||
|
width: 40px;
|
||||||
|
height: 8px;
|
||||||
|
background: #14C9C9;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
margin-top: -25px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片网格 */
|
||||||
|
.card-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 4px 10px rgba(14, 42, 51, 0.06);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.card:hover {
|
||||||
|
box-shadow: 0 6px 16px rgba(14, 42, 51, 0.12);
|
||||||
|
}
|
||||||
|
.card .icon img {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
}
|
||||||
|
.card .info .title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
}
|
||||||
|
.card .info .desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹窗 */
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
background: #fff;
|
||||||
|
padding: 18px;
|
||||||
|
width: 340px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
.profile-modal{ width: 920px; max-width: 96vw; }
|
||||||
|
.profile-header{ display:flex; align-items:center; gap:16px; padding:14px; background:linear-gradient(90deg,#f3fbfb,#ffffff); border-radius:8px; margin-bottom:16px; }
|
||||||
|
.profile-avatar img{ width:56px; height:56px; border-radius:50%; }
|
||||||
|
.profile-meta{ display:flex; flex-direction:column; gap:4px; }
|
||||||
|
.profile-name{ font-size:16px; font-weight:700; color:#222; }
|
||||||
|
.profile-sub{ font-size:12px; color:#666; }
|
||||||
|
|
||||||
|
.profile-section{ background:#fff; border:1px solid #eef2f2; border-radius:8px; padding:12px 14px; margin:16px 0; }
|
||||||
|
.section-title{ display:flex; align-items:center;margin-bottom:10px; }
|
||||||
|
.section-title .dot{ width:6px; height:16px; background:#14C9C9; border-radius:3px; display:inline-block; margin-right:8px; }
|
||||||
|
.section-title > span:first-child{ margin-right:8px; }
|
||||||
|
.edit-btn{ border:1px solid #14C9C9; color:#14C9C9; background:#ecfafa; border-radius:6px; padding:6px 12px; cursor:pointer; }
|
||||||
|
.section-body{ padding:4px 0; }
|
||||||
|
.row{ display:grid; grid-template-columns: 80px 1fr 80px 1fr 80px 1fr; gap:12px; align-items:center; padding:8px 0; }
|
||||||
|
.row + .row{ border-top:1px dashed #eef2f2; }
|
||||||
|
.col.label{ color:#666; font-size:13px; }
|
||||||
|
.input{ width:100%; height:36px; border:1px solid #e5e7eb; border-radius:6px; padding:0 10px; background:#fafafa; }
|
||||||
|
.avatar-lg{ width:64px; height:64px; border-radius:50%; }
|
||||||
|
.avatar-edit{ display:flex; align-items:center; gap:10px; }
|
||||||
|
|
||||||
|
.modal-actions{ display:flex; justify-content:flex-end; gap:8px; margin-top:8px; }
|
||||||
|
.btn{ padding:8px 14px; border-radius:8px; border:1px solid transparent; cursor:pointer; }
|
||||||
|
.btn.ghost{ background:#fff; border-color:#e5e7eb; }
|
||||||
|
.btn.primary{ background:#14C9C9; color:#fff; }
|
||||||
|
.modal label {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
.drawer-overlay {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.menu-btn {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
left: -260px;
|
||||||
|
transition: left 0.28s;
|
||||||
|
top: 44px;
|
||||||
|
width: 200px;
|
||||||
|
z-index: 60;
|
||||||
|
}
|
||||||
|
.sidebar.open {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
.drawer-overlay {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
inset: 44px 0 0 0;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
margin-left: 0;
|
||||||
|
padding: 16px;
|
||||||
|
margin-top: 56px;
|
||||||
|
}
|
||||||
|
.topbar {
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
.brand span {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.modal {
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -5,10 +5,15 @@
|
|||||||
<button class="menu-btn" @click="sidebarOpen = !sidebarOpen">☰</button>
|
<button class="menu-btn" @click="sidebarOpen = !sidebarOpen">☰</button>
|
||||||
<div class="brand">
|
<div class="brand">
|
||||||
<img src="/src/assets/logo.png" alt="logo" />
|
<img src="/src/assets/logo.png" alt="logo" />
|
||||||
<span>销售数据平台</span>
|
<!-- <span>销售数据平台</span> -->
|
||||||
|
</div>
|
||||||
|
<div class="user-dropdown" @click.stop="toggleUserMenu">
|
||||||
|
<img :src="userInfo.avatar_pull" alt="avatar" class="avatar" />
|
||||||
|
<i class="iconfont-sys iconsys-xiala" v-if="false"></i>
|
||||||
|
<div v-if="showUserMenu" class="dropdown-menu" @click.stop>
|
||||||
|
<div class="item" @click="openProfile">个人中心</div>
|
||||||
|
<div class="item danger" @click="confirmLogout">退出登录</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="user" @click="showProfile = true">
|
|
||||||
<img :src="user.avatar" alt="avatar" class="avatar" />
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -21,7 +26,7 @@
|
|||||||
@click="onNav(item.id, index)"
|
@click="onNav(item.id, index)"
|
||||||
:class="{ active: activeMenu === index }"
|
:class="{ active: activeMenu === index }"
|
||||||
>
|
>
|
||||||
{{ item.name }}
|
<i class="iconfont-sys" v-html="item.icon" style="margin-right: 10px;"></i>{{ item.name }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
@ -31,14 +36,15 @@
|
|||||||
<main class="content">
|
<main class="content">
|
||||||
<section v-for="m in menus" :key="m.id" :id="m.id" class="section">
|
<section v-for="m in menus" :key="m.id" :id="m.id" class="section">
|
||||||
<h3>{{ m.name }}</h3>
|
<h3>{{ m.name }}</h3>
|
||||||
|
<div class="title-block"></div>
|
||||||
<div class="card-grid">
|
<div class="card-grid">
|
||||||
<div class="card" v-for="(item, index) in m.cards" :key="index">
|
<div class="card" v-for="(item, index) in m.cards" :key="index" @click="onCardClick(item)">
|
||||||
<div class="icon">
|
<div class="icon">
|
||||||
<img src="/src/assets/card-icon.png" alt="icon" />
|
<img src="/src/assets/card-icon.png" alt="icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<div class="title">{{ item.title }}</div>
|
<div class="title">{{ item.title }}</div>
|
||||||
<div class="desc">{{ item.desc }}</div>
|
<div class="desc">{{ item.description }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -47,17 +53,101 @@
|
|||||||
|
|
||||||
<!-- 修改资料弹窗 -->
|
<!-- 修改资料弹窗 -->
|
||||||
<div v-if="showProfile" class="modal-overlay" @click.self="showProfile = false">
|
<div v-if="showProfile" class="modal-overlay" @click.self="showProfile = false">
|
||||||
<div class="modal">
|
<div class="modal profile-modal">
|
||||||
<h3>修改个人信息</h3>
|
<!-- 顶部用户信息 -->
|
||||||
<label>头像</label>
|
<div class="profile-header">
|
||||||
<input type="file" />
|
<div class="profile-avatar">
|
||||||
<label>账号</label>
|
<img :src="userInfo.avatar_pull" alt="avatar" />
|
||||||
<input v-model="profile.username" />
|
</div>
|
||||||
<label>密码</label>
|
<div class="profile-meta">
|
||||||
<input v-model="profile.password" type="password" />
|
<div class="profile-name">{{ userInfo.name }}</div>
|
||||||
|
<div class="profile-sub">联系电话:{{ userInfo.mobile || '-' }}</div>
|
||||||
|
<div class="profile-sub">编号:{{ userInfo.sn || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 基本设置 -->
|
||||||
|
<div class="profile-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>基本设置</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col label">头像</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="avatar-edit">
|
||||||
|
<img :src="userInfo.avatar_pull" alt="avatar" class="avatar-lg" />
|
||||||
|
<input v-if="basicEditing" type="file" @change="uploadAvatar" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col label">昵称</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" :disabled="!basicEditing" v-model="userInfo.nickname" placeholder="请输入昵称" />
|
||||||
|
</div>
|
||||||
|
<div class="col label">联系方式</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" :disabled="!basicEditing" v-model="userInfo.mobile" placeholder="请输入联系方式" />
|
||||||
|
</div>
|
||||||
|
<div class="col label">编号</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" disabled v-model="userInfo.sn" placeholder="请输入编号" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button @click="showProfile = false">取消</button>
|
<button class="edit-btn" @click="saveProfile">{{ basicEditing ? '保存' : '编辑' }}</button>
|
||||||
<button @click="saveProfile">保存</button>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 更改密码 -->
|
||||||
|
<div class="profile-section">
|
||||||
|
<div class="section-title">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<span>更改密码</span>
|
||||||
|
</div>
|
||||||
|
<div class="section-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col label">当前密码</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" :type="pwdEditing ? 'password' : 'password'" :disabled="!pwdEditing" v-model="pwdForm.password" placeholder="请输入原始密码" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col label">新密码</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" :type="pwdEditing ? 'password' : 'password'" :disabled="!pwdEditing" v-model="pwdForm.password_new" placeholder="请输入新密码" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col label">确认新密码</div>
|
||||||
|
<div class="col">
|
||||||
|
<input class="input" :type="pwdEditing ? 'password' : 'password'" :disabled="!pwdEditing" v-model="pwdForm.confirm" placeholder="请输入确认密码" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="edit-btn" @click="onResetPassword">{{ pwdEditing ? '保存' : '编辑' }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn ghost" @click="showProfile = false">关闭</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 退出登录确认 -->
|
||||||
|
<div v-if="showLogoutConfirm" class="modal-overlay" @click.self="showLogoutConfirm = false">
|
||||||
|
<div class="modal confirm-modal">
|
||||||
|
<h3>确认退出登录?</h3>
|
||||||
|
<p style="color:#666;margin-top:6px;">退出后需要重新登录才能进入系统。</p>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button class="btn ghost" @click="showLogoutConfirm = false">取消</button>
|
||||||
|
<button class="btn primary" @click="doLogout">确认退出</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -66,33 +156,84 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from "vue";
|
import { ref, onMounted } from "vue";
|
||||||
import { getMenus, getUser } from "../api";
|
import { getMenus, getUser, updateUserInfo, resetPassword, uploadImage } from "../api";
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
const menus = ref([]);
|
const menus = ref([]);
|
||||||
const user = ref({ avatar: "https://i.pravatar.cc/100", name: "" });
|
|
||||||
const profile = ref({ username: "", password: "" });
|
const profile = ref({ username: "", password: "" });
|
||||||
const showProfile = ref(false);
|
const showProfile = ref(false);
|
||||||
const sidebarOpen = ref(false);
|
const sidebarOpen = ref(false);
|
||||||
|
const basicEditing = ref(false);
|
||||||
|
const pwdEditing = ref(false);
|
||||||
|
const pwdForm = ref({ password: "", password_new: "", confirm: "" });
|
||||||
|
const showUserMenu = ref(false);
|
||||||
|
const showLogoutConfirm = ref(false);
|
||||||
|
const userInfo = ref({});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const m = await getMenus();
|
const m = await getMenus();
|
||||||
|
const res = m.data;
|
||||||
// 模拟数据结构:[{ id, name, cards: [{ title, desc }] }]
|
// 模拟数据结构:[{ id, name, cards: [{ title, desc }] }]
|
||||||
menus.value = m.map((section) => ({
|
menus.value = res.map((section) => ({
|
||||||
...section,
|
...section,
|
||||||
cards: section.cards.map((c) =>
|
cards: section.nav_item.map((c) =>
|
||||||
typeof c === "string"
|
typeof c === "string"
|
||||||
? { title: c, desc: "每日12次更新" }
|
? { title: c, description: "" }
|
||||||
: c
|
: c
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
const u = await getUser();
|
const {data} = await getUser();
|
||||||
user.value = u;
|
userInfo.value = data.user;
|
||||||
profile.value.username = u.name || "";
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// 下载文件
|
||||||
|
async function onCardClick(item){
|
||||||
|
if (!item) return;
|
||||||
|
const url = item.url_pull;
|
||||||
|
const type = item.type;
|
||||||
|
if (!url) return;
|
||||||
|
if (type === 'link') {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'file') {
|
||||||
|
window.open(url, '_blank');
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// // 对所有文件类型都使用 fetch 下载,确保真正的下载
|
||||||
|
// const response = await fetch(url, {
|
||||||
|
// method: 'GET',
|
||||||
|
// headers: {
|
||||||
|
// Accept: '*/*'
|
||||||
|
// }
|
||||||
|
// })
|
||||||
|
|
||||||
|
// if (!response.ok) {
|
||||||
|
// throw new Error(`下载失败: ${response.status} ${response.statusText}`)
|
||||||
|
// }
|
||||||
|
// const blob = await response.blob()
|
||||||
|
// const url = window.URL.createObjectURL(blob)
|
||||||
|
// const link = document.createElement('a')
|
||||||
|
// link.href = url
|
||||||
|
// link.download = file.name || file.file_name || 'download'
|
||||||
|
// link.style.display = 'none'
|
||||||
|
// document.body.appendChild(link)
|
||||||
|
// link.click()
|
||||||
|
// document.body.removeChild(link)
|
||||||
|
// window.URL.revokeObjectURL(url)
|
||||||
|
|
||||||
|
// ElMessage.success('下载成功')
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error('下载失败:', error)
|
||||||
|
// const errorMessage = error instanceof Error ? error.message : '未知错误'
|
||||||
|
// ElMessage.error(`下载失败: ${errorMessage}`)
|
||||||
|
// }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const activeMenu = ref(0);
|
const activeMenu = ref(0);
|
||||||
function onNav(id, index) {
|
function onNav(id, index) {
|
||||||
@ -108,10 +249,72 @@ function onNav(id, index) {
|
|||||||
sidebarOpen.value = false;
|
sidebarOpen.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveProfile() {
|
async function uploadAvatar(e){
|
||||||
alert("已保存(模拟)");
|
const file = e.target.files[0];
|
||||||
showProfile.value = false;
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
formData.append('scene', 'user');
|
||||||
|
const { data } = await uploadImage(formData);
|
||||||
|
if (data) {
|
||||||
|
userInfo.value.avatar = data.href;
|
||||||
|
userInfo.value.avatar_pull = data.url;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
async function saveProfile(){
|
||||||
|
basicEditing.value = !basicEditing.value;
|
||||||
|
if(basicEditing.value) return;
|
||||||
|
try {
|
||||||
|
const { data } = await updateUserInfo(userInfo.value);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('保存用户信息失败', e);
|
||||||
|
} finally {
|
||||||
|
basicEditing.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function onResetPassword(){
|
||||||
|
pwdEditing.value = !pwdEditing.value;
|
||||||
|
if(pwdEditing.value) return;
|
||||||
|
if(pwdForm.value.password_new !== pwdForm.value.confirm){
|
||||||
|
alert('新密码和确认密码不一致');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { message } = await resetPassword(pwdForm.value);
|
||||||
|
alert(message);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e?.response?.data?.message || '重置密码失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function toggleUserMenu(){
|
||||||
|
showUserMenu.value = !showUserMenu.value;
|
||||||
|
}
|
||||||
|
async function openProfile(){
|
||||||
|
showUserMenu.value = false;
|
||||||
|
try {
|
||||||
|
const { data } = await getUser();
|
||||||
|
if (data.user) {
|
||||||
|
userInfo.value = data.user;
|
||||||
|
profile.value.nickname = data.user.nickname || data.user.name || '';
|
||||||
|
profile.value.phone = data.user.phone || '';
|
||||||
|
profile.value.code = data.user.code || data.user.id || '';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取用户信息失败', e);
|
||||||
|
} finally {
|
||||||
|
showProfile.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function confirmLogout(){
|
||||||
|
showUserMenu.value = false;
|
||||||
|
showLogoutConfirm.value = true;
|
||||||
|
}
|
||||||
|
function doLogout(){
|
||||||
|
// 清空本地缓存并跳转登录
|
||||||
|
localStorage.clear();
|
||||||
|
window.location.href = '/login';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('click', () => { showUserMenu.value = false; });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@ -128,8 +331,8 @@ function saveProfile() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
background: #0f7c72;
|
background: #fff;
|
||||||
color: #fff;
|
/* color: #fff; */
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
@ -141,7 +344,7 @@ function saveProfile() {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.brand img {
|
.brand img {
|
||||||
height: 28px;
|
height: 22px;
|
||||||
}
|
}
|
||||||
.menu-btn {
|
.menu-btn {
|
||||||
display: none;
|
display: none;
|
||||||
@ -151,17 +354,24 @@ function saveProfile() {
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.user img.avatar {
|
.user-dropdown img.avatar {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.user-dropdown{ position:relative; }
|
||||||
|
.user-dropdown .dropdown-menu{
|
||||||
|
position:absolute; right:0; top:46px; width:140px; background:#fff; border:1px solid #e5e7eb; border-radius:8px; box-shadow:0 10px 30px rgba(0,0,0,.08); z-index:120;
|
||||||
|
}
|
||||||
|
.user-dropdown .dropdown-menu .item{ padding:10px 12px; cursor:pointer; font-size:14px; color:#333; }
|
||||||
|
.user-dropdown .dropdown-menu .item:hover{ background:#f6fbfb; color:#009999; }
|
||||||
|
.user-dropdown .dropdown-menu .item.danger{ color:#e35d5d; }
|
||||||
|
|
||||||
/* 左侧导航 */
|
/* 左侧导航 */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 220px;
|
width: 220px;
|
||||||
background: #f8f9fb;
|
background: #fff;
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
@ -185,12 +395,12 @@ function saveProfile() {
|
|||||||
}
|
}
|
||||||
.sidebar li:hover {
|
.sidebar li:hover {
|
||||||
background: #eef6f6;
|
background: #eef6f6;
|
||||||
color: #0f7c72;
|
color: #009999;
|
||||||
}
|
}
|
||||||
.sidebar li.active {
|
.sidebar li.active {
|
||||||
background: #e6f3f2;
|
background: rgba(0, 153, 153, 0.09);
|
||||||
border-left: 3px solid #0f7c72;
|
border-left: 3px solid #009999;
|
||||||
color: #0f7c72;
|
color: #009999;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,10 +418,19 @@ function saveProfile() {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #222;
|
color: #222;
|
||||||
margin: 20px 0 16px;
|
margin: 20px 0 16px;
|
||||||
border-left: 4px solid #0f7c72;
|
/* border-left: 4px solid #0f7c72; */
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.title-block {
|
||||||
|
width: 40px;
|
||||||
|
height: 8px;
|
||||||
|
background: #14C9C9;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
margin-top: -25px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 卡片网格 */
|
/* 卡片网格 */
|
||||||
.card-grid {
|
.card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -227,6 +446,7 @@ function saveProfile() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
transition: box-shadow 0.2s;
|
transition: box-shadow 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.card:hover {
|
.card:hover {
|
||||||
box-shadow: 0 6px 16px rgba(14, 42, 51, 0.12);
|
box-shadow: 0 6px 16px rgba(14, 42, 51, 0.12);
|
||||||
@ -263,6 +483,30 @@ function saveProfile() {
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
.profile-modal{ width: 920px; max-width: 96vw; }
|
||||||
|
.profile-header{ display:flex; align-items:center; gap:16px; padding:14px; background:linear-gradient(90deg,#f3fbfb,#ffffff); border-radius:8px; margin-bottom:16px; }
|
||||||
|
.profile-avatar img{ width:56px; height:56px; border-radius:50%; }
|
||||||
|
.profile-meta{ display:flex; flex-direction:column; gap:4px; }
|
||||||
|
.profile-name{ font-size:16px; font-weight:700; color:#222; }
|
||||||
|
.profile-sub{ font-size:12px; color:#666; }
|
||||||
|
|
||||||
|
.profile-section{ background:#fff; border:1px solid #eef2f2; border-radius:8px; padding:12px 14px; margin:16px 0; }
|
||||||
|
.section-title{ display:flex; align-items:center;margin-bottom:10px; }
|
||||||
|
.section-title .dot{ width:6px; height:16px; background:#14C9C9; border-radius:3px; display:inline-block; margin-right:8px; }
|
||||||
|
.section-title > span:first-child{ margin-right:8px; }
|
||||||
|
.edit-btn{ border:1px solid #14C9C9; color:#14C9C9; background:#ecfafa; border-radius:6px; padding:6px 12px; cursor:pointer; }
|
||||||
|
.section-body{ padding:4px 0; }
|
||||||
|
.row{ display:grid; grid-template-columns: 80px 1fr 80px 1fr 80px 1fr; gap:12px; align-items:center; padding:8px 0; }
|
||||||
|
.row + .row{ border-top:1px dashed #eef2f2; }
|
||||||
|
.col.label{ color:#666; font-size:13px; }
|
||||||
|
.input{ width:100%; height:36px; border:1px solid #e5e7eb; border-radius:6px; padding:0 10px; background:#fafafa; }
|
||||||
|
.avatar-lg{ width:64px; height:64px; border-radius:50%; }
|
||||||
|
.avatar-edit{ display:flex; align-items:center; gap:10px; }
|
||||||
|
|
||||||
|
.modal-actions{ display:flex; justify-content:flex-end; gap:8px; margin-top:8px; }
|
||||||
|
.btn{ padding:8px 14px; border-radius:8px; border:1px solid transparent; cursor:pointer; }
|
||||||
|
.btn.ghost{ background:#fff; border-color:#e5e7eb; }
|
||||||
|
.btn.primary{ background:#14C9C9; color:#fff; }
|
||||||
.modal label {
|
.modal label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
|||||||
@ -4,31 +4,64 @@
|
|||||||
|
|
||||||
<div class="login-right">
|
<div class="login-right">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
<div class="corner-qrcode" @click="tab='scan'">
|
<div class="corner-qrcode" @click="tabFlip">
|
||||||
</div>
|
</div>
|
||||||
<h2 class="title">账号登录</h2>
|
<h2 class="title">{{ tab==='account' ? '账号登录' : '扫码登录' }}</h2>
|
||||||
|
<div v-if="tab==='account'" class="login-form">
|
||||||
|
<el-form
|
||||||
|
ref="loginFormRef"
|
||||||
|
:model="loginForm"
|
||||||
|
:rules="loginFormRules"
|
||||||
|
label-width="60px"
|
||||||
|
label-position="left"
|
||||||
|
>
|
||||||
|
<el-form-item label="账号" prop="account">
|
||||||
|
<el-input
|
||||||
|
v-model="loginForm.account"
|
||||||
|
placeholder="请输入账号"
|
||||||
|
clearable
|
||||||
|
prefix-icon="User"
|
||||||
|
maxlength="20"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<!-- <div class="tabs">
|
<el-form-item label="密码" prop="password">
|
||||||
<button :class="{active:tab==='account'}" @click="tab='account'">账号登录</button>
|
<el-input
|
||||||
<button :class="{active:tab==='scan'}" @click="tab='scan'">扫码登录</button>
|
v-model="loginForm.password"
|
||||||
</div> -->
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
show-password
|
||||||
|
clearable
|
||||||
|
prefix-icon="Lock"
|
||||||
|
maxlength="20"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
<div v-if="tab==='account'" class="form">
|
<el-form-item>
|
||||||
<div class="form-row">
|
<el-button
|
||||||
<label>账号</label>
|
type="primary"
|
||||||
<input v-model="username" placeholder="请输入账号" />
|
@click="onLogin"
|
||||||
|
:loading="loginLoading"
|
||||||
|
class="login-btn"
|
||||||
|
size="large"
|
||||||
|
>
|
||||||
|
登录
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-row">
|
<div v-if="tab==='scan'">
|
||||||
<label>密码</label>
|
<div class="wechat-iframe-container">
|
||||||
<input v-model="password" type="password" placeholder="请输入密码" />
|
<iframe
|
||||||
|
:src="wechatUrl"
|
||||||
|
class="wechat-iframe"
|
||||||
|
frameborder="0"
|
||||||
|
allowfullscreen
|
||||||
|
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-top-navigation"
|
||||||
|
></iframe>
|
||||||
</div>
|
</div>
|
||||||
<button class="login-btn" @click="onLogin">登录</button>
|
<!-- <p class="qr-tip">请在下方页面中完成微信授权登录</p> -->
|
||||||
</div>
|
<!-- <button class="login-btn" @click="onScanLogin">已授权,确认登录</button> -->
|
||||||
|
|
||||||
<div v-else class="qr-area">
|
|
||||||
<img :src="qrSrc" alt="qr" class="qr-img" />
|
|
||||||
<p class="qr-tip">请使用微信扫码授权登录</p>
|
|
||||||
<button class="login-btn" @click="onScanLogin">已扫码,确认登录</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -36,27 +69,109 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { apiLogin } from '../api'
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { apiLogin, apiqrcode } from '../api'
|
||||||
|
import { isLoggedIn, saveLoginData, handleAuthRedirect } from '../utils/auth'
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const tab = ref('account')
|
const tab = ref('account')
|
||||||
const username = ref('')
|
const loginLoading = ref(false)
|
||||||
const password = ref('')
|
const loginForm = ref({
|
||||||
const qrSrc = ref('https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wechat-login')
|
account: '',
|
||||||
|
password: ''
|
||||||
|
})
|
||||||
|
const loginFormRef = ref()
|
||||||
|
const wechatUrl = ref('')
|
||||||
|
|
||||||
async function onLogin(){
|
// 登录表单校验规则
|
||||||
|
const loginFormRules = {
|
||||||
|
account: [
|
||||||
|
{ required: true, message: '请输入账号', trigger: 'blur' },
|
||||||
|
{ min: 3, max: 20, message: '账号长度在 3 到 20 个字符', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||||
|
{ min: 6, max: 20, message: '密码长度在 6 到 20 个字符', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 页面加载时检查登录状态
|
||||||
|
onMounted(() => {
|
||||||
|
if (isLoggedIn()) {
|
||||||
|
ElMessage.info('您已登录,正在跳转到首页...')
|
||||||
|
router.replace('/dashboard')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function tabFlip(){
|
||||||
|
tab.value = tab.value === 'account' ? 'scan' : 'account'
|
||||||
|
if(tab.value === 'scan'){
|
||||||
try {
|
try {
|
||||||
// const res = await apiLogin({ username: username.value, password: password.value })
|
const res = await apiqrcode()
|
||||||
// if(res && res.token){
|
console.log('微信登录接口返回:', res)
|
||||||
// localStorage.setItem('token', res.token)
|
|
||||||
localStorage.setItem('token', '2222222222')
|
// 直接保存微信登录URL用于iframe
|
||||||
|
wechatUrl.value = res.data?.qr_url
|
||||||
|
console.log('微信登录URL:', wechatUrl.value)
|
||||||
|
|
||||||
|
if (!wechatUrl.value) {
|
||||||
|
throw new Error('未获取到微信登录URL')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取微信登录URL失败:', error)
|
||||||
|
// 如果接口失败,使用备用URL
|
||||||
|
wechatUrl.value = 'https://open.weixin.qq.com/connect/qrconnect?appid=wxfd03df93b36c3728&redirect_uri=https%3A%2F%2Fbsh.yw.hnzhwlkj.cn&response_type=code&scope=snsapi_login&state=backup#wechat_redirect'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function onLogin(){
|
||||||
|
// 表单校验
|
||||||
|
if (!loginFormRef.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await loginFormRef.value.validate()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.warning('请检查表单信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loginLoading.value = true
|
||||||
|
try{
|
||||||
|
const res = await apiLogin({
|
||||||
|
account: loginForm.value.account,
|
||||||
|
password: loginForm.value.password
|
||||||
|
})
|
||||||
|
if(res && res.data.access_token){
|
||||||
|
// 使用工具函数保存登录数据
|
||||||
|
saveLoginData(res.data)
|
||||||
|
ElMessage.success('登录成功')
|
||||||
router.push('/dashboard')
|
router.push('/dashboard')
|
||||||
// } else {
|
} else {
|
||||||
// alert('登录失败')
|
ElMessage.error('登录失败')
|
||||||
// }
|
}
|
||||||
} catch(e){
|
} catch(e){
|
||||||
alert(e?.response?.data?.message || '登录失败')
|
ElMessage.error(e?.response?.data?.message || '登录失败')
|
||||||
|
} finally {
|
||||||
|
loginLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片加载成功
|
||||||
|
function onImageLoad() {
|
||||||
|
console.log('二维码图片加载成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 图片加载失败
|
||||||
|
function onImageError() {
|
||||||
|
console.error('二维码图片加载失败,URL:', qrSrc.value)
|
||||||
|
// 如果图片加载失败,尝试使用备用二维码
|
||||||
|
if (!qrSrc.value.includes('qrserver.com')) {
|
||||||
|
qrSrc.value = 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wechat-login'
|
||||||
|
} else {
|
||||||
|
// 如果已经是二维码服务但还是失败,尝试重新生成
|
||||||
|
console.log('尝试重新生成二维码...')
|
||||||
|
tabFlip() // 重新调用获取二维码
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -96,13 +211,23 @@ async function onScanLogin(){
|
|||||||
padding-right:6%;
|
padding-right:6%;
|
||||||
}
|
}
|
||||||
.login-card{
|
.login-card{
|
||||||
width:360px;
|
width:420px;
|
||||||
background:#fff;
|
background:#fff;
|
||||||
border-radius:12px;
|
border-radius:12px;
|
||||||
padding:60px 28px;
|
|
||||||
box-shadow: 0 10px 30px rgba(14,42,51,0.08);
|
box-shadow: 0 10px 30px rgba(14,42,51,0.08);
|
||||||
position:relative;
|
position:relative;
|
||||||
}
|
}
|
||||||
|
.login-form {
|
||||||
|
padding: 20px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form .el-form-item {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form .el-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
.corner-qrcode{
|
.corner-qrcode{
|
||||||
position:absolute;
|
position:absolute;
|
||||||
width: 50px;
|
width: 50px;
|
||||||
@ -117,7 +242,7 @@ async function onScanLogin(){
|
|||||||
/* title */
|
/* title */
|
||||||
.title{
|
.title{
|
||||||
text-align:center;
|
text-align:center;
|
||||||
margin:0 0 12px;
|
margin:60 0 12px;
|
||||||
font-size:20px;
|
font-size:20px;
|
||||||
color:#333;
|
color:#333;
|
||||||
font-weight:700;
|
font-weight:700;
|
||||||
@ -155,6 +280,26 @@ async function onScanLogin(){
|
|||||||
.qr-area{ text-align:center; margin-top:6px;}
|
.qr-area{ text-align:center; margin-top:6px;}
|
||||||
.qr-img{ width:160px; height:160px; display:block; margin:12px auto; border-radius:6px; border:1px solid #eee;}
|
.qr-img{ width:160px; height:160px; display:block; margin:12px auto; border-radius:6px; border:1px solid #eee;}
|
||||||
.qr-tip{ color:#777; font-size:13px; margin-bottom:8px;}
|
.qr-tip{ color:#777; font-size:13px; margin-bottom:8px;}
|
||||||
|
.qr-debug{ color:#999; font-size:11px; margin:4px 0; word-break: break-all;}
|
||||||
|
|
||||||
|
/* 微信iframe容器 */
|
||||||
|
.wechat-iframe-container{
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
height: 500px;
|
||||||
|
padding: 15px;
|
||||||
|
margin: 12px auto;
|
||||||
|
/* border: 1px solid #e9ecef; */
|
||||||
|
/* border-radius: 8px; */
|
||||||
|
overflow: hidden;
|
||||||
|
/* box-shadow: 0 2px 8px rgba(0,0,0,0.1); */
|
||||||
|
}
|
||||||
|
.wechat-iframe{
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
/* responsive */
|
/* responsive */
|
||||||
@media (max-width: 768px){
|
@media (max-width: 768px){
|
||||||
@ -163,5 +308,9 @@ async function onScanLogin(){
|
|||||||
.login-right{ width:100%; padding:0 16px; margin:0 auto; }
|
.login-right{ width:100%; padding:0 16px; margin:0 auto; }
|
||||||
.login-card{ width:100%; padding:18px; border-radius:10px; }
|
.login-card{ width:100%; padding:18px; border-radius:10px; }
|
||||||
.qr-img{ width:120px; height:120px; }
|
.qr-img{ width:120px; height:120px; }
|
||||||
|
.wechat-iframe-container{
|
||||||
|
max-width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
285
src/views/wx_callback.vue
Normal file
285
src/views/wx_callback.vue
Normal file
@ -0,0 +1,285 @@
|
|||||||
|
<template>
|
||||||
|
<div class="wx-callback">
|
||||||
|
<el-card class="callback-card">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<el-icon><Loading /></el-icon>
|
||||||
|
<span>正在处理微信登录...</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="loading" class="loading-content">
|
||||||
|
<el-icon class="loading-icon"><Loading /></el-icon>
|
||||||
|
<p>请稍候,正在获取授权信息</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-else-if="errorMsg"
|
||||||
|
:title="errorMsg"
|
||||||
|
type="error"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-else
|
||||||
|
title="授权成功,正在跳转"
|
||||||
|
type="success"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 绑定账户弹窗 -->
|
||||||
|
<el-dialog
|
||||||
|
v-model="showBind"
|
||||||
|
title="完善信息并绑定账户"
|
||||||
|
width="400px"
|
||||||
|
:close-on-click-modal="false"
|
||||||
|
:close-on-press-escape="false"
|
||||||
|
:show-close="false"
|
||||||
|
>
|
||||||
|
<el-form
|
||||||
|
ref="bindFormRef"
|
||||||
|
:model="bindForm"
|
||||||
|
:rules="bindFormRules"
|
||||||
|
label-width="80px"
|
||||||
|
label-position="left"
|
||||||
|
>
|
||||||
|
<el-form-item label="姓名" prop="name">
|
||||||
|
<el-input
|
||||||
|
v-model="bindForm.name"
|
||||||
|
placeholder="请输入姓名"
|
||||||
|
clearable
|
||||||
|
maxlength="20"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="手机号" prop="mobile">
|
||||||
|
<el-input
|
||||||
|
v-model="bindForm.mobile"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
clearable
|
||||||
|
maxlength="11"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="编号" prop="sn">
|
||||||
|
<el-input
|
||||||
|
v-model="bindForm.sn"
|
||||||
|
placeholder="请输入编号"
|
||||||
|
clearable
|
||||||
|
maxlength="20"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="密码" prop="password">
|
||||||
|
<el-input
|
||||||
|
v-model="bindForm.password"
|
||||||
|
type="password"
|
||||||
|
placeholder="请输入密码"
|
||||||
|
show-password
|
||||||
|
clearable
|
||||||
|
maxlength="20"
|
||||||
|
show-word-limit
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="dialog-footer">
|
||||||
|
<el-button @click="onCancelBind">取消</el-button>
|
||||||
|
<el-button type="primary" @click="onBind" :loading="bindLoading">
|
||||||
|
确认绑定
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getWechatUserInfo, bindWechatAccount } from '../api'
|
||||||
|
import { saveLoginData } from '../utils/auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const loading = ref(true)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const showBind = ref(false)
|
||||||
|
const bindLoading = ref(false)
|
||||||
|
const bindForm = ref({ name: '', mobile: '', sn: '', password: '' })
|
||||||
|
const bindFormRef = ref()
|
||||||
|
let cachedState = ''
|
||||||
|
const userInfo = ref({})
|
||||||
|
|
||||||
|
// 表单校验规则
|
||||||
|
const bindFormRules = {
|
||||||
|
name: [
|
||||||
|
{ required: true, message: '请输入姓名', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
mobile: [
|
||||||
|
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||||
|
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
sn: [
|
||||||
|
{ required: true, message: '请输入编号', trigger: 'blur' }
|
||||||
|
],
|
||||||
|
password: [
|
||||||
|
{ required: true, message: '请输入密码', trigger: 'blur' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQuery(){
|
||||||
|
// 优先从路由 query 获取
|
||||||
|
const { code, state } = route.query || {}
|
||||||
|
if (code && state) return { code, state }
|
||||||
|
// 兜底从 location 解析
|
||||||
|
const usp = new URLSearchParams(window.location.search)
|
||||||
|
return { code: usp.get('code'), state: usp.get('state') }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try{
|
||||||
|
const { code, state } = parseQuery()
|
||||||
|
cachedState = state || ''
|
||||||
|
if(!code){
|
||||||
|
ElMessage.error('未获取到微信授权code')
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const res = await getWechatUserInfo({ code, state })
|
||||||
|
// 假设后端返回 token 与跳转页
|
||||||
|
const data = res?.data || res
|
||||||
|
userInfo.value = data
|
||||||
|
if(data?.is_bind){
|
||||||
|
// 使用工具函数保存登录数据
|
||||||
|
saveLoginData(userInfo.value)
|
||||||
|
router.replace('/dashboard')
|
||||||
|
return
|
||||||
|
}else{
|
||||||
|
showBind.value = true
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
ElMessage.error(e?.response?.data?.message || '授权失败,请重试')
|
||||||
|
}finally{
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function onCancelBind(){
|
||||||
|
showBind.value = false
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onBind(){
|
||||||
|
// 表单校验
|
||||||
|
if (!bindFormRef.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await bindFormRef.value.validate()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.warning('请检查表单信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
bindLoading.value = true
|
||||||
|
try{
|
||||||
|
const payload = { ...bindForm.value, ...userInfo.value }
|
||||||
|
const res = await bindWechatAccount(payload)
|
||||||
|
const data = res?.data || res
|
||||||
|
ElMessage.success(data.message || '绑定成功')
|
||||||
|
if (data) {
|
||||||
|
showBind.value = false
|
||||||
|
// 使用工具函数保存登录数据
|
||||||
|
saveLoginData(data)
|
||||||
|
router.replace('/dashboard')
|
||||||
|
}
|
||||||
|
}catch(e){
|
||||||
|
ElMessage.error(e?.response?.data?.message || '绑定失败,请重试')
|
||||||
|
}finally{
|
||||||
|
bindLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.wx-callback {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f6f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.callback-card {
|
||||||
|
width: 400px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
animation: rotate 2s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rotate {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表单样式优化 */
|
||||||
|
.el-form-item {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-form-item__label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__wrapper {
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 字符计数样式 */
|
||||||
|
.el-input__count {
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
@ -1,9 +1,26 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig, loadEnv } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
|
// 加载环境变量
|
||||||
|
const env = loadEnv(mode, process.cwd(), '')
|
||||||
|
|
||||||
|
return {
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
base: env.VITE_BASE_URL || '/',
|
||||||
server: {
|
server: {
|
||||||
port: 5173
|
port: parseInt(env.VITE_PORT) || 3000,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: env.VITE_API_URL || 'http://localhost:4000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, '')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
host: true,
|
||||||
|
allowedHosts: [
|
||||||
|
'bsh.yw.hnzhwlkj.cn'
|
||||||
|
]
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user