Compare commits

..
915 changed files with 4081 additions and 28253 deletions
+2
View File
@@ -22,3 +22,5 @@ unpackage/dist
*.sw?
TUICallKit
# 本工程的通话源码复用仓库内 ../wx/TUICallKit;保留相对软链接以便干净克隆后可直接构建。
!TUICallKit
+1
View File
@@ -0,0 +1 @@
../wx/TUICallKit
+1 -1
View File
@@ -63,7 +63,7 @@
"requiredBackgroundModes" : [ "audio" ],
"plugins" : {
"WechatSI" : {
"version" : "0.3.5",
"version" : "0.3.9",
"provider" : "wx069ba97219f66d99"
}
}
+3 -3
View File
@@ -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。
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

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 × 320JPEG。
- 风格:中式、圆润 3D、暖色奶油背景、大轮廓、适老化高辨识度。
- 糖级、边框和角标不画入图标,由游戏界面控制,方便医生调整分类。
- `milkTea.jpg` 是唯一自带角色表情的高糖小反派候选。
- 驼乳属于产品道具,不计入 42 个普通食物图标,本轮未替换现有产品罐素材。
- 本目录当前仅作设计候选,没有接入正式游戏,也不会进入微信小程序发布包。
目录说明:
- `icons/`:可直接用于游戏的独立图标。
- `sheets/`:11 张分组预览母版,方便整体审美检查。
- `manifest.json`:食物中文名、代码键名与文件路径清单。
医学提示:图标仅表达食物外观,不代表最终糖级结论;糖级和健康说明仍需甄养堂医生审核。
Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

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" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

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); }
File diff suppressed because it is too large Load Diff
+2
View File
@@ -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: {
-281
View File
@@ -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 })
}
-87
View File
@@ -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' })
}
-8
View File
@@ -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 })
}
-24
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
@@ -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>
File diff suppressed because it is too large Load Diff
@@ -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
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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,

Some files were not shown because too many files have changed in this diff Show More