更新
This commit is contained in:
+151
-25
@@ -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_name(admin.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'">{{
|
||||
|
||||
Reference in New Issue
Block a user