This commit is contained in:
Your Name
2026-04-18 15:54:14 +08:00
parent fa3da15228
commit 4097fe0cc6
271 changed files with 1257 additions and 369 deletions
+151 -25
View File
@@ -5,7 +5,7 @@
<div class="flex items-center justify-between">
<span class="text-lg font-semibold">企业微信客户管理</span>
<div class="flex gap-2">
<el-button type="primary" :loading="syncing" @click="syncCustomers">
<el-button type="primary" :loading="syncing" @click="syncCustomers()">
<template #icon><Refresh /></template>
立即同步
</el-button>
@@ -44,7 +44,7 @@
</el-card>
<el-card shadow="hover">
<div class="text-gray-500 text-sm mb-1">同步状态</div>
<el-tag :type="stats.syncStatus === 'success' ? 'success' : 'info'" size="small">
<el-tag :type="syncStatusTagType" size="small">
{{ stats.syncStatusText }}
</el-tag>
</el-card>
@@ -100,21 +100,24 @@
<el-table-column label="跟进人" min-width="150">
<template #default="{ row }">
<div v-if="row.follow_users && row.follow_users.length">
<el-tag
<el-tooltip
v-for="(user, idx) in row.follow_users"
:key="idx"
size="small"
class="mr-1 mb-1"
:key="(user.userid || '') + '-' + idx"
:disabled="!followStaffTooltip(user)"
:content="followStaffTooltip(user)"
placement="top"
>
{{ user.name }}
</el-tag>
<el-tag size="small" class="mr-1 mb-1">
{{ formatFollowUser(user) }}
</el-tag>
</el-tooltip>
</div>
<span v-else class="text-gray-400"></span>
</template>
</el-table-column>
<el-table-column label="添加时间" width="160">
<template #default="{ row }">
{{ formatTime(row.create_time) }}
{{ formatTime(firstExternalAddTime(row)) }}
</template>
</el-table-column>
<el-table-column label="更新时间" width="160">
@@ -186,18 +189,34 @@
{{ currentCustomer.position || '—' }}
</el-descriptions-item>
<el-descriptions-item label="添加时间" :span="2">
{{ formatTime(currentCustomer.create_time) }}
{{ formatTime(firstExternalAddTime(currentCustomer)) }}
</el-descriptions-item>
<el-descriptions-item label="更新时间" :span="2">
{{ formatTime(currentCustomer.update_time) }}
</el-descriptions-item>
<el-descriptions-item label="跟进人" :span="2">
<div v-if="currentCustomer.follow_users?.length">
<el-tooltip
v-for="(user, idx) in currentCustomer.follow_users"
:key="(user.userid || '') + '-' + idx"
:disabled="!followStaffTooltip(user)"
:content="followStaffTooltip(user)"
placement="top"
>
<el-tag size="small" class="mr-1 mb-1">
{{ formatFollowUser(user) }}
</el-tag>
</el-tooltip>
</div>
<span v-else class="text-gray-400"></span>
</el-descriptions-item>
</el-descriptions>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { Refresh, Setting } from '@element-plus/icons-vue'
import { usePaging } from '@/hooks/usePaging'
import feedback from '@/utils/feedback'
@@ -222,6 +241,19 @@ const stats = reactive({
syncStatusText: '未同步'
})
const syncStatusTagType = computed(() => {
switch (stats.syncStatus) {
case 'success':
return 'success'
case 'syncing':
return 'warning'
case 'failed':
return 'danger'
default:
return 'info'
}
})
const syncSettings = reactive({
auto_sync: false,
interval: 3600
@@ -233,6 +265,15 @@ const queryParams = reactive({
})
let syncTimer: number | null = null
/** 后台同步完成后轮询统计,直到非「同步中」 */
let pollSyncTimer: number | null = null
function stopPollSync() {
if (pollSyncTimer !== null) {
clearInterval(pollSyncTimer)
pollSyncTimer = null
}
}
async function fetchLists(params: Record<string, unknown>) {
return qywxCustomerLists(params)
@@ -249,16 +290,45 @@ function handleReset() {
resetParams()
}
async function syncCustomers() {
syncing.value = true
try {
await qywxCustomerSync()
feedback.msgSuccess('同步成功')
function startPollSyncUntilIdle() {
stopPollSync()
let ticks = 0
pollSyncTimer = window.setInterval(async () => {
ticks += 1
await loadStats()
await getLists()
if (stats.syncStatus !== 'syncing' || ticks >= 120) {
stopPollSync()
syncing.value = false
if (ticks >= 120 && stats.syncStatus === 'syncing') {
feedback.msgWarning('长时间仍为「同步中」,请刷新页面或查看服务器 runtime/log/qywx_sync.log(可能未安装 CLI php 或禁用了 exec')
} else if (stats.syncStatus === 'failed') {
feedback.msgError('同步失败,请查看服务器 runtime/log/qywx_sync.log 或后台同步状态')
}
}
}, 3000)
}
async function syncCustomers(options?: { silent?: boolean }) {
const silent = options?.silent === true
syncing.value = true
try {
const res: any = await qywxCustomerSync()
if (!silent) {
feedback.msgSuccess(res?.message || '操作成功')
}
await loadStats()
await getLists()
if (res?.background) {
startPollSyncUntilIdle()
} else {
syncing.value = false
}
} catch (e: any) {
feedback.msgError(e?.message || '同步失败')
} finally {
const msg = e?.message || e?.msg || '同步失败'
if (!silent || !String(msg).includes('正在执行')) {
feedback.msgError(msg)
}
syncing.value = false
}
}
@@ -266,7 +336,9 @@ async function syncCustomers() {
async function loadStats() {
try {
const res: any = await qywxCustomerStats()
Object.assign(stats, res.data || res)
if (res && typeof res === 'object') {
Object.assign(stats, res)
}
} catch (e) {
console.error('加载统计失败', e)
}
@@ -275,7 +347,9 @@ async function loadStats() {
async function loadSyncSettings() {
try {
const res: any = await qywxSyncSettingsGet()
Object.assign(syncSettings, res.data || res)
if (res && typeof res === 'object') {
Object.assign(syncSettings, res)
}
// 如果开启了自动同步,启动定时器
if (syncSettings.auto_sync) {
@@ -306,7 +380,7 @@ function startSyncTimer() {
if (syncTimer) return
syncTimer = window.setInterval(() => {
syncCustomers()
syncCustomers({ silent: true })
}, syncSettings.interval * 1000)
}
@@ -322,10 +396,61 @@ function viewDetail(row: any) {
showDetail.value = true
}
function formatTime(timestamp: number | string) {
if (!timestamp) return '—'
const date = new Date(typeof timestamp === 'number' ? timestamp * 1000 : timestamp)
return date.toLocaleString('zh-CN')
/** 列表接口会写入 admin_nameadmin.work_wechat_userid = userid */
function formatFollowUser(user: Record<string, any>) {
const adminName = String(user?.admin_name ?? '').trim()
const id = String(user?.userid ?? '').trim()
const name = String(user?.name ?? '').trim()
if (adminName) return adminName
if (name && id) return `${name}${id}`
if (name) return name
if (id) return id
return '—'
}
function followStaffTooltip(user: Record<string, any>) {
const adminName = String(user?.admin_name ?? '').trim()
const id = String(user?.userid ?? '').trim()
const parts: string[] = []
if (adminName) parts.push('后台:' + adminName)
if (id) parts.push('企微:' + id)
return parts.join('')
}
/**
* 添加时间:优先接口字段 external_first_add_time(同步写入 + 列表对未回填行按 JSON 兜底);
* 再解析 follow_users;最后退回 create_time
*/
function firstExternalAddTime(row: Record<string, any> | null | undefined): number | string | null {
if (!row) return null
const fromApi = Number(row.external_first_add_time ?? 0)
if (fromApi > 0) return fromApi
const users = row.follow_users
if (Array.isArray(users) && users.length > 0) {
let min = 0
for (const u of users) {
const t = Number(u?.createtime ?? 0)
if (t > 0 && (min === 0 || t < min)) min = t
}
if (min > 0) return min
}
return row.create_time ?? null
}
function formatTime(timestamp: number | string | null | undefined) {
if (timestamp === '' || timestamp === null || timestamp === undefined) return '—'
const n = Number(timestamp)
const isNumericString = typeof timestamp === 'string' && /^\d+$/.test(timestamp.trim())
let ms: number
if (typeof timestamp === 'number') {
ms = timestamp < 1e12 ? timestamp * 1000 : timestamp
} else if (!Number.isNaN(n) && isNumericString) {
ms = n < 1e12 ? n * 1000 : n
} else {
ms = new Date(timestamp).getTime()
}
if (Number.isNaN(ms)) return '—'
return new Date(ms).toLocaleString('zh-CN')
}
onMounted(() => {
@@ -336,6 +461,7 @@ onMounted(() => {
onUnmounted(() => {
stopSyncTimer()
stopPollSync()
})
</script>
@@ -48,6 +48,19 @@
min-width="150"
show-overflow-tooltip
/>
<el-table-column
label="人数"
min-width="100"
align="center"
show-overflow-tooltip
>
<template #header>
<span title="含本部门及所有下级部门的管理员数">人数含下级</span>
</template>
<template #default="{ row }">
{{ row.admin_count ?? 0 }}
</template>
</el-table-column>
<el-table-column label="部门状态" prop="status" min-width="100">
<template #default="{ row }">
<el-tag class="ml-2" :type="row.status ? 'primary' : 'danger'">{{