This commit is contained in:
浅念 2025-11-18 10:40:54 +08:00
parent 1935a60c92
commit 086cf94ae5
39 changed files with 11020 additions and 127 deletions

22
.env Normal file
View 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

Binary file not shown.

BIN
.env.production Normal file

Binary file not shown.

56
dist/api/data.json vendored Normal file
View 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
View File

@ -0,0 +1,4 @@
{
"code": 200,
"token": "mocked-jwt-token"
}

66
dist/api/menus.json vendored Normal file
View 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
View File

@ -0,0 +1,4 @@
{
"name": "Admin",
"avatar": "https://i.pravatar.cc/100"
}

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

Binary file not shown.

BIN
dist/assets/iconfont-DO81I5ZO.woff vendored Normal file

Binary file not shown.

BIN
dist/assets/iconfont-Do8FRWo6.woff2 vendored Normal file

Binary file not shown.

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

File diff suppressed because one or more lines are too long

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

13
dist/index.html vendored Normal file
View 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>

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<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>
<body>
<div id="app"></div>

947
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,19 +3,23 @@
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"dev": "vite --mode development",
"dev:prod": "vite --mode production",
"build": "vite build --mode production",
"build:dev": "vite build --mode development",
"serve": "vite preview",
"mock": "node mock-server.js"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.6.0",
"element-plus": "^2.11.5",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.0",
"vite": "^5.0.0",
"express": "^4.18.2"
"express": "^4.18.2",
"vite": "^5.0.0"
}
}

View File

@ -1,13 +1,42 @@
import request from './request'
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(){
return request.get('/api/menus.json')
return request.get('/common/nav/categories/complete')
}
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)
}

View File

@ -1,16 +1,131 @@
import axios from 'axios'
const service = axios.create({
baseURL: '', // use relative so vite proxy/public works
baseURL: import.meta.env.VITE_API_URL || '', // 使用环境变量配置API地址
timeout: 5000
})
service.interceptors.request.use(cfg => {
const token = localStorage.getItem('token')
if (token) cfg.headers['Authorization'] = `Bearer ${token}`
// --- Token 管理 ---
const ACCESS_TOKEN_KEY = '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
}, err => Promise.reject(err))
service.interceptors.response.use(res => res.data, err => Promise.reject(err))
// 响应拦截:直出 data401 时尝试一次刷新后重试
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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 B

After

Width:  |  Height:  |  Size: 1.0 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 0 B

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -1,6 +1,19 @@
import { createApp } from 'vue'
import App from './App.vue'
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/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')

View File

