commit
61a45b3c9d
15 changed files with 5788 additions and 0 deletions
-
36.gitignore
-
3.vscode/extensions.json
-
38README.md
-
13index.html
-
8jsconfig.json
-
4680package-lock.json
-
25package.json
-
BINpublic/favicon.ico
-
8src/App.vue
-
71src/api/visitorsource.js
-
21src/main.js
-
26src/routes/index.js
-
56src/utils/request.js
-
774src/view/SourceTracker.vue
-
29vite.config.js
@ -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__/ |
|||
@ -0,0 +1,3 @@ |
|||
{ |
|||
"recommendations": ["Vue.volar"] |
|||
} |
|||
@ -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 |
|||
``` |
|||
@ -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> |
|||
@ -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
File diff suppressed because it is too large
View File
@ -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" |
|||
} |
|||
} |
|||
@ -0,0 +1,8 @@ |
|||
<script setup> |
|||
</script> |
|||
|
|||
<template> |
|||
<router-view></router-view> |
|||
</template> |
|||
|
|||
<style scoped></style> |
|||
@ -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' |
|||
} |
|||
}); |
|||
} |
|||
} |
|||
@ -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') |
|||
@ -0,0 +1,26 @@ |
|||
import { createWebHashHistory, createRouter } from "vue-router"; |
|||
/*createWebHashHistory:创建哈希模式的路由历史(URL 中带 #, |
|||
如 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 应用使用
|
|||
@ -0,0 +1,56 @@ |
|||
import axios from 'axios'; |
|||
import router from '../routes'; |
|||
import {ElMessage} from 'element-plus' |
|||
/*export default function (options) { ... } 是默认导出一个匿名函数, |
|||
你在其他文件导入时,可以给它起任意名字 。 |
|||
接收 options 参数(axios 的请求配置,如 url、method、data 等)*/ |
|||
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) |
|||
}); |
|||
} |
|||
@ -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)"><</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)">></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)"><</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)">></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)"><</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)">></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> |
|||
@ -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/,'') |
|||
} |
|||
} |
|||
} |
|||
}) |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue