企业微信外部联系人同步

This commit is contained in:
Guoxianpeng
2026-04-22 12:19:24 +08:00
parent 906684c1ed
commit 6e37c6ec05
25 changed files with 3362 additions and 325 deletions
+17 -2
View File
@@ -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 })
}
+43
View File
@@ -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
})
}
}
+28
View File
@@ -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',
+303 -8
View File
@@ -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)
+901
View File
@@ -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>