@ -1,11 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
import { ElMessage } from 'element-plus'
import Login from '../views/Login.vue'
import Dashboard from '../views/Dashboard.vue'
import WxCallback from '../views/wx_callback.vue'
import { isLoggedIn, clearTokens } from '../utils/auth'
const routes = [
{ path: '/', redirect: '/login' },
{ path: '/login', component: Login },
{ path: '/dashboard', component: Dashboard }
{ path: '/login', component: Login, meta: { public: true } },
{ path: '/dashboard', component: Dashboard },
{ path: '/wx_callback', component: WxCallback, meta: { public: true } }
]
const router = createRouter({
@ -13,4 +17,27 @@ const router = createRouter({
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

84
src/utils/auth.js Normal file
View 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)
}

View File

@ -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

View 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": "&#xe634;",
"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": "&#xe6b4;",
"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": "&#xe641;",
"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": "&#xe70e;",
"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>

View 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>

View File

@ -5,10 +5,15 @@
<button class="menu-btn" @click="sidebarOpen = !sidebarOpen"></button>
<div class="brand">
<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 class="user" @click="showProfile = true">
<img :src="user.avatar" alt="avatar" class="avatar" />
</div>
</header>
@ -21,7 +26,7 @@
@click="onNav(item.id, index)"
:class="{ active: activeMenu === index }"
>
{{ item.name }}
<i class="iconfont-sys" v-html="item.icon" style="margin-right: 10px;"></i>{{ item.name }}
</li>
</ul>
</aside>
@ -31,14 +36,15 @@
<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">
<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.desc }}</div>
<div class="desc">{{ item.description }}</div>
</div>
</div>
</div>
@ -47,17 +53,101 @@
<!-- 修改资料弹窗 -->
<div v-if="showProfile" class="modal-overlay" @click.self="showProfile = false">
<div class="modal">
<h3>修改个人信息</h3>
<label>头像</label>
<input type="file" />
<label>账号</label>
<input v-model="profile.username" />
<label>密码</label>
<input v-model="profile.password" type="password" />
<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 @click="showProfile = false">取消</button>
<button @click="saveProfile">保存</button>
<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>
@ -66,33 +156,84 @@
<script setup>
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 user = ref({ avatar: "https://i.pravatar.cc/100", name: "" });
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 = m.map((section) => ({
menus.value = res.map((section) => ({
...section,
cards: section.cards.map((c) =>
cards: section.nav_item.map((c) =>
typeof c === "string"
? { title: c, desc: "每日12次更新" }
? { title: c, description: "" }
: c
),
}));
const u = await getUser();
user.value = u;
profile.value.username = u.name || "";
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) {
@ -108,10 +249,72 @@ function onNav(id, index) {
sidebarOpen.value = false;
}
function saveProfile() {
alert("已保存(模拟)");
showProfile.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>
@ -128,8 +331,8 @@ function saveProfile() {
align-items: center;
justify-content: space-between;
padding: 0 16px;
background: #0f7c72;
color: #fff;
background: #fff;
/* color: #fff; */
position: sticky;
top: 0;
z-index: 50;
@ -141,7 +344,7 @@ function saveProfile() {
font-weight: 600;
}
.brand img {
height: 28px;
height: 22px;
}
.menu-btn {
display: none;
@ -151,17 +354,24 @@ function saveProfile() {
font-size: 20px;
cursor: pointer;
}
.user img.avatar {
.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: #f8f9fb;
background: #fff;
padding: 12px 0;
position: fixed;
left: 0;
@ -185,12 +395,12 @@ function saveProfile() {
}
.sidebar li:hover {
background: #eef6f6;
color: #0f7c72;
color: #009999;
}
.sidebar li.active {
background: #e6f3f2;
border-left: 3px solid #0f7c72;
color: #0f7c72;
background: rgba(0, 153, 153, 0.09);
border-left: 3px solid #009999;
color: #009999;
font-weight: 600;
}
@ -208,10 +418,19 @@ function saveProfile() {
font-weight: 600;
color: #222;
margin: 20px 0 16px;
border-left: 4px solid #0f7c72;
/* 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;
@ -227,6 +446,7 @@ function saveProfile() {
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);
@ -263,6 +483,30 @@ function saveProfile() {
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;

View File

@ -4,31 +4,64 @@
<div class="login-right">
<div class="login-card">
<div class="corner-qrcode" @click="tab='scan'">
<div class="corner-qrcode" @click="tabFlip">
</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">
<button :class="{active:tab==='account'}" @click="tab='account'">账号登录</button>
<button :class="{active:tab==='scan'}" @click="tab='scan'">扫码登录</button>
</div> -->
<el-form-item label="密码" prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="请输入密码"
show-password
clearable
prefix-icon="Lock"
maxlength="20"
/>
</el-form-item>
<div v-if="tab==='account'" class="form">
<div class="form-row">
<label>账号</label>
<input v-model="username" placeholder="请输入账号" />
<el-form-item>
<el-button
type="primary"
@click="onLogin"
:loading="loginLoading"
class="login-btn"
size="large"
>
登录
</el-button>
</el-form-item>
</el-form>
</div>
<div class="form-row">
<label>密码</label>
<input v-model="password" type="password" placeholder="请输入密码" />
<div v-if="tab==='scan'">
<div class="wechat-iframe-container">
<iframe
:src="wechatUrl"
class="wechat-iframe"
frameborder="0"
allowfullscreen
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-top-navigation"
></iframe>
</div>
<button class="login-btn" @click="onLogin">登录</button>
</div>
<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>
<!-- <p class="qr-tip">请在下方页面中完成微信授权登录</p> -->
<!-- <button class="login-btn" @click="onScanLogin">已授权确认登录</button> -->
</div>
</div>
</div>
@ -36,27 +69,109 @@
</template>
<script setup>
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
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 tab = ref('account')
const username = ref('')
const password = ref('')
const qrSrc = ref('https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=wechat-login')
const loginLoading = ref(false)
const loginForm = ref({
account: '',
password: ''
})
const loginFormRef = ref()
const wechatUrl = ref('')
//
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 {
const res = await apiqrcode()
console.log('微信登录接口返回:', res)
// URLiframe
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({ username: username.value, password: password.value })
// if(res && res.token){
// localStorage.setItem('token', res.token)
localStorage.setItem('token', '2222222222')
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')
// } else {
// alert('')
// }
} else {
ElMessage.error('登录失败')
}
} 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%;
}
.login-card{
width:360px;
width:420px;
background:#fff;
border-radius:12px;
padding:60px 28px;
box-shadow: 0 10px 30px rgba(14,42,51,0.08);
position:relative;
}
.login-form {
padding: 20px 28px;
}
.login-form .el-form-item {
margin-bottom: 20px;
}
.login-form .el-input {
width: 100%;
}
.corner-qrcode{
position:absolute;
width: 50px;
@ -117,7 +242,7 @@ async function onScanLogin(){
/* title */
.title{
text-align:center;
margin:0 0 12px;
margin:60 0 12px;
font-size:20px;
color:#333;
font-weight:700;
@ -155,6 +280,26 @@ async function onScanLogin(){
.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-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 */
@media (max-width: 768px){
@ -163,5 +308,9 @@ async function onScanLogin(){
.login-right{ width:100%; padding:0 16px; margin:0 auto; }
.login-card{ width:100%; padding:18px; border-radius:10px; }
.qr-img{ width:120px; height:120px; }
.wechat-iframe-container{
max-width: 100%;
height: 400px;
}
}
</style>

285
src/views/wx_callback.vue Normal file
View 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>

View File

@ -1,9 +1,26 @@
import { defineConfig } from 'vite'
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
export default defineConfig(({ mode }) => {
// 加载环境变量
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [vue()],
base: env.VITE_BASE_URL || '/',
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'
]
},
}
})