Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58cc59cf8a | ||
|
|
796441b407 |
Binary file not shown.
@@ -116,17 +116,6 @@
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="goto('/training/pages/camera-grip-ring')">
|
||||
<view class="menu-icon menu-icon--grip">
|
||||
<view class="grip-ring-shape" />
|
||||
</view>
|
||||
<view class="menu-info">
|
||||
<text class="menu-title">握力环 2.0</text>
|
||||
<text class="menu-sub">摄像头识别 · 试玩</text>
|
||||
</view>
|
||||
<view class="menu-arrow">›</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-item" @click="goto('/training/pages/metronome')">
|
||||
<view class="menu-icon menu-icon--metro">
|
||||
<view class="metro-bar metro-bar-1" />
|
||||
@@ -174,7 +163,6 @@ const trainingItems: TrainingItem[] = [
|
||||
{ url: '/training/pages/foot-pedal', title: '脚蹬器', sub: '下肢有氧', icon: 'pedal', iconClass: 'menu-icon--pedal' },
|
||||
{ url: '/training/pages/pilates-ring', title: '瑜伽环', sub: '核心塑形', icon: 'pilates', iconClass: 'menu-icon--pilates' },
|
||||
{ url: '/training/pages/grip-ring', title: '握力环', sub: '握力训练', icon: 'grip', iconClass: 'menu-icon--grip' },
|
||||
{ url: '/training/pages/camera-grip-ring', title: '握力环 2.0', sub: '摄像头识别', icon: 'grip', iconClass: 'menu-icon--grip' },
|
||||
{ url: '/training/pages/metronome', title: '耗糖节拍器', sub: '健走配速', icon: 'metro', iconClass: 'menu-icon--metro', featured: true },
|
||||
]
|
||||
|
||||
|
||||
@@ -122,17 +122,6 @@
|
||||
"backgroundColor": "#f8fafc"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/camera-grip-ring",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "握力环训练 2.0",
|
||||
"navigationBarBackgroundColor": "#F8FAFC",
|
||||
"navigationBarTextStyle": "black",
|
||||
"backgroundColor": "#F8FAFC",
|
||||
"disableScroll": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/pilates-ring",
|
||||
"style": {
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { CameraFrame } from './types'
|
||||
|
||||
type FrameListener = {
|
||||
start: () => void
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
type CameraContextLike = {
|
||||
onCameraFrame?: (cb: (frame: { width: number; height: number; data: ArrayBuffer }) => void) => FrameListener
|
||||
}
|
||||
|
||||
function createContext(cameraId?: string): CameraContextLike | null {
|
||||
try {
|
||||
// uni-app 优先:可按 camera 组件 id 绑定
|
||||
if (typeof uni !== 'undefined' && typeof uni.createCameraContext === 'function') {
|
||||
return uni.createCameraContext(cameraId || 'gripCamera') as CameraContextLike
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
// @ts-ignore
|
||||
if (typeof wx !== 'undefined' && typeof wx.createCameraContext === 'function') {
|
||||
// @ts-ignore
|
||||
return wx.createCameraContext() as CameraContextLike
|
||||
}
|
||||
} catch (_) {}
|
||||
// #endif
|
||||
return null
|
||||
}
|
||||
|
||||
export function isCameraFrameSupported(): boolean {
|
||||
try {
|
||||
const ctx = createContext('gripCamera')
|
||||
return typeof ctx?.onCameraFrame === 'function'
|
||||
} catch (_) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 CameraContext.onCameraFrame:只保留最新一帧,不入 Vue 响应式。
|
||||
*/
|
||||
export function createCameraFramePump(options?: { cameraId?: string }) {
|
||||
let listener: FrameListener | null = null
|
||||
let latest: CameraFrame | null = null
|
||||
let running = false
|
||||
let callbackCount = 0
|
||||
let fpsWindowStart = Date.now()
|
||||
let fps = 0
|
||||
let onFrame: ((frame: CameraFrame) => void) | null = null
|
||||
|
||||
function start(cb: (frame: CameraFrame) => void) {
|
||||
stop()
|
||||
onFrame = cb
|
||||
const ctx = createContext(options?.cameraId || 'gripCamera')
|
||||
if (!ctx?.onCameraFrame) {
|
||||
throw new Error('当前基础库不支持 onCameraFrame')
|
||||
}
|
||||
|
||||
listener = ctx.onCameraFrame((raw) => {
|
||||
if (!running) return
|
||||
const frame: CameraFrame = {
|
||||
width: raw.width,
|
||||
height: raw.height,
|
||||
data: raw.data,
|
||||
ts: Date.now(),
|
||||
}
|
||||
latest = frame
|
||||
callbackCount += 1
|
||||
const now = Date.now()
|
||||
if (now - fpsWindowStart >= 1000) {
|
||||
fps = callbackCount
|
||||
callbackCount = 0
|
||||
fpsWindowStart = now
|
||||
}
|
||||
onFrame?.(frame)
|
||||
})
|
||||
running = true
|
||||
listener.start()
|
||||
}
|
||||
|
||||
function stop() {
|
||||
running = false
|
||||
try {
|
||||
listener?.stop()
|
||||
} catch (_) {}
|
||||
listener = null
|
||||
onFrame = null
|
||||
}
|
||||
|
||||
function getLatest() {
|
||||
return latest
|
||||
}
|
||||
|
||||
function getFps() {
|
||||
return fps
|
||||
}
|
||||
|
||||
function isRunning() {
|
||||
return running
|
||||
}
|
||||
|
||||
return { start, stop, getLatest, getFps, isRunning }
|
||||
}
|
||||
|
||||
export type CameraFramePump = ReturnType<typeof createCameraFramePump>
|
||||
@@ -1,186 +0,0 @@
|
||||
import { createCameraFramePump } from './camera-frames'
|
||||
import { createOpenCloseClassifier } from './open-close-classifier'
|
||||
import { estimateOpennessFromRoi } from './roi-openness'
|
||||
import type { GestureObservation, GestureSource, RepCompletedInfo } from './types'
|
||||
import {
|
||||
createVisionKitHandDetector,
|
||||
getVisionKitSupportHint,
|
||||
isLikelyVisionKitUnsupportedHost,
|
||||
isVisionKitHandSupported,
|
||||
} from './visionkit-hand'
|
||||
|
||||
export interface GesturePipelineHandlers {
|
||||
onObservation: (obs: GestureObservation) => void
|
||||
onRepCompleted?: (info: RepCompletedInfo) => void
|
||||
/** 非致命提示(如已降级 ROI) */
|
||||
onWarning?: (message: string) => void
|
||||
/** 致命错误(相机帧都起不来) */
|
||||
onError?: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 编排:优先 VisionKit;开发者工具/不支持设备自动降级 ROI 联调。
|
||||
*/
|
||||
export function createGesturePipeline(handlers: GesturePipelineHandlers) {
|
||||
const pump = createCameraFramePump()
|
||||
// ~10Hz;置信度适中,减少噪声帧
|
||||
const detector = createVisionKitHandDetector({ minIntervalMs: 100, scoreThreshold: 0.28 })
|
||||
// 握力环校准:半开即可计张开,相对抬升即可完成松开
|
||||
const classifier = createOpenCloseClassifier({
|
||||
openEnter: 0.45,
|
||||
openExit: 0.38,
|
||||
closeEnter: 0.36,
|
||||
closeExit: 0.42,
|
||||
emaAlpha: 0.42,
|
||||
minHoldMs: 120,
|
||||
lostMs: 1400,
|
||||
reopenRise: 0.08,
|
||||
})
|
||||
|
||||
let active = false
|
||||
let paused = false
|
||||
let mode: GestureSource = 'none'
|
||||
let lastRoiTs = 0
|
||||
let lastUiEmitTs = 0
|
||||
let lastEmittedPhase = ''
|
||||
let lastEmittedCompression = -1
|
||||
let lastEmittedHold = -1
|
||||
let lastSyntheticTs = 0
|
||||
const UI_EMIT_MIN_MS = 100
|
||||
|
||||
function emitFromSample(sample: Parameters<typeof classifier.update>[0]) {
|
||||
if (!active || paused) return
|
||||
const out = classifier.update(sample)
|
||||
const now = Date.now()
|
||||
const phaseChanged = out.phase !== lastEmittedPhase
|
||||
const due = now - lastUiEmitTs >= UI_EMIT_MIN_MS
|
||||
const compressionDelta = Math.abs(out.compression - lastEmittedCompression) >= 0.08
|
||||
const holdDelta = Math.abs(out.holdProgress - lastEmittedHold) >= 0.12
|
||||
|
||||
// 计次始终即时;UI 观察节流,避免小程序每帧 setData 卡顿
|
||||
if (out.rep) {
|
||||
handlers.onRepCompleted?.(out.rep)
|
||||
}
|
||||
|
||||
if (!phaseChanged && !due && !compressionDelta && !holdDelta && !out.rep) {
|
||||
return
|
||||
}
|
||||
|
||||
lastUiEmitTs = now
|
||||
lastEmittedPhase = out.phase
|
||||
lastEmittedCompression = out.compression
|
||||
lastEmittedHold = out.holdProgress
|
||||
|
||||
handlers.onObservation({
|
||||
ts: now,
|
||||
openness: out.openness,
|
||||
compression: out.compression,
|
||||
phase: out.phase,
|
||||
trackingQuality: out.trackingQuality,
|
||||
source: out.source,
|
||||
holdProgress: out.holdProgress,
|
||||
fps: pump.getFps(),
|
||||
})
|
||||
}
|
||||
|
||||
async function startVisionKit(): Promise<boolean> {
|
||||
if (!isVisionKitHandSupported()) return false
|
||||
try {
|
||||
await detector.start((sample) => emitFromSample(sample))
|
||||
mode = 'visionkit'
|
||||
return true
|
||||
} catch (_) {
|
||||
detector.stop()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function startRoiFallback(reason?: string) {
|
||||
mode = 'roi'
|
||||
const hint = reason || getVisionKitSupportHint() || '已降级为 ROI 联调模式(非真机手势识别)'
|
||||
handlers.onWarning?.(hint)
|
||||
pump.start((frame) => {
|
||||
if (!active || paused) return
|
||||
const now = Date.now()
|
||||
if (now - lastRoiTs < 160) return
|
||||
lastRoiTs = now
|
||||
emitFromSample(estimateOpennessFromRoi(frame))
|
||||
})
|
||||
}
|
||||
|
||||
async function start() {
|
||||
stop()
|
||||
|
||||
// 开发者工具直接走 ROI,避免 createVKSession 抛 “does not support version v1”
|
||||
const forceRoi = isLikelyVisionKitUnsupportedHost()
|
||||
|
||||
try {
|
||||
if (!forceRoi) {
|
||||
const vkOk = await startVisionKit()
|
||||
if (vkOk) {
|
||||
active = true
|
||||
paused = false
|
||||
pump.start((frame) => {
|
||||
if (!active || paused) return
|
||||
detector.pushFrame(frame)
|
||||
// 手离开画面时 VK 可能完全静默:补发“无手”样本,
|
||||
// 让状态机能正常走丢失→UNTRACKED,回来后能重新武装
|
||||
const latest = detector.getLatest()
|
||||
const now = Date.now()
|
||||
if ((!latest || now - latest.ts > 500) && now - lastSyntheticTs > 250) {
|
||||
lastSyntheticTs = now
|
||||
emitFromSample(null)
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// VisionKit 不可用:ROI 回退,保证链路可联调
|
||||
active = true
|
||||
paused = false
|
||||
startRoiFallback(forceRoi ? getVisionKitSupportHint() : 'VisionKit 启动失败,已降级 ROI 联调')
|
||||
return true
|
||||
} catch (e: any) {
|
||||
handlers.onError?.(e?.message || '手势管线启动失败')
|
||||
stop()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function setPaused(v: boolean) {
|
||||
paused = v
|
||||
}
|
||||
|
||||
function setNearGoal(enabled: boolean) {
|
||||
classifier.setNearGoal(enabled)
|
||||
}
|
||||
|
||||
function stop() {
|
||||
active = false
|
||||
paused = false
|
||||
mode = 'none'
|
||||
lastRoiTs = 0
|
||||
lastUiEmitTs = 0
|
||||
lastEmittedPhase = ''
|
||||
lastEmittedCompression = -1
|
||||
lastEmittedHold = -1
|
||||
lastSyntheticTs = 0
|
||||
pump.stop()
|
||||
detector.stop()
|
||||
classifier.setNearGoal(false)
|
||||
classifier.reset()
|
||||
}
|
||||
|
||||
function isActive() {
|
||||
return active
|
||||
}
|
||||
|
||||
function getMode() {
|
||||
return mode
|
||||
}
|
||||
|
||||
return { start, stop, setPaused, setNearGoal, isActive, getMode }
|
||||
}
|
||||
|
||||
export type GesturePipeline = ReturnType<typeof createGesturePipeline>
|
||||
@@ -1,189 +0,0 @@
|
||||
import type { GesturePhase, GestureSource, HandSample, RepCompletedInfo, TrackingQuality } from './types'
|
||||
|
||||
export interface ClassifierOutput {
|
||||
phase: GesturePhase
|
||||
trackingQuality: TrackingQuality
|
||||
openness: number
|
||||
compression: number
|
||||
holdProgress: number
|
||||
source: GestureSource
|
||||
rep?: RepCompletedInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 开合状态机(握力环校准版)
|
||||
* 计数:OPEN → CLOSING → CLOSED(保持) → REOPENING → 相对张开,才 +1
|
||||
* 松开不必完全张开:相对握紧谷底抬升即可计次,避免做到后期张不开导致卡在 29
|
||||
*/
|
||||
export function createOpenCloseClassifier(options?: {
|
||||
openEnter?: number
|
||||
openExit?: number
|
||||
closeEnter?: number
|
||||
closeExit?: number
|
||||
minHoldMs?: number
|
||||
lostMs?: number
|
||||
emaAlpha?: number
|
||||
/** 相对松开:相对握紧谷底至少抬升这么多才计 1 次 */
|
||||
reopenRise?: number
|
||||
}) {
|
||||
const openEnter = options?.openEnter ?? 0.48
|
||||
const openExit = options?.openExit ?? 0.4
|
||||
const closeEnter = options?.closeEnter ?? 0.38
|
||||
const closeExit = options?.closeExit ?? 0.44
|
||||
const minHoldMs = options?.minHoldMs ?? 140
|
||||
const lostMs = options?.lostMs ?? 1200
|
||||
const emaAlpha = options?.emaAlpha ?? 0.4
|
||||
const reopenRise = options?.reopenRise ?? 0.1
|
||||
|
||||
let phase: GesturePhase = 'UNTRACKED'
|
||||
let openness = 0.5
|
||||
let holdStartedAt = 0
|
||||
let holdProgress = 0
|
||||
let lastSeenAt = 0
|
||||
let opennessPeak = 0
|
||||
let closedTrough = 1
|
||||
let source: GestureSource = 'none'
|
||||
let lastRepTs = 0
|
||||
|
||||
let reopenRiseActive = reopenRise
|
||||
|
||||
function resetPartial() {
|
||||
holdStartedAt = 0
|
||||
holdProgress = 0
|
||||
opennessPeak = 0
|
||||
closedTrough = 1
|
||||
}
|
||||
|
||||
/** 接近目标次数时进一步放宽松开判定 */
|
||||
function setNearGoal(enabled: boolean) {
|
||||
reopenRiseActive = enabled ? 0.045 : reopenRise
|
||||
}
|
||||
|
||||
function canCountRep(now: number) {
|
||||
// 防连发:两次有效计次至少间隔 280ms
|
||||
return now - lastRepTs >= 280
|
||||
}
|
||||
|
||||
function update(sample: HandSample | null, now = Date.now()): ClassifierOutput {
|
||||
let rep: RepCompletedInfo | undefined
|
||||
|
||||
if (!sample || sample.score <= 0.05) {
|
||||
if (lastSeenAt && now - lastSeenAt > lostMs) {
|
||||
phase = 'UNTRACKED'
|
||||
resetPartial()
|
||||
}
|
||||
const withinGrace = lastSeenAt > 0 && now - lastSeenAt <= 600
|
||||
return {
|
||||
phase,
|
||||
trackingQuality: phase === 'UNTRACKED' ? 'lost' : withinGrace ? 'good' : 'weak',
|
||||
openness,
|
||||
compression: 1 - openness,
|
||||
holdProgress,
|
||||
source,
|
||||
rep,
|
||||
}
|
||||
}
|
||||
|
||||
lastSeenAt = now
|
||||
source = sample.source
|
||||
openness = openness * (1 - emaAlpha) + sample.openness * emaAlpha
|
||||
const quality: TrackingQuality = sample.score >= 0.22 ? 'good' : 'weak'
|
||||
|
||||
switch (phase) {
|
||||
case 'UNTRACKED':
|
||||
if (openness >= openEnter) {
|
||||
phase = 'OPEN_READY'
|
||||
opennessPeak = openness
|
||||
resetPartial()
|
||||
}
|
||||
break
|
||||
|
||||
case 'OPEN_READY':
|
||||
opennessPeak = Math.max(opennessPeak, openness)
|
||||
if (openness <= closeEnter) {
|
||||
phase = 'CLOSING'
|
||||
closedTrough = openness
|
||||
}
|
||||
break
|
||||
|
||||
case 'CLOSING':
|
||||
closedTrough = Math.min(closedTrough, openness)
|
||||
if (openness <= closeEnter) {
|
||||
phase = 'CLOSED_CONFIRMED'
|
||||
holdStartedAt = now
|
||||
holdProgress = 0
|
||||
closedTrough = openness
|
||||
} else if (openness >= openEnter) {
|
||||
phase = 'OPEN_READY'
|
||||
}
|
||||
break
|
||||
|
||||
case 'CLOSED_CONFIRMED': {
|
||||
closedTrough = Math.min(closedTrough, openness)
|
||||
const held = now - holdStartedAt
|
||||
holdProgress = Math.min(1, held / minHoldMs)
|
||||
if (held >= minHoldMs && openness >= closeExit) {
|
||||
phase = 'REOPENING'
|
||||
} else if (openness >= openEnter && held < minHoldMs) {
|
||||
// 保持不足就完全张开:作废本轮
|
||||
phase = 'OPEN_READY'
|
||||
resetPartial()
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'REOPENING': {
|
||||
closedTrough = Math.min(closedTrough, openness)
|
||||
const rise = openness - closedTrough
|
||||
// 绝对张开,或相对握紧谷底抬升足够 → 计 1 次
|
||||
const reopened = openness >= openEnter || (rise >= reopenRiseActive && openness >= closeExit)
|
||||
if (reopened && canCountRep(now)) {
|
||||
lastRepTs = now
|
||||
rep = {
|
||||
ts: now,
|
||||
holdMs: holdStartedAt ? now - holdStartedAt : 0,
|
||||
opennessPeak,
|
||||
source,
|
||||
}
|
||||
phase = 'OPEN_READY'
|
||||
resetPartial()
|
||||
opennessPeak = openness
|
||||
} else if (openness <= closeEnter) {
|
||||
// 松开中又握回去:回到保持,不直接作废
|
||||
phase = 'CLOSED_CONFIRMED'
|
||||
holdStartedAt = now
|
||||
holdProgress = 0
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
phase,
|
||||
trackingQuality: quality,
|
||||
openness,
|
||||
compression: 1 - openness,
|
||||
holdProgress,
|
||||
source,
|
||||
rep,
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
phase = 'UNTRACKED'
|
||||
openness = 0.5
|
||||
source = 'none'
|
||||
lastSeenAt = 0
|
||||
lastRepTs = 0
|
||||
reopenRiseActive = reopenRise
|
||||
resetPartial()
|
||||
}
|
||||
|
||||
function getPhase() {
|
||||
return phase
|
||||
}
|
||||
|
||||
return { update, reset, getPhase, setNearGoal }
|
||||
}
|
||||
|
||||
export type OpenCloseClassifier = ReturnType<typeof createOpenCloseClassifier>
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { HandSample, Point2D } from './types'
|
||||
|
||||
/** VisionKit 文档:1=布/开掌,3=握拳 */
|
||||
const GESTURE_OPEN = 1
|
||||
const GESTURE_FIST = 3
|
||||
|
||||
/**
|
||||
* OpenPose-21 指尖索引(相对手腕距离用于连续 openness)
|
||||
* 0 wrist, 4 thumb tip, 8 index, 12 middle, 16 ring, 20 pinky
|
||||
*/
|
||||
const TIP_INDEXES = [4, 8, 12, 16, 20]
|
||||
|
||||
function dist(a: Point2D, b: Point2D) {
|
||||
const dx = a.x - b.x
|
||||
const dy = a.y - b.y
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
function clamp01(v: number) {
|
||||
return Math.max(0, Math.min(1, v))
|
||||
}
|
||||
|
||||
/** 离散手势 → 开合度;未知手势返回 null(交给几何或保持) */
|
||||
export function opennessFromGesture(gesture?: number): number | null {
|
||||
if (gesture === GESTURE_OPEN) return 1
|
||||
if (gesture === GESTURE_FIST) return 0
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 用指尖到手腕的平均距离 / 掌宽 估计 openness。
|
||||
* 握力环场景开合幅度偏小,映射区间比裸掌更紧一些。
|
||||
*/
|
||||
export function opennessFromLandmarks(points?: Point2D[]): number | null {
|
||||
if (!points || points.length < 21) return null
|
||||
const wrist = points[0]
|
||||
const mcpIndex = points[5]
|
||||
const mcpPinky = points[17]
|
||||
const palmWidth = dist(mcpIndex, mcpPinky)
|
||||
if (palmWidth < 1e-6) return null
|
||||
|
||||
let tipSum = 0
|
||||
for (const i of TIP_INDEXES) {
|
||||
tipSum += dist(points[i], wrist)
|
||||
}
|
||||
const tipMean = tipSum / TIP_INDEXES.length
|
||||
// 握力环:握紧约 0.9~1.35 掌宽,半开约 1.5~2.2 掌宽
|
||||
const ratio = tipMean / palmWidth
|
||||
return clamp01((ratio - 0.95) / 1.15)
|
||||
}
|
||||
|
||||
export function mergeOpenness(sample: Pick<HandSample, 'gesture' | 'points'>): number | null {
|
||||
const fromGeom = opennessFromLandmarks(sample.points)
|
||||
const fromGesture = opennessFromGesture(sample.gesture)
|
||||
|
||||
if (fromGeom != null && fromGesture != null) {
|
||||
// 几何为主:离散手势(布/拳)在捏环时容易误判,只做轻牵引
|
||||
return clamp01(fromGeom * 0.85 + fromGesture * 0.15)
|
||||
}
|
||||
if (fromGeom != null) return fromGeom
|
||||
if (fromGesture != null) return fromGesture
|
||||
return null
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import type { CameraFrame, HandSample } from './types'
|
||||
|
||||
function clamp01(v: number) {
|
||||
return Math.max(0, Math.min(1, v))
|
||||
}
|
||||
|
||||
/**
|
||||
* 开发者工具 / 不支持 VisionKit 时的粗开合估计:
|
||||
* 取画面中心 ROI 的亮度方差与边缘能量,映射到 openness。
|
||||
* 仅用于打通链路与 UI 联调,不能作为产品识别。
|
||||
*/
|
||||
export function estimateOpennessFromRoi(frame: CameraFrame): HandSample {
|
||||
const { width, height, data } = frame
|
||||
const u8 = new Uint8Array(data)
|
||||
const cx0 = Math.floor(width * 0.28)
|
||||
const cy0 = Math.floor(height * 0.22)
|
||||
const cx1 = Math.floor(width * 0.72)
|
||||
const cy1 = Math.floor(height * 0.78)
|
||||
const step = Math.max(4, Math.floor(Math.min(width, height) / 40))
|
||||
|
||||
let count = 0
|
||||
let sum = 0
|
||||
let sumSq = 0
|
||||
let edge = 0
|
||||
let prev = 0
|
||||
|
||||
for (let y = cy0; y < cy1; y += step) {
|
||||
for (let x = cx0; x < cx1; x += step) {
|
||||
const i = (y * width + x) * 4
|
||||
const r = u8[i]
|
||||
const g = u8[i + 1]
|
||||
const b = u8[i + 2]
|
||||
// 粗略肤色门控 + 亮度
|
||||
const luma = 0.299 * r + 0.587 * g + 0.114 * b
|
||||
const skinish = r > 60 && g > 30 && b > 20 && r >= g && Math.abs(r - g) > 8
|
||||
if (!skinish && luma < 40) continue
|
||||
sum += luma
|
||||
sumSq += luma * luma
|
||||
edge += Math.abs(luma - prev)
|
||||
prev = luma
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (count < 20) {
|
||||
return {
|
||||
ts: Date.now(),
|
||||
openness: 0.5,
|
||||
score: 0.05,
|
||||
source: 'roi',
|
||||
}
|
||||
}
|
||||
|
||||
const mean = sum / count
|
||||
const variance = Math.max(0, sumSq / count - mean * mean)
|
||||
const edgeNorm = edge / count
|
||||
|
||||
// 张开手掌通常带来更大轮廓/边缘;握拳更紧凑。经验映射,仅联调。
|
||||
const openness = clamp01((Math.sqrt(variance) / 55 + edgeNorm / 40) * 0.85)
|
||||
const score = clamp01(0.25 + count / 800)
|
||||
|
||||
return {
|
||||
ts: Date.now(),
|
||||
openness,
|
||||
score,
|
||||
source: 'roi',
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/** 握力环 2.0 视觉观测与状态机共享类型 */
|
||||
|
||||
export type TrackingQuality = 'good' | 'weak' | 'lost'
|
||||
|
||||
export type GesturePhase =
|
||||
| 'UNTRACKED'
|
||||
| 'OPEN_READY'
|
||||
| 'CLOSING'
|
||||
| 'CLOSED_CONFIRMED'
|
||||
| 'REOPENING'
|
||||
|
||||
export type GestureSource = 'visionkit' | 'roi' | 'mock' | 'none'
|
||||
|
||||
export interface Point2D {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface CameraFrame {
|
||||
width: number
|
||||
height: number
|
||||
data: ArrayBuffer
|
||||
ts: number
|
||||
}
|
||||
|
||||
export interface HandSample {
|
||||
ts: number
|
||||
/** 0=握紧 … 1=张开 */
|
||||
openness: number
|
||||
score: number
|
||||
gesture?: number
|
||||
points?: Point2D[]
|
||||
source: GestureSource
|
||||
}
|
||||
|
||||
export interface GestureObservation {
|
||||
ts: number
|
||||
openness: number
|
||||
/** 0=张开 … 1=握紧,供能量环使用 */
|
||||
compression: number
|
||||
phase: GesturePhase
|
||||
trackingQuality: TrackingQuality
|
||||
source: GestureSource
|
||||
holdProgress: number
|
||||
fps: number
|
||||
}
|
||||
|
||||
export interface RepCompletedInfo {
|
||||
ts: number
|
||||
holdMs: number
|
||||
opennessPeak: number
|
||||
source: GestureSource
|
||||
}
|
||||
@@ -1,296 +0,0 @@
|
||||
import type { CameraFrame, HandSample, Point2D } from './types'
|
||||
import { mergeOpenness } from './openness'
|
||||
|
||||
type VkSession = {
|
||||
start: (cb?: (errno: number | null) => void) => void
|
||||
stop?: () => void
|
||||
destroy?: () => void
|
||||
detectHand?: (opts: Record<string, unknown>) => void
|
||||
on?: (event: string, cb: (...args: any[]) => void) => void
|
||||
off?: (event: string, cb?: (...args: any[]) => void) => void
|
||||
}
|
||||
|
||||
type WxLike = {
|
||||
createVKSession?: (opts: Record<string, unknown>) => VkSession
|
||||
getSystemInfoSync?: () => { SDKVersion?: string; platform?: string; brand?: string; model?: string }
|
||||
getAppBaseInfo?: () => { SDKVersion?: string }
|
||||
getDeviceInfo?: () => { platform?: string; brand?: string; model?: string; system?: string }
|
||||
}
|
||||
|
||||
function getWx(): WxLike | null {
|
||||
// #ifdef MP-WEIXIN
|
||||
// @ts-ignore
|
||||
if (typeof wx !== 'undefined') return wx as WxLike
|
||||
// #endif
|
||||
return null
|
||||
}
|
||||
|
||||
function parseVersion(v?: string) {
|
||||
if (!v) return [0, 0, 0]
|
||||
return v.split('.').map((n) => Number(n) || 0)
|
||||
}
|
||||
|
||||
function gteVersion(current: string | undefined, need: string) {
|
||||
const a = parseVersion(current)
|
||||
const b = parseVersion(need)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
if (a[i] > b[i]) return true
|
||||
if (a[i] < b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function readRuntimeInfo() {
|
||||
const wxApi = getWx()
|
||||
let SDKVersion = ''
|
||||
let platform = ''
|
||||
let brand = ''
|
||||
let model = ''
|
||||
try {
|
||||
const base = wxApi?.getAppBaseInfo?.()
|
||||
if (base?.SDKVersion) SDKVersion = base.SDKVersion
|
||||
} catch (_) {}
|
||||
try {
|
||||
const device = wxApi?.getDeviceInfo?.()
|
||||
if (device?.platform) platform = String(device.platform)
|
||||
if (device?.brand) brand = String(device.brand)
|
||||
if (device?.model) model = String(device.model)
|
||||
} catch (_) {}
|
||||
// 兼容旧环境
|
||||
if (!SDKVersion || !platform) {
|
||||
try {
|
||||
const info = wxApi?.getSystemInfoSync?.() || {}
|
||||
SDKVersion = SDKVersion || info.SDKVersion || ''
|
||||
platform = platform || String(info.platform || '')
|
||||
brand = brand || String(info.brand || '')
|
||||
model = model || String(info.model || '')
|
||||
} catch (_) {}
|
||||
}
|
||||
return { SDKVersion, platform, brand, model }
|
||||
}
|
||||
|
||||
/** 开发者工具 / 模拟器通常不支持 VisionKit Hand */
|
||||
export function isLikelyVisionKitUnsupportedHost(): boolean {
|
||||
const { platform, brand, model } = readRuntimeInfo()
|
||||
const blob = `${platform} ${brand} ${model}`.toLowerCase()
|
||||
return (
|
||||
blob.includes('devtools') ||
|
||||
blob.includes('mac') ||
|
||||
blob.includes('windows') ||
|
||||
blob.includes('devtools') ||
|
||||
platform === 'devtools'
|
||||
)
|
||||
}
|
||||
|
||||
export function isVisionKitHandSupported(): boolean {
|
||||
const wxApi = getWx()
|
||||
if (!wxApi?.createVKSession) return false
|
||||
if (isLikelyVisionKitUnsupportedHost()) return false
|
||||
const { SDKVersion } = readRuntimeInfo()
|
||||
// VisionKit Hand: 基础库 2.28.0+
|
||||
return gteVersion(SDKVersion, '2.28.0')
|
||||
}
|
||||
|
||||
export function getVisionKitSupportHint(): string {
|
||||
if (isLikelyVisionKitUnsupportedHost()) {
|
||||
return '当前是开发者工具/电脑环境,VisionKit 不可用,请用真机预览;已自动降级为 ROI 联调模式'
|
||||
}
|
||||
if (!getWx()?.createVKSession) {
|
||||
return '当前微信不支持 VisionKit'
|
||||
}
|
||||
const { SDKVersion } = readRuntimeInfo()
|
||||
if (!gteVersion(SDKVersion, '2.28.0')) {
|
||||
return `基础库 ${SDKVersion || '未知'} 过低,VisionKit Hand 需 ≥ 2.28.0`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizePoints(raw: any): Point2D[] | undefined {
|
||||
if (!Array.isArray(raw) || raw.length === 0) return undefined
|
||||
return raw.map((p) => {
|
||||
if (Array.isArray(p)) return { x: Number(p[0]) || 0, y: Number(p[1]) || 0 }
|
||||
return { x: Number(p?.x) || 0, y: Number(p?.y) || 0 }
|
||||
})
|
||||
}
|
||||
|
||||
function isUnsupportedDeviceError(err: unknown) {
|
||||
const msg = String((err as any)?.message || err || '')
|
||||
return (
|
||||
msg.includes('does not support version') ||
|
||||
msg.includes('not support') ||
|
||||
msg.includes('不支持') ||
|
||||
msg.includes('v1')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* VisionKit Hand mode=2:由业务喂入 onCameraFrame 的 RGBA。
|
||||
* 内部节流,保证同一时刻最多一次 detectHand。
|
||||
*/
|
||||
export function createVisionKitHandDetector(options?: {
|
||||
scoreThreshold?: number
|
||||
minIntervalMs?: number
|
||||
}) {
|
||||
const scoreThreshold = options?.scoreThreshold ?? 0.5
|
||||
const minIntervalMs = options?.minIntervalMs ?? 100 // ~10Hz
|
||||
// busy 卡死看门狗:detectHand 后超过该时长无回调则强制释放
|
||||
// 注意:手不在画面时 VK 可能整段不回调,这属于正常情况,不能当作会话挂死
|
||||
const BUSY_TIMEOUT_MS = 700
|
||||
|
||||
let session: VkSession | null = null
|
||||
let started = false
|
||||
let busy = false
|
||||
let busySince = 0
|
||||
let restarting = false
|
||||
let sessionStartedAt = 0
|
||||
let lastCallbackTs = 0
|
||||
let lastDetectTs = 0
|
||||
let latestSample: HandSample | null = null
|
||||
let onSample: ((sample: HandSample) => void) | null = null
|
||||
|
||||
function handleAnchors(anchors: any[]) {
|
||||
busy = false
|
||||
lastCallbackTs = Date.now()
|
||||
if (!anchors || !anchors.length) {
|
||||
latestSample = {
|
||||
ts: Date.now(),
|
||||
openness: latestSample?.openness ?? 0.5,
|
||||
score: 0,
|
||||
source: 'visionkit',
|
||||
}
|
||||
onSample?.(latestSample)
|
||||
return
|
||||
}
|
||||
|
||||
const sorted = [...anchors].sort((a, b) => (Number(b?.score) || 0) - (Number(a?.score) || 0))
|
||||
const best = sorted[0]
|
||||
const points = normalizePoints(best?.points)
|
||||
const gesture = Number(best?.gesture)
|
||||
const score = Number(best?.score) || Number(best?.confidence?.[0]) || 0
|
||||
const openness = mergeOpenness({ gesture, points })
|
||||
|
||||
latestSample = {
|
||||
ts: Date.now(),
|
||||
openness: openness == null ? (latestSample?.openness ?? 0.5) : openness,
|
||||
score,
|
||||
gesture: Number.isFinite(gesture) ? gesture : undefined,
|
||||
points,
|
||||
source: 'visionkit',
|
||||
}
|
||||
onSample?.(latestSample)
|
||||
}
|
||||
|
||||
function teardownSession() {
|
||||
busy = false
|
||||
try {
|
||||
session?.stop?.()
|
||||
} catch (_) {}
|
||||
try {
|
||||
session?.destroy?.()
|
||||
} catch (_) {}
|
||||
session = null
|
||||
}
|
||||
|
||||
function createAndStartSession(): Promise<void> {
|
||||
const wxApi = getWx()
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
session = wxApi!.createVKSession!({
|
||||
track: {
|
||||
hand: { mode: 2 },
|
||||
},
|
||||
})
|
||||
session.on?.('updateAnchors', (anchors: any[]) => {
|
||||
handleAnchors(anchors || [])
|
||||
})
|
||||
session.on?.('removeAnchors', () => {
|
||||
busy = false
|
||||
lastCallbackTs = Date.now()
|
||||
latestSample = {
|
||||
ts: Date.now(),
|
||||
openness: latestSample?.openness ?? 0.5,
|
||||
score: 0,
|
||||
source: 'visionkit',
|
||||
}
|
||||
onSample?.(latestSample)
|
||||
})
|
||||
session.start?.((errno) => {
|
||||
if (errno) {
|
||||
reject(new Error(`VisionKit start 失败: ${errno}`))
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
started = true
|
||||
busy = false
|
||||
sessionStartedAt = now
|
||||
lastCallbackTs = now
|
||||
resolve()
|
||||
})
|
||||
} catch (e: any) {
|
||||
if (isUnsupportedDeviceError(e)) {
|
||||
reject(new Error(getVisionKitSupportHint() || '当前设备不支持 VisionKit Hand'))
|
||||
return
|
||||
}
|
||||
reject(e instanceof Error ? e : new Error(String(e)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function start(cb: (sample: HandSample) => void): Promise<void> {
|
||||
stop()
|
||||
onSample = cb
|
||||
const wxApi = getWx()
|
||||
if (!wxApi?.createVKSession) {
|
||||
return Promise.reject(new Error('不支持 VisionKit'))
|
||||
}
|
||||
if (isLikelyVisionKitUnsupportedHost()) {
|
||||
return Promise.reject(new Error(getVisionKitSupportHint()))
|
||||
}
|
||||
return createAndStartSession()
|
||||
}
|
||||
|
||||
function pushFrame(frame: CameraFrame) {
|
||||
if (!started || restarting) return
|
||||
const now = Date.now()
|
||||
|
||||
// busy 看门狗:detectHand 无回调时强制释放,避免识别永久停摆
|
||||
if (busy && now - busySince > BUSY_TIMEOUT_MS) {
|
||||
busy = false
|
||||
}
|
||||
|
||||
// 不再定期重建 VKSession:长跑中 destroy/create 容易把微信原生桥卡死(表现为 29 次后整页无响应)
|
||||
if (!session?.detectHand || busy) return
|
||||
if (now - lastDetectTs < minIntervalMs) return
|
||||
lastDetectTs = now
|
||||
busy = true
|
||||
busySince = now
|
||||
try {
|
||||
session.detectHand({
|
||||
frameBuffer: frame.data,
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
scoreThreshold,
|
||||
algoMode: 2,
|
||||
})
|
||||
} catch (_) {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
started = false
|
||||
restarting = false
|
||||
onSample = null
|
||||
sessionStartedAt = 0
|
||||
lastCallbackTs = 0
|
||||
teardownSession()
|
||||
}
|
||||
|
||||
function getLatest() {
|
||||
return latestSample
|
||||
}
|
||||
|
||||
return { start, stop, pushFrame, getLatest }
|
||||
}
|
||||
|
||||
export type VisionKitHandDetector = ReturnType<typeof createVisionKitHandDetector>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -204,13 +204,13 @@ const instance = getCurrentInstance()
|
||||
|
||||
// 由父级 prop 信号驱动触发特效(避免跨组件 ref 调用, 小程序端更稳)
|
||||
const props = defineProps<{
|
||||
crushSignal?: { type: CrushItemType; nonce: number; intensity?: number } | null
|
||||
crushSignal?: { type: CrushItemType; nonce: number } | null
|
||||
}>()
|
||||
|
||||
watch(
|
||||
() => props.crushSignal,
|
||||
(sig) => {
|
||||
if (sig) triggerCrush(sig.type, sig.intensity ?? 1)
|
||||
if (sig) triggerCrush(sig.type)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -270,13 +270,12 @@ function initCanvas() {
|
||||
// 触发捏碎特效
|
||||
// ============================================================
|
||||
|
||||
function triggerCrush(itemType: CrushItemType, intensity = 1) {
|
||||
function triggerCrush(itemType: CrushItemType) {
|
||||
if (!ctx.value || !canvasNode.value) return
|
||||
|
||||
const item = CRUSH_ITEMS[itemType]
|
||||
if (!item) return
|
||||
|
||||
const scale = Math.max(0.45, Math.min(1.15, intensity))
|
||||
const centerX = canvasWidth.value / 2
|
||||
const centerY = canvasHeight.value / 2
|
||||
const maxR = Math.min(canvasWidth.value, canvasHeight.value)
|
||||
@@ -285,44 +284,39 @@ function triggerCrush(itemType: CrushItemType, intensity = 1) {
|
||||
flashList.value.push({
|
||||
x: centerX,
|
||||
y: centerY,
|
||||
radius: maxR * 0.12 * scale,
|
||||
maxRadius: maxR * 0.62 * scale,
|
||||
radius: maxR * 0.12,
|
||||
maxRadius: maxR * 0.62,
|
||||
color: item.flashColor,
|
||||
life: 1.0,
|
||||
decay: PHYSICS.FLASH_DECAY,
|
||||
})
|
||||
|
||||
// 2. 双层冲击波(低强度时只保留一层)
|
||||
// 2. 双层冲击波
|
||||
shockwaves.value.push({
|
||||
x: centerX, y: centerY,
|
||||
radius: 12, maxRadius: maxR * 0.62 * scale,
|
||||
radius: 12, maxRadius: maxR * 0.62,
|
||||
color: item.shockwaveColor, width: 5,
|
||||
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY,
|
||||
})
|
||||
if (scale >= 0.8) {
|
||||
shockwaves.value.push({
|
||||
x: centerX, y: centerY,
|
||||
radius: 4, maxRadius: maxR * 0.42 * scale,
|
||||
color: item.shockwaveColor, width: 3,
|
||||
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY * 1.3,
|
||||
})
|
||||
}
|
||||
|
||||
const debrisCount = Math.max(8, Math.round(item.debrisCount * scale))
|
||||
const sparkCount = Math.max(6, Math.round(item.sparkCount * scale))
|
||||
const sparkleCount = Math.max(2, Math.round(item.sparkleCount * scale))
|
||||
shockwaves.value.push({
|
||||
x: centerX, y: centerY,
|
||||
radius: 4, maxRadius: maxR * 0.42,
|
||||
color: item.shockwaveColor, width: 3,
|
||||
life: 1.0, decay: PHYSICS.SHOCKWAVE_DECAY * 1.3,
|
||||
})
|
||||
|
||||
// 3. 碎片(主体飞溅)
|
||||
for (let i = 0; i < debrisCount; i++) {
|
||||
const angle = (Math.PI * 2 * i) / debrisCount + (Math.random() - 0.5) * 0.7
|
||||
const speed = (item.speedMin + Math.random() * (item.speedMax - item.speedMin)) * (0.85 + scale * 0.15)
|
||||
const size = (item.sizeMin + Math.random() * (item.sizeMax - item.sizeMin)) * scale
|
||||
for (let i = 0; i < item.debrisCount; i++) {
|
||||
const angle = (Math.PI * 2 * i) / item.debrisCount + (Math.random() - 0.5) * 0.7
|
||||
const speed = item.speedMin + Math.random() * (item.speedMax - item.speedMin)
|
||||
const size = item.sizeMin + Math.random() * (item.sizeMax - item.sizeMin)
|
||||
const color = item.colors[Math.floor(Math.random() * item.colors.length)]
|
||||
const shape = item.shapes[Math.floor(Math.random() * item.shapes.length)]
|
||||
|
||||
// confetti(彩纸): 初速带强烈向上偏移 + 水平摇摆下落
|
||||
const confetti = !!item.confetti
|
||||
const vy0 = confetti
|
||||
? Math.sin(angle) * speed - 2.5
|
||||
? Math.sin(angle) * speed - 2.5 // 先向上窜
|
||||
: Math.sin(angle) * speed
|
||||
|
||||
debrisList.value.push({
|
||||
@@ -336,18 +330,18 @@ function triggerCrush(itemType: CrushItemType, intensity = 1) {
|
||||
rotation: Math.random() * Math.PI * 2,
|
||||
rotationSpeed: (Math.random() - 0.5) * (confetti ? 0.5 : 0.3),
|
||||
life: 1.0,
|
||||
decay: PHYSICS.DEBRIS_DECAY * (confetti ? 0.7 : 1) * (scale < 0.7 ? 1.25 : 1),
|
||||
decay: PHYSICS.DEBRIS_DECAY * (confetti ? 0.7 : 1),
|
||||
gravity: item.gravity,
|
||||
glow: !confetti,
|
||||
glow: !confetti, // 彩纸不发光,实色碎片发光
|
||||
swing: confetti ? 0.6 + Math.random() * 1.2 : 0,
|
||||
age: Math.random() * Math.PI * 2,
|
||||
})
|
||||
}
|
||||
|
||||
// 4. 火花
|
||||
for (let i = 0; i < sparkCount; i++) {
|
||||
// 4. 火花(细小高速亮点,带拖尾)
|
||||
for (let i = 0; i < item.sparkCount; i++) {
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const speed = item.speedMax * (0.9 + Math.random() * 0.8) * scale
|
||||
const speed = item.speedMax * (0.9 + Math.random() * 0.8)
|
||||
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
||||
sparkList.value.push({
|
||||
x: centerX,
|
||||
@@ -365,8 +359,8 @@ function triggerCrush(itemType: CrushItemType, intensity = 1) {
|
||||
}
|
||||
|
||||
// 5. 闪烁星光
|
||||
for (let i = 0; i < sparkleCount; i++) {
|
||||
const r = maxR * (0.08 + Math.random() * 0.32) * scale
|
||||
for (let i = 0; i < item.sparkleCount; i++) {
|
||||
const r = maxR * (0.08 + Math.random() * 0.32)
|
||||
const a = Math.random() * Math.PI * 2
|
||||
const color = item.sparkColors[Math.floor(Math.random() * item.sparkColors.length)]
|
||||
sparkleList.value.push({
|
||||
@@ -376,7 +370,7 @@ function triggerCrush(itemType: CrushItemType, intensity = 1) {
|
||||
rotation: Math.random() * Math.PI,
|
||||
spin: (Math.random() - 0.5) * 0.16,
|
||||
color,
|
||||
life: 1.0 + Math.random() * 0.4,
|
||||
life: 1.0 + Math.random() * 0.4, // 错峰出现
|
||||
decay: PHYSICS.SPARKLE_DECAY * (0.8 + Math.random() * 0.5),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -39,8 +39,16 @@
|
||||
|
||||
<div class="grid grid-cols-4 gap-4 mb-4">
|
||||
<el-card shadow="hover">
|
||||
<div class="text-gray-500 text-sm mb-1">总客户数</div>
|
||||
<div class="text-gray-500 text-sm mb-1">总客户数(去重)</div>
|
||||
<div class="text-2xl font-bold text-primary">{{ stats.total }}</div>
|
||||
<el-tooltip
|
||||
content="企微管理后台「全部客户」按客户×添加人关系计数:同一客户被 N 名员工添加计 N 条。与企微对账请看此数字。"
|
||||
placement="bottom"
|
||||
>
|
||||
<div class="text-xs text-gray-400 mt-1 cursor-help">
|
||||
跟进关系数(企微口径):{{ stats.relation_total }}
|
||||
</div>
|
||||
</el-tooltip>
|
||||
</el-card>
|
||||
<el-card shadow="hover" class="today-arrival-card cursor-pointer" @click="openArrivalDrawer">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
@@ -52,6 +60,9 @@
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="text-2xl font-bold text-success leading-none">{{ stats.today }}</div>
|
||||
<div class="text-xs text-gray-400 pb-1">人</div>
|
||||
<div v-if="arrival.returning > 0" class="text-xs text-warning pb-1">
|
||||
含老客户 {{ arrival.returning }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 24 小时分布迷你柱:高度相对今日峰值等比 -->
|
||||
<div class="hourly-bars mt-2" :title="hourlyTooltip">
|
||||
@@ -114,6 +125,24 @@
|
||||
@change="onAddTimeRangeChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<template #label>
|
||||
<el-tooltip
|
||||
content="首次添加:客户第一次加入企业的时间在所选范围内。任意添加(企微口径):范围内发生过添加动作即算,含老客户被重加/被其他员工添加,与企微后台时间筛选一致。"
|
||||
placement="top"
|
||||
>
|
||||
<span class="cursor-help">时间口径</span>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
<el-select
|
||||
v-model="queryParams.add_time_mode"
|
||||
class="!w-[180px]"
|
||||
@change="onAddTimeModeChange"
|
||||
>
|
||||
<el-option label="首次添加" value="first" />
|
||||
<el-option label="任意添加(企微口径)" value="any" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="标签">
|
||||
<el-select
|
||||
v-model="queryParams.tag_ids"
|
||||
@@ -158,7 +187,23 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<el-avatar :size="40" :src="row.avatar" />
|
||||
<div>
|
||||
<div class="font-medium">{{ row.name }}</div>
|
||||
<div class="font-medium flex items-center gap-1">
|
||||
<span>{{ row.name }}</span>
|
||||
<el-tooltip
|
||||
v-if="row.readd_flag"
|
||||
content="这个人以前加过企业:被删除后重新添加,或已是企业客户又被其他员工添加"
|
||||
placement="top"
|
||||
>
|
||||
<el-tag size="small" type="warning" effect="plain">以前加过</el-tag>
|
||||
</el-tooltip>
|
||||
<el-tooltip
|
||||
v-if="row.is_deleted"
|
||||
content="该客户已被删除/流失(企微中已不是企业客户),仅在「任意添加」口径下展示"
|
||||
placement="top"
|
||||
>
|
||||
<el-tag size="small" type="danger" effect="plain">已删除</el-tag>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="text-xs text-gray-400">{{ row.external_userid }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -278,6 +323,9 @@
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-base font-semibold">今日进入明细</span>
|
||||
<el-tag type="success" effect="plain">共 {{ arrivalList.total }} 人</el-tag>
|
||||
<el-tag v-if="arrival.returning > 0" type="warning" effect="plain">
|
||||
老客户 {{ arrival.returning }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-button link type="primary" :loading="arrivalListLoading" @click="loadArrival(true)">
|
||||
<template #icon><Refresh /></template>
|
||||
@@ -331,8 +379,17 @@
|
||||
{{ (row.customer_name || '?').slice(0, 1) }}
|
||||
</el-avatar>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-medium truncate">
|
||||
{{ row.customer_name || row.external_userid || '(未知客户)' }}
|
||||
<div class="text-sm font-medium truncate flex items-center gap-1">
|
||||
<span class="truncate">
|
||||
{{ row.customer_name || row.external_userid || '(未知客户)' }}
|
||||
</span>
|
||||
<el-tooltip
|
||||
v-if="row.is_old_customer"
|
||||
content="老客户回流:今天之前就加过企业(删除后重加,或已是其他员工的客户)"
|
||||
placement="top"
|
||||
>
|
||||
<el-tag size="small" type="warning" effect="plain">老客户</el-tag>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-0.5">
|
||||
<span>接待:{{ row.admin_name || row.user_id || '—' }}</span>
|
||||
@@ -469,6 +526,9 @@
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="类型">
|
||||
{{ currentCustomer.type === 1 ? '微信用户' : '企业微信用户' }}
|
||||
<el-tag v-if="currentCustomer.readd_flag" size="small" type="warning" effect="plain" class="ml-1">
|
||||
以前加过
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="企业名称" :span="2">
|
||||
{{ currentCustomer.corp_name || '—' }}
|
||||
@@ -544,6 +604,7 @@ const currentCustomer = ref<any>(null)
|
||||
|
||||
const stats = reactive({
|
||||
total: 0,
|
||||
relation_total: 0,
|
||||
today: 0,
|
||||
today_follow_staff: 0,
|
||||
lastSync: '',
|
||||
@@ -575,12 +636,14 @@ const queryParams = reactive<{
|
||||
tag_ids: string[]
|
||||
add_time_start: string
|
||||
add_time_end: string
|
||||
add_time_mode: 'first' | 'any'
|
||||
}>({
|
||||
name: '',
|
||||
follow_user: '',
|
||||
tag_ids: [],
|
||||
add_time_start: '',
|
||||
add_time_end: ''
|
||||
add_time_end: '',
|
||||
add_time_mode: 'first'
|
||||
})
|
||||
|
||||
/** 与列表「添加时间」列口径一致(库内 external_first_add_time / create_time) */
|
||||
@@ -597,6 +660,13 @@ function onAddTimeRangeChange(val: [string, string] | null) {
|
||||
resetPage()
|
||||
}
|
||||
|
||||
/** 口径切换只在已选时间范围时才需要重查 */
|
||||
function onAddTimeModeChange() {
|
||||
if (queryParams.add_time_start || queryParams.add_time_end) {
|
||||
resetPage()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 标签维度(筛选下拉 + 抽屉面板共用同一份数据) ──────────────────────────
|
||||
interface TagItem {
|
||||
tag_id: string
|
||||
@@ -689,6 +759,7 @@ function filterByTag(tagId: string) {
|
||||
interface ArrivalStats {
|
||||
total: number
|
||||
recent_time: number
|
||||
returning: number
|
||||
hourly: number[]
|
||||
by_state: { state: string; count: number }[]
|
||||
}
|
||||
@@ -702,11 +773,13 @@ interface ArrivalItem {
|
||||
customer_avatar: string
|
||||
state: string
|
||||
welcome_code: number
|
||||
is_old_customer: number
|
||||
}
|
||||
|
||||
const arrival = reactive<ArrivalStats>({
|
||||
total: 0,
|
||||
recent_time: 0,
|
||||
returning: 0,
|
||||
hourly: new Array(24).fill(0),
|
||||
by_state: []
|
||||
})
|
||||
@@ -740,6 +813,7 @@ async function loadArrival(refreshList = false) {
|
||||
if (res) {
|
||||
arrival.total = Number(res.total ?? 0)
|
||||
arrival.recent_time = Number(res.recent_time ?? 0)
|
||||
arrival.returning = Number(res.returning ?? 0)
|
||||
arrival.hourly = Array.isArray(res.hourly) && res.hourly.length === 24
|
||||
? res.hourly.map((n: any) => Number(n) || 0)
|
||||
: new Array(24).fill(0)
|
||||
@@ -829,6 +903,7 @@ function handleReset() {
|
||||
queryParams.tag_ids = []
|
||||
queryParams.add_time_start = ''
|
||||
queryParams.add_time_end = ''
|
||||
queryParams.add_time_mode = 'first'
|
||||
addTimeRange.value = null
|
||||
resetParams()
|
||||
}
|
||||
|
||||
@@ -77,20 +77,37 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
}
|
||||
}
|
||||
|
||||
// 添加时间:与 lists 排序口径一致(external_first_add_time 优先,0 则 create_time)
|
||||
// 添加时间筛选,两种口径:
|
||||
// first(默认)= 首次添加时间在窗口内(external_first_add_time 优先,0 则 create_time)
|
||||
// any = 企微「全部客户+时间筛选」口径:存在"添加时间在窗口内的现存跟进关系"即命中
|
||||
// (含老客户被重加/被其他员工添加;关系已解除的不算),数据源为现存关系表
|
||||
$addStart = trim((string) ($this->params['add_time_start'] ?? ''));
|
||||
$addEnd = trim((string) ($this->params['add_time_end'] ?? ''));
|
||||
$effExpr = 'COALESCE(NULLIF(external_first_add_time, 0), create_time)';
|
||||
if ($addStart !== '') {
|
||||
$t = strtotime($addStart . ' 00:00:00');
|
||||
if ($t !== false) {
|
||||
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' >= ?', [$t]);
|
||||
$addMode = trim((string) ($this->params['add_time_mode'] ?? 'first'));
|
||||
$startTs = $addStart !== '' ? strtotime($addStart . ' 00:00:00') : false;
|
||||
$endTs = $addEnd !== '' ? strtotime($addEnd . ' 23:59:59') : false;
|
||||
|
||||
if ($addMode === 'any' && ($startTs !== false || $endTs !== false)) {
|
||||
$followQuery = Db::name('qywx_external_contact_follow')->where('createtime', '>', 0);
|
||||
if ($startTs !== false) {
|
||||
$followQuery->where('createtime', '>=', $startTs);
|
||||
}
|
||||
}
|
||||
if ($addEnd !== '') {
|
||||
$t = strtotime($addEnd . ' 23:59:59');
|
||||
if ($t !== false) {
|
||||
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$t]);
|
||||
if ($endTs !== false) {
|
||||
$followQuery->where('createtime', '<=', $endTs);
|
||||
}
|
||||
$matchedExtIds = $followQuery->group('external_userid')->column('external_userid');
|
||||
if ($matchedExtIds === []) {
|
||||
$query->whereRaw('1=0');
|
||||
} else {
|
||||
$query->whereIn('external_userid', $matchedExtIds);
|
||||
}
|
||||
} else {
|
||||
$effExpr = 'COALESCE(NULLIF(external_first_add_time, 0), create_time)';
|
||||
if ($startTs !== false) {
|
||||
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' >= ?', [$startTs]);
|
||||
}
|
||||
if ($endTs !== false) {
|
||||
$query->whereRaw($effExpr . ' > 0 AND ' . $effExpr . ' <= ?', [$endTs]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,6 +175,9 @@ class CustomerLists extends BaseAdminDataLists implements ListsSearchInterface
|
||||
$fromDb = (int) ($item['external_first_add_time'] ?? 0);
|
||||
$fromJson = CustomerLogic::minFollowCreatetime($followUsers);
|
||||
$item['external_first_add_time'] = $fromDb > 0 ? $fromDb : $fromJson;
|
||||
|
||||
// 「任意添加」口径会带出软删行,前端据此显示"已删除"标记
|
||||
$item['is_deleted'] = !empty($item['delete_time']) ? 1 : 0;
|
||||
}
|
||||
unset($item);
|
||||
|
||||
|
||||
@@ -585,15 +585,32 @@ class CustomerLogic extends BaseLogic
|
||||
/**
|
||||
* 客户联系事件回调:按 external_userid 拉取详情并 UPSERT(不走全量同步文件锁)。
|
||||
*
|
||||
* @param bool $isAddEvent 是否 add_external_contact 事件:是且本地已有该客户(含软删行)时,
|
||||
* 新增一行 readd_flag=1(以前加过)的记录,原有行不动
|
||||
* @param string $eventUserId 本次添加人的企微 userid(add 事件回调里的 UserID,用于取本次添加时间)
|
||||
* @param int $eventTime 回调事件时间(企微 CreateTime,秒)
|
||||
* @see https://developer.work.weixin.qq.com/document/path/92130
|
||||
*/
|
||||
public static function upsertSingleExternalContactFromApi(string $externalUserId): void
|
||||
{
|
||||
public static function upsertSingleExternalContactFromApi(
|
||||
string $externalUserId,
|
||||
bool $isAddEvent = false,
|
||||
string $eventUserId = '',
|
||||
int $eventTime = 0
|
||||
): void {
|
||||
$externalUserId = trim($externalUserId);
|
||||
$eventUserId = trim($eventUserId);
|
||||
if ($externalUserId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
// 必须在写库之前判断"是否已存在",否则无法区分新客户和重加客户
|
||||
$existedBefore = false;
|
||||
if ($isAddEvent) {
|
||||
$existedBefore = Db::name('qywx_external_contact')
|
||||
->where('external_userid', $externalUserId)
|
||||
->count() > 0;
|
||||
}
|
||||
|
||||
$service = new WechatWorkService();
|
||||
$detail = [];
|
||||
$lastError = null;
|
||||
@@ -619,6 +636,14 @@ class CustomerLogic extends BaseLogic
|
||||
if (empty($detail['external_contact']) || !is_array($detail['external_contact'])) {
|
||||
$errcode = is_array($lastError) ? ($lastError['errcode'] ?? null) : null;
|
||||
$errmsg = is_array($lastError) ? (string) ($lastError['errmsg'] ?? '') : '';
|
||||
// 84061 = 已不是企业客户(双向解除/员工删除后无人跟进):与企微对齐,本地软删。
|
||||
// 注意:客户单方删除员工(del_follow_user)时企微仍保留客户,get 会成功返回,不会走到这里。
|
||||
if ($errcode === 84061) {
|
||||
self::softDeleteExternalContactRow($externalUserId);
|
||||
Log::info('qywx external contact callback: 企微侧已不存在(84061),本地软删 ext=' . $externalUserId);
|
||||
|
||||
return;
|
||||
}
|
||||
Log::warning(sprintf(
|
||||
'qywx external contact callback: 拉取详情为空,跳过 UPSERT errcode=%s errmsg=%s ext=%s',
|
||||
$errcode === null ? '?' : (string) $errcode,
|
||||
@@ -634,6 +659,18 @@ class CustomerLogic extends BaseLogic
|
||||
$followUsers = [];
|
||||
}
|
||||
|
||||
// 重复添加:不动原有行,新增一行并打「以前加过」标记(与企微"每次添加都算一条"对齐)
|
||||
if ($isAddEvent && $existedBefore) {
|
||||
self::insertReaddContactRow(
|
||||
$detail['external_contact'],
|
||||
$followUsers,
|
||||
$eventUserId,
|
||||
$eventTime
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$syncCount = 0;
|
||||
$newCount = 0;
|
||||
$updateCount = 0;
|
||||
@@ -652,6 +689,48 @@ class CustomerLogic extends BaseLogic
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 重复添加:为已存在的客户新增一行记录(原有行保持不变)。
|
||||
*
|
||||
* 新行的「添加时间」取本次添加动作的时间:优先本次添加人($eventUserId)在 follow_user
|
||||
* 里的 createtime,其次回调事件时间,最后兜底当前时间——保证这行出现在"本次添加"的日期下。
|
||||
*
|
||||
* @param array<string, mixed> $externalContact
|
||||
* @param array<int, array<string, mixed>> $followUsers
|
||||
*/
|
||||
private static function insertReaddContactRow(
|
||||
array $externalContact,
|
||||
array $followUsers,
|
||||
string $eventUserId,
|
||||
int $eventTime
|
||||
): void {
|
||||
$row = self::buildContactRow($externalContact, $followUsers);
|
||||
|
||||
$addTime = 0;
|
||||
if ($eventUserId !== '') {
|
||||
foreach ($followUsers as $fu) {
|
||||
if (is_array($fu) && trim((string) ($fu['userid'] ?? '')) === $eventUserId) {
|
||||
$addTime = self::normalizeFollowUserCreatetime($fu);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($addTime <= 0) {
|
||||
$addTime = $eventTime > 0 ? $eventTime : time();
|
||||
}
|
||||
|
||||
$row['external_first_add_time'] = $addTime;
|
||||
$row['readd_flag'] = 1;
|
||||
|
||||
Db::name('qywx_external_contact')->insert($row);
|
||||
|
||||
$extId = (string) $row['external_userid'];
|
||||
self::syncContactTagsRelation($extId, $followUsers);
|
||||
self::syncContactFollowRelation($extId, $followUsers);
|
||||
|
||||
Log::info('qywx external contact callback: 重复添加,新增一行(readd_flag=1) ext=' . $extId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 事件流水(零误差进入计数)
|
||||
*
|
||||
@@ -720,7 +799,7 @@ class CustomerLogic extends BaseLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户联系「删除企业客户」等事件:本地软删除一行。
|
||||
* 客户彻底流失(企微 get 返回 84061):该客户的所有行(含历史重加行)一并软删。
|
||||
*/
|
||||
public static function softDeleteExternalContactRow(string $externalUserId): void
|
||||
{
|
||||
@@ -739,84 +818,9 @@ class CustomerLogic extends BaseLogic
|
||||
Db::name('qywx_external_contact_tag')
|
||||
->where('external_userid', $externalUserId)
|
||||
->delete();
|
||||
}
|
||||
|
||||
/**
|
||||
* `del_follow_user` 事件:某员工不再跟进该客户。
|
||||
* 只更新本地 `follow_users` JSON:移除匹配的 userid;若已无跟进人则软删该行。
|
||||
* 不再回调 /externalcontact/get,避免 84061「not external contact」刷 warning。
|
||||
*/
|
||||
public static function removeFollowUserFromLocal(string $externalUserId, string $userId): void
|
||||
{
|
||||
$externalUserId = trim($externalUserId);
|
||||
$userId = trim($userId);
|
||||
if ($externalUserId === '' || $userId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$row = Db::name('qywx_external_contact')
|
||||
Db::name('qywx_external_contact_follow')
|
||||
->where('external_userid', $externalUserId)
|
||||
->find();
|
||||
if (!$row) {
|
||||
return;
|
||||
}
|
||||
|
||||
$followUsers = json_decode((string) ($row['follow_users'] ?? '[]'), true);
|
||||
$followUsers = is_array($followUsers) ? $followUsers : [];
|
||||
$kept = [];
|
||||
$removed = false;
|
||||
foreach ($followUsers as $fu) {
|
||||
if (!is_array($fu)) {
|
||||
continue;
|
||||
}
|
||||
$uid = trim((string) ($fu['userid'] ?? ''));
|
||||
if ($uid === $userId) {
|
||||
$removed = true;
|
||||
continue;
|
||||
}
|
||||
$kept[] = $fu;
|
||||
}
|
||||
|
||||
if (!$removed) {
|
||||
return;
|
||||
}
|
||||
|
||||
$now = time();
|
||||
if ($kept === []) {
|
||||
Db::name('qywx_external_contact')
|
||||
->where('id', (int) $row['id'])
|
||||
->update([
|
||||
'follow_users' => json_encode([], JSON_UNESCAPED_UNICODE),
|
||||
'tags' => '[]',
|
||||
'delete_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
|
||||
// 整客户已无人跟进 → 关系表清空
|
||||
Db::name('qywx_external_contact_tag')
|
||||
->where('external_userid', $externalUserId)
|
||||
->delete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$minCreate = self::minFollowCreatetime($kept);
|
||||
$update = [
|
||||
'follow_users' => json_encode($kept, JSON_UNESCAPED_UNICODE),
|
||||
'tags' => self::extractFollowUserTags($kept),
|
||||
'update_time' => $now,
|
||||
];
|
||||
if ($minCreate > 0) {
|
||||
// 移除最早那条跟进人后,首次添加时间可能后移;重新刷新字段以与 JSON 保持一致
|
||||
$update['external_first_add_time'] = $minCreate;
|
||||
}
|
||||
|
||||
Db::name('qywx_external_contact')
|
||||
->where('id', (int) $row['id'])
|
||||
->update($update);
|
||||
|
||||
// 关系表按剩余 kept follow_users 同步(自动清掉离开员工那行 + 保留其他员工的标签)
|
||||
self::syncContactTagsRelation($externalUserId, $kept);
|
||||
->delete();
|
||||
}
|
||||
|
||||
private static function upsertOneExternalContactBundle(
|
||||
@@ -845,32 +849,16 @@ class CustomerLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
$now = time();
|
||||
$row = [
|
||||
'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),
|
||||
'tags' => self::extractFollowUserTags($followUsers),
|
||||
'follow_admin_ids' => self::resolveFollowAdminIds($followUsers),
|
||||
'external_first_add_time' => self::minFollowCreatetime($followUsers),
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
'delete_time' => null,
|
||||
];
|
||||
$row = self::buildContactRow($externalContact, $followUsers);
|
||||
|
||||
self::upsertExternalContactRow($row, $syncCount, $newCount, $updateCount);
|
||||
|
||||
// 同步「客户↔员工↔标签」关系表(用于检索/统计;列表筛选/聚合不必再解析 follow_users JSON)
|
||||
self::syncContactTagsRelation((string) $row['external_userid'], $followUsers);
|
||||
|
||||
// 同步「客户↔员工」现存跟进关系表(企微口径的添加时间筛选用)
|
||||
self::syncContactFollowRelation((string) $row['external_userid'], $followUsers);
|
||||
|
||||
if (($syncCount % 25) === 0) {
|
||||
usleep(8000);
|
||||
}
|
||||
@@ -982,6 +970,67 @@ class CustomerLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步「客户↔员工」现存跟进关系表(zyt_qywx_external_contact_follow)。
|
||||
*
|
||||
* 与企微后台「全部客户 + 时间筛选」同口径:只保留当前仍存在的跟进关系及其 createtime;
|
||||
* 关系被解除(员工删客户且未重加)后行随之删除,客户在对应日期的筛选下即不再出现。
|
||||
*
|
||||
* @param array<int, mixed> $followUsers /externalcontact/get 返回的 follow_user[]
|
||||
* @internal 仅供 UPSERT / 回填复用
|
||||
*/
|
||||
public static function syncContactFollowRelation(string $externalUserId, array $followUsers): void
|
||||
{
|
||||
$externalUserId = trim($externalUserId);
|
||||
if ($externalUserId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
foreach ($followUsers as $fu) {
|
||||
if (!is_array($fu)) {
|
||||
continue;
|
||||
}
|
||||
$uid = trim((string) ($fu['userid'] ?? ''));
|
||||
if ($uid === '') {
|
||||
continue;
|
||||
}
|
||||
$rows[$uid] = self::normalizeFollowUserCreatetime($fu);
|
||||
}
|
||||
|
||||
try {
|
||||
if ($rows === []) {
|
||||
Db::name('qywx_external_contact_follow')
|
||||
->where('external_userid', $externalUserId)
|
||||
->delete();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Db::name('qywx_external_contact_follow')
|
||||
->where('external_userid', $externalUserId)
|
||||
->whereNotIn('follow_user_id', array_keys($rows))
|
||||
->delete();
|
||||
|
||||
$now = time();
|
||||
foreach ($rows as $uid => $createtime) {
|
||||
Db::name('qywx_external_contact_follow')
|
||||
->duplicate(['createtime' => $createtime, 'update_time' => $now])
|
||||
->insert([
|
||||
'external_userid' => $externalUserId,
|
||||
'follow_user_id' => $uid,
|
||||
'createtime' => $createtime,
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('qywx contact follow relation sync failed: ' . $e->getMessage(), [
|
||||
'ext' => $externalUserId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把所有 follow_user[].tags 合并去重(按 tag_id),返回 JSON 字符串。
|
||||
*
|
||||
@@ -1062,6 +1111,38 @@ class CustomerLogic extends BaseLogic
|
||||
return json_encode(array_values(array_unique($adminIds)), JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 由企微 API 返回的 external_contact + follow_user 组装一行客户表数据
|
||||
*
|
||||
* @param array<string, mixed> $externalContact
|
||||
* @param array<int, array<string, mixed>> $followUsers
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function buildContactRow(array $externalContact, array $followUsers): array
|
||||
{
|
||||
$now = time();
|
||||
|
||||
return [
|
||||
'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),
|
||||
'tags' => self::extractFollowUserTags($followUsers),
|
||||
'follow_admin_ids' => self::resolveFollowAdminIds($followUsers),
|
||||
'external_first_add_time' => self::minFollowCreatetime($followUsers),
|
||||
'create_time' => $now,
|
||||
'update_time' => $now,
|
||||
'delete_time' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否 MySQL 锁等待超时 / 死锁(可短重试)
|
||||
*/
|
||||
@@ -1073,7 +1154,13 @@ class CustomerLogic extends BaseLogic
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT ... ON DUPLICATE KEY UPDATE + 锁冲突重试
|
||||
* 客户行写入 + 锁冲突重试。
|
||||
*
|
||||
* 表允许同一 external_userid 多行(重复添加会新增行),不再依赖唯一键 UPSERT:
|
||||
* - 已有行 → 只更新"最新一行"(MAX(id)),历史重加行保持原样;
|
||||
* - 无行 → 插入新行。
|
||||
* 更新时不覆盖 create_time / readd_flag / external_first_add_time
|
||||
* (最新行的添加时间代表"该行那次添加"的时间,由插入时决定,后续详情刷新不应改动)。
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
@@ -1084,33 +1171,21 @@ class CustomerLogic extends BaseLogic
|
||||
while (true) {
|
||||
$attempt++;
|
||||
try {
|
||||
$affected = Db::name('qywx_external_contact')->duplicate([
|
||||
'name',
|
||||
'avatar',
|
||||
'type',
|
||||
'gender',
|
||||
'unionid',
|
||||
'position',
|
||||
'corp_name',
|
||||
'corp_full_name',
|
||||
'external_profile',
|
||||
'follow_users',
|
||||
'tags',
|
||||
'follow_admin_ids',
|
||||
'external_first_add_time',
|
||||
'update_time',
|
||||
'delete_time',
|
||||
])->insert($row);
|
||||
$latestId = (int) Db::name('qywx_external_contact')
|
||||
->where('external_userid', (string) $row['external_userid'])
|
||||
->order('id', 'desc')
|
||||
->value('id');
|
||||
|
||||
$syncCount++;
|
||||
// PDO MySQL:新插入 1;更新 2;值完全相同 0
|
||||
if ($affected === 1) {
|
||||
if ($latestId > 0) {
|
||||
$update = $row;
|
||||
unset($update['external_userid'], $update['create_time'], $update['external_first_add_time']);
|
||||
Db::name('qywx_external_contact')->where('id', $latestId)->update($update);
|
||||
$updateCount++;
|
||||
} else {
|
||||
Db::name('qywx_external_contact')->insert($row);
|
||||
$newCount++;
|
||||
} elseif ($affected === 2) {
|
||||
$updateCount++;
|
||||
} elseif ($affected === 0) {
|
||||
$updateCount++;
|
||||
}
|
||||
$syncCount++;
|
||||
|
||||
return;
|
||||
} catch (\Throwable $e) {
|
||||
@@ -1211,8 +1286,16 @@ class CustomerLogic extends BaseLogic
|
||||
*/
|
||||
public static function getStats()
|
||||
{
|
||||
$total = QywxExternalContact::whereNull('delete_time')->count();
|
||||
|
||||
// 表内同一客户可能多行(重复添加各占一行),去重后才是真实客户数
|
||||
$total = (int) Db::name('qywx_external_contact')
|
||||
->whereNull('delete_time')
|
||||
->fieldRaw('COUNT(DISTINCT external_userid) AS c')
|
||||
->find()['c'];
|
||||
|
||||
// 企微管理后台「全部客户」按"客户×添加人"关系计数(同一客户被 N 名员工添加计 N 条),
|
||||
// 现存跟进关系表与其同口径,便于对账。
|
||||
$relationTotal = (int) Db::name('qywx_external_contact_follow')->count();
|
||||
|
||||
$todayStart = strtotime(date('Y-m-d 00:00:00'));
|
||||
|
||||
// 今日进入数 = 今日收到的 add_external_contact 事件条数。
|
||||
@@ -1254,6 +1337,7 @@ class CustomerLogic extends BaseLogic
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'relation_total' => $relationTotal,
|
||||
'today' => $today,
|
||||
'today_follow_staff' => $todayFollowStaff,
|
||||
'lastSync' => $lastSync,
|
||||
@@ -1332,14 +1416,74 @@ class CustomerLogic extends BaseLogic
|
||||
}
|
||||
}
|
||||
|
||||
// 老客户回流数(去重客户维度):今天有 add 事件,但今天之前就加过——
|
||||
// 依据①事件流水里有更早的 add 事件,或②客户表首次添加时间早于今天(覆盖流水表上线前的老客户)
|
||||
$returning = 0;
|
||||
if ($total > 0) {
|
||||
$todayExtIds = Db::name('qywx_external_contact_event')
|
||||
->where('change_type', 'add_external_contact')
|
||||
->where('event_time', '>=', $todayStart)
|
||||
->where('event_time', '<', $todayEnd)
|
||||
->group('external_userid')
|
||||
->column('external_userid');
|
||||
$todayExtIds = array_values(array_filter($todayExtIds));
|
||||
if ($todayExtIds !== []) {
|
||||
$oldSet = self::resolveOldCustomerSet($todayExtIds, $todayStart);
|
||||
$returning = count($oldSet);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'total' => $total,
|
||||
'recent_time' => $recent,
|
||||
'returning' => $returning,
|
||||
'hourly' => array_values($hourly),
|
||||
'by_state' => $byState,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 判定"老客户":在 $beforeTs 之前就已加过企业的客户集合。
|
||||
*
|
||||
* 依据(满足其一即算老客户):
|
||||
* ① 事件流水表在 $beforeTs 之前存在该客户的 add_external_contact 记录;
|
||||
* ② 客户表 external_first_add_time(企微 follow_user 最早 createtime)早于 $beforeTs——
|
||||
* 覆盖事件流水表上线之前就已存在的客户。
|
||||
*
|
||||
* @param string[] $extIds
|
||||
* @return array<string, true> external_userid 为键的集合
|
||||
*/
|
||||
private static function resolveOldCustomerSet(array $extIds, int $beforeTs): array
|
||||
{
|
||||
$extIds = array_values(array_unique(array_filter($extIds)));
|
||||
if ($extIds === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$old = [];
|
||||
|
||||
$earlierEventIds = Db::name('qywx_external_contact_event')
|
||||
->whereIn('external_userid', $extIds)
|
||||
->where('change_type', 'add_external_contact')
|
||||
->where('event_time', '<', $beforeTs)
|
||||
->group('external_userid')
|
||||
->column('external_userid');
|
||||
foreach ($earlierEventIds as $id) {
|
||||
$old[(string) $id] = true;
|
||||
}
|
||||
|
||||
$earlierFirstAddIds = Db::name('qywx_external_contact')
|
||||
->whereIn('external_userid', $extIds)
|
||||
->where('external_first_add_time', '>', 0)
|
||||
->where('external_first_add_time', '<', $beforeTs)
|
||||
->column('external_userid');
|
||||
foreach ($earlierFirstAddIds as $id) {
|
||||
$old[(string) $id] = true;
|
||||
}
|
||||
|
||||
return $old;
|
||||
}
|
||||
|
||||
/**
|
||||
* 今日"进入明细"流水(分页,按时间倒序)
|
||||
*
|
||||
@@ -1391,6 +1535,9 @@ class CustomerLogic extends BaseLogic
|
||||
$adminMap = Admin::whereIn('work_wechat_userid', $userIds)->column('name', 'work_wechat_userid');
|
||||
}
|
||||
|
||||
// 标记老客户:今天之前就加过企业(重加 / 被另一名员工添加的回流客户)
|
||||
$oldSet = self::resolveOldCustomerSet($extIds, $todayStart);
|
||||
|
||||
$lists = [];
|
||||
foreach ($rows as $r) {
|
||||
$ext = (string) ($r['external_userid'] ?? '');
|
||||
@@ -1405,6 +1552,7 @@ class CustomerLogic extends BaseLogic
|
||||
'customer_avatar' => (string) ($customerMap[$ext]['avatar'] ?? ''),
|
||||
'state' => (string) ($r['state'] ?? ''),
|
||||
'welcome_code' => (int) ($r['welcome_code'] ?? 0),
|
||||
'is_old_customer' => isset($oldSet[$ext]) ? 1 : 0,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -133,12 +133,6 @@ class QywxExternalContactCallbackController extends BaseApiController
|
||||
$failReason !== '' ? $failReason : '-'
|
||||
));
|
||||
|
||||
if ($changeType === 'del_external_contact') {
|
||||
CustomerLogic::softDeleteExternalContactRow($extId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($changeType === 'add_half_external_contact') {
|
||||
// 半客户:客户尚未通过验证,/externalcontact/get 通常返回 84061「客户尚未通过」之类,
|
||||
// 这里只 log 不写库,避免产生 noise;客户通过后会再触发 add_external_contact 事件再走 UPSERT。
|
||||
@@ -153,17 +147,17 @@ class QywxExternalContactCallbackController extends BaseApiController
|
||||
return;
|
||||
}
|
||||
|
||||
if ($changeType === 'del_follow_user') {
|
||||
// 某员工不再跟进该客户:只需把本地 follow_users 里对应 userid 移除;
|
||||
// 若已无跟进人则软删;不再回调 /externalcontact/get(最后一个跟进人被删时会稳定返回 84061)。
|
||||
if ($userId !== '') {
|
||||
CustomerLogic::removeFollowUserFromLocal($extId, $userId);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// 其余变更(添加/编辑/转接成功/标签变化等):以 get 详情为准 UPSERT,避免遗漏未枚举的 ChangeType
|
||||
CustomerLogic::upsertSingleExternalContactFromApi($extId);
|
||||
// 其余全部变更(添加/编辑/删除/被删/转接/标签变化等):一律以 /externalcontact/get 的真实状态为准。
|
||||
// - 仍是企业客户(含"客户单方删除员工",企微会保留客户与跟进关系)→ UPSERT 最新数据;
|
||||
// - 已彻底不是企业客户(84061)→ 本地软删。
|
||||
// 实测验证:del_follow_user(客户删员工)后 get 依然成功返回且 follow_user 保留,
|
||||
// 本地自行推断移除跟进人/软删会导致客户数持续少于企微,故删除类事件不再走本地推断。
|
||||
// 添加事件若命中已有客户(含软删行):不动原有行,新增一行 readd_flag=1(以前加过)的记录。
|
||||
CustomerLogic::upsertSingleExternalContactFromApi(
|
||||
$extId,
|
||||
$changeType === 'add_external_contact',
|
||||
$userId,
|
||||
$eventTime
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- 医生表(la_admin)新增字段:是否开启美颜功能
|
||||
-- 开启后医生端(管理后台通话)才显示美颜设置入口;关闭则不显示
|
||||
-- 若项目使用 zyt_ 前缀,请将 la_admin 替换为 zyt_admin 后执行
|
||||
|
||||
ALTER TABLE `zyt_admin` ADD COLUMN `enable_beauty` tinyint(1) NOT NULL DEFAULT 1 COMMENT '是否开启美颜功能:0-关闭 1-开启';
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 企微客户表:新增「以前加过」标记
|
||||
-- 语义:该客户本次添加之前就加过企业(被删除后重新添加,或已是企业客户又被其他员工添加)。
|
||||
-- 写入:客户联系回调收到 add_external_contact 时,若本地已存在该 external_userid(含软删行)则置 1;
|
||||
-- 置 1 后不回退(历史事实)。全量同步不改此字段。
|
||||
ALTER TABLE `zyt_qywx_external_contact`
|
||||
ADD COLUMN `readd_flag` tinyint(1) unsigned NOT NULL DEFAULT 0 COMMENT '以前加过:1=本次添加前已加过企业(重加/多员工添加)' AFTER `external_first_add_time`;
|
||||
|
||||
-- 历史回填:事件流水中有 2 条及以上 add_external_contact 的客户,说明发生过重复添加
|
||||
UPDATE `zyt_qywx_external_contact` c
|
||||
INNER JOIN (
|
||||
SELECT `external_userid`
|
||||
FROM `zyt_qywx_external_contact_event`
|
||||
WHERE `change_type` = 'add_external_contact' AND `external_userid` <> ''
|
||||
GROUP BY `external_userid`
|
||||
HAVING COUNT(*) >= 2
|
||||
) e ON e.`external_userid` = c.`external_userid`
|
||||
SET c.`readd_flag` = 1;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 企微客户事件流水表:按客户维度查询的索引
|
||||
-- 背景:今日进入明细/统计需要标记「老客户」(判断某客户在今天之前是否有 add_external_contact 事件),
|
||||
-- 查询形态为 WHERE external_userid IN (...) AND change_type = ? AND event_time < ?。
|
||||
-- 现有索引均以 change_type / event_time / state 开头,无法按 external_userid 检索,
|
||||
-- 26 万行时单次判定耗时可达数十秒;补充此索引后为毫秒级。
|
||||
-- 注意:MySQL 5.7 不支持 ADD INDEX IF NOT EXISTS,重复执行会报 1061(索引已存在),可忽略。
|
||||
ALTER TABLE `zyt_qywx_external_contact_event`
|
||||
ADD INDEX `idx_ext_change_time` (`external_userid`, `change_type`, `event_time`);
|
||||
@@ -0,0 +1,10 @@
|
||||
-- 企微客户表:允许同一 external_userid 多行(每次"添加"新增一条记录)
|
||||
-- 背景:产品要求与企微「全部客户」展示对齐——客户被删除后重新添加、或被其他员工再次添加时,
|
||||
-- 不覆盖原纪录,而是新增一行并打 readd_flag=1(以前加过),原有行保持不变。
|
||||
-- 落库逻辑同步调整(CustomerLogic):
|
||||
-- * 回调 add_external_contact 且已有该客户 → INSERT 新行(readd_flag=1);
|
||||
-- * 其他事件 / 全量同步 → 只更新该客户"最新一行"(MAX(id)),不再依赖唯一键 UPSERT;
|
||||
-- * 彻底流失(84061)→ 该客户所有行一并软删。
|
||||
ALTER TABLE `zyt_qywx_external_contact`
|
||||
DROP INDEX `uk_external_userid`,
|
||||
ADD INDEX `idx_external_userid` (`external_userid`);
|
||||
@@ -0,0 +1,17 @@
|
||||
-- 企微客户「现存跟进关系」表:external_userid × follow_user_id 一行,带企微 createtime
|
||||
-- 用途:客户列表「任意添加(企微口径)」时间筛选——企微后台的时间筛选按"现存跟进关系的添加时间"
|
||||
-- 匹配(关系被删后该客户不再出现在对应日期下),follow_users JSON 无法做 SQL 范围筛选,故落关系表。
|
||||
-- 维护:CustomerLogic 在每次 UPSERT 客户时同步;软删客户时清空该客户的关系行。
|
||||
-- 回填:执行 php think 版本回填脚本或 server/backfill_follow_relations.php(从 follow_users JSON 解析)。
|
||||
CREATE TABLE IF NOT EXISTS `zyt_qywx_external_contact_follow` (
|
||||
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
|
||||
`external_userid` varchar(64) NOT NULL DEFAULT '' COMMENT '外部联系人 external_userid',
|
||||
`follow_user_id` varchar(64) NOT NULL DEFAULT '' COMMENT '跟进员工企微 userid',
|
||||
`createtime` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '该员工添加此客户的时间(企微 follow_user.createtime,秒)',
|
||||
`create_time` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '本行创建时间',
|
||||
`update_time` int(10) unsigned NOT NULL DEFAULT 0 COMMENT '本行更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_ext_user` (`external_userid`, `follow_user_id`),
|
||||
KEY `idx_createtime` (`createtime`),
|
||||
KEY `idx_user` (`follow_user_id`)
|
||||
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '企微客户现存跟进关系(企微口径时间筛选用)';
|
||||
-6968
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user