Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
49efa21af3 | ||
|
|
c8507aed43 | ||
|
|
87b8949f95 | ||
|
|
1626b6f498 | ||
|
|
68b5b12b59 | ||
|
|
9e684f0afb |
@@ -22,3 +22,5 @@ unpackage/dist
|
||||
*.sw?
|
||||
|
||||
TUICallKit
|
||||
# 本工程的通话源码复用仓库内 ../wx/TUICallKit;保留相对软链接以便干净克隆后可直接构建。
|
||||
!TUICallKit
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../wx/TUICallKit
|
||||
@@ -63,7 +63,7 @@
|
||||
"requiredBackgroundModes" : [ "audio" ],
|
||||
"plugins" : {
|
||||
"WechatSI" : {
|
||||
"version" : "0.3.5",
|
||||
"version" : "0.3.9",
|
||||
"provider" : "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"version": "0.3.9",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"version": "0.3.9",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
@@ -213,7 +213,7 @@
|
||||
"mp-weixin": {
|
||||
"usingPlugins": {
|
||||
"WechatSI": {
|
||||
"version": "0.3.5",
|
||||
"version": "0.3.9",
|
||||
"provider": "wx069ba97219f66d99"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
## 已接入能力
|
||||
|
||||
- 复用主小程序 `token` 与微信小程序登录,不创建第二套游戏账号。
|
||||
- 按周一日期、性别自动分配最多 7 人的同行组。
|
||||
- 按周一日期自动分配最多 7 人的混合同行组,前期不区分男女。
|
||||
- 本周已经进入旧男女组的账号,下次读取榜单时保留成绩并自动迁入混合组。
|
||||
- 首次入组使用数据库分配锁,多个用户同时进入也不会重复分组或超过 7 人。
|
||||
- 以真实平台昵称、头像、认糖数和本周最高分排序。
|
||||
- 每局用 `session_key` 上报绝对进度,断网重试不会重复加分。
|
||||
@@ -33,7 +34,7 @@
|
||||
|
||||
## 上线前检查
|
||||
|
||||
- 用男女各两个测试账号进入,确认被分入对应性别组。
|
||||
- 用不同账号进入,确认按进入顺序加入同一个最多 7 人的混合同行组。
|
||||
- 同一局重复提交相同 `session_key`,确认周认糖数不重复增加。
|
||||
- 断网完成几次消除,再联网打开榜单,确认成绩补传。
|
||||
- 分享给另一个微信账号,确认能直接进入游戏且链接中没有用户 ID。
|
||||
|
||||
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 55 KiB |
@@ -0,0 +1,577 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// 识糖小课堂棋盘压力测试:复刻生产版的 5x4 棋盘、掉落、洗牌和渐进换组规则。
|
||||
// 运行:node tongji/endless-game/build/stress-game.mjs --runs 10000
|
||||
|
||||
const ROWS = 5
|
||||
const COLS = 4
|
||||
const HIGH_CAP = 3
|
||||
const MAX_CASCADE_WAVES = 3
|
||||
const DANGER_DROP_CHANCE = 0.1
|
||||
const COLUMN_GROUP_BIAS = 0.72
|
||||
|
||||
const FOOD_GROUPS = [
|
||||
{ title: '中式早餐', safe: ['egg', 'cucumber', 'corn', 'grainMantou'], danger: 'congee' },
|
||||
{ title: '家常粥餐', safe: ['celery', 'mushroom', 'chicken', 'taro'], danger: 'centuryCongee' },
|
||||
{ title: '粗粮早餐', safe: ['broccoli', 'milk', 'brownRice', 'potato'], danger: 'milletCongee' },
|
||||
{ title: '水果饮品', safe: ['apple', 'strawberry', 'banana', 'mango'], danger: 'orangeJuice' },
|
||||
{ title: '日常饮品', safe: ['milk', 'tea', 'blackCoffee', 'coffee'], danger: 'soda' },
|
||||
{ title: '下午茶甜饮', safe: ['milk', 'tea', 'apple', 'strawberry'], danger: 'milkTea' },
|
||||
{ title: '南方汤粉', safe: ['pepper', 'greenBean', 'shrimp', 'udon'], danger: 'riceNoodleSoup' },
|
||||
{ title: '北方面食', safe: ['onion', 'celery', 'corn', 'grainMantou'], danger: 'noodleSoup' },
|
||||
{ title: '家常餐桌', safe: ['tofu', 'spinach', 'carrot', 'fish'], danger: 'redBeanCongee' }
|
||||
]
|
||||
|
||||
const HIGH_KEYS = new Set(FOOD_GROUPS.map(group => group.danger))
|
||||
const SAFE_KEYS = new Set(FOOD_GROUPS.flatMap(group => group.safe))
|
||||
|
||||
function readNumberArg(name, fallback) {
|
||||
const index = process.argv.indexOf(name)
|
||||
if (index < 0) return fallback
|
||||
const value = Number(process.argv[index + 1])
|
||||
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback
|
||||
}
|
||||
|
||||
const RUNS = readNumberArg('--runs', 10000)
|
||||
const TURNS = readNumberArg('--turns', 80)
|
||||
const BASE_SEED = readNumberArg('--seed', 20260722)
|
||||
|
||||
function createRng(seed) {
|
||||
let state = seed >>> 0 || 1
|
||||
return () => {
|
||||
state ^= state << 13
|
||||
state ^= state >>> 17
|
||||
state ^= state << 5
|
||||
return (state >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
function cloneBoard(source) {
|
||||
return source.map(tile => tile ? { ...tile } : null)
|
||||
}
|
||||
|
||||
function tile(key) {
|
||||
return { key }
|
||||
}
|
||||
|
||||
function isAdjacent(a, b) {
|
||||
const ar = Math.floor(a / COLS)
|
||||
const ac = a % COLS
|
||||
const br = Math.floor(b / COLS)
|
||||
const bc = b % COLS
|
||||
return Math.abs(ar - br) + Math.abs(ac - bc) === 1
|
||||
}
|
||||
|
||||
function swap(source, a, b) {
|
||||
const next = cloneBoard(source)
|
||||
;[next[a], next[b]] = [next[b], next[a]]
|
||||
return next
|
||||
}
|
||||
|
||||
function shuffle(source, rng) {
|
||||
const result = cloneBoard(source)
|
||||
for (let i = result.length - 1; i > 0; i -= 1) {
|
||||
const j = Math.floor(rng() * (i + 1))
|
||||
;[result[i], result[j]] = [result[j], result[i]]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function findMatches(source) {
|
||||
const groups = []
|
||||
for (let row = 0; row < ROWS; row += 1) {
|
||||
let start = 0
|
||||
while (start < COLS) {
|
||||
let end = start + 1
|
||||
while (end < COLS && source[row * COLS + end]?.key === source[row * COLS + start]?.key) end += 1
|
||||
if (source[row * COLS + start] && end - start >= 3) {
|
||||
groups.push({ direction: 'row', indices: Array.from({ length: end - start }, (_, n) => row * COLS + start + n) })
|
||||
}
|
||||
start = end
|
||||
}
|
||||
}
|
||||
for (let col = 0; col < COLS; col += 1) {
|
||||
let start = 0
|
||||
while (start < ROWS) {
|
||||
let end = start + 1
|
||||
while (end < ROWS && source[end * COLS + col]?.key === source[start * COLS + col]?.key) end += 1
|
||||
if (source[start * COLS + col] && end - start >= 3) {
|
||||
groups.push({ direction: 'col', indices: Array.from({ length: end - start }, (_, n) => (start + n) * COLS + col) })
|
||||
}
|
||||
start = end
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
function enumerateMatchingMoves(source) {
|
||||
const moves = []
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
const candidates = []
|
||||
if (i % COLS < COLS - 1) candidates.push(i + 1)
|
||||
if (i + COLS < source.length) candidates.push(i + COLS)
|
||||
for (const j of candidates) {
|
||||
const copy = swap(source, i, j)
|
||||
const groups = findMatches(copy)
|
||||
if (!groups.length) continue
|
||||
const indices = new Set(groups.flatMap(group => group.indices))
|
||||
const hitsHigh = [...indices].some(index => HIGH_KEYS.has(copy[index]?.key))
|
||||
moves.push({ from: i, to: j, hitsHigh, groups })
|
||||
}
|
||||
}
|
||||
return moves
|
||||
}
|
||||
|
||||
function safeMoves(source) {
|
||||
return enumerateMatchingMoves(source).filter(move => !move.hitsHigh)
|
||||
}
|
||||
|
||||
function wouldCreateMatchAt(source, index, key) {
|
||||
const row = Math.floor(index / COLS)
|
||||
const col = index % COLS
|
||||
const keyAt = target => target === index ? key : source[target]?.key
|
||||
let horizontal = 1
|
||||
for (let c = col - 1; c >= 0 && keyAt(row * COLS + c) === key; c -= 1) horizontal += 1
|
||||
for (let c = col + 1; c < COLS && keyAt(row * COLS + c) === key; c += 1) horizontal += 1
|
||||
if (horizontal >= 3) return true
|
||||
let vertical = 1
|
||||
for (let r = row - 1; r >= 0 && keyAt(r * COLS + col) === key; r -= 1) vertical += 1
|
||||
for (let r = row + 1; r < ROWS && keyAt(r * COLS + col) === key; r += 1) vertical += 1
|
||||
return vertical >= 3
|
||||
}
|
||||
|
||||
function openingKeys(state) {
|
||||
const [a, b, c, d] = state.activeSafeKeys
|
||||
const danger = FOOD_GROUPS[state.themeIndex].danger
|
||||
return [
|
||||
a, b, c, d,
|
||||
b, c, d, a,
|
||||
c, d, a, danger,
|
||||
d, a, danger, b,
|
||||
a, b, c, d
|
||||
]
|
||||
}
|
||||
|
||||
function forcedPlayableKeys(state) {
|
||||
const [a, b, c, d] = state.activeSafeKeys
|
||||
const danger = FOOD_GROUPS[state.themeIndex].danger
|
||||
return [
|
||||
d, a, danger, c,
|
||||
d, c, b, danger,
|
||||
c, a, b, a,
|
||||
d, d, a, a,
|
||||
b, d, c, b
|
||||
]
|
||||
}
|
||||
|
||||
function buildPlayableBoard(state, keys = openingKeys(state)) {
|
||||
let candidate = keys.map(tile)
|
||||
for (let attempt = 0; attempt < 240; attempt += 1) {
|
||||
if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate
|
||||
candidate = shuffle(candidate, state.rng)
|
||||
}
|
||||
state.metrics.guaranteedFallbacks += 1
|
||||
return forcedPlayableKeys(state).map(tile)
|
||||
}
|
||||
|
||||
function makeStablePlayableBoard(state, source) {
|
||||
const tiles = source.filter(Boolean).map(item => ({ ...item }))
|
||||
if (tiles.length === ROWS * COLS) {
|
||||
for (let attempt = 0; attempt < 300; attempt += 1) {
|
||||
const candidate = shuffle(tiles, state.rng)
|
||||
if (!findMatches(candidate).length && safeMoves(candidate).length) return candidate
|
||||
}
|
||||
}
|
||||
state.metrics.stableFallbacks += 1
|
||||
return buildPlayableBoard(state)
|
||||
}
|
||||
|
||||
function queueSafeGroupTransition(state, keys) {
|
||||
const target = [...keys]
|
||||
const last = state.queuedSafeGroups[state.queuedSafeGroups.length - 1]
|
||||
if (!last || !last.every((key, index) => key === target[index])) {
|
||||
state.queuedSafeGroups.push(target)
|
||||
}
|
||||
startNextTransition(state)
|
||||
}
|
||||
|
||||
function startNextTransition(state) {
|
||||
if (state.transition) return false
|
||||
while (state.queuedSafeGroups.length) {
|
||||
const target = state.queuedSafeGroups[0]
|
||||
const slot = target.findIndex((key, index) => state.activeSafeKeys[index] !== key)
|
||||
if (slot < 0) {
|
||||
state.queuedSafeGroups.shift()
|
||||
continue
|
||||
}
|
||||
const from = state.activeSafeKeys[slot]
|
||||
const to = target[slot]
|
||||
state.activeSafeKeys[slot] = to
|
||||
state.transition = { from, to, slot, age: 0 }
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function finishTransitionIfReady(state) {
|
||||
let changed = false
|
||||
while (state.transition) {
|
||||
const { from, to } = state.transition
|
||||
const remaining = state.board.filter(item => item?.key === from).length
|
||||
if (remaining > 2) break
|
||||
if (remaining) {
|
||||
state.board = state.board.map(item => item?.key === from ? { ...item, key: to } : item)
|
||||
state.metrics.tailConversions += remaining
|
||||
}
|
||||
state.transition = null
|
||||
changed = true
|
||||
startNextTransition(state)
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
function prepareTransitionForReshuffle(state) {
|
||||
const activeSet = new Set(state.activeSafeKeys)
|
||||
const counts = Object.fromEntries(state.activeSafeKeys.map(key => [key, 0]))
|
||||
state.board.forEach(item => {
|
||||
if (item && activeSet.has(item.key)) counts[item.key] += 1
|
||||
})
|
||||
let imported = 0
|
||||
const next = state.board.map(item => {
|
||||
if (!item || item.key === 'camel' || HIGH_KEYS.has(item.key)) return item ? { ...item } : item
|
||||
if (state.transition && item.key === state.transition.from) {
|
||||
imported += 1
|
||||
counts[state.transition.to] += 1
|
||||
return { ...item, key: state.transition.to }
|
||||
}
|
||||
if (activeSet.has(item.key)) return { ...item }
|
||||
const key = [...state.activeSafeKeys].sort((a, b) => counts[a] - counts[b])[0]
|
||||
counts[key] += 1
|
||||
imported += 1
|
||||
return { ...item, key }
|
||||
})
|
||||
state.board = next
|
||||
state.metrics.reshuffleImports += imported
|
||||
}
|
||||
|
||||
function randomFoodKey(state, snapshot, col) {
|
||||
const highs = snapshot.filter(item => item && HIGH_KEYS.has(item.key)).length
|
||||
if (highs < HIGH_CAP && state.rng() < DANGER_DROP_CHANCE) {
|
||||
return FOOD_GROUPS[state.themeIndex].danger
|
||||
}
|
||||
if (state.rng() < COLUMN_GROUP_BIAS) return state.activeSafeKeys[col % state.activeSafeKeys.length]
|
||||
return state.activeSafeKeys[Math.floor(state.rng() * state.activeSafeKeys.length)]
|
||||
}
|
||||
|
||||
function pickDropKey(state, snapshot, index, col) {
|
||||
for (let attempt = 0; attempt < 16; attempt += 1) {
|
||||
const key = randomFoodKey(state, snapshot, col)
|
||||
if (!wouldCreateMatchAt(snapshot, index, key)) return key
|
||||
}
|
||||
const fallback = [...state.activeSafeKeys, FOOD_GROUPS[state.themeIndex].danger]
|
||||
.find(key => !wouldCreateMatchAt(snapshot, index, key))
|
||||
return fallback || state.activeSafeKeys[(index + col) % state.activeSafeKeys.length]
|
||||
}
|
||||
|
||||
function collapseAndFill(state) {
|
||||
const next = Array(ROWS * COLS).fill(null)
|
||||
for (let col = 0; col < COLS; col += 1) {
|
||||
const existing = []
|
||||
for (let row = ROWS - 1; row >= 0; row -= 1) {
|
||||
const item = state.board[row * COLS + col]
|
||||
if (item) existing.push(item)
|
||||
}
|
||||
existing.forEach((item, offset) => {
|
||||
next[(ROWS - 1 - offset) * COLS + col] = item
|
||||
})
|
||||
}
|
||||
for (let row = 0; row < ROWS; row += 1) {
|
||||
for (let col = 0; col < COLS; col += 1) {
|
||||
const index = row * COLS + col
|
||||
if (!next[index]) next[index] = tile(pickDropKey(state, next, index, col))
|
||||
}
|
||||
}
|
||||
state.board = next
|
||||
}
|
||||
|
||||
function reshuffle(state) {
|
||||
prepareTransitionForReshuffle(state)
|
||||
let candidate = null
|
||||
for (let attempt = 0; attempt < 240; attempt += 1) {
|
||||
const shuffled = shuffle(state.board, state.rng)
|
||||
if (findMatches(shuffled).length || !safeMoves(shuffled).length) continue
|
||||
candidate = shuffled
|
||||
break
|
||||
}
|
||||
state.board = candidate || buildPlayableBoard(state, forcedPlayableKeys(state))
|
||||
finishTransitionIfReady(state)
|
||||
state.metrics.reshuffles += 1
|
||||
}
|
||||
|
||||
function advanceTheme(state) {
|
||||
const previous = FOOD_GROUPS[state.themeIndex]
|
||||
state.themeIndex = (state.themeIndex + 1) % FOOD_GROUPS.length
|
||||
const next = FOOD_GROUPS[state.themeIndex]
|
||||
state.board = state.board.map(item => item?.key === previous.danger ? { ...item, key: next.danger } : item)
|
||||
queueSafeGroupTransition(state, next.safe)
|
||||
state.metrics.themeChanges += 1
|
||||
}
|
||||
|
||||
function resolveMatches(state, initialGroups) {
|
||||
let groups = initialGroups
|
||||
let wave = 0
|
||||
let camelMatch = 0
|
||||
while (groups.length && wave < MAX_CASCADE_WAVES) {
|
||||
wave += 1
|
||||
const remove = new Set(groups.flatMap(group => group.indices))
|
||||
for (const group of groups) {
|
||||
if (group.indices.every(index => state.board[index]?.key === 'camel')) {
|
||||
camelMatch = Math.max(camelMatch, group.indices.length)
|
||||
}
|
||||
}
|
||||
remove.forEach(index => { state.board[index] = null })
|
||||
collapseAndFill(state)
|
||||
finishTransitionIfReady(state)
|
||||
groups = findMatches(state.board)
|
||||
const autoHigh = groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key)))
|
||||
if (autoHigh) {
|
||||
state.metrics.automaticHighAvoided += 1
|
||||
state.board = makeStablePlayableBoard(state, state.board)
|
||||
groups = []
|
||||
} else if (wave >= MAX_CASCADE_WAVES && groups.length) {
|
||||
state.metrics.cascadeCaps += 1
|
||||
state.board = makeStablePlayableBoard(state, state.board)
|
||||
groups = []
|
||||
}
|
||||
}
|
||||
state.metrics.maxCascade = Math.max(state.metrics.maxCascade, wave)
|
||||
if (camelMatch >= 3) advanceTheme(state)
|
||||
}
|
||||
|
||||
function useCamelTool(state) {
|
||||
const danger = FOOD_GROUPS[state.themeIndex].danger
|
||||
const targets = state.board
|
||||
.map((item, index) => item?.key === danger ? index : -1)
|
||||
.filter(index => index >= 0)
|
||||
if (!targets.length) return false
|
||||
const index = targets[Math.floor(state.rng() * targets.length)]
|
||||
state.board[index] = tile('camel')
|
||||
state.metrics.toolUses += 1
|
||||
const groups = findMatches(state.board)
|
||||
if (groups.length) resolveMatches(state, groups)
|
||||
return true
|
||||
}
|
||||
|
||||
function recordFailure(state, type, detail = '') {
|
||||
state.metrics.failures[type] = (state.metrics.failures[type] || 0) + 1
|
||||
if (state.metrics.samples.length < 20) {
|
||||
state.metrics.samples.push({ run: state.run, turn: state.turn, type, detail, board: state.board.map(item => item?.key || '-') })
|
||||
}
|
||||
}
|
||||
|
||||
function validateState(state, expectStable = true) {
|
||||
if (state.board.length !== ROWS * COLS || state.board.some(item => !item?.key)) {
|
||||
recordFailure(state, 'invalid_board_size')
|
||||
return false
|
||||
}
|
||||
const currentDanger = FOOD_GROUPS[state.themeIndex].danger
|
||||
const highKeys = state.board.filter(item => HIGH_KEYS.has(item.key)).map(item => item.key)
|
||||
if (highKeys.some(key => key !== currentDanger)) recordFailure(state, 'wrong_theme_danger', highKeys.join(','))
|
||||
if (highKeys.length > HIGH_CAP) recordFailure(state, 'danger_cap_exceeded', String(highKeys.length))
|
||||
|
||||
const allowed = new Set([...state.activeSafeKeys, 'camel'])
|
||||
if (state.transition) allowed.add(state.transition.from)
|
||||
const stale = state.board.filter(item => !HIGH_KEYS.has(item.key) && !allowed.has(item.key)).map(item => item.key)
|
||||
if (stale.length) recordFailure(state, 'stale_food', stale.join(','))
|
||||
|
||||
const normalTypes = new Set(state.board.filter(item => SAFE_KEYS.has(item.key)).map(item => item.key))
|
||||
if (normalTypes.size > 5) recordFailure(state, 'too_many_normal_types', String(normalTypes.size))
|
||||
if (new Set(state.activeSafeKeys).size !== 4) recordFailure(state, 'duplicate_active_food')
|
||||
if (state.transition) {
|
||||
const remaining = state.board.filter(item => item.key === state.transition.from).length
|
||||
if (remaining <= 2) recordFailure(state, 'orphan_transition_tail', `${state.transition.from}:${remaining}`)
|
||||
}
|
||||
if (expectStable && findMatches(state.board).length) recordFailure(state, 'unresolved_match')
|
||||
return true
|
||||
}
|
||||
|
||||
function createMetrics() {
|
||||
return {
|
||||
runs: RUNS,
|
||||
turnsPerRun: TURNS,
|
||||
totalTurns: 0,
|
||||
validMoves: 0,
|
||||
reshuffles: 0,
|
||||
reshuffleImports: 0,
|
||||
guaranteedFallbacks: 0,
|
||||
stableFallbacks: 0,
|
||||
themeChanges: 0,
|
||||
toolUses: 0,
|
||||
tailConversions: 0,
|
||||
automaticHighAvoided: 0,
|
||||
cascadeCaps: 0,
|
||||
maxCascade: 0,
|
||||
unsafeHighMoveProbes: 0,
|
||||
maxTransitionAge: 0,
|
||||
maxQueuedThemes: 0,
|
||||
transitionDrainTurns: 0,
|
||||
unfinishedTransitions: 0,
|
||||
failures: {},
|
||||
samples: []
|
||||
}
|
||||
}
|
||||
|
||||
function runTargetedTailTests(metrics) {
|
||||
for (let groupIndex = 0; groupIndex < FOOD_GROUPS.length; groupIndex += 1) {
|
||||
const nextIndex = (groupIndex + 1) % FOOD_GROUPS.length
|
||||
const previousGroup = FOOD_GROUPS[groupIndex]
|
||||
const nextGroup = FOOD_GROUPS[nextIndex]
|
||||
// 相邻主题允许在相同槽位保留同一种食物。定向测试应选择真正退出下一组的食物,
|
||||
// 不能固定测试第 0 槽,否则“牛奶→牛奶”会被误报为尾巴未转换。
|
||||
const slot = previousGroup.safe.findIndex(key => !nextGroup.safe.includes(key))
|
||||
if (slot < 0) continue
|
||||
for (const remaining of [0, 1, 2]) {
|
||||
const state = {
|
||||
run: -1,
|
||||
turn: remaining,
|
||||
rng: createRng(BASE_SEED + groupIndex * 17 + remaining),
|
||||
metrics,
|
||||
themeIndex: nextIndex,
|
||||
activeSafeKeys: [...nextGroup.safe],
|
||||
transition: {
|
||||
from: previousGroup.safe[slot],
|
||||
to: nextGroup.safe[slot],
|
||||
slot,
|
||||
age: 0
|
||||
},
|
||||
queuedSafeGroups: [[...nextGroup.safe]],
|
||||
board: []
|
||||
}
|
||||
const fill = nextGroup.safe
|
||||
state.board = Array.from({ length: ROWS * COLS }, (_, index) => tile(fill[index % fill.length]))
|
||||
for (let index = 0; index < remaining; index += 1) state.board[index] = tile(state.transition.from)
|
||||
finishTransitionIfReady(state)
|
||||
if (state.board.some(item => item.key === state.transition?.from || item.key === previousGroup.safe[slot])) {
|
||||
recordFailure(state, 'targeted_tail_not_converted', `${groupIndex}:${remaining}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const metrics = createMetrics()
|
||||
runTargetedTailTests(metrics)
|
||||
|
||||
for (let run = 0; run < RUNS; run += 1) {
|
||||
const state = {
|
||||
run,
|
||||
turn: 0,
|
||||
rng: createRng(BASE_SEED + run * 2654435761),
|
||||
metrics,
|
||||
themeIndex: run % FOOD_GROUPS.length,
|
||||
activeSafeKeys: [...FOOD_GROUPS[run % FOOD_GROUPS.length].safe],
|
||||
transition: null,
|
||||
queuedSafeGroups: [],
|
||||
board: []
|
||||
}
|
||||
state.board = buildPlayableBoard(state)
|
||||
validateState(state)
|
||||
|
||||
let toolsUsed = 0
|
||||
for (let turn = 0; turn < TURNS; turn += 1) {
|
||||
state.turn = turn
|
||||
metrics.totalTurns += 1
|
||||
if (state.transition) {
|
||||
state.transition.age += 1
|
||||
metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age)
|
||||
}
|
||||
metrics.maxQueuedThemes = Math.max(metrics.maxQueuedThemes, state.queuedSafeGroups.length)
|
||||
|
||||
// 模拟玩家积攒到驼乳后替换高糖,但每局限制5次,避免测试人为填满棋盘。
|
||||
if (toolsUsed < 5 && turn > 0 && turn % 13 === 0 && useCamelTool(state)) {
|
||||
toolsUsed += 1
|
||||
finishTransitionIfReady(state)
|
||||
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
|
||||
}
|
||||
|
||||
let moves = safeMoves(state.board)
|
||||
if (!moves.length) {
|
||||
reshuffle(state)
|
||||
moves = safeMoves(state.board)
|
||||
}
|
||||
if (!moves.length) {
|
||||
recordFailure(state, 'dead_board_after_reshuffle')
|
||||
break
|
||||
}
|
||||
|
||||
// 额外探测“主动三消高糖会结束”的判定,不改变主模拟棋盘。
|
||||
const unsafe = enumerateMatchingMoves(state.board).find(move => move.hitsHigh)
|
||||
if (unsafe) {
|
||||
const probe = swap(state.board, unsafe.from, unsafe.to)
|
||||
const direct = findMatches(probe)
|
||||
if (!direct.some(group => group.indices.some(index => HIGH_KEYS.has(probe[index]?.key)))) {
|
||||
recordFailure(state, 'unsafe_high_probe_missed')
|
||||
}
|
||||
metrics.unsafeHighMoveProbes += 1
|
||||
}
|
||||
|
||||
const move = moves[Math.floor(state.rng() * moves.length)]
|
||||
state.board = swap(state.board, move.from, move.to)
|
||||
const groups = findMatches(state.board)
|
||||
if (!groups.length) {
|
||||
recordFailure(state, 'safe_move_without_match')
|
||||
break
|
||||
}
|
||||
if (groups.some(group => group.indices.some(index => HIGH_KEYS.has(state.board[index]?.key)))) {
|
||||
recordFailure(state, 'safe_move_hit_high')
|
||||
break
|
||||
}
|
||||
resolveMatches(state, groups)
|
||||
metrics.validMoves += 1
|
||||
|
||||
// 用较快节奏覆盖连续换组和排队逻辑,强度高于正常玩家。
|
||||
if ((turn + 1) % 16 === 0 && turn + 1 < TURNS) advanceTheme(state)
|
||||
finishTransitionIfReady(state)
|
||||
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
|
||||
if (!safeMoves(state.board).length) reshuffle(state)
|
||||
validateState(state)
|
||||
}
|
||||
|
||||
// 停止新增主题后继续正常消除,验证排队中的旧食物能否全部退出,避免队列永久积压。
|
||||
let drainTurn = 0
|
||||
while ((state.transition || state.queuedSafeGroups.length) && drainTurn < 80) {
|
||||
state.turn = TURNS + drainTurn
|
||||
if (state.transition) {
|
||||
state.transition.age += 1
|
||||
metrics.maxTransitionAge = Math.max(metrics.maxTransitionAge, state.transition.age)
|
||||
}
|
||||
let moves = safeMoves(state.board)
|
||||
if (!moves.length) {
|
||||
reshuffle(state)
|
||||
moves = safeMoves(state.board)
|
||||
}
|
||||
if (!moves.length) {
|
||||
recordFailure(state, 'dead_board_while_draining')
|
||||
break
|
||||
}
|
||||
const move = moves[Math.floor(state.rng() * moves.length)]
|
||||
state.board = swap(state.board, move.from, move.to)
|
||||
resolveMatches(state, findMatches(state.board))
|
||||
metrics.validMoves += 1
|
||||
finishTransitionIfReady(state)
|
||||
if (findMatches(state.board).length) state.board = makeStablePlayableBoard(state, state.board)
|
||||
if (!safeMoves(state.board).length) reshuffle(state)
|
||||
validateState(state)
|
||||
drainTurn += 1
|
||||
}
|
||||
metrics.transitionDrainTurns += drainTurn
|
||||
if (state.transition || state.queuedSafeGroups.length) metrics.unfinishedTransitions += 1
|
||||
}
|
||||
|
||||
const failureCount = Object.values(metrics.failures).reduce((sum, count) => sum + count, 0)
|
||||
const result = {
|
||||
ok: failureCount === 0,
|
||||
...metrics,
|
||||
failureCount
|
||||
}
|
||||
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
||||
if (!result.ok) process.exitCode = 1
|
||||
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import type { Plugin } from 'vite'
|
||||
|
||||
const FOOD_ICON_FILES = [
|
||||
'tofu.jpg',
|
||||
'spinach.jpg',
|
||||
'carrot.jpg',
|
||||
'fish.jpg',
|
||||
'redBeanCongee.jpg',
|
||||
'milkTea.jpg'
|
||||
]
|
||||
|
||||
/**
|
||||
* Keep endless-game food icons inside the tongji subpackage.
|
||||
* Importing them from Vue would make Vite emit them into the main-package assets folder.
|
||||
*/
|
||||
export function tongjiFoodAssetsPlugin(): Plugin {
|
||||
return {
|
||||
name: 'tongji-food-assets',
|
||||
apply: 'build',
|
||||
generateBundle() {
|
||||
FOOD_ICON_FILES.forEach((fileName) => {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: `tongji/endless-game/assets/food/${fileName}`,
|
||||
source: readFileSync(resolve(process.cwd(), 'tongji/endless-game/assets/food', fileName))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
|
||||
const connected = ref(false)
|
||||
const loading = ref(false)
|
||||
const syncStatus = ref('idle')
|
||||
const lastError = ref('')
|
||||
const leaderboard = ref({ ...EMPTY_BOARD })
|
||||
const confirmedSessionLearned = ref(0)
|
||||
|
||||
@@ -73,17 +74,19 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
|
||||
}
|
||||
connected.value = true
|
||||
syncStatus.value = 'synced'
|
||||
lastError.value = ''
|
||||
}
|
||||
|
||||
async function refreshLeaderboard() {
|
||||
const res = await api({
|
||||
url: '/api/tcm/gameWeeklyLeaderboard',
|
||||
method: 'GET'
|
||||
method: 'GET',
|
||||
data: { session_key: sessionKey }
|
||||
})
|
||||
if (res?.code !== 1 || !res.data) {
|
||||
throw new Error(res?.msg || '同行榜加载失败')
|
||||
}
|
||||
applyLeaderboard(res.data)
|
||||
applyLeaderboard(res.data, sessionKey)
|
||||
return res.data
|
||||
}
|
||||
|
||||
@@ -112,9 +115,10 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
|
||||
await refreshLeaderboard()
|
||||
if (queuedPayloads.length) flushProgress()
|
||||
return true
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
connected.value = false
|
||||
syncStatus.value = 'offline'
|
||||
lastError.value = String(error?.message || '平台连接失败,请点击重试')
|
||||
return false
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -124,14 +128,21 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
|
||||
return connectPromise
|
||||
}
|
||||
|
||||
function beginSession() {
|
||||
function beginSession(existingSessionKey = '') {
|
||||
if (syncTimer) clearTimeout(syncTimer)
|
||||
sessionKey = createSessionKey()
|
||||
const restoredKey = String(existingSessionKey || '').trim()
|
||||
sessionKey = /^[A-Za-z0-9_-]{16,64}$/.test(restoredKey)
|
||||
? restoredKey
|
||||
: createSessionKey()
|
||||
confirmedSessionLearned.value = 0
|
||||
syncStatus.value = queuedPayloads.length ? 'pending' : (connected.value ? 'synced' : 'offline')
|
||||
return sessionKey
|
||||
}
|
||||
|
||||
function getSessionKey() {
|
||||
return sessionKey
|
||||
}
|
||||
|
||||
function queueProgress(learnedCount, score, ended = false) {
|
||||
const nextPayload = {
|
||||
session_key: sessionKey,
|
||||
@@ -191,9 +202,10 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
|
||||
|| Number(item.ended) > Number(payload.ended)
|
||||
))
|
||||
persistPendingPayloads()
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
connected.value = false
|
||||
syncStatus.value = 'offline'
|
||||
lastError.value = String(error?.message || '成绩暂未保存,联网后会自动重试')
|
||||
// 队首保留原绝对值,下次连接或打开榜单时安全重试。
|
||||
persistPendingPayloads()
|
||||
} finally {
|
||||
@@ -237,10 +249,12 @@ export function useGamePlatform(proxy, ensureLoggedIn) {
|
||||
connected,
|
||||
loading,
|
||||
syncStatus,
|
||||
lastError,
|
||||
leaderboard,
|
||||
confirmedSessionLearned,
|
||||
connect,
|
||||
beginSession,
|
||||
getSessionKey,
|
||||
queueProgress,
|
||||
flushProgress,
|
||||
syncAndRefresh,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# 识糖小课堂 3D 食物图标候选稿 v1
|
||||
|
||||
- 共 42 个食物图标,文件名与 `FOODS` 中的英文键名一致。
|
||||
- 单图尺寸:320 × 320,JPEG。
|
||||
- 风格:中式、圆润 3D、暖色奶油背景、大轮廓、适老化高辨识度。
|
||||
- 糖级、边框和角标不画入图标,由游戏界面控制,方便医生调整分类。
|
||||
- `milkTea.jpg` 是唯一自带角色表情的高糖小反派候选。
|
||||
- 驼乳属于产品道具,不计入 42 个普通食物图标,本轮未替换现有产品罐素材。
|
||||
- 本目录当前仅作设计候选,没有接入正式游戏,也不会进入微信小程序发布包。
|
||||
|
||||
目录说明:
|
||||
|
||||
- `icons/`:可直接用于游戏的独立图标。
|
||||
- `sheets/`:11 张分组预览母版,方便整体审美检查。
|
||||
- `manifest.json`:食物中文名、代码键名与文件路径清单。
|
||||
|
||||
医学提示:图标仅表达食物外观,不代表最终糖级结论;糖级和健康说明仍需甄养堂医生审核。
|
||||
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"version": 1,
|
||||
"style": "chinese-rounded-3d-elderly-friendly",
|
||||
"iconSize": {
|
||||
"width": 320,
|
||||
"height": 320,
|
||||
"format": "jpg"
|
||||
},
|
||||
"items": [
|
||||
{ "key": "corn", "name": "玉米", "file": "icons/corn.jpg" },
|
||||
{ "key": "egg", "name": "鸡蛋", "file": "icons/egg.jpg" },
|
||||
{ "key": "tomato", "name": "番茄", "file": "icons/tomato.jpg" },
|
||||
{ "key": "potato", "name": "红薯", "file": "icons/potato.jpg" },
|
||||
{ "key": "cucumber", "name": "黄瓜", "file": "icons/cucumber.jpg" },
|
||||
{ "key": "lettuce", "name": "生菜", "file": "icons/lettuce.jpg" },
|
||||
{ "key": "grainMantou", "name": "杂粮馒头", "file": "icons/grainMantou.jpg" },
|
||||
{ "key": "wonton", "name": "馄饨", "file": "icons/wonton.jpg" },
|
||||
{ "key": "celery", "name": "芹菜", "file": "icons/celery.jpg" },
|
||||
{ "key": "mushroom", "name": "香菇", "file": "icons/mushroom.jpg" },
|
||||
{ "key": "chicken", "name": "鸡肉", "file": "icons/chicken.jpg" },
|
||||
{ "key": "taro", "name": "芋头", "file": "icons/taro.jpg" },
|
||||
{ "key": "broccoli", "name": "西兰花", "file": "icons/broccoli.jpg" },
|
||||
{ "key": "milk", "name": "牛奶", "file": "icons/milk.jpg" },
|
||||
{ "key": "brownRice", "name": "糙米饭", "file": "icons/brownRice.jpg" },
|
||||
{ "key": "apple", "name": "苹果", "file": "icons/apple.jpg" },
|
||||
{ "key": "strawberry", "name": "草莓", "file": "icons/strawberry.jpg" },
|
||||
{ "key": "banana", "name": "香蕉", "file": "icons/banana.jpg" },
|
||||
{ "key": "mango", "name": "芒果", "file": "icons/mango.jpg" },
|
||||
{ "key": "tea", "name": "红茶", "file": "icons/tea.jpg" },
|
||||
{ "key": "blackCoffee", "name": "黑咖啡", "file": "icons/blackCoffee.jpg" },
|
||||
{ "key": "coffee", "name": "咖啡", "file": "icons/coffee.jpg" },
|
||||
{ "key": "pineapple", "name": "菠萝", "file": "icons/pineapple.jpg" },
|
||||
{ "key": "pepper", "name": "青椒", "file": "icons/pepper.jpg" },
|
||||
{ "key": "greenBean", "name": "四季豆", "file": "icons/greenBean.jpg" },
|
||||
{ "key": "shrimp", "name": "虾仁", "file": "icons/shrimp.jpg" },
|
||||
{ "key": "udon", "name": "乌冬面", "file": "icons/udon.jpg" },
|
||||
{ "key": "onion", "name": "洋葱", "file": "icons/onion.jpg" },
|
||||
{ "key": "congee", "name": "白米粥", "file": "icons/congee.jpg" },
|
||||
{ "key": "eightCongee", "name": "八宝粥", "file": "icons/eightCongee.jpg" },
|
||||
{ "key": "centuryCongee", "name": "皮蛋瘦肉粥", "file": "icons/centuryCongee.jpg" },
|
||||
{ "key": "milletCongee", "name": "小米粥", "file": "icons/milletCongee.jpg" },
|
||||
{ "key": "orangeJuice", "name": "柳橙汁", "file": "icons/orangeJuice.jpg" },
|
||||
{ "key": "soda", "name": "含糖汽水", "file": "icons/soda.jpg" },
|
||||
{ "key": "riceNoodleSoup", "name": "汤粉", "file": "icons/riceNoodleSoup.jpg" },
|
||||
{ "key": "noodleSoup", "name": "汤面", "file": "icons/noodleSoup.jpg" },
|
||||
{ "key": "milkTea", "name": "含糖奶茶", "file": "icons/milkTea.jpg" },
|
||||
{ "key": "tofu", "name": "豆腐", "file": "icons/tofu.jpg" },
|
||||
{ "key": "spinach", "name": "菠菜", "file": "icons/spinach.jpg" },
|
||||
{ "key": "carrot", "name": "胡萝卜", "file": "icons/carrot.jpg" },
|
||||
{ "key": "fish", "name": "鱼", "file": "icons/fish.jpg" },
|
||||
{ "key": "redBeanCongee", "name": "软烂红豆粥", "file": "icons/redBeanCongee.jpg" }
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 110 KiB |
@@ -46,7 +46,44 @@
|
||||
.eg-nav-actions { display: flex; width: 156rpx; flex-shrink: 0; justify-content: flex-end; gap: 10rpx; }
|
||||
.eg-nav-btn--small { width: 68rpx; height: 68rpx; border-radius: 22rpx; }
|
||||
|
||||
.eg-title-wrap { display: flex; min-width: 0; flex-direction: column; align-items: center; }
|
||||
.eg-title-wrap { position: relative; display: flex; min-width: 0; flex-direction: column; align-items: center; }
|
||||
.eg-title-wrap > .eg-title,
|
||||
.eg-title-wrap > .eg-subtitle { transition: opacity .08s ease; }
|
||||
.eg-title-wrap.is-noticing > .eg-title,
|
||||
.eg-title-wrap.is-noticing > .eg-subtitle { opacity: .16; }
|
||||
.eg-nav-notice {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 5rpx 8rpx;
|
||||
border: 1rpx solid rgba(21,94,75,.09);
|
||||
border-radius: 16rpx;
|
||||
color: rgba(38,86,72,.82);
|
||||
background: rgba(255,255,255,.58);
|
||||
box-shadow: 0 4rpx 12rpx rgba(13,64,51,.06);
|
||||
text-align: center;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: egNavNotice 1.05s ease-out both;
|
||||
pointer-events: none;
|
||||
}
|
||||
.eg-nav-notice-title,
|
||||
.eg-nav-notice-detail {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
max-width: 100%;
|
||||
line-height: 1.15;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.eg-nav-notice-title { font-size: 23rpx; font-weight: 800; }
|
||||
.eg-nav-notice-detail { margin-top: 2rpx; color: rgba(154,106,42,.78); font-size: 20rpx; font-weight: 700; }
|
||||
.eg-title, .eg-subtitle { overflow: hidden; max-width: 100%; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.eg-title { color: #155e4b; font-size: 40rpx; font-weight: 800; }
|
||||
.eg-subtitle { margin-top: 2rpx; color: #648278; font-size: 24rpx; }
|
||||
@@ -117,12 +154,20 @@
|
||||
background: rgba(255,255,255,.92);
|
||||
box-shadow: 0 18rpx 44rpx rgba(29, 78, 59, .12);
|
||||
}
|
||||
.eg-board-shell.is-flowing {
|
||||
border-color: rgba(245, 158, 11, .72);
|
||||
box-shadow: 0 0 0 5rpx rgba(255, 221, 89, .2), 0 18rpx 46rpx rgba(234, 88, 12, .2);
|
||||
animation: egFlowShell 1.05s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.eg-board-top { display: flex; align-items: center; justify-content: space-between; margin: 0 4rpx 14rpx; }
|
||||
.eg-board-bonuses { display: flex; align-items: center; gap: 8rpx; }
|
||||
.eg-combo { padding: 8rpx 16rpx; border-radius: 999rpx; color: #45685d; background: #e8f3ee; font-size: 25rpx; font-weight: 700; }
|
||||
.eg-combo.is-hot { color: #fff; background: linear-gradient(135deg, #f59e0b, #ea580c); }
|
||||
.eg-double-state { padding: 8rpx 14rpx; border: 2rpx solid rgba(255,255,255,.9); border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #7c3aed, #d946ef); box-shadow: 0 4rpx 12rpx rgba(124,58,237,.24); font-size: 23rpx; font-weight: 900; white-space: nowrap; }
|
||||
.eg-flow-state { display: flex; align-items: center; gap: 8rpx; padding: 8rpx 14rpx; border: 3rpx solid #fff3a3; border-radius: 999rpx; color: #fff; background: linear-gradient(135deg, #f97316, #dc2626); box-shadow: 0 5rpx 16rpx rgba(220,38,38,.3); font-size: 25rpx; font-weight: 1000; white-space: nowrap; animation: egFlowBadge .62s ease-in-out infinite alternate; }
|
||||
.eg-flow-state > text:last-child { min-width: 62rpx; color: #fff7a8; text-align: right; }
|
||||
.eg-flow-state.is-paused { border-color: #d9f6ff; background: linear-gradient(135deg, #1687b4, #3155a6); box-shadow: 0 5rpx 16rpx rgba(34,109,166,.28); animation: none; }
|
||||
.eg-risk { display: flex; align-items: center; gap: 10rpx; color: #8d512c; font-size: 24rpx; }
|
||||
.eg-risk-dots { display: flex; gap: 5rpx; }
|
||||
.eg-risk-dot { width: 12rpx; height: 12rpx; border-radius: 50%; background: #ead8c9; }
|
||||
@@ -167,7 +212,15 @@
|
||||
.eg-cell.is-level-high { background: #fff0e4; }
|
||||
.eg-cell.is-milk { background: #edf7ff; }
|
||||
.eg-cell.is-high { border-color: #ef7b45; }
|
||||
.eg-cell.is-villain {
|
||||
background: radial-gradient(circle at 50% 38%, #fff7e8 0%, #ffe9d8 70%, #ffddc6 100%);
|
||||
box-shadow: inset 0 -5rpx 0 rgba(154,57,25,.08), 0 6rpx 15rpx rgba(191,72,31,.16);
|
||||
}
|
||||
.eg-cell.is-villain .eg-food-image { animation: egVillainPeek 2.2s ease-in-out infinite; }
|
||||
.eg-cell.is-villain.is-clearing .eg-food-image,
|
||||
.eg-cell.is-villain.is-dragging .eg-food-image { animation: none; }
|
||||
.eg-food-image { width: 132rpx; height: 92rpx; margin-top: 7rpx; border-radius: 17rpx; filter: saturate(1.06) contrast(1.03); }
|
||||
.eg-food-image.is-large-square { width: 136rpx; height: 102rpx; margin-top: 2rpx; filter: saturate(1.1) contrast(1.06); }
|
||||
.eg-food-name { margin-top: 8rpx; color: #203d34; font-size: 29rpx; font-weight: 900; letter-spacing: 1rpx; line-height: 1.1; }
|
||||
.eg-sugar-tag { position: absolute; top: 5rpx; right: 5rpx; min-width: 52rpx; padding: 4rpx 10rpx; border: 2rpx solid #fff; border-radius: 999rpx; color: #fff; box-shadow: 0 3rpx 8rpx rgba(25,55,45,.22); font-size: 23rpx; font-weight: 900; line-height: 1.25; text-align: center; }
|
||||
.eg-sugar-tag.is-low { color: #316a54; border-color: rgba(255,255,255,.82); background: #dcefe6; box-shadow: 0 2rpx 5rpx rgba(32,101,75,.1); }
|
||||
@@ -337,6 +390,9 @@
|
||||
.eg-weekly-list { display: flex; flex-direction: column; gap: 7rpx; margin-top: 15rpx; }
|
||||
.eg-weekly-row { display: flex; min-height: 68rpx; align-items: center; padding: 7rpx 14rpx; border: 2rpx solid transparent; border-radius: 19rpx; color: #345c50; background: rgba(255,255,255,.72); }
|
||||
.eg-weekly-row.is-me { border-color: #f2bd48; color: #174f3e; background: linear-gradient(90deg, #fff2bb, #fff9e3); box-shadow: 0 5rpx 14rpx rgba(153,102,17,.12); transform: scale(1.015); }
|
||||
.eg-weekly-row.is-waiting { color: #8a9b94; border-style: dashed; border-color: #dbe8e2; background: rgba(247,251,249,.72); }
|
||||
.eg-weekly-row.is-waiting .eg-weekly-avatar { color: #6e887e; border-color: #dce9e3; background: #edf4f1; box-shadow: none; }
|
||||
.eg-weekly-row.is-waiting .eg-weekly-count { color: #9baba4; }
|
||||
.eg-weekly-place { width: 43rpx; color: #6b7d76; font-size: 27rpx; font-weight: 1000; text-align: center; }
|
||||
.eg-weekly-row:nth-child(1) .eg-weekly-place { color: #dd7b17; font-size: 31rpx; }
|
||||
.eg-weekly-avatar { display: flex; width: 52rpx; height: 52rpx; flex: 0 0 52rpx; align-items: center; justify-content: center; margin-left: 6rpx; border: 3rpx solid rgba(255,255,255,.9); border-radius: 50%; color: #fff; background: #5d9b84; box-shadow: 0 3rpx 9rpx rgba(30,78,61,.16); font-size: 24rpx; font-weight: 1000; }
|
||||
@@ -350,6 +406,9 @@
|
||||
.eg-weekly-name { flex: 1; margin-left: 13rpx; overflow: hidden; font-size: 27rpx; font-weight: 850; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.eg-weekly-me { margin-right: 8rpx; padding: 3rpx 9rpx; border-radius: 999rpx; color: #fff; background: #dd6b27; font-size: 18rpx; font-weight: 900; }
|
||||
.eg-weekly-count { min-width: 70rpx; color: #315b4d; font-size: 29rpx; font-weight: 1000; text-align: right; }
|
||||
.eg-weekly-connection { display: flex; align-items: center; justify-content: space-between; gap: 12rpx; margin-top: 13rpx; padding: 12rpx 15rpx; border: 2rpx solid #e4d7b2; border-radius: 18rpx; color: #6b6655; background: #fff9e8; font-size: 21rpx; font-weight: 700; }
|
||||
.eg-weekly-connection > text:first-child { flex: 1; }
|
||||
.eg-weekly-retry { flex-shrink: 0; padding: 6rpx 12rpx; border-radius: 999rpx; color: #fff; background: #23765c; font-weight: 900; }
|
||||
.eg-cheer-card { margin-top: 16rpx; padding: 15rpx 17rpx 14rpx; border: 3rpx solid #f0d38d; border-radius: 23rpx; background: linear-gradient(135deg, #fff9d9, #fff1c2); }
|
||||
.eg-cheer-card.is-family { border-color: #d9c8ef; background: linear-gradient(135deg, #f8f2ff, #efe7ff); }
|
||||
.eg-cheer-card.is-bright { border-color: #f4c77b; background: linear-gradient(135deg, #fff8dc, #ffeec7); }
|
||||
@@ -417,6 +476,13 @@
|
||||
100% { opacity: 0; transform: scale(.32) rotate(7deg); }
|
||||
}
|
||||
|
||||
@keyframes egVillainPeek {
|
||||
0%, 72%, 100% { transform: translateY(0) rotate(0); filter: saturate(1.08) contrast(1.05); }
|
||||
80% { transform: translateY(-3rpx) rotate(-1.5deg); filter: saturate(1.15) contrast(1.08); }
|
||||
88% { transform: translateY(0) rotate(1.5deg); }
|
||||
95% { transform: translateY(-2rpx) rotate(0); }
|
||||
}
|
||||
|
||||
@keyframes egTaskRewardFlight {
|
||||
0% { opacity: 0; transform: translate3d(-20rpx, 0, 0) scale(.62) rotate(-8deg); }
|
||||
16% { opacity: 1; transform: translate3d(0, -24rpx, 0) scale(1.1) rotate(3deg); }
|
||||
@@ -424,6 +490,13 @@
|
||||
100% { opacity: 0; transform: translate3d(350rpx, 1205rpx, 0) scale(.3) rotate(2deg); }
|
||||
}
|
||||
|
||||
@keyframes egNavNotice {
|
||||
0% { opacity: 0; transform: translate(-50%, -42%); }
|
||||
12% { opacity: .9; transform: translate(-50%, -50%); }
|
||||
42% { opacity: .82; transform: translate(-50%, -50%); }
|
||||
100% { opacity: 0; transform: translate(-50%, -58%); }
|
||||
}
|
||||
|
||||
@keyframes egRewardGlow {
|
||||
from { opacity: .4; transform: scale(.86); }
|
||||
to { opacity: .9; transform: scale(1.12); }
|
||||
@@ -553,6 +626,16 @@
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes egFlowShell {
|
||||
from { filter: saturate(1); }
|
||||
to { filter: saturate(1.08); }
|
||||
}
|
||||
|
||||
@keyframes egFlowBadge {
|
||||
from { transform: scale(.98); filter: brightness(.96); }
|
||||
to { transform: scale(1.035); filter: brightness(1.12); }
|
||||
}
|
||||
|
||||
@keyframes egStageIn {
|
||||
0% { opacity: 0; transform: scale(.7) translateY(34rpx); }
|
||||
18% { opacity: 1; transform: scale(1.06) translateY(0); }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import uni from '@dcloudio/vite-plugin-uni'
|
||||
import Optimization from '@uni-ku/bundle-optimizer'
|
||||
import { tongjiFoodAssetsPlugin } from './tongji/endless-game/build/tongjiFoodAssetsPlugin'
|
||||
|
||||
// uni-app 工程根目录就是源码目录(HBuilderX 兼容)
|
||||
// 编译产物默认输出到 ./dist/<mode>/<platform>
|
||||
@@ -16,6 +17,7 @@ export default defineConfig({
|
||||
dts: false,
|
||||
logger: false,
|
||||
}),
|
||||
tongjiFoodAssetsPlugin(),
|
||||
],
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
|
||||
@@ -1,281 +0,0 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface MyPatientListParams {
|
||||
page_no: number
|
||||
page_size: number
|
||||
keyword?: string
|
||||
status_filter?: '' | 'unbooked' | 'pending_interview' | 'completed' | 'missed'
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
export function myPatientLists(params: MyPatientListParams) {
|
||||
return request.get({ url: '/firstvisit.myPatient/lists', params })
|
||||
}
|
||||
|
||||
export interface MyPatientOrderListParams {
|
||||
page_no: number
|
||||
page_size: number
|
||||
keyword?: string
|
||||
prescription_audit_status?: '' | number
|
||||
payment_slip_audit_status?: '' | number
|
||||
fulfillment_status?: '' | number
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
export function myPatientOrderLists(params: MyPatientOrderListParams) {
|
||||
return request.get({ url: '/firstvisit.myPatient/orders', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderDetail(params: { id: number }) {
|
||||
return request.get({ url: '/firstvisit.myPatient/orderDetail', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderEdit(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderEdit', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderAuditPrescription(params: {
|
||||
id: number
|
||||
action: 'approve' | 'reject'
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderAuditPrescription', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderRevokeRxAudit(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderRevokeRxAudit', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderAuditPayment(params: {
|
||||
id: number
|
||||
action: 'approve' | 'reject'
|
||||
remark?: string
|
||||
}) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderAuditPayment', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderRevokePayAudit(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderRevokePayAudit', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderDdcode(params: {
|
||||
id: number
|
||||
express_company: string
|
||||
tracking_number: string
|
||||
}) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderDdcode', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderShip(params: {
|
||||
id: number
|
||||
ship_mode?: 'gancao' | 'direct'
|
||||
express_company: string
|
||||
tracking_number: string
|
||||
}) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderShip', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderAddPayOrder(params: {
|
||||
id: number
|
||||
order_type: number
|
||||
pay_amount: number
|
||||
pay_remark?: string
|
||||
completion_request?: number
|
||||
pay_create_type?: 'fubei' | 'express_cod'
|
||||
}) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderAddPayOrder', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderComplete(params: { id: number; fulfillment_status: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderComplete', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderRefund(params: { id: number; reason: string; refund_amount?: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderRefund', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderWithdraw(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderWithdraw', params })
|
||||
}
|
||||
|
||||
export function myPatientOrderUploadToPharmacy(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/orderUploadToPharmacy', params })
|
||||
}
|
||||
|
||||
export interface MyPatientProgressListParams {
|
||||
page_no: number
|
||||
page_size: number
|
||||
keyword?: string
|
||||
status?: '' | 1 | 3 | 4
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
}
|
||||
|
||||
export function myPatientProgressLists(params: MyPatientProgressListParams) {
|
||||
return request.get({ url: '/firstvisit.myPatient/progress', params })
|
||||
}
|
||||
|
||||
export function myPatientCreateAppointment(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.myPatient/createAppointment', params })
|
||||
}
|
||||
|
||||
export function myPatientCancelAppointment(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/cancelAppointment', params })
|
||||
}
|
||||
|
||||
export function myPatientAssistants() {
|
||||
return request.get({ url: '/firstvisit.myPatient/assistants' })
|
||||
}
|
||||
|
||||
export function myPatientAssign(params: { id: number; assistant_id: number; is_inherit?: 0 | 1 }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/assign', params })
|
||||
}
|
||||
|
||||
export function myPatientFillIdCard(params: { id: number; id_card: string }) {
|
||||
return request.post({ url: '/firstvisit.myPatient/fillIdCard', params })
|
||||
}
|
||||
|
||||
export interface FirstVisitConversionParams {
|
||||
time_type: 'today' | 'yesterday' | 'week' | 'month' | 'quarter' | 'year'
|
||||
dept_id?: number
|
||||
assistant_id?: number
|
||||
media_channel_code?: string
|
||||
}
|
||||
|
||||
/** 一诊综合数据转化:服务端按当前角色 DataScope 与所选部门/员工取交集。 */
|
||||
export function firstVisitConversionOverview(params: FirstVisitConversionParams) {
|
||||
return request.get(
|
||||
{ url: '/firstvisit.conversion/overview', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export interface FirstVisitRegistrationStatsParams {
|
||||
time_type: 'today' | 'week' | 'month'
|
||||
dept_id?: number
|
||||
assistant_id?: number
|
||||
}
|
||||
|
||||
/** 一诊挂号统计:部门和员工参数只会在服务端 DataScope 权限范围内继续收窄。 */
|
||||
export function firstVisitRegistrationStatsOverview(params: FirstVisitRegistrationStatsParams) {
|
||||
return request.get(
|
||||
{ url: '/firstvisit.registrationStats/overview', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export interface FirstVisitDoctorDashboardParams {
|
||||
time_type: 'today' | 'week' | 'month'
|
||||
dept_id?: number
|
||||
doctor_id?: number
|
||||
active_only?: 0 | 1
|
||||
alert_threshold?: number
|
||||
}
|
||||
|
||||
/** 一诊医生看板:医生与经手医助范围均由服务端根据当前角色和部门权限计算。 */
|
||||
export function firstVisitDoctorDashboardOverview(params: FirstVisitDoctorDashboardParams) {
|
||||
return request.get(
|
||||
{ url: '/firstvisit.doctorDashboard/overview', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function wecomPromotionOverview() {
|
||||
return request.get({ url: '/firstvisit.wecomPromotion/overview' })
|
||||
}
|
||||
|
||||
export function wecomPromotionSavePool(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/savePool', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionDeletePool(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deletePool', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionSaveLink(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/saveLink', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionCheckApiPermission() {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/checkApiPermission' })
|
||||
}
|
||||
|
||||
export function wecomPromotionSyncRemoteLinks(params: { pool_id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/syncRemoteLinks', params, timeout: 120000 })
|
||||
}
|
||||
|
||||
export function wecomPromotionRemoteLinkDetail(params: { id: number }) {
|
||||
return request.get({ url: '/firstvisit.wecomPromotion/remoteLinkDetail', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionDeleteRemoteLink(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deleteRemoteLink', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionToggleLink(params: { id: number; status: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/toggleLink', params })
|
||||
}
|
||||
|
||||
export function wecomPromotionDeleteLink(params: { id: number }) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/deleteLink', params })
|
||||
}
|
||||
|
||||
export type WecomPromotionCustomerChatStatus = '' | 'messaged' | 'silent' | 'unknown'
|
||||
|
||||
export interface WecomPromotionCustomerStatsParams {
|
||||
page_no: number
|
||||
page_size: number
|
||||
promotion_link_id?: number
|
||||
userid?: string
|
||||
chat_status?: 0 | 1 | 2
|
||||
}
|
||||
|
||||
export interface WecomPromotionCustomerStatsSummary {
|
||||
customer_count: number
|
||||
messaged_customer_count: number
|
||||
message_customer_rate: number
|
||||
received_message_count: number
|
||||
message_count_known_count: number
|
||||
}
|
||||
|
||||
export interface WecomPromotionCustomerStatRow {
|
||||
id?: number
|
||||
external_userid_masked?: string
|
||||
customer_id_masked?: string
|
||||
customer_name_masked?: string
|
||||
link_id?: number | string
|
||||
link_name?: string
|
||||
member_id?: number
|
||||
member_name?: string
|
||||
department_name?: string
|
||||
dept_name?: string
|
||||
has_messaged?: boolean | number
|
||||
chat_status?: 0 | 1 | 2
|
||||
message_count_known?: boolean | number
|
||||
received_message_count?: number
|
||||
last_synced_at?: string
|
||||
last_message_at?: string
|
||||
}
|
||||
|
||||
export interface WecomPromotionCustomerStatsResult {
|
||||
summary?: Partial<WecomPromotionCustomerStatsSummary>
|
||||
lists?: WecomPromotionCustomerStatRow[]
|
||||
total?: number
|
||||
link_options?: Array<{ id: number | string; name: string }>
|
||||
member_options?: Array<{ id: number; name: string; department_name?: string; dept_name?: string }>
|
||||
meta?: { last_synced_at?: string }
|
||||
}
|
||||
|
||||
/** 获客客户消息统计:服务端继续按当前角色和部门数据范围收窄。 */
|
||||
export function wecomPromotionCustomerStats(params: WecomPromotionCustomerStatsParams) {
|
||||
return request.get(
|
||||
{ url: '/firstvisit.wecomPromotion/customerStatistics', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function wecomPromotionSyncCustomers(params: { promotion_link_id?: number } = {}) {
|
||||
return request.post({ url: '/firstvisit.wecomPromotion/syncCustomers', params, timeout: 120000 })
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
export interface MedicineMappingQuery {
|
||||
page_no: number
|
||||
page_size: number
|
||||
local_name?: string
|
||||
remote_keyword?: string
|
||||
mapping_status?: '' | 'mapped' | 'unmapped' | 'invalid'
|
||||
}
|
||||
|
||||
export interface MedicineMappingRow {
|
||||
local_medicine_id: number
|
||||
local_name: string
|
||||
local_unit: string
|
||||
local_status: number
|
||||
mapping_id: number | null
|
||||
mapping_status: number
|
||||
medicine_code: string | null
|
||||
remote_name: string | null
|
||||
remote_brand: string | null
|
||||
remote_unit: string | null
|
||||
settlement_price: string | number | null
|
||||
retail_price: string | number | null
|
||||
catalog_version: number | null
|
||||
remote_status: number | null
|
||||
remote_deleted: number | null
|
||||
operator_name: string | null
|
||||
mapping_update_time: number | null
|
||||
}
|
||||
|
||||
export interface CatalogOption {
|
||||
medicine_code: string
|
||||
name: string
|
||||
brand: string
|
||||
unit: string
|
||||
settlement_price: string | number
|
||||
retail_price: string | number
|
||||
catalog_version: number
|
||||
status: number
|
||||
}
|
||||
|
||||
export interface PharmacySyncStatus {
|
||||
sync_enabled: boolean
|
||||
cursor: number
|
||||
last_success_time: number
|
||||
last_failure_time: number
|
||||
last_error_summary: string
|
||||
is_syncing: boolean
|
||||
catalog_total: number
|
||||
catalog_active: number
|
||||
unmapped_local: number
|
||||
}
|
||||
|
||||
export interface PharmacySyncResult {
|
||||
pages: number
|
||||
pulled: number
|
||||
received: number
|
||||
created: number
|
||||
updated: number
|
||||
unchanged: number
|
||||
deactivated: number
|
||||
cursor: number
|
||||
}
|
||||
|
||||
export function medicineMappingLists(params: MedicineMappingQuery) {
|
||||
return request.get({ url: '/pharmacy.medicineMapping/lists', params })
|
||||
}
|
||||
|
||||
export function medicineMappingStatus() {
|
||||
return request.get({ url: '/pharmacy.medicineMapping/status' })
|
||||
}
|
||||
|
||||
export function medicineCatalogOptions(params: { keyword?: string; limit?: number }) {
|
||||
return request.get({ url: '/pharmacy.medicineMapping/catalogOptions', params })
|
||||
}
|
||||
|
||||
export function medicineMappingSave(params: { local_medicine_id: number; medicine_code: string }) {
|
||||
return request.post({ url: '/pharmacy.medicineMapping/save', params })
|
||||
}
|
||||
|
||||
export function medicineMappingUnlink(params: { local_medicine_id: number }) {
|
||||
return request.post({ url: '/pharmacy.medicineMapping/unlink', params })
|
||||
}
|
||||
|
||||
export function medicineCatalogSync() {
|
||||
return request.post({ url: '/pharmacy.medicineMapping/sync' })
|
||||
}
|
||||
@@ -1,13 +1,5 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
/** 角色数据驾驶舱:服务端统一按当前管理员的数据范围聚合。 */
|
||||
export function performanceDashboardOverview(params?: { ranking_dept_id?: number; _t?: number }) {
|
||||
return request.get(
|
||||
{ url: '/stats.performanceDashboard/overview', params, timeout: 120000 },
|
||||
{ ignoreCancelToken: true }
|
||||
)
|
||||
}
|
||||
|
||||
export function getConversionStatsOverview(params: any) {
|
||||
return request.get({ url: '/stats.conversion/overview', params })
|
||||
}
|
||||
|
||||
@@ -424,15 +424,6 @@ export function prescriptionOrderEdit(params: Record<string, unknown>) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/edit', params })
|
||||
}
|
||||
|
||||
/** 仅修改业务订单的承运商与快递单号;所有履约状态均可使用 */
|
||||
export function prescriptionOrderDdcode(params: {
|
||||
id: number
|
||||
express_company: string
|
||||
tracking_number: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/ddcode', params })
|
||||
}
|
||||
|
||||
/** 业务订单详情:仅修改关联处方的患者姓名与手机号 */
|
||||
export function prescriptionOrderPatchPrescriptionPatient(params: {
|
||||
id: number
|
||||
@@ -537,21 +528,6 @@ export function prescriptionOrderSubmitGancaoRecipel(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/submitGancaoRecipel', params })
|
||||
}
|
||||
|
||||
/** 按业务订单 ship_mode 上传:gancao 走甘草,direct 走洛阳药房 ERP */
|
||||
export function prescriptionOrderUploadToPharmacy(params: { id: number }) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/uploadToPharmacy', params })
|
||||
}
|
||||
|
||||
/** 人工核对甘草不确定提交结果。 */
|
||||
export function prescriptionOrderConfirmGancaoSubmission(params: {
|
||||
id: number
|
||||
resolution: 'CONFIRM_SUCCESS' | 'CONFIRM_NOT_CREATED'
|
||||
remote_order_no?: string
|
||||
note: string
|
||||
}) {
|
||||
return request.post({ url: '/tcm.prescriptionOrder/confirmGancaoSubmission', params })
|
||||
}
|
||||
|
||||
/** 甘草药管家:预下单测试(仅 CTM_PREVIEW,不提交订单) */
|
||||
export function prescriptionOrderPreviewGancaoRecipel(params: {
|
||||
id: number
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<span v-if="disabled" class="medicine-name-readonly">{{ modelValue || '—' }}</span>
|
||||
<el-select
|
||||
v-else
|
||||
:model-value="selectedMedicineId"
|
||||
:model-value="modelValue"
|
||||
class="medicine-name-select w-full"
|
||||
filterable
|
||||
remote
|
||||
@@ -14,14 +14,7 @@
|
||||
@visible-change="onVisibleChange"
|
||||
@update:model-value="onUpdate"
|
||||
>
|
||||
<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.id">
|
||||
<div class="medicine-name-option">
|
||||
<span>{{ item.name }}</span>
|
||||
<span class="medicine-name-option__meta">
|
||||
{{ item.id > 0 ? [item.supplier, item.unit, `ID ${item.id}`].filter(Boolean).join(' · ') : '历史名称,请重新选择' }}
|
||||
</span>
|
||||
</div>
|
||||
</el-option>
|
||||
<el-option v-for="item in options" :key="item.id" :label="item.name" :value="item.name" />
|
||||
</el-select>
|
||||
</template>
|
||||
|
||||
@@ -31,7 +24,6 @@ import { medicineLists } from '@/api/medicine'
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
medicineId?: number | null
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{ disabled: false }
|
||||
@@ -39,30 +31,18 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
'update:medicineId': [value: number | undefined]
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
type MedicineOption = { id: number; name: string; supplier?: string; unit?: string }
|
||||
const options = ref<MedicineOption[]>([])
|
||||
|
||||
const selectedMedicineId = computed<number | ''>(() => {
|
||||
const id = Number(props.medicineId)
|
||||
if (Number.isInteger(id) && id > 0) {
|
||||
return id
|
||||
}
|
||||
return (props.modelValue || '').trim() ? 0 : ''
|
||||
})
|
||||
const options = ref<{ id: number; name: string }[]>([])
|
||||
|
||||
function ensureCurrentInOptions() {
|
||||
const v = (props.modelValue || '').trim()
|
||||
if (!v) {
|
||||
return
|
||||
}
|
||||
const id = Number(props.medicineId)
|
||||
const currentId = Number.isInteger(id) && id > 0 ? id : 0
|
||||
if (!options.value.some((o) => o.id === currentId)) {
|
||||
options.value = [{ id: currentId, name: v }, ...options.value]
|
||||
if (!options.value.some((o) => o.name === v)) {
|
||||
options.value = [{ id: 0, name: v }, ...options.value]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +56,7 @@ const remoteMethod = async (query: string) => {
|
||||
page_size: 100,
|
||||
status: 1
|
||||
})
|
||||
options.value = (res.lists || []) as MedicineOption[]
|
||||
options.value = res.lists || []
|
||||
ensureCurrentInOptions()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
@@ -92,19 +72,13 @@ const onVisibleChange = (open: boolean) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onUpdate = (val: number | string) => {
|
||||
const id = Number(val)
|
||||
const selected = id > 0 ? options.value.find((item) => item.id === id) : undefined
|
||||
emit('update:modelValue', selected?.name || '')
|
||||
emit('update:medicineId', selected?.id)
|
||||
const onUpdate = (val: string) => {
|
||||
emit('update:modelValue', val || '')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [props.modelValue, props.medicineId],
|
||||
() => props.modelValue,
|
||||
() => {
|
||||
if (!(props.modelValue || '').trim() && Number(props.medicineId) > 0) {
|
||||
emit('update:medicineId', undefined)
|
||||
}
|
||||
ensureCurrentInOptions()
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -119,23 +93,4 @@ watch(
|
||||
.medicine-name-select.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.medicine-name-option {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 60%);
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
|
||||
> span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.medicine-name-option__meta {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -139,12 +139,7 @@
|
||||
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
||||
>
|
||||
<div class="herb-editor-card__title">主方 {{ row.index + 1 }}</div>
|
||||
<MedicineNameSelect
|
||||
v-model="formData.herbs[row.index].name"
|
||||
v-model:medicine-id="formData.herbs[row.index].medicine_id"
|
||||
:disabled="herbsLocked"
|
||||
class="herb-editor-card__select"
|
||||
/>
|
||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
|
||||
<div
|
||||
v-if="isDuplicateHerbName(row.index)"
|
||||
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
||||
@@ -181,12 +176,7 @@
|
||||
:class="{ 'herb-editor-card--dup': isDuplicateHerbName(row.index) }"
|
||||
>
|
||||
<div class="herb-editor-card__title">辅方</div>
|
||||
<MedicineNameSelect
|
||||
v-model="formData.herbs[row.index].name"
|
||||
v-model:medicine-id="formData.herbs[row.index].medicine_id"
|
||||
:disabled="herbsLocked"
|
||||
class="herb-editor-card__select"
|
||||
/>
|
||||
<MedicineNameSelect v-model="formData.herbs[row.index].name" :disabled="herbsLocked" class="herb-editor-card__select" />
|
||||
<div
|
||||
v-if="isDuplicateHerbName(row.index)"
|
||||
class="herb-editor-card__dup-hint text-amber-600 text-xs mb-1"
|
||||
@@ -1055,7 +1045,6 @@ import { Search } from '@element-plus/icons-vue'
|
||||
type FormulaType = '主方' | '辅方'
|
||||
|
||||
interface Herb {
|
||||
medicine_id?: number
|
||||
name: string
|
||||
dosage: number
|
||||
formula_type?: FormulaType
|
||||
@@ -1082,10 +1071,6 @@ function normalizeHerbRow(raw: any): Herb {
|
||||
dosage: Number(raw?.dosage) || 0,
|
||||
formula_type: normalizeFormulaType(raw?.formula_type)
|
||||
}
|
||||
const medicineId = Number(raw?.medicine_id ?? raw?.id ?? 0)
|
||||
if (Number.isInteger(medicineId) && medicineId > 0) {
|
||||
row.medicine_id = medicineId
|
||||
}
|
||||
if (raw?.locked === true || raw?.locked === 1) {
|
||||
row.locked = true
|
||||
}
|
||||
@@ -1593,8 +1578,8 @@ function parseRecipeToHerbs(text: string): { name: string; dosage: number }[] {
|
||||
return herbs
|
||||
}
|
||||
|
||||
/** 仅有一条完全同名记录时返回稳定药材身份,否则不猜测。 */
|
||||
async function resolveHerbFromLibrary(rawName: string): Promise<{ medicine_id: number; name: string } | null> {
|
||||
/** 仅在药品库中存在「完全同名」药材时返回规范药名,否则返回 null(不模糊猜测) */
|
||||
async function resolveHerbNameFromLibrary(rawName: string): Promise<string | null> {
|
||||
const q = rawName.trim()
|
||||
if (!q) return null
|
||||
try {
|
||||
@@ -1605,10 +1590,8 @@ async function resolveHerbFromLibrary(rawName: string): Promise<{ medicine_id: n
|
||||
status: 1
|
||||
})
|
||||
const lists = (res.lists || []) as { id: number; name: string }[]
|
||||
const exact = lists.filter((item) => (item.name ?? '').trim() === q)
|
||||
return exact.length === 1
|
||||
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
|
||||
: null
|
||||
const exact = lists.find((item) => (item.name ?? '').trim() === q)
|
||||
return exact ? exact.name.trim() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -1629,12 +1612,12 @@ async function handlePasteRecipeImport() {
|
||||
const resolved: Herb[] = []
|
||||
const skippedNames: string[] = []
|
||||
for (const row of parsed) {
|
||||
const medicine = await resolveHerbFromLibrary(row.name)
|
||||
if (!medicine) {
|
||||
const name = await resolveHerbNameFromLibrary(row.name)
|
||||
if (!name) {
|
||||
skippedNames.push(row.name.trim())
|
||||
continue
|
||||
}
|
||||
resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
|
||||
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
|
||||
}
|
||||
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
||||
if (resolved.length === 0) {
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
<template>
|
||||
<template v-if="needsReconcile">
|
||||
<el-button
|
||||
v-perms="['tcm.prescriptionOrder/confirmGancaoSubmission']"
|
||||
type="danger"
|
||||
size="small"
|
||||
plain
|
||||
@click="openDialog"
|
||||
>核对甘草提交</el-button
|
||||
>
|
||||
<el-dialog
|
||||
v-model="visible"
|
||||
title="人工核对甘草提交"
|
||||
width="min(92vw, 520px)"
|
||||
append-to-body
|
||||
destroy-on-close
|
||||
:close-on-click-modal="false"
|
||||
>
|
||||
<el-alert
|
||||
title="请先在甘草后台核对。本操作会写入不可变更的审计记录。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-form label-width="100px" @submit.prevent="submit">
|
||||
<el-form-item label="核对结果" required>
|
||||
<el-radio-group v-model="form.resolution">
|
||||
<el-radio label="CONFIRM_SUCCESS">确认已创建</el-radio>
|
||||
<el-radio label="CONFIRM_NOT_CREATED">确认未创建</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="form.resolution === 'CONFIRM_SUCCESS'"
|
||||
label="甘草单号"
|
||||
required
|
||||
>
|
||||
<el-input v-model="form.remote_order_no" maxlength="64" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="核对依据" required>
|
||||
<el-input
|
||||
v-model="form.note"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
placeholder="例如:核对时间、甘草后台查询条件及结果"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submit"
|
||||
>确认并记录</el-button
|
||||
>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { prescriptionOrderConfirmGancaoSubmission } from '@/api/tcm'
|
||||
import feedback from '@/utils/feedback'
|
||||
|
||||
const props = defineProps<{ order: Record<string, any> | null | undefined }>()
|
||||
const emit = defineEmits<{ resolved: [] }>()
|
||||
|
||||
const needsReconcile = computed(() => {
|
||||
const target = String(props.order?.pharmacy_claim_target || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
const status = String(props.order?.pharmacy_claim_status || '')
|
||||
.trim()
|
||||
.toUpperCase()
|
||||
const leaseExpiresAt = Number(props.order?.pharmacy_claim_lease_expires_at || 0)
|
||||
const expiredPending =
|
||||
status === 'PENDING' &&
|
||||
leaseExpiresAt > 0 &&
|
||||
leaseExpiresAt <= Math.floor(Date.now() / 1000)
|
||||
return (
|
||||
target === 'gancao' && (expiredPending || ['UNKNOWN', 'PENDING_RECONCILE'].includes(status))
|
||||
)
|
||||
})
|
||||
const visible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const form = reactive({
|
||||
resolution: 'CONFIRM_SUCCESS' as 'CONFIRM_SUCCESS' | 'CONFIRM_NOT_CREATED',
|
||||
remote_order_no: '',
|
||||
note: ''
|
||||
})
|
||||
|
||||
function openDialog() {
|
||||
form.resolution = 'CONFIRM_SUCCESS'
|
||||
form.remote_order_no = ''
|
||||
form.note = ''
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
const id = Number(props.order?.id)
|
||||
if (!id) return
|
||||
if (form.resolution === 'CONFIRM_SUCCESS' && !form.remote_order_no.trim()) {
|
||||
feedback.msgError('请填写甘草药方单号')
|
||||
return
|
||||
}
|
||||
if (!form.note.trim()) {
|
||||
feedback.msgError('请填写甘草后台核对依据')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await prescriptionOrderConfirmGancaoSubmission({
|
||||
id,
|
||||
resolution: form.resolution,
|
||||
remote_order_no:
|
||||
form.resolution === 'CONFIRM_SUCCESS' ? form.remote_order_no.trim() : '',
|
||||
note: form.note.trim()
|
||||
})
|
||||
feedback.msgSuccess('甘草提交核对已记录')
|
||||
visible.value = false
|
||||
emit('resolved')
|
||||
} catch {
|
||||
// Request interceptor presents the server-side reconciliation error.
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -30,13 +30,6 @@
|
||||
round
|
||||
class="ml-2"
|
||||
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
|
||||
<el-tag
|
||||
v-if="detailData?.ej_pharmacy_order_no"
|
||||
type="warning"
|
||||
effect="plain"
|
||||
round
|
||||
class="ml-2"
|
||||
>洛阳 {{ detailData.ej_pharmacy_order_no }}</el-tag>
|
||||
<!-- 完整版:发货类型切换等 -->
|
||||
<slot v-if="detailData" name="header-extra" :detail="detailData" />
|
||||
</div>
|
||||
@@ -682,7 +675,7 @@
|
||||
|
||||
<!-- 物流轨迹 -->
|
||||
<el-card
|
||||
v-if="loadRelatedData && String(detailData.tracking_number || '').trim()"
|
||||
v-if="String(detailData.tracking_number || '').trim()"
|
||||
shadow="never"
|
||||
class="po-panel po-panel-logistics"
|
||||
:class="
|
||||
@@ -802,7 +795,6 @@
|
||||
|
||||
<!-- 操作日志 -->
|
||||
<el-card
|
||||
v-if="loadRelatedData"
|
||||
v-perms="['tcm.prescriptionOrder/logs']"
|
||||
shadow="never"
|
||||
class="po-panel border-gray-100 mt-4"
|
||||
@@ -1058,17 +1050,11 @@ const props = withDefaults(
|
||||
gancaoPreviewLoading?: boolean
|
||||
/** 嵌套在其他抽屉内时需要 append-to-body */
|
||||
appendToBody?: boolean
|
||||
/** 自定义详情请求;用于在有独立行级权限边界的页面安全复用抽屉。 */
|
||||
detailLoader?: (params: { id: number }) => Promise<any>
|
||||
/** 是否加载原订单页的物流、日志和未关联支付单等附加接口。 */
|
||||
loadRelatedData?: boolean
|
||||
}>(),
|
||||
{
|
||||
readonly: false,
|
||||
gancaoPreviewLoading: false,
|
||||
appendToBody: false,
|
||||
detailLoader: undefined,
|
||||
loadRelatedData: true
|
||||
appendToBody: false
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1656,10 +1642,6 @@ async function updateJdLogistics() {
|
||||
}
|
||||
|
||||
// ─── 打开 / 刷新 ───
|
||||
function requestDetail(id: number) {
|
||||
return props.detailLoader ? props.detailLoader({ id }) : prescriptionOrderDetail({ id })
|
||||
}
|
||||
|
||||
async function open(id: number) {
|
||||
// 页面级同参数字典请求会取消抽屉挂载时的那次(axios 去重取消),打开时兜底重试
|
||||
void loadServicePackageOptions()
|
||||
@@ -1674,22 +1656,22 @@ async function open(id: number) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const res: any = await requestDetail(id)
|
||||
const res: any = await prescriptionOrderDetail({ id })
|
||||
const d = res?.data ?? res ?? null
|
||||
detailData.value = d
|
||||
if (d) {
|
||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
||||
const dig = String(d.recipient_phone || '').replace(/\D/g, '')
|
||||
logisticsTracePhoneTail.value = dig.length >= 4 ? dig : ''
|
||||
if (props.loadRelatedData && String(d.tracking_number || '').trim()) {
|
||||
if (String(d.tracking_number || '').trim()) {
|
||||
fetchLogisticsTrace()
|
||||
}
|
||||
if (props.loadRelatedData) fetchLogs(id)
|
||||
fetchLogs(id)
|
||||
|
||||
// 加载未关联的支付单
|
||||
const diagId = d.diagnosis_id
|
||||
const linkedIds = d.pay_order_ids || []
|
||||
if (props.loadRelatedData && diagId) {
|
||||
if (diagId) {
|
||||
void loadDetailUnlinkedPayOrders(diagId, id, linkedIds)
|
||||
}
|
||||
}
|
||||
@@ -1706,20 +1688,20 @@ async function refresh() {
|
||||
const id = Number(detailData.value?.id)
|
||||
if (!id) return
|
||||
try {
|
||||
const res: any = await requestDetail(id)
|
||||
const res: any = await prescriptionOrderDetail({ id })
|
||||
const d = res?.data ?? res ?? null
|
||||
if (!d) return
|
||||
const prevTracking = String(detailData.value?.tracking_number || '').trim()
|
||||
detailData.value = d
|
||||
const nextTracking = String(d.tracking_number || '').trim()
|
||||
if (props.loadRelatedData && nextTracking && nextTracking !== prevTracking) {
|
||||
if (nextTracking && nextTracking !== prevTracking) {
|
||||
bumpLogisticsTraceRequestToken()
|
||||
detailLogisticsExpress.value = String(d.express_company || 'auto') || 'auto'
|
||||
void fetchLogisticsTrace()
|
||||
}
|
||||
if (props.loadRelatedData) void fetchLogs(id)
|
||||
void fetchLogs(id)
|
||||
const diagId = d.diagnosis_id
|
||||
if (props.loadRelatedData && diagId) {
|
||||
if (diagId) {
|
||||
void loadDetailUnlinkedPayOrders(diagId, id, d.pay_order_ids || [])
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -11,45 +11,6 @@ export const TCM_ASSISTANT_ROLE_ID = 2
|
||||
/** 与 server/config/project.php prescription_audit_roles 默认一致,可处方审核的角色 */
|
||||
export const PRESCRIPTION_AUDIT_ROLE_IDS = [0, 3, 6]
|
||||
|
||||
export function isRemoteSnapshotLocked(row: Record<string, unknown> | null | undefined): boolean {
|
||||
if (!row) return false
|
||||
if (String(row.gancao_reciperl_order_no || '').trim()) return true
|
||||
if (String(row.ej_pharmacy_order_no || '').trim()) return true
|
||||
if (Number(row.gancao_submit_time || 0) > 0 || Number(row.ej_pharmacy_submit_time || 0) > 0) {
|
||||
return true
|
||||
}
|
||||
return ['PENDING', 'UNKNOWN', 'PENDING_RECONCILE', 'SUCCESS'].includes(
|
||||
String(row.pharmacy_claim_status || '').toUpperCase()
|
||||
)
|
||||
}
|
||||
|
||||
export type SupplyMode = 'gancao' | 'direct' | 'self'
|
||||
|
||||
export function supplyModeKey(row: Record<string, unknown> | null | undefined): SupplyMode {
|
||||
if (
|
||||
String(row?.ship_mode || '')
|
||||
.trim()
|
||||
.toLowerCase() === 'direct'
|
||||
)
|
||||
return 'direct'
|
||||
if (String(row?.gancao_reciperl_order_no || '').trim()) return 'gancao'
|
||||
return 'self'
|
||||
}
|
||||
|
||||
export function supplyModeLabel(row: Record<string, unknown> | null | undefined): string {
|
||||
const mode = supplyModeKey(row)
|
||||
if (mode === 'direct') return '洛阳直发'
|
||||
return mode === 'gancao' ? '甘草' : '自营'
|
||||
}
|
||||
|
||||
export function supplyModeTagType(
|
||||
row: Record<string, unknown> | null | undefined
|
||||
): 'success' | 'warning' | 'info' {
|
||||
const mode = supplyModeKey(row)
|
||||
if (mode === 'direct') return 'warning'
|
||||
return mode === 'gancao' ? 'success' : 'info'
|
||||
}
|
||||
|
||||
export function formatTime(v: unknown) {
|
||||
if (v === null || v === undefined || v === '') return '—'
|
||||
if (typeof v === 'number' && v > 1e9 && v < 1e11) {
|
||||
@@ -173,8 +134,6 @@ export function logActionText(act: string) {
|
||||
revoke_rx_audit: '撤回处方审核',
|
||||
revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单',
|
||||
ej_pharmacy_submit: '洛阳药房下单',
|
||||
ej_pharmacy_callback: '洛阳药房状态',
|
||||
patch_rx_patient: '处方患者信息',
|
||||
patch_rx_usage: '服用参数',
|
||||
update_amount: '修改订单金额',
|
||||
|
||||
@@ -696,12 +696,7 @@
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect
|
||||
v-model="editForm.herbs[row.index].name"
|
||||
v-model:medicine-id="editForm.herbs[row.index].medicine_id"
|
||||
:disabled="herbsLocked"
|
||||
class="w-full"
|
||||
/>
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
@@ -737,12 +732,7 @@
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect
|
||||
v-model="editForm.herbs[row.index].name"
|
||||
v-model:medicine-id="editForm.herbs[row.index].medicine_id"
|
||||
:disabled="herbsLocked"
|
||||
class="w-full"
|
||||
/>
|
||||
<MedicineNameSelect v-model="editForm.herbs[row.index].name" :disabled="herbsLocked" class="w-full" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
@@ -1304,14 +1294,6 @@
|
||||
|
||||
<div v-show="createOrderStep === 1" class="create-order-step-panel">
|
||||
<el-row :gutter="20">
|
||||
<el-col v-if="canSelectShipMode" :span="24">
|
||||
<el-form-item label="发货药房" prop="ship_mode">
|
||||
<el-radio-group v-model="createOrderForm.ship_mode">
|
||||
<el-radio-button label="gancao">甘草药房</el-radio-button>
|
||||
<el-radio-button label="direct">洛阳药房</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="8">
|
||||
<el-form-item label="复诊">
|
||||
<el-switch v-model="createOrderForm.is_follow_up" :active-value="1" :inactive-value="0" />
|
||||
@@ -1715,7 +1697,6 @@ import { roleAll } from '@/api/perms/role'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import useUserStore from '@/stores/modules/user'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
import DaterangePicker from '@/components/daterange-picker/index.vue'
|
||||
import MedicineNameSelect from '@/components/medicine-name-select/index.vue'
|
||||
@@ -1728,7 +1709,7 @@ import jsPDF from 'jspdf'
|
||||
const TcmDiagnosisEditView = defineAsyncComponent(() => import('@/views/tcm/diagnosis/edit.vue'))
|
||||
|
||||
type FormulaType = '主方' | '辅方'
|
||||
type HerbRow = { medicine_id?: number; name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
|
||||
type HerbRow = { name: string; dosage: number; formula_type: FormulaType; locked?: boolean }
|
||||
|
||||
type AuxUsageForm = {
|
||||
dosage_amount?: number
|
||||
@@ -1833,10 +1814,6 @@ function normalizeHerbRow(raw: any): HerbRow {
|
||||
dosage: Number(raw?.dosage) || 0,
|
||||
formula_type: normalizeFormulaType(raw?.formula_type)
|
||||
}
|
||||
const medicineId = Number(raw?.medicine_id ?? raw?.id ?? 0)
|
||||
if (Number.isInteger(medicineId) && medicineId > 0) {
|
||||
row.medicine_id = medicineId
|
||||
}
|
||||
if (raw?.locked === true || raw?.locked === 1) {
|
||||
row.locked = true
|
||||
}
|
||||
@@ -2029,7 +2006,6 @@ const createOrderForm = reactive({
|
||||
service_package: [] as string[],
|
||||
express_company: 'auto',
|
||||
tracking_number: '',
|
||||
ship_mode: 'gancao' as 'gancao' | 'direct',
|
||||
fee_type: 3,
|
||||
amount: 0,
|
||||
internal_cost: undefined as number | undefined,
|
||||
@@ -2038,10 +2014,6 @@ const createOrderForm = reactive({
|
||||
pay_order_ids: [] as number[]
|
||||
})
|
||||
|
||||
// 只有具备「设置发货类型」权限的账号才可在创建业务订单时选择洛阳药房;
|
||||
// 无权限账号保持历史默认逻辑,固定走甘草药房。
|
||||
const canSelectShipMode = computed(() => hasPermission(['tcm.prescriptionOrder/setShipMode']))
|
||||
|
||||
// 省市区数据(简化版,实际项目中应该从 API 获取或使用完整的省市区数据)
|
||||
const regionOptions = ref([])
|
||||
|
||||
@@ -2280,7 +2252,6 @@ function resetCreateOrderForm() {
|
||||
createOrderForm.service_package = []
|
||||
createOrderForm.express_company = 'auto'
|
||||
createOrderForm.tracking_number = ''
|
||||
createOrderForm.ship_mode = 'gancao'
|
||||
createOrderForm.fee_type = 3
|
||||
createOrderForm.amount = 0
|
||||
createOrderForm.internal_cost = undefined
|
||||
@@ -2454,7 +2425,6 @@ async function submitCreateOrderFromPrescription() {
|
||||
: '',
|
||||
express_company: createOrderForm.express_company || 'auto',
|
||||
tracking_number: createOrderForm.tracking_number || '',
|
||||
ship_mode: createOrderForm.ship_mode,
|
||||
fee_type: createOrderForm.fee_type,
|
||||
amount: createOrderForm.amount,
|
||||
remark_extra: createOrderForm.remark_extra || '',
|
||||
@@ -3631,7 +3601,7 @@ function parseRecipePasteToHerbs(text: string): { name: string; dosage: number }
|
||||
return herbs
|
||||
}
|
||||
|
||||
async function resolvePasteHerbFromLibrary(rawName: string): Promise<{ medicine_id: number; name: string } | null> {
|
||||
async function resolvePasteHerbNameFromLibrary(rawName: string): Promise<string | null> {
|
||||
const q = rawName.trim()
|
||||
if (!q) return null
|
||||
try {
|
||||
@@ -3642,10 +3612,8 @@ async function resolvePasteHerbFromLibrary(rawName: string): Promise<{ medicine_
|
||||
status: 1
|
||||
})
|
||||
const lists = (res.lists || []) as { id: number; name: string }[]
|
||||
const exact = lists.filter((item) => (item.name ?? '').trim() === q)
|
||||
return exact.length === 1
|
||||
? { medicine_id: Number(exact[0].id), name: exact[0].name.trim() }
|
||||
: null
|
||||
const exact = lists.find((item) => (item.name ?? '').trim() === q)
|
||||
return exact ? exact.name.trim() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@@ -3666,12 +3634,12 @@ async function handlePasteRecipeImport() {
|
||||
const resolved: HerbRow[] = []
|
||||
const skippedNames: string[] = []
|
||||
for (const row of parsed) {
|
||||
const medicine = await resolvePasteHerbFromLibrary(row.name)
|
||||
if (!medicine) {
|
||||
const name = await resolvePasteHerbNameFromLibrary(row.name)
|
||||
if (!name) {
|
||||
skippedNames.push(row.name.trim())
|
||||
continue
|
||||
}
|
||||
resolved.push({ ...medicine, dosage: row.dosage, formula_type: '主方' })
|
||||
resolved.push({ name, dosage: row.dosage, formula_type: '主方' })
|
||||
}
|
||||
const skippedUnique = [...new Set(skippedNames.filter(Boolean))]
|
||||
if (resolved.length === 0) {
|
||||
|
||||
@@ -157,11 +157,7 @@
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect
|
||||
v-model="row.name"
|
||||
v-model:medicine-id="row.medicine_id"
|
||||
:disabled="editMode === 'view'"
|
||||
/>
|
||||
<MedicineNameSelect v-model="row.name" :disabled="editMode === 'view'" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="150">
|
||||
@@ -261,7 +257,7 @@ const editForm = reactive({
|
||||
id: 0,
|
||||
prescription_name: '',
|
||||
formula_type: '主方',
|
||||
herbs: [] as Array<{ medicine_id?: number; name: string; dosage: number }>,
|
||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||
is_public: 0,
|
||||
disable_edit: 0
|
||||
})
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
{{ listStats.periodLine }}
|
||||
</p>
|
||||
<!-- <p class="relative mt-1.5 text-xs text-slate-400 leading-relaxed">
|
||||
业绩 = 排除业务订单已取消(4)、拒收(9)、退款(10)后的关联支付金额;下方为合计与排除项明细
|
||||
业绩 = 除业务订单已取消(4)外关联支付金额;下方为合计与已取消明细
|
||||
</p> -->
|
||||
</div>
|
||||
</div>
|
||||
@@ -373,9 +373,9 @@
|
||||
<el-tag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="supplyModeTagType(row)"
|
||||
:type="String(row.gancao_reciperl_order_no || '').trim() ? 'success' : 'info'"
|
||||
>
|
||||
{{ supplyModeLabel(row) }}
|
||||
{{ String(row.gancao_reciperl_order_no || '').trim() ? '甘草' : '自营' }}
|
||||
</el-tag>
|
||||
<span class="text-gray-400">#{{ row.id }}</span>
|
||||
</div>
|
||||
@@ -594,13 +594,13 @@
|
||||
@click="confirmWithdraw(row)"
|
||||
>撤回</el-button>
|
||||
<el-button
|
||||
v-if="canUploadPharmacyRow(row)"
|
||||
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
|
||||
v-if="canUploadGancaoRow(row)"
|
||||
v-perms="['tcm.prescriptionOrder/submitGancaoRecipel']"
|
||||
type="warning"
|
||||
link
|
||||
:loading="pharmacySubmitId === row.id"
|
||||
@click="confirmUploadToPharmacy(row)"
|
||||
>上传药房</el-button>
|
||||
:loading="gancaoSubmitId === row.id"
|
||||
@click="confirmSubmitGancaoRecipel(row)"
|
||||
>上传药方</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -638,7 +638,7 @@
|
||||
<el-radio-button label="gancao">甘草药房</el-radio-button>
|
||||
<el-radio-button
|
||||
label="direct"
|
||||
:disabled="isShipModeLocked(detail)"
|
||||
:disabled="isShipModeLockedToGancao(detail)"
|
||||
>洛阳药房</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-tag
|
||||
@@ -650,19 +650,6 @@
|
||||
</div>
|
||||
</template>
|
||||
<template #header-actions="{ detail }">
|
||||
<gancao-submission-reconcile-button
|
||||
:order="detail"
|
||||
@resolved="handleGancaoSubmissionResolved"
|
||||
/>
|
||||
<el-button
|
||||
v-if="canUploadPharmacyRow(detail)"
|
||||
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
|
||||
type="warning"
|
||||
size="small"
|
||||
plain
|
||||
:loading="pharmacySubmitId === detail.id"
|
||||
@click="confirmUploadToPharmacy(detail)"
|
||||
>上传药房</el-button>
|
||||
<el-button
|
||||
v-if="canRxAudit(detail)"
|
||||
v-perms="['tcm.prescriptionOrder/auditPrescription']"
|
||||
@@ -2218,7 +2205,6 @@ import { useRoute } from 'vue-router'
|
||||
import { ArrowDown, InfoFilled, QuestionFilled, Search, Calendar, Document, Link as LinkIcon, Wallet } from '@element-plus/icons-vue'
|
||||
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||
import PrescriptionOrderDetailDrawer from './components/PrescriptionOrderDetailDrawer.vue'
|
||||
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
||||
import {
|
||||
TCM_ASSISTANT_ROLE_ID,
|
||||
PRESCRIPTION_AUDIT_ROLE_IDS,
|
||||
@@ -2245,17 +2231,13 @@ import {
|
||||
type ServicePackageOption,
|
||||
normalizeServicePackageOptions,
|
||||
parseServicePackageValues,
|
||||
mergeServicePackageSelectOptions,
|
||||
isRemoteSnapshotLocked,
|
||||
supplyModeLabel,
|
||||
supplyModeTagType
|
||||
mergeServicePackageSelectOptions
|
||||
} from './components/prescription-order-utils'
|
||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||
import {
|
||||
prescriptionOrderAuditPayment,
|
||||
prescriptionOrderAuditPrescription,
|
||||
prescriptionOrderDetail,
|
||||
prescriptionOrderDdcode,
|
||||
prescriptionOrderEdit,
|
||||
prescriptionOrderLists,
|
||||
prescriptionOrderExport,
|
||||
@@ -2274,7 +2256,7 @@ import {
|
||||
prescriptionOrderBatchAssignAssistant,
|
||||
prescriptionOrderPatchPrescriptionPatient,
|
||||
prescriptionOrderLinkPayOrder,
|
||||
prescriptionOrderUploadToPharmacy,
|
||||
prescriptionOrderSubmitGancaoRecipel,
|
||||
prescriptionOrderPreviewGancaoRecipel,
|
||||
prescriptionDetail,
|
||||
prescriptionLibraryLists,
|
||||
@@ -2594,13 +2576,12 @@ function handleFulfillmentStatusTabClick(value: number | '') {
|
||||
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
|
||||
|
||||
const supplyModeTabs = [
|
||||
{ label: '全部', value: '' as '' | 'gancao' | 'direct' | 'self' },
|
||||
{ label: '全部', value: '' as '' | 'gancao' | 'self' },
|
||||
{ label: '甘草', value: 'gancao' as const },
|
||||
{ label: '洛阳直发', value: 'direct' as const },
|
||||
{ label: '自营', value: 'self' as const }
|
||||
]
|
||||
|
||||
function handleSupplyModeTabClick(value: '' | 'gancao' | 'direct' | 'self') {
|
||||
function handleSupplyModeTabClick(value: '' | 'gancao' | 'self') {
|
||||
queryParams.supply_mode = value
|
||||
resetPage()
|
||||
}
|
||||
@@ -2634,8 +2615,8 @@ const queryParams = reactive({
|
||||
fulfillment_status: '' as number | '',
|
||||
prescription_audit_status: '' as number | '',
|
||||
payment_slip_audit_status: '' as number | '',
|
||||
/** 供货方式:甘草 / 洛阳直发(含未上传)/ 自营 */
|
||||
supply_mode: '' as '' | 'gancao' | 'direct' | 'self',
|
||||
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
|
||||
supply_mode: '' as '' | 'gancao' | 'self',
|
||||
/** 服务渠道:'' 不限;'0' 未指派(库内 '' 或 '0') */
|
||||
service_channel: '' as '' | '0',
|
||||
/** 是否含辅方:'' 不限;'1' 含辅方;'0' 不含辅方 */
|
||||
@@ -2920,7 +2901,7 @@ const listStats = computed(() => {
|
||||
const oC = hasOrderSplit ? n(ex?.stats_order_amount_cancelled) : 0
|
||||
const pNc = hasPaySplit ? n(ex?.stats_linked_pay_amount_not_cancelled) : n(pay)
|
||||
const pC = hasPaySplit ? n(ex?.stats_linked_pay_amount_cancelled) : 0
|
||||
// 业绩 = 排除履约已取消(4)、拒收(9)、退款(10)后的订单金额;优先用后端显式字段,兜底到 not_cancelled
|
||||
// 业绩 = 除履约已取消(fulfillment_status=4)外的全部订单金额;优先用后端显式字段,兜底到 not_cancelled
|
||||
const oPerf = ex?.stats_order_amount_performance !== undefined
|
||||
? n(ex?.stats_order_amount_performance)
|
||||
: oNc
|
||||
@@ -2960,7 +2941,7 @@ const listStats = computed(() => {
|
||||
payHeading: `${headPrefix}业绩(关联实付)`,
|
||||
periodLine,
|
||||
scopeHint,
|
||||
orderSplitHint: '业绩 = 排除履约已取消(4)、拒收(9)、退款(10)后的订单金额;下方为合计与排除项明细'
|
||||
orderSplitHint: '业绩 = 除履约已取消(4)外全部订单金额;下方为合计与已取消明细'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3090,7 +3071,14 @@ function canEditRow(row: {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
const gcTime = Number(row.gancao_submit_time || 0)
|
||||
const gcLocked = gcNo !== '' || gcTime > 0
|
||||
|
||||
// 甘草已提交:仅允许在待双审/履约中/已发货/进行中下修改快递(已签收 6 不可改单号;后端 edit 亦拦截)
|
||||
if (gcLocked) {
|
||||
return [1, 2, 5, 7].includes(fs)
|
||||
}
|
||||
|
||||
// 未提交甘草:仅待双审(1)、履约中(2)可全量编辑
|
||||
return fs === 1 || fs === 2
|
||||
@@ -3118,7 +3106,6 @@ function canRevokeRxAudit(row: {
|
||||
payment_slip_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
}) {
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
const fs = Number(row.fulfillment_status)
|
||||
if (fs === 3 || fs === 4 || fs === 6) return false
|
||||
const rxStatus = Number(row.prescription_audit_status)
|
||||
@@ -3141,7 +3128,6 @@ function canRevokePayAudit(row: {
|
||||
}
|
||||
|
||||
function canWithdrawRow(row: { fulfillment_status?: number }) {
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
// 只有「待双审通过」可撤回,已发货后不可撤回
|
||||
return Number(row.fulfillment_status) === 1
|
||||
}
|
||||
@@ -3166,13 +3152,13 @@ function shipModeLabel(v: unknown): string {
|
||||
return normalizeShipMode(v) === 'direct' ? '洛阳药房' : '甘草药房'
|
||||
}
|
||||
|
||||
function isShipModeLocked(row: { gancao_reciperl_order_no?: string | null; ej_pharmacy_order_no?: string | null }) {
|
||||
return isRemoteSnapshotLocked(row as Record<string, unknown>)
|
||||
function isShipModeLockedToGancao(row: { gancao_reciperl_order_no?: string | null }) {
|
||||
return String(row.gancao_reciperl_order_no || '').trim() !== ''
|
||||
}
|
||||
|
||||
function canEditShipMode(row: { fulfillment_status?: number; gancao_reciperl_order_no?: string; ej_pharmacy_order_no?: string }) {
|
||||
function canEditShipMode(row: { fulfillment_status?: number }) {
|
||||
const fs = Number(row.fulfillment_status)
|
||||
return fs !== 3 && fs !== 4 && !isShipModeLocked(row)
|
||||
return fs !== 3 && fs !== 4
|
||||
}
|
||||
|
||||
const shipModeSaving = ref(false)
|
||||
@@ -3214,11 +3200,6 @@ async function onDetailShipModeChange(mode: string | number | boolean | undefine
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGancaoSubmissionResolved() {
|
||||
await refreshCurrentPrescriptionOrderDetail()
|
||||
getLists()
|
||||
}
|
||||
|
||||
function canAddPayOrderRow(row: {
|
||||
fulfillment_status?: number
|
||||
amount?: number | string
|
||||
@@ -3244,31 +3225,19 @@ function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_sta
|
||||
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
|
||||
}
|
||||
|
||||
function canQuickTrackRow(_row: { fulfillment_status?: number }) {
|
||||
return true
|
||||
function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||
// 仅已发货(5) 可修改快递单号;已签收(6) 不可再改
|
||||
return Number(row.fulfillment_status) === 5
|
||||
}
|
||||
|
||||
function canUploadPharmacyRow(row: {
|
||||
function canUploadGancaoRow(row: {
|
||||
prescription_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
ship_mode?: string
|
||||
gancao_reciperl_order_no?: string
|
||||
ej_pharmacy_order_no?: string
|
||||
ej_pharmacy_status?: string
|
||||
ej_pharmacy_review_status?: string
|
||||
can_upload_pharmacy?: boolean
|
||||
gancao_reciperl_order_no?: string
|
||||
}) {
|
||||
if (row.can_upload_pharmacy === false) return false
|
||||
// 处方审核已通过(1) 且没有甘草订单号时才显示
|
||||
const rxStatus = Number(row.prescription_audit_status)
|
||||
const fs = Number(row.fulfillment_status)
|
||||
const ejRejected = ['REJECTED'].includes(String(row.ej_pharmacy_status || '').toUpperCase())
|
||||
|| ['REJECTED'].includes(String(row.ej_pharmacy_review_status || '').toUpperCase())
|
||||
if (rxStatus !== 1 || ([3, 4, 8, 9, 10, 11, 12].includes(fs) && !(fs === 9 && ejRejected))) return false
|
||||
if (normalizeShipMode(row.ship_mode) === 'direct') {
|
||||
return (String(row.ej_pharmacy_order_no || '').trim() === '' || ejRejected)
|
||||
&& String(row.gancao_reciperl_order_no || '').trim() === ''
|
||||
}
|
||||
return String(row.gancao_reciperl_order_no || '').trim() === '' && String(row.ej_pharmacy_order_no || '').trim() === ''
|
||||
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
return rxStatus === 1 && gancaoOrderNo === ''
|
||||
}
|
||||
|
||||
// ─── 详情抽屉(共享组件 PrescriptionOrderDetailDrawer):状态桥接 ───
|
||||
@@ -3894,7 +3863,7 @@ async function confirmWithdraw(row: { id: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
const pharmacySubmitId = ref(0)
|
||||
const gancaoSubmitId = ref(0)
|
||||
const gancaoPreviewDrawerVisible = ref(false)
|
||||
const gancaoPreviewLoading = ref(false)
|
||||
const gancaoPreviewData = ref<any>(null)
|
||||
@@ -4033,24 +4002,18 @@ async function testGancaoPreviewFromDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string }) {
|
||||
const isLuoyang = normalizeShipMode(row.ship_mode) === 'direct'
|
||||
const pharmacyName = isLuoyang ? '洛阳药房' : '甘草药房'
|
||||
async function confirmSubmitGancaoRecipel(row: { id: number }) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`将把本单关联处方提交至${pharmacyName},提交成功后不可切换发货药房。确定继续?`,
|
||||
'上传药房',
|
||||
'将把本单关联处方提交至甘草药管家开放平台(先 CTM_PREVIEW 预检查再 CTM_SUBMIT_RECIPEL 正式下单;成功后将按甘草侧规则扣费,详见 https://apidoc.igancao.com/service-doc/scm-outer-recipel.html )。确定继续?',
|
||||
'上传甘草药方',
|
||||
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
|
||||
)
|
||||
pharmacySubmitId.value = row.id
|
||||
const res: any = await prescriptionOrderUploadToPharmacy({ id: row.id })
|
||||
gancaoSubmitId.value = row.id
|
||||
const res: any = await prescriptionOrderSubmitGancaoRecipel({ id: row.id })
|
||||
const d = res?.data ?? res
|
||||
const no = d?.pharmacy_order_no != null
|
||||
? String(d.pharmacy_order_no)
|
||||
: d?.recipel_order_no != null
|
||||
? String(d.recipel_order_no)
|
||||
: ''
|
||||
feedback.msgSuccess(no ? `上传成功,${pharmacyName}单号:${no}` : '上传成功')
|
||||
const no = d?.recipel_order_no != null ? String(d.recipel_order_no) : ''
|
||||
feedback.msgSuccess(no ? `上传成功,甘草处方单号:${no}` : '上传成功')
|
||||
getLists()
|
||||
await detailDrawerRef.value?.refreshIfCurrent(row.id)
|
||||
} catch (e: any) {
|
||||
@@ -4058,7 +4021,7 @@ async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string })
|
||||
/* 拦截器已提示 */
|
||||
}
|
||||
} finally {
|
||||
pharmacySubmitId.value = 0
|
||||
gancaoSubmitId.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4094,8 +4057,33 @@ async function submitQuickTrack() {
|
||||
}
|
||||
quickTrackSaving.value = true
|
||||
try {
|
||||
await prescriptionOrderDdcode({
|
||||
id: quickTrackRowId.value,
|
||||
// 先拉详情,获取所有必填字段,再只覆盖快递信息
|
||||
const res: any = await prescriptionOrderDetail({ id: quickTrackRowId.value })
|
||||
const d = res?.data ?? res
|
||||
if (!d) {
|
||||
feedback.msgError('加载订单数据失败')
|
||||
return
|
||||
}
|
||||
await prescriptionOrderEdit({
|
||||
id: d.id,
|
||||
recipient_name: d.recipient_name || '',
|
||||
recipient_phone: d.recipient_phone || '',
|
||||
shipping_province: d.shipping_province || '',
|
||||
shipping_city: d.shipping_city || '',
|
||||
shipping_district: d.shipping_district || '',
|
||||
shipping_address: d.shipping_address || '',
|
||||
is_follow_up: d.is_follow_up ? 1 : 0,
|
||||
medication_days: (d.medication_days != null && String(d.medication_days).trim() !== '') ? d.medication_days : '',
|
||||
prev_staff: d.prev_staff || '',
|
||||
service_channel: d.service_channel || '',
|
||||
service_package: d.service_package || '',
|
||||
fee_type: Number(d.fee_type) || 3,
|
||||
amount: Number(d.amount) || 0,
|
||||
remark_extra: d.remark_extra || '',
|
||||
remark_assistant: d.remark_assistant || '',
|
||||
internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '',
|
||||
pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [],
|
||||
// 仅覆盖快递字段
|
||||
express_company: quickTrackForm.express_company || 'auto',
|
||||
tracking_number: quickTrackForm.tracking_number.trim()
|
||||
})
|
||||
@@ -4104,6 +4092,17 @@ async function submitQuickTrack() {
|
||||
getLists()
|
||||
// 若详情 Drawer 打开且是同一订单,同步刷新
|
||||
await detailDrawerRef.value?.refreshIfCurrent(quickTrackRowId.value)
|
||||
// 仅保存前为履约中(2)时才询问确认发货;已发货(5)/已签收(6)等修改单号不再弹窗
|
||||
if (canShipRow({ fulfillment_status: Number(d.fulfillment_status) })) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`,
|
||||
'确认发货',
|
||||
{ type: 'success', confirmButtonText: '确认发货', cancelButtonText: '稍后再说' }
|
||||
)
|
||||
openShip({ id: quickTrackRowId.value, tracking_number: quickTrackForm.tracking_number, express_company: quickTrackForm.express_company })
|
||||
} catch { /* 用户点了「稍后」 */ }
|
||||
}
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
|
||||
@@ -365,8 +365,8 @@
|
||||
<el-tag
|
||||
size="small"
|
||||
effect="plain"
|
||||
:type="supplyModeTagType(row)"
|
||||
>{{ supplyModeLabel(row) }}</el-tag>
|
||||
:type="String(row.gancao_reciperl_order_no || '').trim() ? 'success' : 'info'"
|
||||
>{{ String(row.gancao_reciperl_order_no || '').trim() ? '甘草' : '自营' }}</el-tag>
|
||||
<span class="po-card__id">#{{ row.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -500,11 +500,7 @@
|
||||
<el-dropdown-item v-if="canRefundRow(row)" command="refund">
|
||||
<span class="text-red-500">退款</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
v-if="canUploadPharmacyRow(row)"
|
||||
v-perms="['tcm.prescriptionOrder/uploadToPharmacy']"
|
||||
command="uploadPharmacy"
|
||||
>上传药房</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canUploadGancaoRow(row)" command="submitGancao">上传药方</el-dropdown-item>
|
||||
<el-dropdown-item v-if="canWithdrawRow(row)" command="withdraw" divided>
|
||||
<span class="text-red-500">撤回订单</span>
|
||||
</el-dropdown-item>
|
||||
@@ -548,10 +544,6 @@
|
||||
>甘草 {{ detailData.gancao_reciperl_order_no }}</el-tag>
|
||||
</div>
|
||||
<div v-if="detailData" class="po-detail-drawer-actions flex flex-wrap items-center gap-2">
|
||||
<gancao-submission-reconcile-button
|
||||
:order="detailData"
|
||||
@resolved="handleGancaoSubmissionResolved"
|
||||
/>
|
||||
<el-button
|
||||
v-if="canRxAudit(detailData)"
|
||||
v-perms="['tcm.prescriptionOrder/auditPrescription']"
|
||||
@@ -1893,12 +1885,10 @@
|
||||
/>
|
||||
<el-form v-loading="shipSaving" label-width="90px" class="pr-2" @submit.prevent="submitShip">
|
||||
<el-form-item label="发货方式">
|
||||
<el-tag
|
||||
:type="shipForm.ship_mode === 'direct' ? 'warning' : 'success'"
|
||||
effect="plain"
|
||||
size="default"
|
||||
>{{ shipDialogModeDisplay }}</el-tag>
|
||||
<span class="text-xs text-gray-400 ml-2">请在订单详情顶部「发货类型」中设置,此处不可修改</span>
|
||||
<el-radio-group v-model="shipForm.ship_mode">
|
||||
<el-radio label="gancao">甘草药方发</el-radio>
|
||||
<el-radio label="direct">药房直发</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="承运商">
|
||||
<el-select v-model="shipForm.express_company" class="w-full">
|
||||
@@ -2707,13 +2697,11 @@ import {
|
||||
User
|
||||
} from '@element-plus/icons-vue'
|
||||
import ListTimeFilter from '@/components/list-time-filter/index.vue'
|
||||
import GancaoSubmissionReconcileButton from './components/GancaoSubmissionReconcileButton.vue'
|
||||
import { useListTimeFilter } from '@/hooks/useListTimeFilter'
|
||||
import {
|
||||
prescriptionOrderAuditPayment,
|
||||
prescriptionOrderAuditPrescription,
|
||||
prescriptionOrderDetail,
|
||||
prescriptionOrderDdcode,
|
||||
prescriptionOrderEdit,
|
||||
prescriptionOrderLists,
|
||||
prescriptionOrderPaidPayOrders,
|
||||
@@ -2731,7 +2719,7 @@ import {
|
||||
prescriptionOrderPatchPrescriptionUsage,
|
||||
prescriptionOrderLinkPayOrder,
|
||||
prescriptionOrderRequestCompletion,
|
||||
prescriptionOrderUploadToPharmacy,
|
||||
prescriptionOrderSubmitGancaoRecipel,
|
||||
prescriptionOrderPreviewGancaoRecipel,
|
||||
prescriptionDetail,
|
||||
getDoctors,
|
||||
@@ -2745,10 +2733,7 @@ import {
|
||||
mergeServicePackageSelectOptions,
|
||||
formatServicePackageLabels,
|
||||
normalizeSlipAuxUsageForm,
|
||||
prescriptionHasAuxFormula,
|
||||
isRemoteSnapshotLocked,
|
||||
supplyModeLabel,
|
||||
supplyModeTagType
|
||||
prescriptionHasAuxFormula
|
||||
} from './components/prescription-order-utils'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { jsPDF } from 'jspdf'
|
||||
@@ -2999,13 +2984,12 @@ function handleFulfillmentStatusTabClick(value: number | '') {
|
||||
const activeFocusKey = ref<'' | 'pendingRx' | 'pendingPay' | 'pendingShip' | 'risk'>('')
|
||||
|
||||
const supplyModeTabs = [
|
||||
{ label: '全部', value: '' as '' | 'gancao' | 'direct' | 'self' },
|
||||
{ label: '全部', value: '' as '' | 'gancao' | 'self' },
|
||||
{ label: '甘草', value: 'gancao' as const },
|
||||
{ label: '洛阳直发', value: 'direct' as const },
|
||||
{ label: '自营', value: 'self' as const }
|
||||
]
|
||||
|
||||
function handleSupplyModeTabClick(value: '' | 'gancao' | 'direct' | 'self') {
|
||||
function handleSupplyModeTabClick(value: '' | 'gancao' | 'self') {
|
||||
queryParams.supply_mode = value
|
||||
resetPage()
|
||||
}
|
||||
@@ -3039,8 +3023,8 @@ const queryParams = reactive({
|
||||
fulfillment_status: '' as number | '',
|
||||
prescription_audit_status: '' as number | '',
|
||||
payment_slip_audit_status: '' as number | '',
|
||||
/** 供货方式:甘草 / 洛阳直发(含未上传)/ 自营 */
|
||||
supply_mode: '' as '' | 'gancao' | 'direct' | 'self',
|
||||
/** 供货方式:甘草(已传甘草药方单号)/ 自营(无甘草单号) */
|
||||
supply_mode: '' as '' | 'gancao' | 'self',
|
||||
/** 下单人(关联操作日志 audit_rx_* / audit_pay_*) */
|
||||
audit_admin_id: '' as number | ''
|
||||
})
|
||||
@@ -3475,7 +3459,13 @@ function canEditRow(row: {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
const gcNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
const gcTime = Number(row.gancao_submit_time || 0)
|
||||
const gcLocked = gcNo !== '' || gcTime > 0
|
||||
|
||||
if (gcLocked) {
|
||||
return [1, 2, 5, 7].includes(fs)
|
||||
}
|
||||
|
||||
return fs === 1 || fs === 2
|
||||
}
|
||||
@@ -3502,7 +3492,6 @@ function canRevokeRxAudit(row: {
|
||||
payment_slip_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
}) {
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
const fs = Number(row.fulfillment_status)
|
||||
if (fs === 3 || fs === 4 || fs === 6) return false
|
||||
const rxStatus = Number(row.prescription_audit_status)
|
||||
@@ -3525,7 +3514,6 @@ function canRevokePayAudit(row: {
|
||||
}
|
||||
|
||||
function canWithdrawRow(row: { fulfillment_status?: number }) {
|
||||
if (isRemoteSnapshotLocked(row as Record<string, unknown>)) return false
|
||||
// 只有「待双审通过」可撤回,已发货后不可撤回
|
||||
return Number(row.fulfillment_status) === 1
|
||||
}
|
||||
@@ -3552,30 +3540,18 @@ function canRefundRow(row: { fulfillment_status?: number; payment_slip_audit_sta
|
||||
return (fs === 5 || fs === 6 || fs === 3 || fs === 9) && Number(row.payment_slip_audit_status) === 1
|
||||
}
|
||||
|
||||
function canQuickTrackRow(_row: { fulfillment_status?: number }) {
|
||||
return true
|
||||
function canQuickTrackRow(row: { fulfillment_status?: number }) {
|
||||
return Number(row.fulfillment_status) === 5
|
||||
}
|
||||
|
||||
function canUploadPharmacyRow(row: {
|
||||
function canUploadGancaoRow(row: {
|
||||
prescription_audit_status?: number
|
||||
fulfillment_status?: number
|
||||
gancao_reciperl_order_no?: string
|
||||
ej_pharmacy_order_no?: string
|
||||
ej_pharmacy_status?: string
|
||||
ej_pharmacy_review_status?: string
|
||||
can_upload_pharmacy?: boolean
|
||||
gancao_reciperl_order_no?: string
|
||||
}) {
|
||||
if (row.can_upload_pharmacy === false) return false
|
||||
// 处方审核已通过(1) 且没有甘草订单号时才显示
|
||||
const rxStatus = Number(row.prescription_audit_status)
|
||||
const fulfillmentStatus = Number(row.fulfillment_status)
|
||||
const gancaoOrderNo = String(row.gancao_reciperl_order_no || '').trim()
|
||||
const ejOrderNo = String(row.ej_pharmacy_order_no || '').trim()
|
||||
const ejRejected = String(row.ej_pharmacy_status || '').toUpperCase() === 'REJECTED'
|
||||
|| String(row.ej_pharmacy_review_status || '').toUpperCase() === 'REJECTED'
|
||||
return rxStatus === 1
|
||||
&& (![3, 4, 8, 9, 10, 11, 12].includes(fulfillmentStatus) || (fulfillmentStatus === 9 && ejRejected))
|
||||
&& gancaoOrderNo === ''
|
||||
&& (ejOrderNo === '' || ejRejected)
|
||||
return rxStatus === 1 && gancaoOrderNo === ''
|
||||
}
|
||||
|
||||
function orderStatusText(s: number | undefined) {
|
||||
@@ -3613,7 +3589,7 @@ const h5ActiveFilterCount = computed(() => {
|
||||
|
||||
// H5: 判断列表卡片「更多」下拉是否需要展示
|
||||
function hasMoreCardActions(row: Record<string, any>) {
|
||||
return canAddPayOrderRow(row) || canCompleteRow(row) || canRefundRow(row) || canUploadPharmacyRow(row) || canWithdrawRow(row)
|
||||
return canAddPayOrderRow(row) || canCompleteRow(row) || canRefundRow(row) || canUploadGancaoRow(row) || canWithdrawRow(row)
|
||||
}
|
||||
|
||||
// H5: 处理列表卡片「更多」下拉指令
|
||||
@@ -3621,7 +3597,7 @@ function handleCardMoreCommand(cmd: string, row: Record<string, any>) {
|
||||
if (cmd === 'addPayOrder') openAddPayOrder(row as any)
|
||||
else if (cmd === 'complete') confirmComplete(row as any)
|
||||
else if (cmd === 'refund') openRefundOrder(row as any)
|
||||
else if (cmd === 'uploadPharmacy') confirmUploadToPharmacy(row as any)
|
||||
else if (cmd === 'submitGancao') confirmSubmitGancaoRecipel(row as any)
|
||||
else if (cmd === 'withdraw') confirmWithdraw(row as any)
|
||||
}
|
||||
|
||||
@@ -3953,8 +3929,6 @@ function logActionText(act: string) {
|
||||
revoke_rx_audit: '撤回处方审核',
|
||||
revoke_pay_audit: '撤回支付审核',
|
||||
gancao_submit: '甘草下单',
|
||||
ej_pharmacy_submit: '洛阳药房下单',
|
||||
ej_pharmacy_callback: '洛阳药房状态',
|
||||
patch_rx_patient: '处方患者信息',
|
||||
update_amount: '修改订单金额',
|
||||
complete: '完成订单',
|
||||
@@ -4747,7 +4721,7 @@ async function confirmWithdraw(row: { id: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
const pharmacySubmitId = ref(0)
|
||||
const gancaoSubmitId = ref(0)
|
||||
const gancaoPreviewDrawerVisible = ref(false)
|
||||
const gancaoPreviewLoading = ref(false)
|
||||
const gancaoPreviewData = ref<any>(null)
|
||||
@@ -4839,19 +4813,18 @@ async function testGancaoPreviewFromDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string }) {
|
||||
async function confirmSubmitGancaoRecipel(row: { id: number }) {
|
||||
try {
|
||||
const target = String(row.ship_mode || 'gancao') === 'direct' ? '洛阳药房' : '甘草药房'
|
||||
await ElMessageBox.confirm(
|
||||
`确认将本单关联处方上传至${target}?`,
|
||||
'上传药房',
|
||||
'将把本单关联处方提交至甘草药管家开放平台(先 CTM_PREVIEW 预检查再 CTM_SUBMIT_RECIPEL 正式下单;成功后将按甘草侧规则扣费,详见 https://apidoc.igancao.com/service-doc/scm-outer-recipel.html )。确定继续?',
|
||||
'上传甘草药方',
|
||||
{ type: 'warning', confirmButtonText: '确定上传', cancelButtonText: '取消' }
|
||||
)
|
||||
pharmacySubmitId.value = row.id
|
||||
const res: any = await prescriptionOrderUploadToPharmacy({ id: row.id })
|
||||
gancaoSubmitId.value = row.id
|
||||
const res: any = await prescriptionOrderSubmitGancaoRecipel({ id: row.id })
|
||||
const d = res?.data ?? res
|
||||
const no = String(d?.pharmacy_order_no || d?.recipel_order_no || '').trim()
|
||||
feedback.msgSuccess(no ? `上传成功,药房单号:${no}` : '上传成功')
|
||||
const no = d?.recipel_order_no != null ? String(d.recipel_order_no) : ''
|
||||
feedback.msgSuccess(no ? `上传成功,甘草处方单号:${no}` : '上传成功')
|
||||
getLists()
|
||||
if (detailVisible.value && Number(detailData.value?.id) === row.id) {
|
||||
try {
|
||||
@@ -4867,7 +4840,7 @@ async function confirmUploadToPharmacy(row: { id: number; ship_mode?: string })
|
||||
/* 拦截器已提示 */
|
||||
}
|
||||
} finally {
|
||||
pharmacySubmitId.value = 0
|
||||
gancaoSubmitId.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4901,8 +4874,33 @@ async function submitQuickTrack() {
|
||||
}
|
||||
quickTrackSaving.value = true
|
||||
try {
|
||||
await prescriptionOrderDdcode({
|
||||
id: quickTrackRowId.value,
|
||||
// 先拉详情,获取所有必填字段,再只覆盖快递信息
|
||||
const res: any = await prescriptionOrderDetail({ id: quickTrackRowId.value })
|
||||
const d = res?.data ?? res
|
||||
if (!d) {
|
||||
feedback.msgError('加载订单数据失败')
|
||||
return
|
||||
}
|
||||
await prescriptionOrderEdit({
|
||||
id: d.id,
|
||||
recipient_name: d.recipient_name || '',
|
||||
recipient_phone: d.recipient_phone || '',
|
||||
shipping_province: d.shipping_province || '',
|
||||
shipping_city: d.shipping_city || '',
|
||||
shipping_district: d.shipping_district || '',
|
||||
shipping_address: d.shipping_address || '',
|
||||
is_follow_up: d.is_follow_up ? 1 : 0,
|
||||
medication_days: (d.medication_days != null && String(d.medication_days).trim() !== '') ? d.medication_days : '',
|
||||
prev_staff: d.prev_staff || '',
|
||||
service_channel: d.service_channel || '',
|
||||
service_package: d.service_package || '',
|
||||
fee_type: Number(d.fee_type) || 3,
|
||||
amount: Number(d.amount) || 0,
|
||||
remark_extra: d.remark_extra || '',
|
||||
remark_assistant: d.remark_assistant || '',
|
||||
internal_cost: (d.internal_cost != null && d.internal_cost !== '') ? d.internal_cost : '',
|
||||
pay_order_ids: Array.isArray(d.pay_order_ids) ? d.pay_order_ids : [],
|
||||
// 仅覆盖快递字段
|
||||
express_company: quickTrackForm.express_company || 'auto',
|
||||
tracking_number: quickTrackForm.tracking_number.trim()
|
||||
})
|
||||
@@ -4915,9 +4913,19 @@ async function submitQuickTrack() {
|
||||
const r: any = await prescriptionOrderDetail({ id: quickTrackRowId.value })
|
||||
const nd = r?.data ?? r ?? null
|
||||
if (nd) detailData.value = nd
|
||||
await fetchLogs(quickTrackRowId.value)
|
||||
} catch { /* 静默 */ }
|
||||
}
|
||||
// 仅保存前为履约中(2)时才询问确认发货;已发货(5)/已签收(6)等修改单号不再弹窗
|
||||
if (canShipRow({ fulfillment_status: Number(d.fulfillment_status) })) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`快递单号「${quickTrackForm.tracking_number}」已保存,是否立即确认发货?`,
|
||||
'确认发货',
|
||||
{ type: 'success', confirmButtonText: '确认发货', cancelButtonText: '稍后再说' }
|
||||
)
|
||||
openShip({ id: quickTrackRowId.value, tracking_number: quickTrackForm.tracking_number, express_company: quickTrackForm.express_company })
|
||||
} catch { /* 用户点了「稍后」 */ }
|
||||
}
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
@@ -4935,10 +4943,6 @@ const shipForm = reactive({
|
||||
tracking_number: ''
|
||||
})
|
||||
|
||||
const shipDialogModeDisplay = computed(() =>
|
||||
shipForm.ship_mode === 'direct' ? '洛阳药房直发' : '甘草药房直发'
|
||||
)
|
||||
|
||||
function openShip(row: { id: number; express_company?: unknown; tracking_number?: unknown; ship_mode?: unknown }) {
|
||||
shipRowId.value = row.id
|
||||
shipForm.ship_mode = String(row.ship_mode || 'gancao') || 'gancao'
|
||||
@@ -4978,13 +4982,6 @@ async function submitShip() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGancaoSubmissionResolved() {
|
||||
getLists()
|
||||
if (detailData.value?.id) {
|
||||
await openDetail(Number(detailData.value.id))
|
||||
}
|
||||
}
|
||||
|
||||
const completeOrderStatusOptions = [
|
||||
{ value: 3, label: '已完成' },
|
||||
{ value: 7, label: '进行中' },
|
||||
|
||||
@@ -1,528 +0,0 @@
|
||||
<template>
|
||||
<div class="conversion-page" v-loading="loading" element-loading-text="正在汇总权限范围内的数据">
|
||||
<header class="page-heading">
|
||||
<div>
|
||||
<h1>综合数据转化</h1>
|
||||
<p>{{ scopeDescription }}</p>
|
||||
</div>
|
||||
<div class="heading-meta">
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
|
||||
<span v-if="dashboard.meta.generated_at">更新于 {{ dashboard.meta.generated_at }}</span>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filter-strip">
|
||||
<div class="filter-item filter-item--time">
|
||||
<span class="filter-label">时间范围</span>
|
||||
<el-segmented v-model="query.time_type" :options="timeOptions" @change="loadDashboard" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span class="filter-label">搜索员工</span>
|
||||
<el-select
|
||||
v-model="query.assistant_id"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部员工"
|
||||
class="employee-select"
|
||||
@change="loadDashboard"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in dashboard.filters.assistants"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="Number(item.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span class="filter-label">部门</span>
|
||||
<el-tree-select
|
||||
v-model="query.dept_id"
|
||||
:data="dashboard.filters.departments"
|
||||
:props="deptTreeProps"
|
||||
node-key="id"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
default-expand-all
|
||||
placeholder="全部可见部门"
|
||||
class="dept-select"
|
||||
@change="handleDeptChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span class="filter-label">渠道</span>
|
||||
<el-select
|
||||
v-model="query.media_channel_code"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部渠道"
|
||||
class="channel-select"
|
||||
@change="loadDashboard"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in dashboard.filters.media_channels"
|
||||
:key="item.code"
|
||||
:label="item.name"
|
||||
:value="item.code"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<span class="range-text">{{ dashboard.meta.start_date }} 至 {{ dashboard.meta.end_date }}</span>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid" aria-label="综合转化指标">
|
||||
<article v-for="metric in metricCards" :key="metric.key" class="metric-card">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ formatMetric(metric.key, metric.type) }}</strong>
|
||||
<small>{{ metric.hint }}</small>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="ranking-grid">
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>部门订单量占比</h2>
|
||||
<p>按订单创建人归属,排除取消、拒收及退款</p>
|
||||
</div>
|
||||
<span>单位:单</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.orders.length" class="bar-list">
|
||||
<div v-for="item in dashboard.rankings.orders" :key="`order-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name">{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-teal" :style="{ width: barWidth(item.value, maxOrderValue) }" /></div>
|
||||
<strong>{{ formatNumber(item.value) }} 单</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="当前范围暂无订单数据" />
|
||||
</article>
|
||||
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>部门金额占比</h2>
|
||||
<p>仅统计未取消、未拒收且未退款的有效金额</p>
|
||||
</div>
|
||||
<span>单位:元</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
|
||||
<div v-for="item in dashboard.rankings.amounts" :key="`amount-${item.id}`" class="bar-row">
|
||||
<span class="bar-name" :title="item.name">{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, maxAmountValue) }" /></div>
|
||||
<strong>{{ formatMoney(item.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="54" description="当前范围暂无金额数据" />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel detail-panel">
|
||||
<div class="panel-heading panel-heading--table">
|
||||
<div>
|
||||
<h2>明细数据列表</h2>
|
||||
<p>展开部门可查看人员明细;挂号=已支付且实收低于 10 元的订单,预约=有效预约记录;开口率=开口/加粉,挂号率=挂号/加粉,面诊率=面诊/预约,接诊率=接诊诊单/加粉</p>
|
||||
</div>
|
||||
<span>{{ dashboard.rows.length }} 个顶层节点</span>
|
||||
</div>
|
||||
<el-table
|
||||
:data="dashboard.rows"
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
class="detail-table"
|
||||
>
|
||||
<el-table-column prop="name" label="部门 / 人员" min-width="250" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<strong :class="{ 'is-parent': Array.isArray(row.children) && row.children.length, 'is-member': row.type === 'member' }">
|
||||
{{ row.name }}
|
||||
</strong>
|
||||
<el-tag v-if="row.type === 'member' && row.role" size="small" type="info" effect="plain" class="role-tag">
|
||||
{{ row.role }}{{ row.is_leader ? ' · 组长' : '' }}
|
||||
</el-tag>
|
||||
<el-tag v-else-if="row.type === 'unbound'" size="small" type="warning" effect="plain" class="role-tag">
|
||||
未绑定账号
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="add_fans_count" label="加粉" min-width="88" align="right" />
|
||||
<el-table-column prop="total_open_count" label="开口" min-width="88" align="right" />
|
||||
<el-table-column prop="paid_appointment_count" label="挂号" min-width="88" align="right" />
|
||||
<el-table-column prop="appointment_total_count" label="预约" min-width="88" align="right" />
|
||||
<el-table-column prop="interview_count" label="面诊" min-width="88" align="right" />
|
||||
<el-table-column prop="completed_order_count" label="接诊诊单" min-width="104" align="right" />
|
||||
<el-table-column label="接诊金额" min-width="120" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.completed_order_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开口率" min-width="96" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.total_open_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号率" min-width="96" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.paid_appointment_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="面诊率" min-width="96" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="面诊接诊率" min-width="116" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.interview_receive_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="接诊率" min-width="96" align="right">
|
||||
<template #default="{ row }">{{ formatPercent(row.receive_rate) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="ROI" min-width="86" align="right">
|
||||
<template #default="{ row }">{{ formatRatio(row.roi) }}</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前权限范围内暂无转化数据" /></template>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="panel target-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>一诊诊金目标追踪</h2>
|
||||
<p>{{ dashboard.target.year }} 年 · 按当前角色、部门及员工筛选范围计算</p>
|
||||
</div>
|
||||
<span>{{ dashboard.target.department_count }} 个目标部门</span>
|
||||
</div>
|
||||
<div class="target-layout">
|
||||
<div class="target-progress-list">
|
||||
<div class="target-summary">
|
||||
<div><span>年度目标</span><strong>{{ formatMoney(dashboard.target.target_amount) }}</strong></div>
|
||||
<div><span>已完成</span><strong>{{ formatMoney(dashboard.target.actual_amount) }}</strong></div>
|
||||
<div><span>完成率</span><strong class="is-teal">{{ nullablePercent(dashboard.target.completion_rate) }}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="progress-block">
|
||||
<div class="progress-copy">
|
||||
<span>年度范围目标</span>
|
||||
<b>{{ nullablePercent(dashboard.target.completion_rate) }}</b>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="progressValue(dashboard.target.completion_rate)"
|
||||
:show-text="false"
|
||||
:stroke-width="12"
|
||||
color="#0f9185"
|
||||
/>
|
||||
<small>已完成 {{ formatMoney(dashboard.target.actual_amount) }} / 目标 {{ formatMoney(dashboard.target.target_amount) }}</small>
|
||||
</div>
|
||||
|
||||
<div class="progress-block progress-block--month">
|
||||
<div class="progress-copy">
|
||||
<span>本月范围目标</span>
|
||||
<b>{{ nullablePercent(dashboard.target.current_month_rate) }}</b>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="progressValue(dashboard.target.current_month_rate)"
|
||||
:show-text="false"
|
||||
:stroke-width="12"
|
||||
color="#2f78df"
|
||||
/>
|
||||
<small>已完成 {{ formatMoney(dashboard.target.current_month_actual) }} / 目标 {{ formatMoney(dashboard.target.current_month_target) }}</small>
|
||||
</div>
|
||||
|
||||
<div v-if="Number(dashboard.target.target_amount) <= 0" class="target-empty-note">
|
||||
当前可见部门尚未维护本年度月度目标,实际诊单金额仍会正常统计。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="trend-wrap">
|
||||
<div class="trend-title">
|
||||
<span>年度累计趋势</span>
|
||||
<div><i class="legend-line is-actual" />实际完成 <i class="legend-line is-target" />目标值</div>
|
||||
</div>
|
||||
<v-charts class="target-chart" :option="targetChartOption" autoresize />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="firstVisitConversionPage">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Lock, Refresh } from '@element-plus/icons-vue'
|
||||
import vCharts from 'vue-echarts'
|
||||
import { firstVisitConversionOverview, type FirstVisitConversionParams } from '@/api/first_visit'
|
||||
|
||||
type MetricType = 'count' | 'money' | 'ratio'
|
||||
|
||||
const emptyDashboard = () => ({
|
||||
meta: {
|
||||
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
|
||||
selected_media_channel_code: '', selected_media_channel_name: '', open_count_source: ''
|
||||
},
|
||||
filters: {
|
||||
departments: [] as any[],
|
||||
assistants: [] as Array<{ id: number; name: string }>,
|
||||
media_channels: [] as Array<{ code: string; name: string }>
|
||||
},
|
||||
summary: {} as Record<string, any>,
|
||||
rankings: { orders: [] as any[], amounts: [] as any[] },
|
||||
rows: [] as any[],
|
||||
target: {
|
||||
year: new Date().getFullYear(), target_amount: 0, actual_amount: 0, completion_rate: null as number | null,
|
||||
current_month_target: 0, current_month_actual: 0, current_month_rate: null as number | null,
|
||||
department_count: 0, months: [] as string[], target_cumulative: [] as number[], actual_cumulative: [] as number[]
|
||||
}
|
||||
})
|
||||
|
||||
const dashboard = reactive(emptyDashboard())
|
||||
const loading = ref(false)
|
||||
const query = reactive<FirstVisitConversionParams>({
|
||||
time_type: 'today',
|
||||
dept_id: undefined,
|
||||
assistant_id: undefined,
|
||||
media_channel_code: ''
|
||||
})
|
||||
const deptTreeProps = { value: 'id', label: 'name', children: 'children' }
|
||||
const timeOptions = [
|
||||
{ label: '今日', value: 'today' },
|
||||
{ label: '昨天', value: 'yesterday' },
|
||||
{ label: '本周', value: 'week' },
|
||||
{ label: '本月', value: 'month' },
|
||||
{ label: '本季度', value: 'quarter' },
|
||||
{ label: '本年', value: 'year' }
|
||||
]
|
||||
const metricCards: Array<{ key: string; label: string; type: MetricType; hint: string }> = [
|
||||
{ key: 'add_fans_count', label: '加粉数', type: 'count', hint: '企微新增客户' },
|
||||
{ key: 'total_open_count', label: '开口数', type: 'count', hint: '来源于个人业绩录入' },
|
||||
{ key: 'interview_count', label: '面诊', type: 'count', hint: '已完成预约' },
|
||||
{ key: 'completed_order_count', label: '接诊诊单', type: 'count', hint: '业务订单,按创建人归属并过滤无效单' },
|
||||
{ key: 'completed_order_amount', label: '诊单金额', type: 'money', hint: '排除取消、拒收、退款及已退款订单' },
|
||||
{ key: 'avg_unit_price', label: '平均客单价', type: 'money', hint: '诊单金额 / 接诊诊单' },
|
||||
{ key: 'account_cost', label: '现金成本', type: 'money', hint: '当前范围内实际投放成本' },
|
||||
{ key: 'roi', label: 'ROI', type: 'ratio', hint: '诊单金额 / 投放成本' }
|
||||
]
|
||||
|
||||
const scopeDescription = computed(() => {
|
||||
const parts = [`${dashboard.meta.time_label || '当前区间'}数据`, dashboard.meta.scope_label || '当前权限范围']
|
||||
if (dashboard.meta.selected_dept_name) parts.push(dashboard.meta.selected_dept_name)
|
||||
if (dashboard.meta.selected_assistant_name) parts.push(dashboard.meta.selected_assistant_name)
|
||||
if (dashboard.meta.selected_media_channel_name) parts.push(`渠道:${dashboard.meta.selected_media_channel_name}`)
|
||||
return parts.join(' · ')
|
||||
})
|
||||
const maxOrderValue = computed(() => Math.max(0, ...dashboard.rankings.orders.map(item => Number(item.value || 0))))
|
||||
const maxAmountValue = computed(() => Math.max(0, ...dashboard.rankings.amounts.map(item => Number(item.value || 0))))
|
||||
const targetChartOption = computed(() => ({
|
||||
animationDuration: 450,
|
||||
color: ['#0f9185', '#2f78df'],
|
||||
tooltip: { trigger: 'axis', valueFormatter: (value: number) => formatMoney(value) },
|
||||
grid: { left: 62, right: 24, top: 28, bottom: 36 },
|
||||
xAxis: { type: 'category', boundaryGap: false, data: dashboard.target.months, axisLine: { lineStyle: { color: '#d9e1e9' } }, axisLabel: { color: '#718096' } },
|
||||
yAxis: { type: 'value', axisLabel: { color: '#718096', formatter: (value: number) => compactNumber(value) }, splitLine: { lineStyle: { color: '#edf1f5' } } },
|
||||
series: [
|
||||
{ name: '实际完成', type: 'line', smooth: true, symbol: 'none', lineStyle: { width: 3 }, areaStyle: { color: 'rgba(15,145,133,.08)' }, data: dashboard.target.actual_cumulative },
|
||||
{ name: '目标值', type: 'line', smooth: true, symbol: 'none', lineStyle: { width: 2, type: 'dashed' }, data: dashboard.target.target_cumulative }
|
||||
]
|
||||
}))
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result: any = await firstVisitConversionOverview(query)
|
||||
Object.assign(dashboard, emptyDashboard(), result || {})
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '综合数据加载失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeptChange() {
|
||||
query.assistant_id = undefined
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
function formatMetric(key: string, type: MetricType) {
|
||||
const value = dashboard.summary[key]
|
||||
if (type === 'money') return formatMoney(value)
|
||||
if (type === 'ratio') return formatRatio(value)
|
||||
return formatNumber(value)
|
||||
}
|
||||
|
||||
function formatNumber(value: any) {
|
||||
return Math.round(Number(value || 0)).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
function formatMoney(value: any) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function formatPercent(value: any) {
|
||||
return `${Number(value || 0).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function nullablePercent(value: any) {
|
||||
return value === null || value === undefined ? '未设置' : formatPercent(value)
|
||||
}
|
||||
|
||||
function formatRatio(value: any) {
|
||||
return Number(value || 0).toFixed(2)
|
||||
}
|
||||
|
||||
function compactNumber(value: number) {
|
||||
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(0)}万`
|
||||
return String(Math.round(value))
|
||||
}
|
||||
|
||||
function barWidth(value: any, maximum: number) {
|
||||
if (maximum <= 0) return '0%'
|
||||
return `${Math.max(4, Math.min(100, Number(value || 0) / maximum * 100))}%`
|
||||
}
|
||||
|
||||
function progressValue(value: any) {
|
||||
return Math.max(0, Math.min(100, Number(value || 0)))
|
||||
}
|
||||
|
||||
onMounted(loadDashboard)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.conversion-page {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
min-height: 640px;
|
||||
padding: 16px;
|
||||
color: #172033;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.filter-strip,
|
||||
.panel,
|
||||
.metric-card {
|
||||
border: 1px solid #dfe5ec;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 15px 18px;
|
||||
border-radius: 10px;
|
||||
|
||||
h1 { margin: 0; font-size: 20px; font-weight: 750; }
|
||||
p { margin: 5px 0 0; color: #8590a2; font-size: 12px; }
|
||||
}
|
||||
|
||||
.heading-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: #8994a5;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.scope-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: #0d8077;
|
||||
}
|
||||
|
||||
.filter-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.filter-item { display: flex; align-items: center; gap: 8px; }
|
||||
.filter-label, .range-text { color: #748094; font-size: 12px; white-space: nowrap; }
|
||||
.range-text { margin-left: auto; }
|
||||
.employee-select { width: 190px; }
|
||||
.dept-select { width: 220px; }
|
||||
.channel-select { width: 180px; }
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 100px;
|
||||
padding: 15px 17px;
|
||||
border-radius: 10px;
|
||||
|
||||
span, small { display: block; color: #748094; font-size: 12px; }
|
||||
strong { display: block; margin: 9px 0 7px; color: #111b2f; font-size: 25px; line-height: 1; font-variant-numeric: tabular-nums; }
|
||||
small { color: #a0a9b6; font-size: 11px; }
|
||||
}
|
||||
|
||||
.ranking-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.panel { padding: 16px; border-radius: 10px; }
|
||||
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
h2 { margin: 0; font-size: 15px; font-weight: 700; }
|
||||
p { margin: 4px 0 0; color: #929dac; font-size: 11px; }
|
||||
> span { color: #929dac; font-size: 11px; white-space: nowrap; }
|
||||
}
|
||||
|
||||
.bar-list { display: grid; gap: 13px; }
|
||||
.bar-row { display: grid; grid-template-columns: 110px minmax(80px, 1fr) 94px; align-items: center; gap: 10px; }
|
||||
.bar-name { overflow: hidden; color: #66748a; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bar-row > strong { text-align: right; font-size: 12px; font-variant-numeric: tabular-nums; }
|
||||
.bar-track { height: 18px; overflow: hidden; border-radius: 5px; background: #edf1f5; }
|
||||
.bar-track i { display: block; height: 100%; border-radius: 5px; transition: width .35s ease; }
|
||||
.bar-track i.is-teal { background: #15998d; }
|
||||
.bar-track i.is-blue { background: #307bdf; }
|
||||
|
||||
.detail-panel { padding-bottom: 10px; }
|
||||
.detail-table {
|
||||
:deep(th.el-table__cell) { color: #66748a; background: #f7f9fb; font-size: 12px; }
|
||||
:deep(td.el-table__cell) { color: #273347; font-size: 12px; }
|
||||
:deep(.el-table__row--level-0 > td.el-table__cell) { background: #edf7f5; font-weight: 650; }
|
||||
strong.is-parent { color: #172033; font-weight: 700; }
|
||||
strong.is-member { color: #314158; font-weight: 600; }
|
||||
.role-tag { margin-left: 7px; vertical-align: middle; }
|
||||
}
|
||||
|
||||
.target-panel { padding-bottom: 18px; }
|
||||
.target-layout { display: grid; grid-template-columns: minmax(390px, .82fr) minmax(500px, 1.18fr); gap: 22px; }
|
||||
.target-progress-list { padding-right: 20px; border-right: 1px solid #e4e9ef; }
|
||||
.target-summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; }
|
||||
.target-summary div { display: grid; gap: 5px; }
|
||||
.target-summary span { color: #8590a2; font-size: 11px; }
|
||||
.target-summary strong { font-size: 18px; font-variant-numeric: tabular-nums; }
|
||||
.target-summary strong.is-teal { color: #0f9185; }
|
||||
.progress-block + .progress-block { margin-top: 18px; }
|
||||
.progress-copy { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 12px; }
|
||||
.progress-copy b { color: #263246; font-size: 13px; }
|
||||
.progress-block small { display: block; margin-top: 7px; color: #8a95a5; font-size: 11px; }
|
||||
.target-empty-note { margin-top: 16px; padding: 9px 11px; border-radius: 7px; color: #a36b1e; background: #fff7e8; font-size: 11px; }
|
||||
.trend-title { display: flex; align-items: center; justify-content: space-between; color: #2e3a4d; font-size: 12px; font-weight: 600; }
|
||||
.trend-title > div { display: flex; align-items: center; gap: 7px; color: #7f8a9b; font-size: 10px; font-weight: 400; }
|
||||
.legend-line { width: 18px; border-top: 2px solid; }
|
||||
.legend-line.is-actual { border-color: #0f9185; }
|
||||
.legend-line.is-target { border-color: #2f78df; border-top-style: dashed; }
|
||||
.target-chart { width: 100%; height: 260px; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
|
||||
.range-text { margin-left: 0; }
|
||||
.target-layout { grid-template-columns: 1fr; }
|
||||
.target-progress-list { padding-right: 0; padding-bottom: 18px; border-right: 0; border-bottom: 1px solid #e4e9ef; }
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.conversion-page { padding: 10px; }
|
||||
.page-heading, .heading-meta { align-items: flex-start; flex-direction: column; }
|
||||
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
|
||||
.filter-item, .filter-item--time { width: 100%; align-items: flex-start; flex-direction: column; }
|
||||
.employee-select, .dept-select, .channel-select { width: 100%; }
|
||||
.bar-row { grid-template-columns: 90px minmax(70px, 1fr) 82px; }
|
||||
.target-summary { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,709 +0,0 @@
|
||||
<template>
|
||||
<div class="doctor-dashboard" v-loading="loading" element-loading-text="正在汇总医生经营数据">
|
||||
<header class="page-heading">
|
||||
<div class="heading-copy">
|
||||
<span class="heading-mark"><el-icon><DataLine /></el-icon></span>
|
||||
<div>
|
||||
<h1>医生看板</h1>
|
||||
<p>从挂号、预约、面诊到接诊成交,统一观察医生经营表现</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heading-actions">
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
|
||||
<span v-if="dashboard.meta.generated_at" class="update-time">更新于 {{ dashboard.meta.generated_at }}</span>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
|
||||
<el-button :icon="Download" @click="exportRows">导出</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filter-strip">
|
||||
<div class="filter-item filter-item--time">
|
||||
<span>时间范围</span>
|
||||
<el-segmented v-model="query.time_type" :options="timeOptions" @change="loadDashboard" />
|
||||
</div>
|
||||
<div v-if="dashboard.filters.can_filter_department" class="filter-item">
|
||||
<span>所属部门</span>
|
||||
<el-tree-select
|
||||
v-model="query.dept_id"
|
||||
:data="dashboard.filters.departments"
|
||||
:props="deptTreeProps"
|
||||
node-key="id"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
default-expand-all
|
||||
placeholder="全部可见部门"
|
||||
class="dept-select"
|
||||
@change="handleDepartmentChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span>医生</span>
|
||||
<el-select
|
||||
v-model="query.doctor_id"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部医生"
|
||||
class="doctor-select"
|
||||
@change="loadDashboard"
|
||||
>
|
||||
<el-option
|
||||
v-for="doctor in dashboard.filters.doctors"
|
||||
:key="doctor.id"
|
||||
:value="Number(doctor.id)"
|
||||
:label="`${doctor.name}${Number(doctor.disable) === 1 ? '(停用)' : ''}`"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-item filter-item--status">
|
||||
<span>医生范围</span>
|
||||
<el-segmented v-model="query.active_only" :options="doctorStatusOptions" @change="handleActiveChange" />
|
||||
</div>
|
||||
<div class="view-switch">
|
||||
<button :class="{ active: viewMode === 'overview' }" @click="viewMode = 'overview'">诊断总览</button>
|
||||
<button :class="{ active: viewMode === 'detail' }" @click="viewMode = 'detail'">医生明细</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid" aria-label="医生经营核心指标">
|
||||
<article class="metric-card metric-card--teal">
|
||||
<div class="metric-icon"><el-icon><Calendar /></el-icon></div>
|
||||
<div>
|
||||
<span>总挂号</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.registration_total) }}</strong>
|
||||
<small>已支付且实收金额大于 0、低于 10 元的订单</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--green">
|
||||
<div class="metric-icon"><el-icon><User /></el-icon></div>
|
||||
<div>
|
||||
<span>总面诊</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.interview_count) }}</strong>
|
||||
<small>过号 {{ formatNumber(dashboard.summary.missed_count) }} · 取消 {{ formatNumber(dashboard.summary.cancelled_count) }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--indigo">
|
||||
<div class="metric-icon"><el-icon><Tickets /></el-icon></div>
|
||||
<div>
|
||||
<span>总诊单</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.order_count) }}</strong>
|
||||
<small>接诊转化率 {{ formatPercent(dashboard.summary.receive_conversion_rate) }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--blue">
|
||||
<div class="metric-icon"><el-icon><Money /></el-icon></div>
|
||||
<div>
|
||||
<span>总成交金额</span>
|
||||
<strong>{{ formatMoney(dashboard.summary.deal_amount) }}</strong>
|
||||
<small>客单价 {{ nullableMoney(dashboard.summary.avg_order_amount) }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--orange">
|
||||
<div class="metric-icon"><el-icon><DataLine /></el-icon></div>
|
||||
<div>
|
||||
<span>总接诊率</span>
|
||||
<strong>{{ formatPercent(dashboard.summary.receive_conversion_rate) }}</strong>
|
||||
<small>总接诊 {{ formatNumber(dashboard.summary.order_count) }} ÷ 总面诊 {{ formatNumber(dashboard.summary.interview_count) }}</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--cyan">
|
||||
<div class="metric-icon"><el-icon><CircleCheck /></el-icon></div>
|
||||
<div>
|
||||
<span>预约完成率</span>
|
||||
<strong>{{ formatPercent(dashboard.summary.appointment_completion_rate) }}</strong>
|
||||
<small>总预约 {{ formatNumber(dashboard.summary.appointment_total) }} · {{ dashboard.meta.doctor_count }} 位有数据医生</small>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<template v-if="viewMode === 'overview'">
|
||||
<section class="two-column-grid">
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>成交金额 TOP</h2><p>按有效诊单金额从高到低</p></div>
|
||||
<span>{{ dashboard.meta.time_label }}</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.amounts.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.amounts" :key="`amount-${item.doctor_id}`" class="bar-row">
|
||||
<b>{{ index + 1 }}</b>
|
||||
<span :title="item.name">{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-blue" :style="{ width: barWidth(item.value, maxAmount) }" /></div>
|
||||
<strong>{{ formatMoney(item.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="52" description="当前范围暂无成交金额" />
|
||||
</article>
|
||||
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>接诊转化率 TOP</h2><p>有效诊单数 ÷ 完成面诊数</p></div>
|
||||
<span>{{ dashboard.meta.time_label }}</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.conversion.length" class="bar-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.conversion" :key="`rate-${item.doctor_id}`" class="bar-row">
|
||||
<b>{{ index + 1 }}</b>
|
||||
<span :title="item.name">{{ item.name }}</span>
|
||||
<div class="bar-track"><i class="is-teal" :style="{ width: percentageWidth(item.value) }" /></div>
|
||||
<strong>{{ formatPercent(item.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="52" description="当前范围暂无转化数据" />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="analytics-grid">
|
||||
<article class="panel trend-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>近 30 天成交金额趋势</h2><p>{{ dashboard.trend.start_date }} 至 {{ dashboard.trend.end_date }}</p></div>
|
||||
<span>单位:元</span>
|
||||
</div>
|
||||
<v-charts class="trend-chart" :option="trendChartOption" autoresize />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel alert-panel" :class="{ 'has-alerts': dashboard.alerts.length }">
|
||||
<div class="panel-heading alert-heading">
|
||||
<div>
|
||||
<h2><el-icon><WarningFilled /></el-icon>需关注医生</h2>
|
||||
<p>仅对完成过面诊的医生计算接诊转化预警</p>
|
||||
</div>
|
||||
<div class="threshold-control">
|
||||
<span>接诊转化率低于</span>
|
||||
<el-select v-model="query.alert_threshold" class="threshold-select" @change="loadDashboard">
|
||||
<el-option v-for="value in thresholdOptions" :key="value" :label="`${value}%`" :value="value" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="dashboard.alerts.length" class="alert-list">
|
||||
<div v-for="item in dashboard.alerts" :key="item.doctor_id" class="alert-row">
|
||||
<span class="alert-icon">!</span>
|
||||
<div class="alert-doctor"><strong>{{ item.doctor_name }}</strong><small>{{ item.department_name }}</small></div>
|
||||
<span class="severity" :class="`is-${item.severity}`">{{ item.severity === 'high' ? '重点关注' : '低于阈值' }}</span>
|
||||
<span class="alert-data">面诊 {{ item.interview_count }} / 接诊 {{ item.order_count }}</span>
|
||||
<p>{{ item.suggestion }}</p>
|
||||
<strong class="alert-rate">{{ formatPercent(item.rate) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="alert-empty">
|
||||
<el-icon><CircleCheck /></el-icon>
|
||||
当前范围内暂无低于 {{ query.alert_threshold }}% 的医生
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<section v-else class="panel detail-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>医生明细 · 按成交金额排序</h2><p>低于接诊率预警线的医生整行标红,过号与取消分别展示</p></div>
|
||||
<span>{{ businessRows.length }} 位有数据医生</span>
|
||||
</div>
|
||||
<el-table
|
||||
:data="visibleDetailRows"
|
||||
class="detail-table"
|
||||
:default-sort="{ prop: 'deal_amount', order: 'descending' }"
|
||||
:row-class-name="detailRowClassName"
|
||||
>
|
||||
<el-table-column prop="doctor_name" label="医生" min-width="130" fixed="left" sortable>
|
||||
<template #default="{ row }"><strong>{{ row.doctor_name }}</strong></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="status" label="状态" min-width="90" sortable>
|
||||
<template #default="{ row }">
|
||||
<span class="status-pill" :class="{ disabled: row.status === 'disabled' }">
|
||||
{{ row.status === 'disabled' ? '停用' : '活跃' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="appointment_total" label="预约" min-width="90" sortable />
|
||||
<el-table-column prop="interview_count" label="面诊" min-width="90" sortable />
|
||||
<el-table-column prop="order_count" label="接诊" min-width="90" sortable />
|
||||
<el-table-column prop="receive_conversion_rate" label="接诊率" min-width="110" sortable>
|
||||
<template #default="{ row }">
|
||||
<span :class="conversionClass(row.receive_conversion_rate)">
|
||||
<span v-if="isLowConversion(row)" class="warning-mark">△</span>{{ formatPercent(row.receive_conversion_rate) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="deal_amount" label="成交金额" min-width="135" sortable>
|
||||
<template #default="{ row }"><strong class="money-text">{{ formatDetailMoney(row.deal_amount) }}</strong></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="过号/取消" min-width="110">
|
||||
<template #default="{ row }">{{ formatNumber(row.appointment_missed) }}/{{ formatNumber(row.appointment_cancelled) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button class="detail-action" type="primary" link @click="openDoctorDetail(row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前权限和筛选范围内暂无医生数据" /></template>
|
||||
</el-table>
|
||||
<button
|
||||
v-if="!query.doctor_id && zeroDataRows.length"
|
||||
type="button"
|
||||
class="zero-doctor-toggle"
|
||||
@click="showZeroRows = !showZeroRows"
|
||||
>
|
||||
<span>{{ showZeroRows ? '▾' : '▸' }}</span>
|
||||
{{ showZeroRows ? '收起' : '已隐藏' }} {{ zeroDataRows.length }} 位无业务数据医生{{ showZeroRows ? '' : '(点击展开)' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<footer class="data-note">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>{{ dashboard.meta.registration_rule }};{{ dashboard.meta.appointment_rule }};{{ dashboard.meta.performance_rule }}。</span>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="firstVisitDoctorDashboardPage">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Calendar,
|
||||
CircleCheck,
|
||||
DataLine,
|
||||
Download,
|
||||
InfoFilled,
|
||||
Lock,
|
||||
Money,
|
||||
Refresh,
|
||||
Tickets,
|
||||
User,
|
||||
WarningFilled
|
||||
} from '@element-plus/icons-vue'
|
||||
import vCharts from 'vue-echarts'
|
||||
import {
|
||||
firstVisitDoctorDashboardOverview,
|
||||
type FirstVisitDoctorDashboardParams
|
||||
} from '@/api/first_visit'
|
||||
|
||||
const emptyDashboard = () => ({
|
||||
meta: {
|
||||
time_type: 'month', time_label: '本月', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', scope_kind: '', selected_dept_name: '', selected_doctor_name: '',
|
||||
doctor_count: 0, registration_rule: '', appointment_rule: '', performance_rule: ''
|
||||
},
|
||||
filters: {
|
||||
departments: [] as any[], doctors: [] as Array<{ id: number; name: string; disable: number }>,
|
||||
can_filter_department: true
|
||||
},
|
||||
summary: {
|
||||
registration_total: 0, appointment_total: 0, interview_count: 0, order_count: 0, deal_amount: 0,
|
||||
avg_order_amount: null as number | null, appointment_completion_rate: null as number | null,
|
||||
receive_conversion_rate: null as number | null, missed_count: 0, cancelled_count: 0
|
||||
},
|
||||
rankings: { amounts: [] as any[], conversion: [] as any[] },
|
||||
trend: { start_date: '', end_date: '', dates: [] as string[], labels: [] as string[], amounts: [] as number[] },
|
||||
alerts: [] as any[],
|
||||
alert_threshold: 15,
|
||||
rows: [] as any[]
|
||||
})
|
||||
|
||||
const dashboard = reactive(emptyDashboard())
|
||||
const loading = ref(false)
|
||||
const viewMode = ref<'overview' | 'detail'>('overview')
|
||||
const showZeroRows = ref(false)
|
||||
const query = reactive<FirstVisitDoctorDashboardParams>({
|
||||
time_type: 'month',
|
||||
active_only: 1,
|
||||
alert_threshold: 15
|
||||
})
|
||||
const timeOptions = [
|
||||
{ label: '今日', value: 'today' },
|
||||
{ label: '本周', value: 'week' },
|
||||
{ label: '本月', value: 'month' }
|
||||
]
|
||||
const doctorStatusOptions = [
|
||||
{ label: '仅活跃医生', value: 1 },
|
||||
{ label: '全部医生', value: 0 }
|
||||
]
|
||||
const thresholdOptions = [10, 15, 20, 30]
|
||||
const deptTreeProps = { label: 'name', value: 'id', children: 'children' }
|
||||
|
||||
const maxAmount = computed(() => Math.max(0, ...dashboard.rankings.amounts.map((item: any) => Number(item.value) || 0)))
|
||||
const businessRows = computed(() => dashboard.rows.filter((row: any) => hasBusinessData(row)))
|
||||
const zeroDataRows = computed(() => dashboard.rows.filter((row: any) => !hasBusinessData(row)))
|
||||
const visibleDetailRows = computed(() => {
|
||||
if (query.doctor_id || showZeroRows.value) return dashboard.rows
|
||||
return businessRows.value
|
||||
})
|
||||
const trendChartOption = computed(() => ({
|
||||
animationDuration: 450,
|
||||
grid: { left: 20, right: 20, top: 18, bottom: 18, containLabel: true },
|
||||
tooltip: { trigger: 'axis', valueFormatter: (value: number) => formatMoney(value) },
|
||||
xAxis: {
|
||||
type: 'category', boundaryGap: false, data: dashboard.trend.labels,
|
||||
axisLine: { lineStyle: { color: '#dfe5eb' } }, axisTick: { show: false },
|
||||
axisLabel: { color: '#7e8a9b', fontSize: 10, interval: 4 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value', splitNumber: 4,
|
||||
axisLabel: { color: '#8793a2', fontSize: 10, formatter: (value: number) => compactMoney(value) },
|
||||
splitLine: { lineStyle: { color: '#eef2f5' } }
|
||||
},
|
||||
series: [{
|
||||
name: '成交金额', type: 'line', smooth: true, symbol: 'circle', symbolSize: 5,
|
||||
data: dashboard.trend.amounts,
|
||||
lineStyle: { color: '#139a8c', width: 3 },
|
||||
itemStyle: { color: '#ffffff', borderColor: '#139a8c', borderWidth: 2 },
|
||||
areaStyle: { color: 'rgba(19,154,140,.08)' }
|
||||
}]
|
||||
}))
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: FirstVisitDoctorDashboardParams = {
|
||||
time_type: query.time_type,
|
||||
active_only: query.active_only ?? 1,
|
||||
alert_threshold: Number(query.alert_threshold || 15)
|
||||
}
|
||||
if (query.dept_id) params.dept_id = Number(query.dept_id)
|
||||
if (query.doctor_id) params.doctor_id = Number(query.doctor_id)
|
||||
const result: any = await firstVisitDoctorDashboardOverview(params)
|
||||
Object.assign(dashboard, emptyDashboard(), result || {})
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '医生看板加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDepartmentChange() {
|
||||
delete query.doctor_id
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
function handleActiveChange() {
|
||||
delete query.doctor_id
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
function formatNumber(value: unknown) {
|
||||
return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
|
||||
}
|
||||
|
||||
function formatMoney(value: unknown) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function formatDetailMoney(value: unknown) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function nullableMoney(value: unknown) {
|
||||
return value === null || value === undefined ? '—' : formatMoney(value)
|
||||
}
|
||||
|
||||
function formatPercent(value: unknown) {
|
||||
return value === null || value === undefined ? '—' : `${Number(value).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function compactMoney(value: number) {
|
||||
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`
|
||||
return `${Math.round(value)}`
|
||||
}
|
||||
|
||||
function barWidth(value: unknown, max: number) {
|
||||
if (max <= 0) return '0%'
|
||||
return `${Math.max(3, Math.min(100, Number(value || 0) / max * 100))}%`
|
||||
}
|
||||
|
||||
function percentageWidth(value: unknown) {
|
||||
return `${Math.max(0, Math.min(100, Number(value) || 0))}%`
|
||||
}
|
||||
|
||||
function conversionClass(value: unknown) {
|
||||
if (value === null || value === undefined) return 'conversion-rate is-neutral'
|
||||
return Number(value) < Number(query.alert_threshold || 15) ? 'conversion-rate is-low' : 'conversion-rate is-good'
|
||||
}
|
||||
|
||||
function hasBusinessData(row: any) {
|
||||
return Number(row.appointment_total || 0) > 0
|
||||
|| Number(row.interview_count || 0) > 0
|
||||
|| Number(row.order_count || 0) > 0
|
||||
|| Number(row.deal_amount || 0) > 0
|
||||
|| Number(row.appointment_missed || 0) > 0
|
||||
|| Number(row.appointment_cancelled || 0) > 0
|
||||
}
|
||||
|
||||
function isLowConversion(row: any) {
|
||||
return Number(row.interview_count || 0) > 0
|
||||
&& Number(row.receive_conversion_rate || 0) < Number(query.alert_threshold || 15)
|
||||
}
|
||||
|
||||
function detailRowClassName({ row }: { row: any }) {
|
||||
if (isLowConversion(row)) return 'is-conversion-warning'
|
||||
if (!hasBusinessData(row)) return 'is-zero-data'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function openDoctorDetail(row: any) {
|
||||
query.doctor_id = Number(row.doctor_id)
|
||||
viewMode.value = 'overview'
|
||||
showZeroRows.value = false
|
||||
await loadDashboard()
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function csvCell(value: unknown) {
|
||||
return `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function exportRows() {
|
||||
if (!dashboard.rows.length) {
|
||||
ElMessage.warning('当前范围暂无可导出的医生数据')
|
||||
return
|
||||
}
|
||||
const headers = ['医生', '状态', '预约', '面诊', '接诊', '接诊率', '成交金额', '过号', '取消']
|
||||
const lines = dashboard.rows.map((row: any) => [
|
||||
row.doctor_name, row.status === 'disabled' ? '停用' : '活跃', row.appointment_total,
|
||||
row.interview_count, row.order_count, formatPercent(row.receive_conversion_rate), row.deal_amount,
|
||||
row.appointment_missed, row.appointment_cancelled
|
||||
].map(csvCell).join(','))
|
||||
const blob = new Blob([`\uFEFF${headers.map(csvCell).join(',')}\n${lines.join('\n')}`], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `医生看板_${dashboard.meta.start_date}_${dashboard.meta.end_date}.csv`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
onMounted(loadDashboard)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.doctor-dashboard {
|
||||
--ink: #17243a;
|
||||
--muted: #768497;
|
||||
--line: #e2e8ee;
|
||||
--canvas: #f4f6f8;
|
||||
--teal: #139a8c;
|
||||
--blue: #3d78e7;
|
||||
min-height: 100%;
|
||||
padding: 18px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.filter-strip,
|
||||
.metric-card,
|
||||
.panel { border: 1px solid var(--line); background: #fff; }
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
min-height: 76px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 13px;
|
||||
}
|
||||
.heading-copy, .heading-actions, .filter-item, .threshold-control { display: flex; align-items: center; }
|
||||
.heading-copy { gap: 12px; }
|
||||
.heading-mark {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: #fff;
|
||||
background: var(--teal);
|
||||
box-shadow: 0 8px 20px rgba(19, 154, 140, .16);
|
||||
font-size: 20px;
|
||||
}
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 { font-size: 20px; line-height: 1.3; }
|
||||
h2 { font-size: 15px; line-height: 1.4; }
|
||||
.heading-copy p, .panel-heading p { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.heading-actions { gap: 10px; color: var(--muted); font-size: 12px; }
|
||||
.scope-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid #cce8e3;
|
||||
border-radius: 7px;
|
||||
color: #117f75;
|
||||
background: #f2faf8;
|
||||
}
|
||||
|
||||
.filter-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-top: 14px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
.filter-item { gap: 8px; color: #68778a; font-size: 12px; }
|
||||
.dept-select { width: 190px; }
|
||||
.doctor-select { width: 160px; }
|
||||
.view-switch { display: flex; margin-left: auto; padding-left: 12px; border-left: 1px solid #e6ebef; }
|
||||
.view-switch button {
|
||||
min-width: 82px;
|
||||
padding: 9px 12px;
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: #657488;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.view-switch button.active { border-bottom-color: var(--teal); color: #0e8277; font-weight: 700; }
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.metric-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-height: 105px;
|
||||
padding: 16px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
.metric-icon {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: 0 0 40px;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.metric-card--teal .metric-icon { color: #0b887c; background: #e8f7f4; }
|
||||
.metric-card--green .metric-icon { color: #37a465; background: #eef8f1; }
|
||||
.metric-card--indigo .metric-icon { color: #556dde; background: #eef0fd; }
|
||||
.metric-card--blue .metric-icon { color: #3976e6; background: #edf3ff; }
|
||||
.metric-card--orange .metric-icon { color: #d77a2c; background: #fff4e9; }
|
||||
.metric-card--cyan .metric-icon { color: #138da1; background: #eaf7f8; }
|
||||
.metric-card span { display: block; color: #748296; font-size: 12px; }
|
||||
.metric-card strong { display: block; margin: 4px 0 3px; font-size: 24px; line-height: 1.1; }
|
||||
.metric-card small { color: #8995a4; font-size: 10px; }
|
||||
|
||||
.two-column-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
|
||||
.panel { margin-top: 14px; border-radius: 12px; overflow: hidden; }
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 14px 16px 11px;
|
||||
}
|
||||
.panel-heading > span { color: #8a96a5; font-size: 11px; }
|
||||
.ranking-panel { min-height: 284px; }
|
||||
.bar-list { padding: 0 16px 15px; }
|
||||
.bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(80px, .35fr) minmax(140px, 1fr) 110px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 34px;
|
||||
}
|
||||
.bar-row > b { color: #8793a2; text-align: center; font-size: 10px; }
|
||||
.bar-row > span { overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bar-row > strong { text-align: right; font-size: 12px; }
|
||||
.bar-track { height: 9px; overflow: hidden; border-radius: 6px; background: #edf1f4; }
|
||||
.bar-track i { display: block; height: 100%; border-radius: inherit; }
|
||||
.bar-track .is-blue { background: var(--blue); }
|
||||
.bar-track .is-teal { background: var(--teal); }
|
||||
|
||||
.analytics-grid { display: grid; grid-template-columns: minmax(0, 1fr); }
|
||||
.analytics-grid .panel { min-height: 300px; }
|
||||
.trend-chart { width: 100%; height: 238px; }
|
||||
|
||||
.alert-panel { border-color: #dfe8e7; }
|
||||
.alert-panel.has-alerts { border-color: #efc4c4; }
|
||||
.alert-heading h2 { display: flex; align-items: center; gap: 6px; }
|
||||
.alert-heading h2 .el-icon { color: #ee5b5b; }
|
||||
.threshold-control { gap: 8px; color: #768497; font-size: 11px; }
|
||||
.threshold-select { width: 88px; }
|
||||
.alert-list { padding: 0 14px 14px; }
|
||||
.alert-row {
|
||||
display: grid;
|
||||
grid-template-columns: 30px minmax(120px, .45fr) 82px 135px minmax(220px, 1fr) 65px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 54px;
|
||||
margin-top: 8px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #f0dddd;
|
||||
border-radius: 9px;
|
||||
background: #fffafa;
|
||||
}
|
||||
.alert-icon { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 50%; color: #fff; background: #ef5a5a; font-weight: 700; }
|
||||
.alert-doctor strong, .alert-doctor small { display: block; }
|
||||
.alert-doctor strong { font-size: 12px; }
|
||||
.alert-doctor small { margin-top: 2px; color: #8d98a6; font-size: 10px; }
|
||||
.severity { padding: 4px 7px; border-radius: 5px; text-align: center; font-size: 10px; }
|
||||
.severity.is-high { color: #d84444; background: #ffe9e9; }
|
||||
.severity.is-medium { color: #c77726; background: #fff1df; }
|
||||
.alert-data { color: #67768a; font-size: 11px; }
|
||||
.alert-row p { color: #7b8797; font-size: 11px; }
|
||||
.alert-rate { color: #e34949; text-align: right; font-size: 15px; }
|
||||
.alert-empty { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 94px; color: #4f8f77; font-size: 12px; }
|
||||
.alert-empty .el-icon { font-size: 20px; }
|
||||
|
||||
.detail-panel { min-height: 360px; }
|
||||
.detail-table { border-top: 1px solid #edf1f4; --el-table-header-bg-color: #f7f9fb; }
|
||||
.detail-table :deep(th.el-table__cell) { height: 43px; color: #68778a; font-weight: 500; }
|
||||
.detail-table :deep(td.el-table__cell) { height: 47px; }
|
||||
.detail-table :deep(.is-conversion-warning > td.el-table__cell) { background: #fff0f0 !important; }
|
||||
.detail-table :deep(.is-zero-data > td.el-table__cell) { color: #98a2af; background: #fafbfc !important; }
|
||||
.money-text { color: #246bd3; }
|
||||
.conversion-rate.is-low { color: #e04d4d; font-weight: 700; }
|
||||
.conversion-rate.is-good { color: #168f72; }
|
||||
.conversion-rate.is-neutral { color: #919cab; }
|
||||
.warning-mark { margin-right: 3px; color: #ec4e4e; font-size: 10px; }
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: 11px;
|
||||
color: #16895f;
|
||||
background: #eaf8ef;
|
||||
font-size: 11px;
|
||||
}
|
||||
.status-pill.disabled { color: #9a6570; background: #f4ecee; }
|
||||
.detail-action { font-size: 12px; }
|
||||
.zero-doctor-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin: 10px 16px 15px;
|
||||
padding: 4px 9px;
|
||||
border: 0;
|
||||
border-radius: 11px;
|
||||
color: #6e7d90;
|
||||
background: #eef3f6;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
.zero-doctor-toggle:hover { color: #0e8277; background: #e7f4f1; }
|
||||
.data-note { display: flex; align-items: center; gap: 6px; padding: 11px 2px 2px; color: #8b97a6; font-size: 11px; }
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
|
||||
.view-switch { margin-left: 0; }
|
||||
.metric-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.doctor-dashboard { padding: 10px; }
|
||||
.page-heading { align-items: flex-start; flex-direction: column; }
|
||||
.heading-actions { flex-wrap: wrap; }
|
||||
.update-time { display: none; }
|
||||
.metric-grid, .two-column-grid { grid-template-columns: 1fr; }
|
||||
.metric-card { min-height: 92px; }
|
||||
.filter-item { width: 100%; justify-content: space-between; }
|
||||
.dept-select, .doctor-select { width: calc(100% - 78px); }
|
||||
.filter-item--time, .filter-item--status { justify-content: flex-start; }
|
||||
.view-switch { width: 100%; justify-content: flex-end; border-left: 0; }
|
||||
.alert-row { grid-template-columns: 30px 1fr 80px 65px; padding: 10px; }
|
||||
.alert-data, .alert-row p { grid-column: 2 / -1; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,684 +0,0 @@
|
||||
<template>
|
||||
<prescription-order-detail-drawer
|
||||
ref="detailDrawerRef"
|
||||
readonly
|
||||
append-to-body
|
||||
:detail-loader="myPatientOrderDetail"
|
||||
:load-related-data="false"
|
||||
>
|
||||
<template #header-actions="{ detail }">
|
||||
<el-dropdown
|
||||
v-if="myPatientOrderActions(detail).length"
|
||||
trigger="click"
|
||||
@command="(command) => openAction(detail, command as MyPatientOrderAction)"
|
||||
>
|
||||
<el-button type="primary" size="small" plain>
|
||||
订单操作<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="action in myPatientOrderActions(detail)"
|
||||
:key="action.key"
|
||||
:command="action.key"
|
||||
:class="{ 'danger-menu-item': action.danger }"
|
||||
>
|
||||
{{ action.label }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</template>
|
||||
</prescription-order-detail-drawer>
|
||||
|
||||
<el-dialog v-model="auditVisible" :title="auditTitle" width="480px" append-to-body destroy-on-close>
|
||||
<el-alert
|
||||
title="通过时审核意见可不填;驳回时必须填写明确原因。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-input
|
||||
v-model="auditRemark"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入审核意见"
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="auditVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitAudit('approve')">同意通过</el-button>
|
||||
<el-button type="danger" :loading="submitting" @click="submitAudit('reject')">驳回</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="trackVisible" title="修改快递单号" width="440px" append-to-body destroy-on-close>
|
||||
<el-form label-width="92px" @submit.prevent="submitTrack">
|
||||
<el-form-item label="承运商">
|
||||
<el-select v-model="trackForm.express_company" class="w-full">
|
||||
<el-option v-for="item in expressOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="快递单号" required>
|
||||
<el-input v-model="trackForm.tracking_number" maxlength="80" clearable placeholder="请输入快递单号" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="trackVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitTrack">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="shipVisible" title="确认发货" width="460px" append-to-body destroy-on-close>
|
||||
<el-alert
|
||||
title="确认后订单将进入“已发货”,请先核对发货方式和快递信息。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-form label-width="92px" @submit.prevent="submitShip">
|
||||
<el-form-item label="发货方式">
|
||||
<el-tag :type="shipForm.ship_mode === 'direct' ? 'warning' : 'success'" effect="plain">
|
||||
{{ shipForm.ship_mode === 'direct' ? '洛阳药房' : '甘草药房' }}
|
||||
</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item label="承运商">
|
||||
<el-select v-model="shipForm.express_company" class="w-full">
|
||||
<el-option v-for="item in expressOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="快递单号" required>
|
||||
<el-input v-model="shipForm.tracking_number" maxlength="80" clearable placeholder="请输入快递单号" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="shipVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitShip">确认发货</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="editVisible" title="编辑订单" width="680px" append-to-body destroy-on-close>
|
||||
<el-form v-loading="editLoading" label-width="104px" class="order-edit-form">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="收货人" required>
|
||||
<el-input v-model="editForm.recipient_name" maxlength="50" />
|
||||
</el-form-item>
|
||||
<el-form-item label="收货手机" required>
|
||||
<el-input v-model="editForm.recipient_phone" maxlength="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="费用类别" required>
|
||||
<el-select v-model="editForm.fee_type" class="w-full">
|
||||
<el-option v-for="item in feeTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="订单金额" required>
|
||||
<el-input-number v-model="editForm.amount" :min="0" :precision="2" :step="10" class="w-full" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="收货地址" required>
|
||||
<el-input v-model="editForm.shipping_address" maxlength="500" />
|
||||
</el-form-item>
|
||||
<div class="form-grid">
|
||||
<el-form-item label="承运商">
|
||||
<el-select v-model="editForm.express_company" class="w-full">
|
||||
<el-option v-for="item in expressOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="快递单号">
|
||||
<el-input v-model="editForm.tracking_number" maxlength="80" clearable />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="医助备注">
|
||||
<el-input v-model="editForm.remark_assistant" type="textarea" :rows="2" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="药房备注">
|
||||
<el-input v-model="editForm.remark_extra" type="textarea" :rows="2" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-alert
|
||||
title="保存后会沿用原订单编辑规则;非已发货订单的支付审核将重新变为待审核。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="editVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" :disabled="editLoading" @click="submitEdit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="payVisible" title="补齐支付单" width="520px" append-to-body destroy-on-close>
|
||||
<el-alert
|
||||
:title="`订单金额 ¥${money(actionRow.amount)},已关联支付 ¥${money(actionRow.linked_pay_paid_total)}`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-form label-width="108px">
|
||||
<el-form-item label="支付单类型">
|
||||
<el-select v-model="payForm.order_type" class="w-full">
|
||||
<el-option v-for="item in payTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="创建方式">
|
||||
<el-radio-group v-model="payForm.pay_create_type">
|
||||
<el-radio value="fubei">付呗支付</el-radio>
|
||||
<el-radio value="express_cod">快递代收</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="补齐金额" required>
|
||||
<el-input-number v-model="payForm.pay_amount" :min="0" :precision="2" :step="10" class="w-full" />
|
||||
</el-form-item>
|
||||
<el-form-item label="支付备注">
|
||||
<el-input v-model="payForm.pay_remark" type="textarea" :rows="3" maxlength="200" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="完单申请">
|
||||
<el-switch v-model="payForm.completion_request" :active-value="1" :inactive-value="0" />
|
||||
<span class="form-tip">同时提交完单申请</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="payVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitPayOrder">确认新增</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="completeVisible" title="完成订单" width="480px" append-to-body destroy-on-close>
|
||||
<el-alert
|
||||
title="请选择真实履约结果。退款必须使用单独的“退款”操作。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="结案状态" required>
|
||||
<el-select v-model="completeStatus" class="w-full">
|
||||
<el-option v-for="item in completeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="completeVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitComplete">确认完成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="refundVisible" title="订单退款" width="500px" append-to-body destroy-on-close>
|
||||
<el-alert
|
||||
title="退款会同步更新关联支付单和订单金额;退款金额留空时按系统计算的最大可退金额处理。"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
class="mb-4"
|
||||
/>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="退款原因" required>
|
||||
<el-input v-model="refundForm.reason" type="textarea" :rows="3" maxlength="500" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="退款金额">
|
||||
<el-input-number
|
||||
v-model="refundForm.refund_amount"
|
||||
:min="0"
|
||||
:precision="2"
|
||||
:step="10"
|
||||
class="w-full"
|
||||
placeholder="留空则退最大可退金额"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="refundVisible = false">取消</el-button>
|
||||
<el-button type="danger" :loading="submitting" @click="submitRefund">确认退款</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ArrowDown } from '@element-plus/icons-vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import feedback from '@/utils/feedback'
|
||||
import PrescriptionOrderDetailDrawer from '@/views/consumer/prescription/components/PrescriptionOrderDetailDrawer.vue'
|
||||
import {
|
||||
myPatientOrderAddPayOrder,
|
||||
myPatientOrderAuditPayment,
|
||||
myPatientOrderAuditPrescription,
|
||||
myPatientOrderComplete,
|
||||
myPatientOrderDdcode,
|
||||
myPatientOrderDetail,
|
||||
myPatientOrderEdit,
|
||||
myPatientOrderRefund,
|
||||
myPatientOrderRevokePayAudit,
|
||||
myPatientOrderRevokeRxAudit,
|
||||
myPatientOrderShip,
|
||||
myPatientOrderUploadToPharmacy,
|
||||
myPatientOrderWithdraw
|
||||
} from '@/api/first_visit'
|
||||
import { myPatientOrderActions, type MyPatientOrderAction } from './order-actions'
|
||||
|
||||
const emit = defineEmits<{ changed: [] }>()
|
||||
const detailDrawerRef = ref<InstanceType<typeof PrescriptionOrderDetailDrawer>>()
|
||||
const actionRow = ref<Record<string, any>>({})
|
||||
const submitting = ref(false)
|
||||
|
||||
const expressOptions = [
|
||||
{ label: '自动识别', value: 'auto' },
|
||||
{ label: '顺丰速运', value: 'sf' },
|
||||
{ label: '京东快递', value: 'jd' },
|
||||
{ label: '极兔速递', value: 'jt' }
|
||||
]
|
||||
const feeTypeOptions = [
|
||||
{ label: '挂号', value: 1 },
|
||||
{ label: '问诊', value: 2 },
|
||||
{ label: '药品', value: 3 },
|
||||
{ label: '首付', value: 4 },
|
||||
{ label: '尾款', value: 5 },
|
||||
{ label: '其他', value: 6 },
|
||||
{ label: '全部', value: 7 },
|
||||
{ label: '代收', value: 8 }
|
||||
]
|
||||
const payTypeOptions = [
|
||||
{ label: '药品', value: 3 },
|
||||
{ label: '尾款', value: 5 },
|
||||
{ label: '其他', value: 6 }
|
||||
]
|
||||
const completeOptions = [
|
||||
{ label: '已完成', value: 3 },
|
||||
{ label: '进行中', value: 7 },
|
||||
{ label: '暂不制药', value: 8 },
|
||||
{ label: '拒收', value: 9 },
|
||||
{ label: '保留药方', value: 11 },
|
||||
{ label: '制药缓发', value: 12 }
|
||||
]
|
||||
|
||||
const auditVisible = ref(false)
|
||||
const auditKind = ref<'prescription' | 'payment'>('prescription')
|
||||
const auditRemark = ref('')
|
||||
const auditTitle = computed(() => auditKind.value === 'prescription' ? '处方业务审核' : '关联支付单审核')
|
||||
|
||||
const trackVisible = ref(false)
|
||||
const trackForm = reactive({ express_company: 'auto', tracking_number: '' })
|
||||
|
||||
const shipVisible = ref(false)
|
||||
const shipForm = reactive<{ ship_mode: 'gancao' | 'direct'; express_company: string; tracking_number: string }>({
|
||||
ship_mode: 'gancao',
|
||||
express_company: 'auto',
|
||||
tracking_number: ''
|
||||
})
|
||||
|
||||
const editVisible = ref(false)
|
||||
const editLoading = ref(false)
|
||||
const editSource = ref<Record<string, any>>({})
|
||||
const editForm = reactive({
|
||||
recipient_name: '',
|
||||
recipient_phone: '',
|
||||
shipping_address: '',
|
||||
fee_type: 3,
|
||||
amount: 0,
|
||||
express_company: 'auto',
|
||||
tracking_number: '',
|
||||
remark_assistant: '',
|
||||
remark_extra: ''
|
||||
})
|
||||
|
||||
const payVisible = ref(false)
|
||||
const payForm = reactive<{
|
||||
order_type: number
|
||||
pay_amount: number
|
||||
pay_remark: string
|
||||
completion_request: number
|
||||
pay_create_type: 'fubei' | 'express_cod'
|
||||
}>({ order_type: 3, pay_amount: 0, pay_remark: '', completion_request: 0, pay_create_type: 'fubei' })
|
||||
|
||||
const completeVisible = ref(false)
|
||||
const completeStatus = ref(3)
|
||||
|
||||
const refundVisible = ref(false)
|
||||
const refundForm = reactive<{ reason: string; refund_amount: number | undefined }>({
|
||||
reason: '',
|
||||
refund_amount: undefined
|
||||
})
|
||||
|
||||
function money(value: unknown) {
|
||||
return Number(value || 0).toFixed(2)
|
||||
}
|
||||
|
||||
function normalizeShipMode(value: unknown): 'gancao' | 'direct' {
|
||||
return String(value || '').toLowerCase() === 'direct' ? 'direct' : 'gancao'
|
||||
}
|
||||
|
||||
function openDetail(row: Record<string, any>) {
|
||||
detailDrawerRef.value?.open(Number(row.id))
|
||||
}
|
||||
|
||||
async function fetchDetail(id: number) {
|
||||
const response: any = await myPatientOrderDetail({ id })
|
||||
return response?.data ?? response ?? null
|
||||
}
|
||||
|
||||
async function openEdit(row: Record<string, any>) {
|
||||
actionRow.value = row
|
||||
editVisible.value = true
|
||||
editLoading.value = true
|
||||
try {
|
||||
const detail = await fetchDetail(Number(row.id))
|
||||
if (!detail) throw new Error('订单详情加载失败')
|
||||
editSource.value = detail
|
||||
editForm.recipient_name = String(detail.recipient_name || '')
|
||||
editForm.recipient_phone = String(detail.recipient_phone || '')
|
||||
editForm.shipping_address = String(detail.shipping_address || '')
|
||||
editForm.fee_type = Number(detail.fee_type || 3)
|
||||
editForm.amount = Number(detail.amount || 0)
|
||||
editForm.express_company = String(detail.express_company || 'auto') || 'auto'
|
||||
editForm.tracking_number = String(detail.tracking_number || '')
|
||||
editForm.remark_assistant = String(detail.remark_assistant || '')
|
||||
editForm.remark_extra = String(detail.remark_extra || '')
|
||||
} catch (error: any) {
|
||||
feedback.msgError(error?.message || '订单详情加载失败')
|
||||
editVisible.value = false
|
||||
} finally {
|
||||
editLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAction(row: Record<string, any>, action: MyPatientOrderAction) {
|
||||
actionRow.value = row
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
void openEdit(row)
|
||||
break
|
||||
case 'audit_prescription':
|
||||
case 'audit_payment':
|
||||
auditKind.value = action === 'audit_prescription' ? 'prescription' : 'payment'
|
||||
auditRemark.value = ''
|
||||
auditVisible.value = true
|
||||
break
|
||||
case 'revoke_rx_audit':
|
||||
void confirmRevokeAudit('prescription')
|
||||
break
|
||||
case 'revoke_pay_audit':
|
||||
void confirmRevokeAudit('payment')
|
||||
break
|
||||
case 'ddcode':
|
||||
trackForm.express_company = String(row.express_company || 'auto') || 'auto'
|
||||
trackForm.tracking_number = String(row.tracking_number || '')
|
||||
trackVisible.value = true
|
||||
break
|
||||
case 'ship':
|
||||
shipForm.ship_mode = normalizeShipMode(row.ship_mode)
|
||||
shipForm.express_company = String(row.express_company || 'auto') || 'auto'
|
||||
shipForm.tracking_number = String(row.tracking_number || '')
|
||||
shipVisible.value = true
|
||||
break
|
||||
case 'add_pay_order': {
|
||||
const diff = Math.max(0, Number(row.amount || 0) - Number(row.linked_pay_paid_total || 0))
|
||||
payForm.order_type = 3
|
||||
payForm.pay_amount = Number(diff.toFixed(2))
|
||||
payForm.pay_remark = ''
|
||||
payForm.completion_request = 0
|
||||
payForm.pay_create_type = 'fubei'
|
||||
payVisible.value = true
|
||||
break
|
||||
}
|
||||
case 'complete':
|
||||
completeStatus.value = 3
|
||||
completeVisible.value = true
|
||||
break
|
||||
case 'refund':
|
||||
refundForm.reason = ''
|
||||
refundForm.refund_amount = undefined
|
||||
refundVisible.value = true
|
||||
break
|
||||
case 'withdraw':
|
||||
void confirmWithdraw()
|
||||
break
|
||||
case 'upload_pharmacy':
|
||||
void confirmUploadPharmacy()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
async function changed(message: string) {
|
||||
feedback.msgSuccess(message)
|
||||
emit('changed')
|
||||
await detailDrawerRef.value?.refreshIfCurrent(actionRow.value.id)
|
||||
}
|
||||
|
||||
async function submitAudit(action: 'approve' | 'reject') {
|
||||
if (action === 'reject' && !auditRemark.value.trim()) {
|
||||
feedback.msgError('驳回时必须填写审核意见')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const params = { id: Number(actionRow.value.id), action, remark: auditRemark.value.trim() }
|
||||
if (auditKind.value === 'prescription') await myPatientOrderAuditPrescription(params)
|
||||
else await myPatientOrderAuditPayment(params)
|
||||
auditVisible.value = false
|
||||
await changed(action === 'approve' ? '审核通过成功' : '订单已驳回')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRevokeAudit(kind: 'prescription' | 'payment') {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确认撤回${kind === 'prescription' ? '处方' : '支付单'}审核并恢复为待审核吗?`,
|
||||
'撤回审核',
|
||||
{ type: 'warning', confirmButtonText: '确认撤回', cancelButtonText: '取消' }
|
||||
)
|
||||
if (kind === 'prescription') await myPatientOrderRevokeRxAudit({ id: Number(actionRow.value.id) })
|
||||
else await myPatientOrderRevokePayAudit({ id: Number(actionRow.value.id) })
|
||||
await changed('审核已撤回')
|
||||
} catch {
|
||||
// 取消确认或请求拦截器已处理
|
||||
}
|
||||
}
|
||||
|
||||
async function submitTrack() {
|
||||
if (!trackForm.tracking_number.trim()) {
|
||||
feedback.msgError('请填写快递单号')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderDdcode({
|
||||
id: Number(actionRow.value.id),
|
||||
express_company: trackForm.express_company || 'auto',
|
||||
tracking_number: trackForm.tracking_number.trim()
|
||||
})
|
||||
trackVisible.value = false
|
||||
await changed('快递单号已保存')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitShip() {
|
||||
if (!shipForm.tracking_number.trim()) {
|
||||
feedback.msgError('请填写快递单号')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderShip({
|
||||
id: Number(actionRow.value.id),
|
||||
ship_mode: shipForm.ship_mode,
|
||||
express_company: shipForm.express_company || 'auto',
|
||||
tracking_number: shipForm.tracking_number.trim()
|
||||
})
|
||||
shipVisible.value = false
|
||||
await changed('确认发货成功')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editForm.recipient_name.trim() || !editForm.recipient_phone.trim() || !editForm.shipping_address.trim()) {
|
||||
feedback.msgError('请完整填写收货人、收货手机和收货地址')
|
||||
return
|
||||
}
|
||||
if (Number(editForm.amount) < 0) {
|
||||
feedback.msgError('订单金额不能为负数')
|
||||
return
|
||||
}
|
||||
const source = editSource.value
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderEdit({
|
||||
id: Number(actionRow.value.id),
|
||||
recipient_name: editForm.recipient_name.trim(),
|
||||
recipient_phone: editForm.recipient_phone.trim(),
|
||||
shipping_province: source.shipping_province || '',
|
||||
shipping_city: source.shipping_city || '',
|
||||
shipping_district: source.shipping_district || '',
|
||||
shipping_address: editForm.shipping_address.trim(),
|
||||
is_follow_up: Number(source.is_follow_up || 0),
|
||||
medication_days: source.medication_days,
|
||||
dose_unit: source.dose_unit || '剂',
|
||||
dose_count: Number(source.dose_count || 1),
|
||||
prev_staff: source.prev_staff || '',
|
||||
service_channel: source.service_channel || '',
|
||||
service_package: source.service_package || '',
|
||||
tracking_number: editForm.tracking_number.trim(),
|
||||
express_company: editForm.express_company || 'auto',
|
||||
fee_type: Number(editForm.fee_type),
|
||||
amount: Number(editForm.amount),
|
||||
remark_extra: editForm.remark_extra.trim(),
|
||||
remark_assistant: editForm.remark_assistant.trim(),
|
||||
pay_order_ids: Array.isArray(source.pay_order_ids) ? source.pay_order_ids : []
|
||||
})
|
||||
editVisible.value = false
|
||||
await changed('订单保存成功')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitPayOrder() {
|
||||
if (!(Number(payForm.pay_amount) > 0)) {
|
||||
feedback.msgError('补齐金额必须大于 0')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderAddPayOrder({
|
||||
id: Number(actionRow.value.id),
|
||||
order_type: Number(payForm.order_type),
|
||||
pay_amount: Number(payForm.pay_amount),
|
||||
pay_remark: payForm.pay_remark.trim(),
|
||||
completion_request: Number(payForm.completion_request),
|
||||
pay_create_type: payForm.pay_create_type
|
||||
})
|
||||
payVisible.value = false
|
||||
await changed('支付单已新增,等待支付审核')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitComplete() {
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderComplete({ id: Number(actionRow.value.id), fulfillment_status: Number(completeStatus.value) })
|
||||
completeVisible.value = false
|
||||
await changed('订单状态已更新')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRefund() {
|
||||
if (!refundForm.reason.trim()) {
|
||||
feedback.msgError('请填写退款原因')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await myPatientOrderRefund({
|
||||
id: Number(actionRow.value.id),
|
||||
reason: refundForm.reason.trim(),
|
||||
refund_amount: refundForm.refund_amount === undefined ? undefined : Number(refundForm.refund_amount)
|
||||
})
|
||||
refundVisible.value = false
|
||||
await changed('退款成功')
|
||||
} catch {
|
||||
// 请求拦截器已提示
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmWithdraw() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认撤回该订单吗?撤回后当前订单将结束。', '撤回订单', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认撤回',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
await myPatientOrderWithdraw({ id: Number(actionRow.value.id) })
|
||||
await changed('订单已撤回')
|
||||
} catch {
|
||||
// 取消确认或请求拦截器已处理
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmUploadPharmacy() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确认将该订单上传到订单设定的药房吗?', '上传药房', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确认上传',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
await myPatientOrderUploadToPharmacy({ id: Number(actionRow.value.id) })
|
||||
await changed('药方上传成功')
|
||||
} catch {
|
||||
// 取消确认或请求拦截器已处理
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ openDetail, openAction })
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 14px;
|
||||
}
|
||||
|
||||
.form-tip {
|
||||
margin-left: 10px;
|
||||
color: #8a95a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
:global(.danger-menu-item) {
|
||||
color: var(--el-color-danger) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,720 +0,0 @@
|
||||
<template>
|
||||
<section class="embedded-panel">
|
||||
<div class="panel-toolbar">
|
||||
<div>
|
||||
<h2>订单管理</h2>
|
||||
<p>展示当前患者范围内的处方业务订单,订单创建人不参与数据归属判断</p>
|
||||
</div>
|
||||
<div class="toolbar-actions">
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ scopeLabel }}</span>
|
||||
<el-button :icon="Refresh" :loading="pager.loading" @click="refreshPanel">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-panel">
|
||||
<div class="filter-heading">
|
||||
<div>
|
||||
<h3>订单检索</h3>
|
||||
<p>审核与履约状态直接展示,点击后立即筛选</p>
|
||||
</div>
|
||||
<el-button link :disabled="!hasActiveFilters" @click="resetFilters">清空全部条件</el-button>
|
||||
</div>
|
||||
|
||||
<div class="status-filter-list">
|
||||
<div class="status-filter-row">
|
||||
<span class="filter-label">处方审核</span>
|
||||
<el-radio-group v-model="formData.prescription_audit_status" @change="handleSearch">
|
||||
<el-radio-button v-for="item in auditFilterOptions" :key="String(item.value)" :value="item.value">
|
||||
{{ item.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="status-filter-row">
|
||||
<span class="filter-label">支付单审核</span>
|
||||
<el-radio-group v-model="formData.payment_slip_audit_status" @change="handleSearch">
|
||||
<el-radio-button v-for="item in auditFilterOptions" :key="String(item.value)" :value="item.value">
|
||||
{{ item.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
<div class="status-filter-row status-filter-row--fulfillment">
|
||||
<span class="filter-label">履约状态</span>
|
||||
<el-radio-group v-model="formData.fulfillment_status" @change="handleSearch">
|
||||
<el-radio-button v-for="item in fulfillmentFilterOptions" :key="String(item.value)" :value="item.value">
|
||||
{{ item.label }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-filter-row">
|
||||
<label class="filter-field filter-field--keyword">
|
||||
<span>关键词</span>
|
||||
<el-input
|
||||
v-model="formData.keyword"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
placeholder="订单号 / 患者 / 手机号 / 处方ID / 诊单ID"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
</label>
|
||||
<label class="filter-field filter-field--date">
|
||||
<span>创建时间</span>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
clearable
|
||||
@change="handleDateChange"
|
||||
/>
|
||||
</label>
|
||||
<div class="filter-actions">
|
||||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-grid">
|
||||
<div class="metric-card">
|
||||
<span>订单数量</span>
|
||||
<strong>{{ summary.orders }}</strong>
|
||||
<small>笔</small>
|
||||
</div>
|
||||
<div class="metric-card">
|
||||
<span>有效订单金额</span>
|
||||
<strong>¥{{ money(summary.amount) }}</strong>
|
||||
<small>排除取消、拒收及退款</small>
|
||||
</div>
|
||||
<div class="metric-card metric-warning">
|
||||
<span>待审核</span>
|
||||
<strong>{{ summary.pending }}</strong>
|
||||
<small>任一审核待处理</small>
|
||||
</div>
|
||||
<div class="metric-card metric-success">
|
||||
<span>已完成 / 签收</span>
|
||||
<strong>{{ summary.completed }}</strong>
|
||||
<small>笔</small>
|
||||
</div>
|
||||
<button
|
||||
class="metric-card metric-card--interactive metric-rejected"
|
||||
:class="{ 'is-active': Number(formData.fulfillment_status) === 9 }"
|
||||
type="button"
|
||||
@click="toggleRejectedFilter"
|
||||
>
|
||||
<span>拒收订单</span>
|
||||
<strong>{{ summary.rejected }}</strong>
|
||||
<small>笔 · 点击查看明细</small>
|
||||
</button>
|
||||
<button
|
||||
class="metric-card metric-card--interactive metric-rejected-rate"
|
||||
:class="{ 'is-active': Number(formData.fulfillment_status) === 9 }"
|
||||
type="button"
|
||||
@click="toggleRejectedFilter"
|
||||
>
|
||||
<span>拒收率</span>
|
||||
<strong>{{ percent(summary.rejectionRate) }}</strong>
|
||||
<small>拒收订单 ÷ 同检索范围订单</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
class="embedded-table"
|
||||
:row-class-name="orderRowClassName"
|
||||
>
|
||||
<el-table-column label="订单" min-width="174" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="primary-cell">
|
||||
<strong>{{ row.order_no || '—' }}</strong>
|
||||
<span>#{{ row.id }} · {{ row.fee_type_text }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="患者" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<div class="primary-cell">
|
||||
<strong>{{ row.patient_name || row.recipient_name || '—' }}</strong>
|
||||
<span>{{ row.patient_phone_masked || row.recipient_phone_masked || '无手机号' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处方 / 诊单" min-width="118">
|
||||
<template #default="{ row }">
|
||||
<div class="id-stack">
|
||||
<span>处方 #{{ row.prescription_id || '—' }}</span>
|
||||
<span>诊单 #{{ row.diagnosis_id || '—' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="有效金额" width="118" align="right">
|
||||
<template #default="{ row }">
|
||||
<strong v-if="row.amount_included" class="amount">¥{{ money(row.effective_amount) }}</strong>
|
||||
<span v-else class="amount-excluded">{{ row.amount_exclusion_text || '不计入' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="处方审核" width="104" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="auditTagType(row.prescription_audit_status)" effect="light">
|
||||
{{ row.prescription_audit_text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="支付单审核" width="112" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="auditTagType(row.payment_slip_audit_status)" effect="light">
|
||||
{{ row.payment_slip_audit_text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="履约状态" width="112" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="fulfillmentTagType(row.fulfillment_status)" effect="light">
|
||||
{{ row.fulfillment_text }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联支付单" width="100" align="center">
|
||||
<template #default="{ row }">{{ Number(row.linked_pay_order_count) > 0 ? `${row.linked_pay_order_count} 笔` : '—' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assistant_name" label="归属助理" min-width="100" show-overflow-tooltip />
|
||||
<el-table-column prop="doctor_name" label="开方人" min-width="90" show-overflow-tooltip />
|
||||
<el-table-column prop="creator_name" label="创建人" min-width="90" show-overflow-tooltip />
|
||||
<el-table-column prop="create_time_text" label="创建时间" min-width="145" />
|
||||
<el-table-column label="操作" width="190" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="order-actions">
|
||||
<el-button type="primary" link @click="emit('openDiagnosis', row)">诊单</el-button>
|
||||
<el-button
|
||||
v-if="canViewMyPatientOrderDetail()"
|
||||
type="primary"
|
||||
link
|
||||
@click="orderActionHostRef?.openDetail(row)"
|
||||
>详情</el-button>
|
||||
<el-dropdown
|
||||
v-if="myPatientOrderActions(row).length"
|
||||
trigger="click"
|
||||
@command="(command) => openOrderAction(row, command as MyPatientOrderAction)"
|
||||
>
|
||||
<el-button type="primary" link>
|
||||
操作<el-icon class="el-icon--right"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="action in myPatientOrderActions(row)"
|
||||
:key="action.key"
|
||||
:command="action.key"
|
||||
:class="{ 'danger-menu-item': action.danger }"
|
||||
>
|
||||
{{ action.label }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前范围内暂无订单" /></template>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
|
||||
<order-action-host ref="orderActionHostRef" @changed="getLists" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ArrowDown, Lock, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { myPatientOrderLists } from '@/api/first_visit'
|
||||
import OrderActionHost from './OrderActionHost.vue'
|
||||
import {
|
||||
canViewMyPatientOrderDetail,
|
||||
myPatientOrderActions,
|
||||
type MyPatientOrderAction
|
||||
} from './order-actions'
|
||||
|
||||
type SelectValue = '' | number
|
||||
|
||||
const emit = defineEmits<{ openDiagnosis: [row: Record<string, any>] }>()
|
||||
const orderActionHostRef = ref<InstanceType<typeof OrderActionHost>>()
|
||||
const dateRange = ref<string[]>([])
|
||||
const formData = reactive({
|
||||
keyword: '',
|
||||
prescription_audit_status: '' as SelectValue,
|
||||
payment_slip_audit_status: '' as SelectValue,
|
||||
fulfillment_status: '' as SelectValue,
|
||||
start_date: '',
|
||||
end_date: ''
|
||||
})
|
||||
|
||||
const auditFilterOptions: Array<{ label: string; value: SelectValue }> = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待审核', value: 0 },
|
||||
{ label: '已通过', value: 1 },
|
||||
{ label: '已驳回', value: 2 }
|
||||
]
|
||||
const fulfillmentFilterOptions: Array<{ label: string; value: SelectValue }> = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待双审通过', value: 1 },
|
||||
{ label: '待发货', value: 2 },
|
||||
{ label: '已完成', value: 3 },
|
||||
{ label: '已取消', value: 4 },
|
||||
{ label: '已发货', value: 5 },
|
||||
{ label: '已签收', value: 6 },
|
||||
{ label: '进行中', value: 7 },
|
||||
{ label: '暂不制药', value: 8 },
|
||||
{ label: '拒收', value: 9 },
|
||||
{ label: '退款', value: 10 },
|
||||
{ label: '保留药方', value: 11 },
|
||||
{ label: '制药缓发', value: 12 }
|
||||
]
|
||||
|
||||
const { pager, getLists, resetPage } = usePaging({
|
||||
fetchFun: myPatientOrderLists as any,
|
||||
params: formData,
|
||||
size: 15,
|
||||
firstLoading: true
|
||||
})
|
||||
|
||||
const summary = computed(() => ({
|
||||
orders: Number(pager.extend?.summary?.orders || 0),
|
||||
amount: Number(pager.extend?.summary?.amount || 0),
|
||||
pending: Number(pager.extend?.summary?.pending || 0),
|
||||
completed: Number(pager.extend?.summary?.completed || 0),
|
||||
rejected: Number(pager.extend?.summary?.rejected || 0),
|
||||
rejectionRate: Number(pager.extend?.summary?.rejection_rate || 0)
|
||||
}))
|
||||
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
|
||||
const hasActiveFilters = computed(() => Boolean(
|
||||
formData.keyword.trim()
|
||||
|| formData.prescription_audit_status !== ''
|
||||
|| formData.payment_slip_audit_status !== ''
|
||||
|| formData.fulfillment_status !== ''
|
||||
|| formData.start_date
|
||||
|| formData.end_date
|
||||
))
|
||||
|
||||
function handleSearch() {
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function refreshPanel() {
|
||||
return getLists()
|
||||
}
|
||||
|
||||
function handleDateChange(value: string[] | null) {
|
||||
formData.start_date = value?.[0] || ''
|
||||
formData.end_date = value?.[1] || value?.[0] || ''
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
formData.keyword = ''
|
||||
formData.prescription_audit_status = ''
|
||||
formData.payment_slip_audit_status = ''
|
||||
formData.fulfillment_status = ''
|
||||
formData.start_date = ''
|
||||
formData.end_date = ''
|
||||
dateRange.value = []
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function toggleRejectedFilter() {
|
||||
formData.fulfillment_status = Number(formData.fulfillment_status) === 9 ? '' : 9
|
||||
resetPage()
|
||||
}
|
||||
|
||||
function money(value: number | string) {
|
||||
return Number(value || 0).toFixed(2)
|
||||
}
|
||||
|
||||
function percent(value: number | string) {
|
||||
const number = Number(value || 0)
|
||||
return `${Number.isInteger(number) ? number.toFixed(0) : number.toFixed(2)}%`
|
||||
}
|
||||
|
||||
function auditTagType(status: number): 'success' | 'warning' | 'danger' | 'info' {
|
||||
if (Number(status) === 1) return 'success'
|
||||
if (Number(status) === 2) return 'danger'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function fulfillmentTagType(status: number): 'success' | 'warning' | 'danger' | 'info' | 'primary' {
|
||||
const value = Number(status)
|
||||
if ([3, 6].includes(value)) return 'success'
|
||||
if ([4, 9, 10].includes(value)) return 'danger'
|
||||
if ([2, 5, 7].includes(value)) return 'primary'
|
||||
return 'warning'
|
||||
}
|
||||
|
||||
function orderRowClassName({ row }: { row: any }) {
|
||||
if ([2].includes(Number(row.prescription_audit_status)) || [2].includes(Number(row.payment_slip_audit_status))) {
|
||||
return 'order-row-risk'
|
||||
}
|
||||
if ([3, 6].includes(Number(row.fulfillment_status))) return 'order-row-done'
|
||||
return ''
|
||||
}
|
||||
|
||||
function openOrderAction(row: Record<string, any>, action: MyPatientOrderAction) {
|
||||
orderActionHostRef.value?.openAction(row, action)
|
||||
}
|
||||
|
||||
defineExpose({ refresh: refreshPanel, loading: computed(() => pager.loading) })
|
||||
|
||||
onMounted(getLists)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.embedded-panel {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.panel-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: #8a95a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-actions,
|
||||
.scope-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.scope-chip {
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
padding: 16px;
|
||||
border: 1px solid #e3e8ef;
|
||||
border-radius: 10px;
|
||||
background: #fbfcfd;
|
||||
}
|
||||
|
||||
.filter-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e8edf2;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #273244;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: #8a95a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.status-filter-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 13px 0;
|
||||
border-bottom: 1px solid #e8edf2;
|
||||
}
|
||||
|
||||
.status-filter-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
:deep(.el-radio-group) {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
:deep(.el-radio-button__inner) {
|
||||
min-width: 72px;
|
||||
padding: 7px 12px;
|
||||
color: #5f6b7d;
|
||||
border: 1px solid #dfe5ec;
|
||||
border-radius: 6px;
|
||||
box-shadow: none;
|
||||
background: #fff;
|
||||
transition: color 0.18s ease, border-color 0.18s ease, background 0.18s ease;
|
||||
}
|
||||
|
||||
:deep(.el-radio-button:first-child .el-radio-button__inner),
|
||||
:deep(.el-radio-button:last-child .el-radio-button__inner) {
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
:deep(.el-radio-button.is-active .el-radio-button__inner) {
|
||||
color: #fff;
|
||||
border-color: #0f9185;
|
||||
background: #0f9185;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
flex: 0 0 76px;
|
||||
padding-top: 7px;
|
||||
color: #475467;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-filter-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 1fr) minmax(300px, 360px) auto;
|
||||
align-items: end;
|
||||
gap: 12px;
|
||||
padding-top: 13px;
|
||||
}
|
||||
|
||||
.filter-field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
|
||||
> span {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.el-date-editor) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 14px 0;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 82px;
|
||||
padding: 14px 16px;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
border: 1px solid #e3e8ef;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
|
||||
span,
|
||||
small {
|
||||
color: #8a95a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
strong {
|
||||
display: block;
|
||||
margin: 7px 0 3px;
|
||||
color: #172033;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-card--interactive {
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
border-color: #e5a9a3;
|
||||
box-shadow: 0 7px 18px rgba(132, 45, 40, 0.08);
|
||||
transform: translateY(-1px);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-color: #d9685f;
|
||||
box-shadow: 0 0 0 2px rgba(217, 104, 95, 0.11);
|
||||
}
|
||||
}
|
||||
|
||||
.metric-rejected,
|
||||
.metric-rejected-rate {
|
||||
border-color: #f0cbc7;
|
||||
background: #fff9f8;
|
||||
|
||||
strong {
|
||||
color: #c64f47;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-warning {
|
||||
border-color: #f3d8aa;
|
||||
background: #fffcf5;
|
||||
}
|
||||
|
||||
.metric-success {
|
||||
border-color: #b9e2dc;
|
||||
background: #f7fcfb;
|
||||
}
|
||||
|
||||
.embedded-table {
|
||||
width: 100%;
|
||||
border: 1px solid #e7ebf0;
|
||||
border-radius: 9px;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(th.el-table__cell) {
|
||||
height: 44px;
|
||||
color: #5f6b7d;
|
||||
background: #f7f9fb;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.order-row-risk > td.el-table__cell) {
|
||||
background: #fff8f7;
|
||||
}
|
||||
|
||||
:deep(.order-row-done > td.el-table__cell) {
|
||||
background: #f8fcfb;
|
||||
}
|
||||
}
|
||||
|
||||
.primary-cell,
|
||||
.id-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
|
||||
strong {
|
||||
color: #202939;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #8b96a8;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: #d04f3f;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.amount-excluded {
|
||||
color: #9aa4b2;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.order-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
|
||||
:deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.danger-menu-item) {
|
||||
color: var(--el-color-danger) !important;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1080px) {
|
||||
.metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.search-filter-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.panel-toolbar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.metric-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-filter-row,
|
||||
.search-filter-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
:deep(.el-button) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,834 +0,0 @@
|
||||
<template>
|
||||
<div class="progress-board">
|
||||
<section class="board-section overview-section">
|
||||
<div class="section-heading">
|
||||
<div class="heading-copy">
|
||||
<h2>今日面诊概览</h2>
|
||||
<span class="heading-badge">{{ isOwnershipMode ? '按本人归属' : '与排班合并' }}</span>
|
||||
</div>
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ scopeLabel }}</span>
|
||||
</div>
|
||||
|
||||
<div class="overview-grid">
|
||||
<article class="overview-card">
|
||||
<span>{{ isOwnershipMode ? '今日本人面诊' : '今日面诊总号源' }}</span>
|
||||
<strong>{{ todayOverview.totalVisits }}</strong>
|
||||
<small>{{ todayOverview.doctorCount }} 位{{ isOwnershipMode ? '接诊' : '排班' }}医生</small>
|
||||
</article>
|
||||
<article class="overview-card">
|
||||
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }}</span>
|
||||
<strong>{{ todayOverview.booked }}</strong>
|
||||
<small>{{ isOwnershipMode ? '本人归属患者的有效挂号' : '按有效挂号占用号源' }}</small>
|
||||
</article>
|
||||
<article :class="['overview-card', isOwnershipMode ? 'overview-card-completed' : 'overview-card-empty']">
|
||||
<span>{{ isOwnershipMode ? '已完成' : '空号' }}</span>
|
||||
<strong>{{ isOwnershipMode ? todayOverview.completed : todayOverview.emptySlots }}</strong>
|
||||
<small>{{ isOwnershipMode ? `已过号 ${todayOverview.missed} 人` : '未被有效挂号占用' }}</small>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="board-section schedule-section">
|
||||
<div class="section-heading">
|
||||
<div class="heading-copy">
|
||||
<h2>{{ isOwnershipMode ? '近一周面诊安排' : '近一周排班' }}</h2>
|
||||
<span class="heading-badge">{{ isOwnershipMode ? '本人患者' : '今日起 7 天' }}</span>
|
||||
</div>
|
||||
<span class="schedule-range">{{ scheduleRange }}</span>
|
||||
</div>
|
||||
|
||||
<div class="schedule-grid">
|
||||
<button
|
||||
v-for="day in weekSchedule"
|
||||
:key="day.date"
|
||||
type="button"
|
||||
class="schedule-card"
|
||||
:class="{
|
||||
'is-today': day.date === today,
|
||||
'is-selected': day.date === selectedScheduleDate
|
||||
}"
|
||||
:aria-pressed="day.date === selectedScheduleDate"
|
||||
@click="selectScheduleDay(day.date)"
|
||||
>
|
||||
<span class="schedule-date">{{ day.date_text }} {{ day.weekday }}</span>
|
||||
<strong>{{ isOwnershipMode ? day.total_appointments : day.total_slots }}</strong>
|
||||
<span class="schedule-card-stats">
|
||||
<span>{{ isOwnershipMode ? '待诊' : '已约' }} {{ isOwnershipMode ? day.waiting_appointments : day.booked_slots }}</span>
|
||||
<i>/</i>
|
||||
<span :class="{ 'is-completed': isOwnershipMode }">{{ isOwnershipMode ? '完成' : '空' }} {{ isOwnershipMode ? day.completed_appointments : day.empty_slots }}</span>
|
||||
</span>
|
||||
<span class="schedule-card-action">
|
||||
<span>{{ day.doctor_count }} 位医生</span>
|
||||
<span>{{ day.date === selectedScheduleDate ? '正在查看' : '查看明细' }} ›</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="schedule-drilldown">
|
||||
<div class="drilldown-heading">
|
||||
<div>
|
||||
<h3>{{ selectedScheduleLabel }}{{ isOwnershipMode ? '面诊安排' : '排班明细' }}</h3>
|
||||
<p>共 {{ selectedScheduleDay.doctor_count }} 位{{ isOwnershipMode ? '接诊' : '排班' }}医生,点击上方日期可切换</p>
|
||||
</div>
|
||||
<div :class="['drilldown-summary', { 'is-ownership': isOwnershipMode }]">
|
||||
<span>{{ isOwnershipMode ? '面诊总数' : '总号源' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.total_appointments : selectedScheduleDay.total_slots }}</strong></span>
|
||||
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.waiting_appointments : selectedScheduleDay.booked_slots }}</strong></span>
|
||||
<span>{{ isOwnershipMode ? '已完成' : '空号' }} <strong>{{ isOwnershipMode ? selectedScheduleDay.completed_appointments : selectedScheduleDay.empty_slots }}</strong></span>
|
||||
<span v-if="isOwnershipMode">已过号 <strong>{{ selectedScheduleDay.missed_appointments }}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedScheduleDoctors.length" class="doctor-schedule-list">
|
||||
<article
|
||||
v-for="(doctor, index) in selectedScheduleDoctors"
|
||||
:key="doctor.doctor_id"
|
||||
class="doctor-schedule-row"
|
||||
>
|
||||
<div class="doctor-identity">
|
||||
<span class="doctor-index">{{ index + 1 }}</span>
|
||||
<div>
|
||||
<strong>{{ doctor.doctor_name }}</strong>
|
||||
<small>{{ isOwnershipMode ? '本人患者接诊医生' : '医生排班' }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="doctor-windows">
|
||||
<span>{{ isOwnershipMode ? '预约时刻' : '排班时段' }}</span>
|
||||
<strong>{{ doctorWindows(doctor) }}</strong>
|
||||
</div>
|
||||
<div class="doctor-metric">
|
||||
<span>{{ isOwnershipMode ? '面诊总数' : '总号源' }}</span>
|
||||
<strong>{{ isOwnershipMode ? doctor.total_appointments : doctor.total_slots }}</strong>
|
||||
</div>
|
||||
<div class="doctor-metric doctor-metric-booked">
|
||||
<span>{{ isOwnershipMode ? '待面诊' : '已预约' }}</span>
|
||||
<strong>{{ isOwnershipMode ? doctor.waiting_appointments : doctor.booked_slots }}</strong>
|
||||
</div>
|
||||
<div :class="['doctor-metric', isOwnershipMode ? 'doctor-metric-completed' : 'doctor-metric-empty']">
|
||||
<span>{{ isOwnershipMode ? '完成 / 过号' : '空号' }}</span>
|
||||
<strong>{{ isOwnershipMode ? `${doctor.completed_appointments} / ${doctor.missed_appointments}` : doctor.empty_slots }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty
|
||||
v-else
|
||||
:image-size="56"
|
||||
:description="isOwnershipMode ? '该日期暂无本人归属患者的有效挂号' : '该日期暂无当前权限范围内的医生排班'"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="board-section queue-section">
|
||||
<div class="section-heading queue-heading">
|
||||
<div class="heading-copy">
|
||||
<h2>候诊列表</h2>
|
||||
<span class="heading-badge">按医生排队</span>
|
||||
<span class="queue-count">共 {{ pager.count }} 人</span>
|
||||
</div>
|
||||
<span class="refresh-time">每 15 秒自动刷新</span>
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="pager.loading"
|
||||
:data="pager.lists"
|
||||
class="queue-table"
|
||||
:row-class-name="tableRowClassName"
|
||||
@row-dblclick="openDiagnosis"
|
||||
>
|
||||
<el-table-column label="排队" width="92">
|
||||
<template #default="{ row }">
|
||||
<span class="queue-number">{{ row.queue_no || '—' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="患者" min-width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="patient-cell">
|
||||
<strong>{{ row.patient_name || '未命名患者' }}</strong>
|
||||
<span>{{ row.phone_masked || '无手机号' }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="doctor_name" label="医生" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column label="预约时间" min-width="135">
|
||||
<template #default="{ row }">
|
||||
<span class="appointment-clock">{{ appointmentClock(row) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="前方等待" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<span :class="['waiting-text', `is-${row.queue_status || 'waiting'}`]">
|
||||
{{ waitingText(row) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" round effect="light" :type="queueTagType(row.queue_status)">
|
||||
{{ row.queue_status_text || '等待中' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty>
|
||||
<el-empty description="当前权限范围内今日暂无候诊患者" />
|
||||
</template>
|
||||
</el-table>
|
||||
|
||||
<div v-if="pager.count > pager.size" class="pagination-wrap">
|
||||
<pagination v-model="pager" @change="getLists" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { Lock } from '@element-plus/icons-vue'
|
||||
import { usePaging } from '@/hooks/usePaging'
|
||||
import { myPatientProgressLists } from '@/api/first_visit'
|
||||
|
||||
const emit = defineEmits<{ openDiagnosis: [row: Record<string, any>] }>()
|
||||
const today = dayjs().format('YYYY-MM-DD')
|
||||
const selectedScheduleDate = ref(today)
|
||||
const formData = reactive({
|
||||
keyword: '',
|
||||
status: 1 as const,
|
||||
start_date: today,
|
||||
end_date: today
|
||||
})
|
||||
|
||||
const { pager, getLists } = usePaging({
|
||||
fetchFun: myPatientProgressLists as any,
|
||||
params: formData,
|
||||
size: 15,
|
||||
firstLoading: true
|
||||
})
|
||||
|
||||
const todayOverview = computed(() => ({
|
||||
totalVisits: Number(pager.extend?.today_overview?.total_visits || 0),
|
||||
booked: Number(pager.extend?.today_overview?.booked || 0),
|
||||
completed: Number(pager.extend?.today_overview?.completed || 0),
|
||||
missed: Number(pager.extend?.today_overview?.missed || 0),
|
||||
emptySlots: Number(pager.extend?.today_overview?.empty_slots || 0),
|
||||
doctorCount: Number(pager.extend?.today_overview?.doctor_count || 0)
|
||||
}))
|
||||
const isOwnershipMode = computed(() => pager.extend?.schedule_mode === 'ownership')
|
||||
|
||||
const weekSchedule = computed(() => {
|
||||
const rows = Array.isArray(pager.extend?.week_schedule) ? pager.extend.week_schedule : []
|
||||
if (rows.length) return rows
|
||||
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const date = dayjs().add(index, 'day')
|
||||
return {
|
||||
date: date.format('YYYY-MM-DD'),
|
||||
date_text: date.format('MM-DD'),
|
||||
weekday: `周${'日一二三四五六'[date.day()]}`,
|
||||
total_slots: 0,
|
||||
booked_slots: 0,
|
||||
empty_slots: 0,
|
||||
doctor_count: 0,
|
||||
doctors: [],
|
||||
total_appointments: 0,
|
||||
waiting_appointments: 0,
|
||||
completed_appointments: 0,
|
||||
missed_appointments: 0
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const selectedScheduleDay = computed(() => {
|
||||
return weekSchedule.value.find((day: any) => day.date === selectedScheduleDate.value) || weekSchedule.value[0] || {
|
||||
date: today,
|
||||
date_text: dayjs().format('MM-DD'),
|
||||
weekday: `周${'日一二三四五六'[dayjs().day()]}`,
|
||||
total_slots: 0,
|
||||
booked_slots: 0,
|
||||
empty_slots: 0,
|
||||
doctor_count: 0,
|
||||
doctors: [],
|
||||
total_appointments: 0,
|
||||
waiting_appointments: 0,
|
||||
completed_appointments: 0,
|
||||
missed_appointments: 0
|
||||
}
|
||||
})
|
||||
const selectedScheduleDoctors = computed(() => {
|
||||
return Array.isArray(selectedScheduleDay.value?.doctors) ? selectedScheduleDay.value.doctors : []
|
||||
})
|
||||
const selectedScheduleLabel = computed(() => {
|
||||
const day = selectedScheduleDay.value
|
||||
return `${day.date_text || ''} ${day.weekday || ''} `
|
||||
})
|
||||
|
||||
const scopeLabel = computed(() => pager.extend?.scope?.label || '按权限加载')
|
||||
const scheduleRange = computed(() => {
|
||||
const rows = weekSchedule.value
|
||||
if (!rows.length) return ''
|
||||
return `${rows[0].date_text} 至 ${rows[rows.length - 1].date_text}`
|
||||
})
|
||||
|
||||
let refreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function refreshPanel(options?: { silent?: boolean }) {
|
||||
return getLists(options)
|
||||
}
|
||||
|
||||
function selectScheduleDay(date: string) {
|
||||
selectedScheduleDate.value = date
|
||||
}
|
||||
|
||||
function doctorWindows(doctor: Record<string, any>) {
|
||||
const source = isOwnershipMode.value ? doctor.appointment_times : doctor.schedule_windows
|
||||
const windows = Array.isArray(source) ? source.filter(Boolean) : []
|
||||
if (windows.length) return windows.join('、')
|
||||
return isOwnershipMode.value ? '暂无预约时刻' : '未设置具体时段'
|
||||
}
|
||||
|
||||
function appointmentClock(row: Record<string, any>) {
|
||||
const time = String(row.appointment_time || '').slice(0, 5)
|
||||
return time || '—'
|
||||
}
|
||||
|
||||
function waitingText(row: Record<string, any>) {
|
||||
if (row.queue_status === 'consulting') return '0(进行中)'
|
||||
if (row.queue_status === 'next') return '0(待接诊)'
|
||||
const ahead = Math.max(0, Number(row.ahead_count || 0))
|
||||
if (ahead === 0) return '0 位'
|
||||
return `${ahead} 位 · 约 ${Number(row.estimated_wait_minutes || ahead * 15)} 分钟`
|
||||
}
|
||||
|
||||
function queueTagType(status: string): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
|
||||
if (status === 'consulting') return 'success'
|
||||
if (status === 'next') return 'warning'
|
||||
if (status === 'completed') return 'success'
|
||||
if (status === 'missed') return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
function tableRowClassName({ row }: { row: Record<string, any> }) {
|
||||
return Number(row.is_self_patient) === 1 ? 'queue-row-self' : ''
|
||||
}
|
||||
|
||||
function openDiagnosis(row: Record<string, any>) {
|
||||
emit('openDiagnosis', row)
|
||||
}
|
||||
|
||||
defineExpose({ refresh: refreshPanel, loading: computed(() => pager.loading) })
|
||||
|
||||
onMounted(() => {
|
||||
getLists()
|
||||
refreshTimer = setInterval(() => getLists({ silent: true }), 15_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.progress-board {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.board-section {
|
||||
padding: 16px;
|
||||
border: 1px solid #e1e7ee;
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.heading-copy,
|
||||
.scope-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heading-copy {
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #172033;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.heading-badge {
|
||||
padding: 3px 7px;
|
||||
border-radius: 5px;
|
||||
color: #758195;
|
||||
background: #eef2f6;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.scope-chip {
|
||||
gap: 5px;
|
||||
color: #0f766e;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.overview-card {
|
||||
min-height: 78px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #dfe5ed;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
|
||||
> span,
|
||||
> small {
|
||||
display: block;
|
||||
color: #778398;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
> strong {
|
||||
display: block;
|
||||
margin: 7px 0 5px;
|
||||
color: #111a2c;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
> small {
|
||||
color: #a0a9b7;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.overview-card-empty > strong {
|
||||
color: #ee4d55;
|
||||
}
|
||||
|
||||
.overview-card-completed > strong {
|
||||
color: #07886d;
|
||||
}
|
||||
|
||||
.schedule-range,
|
||||
.refresh-time,
|
||||
.queue-count {
|
||||
color: #929dac;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.schedule-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.schedule-card {
|
||||
min-width: 0;
|
||||
padding: 11px 8px;
|
||||
text-align: center;
|
||||
border: 1px solid #dfe5ed;
|
||||
border-radius: 9px;
|
||||
background: #fbfcfe;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, background 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
border-color: #7ac9c2;
|
||||
background: #f7fcfb;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 3px solid rgba(15, 145, 133, 0.18);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&.is-today {
|
||||
border-color: #8fd3cd;
|
||||
background: #f4fbfa;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
border-color: #0f9185;
|
||||
background: #effaf8;
|
||||
box-shadow: 0 8px 20px rgba(15, 118, 110, 0.1), inset 0 -3px 0 #0f9185;
|
||||
}
|
||||
|
||||
> strong {
|
||||
display: block;
|
||||
margin: 7px 0 5px;
|
||||
color: #131c2d;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-date {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: #70809a;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.schedule-card-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
color: #0c9967;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
|
||||
i {
|
||||
color: #a5afbd;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
span:last-child {
|
||||
color: #d8862b;
|
||||
}
|
||||
|
||||
span.is-completed {
|
||||
color: #07886d;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-card-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
margin: 10px 2px 0;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid #e7ecef;
|
||||
color: #7b8799;
|
||||
font-size: 10px;
|
||||
|
||||
span:last-child {
|
||||
color: #0f8077;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.schedule-drilldown {
|
||||
margin-top: 14px;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
background: #f7f9fb;
|
||||
}
|
||||
|
||||
.drilldown-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 11px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 4px 0 0;
|
||||
color: #8a95a6;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.drilldown-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
color: #7a8698;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
|
||||
strong {
|
||||
margin-left: 3px;
|
||||
color: #243044;
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
span:last-child strong {
|
||||
color: #d17820;
|
||||
}
|
||||
}
|
||||
|
||||
.doctor-schedule-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.doctor-schedule-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(150px, 1fr) minmax(210px, 1.8fr) repeat(3, minmax(64px, 0.55fr));
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
min-height: 58px;
|
||||
padding: 8px 13px;
|
||||
border: 1px solid #e4e9ef;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.doctor-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
|
||||
> div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
strong,
|
||||
small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
strong {
|
||||
overflow: hidden;
|
||||
color: #202b3d;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
small {
|
||||
margin-top: 2px;
|
||||
color: #9aa4b2;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.doctor-index {
|
||||
display: grid;
|
||||
flex: 0 0 28px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
place-items: center;
|
||||
border-radius: 7px;
|
||||
color: #0f766e;
|
||||
background: #e7f6f4;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.doctor-windows,
|
||||
.doctor-metric {
|
||||
min-width: 0;
|
||||
|
||||
span,
|
||||
strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #98a2b3;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
strong {
|
||||
margin-top: 3px;
|
||||
color: #344054;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
.doctor-windows strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doctor-metric {
|
||||
text-align: right;
|
||||
|
||||
strong {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.doctor-metric-booked strong {
|
||||
color: #07886d;
|
||||
}
|
||||
|
||||
.doctor-metric-empty strong {
|
||||
color: #d17820;
|
||||
}
|
||||
|
||||
.doctor-metric-completed strong {
|
||||
color: #07886d;
|
||||
}
|
||||
|
||||
.queue-heading {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.queue-table {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(th.el-table__cell) {
|
||||
height: 40px;
|
||||
color: #667085;
|
||||
background: #f7f9fb;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(td.el-table__cell) {
|
||||
height: 46px;
|
||||
padding: 6px 0;
|
||||
color: #253044;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:deep(.queue-row-self > td.el-table__cell) {
|
||||
background: #fff8e8 !important;
|
||||
}
|
||||
|
||||
:deep(.queue-row-self > td.el-table__cell:first-child) {
|
||||
border-left: 3px solid #f08332;
|
||||
}
|
||||
}
|
||||
|
||||
.queue-number {
|
||||
display: inline-grid;
|
||||
min-width: 26px;
|
||||
height: 26px;
|
||||
padding: 0 7px;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
color: #0f766e;
|
||||
background: #e7f6f4;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.patient-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
|
||||
strong {
|
||||
color: #1f2937;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
span {
|
||||
color: #98a2b3;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
.appointment-clock {
|
||||
color: #263246;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.waiting-text {
|
||||
color: #667085;
|
||||
font-size: 12px;
|
||||
|
||||
&.is-consulting {
|
||||
color: #07886d;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.is-next {
|
||||
color: #d17820;
|
||||
}
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.schedule-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.doctor-schedule-row {
|
||||
grid-template-columns: minmax(140px, 1fr) minmax(180px, 1.5fr) repeat(3, minmax(56px, 0.5fr));
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.board-section {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.overview-grid,
|
||||
.schedule-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.heading-copy {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.drilldown-heading,
|
||||
.drilldown-summary {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.drilldown-summary {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.doctor-schedule-row {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
.doctor-identity,
|
||||
.doctor-windows {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.doctor-metric {
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,125 +0,0 @@
|
||||
import { hasPermission } from '@/utils/perm'
|
||||
import { isRemoteSnapshotLocked } from '@/views/consumer/prescription/components/prescription-order-utils'
|
||||
|
||||
export type MyPatientOrderAction =
|
||||
| 'edit'
|
||||
| 'audit_prescription'
|
||||
| 'revoke_rx_audit'
|
||||
| 'audit_payment'
|
||||
| 'revoke_pay_audit'
|
||||
| 'ddcode'
|
||||
| 'ship'
|
||||
| 'add_pay_order'
|
||||
| 'complete'
|
||||
| 'refund'
|
||||
| 'withdraw'
|
||||
| 'upload_pharmacy'
|
||||
|
||||
export interface MyPatientOrderActionItem {
|
||||
key: MyPatientOrderAction
|
||||
label: string
|
||||
danger?: boolean
|
||||
}
|
||||
|
||||
function permitted(permission: string) {
|
||||
return hasPermission([permission])
|
||||
}
|
||||
|
||||
function remoteLocked(row: Record<string, any>) {
|
||||
return isRemoteSnapshotLocked(row as Record<string, unknown>)
|
||||
}
|
||||
|
||||
export function canViewMyPatientOrderDetail() {
|
||||
return permitted('tcm.prescriptionOrder/detail')
|
||||
}
|
||||
|
||||
export function myPatientOrderActions(row: Record<string, any>): MyPatientOrderActionItem[] {
|
||||
const actions: MyPatientOrderActionItem[] = []
|
||||
const fulfillment = Number(row.fulfillment_status)
|
||||
const prescriptionAudit = Number(row.prescription_audit_status)
|
||||
const paymentAudit = Number(row.payment_slip_audit_status)
|
||||
const locked = remoteLocked(row)
|
||||
|
||||
if (
|
||||
canViewMyPatientOrderDetail()
|
||||
&& permitted('tcm.prescriptionOrder/edit')
|
||||
&& fulfillment === 1
|
||||
&& !locked
|
||||
) {
|
||||
actions.push({ key: 'edit', label: '编辑订单' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/auditPrescription')
|
||||
&& prescriptionAudit === 0
|
||||
&& ![3, 4, 6].includes(fulfillment)
|
||||
) {
|
||||
actions.push({ key: 'audit_prescription', label: '处方审核' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/auditPrescription')
|
||||
&& [1, 2].includes(prescriptionAudit)
|
||||
&& paymentAudit === 0
|
||||
&& ![3, 4, 6].includes(fulfillment)
|
||||
&& !locked
|
||||
) {
|
||||
actions.push({ key: 'revoke_rx_audit', label: '撤回处方审核' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/auditPayment')
|
||||
&& prescriptionAudit === 1
|
||||
&& paymentAudit === 0
|
||||
&& ![3, 4].includes(fulfillment)
|
||||
) {
|
||||
actions.push({ key: 'audit_payment', label: '支付单审核' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/auditPayment')
|
||||
&& prescriptionAudit === 1
|
||||
&& [1, 2].includes(paymentAudit)
|
||||
&& ![3, 4, 6].includes(fulfillment)
|
||||
) {
|
||||
actions.push({ key: 'revoke_pay_audit', label: '撤回支付审核' })
|
||||
}
|
||||
if (permitted('tcm.prescriptionOrder/ddcode')) {
|
||||
actions.push({ key: 'ddcode', label: '修改快递单号' })
|
||||
}
|
||||
if (permitted('tcm.prescriptionOrder/ship') && fulfillment === 2) {
|
||||
actions.push({ key: 'ship', label: '确认发货' })
|
||||
}
|
||||
const amount = Math.round((Number(row.amount) || 0) * 100) / 100
|
||||
const paidTotal = Math.round((Number(row.linked_pay_paid_total) || 0) * 100) / 100
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/addPayOrder')
|
||||
&& [5, 6].includes(fulfillment)
|
||||
&& paidTotal < amount
|
||||
) {
|
||||
actions.push({ key: 'add_pay_order', label: '补齐支付单' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/complete')
|
||||
&& [5, 6].includes(fulfillment)
|
||||
&& paymentAudit === 1
|
||||
) {
|
||||
actions.push({ key: 'complete', label: '完成订单' })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/refund')
|
||||
&& [3, 5, 6, 9].includes(fulfillment)
|
||||
&& paymentAudit === 1
|
||||
) {
|
||||
actions.push({ key: 'refund', label: '退款', danger: true })
|
||||
}
|
||||
if (permitted('tcm.prescriptionOrder/withdraw') && fulfillment === 1 && !locked) {
|
||||
actions.push({ key: 'withdraw', label: '撤回订单', danger: true })
|
||||
}
|
||||
if (
|
||||
permitted('tcm.prescriptionOrder/uploadToPharmacy')
|
||||
&& row.can_upload_pharmacy !== false
|
||||
&& prescriptionAudit === 1
|
||||
&& ![3, 4, 8, 10, 11, 12].includes(fulfillment)
|
||||
) {
|
||||
actions.push({ key: 'upload_pharmacy', label: '上传药房' })
|
||||
}
|
||||
|
||||
return actions
|
||||
}
|
||||
@@ -1,646 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="registration-stats"
|
||||
v-loading="loading"
|
||||
element-loading-text="正在汇总权限范围内的挂号与预约数据"
|
||||
>
|
||||
<header class="page-heading">
|
||||
<div class="heading-copy">
|
||||
<span class="heading-mark"><el-icon><Histogram /></el-icon></span>
|
||||
<div>
|
||||
<h1>挂号统计</h1>
|
||||
<p>挂号、预约、诊单与目标数据按当前角色和部门权限实时汇总</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="heading-actions">
|
||||
<span class="scope-chip"><el-icon><Lock /></el-icon>{{ dashboard.meta.scope_label || '数据范围' }}</span>
|
||||
<span v-if="dashboard.meta.generated_at" class="update-time">更新于 {{ dashboard.meta.generated_at }}</span>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="loadDashboard">刷新</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="filter-strip">
|
||||
<div class="filter-item">
|
||||
<span>部门</span>
|
||||
<el-tree-select
|
||||
v-model="query.dept_id"
|
||||
:data="dashboard.filters.departments"
|
||||
:props="deptTreeProps"
|
||||
node-key="id"
|
||||
clearable
|
||||
filterable
|
||||
check-strictly
|
||||
default-expand-all
|
||||
placeholder="全部可见部门"
|
||||
class="dept-select"
|
||||
@change="handleDepartmentChange"
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-item filter-item--time">
|
||||
<span>时间</span>
|
||||
<el-segmented v-model="query.time_type" :options="timeOptions" @change="loadDashboard" />
|
||||
</div>
|
||||
<div class="filter-item">
|
||||
<span>员工</span>
|
||||
<el-select
|
||||
v-model="query.assistant_id"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="全部可见员工"
|
||||
class="employee-select"
|
||||
@change="loadDashboard"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in dashboard.filters.assistants"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="Number(item.id)"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="filter-summary">
|
||||
<strong>{{ dashboard.meta.start_date || '—' }}</strong>
|
||||
<span>至 {{ dashboard.meta.end_date || '—' }} · {{ dashboard.meta.member_count || 0 }} 位员工</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="metric-grid" aria-label="挂号统计核心指标">
|
||||
<article class="metric-card metric-card--teal">
|
||||
<div class="metric-icon"><el-icon><Wallet /></el-icon></div>
|
||||
<div>
|
||||
<span>{{ dashboard.meta.time_label || '今日' }}总挂号</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.registration_count) }}</strong>
|
||||
<small :class="compareClass(dashboard.summary.registration_compare_rate)">
|
||||
{{ compareText(dashboard.summary.registration_compare_rate) }}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--blue">
|
||||
<div class="metric-icon"><el-icon><Calendar /></el-icon></div>
|
||||
<div>
|
||||
<span>{{ dashboard.meta.time_label || '今日' }}总预约</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.appointment_count) }}</strong>
|
||||
<small :class="compareClass(dashboard.summary.appointment_compare_rate)">
|
||||
{{ compareText(dashboard.summary.appointment_compare_rate) }}
|
||||
</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--violet">
|
||||
<div class="metric-icon"><el-icon><DocumentChecked /></el-icon></div>
|
||||
<div>
|
||||
<span>{{ dashboard.meta.time_label || '今日' }}总诊单</span>
|
||||
<strong>{{ formatNumber(dashboard.summary.order_count) }}</strong>
|
||||
<small>排除取消、拒收和退款订单</small>
|
||||
</div>
|
||||
</article>
|
||||
<article class="metric-card metric-card--amber">
|
||||
<div class="metric-icon"><el-icon><Wallet /></el-icon></div>
|
||||
<div>
|
||||
<span>{{ dashboard.meta.time_label || '今日' }}总业绩</span>
|
||||
<strong>{{ formatMoney(dashboard.summary.order_amount) }}</strong>
|
||||
<small>按业务订单创建人归属</small>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel employee-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>本组员工挂号与预约统计</h2>
|
||||
<p>挂号按已支付且实收低于 10 元的支付订单统计;预约按预约记录统计</p>
|
||||
</div>
|
||||
<span class="panel-badge">{{ dashboard.meta.time_label || '当前范围' }}</span>
|
||||
</div>
|
||||
<el-table
|
||||
:data="dashboard.employee_rows"
|
||||
row-key="id"
|
||||
:tree-props="{ children: 'children' }"
|
||||
default-expand-all
|
||||
class="stats-table"
|
||||
>
|
||||
<el-table-column prop="name" label="部门 / 员工" min-width="250" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="name-cell" :class="`is-${row.row_type}`">
|
||||
<span v-if="row.row_type === 'department'" class="dept-dot" />
|
||||
<el-avatar v-else :size="26">{{ avatarText(row.name) }}</el-avatar>
|
||||
<strong>{{ row.name }}</strong>
|
||||
<em v-if="row.row_type === 'department'">{{ row.member_count }} 人</em>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="registration_count" :label="`${dashboard.meta.time_label || '当前'}挂号`" min-width="118" align="right" sortable />
|
||||
<el-table-column prop="appointment_count" :label="`${dashboard.meta.time_label || '当前'}预约`" min-width="118" align="right" sortable />
|
||||
<el-table-column prop="tomorrow_count" label="明日预约" min-width="105" align="right" sortable />
|
||||
<el-table-column prop="day_after_count" label="后日预约" min-width="105" align="right" sortable />
|
||||
<el-table-column prop="order_count" label="诊单" min-width="86" align="right" sortable />
|
||||
<el-table-column label="业绩" min-width="128" align="right" sortable :sort-method="sortAmount">
|
||||
<template #default="{ row }">{{ formatMoney(row.order_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号较上期" min-width="112" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="['rate-text', compareClass(row.registration_compare_rate)]">
|
||||
{{ compactCompare(row.registration_compare_rate) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约较上期" min-width="112" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="['rate-text', compareClass(row.appointment_compare_rate)]">
|
||||
{{ compactCompare(row.appointment_compare_rate) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" min-width="92" align="center">
|
||||
<template #default><span class="status-pill"><i />正常</span></template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前权限与筛选范围内暂无医助数据" /></template>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="panel target-panel">
|
||||
<div class="panel-heading">
|
||||
<div>
|
||||
<h2>一诊诊金目标追踪</h2>
|
||||
<p>{{ dashboard.target.year }} 年 · {{ dashboard.target.scope_note }}</p>
|
||||
</div>
|
||||
<span class="panel-badge">{{ dashboard.target.department_count || 0 }} 个目标部门</span>
|
||||
</div>
|
||||
<div class="target-layout">
|
||||
<div class="target-summary">
|
||||
<div class="target-numbers">
|
||||
<div><span>年度目标</span><strong>{{ formatMoney(dashboard.target.target_amount) }}</strong></div>
|
||||
<div><span>已完成</span><strong>{{ formatMoney(dashboard.target.actual_amount) }}</strong></div>
|
||||
<div><span>完成率</span><strong class="is-teal">{{ nullablePercent(dashboard.target.completion_rate) }}</strong></div>
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="progressValue(dashboard.target.completion_rate)"
|
||||
:show-text="false"
|
||||
:stroke-width="13"
|
||||
color="#139a8c"
|
||||
/>
|
||||
<p v-if="Number(dashboard.target.target_amount) > 0">
|
||||
尚差 {{ formatMoney(Math.max(0, Number(dashboard.target.target_amount) - Number(dashboard.target.actual_amount))) }} 达成年度目标
|
||||
</p>
|
||||
<p v-else class="target-empty">当前范围未维护可用目标,实际业绩仍按统一口径正常统计。</p>
|
||||
</div>
|
||||
<div class="target-chart-wrap">
|
||||
<div class="chart-legend"><i class="actual" />累计完成 <i class="target" />累计目标</div>
|
||||
<v-charts class="target-chart" :option="targetChartOption" autoresize />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="ranking-grid">
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>{{ dashboard.meta.time_label || '今日' }}业绩 TOP</h2><p>按业务订单创建人排序</p></div>
|
||||
<span class="panel-badge">金额</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.performance.length" class="ranking-list">
|
||||
<div v-for="(item, index) in dashboard.rankings.performance" :key="`performance-${item.admin_id}`" class="ranking-row">
|
||||
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
|
||||
<span>{{ item.name }}<small>{{ item.count }} 单</small></span>
|
||||
<div class="rank-track"><i :style="{ width: rankWidth(item.value, maxPerformance) }" /></div>
|
||||
<strong>{{ formatMoney(item.value) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="52" description="暂无业绩数据" />
|
||||
</article>
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>{{ dashboard.meta.time_label || '今日' }}挂号 TOP</h2><p>按低于 10 元的已支付订单笔数排序</p></div>
|
||||
<span class="panel-badge">挂号</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.registrations.length" class="ranking-list ranking-list--blue">
|
||||
<div v-for="(item, index) in dashboard.rankings.registrations" :key="`registration-${item.admin_id}`" class="ranking-row">
|
||||
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
|
||||
<span>{{ item.name }}<small>已支付小额订单</small></span>
|
||||
<div class="rank-track"><i :style="{ width: rankWidth(item.value, maxRegistrations) }" /></div>
|
||||
<strong>{{ formatNumber(item.value) }} 笔</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="52" description="暂无挂号数据" />
|
||||
</article>
|
||||
<article class="panel ranking-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>{{ dashboard.meta.time_label || '今日' }}预约 TOP</h2><p>按有效预约记录数量排序</p></div>
|
||||
<span class="panel-badge">预约</span>
|
||||
</div>
|
||||
<div v-if="dashboard.rankings.appointments.length" class="ranking-list ranking-list--blue">
|
||||
<div v-for="(item, index) in dashboard.rankings.appointments" :key="`appointment-${item.admin_id}`" class="ranking-row">
|
||||
<b :class="{ 'is-top': index < 3 }">{{ index + 1 }}</b>
|
||||
<span>{{ item.name }}<small>有效预约</small></span>
|
||||
<div class="rank-track"><i :style="{ width: rankWidth(item.value, maxAppointments) }" /></div>
|
||||
<strong>{{ formatNumber(item.value) }} 个</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :image-size="52" description="暂无预约数据" />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="panel department-panel">
|
||||
<div class="panel-heading">
|
||||
<div><h2>按部门统计</h2><p>员工按最深层有效归属部门唯一计入,避免多部门重复累计</p></div>
|
||||
<span class="panel-badge">{{ dashboard.departments.length }} 个部门</span>
|
||||
</div>
|
||||
<el-table :data="dashboard.departments" class="stats-table department-table">
|
||||
<el-table-column prop="name" label="部门" min-width="220">
|
||||
<template #default="{ row }"><strong>{{ row.name }}</strong></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="member_count" label="人数" min-width="90" align="right" sortable />
|
||||
<el-table-column prop="registration_count" :label="`${dashboard.meta.time_label || '当前'}挂号`" min-width="118" align="right" sortable />
|
||||
<el-table-column prop="appointment_count" :label="`${dashboard.meta.time_label || '当前'}预约`" min-width="118" align="right" sortable />
|
||||
<el-table-column prop="tomorrow_count" label="明日预约" min-width="105" align="right" sortable />
|
||||
<el-table-column prop="order_count" label="诊单" min-width="90" align="right" sortable />
|
||||
<el-table-column label="业绩" min-width="130" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.order_amount) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="挂号环比" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="['rate-text', compareClass(row.registration_compare_rate)]">
|
||||
{{ compactCompare(row.registration_compare_rate) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="预约环比" min-width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="['rate-text', compareClass(row.appointment_compare_rate)]">
|
||||
{{ compactCompare(row.appointment_compare_rate) }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="当前范围暂无部门汇总" /></template>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<footer class="data-note">
|
||||
<el-icon><InfoFilled /></el-icon>
|
||||
<span>{{ dashboard.meta.registration_rule }};{{ dashboard.meta.appointment_rule }};{{ dashboard.meta.performance_rule }}。</span>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="firstVisitRegistrationStatsPage">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
Calendar,
|
||||
DocumentChecked,
|
||||
Histogram,
|
||||
InfoFilled,
|
||||
Lock,
|
||||
Refresh,
|
||||
Wallet
|
||||
} from '@element-plus/icons-vue'
|
||||
import vCharts from 'vue-echarts'
|
||||
import {
|
||||
firstVisitRegistrationStatsOverview,
|
||||
type FirstVisitRegistrationStatsParams
|
||||
} from '@/api/first_visit'
|
||||
|
||||
const emptyDashboard = () => ({
|
||||
meta: {
|
||||
time_type: 'today', time_label: '今日', start_date: '', end_date: '', generated_at: '',
|
||||
scope_value: 4, scope_label: '', selected_dept_name: '', selected_assistant_name: '',
|
||||
member_count: 0, registration_rule: '', appointment_rule: '', performance_rule: ''
|
||||
},
|
||||
filters: { departments: [] as any[], assistants: [] as Array<{ id: number; name: string }> },
|
||||
summary: {
|
||||
registration_count: 0, registration_compare_count: 0, registration_compare_rate: null as number | null,
|
||||
appointment_count: 0, appointment_compare_count: 0, appointment_compare_rate: null as number | null,
|
||||
order_count: 0, order_amount: 0, range_label: '今日'
|
||||
},
|
||||
employee_rows: [] as any[],
|
||||
rankings: { performance: [] as any[], registrations: [] as any[], appointments: [] as any[] },
|
||||
departments: [] as any[],
|
||||
target: {
|
||||
year: new Date().getFullYear(), target_amount: 0, actual_amount: 0,
|
||||
completion_rate: null as number | null, department_count: 0, scope_note: '',
|
||||
months: [] as string[], target_cumulative: [] as number[], actual_cumulative: [] as number[]
|
||||
}
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const dashboard = reactive(emptyDashboard())
|
||||
const query = reactive<FirstVisitRegistrationStatsParams>({ time_type: 'today' })
|
||||
const timeOptions = [
|
||||
{ label: '今日', value: 'today' },
|
||||
{ label: '本周', value: 'week' },
|
||||
{ label: '本月', value: 'month' }
|
||||
]
|
||||
const deptTreeProps = { label: 'name', value: 'id', children: 'children' }
|
||||
|
||||
const maxPerformance = computed(() => Math.max(0, ...dashboard.rankings.performance.map((item: any) => Number(item.value) || 0)))
|
||||
const maxRegistrations = computed(() => Math.max(0, ...dashboard.rankings.registrations.map((item: any) => Number(item.value) || 0)))
|
||||
const maxAppointments = computed(() => Math.max(0, ...dashboard.rankings.appointments.map((item: any) => Number(item.value) || 0)))
|
||||
const targetChartOption = computed(() => ({
|
||||
animationDuration: 450,
|
||||
grid: { left: 18, right: 18, top: 18, bottom: 12, containLabel: true },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
valueFormatter: (value: number) => formatMoney(value)
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
boundaryGap: false,
|
||||
data: dashboard.target.months,
|
||||
axisLine: { lineStyle: { color: '#d9e1e8' } },
|
||||
axisTick: { show: false },
|
||||
axisLabel: { color: '#7d8b9c', fontSize: 11 }
|
||||
},
|
||||
yAxis: {
|
||||
type: 'value',
|
||||
splitNumber: 3,
|
||||
axisLabel: { color: '#8b98a8', formatter: (value: number) => compactMoney(value) },
|
||||
splitLine: { lineStyle: { color: '#eef2f5' } }
|
||||
},
|
||||
series: [
|
||||
{
|
||||
name: '累计完成', type: 'line', smooth: true, symbol: 'circle', symbolSize: 5,
|
||||
data: dashboard.target.actual_cumulative,
|
||||
lineStyle: { color: '#139a8c', width: 3 },
|
||||
itemStyle: { color: '#ffffff', borderColor: '#139a8c', borderWidth: 2 },
|
||||
areaStyle: { color: 'rgba(19,154,140,.08)' }
|
||||
},
|
||||
{
|
||||
name: '累计目标', type: 'line', smooth: true, showSymbol: false,
|
||||
data: dashboard.target.target_cumulative,
|
||||
lineStyle: { color: '#5f86e8', width: 2, type: 'dashed' }
|
||||
}
|
||||
]
|
||||
}))
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: FirstVisitRegistrationStatsParams = { time_type: query.time_type }
|
||||
if (query.dept_id) params.dept_id = Number(query.dept_id)
|
||||
if (query.assistant_id) params.assistant_id = Number(query.assistant_id)
|
||||
const result: any = await firstVisitRegistrationStatsOverview(params)
|
||||
Object.assign(dashboard, emptyDashboard(), result || {})
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.message || '挂号统计加载失败,请稍后重试')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleDepartmentChange() {
|
||||
delete query.assistant_id
|
||||
loadDashboard()
|
||||
}
|
||||
|
||||
function formatNumber(value: unknown) {
|
||||
return Number(value || 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
|
||||
}
|
||||
|
||||
function formatMoney(value: unknown) {
|
||||
return `¥${Number(value || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
|
||||
}
|
||||
|
||||
function compactMoney(value: number) {
|
||||
if (Math.abs(value) >= 10000) return `${(value / 10000).toFixed(value >= 100000 ? 0 : 1)}万`
|
||||
return `${Math.round(value)}`
|
||||
}
|
||||
|
||||
function nullablePercent(value: unknown) {
|
||||
return value === null || value === undefined ? '未设置' : `${Number(value).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function progressValue(value: unknown) {
|
||||
return Math.min(100, Math.max(0, Number(value) || 0))
|
||||
}
|
||||
|
||||
function compareText(value: unknown) {
|
||||
if (value === null || value === undefined) return '上期无数据,暂不计算环比'
|
||||
const number = Number(value)
|
||||
if (number === 0) return '与上期持平'
|
||||
return `${number > 0 ? '较上期增长' : '较上期下降'} ${Math.abs(number).toFixed(1)}%`
|
||||
}
|
||||
|
||||
function compactCompare(value: unknown) {
|
||||
if (value === null || value === undefined) return '—'
|
||||
const number = Number(value)
|
||||
return `${number > 0 ? '+' : ''}${number.toFixed(1)}%`
|
||||
}
|
||||
|
||||
function compareClass(value: unknown) {
|
||||
if (value === null || value === undefined || Number(value) === 0) return 'is-neutral'
|
||||
return Number(value) > 0 ? 'is-up' : 'is-down'
|
||||
}
|
||||
|
||||
function rankWidth(value: unknown, max: number) {
|
||||
if (max <= 0) return '0%'
|
||||
return `${Math.max(4, Math.min(100, Number(value || 0) / max * 100))}%`
|
||||
}
|
||||
|
||||
function avatarText(name: unknown) {
|
||||
const text = String(name || '员').trim()
|
||||
return text.slice(-1)
|
||||
}
|
||||
|
||||
function sortAmount(left: any, right: any) {
|
||||
return Number(left?.order_amount || 0) - Number(right?.order_amount || 0)
|
||||
}
|
||||
|
||||
onMounted(loadDashboard)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.registration-stats {
|
||||
--ink: #17243a;
|
||||
--muted: #778598;
|
||||
--line: #e3e9ef;
|
||||
--canvas: #f4f6f8;
|
||||
--teal: #139a8c;
|
||||
--blue: #4f78e5;
|
||||
min-height: 100%;
|
||||
padding: 18px;
|
||||
color: var(--ink);
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.filter-strip,
|
||||
.panel,
|
||||
.metric-card {
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
min-height: 76px;
|
||||
padding: 14px 18px;
|
||||
border-radius: 13px;
|
||||
}
|
||||
|
||||
.heading-copy,
|
||||
.heading-actions,
|
||||
.filter-item,
|
||||
.name-cell,
|
||||
.chart-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.heading-copy { gap: 12px; }
|
||||
.heading-mark {
|
||||
display: grid;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: #fff;
|
||||
background: var(--teal);
|
||||
box-shadow: 0 8px 20px rgba(19, 154, 140, .18);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 { font-size: 20px; line-height: 1.3; letter-spacing: .01em; }
|
||||
h2 { font-size: 15px; line-height: 1.4; }
|
||||
.heading-copy p, .panel-heading p { margin-top: 4px; color: var(--muted); font-size: 12px; }
|
||||
.heading-actions { gap: 12px; color: var(--muted); font-size: 12px; }
|
||||
.scope-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 9px;
|
||||
border: 1px solid #cbe8e3;
|
||||
border-radius: 7px;
|
||||
color: #127f75;
|
||||
background: #f1faf8;
|
||||
}
|
||||
|
||||
.filter-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 22px;
|
||||
margin-top: 14px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 11px;
|
||||
}
|
||||
.filter-item { gap: 9px; color: #68778b; font-size: 13px; }
|
||||
.dept-select { width: 220px; }
|
||||
.employee-select { width: 180px; }
|
||||
.filter-summary { margin-left: auto; text-align: right; }
|
||||
.filter-summary strong { display: block; font-size: 13px; }
|
||||
.filter-summary span { color: var(--muted); font-size: 11px; }
|
||||
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.metric-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 13px;
|
||||
min-height: 114px;
|
||||
padding: 18px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.metric-icon {
|
||||
display: grid;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
flex: 0 0 42px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.metric-card--teal .metric-icon { color: #107f75; background: #e9f7f5; }
|
||||
.metric-card--blue .metric-icon { color: #416bd7; background: #edf1ff; }
|
||||
.metric-card--violet .metric-icon { color: #7257c7; background: #f2efff; }
|
||||
.metric-card--amber .metric-icon { color: #bc7428; background: #fff4e7; }
|
||||
.metric-card span { display: block; color: #748296; font-size: 13px; }
|
||||
.metric-card strong { display: block; margin: 4px 0 2px; font-size: 27px; line-height: 1.15; }
|
||||
.metric-card small { color: #8b98a8; font-size: 11px; }
|
||||
.metric-card small.is-up, .rate-text.is-up { color: #17956f; }
|
||||
.metric-card small.is-down, .rate-text.is-down { color: #e25858; }
|
||||
|
||||
.panel { margin-top: 14px; border-radius: 12px; overflow: hidden; }
|
||||
.panel-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 15px 16px 13px;
|
||||
}
|
||||
.panel-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
color: #758497;
|
||||
background: #f2f5f7;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.stats-table { --el-table-header-bg-color: #f7f9fb; --el-table-border-color: #e7ecf1; }
|
||||
.stats-table :deep(th.el-table__cell) { height: 42px; color: #68778b; font-weight: 500; }
|
||||
.stats-table :deep(td.el-table__cell) { height: 47px; }
|
||||
.stats-table :deep(.el-table__row--level-0) { background: #fafcfd; }
|
||||
.name-cell { gap: 9px; }
|
||||
.name-cell em { color: #8d99a7; font-size: 11px; font-style: normal; font-weight: 400; }
|
||||
.name-cell.is-department strong { font-weight: 700; }
|
||||
.name-cell :deep(.el-avatar) { color: #167e75; background: #e8f5f3; font-size: 11px; }
|
||||
.dept-dot { width: 8px; height: 8px; border-radius: 3px; background: var(--teal); }
|
||||
.status-pill { display: inline-flex; align-items: center; gap: 5px; color: #218d71; font-size: 12px; }
|
||||
.status-pill i { width: 6px; height: 6px; border-radius: 50%; background: #34b38d; }
|
||||
.rate-text { font-size: 12px; }
|
||||
.rate-text.is-neutral { color: #8b98a8; }
|
||||
|
||||
.target-layout { display: grid; grid-template-columns: minmax(340px, .8fr) minmax(480px, 1.2fr); border-top: 1px solid #edf1f4; }
|
||||
.target-summary { display: flex; flex-direction: column; justify-content: center; padding: 24px 22px; border-right: 1px solid #edf1f4; }
|
||||
.target-numbers { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; margin-bottom: 22px; }
|
||||
.target-numbers span { display: block; color: var(--muted); font-size: 12px; }
|
||||
.target-numbers strong { display: block; margin-top: 5px; font-size: 20px; }
|
||||
.target-numbers .is-teal { color: var(--teal); }
|
||||
.target-summary p { margin-top: 9px; color: var(--muted); font-size: 11px; }
|
||||
.target-summary .target-empty { color: #b17832; }
|
||||
.target-chart-wrap { position: relative; min-height: 238px; padding: 10px 14px 4px; }
|
||||
.target-chart { width: 100%; height: 225px; }
|
||||
.chart-legend { position: absolute; z-index: 2; top: 11px; right: 18px; gap: 6px; color: #778598; font-size: 11px; }
|
||||
.chart-legend i { width: 18px; height: 3px; margin-left: 8px; border-radius: 2px; }
|
||||
.chart-legend .actual { background: var(--teal); }
|
||||
.chart-legend .target { background: repeating-linear-gradient(90deg, var(--blue) 0 5px, transparent 5px 8px); }
|
||||
|
||||
.ranking-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||||
.ranking-panel { min-height: 286px; }
|
||||
.ranking-list { padding: 2px 16px 16px; }
|
||||
.ranking-row { display: grid; grid-template-columns: 30px minmax(86px, .8fr) minmax(64px, 1fr) auto; align-items: center; gap: 10px; min-height: 43px; border-top: 1px solid #eff2f5; }
|
||||
.ranking-row > b { display: grid; width: 22px; height: 22px; place-items: center; border-radius: 7px; color: #8491a1; background: #f1f4f6; font-size: 11px; }
|
||||
.ranking-row > b.is-top { color: #fff; background: var(--teal); }
|
||||
.ranking-list--blue .ranking-row > b.is-top { background: var(--blue); }
|
||||
.ranking-row > span { overflow: hidden; font-size: 12px; font-weight: 600; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ranking-row small { margin-left: 6px; color: #939eab; font-size: 10px; font-weight: 400; }
|
||||
.ranking-row > strong { text-align: right; font-size: 12px; }
|
||||
.rank-track { height: 7px; overflow: hidden; border-radius: 5px; background: #edf1f4; }
|
||||
.rank-track i { display: block; height: 100%; border-radius: inherit; background: var(--teal); }
|
||||
.ranking-list--blue .rank-track i { background: var(--blue); }
|
||||
.department-table { border-top: 1px solid #edf1f4; }
|
||||
.data-note { display: flex; align-items: center; gap: 6px; padding: 11px 2px 2px; color: #8b97a6; font-size: 11px; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.filter-strip { align-items: flex-start; flex-wrap: wrap; }
|
||||
.filter-summary { margin-left: 0; }
|
||||
.target-layout { grid-template-columns: 1fr; }
|
||||
.target-summary { border-right: 0; border-bottom: 1px solid #edf1f4; }
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.registration-stats { padding: 10px; }
|
||||
.page-heading { align-items: flex-start; flex-direction: column; }
|
||||
.heading-actions { flex-wrap: wrap; }
|
||||
.update-time { display: none; }
|
||||
.metric-grid, .ranking-grid { grid-template-columns: 1fr; }
|
||||
.filter-item { width: 100%; justify-content: space-between; }
|
||||
.dept-select, .employee-select { width: calc(100% - 52px); }
|
||||
.filter-item--time { justify-content: flex-start; }
|
||||
.target-numbers { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -1,686 +0,0 @@
|
||||
<template>
|
||||
<div class="mapping-page">
|
||||
<header class="page-header">
|
||||
<h1>洛阳药房药材映射</h1>
|
||||
<el-button
|
||||
v-if="status.sync_enabled"
|
||||
v-perms="['pharmacy.medicineMapping/sync']"
|
||||
type="primary"
|
||||
:icon="Refresh"
|
||||
:loading="syncing"
|
||||
@click="handleSync"
|
||||
>
|
||||
增量同步
|
||||
</el-button>
|
||||
</header>
|
||||
|
||||
<section class="status-strip" v-loading="statusLoading">
|
||||
<div class="status-item">
|
||||
<span>目录</span>
|
||||
<strong>{{ status.catalog_active }} / {{ status.catalog_total }}</strong>
|
||||
<small>启用 / 总数</small>
|
||||
</div>
|
||||
<div class="status-item warning">
|
||||
<span>未映射本地药材</span>
|
||||
<strong>{{ status.unmapped_local }}</strong>
|
||||
<small>仅统计启用药材</small>
|
||||
</div>
|
||||
<div class="status-item">
|
||||
<span>同步游标</span>
|
||||
<strong>{{ status.cursor }}</strong>
|
||||
<small>{{ formatTime(status.last_success_time) || '尚未成功同步' }}</small>
|
||||
</div>
|
||||
<div class="status-item" :class="{ danger: !!status.last_error_summary }">
|
||||
<span>最近同步</span>
|
||||
<strong>{{
|
||||
status.is_syncing ? '进行中' : status.last_error_summary ? '失败' : '正常'
|
||||
}}</strong>
|
||||
<small :title="status.last_error_summary">
|
||||
{{
|
||||
status.last_error_summary ||
|
||||
formatTime(status.last_failure_time) ||
|
||||
'无失败记录'
|
||||
}}
|
||||
</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<el-form class="filter-bar" inline @submit.prevent>
|
||||
<el-form-item label="本地药材">
|
||||
<el-input
|
||||
v-model="query.local_name"
|
||||
clearable
|
||||
placeholder="名称"
|
||||
:prefix-icon="Search"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="远端目录">
|
||||
<el-input
|
||||
v-model="query.remote_keyword"
|
||||
clearable
|
||||
placeholder="名称或编码"
|
||||
:prefix-icon="Search"
|
||||
@keyup.enter="search"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="映射状态">
|
||||
<el-select
|
||||
v-model="query.mapping_status"
|
||||
clearable
|
||||
placeholder="全部"
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option label="已映射" value="mapped" />
|
||||
<el-option label="未映射" value="unmapped" />
|
||||
<el-option label="远端失效" value="invalid" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :icon="Search" @click="search">查询</el-button>
|
||||
<el-button @click="resetFilters">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div class="table-wrap">
|
||||
<el-table v-loading="loading" :data="rows" border stripe table-layout="fixed">
|
||||
<el-table-column label="本地药材" min-width="190" fixed="left">
|
||||
<template #default="{ row }">
|
||||
<div class="medicine-name">{{ row.local_name }}</div>
|
||||
<div class="subline">
|
||||
ID {{ row.local_medicine_id }} · {{ row.local_unit || '-' }}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="本地状态" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.local_status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.local_status === 1 ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="映射状态" width="105" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="mappingTag(row.mapping_status)" size="small">
|
||||
{{ mappingText(row.mapping_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="洛阳药房目录" min-width="250">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.medicine_code">
|
||||
<div class="medicine-name">{{ row.remote_name || '目录项不可用' }}</div>
|
||||
<div class="subline code">{{ row.medicine_code }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="medicine-name">-</div>
|
||||
<div class="subline">未映射</div>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="品牌" min-width="130" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.mapping_status === 0 ? '-' : row.remote_brand || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单位" width="80" align="center">
|
||||
<template #default="{ row }">{{ row.remote_unit || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="价格" width="155" align="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.mapping_status !== 0">
|
||||
<div>结算 ¥{{ formatPrice(row.settlement_price) }}</div>
|
||||
<div class="subline">零售 ¥{{ formatPrice(row.retail_price) }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>-</div>
|
||||
<div class="subline">未映射</div>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="版本 / 状态" width="130" align="center">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.medicine_code">
|
||||
<div>v{{ row.catalog_version || 0 }}</div>
|
||||
<div class="subline">
|
||||
{{
|
||||
row.remote_status === 1 && row.remote_deleted !== 1
|
||||
? '远端启用'
|
||||
: '远端停用'
|
||||
}}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>-</div>
|
||||
<div class="subline">未映射</div>
|
||||
</template>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="170" fixed="right" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-perms="['pharmacy.medicineMapping/save']"
|
||||
type="primary"
|
||||
link
|
||||
:icon="Link"
|
||||
:disabled="row.local_status !== 1"
|
||||
@click="openMapping(row)"
|
||||
>
|
||||
{{ row.mapping_status === 1 ? '更换' : '映射' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-if="row.mapping_status === 1 || row.mapping_status === 2"
|
||||
v-perms="['pharmacy.medicineMapping/unlink']"
|
||||
type="danger"
|
||||
link
|
||||
:icon="CloseBold"
|
||||
@click="handleUnlink(row)"
|
||||
>
|
||||
解除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="pagination-wrap">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page_no"
|
||||
v-model:page-size="query.page_size"
|
||||
:total="total"
|
||||
:page-sizes="[20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="loadRows"
|
||||
@current-change="loadRows"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="`${editingRow?.mapping_status === 1 ? '更换' : '建立'}药材映射`"
|
||||
width="min(560px, calc(100vw - 32px))"
|
||||
destroy-on-close
|
||||
>
|
||||
<div class="local-summary">
|
||||
<span>本地药材</span>
|
||||
<strong>{{ editingRow?.local_name }}</strong>
|
||||
<small
|
||||
>ID {{ editingRow?.local_medicine_id }} ·
|
||||
{{ editingRow?.local_unit || '-' }}</small
|
||||
>
|
||||
</div>
|
||||
<el-form label-position="top" class="mapping-form">
|
||||
<el-form-item label="洛阳药房药材">
|
||||
<el-select
|
||||
v-model="selectedCode"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
clearable
|
||||
:remote-method="searchCatalog"
|
||||
:loading="catalogLoading"
|
||||
placeholder="输入药材名称或编码检索"
|
||||
style="width: 100%"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in catalogOptions"
|
||||
:key="option.medicine_code"
|
||||
:label="`${option.name} · ${option.medicine_code}`"
|
||||
:value="option.medicine_code"
|
||||
>
|
||||
<div class="option-row">
|
||||
<span>{{ option.name }}</span>
|
||||
<small
|
||||
>{{ option.medicine_code }} · {{ option.brand || '无品牌' }} ·
|
||||
{{ option.unit }}</small
|
||||
>
|
||||
</div>
|
||||
</el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
:disabled="!selectedCode"
|
||||
@click="saveMapping"
|
||||
>
|
||||
保存映射
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" name="pharmacyMedicineMapping">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { CloseBold, Link, Refresh, Search } from '@element-plus/icons-vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import feedback from '@/utils/feedback'
|
||||
import { createLatestRequestGuard } from './latest-request.mjs'
|
||||
import {
|
||||
medicineCatalogOptions,
|
||||
medicineCatalogSync,
|
||||
medicineMappingLists,
|
||||
medicineMappingSave,
|
||||
medicineMappingStatus,
|
||||
medicineMappingUnlink,
|
||||
type CatalogOption,
|
||||
type MedicineMappingQuery,
|
||||
type MedicineMappingRow,
|
||||
type PharmacySyncResult,
|
||||
type PharmacySyncStatus
|
||||
} from '@/api/pharmacy'
|
||||
|
||||
const emptyStatus = (): PharmacySyncStatus => ({
|
||||
sync_enabled: false,
|
||||
cursor: 0,
|
||||
last_success_time: 0,
|
||||
last_failure_time: 0,
|
||||
last_error_summary: '',
|
||||
is_syncing: false,
|
||||
catalog_total: 0,
|
||||
catalog_active: 0,
|
||||
unmapped_local: 0
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const statusLoading = ref(false)
|
||||
const syncing = ref(false)
|
||||
const saving = ref(false)
|
||||
const catalogLoading = ref(false)
|
||||
const rows = ref<MedicineMappingRow[]>([])
|
||||
const total = ref(0)
|
||||
const status = ref<PharmacySyncStatus>(emptyStatus())
|
||||
const query = reactive<MedicineMappingQuery>({
|
||||
page_no: 1,
|
||||
page_size: 20,
|
||||
local_name: '',
|
||||
remote_keyword: '',
|
||||
mapping_status: ''
|
||||
})
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editingRow = ref<MedicineMappingRow | null>(null)
|
||||
const selectedCode = ref('')
|
||||
const catalogOptions = ref<CatalogOption[]>([])
|
||||
let catalogTimer: number | undefined
|
||||
const listRequests = createLatestRequestGuard<MedicineMappingQuery>()
|
||||
const catalogRequests = createLatestRequestGuard<{ keyword: string; localMedicineId: number }>()
|
||||
const statusRequests = createLatestRequestGuard()
|
||||
|
||||
const formatPrice = (value: string | number | null | undefined) => {
|
||||
const number = Number(value || 0)
|
||||
return Number.isFinite(number) ? number.toFixed(2) : '0.00'
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number) => {
|
||||
if (!timestamp) return ''
|
||||
return new Date(timestamp * 1000).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
const mappingText = (value: number) => {
|
||||
if (value === 1) return '已映射'
|
||||
if (value === 2) return '远端失效'
|
||||
return '未映射'
|
||||
}
|
||||
|
||||
const mappingTag = (value: number): 'success' | 'warning' | 'info' => {
|
||||
if (value === 1) return 'success'
|
||||
if (value === 2) return 'warning'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const loadRows = async () => {
|
||||
const ticket = listRequests.next({ ...query })
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await medicineMappingLists(ticket.snapshot)
|
||||
if (listRequests.isLatest(ticket)) {
|
||||
rows.value = response.lists || []
|
||||
total.value = response.count || 0
|
||||
}
|
||||
} finally {
|
||||
if (listRequests.isLatest(ticket)) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadStatus = async () => {
|
||||
const ticket = statusRequests.next(undefined)
|
||||
statusLoading.value = true
|
||||
try {
|
||||
const nextStatus = await medicineMappingStatus()
|
||||
if (statusRequests.isLatest(ticket)) {
|
||||
status.value = nextStatus
|
||||
}
|
||||
} finally {
|
||||
if (statusRequests.isLatest(ticket)) {
|
||||
statusLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const search = () => {
|
||||
query.page_no = 1
|
||||
void loadRows()
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
query.local_name = ''
|
||||
query.remote_keyword = ''
|
||||
query.mapping_status = ''
|
||||
search()
|
||||
}
|
||||
|
||||
const handleSync = async () => {
|
||||
syncing.value = true
|
||||
try {
|
||||
const result = (await medicineCatalogSync()) as PharmacySyncResult
|
||||
feedback.msgSuccess(
|
||||
`同步完成:拉取 ${result.pulled},新增 ${result.created},更新 ${result.updated},停用 ${result.deactivated}`
|
||||
)
|
||||
await Promise.all([loadRows(), loadStatus()])
|
||||
} finally {
|
||||
syncing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const searchCatalogNow = async (
|
||||
keyword: string,
|
||||
localMedicineId = Number(editingRow.value?.local_medicine_id || 0)
|
||||
) => {
|
||||
const ticket = catalogRequests.next({ keyword: keyword.trim(), localMedicineId })
|
||||
catalogLoading.value = true
|
||||
try {
|
||||
const options = await medicineCatalogOptions({ keyword: ticket.snapshot.keyword, limit: 30 })
|
||||
if (
|
||||
catalogRequests.isLatest(ticket) &&
|
||||
Number(editingRow.value?.local_medicine_id || 0) === ticket.snapshot.localMedicineId
|
||||
) {
|
||||
catalogOptions.value = options
|
||||
}
|
||||
} finally {
|
||||
if (catalogRequests.isLatest(ticket)) {
|
||||
catalogLoading.value = false
|
||||
}
|
||||
}
|
||||
return ticket
|
||||
}
|
||||
|
||||
const searchCatalog = (keyword: string) => {
|
||||
if (catalogTimer) window.clearTimeout(catalogTimer)
|
||||
catalogRequests.invalidate()
|
||||
catalogTimer = window.setTimeout(() => void searchCatalogNow(keyword), 250)
|
||||
}
|
||||
|
||||
const openMapping = async (row: MedicineMappingRow) => {
|
||||
if (catalogTimer) window.clearTimeout(catalogTimer)
|
||||
catalogRequests.invalidate()
|
||||
const rowSnapshot = { ...row }
|
||||
const localMedicineId = Number(rowSnapshot.local_medicine_id)
|
||||
editingRow.value = rowSnapshot
|
||||
selectedCode.value = rowSnapshot.mapping_status === 1 ? rowSnapshot.medicine_code || '' : ''
|
||||
catalogOptions.value = []
|
||||
dialogVisible.value = true
|
||||
const initialTicket = await searchCatalogNow(rowSnapshot.local_name, localMedicineId)
|
||||
if (
|
||||
!catalogRequests.isLatest(initialTicket) ||
|
||||
Number(editingRow.value?.local_medicine_id || 0) !== localMedicineId
|
||||
)
|
||||
return
|
||||
if (
|
||||
selectedCode.value &&
|
||||
!catalogOptions.value.some((item) => item.medicine_code === selectedCode.value)
|
||||
) {
|
||||
await searchCatalogNow(selectedCode.value, localMedicineId)
|
||||
}
|
||||
}
|
||||
|
||||
const saveMapping = async () => {
|
||||
if (!editingRow.value || !selectedCode.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
await medicineMappingSave({
|
||||
local_medicine_id: editingRow.value.local_medicine_id,
|
||||
medicine_code: selectedCode.value
|
||||
})
|
||||
dialogVisible.value = false
|
||||
await Promise.all([loadRows(), loadStatus()])
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUnlink = async (row: MedicineMappingRow) => {
|
||||
await ElMessageBox.confirm(
|
||||
`确认解除“${row.local_name}”与 ${row.medicine_code} 的映射?`,
|
||||
'解除映射',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '解除',
|
||||
cancelButtonText: '取消'
|
||||
}
|
||||
)
|
||||
await medicineMappingUnlink({ local_medicine_id: row.local_medicine_id })
|
||||
await Promise.all([loadRows(), loadStatus()])
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void Promise.all([loadRows(), loadStatus()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mapping-page {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
min-height: 88px;
|
||||
margin-bottom: 16px;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.status-item {
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
border-right: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.status-item:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.status-item span,
|
||||
.status-item small {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-item strong {
|
||||
display: block;
|
||||
margin: 5px 0 2px;
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.status-item.warning strong {
|
||||
color: var(--el-color-warning-dark-2);
|
||||
}
|
||||
|
||||
.status-item.danger strong,
|
||||
.status-item.danger small {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0 8px;
|
||||
padding: 14px 16px 0;
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-bottom: 0;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
}
|
||||
|
||||
.filter-bar :deep(.el-input) {
|
||||
width: 190px;
|
||||
}
|
||||
|
||||
.table-wrap {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.table-wrap :deep(.el-table) {
|
||||
min-width: 1220px;
|
||||
}
|
||||
|
||||
.medicine-name {
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subline,
|
||||
.muted {
|
||||
margin-top: 3px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
overflow-x: auto;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.local-summary {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 3px 12px;
|
||||
padding: 12px 14px;
|
||||
border-left: 3px solid var(--el-color-primary);
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.local-summary span,
|
||||
.local-summary small {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.local-summary small {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
.mapping-form {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.option-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.option-row small {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.status-strip {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.status-item:nth-child(2) {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.status-item:nth-child(-n + 2) {
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.mapping-page {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-header .el-button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.status-strip {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-item,
|
||||
.status-item:nth-child(2) {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.status-item:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.filter-bar :deep(.el-form-item),
|
||||
.filter-bar :deep(.el-input),
|
||||
.filter-bar :deep(.el-select) {
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
.pagination-wrap {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +0,0 @@
|
||||
export interface LatestRequestTicket<T> {
|
||||
readonly generation: number
|
||||
readonly snapshot: T
|
||||
}
|
||||
|
||||
export interface LatestRequestGuard<T> {
|
||||
next(snapshot: T): LatestRequestTicket<T>
|
||||
invalidate(): number
|
||||
isLatest(ticket: LatestRequestTicket<T>): boolean
|
||||
}
|
||||
|
||||
export function createLatestRequestGuard<T>(): LatestRequestGuard<T>
|
||||
@@ -1,22 +0,0 @@
|
||||
export function createLatestRequestGuard() {
|
||||
let generation = 0
|
||||
|
||||
return {
|
||||
next(snapshot) {
|
||||
generation += 1
|
||||
const stableSnapshot = Array.isArray(snapshot)
|
||||
? [...snapshot]
|
||||
: snapshot && typeof snapshot === 'object'
|
||||
? { ...snapshot }
|
||||
: snapshot
|
||||
return Object.freeze({ generation, snapshot: stableSnapshot })
|
||||
},
|
||||
invalidate() {
|
||||
generation += 1
|
||||
return generation
|
||||
},
|
||||
isLatest(ticket) {
|
||||
return ticket?.generation === generation
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,7 +131,7 @@
|
||||
<el-table-column label="序号" type="index" width="60" />
|
||||
<el-table-column label="药材名称" min-width="220">
|
||||
<template #default="{ row }">
|
||||
<MedicineNameSelect v-model="row.name" v-model:medicine-id="row.medicine_id" />
|
||||
<MedicineNameSelect v-model="row.name" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剂量(克)" min-width="120">
|
||||
@@ -321,7 +321,7 @@ const formData = reactive({
|
||||
pulse: '',
|
||||
pulse_condition: '',
|
||||
clinical_diagnosis: '',
|
||||
herbs: [] as Array<{ medicine_id?: number; name: string; dosage: number }>,
|
||||
herbs: [] as Array<{ name: string; dosage: number }>,
|
||||
dose_count: 7,
|
||||
dose_unit: '剂',
|
||||
usage_days: 7,
|
||||
|
||||