Compare commits
1
Commits
master6-12
...
qywx1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e37c6ec05 |
+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>
|
||||
@@ -27,6 +27,7 @@ use app\common\controller\BaseLikeAdminController;
|
||||
class BaseAdminController extends BaseLikeAdminController
|
||||
{
|
||||
protected int $adminId = 0;
|
||||
protected string $adminName = '';
|
||||
protected array $adminInfo = [];
|
||||
|
||||
public function initialize()
|
||||
@@ -34,6 +35,7 @@ class BaseAdminController extends BaseLikeAdminController
|
||||
if (isset($this->request->adminInfo) && $this->request->adminInfo) {
|
||||
$this->adminInfo = $this->request->adminInfo;
|
||||
$this->adminId = $this->request->adminInfo['admin_id'];
|
||||
$this->adminName = $this->request->adminInfo['name'] ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,13 +7,54 @@ namespace app\adminapi\controller\qywx;
|
||||
use app\adminapi\controller\BaseAdminController;
|
||||
use app\adminapi\lists\qywx\CustomerLists;
|
||||
use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use app\adminapi\validate\qywx\CustomerValidate;
|
||||
use think\facade\Cache;
|
||||
use think\facade\Log;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
|
||||
/**
|
||||
* 企业微信客户管理控制器
|
||||
* 企业微信客户管理控制器(精简版)
|
||||
*/
|
||||
class CustomerController extends BaseAdminController
|
||||
{
|
||||
// 免登录验证
|
||||
public array $notNeedLogin = ['lists', 'stats', 'getStaffList', 'import', 'batchGetCustomers', 'getSyncSettings', 'saveSyncSettings', 'sync', 'asyncSync', 'getSyncStatus', 'getSyncTaskStatus'];
|
||||
|
||||
/**
|
||||
* @notes 批量获取企业微信客户(支持分页)
|
||||
*/
|
||||
public function batchGetCustomers()
|
||||
{
|
||||
try {
|
||||
$userId = input('userid', '');
|
||||
$cursor = input('cursor', '');
|
||||
$limit = (int)input('limit', 50);
|
||||
|
||||
if (empty($userId)) {
|
||||
return $this->fail('缺少用户ID参数');
|
||||
}
|
||||
|
||||
$service = new \app\common\service\wechat\WechatWorkService();
|
||||
$result = $service->batchGetCustomerDetails($userId, $cursor, $limit);
|
||||
|
||||
if ($result === false) {
|
||||
return $this->fail('获取客户列表失败,可能是权限不足');
|
||||
}
|
||||
|
||||
// 构建响应数据
|
||||
$response = [
|
||||
'customers' => $result['customers'] ?? [],
|
||||
'next_cursor' => $result['next_cursor'] ?? '',
|
||||
'count' => $result['count'] ?? 0,
|
||||
'limit' => $limit
|
||||
];
|
||||
|
||||
return $this->success('获取成功', $response);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail('获取失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 客户列表
|
||||
*/
|
||||
@@ -23,15 +64,44 @@ class CustomerController extends BaseAdminController
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 同步企业微信客户
|
||||
* @notes 获取企业微信token
|
||||
*/
|
||||
public function sync()
|
||||
public function getAccessToken()
|
||||
{
|
||||
$result = CustomerLogic::syncCustomers();
|
||||
if ($result === false) {
|
||||
$token = CustomerLogic::getAccessToken();
|
||||
if ($token === false) {
|
||||
return $this->fail(CustomerLogic::getError());
|
||||
}
|
||||
return $this->success('同步成功', $result);
|
||||
return $this->success('获取成功', ['access_token' => $token]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取外部联系人列表
|
||||
*/
|
||||
public function getExternalContacts()
|
||||
{
|
||||
$userId = input('user_id', '');
|
||||
$contacts = CustomerLogic::getExternalContacts($userId);
|
||||
if ($contacts === false) {
|
||||
return $this->fail(CustomerLogic::getError());
|
||||
}
|
||||
return $this->success('获取成功', $contacts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取客户详情
|
||||
*/
|
||||
public function getContactDetail()
|
||||
{
|
||||
$externalUserId = input('external_user_id', '');
|
||||
if (empty($externalUserId)) {
|
||||
return $this->fail('缺少外部联系人ID');
|
||||
}
|
||||
$detail = CustomerLogic::getContactDetail($externalUserId);
|
||||
if ($detail === false) {
|
||||
return $this->fail(CustomerLogic::getError());
|
||||
}
|
||||
return $this->success('获取成功', $detail);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -43,13 +113,67 @@ class CustomerController extends BaseAdminController
|
||||
return $this->data($stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 按时间点统计用户数量
|
||||
*/
|
||||
public function countByTime()
|
||||
{
|
||||
try {
|
||||
$time = input('time', time());
|
||||
$timestamp = is_numeric($time) ? (int)$time : strtotime($time);
|
||||
|
||||
if (!$timestamp) {
|
||||
return $this->fail('时间格式错误');
|
||||
}
|
||||
|
||||
$count = \app\common\model\QywxExternalContact::where('created_at', '<=', $timestamp)->count();
|
||||
|
||||
return $this->success('获取成功', [
|
||||
'count' => $count,
|
||||
'time' => date('Y-m-d H:i:s', $timestamp)
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail('获取失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取企业微信员工列表
|
||||
*/
|
||||
public function getStaffList()
|
||||
{
|
||||
try {
|
||||
$service = new \app\common\service\wechat\WechatWorkService();
|
||||
$userList = $service->getDepartmentUserList();
|
||||
|
||||
// 确保返回的数据是正确的UTF-8编码
|
||||
$userList = array_map(function($user) {
|
||||
return $this->recursiveConvert($user);
|
||||
}, $userList);
|
||||
|
||||
return $this->success('获取成功', $userList);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('getStaffList error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
|
||||
error_log('Stack trace: ' . $e->getTraceAsString());
|
||||
return $this->fail('获取失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取同步设置
|
||||
*/
|
||||
public function getSyncSettings()
|
||||
{
|
||||
$settings = CustomerLogic::getSyncSettings();
|
||||
return $this->data($settings);
|
||||
// 从缓存获取同步设置
|
||||
$cacheKey = 'qywx_sync_settings';
|
||||
$settings = Cache::get($cacheKey, [
|
||||
'auto_sync' => false,
|
||||
'interval' => 3600,
|
||||
'limit' => 30,
|
||||
'days' => 7
|
||||
]);
|
||||
|
||||
return $this->success('获取成功', $settings);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,11 +181,736 @@ class CustomerController extends BaseAdminController
|
||||
*/
|
||||
public function saveSyncSettings()
|
||||
{
|
||||
$params = (new CustomerValidate())->post()->goCheck('syncSettings');
|
||||
$result = CustomerLogic::saveSyncSettings($params);
|
||||
if ($result === false) {
|
||||
return $this->fail(CustomerLogic::getError());
|
||||
$settings = input('post.');
|
||||
|
||||
// 验证并设置默认值
|
||||
$validatedSettings = [
|
||||
'auto_sync' => (bool)($settings['auto_sync'] ?? false),
|
||||
'interval' => max(60, (int)($settings['interval'] ?? 3600)), // 最小1分钟
|
||||
'limit' => min(100, max(10, (int)($settings['limit'] ?? 30))), // 10-100之间
|
||||
'days' => min(30, max(1, (int)($settings['days'] ?? 7))) // 1-30天之间
|
||||
];
|
||||
|
||||
// 保存到缓存
|
||||
$cacheKey = 'qywx_sync_settings';
|
||||
Cache::set($cacheKey, $validatedSettings, 86400 * 30); // 缓存30天
|
||||
|
||||
return $this->success('保存成功', $validatedSettings);
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 同步企业微信客户
|
||||
*/
|
||||
public function sync()
|
||||
{
|
||||
try {
|
||||
$limit = (int)input('limit', 30);
|
||||
$fullSync = (bool)input('full_sync', false);
|
||||
$syncMode = input('sync_mode', 'all');
|
||||
$selectedUsers = input('selected_users', []);
|
||||
$days = (int)input('days', 7); // 最近几天的数据
|
||||
|
||||
// 设置执行超时时间
|
||||
set_time_limit(120);
|
||||
|
||||
Log::info('开始同步企业微信客户', [
|
||||
'limit' => $limit,
|
||||
'full_sync' => $fullSync,
|
||||
'sync_mode' => $syncMode,
|
||||
'selected_users' => $selectedUsers,
|
||||
'days' => $days
|
||||
]);
|
||||
|
||||
$service = new \app\common\service\wechat\WechatWorkService();
|
||||
|
||||
// 1. 获取员工列表
|
||||
$userList = $service->getDepartmentUserList();
|
||||
if (empty($userList)) {
|
||||
Log::warning('未获取到员工列表');
|
||||
return $this->fail('未获取到员工列表');
|
||||
}
|
||||
|
||||
// 按同步模式过滤员工
|
||||
if ($syncMode === 'specific' && !empty($selectedUsers)) {
|
||||
$userList = array_filter($userList, function($user) use ($selectedUsers) {
|
||||
return in_array($user['userid'] ?? '', $selectedUsers);
|
||||
});
|
||||
}
|
||||
|
||||
$syncCount = 0;
|
||||
$newCount = 0;
|
||||
$updateCount = 0;
|
||||
$processedCustomers = [];
|
||||
|
||||
// 获取上次同步时间(使用缓存存储,避免session丢失)
|
||||
$cacheKey = 'qywx_last_sync_time';
|
||||
$lastSyncTime = Cache::get($cacheKey, 0);
|
||||
$currentTime = time();
|
||||
|
||||
// 计算时间范围(最近N天)
|
||||
$timeLimit = $currentTime - ($days * 86400);
|
||||
|
||||
// 2. 遍历每个员工,获取其客户列表
|
||||
foreach ($userList as $user) {
|
||||
$userId = $user['userid'] ?? '';
|
||||
if (empty($userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Log::info('开始同步员工客户', ['user_id' => $userId, 'user_name' => $user['name'] ?? '']);
|
||||
|
||||
// 获取该成员的客户列表(支持分页)
|
||||
$cursor = '';
|
||||
$hasMore = true;
|
||||
|
||||
while ($hasMore && $syncCount < $limit) {
|
||||
$customerResult = $service->getExternalContactList($userId, $cursor, 100);
|
||||
if (empty($customerResult) || empty($customerResult['external_userid'])) {
|
||||
Log::info('员工无客户数据', ['user_id' => $userId]);
|
||||
break;
|
||||
}
|
||||
|
||||
$customerList = $customerResult['external_userid'] ?? [];
|
||||
$cursor = $customerResult['next_cursor'] ?? '';
|
||||
$hasMore = !empty($cursor);
|
||||
|
||||
Log::info('获取到员工客户列表', [
|
||||
'user_id' => $userId,
|
||||
'customer_count' => count($customerList),
|
||||
'has_more' => $hasMore
|
||||
]);
|
||||
|
||||
// 3. 处理客户,限制总数量
|
||||
foreach ($customerList as $externalUserId) {
|
||||
// 达到限制数量,停止同步
|
||||
if ($syncCount >= $limit) {
|
||||
$hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// 避免重复处理同一个客户
|
||||
if (isset($processedCustomers[$externalUserId])) {
|
||||
continue;
|
||||
}
|
||||
$processedCustomers[$externalUserId] = true;
|
||||
|
||||
// 增量同步:检查客户是否已存在且更新时间较新
|
||||
if (!$fullSync) {
|
||||
$existing = \app\common\model\QywxExternalContact::where('external_user_id', $externalUserId)->find();
|
||||
if ($existing && $existing->update_time && strtotime($existing->update_time) > time() - 86400) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取客户详情
|
||||
$detail = $service->getExternalContactDetail($externalUserId);
|
||||
if (empty($detail)) {
|
||||
Log::warning('获取客户详情失败', ['external_user_id' => $externalUserId]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$externalContact = $detail['external_contact'] ?? [];
|
||||
$followUsers = $detail['follow_user'] ?? [];
|
||||
|
||||
// 获取客户实际添加时间
|
||||
$createTime = time();
|
||||
if (!empty($followUsers)) {
|
||||
// 取第一个跟进人的添加时间
|
||||
$firstFollow = reset($followUsers);
|
||||
if (isset($firstFollow['create_time'])) {
|
||||
$createTime = $firstFollow['create_time'];
|
||||
}
|
||||
}
|
||||
|
||||
// 增量同步:只同步上次同步时间之后的客户
|
||||
if (!$fullSync && $createTime <= $lastSyncTime) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 时间范围过滤:只同步最近N天的客户
|
||||
if ($createTime < $timeLimit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 保存或更新客户信息
|
||||
$data = [
|
||||
'external_user_id' => $externalContact['external_userid'] ?? '',
|
||||
'name' => $externalContact['name'] ?? '',
|
||||
'avatar' => $externalContact['avatar'] ?? '',
|
||||
'type' => $externalContact['type'] ?? 1,
|
||||
'gender' => $externalContact['gender'] ?? 0,
|
||||
'unionid' => $externalContact['unionid'] ?? '',
|
||||
'position' => $externalContact['position'] ?? '',
|
||||
'corp_name' => $externalContact['corp_name'] ?? '',
|
||||
'corp_full_name' => $externalContact['corp_full_name'] ?? '',
|
||||
'external_profile' => json_encode($externalContact['external_profile'] ?? [], JSON_UNESCAPED_UNICODE),
|
||||
'follow_users' => json_encode($followUsers, JSON_UNESCAPED_UNICODE),
|
||||
];
|
||||
|
||||
// 处理添加人信息
|
||||
if (!empty($followUsers)) {
|
||||
// 取第一个跟进人作为添加人
|
||||
$firstFollow = reset($followUsers);
|
||||
if (isset($firstFollow['userid']) && isset($firstFollow['name'])) {
|
||||
$data['first_staff_id'] = $firstFollow['userid'];
|
||||
$data['first_staff_name'] = $firstFollow['name'];
|
||||
$data['first_add_time'] = date('Y-m-d H:i:s', $createTime);
|
||||
$data['last_staff_id'] = $firstFollow['userid'];
|
||||
$data['last_staff_name'] = $firstFollow['name'];
|
||||
$data['last_add_time'] = date('Y-m-d H:i:s', $createTime);
|
||||
}
|
||||
}
|
||||
|
||||
$existing = \app\common\model\QywxExternalContact::where('external_user_id', $data['external_user_id'])->find();
|
||||
if ($existing) {
|
||||
$existing->save($data);
|
||||
$updateCount++;
|
||||
Log::info('更新客户信息', ['external_user_id' => $data['external_user_id'], 'name' => $data['name']]);
|
||||
} else {
|
||||
\app\common\model\QywxExternalContact::create($data);
|
||||
$newCount++;
|
||||
Log::info('新增客户信息', ['external_user_id' => $data['external_user_id'], 'name' => $data['name']]);
|
||||
}
|
||||
|
||||
$syncCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 达到限制数量,停止同步
|
||||
if ($syncCount >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存本次同步时间(使用缓存存储)
|
||||
Cache::set($cacheKey, $currentTime, 86400 * 30); // 缓存30天
|
||||
|
||||
// 更新同步状态
|
||||
$syncStatus = [
|
||||
'last_sync_time' => date('Y-m-d H:i:s', $currentTime),
|
||||
'sync_count' => $syncCount,
|
||||
'new_count' => $newCount,
|
||||
'update_count' => $updateCount,
|
||||
'status' => 'completed'
|
||||
];
|
||||
Cache::set('qywx_sync_status', $syncStatus, 86400 * 30);
|
||||
|
||||
Log::info('同步企业微信客户完成', [
|
||||
'sync_count' => $syncCount,
|
||||
'new_count' => $newCount,
|
||||
'update_count' => $updateCount,
|
||||
'last_sync_time' => date('Y-m-d H:i:s', $currentTime)
|
||||
]);
|
||||
|
||||
return $this->success('同步成功', [
|
||||
'sync_count' => $syncCount,
|
||||
'new_count' => $newCount,
|
||||
'update_count' => $updateCount,
|
||||
'last_sync_time' => date('Y-m-d H:i:s', $currentTime),
|
||||
'time_limit' => date('Y-m-d H:i:s', $timeLimit)
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('同步企业微信客户失败', [
|
||||
'message' => $e->getMessage(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine()
|
||||
]);
|
||||
return $this->fail('同步失败: ' . $e->getMessage());
|
||||
}
|
||||
return $this->success('保存成功');
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取同步状态
|
||||
*/
|
||||
public function getSyncStatus()
|
||||
{
|
||||
try {
|
||||
// 从缓存获取同步状态
|
||||
$syncStatus = Cache::get('qywx_sync_status', [
|
||||
'last_sync_time' => '',
|
||||
'sync_count' => 0,
|
||||
'new_count' => 0,
|
||||
'update_count' => 0,
|
||||
'status' => 'idle'
|
||||
]);
|
||||
|
||||
return $this->success('获取成功', $syncStatus);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail('获取失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 导出客户数据
|
||||
*/
|
||||
public function export()
|
||||
{
|
||||
try {
|
||||
$customers = \app\common\model\QywxExternalContact::select();
|
||||
|
||||
// 准备导出数据
|
||||
$exportData = [];
|
||||
foreach ($customers as $customer) {
|
||||
$followUsers = json_decode($customer->follow_users, true) ?? [];
|
||||
$followUserNames = array_column($followUsers, 'name');
|
||||
|
||||
$exportData[] = [
|
||||
'客户名称' => $customer->name,
|
||||
'External ID' => $customer->external_user_id,
|
||||
'性别' => $customer->gender == 1 ? '男' : ($customer->gender == 2 ? '女' : '未知'),
|
||||
'类型' => $customer->type == 1 ? '微信' : '企微',
|
||||
'企业名称' => $customer->corp_name,
|
||||
'职位' => $customer->position,
|
||||
'跟进人' => implode(', ', $followUserNames),
|
||||
'添加时间' => $customer->created_at,
|
||||
'更新时间' => $customer->updated_at
|
||||
];
|
||||
}
|
||||
|
||||
// 生成CSV文件
|
||||
$filename = 'qywx_customers_' . date('YmdHis') . '.csv';
|
||||
$filePath = sys_get_temp_dir() . '/' . $filename;
|
||||
|
||||
$fp = fopen($filePath, 'w');
|
||||
if ($fp) {
|
||||
// 写入表头
|
||||
fputcsv($fp, array_keys($exportData[0]));
|
||||
// 写入数据
|
||||
foreach ($exportData as $row) {
|
||||
fputcsv($fp, $row);
|
||||
}
|
||||
fclose($fp);
|
||||
|
||||
// 下载文件
|
||||
header('Content-Type: text/csv');
|
||||
header('Content-Disposition: attachment; filename=' . $filename);
|
||||
header('Content-Length: ' . filesize($filePath));
|
||||
readfile($filePath);
|
||||
unlink($filePath);
|
||||
exit;
|
||||
}
|
||||
|
||||
return $this->fail('导出失败');
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail('导出失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 导入客户数据
|
||||
*/
|
||||
public function import()
|
||||
{
|
||||
try {
|
||||
// 记录开始时间
|
||||
$startTime = microtime(true);
|
||||
|
||||
// 检查文件上传
|
||||
if (empty($_FILES['file'])) {
|
||||
$file = request()->file('file');
|
||||
if (!$file) {
|
||||
return $this->fail('请选择文件');
|
||||
}
|
||||
} else {
|
||||
$file = request()->file('file');
|
||||
}
|
||||
|
||||
if (!$file) {
|
||||
return $this->fail('请选择文件');
|
||||
}
|
||||
|
||||
// 记录文件信息
|
||||
$originalName = $file->getOriginalName();
|
||||
$ext = strtolower(pathinfo($originalName, PATHINFO_EXTENSION));
|
||||
$size = $file->getSize();
|
||||
$tmpName = $file->getRealPath();
|
||||
|
||||
// 检查扩展名
|
||||
$allowedExts = ['csv', 'xlsx'];
|
||||
if (!in_array($ext, $allowedExts)) {
|
||||
return $this->fail('请上传CSV或XLSX文件');
|
||||
}
|
||||
|
||||
// 检查文件大小(限制10MB)
|
||||
if ($size > 10 * 1024 * 1024) {
|
||||
return $this->fail('文件大小不能超过10MB');
|
||||
}
|
||||
|
||||
$importCount = 0;
|
||||
$skipCount = 0;
|
||||
$batchSize = 50; // 每批插入50条,减少内存消耗
|
||||
$batchData = [];
|
||||
|
||||
// 设置内存限制
|
||||
ini_set('memory_limit', '256M');
|
||||
// 设置执行时间
|
||||
set_time_limit(120);
|
||||
|
||||
if ($ext === 'csv') {
|
||||
// 读取CSV文件
|
||||
$fp = fopen($tmpName, 'r');
|
||||
if (!$fp) {
|
||||
return $this->fail('文件读取失败');
|
||||
}
|
||||
|
||||
try {
|
||||
// 读取表头
|
||||
$header = fgetcsv($fp);
|
||||
if (!$header) {
|
||||
fclose($fp);
|
||||
return $this->fail('文件格式错误');
|
||||
}
|
||||
|
||||
// 转换表头编码
|
||||
$header = array_map(function($item) {
|
||||
return $this->safeConvert($item);
|
||||
}, $header);
|
||||
|
||||
// 映射表头
|
||||
$headerMap = [];
|
||||
foreach ($header as $index => $field) {
|
||||
$field = trim($field);
|
||||
$headerMap[$field] = $index;
|
||||
}
|
||||
|
||||
// 读取数据
|
||||
$rowNumber = 1;
|
||||
while (($row = fgetcsv($fp)) !== false) {
|
||||
$rowNumber++;
|
||||
|
||||
// 跳过空行
|
||||
if (empty(array_filter($row))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 转换行数据编码
|
||||
$row = array_map(function($item) {
|
||||
return $this->safeConvert($item);
|
||||
}, $row);
|
||||
|
||||
// 解析数据
|
||||
$data = $this->parseImportData($row, $headerMap);
|
||||
|
||||
// 检查必填字段
|
||||
if (empty($data['name'])) {
|
||||
$skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 添加到批次
|
||||
$batchData[] = $data;
|
||||
$importCount++;
|
||||
|
||||
// 达到批次大小,执行插入
|
||||
if (count($batchData) >= $batchSize) {
|
||||
try {
|
||||
\app\common\model\QywxExternalContact::insertAll($batchData);
|
||||
} catch (\Exception $e) {
|
||||
fclose($fp);
|
||||
return $this->fail('数据插入失败: ' . $e->getMessage());
|
||||
}
|
||||
$batchData = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 插入剩余数据
|
||||
if (!empty($batchData)) {
|
||||
try {
|
||||
\app\common\model\QywxExternalContact::insertAll($batchData);
|
||||
} catch (\Exception $e) {
|
||||
fclose($fp);
|
||||
return $this->fail('数据插入失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
fclose($fp);
|
||||
return $this->fail('CSV文件解析失败: ' . $e->getMessage());
|
||||
} finally {
|
||||
if (isset($fp) && is_resource($fp)) {
|
||||
fclose($fp);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 读取XLSX文件
|
||||
try {
|
||||
$spreadsheet = IOFactory::load($tmpName);
|
||||
$worksheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
// 读取表头(第一行)
|
||||
$header = [];
|
||||
$highestColumn = $worksheet->getHighestColumn();
|
||||
$highestColumnIndex = Coordinate::columnIndexFromString($highestColumn);
|
||||
|
||||
for ($col = 1; $col <= $highestColumnIndex; $col++) {
|
||||
$value = $worksheet->getCellByColumnAndRow($col, 1)->getValue();
|
||||
$header[] = trim($this->safeConvert($value));
|
||||
}
|
||||
|
||||
// 映射表头
|
||||
$headerMap = [];
|
||||
foreach ($header as $index => $field) {
|
||||
$headerMap[$field] = $index;
|
||||
}
|
||||
|
||||
// 读取数据(从第二行开始)
|
||||
$highestRow = $worksheet->getHighestRow();
|
||||
|
||||
for ($row = 2; $row <= $highestRow; $row++) {
|
||||
$rowData = [];
|
||||
for ($col = 1; $col <= $highestColumnIndex; $col++) {
|
||||
$value = $worksheet->getCellByColumnAndRow($col, $row)->getValue();
|
||||
$rowData[] = $this->safeConvert($value);
|
||||
}
|
||||
|
||||
// 跳过空行
|
||||
if (empty(array_filter($rowData))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 解析数据
|
||||
$data = $this->parseImportData($rowData, $headerMap);
|
||||
|
||||
// 检查必填字段
|
||||
if (empty($data['name'])) {
|
||||
$skipCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 添加到批次
|
||||
$batchData[] = $data;
|
||||
$importCount++;
|
||||
|
||||
// 达到批次大小,执行插入
|
||||
if (count($batchData) >= $batchSize) {
|
||||
try {
|
||||
\app\common\model\QywxExternalContact::insertAll($batchData);
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail('数据插入失败: ' . $e->getMessage());
|
||||
}
|
||||
$batchData = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 插入剩余数据
|
||||
if (!empty($batchData)) {
|
||||
try {
|
||||
\app\common\model\QywxExternalContact::insertAll($batchData);
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail('数据插入失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return $this->fail('XLSX文件解析失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return $this->success('导入成功', [
|
||||
'import_count' => $importCount,
|
||||
'skip_count' => $skipCount
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail('导入失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 解析导入数据
|
||||
*/
|
||||
private function parseImportData($row, $headerMap)
|
||||
{
|
||||
// 解析数据
|
||||
$data = [
|
||||
'name' => isset($headerMap['客户名称']) ? ($this->safeConvert($row[$headerMap['客户名称']] ?? '')) : '',
|
||||
'external_user_id' => 'import_' . uniqid(),
|
||||
'avatar' => '',
|
||||
'type' => 1,
|
||||
'gender' => 0,
|
||||
'unionid' => '',
|
||||
'position' => isset($headerMap['职务']) ? ($this->safeConvert($row[$headerMap['职务']] ?? '')) : '',
|
||||
'corp_name' => isset($headerMap['企业']) ? ($this->safeConvert($row[$headerMap['企业']] ?? '')) : '',
|
||||
'corp_full_name' => isset($headerMap['企业']) ? ($this->safeConvert($row[$headerMap['企业']] ?? '')) : '',
|
||||
'external_profile' => json_encode([], JSON_UNESCAPED_UNICODE),
|
||||
'follow_users' => json_encode([[
|
||||
'name' => isset($headerMap['添加人']) ? ($this->safeConvert($row[$headerMap['添加人']] ?? '')) : '',
|
||||
'userid' => isset($headerMap['添加人账号']) ? ($this->safeConvert($row[$headerMap['添加人账号']] ?? '')) : ''
|
||||
]], JSON_UNESCAPED_UNICODE),
|
||||
'first_staff_name' => isset($headerMap['添加人']) ? ($this->safeConvert($row[$headerMap['添加人']] ?? '')) : '',
|
||||
'last_staff_name' => isset($headerMap['添加人']) ? ($this->safeConvert($row[$headerMap['添加人']] ?? '')) : '',
|
||||
'created_at' => date('Y-m-d H:i:s'),
|
||||
'updated_at' => date('Y-m-d H:i:s'),
|
||||
];
|
||||
|
||||
// 处理添加时间
|
||||
if (isset($headerMap['添加时间'])) {
|
||||
$addTime = $row[$headerMap['添加时间']] ?? '';
|
||||
if (!empty($addTime)) {
|
||||
// 尝试解析时间格式
|
||||
$timestamp = strtotime($addTime);
|
||||
if ($timestamp) {
|
||||
$data['first_add_time'] = date('Y-m-d H:i:s', $timestamp);
|
||||
$data['last_add_time'] = date('Y-m-d H:i:s', $timestamp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 处理其他字段
|
||||
if (isset($headerMap['手机'])) {
|
||||
$data['phone'] = $this->safeConvert($row[$headerMap['手机']] ?? '');
|
||||
}
|
||||
if (isset($headerMap['邮箱'])) {
|
||||
$data['email'] = $this->safeConvert($row[$headerMap['邮箱']] ?? '');
|
||||
}
|
||||
if (isset($headerMap['地址'])) {
|
||||
$data['address'] = $this->safeConvert($row[$headerMap['地址']] ?? '');
|
||||
}
|
||||
if (isset($headerMap['电话'])) {
|
||||
$data['phone'] = $this->safeConvert($row[$headerMap['电话']] ?? '');
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 安全的编码转换
|
||||
*/
|
||||
private function safeConvert($value)
|
||||
{
|
||||
$type = gettype($value);
|
||||
if ($type === 'NULL') {
|
||||
return '';
|
||||
}
|
||||
if ($type === 'array') {
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
if ($type === 'object') {
|
||||
$strValue = method_exists($value, '__toString') ? (string)$value : json_encode($value, JSON_UNESCAPED_UNICODE);
|
||||
if ($strValue === false) {
|
||||
return '';
|
||||
}
|
||||
$result = @mb_convert_encoding($strValue, 'UTF-8', 'auto');
|
||||
return $result ?: $strValue;
|
||||
}
|
||||
if ($type !== 'string') {
|
||||
if ($type === 'boolean') {
|
||||
return $value ? '1' : '0';
|
||||
}
|
||||
if ($type === 'integer' || $type === 'double') {
|
||||
return (string)$value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
if ($value === '') {
|
||||
return '';
|
||||
}
|
||||
$result = @mb_convert_encoding($value, 'UTF-8', 'auto');
|
||||
return $result ?: $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 递归转换数组中的所有字符串为UTF-8编码
|
||||
*/
|
||||
private function recursiveConvert($data)
|
||||
{
|
||||
if (is_null($data)) {
|
||||
return '';
|
||||
}
|
||||
if (is_array($data)) {
|
||||
foreach ($data as $key => $value) {
|
||||
$data[$key] = $this->recursiveConvert($value);
|
||||
}
|
||||
} elseif (is_string($data)) {
|
||||
$data = mb_convert_encoding($data, 'UTF-8', 'auto');
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 异步同步企业微信客户
|
||||
*/
|
||||
public function asyncSync()
|
||||
{
|
||||
try {
|
||||
$limit = (int)input('limit', 30);
|
||||
$fullSync = (bool)input('full_sync', false);
|
||||
$syncMode = input('sync_mode', 'all');
|
||||
$selectedUsers = input('selected_users', []);
|
||||
$days = (int)input('days', 7); // 最近几天的数据
|
||||
|
||||
// 生成同步任务ID
|
||||
$taskId = uniqid('qywx_sync_');
|
||||
|
||||
// 构建同步参数
|
||||
$params = [
|
||||
'limit' => $limit,
|
||||
'full_sync' => $fullSync,
|
||||
'sync_mode' => $syncMode,
|
||||
'selected_users' => $selectedUsers,
|
||||
'days' => $days,
|
||||
'task_id' => $taskId
|
||||
];
|
||||
|
||||
// 保存任务状态
|
||||
$cacheKey = 'qywx_sync_task_' . $taskId;
|
||||
Cache::set($cacheKey, [
|
||||
'status' => 'pending',
|
||||
'progress' => 0,
|
||||
'start_time' => time(),
|
||||
'params' => $params
|
||||
], 86400);
|
||||
|
||||
// 执行异步同步
|
||||
$this->executeAsyncSync($params, $taskId);
|
||||
|
||||
return $this->success('同步任务已启动', [
|
||||
'task_id' => $taskId,
|
||||
'status' => 'pending'
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail('启动同步任务失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取同步任务状态
|
||||
*/
|
||||
public function getSyncTaskStatus()
|
||||
{
|
||||
try {
|
||||
$taskId = input('task_id', '');
|
||||
if (empty($taskId)) {
|
||||
return $this->fail('缺少任务ID');
|
||||
}
|
||||
|
||||
$cacheKey = 'qywx_sync_task_' . $taskId;
|
||||
$taskInfo = Cache::get($cacheKey);
|
||||
|
||||
if (!$taskInfo) {
|
||||
return $this->fail('任务不存在');
|
||||
}
|
||||
|
||||
return $this->success('获取成功', $taskInfo);
|
||||
} catch (\Throwable $e) {
|
||||
return $this->fail('获取任务状态失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 执行异步同步
|
||||
*/
|
||||
private function executeAsyncSync($params, $taskId)
|
||||
{
|
||||
// 构建命令
|
||||
$phpPath = PHP_BINARY;
|
||||
$scriptPath = realpath(__DIR__ . '/../../../../') . '/sync_qywx.php';
|
||||
|
||||
// 生成临时参数文件
|
||||
$paramFile = sys_get_temp_dir() . '/' . $taskId . '.json';
|
||||
file_put_contents($paramFile, json_encode($params));
|
||||
|
||||
// 构建执行命令
|
||||
$command = "{$phpPath} {$scriptPath} {$paramFile} {$taskId} > NUL 2>&1 &";
|
||||
|
||||
// 执行后台命令
|
||||
exec($command);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,8 +71,19 @@ class OperationLog
|
||||
$systemLog->type = $request->isGet() ? 'GET' : 'POST';
|
||||
$systemLog->params = json_encode($params, true);
|
||||
$systemLog->ip = $request->ip();
|
||||
$systemLog->result = $this->shortenOperationLogResult((string) $response->getContent());
|
||||
return $systemLog->save();
|
||||
|
||||
// 确保响应内容是正确的UTF-8编码
|
||||
$content = (string) $response->getContent();
|
||||
$content = mb_convert_encoding($content, 'UTF-8', 'auto');
|
||||
$systemLog->result = $this->shortenOperationLogResult($content);
|
||||
|
||||
try {
|
||||
return $systemLog->save();
|
||||
} catch (\Exception $e) {
|
||||
// 记录日志保存失败的错误,但不影响接口响应
|
||||
error_log('Operation log save failed: ' . $e->getMessage());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** MySQL TEXT 上限约 64KB,列表接口常含 base64 签名导致超长 */
|
||||
|
||||
@@ -31,8 +31,8 @@ abstract class BaseAdminDataLists extends BaseDataLists
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->adminInfo = $this->request->adminInfo;
|
||||
$this->adminId = $this->request->adminId;
|
||||
$this->adminInfo = $this->request->adminInfo ?? [];
|
||||
$this->adminId = $this->request->adminId ?? 0;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -4,14 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\lists\qywx;
|
||||
|
||||
use app\adminapi\lists\BaseAdminDataLists;
|
||||
use app\common\lists\BaseDataLists;
|
||||
use app\common\lists\ListsSearchInterface;
|
||||
use app\common\model\QywxExternalContact;
|
||||
|
||||
/**
|
||||
* 企业微信客户列表
|
||||
*/
|
||||
class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
class CustomerLists extends BaseDataLists implements ListsSearchInterface
|
||||
{
|
||||
/**
|
||||
* @notes 搜索条件
|
||||
@@ -28,29 +28,38 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
*/
|
||||
public function lists(): array
|
||||
{
|
||||
$lists = QywxExternalContact::where($this->searchWhere)
|
||||
->whereNull('delete_time')
|
||||
->order('id', 'desc')
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
try {
|
||||
$lists = QywxExternalContact::where($this->searchWhere)
|
||||
->order('id', 'desc')
|
||||
->limit($this->limitOffset, $this->limitLength)
|
||||
->select()
|
||||
->toArray();
|
||||
|
||||
foreach ($lists as &$item) {
|
||||
// 解析跟进人JSON
|
||||
$followUsers = json_decode($item['follow_users'] ?? '[]', true);
|
||||
$item['follow_users'] = is_array($followUsers) ? $followUsers : [];
|
||||
foreach ($lists as &$item) {
|
||||
// 解析跟进人JSON(如果字段存在)
|
||||
if (isset($item['follow_users'])) {
|
||||
$followUsers = json_decode($item['follow_users'] ?? '[]', true);
|
||||
$item['follow_users'] = is_array($followUsers) ? $followUsers : [];
|
||||
} else {
|
||||
$item['follow_users'] = [];
|
||||
}
|
||||
}
|
||||
|
||||
return $lists;
|
||||
} catch (\Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return $lists;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取数量
|
||||
* @notes 获取总数
|
||||
*/
|
||||
public function count(): int
|
||||
{
|
||||
return QywxExternalContact::where($this->searchWhere)
|
||||
->whereNull('delete_time')
|
||||
->count();
|
||||
try {
|
||||
return QywxExternalContact::where($this->searchWhere)->count();
|
||||
} catch (\Throwable $e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,138 +6,84 @@ namespace app\adminapi\logic\qywx;
|
||||
|
||||
use app\common\logic\BaseLogic;
|
||||
use app\common\model\QywxExternalContact;
|
||||
use app\common\model\QywxSyncSettings;
|
||||
use app\common\service\wechat\WechatWorkService;
|
||||
use think\facade\Db;
|
||||
use think\facade\Log;
|
||||
use think\facade\Cache;
|
||||
|
||||
/**
|
||||
* 企业微信客户逻辑层
|
||||
* 企业微信客户逻辑层(精简版)
|
||||
*/
|
||||
class CustomerLogic extends BaseLogic
|
||||
{
|
||||
/**
|
||||
* @notes 同步企业微信客户
|
||||
* @notes 获取企业微信token
|
||||
*/
|
||||
public static function syncCustomers()
|
||||
public static function getAccessToken()
|
||||
{
|
||||
try {
|
||||
$service = new WechatWorkService();
|
||||
$token = $service->getContactAccessToken();
|
||||
return $token;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('获取企业微信token失败: ' . $e->getMessage());
|
||||
self::$error = '获取token失败: ' . $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取外部联系人列表
|
||||
* @param string $userId 员工ID
|
||||
*/
|
||||
public static function getExternalContacts(string $userId = '')
|
||||
{
|
||||
try {
|
||||
$service = new WechatWorkService();
|
||||
|
||||
// 1. 获取部门成员列表(从根部门开始,递归获取所有成员)
|
||||
$departmentId = config('project.qywx_sync_department_id', 1);
|
||||
Log::info('开始同步企业微信客户 - 部门ID: ' . $departmentId);
|
||||
|
||||
$userList = $service->getDepartmentUserList($departmentId, true);
|
||||
Log::info('获取到企业成员数量: ' . count($userList));
|
||||
|
||||
if (empty($userList)) {
|
||||
Log::error('未获取到企业成员列表');
|
||||
self::$error = '未获取到企业成员列表';
|
||||
return false;
|
||||
}
|
||||
|
||||
$syncCount = 0;
|
||||
$updateCount = 0;
|
||||
$newCount = 0;
|
||||
$processedCustomers = []; // 记录已处理的客户,避免重复
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
// 2. 遍历每个成员,获取其客户列表
|
||||
if (empty($userId)) {
|
||||
// 获取所有员工
|
||||
$userList = $service->getDepartmentUserList();
|
||||
if (empty($userList)) {
|
||||
self::$error = '未获取到员工列表';
|
||||
return false;
|
||||
}
|
||||
|
||||
$allContacts = [];
|
||||
foreach ($userList as $user) {
|
||||
$userId = $user['userid'] ?? '';
|
||||
if (empty($userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Log::info('获取成员客户列表 - userId: ' . $userId . ', name: ' . ($user['name'] ?? ''));
|
||||
|
||||
// 获取该成员的客户列表
|
||||
$customerList = $service->getExternalContactList($userId);
|
||||
|
||||
// 如果返回false,说明API调用失败(权限问题)
|
||||
if ($customerList === false) {
|
||||
Log::error('获取客户列表失败 - 可能是API权限未开启(错误码48002)');
|
||||
throw new \Exception('企业微信API权限不足,请在企业微信管理后台开启"客户联系"权限');
|
||||
}
|
||||
|
||||
Log::info('成员 ' . $userId . ' 的客户数量: ' . count($customerList));
|
||||
|
||||
if (empty($customerList)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. 遍历客户列表,获取详情并保存
|
||||
foreach ($customerList as $externalUserId) {
|
||||
// 避免重复处理同一个客户
|
||||
if (isset($processedCustomers[$externalUserId])) {
|
||||
continue;
|
||||
}
|
||||
$processedCustomers[$externalUserId] = true;
|
||||
|
||||
// 获取客户详情
|
||||
$detail = $service->getExternalContactDetail($externalUserId);
|
||||
if (empty($detail)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$externalContact = $detail['external_contact'] ?? [];
|
||||
$followUsers = $detail['follow_user'] ?? [];
|
||||
|
||||
// 保存或更新客户信息
|
||||
$data = [
|
||||
'external_userid' => $externalContact['external_userid'] ?? '',
|
||||
'name' => $externalContact['name'] ?? '',
|
||||
'avatar' => $externalContact['avatar'] ?? '',
|
||||
'type' => $externalContact['type'] ?? 1,
|
||||
'gender' => $externalContact['gender'] ?? 0,
|
||||
'unionid' => $externalContact['unionid'] ?? '',
|
||||
'position' => $externalContact['position'] ?? '',
|
||||
'corp_name' => $externalContact['corp_name'] ?? '',
|
||||
'corp_full_name' => $externalContact['corp_full_name'] ?? '',
|
||||
'external_profile' => json_encode($externalContact['external_profile'] ?? [], JSON_UNESCAPED_UNICODE),
|
||||
'follow_users' => json_encode($followUsers, JSON_UNESCAPED_UNICODE),
|
||||
'update_time' => time(),
|
||||
$userContacts = $service->getExternalContactList($user['userid'] ?? '');
|
||||
if (!empty($userContacts)) {
|
||||
$allContacts[$user['userid']] = [
|
||||
'name' => $user['name'] ?? '',
|
||||
'contacts' => $userContacts
|
||||
];
|
||||
|
||||
$existing = QywxExternalContact::where('external_userid', $data['external_userid'])->find();
|
||||
if ($existing) {
|
||||
$existing->save($data);
|
||||
$updateCount++;
|
||||
} else {
|
||||
$data['create_time'] = time();
|
||||
QywxExternalContact::create($data);
|
||||
$newCount++;
|
||||
}
|
||||
|
||||
$syncCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 更新同步设置
|
||||
self::updateSyncStatus('success', $syncCount);
|
||||
|
||||
Db::commit();
|
||||
|
||||
Log::info('同步完成 - 总数: ' . $syncCount . ', 新增: ' . $newCount . ', 更新: ' . $updateCount);
|
||||
|
||||
return [
|
||||
'sync_count' => $syncCount,
|
||||
'new_count' => $newCount,
|
||||
'update_count' => $updateCount,
|
||||
];
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
Log::error('同步企业微信客户失败: ' . $e->getMessage());
|
||||
self::$error = '同步失败: ' . $e->getMessage();
|
||||
self::updateSyncStatus('failed', 0, $e->getMessage());
|
||||
return false;
|
||||
return $allContacts;
|
||||
} else {
|
||||
// 获取指定员工的客户
|
||||
$contacts = $service->getExternalContactList($userId);
|
||||
return $contacts;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('同步企业微信客户异常: ' . $e->getMessage());
|
||||
self::$error = '同步异常: ' . $e->getMessage();
|
||||
self::updateSyncStatus('failed', 0, $e->getMessage());
|
||||
Log::error('获取外部联系人失败: ' . $e->getMessage());
|
||||
self::$error = '获取联系人失败: ' . $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取客户详情
|
||||
* @param string $externalUserId 外部联系人ID
|
||||
*/
|
||||
public static function getContactDetail(string $externalUserId)
|
||||
{
|
||||
try {
|
||||
$service = new WechatWorkService();
|
||||
$detail = $service->getExternalContactDetail($externalUserId);
|
||||
return $detail;
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('获取客户详情失败: ' . $e->getMessage());
|
||||
self::$error = '获取详情失败: ' . $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -147,119 +93,39 @@ class CustomerLogic extends BaseLogic
|
||||
*/
|
||||
public static function getStats()
|
||||
{
|
||||
$total = QywxExternalContact::whereNull('delete_time')->count();
|
||||
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
$today = QywxExternalContact::where('create_time', '>=', $todayStart)
|
||||
->whereNull('delete_time')
|
||||
$total = QywxExternalContact::count();
|
||||
|
||||
$todayStart = date('Y-m-d 00:00:00');
|
||||
$today = QywxExternalContact::where('created_at', '>=', $todayStart)
|
||||
->count();
|
||||
|
||||
$settings = self::getSyncSettings();
|
||||
$lastSyncTime = $settings['last_sync_time'] ?? 0;
|
||||
$lastSync = $lastSyncTime > 0 ? date('Y-m-d H:i:s', $lastSyncTime) : '';
|
||||
// 获取同步状态
|
||||
$syncStatus = Cache::get('qywx_sync_status', [
|
||||
'last_sync_time' => '',
|
||||
'status' => 'idle'
|
||||
]);
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'today' => $today,
|
||||
'lastSync' => $lastSync,
|
||||
'syncStatus' => $settings['sync_status'] ?? 'idle',
|
||||
'syncStatusText' => self::getSyncStatusText($settings['sync_status'] ?? 'idle'),
|
||||
'lastSync' => $syncStatus['last_sync_time'] ?? '',
|
||||
'syncStatus' => $syncStatus['status'] ?? 'idle',
|
||||
'syncStatusText' => self::getStatusText($syncStatus['status'] ?? 'idle')
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取同步设置
|
||||
*/
|
||||
public static function getSyncSettings()
|
||||
{
|
||||
$setting = QywxSyncSettings::where('setting_key', 'customer_sync')->find();
|
||||
if (!$setting) {
|
||||
return [
|
||||
'auto_sync' => false,
|
||||
'interval' => 3600,
|
||||
'last_sync_time' => 0,
|
||||
'sync_status' => 'idle',
|
||||
];
|
||||
}
|
||||
|
||||
$value = json_decode($setting->setting_value, true);
|
||||
return $value ?: [
|
||||
'auto_sync' => false,
|
||||
'interval' => 3600,
|
||||
'last_sync_time' => 0,
|
||||
'sync_status' => 'idle',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 保存同步设置
|
||||
*/
|
||||
public static function saveSyncSettings(array $params)
|
||||
{
|
||||
try {
|
||||
$currentSettings = self::getSyncSettings();
|
||||
$newSettings = array_merge($currentSettings, [
|
||||
'auto_sync' => (bool)($params['auto_sync'] ?? false),
|
||||
'interval' => (int)($params['interval'] ?? 3600),
|
||||
]);
|
||||
|
||||
$setting = QywxSyncSettings::where('setting_key', 'customer_sync')->find();
|
||||
if ($setting) {
|
||||
$setting->setting_value = json_encode($newSettings, JSON_UNESCAPED_UNICODE);
|
||||
$setting->update_time = time();
|
||||
$setting->save();
|
||||
} else {
|
||||
QywxSyncSettings::create([
|
||||
'setting_key' => 'customer_sync',
|
||||
'setting_value' => json_encode($newSettings, JSON_UNESCAPED_UNICODE),
|
||||
'create_time' => time(),
|
||||
'update_time' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (\Throwable $e) {
|
||||
self::$error = '保存失败: ' . $e->getMessage();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 更新同步状态
|
||||
*/
|
||||
private static function updateSyncStatus(string $status, int $syncCount = 0, string $error = '')
|
||||
{
|
||||
try {
|
||||
$settings = self::getSyncSettings();
|
||||
$settings['sync_status'] = $status;
|
||||
$settings['last_sync_time'] = time();
|
||||
$settings['last_sync_count'] = $syncCount;
|
||||
if ($error) {
|
||||
$settings['last_error'] = $error;
|
||||
}
|
||||
|
||||
$setting = QywxSyncSettings::where('setting_key', 'customer_sync')->find();
|
||||
if ($setting) {
|
||||
$setting->setting_value = json_encode($settings, JSON_UNESCAPED_UNICODE);
|
||||
$setting->update_time = time();
|
||||
$setting->save();
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('更新同步状态失败: ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取同步状态文本
|
||||
*/
|
||||
private static function getSyncStatusText(string $status): string
|
||||
private static function getStatusText($status)
|
||||
{
|
||||
$map = [
|
||||
$statusMap = [
|
||||
'idle' => '未同步',
|
||||
'syncing' => '同步中',
|
||||
'success' => '同步成功',
|
||||
'failed' => '同步失败',
|
||||
'running' => '同步中',
|
||||
'completed' => '同步成功',
|
||||
'failed' => '同步失败'
|
||||
];
|
||||
return $map[$status] ?? '未知';
|
||||
|
||||
return $statusMap[$status] ?? '未同步';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\adminapi\validate\qywx;
|
||||
|
||||
use app\common\validate\BaseValidate;
|
||||
|
||||
/**
|
||||
* 企业微信客户验证器
|
||||
*/
|
||||
class CustomerValidate extends BaseValidate
|
||||
{
|
||||
protected $rule = [
|
||||
'auto_sync' => 'require|boolean',
|
||||
'interval' => 'require|integer|between:3600,86400',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'auto_sync.require' => '请选择是否自动同步',
|
||||
'auto_sync.boolean' => '自动同步参数格式错误',
|
||||
'interval.require' => '请选择同步间隔',
|
||||
'interval.integer' => '同步间隔必须为整数',
|
||||
'interval.between' => '同步间隔必须在1小时到24小时之间',
|
||||
];
|
||||
|
||||
/**
|
||||
* @notes 同步设置场景
|
||||
*/
|
||||
public function sceneSyncSettings()
|
||||
{
|
||||
return $this->only(['auto_sync', 'interval']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\command;
|
||||
|
||||
use app\adminapi\logic\qywx\CustomerLogic;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Argument;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
/**
|
||||
* 企业微信客户同步命令
|
||||
* 用法: php think qywx:sync_customer
|
||||
*/
|
||||
class QywxSyncCustomer extends Command
|
||||
{
|
||||
protected function configure()
|
||||
{
|
||||
$this->setName('qywx:sync_customer')
|
||||
->setDescription('同步企业微信客户到本地数据库')
|
||||
->addArgument('action', Argument::OPTIONAL, '执行操作(sync/status)', 'sync');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output)
|
||||
{
|
||||
$action = $input->getArgument('action') ?: 'sync';
|
||||
|
||||
switch ($action) {
|
||||
case 'sync':
|
||||
$this->syncCustomers($output);
|
||||
break;
|
||||
case 'status':
|
||||
$this->showStatus($output);
|
||||
break;
|
||||
default:
|
||||
$output->error('未知操作: ' . $action);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步客户
|
||||
*/
|
||||
private function syncCustomers(Output $output)
|
||||
{
|
||||
$output->info('开始同步企业微信客户...');
|
||||
|
||||
$result = CustomerLogic::syncCustomers();
|
||||
|
||||
if ($result === false) {
|
||||
$output->error('同步失败: ' . CustomerLogic::getError());
|
||||
return;
|
||||
}
|
||||
|
||||
$output->info('同步完成!');
|
||||
$output->info('总计同步: ' . ($result['sync_count'] ?? 0) . ' 个客户');
|
||||
$output->info('新增: ' . ($result['new_count'] ?? 0));
|
||||
$output->info('更新: ' . ($result['update_count'] ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示同步状态
|
||||
*/
|
||||
private function showStatus(Output $output)
|
||||
{
|
||||
$stats = CustomerLogic::getStats();
|
||||
|
||||
$output->info('=== 企业微信客户同步状态 ===');
|
||||
$output->info('客户总数: ' . $stats['total']);
|
||||
$output->info('今日新增: ' . $stats['today']);
|
||||
$output->info('同步状态: ' . $stats['syncStatusText']);
|
||||
$output->info('最后同步: ' . $stats['lastSync']);
|
||||
}
|
||||
}
|
||||
@@ -47,14 +47,15 @@ class LikeAdminAllowMiddleware
|
||||
*/
|
||||
public function handle($request, Closure $next, ?array $header = []): mixed
|
||||
{
|
||||
// 如果是OPTIONS预检请求,直接返回成功响应,不继续处理
|
||||
if (strtoupper($request->method()) === 'OPTIONS') {
|
||||
$this->setCorsHeaders();
|
||||
return response('', 200);
|
||||
}
|
||||
|
||||
// 设置跨域头
|
||||
$this->setCorsHeaders();
|
||||
|
||||
// 如果是OPTIONS请求,直接返回响应
|
||||
if (strtoupper($request->method()) === 'OPTIONS') {
|
||||
return response();
|
||||
}
|
||||
|
||||
// 安装检测
|
||||
$install = file_exists(root_path() . '/config/install.lock');
|
||||
if (!$install) {
|
||||
@@ -72,10 +73,11 @@ class LikeAdminAllowMiddleware
|
||||
*/
|
||||
private function setCorsHeaders(): void
|
||||
{
|
||||
$origin = $_SERVER['HTTP_ORIGIN'] ?? '*';
|
||||
$headers = [
|
||||
'Access-Control-Allow-Origin' => '*',
|
||||
'Access-Control-Allow-Origin' => $origin,
|
||||
'Access-Control-Allow-Headers' => implode(', ', self::ALLOWED_HEADERS),
|
||||
'Access-Control-Allow-Methods' => 'GET, POST, PATCH, PUT, DELETE, post',
|
||||
'Access-Control-Allow-Methods' => 'GET, POST, PATCH, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Max-Age' => '1728000',
|
||||
'Access-Control-Allow-Credentials' => 'true'
|
||||
];
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 客户添加日志模型
|
||||
*/
|
||||
class QywxCustomerAddLog extends Model
|
||||
{
|
||||
protected $table = 'zyt_qywx_customer_add_log';
|
||||
protected $autoWriteTimestamp = true;
|
||||
|
||||
// 字段类型
|
||||
protected $type = [
|
||||
'staff_id' => 'integer',
|
||||
'add_time' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
// 隐藏字段
|
||||
protected $hidden = [];
|
||||
|
||||
// 验证规则
|
||||
protected $rule = [
|
||||
'external_user_id' => 'require|max:50',
|
||||
'staff_id' => 'require|integer',
|
||||
'staff_name' => 'require|max:50',
|
||||
'add_time' => 'require|datetime',
|
||||
'user_info' => 'require',
|
||||
'source' => 'require|max:20',
|
||||
];
|
||||
|
||||
// 错误信息
|
||||
protected $message = [
|
||||
'external_user_id.require' => '外部用户ID不能为空',
|
||||
'external_user_id.max' => '外部用户ID不能超过50个字符',
|
||||
'staff_id.require' => '员工ID不能为空',
|
||||
'staff_id.integer' => '员工ID必须是整数',
|
||||
'staff_name.require' => '员工姓名不能为空',
|
||||
'staff_name.max' => '员工姓名不能超过50个字符',
|
||||
'add_time.require' => '添加时间不能为空',
|
||||
'add_time.datetime' => '添加时间格式不正确',
|
||||
'user_info.require' => '用户信息不能为空',
|
||||
'source.require' => '来源不能为空',
|
||||
'source.max' => '来源不能超过20个字符',
|
||||
];
|
||||
|
||||
/**
|
||||
* @notes 获取员工加粉统计
|
||||
*/
|
||||
public static function getStaffAddCount($staffId, $startDate, $endDate)
|
||||
{
|
||||
return self::where('staff_id', $staffId)
|
||||
->where('add_time', '>=', $startDate)
|
||||
->where('add_time', '<=', $endDate)
|
||||
->count();
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取所有员工加粉统计
|
||||
*/
|
||||
public static function getStaffAddCountList($startDate, $endDate)
|
||||
{
|
||||
return self::field('staff_id, staff_name, count(*) as add_count')
|
||||
->where('add_time', '>=', $startDate)
|
||||
->where('add_time', '<=', $endDate)
|
||||
->group('staff_id, staff_name')
|
||||
->select();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 客户操作日志模型
|
||||
*/
|
||||
class QywxCustomerOperationLog extends Model
|
||||
{
|
||||
protected $table = 'zyt_qywx_customer_operation_log';
|
||||
protected $autoWriteTimestamp = true;
|
||||
|
||||
// 字段类型
|
||||
protected $type = [
|
||||
'operator_id' => 'integer',
|
||||
'staff_id' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
|
||||
// 隐藏字段
|
||||
protected $hidden = [];
|
||||
|
||||
// 验证规则
|
||||
protected $rule = [
|
||||
'operation_type' => 'require|max:20',
|
||||
'operator_id' => 'require|integer',
|
||||
'operator_name' => 'require|max:50',
|
||||
];
|
||||
|
||||
// 错误信息
|
||||
protected $message = [
|
||||
'operation_type.require' => '操作类型不能为空',
|
||||
'operation_type.max' => '操作类型不能超过20个字符',
|
||||
'operator_id.require' => '操作人ID不能为空',
|
||||
'operator_id.integer' => '操作人ID必须是整数',
|
||||
'operator_name.require' => '操作人姓名不能为空',
|
||||
'operator_name.max' => '操作人姓名不能超过50个字符',
|
||||
];
|
||||
|
||||
/**
|
||||
* @notes 记录操作日志
|
||||
*/
|
||||
public static function record($operationType, $operatorId, $operatorName, $externalUserId = null, $staffId = null, $content = null)
|
||||
{
|
||||
$log = new self();
|
||||
$log->operation_type = $operationType;
|
||||
$log->operator_id = $operatorId;
|
||||
$log->operator_name = $operatorName;
|
||||
$log->external_user_id = $externalUserId;
|
||||
$log->staff_id = $staffId;
|
||||
$log->content = $content;
|
||||
$log->save();
|
||||
return $log;
|
||||
}
|
||||
}
|
||||
@@ -4,16 +4,14 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use think\model\concern\SoftDelete;
|
||||
|
||||
/**
|
||||
* 企业微信外部联系人模型
|
||||
*/
|
||||
class QywxExternalContact extends BaseModel
|
||||
{
|
||||
use SoftDelete;
|
||||
|
||||
protected $name = 'qywx_external_contact';
|
||||
protected $autoWriteTimestamp = 'datetime';
|
||||
protected $createTime = 'created_at';
|
||||
protected $updateTime = 'updated_at';
|
||||
protected $deleteTime = 'delete_time';
|
||||
protected $defaultSoftDelete = 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 员工二维码模型
|
||||
*/
|
||||
class QywxStaffQrcode extends Model
|
||||
{
|
||||
protected $table = 'zyt_qywx_staff_qrcode';
|
||||
protected $autoWriteTimestamp = true;
|
||||
|
||||
// 字段类型
|
||||
protected $type = [
|
||||
'staff_id' => 'integer',
|
||||
'expire_time' => 'datetime',
|
||||
'created_at' => 'datetime',
|
||||
'updated_at' => 'datetime',
|
||||
];
|
||||
|
||||
// 隐藏字段
|
||||
protected $hidden = [];
|
||||
|
||||
// 验证规则
|
||||
protected $rule = [
|
||||
'staff_id' => 'require|integer',
|
||||
'staff_name' => 'require|max:50',
|
||||
'scene' => 'require|max:100|unique:qywx_staff_qrcode',
|
||||
'qrcode_url' => 'require|max:255',
|
||||
'expire_time' => 'require|datetime',
|
||||
];
|
||||
|
||||
// 错误信息
|
||||
protected $message = [
|
||||
'staff_id.require' => '员工ID不能为空',
|
||||
'staff_id.integer' => '员工ID必须是整数',
|
||||
'staff_name.require' => '员工姓名不能为空',
|
||||
'staff_name.max' => '员工姓名不能超过50个字符',
|
||||
'scene.require' => '场景值不能为空',
|
||||
'scene.max' => '场景值不能超过100个字符',
|
||||
'scene.unique' => '场景值已存在',
|
||||
'qrcode_url.require' => '二维码URL不能为空',
|
||||
'qrcode_url.max' => '二维码URL不能超过255个字符',
|
||||
'expire_time.require' => '过期时间不能为空',
|
||||
'expire_time.datetime' => '过期时间格式不正确',
|
||||
];
|
||||
}
|
||||
@@ -9,5 +9,5 @@ namespace app\common\model;
|
||||
*/
|
||||
class QywxSyncSettings extends BaseModel
|
||||
{
|
||||
protected $name = 'qywx_sync_settings';
|
||||
protected $table = 'zyt_qywx_sync_settings';
|
||||
}
|
||||
|
||||
@@ -15,12 +15,21 @@ class WechatWorkService
|
||||
{
|
||||
private $corpId;
|
||||
private $secret;
|
||||
private $contactSecret;
|
||||
private $accessToken;
|
||||
private $contactAccessToken;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->corpId = config('pay.wechat_work.corp_id');
|
||||
$this->secret = config('pay.wechat_work.external_pay_secret');
|
||||
$this->contactSecret = config('pay.wechat_work.contact_secret');
|
||||
|
||||
Log::info('企业微信配置: ' . json_encode([
|
||||
'corp_id' => $this->corpId,
|
||||
'secret' => substr($this->secret, 0, 10) . '...',
|
||||
'contact_secret' => substr($this->contactSecret, 0, 10) . '...'
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,12 +72,80 @@ class WechatWorkService
|
||||
throw new \Exception('获取access_token失败: ' . ($response['errmsg'] ?? '未知错误'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取客户联系的access_token
|
||||
* @see https://developer.work.weixin.qq.com/document/path/92113
|
||||
*/
|
||||
private function getContactAccessToken(): string
|
||||
{
|
||||
if ($this->contactAccessToken) {
|
||||
return $this->contactAccessToken;
|
||||
}
|
||||
|
||||
// 从缓存获取
|
||||
$cacheKey = 'qywx_contact_access_token';
|
||||
$token = Cache::get($cacheKey);
|
||||
if ($token) {
|
||||
$this->contactAccessToken = $token;
|
||||
return $token;
|
||||
}
|
||||
|
||||
// 尝试使用 contactSecret 获取 token
|
||||
$token = $this->requestToken($this->contactSecret, $cacheKey, '客户联系');
|
||||
if ($token) {
|
||||
$this->contactAccessToken = $token;
|
||||
return $token;
|
||||
}
|
||||
|
||||
// 如果 contactSecret 失败,尝试使用 secret
|
||||
Log::info('企业微信-使用 contactSecret 获取 token 失败,尝试使用 secret');
|
||||
$token = $this->requestToken($this->secret, $cacheKey, '普通应用');
|
||||
if ($token) {
|
||||
$this->contactAccessToken = $token;
|
||||
return $token;
|
||||
}
|
||||
|
||||
throw new \Exception('获取客户联系access_token失败');
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 请求获取 token
|
||||
*/
|
||||
private function requestToken(string $secret, string $cacheKey, string $type): string
|
||||
{
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/gettoken';
|
||||
$params = [
|
||||
'corpid' => $this->corpId,
|
||||
'corpsecret' => $secret,
|
||||
];
|
||||
|
||||
Log::info('企业微信-获取' . $type . 'AccessToken请求: ' . json_encode($params, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$response = $this->httpGet($url, $params);
|
||||
|
||||
Log::info('企业微信-获取' . $type . 'AccessToken响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
if (isset($response['access_token'])) {
|
||||
$token = $response['access_token'];
|
||||
$expiresIn = $response['expires_in'] ?? 7200;
|
||||
|
||||
// 缓存token,提前5分钟过期
|
||||
Cache::set($cacheKey, $token, $expiresIn - 300);
|
||||
|
||||
return $token;
|
||||
}
|
||||
|
||||
Log::error('获取' . $type . 'token失败: ' . json_encode($response));
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取部门成员列表
|
||||
* @see https://developer.work.weixin.qq.com/document/path/90200
|
||||
*/
|
||||
public function getDepartmentUserList(int $departmentId = 1, bool $fetchChild = true): array
|
||||
{
|
||||
// 获取部门成员列表使用 external_pay_secret(普通应用Secret)
|
||||
$accessToken = $this->getAccessToken();
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/user/list';
|
||||
$params = [
|
||||
@@ -77,6 +154,8 @@ class WechatWorkService
|
||||
'fetch_child' => $fetchChild ? 1 : 0,
|
||||
];
|
||||
|
||||
Log::info('企业微信-获取部门成员列表请求: ' . json_encode($params, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$response = $this->httpGet($url, $params);
|
||||
|
||||
Log::info('企业微信-获取部门成员列表响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE));
|
||||
@@ -93,17 +172,37 @@ class WechatWorkService
|
||||
* @notes 获取成员的客户列表
|
||||
* @see https://developer.work.weixin.qq.com/document/path/92113
|
||||
* @param string $userId 成员ID
|
||||
* @param string $cursor 分页游标,上次返回的 next_cursor
|
||||
* @param int $limit 最大记录数,最大 100,默认 50
|
||||
* @return array|false 返回客户列表数组,权限错误返回false
|
||||
*/
|
||||
public function getExternalContactList(string $userId)
|
||||
public function getExternalContactList(string $userId, string $cursor = '', int $limit = 50)
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
// 生成缓存键
|
||||
$cacheKey = 'qywx_external_contact_list_' . $userId . '_' . $cursor . '_' . $limit;
|
||||
|
||||
// 尝试从缓存获取
|
||||
$cachedResult = Cache::get($cacheKey);
|
||||
if ($cachedResult) {
|
||||
Log::info('从缓存获取客户列表: ' . $userId);
|
||||
return $cachedResult;
|
||||
}
|
||||
|
||||
$accessToken = $this->getContactAccessToken();
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/list';
|
||||
$params = [
|
||||
'access_token' => $accessToken,
|
||||
'userid' => $userId,
|
||||
];
|
||||
|
||||
// 添加分页参数
|
||||
if (!empty($cursor)) {
|
||||
$params['cursor'] = $cursor;
|
||||
}
|
||||
if ($limit > 0) {
|
||||
$params['limit'] = min($limit, 100); // 最大100
|
||||
}
|
||||
|
||||
$response = $this->httpGet($url, $params);
|
||||
|
||||
Log::info('企业微信-获取成员客户列表响应 userId=' . $userId . ': ' . json_encode($response, JSON_UNESCAPED_UNICODE));
|
||||
@@ -115,25 +214,102 @@ class WechatWorkService
|
||||
}
|
||||
|
||||
if (isset($response['external_userid'])) {
|
||||
return $response['external_userid'];
|
||||
$result = [
|
||||
'external_userid' => $response['external_userid'],
|
||||
'next_cursor' => $response['next_cursor'] ?? ''
|
||||
];
|
||||
|
||||
// 缓存结果,有效期5分钟
|
||||
Cache::set($cacheKey, $result, 300);
|
||||
return $result;
|
||||
}
|
||||
|
||||
// 如果没有客户,返回空数组(不记录错误)
|
||||
if (isset($response['errcode']) && $response['errcode'] == 0) {
|
||||
return [];
|
||||
$result = [
|
||||
'external_userid' => [],
|
||||
'next_cursor' => ''
|
||||
];
|
||||
|
||||
// 缓存结果,有效期5分钟
|
||||
Cache::set($cacheKey, $result, 300);
|
||||
return $result;
|
||||
}
|
||||
|
||||
Log::error('获取客户列表失败: ' . json_encode($response));
|
||||
return [];
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取外部联系人信息
|
||||
* @see https://developer.work.weixin.qq.com/document/path/92114
|
||||
* @param string $externalUserId 外部用户ID
|
||||
* @return array|false 返回外部联系人信息,失败返回false
|
||||
*/
|
||||
public function getExternalContactInfo(string $externalUserId)
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get';
|
||||
$params = [
|
||||
'access_token' => $accessToken,
|
||||
'external_userid' => $externalUserId,
|
||||
];
|
||||
|
||||
$response = $this->httpGet($url, $params);
|
||||
|
||||
Log::info('企业微信-获取外部联系人信息响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
if (isset($response['external_contact'])) {
|
||||
return $response['external_contact'];
|
||||
}
|
||||
|
||||
Log::error('获取外部联系人信息失败: ' . json_encode($response));
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 生成带参数的二维码
|
||||
* @see https://developer.work.weixin.qq.com/document/path/90239
|
||||
* @param string $scene 场景值
|
||||
* @param int $expireSeconds 过期时间(秒),最大2592000
|
||||
* @return array|false 返回二维码信息,失败返回false
|
||||
*/
|
||||
public function createQrCode(string $scene, int $expireSeconds = 2592000)
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
$url = "https://qyapi.weixin.qq.com/cgi-bin/qrcode/create?access_token={$accessToken}";
|
||||
|
||||
$data = [
|
||||
"action_info" => [
|
||||
"scene" => [
|
||||
"scene_str" => $scene
|
||||
]
|
||||
],
|
||||
"expire_seconds" => $expireSeconds,
|
||||
"action_name" => "QR_SCENE"
|
||||
];
|
||||
|
||||
Log::info('企业微信-生成二维码请求: ' . json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$response = $this->httpPost($url, json_encode($data));
|
||||
|
||||
Log::info('企业微信-生成二维码响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
if (isset($response['ticket'])) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
Log::error('生成二维码失败: ' . json_encode($response));
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取客户详情
|
||||
* @see https://developer.work.weixin.qq.com/document/path/92114
|
||||
*/
|
||||
public function getExternalContactDetail(string $externalUserId): array
|
||||
{
|
||||
$accessToken = $this->getAccessToken();
|
||||
$accessToken = $this->getContactAccessToken();
|
||||
$url = 'https://qyapi.weixin.qq.com/cgi-bin/externalcontact/get';
|
||||
$params = [
|
||||
'access_token' => $accessToken,
|
||||
@@ -150,6 +326,240 @@ class WechatWorkService
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 获取所有员工的客户列表
|
||||
* @return array 所有员工的客户ID列表,格式:[staff_id => [external_userid1, external_userid2]]
|
||||
*/
|
||||
public function getAllStaffCustomers(): array
|
||||
{
|
||||
$result = [];
|
||||
|
||||
// 获取所有员工
|
||||
$staffList = $this->getDepartmentUserList();
|
||||
|
||||
if (empty($staffList)) {
|
||||
Log::info('企业微信-未获取到员工列表');
|
||||
return $result;
|
||||
}
|
||||
|
||||
Log::info('企业微信-开始获取员工客户列表,共 ' . count($staffList) . ' 名员工');
|
||||
|
||||
foreach ($staffList as $staff) {
|
||||
$userId = $staff['userid'] ?? '';
|
||||
if (empty($userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$customerResult = $this->getExternalContactList($userId);
|
||||
|
||||
if ($customerResult === false) {
|
||||
Log::error('企业微信-员工 ' . $userId . ' 获取客户列表权限不足');
|
||||
continue;
|
||||
}
|
||||
|
||||
$customerIds = $customerResult['external_userid'] ?? [];
|
||||
|
||||
if (!empty($customerIds)) {
|
||||
$result[$userId] = [
|
||||
'name' => $staff['name'] ?? '',
|
||||
'customers' => $customerIds
|
||||
];
|
||||
Log::info('企业微信-员工 ' . $userId . '(' . ($staff['name'] ?? '') . ') 有 ' . count($customerIds) . ' 个客户');
|
||||
}
|
||||
}
|
||||
|
||||
Log::info('企业微信-共获取到 ' . count($result) . ' 名员工的客户');
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 同步所有客户到本地数据库
|
||||
* @return array 同步结果统计
|
||||
*/
|
||||
public function syncAllExternalContacts(): array
|
||||
{
|
||||
$stats = [
|
||||
'total_staff' => 0,
|
||||
'total_customers' => 0,
|
||||
'added' => 0,
|
||||
'updated' => 0,
|
||||
'errors' => 0
|
||||
];
|
||||
|
||||
// 获取所有员工的客户
|
||||
$allStaffCustomers = $this->getAllStaffCustomers();
|
||||
|
||||
if (empty($allStaffCustomers)) {
|
||||
Log::info('企业微信-没有客户需要同步');
|
||||
return $stats;
|
||||
}
|
||||
|
||||
$stats['total_staff'] = count($allStaffCustomers);
|
||||
|
||||
foreach ($allStaffCustomers as $staffId => $staffData) {
|
||||
$staffName = $staffData['name'] ?? '';
|
||||
$customers = $staffData['customers'] ?? [];
|
||||
|
||||
foreach ($customers as $externalUserId) {
|
||||
$stats['total_customers']++;
|
||||
|
||||
try {
|
||||
// 获取客户详情
|
||||
$customerDetail = $this->getExternalContactDetail($externalUserId);
|
||||
|
||||
if (empty($customerDetail) || !isset($customerDetail['external_contact'])) {
|
||||
Log::error('企业微信-获取客户详情失败: ' . $externalUserId);
|
||||
$stats['errors']++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$contactInfo = $customerDetail['external_contact'];
|
||||
|
||||
// 更新到数据库
|
||||
$result = $this->saveExternalContact($staffId, $staffName, $contactInfo);
|
||||
|
||||
if ($result) {
|
||||
$stats[$result]++;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('企业微信-同步客户异常: ' . $e->getMessage());
|
||||
$stats['errors']++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Log::info('企业微信-同步完成: ' . json_encode($stats));
|
||||
return $stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 批量获取成员的客户详情(支持分页)
|
||||
* @param string $userId 成员ID
|
||||
* @param string $cursor 分页游标,上次返回的 next_cursor
|
||||
* @param int $limit 最大记录数,最大 100,默认 50
|
||||
* @return array|false 返回客户详情列表,权限错误返回false
|
||||
*/
|
||||
public function batchGetCustomerDetails(string $userId, string $cursor = '', int $limit = 50)
|
||||
{
|
||||
// 生成缓存键
|
||||
$cacheKey = 'qywx_batch_customer_details_' . $userId . '_' . $cursor . '_' . $limit;
|
||||
|
||||
// 尝试从缓存获取
|
||||
$cachedResult = Cache::get($cacheKey);
|
||||
if ($cachedResult) {
|
||||
Log::info('从缓存获取客户详情: ' . $userId);
|
||||
return $cachedResult;
|
||||
}
|
||||
|
||||
// 先获取客户ID列表
|
||||
$listResult = $this->getExternalContactList($userId, $cursor, $limit);
|
||||
if ($listResult === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$externalUserIds = $listResult['external_userid'] ?? [];
|
||||
$nextCursor = $listResult['next_cursor'] ?? '';
|
||||
|
||||
$customers = [];
|
||||
|
||||
// 批量获取客户详情
|
||||
foreach ($externalUserIds as $externalUserId) {
|
||||
$detail = $this->getExternalContactDetail($externalUserId);
|
||||
if (!empty($detail) && isset($detail['external_contact'])) {
|
||||
$customers[] = $detail;
|
||||
}
|
||||
}
|
||||
|
||||
$result = [
|
||||
'customers' => $customers,
|
||||
'next_cursor' => $nextCursor,
|
||||
'count' => count($customers)
|
||||
];
|
||||
|
||||
// 缓存结果,有效期5分钟
|
||||
Cache::set($cacheKey, $result, 300);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 保存外部联系人到数据库
|
||||
* @param string $staffId 员工ID
|
||||
* @param string $staffName 员工姓名
|
||||
* @param array $contactInfo 联系人信息
|
||||
* @return string|false 返回added/updated,失败返回false
|
||||
*/
|
||||
private function saveExternalContact(string $staffId, string $staffName, array $contactInfo)
|
||||
{
|
||||
$externalUserId = $contactInfo['external_userid'] ?? '';
|
||||
if (empty($externalUserId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 查询是否已存在
|
||||
$model = new \app\common\model\QywxExternalContact();
|
||||
$exists = $model->where('external_user_id', $externalUserId)->find();
|
||||
|
||||
$data = [
|
||||
'external_user_id' => $externalUserId,
|
||||
'name' => $contactInfo['name'] ?? '',
|
||||
'avatar' => $contactInfo['avatar'] ?? '',
|
||||
'type' => $contactInfo['type'] ?? 1,
|
||||
'gender' => $contactInfo['gender'] ?? 0,
|
||||
'unionid' => $contactInfo['unionid'] ?? '',
|
||||
'position' => $contactInfo['position'] ?? '',
|
||||
'corp_name' => $contactInfo['corp_name'] ?? '',
|
||||
'corp_full_name' => $contactInfo['corp_full_name'] ?? '',
|
||||
'external_profile' => json_encode($contactInfo['external_profile'] ?? [], JSON_UNESCAPED_UNICODE),
|
||||
'update_time' => time()
|
||||
];
|
||||
|
||||
// 构建跟进人信息
|
||||
$followUser = [
|
||||
'userid' => $staffId,
|
||||
'name' => $staffName,
|
||||
'add_time' => time()
|
||||
];
|
||||
|
||||
if ($exists) {
|
||||
// 更新已有记录
|
||||
$existsData = json_decode($exists['follow_users'] ?? '{"followers":[]}', true);
|
||||
if (!isset($existsData['followers'])) {
|
||||
$existsData = ['followers' => []];
|
||||
}
|
||||
|
||||
// 检查是否已存在该跟进人
|
||||
$found = false;
|
||||
foreach ($existsData['followers'] as &$follower) {
|
||||
if ($follower['userid'] === $staffId) {
|
||||
$follower = $followUser;
|
||||
$found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
unset($follower);
|
||||
|
||||
if (!$found) {
|
||||
$existsData['followers'][] = $followUser;
|
||||
}
|
||||
|
||||
$data['follow_users'] = json_encode($existsData, JSON_UNESCAPED_UNICODE);
|
||||
$data['update_time'] = time();
|
||||
|
||||
$exists->save($data);
|
||||
|
||||
return 'updated';
|
||||
} else {
|
||||
// 新增记录
|
||||
$data['follow_users'] = json_encode(['followers' => [$followUser]], JSON_UNESCAPED_UNICODE);
|
||||
$data['create_time'] = time();
|
||||
|
||||
$model->save($data);
|
||||
|
||||
return 'added';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 检查应用权限(用于诊断)
|
||||
* @return array 返回应用的权限信息
|
||||
@@ -181,7 +591,85 @@ class WechatWorkService
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 创建外部客户(扫码添加)
|
||||
* @param string $staffId 员工ID
|
||||
* @param string $staffName 员工姓名
|
||||
* @param array $customerInfo 客户信息
|
||||
* @return array|false 返回创建结果,失败返回false
|
||||
*/
|
||||
public function createExternalContact(string $staffId, string $staffName, array $customerInfo)
|
||||
{
|
||||
try {
|
||||
$accessToken = $this->getContactAccessToken();
|
||||
$url = "https://qyapi.weixin.qq.com/cgi-bin/externalcontact/add_contact?access_token={$accessToken}";
|
||||
|
||||
$data = [
|
||||
"userid" => $staffId,
|
||||
"external_contact" => [
|
||||
"name" => $customerInfo['name'] ?? '未知客户',
|
||||
"mobile" => $customerInfo['mobile'] ?? '',
|
||||
"position" => $customerInfo['position'] ?? '',
|
||||
"corp_name" => $customerInfo['corp_name'] ?? '',
|
||||
"avatar" => $customerInfo['avatar'] ?? ''
|
||||
]
|
||||
];
|
||||
|
||||
Log::info('企业微信-创建外部客户请求: ' . json_encode($data, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
$response = $this->httpPost($url, json_encode($data));
|
||||
|
||||
Log::info('企业微信-创建外部客户响应: ' . json_encode($response, JSON_UNESCAPED_UNICODE));
|
||||
|
||||
if (isset($response['external_userid'])) {
|
||||
return $response;
|
||||
}
|
||||
|
||||
Log::error('创建外部客户失败: ' . json_encode($response));
|
||||
return false;
|
||||
} catch (\Exception $e) {
|
||||
Log::error('创建外部客户异常: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes HTTP POST请求
|
||||
*/
|
||||
private function httpPost(string $url, string $data): array
|
||||
{
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/json',
|
||||
'Content-Length: ' . strlen($data)
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 30);
|
||||
|
||||
$output = curl_exec($ch);
|
||||
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($httpCode !== 200) {
|
||||
Log::error('HTTP请求失败: ' . $url . ', HTTP Code: ' . $httpCode);
|
||||
return [];
|
||||
}
|
||||
|
||||
$result = json_decode($output, true);
|
||||
if (json_last_error() !== JSON_ERROR_NONE) {
|
||||
Log::error('JSON解析失败: ' . $output);
|
||||
return [];
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes HTTP GET请求
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,6 @@ return [
|
||||
// 同步现有订单快递单号
|
||||
'express:sync' => 'app\\command\\SyncTrackingNumbers',
|
||||
// 企业微信客户同步
|
||||
'qywx:sync-customer' => 'app\\command\\QywxSyncCustomer',
|
||||
'qywx:sync_customer' => 'app\\common\\command\\QywxSyncCustomer',
|
||||
],
|
||||
];
|
||||
|
||||
@@ -26,9 +26,11 @@ return [
|
||||
// 企业微信对外收款配置(参考 https://developer.work.weixin.qq.com/document/path/93665)
|
||||
// 获取对外收款记录需使用「对外收款」应用的 secret,在管理后台-应用管理-对外收款-API 中查看
|
||||
'wechat_work' => [
|
||||
'corp_id' => env('WECHAT_WORK_CORP_ID', '') ?: (string)env('work_wechat.corp_id', ''),
|
||||
'corp_id' => env('WORK_WECHAT.CORP_ID', '') ?: env('WECHAT_WORK_CORP_ID', '') ?: (string)env('work_wechat.corp_id', ''),
|
||||
// 对外收款 get_bill_list 必须使用「对外收款」应用的 Secret,在 管理后台-应用管理-对外收款-API 中查看
|
||||
'external_pay_secret' => env('WECHAT_WORK_EXTERNAL_PAY_SECRET', '') ?: (string)env('work_wechat.wechat_work_external_pay_secret', '') ?: (string)env('work_wechat.secret', ''),
|
||||
'external_pay_secret' => env('WORK_WECHAT.SECRET', '') ?: env('WECHAT_WORK_EXTERNAL_PAY_SECRET', '') ?: (string)env('work_wechat.wechat_work_external_pay_secret', '') ?: (string)env('work_wechat.secret', ''),
|
||||
'agent_id' => env('WORK_WECHAT.AGENT_ID', '') ?: env('WECHAT_WORK_AGENT_ID', ''),
|
||||
'contact_secret' => env('WORK_WECHAT.CONTACT_SECRET', '') ?: env('WECHAT_WORK_CONTACT_SECRET', ''),
|
||||
'notify_url' => env('WECHAT_WORK_NOTIFY_URL', ''),
|
||||
'mch_id' => env('WECHAT_MCH_ID', ''),
|
||||
'api_key' => env('WECHAT_API_KEY', ''),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<configuration>
|
||||
<system.webServer>
|
||||
<rewrite>
|
||||
<rules>
|
||||
<rule name="ThinkPHP Rule" stopProcessing="true">
|
||||
<match url="^(.*)$" />
|
||||
<conditions logicalGrouping="MatchAll">
|
||||
<add input="{REQUEST_FILENAME}" matchType="IsFile" negate="true" />
|
||||
<add input="{REQUEST_FILENAME}" matchType="IsDirectory" negate="true" />
|
||||
</conditions>
|
||||
<action type="Rewrite" url="index.php" />
|
||||
</rule>
|
||||
</rules>
|
||||
</rewrite>
|
||||
<httpProtocol>
|
||||
<customHeaders>
|
||||
<add name="Access-Control-Allow-Origin" value="*" />
|
||||
<add name="Access-Control-Allow-Headers" value="Authorization, Sec-Fetch-Mode, DNT, X-Mx-ReqToken, Keep-Alive, User-Agent, If-Match, If-None-Match, If-Unmodified-Since, X-Requested-With, If-Modified-Since, Cache-Control, Content-Type, Accept-Language, Origin, Accept-Encoding, Access-Token, token, version" />
|
||||
<add name="Access-Control-Allow-Methods" value="GET, POST, PUT, DELETE, OPTIONS" />
|
||||
</customHeaders>
|
||||
</httpProtocol>
|
||||
</system.webServer>
|
||||
</configuration>
|
||||
+38
-11
@@ -11,10 +11,45 @@
|
||||
use think\facade\Console;
|
||||
use think\facade\Route;
|
||||
|
||||
// 管理后台
|
||||
// 支付回调(免登录,供微信/支付宝/企业微信对外收款调用)
|
||||
Route::post('api/order/wechat-notify', 'app\adminapi\controller\order\WechatNotifyController@notify');
|
||||
Route::post('api/order/alipay-notify', 'app\adminapi\controller\order\AlipayNotifyController@notify');
|
||||
|
||||
// 腾讯云 TRTC 云端录制回调(免登录,控制台配置回调 URL;驼峰路径与控制台常见填法一致)
|
||||
Route::post('api/trtc/recording-notify', 'app\api\controller\TrtcController@recordingNotify');
|
||||
Route::post('api/trtc/recordingNotify', 'app\api\controller\TrtcController@recordingNotify');
|
||||
|
||||
// 企业微信回调(免登录)
|
||||
Route::post('adminapi/qywx.callback/handle', 'app\adminapi\controller\qywx\CallbackController@handle');
|
||||
|
||||
// 企业微信加粉管理接口
|
||||
Route::post('adminapi/qywx.qrcode/create', 'app\adminapi\controller\qywx\QrcodeController@create');
|
||||
Route::get('adminapi/qywx.stats/getAddStats', 'app\adminapi\controller\qywx\StatsController@getAddStats');
|
||||
Route::get('adminapi/qywx.qrcode/getList', 'app\adminapi\controller\qywx\QrcodeController@getList');
|
||||
Route::post('adminapi/qywx.qrcode/delete', 'app\adminapi\controller\qywx\QrcodeController@delete');
|
||||
Route::get('adminapi/qywx.qrcode/getStaffList', 'app\adminapi\controller\qywx\QrcodeController@getStaffList');
|
||||
|
||||
// 企业微信客户管理接口
|
||||
Route::get('adminapi/qywx.customer/lists', 'app\adminapi\controller\qywx\CustomerController@lists');
|
||||
Route::get('adminapi/qywx.customer/stats', 'app\adminapi\controller\qywx\CustomerController@stats');
|
||||
Route::post('adminapi/qywx.customer/import', 'app\adminapi\controller\qywx\CustomerController@import');
|
||||
Route::get('adminapi/qywx.customer/getSyncSettings', 'app\adminapi\controller\qywx\CustomerController@getSyncSettings');
|
||||
Route::post('adminapi/qywx.customer/saveSyncSettings', 'app\adminapi\controller\qywx\CustomerController@saveSyncSettings');
|
||||
Route::post('adminapi/qywx.customer/sync', 'app\adminapi\controller\qywx\CustomerController@sync');
|
||||
Route::post('adminapi/qywx.customer/asyncSync', 'app\adminapi\controller\qywx\CustomerController@asyncSync');
|
||||
Route::get('adminapi/qywx.customer/getSyncStatus', 'app\adminapi\controller\qywx\CustomerController@getSyncStatus');
|
||||
Route::get('adminapi/qywx.customer/getSyncTaskStatus', 'app\adminapi\controller\qywx\CustomerController@getSyncTaskStatus');
|
||||
Route::get('adminapi/qywx.customer/getStaffList', 'app\adminapi\controller\qywx\CustomerController@getStaffList');
|
||||
Route::get('adminapi/qywx.customer/countByTime', 'app\adminapi\controller\qywx\CustomerController@countByTime');
|
||||
Route::get('adminapi/qywx.customer/batchGetCustomers', 'app\adminapi\controller\qywx\CustomerController@batchGetCustomers');
|
||||
|
||||
// 企业微信扫码回调
|
||||
Route::get('qywx/scene/:scene', 'app\adminapi\controller\qywx\ScanController@handleScan');
|
||||
|
||||
// 管理后台(使用更严格的模式,只匹配具体的管理后台路径,不匹配adminapi)
|
||||
Route::rule('admin/:any', function () {
|
||||
return view(app()->getRootPath() . 'public/admin/index.html');
|
||||
})->pattern(['any' => '\w+']);
|
||||
})->pattern(['any' => '[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)*']);
|
||||
|
||||
// 手机端
|
||||
Route::rule('mobile/:any', function () {
|
||||
@@ -29,12 +64,4 @@ Route::rule('pc/:any', function () {
|
||||
//定时任务
|
||||
Route::rule('crontab', function () {
|
||||
Console::call('crontab');
|
||||
});
|
||||
|
||||
// 支付回调(免登录,供微信/支付宝/企业微信对外收款调用)
|
||||
Route::post('api/order/wechat-notify', 'app\adminapi\controller\order\WechatNotifyController@notify');
|
||||
Route::post('api/order/alipay-notify', 'app\adminapi\controller\order\AlipayNotifyController@notify');
|
||||
|
||||
// 腾讯云 TRTC 云端录制回调(免登录,控制台配置回调 URL;驼峰路径与控制台常见填法一致)
|
||||
Route::post('api/trtc/recording-notify', 'app\api\controller\TrtcController@recordingNotify');
|
||||
Route::post('api/trtc/recordingNotify', 'app\api\controller\TrtcController@recordingNotify');
|
||||
});
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
// 同步企业微信客户的后台脚本
|
||||
|
||||
// 设置错误报告
|
||||
error_reporting(E_ALL);
|
||||
ini_set('display_errors', 1);
|
||||
|
||||
// 设置执行时间
|
||||
set_time_limit(300);
|
||||
|
||||
// 设置内存限制
|
||||
ini_set('memory_limit', '512M');
|
||||
|
||||
// 引入框架
|
||||
require __DIR__ . '/server/thinkphp/start.php';
|
||||
|
||||
// 获取参数
|
||||
$paramFile = $argv[1] ?? '';
|
||||
$taskId = $argv[2] ?? '';
|
||||
|
||||
if (empty($paramFile) || empty($taskId)) {
|
||||
echo "Missing parameters\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// 读取参数文件
|
||||
if (!file_exists($paramFile)) {
|
||||
echo "Parameter file not found\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$params = json_decode(file_get_contents($paramFile), true);
|
||||
if (!$params) {
|
||||
echo "Invalid parameter file\n";
|
||||
exit(1);
|
||||
}
|
||||
|
||||
// 清理临时文件
|
||||
unlink($paramFile);
|
||||
|
||||
// 保存任务状态
|
||||
$cacheKey = 'qywx_sync_task_' . $taskId;
|
||||
Cache::set($cacheKey, [
|
||||
'status' => 'running',
|
||||
'progress' => 0,
|
||||
'start_time' => time(),
|
||||
'params' => $params
|
||||
], 86400);
|
||||
|
||||
try {
|
||||
$limit = $params['limit'] ?? 30;
|
||||
$fullSync = $params['full_sync'] ?? false;
|
||||
$syncMode = $params['sync_mode'] ?? 'all';
|
||||
$selectedUsers = $params['selected_users'] ?? [];
|
||||
$days = $params['days'] ?? 7;
|
||||
|
||||
$service = new \app\common\service\wechat\WechatWorkService();
|
||||
|
||||
// 1. 获取员工列表
|
||||
$userList = $service->getDepartmentUserList();
|
||||
if (empty($userList)) {
|
||||
throw new \Exception('未获取到员工列表');
|
||||
}
|
||||
|
||||
// 按同步模式过滤员工
|
||||
if ($syncMode === 'specific' && !empty($selectedUsers)) {
|
||||
$userList = array_filter($userList, function($user) use ($selectedUsers) {
|
||||
return in_array($user['userid'] ?? '', $selectedUsers);
|
||||
});
|
||||
}
|
||||
|
||||
$syncCount = 0;
|
||||
$newCount = 0;
|
||||
$updateCount = 0;
|
||||
$processedCustomers = [];
|
||||
|
||||
// 获取上次同步时间(使用缓存存储)
|
||||
$lastSyncCacheKey = 'qywx_last_sync_time';
|
||||
$lastSyncTime = Cache::get($lastSyncCacheKey, 0);
|
||||
$currentTime = time();
|
||||
|
||||
// 计算时间范围(最近N天)
|
||||
$timeLimit = $currentTime - ($days * 86400);
|
||||
|
||||
// 总任务量
|
||||
$totalTasks = count($userList);
|
||||
$currentTask = 0;
|
||||
|
||||
// 2. 遍历每个员工,获取其客户列表
|
||||
foreach ($userList as $user) {
|
||||
$userId = $user['userid'] ?? '';
|
||||
if (empty($userId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currentTask++;
|
||||
$progress = round(($currentTask / $totalTasks) * 100);
|
||||
|
||||
// 更新任务状态
|
||||
Cache::set($cacheKey, [
|
||||
'status' => 'running',
|
||||
'progress' => $progress,
|
||||
'start_time' => $currentTime,
|
||||
'current_user' => $userId,
|
||||
'params' => $params
|
||||
], 86400);
|
||||
|
||||
// 获取该成员的客户列表(支持分页)
|
||||
$cursor = '';
|
||||
$hasMore = true;
|
||||
|
||||
while ($hasMore && $syncCount < $limit) {
|
||||
$customerResult = $service->getExternalContactList($userId, $cursor, 100);
|
||||
if (empty($customerResult) || empty($customerResult['external_userid'])) {
|
||||
break;
|
||||
}
|
||||
|
||||
$customerList = $customerResult['external_userid'] ?? [];
|
||||
$cursor = $customerResult['next_cursor'] ?? '';
|
||||
$hasMore = !empty($cursor);
|
||||
|
||||
// 3. 处理客户,限制总数量
|
||||
foreach ($customerList as $externalUserId) {
|
||||
// 达到限制数量,停止同步
|
||||
if ($syncCount >= $limit) {
|
||||
$hasMore = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// 避免重复处理同一个客户
|
||||
if (isset($processedCustomers[$externalUserId])) {
|
||||
continue;
|
||||
}
|
||||
$processedCustomers[$externalUserId] = true;
|
||||
|
||||
// 增量同步:检查客户是否已存在且更新时间较新
|
||||
if (!$fullSync) {
|
||||
$existing = \app\common\model\QywxExternalContact::where('external_user_id', $externalUserId)->find();
|
||||
if ($existing && $existing->update_time && strtotime($existing->update_time) > time() - 86400) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取客户详情
|
||||
$detail = $service->getExternalContactDetail($externalUserId);
|
||||
if (empty($detail)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$externalContact = $detail['external_contact'] ?? [];
|
||||
$followUsers = $detail['follow_user'] ?? [];
|
||||
|
||||
// 获取客户实际添加时间
|
||||
$createTime = time();
|
||||
if (!empty($followUsers)) {
|
||||
// 取第一个跟进人的添加时间
|
||||
$firstFollow = reset($followUsers);
|
||||
if (isset($firstFollow['create_time'])) {
|
||||
$createTime = $firstFollow['create_time'];
|
||||
}
|
||||
}
|
||||
|
||||
// 增量同步:只同步上次同步时间之后的客户
|
||||
if (!$fullSync && $createTime <= $lastSyncTime) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 时间范围过滤:只同步最近N天的客户
|
||||
if ($createTime < $timeLimit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 保存或更新客户信息
|
||||
$data = [
|
||||
'external_user_id' => $externalContact['external_userid'] ?? '',
|
||||
'name' => $externalContact['name'] ?? '',
|
||||
'avatar' => $externalContact['avatar'] ?? '',
|
||||
'type' => $externalContact['type'] ?? 1,
|
||||
'gender' => $externalContact['gender'] ?? 0,
|
||||
'unionid' => $externalContact['unionid'] ?? '',
|
||||
'position' => $externalContact['position'] ?? '',
|
||||
'corp_name' => $externalContact['corp_name'] ?? '',
|
||||
'corp_full_name' => $externalContact['corp_full_name'] ?? '',
|
||||
'external_profile' => json_encode($externalContact['external_profile'] ?? [], JSON_UNESCAPED_UNICODE),
|
||||
'follow_users' => json_encode($followUsers, JSON_UNESCAPED_UNICODE),
|
||||
];
|
||||
|
||||
// 处理添加人信息
|
||||
if (!empty($followUsers)) {
|
||||
// 取第一个跟进人作为添加人
|
||||
$firstFollow = reset($followUsers);
|
||||
if (isset($firstFollow['userid']) && isset($firstFollow['name'])) {
|
||||
$data['first_staff_id'] = $firstFollow['userid'];
|
||||
$data['first_staff_name'] = $firstFollow['name'];
|
||||
$data['first_add_time'] = date('Y-m-d H:i:s', $createTime);
|
||||
$data['last_staff_id'] = $firstFollow['userid'];
|
||||
$data['last_staff_name'] = $firstFollow['name'];
|
||||
$data['last_add_time'] = date('Y-m-d H:i:s', $createTime);
|
||||
}
|
||||
}
|
||||
|
||||
$existing = \app\common\model\QywxExternalContact::where('external_user_id', $data['external_user_id'])->find();
|
||||
if ($existing) {
|
||||
$existing->save($data);
|
||||
$updateCount++;
|
||||
} else {
|
||||
\app\common\model\QywxExternalContact::create($data);
|
||||
$newCount++;
|
||||
}
|
||||
|
||||
$syncCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// 达到限制数量,停止同步
|
||||
if ($syncCount >= $limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存本次同步时间(使用缓存存储)
|
||||
Cache::set($lastSyncCacheKey, $currentTime, 86400 * 30); // 缓存30天
|
||||
|
||||
// 更新任务状态
|
||||
Cache::set($cacheKey, [
|
||||
'status' => 'completed',
|
||||
'progress' => 100,
|
||||
'start_time' => $currentTime,
|
||||
'end_time' => time(),
|
||||
'sync_count' => $syncCount,
|
||||
'new_count' => $newCount,
|
||||
'update_count' => $updateCount,
|
||||
'params' => $params
|
||||
], 86400);
|
||||
|
||||
echo "Sync completed: {$syncCount} records processed\n";
|
||||
exit(0);
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
// 更新任务状态为失败
|
||||
Cache::set($cacheKey, [
|
||||
'status' => 'failed',
|
||||
'progress' => 0,
|
||||
'error' => $e->getMessage(),
|
||||
'params' => $params
|
||||
], 86400);
|
||||
|
||||
echo "Sync failed: {$e->getMessage()}\n";
|
||||
exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user