Browse Source

客户来源统计前端

master
zxz 1 month ago
commit
61a45b3c9d
  1. 36
      .gitignore
  2. 3
      .vscode/extensions.json
  3. 38
      README.md
  4. 13
      index.html
  5. 8
      jsconfig.json
  6. 4680
      package-lock.json
  7. 25
      package.json
  8. BIN
      public/favicon.ico
  9. 8
      src/App.vue
  10. 71
      src/api/visitorsource.js
  11. 21
      src/main.js
  12. 26
      src/routes/index.js
  13. 56
      src/utils/request.js
  14. 774
      src/view/SourceTracker.vue
  15. 29
      vite.config.js

36
.gitignore

@ -0,0 +1,36 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/

3
.vscode/extensions.json

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

38
README.md

@ -0,0 +1,38 @@
# SourceTracker
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Compile and Minify for Production
```sh
npm run build
```

13
index.html

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

8
jsconfig.json

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

4680
package-lock.json
File diff suppressed because it is too large
View File

25
package.json

@ -0,0 +1,25 @@
{
"name": "sourcetracker",
"version": "0.0.0",
"private": true,
"type": "module",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.2",
"element-plus": "^2.11.9",
"vue": "^3.5.25",
"vue-router": "^4.6.3"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.2",
"vite": "^7.2.4",
"vite-plugin-vue-devtools": "^8.0.5"
}
}

BIN
public/favicon.ico

8
src/App.vue

@ -0,0 +1,8 @@
<script setup>
</script>
<template>
<router-view></router-view>
</template>
<style scoped></style>

71
src/api/visitorsource.js

@ -0,0 +1,71 @@
import http from '../utils/request.js'
export default {
acountData: (obj) => {
return http({
url: '/api/toAnchorUserTypeStat/getDetails',
method: 'post',
data: obj,
headers: {
//定义将数据以json的形式传入后台(java)
'Content-Type': 'application/json'
}
});
},
natureData: (obj) => {
return http({
url: '/api/toNotRegistered/getjwCodes',
method: 'post',
data: obj,
headers: {
//定义将数据以json的形式传入后台(java)
'Content-Type': 'application/json'
}
});
},
emailData: (obj) => {
return http({
url: '/api/toUserCount/toEmail',
method: 'post',
data: obj,
headers: {
//定义将数据以json的形式传入后台(java)
'Content-Type': 'application/json'
}
});
},
googleData: (obj) => {
return http({
url: '/api/toUserCount/toGoogle',
method: 'post',
data: obj,
headers: {
//定义将数据以json的形式传入后台(java)
'Content-Type': 'application/json'
}
});
},
smsData: (obj) => {
return http({
url: '/api/toUserCount/toMessage',
method: 'post',
data: obj,
headers: {
//定义将数据以json的形式传入后台(java)
'Content-Type': 'application/json'
}
});
},
crm: (obj) => {
return http({
url: '/api/toCRM/getCount',
method: 'post',
data: obj,
headers: {
//定义将数据以json的形式传入后台(java)
'Content-Type': 'application/json'
}
});
}
}

21
src/main.js

@ -0,0 +1,21 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './routes/index.js'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
const app = createApp(App);
app.use(ElementPlus)
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(router)
app.mount('#app')

26
src/routes/index.js

@ -0,0 +1,26 @@
import { createWebHashHistory, createRouter } from "vue-router";
/*createWebHashHistoryURL #
http://localhost:8080/#/member),用于处理路由跳转逻辑。
createRouter创建路由实例将路由配置和历史模式关联起来
*/
//导入两个 Vue 组件
import sourcetracker from '../view/SourceTracker.vue'
//定义路由规则(routes 数组)
//设置index.vue的路径
const routes = [
{
path: '/', // 路由路径(根路径)
name: 'st', // 路由名称(可选,用于编程式导航)
component: sourcetracker, // 对应的组件(根路径显示 index 组件)
}
]
//创建路由实例并导出
const router = createRouter({
history: createWebHashHistory(), // 指定路由历史模式(哈希模式)
routes // 传入路由规则数组(等价于 routes: routes)//是上面的对象
})
export default router; // 导出路由实例,供 Vue 应用使用

56
src/utils/request.js

@ -0,0 +1,56 @@
import axios from 'axios';
import router from '../routes';
import {ElMessage} from 'element-plus'
/*export default function (options) { ... }
你在其他文件导入时可以给它起任意名字
接收 options 参数axios 的请求配置 urlmethoddata */
export default function (options) {
/*options是一个对象,其中包含了许多用于配置请求的参数,例如请求的url、请求方法(GET、POST等)、请求头等*/
//Object.assign用于合并对象的数据
options.headers = Object.assign(
/*配置每次发送请求都从sessionStorage中获取名字叫token的数据,localStorage.getItem('token'):从本地存储中获取 token(登录后存储的身份凭证)*/
//添加到请求头部的Authorization属性中
{Authorization: localStorage.getItem('token')},
options.headers || {}
);
//axios() 返回一个promise对象,用于异步请求
return axios(options)
//该函数在请求成功并返回数据时被调用
.then(({status, data, statusText}) => {
//status:HTTP状态码,例如200表示请求成功。
//data:服务器返回的数据。
// statusText:HTTP状态文本,例如"OK"表示请求成功。
if (status == 200) {
console.log(data);
return data;
} else {
ElMessage.error('访问服务器出现异常')
}
})
.then(({code, data, msg}) => {
switch (code) {
case 200:
return data;
case 401:
if(msg)
msg='认证失败'
ElMessage({//提供的全局消息的组件页面会从顶部或角落弹出一个短暂显示的提示框,几秒后自动消失,无需用户手动关闭,用于轻量级反馈。
message: msg,
type: 'warning',//不同的类型是不同的颜色啥的
})
/* setTimeout(function(){
sessionStorage.removeItem('token');
router.push("/login");
},1800);
/!*setTimeout(...)
JavaScript 内置函数用于延迟执行代码第一个参数是要执行的函数
第二个参数是延迟时间单位毫秒1800 1.8 *!/*/
break;
default:
if(msg)
ElMessage(msg)
}
}).catch(e => {
console.log(e.stack)
});
}

774
src/view/SourceTracker.vue

