创建面板项目

This commit is contained in:
浅念 2025-09-27 16:42:15 +08:00
commit f65321a3ae
25 changed files with 2213 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

12
index.html Normal file
View File

@ -0,0 +1,12 @@
<!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>Vue App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

23
mock-server.js Normal file
View File

@ -0,0 +1,23 @@
/**
* Simple mock server for login credential handling.
* Run with: node mock-server.js
* It listens on port 4000 and exposes:
* POST /api/login (expects { username, password } ), returns token on success
* GET /api/menus and GET /api/user are served from /public/api by vite dev server in normal flow.
*/
import express from 'express'
import bodyParser from 'body-parser'
const app = express()
app.use(bodyParser.json())
app.post('/api/login', (req,res) => {
const { username, password } = req.body || {}
// simple check: admin / 123456 succeed, or if username === 'scan' allow (for QR)
if ((username === 'admin' && password === '123456') || username === 'scan') {
return res.json({ code: 200, token: 'mocked-jwt-token' })
}
return res.status(401).json({ code: 401, message: '账号或密码错误' })
})
const port = 4000
app.listen(port, () => console.log(`Mock server running on http://localhost:${port}`))

1464
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

21
package.json Normal file
View File

@ -0,0 +1,21 @@
{
"name": "my-vue-app-login-scan",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
"mock": "node mock-server.js"
},
"dependencies": {
"axios": "^1.6.0",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.5.0",
"vite": "^5.0.0",
"express": "^4.18.2"
}
}

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

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

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

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

10
src/App.vue Normal file
View File

@ -0,0 +1,10 @@
<template>
<router-view />
</template>
<script setup>
</script>
<style>
html,body,#app { height: 100%; margin: 0; }
</style>

13
src/api/index.js Normal file
View File

@ -0,0 +1,13 @@
import request from './request'
export function apiLogin(payload){
return request.post('/api/login', payload)
}
export function getMenus(){
return request.get('/api/menus.json')
}
export function getUser(){
return request.get('/api/user.json')
}

16
src/api/request.js Normal file
View File

@ -0,0 +1,16 @@
import axios from 'axios'
const service = axios.create({
baseURL: '', // use relative so vite proxy/public works
timeout: 5000
})
service.interceptors.request.use(cfg => {
const token = localStorage.getItem('token')
if (token) cfg.headers['Authorization'] = `Bearer ${token}`
return cfg
}, err => Promise.reject(err))
service.interceptors.response.use(res => res.data, err => Promise.reject(err))
export default service

BIN
src/assets/Rectangle.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
src/assets/card-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

BIN
src/assets/img/avatar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

0
src/assets/logo.png Normal file
View File

BIN
src/assets/qrcode.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 951 B

5
src/assets/style.css Normal file
View File

@ -0,0 +1,5 @@
/* global styles */
*{box-sizing:border-box}
body,html{height:100%;margin:0;font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial;}
a{color:inherit}
button{font-family:inherit}

6
src/main.js Normal file
View File

@ -0,0 +1,6 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import './assets/style.css'
createApp(App).use(router).mount('#app')

16
src/router/index.js Normal file
View File

@ -0,0 +1,16 @@
import { createRouter, createWebHistory } from 'vue-router'
import Login from '../views/Login.vue'
import Dashboard from '../views/Dashboard.vue'
const routes = [
{ path: '/', redirect: '/login' },
{ path: '/login', component: Login },
{ path: '/dashboard', component: Dashboard }
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

24
src/utils/request.js Normal file
View File

@ -0,0 +1,24 @@
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

319
src/views/Dashboard.vue Normal file
View File

@ -0,0 +1,319 @@
<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" @click="showProfile = true">
<img :src="user.avatar" alt="avatar" class="avatar" />
</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 }"
>
{{ 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="card-grid">
<div class="card" v-for="(item, index) in m.cards" :key="index">
<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>
</div>
</div>
</section>
</main>
<!-- 修改资料弹窗 -->
<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="actions">
<button @click="showProfile = false">取消</button>
<button @click="saveProfile">保存</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import { getMenus, getUser } from "../api";
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);
onMounted(async () => {
try {
const m = await getMenus();
// [{ id, name, cards: [{ title, desc }] }]
menus.value = m.map((section) => ({
...section,
cards: section.cards.map((c) =>
typeof c === "string"
? { title: c, desc: "每日12次更新" }
: c
),
}));
const u = await getUser();
user.value = u;
profile.value.username = u.name || "";
} catch (e) {
console.error(e);
}
});
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;
}
function saveProfile() {
alert("已保存(模拟)");
showProfile.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: #0f7c72;
color: #fff;
position: sticky;
top: 0;
z-index: 50;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.brand img {
height: 28px;
}
.menu-btn {
display: none;
background: none;
border: none;
color: #fff;
font-size: 20px;
cursor: pointer;
}
.user img.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
cursor: pointer;
}
/* 左侧导航 */
.sidebar {
width: 220px;
background: #f8f9fb;
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: #0f7c72;
}
.sidebar li.active {
background: #e6f3f2;
border-left: 3px solid #0f7c72;
color: #0f7c72;
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;
}
/* 卡片网格 */
.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;
}
.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);
}
.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>

