diff --git a/admin/src/api/qywx.ts b/admin/src/api/qywx.ts index 55d6f4b7..274a6711 100644 --- a/admin/src/api/qywx.ts +++ b/admin/src/api/qywx.ts @@ -15,6 +15,21 @@ export function qywxCustomerStats() { return request.get({ url: '/qywx.customer/stats' }) } +/** + * 今日进入分布(事件流水零误差口径) + * 返回 { total, recent_time, hourly: number[24], by_state: [{state,count}] } + */ +export function qywxCustomerTodayArrival() { + return request.get({ url: '/qywx.customer/todayArrival' }) +} + +/** + * 今日进入明细流水(每一次 add_external_contact 推送 = 一行) + */ +export function qywxCustomerTodayArrivalList(params: { page_no?: number; page_size?: number }) { + return request.get({ url: '/qywx.customer/todayArrivalList', params }) +} + // 获取同步设置 export function qywxSyncSettingsGet() { return request.get({ url: '/qywx.customer/getSyncSettings' }) diff --git a/admin/src/components/tcm-prescription/index.vue b/admin/src/components/tcm-prescription/index.vue index 11123b03..a420516e 100644 --- a/admin/src/components/tcm-prescription/index.vue +++ b/admin/src/components/tcm-prescription/index.vue @@ -98,9 +98,16 @@ v-for="(herb, index) in formData.herbs" :key="'herb-' + index" class="herb-editor-card" + :class="{ 'herb-editor-card--dup': isDuplicateHerbName(index) }" >
中药 {{ index + 1 }}
+
+ 该药名与其他行重复,请合并剂量或删除多余行 +
下载PDF - - 作废处方 - + + + 作废处方 + + + 新建处方 关闭
@@ -714,6 +730,13 @@ const approvedPreviewOnly = computed(() => { return Number(s.audit_status) === 1 && Number(s.void_status) !== 1 }) +/** 已有关联业务订单(与列表 has_prescription_order 一致)则禁止作废 */ +const hasPrescriptionOrderBlockVoid = computed(() => { + const s = savedPrescription.value + if (!s) return false + return Number(s.has_prescription_order) === 1 +}) + const drawerTitle = computed(() => (approvedPreviewOnly.value ? '查看处方' : '中医处方单')) // 处方库相关 @@ -1084,6 +1107,33 @@ const initSignatureCanvas = () => { ctx.lineJoin = 'round' } +/** 药名去首尾空格后比较,避免「 黄芪 」与「黄芪」判成不同 */ +function normalizeHerbName(name: string | undefined): string { + return (name ?? '').trim() +} + +/** 当前行药名是否与其他行重复(非空且出现次数大于 1) */ +function isDuplicateHerbName(index: number): boolean { + const herbs = formData.herbs + const key = normalizeHerbName(herbs[index]?.name) + if (!key) return false + let count = 0 + for (let i = 0; i < herbs.length; i++) { + if (normalizeHerbName(herbs[i]?.name) === key) count++ + } + return count > 1 +} + +function findDuplicateHerbNames(): string[] { + const map = new Map() + for (const h of formData.herbs) { + const key = normalizeHerbName(h?.name) + if (!key) continue + map.set(key, (map.get(key) ?? 0) + 1) + } + return [...map.entries()].filter(([, c]) => c > 1).map(([name]) => name) +} + const handleAddHerb = () => { formData.herbs.push({ name: '', dosage: 6 }) } @@ -1132,6 +1182,10 @@ const handleImportLibrary = (row: any) => { const importedHerbs = JSON.parse(JSON.stringify(row.herbs)) formData.herbs = importedHerbs + if (findDuplicateHerbNames().length) { + feedback.msgWarning('导入的处方中存在重复药名,请合并剂量或删除多余行') + } + feedback.msgSuccess(`已导入处方「${row.prescription_name}」,共${importedHerbs.length}味药材`) showLibraryDialog.value = false } @@ -1174,6 +1228,11 @@ const handleSave = async () => { feedback.msgWarning('请至少添加一味中药') return } + const dupNames = findDuplicateHerbNames() + if (dupNames.length) { + feedback.msgWarning(`存在重复药名:${dupNames.join('、')},请合并剂量或删除多余行后再保存`) + return + } for (const h of formData.herbs) { if (!h.name?.trim()) { feedback.msgWarning('请填写中药名称') @@ -1252,6 +1311,10 @@ const formatVoidTime = (ts: number | null | undefined) => { const handleVoid = async () => { const id = savedPrescription.value?.id if (!id) return + if (Number(savedPrescription.value?.has_prescription_order) === 1) { + feedback.msgWarning('该处方已存在业务订单,无法作废') + return + } try { await feedback.confirm('确定要作废该处方吗?作废后不可恢复。') voiding.value = true @@ -1453,6 +1516,11 @@ defineExpose({ border-radius: 6px; } + .herb-editor-card--dup { + border-color: var(--el-color-warning); + background: var(--el-color-warning-light-9); + } + .herb-editor-card__title { font-size: 12px; color: #909399; diff --git a/admin/src/views/consumer/prescription/index.vue b/admin/src/views/consumer/prescription/index.vue index a5ae8a28..b2cc5cde 100644 --- a/admin/src/views/consumer/prescription/index.vue +++ b/admin/src/views/consumer/prescription/index.vue @@ -110,6 +110,19 @@ {{ row.prescription_type || '—' }} + + + + + + +
+ + + + +
+
今日推广渠道 Top
+
+ + {{ it.state || '(无 state)' }} · {{ it.count }} + +
+
+ + + +
+ + {{ (row.customer_name || '?').slice(0, 1) }} + +
+
+ {{ row.customer_name || row.external_userid || '(未知客户)' }} +
+
+ 接待:{{ row.admin_name || row.user_id || '—' }} + + 渠道:{{ row.state }} + + 欢迎语 +
+
+
+
+
+ + +
+ + 加载更多(还剩 {{ arrivalList.total - arrivalList.lists.length }}) + +
+
+
+ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue' -import { Refresh, Setting } from '@element-plus/icons-vue' +import { Refresh, Setting, DataLine } from '@element-plus/icons-vue' import { usePaging } from '@/hooks/usePaging' import feedback from '@/utils/feedback' import { @@ -225,7 +351,9 @@ import { qywxCustomerSync, qywxCustomerStats, qywxSyncSettingsGet, - qywxSyncSettingsSave + qywxSyncSettingsSave, + qywxCustomerTodayArrival, + qywxCustomerTodayArrivalList } from '@/api/qywx' const syncing = ref(false) @@ -236,6 +364,7 @@ const currentCustomer = ref(null) const stats = reactive({ total: 0, today: 0, + today_follow_staff: 0, lastSync: '', syncStatus: 'idle', syncStatusText: '未同步' @@ -264,6 +393,124 @@ const queryParams = reactive({ follow_user: '' }) +// ── 今日进入分布 / 明细(事件流水零误差口径) ────────────────────────────── +interface ArrivalStats { + total: number + recent_time: number + hourly: number[] + by_state: { state: string; count: number }[] +} +interface ArrivalItem { + id: number + event_time: number + user_id: string + admin_name: string + external_userid: string + customer_name: string + customer_avatar: string + state: string + welcome_code: number +} + +const arrival = reactive({ + total: 0, + recent_time: 0, + hourly: new Array(24).fill(0), + by_state: [] +}) +const showArrival = ref(false) +const arrivalListLoading = ref(false) +const arrivalList = reactive<{ total: number; page_no: number; page_size: number; lists: ArrivalItem[] }>({ + total: 0, + page_no: 1, + page_size: 20, + lists: [] +}) +const currentHour = new Date().getHours() +const hourlyMax = computed(() => Math.max(1, ...arrival.hourly)) +function barHeight(c: number) { + // 柱图容器高度 32px;0 值保留一条 2px 的底线让用户知道"这个小时有格子" + if (c <= 0) return '2px' + const ratio = c / hourlyMax.value + return Math.max(4, Math.round(ratio * 32)) + 'px' +} +const hourlyTooltip = computed(() => { + const parts: string[] = [] + arrival.hourly.forEach((c, h) => { + if (c > 0) parts.push(`${String(h).padStart(2, '0')}:00 · ${c}`) + }) + return parts.length ? parts.join(' ') : '今天还没有人进入' +}) + +async function loadArrival(refreshList = false) { + try { + const res: any = await qywxCustomerTodayArrival() + if (res) { + arrival.total = Number(res.total ?? 0) + arrival.recent_time = Number(res.recent_time ?? 0) + arrival.hourly = Array.isArray(res.hourly) && res.hourly.length === 24 + ? res.hourly.map((n: any) => Number(n) || 0) + : new Array(24).fill(0) + arrival.by_state = Array.isArray(res.by_state) ? res.by_state : [] + } + } catch (e) { + console.error('加载今日进入分布失败', e) + } + if (refreshList && showArrival.value) { + arrivalList.page_no = 1 + arrivalList.lists = [] + await fetchArrivalPage() + } +} + +async function fetchArrivalPage() { + arrivalListLoading.value = true + try { + const res: any = await qywxCustomerTodayArrivalList({ + page_no: arrivalList.page_no, + page_size: arrivalList.page_size + }) + if (res) { + arrivalList.total = Number(res.total ?? 0) + const rows: ArrivalItem[] = Array.isArray(res.lists) ? res.lists : [] + if (arrivalList.page_no === 1) { + arrivalList.lists = rows + } else { + arrivalList.lists.push(...rows) + } + } + } catch (e) { + console.error('加载今日进入明细失败', e) + } finally { + arrivalListLoading.value = false + } +} + +async function openArrivalDrawer() { + showArrival.value = true + arrivalList.page_no = 1 + arrivalList.lists = [] + // 分布和明细一起刷新,确保抽屉里看到的数字和外面卡片一致 + await Promise.all([loadArrival(false), fetchArrivalPage()]) +} + +async function loadMoreArrival() { + if (arrivalList.lists.length >= arrivalList.total) return + arrivalList.page_no += 1 + await fetchArrivalPage() +} + +function formatHm(ts: number) { + if (!ts || ts <= 0) return '' + const d = new Date(ts < 1e12 ? ts * 1000 : ts) + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` +} +function formatHms(ts: number) { + if (!ts || ts <= 0) return '—' + const d = new Date(ts < 1e12 ? ts * 1000 : ts) + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}` +} + let syncTimer: number | null = null /** 后台同步完成后轮询统计,直到非「同步中」 */ let pollSyncTimer: number | null = null @@ -453,15 +700,34 @@ function formatTime(timestamp: number | string | null | undefined) { return new Date(ms).toLocaleString('zh-CN') } +/** 今日进入分布 30s 轮询:回调一到,事件流水立刻能反映在卡片上 */ +let arrivalPollTimer: number | null = null +function startArrivalPolling() { + if (arrivalPollTimer !== null) return + arrivalPollTimer = window.setInterval(() => { + loadStats() + loadArrival(showArrival.value) + }, 30000) +} +function stopArrivalPolling() { + if (arrivalPollTimer !== null) { + clearInterval(arrivalPollTimer) + arrivalPollTimer = null + } +} + onMounted(() => { getLists() loadStats() loadSyncSettings() + loadArrival(false) + startArrivalPolling() }) onUnmounted(() => { stopSyncTimer() stopPollSync() + stopArrivalPolling() }) @@ -470,4 +736,37 @@ onUnmounted(() => { max-width: 1400px; margin: 0 auto; } + +.today-arrival-card { + transition: transform 0.15s ease, box-shadow 0.15s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + } +} + +.hourly-bars { + display: grid; + grid-template-columns: repeat(24, 1fr); + align-items: end; + gap: 2px; + height: 32px; + + .hourly-bar { + background: var(--el-color-success); + border-radius: 2px 2px 0 0; + min-height: 2px; + transition: height 0.3s ease; + + &.is-empty { + background: var(--el-border-color-lighter); + } + + &.is-current { + outline: 1px solid var(--el-color-success-dark-2); + outline-offset: -1px; + } + } +} diff --git a/admin/src/views/order/index.vue b/admin/src/views/order/index.vue index c0d3f71a..d688bb44 100644 --- a/admin/src/views/order/index.vue +++ b/admin/src/views/order/index.vue @@ -22,6 +22,23 @@ /> + + + + + + @@ -523,6 +540,7 @@ - + @@ -72,14 +72,14 @@ - + - + @@ -99,7 +99,7 @@ - +
diff --git a/server/server.zip b/server/server.zip index 09168476..dfe6cbfe 100644 Binary files a/server/server.zip and b/server/server.zip differ