企业微信外部联系人同步
This commit is contained in:
+17
-2
@@ -6,8 +6,8 @@ export function qywxCustomerLists(params: any) {
|
||||
}
|
||||
|
||||
// 同步企业微信客户
|
||||
export function qywxCustomerSync() {
|
||||
return request.post({ url: '/qywx.customer/sync' })
|
||||
export function qywxCustomerSync(params: any = {}) {
|
||||
return request.post({ url: '/qywx.customer/sync', params })
|
||||
}
|
||||
|
||||
// 获取统计信息
|
||||
@@ -15,6 +15,16 @@ export function qywxCustomerStats() {
|
||||
return request.get({ url: '/qywx.customer/stats' })
|
||||
}
|
||||
|
||||
// 按时间点统计用户数量
|
||||
export function qywxCustomerCountByTime(time: any) {
|
||||
return request.get({ url: '/qywx.customer/countByTime', params: { time } })
|
||||
}
|
||||
|
||||
// 获取企业微信员工列表
|
||||
export function qywxGetStaffList() {
|
||||
return request.get({ url: '/qywx.customer/getStaffList' })
|
||||
}
|
||||
|
||||
// 获取同步设置
|
||||
export function qywxSyncSettingsGet() {
|
||||
return request.get({ url: '/qywx.customer/getSyncSettings' })
|
||||
@@ -24,3 +34,8 @@ export function qywxSyncSettingsGet() {
|
||||
export function qywxSyncSettingsSave(params: any) {
|
||||
return request.post({ url: '/qywx.customer/saveSyncSettings', params })
|
||||
}
|
||||
|
||||
// 批量获取企业微信客户(支持分页)
|
||||
export function qywxBatchGetCustomers(params: any) {
|
||||
return request.get({ url: '/qywx.customer/batchGetCustomers', params })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 企业微信API
|
||||
export const qywxApi = {
|
||||
// 获取加粉统计
|
||||
getAddStats: (params) => {
|
||||
return request.get({
|
||||
url: '/qywx.stats/getAddStats',
|
||||
params
|
||||
})
|
||||
},
|
||||
|
||||
// 生成二维码
|
||||
createQrcode: (data) => {
|
||||
return request.post({
|
||||
url: '/qywx.qrcode/create',
|
||||
data
|
||||
})
|
||||
},
|
||||
|
||||
// 获取二维码列表
|
||||
getQrcodeList: (params) => {
|
||||
return request.get({
|
||||
url: '/qywx.qrcode/getList',
|
||||
params
|
||||
})
|
||||
},
|
||||
|
||||
// 获取企业微信员工列表
|
||||
getStaffList: () => {
|
||||
return request.get({
|
||||
url: '/qywx.qrcode/getStaffList'
|
||||
})
|
||||
},
|
||||
|
||||
// 删除二维码
|
||||
deleteQrcode: (data) => {
|
||||
return request.post({
|
||||
url: '/qywx.qrcode/delete',
|
||||
data
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -82,6 +82,34 @@ export const constantRoutes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/tcm/diagnosis/h5',
|
||||
component: () => import('@/views/tcm/diagnosis/index_h5.vue')
|
||||
},
|
||||
{
|
||||
path: '/qywx',
|
||||
component: LAYOUT,
|
||||
meta: {
|
||||
title: '企业微信',
|
||||
icon: 'weixin'
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'index',
|
||||
component: () => import('@/views/qywx/index.vue'),
|
||||
name: 'qywxFansManage',
|
||||
meta: {
|
||||
title: '加粉管理',
|
||||
icon: 'user_guanli'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: 'customer',
|
||||
component: () => import('@/views/fans/qywx.vue'),
|
||||
name: 'qywxCustomerManage',
|
||||
meta: {
|
||||
title: '客户管理',
|
||||
icon: 'user_gaikuang'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
// {
|
||||
// path: '/dev_tools',
|
||||
|
||||
@@ -9,7 +9,15 @@
|
||||
<template #icon><Refresh /></template>
|
||||
立即同步
|
||||
</el-button>
|
||||
<el-button @click="showSyncSettings = true">
|
||||
<el-button @click="importCustomers">
|
||||
<template #icon><Upload /></template>
|
||||
导入数据
|
||||
</el-button>
|
||||
<el-button @click="batchGetCustomers">
|
||||
<template #icon><Download /></template>
|
||||
批量获取
|
||||
</el-button>
|
||||
<el-button @click="openSyncSettings">
|
||||
<template #icon><Setting /></template>
|
||||
同步设置
|
||||
</el-button>
|
||||
@@ -49,6 +57,25 @@
|
||||
</el-tag>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<el-card shadow="hover">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="text-gray-500 text-sm">按时间点统计:</div>
|
||||
<el-date-picker
|
||||
v-model="countTime"
|
||||
type="datetime"
|
||||
placeholder="选择时间"
|
||||
@change="getCountByTime"
|
||||
/>
|
||||
<el-button type="primary" @click="getCountByTime">查询</el-button>
|
||||
<div v-if="timeCount" class="ml-4">
|
||||
<span class="text-gray-500">该时间点用户数:</span>
|
||||
<span class="text-xl font-bold text-primary">{{ timeCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never">
|
||||
@@ -82,7 +109,7 @@
|
||||
<el-avatar :size="40" :src="row.avatar" />
|
||||
<div>
|
||||
<div class="font-medium">{{ row.name }}</div>
|
||||
<div class="text-xs text-gray-400">{{ row.external_userid }}</div>
|
||||
<div class="text-xs text-gray-400">{{ row.external_user_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -114,7 +141,7 @@
|
||||
</el-table-column>
|
||||
<el-table-column label="添加时间" width="160">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.create_time) }}
|
||||
{{ formatTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="更新时间" width="160">
|
||||
@@ -157,6 +184,47 @@
|
||||
<el-option label="每天" :value="86400" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="同步模式">
|
||||
<el-select v-model="syncSettings.sync_mode">
|
||||
<el-option label="全部用户" :value="'all'" />
|
||||
<el-option label="指定用户" :value="'specific'" />
|
||||
<el-option label="按时间范围" :value="'time'" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="syncSettings.sync_mode === 'specific'" label="选择用户">
|
||||
<el-select
|
||||
v-model="syncSettings.selected_users"
|
||||
multiple
|
||||
placeholder="请选择要同步的用户"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="user in staffList"
|
||||
:key="user.userid"
|
||||
:label="user.name"
|
||||
:value="user.userid"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="syncSettings.sync_mode === 'time'" label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="syncSettings.time_range"
|
||||
type="datetimerange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始时间"
|
||||
end-placeholder="结束时间"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="同步数量限制">
|
||||
<el-input-number
|
||||
v-model="syncSettings.limit"
|
||||
:min="10"
|
||||
:max="1000"
|
||||
:step="10"
|
||||
placeholder="每次同步的最大数量"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showSyncSettings = false">取消</el-button>
|
||||
@@ -172,7 +240,7 @@
|
||||
>
|
||||
<el-descriptions v-if="currentCustomer" :column="2" border>
|
||||
<el-descriptions-item label="客户名称">{{ currentCustomer.name }}</el-descriptions-item>
|
||||
<el-descriptions-item label="External ID">{{ currentCustomer.external_userid }}</el-descriptions-item>
|
||||
<el-descriptions-item label="External ID">{{ currentCustomer.external_user_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="性别">
|
||||
{{ currentCustomer.gender === 1 ? '男' : currentCustomer.gender === 2 ? '女' : '未知' }}
|
||||
</el-descriptions-item>
|
||||
@@ -193,26 +261,130 @@
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 导入数据对话框 -->
|
||||
<el-dialog
|
||||
v-model="showImport"
|
||||
title="导入企业微信客户数据"
|
||||
width="500px"
|
||||
>
|
||||
<div class="mb-4">
|
||||
<el-upload
|
||||
class="upload-demo"
|
||||
:action="importUrl"
|
||||
:headers="uploadHeaders"
|
||||
:on-success="handleImportSuccess"
|
||||
:on-error="handleImportError"
|
||||
:auto-upload="false"
|
||||
name="file"
|
||||
ref="uploadRef"
|
||||
accept=".csv,.xlsx"
|
||||
:limit="1"
|
||||
with-credentials
|
||||
>
|
||||
<template #trigger>
|
||||
<el-button type="primary">选择文件</el-button>
|
||||
</template>
|
||||
<template #tip>
|
||||
<div class="el-upload__tip text-xs text-gray-400">
|
||||
请上传从企业微信导出的CSV或XLSX文件,支持的表头格式:
|
||||
<div class="mt-1">客户名称、描述、添加人、添加人账号、添加人所属部门、添加时间、来源、手机、企业、邮箱、地址、职务、电话、标签组1-11</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-upload>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showImport = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitImport">开始导入</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 批量获取客户对话框 -->
|
||||
<el-dialog
|
||||
v-model="showBatchGet"
|
||||
title="批量获取企业微信客户"
|
||||
width="500px"
|
||||
>
|
||||
<el-form :model="batchGetForm" label-width="120px">
|
||||
<el-form-item label="选择员工">
|
||||
<el-select
|
||||
v-model="batchGetForm.userid"
|
||||
placeholder="请选择要获取客户的员工"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="user in staffList"
|
||||
:key="user.userid"
|
||||
:label="user.name"
|
||||
:value="user.userid"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="获取数量">
|
||||
<el-input-number
|
||||
v-model="batchGetForm.limit"
|
||||
:min="10"
|
||||
:max="100"
|
||||
:step="10"
|
||||
placeholder="每次获取的最大数量"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div v-if="batchGetResult" class="mt-4 p-2 bg-gray-50 rounded">
|
||||
<div class="text-sm">
|
||||
<p>获取结果:</p>
|
||||
<p>客户数量:{{ batchGetResult.count }}</p>
|
||||
<p v-if="batchGetResult.next_cursor">下次游标:{{ batchGetResult.next_cursor }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<el-button @click="showBatchGet = false">取消</el-button>
|
||||
<el-button type="primary" :loading="batchGetting" @click="submitBatchGet">开始获取</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, onUnmounted } from 'vue'
|
||||
import { Refresh, Setting } from '@element-plus/icons-vue'
|
||||
import { Refresh, Setting, Upload, Download } from '@element-plus/icons-vue'
|
||||
import configs from '@/config'
|
||||
import { getToken } from '@/utils/auth'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import feedback from '@/utils/feedback'
|
||||
import {
|
||||
qywxCustomerLists,
|
||||
qywxCustomerSync,
|
||||
qywxCustomerStats,
|
||||
qywxCustomerCountByTime,
|
||||
qywxGetStaffList,
|
||||
qywxSyncSettingsGet,
|
||||
qywxSyncSettingsSave
|
||||
qywxSyncSettingsSave,
|
||||
qywxBatchGetCustomers
|
||||
} from '@/api/qywx'
|
||||
|
||||
const syncing = ref(false)
|
||||
const showSyncSettings = ref(false)
|
||||
const showDetail = ref(false)
|
||||
const showImport = ref(false)
|
||||
const showBatchGet = ref(false)
|
||||
const currentCustomer = ref<any>(null)
|
||||
const uploadRef = ref<any>(null)
|
||||
const fileList = ref<any[]>([])
|
||||
const importUrl = ref(`${configs.baseUrl}/adminapi/qywx.customer/import`)
|
||||
const uploadHeaders = ref({
|
||||
Authorization: getToken()
|
||||
})
|
||||
const staffList = ref<any[]>([])
|
||||
const countTime = ref<Date | null>(null)
|
||||
const timeCount = ref<number | null>(null)
|
||||
const batchGetting = ref(false)
|
||||
const batchGetForm = reactive({
|
||||
userid: '',
|
||||
limit: 50,
|
||||
cursor: ''
|
||||
})
|
||||
const batchGetResult = ref<any>(null)
|
||||
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
@@ -224,7 +396,11 @@ const stats = reactive({
|
||||
|
||||
const syncSettings = reactive({
|
||||
auto_sync: false,
|
||||
interval: 3600
|
||||
interval: 3600,
|
||||
sync_mode: 'all',
|
||||
selected_users: [],
|
||||
time_range: null,
|
||||
limit: 100
|
||||
})
|
||||
|
||||
const queryParams = reactive({
|
||||
@@ -251,18 +427,91 @@ function handleReset() {
|
||||
|
||||
async function syncCustomers() {
|
||||
syncing.value = true
|
||||
// 更新同步状态为同步中
|
||||
stats.syncStatus = 'running'
|
||||
stats.syncStatusText = '同步中'
|
||||
try {
|
||||
await qywxCustomerSync()
|
||||
const res = await qywxCustomerSync({
|
||||
limit: syncSettings.limit,
|
||||
full_sync: false,
|
||||
sync_mode: syncSettings.sync_mode,
|
||||
selected_users: syncSettings.selected_users,
|
||||
time_range: syncSettings.time_range
|
||||
})
|
||||
feedback.msgSuccess('同步成功')
|
||||
await loadStats()
|
||||
await getLists()
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || '同步失败')
|
||||
// 更新同步状态为失败
|
||||
stats.syncStatus = 'failed'
|
||||
stats.syncStatusText = '同步失败'
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStaffList() {
|
||||
try {
|
||||
const res: any = await qywxGetStaffList()
|
||||
console.log('员工列表数据:', res)
|
||||
staffList.value = Array.isArray(res) ? res : (res.data || [])
|
||||
console.log('处理后员工列表:', staffList.value)
|
||||
} catch (e) {
|
||||
console.error('加载员工列表失败', e)
|
||||
feedback.msgError('加载员工列表失败: ' + (e as any)?.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function exportCustomers() {
|
||||
try {
|
||||
window.location.href = '/adminapi/qywx.customer/export'
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || '导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
function importCustomers() {
|
||||
showImport.value = true
|
||||
fileList.value = []
|
||||
}
|
||||
|
||||
function submitImport() {
|
||||
if (!uploadRef.value) {
|
||||
feedback.msgError('上传组件未初始化')
|
||||
return
|
||||
}
|
||||
uploadRef.value.submit()
|
||||
}
|
||||
|
||||
function handleImportSuccess(response: any) {
|
||||
if (response.code === 1) {
|
||||
feedback.msgSuccess('导入成功')
|
||||
showImport.value = false
|
||||
loadStats()
|
||||
getLists()
|
||||
} else {
|
||||
feedback.msgError(response.msg || '导入失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportError(error: any) {
|
||||
feedback.msgError('导入失败: ' + (error?.message || '网络错误'))
|
||||
}
|
||||
|
||||
async function getCountByTime() {
|
||||
if (!countTime.value) {
|
||||
feedback.msgError('请选择时间')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res: any = await qywxCustomerCountByTime(countTime.value)
|
||||
timeCount.value = res.data?.count || 0
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || '统计失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const res: any = await qywxCustomerStats()
|
||||
@@ -277,6 +526,9 @@ async function loadSyncSettings() {
|
||||
const res: any = await qywxSyncSettingsGet()
|
||||
Object.assign(syncSettings, res.data || res)
|
||||
|
||||
// 加载员工列表
|
||||
await loadStaffList()
|
||||
|
||||
// 如果开启了自动同步,启动定时器
|
||||
if (syncSettings.auto_sync) {
|
||||
startSyncTimer()
|
||||
@@ -286,6 +538,13 @@ async function loadSyncSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// 打开同步设置对话框
|
||||
function openSyncSettings() {
|
||||
showSyncSettings.value = true
|
||||
// 确保员工列表已加载
|
||||
loadStaffList()
|
||||
}
|
||||
|
||||
async function saveSyncSettings() {
|
||||
try {
|
||||
await qywxSyncSettingsSave(syncSettings)
|
||||
@@ -322,6 +581,42 @@ function viewDetail(row: any) {
|
||||
showDetail.value = true
|
||||
}
|
||||
|
||||
function batchGetCustomers() {
|
||||
showBatchGet.value = true
|
||||
batchGetResult.value = null
|
||||
// 确保员工列表已加载
|
||||
loadStaffList()
|
||||
}
|
||||
|
||||
async function submitBatchGet() {
|
||||
if (!batchGetForm.userid) {
|
||||
feedback.msgError('请选择员工')
|
||||
return
|
||||
}
|
||||
|
||||
batchGetting.value = true
|
||||
try {
|
||||
const res: any = await qywxBatchGetCustomers({
|
||||
userid: batchGetForm.userid,
|
||||
limit: batchGetForm.limit,
|
||||
cursor: batchGetForm.cursor
|
||||
})
|
||||
|
||||
if (res.code === 1) {
|
||||
batchGetResult.value = res.data
|
||||
feedback.msgSuccess('获取成功')
|
||||
// 保存下次游标
|
||||
batchGetForm.cursor = res.data.next_cursor || ''
|
||||
} else {
|
||||
feedback.msgError(res.msg || '获取失败')
|
||||
}
|
||||
} catch (e: any) {
|
||||
feedback.msgError(e?.message || '获取失败')
|
||||
} finally {
|
||||
batchGetting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number | string) {
|
||||
if (!timestamp) return '—'
|
||||
const date = new Date(typeof timestamp === 'number' ? timestamp * 1000 : timestamp)
|
||||
|
||||
@@ -0,0 +1,901 @@
|
||||
<template>
|
||||
<div class="qywx-container">
|
||||
<div class="page-header">
|
||||
<h1>企业微信管理</h1>
|
||||
<el-button type="primary" @click="refreshData">
|
||||
<el-icon><Refresh /></el-icon> 刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-cards">
|
||||
<el-card class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-value">{{ stats.totalAddCount }}</div>
|
||||
<div class="stats-label">总加粉数</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-value">{{ stats.todayAddCount }}</div>
|
||||
<div class="stats-label">今日加粉</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-value">{{ stats.weekAddCount }}</div>
|
||||
<div class="stats-label">本周加粉</div>
|
||||
</div>
|
||||
</el-card>
|
||||
<el-card class="stats-card">
|
||||
<div class="stats-item">
|
||||
<div class="stats-value">{{ stats.monthAddCount }}</div>
|
||||
<div class="stats-label">本月加粉</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 筛选条件 -->
|
||||
<div class="filter-section">
|
||||
<el-form :inline="true" :model="filterForm" class="filter-form">
|
||||
<el-form-item label="时间范围">
|
||||
<el-date-picker
|
||||
v-model="filterForm.dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="员工">
|
||||
<el-select v-model="filterForm.staffId" placeholder="选择员工">
|
||||
<el-option label="全部员工" value="0" />
|
||||
<el-option
|
||||
v-for="staff in staffList"
|
||||
:key="staff.id"
|
||||
:label="staff.name"
|
||||
:value="staff.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="getAddStats">查询</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
|
||||
<!-- 统计图表 -->
|
||||
<div class="chart-section">
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>加粉趋势</span>
|
||||
</div>
|
||||
</template>
|
||||
<div id="trendChart" ref="trendChart" class="chart"></div>
|
||||
</el-card>
|
||||
<el-card class="chart-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>员工加粉排行</span>
|
||||
</div>
|
||||
</template>
|
||||
<div id="rankChart" ref="rankChart" class="chart"></div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<!-- 二维码管理 -->
|
||||
<div class="qrcode-section">
|
||||
<div class="section-header">
|
||||
<h2>员工二维码</h2>
|
||||
<div class="header-actions">
|
||||
<el-button
|
||||
type="danger"
|
||||
:disabled="selectedQrcodes.length === 0"
|
||||
@click="handleDeleteQrcodes"
|
||||
>
|
||||
<el-icon><Delete /></el-icon> 删除
|
||||
</el-button>
|
||||
<el-button type="primary" @click="showCreateQrcodeDialog">
|
||||
<el-icon><Plus /></el-icon> 生成二维码
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片式二维码列表 -->
|
||||
<div class="qrcode-grid" v-if="qrcodeList.length > 0">
|
||||
<div class="qrcode-card" v-for="item in qrcodeList" :key="item.id">
|
||||
<div class="qrcode-card-header">
|
||||
<div class="staff-info">
|
||||
<el-checkbox v-model="selectedQrcodes" :label="item.id" />
|
||||
<div class="staff-avatar">{{ item.staff_name?.charAt(0) || '?' }}</div>
|
||||
<div class="staff-details">
|
||||
<div class="staff-name">{{ item.staff_name }}</div>
|
||||
<div class="staff-id">ID: {{ item.staff_id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qrcode-card-body">
|
||||
<div class="qrcode-wrapper">
|
||||
<el-image
|
||||
:src="item.qrcode_url"
|
||||
:preview-src-list="[item.qrcode_url]"
|
||||
fit="contain"
|
||||
class="qrcode-image"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qrcode-card-footer">
|
||||
<div class="expire-info">
|
||||
<span class="expire-label">过期时间</span>
|
||||
<span class="expire-time">{{ item.expire_time }}</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button size="small" @click="downloadQrcode(item)">
|
||||
<el-icon><Download /></el-icon> 下载
|
||||
</el-button>
|
||||
<el-button size="small" type="primary" @click="refreshQrcode(item)">
|
||||
<el-icon><Refresh /></el-icon> 刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div class="empty-state" v-else>
|
||||
<el-empty description="暂无二维码数据">
|
||||
<el-button type="primary" @click="showCreateQrcodeDialog">
|
||||
立即生成
|
||||
</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="pagination-wrapper" v-if="qrcodePage.total > 0">
|
||||
<el-pagination
|
||||
v-model:current-page="qrcodePage.current"
|
||||
v-model:page-size="qrcodePage.size"
|
||||
:page-sizes="[8, 16, 24, 48]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="qrcodePage.total"
|
||||
@size-change="handleQrcodeSizeChange"
|
||||
@current-change="handleQrcodeCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成二维码对话框 -->
|
||||
<el-dialog
|
||||
v-model="createQrcodeDialogVisible"
|
||||
title="生成员工二维码"
|
||||
width="500px"
|
||||
>
|
||||
<el-form :model="qrcodeForm" label-width="100px">
|
||||
<el-form-item label="员工" required>
|
||||
<el-select v-model="qrcodeForm.staff_id" placeholder="选择员工" :loading="loadingStaff">
|
||||
<el-option
|
||||
v-for="staff in staffList"
|
||||
:key="staff.id"
|
||||
:label="staff.name"
|
||||
:value="staff.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="过期天数">
|
||||
<el-input-number v-model="qrcodeForm.expire_days" :min="1" :max="365" :step="1" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="createQrcodeDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="createQrcode">确定</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick } from 'vue'
|
||||
import { ElMessage, ElLoading, ElMessageBox } from 'element-plus'
|
||||
import * as echarts from 'echarts'
|
||||
import QRCode from 'qrcode'
|
||||
import { Refresh, Plus, Download, Delete } from '@element-plus/icons-vue'
|
||||
import { qywxApi } from '@/api/qywx/index.js'
|
||||
|
||||
// 统计数据
|
||||
const stats = ref({
|
||||
totalAddCount: 0,
|
||||
todayAddCount: 0,
|
||||
weekAddCount: 0,
|
||||
monthAddCount: 0
|
||||
})
|
||||
|
||||
// 筛选表单
|
||||
const filterForm = ref({
|
||||
dateRange: [new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), new Date()],
|
||||
staffId: 0
|
||||
})
|
||||
|
||||
// 员工列表
|
||||
const staffList = ref([])
|
||||
const loadingStaff = ref(false)
|
||||
|
||||
// 获取企业微信员工列表
|
||||
const getStaffList = async () => {
|
||||
try {
|
||||
loadingStaff.value = true
|
||||
const data = await qywxApi.getStaffList()
|
||||
staffList.value = data.list
|
||||
} catch (error) {
|
||||
ElMessage.error('获取员工列表失败')
|
||||
console.error('获取员工列表失败:', error)
|
||||
} finally {
|
||||
loadingStaff.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 二维码列表
|
||||
const qrcodeList = ref([])
|
||||
const qrcodePage = ref({
|
||||
current: 1,
|
||||
size: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
// 选中的二维码
|
||||
const selectedQrcodes = ref([])
|
||||
|
||||
// 对话框
|
||||
const createQrcodeDialogVisible = ref(false)
|
||||
const qrcodeForm = ref({
|
||||
staff_id: 0,
|
||||
staff_name: '',
|
||||
expire_days: 30
|
||||
})
|
||||
|
||||
// 图表实例
|
||||
const trendChart = ref(null)
|
||||
const rankChart = ref(null)
|
||||
let trendChartInstance = null
|
||||
let rankChartInstance = null
|
||||
|
||||
// 初始化
|
||||
onMounted(() => {
|
||||
getStaffList()
|
||||
getAddStats()
|
||||
getQrcodeList()
|
||||
initCharts()
|
||||
})
|
||||
|
||||
// 刷新数据
|
||||
const refreshData = () => {
|
||||
getAddStats()
|
||||
getQrcodeList()
|
||||
}
|
||||
|
||||
// 处理日期变化
|
||||
const handleDateChange = () => {
|
||||
getAddStats()
|
||||
}
|
||||
|
||||
// 获取加粉统计
|
||||
const getAddStats = async () => {
|
||||
try {
|
||||
const loading = ElLoading.service({ lock: true, text: '加载中...' })
|
||||
|
||||
const startDate = filterForm.value.dateRange[0].toISOString().split('T')[0]
|
||||
const endDate = filterForm.value.dateRange[1].toISOString().split('T')[0]
|
||||
|
||||
const data = await qywxApi.getAddStats({
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
staff_id: filterForm.value.staffId
|
||||
})
|
||||
|
||||
stats.value.totalAddCount = data.total_add_count
|
||||
|
||||
// 计算今日、本周、本月加粉数
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const weekStart = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
const monthStart = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]
|
||||
|
||||
stats.value.todayAddCount = data.date_stats.filter(item => item.date === today).reduce((sum, item) => sum + item.add_count, 0)
|
||||
stats.value.weekAddCount = data.date_stats.filter(item => item.date >= weekStart).reduce((sum, item) => sum + item.add_count, 0)
|
||||
stats.value.monthAddCount = data.date_stats.filter(item => item.date >= monthStart).reduce((sum, item) => sum + item.add_count, 0)
|
||||
|
||||
// 更新图表
|
||||
updateCharts(data)
|
||||
|
||||
loading.close()
|
||||
} catch (error) {
|
||||
ElMessage.error('获取统计数据失败')
|
||||
console.error('获取统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 生成二维码图片base64
|
||||
const generateQrcodeImage = async (scene) => {
|
||||
try {
|
||||
// 使用公网地址作为扫码回调URL
|
||||
const callbackUrl = `${import.meta.env.VITE_APP_BASE_URL}/qywx/scene/${scene}`
|
||||
const qrcodeUrl = await QRCode.toDataURL(callbackUrl, {
|
||||
width: 200,
|
||||
margin: 2,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff'
|
||||
}
|
||||
})
|
||||
return qrcodeUrl
|
||||
} catch (error) {
|
||||
console.error('生成二维码失败:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 获取二维码列表
|
||||
const getQrcodeList = async () => {
|
||||
try {
|
||||
const data = await qywxApi.getQrcodeList({
|
||||
page: qrcodePage.value.current,
|
||||
limit: qrcodePage.value.size
|
||||
})
|
||||
|
||||
qrcodeList.value = await Promise.all(
|
||||
data.list.map(async (item) => {
|
||||
if (!item.qrcode_url) {
|
||||
item.qrcode_url = await generateQrcodeImage(item.scene)
|
||||
}
|
||||
return item
|
||||
})
|
||||
)
|
||||
qrcodePage.value.total = data.total
|
||||
} catch (error) {
|
||||
ElMessage.error('获取二维码列表失败')
|
||||
console.error('获取二维码列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理页码变化
|
||||
const handleQrcodeSizeChange = (size) => {
|
||||
qrcodePage.value.size = size
|
||||
getQrcodeList()
|
||||
}
|
||||
|
||||
const handleQrcodeCurrentChange = (current) => {
|
||||
qrcodePage.value.current = current
|
||||
getQrcodeList()
|
||||
}
|
||||
|
||||
// 显示生成二维码对话框
|
||||
const showCreateQrcodeDialog = () => {
|
||||
qrcodeForm.value = {
|
||||
staff_id: 0,
|
||||
staff_name: '',
|
||||
expire_days: 30
|
||||
}
|
||||
createQrcodeDialogVisible.value = true
|
||||
}
|
||||
|
||||
// 生成二维码
|
||||
const createQrcode = async () => {
|
||||
try {
|
||||
// 根据选择的员工ID获取员工姓名
|
||||
const selectedStaff = staffList.value.find(staff => staff.id === qrcodeForm.value.staff_id)
|
||||
if (!selectedStaff) {
|
||||
ElMessage.error('请选择员工')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = {
|
||||
...qrcodeForm.value,
|
||||
staff_name: selectedStaff.name
|
||||
}
|
||||
|
||||
const res = await qywxApi.createQrcode(formData)
|
||||
|
||||
// 生成二维码图片
|
||||
const qrcodeUrl = await generateQrcodeImage(res.scene)
|
||||
|
||||
// 更新列表中的二维码
|
||||
const existIndex = qrcodeList.value.findIndex(item => item.scene === res.scene)
|
||||
if (existIndex >= 0) {
|
||||
qrcodeList.value[existIndex].qrcode_url = qrcodeUrl
|
||||
}
|
||||
|
||||
ElMessage.success('生成二维码成功')
|
||||
createQrcodeDialogVisible.value = false
|
||||
getQrcodeList()
|
||||
} catch (error) {
|
||||
ElMessage.error('生成二维码失败')
|
||||
console.error('生成二维码失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 下载二维码
|
||||
const downloadQrcode = async (row) => {
|
||||
try {
|
||||
// 如果没有二维码URL,先生成
|
||||
let qrcodeUrl = row.qrcode_url
|
||||
if (!qrcodeUrl) {
|
||||
qrcodeUrl = await generateQrcodeImage(row.scene)
|
||||
}
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = qrcodeUrl
|
||||
link.download = `${row.staff_name}_二维码.png`
|
||||
link.click()
|
||||
} catch (error) {
|
||||
ElMessage.error('下载二维码失败')
|
||||
console.error('下载二维码失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新二维码
|
||||
const refreshQrcode = async (row) => {
|
||||
try {
|
||||
// 确保staff_name存在
|
||||
let staffName = row.staff_name
|
||||
if (!staffName) {
|
||||
// 如果没有staff_name,尝试从员工列表中查找
|
||||
const staff = staffList.value.find(s => s.id === row.staff_id)
|
||||
if (staff) {
|
||||
staffName = staff.name
|
||||
} else {
|
||||
ElMessage.error('找不到员工信息')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
await qywxApi.createQrcode({
|
||||
staff_id: row.staff_id,
|
||||
staff_name: staffName,
|
||||
expire_days: 30
|
||||
})
|
||||
|
||||
// 重新生成二维码图片
|
||||
const qrcodeUrl = await generateQrcodeImage(row.scene)
|
||||
const existIndex = qrcodeList.value.findIndex(item => item.scene === row.scene)
|
||||
if (existIndex >= 0) {
|
||||
qrcodeList.value[existIndex].qrcode_url = qrcodeUrl
|
||||
}
|
||||
|
||||
ElMessage.success('刷新二维码成功')
|
||||
} catch (error) {
|
||||
ElMessage.error('刷新二维码失败')
|
||||
console.error('刷新二维码失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 删除二维码
|
||||
const handleDeleteQrcodes = async () => {
|
||||
try {
|
||||
if (selectedQrcodes.value.length === 0) {
|
||||
ElMessage.warning('请选择要删除的二维码')
|
||||
return
|
||||
}
|
||||
|
||||
await ElMessageBox.confirm(
|
||||
`确定要删除选中的 ${selectedQrcodes.value.length} 个二维码吗?`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
)
|
||||
|
||||
const data = await qywxApi.deleteQrcode({
|
||||
ids: selectedQrcodes.value
|
||||
})
|
||||
|
||||
ElMessage.success('删除成功')
|
||||
selectedQrcodes.value = []
|
||||
getQrcodeList()
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
ElMessage.error('删除失败')
|
||||
console.error('删除二维码失败:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化图表
|
||||
const initCharts = () => {
|
||||
nextTick(() => {
|
||||
if (trendChart.value) {
|
||||
trendChartInstance = echarts.init(trendChart.value)
|
||||
}
|
||||
if (rankChart.value) {
|
||||
rankChartInstance = echarts.init(rankChart.value)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 更新图表
|
||||
const updateCharts = (data) => {
|
||||
nextTick(() => {
|
||||
// 更新趋势图
|
||||
if (trendChartInstance) {
|
||||
const dates = data.date_stats.map(item => item.date)
|
||||
const counts = data.date_stats.map(item => item.add_count)
|
||||
|
||||
trendChartInstance.setOption({
|
||||
title: {
|
||||
text: '加粉趋势',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis'
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: dates
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [{
|
||||
data: counts,
|
||||
type: 'line',
|
||||
smooth: true,
|
||||
itemStyle: {
|
||||
color: '#10b981'
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
// 更新排行榜
|
||||
if (rankChartInstance) {
|
||||
const staffNames = data.staff_stats.map(item => item.staff_name)
|
||||
const addCounts = data.staff_stats.map(item => item.add_count)
|
||||
|
||||
rankChartInstance.setOption({
|
||||
title: {
|
||||
text: '员工加粉排行',
|
||||
left: 'center'
|
||||
},
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
axisPointer: {
|
||||
type: 'shadow'
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: staffNames
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value'
|
||||
},
|
||||
series: [{
|
||||
data: addCounts,
|
||||
type: 'bar',
|
||||
itemStyle: {
|
||||
color: '#3b82f6'
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 响应式处理
|
||||
window.addEventListener('resize', () => {
|
||||
if (trendChartInstance) {
|
||||
trendChartInstance.resize()
|
||||
}
|
||||
if (rankChartInstance) {
|
||||
rankChartInstance.resize()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.qywx-container {
|
||||
padding: 24px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 20px 24px;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 26px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
margin: 0;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.stats-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.stats-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: linear-gradient(90deg, #667eea, #764ba2);
|
||||
}
|
||||
|
||||
.stats-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 30px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.stats-item {
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.stats-value {
|
||||
font-size: 40px;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.stats-label {
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.chart-card {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
/* 二维码管理区域 */
|
||||
.qrcode-section {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 2px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 卡片网格布局 */
|
||||
.qrcode-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.qrcode-card {
|
||||
background: linear-gradient(145deg, #ffffff 0%, #f9fafb 100%);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s ease;
|
||||
overflow: hidden;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.qrcode-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 30px rgba(102, 126, 234, 0.2);
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.qrcode-card-header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.staff-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.staff-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.staff-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.staff-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.staff-id {
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.qrcode-card-body {
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.qrcode-wrapper {
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
padding: 12px;
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.qrcode-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.qrcode-card-footer {
|
||||
padding: 16px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
.expire-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.expire-label {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.expire-time {
|
||||
color: #1a1a2e;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.actions .el-button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
.pagination-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 1200px) {
|
||||
.stats-cards {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.chart-section {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stats-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.qrcode-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user