144
src/views/Login.vue Normal file
View File

@ -0,0 +1,144 @@
<template>
<div class="login-page">
<div class="login-left"></div>
<div class="login-right">
<div class="login-card">
<div class="corner-qrcode" @click="tab='scan'">
</div>
<h2 class="title">账号登录</h2>
<!-- <div class="tabs">
<button :class="{active:tab==='account'}" @click="tab='account'">账号登录</button>
<button :class="{active:tab==='scan'}" @click="tab='scan'">扫码登录</button>
</div> -->
<div v-if="tab==='account'" class="form">
<label>账号</label>
<input v-model="username" placeholder="请输入账号" />
<label>密码</label>
<input v-model="password" type="password" placeholder="请输入密码" />
<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>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { apiLogin } from '../api'
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')
async function onLogin(){
try{
// const res = await apiLogin({ username: username.value, password: password.value })
// if(res && res.token){
// localStorage.setItem('token', res.token)
localStorage.setItem('token', '2222222222')
router.push('/dashboard')
// } else {
// alert('')
// }
} catch(e){
alert(e?.response?.data?.message || '登录失败')
}
}
async function onScanLogin(){
// simulate qr login success, call login with special username 'scan'
try{
const res = await apiLogin({ username: 'scan', password: 'scan' })
if(res && res.token){
localStorage.setItem('token', res.token)
router.push('/dashboard')
}
} catch(e){
alert('扫码登录失败')
}
}
</script>
<style scoped>
.login-page{
height:100vh;
display:flex;
background: url('/src/assets/Rectangle.png') no-repeat;
background-size: 100% 100%;
align-items:center;
justify-content:center;
}
/* left large image-ish area (you can replace with your image) */
.login-left{
flex:1;
}
/* right card */
.login-right{
width:520px;
display:flex;
align-items:center;
justify-content:center;
padding-right:6%;
}
.login-card{
width:360px;
background:#fff;
border-radius:12px;
padding:60px 28px;
box-shadow: 0 10px 30px rgba(14,42,51,0.08);
position:relative;
}
.corner-qrcode{
position:absolute;
width: 50px;
height: 50px;
right: -3px;
top: -3px;
font-size:12px;
color:#009688;
cursor:pointer;
background: url('/src/assets/qrcode.png') no-repeat center;
}
/* title */
.title{
text-align:center;
margin:0 0 12px;
font-size:20px;
color:#333;
font-weight:700;
}
/* tabs */
.tabs{ display:flex; gap:10px; justify-content:center; margin-bottom:12px;}
.tabs button{ padding:8px 14px; border-radius:20px; border:none; background:#f3f6f6; cursor:pointer;}
.tabs button.active{ background:#009688; color:#fff; font-weight:600;}
/* form */
.form label{ display:block; margin-top:8px; color:#666; font-size:13px;}
.form input{ width:100%; padding:10px; margin-top:6px; border-radius:6px; border:1px solid #eee; background:#fafafa;}
.login-btn{ width:100%; padding:12px; margin-top:60px; border-radius:16px; border:none; background:#009688; color:#fff; cursor:pointer; font-size:16px;}
/* qr area */
.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;}
/* responsive */
@media (max-width: 768px){
.login-page{ align-items:flex-start; padding-top:40px; }
.login-left{ display:none; }
.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; }
}
</style>

9
vite.config.js Normal file
View File

@ -0,0 +1,9 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173
}
})