@ -0,0 +1,774 @@
<script setup>
import { ref, onMounted } from 'vue';
import visitorsource from '@/api/visitorsource';
const inputJwCodes = ref('');
//
const accountData = ref([]); //
const natureData = ref([]); //
const defaultConfig =ref( {
pageSize: 15,
startTime: '2025-01-04T08:00'
});
const tableData = ref({
emailData: {
page: 1,
total: '',
list: '',
},
googleData: {
page: 1,
total: '',
list: '',
},
smsData: {
page: 1,
total: '',
list: '',
}
});
const crm=ref({});
//
const isLoadingj = ref(false);
const isLoadingt = ref(false);
//
const errorMsg = ref('');
/**
* 搜索功能根据输入的精网号筛选数据
* 实际项目中这里会把搜索关键词传给后台接口返回筛选后的数据
*/
const handleSearch = async () => {
// 1. /
const jwCodes = inputJwCodes.value
.split(/[\s\n]+/) //
.filter(code => code.trim() !== '') //
.filter((code, index, self) => self.indexOf(code) === index); //
// 2.
const requestData = {
jwCodes: jwCodes // ["94618024", "94618030"]
};
console.log(requestData)
if (!jwCodes) {
errorMsg.value = '请输入精网号后再搜索';
return;
}
isLoadingj.value = true;
errorMsg.value = '';
try {
// query
const accountRes = await visitorsource.acountData(requestData);
const natureRes = await visitorsource.natureData(requestData);
accountData.value = accountRes;
natureData.value = natureRes;
console.log(1);
console.log(accountData.value);
console.log(natureRes);
} catch (err) {
errorMsg.value = `搜索失败:${err.message}`;
} finally {
isLoadingj.value = false;
}
};
/**
* 快捷键搜索Cmd+Enter Ctrl+Enter
*/
const handleKeyDown = (e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
};
const timePageSearch = async (tableKey, date) => {
try {
const requesttime ={
startTime: defaultConfig.value.startTime.replace('T', ' ') + ':00',
page:'',
pageSize:defaultConfig.value.pageSize,
};
switch (tableKey){
case 'email':
requesttime.page=tableData.value.emailData.page+date
const emailRes = await visitorsource.emailData(requesttime);
tableData.value.emailData=emailRes
break;
case 'google':
requesttime.page=tableData.value.googleData.page+date
const googleRes = await visitorsource.googleData(requesttime);
tableData.value.googleData=googleRes
break;
case 'sms':
requesttime.page=tableData.value.smsData.page+date
const smsRes = await visitorsource.smsData(requesttime);
tableData.value.smsData=smsRes
break;
case 'crm':
const onlyDate = defaultConfig.value.startTime.split('T')[0];
const requestDate= {
startTime: onlyDate //
};
const crmRes = await visitorsource.crm(requestDate);
crm.value=crmRes
break;
}
errorMsg.value = '';
// query
console.log(77);
console.log(tableData.value.emailData.list.length);
console.log(crm.value);
} catch (err) {
errorMsg.value = `搜索失败:${err.message}aaaa`;
} finally {
isLoadingt.value = false;
}
};
const timeSearch=async ()=>{
try{
isLoadingt.value = true;
errorMsg.value = '';
// 1
tableData.value.emailData.page = 1;
tableData.value.googleData.page = 1;
tableData.value.smsData.page = 1;
await timePageSearch('email',0);
await timePageSearch('google',0);
await timePageSearch('sms',0);
await timePageSearch('crm',0);
}catch (err) {
errorMsg.value = `搜索失败:${err.message}`;
} finally {
isLoadingt.value = false;
}
}
//
onMounted(() => {
// loadAllData();
});
</script>
<template>
<div class="body">
<!-- 页面标题 -->
<div class="page-header">
<h1>访客来源统计</h1>
<p>输入精网号查询</p>
</div>
<!-- 错误提示有错误时显示 -->
<div v-if="errorMsg" class="error-alert mb-4 p-3 bg-red-50 text-red-600 rounded-lg text-sm">
{{ errorMsg }}
</div>
<!-- 查询输入区域 -->
<div class="query-section">
<div class="query-input-container">
<input
type="text"
class="query-input"
placeholder="输入用换行和分隔的精网号(例如:94618024 94618030)..."
@keydown="handleKeyDown"
:disabled="isLoadingj"
v-model="inputJwCodes"
/>
<button class="search-btn" @click="handleSearch" :disabled="isLoadingj">
<!-- 加载状态显示旋转动画 -->
<template v-if="isLoadingj">
<span class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></span>
加载中...
</template>
<template v-else>
搜索
</template>
</button>
</div>
<p class="query-hint"> Press Cmd+Enter to search</p>
</div>
<!-- 结果表格区域 -->
<div class="results-section">
<!-- 表一号主对应引流 -->
<div class="table-card account">
<div class="table-card-header">号主对应引流</div>
<div class="table-card-body">
<!-- 加载中显示加载提示 -->
<div v-if="isLoadingj" class="loading">加载中...</div>
<!-- 无数据时显示无结果提示 -->
<div v-else-if="!errorMsg &&(!accountData|| accountData.length===0)" class="no-results">
<span class="no-results-icon">📊</span>
<p>暂无相关数据</p>
</div>
<!-- 有数据时渲染表格 -->
<table v-else>
<tr>
<th>姓名</th>
<th>用户类型</th>
<th>用户数量</th>
</tr>
<!-- 循环渲染接口返回的数据 -->
<tr v-for="(item, index) in accountData" :key="index">
<td>{{ item.name }}</td>
<td>{{ item.userType }}</td>
<td>{{ item.userCount }}</td>
</tr>
</table>
</div>
</div>
<!-- 表二自然引流详情 -->
<div class="table-card nature">
<div class="table-card-header">自然引流详情</div>
<div class="table-card-body">
<div v-if="isLoadingj" class="loading">加载中...</div>
<div v-else-if="!errorMsg&&(!natureData|| natureData.length===0) " class="no-results">
<span class="no-results-icon">🆔</span>
<p>暂无相关精网号数据</p>
</div>
<table v-else>
<tr>
<th>精网号</th>
</tr>
<tr v-for="(item, index) in natureData" :key="index">
<td>{{ item }}</td>
</tr>
</table>
</div>
</div>
</div>
<!-- 时间筛选标签区域 -->
<div class="filter-tabs-section mt-6">
<input type="datetime-local"
class="filter-input"
v-model="defaultConfig.startTime"
placeholder="选择日期和时间"/>
<button
class="filter-tab"
:class="{ active: activeTab === 'other' }"
@click="timeSearch"
>
<span class="icon">🔍</span>
</button>
</div>
<!-- 数据卡片区域单行排列 -->
<div class="data-cards-section mt-4">
<!-- 邮箱人数卡片带分页+状态 -->
<div class="data-card">
<div class="data-card-header">邮箱人数</div>
<div class="data-card-body">
<!-- 内容容器包裹加载/无数据/表格 -->
<div class="card-content">
<div v-if="isLoadingt" class="loading">加载中...</div>
<div v-else-if="tableData.emailData.list.length === 0&&!errorMsg " class="no-results small">
暂无数据
</div>
<table class="data-table" v-else>
<tr>
<th>时间</th>
<th>人数</th>
</tr>
<tr v-for="(item, index) in tableData.emailData.list" :key="index">
<td>{{ item.businessDate }}</td>
<td>{{ item.userCount}}</td>
</tr>
</table>
</div>
<!-- 分页组件只有加载完成且有数据时显示 -->
<div class="card-pagination" v-if="!isLoadingt && tableData.emailData.list.length > 0 && !errorMsg">
<button class="page-btn" :disabled="tableData.emailData.page===1" @click="timePageSearch('email',-1)">&lt;</button>
<span class="page-info"> {{ tableData.emailData.page }} / {{ Math.ceil(tableData.emailData.total/defaultConfig.pageSize) }} </span>
<button class="page-btn" :disabled="tableData.emailData.page ===Math.ceil(tableData.emailData.total/defaultConfig.pageSize) " @click="timePageSearch('email',1)">&gt;</button>
</div>
</div>
</div>
<!-- 谷歌人数卡片带分页+状态 -->
<div class="data-card">
<div class="data-card-header">谷歌人数</div>
<div class="data-card-body">
<div class="card-content">
<div v-if="isLoadingt" class="loading">加载中...</div>
<div v-else-if="tableData.googleData.list.length === 0 && !errorMsg" class="no-results small">
暂无数据
</div>
<table class="data-table" v-else>
<tr>
<th>时间</th>
<th>人数</th>
</tr>
<tr v-for="(item, index) in tableData.googleData.list" :key="index">
<td>{{ item.businessDate}}</td>
<td>{{ item.userCount }}</td>
</tr>
</table>
</div>
<div class="card-pagination" v-if="!isLoadingt && tableData.googleData.list.length > 0 && !errorMsg">
<button class="page-btn" :disabled="tableData.googleData.page === 1" @click="timePageSearch('google',-1)">&lt;</button>
<span class="page-info"> {{ tableData.googleData.page }} / {{ Math.ceil(tableData.googleData.total/defaultConfig.pageSize) }} </span>
<button class="page-btn" :disabled="tableData.googleData.page === Math.ceil(tableData.googleData.total/defaultConfig.pageSize) " @click="timePageSearch('google',1)">&gt;</button>
</div>
</div>
</div>
<!-- 短信人数卡片带分页+状态 -->
<div class="data-card">
<div class="data-card-header">短信人数</div>
<div class="data-card-body">
<div class="card-content">
<div v-if="isLoadingt" class="loading">加载中...</div>
<div v-else-if="tableData.smsData.list.length === 0 && !errorMsg" class="no-results small">
暂无数据
</div>
<table class="data-table" v-else>
<tr>
<th>时间</th>
<th>人数</th>
</tr>
<tr v-for="(item, index) in tableData.smsData.list" :key="index">
<td>{{ item.businessDate }}</td>
<td>{{ item.userCount }}</td>
</tr>
</table>
</div>
<div class="card-pagination" v-if="!isLoadingt &&tableData.smsData.list.length > 0 && !errorMsg">
<button class="page-btn" :disabled="tableData.smsData.page === 1" @click="timePageSearch('sms',-1)">&lt;</button>
<span class="page-info"> {{tableData.smsData.page }} / {{Math.ceil(tableData.smsData.total/defaultConfig.pageSize) }} </span>
<button class="page-btn" :disabled="tableData.smsData.page === Math.ceil(tableData.smsData.total/defaultConfig.pageSize)" @click="timePageSearch('sms',1)">&gt;</button>
</div>
</div>
</div>
<!-- CRM人数卡片无分页+状态 -->
<div class="data-card">
<div class="data-card-header">CRM人数</div>
<div class="data-card-body">
<div class="card-content">
<div v-if="isLoadingt" class="loading">加载中...</div>
<div v-else-if="!crm.userCount && !errorMsg" class="no-results small">
暂无数据
</div>
<table class="data-table" v-else>
<tr>
<th>人数</th>
</tr>
<tr v-for="(item, index) in crm" :key="index">
<td>{{item}}</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* 原有样式保持不变 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
.body {
background-color: #f8faff;
color: #2d3748;
padding: 30px;
max-width: 1400px;
margin: 0 auto;
}
/* 标题区域 */
.page-header {
margin-bottom: 20px;
}
.page-header h1 {
font-size: 22px;
color: #2b6cb0;
font-weight: 600;
}
.page-header p {
font-size: 14px;
color: #718096;
margin-top: 4px;
}
/* 错误提示样式 */
.error-alert {
border: 1px solid #fecaca;
}
/* 查询输入区域 */
.query-section {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
margin-bottom: 25px;
}
.query-input-container {
display: flex;
gap: 10px;
align-items: center;
}
.query-input {
flex: 1;
height: 42px;
padding: 0 15px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
}
.query-input:disabled {
background-color: #f9fafb;
cursor: not-allowed;
}
.query-input:focus {
outline: none;
border-color: #63b3ed;
box-shadow: 0 0 0 2px rgba(99, 179, 237, 0.1);
}
.search-btn {
height: 42px;
padding: 0 20px;
background-color: #3182ce;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.search-btn:disabled {
background-color: #94a3b8;
cursor: not-allowed;
}
.search-btn:hover:not(:disabled) {
background-color: #2b6cb0;
}
.query-hint {
font-size: 12px;
color: #718096;
margin-top: 8px;
}
/* 结果展示区域 */
.results-section {
display: grid;
grid-template-columns: 0.7fr 0.3fr;
gap: 20px;
margin-bottom: 20px;
}
/* 表格卡片(上方两个表格) */
.table-card {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
overflow: hidden;
}
.table-card-header {
padding: 12px 16px;
background-color: #ebf8ff;
color: #2b6cb0;
font-size: 15px;
font-weight: 500;
}
.table-card-body {
padding: 16px;
min-height: 280px; /* 固定最小高度,避免加载时布局跳动 */
display: flex;
align-items: center;
justify-content: center;
}
/* 加载提示样式 */
.loading {
color: #718096;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
}
.loading::before {
content: '';
width: 16px;
height: 16px;
border: 2px solid #cbd5e0;
border-top-color: #3182ce;
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* 无结果提示样式 */
.no-results {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #718096;
text-align: center;
gap: 8px;
}
.no-results.small {
padding: 20px 0;
font-size: 13px;
}
.no-results-icon {
font-size: 32px;
color: #cbd5e0;
}
/* 表格样式 */
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
table th:first-child,
table td:first-child {
max-width: 90px;
white-space: normal;
word-wrap: break-word;
}
th, td {
padding: 10px 8px;
text-align: left;
border: 1px solid #f0f4f8;
}
th {
color: #4a5568;
font-weight: 500;
}
td {
color: #2d3748;
}
tr:hover {
background-color: #f7fafc;
}
/* 时间筛选标签样式 */
.filter-tabs-section {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
/* 时间选择框样式(匹配页面风格) */
.filter-input {
/* 基础样式:和按钮统一高度、间距 */
padding: 6px 12px;
margin-right: 10px;
border: 1px solid #f0f4f8;
border-radius: 6px;
background-color: #fff;
font-size: 14px;
color: #2d3748;
height: 36px;
box-sizing: border-box;
/* 适配 datetime-local 内置图标 */
appearance: none;
-webkit-appearance: none;
}
/* 聚焦时高亮(和搜索框交互风格一致) */
.filter-input:focus {
outline: none;
border-color: #3182ce;
box-shadow: 0 0 0 2px rgba(49, 130, 206, 0.1);
}
/* 禁用时样式(和按钮禁用状态统一) */
.filter-input:disabled {
background-color: #f5f7fa;
color: #919eab;
border-color: #e2e8f0;
cursor: not-allowed;
}
/* 原有 filter-tab 按钮样式(确保和输入框对齐) */
.filter-tab {
/* 保持原有样式,调整高度和输入框一致 */
height: 36px;
padding: 0 16px;
margin-right: 8px;
border: 1px solid #f0f4f8;
border-radius: 6px;
background-color: #fff;
font-size: 14px;
color: #2d3748;
cursor: pointer;
transition: all 0.2s;
}
.filter-tab.active {
background-color: #ebf8ff;
color: #2b6cb0;
border-color: #3182ce;
}
.filter-tab:disabled {
background-color: #f5f7fa;
color: #919eab;
cursor: not-allowed;
}
.filter-tab {
padding: 6px 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background-color: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.filter-tab:disabled {
background-color: #f9fafb;
color: #94a3b8;
cursor: not-allowed;
}
.filter-tab.active {
background-color: #2b6cb0;
color: white;
border-color: #2b6cb0;
}
.filter-tab .icon {
font-size: 16px;
}
/* 数据卡片区域(原有样式调整) */
.data-cards-section {
display: flex;
gap: 20px;
overflow-x: auto;
padding-bottom: 10px;
}
.data-card {
flex: 1;
min-width: 200px;
background: white;
border: 1px solid #f0f4f8;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
overflow: hidden;
}
.data-card-header {
padding: 10px 14px;
background-color: #ebf8ff;
color: #2b6cb0;
font-size: 14px;
font-weight: 500;
border-bottom: 1px solid #f0f4f8;
text-align: center;
}
/* 关键修改:取消垂直居中,改为 flex 垂直分布 */
.data-card-body {
padding: 14px;
min-height: 400px;
display: flex;
flex-direction: column; /* 子元素垂直排列(内容在上,分页在下) */
justify-content: flex-start; /* 整体顶部对齐,不再垂直居中 */
gap: 15px; /* 内容和分页之间的间距 */
}
/* 内容容器(包裹加载/无数据/表格) */
.card-content {
flex: 1; /* 占满除分页外的所有空间 */
display: flex;
align-items: flex-start; /* 表格/状态提示顶部对齐 */
justify-content: center; /* 加载/无数据时水平居中 */
width: 100%;
}
/* 表格样式保持不变 */
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table th {
text-align: center;
font-size: 13px;
padding: 8px 4px;
border: 1px solid #f0f4f8;
}
.data-table td {
text-align: center;
font-size: 13px;
padding: 8px 4px;
border: 1px solid #f0f4f8;
}
/* 加载状态样式(保持水平居中,取消垂直居中) */
.loading {
color: #718096;
font-size: 14px;
display: flex;
align-items: center;
gap: 8px;
margin-top: 20px; /* 顶部留一点间距,避免贴边 */
}
.loading::before {
content: '';
width: 16px;
height: 16px;
border: 2px solid #cbd5e0;
border-top-color: #3182ce;
border-radius: 50%;
animation: spin 1s linear infinite;
}
/* 无数据状态样式(顶部对齐) */
.no-results.small {
padding: 20px 0 0 0; /* 只留顶部间距 */
font-size: 13px;
color: #718096;
text-align: center;
width: 100%;
}
/* 分页组件样式 */
.card-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 12px;
color: #666;
padding-top: 10px;
border-top: 1px solid #f0f4f8; /* 加分割线,区分表格和分页 */
}
.page-btn {
padding: 3px 8px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.page-btn:disabled {
color: #999;
cursor: not-allowed;
background: #f5f7fa;
border-color: #e5e7eb;
}
.page-btn:hover:not(:disabled) {
border-color: #3182ce;
color: #3182ce;
}
.page-info {
min-width: 80px;
text-align: center;
}
/* 旋转动画 */
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

29
vite.config.js

@ -0,0 +1,29 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
server:{
port:8888,
open:true,
proxy:{
'/api':{
target:'http://localhost:8082',
changeOrigin:true,
rewrite:(path)=>path.replace(/^\/api/,'')
}
}
}
})
Loading…
Cancel
Save