Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfe50a8d67 | ||
|
|
f10158b454 | ||
|
|
6c6922c400 | ||
|
|
55a71bfbe3 | ||
|
|
443e501da5 | ||
|
|
c2040c2b15 | ||
|
|
0d18089f71 |
@@ -24,7 +24,10 @@ import {
|
|||||||
splitHighlight,
|
splitHighlight,
|
||||||
type HighlightPart,
|
type HighlightPart,
|
||||||
} from '#/utils/dictSearchRank';
|
} from '#/utils/dictSearchRank';
|
||||||
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
|
import {
|
||||||
|
getProductListDoctorReception,
|
||||||
|
getUsedExcipientListDoctorReception,
|
||||||
|
} from '#/views/doctor/doctor-reception/api';
|
||||||
|
|
||||||
export type ChineseDrugBubbleItem = {
|
export type ChineseDrugBubbleItem = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -83,6 +86,8 @@ const open = ref(false);
|
|||||||
const searching = ref(false);
|
const searching = ref(false);
|
||||||
const highlightIndex = ref(0);
|
const highlightIndex = ref(0);
|
||||||
const options = ref<ChineseDrugBubbleItem[]>([]);
|
const options = ref<ChineseDrugBubbleItem[]>([]);
|
||||||
|
/** 当前列表是否「用过辅料」空搜结果(用于提示文案) */
|
||||||
|
const showingUsedExcipients = ref(false);
|
||||||
const scrollTop = ref(0);
|
const scrollTop = ref(0);
|
||||||
const listViewportH = ref(200);
|
const listViewportH = ref(200);
|
||||||
const panelStyle = ref<Record<string, string>>({});
|
const panelStyle = ref<Record<string, string>>({});
|
||||||
@@ -217,24 +222,39 @@ function updatePanelPlacement() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 远程搜药;空关键词不请求(与原中药 Select 一致)
|
* 远程搜药;空关键词时:辅料(type=8)拉「用过」列表,中药仍不请求
|
||||||
*/
|
*/
|
||||||
async function fetchDrugs(kw: string) {
|
async function fetchDrugs(kw: string) {
|
||||||
const name = String(kw || '').trim();
|
const name = String(kw || '').trim();
|
||||||
if (!name) {
|
// 非辅料空词不请求(与原中药 Select 一致)
|
||||||
|
if (!name && Number(props.type) !== 8) {
|
||||||
options.value = [];
|
options.value = [];
|
||||||
|
showingUsedExcipients.value = false;
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
|
let list: any[] = [];
|
||||||
|
if (!name && Number(props.type) === 8) {
|
||||||
|
// 聚焦空词:展示该医生云端「用过辅料」
|
||||||
|
const res = await getUsedExcipientListDoctorReception({
|
||||||
|
store_id: props.storeId || 0,
|
||||||
|
...(props.registerId > 0 ? { register_id: props.registerId } : {}),
|
||||||
|
limit: 30,
|
||||||
|
});
|
||||||
|
list = Array.isArray(res) ? res : [];
|
||||||
|
showingUsedExcipients.value = true;
|
||||||
|
} else {
|
||||||
const res = await getProductListDoctorReception({
|
const res = await getProductListDoctorReception({
|
||||||
name,
|
name,
|
||||||
type: props.type,
|
type: props.type,
|
||||||
store_id: props.storeId || 0,
|
store_id: props.storeId || 0,
|
||||||
...(props.registerId > 0 ? { register_id: props.registerId } : {}),
|
...(props.registerId > 0 ? { register_id: props.registerId } : {}),
|
||||||
});
|
});
|
||||||
const list = Array.isArray(res) ? res : [];
|
list = Array.isArray(res) ? res : [];
|
||||||
|
showingUsedExcipients.value = false;
|
||||||
|
}
|
||||||
const mapped: ChineseDrugBubbleItem[] = list.map((item: any) => {
|
const mapped: ChineseDrugBubbleItem[] = list.map((item: any) => {
|
||||||
const drug = item.drug || {};
|
const drug = item.drug || {};
|
||||||
return {
|
return {
|
||||||
@@ -249,7 +269,8 @@ async function fetchDrugs(kw: string) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
options.value = rankByMatchScore(
|
options.value = name
|
||||||
|
? rankByMatchScore(
|
||||||
mapped,
|
mapped,
|
||||||
name,
|
name,
|
||||||
(it) => drugNameOf(it),
|
(it) => drugNameOf(it),
|
||||||
@@ -266,13 +287,15 @@ async function fetchDrugs(kw: string) {
|
|||||||
]
|
]
|
||||||
.map((v) => String(v || ''))
|
.map((v) => String(v || ''))
|
||||||
.join(' '),
|
.join(' '),
|
||||||
);
|
)
|
||||||
|
: mapped;
|
||||||
highlightIndex.value = options.value.length > 0 ? 0 : -1;
|
highlightIndex.value = options.value.length > 0 ? 0 : -1;
|
||||||
scrollTop.value = 0;
|
scrollTop.value = 0;
|
||||||
if (listRef.value) listRef.value.scrollTop = 0;
|
if (listRef.value) listRef.value.scrollTop = 0;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('中药药名搜索失败:', e);
|
console.error('中药药名搜索失败:', e);
|
||||||
options.value = [];
|
options.value = [];
|
||||||
|
showingUsedExcipients.value = false;
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
nextTick(() => updatePanelPlacement());
|
nextTick(() => updatePanelPlacement());
|
||||||
@@ -289,11 +312,8 @@ function openPanel() {
|
|||||||
searching.value = true;
|
searching.value = true;
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
updatePanelPlacement();
|
updatePanelPlacement();
|
||||||
if (keyword.value.trim()) {
|
// 有关键词走搜索;空词时辅料拉「用过」,中药保持空列表
|
||||||
debouncedFetch(keyword.value);
|
debouncedFetch(keyword.value);
|
||||||
} else {
|
|
||||||
options.value = [];
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,6 +321,7 @@ function closePanel(restoreDisplay = true) {
|
|||||||
open.value = false;
|
open.value = false;
|
||||||
searching.value = false;
|
searching.value = false;
|
||||||
options.value = [];
|
options.value = [];
|
||||||
|
showingUsedExcipients.value = false;
|
||||||
highlightIndex.value = 0;
|
highlightIndex.value = 0;
|
||||||
if (restoreDisplay) {
|
if (restoreDisplay) {
|
||||||
keyword.value = String(props.displayName || '');
|
keyword.value = String(props.displayName || '');
|
||||||
@@ -463,6 +484,7 @@ defineExpose({
|
|||||||
<span>↑↓←→ 循环切换 · Enter 选用 · Esc 关闭</span>
|
<span>↑↓←→ 循环切换 · Enter 选用 · Esc 关闭</span>
|
||||||
<span class="chinese-drug-name-bubble__count">
|
<span class="chinese-drug-name-bubble__count">
|
||||||
<template v-if="keyword.trim()">搜到 {{ options.length }} 条</template>
|
<template v-if="keyword.trim()">搜到 {{ options.length }} 条</template>
|
||||||
|
<template v-else-if="showingUsedExcipients">用过 {{ options.length }} 条</template>
|
||||||
<template v-else>输入药名搜索</template>
|
<template v-else>输入药名搜索</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -473,6 +495,8 @@ defineExpose({
|
|||||||
:description="
|
:description="
|
||||||
keyword.trim()
|
keyword.trim()
|
||||||
? '未找到匹配药材'
|
? '未找到匹配药材'
|
||||||
|
: Number(type) === 8
|
||||||
|
? '暂无用过的辅料,请输入名称搜索'
|
||||||
: '请输入药名/别名/拼音/数字搜索'
|
: '请输入药名/别名/拼音/数字搜索'
|
||||||
"
|
"
|
||||||
class="py-4"
|
class="py-4"
|
||||||
|
|||||||
@@ -0,0 +1,413 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 中药煎法/用法气泡选择器
|
||||||
|
* 对齐药名气泡:点击触发浮层,不用 Select 下拉占屏;选项为本地列表(含默认「煎服」=0)
|
||||||
|
*/
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { DownOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { Button, Input } from 'ant-design-vue';
|
||||||
|
|
||||||
|
export type DrugWayOption = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 当前煎法 id,0=煎服 */
|
||||||
|
modelValue?: number;
|
||||||
|
/** 煎法选项(不含默认煎服,组件内自动补 0) */
|
||||||
|
options?: DrugWayOption[];
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** 紧凑宽度,嵌在中药卡片同一行(仅 input 触发器) */
|
||||||
|
compact?: boolean;
|
||||||
|
/**
|
||||||
|
* 触发器形态。
|
||||||
|
* link:文字链,适合放第二行,避免和克数单位抢同一行宽度;
|
||||||
|
* input:只读输入框,看起来像下拉。
|
||||||
|
*/
|
||||||
|
trigger?: 'input' | 'link';
|
||||||
|
inputClass?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: 0,
|
||||||
|
options: () => [],
|
||||||
|
placeholder: '用法',
|
||||||
|
disabled: false,
|
||||||
|
compact: false,
|
||||||
|
trigger: 'input',
|
||||||
|
inputClass: '',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [id: number];
|
||||||
|
change: [id: number, option: DrugWayOption];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLElement | null>(null);
|
||||||
|
const panelRef = ref<HTMLElement | null>(null);
|
||||||
|
const searchInputRef = ref<{ focus?: () => void } | null>(null);
|
||||||
|
const keyword = ref('');
|
||||||
|
const open = ref(false);
|
||||||
|
const highlightIndex = ref(0);
|
||||||
|
const panelStyle = ref<Record<string, string>>({});
|
||||||
|
|
||||||
|
/** 默认煎服 + 业务选项,按 id 去重 */
|
||||||
|
const allOptions = computed<DrugWayOption[]>(() => {
|
||||||
|
const list: DrugWayOption[] = [{ id: 0, name: '煎服' }];
|
||||||
|
const seen = new Set<number>([0]);
|
||||||
|
for (const o of props.options || []) {
|
||||||
|
const id = Number(o?.id || 0);
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
list.push({ id, name: String(o?.name || '') });
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
const displayLabel = computed(() => {
|
||||||
|
const hit = allOptions.value.find(
|
||||||
|
(o) => Number(o.id) === Number(props.modelValue || 0),
|
||||||
|
);
|
||||||
|
return hit?.name || props.placeholder || '煎服';
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredOptions = computed(() => {
|
||||||
|
const kw = keyword.value.trim().toLowerCase();
|
||||||
|
if (!kw) return allOptions.value;
|
||||||
|
return allOptions.value.filter((o) =>
|
||||||
|
String(o.name || '').toLowerCase().includes(kw),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
() => {
|
||||||
|
if (!open.value) keyword.value = displayLabel.value;
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
function updatePanelPlacement() {
|
||||||
|
const el = containerRef.value;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const panelW = Math.max(160, Math.min(240, Math.max(rect.width, 160)));
|
||||||
|
const spaceBelow = window.innerHeight - rect.bottom;
|
||||||
|
const preferBelow = spaceBelow >= 220 || spaceBelow >= rect.top;
|
||||||
|
const top = preferBelow
|
||||||
|
? rect.bottom + 4
|
||||||
|
: Math.max(8, rect.top - 4 - 220);
|
||||||
|
let left = rect.left;
|
||||||
|
if (left + panelW > window.innerWidth - 8) {
|
||||||
|
left = Math.max(8, window.innerWidth - panelW - 8);
|
||||||
|
}
|
||||||
|
panelStyle.value = {
|
||||||
|
position: 'fixed',
|
||||||
|
top: `${top}px`,
|
||||||
|
left: `${left}px`,
|
||||||
|
width: `${panelW}px`,
|
||||||
|
zIndex: '2100',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPanel() {
|
||||||
|
if (props.disabled) return;
|
||||||
|
open.value = true;
|
||||||
|
keyword.value = '';
|
||||||
|
highlightIndex.value = Math.max(
|
||||||
|
0,
|
||||||
|
filteredOptions.value.findIndex(
|
||||||
|
(o) => Number(o.id) === Number(props.modelValue || 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
nextTick(() => {
|
||||||
|
updatePanelPlacement();
|
||||||
|
const el = searchInputRef.value as any;
|
||||||
|
el?.focus?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePanel(restore = true) {
|
||||||
|
open.value = false;
|
||||||
|
if (restore) keyword.value = displayLabel.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectOption(opt: DrugWayOption) {
|
||||||
|
emit('update:modelValue', Number(opt.id));
|
||||||
|
emit('change', Number(opt.id), opt);
|
||||||
|
keyword.value = opt.name;
|
||||||
|
open.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFocus() {
|
||||||
|
openPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 文字链触发:再点一次收起。
|
||||||
|
* 为什么不复用输入框的「点一下只打开」:链没有输入态,开关需要成对,否则点文字关不掉。
|
||||||
|
*/
|
||||||
|
function onLinkClick() {
|
||||||
|
if (props.disabled) return;
|
||||||
|
if (open.value) {
|
||||||
|
closePanel(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInput() {
|
||||||
|
if (!open.value) openPanel();
|
||||||
|
highlightIndex.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveHighlight(delta: number) {
|
||||||
|
const len = filteredOptions.value.length;
|
||||||
|
if (len <= 0) return;
|
||||||
|
highlightIndex.value = (highlightIndex.value + delta + len) % len;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (props.disabled) return;
|
||||||
|
if (!open.value) {
|
||||||
|
// 文字链的 Enter 会走按钮原生 click,这里再开一次会立刻被 toggle 关掉
|
||||||
|
if (e.key === 'ArrowDown' || (e.key === 'Enter' && props.trigger !== 'link')) {
|
||||||
|
e.preventDefault();
|
||||||
|
openPanel();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault();
|
||||||
|
closePanel(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault();
|
||||||
|
moveHighlight(1);
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
moveHighlight(-1);
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
const hit = filteredOptions.value[highlightIndex.value];
|
||||||
|
if (hit) selectOption(hit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDocPointerDown(e: MouseEvent) {
|
||||||
|
if (!open.value) return;
|
||||||
|
const t = e.target as Node;
|
||||||
|
if (containerRef.value?.contains(t)) return;
|
||||||
|
if (panelRef.value?.contains(t)) return;
|
||||||
|
closePanel(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWinChange() {
|
||||||
|
if (open.value) updatePanelPlacement();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('mousedown', onDocPointerDown, true);
|
||||||
|
window.addEventListener('resize', onWinChange);
|
||||||
|
window.addEventListener('scroll', onWinChange, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('mousedown', onDocPointerDown, true);
|
||||||
|
window.removeEventListener('resize', onWinChange);
|
||||||
|
window.removeEventListener('scroll', onWinChange, true);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="containerRef"
|
||||||
|
class="chinese-drug-way-bubble"
|
||||||
|
:class="[
|
||||||
|
{
|
||||||
|
'chinese-drug-way-bubble--compact': compact && trigger !== 'link',
|
||||||
|
'chinese-drug-way-bubble--link': trigger === 'link',
|
||||||
|
},
|
||||||
|
inputClass,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<!-- 第二行文字链:只占文字宽度,点开仍是同一套煎法气泡 -->
|
||||||
|
<Button
|
||||||
|
v-if="trigger === 'link'"
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
html-type="button"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="chinese-drug-way-bubble__link"
|
||||||
|
@click="onLinkClick"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
>
|
||||||
|
<span>{{ displayLabel }}</span>
|
||||||
|
<DownOutlined class="chinese-drug-way-bubble__caret" />
|
||||||
|
</Button>
|
||||||
|
<Input
|
||||||
|
v-else
|
||||||
|
:value="open ? keyword : displayLabel"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
size="small"
|
||||||
|
readonly
|
||||||
|
class="chinese-drug-way-bubble__trigger"
|
||||||
|
@focus="handleFocus"
|
||||||
|
@click="openPanel"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
>
|
||||||
|
<template #suffix>
|
||||||
|
<DownOutlined class="text-muted-foreground text-xs" />
|
||||||
|
</template>
|
||||||
|
</Input>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
ref="panelRef"
|
||||||
|
class="chinese-drug-way-bubble__panel"
|
||||||
|
:style="panelStyle"
|
||||||
|
>
|
||||||
|
<div class="chinese-drug-way-bubble__search">
|
||||||
|
<Input
|
||||||
|
ref="searchInputRef"
|
||||||
|
v-model:value="keyword"
|
||||||
|
size="small"
|
||||||
|
allow-clear
|
||||||
|
placeholder="筛选煎法"
|
||||||
|
@input="handleInput"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="chinese-drug-way-bubble__list">
|
||||||
|
<div
|
||||||
|
v-for="(opt, idx) in filteredOptions"
|
||||||
|
:key="opt.id"
|
||||||
|
class="chinese-drug-way-bubble__item"
|
||||||
|
:class="{
|
||||||
|
active: Number(modelValue) === Number(opt.id),
|
||||||
|
highlight: idx === highlightIndex,
|
||||||
|
}"
|
||||||
|
@mousedown.prevent="selectOption(opt)"
|
||||||
|
@mouseenter="highlightIndex = idx"
|
||||||
|
>
|
||||||
|
{{ opt.name }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!filteredOptions.length"
|
||||||
|
class="chinese-drug-way-bubble__empty"
|
||||||
|
>
|
||||||
|
无匹配煎法
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chinese-drug-way-bubble {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 72px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chinese-drug-way-bubble--compact {
|
||||||
|
width: 88px;
|
||||||
|
max-width: 88px;
|
||||||
|
flex: 0 0 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文字链不锁宽,避免再把单位挤出可视区 */
|
||||||
|
.chinese-drug-way-bubble--link {
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chinese-drug-way-bubble--link :deep(.chinese-drug-way-bubble__link.ant-btn) {
|
||||||
|
height: 22px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chinese-drug-way-bubble__caret {
|
||||||
|
margin-left: 2px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chinese-drug-way-bubble--compact :deep(.ant-input-affix-wrapper),
|
||||||
|
.chinese-drug-way-bubble--compact :deep(.ant-input) {
|
||||||
|
width: 100% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chinese-drug-way-bubble__trigger {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chinese-drug-way-bubble__trigger :deep(input) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Teleport 到 body,不用 scoped */
|
||||||
|
.chinese-drug-way-bubble__panel {
|
||||||
|
overflow: hidden;
|
||||||
|
background: hsl(var(--popover, var(--background)));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 1px hsl(var(--primary) / 8%),
|
||||||
|
0 12px 28px -12px rgb(0 0 0 / 28%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chinese-drug-way-bubble__search {
|
||||||
|
padding: 8px 8px 4px;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chinese-drug-way-bubble__list {
|
||||||
|
max-height: 240px;
|
||||||
|
padding: 4px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chinese-drug-way-bubble__item {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: background 0.12s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chinese-drug-way-bubble__item:hover,
|
||||||
|
.chinese-drug-way-bubble__item.highlight {
|
||||||
|
background: hsl(var(--accent, var(--muted) / 0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chinese-drug-way-bubble__item.active {
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
background: hsl(var(--primary) / 0.1);
|
||||||
|
box-shadow: inset 0 0 0 1px hsl(var(--primary) / 25%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chinese-drug-way-bubble__empty {
|
||||||
|
padding: 16px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* 中药煎法/用法气泡选择器
|
||||||
|
*/
|
||||||
|
export { default as ChineseDrugWayBubble } from './chinese-drug-way-bubble.vue';
|
||||||
|
export type { DrugWayOption } from './chinese-drug-way-bubble.vue';
|
||||||
@@ -16,7 +16,7 @@ import { SearchOutlined, LoadingOutlined } from '@ant-design/icons-vue';
|
|||||||
import { Button, Input, Spin } from 'ant-design-vue';
|
import { Button, Input, Spin } from 'ant-design-vue';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
|
|
||||||
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
|
import { getProductListDoctorReception, getUsedExcipientListDoctorReception } from '#/views/doctor/doctor-reception/api';
|
||||||
|
|
||||||
/** 无图时占位(中药/西药等统一兜底,避免仓绑药下拉只剩文字) */
|
/** 无图时占位(中药/西药等统一兜底,避免仓绑药下拉只剩文字) */
|
||||||
const DRUG_PLACEHOLDER_IMAGE =
|
const DRUG_PLACEHOLDER_IMAGE =
|
||||||
@@ -27,7 +27,7 @@ const DRUG_PLACEHOLDER_IMAGE =
|
|||||||
interface Props {
|
interface Props {
|
||||||
/**
|
/**
|
||||||
* 药品类型
|
* 药品类型
|
||||||
* 1-中药,2-西药
|
* 1-中药,2-西药,8-辅料
|
||||||
*/
|
*/
|
||||||
type?: number;
|
type?: number;
|
||||||
/**
|
/**
|
||||||
@@ -100,6 +100,11 @@ const searchResults = ref<any[]>([]);
|
|||||||
*/
|
*/
|
||||||
const showDropdown = ref(false);
|
const showDropdown = ref(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 当前列表是否「用过辅料」空搜结果(用于提示)
|
||||||
|
*/
|
||||||
|
const showingUsedExcipients = ref(false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 组件容器引用
|
* 组件容器引用
|
||||||
*/
|
*/
|
||||||
@@ -127,41 +132,19 @@ function resolveDropdownImage(item: any): string {
|
|||||||
return item._image || DRUG_PLACEHOLDER_IMAGE;
|
return item._image || DRUG_PLACEHOLDER_IMAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 方法定义 ====================
|
/**
|
||||||
|
* 是否辅料单类型模式(空搜拉「用过」)
|
||||||
|
*/
|
||||||
|
function isExcipientSingleType(): boolean {
|
||||||
|
return props.types.length === 0 && Number(props.type) === 8;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 搜索药品
|
* 把门店/主库商品行打平成下拉展示字段
|
||||||
* @param keyword 搜索关键词
|
|
||||||
* @description 调用后端API搜索药品,支持按名称和拼音搜索;
|
|
||||||
* 传入 types 时允许空关键词拉取该类型全量列表(用于下拉展开)
|
|
||||||
*/
|
*/
|
||||||
const searchDrugs = debounce(async (keyword: string) => {
|
function mapProductRows(res: any): any[] {
|
||||||
// 单类型模式:空关键词清空;多类型模式:空关键词仍请求后端拉全量
|
if (!Array.isArray(res)) return [];
|
||||||
if ((!keyword || keyword.length < 1) && props.types.length === 0) {
|
return res.map((item: any) => {
|
||||||
searchResults.value = [];
|
|
||||||
showDropdown.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isSearching.value = true;
|
|
||||||
showDropdown.value = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// types 优先:有多类型时只传 types,不传 type,避免后端被单 type 覆盖
|
|
||||||
const typeParams =
|
|
||||||
props.types.length > 0
|
|
||||||
? { types: props.types }
|
|
||||||
: { type: props.type };
|
|
||||||
const res = await getProductListDoctorReception({
|
|
||||||
name: keyword || '',
|
|
||||||
...typeParams,
|
|
||||||
store_id: props.storeId,
|
|
||||||
...(props.registerId ? { register_id: props.registerId } : {}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 处理返回数据(兼容门店 relation 与平台主库打平结构)
|
|
||||||
if (Array.isArray(res)) {
|
|
||||||
searchResults.value = res.map((item: any) => {
|
|
||||||
const drug = item.drug || {};
|
const drug = item.drug || {};
|
||||||
return {
|
return {
|
||||||
...item,
|
...item,
|
||||||
@@ -170,6 +153,8 @@ const searchDrugs = debounce(async (keyword: string) => {
|
|||||||
_drugName: drug.drug_name || item.drug_name || '',
|
_drugName: drug.drug_name || item.drug_name || '',
|
||||||
_image: drug.image || item.image || '',
|
_image: drug.image || item.image || '',
|
||||||
_specification: drug.specification || item.specification || '',
|
_specification: drug.specification || item.specification || '',
|
||||||
|
// 辅料等按药配置的开方快捷数量(逗号串),供开方侧解析成按钮
|
||||||
|
_quickNumbers: drug.quick_numbers || item.quick_numbers || '',
|
||||||
_supplier: drug.supplier?.name || item.supplier?.name || '',
|
_supplier: drug.supplier?.name || item.supplier?.name || '',
|
||||||
_price: Number(item.price ?? 0),
|
_price: Number(item.price ?? 0),
|
||||||
// 已绑定配送仓:列表展示「多仓」标识
|
// 已绑定配送仓:列表展示「多仓」标识
|
||||||
@@ -184,12 +169,59 @@ const searchDrugs = debounce(async (keyword: string) => {
|
|||||||
_useFrequency: drug.useFrequency,
|
_useFrequency: drug.useFrequency,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
// ==================== 方法定义 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索药品
|
||||||
|
* @param keyword 搜索关键词
|
||||||
|
* @description 调用后端API搜索药品;辅料(type=8)空关键词拉「用过」列表并展开;
|
||||||
|
* 传入 types 时允许空关键词拉取该类型全量列表(用于下拉展开)
|
||||||
|
*/
|
||||||
|
const searchDrugs = debounce(async (keyword: string) => {
|
||||||
|
const kw = String(keyword || '').trim();
|
||||||
|
// 单类型非辅料:空关键词清空并收起
|
||||||
|
if (!kw && props.types.length === 0 && !isExcipientSingleType()) {
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
|
showingUsedExcipients.value = false;
|
||||||
|
showDropdown.value = false;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isSearching.value = true;
|
||||||
|
showDropdown.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res: any = [];
|
||||||
|
if (!kw && isExcipientSingleType()) {
|
||||||
|
// 辅料空搜:医生云端「用过辅料」
|
||||||
|
res = await getUsedExcipientListDoctorReception({
|
||||||
|
store_id: props.storeId,
|
||||||
|
...(props.registerId ? { register_id: props.registerId } : {}),
|
||||||
|
limit: 30,
|
||||||
|
});
|
||||||
|
showingUsedExcipients.value = true;
|
||||||
|
} else {
|
||||||
|
showingUsedExcipients.value = false;
|
||||||
|
// types 优先:有多类型时只传 types,不传 type,避免后端被单 type 覆盖
|
||||||
|
const typeParams =
|
||||||
|
props.types.length > 0
|
||||||
|
? { types: props.types }
|
||||||
|
: { type: props.type };
|
||||||
|
res = await getProductListDoctorReception({
|
||||||
|
name: kw || '',
|
||||||
|
...typeParams,
|
||||||
|
store_id: props.storeId,
|
||||||
|
...(props.registerId ? { register_id: props.registerId } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
searchResults.value = mapProductRows(res);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('搜索药品失败:', error);
|
console.error('搜索药品失败:', error);
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
|
showingUsedExcipients.value = false;
|
||||||
} finally {
|
} finally {
|
||||||
isSearching.value = false;
|
isSearching.value = false;
|
||||||
}
|
}
|
||||||
@@ -208,7 +240,7 @@ function handleInput(e: Event) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理输入框获得焦点
|
* 处理输入框获得焦点
|
||||||
* @description 多类型模式聚焦即拉取列表并展开;单类型模式仅在已有结果时展示下拉
|
* @description 多类型 / 辅料:聚焦即拉取列表并展开;其它单类型仅在已有结果时展示下拉
|
||||||
*/
|
*/
|
||||||
function handleFocus() {
|
function handleFocus() {
|
||||||
if (props.types.length > 0) {
|
if (props.types.length > 0) {
|
||||||
@@ -216,6 +248,12 @@ function handleFocus() {
|
|||||||
showDropdown.value = true;
|
showDropdown.value = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// 辅料:聚焦空词直接拉「用过」并展开气泡
|
||||||
|
if (isExcipientSingleType()) {
|
||||||
|
searchDrugs(searchKeyword.value || '');
|
||||||
|
showDropdown.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (searchResults.value.length > 0) {
|
if (searchResults.value.length > 0) {
|
||||||
showDropdown.value = true;
|
showDropdown.value = true;
|
||||||
}
|
}
|
||||||
@@ -231,6 +269,7 @@ function handleSelectDrug(drug: any) {
|
|||||||
searchKeyword.value = '';
|
searchKeyword.value = '';
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
showDropdown.value = false;
|
showDropdown.value = false;
|
||||||
|
showingUsedExcipients.value = false;
|
||||||
highlightIndex.value = -1;
|
highlightIndex.value = -1;
|
||||||
if (props.isCard) {
|
if (props.isCard) {
|
||||||
selectedDrug.value = drug;
|
selectedDrug.value = drug;
|
||||||
@@ -319,6 +358,7 @@ watch(
|
|||||||
() => {
|
() => {
|
||||||
searchResults.value = [];
|
searchResults.value = [];
|
||||||
showDropdown.value = false;
|
showDropdown.value = false;
|
||||||
|
showingUsedExcipients.value = false;
|
||||||
searchKeyword.value = '';
|
searchKeyword.value = '';
|
||||||
selectedDrug.value = null;
|
selectedDrug.value = null;
|
||||||
},
|
},
|
||||||
@@ -392,7 +432,7 @@ watch(
|
|||||||
<!-- 加载中 -->
|
<!-- 加载中 -->
|
||||||
<div v-if="isSearching" class="drug-dropdown__loading">
|
<div v-if="isSearching" class="drug-dropdown__loading">
|
||||||
<Spin size="small" />
|
<Spin size="small" />
|
||||||
<span>搜索中...</span>
|
<span>{{ showingUsedExcipients ? '加载用过辅料...' : '搜索中...' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 无结果 -->
|
<!-- 无结果 -->
|
||||||
@@ -400,11 +440,21 @@ watch(
|
|||||||
v-else-if="searchResults.length === 0"
|
v-else-if="searchResults.length === 0"
|
||||||
class="drug-dropdown__empty"
|
class="drug-dropdown__empty"
|
||||||
>
|
>
|
||||||
暂无匹配药品
|
{{
|
||||||
|
showingUsedExcipients
|
||||||
|
? '暂无用过的辅料,请输入名称搜索'
|
||||||
|
: '暂无匹配药品'
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 结果列表 -->
|
<!-- 结果列表 -->
|
||||||
<div v-else class="drug-dropdown__list">
|
<div v-else class="drug-dropdown__list">
|
||||||
|
<div
|
||||||
|
v-if="showingUsedExcipients"
|
||||||
|
class="drug-dropdown__hint"
|
||||||
|
>
|
||||||
|
最近用过的辅料
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in searchResults"
|
v-for="(item, index) in searchResults"
|
||||||
:key="item._id"
|
:key="item._id"
|
||||||
@@ -505,6 +555,12 @@ watch(
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__hint {
|
||||||
|
padding: 6px 12px 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.drug-item {
|
.drug-item {
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 识图传方 / 审方共用的委托调剂(代配)编辑器
|
||||||
|
* 直接复用接诊的制剂方式、制剂要求、煎法、备注,避免再写一套只读文案
|
||||||
|
*/
|
||||||
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import ChineseMedicineConfig, {
|
||||||
|
type ChineseMedicineConfigChange,
|
||||||
|
} from '#/views/doctor/doctor-reception/components/ChineseMedicineConfig.vue';
|
||||||
|
import { getProcessRuleList } from '#/views/doctor/doctor-reception/api';
|
||||||
|
import {
|
||||||
|
applySingleChildProcessRuleAutoSelect,
|
||||||
|
resolveProcessRuleLoadedLevel,
|
||||||
|
} from '#/utils/processRuleCascade';
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
processRuleId?: number | null;
|
||||||
|
childProcessRuleId?: number | null;
|
||||||
|
processRuleNoteId?: number | null;
|
||||||
|
dosage?: number;
|
||||||
|
dayDosage?: number;
|
||||||
|
/** 1 自制剂,2 委托调剂;不传则有规则 ID 时当代配 */
|
||||||
|
ruleType?: number;
|
||||||
|
packageMethodId?: number | null;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** 未填字段名,透传给 ChineseMedicineConfig 标红 */
|
||||||
|
errorKeys?: string[];
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
processRuleId: 0,
|
||||||
|
childProcessRuleId: 0,
|
||||||
|
processRuleNoteId: 0,
|
||||||
|
dosage: 7,
|
||||||
|
dayDosage: 2,
|
||||||
|
ruleType: 0,
|
||||||
|
packageMethodId: 2,
|
||||||
|
disabled: false,
|
||||||
|
errorKeys: () => [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
change: [payload: ChineseMedicineConfigChange];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
/** 传方默认就是委托调剂,否则制剂/煎法列表不会出来 */
|
||||||
|
const ruleType = ref(2);
|
||||||
|
const packageMethodId = ref<number | null>(2);
|
||||||
|
const processRuleId = ref<number | null>(null);
|
||||||
|
const childProcessRuleId = ref<number | null>(null);
|
||||||
|
const processRuleNoteId = ref<number | null>(null);
|
||||||
|
const dosage = ref(7);
|
||||||
|
const dayDosage = ref(2);
|
||||||
|
const processRuleList = ref<Array<{ id: number; name: string }>>([]);
|
||||||
|
const childProcessRuleList = ref<Array<{ id: number; name: string }>>([]);
|
||||||
|
const processRuleNoteList = ref<Array<{ id: number; note?: string; name?: string }>>([]);
|
||||||
|
|
||||||
|
function toId(value: number | null | undefined) {
|
||||||
|
const n = Number(value || 0);
|
||||||
|
return n > 0 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把当前选择一次性抛给父页面,提交和审方都读这一份 */
|
||||||
|
function emitCurrent(extra: ChineseMedicineConfigChange = {}) {
|
||||||
|
emit('change', {
|
||||||
|
ruleType: ruleType.value,
|
||||||
|
packageMethodId: ruleType.value === 1 ? packageMethodId.value : null,
|
||||||
|
processRuleId: ruleType.value === 2 ? processRuleId.value : null,
|
||||||
|
childProcessRuleId: ruleType.value === 2 ? childProcessRuleId.value : null,
|
||||||
|
processRuleNoteId: ruleType.value === 2 ? processRuleNoteId.value : null,
|
||||||
|
dosage: dosage.value,
|
||||||
|
dayDosage: dayDosage.value,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProcessRuleData(pid = 0, ruleId = 0) {
|
||||||
|
const data = ruleId === 0 ? { pid } : { rule_id: ruleId };
|
||||||
|
const value = await getProcessRuleList(data);
|
||||||
|
const list = Array.isArray(value) ? value : [];
|
||||||
|
if (ruleId !== 0) {
|
||||||
|
processRuleNoteList.value = list;
|
||||||
|
} else if (pid === 0) {
|
||||||
|
processRuleList.value = list;
|
||||||
|
} else {
|
||||||
|
childProcessRuleList.value = list;
|
||||||
|
}
|
||||||
|
await applySingleChildProcessRuleAutoSelect(
|
||||||
|
resolveProcessRuleLoadedLevel(pid, ruleId),
|
||||||
|
{
|
||||||
|
ruleType: ruleType.value,
|
||||||
|
disabled: props.disabled,
|
||||||
|
processRuleId: processRuleId.value,
|
||||||
|
childProcessRuleId: childProcessRuleId.value,
|
||||||
|
processRuleNoteId: processRuleNoteId.value,
|
||||||
|
processRuleList: processRuleList.value,
|
||||||
|
childProcessRuleList: childProcessRuleList.value,
|
||||||
|
processRuleNoteList: processRuleNoteList.value,
|
||||||
|
load: loadProcessRuleData,
|
||||||
|
setProcessRuleId: (id) => {
|
||||||
|
processRuleId.value = id;
|
||||||
|
},
|
||||||
|
setChildProcessRuleId: (id) => {
|
||||||
|
childProcessRuleId.value = id;
|
||||||
|
},
|
||||||
|
setProcessRuleNoteId: (id) => {
|
||||||
|
processRuleNoteId.value = id;
|
||||||
|
},
|
||||||
|
clearBelowProcessRule: () => {
|
||||||
|
childProcessRuleId.value = null;
|
||||||
|
processRuleNoteId.value = null;
|
||||||
|
childProcessRuleList.value = [];
|
||||||
|
processRuleNoteList.value = [];
|
||||||
|
},
|
||||||
|
clearBelowChild: () => {
|
||||||
|
processRuleNoteId.value = null;
|
||||||
|
processRuleNoteList.value = [];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 打开已有订单时按已选规则把煎法和备注列表补出来 */
|
||||||
|
async function hydrate() {
|
||||||
|
processRuleId.value = toId(props.processRuleId);
|
||||||
|
childProcessRuleId.value = toId(props.childProcessRuleId);
|
||||||
|
processRuleNoteId.value = toId(props.processRuleNoteId);
|
||||||
|
dosage.value = Number(props.dosage || 7);
|
||||||
|
dayDosage.value = Number(props.dayDosage || 2);
|
||||||
|
if (props.ruleType === 1 || props.ruleType === 2) {
|
||||||
|
ruleType.value = props.ruleType;
|
||||||
|
} else if (processRuleId.value) {
|
||||||
|
ruleType.value = 2;
|
||||||
|
}
|
||||||
|
if (Number(props.packageMethodId || 0) > 0) {
|
||||||
|
packageMethodId.value = Number(props.packageMethodId);
|
||||||
|
}
|
||||||
|
if (processRuleList.value.length === 0) {
|
||||||
|
await loadProcessRuleData();
|
||||||
|
}
|
||||||
|
if (processRuleId.value) {
|
||||||
|
await loadProcessRuleData(processRuleId.value);
|
||||||
|
}
|
||||||
|
if (childProcessRuleId.value) {
|
||||||
|
await loadProcessRuleData(0, childProcessRuleId.value);
|
||||||
|
}
|
||||||
|
const changed =
|
||||||
|
Number(processRuleId.value || 0) !== Number(props.processRuleId || 0) ||
|
||||||
|
Number(childProcessRuleId.value || 0) !== Number(props.childProcessRuleId || 0) ||
|
||||||
|
Number(processRuleNoteId.value || 0) !== Number(props.processRuleNoteId || 0);
|
||||||
|
if (changed) {
|
||||||
|
emitCurrent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onChange(payload: ChineseMedicineConfigChange) {
|
||||||
|
if (props.disabled) return;
|
||||||
|
const oldRuleType = ruleType.value;
|
||||||
|
const oldProcessRuleId = processRuleId.value;
|
||||||
|
const oldChildProcessRuleId = childProcessRuleId.value;
|
||||||
|
if (payload.ruleType === 2 && payload.ruleType !== oldRuleType && processRuleList.value.length === 0) {
|
||||||
|
await loadProcessRuleData();
|
||||||
|
}
|
||||||
|
if (payload.ruleType === 1 && oldRuleType === 2) {
|
||||||
|
processRuleId.value = null;
|
||||||
|
childProcessRuleId.value = null;
|
||||||
|
processRuleNoteId.value = null;
|
||||||
|
childProcessRuleList.value = [];
|
||||||
|
processRuleNoteList.value = [];
|
||||||
|
}
|
||||||
|
if (payload.processRuleId != null && payload.processRuleId !== oldProcessRuleId) {
|
||||||
|
childProcessRuleId.value = null;
|
||||||
|
processRuleNoteId.value = null;
|
||||||
|
childProcessRuleList.value = [];
|
||||||
|
processRuleNoteList.value = [];
|
||||||
|
}
|
||||||
|
if (payload.childProcessRuleId != null && payload.childProcessRuleId !== oldChildProcessRuleId) {
|
||||||
|
processRuleNoteId.value = null;
|
||||||
|
processRuleNoteList.value = [];
|
||||||
|
}
|
||||||
|
if (payload.ruleType != null) ruleType.value = payload.ruleType;
|
||||||
|
if (payload.packageMethodId !== undefined) packageMethodId.value = payload.packageMethodId ?? 2;
|
||||||
|
if (payload.processRuleId !== undefined) processRuleId.value = payload.processRuleId;
|
||||||
|
if (payload.childProcessRuleId !== undefined) childProcessRuleId.value = payload.childProcessRuleId;
|
||||||
|
if (payload.processRuleNoteId !== undefined) processRuleNoteId.value = payload.processRuleNoteId;
|
||||||
|
if (payload.dosage != null) dosage.value = payload.dosage;
|
||||||
|
if (payload.dayDosage != null) dayDosage.value = payload.dayDosage;
|
||||||
|
if (payload.processRuleId != null && payload.processRuleId !== oldProcessRuleId) {
|
||||||
|
await loadProcessRuleData(payload.processRuleId);
|
||||||
|
}
|
||||||
|
if (payload.childProcessRuleId != null && payload.childProcessRuleId !== oldChildProcessRuleId) {
|
||||||
|
await loadProcessRuleData(0, payload.childProcessRuleId);
|
||||||
|
}
|
||||||
|
emitCurrent();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
props.processRuleId,
|
||||||
|
props.childProcessRuleId,
|
||||||
|
props.processRuleNoteId,
|
||||||
|
props.dosage,
|
||||||
|
props.dayDosage,
|
||||||
|
props.ruleType,
|
||||||
|
],
|
||||||
|
() => {
|
||||||
|
const same =
|
||||||
|
toId(props.processRuleId) === processRuleId.value &&
|
||||||
|
toId(props.childProcessRuleId) === childProcessRuleId.value &&
|
||||||
|
toId(props.processRuleNoteId) === processRuleNoteId.value &&
|
||||||
|
Number(props.dosage || 7) === dosage.value &&
|
||||||
|
Number(props.dayDosage || 2) === dayDosage.value &&
|
||||||
|
(props.ruleType !== 1 && props.ruleType !== 2 || props.ruleType === ruleType.value);
|
||||||
|
if (same) return;
|
||||||
|
void hydrate();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void hydrate();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ChineseMedicineConfig
|
||||||
|
:rule-type="ruleType"
|
||||||
|
:package-method-id="packageMethodId"
|
||||||
|
:process-rule-id="processRuleId"
|
||||||
|
:child-process-rule-id="childProcessRuleId"
|
||||||
|
:process-rule-note-id="processRuleNoteId"
|
||||||
|
:dosage="dosage"
|
||||||
|
:day-dosage="dayDosage"
|
||||||
|
:process-rule-list="processRuleList"
|
||||||
|
:child-process-rule-list="childProcessRuleList"
|
||||||
|
:process-rule-note-list="processRuleNoteList"
|
||||||
|
:disabled="disabled"
|
||||||
|
:error-keys="errorKeys"
|
||||||
|
@change="onChange"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
* 带快捷预设的搜索时间范围选择器
|
* 带快捷预设的搜索时间范围选择器
|
||||||
* - 上方 RangePicker 手选,下方快捷 link:今天 / 本周 / 本月 / 上一个月 / 下一个月
|
* - 上方 RangePicker 手选,下方快捷 link:今天 / 本周 / 本月 / 上一个月 / 下一个月
|
||||||
* - 支持 valueFormat='YYYY-MM-DD'(VbenForm)或 Dayjs 二元组(对账页)
|
* - 支持 valueFormat='YYYY-MM-DD'(VbenForm)或 Dayjs 二元组(对账页)
|
||||||
|
* - 界面按天展示;写出值时开始为当天 00:00:00、结束为当天 23:59:59,避免漏查当天末笔
|
||||||
*/
|
*/
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
@@ -69,8 +70,9 @@ const innerRange = computed<[Dayjs, Dayjs] | null>({
|
|||||||
emit('change', null);
|
emit('change', null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// formatRangeByValueFormat 内会规范到 00:00:00 / 23:59:59
|
||||||
const formatted = formatRangeByValueFormat(
|
const formatted = formatRangeByValueFormat(
|
||||||
[next[0].startOf('day'), next[1].startOf('day')],
|
[next[0], next[1]],
|
||||||
props.valueFormat,
|
props.valueFormat,
|
||||||
);
|
);
|
||||||
mValue.value = formatted as [string, string] | [Dayjs, Dayjs];
|
mValue.value = formatted as [string, string] | [Dayjs, Dayjs];
|
||||||
@@ -145,7 +147,6 @@ const pickerValue = computed<[Dayjs, Dayjs] | undefined>({
|
|||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:format="format"
|
:format="format"
|
||||||
:value-format="valueFormat"
|
|
||||||
class="search-time-range-picker__picker w-full"
|
class="search-time-range-picker__picker w-full"
|
||||||
@change="onPickerChange"
|
@change="onPickerChange"
|
||||||
/>
|
/>
|
||||||
|
|||||||
872
apps/web-antd/src/components/loading/OcrImageLoading.vue
Normal file
872
apps/web-antd/src/components/loading/OcrImageLoading.vue
Normal file
@@ -0,0 +1,872 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 识图开方/传方加载动画
|
||||||
|
* 视觉与交互对齐 sql/doc/ai识别图片转成处方的加载动画.html:
|
||||||
|
* 扫描原图 → 进度条 → 结构化处方行逐条揭示
|
||||||
|
* 暗色跟随页面 .dark,不用 prefers-color-scheme 以免与后台主题冲突
|
||||||
|
*/
|
||||||
|
import { onMounted, onUnmounted, ref } from 'vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'OcrImageLoading' });
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 主标题 */
|
||||||
|
title?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
title: 'AI 处方识别',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardRef = ref<HTMLElement | null>(null);
|
||||||
|
const docRef = ref<HTMLElement | null>(null);
|
||||||
|
const sharpRef = ref<HTMLElement | null>(null);
|
||||||
|
const scannerRef = ref<HTMLElement | null>(null);
|
||||||
|
const fillRef = ref<HTMLElement | null>(null);
|
||||||
|
const pctRef = ref<HTMLElement | null>(null);
|
||||||
|
const rowRefs = ref<(HTMLElement | null)[]>([]);
|
||||||
|
|
||||||
|
const SCAN_MS = 3000;
|
||||||
|
const HOLD_MS = 1600;
|
||||||
|
const CYCLE = SCAN_MS + HOLD_MS;
|
||||||
|
const THRESHOLDS = [0.08, 0.24, 0.4, 0.54, 0.68, 0.82];
|
||||||
|
|
||||||
|
/** 演示用中药行(仅动画占位,不代表真实识别结果) */
|
||||||
|
const DEMO_ROWS = [
|
||||||
|
{ tag: '患者', text: '待识别', rx: false },
|
||||||
|
{ tag: '诊断', text: '解析中…', rx: false },
|
||||||
|
{ tag: 'Rx', text: '炙黄芪 15 g', rx: true },
|
||||||
|
{ tag: 'Rx', text: '山楂 9 g', rx: true },
|
||||||
|
{ tag: 'Rx', text: '龙胆草 5 g', rx: true },
|
||||||
|
{ tag: '医嘱', text: '解析中…', rx: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
let rafId = 0;
|
||||||
|
let unsetTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let resizeObs: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const card = cardRef.value;
|
||||||
|
const docEl = docRef.value;
|
||||||
|
const sharp = sharpRef.value;
|
||||||
|
const scanner = scannerRef.value;
|
||||||
|
const fill = fillRef.value;
|
||||||
|
const pctEl = pctRef.value;
|
||||||
|
if (!card || !docEl || !sharp || !scanner || !fill || !pctEl) return;
|
||||||
|
|
||||||
|
let docH = 0;
|
||||||
|
const measure = () => {
|
||||||
|
docH = docEl.clientHeight;
|
||||||
|
};
|
||||||
|
measure();
|
||||||
|
if (typeof ResizeObserver !== 'undefined') {
|
||||||
|
resizeObs = new ResizeObserver(measure);
|
||||||
|
resizeObs.observe(docEl);
|
||||||
|
}
|
||||||
|
|
||||||
|
let t0 = performance.now();
|
||||||
|
let prevT = -1;
|
||||||
|
|
||||||
|
const frame = (now: number) => {
|
||||||
|
const t = (now - t0) % CYCLE;
|
||||||
|
if (t < prevT) {
|
||||||
|
card.classList.add('no-anim');
|
||||||
|
if (unsetTimer) clearTimeout(unsetTimer);
|
||||||
|
unsetTimer = setTimeout(() => card.classList.remove('no-anim'), 60);
|
||||||
|
}
|
||||||
|
prevT = t;
|
||||||
|
|
||||||
|
const p = Math.min(t / SCAN_MS, 1);
|
||||||
|
const done = t >= SCAN_MS;
|
||||||
|
if (!docH) measure();
|
||||||
|
scanner.style.transform = `translateY(${(p * docH).toFixed(1)}px)`;
|
||||||
|
scanner.style.opacity = done ? '0' : p < 0.015 ? '0' : '1';
|
||||||
|
sharp.style.clipPath = `inset(0 0 ${((1 - p) * 100).toFixed(2)}% 0)`;
|
||||||
|
fill.style.width = `${(p * 100).toFixed(1)}%`;
|
||||||
|
pctEl.textContent = String(Math.round(p * 100));
|
||||||
|
card.classList.toggle('is-done', done);
|
||||||
|
const rows = rowRefs.value.filter(Boolean) as HTMLElement[];
|
||||||
|
for (let i = 0; i < rows.length; i++) {
|
||||||
|
rows[i].classList.toggle('show', p >= THRESHOLDS[i]);
|
||||||
|
}
|
||||||
|
rafId = requestAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
rafId = requestAnimationFrame(frame);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (rafId) cancelAnimationFrame(rafId);
|
||||||
|
if (unsetTimer) clearTimeout(unsetTimer);
|
||||||
|
resizeObs?.disconnect();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ocr-load">
|
||||||
|
<div ref="cardRef" class="ocr-load-card">
|
||||||
|
<div class="ocr-load-head">
|
||||||
|
<div class="ocr-load-logo">
|
||||||
|
<span class="ocr-load-logo-halo" aria-hidden="true" />
|
||||||
|
<svg class="ocr-load-logo-mark" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="ocrLoadLogoCross"
|
||||||
|
x1="6"
|
||||||
|
y1="6"
|
||||||
|
x2="18"
|
||||||
|
y2="18"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
>
|
||||||
|
<stop offset="0" stop-color="#5EEAD4" />
|
||||||
|
<stop offset=".48" stop-color="#10B981" />
|
||||||
|
<stop offset="1" stop-color="#047857" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
d="M12 6.6v10.8M6.6 12h10.8"
|
||||||
|
stroke="url(#ocrLoadLogoCross)"
|
||||||
|
stroke-width="2.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ocr-load-meta">
|
||||||
|
<div class="ocr-load-title">{{ title }}</div>
|
||||||
|
<div class="ocr-load-sub">
|
||||||
|
<span class="ocr-load-pulse" />
|
||||||
|
<span class="ocr-load-swap">
|
||||||
|
<span class="s1">正在解析处方图像…</span>
|
||||||
|
<span class="s2">识别完成 · 已生成电子处方</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ocr-load-pct">
|
||||||
|
<span ref="pctRef">0</span><i>%</i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ocr-load-track">
|
||||||
|
<div ref="fillRef" class="ocr-load-fill" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ocr-load-stage">
|
||||||
|
<div class="ocr-load-panel-src">
|
||||||
|
<div class="ocr-load-label">原始处方图像</div>
|
||||||
|
<div ref="docRef" class="ocr-load-doc">
|
||||||
|
<div class="ocr-load-paper blur" aria-hidden="true">
|
||||||
|
<div class="p-top">
|
||||||
|
<span class="p-cross" />
|
||||||
|
<span class="p-lines"><i style="width: 56%" /><i style="width: 30%" /></span>
|
||||||
|
</div>
|
||||||
|
<span class="p-rule" />
|
||||||
|
<div class="p-body">
|
||||||
|
<i style="width: 94%" /><i style="width: 78%" />
|
||||||
|
<i style="width: 88%" /><i style="width: 66%" />
|
||||||
|
<i style="width: 82%" /><i style="width: 48%" />
|
||||||
|
</div>
|
||||||
|
<span class="p-seal" />
|
||||||
|
</div>
|
||||||
|
<div ref="sharpRef" class="ocr-load-paper sharp">
|
||||||
|
<div class="p-top">
|
||||||
|
<span class="p-cross" />
|
||||||
|
<span class="p-lines"><i style="width: 56%" /><i style="width: 30%" /></span>
|
||||||
|
</div>
|
||||||
|
<span class="p-rule" />
|
||||||
|
<div class="p-body">
|
||||||
|
<i style="width: 94%" /><i style="width: 78%" />
|
||||||
|
<i style="width: 88%" /><i style="width: 66%" />
|
||||||
|
<i style="width: 82%" /><i style="width: 48%" />
|
||||||
|
</div>
|
||||||
|
<span class="p-seal" />
|
||||||
|
</div>
|
||||||
|
<div ref="scannerRef" class="ocr-load-scanner">
|
||||||
|
<div class="glow" />
|
||||||
|
<div class="line" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ocr-load-arrow" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none">
|
||||||
|
<path
|
||||||
|
d="M4.5 12h13M12.5 7l5 5-5 5"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.8"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ocr-load-panel-res">
|
||||||
|
<div class="ocr-load-label">结构化处方</div>
|
||||||
|
<div class="ocr-load-result">
|
||||||
|
<div
|
||||||
|
v-for="(row, i) in DEMO_ROWS"
|
||||||
|
:key="i"
|
||||||
|
:ref="(el) => { rowRefs[i] = el as HTMLElement | null }"
|
||||||
|
class="ocr-load-rrow"
|
||||||
|
:class="{ rx: row.rx }"
|
||||||
|
>
|
||||||
|
<span class="tag">{{ row.tag }}</span>
|
||||||
|
<span class="val">{{ row.text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ocr-load {
|
||||||
|
--g-300: #6ee7b7;
|
||||||
|
--g-400: #34d399;
|
||||||
|
--g-500: #10b981;
|
||||||
|
--g-600: #059669;
|
||||||
|
--g-700: #047857;
|
||||||
|
--ink: #0b3a2c;
|
||||||
|
--ink-2: #4e7d6c;
|
||||||
|
--ink-3: #8aab9e;
|
||||||
|
--glass-a: rgba(255, 255, 255, 0.7);
|
||||||
|
--glass-b: rgba(255, 255, 255, 0.34);
|
||||||
|
--edge-a: rgba(255, 255, 255, 0.95);
|
||||||
|
--edge-b: rgba(255, 255, 255, 0.06);
|
||||||
|
--sheen: rgba(255, 255, 255, 0.7);
|
||||||
|
--ring: rgba(255, 255, 255, 0.6);
|
||||||
|
--panel: rgba(255, 255, 255, 0.38);
|
||||||
|
--hairline2: rgba(11, 58, 44, 0.06);
|
||||||
|
--paper: #fff;
|
||||||
|
--paper-line: #dceae5;
|
||||||
|
--logo-c1: #5eead4;
|
||||||
|
--logo-c2: #10b981;
|
||||||
|
--logo-c3: #047857;
|
||||||
|
--shadow:
|
||||||
|
0 40px 80px -42px rgba(3, 55, 40, 0.42),
|
||||||
|
0 16px 36px -24px rgba(3, 55, 40, 0.26),
|
||||||
|
0 2px 6px -2px rgba(3, 55, 40, 0.08);
|
||||||
|
--shadow-paper:
|
||||||
|
0 12px 26px -16px rgba(3, 55, 40, 0.45),
|
||||||
|
0 0 0 1px rgba(11, 58, 44, 0.05);
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 280px;
|
||||||
|
padding: 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.dark) .ocr-load {
|
||||||
|
--g-300: #5eead4;
|
||||||
|
--g-400: #34d399;
|
||||||
|
--g-500: #2bd9a0;
|
||||||
|
--g-600: #10b981;
|
||||||
|
--g-700: #7bf0c8;
|
||||||
|
--ink: #e9f7f2;
|
||||||
|
--ink-2: #9dc2b6;
|
||||||
|
--ink-3: #6f9388;
|
||||||
|
--glass-a: rgba(28, 43, 38, 0.58);
|
||||||
|
--glass-b: rgba(10, 20, 17, 0.34);
|
||||||
|
--edge-a: rgba(255, 255, 255, 0.32);
|
||||||
|
--edge-b: rgba(255, 255, 255, 0.02);
|
||||||
|
--sheen: rgba(255, 255, 255, 0.13);
|
||||||
|
--ring: rgba(255, 255, 255, 0.1);
|
||||||
|
--panel: rgba(255, 255, 255, 0.035);
|
||||||
|
--hairline2: rgba(255, 255, 255, 0.055);
|
||||||
|
--paper: #e9f3ef;
|
||||||
|
--paper-line: #c9dcd4;
|
||||||
|
--logo-c1: #b9f6e0;
|
||||||
|
--logo-c2: #34d399;
|
||||||
|
--logo-c3: #0fa97d;
|
||||||
|
--shadow:
|
||||||
|
0 40px 90px -38px rgba(0, 0, 0, 0.85),
|
||||||
|
0 16px 40px -26px rgba(0, 0, 0, 0.6),
|
||||||
|
0 2px 6px -2px rgba(0, 0, 0, 0.4);
|
||||||
|
--shadow-paper:
|
||||||
|
0 12px 28px -16px rgba(0, 0, 0, 0.7),
|
||||||
|
0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-card {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
width: min(720px, 100%);
|
||||||
|
padding: 22px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: linear-gradient(150deg, var(--glass-a), var(--glass-b));
|
||||||
|
backdrop-filter: blur(34px) saturate(190%) brightness(1.05);
|
||||||
|
box-shadow:
|
||||||
|
var(--shadow),
|
||||||
|
inset 0 1px 0 0 var(--ring),
|
||||||
|
inset 0 -1px 0 0 rgba(255, 255, 255, 0.06);
|
||||||
|
transition: box-shadow 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 1px;
|
||||||
|
pointer-events: none;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(
|
||||||
|
150deg,
|
||||||
|
var(--edge-a) 0%,
|
||||||
|
var(--edge-b) 30%,
|
||||||
|
var(--edge-b) 58%,
|
||||||
|
var(--edge-a) 100%
|
||||||
|
);
|
||||||
|
mask:
|
||||||
|
linear-gradient(#000 0 0) content-box,
|
||||||
|
linear-gradient(#000 0 0);
|
||||||
|
mask-composite: exclude;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-card.is-done {
|
||||||
|
box-shadow:
|
||||||
|
var(--shadow),
|
||||||
|
inset 0 1px 0 0 var(--ring),
|
||||||
|
0 0 0 1px rgba(16, 185, 129, 0.2),
|
||||||
|
0 0 64px -16px rgba(16, 185, 129, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-card.no-anim .ocr-load-rrow {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-head {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-logo {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
background: linear-gradient(
|
||||||
|
150deg,
|
||||||
|
rgba(255, 255, 255, 0.66) 0%,
|
||||||
|
rgba(255, 255, 255, 0.2) 52%,
|
||||||
|
rgba(255, 255, 255, 0.34) 100%
|
||||||
|
);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow:
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.92),
|
||||||
|
inset 0 0 20px rgba(16, 185, 129, 0.12),
|
||||||
|
0 6px 18px -8px rgba(5, 95, 70, 0.3),
|
||||||
|
0 0 0 1px rgba(16, 185, 129, 0.14);
|
||||||
|
backdrop-filter: blur(16px) saturate(200%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-logo-halo {
|
||||||
|
position: absolute;
|
||||||
|
inset: -9px;
|
||||||
|
z-index: -1;
|
||||||
|
pointer-events: none;
|
||||||
|
background: radial-gradient(circle, rgba(16, 185, 129, 0.3), transparent 68%);
|
||||||
|
border-radius: 24px;
|
||||||
|
filter: blur(9px);
|
||||||
|
animation: ocrLogoBreath 3.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ocrLogoBreath {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-logo-mark {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
filter: drop-shadow(0 1px 3px rgba(16, 185, 129, 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-logo-mark :deep(.s1) {
|
||||||
|
stop-color: var(--logo-c1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-logo-mark :deep(.s2) {
|
||||||
|
stop-color: var(--logo-c2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-logo-mark :deep(.s3) {
|
||||||
|
stop-color: var(--logo-c3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-meta {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-title {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--ink);
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-sub {
|
||||||
|
position: relative;
|
||||||
|
height: 17px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-pulse {
|
||||||
|
display: inline-block;
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
margin-right: 7px;
|
||||||
|
vertical-align: 1.5px;
|
||||||
|
background: var(--g-500);
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.55);
|
||||||
|
animation: ocrBeat 1.7s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ocrBeat {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 7px rgba(16, 185, 129, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-card.is-done .ocr-load-pulse {
|
||||||
|
background: var(--g-600);
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-swap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-swap .s1,
|
||||||
|
.ocr-load-swap .s2 {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition:
|
||||||
|
opacity 0.38s ease,
|
||||||
|
transform 0.38s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-swap .s2 {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--g-700);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-card.is-done .ocr-load-swap .s1 {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-card.is-done .ocr-load-swap .s2 {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-pct {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 52px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--g-700);
|
||||||
|
text-align: right;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-pct i {
|
||||||
|
margin-left: 1px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 550;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-track {
|
||||||
|
position: relative;
|
||||||
|
height: 5px;
|
||||||
|
margin: 18px 0 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--hairline2);
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 0%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(90deg, var(--g-400), var(--g-500) 58%, var(--g-600));
|
||||||
|
border-radius: 99px;
|
||||||
|
box-shadow: 0 0 14px rgba(16, 185, 129, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-fill::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.9), transparent);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
animation: ocrSheen 1.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ocrSheen {
|
||||||
|
to {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-stage {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-label {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 10.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-3);
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-label::before {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
content: '';
|
||||||
|
background: var(--g-400);
|
||||||
|
border-radius: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-panel-src {
|
||||||
|
flex: 0 0 200px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-panel-res {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-doc {
|
||||||
|
position: relative;
|
||||||
|
height: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--paper);
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: var(--shadow-paper);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-paper {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 14px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-paper.blur {
|
||||||
|
opacity: 0.66;
|
||||||
|
filter: blur(1.5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-paper.sharp {
|
||||||
|
clip-path: inset(0 0 100% 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-top {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-cross {
|
||||||
|
position: relative;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-cross::before,
|
||||||
|
.p-cross::after {
|
||||||
|
position: absolute;
|
||||||
|
content: '';
|
||||||
|
background: #8fc7b4;
|
||||||
|
border-radius: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-cross::before {
|
||||||
|
top: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
left: 50%;
|
||||||
|
width: 3px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-cross::after {
|
||||||
|
top: 50%;
|
||||||
|
right: 4px;
|
||||||
|
left: 4px;
|
||||||
|
height: 3px;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-lines {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-lines i,
|
||||||
|
.p-body i {
|
||||||
|
display: block;
|
||||||
|
height: 5px;
|
||||||
|
background: var(--paper-line);
|
||||||
|
border-radius: 99px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-lines i + i {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-rule {
|
||||||
|
display: block;
|
||||||
|
height: 1px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
background: var(--paper-line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-body {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-body i {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-seal {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 1.5px dashed rgba(16, 185, 129, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-paper.sharp .p-lines i,
|
||||||
|
.ocr-load-paper.sharp .p-body i {
|
||||||
|
background: linear-gradient(90deg, var(--g-300), var(--g-500));
|
||||||
|
box-shadow: 0 0 8px rgba(16, 185, 129, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-paper.sharp .p-rule {
|
||||||
|
background: linear-gradient(90deg, var(--g-400), transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-paper.sharp .p-cross {
|
||||||
|
background: rgba(16, 185, 129, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-paper.sharp .p-cross::before,
|
||||||
|
.ocr-load-paper.sharp .p-cross::after {
|
||||||
|
background: var(--g-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-paper.sharp .p-seal {
|
||||||
|
border-color: rgba(16, 185, 129, 0.6);
|
||||||
|
box-shadow: 0 0 14px rgba(16, 185, 129, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-scanner {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 4;
|
||||||
|
height: 0;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-scanner .line {
|
||||||
|
position: absolute;
|
||||||
|
top: -1px;
|
||||||
|
right: -2px;
|
||||||
|
left: -2px;
|
||||||
|
height: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0%,
|
||||||
|
rgba(16, 185, 129, 0.85) 14%,
|
||||||
|
#6ee7b7 50%,
|
||||||
|
rgba(16, 185, 129, 0.85) 86%,
|
||||||
|
transparent 100%
|
||||||
|
);
|
||||||
|
box-shadow:
|
||||||
|
0 0 10px 1px rgba(16, 185, 129, 0.9),
|
||||||
|
0 0 30px 6px rgba(16, 185, 129, 0.38);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-scanner .glow {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 70px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to top,
|
||||||
|
rgba(16, 185, 129, 0.24) 0%,
|
||||||
|
rgba(16, 185, 129, 0.09) 46%,
|
||||||
|
rgba(16, 185, 129, 0) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-arrow {
|
||||||
|
display: grid;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
place-items: center;
|
||||||
|
width: 28px;
|
||||||
|
color: var(--g-300);
|
||||||
|
transition:
|
||||||
|
color 0.5s ease,
|
||||||
|
transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-arrow svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-card.is-done .ocr-load-arrow {
|
||||||
|
color: var(--g-600);
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-result {
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--hairline2);
|
||||||
|
border-radius: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-rrow {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--ink);
|
||||||
|
clip-path: inset(0 100% 0 0);
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-5px);
|
||||||
|
transition:
|
||||||
|
clip-path 0.58s cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
opacity 0.3s ease,
|
||||||
|
transform 0.58s cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
will-change: clip-path, transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-rrow + .ocr-load-rrow {
|
||||||
|
border-top: 1px solid var(--hairline2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-rrow.show {
|
||||||
|
clip-path: inset(0 0 0 0);
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-rrow .tag {
|
||||||
|
flex: 0 0 36px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-3);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-rrow.rx .tag {
|
||||||
|
font-family: Georgia, 'Times New Roman', serif;
|
||||||
|
font-size: 13px;
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--g-600);
|
||||||
|
letter-spacing: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-rrow .val {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ink-2);
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-rrow .val :deep(b) {
|
||||||
|
font-weight: 650;
|
||||||
|
color: var(--g-700);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-card.is-done .ocr-load-rrow {
|
||||||
|
background: rgba(16, 185, 129, 0.035);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ocr-load-stage {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-panel-src {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-arrow {
|
||||||
|
width: 100%;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-arrow svg {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-load-doc {
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 处方原图独立预览浮窗
|
||||||
|
* - 用 SubWindow 拖出主抽屉/页面,可拖动窗口、四角改尺寸
|
||||||
|
* - 图内:按住拖拽平移、滚轮/按钮缩放、一键还原
|
||||||
|
* 为什么单独组件:接诊识图抽屉与识图传方页共用同一套对照体验
|
||||||
|
*/
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
LeftOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
ZoomInOutlined,
|
||||||
|
ZoomOutOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { Button } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_SUB_WINDOW_Z_INDEX,
|
||||||
|
} from '#/components/sub-window/constants';
|
||||||
|
import SubWindow from '#/components/sub-window/SubWindow.vue';
|
||||||
|
|
||||||
|
const IMAGE_SCALE_MIN = 0.4;
|
||||||
|
const IMAGE_SCALE_MAX = 4;
|
||||||
|
const IMAGE_SCALE_STEP = 0.2;
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 是否打开浮窗 */
|
||||||
|
open?: boolean;
|
||||||
|
/** 原图 URL 列表 */
|
||||||
|
urls?: string[];
|
||||||
|
title?: string;
|
||||||
|
storageKey?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
open: false,
|
||||||
|
urls: () => [],
|
||||||
|
title: '处方原图预览',
|
||||||
|
storageKey: 'pc_ocr_origin_image_float',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:open': [value: boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const activeIndex = ref(0);
|
||||||
|
const imageScale = ref(1);
|
||||||
|
const imagePanX = ref(0);
|
||||||
|
const imagePanY = ref(0);
|
||||||
|
const imageDragging = ref(false);
|
||||||
|
const imageDragStartX = ref(0);
|
||||||
|
const imageDragStartY = ref(0);
|
||||||
|
const imagePanOriginX = ref(0);
|
||||||
|
const imagePanOriginY = ref(0);
|
||||||
|
|
||||||
|
const urlList = computed(() =>
|
||||||
|
(props.urls || []).filter((u) => !!String(u || '').trim()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentUrl = computed(
|
||||||
|
() => urlList.value[activeIndex.value] || urlList.value[0] || '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const imageTransformStyle = computed(() => ({
|
||||||
|
transform: `translate(${imagePanX.value}px, ${imagePanY.value}px) scale(${imageScale.value})`,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const windowOpen = computed({
|
||||||
|
get: () => props.open,
|
||||||
|
set: (v: boolean) => emit('update:open', v),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 还原缩放与平移到初始态 */
|
||||||
|
function resetImageView() {
|
||||||
|
imageScale.value = 1;
|
||||||
|
imagePanX.value = 0;
|
||||||
|
imagePanY.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
imageScale.value = Math.min(
|
||||||
|
IMAGE_SCALE_MAX,
|
||||||
|
Math.round((imageScale.value + IMAGE_SCALE_STEP) * 100) / 100,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
imageScale.value = Math.max(
|
||||||
|
IMAGE_SCALE_MIN,
|
||||||
|
Math.round((imageScale.value - IMAGE_SCALE_STEP) * 100) / 100,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 滚轮缩放(阻止冒泡,避免被 SubWindow 当成页面滚动拦截) */
|
||||||
|
function onImageWheel(e: WheelEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.deltaY < 0) zoomIn();
|
||||||
|
else zoomOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageMouseDown(e: MouseEvent) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
imageDragging.value = true;
|
||||||
|
imageDragStartX.value = e.clientX;
|
||||||
|
imageDragStartY.value = e.clientY;
|
||||||
|
imagePanOriginX.value = imagePanX.value;
|
||||||
|
imagePanOriginY.value = imagePanY.value;
|
||||||
|
document.addEventListener('mousemove', onImageMouseMove);
|
||||||
|
document.addEventListener('mouseup', onImageMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageMouseMove(e: MouseEvent) {
|
||||||
|
if (!imageDragging.value) return;
|
||||||
|
imagePanX.value = imagePanOriginX.value + (e.clientX - imageDragStartX.value);
|
||||||
|
imagePanY.value = imagePanOriginY.value + (e.clientY - imageDragStartY.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageMouseUp() {
|
||||||
|
imageDragging.value = false;
|
||||||
|
document.removeEventListener('mousemove', onImageMouseMove);
|
||||||
|
document.removeEventListener('mouseup', onImageMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevImage() {
|
||||||
|
if (urlList.value.length <= 1) return;
|
||||||
|
activeIndex.value =
|
||||||
|
(activeIndex.value - 1 + urlList.value.length) % urlList.value.length;
|
||||||
|
resetImageView();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextImage() {
|
||||||
|
if (urlList.value.length <= 1) return;
|
||||||
|
activeIndex.value = (activeIndex.value + 1) % urlList.value.length;
|
||||||
|
resetImageView();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(v) => {
|
||||||
|
if (v) {
|
||||||
|
activeIndex.value = 0;
|
||||||
|
resetImageView();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(urlList, (list) => {
|
||||||
|
if (activeIndex.value >= list.length) {
|
||||||
|
activeIndex.value = Math.max(0, list.length - 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('mousemove', onImageMouseMove);
|
||||||
|
document.removeEventListener('mouseup', onImageMouseUp);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SubWindow
|
||||||
|
v-model:open="windowOpen"
|
||||||
|
:title="title"
|
||||||
|
:storage-key="storageKey"
|
||||||
|
:width="720"
|
||||||
|
:height="780"
|
||||||
|
:min-width="420"
|
||||||
|
:min-height="360"
|
||||||
|
:default-x="24"
|
||||||
|
:default-y="72"
|
||||||
|
:z-index="DEFAULT_SUB_WINDOW_Z_INDEX + 20"
|
||||||
|
>
|
||||||
|
<div class="ocr-origin-float">
|
||||||
|
<div class="ocr-origin-float__toolbar">
|
||||||
|
<Button type="link" size="small" :disabled="urlList.length <= 1" @click="prevImage">
|
||||||
|
<LeftOutlined />
|
||||||
|
</Button>
|
||||||
|
<span class="ocr-origin-float__page">
|
||||||
|
{{ urlList.length ? activeIndex + 1 : 0 }} / {{ urlList.length }}
|
||||||
|
</span>
|
||||||
|
<Button type="link" size="small" :disabled="urlList.length <= 1" @click="nextImage">
|
||||||
|
<RightOutlined />
|
||||||
|
</Button>
|
||||||
|
<span class="ocr-origin-float__sep" />
|
||||||
|
<Button type="link" size="small" @click="zoomOut">
|
||||||
|
<ZoomOutOutlined /> 缩小
|
||||||
|
</Button>
|
||||||
|
<Button type="link" size="small" @click="zoomIn">
|
||||||
|
<ZoomInOutlined /> 放大
|
||||||
|
</Button>
|
||||||
|
<Button type="link" size="small" @click="resetImageView">
|
||||||
|
<ReloadOutlined /> 还原
|
||||||
|
</Button>
|
||||||
|
<span class="ocr-origin-float__percent">{{ Math.round(imageScale * 100) }}%</span>
|
||||||
|
<span class="ocr-origin-float__hint">拖拽平移 · 滚轮缩放</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="ocr-origin-float__body"
|
||||||
|
:class="{ dragging: imageDragging }"
|
||||||
|
@wheel="onImageWheel"
|
||||||
|
@mousedown="onImageMouseDown"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="currentUrl"
|
||||||
|
:src="currentUrl"
|
||||||
|
class="ocr-origin-float__img"
|
||||||
|
:style="imageTransformStyle"
|
||||||
|
alt="处方原图"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
<div v-else class="ocr-origin-float__empty">暂无原图</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SubWindow>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ocr-origin-float {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
background: hsl(var(--background));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-float__toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
background: hsl(var(--muted) / 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-float__page {
|
||||||
|
min-width: 48px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-float__sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 14px;
|
||||||
|
margin: 0 4px;
|
||||||
|
background: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-float__percent {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-float__hint {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 11px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-float__body {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, hsl(var(--muted) / 0.35) 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, hsl(var(--muted) / 0.35) 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, hsl(var(--muted) / 0.35) 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, hsl(var(--muted) / 0.35) 75%);
|
||||||
|
background-position:
|
||||||
|
0 0,
|
||||||
|
0 8px,
|
||||||
|
8px -8px,
|
||||||
|
-8px 0;
|
||||||
|
background-size: 16px 16px;
|
||||||
|
background-color: hsl(var(--card, var(--background)));
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-float__body.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-float__img {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-float__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* 处方原图独立预览浮窗
|
||||||
|
*/
|
||||||
|
export { default as OcrOriginImageFloat } from './OcrOriginImageFloat.vue';
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 处方原图对照模块(内嵌布局,非浮窗)
|
||||||
|
* - 作为独立栏位放在识图结果旁,方便对照药名
|
||||||
|
* - 图内:拖拽平移、滚轮/按钮缩放、一键还原;多图可切换
|
||||||
|
*/
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
LeftOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
ZoomInOutlined,
|
||||||
|
ZoomOutOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { Button } from 'ant-design-vue';
|
||||||
|
|
||||||
|
const IMAGE_SCALE_MIN = 0.4;
|
||||||
|
const IMAGE_SCALE_MAX = 4;
|
||||||
|
const IMAGE_SCALE_STEP = 0.2;
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 原图 URL 列表 */
|
||||||
|
urls?: string[];
|
||||||
|
title?: string;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
urls: () => [],
|
||||||
|
title: '处方原图',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeIndex = ref(0);
|
||||||
|
const imageScale = ref(1);
|
||||||
|
const imagePanX = ref(0);
|
||||||
|
const imagePanY = ref(0);
|
||||||
|
const imageDragging = ref(false);
|
||||||
|
const imageDragStartX = ref(0);
|
||||||
|
const imageDragStartY = ref(0);
|
||||||
|
const imagePanOriginX = ref(0);
|
||||||
|
const imagePanOriginY = ref(0);
|
||||||
|
|
||||||
|
const urlList = computed(() =>
|
||||||
|
(props.urls || []).filter((u) => !!String(u || '').trim()),
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentUrl = computed(
|
||||||
|
() => urlList.value[activeIndex.value] || urlList.value[0] || '',
|
||||||
|
);
|
||||||
|
|
||||||
|
const imageTransformStyle = computed(() => ({
|
||||||
|
transform: `translate(${imagePanX.value}px, ${imagePanY.value}px) scale(${imageScale.value})`,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** 还原缩放与平移 */
|
||||||
|
function resetImageView() {
|
||||||
|
imageScale.value = 1;
|
||||||
|
imagePanX.value = 0;
|
||||||
|
imagePanY.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
imageScale.value = Math.min(
|
||||||
|
IMAGE_SCALE_MAX,
|
||||||
|
Math.round((imageScale.value + IMAGE_SCALE_STEP) * 100) / 100,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOut() {
|
||||||
|
imageScale.value = Math.max(
|
||||||
|
IMAGE_SCALE_MIN,
|
||||||
|
Math.round((imageScale.value - IMAGE_SCALE_STEP) * 100) / 100,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 滚轮缩放 */
|
||||||
|
function onImageWheel(e: WheelEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.deltaY < 0) zoomIn();
|
||||||
|
else zoomOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageMouseDown(e: MouseEvent) {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
e.preventDefault();
|
||||||
|
imageDragging.value = true;
|
||||||
|
imageDragStartX.value = e.clientX;
|
||||||
|
imageDragStartY.value = e.clientY;
|
||||||
|
imagePanOriginX.value = imagePanX.value;
|
||||||
|
imagePanOriginY.value = imagePanY.value;
|
||||||
|
document.addEventListener('mousemove', onImageMouseMove);
|
||||||
|
document.addEventListener('mouseup', onImageMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageMouseMove(e: MouseEvent) {
|
||||||
|
if (!imageDragging.value) return;
|
||||||
|
imagePanX.value = imagePanOriginX.value + (e.clientX - imageDragStartX.value);
|
||||||
|
imagePanY.value = imagePanOriginY.value + (e.clientY - imageDragStartY.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onImageMouseUp() {
|
||||||
|
imageDragging.value = false;
|
||||||
|
document.removeEventListener('mousemove', onImageMouseMove);
|
||||||
|
document.removeEventListener('mouseup', onImageMouseUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevImage() {
|
||||||
|
if (urlList.value.length <= 1) return;
|
||||||
|
activeIndex.value =
|
||||||
|
(activeIndex.value - 1 + urlList.value.length) % urlList.value.length;
|
||||||
|
resetImageView();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextImage() {
|
||||||
|
if (urlList.value.length <= 1) return;
|
||||||
|
activeIndex.value = (activeIndex.value + 1) % urlList.value.length;
|
||||||
|
resetImageView();
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(urlList, (list) => {
|
||||||
|
if (activeIndex.value >= list.length) {
|
||||||
|
activeIndex.value = Math.max(0, list.length - 1);
|
||||||
|
}
|
||||||
|
resetImageView();
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.removeEventListener('mousemove', onImageMouseMove);
|
||||||
|
document.removeEventListener('mouseup', onImageMouseUp);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="ocr-origin-panel">
|
||||||
|
<div class="ocr-origin-panel__head">
|
||||||
|
<span class="ocr-origin-panel__title">{{ title }}</span>
|
||||||
|
<span class="ocr-origin-panel__hint">拖拽平移 · 滚轮缩放</span>
|
||||||
|
</div>
|
||||||
|
<div class="ocr-origin-panel__toolbar">
|
||||||
|
<Button type="link" size="small" :disabled="urlList.length <= 1" @click="prevImage">
|
||||||
|
<LeftOutlined />
|
||||||
|
</Button>
|
||||||
|
<span class="ocr-origin-panel__page">
|
||||||
|
{{ urlList.length ? activeIndex + 1 : 0 }} / {{ urlList.length }}
|
||||||
|
</span>
|
||||||
|
<Button type="link" size="small" :disabled="urlList.length <= 1" @click="nextImage">
|
||||||
|
<RightOutlined />
|
||||||
|
</Button>
|
||||||
|
<span class="ocr-origin-panel__sep" />
|
||||||
|
<Button type="link" size="small" @click="zoomOut">
|
||||||
|
<ZoomOutOutlined /> 缩小
|
||||||
|
</Button>
|
||||||
|
<Button type="link" size="small" @click="zoomIn">
|
||||||
|
<ZoomInOutlined /> 放大
|
||||||
|
</Button>
|
||||||
|
<Button type="link" size="small" @click="resetImageView">
|
||||||
|
<ReloadOutlined /> 还原
|
||||||
|
</Button>
|
||||||
|
<span class="ocr-origin-panel__percent">{{ Math.round(imageScale * 100) }}%</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="ocr-origin-panel__body"
|
||||||
|
:class="{ dragging: imageDragging }"
|
||||||
|
@wheel="onImageWheel"
|
||||||
|
@mousedown="onImageMouseDown"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="currentUrl"
|
||||||
|
:src="currentUrl"
|
||||||
|
class="ocr-origin-panel__img"
|
||||||
|
:style="imageTransformStyle"
|
||||||
|
alt="处方原图"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
<div v-else class="ocr-origin-panel__empty">暂无原图</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ocr-origin-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
border: 1px solid hsl(var(--primary) / 30%);
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 0 8px hsl(var(--primary) / 16%);
|
||||||
|
/* 挂载时内容再淡入一层,配合外栏宽度展开,避免整块硬切 */
|
||||||
|
animation: ocr-origin-panel-in 0.8s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes ocr-origin-panel-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-panel__head {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 10px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-panel__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-panel__hint {
|
||||||
|
font-size: 11px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-panel__toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px 4px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 6px 6px;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-panel__page {
|
||||||
|
min-width: 44px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-panel__sep {
|
||||||
|
width: 1px;
|
||||||
|
height: 14px;
|
||||||
|
margin: 0 4px;
|
||||||
|
background: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-panel__percent {
|
||||||
|
margin-left: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-panel__body {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 280px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: grab;
|
||||||
|
user-select: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(45deg, hsl(var(--muted) / 0.35) 25%, transparent 25%),
|
||||||
|
linear-gradient(-45deg, hsl(var(--muted) / 0.35) 25%, transparent 25%),
|
||||||
|
linear-gradient(45deg, transparent 75%, hsl(var(--muted) / 0.35) 75%),
|
||||||
|
linear-gradient(-45deg, transparent 75%, hsl(var(--muted) / 0.35) 75%);
|
||||||
|
background-color: hsl(var(--muted) / 0.2);
|
||||||
|
background-position:
|
||||||
|
0 0,
|
||||||
|
0 8px,
|
||||||
|
8px -8px,
|
||||||
|
-8px 0;
|
||||||
|
background-size: 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-panel__body.dragging {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-panel__img {
|
||||||
|
display: block;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
pointer-events: none;
|
||||||
|
will-change: transform;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-origin-panel__empty {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* 处方原图对照模块(内嵌布局栏,非浮窗)
|
||||||
|
*/
|
||||||
|
export { default as OcrOriginImagePanel } from './OcrOriginImagePanel.vue';
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
/**
|
/**
|
||||||
* 门店详情 - 资金账户面板(余额 + 资金流水 + 账户变动)
|
* 门店详情 - 资金账户面板(余额 + 资金流水 + 账户变动)
|
||||||
* 仅超管/系统管理员在 StoreCardModal 中可见,数据按门店 ID 查询
|
* 仅超管/系统管理员在 StoreCardModal 中可见,数据按门店 ID 查询
|
||||||
|
* 口径说明:资金流水按履约店 store_id;账户变动/余额按分账利润归属店 user_id
|
||||||
*/
|
*/
|
||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
|
|
||||||
@@ -65,6 +66,9 @@ const fundWaterColumns = [
|
|||||||
{ title: '金额', dataIndex: 'price', key: 'price', width: 110 },
|
{ title: '金额', dataIndex: 'price', key: 'price', width: 110 },
|
||||||
{ title: '流水类型', dataIndex: 'type', key: 'type', width: 110 },
|
{ title: '流水类型', dataIndex: 'type', key: 'type', width: 110 },
|
||||||
{ title: '订单类型', dataIndex: 'order_type', key: 'order_type', width: 110 },
|
{ title: '订单类型', dataIndex: 'order_type', key: 'order_type', width: 110 },
|
||||||
|
// store_id 便于核对是否本店履约(委托复诊时流水店与利润店可能不同)
|
||||||
|
{ title: '门店ID', dataIndex: 'store_id', key: 'store_id', width: 90 },
|
||||||
|
{ title: '诊所', dataIndex: 'store_name', key: 'store_name', width: 120, ellipsis: true },
|
||||||
{ title: '订单号', dataIndex: 'order_no', key: 'order_no', ellipsis: true },
|
{ title: '订单号', dataIndex: 'order_no', key: 'order_no', ellipsis: true },
|
||||||
{ title: '医生', dataIndex: 'doctor_name', key: 'doctor_name', width: 100 },
|
{ title: '医生', dataIndex: 'doctor_name', key: 'doctor_name', width: 100 },
|
||||||
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||||
@@ -77,6 +81,7 @@ const changeLogColumns = [
|
|||||||
{ title: '变动前', dataIndex: 'before_amount', key: 'before_amount', width: 100 },
|
{ title: '变动前', dataIndex: 'before_amount', key: 'before_amount', width: 100 },
|
||||||
{ title: '变动后', dataIndex: 'after_amount', key: 'after_amount', width: 100 },
|
{ title: '变动后', dataIndex: 'after_amount', key: 'after_amount', width: 100 },
|
||||||
{ title: '变动值', dataIndex: 'change_amount', key: 'change_amount', width: 100 },
|
{ title: '变动值', dataIndex: 'change_amount', key: 'change_amount', width: 100 },
|
||||||
|
{ title: '主体ID', dataIndex: 'user_id', key: 'user_id', width: 90 },
|
||||||
{ title: '订单号', dataIndex: 'order_no', key: 'order_no', ellipsis: true },
|
{ title: '订单号', dataIndex: 'order_no', key: 'order_no', ellipsis: true },
|
||||||
{ title: '来源表', dataIndex: 'source_table_txt', key: 'source_table_txt', width: 110 },
|
{ title: '来源表', dataIndex: 'source_table_txt', key: 'source_table_txt', width: 110 },
|
||||||
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
|
{ title: '备注', dataIndex: 'remark', key: 'remark', ellipsis: true },
|
||||||
@@ -123,6 +128,7 @@ async function loadFundWater(page = fundWaterPage.value) {
|
|||||||
fundWaterList.value = (res?.items || []).map((item: any) => ({
|
fundWaterList.value = (res?.items || []).map((item: any) => ({
|
||||||
...item,
|
...item,
|
||||||
doctor_name: item?.doctor_info?.name || '-',
|
doctor_name: item?.doctor_info?.name || '-',
|
||||||
|
store_name: item?.store?.name || '-',
|
||||||
}));
|
}));
|
||||||
fundWaterTotal.value = Number(res?.total || 0);
|
fundWaterTotal.value = Number(res?.total || 0);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -237,6 +243,9 @@ function orderTypeTag(orderType: number) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="finance-section finance-section--list">
|
<section class="finance-section finance-section--list">
|
||||||
|
<p class="finance-hint">
|
||||||
|
资金流水按订单履约店;账户变动/余额按分账利润归属店。委托复诊等场景两边主体可能不同,属业务口径而非串店。
|
||||||
|
</p>
|
||||||
<Segmented
|
<Segmented
|
||||||
v-model:value="activeListTab"
|
v-model:value="activeListTab"
|
||||||
block
|
block
|
||||||
@@ -266,7 +275,7 @@ function orderTypeTag(orderType: number) {
|
|||||||
}"
|
}"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
size="small"
|
size="small"
|
||||||
:scroll="{ x: 800, y: 320 }"
|
:scroll="{ x: 1000, y: 320 }"
|
||||||
@change="(pag: any) => {
|
@change="(pag: any) => {
|
||||||
fundWaterPageSize = pag.pageSize || fundWaterPageSize;
|
fundWaterPageSize = pag.pageSize || fundWaterPageSize;
|
||||||
loadFundWater(pag.current || 1);
|
loadFundWater(pag.current || 1);
|
||||||
@@ -311,12 +320,24 @@ function orderTypeTag(orderType: number) {
|
|||||||
}"
|
}"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
size="small"
|
size="small"
|
||||||
:scroll="{ x: 960, y: 320 }"
|
:scroll="{ x: 1080, y: 320 }"
|
||||||
@change="(pag: any) => {
|
@change="(pag: any) => {
|
||||||
changeLogPageSize = pag.pageSize || changeLogPageSize;
|
changeLogPageSize = pag.pageSize || changeLogPageSize;
|
||||||
loadChangeLog(pag.current || 1);
|
loadChangeLog(pag.current || 1);
|
||||||
}"
|
}"
|
||||||
/>
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template
|
||||||
|
v-if="
|
||||||
|
column.key === 'before_amount' ||
|
||||||
|
column.key === 'after_amount' ||
|
||||||
|
column.key === 'change_amount'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{ formatMoney(record[column.key]) }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -354,6 +375,13 @@ function orderTypeTag(orderType: number) {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.finance-hint {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
.finance-list-segment {
|
.finance-list-segment {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export const ENCRYPT_FIELDS = new Set([
|
|||||||
'phone',
|
'phone',
|
||||||
'open_api_key',
|
'open_api_key',
|
||||||
'api_key',
|
'api_key',
|
||||||
|
'app_secret',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/** 需要生成 _tm_text 并默认脱敏展示的字段(encrypt_only) */
|
/** 需要生成 _tm_text 并默认脱敏展示的字段(encrypt_only) */
|
||||||
@@ -98,7 +99,12 @@ export function maskSensitiveField(field: string, plainText: unknown): string {
|
|||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
const str = String(plainText);
|
const str = String(plainText);
|
||||||
if (field === 'password' || field === 'api_key' || field === 'open_api_key') {
|
if (
|
||||||
|
field === 'password' ||
|
||||||
|
field === 'api_key' ||
|
||||||
|
field === 'open_api_key' ||
|
||||||
|
field === 'app_secret'
|
||||||
|
) {
|
||||||
return '*'.repeat(Math.max(str.length, 6));
|
return '*'.repeat(Math.max(str.length, 6));
|
||||||
}
|
}
|
||||||
if (PHONE_FIELDS.has(field)) {
|
if (PHONE_FIELDS.has(field)) {
|
||||||
|
|||||||
67
apps/web-antd/src/utils/imageTransferQuote.ts
Normal file
67
apps/web-antd/src/utils/imageTransferQuote.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* 识图传方报价:请求侧增量组装
|
||||||
|
* 首次全量 drugs;同味数下只传 quote_token + drugs_patch
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type QuoteDrugRow = {
|
||||||
|
drug_id: number;
|
||||||
|
name?: string;
|
||||||
|
number?: number;
|
||||||
|
price?: number;
|
||||||
|
way_id?: number;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 比较两味报价药品是否等价(用于算 patch) */
|
||||||
|
export function isSameQuoteDrug(
|
||||||
|
a?: QuoteDrugRow | null,
|
||||||
|
b?: QuoteDrugRow | null,
|
||||||
|
): boolean {
|
||||||
|
if (!a || !b) return false;
|
||||||
|
return (
|
||||||
|
Number(a.drug_id || 0) === Number(b.drug_id || 0) &&
|
||||||
|
Number(a.number || 0) === Number(b.number || 0) &&
|
||||||
|
Number(a.price || 0) === Number(b.price || 0) &&
|
||||||
|
String(a.name || '') === String(b.name || '') &&
|
||||||
|
Number(a.way_id || 0) === Number(b.way_id || 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装增量报价入参
|
||||||
|
* 为什么:点选改一味时不必每次 POST 整方 drugs,服务端用 token 缓存整方再 merge patch
|
||||||
|
*/
|
||||||
|
export function buildIncrementalQuotePayload(opts: {
|
||||||
|
scalars: Record<string, any>;
|
||||||
|
drugs: QuoteDrugRow[];
|
||||||
|
quoteToken?: string;
|
||||||
|
lastQuoteDrugs?: QuoteDrugRow[] | null;
|
||||||
|
}): { payload: Record<string, any>; nextLastDrugs: QuoteDrugRow[] } {
|
||||||
|
const scalars = opts.scalars || {};
|
||||||
|
const drugs = Array.isArray(opts.drugs) ? opts.drugs : [];
|
||||||
|
const quoteToken = String(opts.quoteToken || '').trim();
|
||||||
|
const prev = Array.isArray(opts.lastQuoteDrugs) ? opts.lastQuoteDrugs : null;
|
||||||
|
const payload: Record<string, any> = { ...scalars };
|
||||||
|
if (quoteToken) {
|
||||||
|
payload.quote_token = quoteToken;
|
||||||
|
}
|
||||||
|
if (!quoteToken || !prev || prev.length !== drugs.length) {
|
||||||
|
payload.drugs = drugs;
|
||||||
|
return { payload, nextLastDrugs: drugs };
|
||||||
|
}
|
||||||
|
const patches: Array<{ index: number; drug: QuoteDrugRow }> = [];
|
||||||
|
for (let i = 0; i < drugs.length; i++) {
|
||||||
|
if (!isSameQuoteDrug(prev[i], drugs[i])) {
|
||||||
|
patches.push({ index: i, drug: drugs[i] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (patches.length === 0) {
|
||||||
|
return { payload, nextLastDrugs: prev };
|
||||||
|
}
|
||||||
|
if (patches.length >= drugs.length) {
|
||||||
|
payload.drugs = drugs;
|
||||||
|
} else {
|
||||||
|
payload.drugs_patch = patches;
|
||||||
|
}
|
||||||
|
return { payload, nextLastDrugs: drugs };
|
||||||
|
}
|
||||||
@@ -1,5 +1,62 @@
|
|||||||
export type ProcessRuleListItem = { id?: number | string };
|
export type ProcessRuleListItem = { id?: number | string };
|
||||||
|
|
||||||
|
/** 代煎+用量必填校验入参(对齐小程序 validateChineseConfigRequired) */
|
||||||
|
export type ChineseConfigRequiredInput = {
|
||||||
|
ruleType?: number | null;
|
||||||
|
packageMethodId?: number | null;
|
||||||
|
processRuleId?: number | null;
|
||||||
|
childProcessRuleId?: number | null;
|
||||||
|
processRuleNoteId?: number | null;
|
||||||
|
dosage?: number | null;
|
||||||
|
dayDosage?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中药制剂方式 / 代煎 / 用量必填校验
|
||||||
|
* 返回 keys 供 ChineseMedicineConfig 标红,message 供 toast
|
||||||
|
*/
|
||||||
|
export function validateChineseConfigRequired(config: ChineseConfigRequiredInput) {
|
||||||
|
const keys: string[] = [];
|
||||||
|
const messages: string[] = [];
|
||||||
|
const ruleType = Number(config?.ruleType || 0);
|
||||||
|
if (ruleType !== 1 && ruleType !== 2) {
|
||||||
|
keys.push('ruleType');
|
||||||
|
messages.push('请选择制剂方式');
|
||||||
|
}
|
||||||
|
if (ruleType === 1) {
|
||||||
|
if (!Number(config?.packageMethodId)) {
|
||||||
|
keys.push('packageMethodId');
|
||||||
|
messages.push('请选择包法');
|
||||||
|
}
|
||||||
|
} else if (ruleType === 2) {
|
||||||
|
if (!Number(config?.processRuleId)) {
|
||||||
|
keys.push('processRuleId');
|
||||||
|
messages.push('请选择制剂要求');
|
||||||
|
}
|
||||||
|
if (!Number(config?.childProcessRuleId)) {
|
||||||
|
keys.push('childProcessRuleId');
|
||||||
|
messages.push('请选择煎法说明');
|
||||||
|
}
|
||||||
|
if (!Number(config?.processRuleNoteId)) {
|
||||||
|
keys.push('processRuleNoteId');
|
||||||
|
messages.push('请选择附加备注');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(Number(config?.dosage) >= 1)) {
|
||||||
|
keys.push('dosage');
|
||||||
|
messages.push('请填写剂数');
|
||||||
|
}
|
||||||
|
if (!(Number(config?.dayDosage) >= 1)) {
|
||||||
|
keys.push('dayDosage');
|
||||||
|
messages.push('请填写用药频次');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
valid: keys.length === 0,
|
||||||
|
keys,
|
||||||
|
message: messages[0] || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type ProcessRuleLoadedLevel = 'root' | 'child' | 'note';
|
export type ProcessRuleLoadedLevel = 'root' | 'child' | 'note';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,9 +9,21 @@ export type SearchTimePreset =
|
|||||||
| 'prevMonth'
|
| 'prevMonth'
|
||||||
| 'nextMonth';
|
| 'nextMonth';
|
||||||
|
|
||||||
|
/** 界面展示用纯日期;提交给后端时再拼时分秒 */
|
||||||
const YMD = 'YYYY-MM-DD';
|
const YMD = 'YYYY-MM-DD';
|
||||||
|
/** 查询边界:开始日 00:00:00、结束日 23:59:59,避免当天末笔被滤掉 */
|
||||||
|
const YMD_START = 'YYYY-MM-DD 00:00:00';
|
||||||
|
const YMD_END = 'YYYY-MM-DD 23:59:59';
|
||||||
|
|
||||||
/** 今天日期(Dayjs) */
|
/**
|
||||||
|
* 将区间规范为「开始日 00:00:00 ~ 结束日 23:59:59」
|
||||||
|
* 搜索按天选时,结束若不落到当天最后一秒,当天 0 点之后的数据会被漏掉
|
||||||
|
*/
|
||||||
|
export function toSearchDayBounds(range: [Dayjs, Dayjs]): [Dayjs, Dayjs] {
|
||||||
|
return [range[0].startOf('day'), range[1].endOf('day')];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 今天日期(Dayjs,当天 00:00:00) */
|
||||||
export function todayDayjs(): Dayjs {
|
export function todayDayjs(): Dayjs {
|
||||||
return dayjs().startOf('day');
|
return dayjs().startOf('day');
|
||||||
}
|
}
|
||||||
@@ -24,30 +36,27 @@ export function startOfWeekMonday(base: Dayjs = todayDayjs()): Dayjs {
|
|||||||
return d.subtract(diff, 'day');
|
return d.subtract(diff, 'day');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 本月1日 ~ 今天(字符串,供 RangePicker valueFormat) */
|
/** 本月1日 00:00:00 ~ 今天 23:59:59(字符串,供表单默认值 / 接口) */
|
||||||
export function monthToTodayRangeString(): [string, string] {
|
export function monthToTodayRangeString(): [string, string] {
|
||||||
const today = todayDayjs();
|
return rangeDayjsToString(monthToTodayRangeDayjs());
|
||||||
return [today.startOf('month').format(YMD), today.format(YMD)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 本月1日 ~ 今天(Dayjs) */
|
/** 本月1日 ~ 今天(Dayjs,已含起止边界) */
|
||||||
export function monthToTodayRangeDayjs(): [Dayjs, Dayjs] {
|
export function monthToTodayRangeDayjs(): [Dayjs, Dayjs] {
|
||||||
const today = todayDayjs();
|
const today = todayDayjs();
|
||||||
return [today.startOf('month'), today];
|
return toSearchDayBounds([today.startOf('month'), today]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 近 N 天(含今天),默认 7 天 */
|
/** 近 N 天(含今天),默认 7 天;字符串已含 00:00:00 / 23:59:59 */
|
||||||
export function lastNDaysRangeString(days = 7): [string, string] {
|
export function lastNDaysRangeString(days = 7): [string, string] {
|
||||||
const today = todayDayjs();
|
return rangeDayjsToString(lastNDaysRangeDayjs(days));
|
||||||
const start = today.subtract(Math.max(days - 1, 0), 'day');
|
|
||||||
return [start.format(YMD), today.format(YMD)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 近 N 天(Dayjs) */
|
/** 近 N 天(Dayjs,已含起止边界) */
|
||||||
export function lastNDaysRangeDayjs(days = 7): [Dayjs, Dayjs] {
|
export function lastNDaysRangeDayjs(days = 7): [Dayjs, Dayjs] {
|
||||||
const today = todayDayjs();
|
const today = todayDayjs();
|
||||||
const start = today.subtract(Math.max(days - 1, 0), 'day');
|
const start = today.subtract(Math.max(days - 1, 0), 'day');
|
||||||
return [start, today];
|
return toSearchDayBounds([start, today]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将 Dayjs / 字符串 / 时间戳统一为 YYYY-MM-DD */
|
/** 将 Dayjs / 字符串 / 时间戳统一为 YYYY-MM-DD */
|
||||||
@@ -98,17 +107,17 @@ export function getMonthAnchorDayjs(current: unknown): Dayjs {
|
|||||||
return todayDayjs();
|
return todayDayjs();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 自然月整月,结束日不超过今天 */
|
/** 自然月整月,结束日不超过今天(返回值已含起止边界) */
|
||||||
function fullMonthRangeCapped(
|
function fullMonthRangeCapped(
|
||||||
month: Dayjs,
|
month: Dayjs,
|
||||||
): [Dayjs, Dayjs] {
|
): [Dayjs, Dayjs] {
|
||||||
const start = month.startOf('month');
|
const start = month.startOf('month');
|
||||||
const end = month.endOf('month').startOf('day');
|
const end = month.endOf('month').startOf('day');
|
||||||
const today = todayDayjs();
|
const today = todayDayjs();
|
||||||
return [start, end.isAfter(today) ? today : end];
|
return toSearchDayBounds([start, end.isAfter(today) ? today : end]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按预设计算区间(Dayjs) */
|
/** 按预设计算区间(Dayjs,已含 00:00:00 / 23:59:59 边界) */
|
||||||
export function resolveSearchTimePreset(
|
export function resolveSearchTimePreset(
|
||||||
preset: SearchTimePreset,
|
preset: SearchTimePreset,
|
||||||
current: unknown,
|
current: unknown,
|
||||||
@@ -116,11 +125,11 @@ export function resolveSearchTimePreset(
|
|||||||
const today = todayDayjs();
|
const today = todayDayjs();
|
||||||
switch (preset) {
|
switch (preset) {
|
||||||
case 'today':
|
case 'today':
|
||||||
return [today, today];
|
return toSearchDayBounds([today, today]);
|
||||||
case 'thisWeek':
|
case 'thisWeek':
|
||||||
return [startOfWeekMonday(today), today];
|
return toSearchDayBounds([startOfWeekMonday(today), today]);
|
||||||
case 'thisMonth':
|
case 'thisMonth':
|
||||||
return [today.startOf('month'), today];
|
return toSearchDayBounds([today.startOf('month'), today]);
|
||||||
case 'prevMonth': {
|
case 'prevMonth': {
|
||||||
const anchor = getMonthAnchorDayjs(current);
|
const anchor = getMonthAnchorDayjs(current);
|
||||||
return fullMonthRangeCapped(anchor.subtract(1, 'month'));
|
return fullMonthRangeCapped(anchor.subtract(1, 'month'));
|
||||||
@@ -134,36 +143,44 @@ export function resolveSearchTimePreset(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按预设计算区间(字符串) */
|
/** 按预设计算区间(字符串,已含时分秒) */
|
||||||
export function resolveSearchTimePresetString(
|
export function resolveSearchTimePresetString(
|
||||||
preset: SearchTimePreset,
|
preset: SearchTimePreset,
|
||||||
current: unknown,
|
current: unknown,
|
||||||
): [string, string] {
|
): [string, string] {
|
||||||
const [a, b] = resolveSearchTimePreset(preset, current);
|
return rangeDayjsToString(resolveSearchTimePreset(preset, current));
|
||||||
return [a.format(YMD), b.format(YMD)];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dayjs 区间转字符串 */
|
/**
|
||||||
|
* Dayjs 区间转接口字符串
|
||||||
|
* 固定开始 00:00:00、结束 23:59:59,与按天筛选的业务口径一致
|
||||||
|
*/
|
||||||
export function rangeDayjsToString(
|
export function rangeDayjsToString(
|
||||||
range: [Dayjs, Dayjs],
|
range: [Dayjs, Dayjs],
|
||||||
): [string, string] {
|
): [string, string] {
|
||||||
return [range[0].format(YMD), range[1].format(YMD)];
|
const [start, end] = toSearchDayBounds(range);
|
||||||
|
return [start.format(YMD_START), end.format(YMD_END)];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 字符串区间转 Dayjs */
|
/** 字符串区间转 Dayjs(兼容纯日期或带时分秒) */
|
||||||
export function rangeStringToDayjs(
|
export function rangeStringToDayjs(
|
||||||
range: [string, string],
|
range: [string, string],
|
||||||
): [Dayjs, Dayjs] {
|
): [Dayjs, Dayjs] {
|
||||||
return [dayjs(range[0], YMD), dayjs(range[1], YMD)];
|
return toSearchDayBounds([dayjs(range[0]), dayjs(range[1])]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将区间按 valueFormat 输出 */
|
/**
|
||||||
|
* 将区间按表单模式输出
|
||||||
|
* valueFormat=YYYY-MM-DD 表示「按天选择」,值仍带完整时分秒边界供接口使用
|
||||||
|
* 未传 valueFormat 时返回 Dayjs(同样规范到起止边界)
|
||||||
|
*/
|
||||||
export function formatRangeByValueFormat(
|
export function formatRangeByValueFormat(
|
||||||
range: [Dayjs, Dayjs],
|
range: [Dayjs, Dayjs],
|
||||||
valueFormat?: string,
|
valueFormat?: string,
|
||||||
): [string, string] | [Dayjs, Dayjs] {
|
): [string, string] | [Dayjs, Dayjs] {
|
||||||
|
const bounded = toSearchDayBounds(range);
|
||||||
if (valueFormat === YMD) {
|
if (valueFormat === YMD) {
|
||||||
return rangeDayjsToString(range);
|
return rangeDayjsToString(bounded);
|
||||||
}
|
}
|
||||||
return range;
|
return bounded;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ import { ChineseDrugNameBubble } from '#/components/chinese-drug-name-bubble';
|
|||||||
import { formatPriceDisplay } from '#/utils/formatPrice';
|
import { formatPriceDisplay } from '#/utils/formatPrice';
|
||||||
import ChineseMedicineConfig from '#/views/doctor/doctor-reception/components/ChineseMedicineConfig.vue';
|
import ChineseMedicineConfig from '#/views/doctor/doctor-reception/components/ChineseMedicineConfig.vue';
|
||||||
import { getDeliveryWarehouseOptionsByDrugs, getPrescriptionInfoApi } from '#/views/doctor/doctor-reception/api';
|
import { getDeliveryWarehouseOptionsByDrugs, getPrescriptionInfoApi } from '#/views/doctor/doctor-reception/api';
|
||||||
|
import { alignChineseDrugsToLatestPrice } from '#/views/doctor/doctor-reception/utils/alignChineseDrugPrice';
|
||||||
import WarehouseSelectModal from './WarehouseSelectModal.vue';
|
import WarehouseSelectModal from './WarehouseSelectModal.vue';
|
||||||
import { detailDoctorOrderToText } from '#/utils/prescriptionDoctorOrder';
|
import { detailDoctorOrderToText } from '#/utils/prescriptionDoctorOrder';
|
||||||
|
|
||||||
@@ -1008,8 +1009,7 @@ async function reusePrescriptionFromList(prescriptionId: number) {
|
|||||||
message.error('解析中药处方失败');
|
message.error('解析中药处方失败');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
prescriptionStore.updateCurrentDrugs(
|
const mappedChinese = (rows || []).map((d: any) => {
|
||||||
(rows || []).map((d: any) => {
|
|
||||||
const drugId = Number(d.drug_id ?? d.id);
|
const drugId = Number(d.drug_id ?? d.id);
|
||||||
return {
|
return {
|
||||||
index_id: d.id ?? drugId,
|
index_id: d.id ?? drugId,
|
||||||
@@ -1018,10 +1018,16 @@ async function reusePrescriptionFromList(prescriptionId: number) {
|
|||||||
number: Number(d.number ?? d.amount ?? 0),
|
number: Number(d.number ?? d.amount ?? 0),
|
||||||
way_id: d.way_id ?? d.use_way?.id ?? d.order,
|
way_id: d.way_id ?? d.use_way?.id ?? d.order,
|
||||||
price: Number(d.price ?? d.sell_price ?? 0),
|
price: Number(d.price ?? d.sell_price ?? 0),
|
||||||
|
buy_price: d.buy_price ?? null,
|
||||||
unit: d.unit,
|
unit: d.unit,
|
||||||
};
|
};
|
||||||
}),
|
});
|
||||||
);
|
await alignChineseDrugsToLatestPrice({
|
||||||
|
registerId: medicalRecordRegisterId.value,
|
||||||
|
storeId: medicalRecordStoreId.value,
|
||||||
|
drugs: mappedChinese,
|
||||||
|
});
|
||||||
|
prescriptionStore.updateCurrentDrugs(mappedChinese);
|
||||||
if (rep0?.dosage != null) prescriptionStore.dosage = Number(rep0.dosage) || prescriptionStore.dosage;
|
if (rep0?.dosage != null) prescriptionStore.dosage = Number(rep0.dosage) || prescriptionStore.dosage;
|
||||||
if (rep0?.day_dosage != null) {
|
if (rep0?.day_dosage != null) {
|
||||||
prescriptionStore.dayDosage = Number(rep0.day_dosage) || prescriptionStore.dayDosage;
|
prescriptionStore.dayDosage = Number(rep0.day_dosage) || prescriptionStore.dayDosage;
|
||||||
@@ -1297,8 +1303,7 @@ async function handleSelectCommonPrescription(data: any, type: number) {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 中药/颗粒药处方
|
// 中药/颗粒药处方
|
||||||
recipes.forEach((recipe: any) => {
|
const chineseRows = recipes.map((recipe: any) => ({
|
||||||
const newProduct = {
|
|
||||||
index_id: recipe.drug_id || recipe.id,
|
index_id: recipe.drug_id || recipe.id,
|
||||||
id: recipe.drug_id || recipe.id,
|
id: recipe.drug_id || recipe.id,
|
||||||
drug_name: recipe.drug_name || recipe.name,
|
drug_name: recipe.drug_name || recipe.name,
|
||||||
@@ -1308,8 +1313,16 @@ async function handleSelectCommonPrescription(data: any, type: number) {
|
|||||||
way_id: recipe.way_id || 0,
|
way_id: recipe.way_id || 0,
|
||||||
specification: recipe.specification || recipe.drug?.specification || '',
|
specification: recipe.specification || recipe.drug?.specification || '',
|
||||||
select_number: 1,
|
select_number: 1,
|
||||||
};
|
}));
|
||||||
// 检查是否已存在
|
// 颗粒药不走供货比例,只核对中药常用方
|
||||||
|
if (type === 2) {
|
||||||
|
await alignChineseDrugsToLatestPrice({
|
||||||
|
registerId: medicalRecordRegisterId.value,
|
||||||
|
storeId: medicalRecordStoreId.value,
|
||||||
|
drugs: chineseRows,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
chineseRows.forEach((newProduct) => {
|
||||||
const existItem = prescriptionStore.currentDrugs.find(
|
const existItem = prescriptionStore.currentDrugs.find(
|
||||||
(item: any) => item.id === newProduct.id,
|
(item: any) => item.id === newProduct.id,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Table,
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
Timeline,
|
|
||||||
} from 'ant-design-vue';
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +44,10 @@ const reconciliationLoading = ref(false);
|
|||||||
const activeTab = ref('timeline');
|
const activeTab = ref('timeline');
|
||||||
const ledgerScope = ref<LedgerScope>('all');
|
const ledgerScope = ref<LedgerScope>('all');
|
||||||
const ledgerSubTab = ref<'product' | 'register'>('product');
|
const ledgerSubTab = ref<'product' | 'register'>('product');
|
||||||
|
/** 商品订单分账:真实入账行 / 下单时冻结的快照 */
|
||||||
|
const productLedgerView = ref<'actual' | 'snapshot'>('actual');
|
||||||
|
/** 时间线一行放几个节点,奇数行从右往左折返 */
|
||||||
|
const TIMELINE_PER_ROW = 4;
|
||||||
const traceData = ref<Record<string, any> | null>(null);
|
const traceData = ref<Record<string, any> | null>(null);
|
||||||
const ledgerProduct = ref<Record<string, any> | null>(null);
|
const ledgerProduct = ref<Record<string, any> | null>(null);
|
||||||
const ledgerRegister = ref<Record<string, any> | null>(null);
|
const ledgerRegister = ref<Record<string, any> | null>(null);
|
||||||
@@ -73,6 +76,61 @@ const currentLedgerData = computed(() =>
|
|||||||
ledgerSubTab.value === 'product' ? ledgerProduct.value : ledgerRegister.value,
|
ledgerSubTab.value === 'product' ? ledgerProduct.value : ledgerRegister.value,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** 商品订单分账快照(无快照时为 null) */
|
||||||
|
const productLedgerSnapshot = computed(
|
||||||
|
() => ledgerProduct.value?.ledger_split ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 节点状态色:完成类用主题色,驳回/退款用警告色,纯记录用弱色。
|
||||||
|
* 只根据已有文案归类,不改时间线数据。
|
||||||
|
*/
|
||||||
|
function timelineStatusTone(status: string) {
|
||||||
|
if (/驳回|拒|退|失败|取消/.test(status)) {
|
||||||
|
return 'warn';
|
||||||
|
}
|
||||||
|
if (!status || status === '记录') {
|
||||||
|
return 'muted';
|
||||||
|
}
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把时间线拆成固定四列的折返行。
|
||||||
|
* 偶数行从左到右占第 1~4 列;奇数行从右往左,第 1 个节点落在最右列,和上一行末尾对齐。
|
||||||
|
*/
|
||||||
|
const timelineRows = computed(() => {
|
||||||
|
const nodes = (traceData.value?.timeline ?? []).map((node: any, index: number) => ({
|
||||||
|
...node,
|
||||||
|
seq: index + 1,
|
||||||
|
tone: timelineStatusTone(String(node.status ?? '')),
|
||||||
|
}));
|
||||||
|
const rows: Array<{
|
||||||
|
nodes: Array<any & { column: number; pointLeft: boolean; hasNext: boolean }>;
|
||||||
|
rtl: boolean;
|
||||||
|
showTurn: boolean;
|
||||||
|
turnColumn: number;
|
||||||
|
}> = [];
|
||||||
|
for (let i = 0; i < nodes.length; i += TIMELINE_PER_ROW) {
|
||||||
|
const chunk = nodes.slice(i, i + TIMELINE_PER_ROW);
|
||||||
|
const rowIndex = rows.length;
|
||||||
|
const rtl = rowIndex % 2 === 1;
|
||||||
|
const placed = chunk.map((node, index) => {
|
||||||
|
const column = rtl ? TIMELINE_PER_ROW - index : index + 1;
|
||||||
|
const hasNext = index < chunk.length - 1;
|
||||||
|
return { ...node, column, hasNext, pointLeft: rtl };
|
||||||
|
});
|
||||||
|
const lastIndex = chunk.length - 1;
|
||||||
|
rows.push({
|
||||||
|
nodes: placed,
|
||||||
|
rtl,
|
||||||
|
showTurn: i + TIMELINE_PER_ROW < nodes.length,
|
||||||
|
turnColumn: rtl ? TIMELINE_PER_ROW - lastIndex : lastIndex + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
|
||||||
function formatDrugCell(value?: string) {
|
function formatDrugCell(value?: string) {
|
||||||
return value?.trim() ? value : '-';
|
return value?.trim() ? value : '-';
|
||||||
}
|
}
|
||||||
@@ -86,6 +144,7 @@ function resetDrawerState() {
|
|||||||
activeTab.value = 'timeline';
|
activeTab.value = 'timeline';
|
||||||
ledgerScope.value = isPlatformAdmin.value ? 'all' : 'store';
|
ledgerScope.value = isPlatformAdmin.value ? 'all' : 'store';
|
||||||
ledgerSubTab.value = 'product';
|
ledgerSubTab.value = 'product';
|
||||||
|
productLedgerView.value = 'actual';
|
||||||
}
|
}
|
||||||
|
|
||||||
const [Drawer, drawerApi] = useVbenDrawer({
|
const [Drawer, drawerApi] = useVbenDrawer({
|
||||||
@@ -356,22 +415,44 @@ watch(isPlatformAdmin, (val) => {
|
|||||||
|
|
||||||
<Tabs v-model:active-key="activeTab" @change="(key) => handleMainTabChange(String(key))">
|
<Tabs v-model:active-key="activeTab" @change="(key) => handleMainTabChange(String(key))">
|
||||||
<Tabs.TabPane key="timeline" tab="溯源时间线">
|
<Tabs.TabPane key="timeline" tab="溯源时间线">
|
||||||
<Timeline v-if="traceData.timeline?.length">
|
<div v-if="timelineRows.length" class="trace-snake">
|
||||||
<Timeline.Item
|
<div
|
||||||
v-for="node in traceData.timeline"
|
v-for="(row, rowIndex) in timelineRows"
|
||||||
:key="node.key"
|
:key="rowIndex"
|
||||||
|
class="trace-snake-wrap"
|
||||||
>
|
>
|
||||||
<div class="font-medium">{{ node.title }}</div>
|
<div class="trace-snake-row">
|
||||||
<div class="text-sm text-gray-500">{{ node.time }}</div>
|
<div
|
||||||
<div v-if="node.status" class="text-sm">
|
v-for="node in row.nodes"
|
||||||
<Tag>{{ node.status }}</Tag>
|
:key="node.key"
|
||||||
|
class="trace-snake-node"
|
||||||
|
:class="{
|
||||||
|
'point-left': node.pointLeft && node.hasNext,
|
||||||
|
'point-right': !node.pointLeft && node.hasNext,
|
||||||
|
}"
|
||||||
|
:style="{ gridColumn: String(node.column), gridRow: '1' }"
|
||||||
|
>
|
||||||
|
<div class="trace-snake-head">
|
||||||
|
<span class="trace-snake-index">{{ node.seq }}</span>
|
||||||
|
<span class="trace-snake-title">{{ node.title }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="node.extra" class="mt-1 text-sm text-gray-600">
|
<span
|
||||||
{{ node.extra }}
|
v-if="node.status"
|
||||||
|
class="trace-snake-status"
|
||||||
|
:class="'is-' + node.tone"
|
||||||
|
>{{ node.status }}</span>
|
||||||
|
<div v-if="node.time" class="trace-snake-time">{{ node.time }}</div>
|
||||||
|
<div v-if="node.extra" class="trace-snake-extra" :title="node.extra">{{ node.extra }}</div>
|
||||||
</div>
|
</div>
|
||||||
</Timeline.Item>
|
</div>
|
||||||
</Timeline>
|
<div
|
||||||
<div v-else class="py-6 text-center text-gray-500">暂无溯源记录</div>
|
v-if="row.showTurn"
|
||||||
|
class="trace-snake-turn"
|
||||||
|
:style="{ left: `calc((${row.turnColumn} - 0.5) * 25%)` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="py-6 text-center text-[hsl(var(--muted-foreground))]">暂无溯源记录</div>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
|
|
||||||
<Tabs.TabPane key="ledger" tab="分账明细">
|
<Tabs.TabPane key="ledger" tab="分账明细">
|
||||||
@@ -392,6 +473,11 @@ watch(isPlatformAdmin, (val) => {
|
|||||||
|
|
||||||
<Tabs v-model:active-key="ledgerSubTab" type="card">
|
<Tabs v-model:active-key="ledgerSubTab" type="card">
|
||||||
<Tabs.TabPane key="product" tab="商品订单分账">
|
<Tabs.TabPane key="product" tab="商品订单分账">
|
||||||
|
<Tabs v-model:active-key="productLedgerView" class="mb-3" size="small">
|
||||||
|
<Tabs.TabPane key="actual" tab="真实分账" />
|
||||||
|
<Tabs.TabPane key="snapshot" tab="快照分账" />
|
||||||
|
</Tabs>
|
||||||
|
<template v-if="productLedgerView === 'actual'">
|
||||||
<div class="mb-2 text-sm">
|
<div class="mb-2 text-sm">
|
||||||
合计分账:{{ ledgerProduct?.sum_money ?? '0' }}
|
合计分账:{{ ledgerProduct?.sum_money ?? '0' }}
|
||||||
<span class="ml-2 text-xs text-[hsl(var(--muted-foreground))]">(不含已取消)</span>
|
<span class="ml-2 text-xs text-[hsl(var(--muted-foreground))]">(不含已取消)</span>
|
||||||
@@ -428,10 +514,35 @@ watch(isPlatformAdmin, (val) => {
|
|||||||
</Table>
|
</Table>
|
||||||
<div
|
<div
|
||||||
v-if="!ledgerProduct?.items?.length && !ledgerLoading"
|
v-if="!ledgerProduct?.items?.length && !ledgerLoading"
|
||||||
class="py-6 text-center text-gray-500"
|
class="py-6 text-center text-[hsl(var(--muted-foreground))]"
|
||||||
>
|
>
|
||||||
暂无分账记录
|
暂无分账记录
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<Descriptions
|
||||||
|
v-if="productLedgerSnapshot"
|
||||||
|
bordered
|
||||||
|
size="small"
|
||||||
|
:column="2"
|
||||||
|
>
|
||||||
|
<Descriptions.Item label="分账方式">
|
||||||
|
{{ productLedgerSnapshot.split_type_txt || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="平台占比">
|
||||||
|
{{ productLedgerSnapshot.platform_percent }}%
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="平台金额">
|
||||||
|
{{ productLedgerSnapshot.platform_amount }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="诊所金额">
|
||||||
|
{{ productLedgerSnapshot.store_amount }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<div v-else class="py-6 text-center text-[hsl(var(--muted-foreground))]">
|
||||||
|
该订单还没有分账快照(上线前的旧单,或尚未结算)
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</Tabs.TabPane>
|
</Tabs.TabPane>
|
||||||
<Tabs.TabPane key="register" tab="挂号订单分账">
|
<Tabs.TabPane key="register" tab="挂号订单分账">
|
||||||
<div class="mb-2 text-sm">
|
<div class="mb-2 text-sm">
|
||||||
@@ -497,3 +608,147 @@ watch(isPlatformAdmin, (val) => {
|
|||||||
<OrderDetailModal />
|
<OrderDetailModal />
|
||||||
</Drawer>
|
</Drawer>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 溯源时间线:固定四列网格,奇数行从右往左折返,列与上一行对齐 */
|
||||||
|
.trace-snake {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 36px;
|
||||||
|
padding: 8px 12px 16px;
|
||||||
|
}
|
||||||
|
.trace-snake-wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.trace-snake-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
grid-template-rows: auto;
|
||||||
|
column-gap: 36px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.trace-snake-node {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 112px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid hsl(var(--primary) / 30%);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
|
||||||
|
}
|
||||||
|
.trace-snake-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 22px;
|
||||||
|
}
|
||||||
|
.trace-snake-index {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border: 1px solid hsl(var(--primary) / 30%);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--primary) / 12%);
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.trace-snake-title {
|
||||||
|
min-width: 0;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.trace-snake-status {
|
||||||
|
align-self: flex-start;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 999px;
|
||||||
|
background: hsl(var(--muted) / 35%);
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 20px;
|
||||||
|
}
|
||||||
|
.trace-snake-status.is-ok {
|
||||||
|
border-color: hsl(var(--primary) / 28%);
|
||||||
|
background: hsl(var(--primary) / 12%);
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
.trace-snake-status.is-warn {
|
||||||
|
border-color: hsl(var(--warning) / 35%);
|
||||||
|
background: hsl(var(--warning) / 12%);
|
||||||
|
color: hsl(var(--warning));
|
||||||
|
}
|
||||||
|
.trace-snake-time {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.trace-snake-extra {
|
||||||
|
margin-top: auto;
|
||||||
|
padding: 6px 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: hsl(var(--muted) / 35%);
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
word-break: break-all;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
}
|
||||||
|
.trace-snake-node.point-right::after,
|
||||||
|
.trace-snake-node.point-left::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 23px;
|
||||||
|
width: 28px;
|
||||||
|
height: 2px;
|
||||||
|
background: hsl(var(--primary) / 45%);
|
||||||
|
}
|
||||||
|
.trace-snake-node.point-right::after {
|
||||||
|
right: -32px;
|
||||||
|
}
|
||||||
|
.trace-snake-node.point-right::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 19px;
|
||||||
|
right: -36px;
|
||||||
|
border-top: 5px solid transparent;
|
||||||
|
border-bottom: 5px solid transparent;
|
||||||
|
border-left: 7px solid hsl(var(--primary) / 70%);
|
||||||
|
}
|
||||||
|
.trace-snake-node.point-left::after {
|
||||||
|
left: -32px;
|
||||||
|
}
|
||||||
|
.trace-snake-node.point-left::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 19px;
|
||||||
|
left: -36px;
|
||||||
|
border-top: 5px solid transparent;
|
||||||
|
border-bottom: 5px solid transparent;
|
||||||
|
border-right: 7px solid hsl(var(--primary) / 70%);
|
||||||
|
}
|
||||||
|
.trace-snake-turn {
|
||||||
|
position: absolute;
|
||||||
|
bottom: -36px;
|
||||||
|
width: 2px;
|
||||||
|
height: 36px;
|
||||||
|
transform: translateX(-1px);
|
||||||
|
background: hsl(var(--primary) / 45%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
const prefix = 'image-transfer-order/';
|
||||||
|
|
||||||
|
/** 传方订单列表 */
|
||||||
|
export async function getImageTransferOrderList(params: Record<string, any>) {
|
||||||
|
return requestClient.get<any>(`${prefix}list`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 传方订单详情,审方前看原图、处方和发货 */
|
||||||
|
export async function getImageTransferOrderDetail(params: { id: number }) {
|
||||||
|
return requestClient.get<any>(`${prefix}detail`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审核通过并推 MES,会带上审方改过的患者信息和药品 */
|
||||||
|
export async function approveImageTransferOrder(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}approve`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 驳回,原因必填 */
|
||||||
|
export async function rejectImageTransferOrder(data: { id: number; reason: string }) {
|
||||||
|
return requestClient.post<any>(`${prefix}reject`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 推送失败后重推,同样带上改过的处方 */
|
||||||
|
export async function retryImageTransferOrder(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}retry`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 审方端报价预览:改地址/代煎/药品后即时重算,不落库 */
|
||||||
|
export async function quoteImageTransferOrder(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}quote`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 医生/诊所:待审或驳回修改后再交 */
|
||||||
|
export async function resubmitImageTransferOrder(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}resubmit`, data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 识图传方审方详情
|
||||||
|
* 抽屉尽量占满屏幕,左边放大处方原图,右边患者信息和药品可改
|
||||||
|
* 通过 / 重推会先把改动落库再推 MES,避免只改界面不生效
|
||||||
|
* 费用明细:审方端始终可见;改地址/代煎/克数会即时 quote 预览
|
||||||
|
*/
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { useDebounceFn } from '@vueuse/core';
|
||||||
|
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Button, Card, Input, InputNumber, message, Select } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { ChineseDrugNameBubble } from '#/components/chinese-drug-name-bubble';
|
||||||
|
import EntrustedProcessPicker from '#/components/entrusted-process-picker/EntrustedProcessPicker.vue';
|
||||||
|
import { OcrOriginImagePanel } from '#/components/ocr-origin-image-panel';
|
||||||
|
import { validateChineseConfigRequired } from '#/utils/processRuleCascade';
|
||||||
|
import { buildIncrementalQuotePayload } from '#/utils/imageTransferQuote';
|
||||||
|
|
||||||
|
import {
|
||||||
|
approveImageTransferOrder,
|
||||||
|
getImageTransferOrderDetail,
|
||||||
|
quoteImageTransferOrder,
|
||||||
|
rejectImageTransferOrder,
|
||||||
|
resubmitImageTransferOrder,
|
||||||
|
retryImageTransferOrder,
|
||||||
|
} from '../api';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ImageTransferOrderDetailDrawer' });
|
||||||
|
|
||||||
|
type DrugRow = {
|
||||||
|
key: number;
|
||||||
|
drug_id: number;
|
||||||
|
name: string;
|
||||||
|
number: number;
|
||||||
|
way_id: number;
|
||||||
|
price: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sexOptions = [
|
||||||
|
{ label: '未知', value: 0 },
|
||||||
|
{ label: '男', value: 1 },
|
||||||
|
{ label: '女', value: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const acting = ref(false);
|
||||||
|
const quoting = ref(false);
|
||||||
|
const detail = ref<Record<string, any> | null>(null);
|
||||||
|
/** 审方改字段后的即时报价(不落库,通过/重推时服务端会再算一遍) */
|
||||||
|
const quotePreview = ref<Record<string, any> | null>(null);
|
||||||
|
/** 增量报价会话:同味数改克数/换药只传 drugs_patch */
|
||||||
|
const quoteToken = ref('');
|
||||||
|
const lastQuoteDrugs = ref<
|
||||||
|
Array<{ drug_id: number; name: string; number: number; price: number; way_id: number }> | null
|
||||||
|
>(null);
|
||||||
|
const rejectReason = ref('');
|
||||||
|
/** 代煎未填字段,提交失败标红 */
|
||||||
|
const chineseErrorKeys = ref<string[]>([]);
|
||||||
|
const patientName = ref('');
|
||||||
|
const patientMobile = ref('');
|
||||||
|
const patientSex = ref(0);
|
||||||
|
const patientAge = ref(0);
|
||||||
|
const patientAddress = ref('');
|
||||||
|
const clinicalDiagnose = ref('');
|
||||||
|
const doctorOrder = ref('');
|
||||||
|
const dosage = ref(1);
|
||||||
|
const dayDosage = ref(1);
|
||||||
|
const processRuleId = ref(0);
|
||||||
|
const childProcessRuleId = ref(0);
|
||||||
|
const processRuleNoteId = ref(0);
|
||||||
|
const ruleType = ref(2);
|
||||||
|
const packageMethodId = ref(2);
|
||||||
|
const drugRows = ref<DrugRow[]>([]);
|
||||||
|
let drugKey = 1;
|
||||||
|
|
||||||
|
const imageUrls = computed(() =>
|
||||||
|
Array.isArray(detail.value?.image_urls) ? detail.value.image_urls.map(String) : [],
|
||||||
|
);
|
||||||
|
/** 待审方和推送失败才改内容;医生/诊所驳回也可改;平台驳回只读 */
|
||||||
|
const canResubmit = computed(() => Number(detail.value?.can_resubmit || 0) === 1);
|
||||||
|
const canAudit = computed(() => Number(detail.value?.can_audit || 0) === 1);
|
||||||
|
const canEdit = computed(() => {
|
||||||
|
const status = Number(detail.value?.status || 0);
|
||||||
|
if (canResubmit.value) {
|
||||||
|
return status === 1 || status === 2;
|
||||||
|
}
|
||||||
|
return status === 1 || status === 4;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 展示用报价:优先即时预览,否则用详情落库值 */
|
||||||
|
const priceView = computed(() => {
|
||||||
|
const q = quotePreview.value;
|
||||||
|
if (q && q.total_price != null && q.total_price !== '') return q;
|
||||||
|
const d = detail.value;
|
||||||
|
if (!d) return null;
|
||||||
|
return {
|
||||||
|
province: d.province,
|
||||||
|
city: d.city,
|
||||||
|
district: d.district,
|
||||||
|
items_price: d.items_price,
|
||||||
|
process_price: d.process_price,
|
||||||
|
trans_expenses: d.trans_expenses,
|
||||||
|
total_price: d.total_price,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const regionText = computed(() => {
|
||||||
|
const p = priceView.value;
|
||||||
|
if (!p) return '';
|
||||||
|
return [p.province, p.city, p.district].filter(Boolean).join(' / ');
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 把详情灌进可编辑表单,避免直接改接口对象 */
|
||||||
|
function fillForm(row: Record<string, any>) {
|
||||||
|
patientName.value = String(row.patient_name || '');
|
||||||
|
patientMobile.value = String(row.patient_mobile || '');
|
||||||
|
patientSex.value = Number(row.sex || 0);
|
||||||
|
patientAge.value = Number(row.age || 0);
|
||||||
|
patientAddress.value = String(row.patient_address || '');
|
||||||
|
clinicalDiagnose.value = String(row.clinical_diagnose || '');
|
||||||
|
doctorOrder.value = String(row.doctor_order || '');
|
||||||
|
dosage.value = Number(row.dosage || 1);
|
||||||
|
dayDosage.value = Number(row.day_dosage || 1);
|
||||||
|
processRuleId.value = Number(row.process_rule_id || 0);
|
||||||
|
childProcessRuleId.value = Number(row.child_process_rule_id || 0);
|
||||||
|
processRuleNoteId.value = Number(row.process_rule_note_id || 0);
|
||||||
|
ruleType.value = Number(row.rule_type || 0) === 1 ? 1 : 2;
|
||||||
|
packageMethodId.value = Number(row.package_method_id || 2);
|
||||||
|
const drugs = Array.isArray(row.drugs) ? row.drugs : [];
|
||||||
|
drugRows.value = drugs.map((drug: Record<string, any>) => ({
|
||||||
|
key: drugKey++,
|
||||||
|
drug_id: Number(drug.drug_id || drug.id || 0),
|
||||||
|
name: String(drug.name || drug.drug_name || ''),
|
||||||
|
number: Number(drug.number || 1),
|
||||||
|
way_id: Number(drug.way_id || 0),
|
||||||
|
price: Number(drug.price || 0),
|
||||||
|
}));
|
||||||
|
quotePreview.value = {
|
||||||
|
province: row.province,
|
||||||
|
city: row.city,
|
||||||
|
district: row.district,
|
||||||
|
items_price: row.items_price,
|
||||||
|
process_price: row.process_price,
|
||||||
|
trans_expenses: row.trans_expenses,
|
||||||
|
total_price: row.total_price,
|
||||||
|
};
|
||||||
|
quoteToken.value = '';
|
||||||
|
lastQuoteDrugs.value = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 通过和重推共用这一份,保证推 MES 的是改完后的处方 */
|
||||||
|
function buildPayload() {
|
||||||
|
return {
|
||||||
|
id: Number(detail.value?.id || 0),
|
||||||
|
patient_name: patientName.value.trim(),
|
||||||
|
patient_mobile: patientMobile.value.trim(),
|
||||||
|
sex: patientSex.value,
|
||||||
|
age: Number(patientAge.value || 0),
|
||||||
|
patient_address: patientAddress.value.trim(),
|
||||||
|
clinical_diagnose: clinicalDiagnose.value.trim(),
|
||||||
|
doctor_order: doctorOrder.value.trim(),
|
||||||
|
dosage: Number(dosage.value || 1),
|
||||||
|
day_dosage: Number(dayDosage.value || 1),
|
||||||
|
rule_type: ruleType.value,
|
||||||
|
package_method_id: ruleType.value === 1 ? packageMethodId.value : 0,
|
||||||
|
process_rule_id: ruleType.value === 2 ? processRuleId.value : 0,
|
||||||
|
child_process_rule_id: ruleType.value === 2 ? childProcessRuleId.value : 0,
|
||||||
|
process_rule_note_id: ruleType.value === 2 ? processRuleNoteId.value : 0,
|
||||||
|
drugs: drugRows.value
|
||||||
|
.filter((row) => row.drug_id > 0 && row.name.trim() !== '')
|
||||||
|
.map((row) => ({
|
||||||
|
drug_id: row.drug_id,
|
||||||
|
id: row.drug_id,
|
||||||
|
drug_name: row.name,
|
||||||
|
name: row.name,
|
||||||
|
number: Number(row.number || 0),
|
||||||
|
way_id: row.way_id,
|
||||||
|
price: row.price,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 即时报价入参:标量全带,drugs 按会话增量 */
|
||||||
|
function buildQuoteRequestPayload() {
|
||||||
|
const full = buildPayload();
|
||||||
|
const drugs = (full.drugs || []).map((d: Record<string, any>) => ({
|
||||||
|
drug_id: Number(d.drug_id || 0),
|
||||||
|
name: String(d.name || ''),
|
||||||
|
number: Number(d.number || 0),
|
||||||
|
price: Number(d.price || 0),
|
||||||
|
way_id: Number(d.way_id || 0),
|
||||||
|
}));
|
||||||
|
const scalars = {
|
||||||
|
store_id: Number(detail.value?.store_id || 0),
|
||||||
|
patient_address: full.patient_address,
|
||||||
|
dosage: full.dosage,
|
||||||
|
rule_type: full.rule_type,
|
||||||
|
package_method_id: full.package_method_id,
|
||||||
|
process_rule_id: full.process_rule_id,
|
||||||
|
child_process_rule_id: full.child_process_rule_id,
|
||||||
|
};
|
||||||
|
return buildIncrementalQuotePayload({
|
||||||
|
scalars,
|
||||||
|
drugs,
|
||||||
|
quoteToken: quoteToken.value,
|
||||||
|
lastQuoteDrugs: lastQuoteDrugs.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm() {
|
||||||
|
if (!patientName.value.trim() || !patientMobile.value.trim()) {
|
||||||
|
message.warning('请填写患者姓名和手机号');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const drugs = buildPayload().drugs;
|
||||||
|
if (!drugs.length) {
|
||||||
|
message.warning('请至少保留一味药品');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (drugs.some((row) => Number(row.number) <= 0)) {
|
||||||
|
message.warning('药品克数要大于 0');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 制剂方式 / 代煎 / 用量与小程序一致必填
|
||||||
|
const cm = validateChineseConfigRequired({
|
||||||
|
ruleType: ruleType.value,
|
||||||
|
packageMethodId: packageMethodId.value,
|
||||||
|
processRuleId: processRuleId.value,
|
||||||
|
childProcessRuleId: childProcessRuleId.value,
|
||||||
|
processRuleNoteId: processRuleNoteId.value,
|
||||||
|
dosage: dosage.value,
|
||||||
|
dayDosage: dayDosage.value,
|
||||||
|
});
|
||||||
|
chineseErrorKeys.value = cm.keys;
|
||||||
|
if (!cm.valid) {
|
||||||
|
message.warning(cm.message || '请完善代煎与用量');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 关闭抽屉并通知列表刷新 */
|
||||||
|
function finish() {
|
||||||
|
const data = drawerApi.getData<{ onDone?: () => void }>() || {};
|
||||||
|
data.onDone?.();
|
||||||
|
drawerApi.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(id: number) {
|
||||||
|
loading.value = true;
|
||||||
|
detail.value = null;
|
||||||
|
quotePreview.value = null;
|
||||||
|
quoteToken.value = '';
|
||||||
|
lastQuoteDrugs.value = null;
|
||||||
|
try {
|
||||||
|
const row = await getImageTransferOrderDetail({ id });
|
||||||
|
detail.value = row;
|
||||||
|
fillForm(row || {});
|
||||||
|
const name = row?.patient_name || '';
|
||||||
|
drawerApi.setState({ title: name ? `传方详情 - ${name}` : '传方详情' });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 审方侧即时报价:改地址/代煎/克数后预览运费与合计
|
||||||
|
* 为什么防抖:InputNumber 连续改会打爆接口
|
||||||
|
*/
|
||||||
|
async function runQuote() {
|
||||||
|
if (!detail.value?.id || !canEdit.value) return;
|
||||||
|
const built = buildQuoteRequestPayload();
|
||||||
|
if (!built.nextLastDrugs.length) {
|
||||||
|
quotePreview.value = null;
|
||||||
|
quoteToken.value = '';
|
||||||
|
lastQuoteDrugs.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
quoting.value = true;
|
||||||
|
try {
|
||||||
|
const res = await quoteImageTransferOrder({
|
||||||
|
...built.payload,
|
||||||
|
store_id: Number(detail.value.store_id || 0),
|
||||||
|
});
|
||||||
|
quotePreview.value = res || null;
|
||||||
|
if (res?.quote_token) {
|
||||||
|
quoteToken.value = String(res.quote_token);
|
||||||
|
}
|
||||||
|
lastQuoteDrugs.value = built.nextLastDrugs;
|
||||||
|
} catch {
|
||||||
|
// token 失效等:清会话下次全量;报价失败不打断审方
|
||||||
|
quoteToken.value = '';
|
||||||
|
lastQuoteDrugs.value = null;
|
||||||
|
} finally {
|
||||||
|
quoting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedQuote = useDebounceFn(runQuote, 420);
|
||||||
|
|
||||||
|
function onProcessChange(payload: {
|
||||||
|
ruleType?: number;
|
||||||
|
packageMethodId?: number | null;
|
||||||
|
processRuleId?: number | null;
|
||||||
|
childProcessRuleId?: number | null;
|
||||||
|
processRuleNoteId?: number | null;
|
||||||
|
dosage?: number;
|
||||||
|
dayDosage?: number;
|
||||||
|
}) {
|
||||||
|
if (chineseErrorKeys.value.length) {
|
||||||
|
chineseErrorKeys.value = [];
|
||||||
|
}
|
||||||
|
if (payload.ruleType != null) ruleType.value = payload.ruleType;
|
||||||
|
if (payload.packageMethodId !== undefined) {
|
||||||
|
packageMethodId.value = Number(payload.packageMethodId || 2);
|
||||||
|
}
|
||||||
|
if (payload.ruleType === 1) {
|
||||||
|
processRuleId.value = 0;
|
||||||
|
childProcessRuleId.value = 0;
|
||||||
|
processRuleNoteId.value = 0;
|
||||||
|
}
|
||||||
|
if (payload.processRuleId !== undefined) {
|
||||||
|
processRuleId.value = Number(payload.processRuleId || 0);
|
||||||
|
}
|
||||||
|
if (payload.childProcessRuleId !== undefined) {
|
||||||
|
childProcessRuleId.value = Number(payload.childProcessRuleId || 0);
|
||||||
|
}
|
||||||
|
if (payload.processRuleNoteId !== undefined) {
|
||||||
|
processRuleNoteId.value = Number(payload.processRuleNoteId || 0);
|
||||||
|
}
|
||||||
|
if (payload.dosage != null) dosage.value = payload.dosage;
|
||||||
|
if (payload.dayDosage != null) dayDosage.value = payload.dayDosage;
|
||||||
|
void debouncedQuote();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPickDrug(index: number, item: Record<string, any>) {
|
||||||
|
const row = drugRows.value[index];
|
||||||
|
if (!row) return;
|
||||||
|
const drugId = Number(item.drug_id || item.drug?.id || 0);
|
||||||
|
const name = String(item.drug?.drug_name || item.drug_name || '');
|
||||||
|
if (!drugId || !name) return;
|
||||||
|
row.drug_id = drugId;
|
||||||
|
row.name = name;
|
||||||
|
row.price = Number(item.price || 0);
|
||||||
|
void debouncedQuote();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDrug() {
|
||||||
|
drugRows.value.push({
|
||||||
|
key: drugKey++,
|
||||||
|
drug_id: 0,
|
||||||
|
name: '',
|
||||||
|
number: 1,
|
||||||
|
way_id: 0,
|
||||||
|
price: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeDrug(index: number) {
|
||||||
|
drugRows.value.splice(index, 1);
|
||||||
|
void debouncedQuote();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 地址或药品克数变化时重算 */
|
||||||
|
watch(
|
||||||
|
[patientAddress, drugRows, dosage],
|
||||||
|
() => {
|
||||||
|
if (canEdit.value) void debouncedQuote();
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
async function onApprove() {
|
||||||
|
if (!detail.value?.id || !validateForm()) return;
|
||||||
|
acting.value = true;
|
||||||
|
try {
|
||||||
|
await approveImageTransferOrder(buildPayload());
|
||||||
|
message.success('已通过并推送');
|
||||||
|
finish();
|
||||||
|
} finally {
|
||||||
|
acting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRetry() {
|
||||||
|
if (!detail.value?.id || !validateForm()) return;
|
||||||
|
acting.value = true;
|
||||||
|
try {
|
||||||
|
await retryImageTransferOrder(buildPayload());
|
||||||
|
message.success('已重推');
|
||||||
|
finish();
|
||||||
|
} finally {
|
||||||
|
acting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 医生/诊所:待审或驳回改完再交 */
|
||||||
|
async function onResubmit() {
|
||||||
|
if (!detail.value?.id || !validateForm()) return;
|
||||||
|
acting.value = true;
|
||||||
|
try {
|
||||||
|
await resubmitImageTransferOrder(buildPayload());
|
||||||
|
message.success('已重新提交');
|
||||||
|
finish();
|
||||||
|
} finally {
|
||||||
|
acting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [RejectModal, rejectModalApi] = useVbenModal({
|
||||||
|
title: '驳回原因',
|
||||||
|
onConfirm: async () => {
|
||||||
|
const reason = rejectReason.value.trim();
|
||||||
|
if (!reason || !detail.value?.id) {
|
||||||
|
message.warning('请填写驳回原因');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rejectModalApi.setState({ confirmLoading: true });
|
||||||
|
try {
|
||||||
|
await rejectImageTransferOrder({ id: detail.value.id, reason });
|
||||||
|
message.success('已驳回');
|
||||||
|
rejectModalApi.close();
|
||||||
|
finish();
|
||||||
|
} finally {
|
||||||
|
rejectModalApi.setState({ confirmLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function openReject() {
|
||||||
|
rejectReason.value = '';
|
||||||
|
rejectModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Drawer, drawerApi] = useVbenDrawer({
|
||||||
|
class: 'w-[92vw]',
|
||||||
|
title: '传方详情',
|
||||||
|
cancelText: '关闭',
|
||||||
|
showConfirmButton: false,
|
||||||
|
onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
detail.value = null;
|
||||||
|
drugRows.value = [];
|
||||||
|
quotePreview.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = drawerApi.getData<{ id?: number }>() || {};
|
||||||
|
const id = Number(data.id || 0);
|
||||||
|
if (id > 0) {
|
||||||
|
void load(id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Drawer>
|
||||||
|
<div v-if="loading" class="text-[hsl(var(--muted-foreground))]">加载中…</div>
|
||||||
|
<div v-else-if="detail" class="ito-audit">
|
||||||
|
<div class="ito-origin">
|
||||||
|
<OcrOriginImagePanel :urls="imageUrls" title="处方原图" class="h-full min-h-0 flex-1" />
|
||||||
|
</div>
|
||||||
|
<div class="ito-form">
|
||||||
|
<Card
|
||||||
|
size="small"
|
||||||
|
title="患者信息"
|
||||||
|
class="border border-primary/25 shadow-[0_0_6px_hsl(var(--primary)/0.12)]"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-xs text-muted-foreground">姓名</div>
|
||||||
|
<Input v-model:value="patientName" :disabled="!canEdit" placeholder="患者姓名" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-xs text-muted-foreground">手机号</div>
|
||||||
|
<Input v-model:value="patientMobile" :disabled="!canEdit" placeholder="手机号" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-xs text-muted-foreground">性别</div>
|
||||||
|
<Select
|
||||||
|
v-model:value="patientSex"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
:options="sexOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-xs text-muted-foreground">年龄</div>
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="patientAge"
|
||||||
|
class="w-full"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
:min="0"
|
||||||
|
:max="200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<div class="mb-1 text-xs text-muted-foreground">地址</div>
|
||||||
|
<Input v-model:value="patientAddress" :disabled="!canEdit" placeholder="收货地址" />
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<div class="mb-1 text-xs text-muted-foreground">诊断</div>
|
||||||
|
<Input v-model:value="clinicalDiagnose" :disabled="!canEdit" placeholder="临床诊断" />
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<div class="mb-1 text-xs text-muted-foreground">医嘱</div>
|
||||||
|
<Input.TextArea
|
||||||
|
v-model:value="doctorOrder"
|
||||||
|
:rows="2"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
placeholder="医嘱"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm sm:col-span-2">共 {{ drugRows.length }} 味药</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<EntrustedProcessPicker
|
||||||
|
:process-rule-id="processRuleId"
|
||||||
|
:child-process-rule-id="childProcessRuleId"
|
||||||
|
:process-rule-note-id="processRuleNoteId"
|
||||||
|
:dosage="dosage"
|
||||||
|
:day-dosage="dayDosage"
|
||||||
|
:rule-type="ruleType"
|
||||||
|
:package-method-id="packageMethodId"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
:error-keys="chineseErrorKeys"
|
||||||
|
@change="onProcessChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<!-- 审方端始终展示报价:药价/代煎费/运费 + 地址解析出的省市区 -->
|
||||||
|
<Card
|
||||||
|
v-if="priceView"
|
||||||
|
size="small"
|
||||||
|
class="mt-3 border border-primary/25 shadow-[0_0_6px_hsl(var(--primary)/0.12)]"
|
||||||
|
:title="quoting ? '费用明细(计算中…)' : '费用明细'"
|
||||||
|
>
|
||||||
|
<div v-if="regionText" class="mb-2 text-xs text-muted-foreground">
|
||||||
|
收货地区:{{ regionText }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="mb-2 text-xs text-muted-foreground">
|
||||||
|
未识别省市区(写完整省市区后可自动算运费)
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-sm sm:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">药价</div>
|
||||||
|
<div class="font-semibold tabular-nums">¥{{ priceView.items_price ?? '0.00' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">代煎费</div>
|
||||||
|
<div class="font-semibold tabular-nums">¥{{ priceView.process_price ?? '0.00' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">运费</div>
|
||||||
|
<div class="font-semibold tabular-nums">¥{{ priceView.trans_expenses ?? '0.00' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-xs text-muted-foreground">合计</div>
|
||||||
|
<div class="font-semibold tabular-nums text-primary">
|
||||||
|
¥{{ priceView.total_price ?? '0.00' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div class="mt-4 flex items-center justify-between">
|
||||||
|
<div class="text-sm font-medium">药品({{ drugRows.length }})</div>
|
||||||
|
<Button v-if="canEdit" size="small" @click="addDrug">添加药品</Button>
|
||||||
|
</div>
|
||||||
|
<div class="ito-drug-grid mt-2">
|
||||||
|
<div
|
||||||
|
v-for="(drug, idx) in drugRows"
|
||||||
|
:key="drug.key"
|
||||||
|
class="rounded border border-primary/30 px-2 py-1.5 text-sm shadow-[0_0_6px_hsl(var(--primary)/0.18)]"
|
||||||
|
>
|
||||||
|
<ChineseDrugNameBubble
|
||||||
|
v-if="canEdit"
|
||||||
|
:display-name="drug.name"
|
||||||
|
:store-id="Number(detail.store_id || 0)"
|
||||||
|
:type="1"
|
||||||
|
compact
|
||||||
|
placeholder="药名/拼音"
|
||||||
|
@select="(item) => onPickDrug(idx, item)"
|
||||||
|
/>
|
||||||
|
<div v-else class="truncate font-medium">{{ drug.name }}</div>
|
||||||
|
<div class="mt-1 flex items-center gap-1">
|
||||||
|
<InputNumber
|
||||||
|
v-model:value="drug.number"
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
:disabled="!canEdit"
|
||||||
|
:min="0.1"
|
||||||
|
:step="1"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<span class="shrink-0 text-xs text-muted-foreground">g</span>
|
||||||
|
<Button
|
||||||
|
v-if="canEdit"
|
||||||
|
type="link"
|
||||||
|
danger
|
||||||
|
size="small"
|
||||||
|
class="!px-1"
|
||||||
|
@click="removeDrug(idx)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="drug.price != null"
|
||||||
|
class="mt-1 text-xs text-muted-foreground tabular-nums"
|
||||||
|
>
|
||||||
|
¥{{ drug.price }}/g
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="detail.reject_reason" class="mt-3 text-sm text-[hsl(var(--destructive))]">
|
||||||
|
驳回:{{ detail.reject_reason }}
|
||||||
|
</div>
|
||||||
|
<div v-if="detail.mes_message" class="mt-2 text-xs text-muted-foreground">
|
||||||
|
{{ detail.mes_message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #append-footer>
|
||||||
|
<Button
|
||||||
|
v-if="canAudit && detail?.status === 1"
|
||||||
|
danger
|
||||||
|
:disabled="acting"
|
||||||
|
@click="openReject"
|
||||||
|
>
|
||||||
|
驳回
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="canAudit && detail?.status === 1"
|
||||||
|
type="primary"
|
||||||
|
:loading="acting"
|
||||||
|
@click="onApprove"
|
||||||
|
>
|
||||||
|
通过并推MES
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="canResubmit"
|
||||||
|
type="primary"
|
||||||
|
:loading="acting"
|
||||||
|
@click="onResubmit"
|
||||||
|
>
|
||||||
|
{{ detail?.status === 2 ? '修改后再交' : '保存并再交' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
v-if="!canResubmit && detail?.status === 4"
|
||||||
|
type="primary"
|
||||||
|
:loading="acting"
|
||||||
|
@click="onRetry"
|
||||||
|
>
|
||||||
|
重推
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<RejectModal>
|
||||||
|
<Input.TextArea
|
||||||
|
v-model:value="rejectReason"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="请填写驳回原因,将退回诊所和上传人"
|
||||||
|
/>
|
||||||
|
</RejectModal>
|
||||||
|
</Drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ito-audit {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
height: calc(100vh - 140px);
|
||||||
|
min-height: 640px;
|
||||||
|
}
|
||||||
|
.ito-origin {
|
||||||
|
display: flex;
|
||||||
|
flex: 1.45;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.ito-form {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 360px;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.ito-drug-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 传方订单卡片:苹果液态玻璃,一行可排多张
|
||||||
|
* 以后换自定义卡片只替换本组件
|
||||||
|
*/
|
||||||
|
defineProps<{
|
||||||
|
order: Record<string, any>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
view: [];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button type="button" class="ito-glass" @click="emit('view')">
|
||||||
|
<div class="ito-head">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="ito-name">
|
||||||
|
<span class="truncate">{{ order.patient_name }}</span>
|
||||||
|
<span class="ito-tag">{{ order.source_text || '识图' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="ito-sub truncate">
|
||||||
|
{{ order.uploader_role_text }} · {{ order.doctor_name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ito-status">{{ order.status_text }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="ito-drugs">
|
||||||
|
<span v-if="!order.drug_count" class="ito-sub">暂无药品</span>
|
||||||
|
<span v-for="(name, idx) in order.drug_preview || []" :key="idx" class="ito-chip">
|
||||||
|
{{ name }}
|
||||||
|
</span>
|
||||||
|
<span v-if="order.drug_count > (order.drug_preview || []).length" class="ito-sub">
|
||||||
|
共 {{ order.drug_count }} 味
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="order.reject_reason" class="ito-reason">驳回:{{ order.reject_reason }}</div>
|
||||||
|
<div class="ito-foot">
|
||||||
|
<span class="ito-time">{{ order.created_at }}</span>
|
||||||
|
<span v-if="order.total_price != null && order.total_price !== ''" class="ito-price">
|
||||||
|
¥{{ order.total_price }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="ito-view">查看</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ito-glass {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
background: linear-gradient(
|
||||||
|
165deg,
|
||||||
|
hsl(var(--background) / 68%) 0%,
|
||||||
|
hsl(var(--background) / 42%) 100%
|
||||||
|
);
|
||||||
|
border: 1px solid hsl(var(--foreground) / 14%);
|
||||||
|
border-radius: 22px;
|
||||||
|
box-shadow:
|
||||||
|
0 10px 36px hsl(var(--foreground) / 10%),
|
||||||
|
inset 0 1px 0 hsl(var(--background) / 80%);
|
||||||
|
backdrop-filter: blur(28px) saturate(1.8);
|
||||||
|
-webkit-backdrop-filter: blur(28px) saturate(1.8);
|
||||||
|
}
|
||||||
|
.ito-glass::before {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
content: '';
|
||||||
|
background: linear-gradient(180deg, hsl(var(--foreground) / 8%) 0%, transparent 46%);
|
||||||
|
border-radius: inherit;
|
||||||
|
}
|
||||||
|
.ito-head,
|
||||||
|
.ito-foot,
|
||||||
|
.ito-name,
|
||||||
|
.ito-drugs {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.ito-head,
|
||||||
|
.ito-foot,
|
||||||
|
.ito-name {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.ito-name {
|
||||||
|
justify-content: flex-start;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.ito-tag {
|
||||||
|
padding: 1px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
border: 1px solid hsl(var(--primary) / 30%);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
|
||||||
|
}
|
||||||
|
.ito-sub,
|
||||||
|
.ito-time {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.ito-status,
|
||||||
|
.ito-view,
|
||||||
|
.ito-price {
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
.ito-price {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.ito-drugs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.ito-chip {
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
border: 1px solid hsl(var(--primary) / 30%);
|
||||||
|
border-radius: 999px;
|
||||||
|
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
|
||||||
|
}
|
||||||
|
.ito-reason {
|
||||||
|
position: relative;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--destructive));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 订单中心 - 传方订单
|
||||||
|
* 状态 Tab 记在本地,刷新后停在原 Tab;时间范围决定当前条件下的处方量
|
||||||
|
*/
|
||||||
|
import { computed, onMounted, ref } from 'vue';
|
||||||
|
|
||||||
|
import { Page, useVbenDrawer } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Button, Tabs } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import SearchTimeRangePicker from '#/components/form/components/search-time-range-picker.vue';
|
||||||
|
import { StatIslands, type StatIslandItem } from '#/components/stat-islands';
|
||||||
|
import { monthToTodayRangeString } from '#/utils/search-time-range';
|
||||||
|
|
||||||
|
import { getImageTransferOrderList } from './api';
|
||||||
|
import DetailDrawer from './components/detail-drawer.vue';
|
||||||
|
import OrderCard from './components/order-card.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ImageTransferOrder' });
|
||||||
|
|
||||||
|
const TAB_STORAGE_KEY = 'image_transfer_order_status_tab';
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ label: '全部', value: 0 },
|
||||||
|
{ label: '待审方', value: 1 },
|
||||||
|
{ label: '已驳回', value: 2 },
|
||||||
|
{ label: '已传MES', value: 3 },
|
||||||
|
{ label: '推送失败', value: 4 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 同步读本地 Tab,避免先渲染「全部」再被改回去,刷新时把记录冲掉 */
|
||||||
|
function readStoredStatus() {
|
||||||
|
const stored = Number(localStorage.getItem(TAB_STORAGE_KEY));
|
||||||
|
return tabs.some((tab) => tab.value === stored) ? stored : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const items = ref<Record<string, any>[]>([]);
|
||||||
|
const total = ref(0);
|
||||||
|
const page = ref(1);
|
||||||
|
const status = ref(readStoredStatus());
|
||||||
|
const timeRange = ref<[string, string] | null>(monthToTodayRangeString());
|
||||||
|
|
||||||
|
const overviewItems = computed<StatIslandItem[]>(() => [
|
||||||
|
{
|
||||||
|
key: 'count',
|
||||||
|
label: '总处方量',
|
||||||
|
// 统计岛默认把数字格式成金额两位小数,处方量用「N单」避开
|
||||||
|
value: `${total.value}单`,
|
||||||
|
icon: 'lucide:file-text',
|
||||||
|
prefix: '',
|
||||||
|
emphasize: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const [DetailDrawerComp, detailDrawerApi] = useVbenDrawer({
|
||||||
|
connectedComponent: DetailDrawer,
|
||||||
|
});
|
||||||
|
|
||||||
|
function onTabChange(key: string | number) {
|
||||||
|
const next = Number(key);
|
||||||
|
if (next === status.value) return;
|
||||||
|
status.value = next;
|
||||||
|
localStorage.setItem(TAB_STORAGE_KEY, String(next));
|
||||||
|
page.value = 1;
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getImageTransferOrderList({
|
||||||
|
page: page.value,
|
||||||
|
page_size: 12,
|
||||||
|
status: status.value,
|
||||||
|
start_at: timeRange.value?.[0] || '',
|
||||||
|
end_at: timeRange.value?.[1] || '',
|
||||||
|
});
|
||||||
|
items.value = res?.items || [];
|
||||||
|
total.value = Number(res?.prescription_count ?? res?.total ?? 0);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTimeChange() {
|
||||||
|
page.value = 1;
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(row: Record<string, any>) {
|
||||||
|
detailDrawerApi.setData({ id: row.id, onDone: load }).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
if (page.value <= 1) return;
|
||||||
|
page.value -= 1;
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
if (page.value * 12 >= total.value) return;
|
||||||
|
page.value += 1;
|
||||||
|
void load();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height title="传方订单">
|
||||||
|
<StatIslands class="mb-3" :items="overviewItems" :columns="4" />
|
||||||
|
<div class="mb-3 max-w-xl">
|
||||||
|
<SearchTimeRangePicker v-model:value="timeRange" value-format="YYYY-MM-DD" @change="onTimeChange" />
|
||||||
|
</div>
|
||||||
|
<Tabs :active-key="String(status)" @change="onTabChange">
|
||||||
|
<Tabs.TabPane v-for="tab in tabs" :key="String(tab.value)" :tab="tab.label">
|
||||||
|
<template v-if="Number(tab.value) === status">
|
||||||
|
<div class="mb-4 flex items-center gap-3">
|
||||||
|
<Button :loading="loading" @click="load">刷新</Button>
|
||||||
|
</div>
|
||||||
|
<div v-if="!loading && !items.length" class="text-[hsl(var(--muted-foreground))]">
|
||||||
|
暂无传方订单
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<OrderCard
|
||||||
|
v-for="row in items"
|
||||||
|
:key="row.id"
|
||||||
|
:order="row"
|
||||||
|
@view="openDetail(row)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="total > 12" class="mt-4 flex gap-2">
|
||||||
|
<Button :disabled="page <= 1" @click="prevPage">上一页</Button>
|
||||||
|
<Button :disabled="page * 12 >= total" @click="nextPage">下一页</Button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
</Tabs>
|
||||||
|
<DetailDrawerComp />
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
@@ -1,19 +1,17 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
/**
|
/**
|
||||||
* 商品订单卡片看板(C 端紧凑排版 + Apple 毛玻璃):与列表视图展示同一批字段,仅排版不同
|
* 商品订单卡片看板:展示字段与列表视图一致,只改排版。
|
||||||
*
|
*
|
||||||
* 排版语言参考 C 端 App 订单列表(外卖/电商):
|
* 尺寸对齐配送仓库「我的订单」卡片(宽卡、分区留白、最多三列),
|
||||||
* - 多列自适应网格(minmax 自动 1-3 列),单卡窄而紧凑,减少纵向占用
|
* 但字段不换成仓库那套(仍保留诊所/人员/金额分项/推广员/包邮等)。
|
||||||
* - 卡片:毛玻璃 + 14px 圆角 + hairline 细边框;状态用「彩点 + 彩字」
|
|
||||||
* - 商品行右侧直接放合计金额(C 端惯例),人员/收货信息压成内联小字行
|
|
||||||
* - 语义标签:tinted 小胶囊(同色低透明度底,无边框)
|
|
||||||
*
|
*
|
||||||
* 数据自取:props.formValues 变化时重置到第 1 页,触底自动加载下一页。
|
* 滚动:不再用内层无限滚动。内层滚动 + 触底追加会和页面滚动抢滚轮,
|
||||||
|
* 卡片悬停上移还会在滚动时反复进出命中区,表现为抽搐。这里改成分页,由页面统一滚动。
|
||||||
* 行操作复用父级 buildRowActions(与列表 #action 槽同一份定义)。
|
* 行操作复用父级 buildRowActions(与列表 #action 槽同一份定义)。
|
||||||
*/
|
*/
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { Button, Empty, Image, message, Spin, Switch } from 'ant-design-vue';
|
import { Button, Empty, Image, message, Pagination, Spin, Switch } from 'ant-design-vue';
|
||||||
|
|
||||||
import { CardActions } from '#/components/table-action';
|
import { CardActions } from '#/components/table-action';
|
||||||
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||||
@@ -60,19 +58,15 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const items = ref<any[]>([]);
|
const items = ref<any[]>([]);
|
||||||
const page = ref(1);
|
const page = ref(1);
|
||||||
const pageCount = ref(1);
|
const pageSize = ref(12);
|
||||||
|
const total = ref(0);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const loadingMore = ref(false);
|
|
||||||
/** 请求序号:只应用最后一次响应,避免快速改筛选时旧结果覆盖新列表 */
|
/** 请求序号:只应用最后一次响应,避免快速改筛选时旧结果覆盖新列表 */
|
||||||
let requestSeq = 0;
|
let requestSeq = 0;
|
||||||
/** 已手动展开商品明细的订单 id 集合 */
|
/** 已手动展开商品明细的订单 id 集合 */
|
||||||
const expandedIds = ref<Set<number>>(new Set());
|
const expandedIds = ref<Set<number>>(new Set());
|
||||||
|
|
||||||
const scrollEl = ref<HTMLElement | null>(null);
|
const scrollEl = ref<HTMLElement | null>(null);
|
||||||
const sentinelEl = ref<HTMLElement | null>(null);
|
|
||||||
let observer: IntersectionObserver | null = null;
|
|
||||||
|
|
||||||
const hasMore = computed(() => page.value < pageCount.value);
|
|
||||||
|
|
||||||
/** 处方类型元数据(全量 0-7,含特色方),无处方类型时返回 null */
|
/** 处方类型元数据(全量 0-7,含特色方),无处方类型时返回 null */
|
||||||
function prescriptionTypeMeta(row: Record<string, any>) {
|
function prescriptionTypeMeta(row: Record<string, any>) {
|
||||||
@@ -201,26 +195,22 @@ function toggleExpand(row: Record<string, any>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 拉取一页数据;reset 时清空列表并回到第 1 页
|
* 拉取当前页。分页而不是追加,避免列表高度在滚动中突变。
|
||||||
* 用 requestSeq 丢弃过期响应,防止筛选连点时旧条件结果覆盖新条件
|
* requestSeq 丢弃过期响应,防止筛选连点时旧条件结果覆盖新列表。
|
||||||
*/
|
*/
|
||||||
async function fetchPage(targetPage: number, reset = false) {
|
async function fetchPage(targetPage: number) {
|
||||||
const seq = ++requestSeq;
|
const seq = ++requestSeq;
|
||||||
if (reset) {
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
} else {
|
|
||||||
loadingMore.value = true;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
const res = await getOrderList({
|
const res = await getOrderList({
|
||||||
page: targetPage,
|
page: targetPage,
|
||||||
pageSize: 12,
|
pageSize: pageSize.value,
|
||||||
...(props.formValues || {}),
|
...(props.formValues || {}),
|
||||||
});
|
});
|
||||||
if (seq !== requestSeq) return;
|
if (seq !== requestSeq) return;
|
||||||
items.value = reset ? (res?.items ?? []) : [...items.value, ...(res?.items ?? [])];
|
items.value = res?.items ?? [];
|
||||||
page.value = res?.page ?? targetPage;
|
page.value = res?.page ?? targetPage;
|
||||||
pageCount.value = res?.page_count ?? 1;
|
total.value = Number(res?.total ?? 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (seq !== requestSeq) return;
|
if (seq !== requestSeq) return;
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -228,7 +218,6 @@ async function fetchPage(targetPage: number, reset = false) {
|
|||||||
} finally {
|
} finally {
|
||||||
if (seq === requestSeq) {
|
if (seq === requestSeq) {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
loadingMore.value = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -237,55 +226,28 @@ async function fetchPage(targetPage: number, reset = false) {
|
|||||||
async function reload() {
|
async function reload() {
|
||||||
page.value = 1;
|
page.value = 1;
|
||||||
items.value = [];
|
items.value = [];
|
||||||
await fetchPage(1, true);
|
await fetchPage(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 操作成功后的原地刷新:保留当前页码,避免退款/发货后跳回第 1 页
|
* 操作成功后的原地刷新:留在当前页,避免退款/发货后跳回第 1 页
|
||||||
* 卡片是累积加载的,这里折衷为按已加载条数一次性重拉(页码 1、pageSize=已加载数)
|
|
||||||
*/
|
*/
|
||||||
async function refreshKeepPage() {
|
async function refreshKeepPage() {
|
||||||
const loaded = Math.max(items.value.length, 12);
|
await fetchPage(page.value);
|
||||||
const seq = ++requestSeq;
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await getOrderList({
|
|
||||||
page: 1,
|
|
||||||
pageSize: loaded,
|
|
||||||
...(props.formValues || {}),
|
|
||||||
});
|
|
||||||
if (seq !== requestSeq) return;
|
|
||||||
items.value = res?.items ?? [];
|
|
||||||
// 折算页数:保持触底加载能继续接在后面
|
|
||||||
page.value = Math.ceil((res?.items?.length ?? 0) / 12) || 1;
|
|
||||||
pageCount.value = Math.ceil((res?.total ?? 0) / 12) || 1;
|
|
||||||
} catch (error) {
|
|
||||||
if (seq === requestSeq) console.error(error);
|
|
||||||
} finally {
|
|
||||||
if (seq === requestSeq) loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({ reload, refreshKeepPage });
|
defineExpose({ reload, refreshKeepPage });
|
||||||
|
|
||||||
async function loadMore() {
|
/** 翻页后回到卡片区顶部,避免停在页面最底部看不到新的一页 */
|
||||||
if (!hasMore.value || loading.value || loadingMore.value) return;
|
async function onPageChange(nextPage: number, nextSize: number) {
|
||||||
await fetchPage(page.value + 1, false);
|
if (nextSize !== pageSize.value) {
|
||||||
}
|
pageSize.value = nextSize;
|
||||||
|
page.value = 1;
|
||||||
/** 触底哨兵:滚动到底部附近自动加载下一页 */
|
} else {
|
||||||
function setupObserver() {
|
page.value = nextPage;
|
||||||
observer?.disconnect();
|
|
||||||
if (!sentinelEl.value) return;
|
|
||||||
observer = new IntersectionObserver(
|
|
||||||
(entries) => {
|
|
||||||
if (entries.some((e) => e.isIntersecting)) {
|
|
||||||
void loadMore();
|
|
||||||
}
|
}
|
||||||
},
|
await fetchPage(page.value);
|
||||||
{ root: scrollEl.value, rootMargin: '120px' },
|
scrollEl.value?.scrollIntoView({ block: 'start' });
|
||||||
);
|
|
||||||
observer.observe(sentinelEl.value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -296,15 +258,8 @@ watch(
|
|||||||
{ deep: true },
|
{ deep: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(() => {
|
||||||
await reload();
|
void reload();
|
||||||
await nextTick();
|
|
||||||
setupObserver();
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
observer?.disconnect();
|
|
||||||
observer = null;
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -313,8 +268,6 @@ onBeforeUnmount(() => {
|
|||||||
<Spin :spinning="loading">
|
<Spin :spinning="loading">
|
||||||
<div v-if="items.length" class="po-card-grid">
|
<div v-if="items.length" class="po-card-grid">
|
||||||
<div v-for="row in items" :key="row.id" class="po-card">
|
<div v-for="row in items" :key="row.id" class="po-card">
|
||||||
<!-- 角落径向荧光(与提现管理统计岛同款质感装饰) -->
|
|
||||||
<div class="po-card__glow" />
|
|
||||||
<!-- 行1 头部:诊所名(可点)+ 状态彩点 -->
|
<!-- 行1 头部:诊所名(可点)+ 状态彩点 -->
|
||||||
<div class="po-card__head">
|
<div class="po-card__head">
|
||||||
<button
|
<button
|
||||||
@@ -583,86 +536,65 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<Empty v-else-if="!loading" description="暂无订单" />
|
<Empty v-else-if="!loading" description="暂无订单" />
|
||||||
</Spin>
|
</Spin>
|
||||||
<div ref="sentinelEl" class="h-8"></div>
|
<div v-if="total > 0" class="po-card-pager">
|
||||||
<div v-if="loadingMore" class="py-3 text-center text-xs text-[hsl(var(--muted-foreground))]">
|
<Pagination
|
||||||
加载中…
|
:current="page"
|
||||||
</div>
|
:page-size="pageSize"
|
||||||
<div
|
:page-size-options="['12', '24', '48']"
|
||||||
v-else-if="items.length && !hasMore"
|
:total="total"
|
||||||
class="py-3 text-center text-xs text-[hsl(var(--muted-foreground))]"
|
show-quick-jumper
|
||||||
>
|
show-size-changer
|
||||||
没有更多了
|
@change="onPageChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.po-card-scroll {
|
.po-card-scroll {
|
||||||
height: 100%;
|
padding: 4px 2px 8px;
|
||||||
/* 左右留 6px:卡片 hover 阴影不被容器裁出切边 */
|
|
||||||
padding: 4px 6px 20px;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 多列自适应网格:单卡最窄 400px,宽屏自动 2-3 列(C 端紧凑卡片密度);
|
/* 与配送仓库订单相同的列数:窄屏 1 列,中屏 2 列,宽屏最多 3 列,卡片随列宽变大 */
|
||||||
min(400px, 100%) 防止窄容器(分屏)时轨道小于容器导致横向滚动 */
|
|
||||||
.po-card-grid {
|
.po-card-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(min(400px, 100%), 1fr));
|
grid-template-columns: minmax(0, 1fr);
|
||||||
gap: 10px;
|
gap: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 玻璃质感紧凑卡片(与提现管理统计岛同款):主色渐变玻璃底 + 12px 圆角 + 1px 边框 */
|
@media (min-width: 768px) {
|
||||||
|
.po-card-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.po-card-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 不用位移、不用毛玻璃:滚动经过卡片时 hover 上移会反复触发重绘,看起来像抽搐 */
|
||||||
.po-card {
|
.po-card {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
background: hsl(var(--card, var(--background)));
|
||||||
background: linear-gradient(
|
|
||||||
135deg,
|
|
||||||
hsl(var(--primary) / 4%),
|
|
||||||
hsl(var(--card, var(--background)))
|
|
||||||
);
|
|
||||||
backdrop-filter: blur(8px);
|
|
||||||
border: 1px solid hsl(var(--border));
|
border: 1px solid hsl(var(--border));
|
||||||
border-radius: 12px;
|
border-radius: 16px;
|
||||||
transition:
|
box-shadow: 0 1px 2px hsl(var(--foreground) / 6%);
|
||||||
transform 0.2s ease,
|
|
||||||
box-shadow 0.2s ease,
|
|
||||||
border-color 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-card:hover {
|
.po-card:hover {
|
||||||
border-color: hsl(var(--primary) / 40%);
|
border-color: hsl(var(--primary) / 35%);
|
||||||
box-shadow:
|
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
|
||||||
0 8px 20px hsl(var(--primary) / 14%),
|
|
||||||
0 2px 6px rgb(0 0 0 / 6%);
|
|
||||||
transform: translateY(-3px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 角落径向荧光:右上角淡主色光斑,hover 增强 */
|
|
||||||
.po-card__glow {
|
|
||||||
position: absolute;
|
|
||||||
top: -40px;
|
|
||||||
right: -40px;
|
|
||||||
width: 110px;
|
|
||||||
height: 110px;
|
|
||||||
pointer-events: none;
|
|
||||||
background: radial-gradient(circle, hsl(var(--primary) / 18%) 0%, transparent 70%);
|
|
||||||
opacity: 0.4;
|
|
||||||
transition: opacity 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.po-card:hover .po-card__glow {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 行1 头部:店铺 + 状态彩点 */
|
|
||||||
.po-card__head {
|
.po-card__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 12px 0;
|
padding: 16px 20px 12px;
|
||||||
|
border-bottom: 1px solid hsl(var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-card__store {
|
.po-card__store {
|
||||||
@@ -678,9 +610,8 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.po-card__store-name {
|
.po-card__store-name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: -0.2px;
|
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -692,33 +623,32 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.po-card__store-arrow {
|
.po-card__store-arrow {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-card__status {
|
.po-card__status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-card__status-dot {
|
.po-card__status-dot {
|
||||||
width: 6px;
|
width: 8px;
|
||||||
height: 6px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 行2 单号/时间小灰字 */
|
|
||||||
.po-card__meta {
|
.po-card__meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1px 12px 0;
|
padding: 12px 20px 0;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,45 +656,41 @@ onBeforeUnmount(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tinted 胶囊行(紧凑小号) */
|
|
||||||
.po-card__pills {
|
.po-card__pills {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
padding: 6px 12px 0;
|
padding: 10px 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-pill {
|
.po-pill {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1px 7px;
|
padding: 2px 10px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 16px;
|
line-height: 20px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-pill--action {
|
.po-pill--action {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: filter 0.15s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-pill--action:hover {
|
.po-pill--action:hover {
|
||||||
filter: brightness(1.08) saturate(1.2);
|
filter: brightness(1.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 行3 商品行:左图文右合计(C 端订单卡惯例) */
|
|
||||||
.po-card__goods {
|
.po-card__goods {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 7px 10px;
|
padding: 12px;
|
||||||
margin: 8px 12px 0;
|
margin: 12px 20px 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: hsl(var(--muted) / 30%);
|
background: hsl(var(--muted) / 30%);
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-card__goods:hover {
|
.po-card__goods:hover {
|
||||||
@@ -776,12 +702,12 @@ onBeforeUnmount(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 40px;
|
width: 68px;
|
||||||
height: 40px;
|
height: 68px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: hsl(var(--card, var(--background)));
|
background: hsl(var(--card, var(--background)));
|
||||||
border-radius: 8px;
|
border: 1px solid hsl(var(--border));
|
||||||
box-shadow: 0 1px 2px rgb(0 0 0 / 6%);
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-card__goods-thumb :deep(img) {
|
.po-card__goods-thumb :deep(img) {
|
||||||
@@ -789,14 +715,14 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.po-card__goods-placeholder {
|
.po-card__goods-placeholder {
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-card__goods-name {
|
.po-card__goods-name {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -805,10 +731,10 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.po-card__goods-count {
|
.po-card__goods-count {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 1px;
|
margin-top: 6px;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -817,38 +743,34 @@ onBeforeUnmount(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
color: hsl(var(--primary));
|
color: hsl(var(--primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-card__goods-toggle i {
|
.po-card__goods-toggle i {
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
transition: transform 0.2s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-card__goods-toggle i.up {
|
.po-card__goods-toggle i.up {
|
||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 合计金额(商品行右侧) */
|
|
||||||
.po-card__amount {
|
.po-card__amount {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-card__amount-value {
|
.po-card__amount-value {
|
||||||
/* DIN 等宽数字:与提现管理统计岛金额同款字体质感 */
|
|
||||||
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-size: 16px;
|
font-size: 20px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: -0.3px;
|
|
||||||
color: hsl(var(--foreground));
|
color: hsl(var(--foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-card__amount-value i {
|
.po-card__amount-value i {
|
||||||
margin-right: 1px;
|
margin-right: 1px;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -861,40 +783,39 @@ onBeforeUnmount(() => {
|
|||||||
.po-card__chips {
|
.po-card__chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
padding: 6px 12px 0;
|
padding: 10px 20px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-card__drug-chip {
|
.po-card__drug-chip {
|
||||||
padding: 1px 8px;
|
padding: 2px 10px;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
background: hsl(var(--muted) / 35%);
|
background: hsl(var(--muted) / 35%);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 行4 人员内联行:label 小灰字 + 可点链接,一行自动换行 */
|
|
||||||
.po-card__people {
|
.po-card__people {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 2px 14px;
|
gap: 6px 16px;
|
||||||
padding: 7px 12px 0;
|
padding: 12px 20px 0;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-kv {
|
.po-kv {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-kv > i {
|
.po-kv > i {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
color: hsl(var(--muted-foreground) / 75%);
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-kv > em {
|
.po-kv > em {
|
||||||
@@ -906,10 +827,10 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.po-link {
|
.po-link {
|
||||||
max-width: 140px;
|
max-width: 180px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
color: hsl(var(--primary));
|
color: hsl(var(--primary));
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -922,56 +843,61 @@ onBeforeUnmount(() => {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 行5/6 收货、推广员内联行:label + 单行截断 */
|
|
||||||
.po-card__express {
|
.po-card__express {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
padding: 4px 12px 0;
|
padding: 8px 20px 0;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-card__express > i {
|
.po-card__express > i {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
color: hsl(var(--muted-foreground) / 75%);
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-card__salesperson-status {
|
.po-card__salesperson-status {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0 7px;
|
padding: 0 8px;
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
line-height: 16px;
|
line-height: 20px;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
background: hsl(var(--muted) / 40%);
|
background: hsl(var(--muted) / 40%);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 弹性占位:吃掉剩余高度让 footer 吸底 */
|
|
||||||
.po-card__spacer {
|
.po-card__spacer {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 8px;
|
min-height: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 行7 footer:左分项/包邮,右操作按钮;hairline 分隔 */
|
|
||||||
.po-card__footer {
|
.po-card__footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 4px 10px;
|
gap: 8px 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 4px 6px 4px 12px;
|
padding: 12px 12px 12px 20px;
|
||||||
border-top: 1px solid hsl(var(--border) / 50%);
|
margin-top: 12px;
|
||||||
|
border-top: 1px solid hsl(var(--border));
|
||||||
}
|
}
|
||||||
|
|
||||||
.po-card__amount-parts {
|
.po-card__amount-parts {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.po-card-pager {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 20px 0 8px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -502,7 +502,9 @@ function prescriptionStatusColor() {
|
|||||||
<Descriptions.Item
|
<Descriptions.Item
|
||||||
:label="deliveryMethod === 0 ? '收货人' : '就诊人'"
|
:label="deliveryMethod === 0 ? '收货人' : '就诊人'"
|
||||||
>
|
>
|
||||||
<span v-if="deliveryMethod === 0">{{ data.address?.name }}</span>
|
<span v-if="deliveryMethod === 0">
|
||||||
|
{{ data.address?.name || data.express_name || '—' }}
|
||||||
|
</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
{{
|
{{
|
||||||
data.express_name || data.patient?.name || data.cancel_remark
|
data.express_name || data.patient?.name || data.cancel_remark
|
||||||
@@ -513,12 +515,24 @@ function prescriptionStatusColor() {
|
|||||||
<SensitiveText
|
<SensitiveText
|
||||||
:record="data.address"
|
:record="data.address"
|
||||||
field="mobile"
|
field="mobile"
|
||||||
:fallback="{ record: data, field: 'patient_mobile' }"
|
:fallback="{ record: data, field: 'express_mobile' }"
|
||||||
/>
|
/>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item v-if="deliveryMethod === 0" label="配送地址">
|
<Descriptions.Item v-if="deliveryMethod === 0" label="配送地址">
|
||||||
{{
|
{{
|
||||||
`${data.address?.province || ''} ${data.address?.region || ''} ${data.address?.detail_address || ''}`
|
(
|
||||||
|
[
|
||||||
|
data.address?.province,
|
||||||
|
data.address?.region || data.express_region,
|
||||||
|
data.address?.detail_address || data.express_address,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ') ||
|
||||||
|
[data.express_region, data.express_address]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ') ||
|
||||||
|
'—'
|
||||||
|
)
|
||||||
}}
|
}}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item v-else label="自提地址">
|
<Descriptions.Item v-else label="自提地址">
|
||||||
@@ -540,6 +554,7 @@ function prescriptionStatusColor() {
|
|||||||
class="tcm-detail-chip w-[220px] max-w-full flex-shrink-0 rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-600 dark:bg-gray-900"
|
class="tcm-detail-chip w-[220px] max-w-full flex-shrink-0 rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-600 dark:bg-gray-900"
|
||||||
>
|
>
|
||||||
<div class="font-medium">
|
<div class="font-medium">
|
||||||
|
<Tag v-if="Number(item.type) === 8" color="lime" class="mr-1">辅料</Tag>
|
||||||
【{{ item.drug?.drug_number }}】{{ item.drug_name }}
|
【{{ item.drug?.drug_number }}】{{ item.drug_name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-gray-600 dark:text-gray-400">
|
<div class="mt-1 text-gray-600 dark:text-gray-400">
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export const PRESCRIPTION_TYPE_MAP: Record<number, TagMeta> = {
|
|||||||
5: { label: '产品服务包', color: 'pink' },
|
5: { label: '产品服务包', color: 'pink' },
|
||||||
6: { label: '非药品', color: 'cyan' },
|
6: { label: '非药品', color: 'cyan' },
|
||||||
7: { label: '医疗器械', color: 'geekblue' },
|
7: { label: '医疗器械', color: 'geekblue' },
|
||||||
|
8: { label: '辅料', color: 'lime' },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 订单状态(status)映射,与列表原 if-else 链颜色语义一致 */
|
/** 订单状态(status)映射,与列表原 if-else 链颜色语义一致 */
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
|||||||
],
|
],
|
||||||
keepSource: true,
|
keepSource: true,
|
||||||
pagerConfig: {},
|
pagerConfig: {},
|
||||||
|
height: 'auto',
|
||||||
columnConfig: {
|
columnConfig: {
|
||||||
useKey: true,
|
useKey: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1184,20 +1184,21 @@ function buildRowActions(row: Record<string, any>): {
|
|||||||
<PrescriptionDetailModal />
|
<PrescriptionDetailModal />
|
||||||
<TraceDrawer />
|
<TraceDrawer />
|
||||||
<ChinaErpSyncDrawer @synced="() => refreshCurrentView()" />
|
<ChinaErpSyncDrawer @synced="() => refreshCurrentView()" />
|
||||||
<!-- 紧凑统计岛:一行 4 格(仅占 2 格),设计对齐提现管理账户卡统计岛 -->
|
<!-- 统计、搜索固定高度,列表/卡片只留一个滚动层,避免页面和表格同时滚动时抢滚轮 -->
|
||||||
<StatIslands class="mb-3" :items="overviewItems" :columns="4" />
|
<div class="flex h-full min-h-0 flex-col overflow-hidden">
|
||||||
|
<StatIslands class="mb-3 shrink-0" :items="overviewItems" :columns="4" />
|
||||||
<!-- 顶部统一搜索(双视图共用,替代原 grid 内嵌表单) -->
|
<!-- 顶部统一搜索(双视图共用,替代原 grid 内嵌表单) -->
|
||||||
<div class="mb-3 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-3">
|
<div class="mb-3 shrink-0 rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--card,var(--background)))] p-3">
|
||||||
<SearchForm />
|
<SearchForm />
|
||||||
</div>
|
</div>
|
||||||
<!-- 视图切换:列表 / 卡片看板;卡片模式下补充导出入口(列表模式导出在表格工具栏) -->
|
<!-- 视图切换:列表 / 卡片看板;卡片模式下补充导出入口(列表模式导出在表格工具栏) -->
|
||||||
<div class="mb-3 flex items-center justify-between gap-3">
|
<div class="mb-3 flex shrink-0 items-center justify-between gap-3">
|
||||||
<ViewModeSwitch v-model="viewMode" />
|
<ViewModeSwitch v-model="viewMode" />
|
||||||
<Button v-if="viewMode === 'card'" type="primary" @click="openExportModal">
|
<Button v-if="viewMode === 'card'" type="primary" @click="openExportModal">
|
||||||
导出
|
导出
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<Grid v-if="viewMode === 'list'">
|
<Grid v-if="viewMode === 'list'" class="h-0 min-h-0 flex-1">
|
||||||
<template #toolbar-actions>
|
<template #toolbar-actions>
|
||||||
<TableAction
|
<TableAction
|
||||||
:flex="false"
|
:flex="false"
|
||||||
@@ -1502,6 +1503,7 @@ function buildRowActions(row: Record<string, any>): {
|
|||||||
class="tcm-drug-chip w-[200px] max-w-full flex-shrink-0 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-gray-600 dark:bg-gray-900"
|
class="tcm-drug-chip w-[200px] max-w-full flex-shrink-0 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-gray-600 dark:bg-gray-900"
|
||||||
>
|
>
|
||||||
<div class="font-medium">
|
<div class="font-medium">
|
||||||
|
<Tag v-if="Number(item.type) === 8" color="lime" class="mr-1">辅料</Tag>
|
||||||
【{{ item.drug?.drug_number }}】{{ item.display_drug_name || item.drug_name }}
|
【{{ item.drug?.drug_number }}】{{ item.display_drug_name || item.drug_name }}
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-1 text-gray-600 dark:text-gray-400">
|
<div class="mt-1 text-gray-600 dark:text-gray-400">
|
||||||
@@ -1536,7 +1538,7 @@ function buildRowActions(row: Record<string, any>): {
|
|||||||
</template>
|
</template>
|
||||||
</Grid>
|
</Grid>
|
||||||
<!-- 卡片看板视图:展示内容与列表一致,行操作/弹窗全部复用父级逻辑 -->
|
<!-- 卡片看板视图:展示内容与列表一致,行操作/弹窗全部复用父级逻辑 -->
|
||||||
<div v-else class="po-card-wrap">
|
<div v-else class="min-h-0 flex-1 overflow-y-auto">
|
||||||
<ProductOrderCardList
|
<ProductOrderCardList
|
||||||
ref="cardListRef"
|
ref="cardListRef"
|
||||||
:form-values="searchValues"
|
:form-values="searchValues"
|
||||||
@@ -1558,6 +1560,7 @@ function buildRowActions(row: Record<string, any>): {
|
|||||||
@reverse-salesperson="handleReverseSalesperson"
|
@reverse-salesperson="handleReverseSalesperson"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<PercentAdjustDrawer />
|
<PercentAdjustDrawer />
|
||||||
<!-- 订单改仓:按药分别选仓 -->
|
<!-- 订单改仓:按药分别选仓 -->
|
||||||
<AntdModal
|
<AntdModal
|
||||||
@@ -1645,10 +1648,4 @@ function buildRowActions(row: Record<string, any>): {
|
|||||||
.tcm-drug-chip {
|
.tcm-drug-chip {
|
||||||
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
|
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 卡片看板容器:撑满剩余高度,内部滚动(统计岛 + 收起态搜索一行 + 切换行约 220px,min-height 兜底) */
|
|
||||||
.po-card-wrap {
|
|
||||||
height: calc(100% - 220px);
|
|
||||||
min-height: 420px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -44,14 +44,14 @@ export const modalFormProps: VbenFormProps = {
|
|||||||
rules: 'required',
|
rules: 'required',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 别名选填:无别名也可保存,后端 syncAliases 空串会清空别名表
|
||||||
component: 'VbenInput',
|
component: 'VbenInput',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
placeholder: '请输入药品别名',
|
placeholder: '选填,多个可用顿号/逗号分隔',
|
||||||
},
|
},
|
||||||
fieldName: 'drug_alias',
|
fieldName: 'drug_alias',
|
||||||
formItemClass: 'col-span-6',
|
formItemClass: 'col-span-6',
|
||||||
label: '药品别名',
|
label: '药品别名',
|
||||||
rules: 'required',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
component: 'VbenInput',
|
component: 'VbenInput',
|
||||||
|
|||||||
105
apps/web-antd/src/views/business/product/excipient/api/index.ts
Normal file
105
apps/web-antd/src/views/business/product/excipient/api/index.ts
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
const prefix = 'excipient/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询辅料列表
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
export async function getExcipientList(data: any) {
|
||||||
|
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 辅料下拉选项
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
export async function getExcipientOption(data: any) {
|
||||||
|
return requestClient.get<any>(`${prefix}option`, { params: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取辅料详情
|
||||||
|
* @param id
|
||||||
|
*/
|
||||||
|
export async function getExcipientInfo(id: number) {
|
||||||
|
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导出辅料药品
|
||||||
|
*/
|
||||||
|
export async function exportExcipientApi() {
|
||||||
|
return requestClient.download(`${prefix}export`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增辅料
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
export async function createExcipient(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}create`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 编辑辅料
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
export async function updateExcipient(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}update`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除辅料
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
export async function deleteExcipient(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}delete`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 导入辅料
|
||||||
|
* @param data
|
||||||
|
*/
|
||||||
|
export async function importExcipientApi(data: Record<string, any>) {
|
||||||
|
return requestClient.upload(`${prefix}import`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览批量更新药品编码(只读)
|
||||||
|
*/
|
||||||
|
export async function previewBatchDrugNumberApi(data: {
|
||||||
|
items: { drug_name: string; drug_number: string }[];
|
||||||
|
}) {
|
||||||
|
return requestClient.post<any>(`${prefix}preview-batch-drug-number`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量更新编码:全量辅料 id/名称/编号(卡片参考)
|
||||||
|
*/
|
||||||
|
export async function getAllDrugNumbersForBatchApi() {
|
||||||
|
return requestClient.get<{
|
||||||
|
items: {
|
||||||
|
id: number;
|
||||||
|
drug_name: string;
|
||||||
|
drug_number: null | string;
|
||||||
|
pinyin_simple: string;
|
||||||
|
}[];
|
||||||
|
}>(`${prefix}list-all-drug-numbers-for-batch`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认批量更新药品编码
|
||||||
|
*/
|
||||||
|
export async function applyBatchDrugNumberApi(data: {
|
||||||
|
items: { id: number; drug_number: string }[];
|
||||||
|
}) {
|
||||||
|
return requestClient.post<any>(`${prefix}apply-batch-drug-number`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步空拼音(首拼/全拼/数字串)
|
||||||
|
*/
|
||||||
|
export async function syncExcipientPinyin() {
|
||||||
|
return requestClient.post<any>(`${prefix}sync-pinyin`, {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 辅料批量更新药品编码弹窗
|
||||||
|
* 接口走 excipient 前缀,文案改为辅料
|
||||||
|
*/
|
||||||
|
import type { UploadFile } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Input,
|
||||||
|
message,
|
||||||
|
Modal,
|
||||||
|
Segmented,
|
||||||
|
Spin,
|
||||||
|
Table,
|
||||||
|
Tag,
|
||||||
|
UploadDragger,
|
||||||
|
} from 'ant-design-vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyBatchDrugNumberApi,
|
||||||
|
getAllDrugNumbersForBatchApi,
|
||||||
|
previewBatchDrugNumberApi,
|
||||||
|
} from '../api';
|
||||||
|
import {
|
||||||
|
isDrugNumberChanged,
|
||||||
|
parseDrugNumberExcelBuffer,
|
||||||
|
} from '../utils/parseDrugNumberExcel';
|
||||||
|
|
||||||
|
type MatchedRow = {
|
||||||
|
id: number;
|
||||||
|
drug_name: string;
|
||||||
|
drug_number_old: string | null;
|
||||||
|
drug_number_new: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MatchedRowView = MatchedRow & { _changed: boolean };
|
||||||
|
|
||||||
|
type AllDrugItem = {
|
||||||
|
id: number;
|
||||||
|
drug_name: string;
|
||||||
|
drug_number: null | string;
|
||||||
|
pinyin_simple?: string | null;
|
||||||
|
};
|
||||||
|
type CardStatusFilter = 'all' | 'changed' | 'unchanged';
|
||||||
|
|
||||||
|
const gridApi = ref();
|
||||||
|
const fileList = ref<UploadFile[]>([]);
|
||||||
|
const selectedFile = ref<File | null>(null);
|
||||||
|
const matched = ref<MatchedRow[]>([]);
|
||||||
|
const notFound = ref<{ drug_name: string; drug_number: string }[]>([]);
|
||||||
|
const invalidRows = ref<{ row: number; drug_name: string; drug_number: string }[]>(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const previewLoading = ref(false);
|
||||||
|
const applyLoading = ref(false);
|
||||||
|
const parseHint = ref('');
|
||||||
|
|
||||||
|
const allDrugs = ref<AllDrugItem[]>([]);
|
||||||
|
/** 打开弹窗时服务端原始编号,用于判断是否有变更 */
|
||||||
|
const initialNumbers = ref<Record<number, string>>({});
|
||||||
|
/** 当前编辑中的编号(卡片可改;导入解析会写入) */
|
||||||
|
const localNumbers = ref<Record<number, string>>({});
|
||||||
|
const loadingAll = ref(false);
|
||||||
|
const cardFilter = ref('');
|
||||||
|
const cardStatusFilter = ref<CardStatusFilter>('all');
|
||||||
|
|
||||||
|
const matchedView = computed<MatchedRowView[]>(() =>
|
||||||
|
matched.value.map((row) => {
|
||||||
|
const newNum =
|
||||||
|
localNumbers.value[row.id] ?? String(row.drug_number_new ?? '');
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
drug_number_new: newNum,
|
||||||
|
_changed: isDrugNumberChanged(initialNumbers.value[row.id], newNum),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const changedCount = computed(
|
||||||
|
() => matchedView.value.filter((r) => r._changed).length,
|
||||||
|
);
|
||||||
|
const cardStatusCounts = computed(() => {
|
||||||
|
const all = allDrugs.value.length;
|
||||||
|
const changed = allDrugs.value.filter((d) => cardIsChanged(d)).length;
|
||||||
|
return {
|
||||||
|
all,
|
||||||
|
changed,
|
||||||
|
unchanged: all - changed,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 含卡片直接编辑:任意与初始值不同的条数 */
|
||||||
|
const totalPendingChanges = computed(() =>
|
||||||
|
allDrugs.value.filter((d) =>
|
||||||
|
isDrugNumberChanged(
|
||||||
|
initialNumbers.value[d.id],
|
||||||
|
localNumbers.value[d.id],
|
||||||
|
),
|
||||||
|
).length,
|
||||||
|
);
|
||||||
|
|
||||||
|
function cardIsChanged(d: AllDrugItem) {
|
||||||
|
return isDrugNumberChanged(
|
||||||
|
initialNumbers.value[d.id],
|
||||||
|
localNumbers.value[d.id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLocalNumber(id: number, v: string) {
|
||||||
|
localNumbers.value = { ...localNumbers.value, [id]: v ?? '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredCards = computed(() => {
|
||||||
|
const q = cardFilter.value.trim().toLowerCase();
|
||||||
|
const keywordFiltered = allDrugs.value.filter((d) => {
|
||||||
|
const nameOk = d.drug_name.toLowerCase().includes(q);
|
||||||
|
const py = (d.pinyin_simple ?? '').toLowerCase();
|
||||||
|
const pyOk = py.includes(q);
|
||||||
|
return q ? nameOk || pyOk : true;
|
||||||
|
});
|
||||||
|
if (cardStatusFilter.value === 'changed') {
|
||||||
|
return keywordFiltered.filter((d) => cardIsChanged(d));
|
||||||
|
}
|
||||||
|
if (cardStatusFilter.value === 'unchanged') {
|
||||||
|
return keywordFiltered.filter((d) => !cardIsChanged(d));
|
||||||
|
}
|
||||||
|
return keywordFiltered;
|
||||||
|
});
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', key: 'id', width: 72 },
|
||||||
|
{ title: '药品名称', dataIndex: 'drug_name', key: 'drug_name', ellipsis: true },
|
||||||
|
{
|
||||||
|
title: '当前编号',
|
||||||
|
dataIndex: 'drug_number_old',
|
||||||
|
key: 'drug_number_old',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '新编号',
|
||||||
|
dataIndex: 'drug_number_new',
|
||||||
|
key: 'drug_number_new',
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '状态',
|
||||||
|
dataIndex: '_changed',
|
||||||
|
key: 'status',
|
||||||
|
width: 88,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
selectedFile.value = null;
|
||||||
|
fileList.value = [];
|
||||||
|
matched.value = [];
|
||||||
|
notFound.value = [];
|
||||||
|
invalidRows.value = [];
|
||||||
|
parseHint.value = '';
|
||||||
|
cardFilter.value = '';
|
||||||
|
cardStatusFilter.value = 'all';
|
||||||
|
allDrugs.value = [];
|
||||||
|
initialNumbers.value = {};
|
||||||
|
localNumbers.value = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [ModalComp, modalApi] = useVbenModal({
|
||||||
|
fullscreenButton: false,
|
||||||
|
draggable: true,
|
||||||
|
showConfirmButton: false,
|
||||||
|
class: '!w-[min(960px,92vw)]',
|
||||||
|
onCancel() {
|
||||||
|
resetState();
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
onOpenChange(isOpen: boolean) {
|
||||||
|
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||||
|
if (isOpen) {
|
||||||
|
loadAllDrugs();
|
||||||
|
} else {
|
||||||
|
resetState();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadAllDrugs() {
|
||||||
|
loadingAll.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getAllDrugNumbersForBatchApi();
|
||||||
|
const items = res?.items ?? [];
|
||||||
|
allDrugs.value = items;
|
||||||
|
const init: Record<number, string> = {};
|
||||||
|
const loc: Record<number, string> = {};
|
||||||
|
for (const d of items) {
|
||||||
|
const s = d.drug_number ?? '';
|
||||||
|
init[d.id] = s;
|
||||||
|
loc[d.id] = s;
|
||||||
|
}
|
||||||
|
initialNumbers.value = init;
|
||||||
|
localNumbers.value = loc;
|
||||||
|
} catch {
|
||||||
|
message.error('加载辅料列表失败');
|
||||||
|
allDrugs.value = [];
|
||||||
|
initialNumbers.value = {};
|
||||||
|
localNumbers.value = {};
|
||||||
|
} finally {
|
||||||
|
loadingAll.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFileChange = (info: { file: UploadFile }) => {
|
||||||
|
const { file } = info;
|
||||||
|
if (file.status === 'removed') {
|
||||||
|
selectedFile.value = null;
|
||||||
|
matched.value = [];
|
||||||
|
notFound.value = [];
|
||||||
|
invalidRows.value = [];
|
||||||
|
parseHint.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const raw = file.originFileObj ?? file;
|
||||||
|
if (raw instanceof File) {
|
||||||
|
selectedFile.value = raw;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function rowClassName(record: MatchedRowView) {
|
||||||
|
return record._changed ? 'drug-batch-row-changed' : 'drug-batch-row-unchanged';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPreview() {
|
||||||
|
if (!selectedFile.value) {
|
||||||
|
message.warning('请先选择 Excel 文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewLoading.value = true;
|
||||||
|
try {
|
||||||
|
const buf = await selectedFile.value.arrayBuffer();
|
||||||
|
const { items, invalidCount, rowCount } = parseDrugNumberExcelBuffer(buf);
|
||||||
|
if (items.length === 0) {
|
||||||
|
message.warning('未解析到有效的「药品编号」与「药品名称」行');
|
||||||
|
matched.value = [];
|
||||||
|
notFound.value = [];
|
||||||
|
invalidRows.value = [];
|
||||||
|
parseHint.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseHint.value =
|
||||||
|
rowCount > 5000
|
||||||
|
? `共 ${rowCount} 行,已按上限处理前 5000 行。`
|
||||||
|
: `共解析 ${items.length} 条有效行。`;
|
||||||
|
|
||||||
|
const res = await previewBatchDrugNumberApi({ items });
|
||||||
|
matched.value = (res.matched || []) as MatchedRow[];
|
||||||
|
notFound.value = res.not_found || [];
|
||||||
|
invalidRows.value = res.invalid || [];
|
||||||
|
for (const m of matched.value) {
|
||||||
|
const v = String(m.drug_number_new ?? '');
|
||||||
|
localNumbers.value = { ...localNumbers.value, [m.id]: v };
|
||||||
|
}
|
||||||
|
if (invalidCount > 0) {
|
||||||
|
message.info(`Excel 中有 ${invalidCount} 行因缺少编号或名称被跳过`);
|
||||||
|
}
|
||||||
|
if ((notFound.value?.length || 0) > 0) {
|
||||||
|
Modal.warning({
|
||||||
|
title: '部分药品名称在系统中未找到',
|
||||||
|
content: `未匹配 ${notFound.value.length} 条,请核对药名是否与后台完全一致。`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
message.error('预览失败');
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runApply() {
|
||||||
|
const items = allDrugs.value
|
||||||
|
.filter((d) =>
|
||||||
|
isDrugNumberChanged(
|
||||||
|
initialNumbers.value[d.id],
|
||||||
|
localNumbers.value[d.id],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((d) => ({
|
||||||
|
id: d.id,
|
||||||
|
drug_number: String(localNumbers.value[d.id] ?? ''),
|
||||||
|
}));
|
||||||
|
if (items.length === 0) {
|
||||||
|
message.info('没有需要更新的编号(与打开弹窗时一致)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
applyLoading.value = true;
|
||||||
|
modalApi.setState({ loading: true });
|
||||||
|
try {
|
||||||
|
const res = await applyBatchDrugNumberApi({ items });
|
||||||
|
const n = res?.updated_count ?? res?.updatedCount ?? 0;
|
||||||
|
message.success(`已更新 ${n} 条记录`);
|
||||||
|
gridApi.value?.reload();
|
||||||
|
resetState();
|
||||||
|
modalApi.close();
|
||||||
|
} catch {
|
||||||
|
message.error('更新失败');
|
||||||
|
} finally {
|
||||||
|
applyLoading.value = false;
|
||||||
|
modalApi.setState({ loading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ModalComp title="批量更新药品编码">
|
||||||
|
<div class="mb-3 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
下方卡片可直接修改编号;亦可使用 Excel:首列表头为「药品编号/药品编码」、第二列「药品名称」,或与之一致的列顺序。仅更新编号与更新时间。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Spin :spinning="loadingAll">
|
||||||
|
<div class="mb-1 text-sm font-medium text-gray-800 dark:text-gray-100">
|
||||||
|
当前辅料参考
|
||||||
|
<Tag>{{ filteredCards.length }}</Tag>
|
||||||
|
条
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
v-model:value="cardFilter"
|
||||||
|
allow-clear
|
||||||
|
class="mb-2"
|
||||||
|
placeholder="按药品名称或拼音首拼筛选"
|
||||||
|
/>
|
||||||
|
<Segmented
|
||||||
|
v-model:value="cardStatusFilter"
|
||||||
|
class="mb-2"
|
||||||
|
:options="[
|
||||||
|
{ label: `全部 ${cardStatusCounts.all}`, value: 'all' },
|
||||||
|
{ label: `有变动 ${cardStatusCounts.changed}`, value: 'changed' },
|
||||||
|
{
|
||||||
|
label: `无变动 ${cardStatusCounts.unchanged}`,
|
||||||
|
value: 'unchanged',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="mb-4 grid max-h-56 grid-cols-2 gap-2 overflow-y-auto sm:grid-cols-3 md:grid-cols-4"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
v-for="d in filteredCards"
|
||||||
|
:key="d.id"
|
||||||
|
:class="[
|
||||||
|
'!mb-0 shadow-sm transition-colors',
|
||||||
|
cardIsChanged(d)
|
||||||
|
? '!border-amber-400 bg-amber-50 dark:!border-amber-500/70 dark:bg-amber-950/45'
|
||||||
|
: '',
|
||||||
|
]"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="truncate text-sm font-medium text-gray-900 dark:text-gray-100"
|
||||||
|
:title="d.drug_name"
|
||||||
|
>
|
||||||
|
{{ d.drug_name }}
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
class="mt-1"
|
||||||
|
placeholder="药品编号"
|
||||||
|
size="small"
|
||||||
|
:value="localNumbers[d.id] ?? ''"
|
||||||
|
@update:value="(v: string) => setLocalNumber(d.id, v)"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</Spin>
|
||||||
|
|
||||||
|
<UploadDragger
|
||||||
|
v-model:file-list="fileList"
|
||||||
|
:before-upload="() => false"
|
||||||
|
:max-count="1"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
name="file"
|
||||||
|
@change="handleFileChange"
|
||||||
|
>
|
||||||
|
<p class="ant-upload-text">点击或拖拽选择 Excel</p>
|
||||||
|
<p class="ant-upload-hint">选择后点击「导入并解析」从服务器比对当前编号</p>
|
||||||
|
</UploadDragger>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
:loading="previewLoading"
|
||||||
|
type="primary"
|
||||||
|
@click="runPreview"
|
||||||
|
>
|
||||||
|
导入并解析
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
:disabled="totalPendingChanges === 0"
|
||||||
|
:loading="applyLoading"
|
||||||
|
danger
|
||||||
|
type="primary"
|
||||||
|
@click="runApply"
|
||||||
|
>
|
||||||
|
确认更新到数据库
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="parseHint" class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ parseHint }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
待保存变更:
|
||||||
|
<Tag :color="totalPendingChanges > 0 ? 'orange' : 'default'">{{
|
||||||
|
totalPendingChanges
|
||||||
|
}}</Tag>
|
||||||
|
条(含卡片修改与导入)
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="matched.length > 0" class="mt-4">
|
||||||
|
<div class="mb-2 text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||||
|
本次 Excel 匹配
|
||||||
|
<Tag color="blue">{{ matched.length }}</Tag>
|
||||||
|
条,其中
|
||||||
|
<Tag v-if="changedCount > 0" color="orange">{{ changedCount }}</Tag>
|
||||||
|
<Tag v-else color="default">0</Tag>
|
||||||
|
条与初始编号不同
|
||||||
|
</div>
|
||||||
|
<Table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="matchedView"
|
||||||
|
:pagination="{ pageSize: 8 }"
|
||||||
|
:row-class-name="(record: MatchedRowView) => rowClassName(record)"
|
||||||
|
:row-key="(row: MatchedRowView, index: number) => `${row.id}-${index}`"
|
||||||
|
:scroll="{ y: 280 }"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'status'">
|
||||||
|
<Tag v-if="record._changed" color="orange">已变更</Tag>
|
||||||
|
<Tag v-else color="default">无变化</Tag>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="notFound.length > 0" class="mt-4">
|
||||||
|
<div class="mb-1 text-sm font-medium text-orange-600 dark:text-orange-400">
|
||||||
|
未找到对应辅料({{ notFound.length }})
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="max-h-32 overflow-auto rounded border border-orange-100 bg-orange-50 p-2 text-xs text-gray-800 dark:border-orange-900/60 dark:bg-orange-950/40 dark:text-gray-200"
|
||||||
|
>
|
||||||
|
<div v-for="(row, i) in notFound.slice(0, 50)" :key="i">
|
||||||
|
{{ row.drug_name }} — {{ row.drug_number }}
|
||||||
|
</div>
|
||||||
|
<div v-if="notFound.length > 50">… 其余 {{ notFound.length - 50 }} 条略</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="invalidRows.length > 0"
|
||||||
|
class="mt-2 text-xs text-gray-500 dark:text-gray-400"
|
||||||
|
>
|
||||||
|
服务端标记无效行:{{ invalidRows.length }} 条
|
||||||
|
</div>
|
||||||
|
</ModalComp>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.drug-batch-row-changed) > td {
|
||||||
|
background-color: rgb(254 252 232) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dark .drug-batch-row-changed) > td {
|
||||||
|
background-color: rgb(69 26 3 / 0.45) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.drug-batch-row-unchanged) > td {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.dark .drug-batch-row-unchanged) > td {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 辅料 Excel 导入弹窗
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message, UploadDragger, type UploadFile } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { importExcipientApi } from '../api';
|
||||||
|
|
||||||
|
const gridApi = ref();
|
||||||
|
const fileList = ref<UploadFile[]>([]);
|
||||||
|
const selectedFile = ref<File | null>(null); // 新增:存储选中的文件
|
||||||
|
const isFileSelected = ref(false); // 新增:标记是否有文件被选中
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
fullscreenButton: false,
|
||||||
|
draggable: true,
|
||||||
|
onCancel() {
|
||||||
|
// 重置文件状态
|
||||||
|
selectedFile.value = null;
|
||||||
|
isFileSelected.value = false;
|
||||||
|
fileList.value = [];
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
onConfirm: async () => {
|
||||||
|
// 检查是否有选中文件
|
||||||
|
if (!selectedFile.value) {
|
||||||
|
message.warning('请先选择要上传的文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
modalApi.setState({ loading: true, confirmLoading: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await importExcipientApi({
|
||||||
|
file: selectedFile.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success('导入成功');
|
||||||
|
gridApi.value?.reload();
|
||||||
|
|
||||||
|
// 重置文件状态
|
||||||
|
selectedFile.value = null;
|
||||||
|
isFileSelected.value = false;
|
||||||
|
fileList.value = [];
|
||||||
|
|
||||||
|
modalApi.close();
|
||||||
|
} catch (error) {
|
||||||
|
message.error('导入失败');
|
||||||
|
} finally {
|
||||||
|
modalApi.setState({ loading: false, confirmLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOpenChange(isOpen: boolean) {
|
||||||
|
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||||
|
// 重置文件状态
|
||||||
|
selectedFile.value = null;
|
||||||
|
isFileSelected.value = false;
|
||||||
|
fileList.value = [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 处理文件选择变化,只存储文件不上传
|
||||||
|
const handleChange = (info: { file: UploadFile }) => {
|
||||||
|
const { file } = info;
|
||||||
|
|
||||||
|
if (file.status === 'removed') {
|
||||||
|
// 文件被移除
|
||||||
|
selectedFile.value = null;
|
||||||
|
isFileSelected.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
// 存储选中的文件
|
||||||
|
selectedFile.value = file;
|
||||||
|
isFileSelected.value = true;
|
||||||
|
message.success('文件已选择,请点击确认按钮上传');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Modal class="w-[80%] h-[80%]" title="上传Excel">
|
||||||
|
<!-- 新增:显示文件选择状态 -->
|
||||||
|
<div v-if="isFileSelected" class="mb-4 p-3 bg-green-50 border border-green-200 rounded">
|
||||||
|
<p class="text-green-700 text-sm">已选择文件:{{ selectedFile?.name }}</p>
|
||||||
|
<p class="text-green-600 text-xs mt-1">文件已准备就绪,点击确认按钮开始上传</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UploadDragger
|
||||||
|
v-model:file-list="fileList"
|
||||||
|
:before-upload="() => false"
|
||||||
|
:max-count="1"
|
||||||
|
:on-change="handleChange"
|
||||||
|
name="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
>
|
||||||
|
<p class="flex justify-center">
|
||||||
|
<svg
|
||||||
|
height="64"
|
||||||
|
viewBox="0 0 32 32"
|
||||||
|
width="64"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient
|
||||||
|
id="vscodeIconsFileTypeExcel0"
|
||||||
|
gradientTransform="translate(0 2100)"
|
||||||
|
gradientUnits="userSpaceOnUse"
|
||||||
|
x1="4.494"
|
||||||
|
x2="13.832"
|
||||||
|
y1="-2092.086"
|
||||||
|
y2="-2075.914"
|
||||||
|
>
|
||||||
|
<stop offset="0" stop-color="#18884f" />
|
||||||
|
<stop offset=".5" stop-color="#117e43" />
|
||||||
|
<stop offset="1" stop-color="#0b6631" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path
|
||||||
|
d="M19.581 15.35L8.512 13.4v14.409A1.19 1.19 0 0 0 9.705 29h19.1A1.19 1.19 0 0 0 30 27.809V22.5Z"
|
||||||
|
fill="#185c37"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M19.581 3H9.705a1.19 1.19 0 0 0-1.193 1.191V9.5L19.581 16l5.861 1.95L30 16V9.5Z"
|
||||||
|
fill="#21a366"
|
||||||
|
/>
|
||||||
|
<path d="M8.512 9.5h11.069V16H8.512Z" fill="#107c41" />
|
||||||
|
<path
|
||||||
|
d="M16.434 8.2H8.512v16.25h7.922a1.2 1.2 0 0 0 1.194-1.191V9.391A1.2 1.2 0 0 0 16.434 8.2"
|
||||||
|
opacity="0.1"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M15.783 8.85H8.512V25.1h7.271a1.2 1.2 0 0 0 1.194-1.191V10.041a1.2 1.2 0 0 0-1.194-1.191"
|
||||||
|
opacity="0.2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M15.783 8.85H8.512V23.8h7.271a1.2 1.2 0 0 0 1.194-1.191V10.041a1.2 1.2 0 0 0-1.194-1.191"
|
||||||
|
opacity="0.2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M15.132 8.85h-6.62V23.8h6.62a1.2 1.2 0 0 0 1.194-1.191V10.041a1.2 1.2 0 0 0-1.194-1.191"
|
||||||
|
opacity="0.2"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M3.194 8.85h11.938a1.193 1.193 0 0 1 1.194 1.191v11.918a1.193 1.193 0 0 1-1.194 1.191H3.194A1.19 1.19 0 0 1 2 21.959V10.041A1.19 1.19 0 0 1 3.194 8.85"
|
||||||
|
fill="url(#vscodeIconsFileTypeExcel0)"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="m5.7 19.873l2.511-3.884l-2.3-3.862h1.847L9.013 14.6c.116.234.2.408.238.524h.017q.123-.281.26-.546l1.342-2.447h1.7l-2.359 3.84l2.419 3.905h-1.809l-1.45-2.711A2.4 2.4 0 0 1 9.2 16.8h-.024a1.7 1.7 0 0 1-.168.351l-1.493 2.722Z"
|
||||||
|
fill="#fff"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M28.806 3h-9.225v6.5H30V4.191A1.19 1.19 0 0 0 28.806 3"
|
||||||
|
fill="#33c481"
|
||||||
|
/>
|
||||||
|
<path d="M19.581 16H30v6.5H19.581Z" fill="#107c41" />
|
||||||
|
</svg>
|
||||||
|
</p>
|
||||||
|
<p class="ant-upload-text">点击或拖动文件到此区域进行选择</p>
|
||||||
|
<p class="ant-upload-hint">
|
||||||
|
支持单个上传,xlsx格式文件。选择文件后需要点击确认按钮进行上传。
|
||||||
|
</p>
|
||||||
|
</UploadDragger>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 辅料新增/编辑弹窗
|
||||||
|
* 不展示使用时间/方法/单位/频率(辅料开方不依赖这些字段)
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
|
||||||
|
import { createExcipient, updateExcipient } from '../api';
|
||||||
|
import { modalFormProps } from '../config/form';
|
||||||
|
|
||||||
|
const isUpdate = ref(false);
|
||||||
|
const gridApi = ref();
|
||||||
|
/** 外部传入的成功回调 */
|
||||||
|
const onSuccessCb = ref<null | (() => void | Promise<void>)>(null);
|
||||||
|
|
||||||
|
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
fullscreenButton: false,
|
||||||
|
draggable: true,
|
||||||
|
onCancel() {
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
onConfirm: async () => {
|
||||||
|
const e = await formApi.validate();
|
||||||
|
if (!e.valid) return;
|
||||||
|
const values = await formApi.getValues();
|
||||||
|
modalApi.setState({ loading: true, confirmLoading: true });
|
||||||
|
try {
|
||||||
|
const submitApi = isUpdate.value
|
||||||
|
? updateExcipient
|
||||||
|
: createExcipient;
|
||||||
|
await submitApi(values);
|
||||||
|
message.success('保存成功');
|
||||||
|
gridApi.value?.reload?.();
|
||||||
|
gridApi.value?.query?.();
|
||||||
|
if (typeof onSuccessCb.value === 'function') {
|
||||||
|
await onSuccessCb.value();
|
||||||
|
}
|
||||||
|
modalApi.close();
|
||||||
|
} finally {
|
||||||
|
modalApi.setState({ loading: false, confirmLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
onSuccessCb.value = null;
|
||||||
|
formApi.resetForm();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = modalApi.getData<Record<string, any>>() || {};
|
||||||
|
gridApi.value = data.gridApi || null;
|
||||||
|
onSuccessCb.value =
|
||||||
|
typeof data.onSuccess === 'function' ? data.onSuccess : null;
|
||||||
|
const { values, update } = data;
|
||||||
|
isUpdate.value = !!update;
|
||||||
|
if (values) {
|
||||||
|
formApi.setValues(values);
|
||||||
|
} else {
|
||||||
|
formApi.resetForm();
|
||||||
|
formApi.setValues({ status: 1, drug_number: '-1' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Modal
|
||||||
|
:title="`${isUpdate === true ? '编辑' : '新增'}辅料`"
|
||||||
|
class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]"
|
||||||
|
>
|
||||||
|
<Form />
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
import type {VbenFormProps} from '#/adapter/form';
|
||||||
|
|
||||||
|
|
||||||
|
export const modalFormProps: VbenFormProps = {
|
||||||
|
wrapperClass: 'grid-cols-12', // 24栅格,
|
||||||
|
commonConfig: {
|
||||||
|
formItemClass: 'col-span-12',
|
||||||
|
// 所有表单项
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// handleSubmit: onSubmit,
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
fieldName: 'id',
|
||||||
|
label: 'ID',
|
||||||
|
formItemClass: 'col-span-6',
|
||||||
|
dependencies: {
|
||||||
|
show: false,
|
||||||
|
triggerFields: ['id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
fieldName: 'showSelect',
|
||||||
|
label: 'ID',
|
||||||
|
formItemClass: 'col-span-6',
|
||||||
|
dependencies: {
|
||||||
|
show: false,
|
||||||
|
triggerFields: ['id'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入药品昵称',
|
||||||
|
},
|
||||||
|
fieldName: 'drug_name',
|
||||||
|
formItemClass: 'col-span-6',
|
||||||
|
label: '药品昵称',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 别名选填:无别名也可保存,后端 syncAliases 空串会清空别名表
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '选填,多个可用顿号/逗号分隔',
|
||||||
|
},
|
||||||
|
fieldName: 'drug_alias',
|
||||||
|
formItemClass: 'col-span-6',
|
||||||
|
label: '药品别名',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '可留空,保存时按药名自动生成',
|
||||||
|
},
|
||||||
|
fieldName: 'pinyin_simple',
|
||||||
|
formItemClass: 'col-span-6',
|
||||||
|
label: '拼音首拼',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '可留空,保存时按药名自动生成',
|
||||||
|
},
|
||||||
|
fieldName: 'pinyin_full',
|
||||||
|
formItemClass: 'col-span-6',
|
||||||
|
label: '拼音全拼',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '可留空,保存时从药名提取数字',
|
||||||
|
},
|
||||||
|
fieldName: 'search_digits',
|
||||||
|
formItemClass: 'col-span-6',
|
||||||
|
label: '检索数字',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '选填,默认 -1',
|
||||||
|
},
|
||||||
|
fieldName: 'drug_number',
|
||||||
|
formItemClass: 'col-span-6',
|
||||||
|
label: '药品编号',
|
||||||
|
defaultValue: '-1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入规格',
|
||||||
|
},
|
||||||
|
fieldName: 'specification',
|
||||||
|
formItemClass: 'col-span-6',
|
||||||
|
label: '规格',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 按药配置开方快捷数量:逗号分隔,空则开方侧不显示快捷按钮
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '选填,如 5,10,15(逗号分隔)',
|
||||||
|
},
|
||||||
|
fieldName: 'quick_numbers',
|
||||||
|
formItemClass: 'col-span-6',
|
||||||
|
label: '快捷数量',
|
||||||
|
},
|
||||||
|
// 辅料不需要中药那套:使用时间/方法/单位/频率;开方侧单位缺省按 g
|
||||||
|
{
|
||||||
|
component: 'RadioGroup',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请选择',
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: '草稿',
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '下架',
|
||||||
|
value: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '上架',
|
||||||
|
value: 3,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
defaultValue: 1,
|
||||||
|
formItemClass: 'col-span-12',
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '商品状态',
|
||||||
|
rules: 'required',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showDefaultActions: false,
|
||||||
|
};
|
||||||
|
export const uploadExcelProps: VbenFormProps = {
|
||||||
|
wrapperClass: 'grid-cols-12', // 24栅格,
|
||||||
|
commonConfig: {
|
||||||
|
formItemClass: 'col-span-12',
|
||||||
|
// 所有表单项
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// handleSubmit: onSubmit,
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'Upload',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请选择',
|
||||||
|
multiple: true,
|
||||||
|
},
|
||||||
|
fieldName: 'frequency_id',
|
||||||
|
formItemClass: 'col-span-12',
|
||||||
|
label: '导入excel',
|
||||||
|
rules: 'file',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showDefaultActions: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { VbenFormProps } from '#/adapter/form';
|
||||||
|
|
||||||
|
// import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export const formOptions: VbenFormProps = {
|
||||||
|
// 默认展开
|
||||||
|
collapsed: false,
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '输入名称',
|
||||||
|
},
|
||||||
|
defaultValue: '',
|
||||||
|
fieldName: 'name',
|
||||||
|
label: '辅料名称',
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// component: 'RangePicker',
|
||||||
|
// componentProps: {
|
||||||
|
// format: 'YYYY-MM-DD', // 确保格式与你需要的一致
|
||||||
|
// },
|
||||||
|
// // defaultValue: [dayjs().startOf('month'), dayjs()],
|
||||||
|
// fieldName: 'search_time',
|
||||||
|
// label: '时间范围',
|
||||||
|
// },
|
||||||
|
],
|
||||||
|
// 控制表单是否显示折叠按钮
|
||||||
|
showCollapseButton: true,
|
||||||
|
submitButtonOptions: {
|
||||||
|
content: '查询',
|
||||||
|
},
|
||||||
|
// 是否在字段值改变时提交表单
|
||||||
|
submitOnChange: true,
|
||||||
|
// 按下回车时是否提交表单
|
||||||
|
submitOnEnter: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { getExcipientList } from '../api';
|
||||||
|
|
||||||
|
interface RowType {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
logo: string;
|
||||||
|
introduce: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gridOptions: VxeGridProps<RowType> = {
|
||||||
|
checkboxConfig: {
|
||||||
|
highlight: true,
|
||||||
|
labelField: '',
|
||||||
|
},
|
||||||
|
columnConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ type: 'checkbox', width: 60 },
|
||||||
|
{ field: 'id', align: 'left', title: 'ID', width: 100 },
|
||||||
|
{ field: 'drug_name', align: 'left', title: '药品名称', slots: { default: 'drug_name' } },
|
||||||
|
{ field: 'drug_number', align: 'left', title: '药品编码(erp)' },
|
||||||
|
{ field: 'specification', align: 'left', title: '规格', minWidth: 120 },
|
||||||
|
{ field: 'quick_numbers', align: 'left', title: '快捷数量', minWidth: 120 },
|
||||||
|
{
|
||||||
|
field: 'central_price',
|
||||||
|
title: '总仓售价',
|
||||||
|
width: 110,
|
||||||
|
slots: { default: 'central_price' },
|
||||||
|
},
|
||||||
|
{ field: 'pinyin_simple', title: '首拼', minWidth: 100, slots: { default: 'pinyin_simple' } },
|
||||||
|
{ field: 'pinyin_full', title: '全拼', minWidth: 140 },
|
||||||
|
{ field: 'search_digits', title: '数字', width: 90 },
|
||||||
|
{ field: 'status', title: '状态', slots: { default: 'status' } },
|
||||||
|
{ field: 'created_at', title: '发布时间' },
|
||||||
|
{ type: 'html', title: '操作', slots: { default: 'action' } },
|
||||||
|
],
|
||||||
|
keepSource: true,
|
||||||
|
pagerConfig: {},
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
// 请求后端接口方法
|
||||||
|
query: async ({ page }, formValues) => {
|
||||||
|
return await getExcipientList({
|
||||||
|
page: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
...formValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// exportConfig: {
|
||||||
|
// api: passApplicationApi,
|
||||||
|
// },
|
||||||
|
height: 'auto',
|
||||||
|
border: false,
|
||||||
|
toolbarConfig: {
|
||||||
|
// 是否显示搜索表单控制按钮
|
||||||
|
// @ts-ignore 正式环境时有完整的类型声明
|
||||||
|
search: true,
|
||||||
|
refresh: true, // 刷新
|
||||||
|
print: false, // 打印
|
||||||
|
export: false, // 导出
|
||||||
|
// custom: true, // 自定义列
|
||||||
|
zoom: true, // 最大化最小化
|
||||||
|
slots: {
|
||||||
|
buttons: 'toolbar-actions',
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
// 自定义列-图标
|
||||||
|
icon: 'vxe-icon-menu',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showOverflow: false,
|
||||||
|
};
|
||||||
321
apps/web-antd/src/views/business/product/excipient/index.vue
Normal file
321
apps/web-antd/src/views/business/product/excipient/index.vue
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 辅料管理列表页
|
||||||
|
* 克隆自中药模块,接口前缀 excipient/,defineOptions.name 对齐菜单 Excipient
|
||||||
|
*/
|
||||||
|
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Button, Modal as AntModal, message, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { TableAction } from '#/components/table-action';
|
||||||
|
import { downloadByData } from '#/util/tool';
|
||||||
|
|
||||||
|
import { deleteExcipient, exportExcipientApi, syncExcipientPinyin } from './api';
|
||||||
|
import DrugNumberBatchModal from './components/DrugNumberBatchModal.vue';
|
||||||
|
import FormModalDemo from './components/modal.vue';
|
||||||
|
import ExcelUpload from './components/ExcelUpload.vue';
|
||||||
|
import CentralPriceCell from '#/views/business/product/_shared/central-price-cell.vue';
|
||||||
|
import CentralStockInModal from '#/views/business/product/_shared/central-stock-in-modal.vue';
|
||||||
|
import DrugDetailModal from '#/views/business/product/_shared/drug-detail-modal.vue';
|
||||||
|
import WarehouseDrugPriceModal from '#/views/business/warehouse-drug-management/admin/components/modal.vue';
|
||||||
|
import { formOptions } from './config/search';
|
||||||
|
import { gridOptions } from './config/table';
|
||||||
|
|
||||||
|
defineOptions({ name: 'Excipient' });
|
||||||
|
|
||||||
|
/** 辅料产品类型(ProductTypeEnum::EXCIPIENT) */
|
||||||
|
const PRODUCT_TYPE = 8;
|
||||||
|
|
||||||
|
const hasTopTableDropDownActions = ref(false);
|
||||||
|
const syncing = ref(false);
|
||||||
|
|
||||||
|
const gridEvents: VxeGridListeners<any> = {
|
||||||
|
checkboxChange() {
|
||||||
|
// eslint-disable-next-line no-use-before-define
|
||||||
|
const records = gridApi.grid.getCheckboxRecords();
|
||||||
|
hasTopTableDropDownActions.value = records.length > 0;
|
||||||
|
},
|
||||||
|
checkboxAll() {
|
||||||
|
// eslint-disable-next-line no-use-before-define
|
||||||
|
const records = gridApi.grid.getCheckboxRecords();
|
||||||
|
hasTopTableDropDownActions.value = records.length > 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
formOptions,
|
||||||
|
gridOptions,
|
||||||
|
gridEvents,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [FormModal, formModalApi] = useVbenModal({
|
||||||
|
connectedComponent: FormModalDemo,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [ExcelUploadModal, ExcelUploadModalApi] = useVbenModal({
|
||||||
|
connectedComponent: ExcelUpload,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [DrugNumberBatchModalComp, drugNumberBatchModalApi] = useVbenModal({
|
||||||
|
connectedComponent: DrugNumberBatchModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [DrugDetailModalComp, drugDetailModalApi] = useVbenModal({
|
||||||
|
connectedComponent: DrugDetailModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [CentralStockInModalComp, centralStockInModalApi] = useVbenModal({
|
||||||
|
connectedComponent: CentralStockInModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [WarehousePriceModalComp, warehousePriceModalApi] = useVbenModal({
|
||||||
|
connectedComponent: WarehouseDrugPriceModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const showModal = (data = {}, isUpdate = false) => {
|
||||||
|
formModalApi.setData({
|
||||||
|
// 表单值
|
||||||
|
values: data,
|
||||||
|
update: isUpdate,
|
||||||
|
gridApi,
|
||||||
|
});
|
||||||
|
formModalApi.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 点击药名:打开详情 / 别名维护弹窗 */
|
||||||
|
function openDrugDetail(row: any) {
|
||||||
|
drugDetailModalApi.setData({
|
||||||
|
drug_id: row?.id,
|
||||||
|
gridApi,
|
||||||
|
});
|
||||||
|
drugDetailModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步本类型空拼音(首拼/全拼/数字) */
|
||||||
|
function handleSyncPinyin() {
|
||||||
|
AntModal.confirm({
|
||||||
|
title: '同步拼音',
|
||||||
|
content:
|
||||||
|
'将扫描本类型药品中首拼/全拼/数字串为空的记录并自动生成,已有值不会覆盖。确认执行?',
|
||||||
|
onOk: async () => {
|
||||||
|
syncing.value = true;
|
||||||
|
try {
|
||||||
|
const res = await syncExcipientPinyin();
|
||||||
|
message.success(
|
||||||
|
`同步完成:更新 ${Number(res?.updated || 0)} 条,跳过 ${Number(res?.skipped || 0)} 条`,
|
||||||
|
);
|
||||||
|
gridApi.query();
|
||||||
|
} finally {
|
||||||
|
syncing.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteApi = (row: any) => {
|
||||||
|
let ids = [];
|
||||||
|
if (row) {
|
||||||
|
ids.push(row);
|
||||||
|
} else {
|
||||||
|
ids = gridApi.grid.getCheckboxRecords().map((item) => item.id);
|
||||||
|
}
|
||||||
|
deleteExcipient({ ids }).then(() => {
|
||||||
|
message.success('删除成功!');
|
||||||
|
gridApi.query();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 辅料导出
|
||||||
|
*/
|
||||||
|
const passApplication = () => {
|
||||||
|
exportExcipientApi().then((res) => {
|
||||||
|
// 创建新的URL表示指定的File对象或者Blob对象。
|
||||||
|
downloadByData(res.data, '萧康云医-辅料导出.xlsx');
|
||||||
|
message.success('导出成功!');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const openExcelUploadModal = () => {
|
||||||
|
ExcelUploadModalApi.setData({
|
||||||
|
gridApi,
|
||||||
|
});
|
||||||
|
ExcelUploadModalApi.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDrugNumberBatchModal = () => {
|
||||||
|
drugNumberBatchModalApi.setData({
|
||||||
|
gridApi,
|
||||||
|
});
|
||||||
|
drugNumberBatchModalApi.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 未入总仓时打开入库弹窗 */
|
||||||
|
function openStockIn(row: any) {
|
||||||
|
if (row.in_central_warehouse) {
|
||||||
|
message.info('该药品已在总仓库');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
centralStockInModalApi.setData({
|
||||||
|
drug_id: row.id,
|
||||||
|
drug_name: row.drug_name,
|
||||||
|
productType: PRODUCT_TYPE,
|
||||||
|
values: row,
|
||||||
|
gridApi,
|
||||||
|
});
|
||||||
|
centralStockInModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 已入库:打开总仓调价弹窗 */
|
||||||
|
function openCentralPriceAdjust(row: any) {
|
||||||
|
const cwd = row.central_warehouse_drug;
|
||||||
|
if (!cwd?.id) {
|
||||||
|
message.warning('未找到总仓药品记录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
warehousePriceModalApi.setData({
|
||||||
|
update: true,
|
||||||
|
listDrugType: PRODUCT_TYPE,
|
||||||
|
gridApi,
|
||||||
|
values: {
|
||||||
|
id: cwd.id,
|
||||||
|
price: cwd.price ?? row.central_price,
|
||||||
|
market_price: cwd.market_price ?? row.central_market_price,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
warehousePriceModalApi.open();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height title="辅料管理">
|
||||||
|
<ExcelUploadModal />
|
||||||
|
<DrugNumberBatchModalComp />
|
||||||
|
<DrugDetailModalComp />
|
||||||
|
<CentralStockInModalComp />
|
||||||
|
<WarehousePriceModalComp />
|
||||||
|
<FormModal />
|
||||||
|
<Grid>
|
||||||
|
<template #toolbar-actions>
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '新增',
|
||||||
|
type: 'primary',
|
||||||
|
icon: 'ant-design:plus-outlined',
|
||||||
|
onClick: showModal.bind(null),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '导入',
|
||||||
|
type: 'primary',
|
||||||
|
icon: 'ix:export-check',
|
||||||
|
onClick: openExcelUploadModal.bind(null),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '批量更新编码',
|
||||||
|
type: 'default',
|
||||||
|
icon: 'ant-design:barcode-outlined',
|
||||||
|
onClick: openDrugNumberBatchModal.bind(null),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '导出',
|
||||||
|
type: 'primary',
|
||||||
|
icon: 'mingcute:download-3-fill',
|
||||||
|
onClick: passApplication.bind(null),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:drop-down-actions="[
|
||||||
|
{
|
||||||
|
label: '删除',
|
||||||
|
icon: 'ant-design:delete-outlined',
|
||||||
|
ifShow: hasTopTableDropDownActions,
|
||||||
|
popConfirm: {
|
||||||
|
title: '确定删除吗?',
|
||||||
|
confirm: deleteApi.bind(null, false),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<template #more>
|
||||||
|
<Button style="margin-left: 16px">
|
||||||
|
批量操作
|
||||||
|
<Icon icon="ant-design:down-outlined" />
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
</TableAction>
|
||||||
|
<Button class="ml-2" :loading="syncing" @click="handleSyncPinyin">
|
||||||
|
同步拼音
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
|
<template #drug_name="{ row }">
|
||||||
|
<div class="cm-drug-name-cell">
|
||||||
|
<a class="text-primary cursor-pointer" @click="openDrugDetail(row)">
|
||||||
|
{{ row.drug_name || '-' }}
|
||||||
|
</a>
|
||||||
|
<!-- 搜索命中别名时展示(对齐开方/仓库) -->
|
||||||
|
<div v-if="row.matched_alias" class="cm-drug-name-cell__alias">
|
||||||
|
别名:{{ row.matched_alias }}
|
||||||
|
<span v-if="row.matched_alias_pinyin">
|
||||||
|
({{ row.matched_alias_pinyin }})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #pinyin_simple="{ row }">
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
row.matched_alias_pinyin || row.pinyin_simple || '-'
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #central_price="{ row }">
|
||||||
|
<CentralPriceCell
|
||||||
|
:row="row"
|
||||||
|
@adjust="openCentralPriceAdjust"
|
||||||
|
@stock-in="openStockIn"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #status="{ row }">
|
||||||
|
<Tag :color="row.status_color">{{ row.status_txt }}</Tag>
|
||||||
|
</template>
|
||||||
|
<template #toolbar-tools></template>
|
||||||
|
<template #action="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '编辑',
|
||||||
|
type: 'link',
|
||||||
|
icon: 'uil:edit',
|
||||||
|
size: 'small',
|
||||||
|
onClick: showModal.bind(null, row, true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '删除',
|
||||||
|
type: 'link',
|
||||||
|
icon: 'ant-design:delete-outlined',
|
||||||
|
size: 'small',
|
||||||
|
popConfirm: {
|
||||||
|
title: '确定删除吗?',
|
||||||
|
confirm: deleteApi.bind(null, row.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:drop-down-actions="[]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.cm-drug-name-cell__alias {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import * as XLSX from 'xlsx';
|
||||||
|
|
||||||
|
const MAX_ROWS = 5000;
|
||||||
|
|
||||||
|
export type ParsedDrugNumberRow = {
|
||||||
|
drug_name: string;
|
||||||
|
drug_number: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeCell(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isInteger(value) ? String(value) : String(value).trim();
|
||||||
|
}
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 表头含空白或全角空格时仍能匹配 */
|
||||||
|
function pickByHeaderAliases(
|
||||||
|
row: Record<string, unknown>,
|
||||||
|
aliases: string[],
|
||||||
|
): string {
|
||||||
|
for (const key of Object.keys(row)) {
|
||||||
|
const k = key.replace(/\s+/g, '').replace(/\u3000/g, '');
|
||||||
|
for (const alias of aliases) {
|
||||||
|
if (k === alias.replace(/\s+/g, '').replace(/\u3000/g, '')) {
|
||||||
|
return normalizeCell(row[key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRowsByObjectKeys(
|
||||||
|
rows: Record<string, unknown>[],
|
||||||
|
): { items: ParsedDrugNumberRow[]; invalidCount: number } {
|
||||||
|
const items: ParsedDrugNumberRow[] = [];
|
||||||
|
let invalidCount = 0;
|
||||||
|
const n = Math.min(rows.length, MAX_ROWS);
|
||||||
|
|
||||||
|
const numberAliases = [
|
||||||
|
'药品编号',
|
||||||
|
'药品编码',
|
||||||
|
'编号',
|
||||||
|
'drug_number',
|
||||||
|
'编码',
|
||||||
|
];
|
||||||
|
const nameAliases = ['药品名称', '药名', 'drug_name', '名称'];
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const row = rows[i];
|
||||||
|
if (!row || typeof row !== 'object') {
|
||||||
|
invalidCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let drugNumber =
|
||||||
|
pickByHeaderAliases(row, numberAliases) ||
|
||||||
|
normalizeCell(row['药品编号']) ||
|
||||||
|
normalizeCell(row['药品编码']) ||
|
||||||
|
normalizeCell(row['编号']) ||
|
||||||
|
normalizeCell(row['drug_number']);
|
||||||
|
|
||||||
|
let drugName =
|
||||||
|
pickByHeaderAliases(row, nameAliases) ||
|
||||||
|
normalizeCell(row['药品名称']) ||
|
||||||
|
normalizeCell(row['药名']) ||
|
||||||
|
normalizeCell(row['drug_name']);
|
||||||
|
|
||||||
|
if (!drugName || !drugNumber) {
|
||||||
|
const empty = !Object.values(row).some(
|
||||||
|
(v) => normalizeCell(v) !== '',
|
||||||
|
);
|
||||||
|
if (empty) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
invalidCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
drug_name: drugName,
|
||||||
|
drug_number: drugNumber,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, invalidCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准「药品编码」表:第 1 列为编号、第 2 列为名称(表头行自动跳过)
|
||||||
|
*/
|
||||||
|
function parseRowsByPosition(sheet: XLSX.WorkSheet): {
|
||||||
|
items: ParsedDrugNumberRow[];
|
||||||
|
invalidCount: number;
|
||||||
|
rowCount: number;
|
||||||
|
} {
|
||||||
|
const aoa = XLSX.utils.sheet_to_json(sheet, {
|
||||||
|
header: 1,
|
||||||
|
defval: '',
|
||||||
|
}) as unknown[][];
|
||||||
|
|
||||||
|
const items: ParsedDrugNumberRow[] = [];
|
||||||
|
let invalidCount = 0;
|
||||||
|
if (aoa.length === 0) {
|
||||||
|
return { items: [], invalidCount: 0, rowCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = 0;
|
||||||
|
const h0 = normalizeCell(aoa[0]?.[0]);
|
||||||
|
const h1 = normalizeCell(aoa[0]?.[1]);
|
||||||
|
if (
|
||||||
|
h0 &&
|
||||||
|
(h0.includes('编号') ||
|
||||||
|
h0.includes('编码') ||
|
||||||
|
h0.includes('代码') ||
|
||||||
|
/药品/.test(h0))
|
||||||
|
) {
|
||||||
|
start = 1;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
h1 &&
|
||||||
|
(h1.includes('名称') || h1.includes('药名') || h1.includes('品名'))
|
||||||
|
) {
|
||||||
|
start = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.min(aoa.length, start + MAX_ROWS);
|
||||||
|
for (let i = start; i < limit; i++) {
|
||||||
|
const row = aoa[i];
|
||||||
|
if (!row?.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const drugNumber = normalizeCell(row[0]);
|
||||||
|
const drugName = normalizeCell(row[1]);
|
||||||
|
if (!drugName || !drugNumber) {
|
||||||
|
const anyCell = row.some((c) => normalizeCell(c) !== '');
|
||||||
|
if (anyCell) {
|
||||||
|
invalidCount++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
items.push({ drug_name: drugName, drug_number: drugNumber });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items, invalidCount, rowCount: aoa.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从第一张工作表解析「药品编号」「药品名称」列(兼容表头别名 + 列位置回退)
|
||||||
|
*/
|
||||||
|
export function parseDrugNumberExcelBuffer(buffer: ArrayBuffer): {
|
||||||
|
items: ParsedDrugNumberRow[];
|
||||||
|
invalidCount: number;
|
||||||
|
rowCount: number;
|
||||||
|
} {
|
||||||
|
const workbook = XLSX.read(buffer, { type: 'array' });
|
||||||
|
const sheetName = workbook.SheetNames[0];
|
||||||
|
if (!sheetName) {
|
||||||
|
return { items: [], invalidCount: 0, rowCount: 0 };
|
||||||
|
}
|
||||||
|
const sheet = workbook.Sheets[sheetName];
|
||||||
|
if (!sheet) {
|
||||||
|
return { items: [], invalidCount: 0, rowCount: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = XLSX.utils.sheet_to_json<Record<string, unknown>>(sheet, {
|
||||||
|
defval: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
let { items, invalidCount } = parseRowsByObjectKeys(rows);
|
||||||
|
let rowCount = rows.length;
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
const pos = parseRowsByPosition(sheet);
|
||||||
|
items = pos.items;
|
||||||
|
invalidCount = pos.invalidCount;
|
||||||
|
rowCount = pos.rowCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
invalidCount,
|
||||||
|
rowCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 与后端 `normalizeDrugNumberForBatch` 对齐,用于对比是否变更
|
||||||
|
*/
|
||||||
|
export function normalizeDrugNumberForCompare(value: unknown): string {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (Number.isInteger(value) || Math.floor(value) === value) {
|
||||||
|
return String(Math.trunc(value));
|
||||||
|
}
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
return String(value).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDrugNumberChanged(
|
||||||
|
oldVal: unknown,
|
||||||
|
newVal: unknown,
|
||||||
|
): boolean {
|
||||||
|
return normalizeDrugNumberForCompare(oldVal) !== normalizeDrugNumberForCompare(newVal);
|
||||||
|
}
|
||||||
@@ -145,15 +145,14 @@ export const modalFormProps: VbenFormProps = {
|
|||||||
rules: 'required', // 必填验证
|
rules: 'required', // 必填验证
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 保健食品别名字段:必填,保健食品的其他名称
|
// 别名选填:无别名也可保存,后端 syncAliases 空串会清空别名表
|
||||||
component: 'VbenInput',
|
component: 'VbenInput',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
placeholder: '请输入保健食品别名',
|
placeholder: '选填,多个可用顿号/逗号分隔',
|
||||||
},
|
},
|
||||||
fieldName: 'drug_alias',
|
fieldName: 'drug_alias',
|
||||||
formItemClass: 'col-span-6',
|
formItemClass: 'col-span-6',
|
||||||
label: '保健食品别名',
|
label: '保健食品别名',
|
||||||
rules: 'required', // 必填验证
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
// 保健食品编号字段:必填,保健食品的唯一编号
|
// 保健食品编号字段:必填,保健食品的唯一编号
|
||||||
|
|||||||
@@ -121,14 +121,14 @@ export const modalFormProps: VbenFormProps = {
|
|||||||
rules: 'required',
|
rules: 'required',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 别名选填:无别名也可保存,后端 syncAliases 空串会清空别名表
|
||||||
component: 'VbenInput',
|
component: 'VbenInput',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
placeholder: '请输入器械别名',
|
placeholder: '选填,多个可用顿号/逗号分隔',
|
||||||
},
|
},
|
||||||
fieldName: 'drug_alias',
|
fieldName: 'drug_alias',
|
||||||
formItemClass: 'col-span-6',
|
formItemClass: 'col-span-6',
|
||||||
label: '器械别名',
|
label: '器械别名',
|
||||||
rules: 'required',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
component: 'VbenInput',
|
component: 'VbenInput',
|
||||||
|
|||||||
@@ -121,14 +121,14 @@ export const modalFormProps: VbenFormProps = {
|
|||||||
rules: 'required',
|
rules: 'required',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 别名选填:无别名也可保存,后端 syncAliases 空串会清空别名表
|
||||||
component: 'VbenInput',
|
component: 'VbenInput',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
placeholder: '请输入产品别名',
|
placeholder: '选填,多个可用顿号/逗号分隔',
|
||||||
},
|
},
|
||||||
fieldName: 'drug_alias',
|
fieldName: 'drug_alias',
|
||||||
formItemClass: 'col-span-6',
|
formItemClass: 'col-span-6',
|
||||||
label: '产品别名',
|
label: '产品别名',
|
||||||
rules: 'required',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
component: 'VbenInput',
|
component: 'VbenInput',
|
||||||
|
|||||||
@@ -134,14 +134,14 @@ export const modalFormProps: VbenFormProps = {
|
|||||||
rules: 'required',
|
rules: 'required',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 别名选填:无别名也可保存,后端 syncAliases 空串会清空别名表
|
||||||
component: 'VbenInput',
|
component: 'VbenInput',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
placeholder: '请输入服务包别名',
|
placeholder: '选填,多个可用顿号/逗号分隔',
|
||||||
},
|
},
|
||||||
fieldName: 'drug_alias',
|
fieldName: 'drug_alias',
|
||||||
formItemClass: 'col-span-6',
|
formItemClass: 'col-span-6',
|
||||||
label: '服务包别名',
|
label: '服务包别名',
|
||||||
rules: 'required',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
component: 'VbenInput',
|
component: 'VbenInput',
|
||||||
|
|||||||
@@ -125,14 +125,14 @@ export const modalFormProps: VbenFormProps = {
|
|||||||
rules: 'required',
|
rules: 'required',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 别名选填:无别名也可保存,后端 syncAliases 空串会清空别名表
|
||||||
component: 'VbenInput',
|
component: 'VbenInput',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
placeholder: '请输入药品别名',
|
placeholder: '选填,多个可用顿号/逗号分隔',
|
||||||
},
|
},
|
||||||
fieldName: 'drug_alias',
|
fieldName: 'drug_alias',
|
||||||
formItemClass: 'col-span-6',
|
formItemClass: 'col-span-6',
|
||||||
label: '药品别名',
|
label: '药品别名',
|
||||||
rules: 'required',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
component: 'VbenInput',
|
component: 'VbenInput',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { modalFormProps } from '../config/form';
|
|||||||
const isUpdate = ref(false);
|
const isUpdate = ref(false);
|
||||||
const gridApi = ref();
|
const gridApi = ref();
|
||||||
const getMyInfoApi = ref();
|
const getMyInfoApi = ref();
|
||||||
|
const existingUrls = ref<string[]>([]);
|
||||||
|
|
||||||
const [Form, formApi] = useVbenForm(modalFormProps);
|
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||||
|
|
||||||
@@ -27,28 +28,45 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
if (!e.valid) return;
|
if (!e.valid) return;
|
||||||
const values = await formApi.getValues();
|
const values = await formApi.getValues();
|
||||||
modalApi.setState({ loading: true, confirmLoading: true });
|
modalApi.setState({ loading: true, confirmLoading: true });
|
||||||
const submitApi = saveNavUrlApi;
|
try {
|
||||||
submitApi(values)
|
// 新图拼在旧图后面,避免 uploadNav 全量同步时误删已有轮播
|
||||||
.then(() => {
|
const nextUrls = Array.isArray(values.url) ? values.url : [];
|
||||||
|
const merged = [...existingUrls.value];
|
||||||
|
for (const u of nextUrls) {
|
||||||
|
const pic = String(u || '').trim();
|
||||||
|
if (pic && !merged.includes(pic)) {
|
||||||
|
merged.push(pic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await saveNavUrlApi({ url: merged });
|
||||||
message.success('保存成功');
|
message.success('保存成功');
|
||||||
gridApi.value?.reload();
|
gridApi.value?.reload?.();
|
||||||
getMyInfoApi.value();
|
getMyInfoApi.value?.();
|
||||||
modalApi.close();
|
modalApi.close();
|
||||||
})
|
} catch (err) {
|
||||||
.finally(() => {
|
console.error(err);
|
||||||
|
} finally {
|
||||||
modalApi.setState({ loading: false, confirmLoading: false });
|
modalApi.setState({ loading: false, confirmLoading: false });
|
||||||
});
|
}
|
||||||
|
|
||||||
},
|
},
|
||||||
onOpenChange(isOpen: boolean) {
|
onOpenChange(isOpen: boolean) {
|
||||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
getMyInfoApi.value = isOpen ? modalApi.getData()?.getMyInfo : null;
|
getMyInfoApi.value = isOpen ? modalApi.getData()?.getMyInfo : null;
|
||||||
const { values, update } = modalApi.getData<Record<string, any>>();
|
const payload = modalApi.getData<Record<string, any>>() || {};
|
||||||
|
existingUrls.value = Array.isArray(payload.existingUrls)
|
||||||
|
? payload.existingUrls.map(String).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const { values, update } = payload;
|
||||||
if (values) {
|
if (values) {
|
||||||
isUpdate.value = update;
|
isUpdate.value = update;
|
||||||
formApi.setValues(values);
|
formApi.setValues(values);
|
||||||
|
} else {
|
||||||
|
isUpdate.value = false;
|
||||||
|
formApi.reset?.();
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
existingUrls.value = [];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -85,9 +85,13 @@ const [UploadModals, UploadModalApi] = useVbenModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const openUpload = () => {
|
const openUpload = () => {
|
||||||
|
// uploadNav 是全量同步:必须把已有图一并带上,否则「只传新图」会把旧轮播软删掉
|
||||||
|
const existingUrls = (data.value?.nav || [])
|
||||||
|
.map((item) => String(item?.pic || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
UploadModalApi.setData({
|
UploadModalApi.setData({
|
||||||
// doctorId,
|
|
||||||
getMyInfo,
|
getMyInfo,
|
||||||
|
existingUrls,
|
||||||
});
|
});
|
||||||
UploadModalApi.open();
|
UploadModalApi.open();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
|
|||||||
|
|
||||||
import { getWarehouseProductTypeOptions } from '../admin/api';
|
import { getWarehouseProductTypeOptions } from '../admin/api';
|
||||||
|
|
||||||
const VALID_TYPES = [1, 2, 3, 5, 6, 7];
|
const VALID_TYPES = [1, 2, 3, 5, 6, 7, 8];
|
||||||
|
|
||||||
function resolveProductType(route: RouteLocationNormalizedLoaded): number {
|
function resolveProductType(route: RouteLocationNormalizedLoaded): number {
|
||||||
const rawParam = route.params.type;
|
const rawParam = route.params.type;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* 诊所 PC 识图传方 API(xk-api clinic-image-transfer)
|
||||||
|
* 本店范围,提交须带 doctor_id
|
||||||
|
*/
|
||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
const prefix = 'clinic-image-transfer/';
|
||||||
|
|
||||||
|
/** 处方图 OCR */
|
||||||
|
export async function clinicOcrPrescriptionImageApi(data: {
|
||||||
|
image_urls: string[];
|
||||||
|
}) {
|
||||||
|
return requestClient.post<any>(`${prefix}ocr-prescription-image`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认提交传方订单 */
|
||||||
|
export async function clinicConfirmImageTransferApi(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}confirm`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本店识图生成记录 */
|
||||||
|
export async function clinicAiListGenerationsApi(params?: { limit?: number }) {
|
||||||
|
return requestClient.get<any>(`${prefix}ai-list-generations`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本店识图记录详情 */
|
||||||
|
export async function clinicAiGenerationDetailApi(params: { id: number }) {
|
||||||
|
return requestClient.get<any>(`${prefix}ai-generation-detail`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 匹配就诊人 */
|
||||||
|
export async function clinicMatchTransferPatientApi(data: {
|
||||||
|
patient_name?: string;
|
||||||
|
patient_mobile?: string;
|
||||||
|
name?: string;
|
||||||
|
mobile?: string;
|
||||||
|
}) {
|
||||||
|
return requestClient.post<any>(`${prefix}match-transfer-patient`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 报价预览 */
|
||||||
|
export async function clinicQuoteTransferImageApi(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}quote`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本店医生下拉(必选) */
|
||||||
|
export async function clinicTransferDoctorOptionApi() {
|
||||||
|
return requestClient.get<any>(`${prefix}doctor-option`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 本店药库搜索 */
|
||||||
|
export async function clinicProductListApi(params: Record<string, any>) {
|
||||||
|
return requestClient.get<any>(`${prefix}product-list`, { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加工规则列表 */
|
||||||
|
export async function clinicProcessRuleListApi(params?: {
|
||||||
|
pid?: number;
|
||||||
|
rule_id?: number;
|
||||||
|
}) {
|
||||||
|
return requestClient.get<any>(`${prefix}process-rule-list`, { params });
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 诊所识图传方入口页
|
||||||
|
* 复用医生传方页 UI/流程,切换诊所 API,并强制选择开方医生
|
||||||
|
*/
|
||||||
|
import DoctorImageTransferPage from '#/views/doctor/image-transfer-prescription/index.vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ClinicImageTransferPrescription' });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DoctorImageTransferPage mode="clinic" />
|
||||||
|
</template>
|
||||||
@@ -45,6 +45,30 @@ export async function getProductListDoctorReception(data: any) {
|
|||||||
return requestClient.get<any>(`${prefix}product-list`, { params: data });
|
return requestClient.get<any>(`${prefix}product-list`, { params: data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 医生用过的辅料(发方成功云端沉淀;气泡空搜优先展示)
|
||||||
|
* xk-api GET doctor-reception/used-excipient-list
|
||||||
|
*/
|
||||||
|
export async function getUsedExcipientListDoctorReception(data: {
|
||||||
|
store_id?: number;
|
||||||
|
register_id?: number;
|
||||||
|
limit?: number;
|
||||||
|
}) {
|
||||||
|
return requestClient.get<any>(`${prefix}used-excipient-list`, { params: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复用/常用方填方前对比中药快照价与当前门店价
|
||||||
|
* 为什么:供货比例变更后关系表已更新,快照价不一致时要让医生确认是否按最新价计算
|
||||||
|
*/
|
||||||
|
export async function compareChineseDrugPricesApi(data: {
|
||||||
|
register_id?: number;
|
||||||
|
store_id?: number;
|
||||||
|
drugs: Array<Record<string, any>>;
|
||||||
|
}) {
|
||||||
|
return requestClient.post<any>(`${prefix}compare-chinese-drug-prices`, data);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取中药规格
|
* 获取中药规格
|
||||||
* @param data
|
* @param data
|
||||||
@@ -442,10 +466,51 @@ export async function aiGeneratePrescriptionApi(data: {
|
|||||||
use_entrusted_process?: 0 | 1 | boolean;
|
use_entrusted_process?: 0 | 1 | boolean;
|
||||||
/** 中药委托调剂:制剂要求 id(yii_process_rule pid=0) */
|
/** 中药委托调剂:制剂要求 id(yii_process_rule pid=0) */
|
||||||
process_rule_id?: number;
|
process_rule_id?: number;
|
||||||
|
/** 语音辨证:dialectic */
|
||||||
|
voice_mode?: string;
|
||||||
|
transcript?: string;
|
||||||
|
asr_provider?: string;
|
||||||
|
asr_duration_ms?: number;
|
||||||
|
return_dosage_process_detail?: 0 | 1 | boolean;
|
||||||
}) {
|
}) {
|
||||||
return requestClient.post<any>(`${prefix}ai-generate-prescription`, data);
|
return requestClient.post<any>(`${prefix}ai-generate-prescription`, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语音转写(multipart:audio 文件)
|
||||||
|
* 字段名必须为 audio,与后端 request()->file('audio') 对齐
|
||||||
|
*/
|
||||||
|
export async function aiVoiceAsrApi(file: File, durationMs = 0) {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append('audio', file);
|
||||||
|
// PC 已转成 wav;显式声明避免 MIME 误判
|
||||||
|
fd.append('audio_format', 'wav');
|
||||||
|
if (durationMs > 0) {
|
||||||
|
fd.append('duration_ms', String(durationMs));
|
||||||
|
}
|
||||||
|
return requestClient.post<any>(`${prefix}ai-voice-asr`, fd, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 语音口述落方(只抽药名剂量) */
|
||||||
|
export async function aiGenerateVoicePrescriptionApi(data: {
|
||||||
|
register_id: number;
|
||||||
|
store_id?: number;
|
||||||
|
prescription_type: number;
|
||||||
|
transcript: string;
|
||||||
|
asr_provider?: string;
|
||||||
|
asr_duration_ms?: number;
|
||||||
|
use_entrusted_process?: 0 | 1 | boolean;
|
||||||
|
process_rule_id?: number;
|
||||||
|
return_dosage_process_detail?: 0 | 1 | boolean;
|
||||||
|
}) {
|
||||||
|
return requestClient.post<any>(
|
||||||
|
`${prefix}ai-generate-voice-prescription`,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** AI 功能门闸(醒目提示 + 是否已签署知情同意) */
|
/** AI 功能门闸(醒目提示 + 是否已签署知情同意) */
|
||||||
export async function aiFeatureGateApi(data: { feature_code: string }) {
|
export async function aiFeatureGateApi(data: { feature_code: string }) {
|
||||||
return requestClient.get<any>(`${prefix}ai-feature-gate`, { params: data });
|
return requestClient.get<any>(`${prefix}ai-feature-gate`, { params: data });
|
||||||
@@ -629,3 +694,46 @@ export async function getProxyPayQrcodeApi(orderId: number) {
|
|||||||
order_id: orderId,
|
order_id: orderId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 处方图 OCR(DeepSeek) */
|
||||||
|
export async function ocrPrescriptionImageApi(data: {
|
||||||
|
image_urls: string[];
|
||||||
|
mode: 'reception' | 'transfer';
|
||||||
|
register_id?: number;
|
||||||
|
store_id?: number;
|
||||||
|
}) {
|
||||||
|
return requestClient.post<any>(`${prefix}ocr-prescription-image`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认识图传方:建患者/挂号 + 待导入传方单 */
|
||||||
|
export async function confirmTransferImageOcrApi(data: Record<string, any>) {
|
||||||
|
return requestClient.post<{
|
||||||
|
transfer_id: number;
|
||||||
|
register_id: number;
|
||||||
|
user_patient_id: number;
|
||||||
|
patient_action: string;
|
||||||
|
}>(`${prefix}confirm-transfer-image-ocr`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 识图传方主动匹配就诊人 */
|
||||||
|
export async function matchTransferPatientApi(data: {
|
||||||
|
patient_name?: string;
|
||||||
|
patient_mobile?: string;
|
||||||
|
name?: string;
|
||||||
|
mobile?: string;
|
||||||
|
}) {
|
||||||
|
return requestClient.post<{
|
||||||
|
matched: boolean;
|
||||||
|
user_patient_id: number;
|
||||||
|
name: string;
|
||||||
|
mobile: string;
|
||||||
|
sex: number;
|
||||||
|
age: number;
|
||||||
|
address: string;
|
||||||
|
}>(`${prefix}match-transfer-patient`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 医生识图传方报价预览 */
|
||||||
|
export async function quoteTransferImageApi(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}quote-image-transfer`, data);
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ import { Button, Checkbox, Descriptions, Input, Radio, Spin, Switch, Tabs, Tag,
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
aiGeneratePrescriptionApi,
|
aiGeneratePrescriptionApi,
|
||||||
|
aiGenerateVoicePrescriptionApi,
|
||||||
aiListGenerationsApi,
|
aiListGenerationsApi,
|
||||||
aiMarkGenerationReadApi,
|
aiMarkGenerationReadApi,
|
||||||
aiMatchPrescriptionDrugsApi,
|
aiMatchPrescriptionDrugsApi,
|
||||||
aiSavePrescriptionDrugSelectionApi,
|
aiSavePrescriptionDrugSelectionApi,
|
||||||
|
aiVoiceAsrApi,
|
||||||
getProcessRuleList,
|
getProcessRuleList,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
|
import { encodeBlobToWav16k } from '../utils/encodeWav';
|
||||||
import AiDisclaimerBanner from '#/views/doctor/components/AiDisclaimerBanner.vue';
|
import AiDisclaimerBanner from '#/views/doctor/components/AiDisclaimerBanner.vue';
|
||||||
import { ensureAiFeatureConsent } from '#/views/doctor/utils/aiFeatureGate';
|
import { ensureAiFeatureConsent } from '#/views/doctor/utils/aiFeatureGate';
|
||||||
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
|
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
|
||||||
@@ -137,6 +140,19 @@ const editingKey = ref('');
|
|||||||
/** 醒目提示文案(系统配置 / 门闸下发) */
|
/** 醒目提示文案(系统配置 / 门闸下发) */
|
||||||
const disclaimerText = ref('');
|
const disclaimerText = ref('');
|
||||||
|
|
||||||
|
/** 语音开方:录音 / 转写 / A·B 模式 */
|
||||||
|
const voiceRecording = ref(false);
|
||||||
|
const voiceAsrLoading = ref(false);
|
||||||
|
const voiceTranscript = ref('');
|
||||||
|
const voiceAsrProvider = ref('');
|
||||||
|
const voiceAsrDurationMs = ref(0);
|
||||||
|
/** dialectic | dictation;默认口述落方(医生口述药味更常见) */
|
||||||
|
const voiceMode = ref<'dialectic' | 'dictation'>('dictation');
|
||||||
|
let voiceMediaRecorder: MediaRecorder | null = null;
|
||||||
|
let voiceAudioChunks: Blob[] = [];
|
||||||
|
let voiceStream: MediaStream | null = null;
|
||||||
|
let voiceRecordStartedAt = 0;
|
||||||
|
|
||||||
const sexLabel = computed(() => {
|
const sexLabel = computed(() => {
|
||||||
const s = Number(patientSex.value);
|
const s = Number(patientSex.value);
|
||||||
if (s === 1) return '男';
|
if (s === 1) return '男';
|
||||||
@@ -193,6 +209,8 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
|||||||
patientAge?: number;
|
patientAge?: number;
|
||||||
chiefComplaint?: string;
|
chiefComplaint?: string;
|
||||||
medicalRecord?: Record<string, any>;
|
medicalRecord?: Record<string, any>;
|
||||||
|
/** assist=携带病历辅助出方;voice=工具栏语音入口,同意后自动开语音弹窗 */
|
||||||
|
entry?: 'assist' | 'voice';
|
||||||
}>();
|
}>();
|
||||||
registerId.value = Number(data?.registerId || 0);
|
registerId.value = Number(data?.registerId || 0);
|
||||||
storeId.value = Number(data?.storeId || 0);
|
storeId.value = Number(data?.storeId || 0);
|
||||||
@@ -205,6 +223,7 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
|||||||
data?.medicalRecord && typeof data.medicalRecord === 'object'
|
data?.medicalRecord && typeof data.medicalRecord === 'object'
|
||||||
? { ...data.medicalRecord }
|
? { ...data.medicalRecord }
|
||||||
: {};
|
: {};
|
||||||
|
const entryMode = data?.entry === 'voice' ? 'voice' : 'assist';
|
||||||
resetPreview();
|
resetPreview();
|
||||||
// 首次使用对应 VIP 功能需签署知情同意;拒绝则关闭抽屉
|
// 首次使用对应 VIP 功能需签署知情同意;拒绝则关闭抽屉
|
||||||
try {
|
try {
|
||||||
@@ -223,6 +242,14 @@ const [Drawer, drawerApi] = useVbenDrawer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void bootstrapHistory();
|
void bootstrapHistory();
|
||||||
|
// 工具栏「语音开方」:同意后直接进入语音弹窗,默认口述落方
|
||||||
|
if (entryMode === 'voice') {
|
||||||
|
voiceMode.value = 'dictation';
|
||||||
|
voiceTranscript.value = '';
|
||||||
|
voiceAsrProvider.value = '';
|
||||||
|
voiceAsrDurationMs.value = 0;
|
||||||
|
voiceModalApi.open();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -235,6 +262,28 @@ const [GenModal, genModalApi] = useVbenModal({
|
|||||||
onConfirm: async () => confirmGenerateAndClose(),
|
onConfirm: async () => confirmGenerateAndClose(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语音开方弹窗:录音转写 → 卡片选 A/B → 确认生成
|
||||||
|
* 未选模式时确认按钮禁用(由 onConfirm 内校验兜底)
|
||||||
|
*/
|
||||||
|
const [VoiceModal, voiceModalApi] = useVbenModal({
|
||||||
|
title: '语音开方',
|
||||||
|
class: 'w-[640px]',
|
||||||
|
confirmText: '确认生成',
|
||||||
|
cancelText: '取消',
|
||||||
|
onConfirm: async () => confirmVoiceGenerate(),
|
||||||
|
onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
stopVoiceRecording(true);
|
||||||
|
voiceTranscript.value = '';
|
||||||
|
voiceMode.value = 'dictation';
|
||||||
|
voiceAsrProvider.value = '';
|
||||||
|
voiceAsrDurationMs.value = 0;
|
||||||
|
voiceAsrLoading.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function resetPreview() {
|
function resetPreview() {
|
||||||
matchedList.value = [];
|
matchedList.value = [];
|
||||||
unmatchedList.value = [];
|
unmatchedList.value = [];
|
||||||
@@ -268,8 +317,12 @@ function resetState() {
|
|||||||
disclaimerText.value = '';
|
disclaimerText.value = '';
|
||||||
genProcessRuleId.value = undefined;
|
genProcessRuleId.value = undefined;
|
||||||
processRuleOptions.value = [];
|
processRuleOptions.value = [];
|
||||||
|
stopVoiceRecording(true);
|
||||||
|
voiceTranscript.value = '';
|
||||||
|
voiceMode.value = 'dictation';
|
||||||
resetPreview();
|
resetPreview();
|
||||||
genModalApi.close();
|
genModalApi.close();
|
||||||
|
voiceModalApi.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(ts: number) {
|
function formatTime(ts: number) {
|
||||||
@@ -617,6 +670,217 @@ async function confirmGenerateAndClose(): Promise<boolean> {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开语音开方弹窗(录音 → 转写 → 选 A/B)
|
||||||
|
*/
|
||||||
|
async function openVoiceModal() {
|
||||||
|
if (generating.value) return;
|
||||||
|
if (!prescriptionType.value) {
|
||||||
|
message.warning('请先选择处方类型');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!registerId.value) {
|
||||||
|
message.warning('挂号无效');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
voiceTranscript.value = '';
|
||||||
|
voiceMode.value = 'dictation';
|
||||||
|
voiceAsrProvider.value = '';
|
||||||
|
voiceAsrDurationMs.value = 0;
|
||||||
|
// 与生成弹窗一致:恢复委托调剂本地记忆并拉制剂要求
|
||||||
|
if (isTcmRx.value && useEntrustedProcess.value && !processRuleOptions.value.length) {
|
||||||
|
void loadTopProcessRules();
|
||||||
|
}
|
||||||
|
voiceModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始 / 停止录音(点击切换)
|
||||||
|
*/
|
||||||
|
async function toggleVoiceRecording() {
|
||||||
|
if (voiceAsrLoading.value) return;
|
||||||
|
if (voiceRecording.value) {
|
||||||
|
stopVoiceRecording(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||||
|
voiceStream = stream;
|
||||||
|
voiceAudioChunks = [];
|
||||||
|
voiceRecordStartedAt = Date.now();
|
||||||
|
const mime = MediaRecorder.isTypeSupported('audio/webm')
|
||||||
|
? 'audio/webm'
|
||||||
|
: '';
|
||||||
|
voiceMediaRecorder = mime
|
||||||
|
? new MediaRecorder(stream, { mimeType: mime })
|
||||||
|
: new MediaRecorder(stream);
|
||||||
|
voiceMediaRecorder.ondataavailable = (ev) => {
|
||||||
|
if (ev.data && ev.data.size > 0) voiceAudioChunks.push(ev.data);
|
||||||
|
};
|
||||||
|
voiceMediaRecorder.onstop = () => {
|
||||||
|
void handleVoiceRecordStop();
|
||||||
|
};
|
||||||
|
voiceMediaRecorder.start();
|
||||||
|
voiceRecording.value = true;
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '无法访问麦克风');
|
||||||
|
voiceRecording.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止录音;discard=true 时丢弃数据不转写
|
||||||
|
*/
|
||||||
|
function stopVoiceRecording(discard = false) {
|
||||||
|
if (!voiceRecording.value && !voiceMediaRecorder) {
|
||||||
|
cleanupVoiceStream();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
voiceRecording.value = false;
|
||||||
|
if (discard) {
|
||||||
|
if (voiceMediaRecorder && voiceMediaRecorder.state !== 'inactive') {
|
||||||
|
voiceMediaRecorder.ondataavailable = null;
|
||||||
|
voiceMediaRecorder.onstop = () => cleanupVoiceStream();
|
||||||
|
try {
|
||||||
|
voiceMediaRecorder.stop();
|
||||||
|
} catch {
|
||||||
|
cleanupVoiceStream();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cleanupVoiceStream();
|
||||||
|
}
|
||||||
|
voiceAudioChunks = [];
|
||||||
|
voiceMediaRecorder = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (voiceMediaRecorder && voiceMediaRecorder.state !== 'inactive') {
|
||||||
|
try {
|
||||||
|
voiceMediaRecorder.stop();
|
||||||
|
} catch {
|
||||||
|
cleanupVoiceStream();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupVoiceStream() {
|
||||||
|
if (voiceStream) {
|
||||||
|
voiceStream.getTracks().forEach((t) => t.stop());
|
||||||
|
voiceStream = null;
|
||||||
|
}
|
||||||
|
voiceMediaRecorder = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 录音结束 → 转 wav → ASR
|
||||||
|
*/
|
||||||
|
async function handleVoiceRecordStop() {
|
||||||
|
const chunks = voiceAudioChunks.slice();
|
||||||
|
const started = voiceRecordStartedAt;
|
||||||
|
cleanupVoiceStream();
|
||||||
|
voiceAudioChunks = [];
|
||||||
|
if (!chunks.length) {
|
||||||
|
message.warning('未录到有效音频');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rawBlob = new Blob(chunks, { type: chunks[0]?.type || 'audio/webm' });
|
||||||
|
const durationMs = Math.max(0, Date.now() - started);
|
||||||
|
voiceAsrLoading.value = true;
|
||||||
|
try {
|
||||||
|
const wavBlob = await encodeBlobToWav16k(rawBlob);
|
||||||
|
const file = new File([wavBlob], `voice_${Date.now()}.wav`, {
|
||||||
|
type: 'audio/wav',
|
||||||
|
});
|
||||||
|
const data = await aiVoiceAsrApi(file, durationMs);
|
||||||
|
const text = String(data?.text || '').trim();
|
||||||
|
if (!text) {
|
||||||
|
message.warning('未识别到有效语音');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
voiceTranscript.value = text;
|
||||||
|
voiceAsrProvider.value = String(data?.provider || 'xunfei');
|
||||||
|
voiceAsrDurationMs.value = Number(data?.duration_ms || durationMs);
|
||||||
|
message.success('转写完成,请选择开方方式');
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || e?.msg || '语音转写失败');
|
||||||
|
} finally {
|
||||||
|
voiceAsrLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语音弹窗确认:按 A/B 调对应生成接口
|
||||||
|
*/
|
||||||
|
async function confirmVoiceGenerate() {
|
||||||
|
const text = voiceTranscript.value.trim();
|
||||||
|
if (!text) {
|
||||||
|
message.warning('请先完成语音转写或填写文本');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!voiceMode.value) {
|
||||||
|
message.warning('请选择辨证出方或口述落方');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isTcmRx.value && useEntrustedProcess.value && !genProcessRuleId.value) {
|
||||||
|
message.warning('请选择制剂要求');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mode = voiceMode.value;
|
||||||
|
const transcript = text;
|
||||||
|
const asrProvider = voiceAsrProvider.value;
|
||||||
|
const asrDuration = voiceAsrDurationMs.value;
|
||||||
|
voiceModalApi.close();
|
||||||
|
if (mode === 'dialectic') {
|
||||||
|
// A:转写当主诉,走现有辨证出方
|
||||||
|
chiefComplaint.value = transcript;
|
||||||
|
void runGenerateInDrawer(transcript, {
|
||||||
|
voice_mode: 'dialectic',
|
||||||
|
transcript,
|
||||||
|
asr_provider: asrProvider,
|
||||||
|
asr_duration_ms: asrDuration,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// B:口述落方
|
||||||
|
void runVoiceDictationGenerate(transcript, asrProvider, asrDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B 口述落方生成
|
||||||
|
*/
|
||||||
|
async function runVoiceDictationGenerate(
|
||||||
|
transcript: string,
|
||||||
|
asrProvider: string,
|
||||||
|
asrDuration: number,
|
||||||
|
) {
|
||||||
|
beginGeneratingPlaceholder();
|
||||||
|
generating.value = true;
|
||||||
|
multiPrescriptions.value = [];
|
||||||
|
multiActiveIndex.value = 0;
|
||||||
|
try {
|
||||||
|
const req: Record<string, any> = {
|
||||||
|
register_id: registerId.value,
|
||||||
|
store_id: storeId.value || undefined,
|
||||||
|
prescription_type: prescriptionType.value,
|
||||||
|
transcript,
|
||||||
|
asr_provider: asrProvider || undefined,
|
||||||
|
asr_duration_ms: asrDuration || undefined,
|
||||||
|
};
|
||||||
|
if (isTcmRx.value && useEntrustedProcess.value && genProcessRuleId.value) {
|
||||||
|
req.use_entrusted_process = 1;
|
||||||
|
req.process_rule_id = genProcessRuleId.value;
|
||||||
|
req.return_dosage_process_detail = returnDosageProcessDetail.value ? 1 : 0;
|
||||||
|
}
|
||||||
|
const data = await aiGenerateVoicePrescriptionApi(req);
|
||||||
|
await applyGenerateResponse(data, '口述识别');
|
||||||
|
} catch (e: any) {
|
||||||
|
endGeneratingPlaceholder();
|
||||||
|
resetPreview();
|
||||||
|
message.error(e?.message || e?.msg || '口述识别失败');
|
||||||
|
} finally {
|
||||||
|
generating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 本地把某条历史标为已读(同步 multi / historyList)
|
* 本地把某条历史标为已读(同步 multi / historyList)
|
||||||
*/
|
*/
|
||||||
@@ -682,7 +946,15 @@ function onMultiTabChange(key: string | number) {
|
|||||||
applyMultiSlot(slot, idx);
|
applyMultiSlot(slot, idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runGenerateInDrawer(chief: string) {
|
async function runGenerateInDrawer(
|
||||||
|
chief: string,
|
||||||
|
voiceMeta?: {
|
||||||
|
voice_mode?: string;
|
||||||
|
transcript?: string;
|
||||||
|
asr_provider?: string;
|
||||||
|
asr_duration_ms?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
beginGeneratingPlaceholder();
|
beginGeneratingPlaceholder();
|
||||||
generating.value = true;
|
generating.value = true;
|
||||||
multiPrescriptions.value = [];
|
multiPrescriptions.value = [];
|
||||||
@@ -704,10 +976,31 @@ async function runGenerateInDrawer(chief: string) {
|
|||||||
req.process_rule_id = genProcessRuleId.value;
|
req.process_rule_id = genProcessRuleId.value;
|
||||||
req.return_dosage_process_detail = returnDosageProcessDetail.value ? 1 : 0;
|
req.return_dosage_process_detail = returnDosageProcessDetail.value ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
if (voiceMeta?.voice_mode) {
|
||||||
|
req.voice_mode = voiceMeta.voice_mode;
|
||||||
|
req.transcript = voiceMeta.transcript || chief;
|
||||||
|
if (voiceMeta.asr_provider) req.asr_provider = voiceMeta.asr_provider;
|
||||||
|
if (voiceMeta.asr_duration_ms) {
|
||||||
|
req.asr_duration_ms = voiceMeta.asr_duration_ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
const data = await aiGeneratePrescriptionApi(req);
|
const data = await aiGeneratePrescriptionApi(req);
|
||||||
|
await applyGenerateResponse(data, '生成');
|
||||||
|
} catch (e: any) {
|
||||||
|
endGeneratingPlaceholder();
|
||||||
|
resetPreview();
|
||||||
|
message.error(e?.message || e?.msg || '生成失败');
|
||||||
|
} finally {
|
||||||
|
generating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一处理生成/口述接口返回(多方案 / 单方案 / 软失败)
|
||||||
|
*/
|
||||||
|
async function applyGenerateResponse(data: any, actionLabel: string) {
|
||||||
const durationText = formatDurationMs(data?.duration_ms);
|
const durationText = formatDurationMs(data?.duration_ms);
|
||||||
const list = Array.isArray(data?.prescriptions) ? data.prescriptions : [];
|
const list = Array.isArray(data?.prescriptions) ? data.prescriptions : [];
|
||||||
// 业务软失败:HTTP 成功但 ok=false,警告提示而非 error
|
|
||||||
if (data?.ok === false) {
|
if (data?.ok === false) {
|
||||||
endGeneratingPlaceholder();
|
endGeneratingPlaceholder();
|
||||||
resetPreview();
|
resetPreview();
|
||||||
@@ -721,7 +1014,7 @@ async function runGenerateInDrawer(chief: string) {
|
|||||||
await loadHistory();
|
await loadHistory();
|
||||||
activeId.value = Number(data.generation_id);
|
activeId.value = Number(data.generation_id);
|
||||||
}
|
}
|
||||||
message.warning(String(data?.message || '生成未成功'));
|
message.warning(String(data?.message || `${actionLabel}未成功`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await loadHistory();
|
await loadHistory();
|
||||||
@@ -745,15 +1038,16 @@ async function runGenerateInDrawer(chief: string) {
|
|||||||
applySafetyFlags(data?.safety_flags || null);
|
applySafetyFlags(data?.safety_flags || null);
|
||||||
applyRxMeta(data);
|
applyRxMeta(data);
|
||||||
void markReadIfNeeded(activeId.value, 0);
|
void markReadIfNeeded(activeId.value, 0);
|
||||||
message.success(
|
const unmatchedN = Array.isArray(data?.match?.unmatched)
|
||||||
durationText ? `已生成(耗时 ${durationText})` : '已生成,请确认导入',
|
? data.match.unmatched.length
|
||||||
);
|
: unmatchedList.value.length;
|
||||||
} catch (e: any) {
|
const okMsg = durationText
|
||||||
endGeneratingPlaceholder();
|
? `已${actionLabel}(耗时 ${durationText})`
|
||||||
resetPreview();
|
: `已${actionLabel},请确认导入`;
|
||||||
message.error(e?.message || e?.msg || '生成失败');
|
if (unmatchedN > 0) {
|
||||||
} finally {
|
message.warning(`${okMsg};${unmatchedN} 味未在药库找到,已标红`);
|
||||||
generating.value = false;
|
} else {
|
||||||
|
message.success(okMsg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -866,6 +1160,10 @@ function onConfirmImport() {
|
|||||||
message.warning('请先勾选已对照药品');
|
message.warning('请先勾选已对照药品');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const skipN = unmatchedList.value.length;
|
||||||
|
if (skipN > 0) {
|
||||||
|
message.warning(`已跳过 ${skipN} 味未对照药品(标红项不会导入)`);
|
||||||
|
}
|
||||||
const importPayload: {
|
const importPayload: {
|
||||||
rows: typeof drugs;
|
rows: typeof drugs;
|
||||||
dosage?: number;
|
dosage?: number;
|
||||||
@@ -914,8 +1212,13 @@ defineExpose({
|
|||||||
patientAge?: number;
|
patientAge?: number;
|
||||||
chiefComplaint?: string;
|
chiefComplaint?: string;
|
||||||
medicalRecord?: Record<string, any>;
|
medicalRecord?: Record<string, any>;
|
||||||
|
/** assist 默认;voice=工具栏语音入口 */
|
||||||
|
entry?: 'assist' | 'voice';
|
||||||
}) {
|
}) {
|
||||||
drawerApi.setData(payload);
|
drawerApi.setData({
|
||||||
|
...payload,
|
||||||
|
entry: payload.entry === 'voice' ? 'voice' : 'assist',
|
||||||
|
});
|
||||||
drawerApi.open();
|
drawerApi.open();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1175,16 +1478,23 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="unmatchedList.length"
|
v-if="unmatchedList.length"
|
||||||
class="mb-1.5 mt-3 text-xs font-medium text-muted-foreground"
|
class="mb-1.5 mt-3 text-xs font-medium text-red-500"
|
||||||
>
|
>
|
||||||
未对照({{ unmatchedList.length }})
|
未对照({{ unmatchedList.length }})— 药库未找到,已标红,不会导入
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-for="(u, ui) in unmatchedList"
|
v-for="(u, ui) in unmatchedList"
|
||||||
:key="ui"
|
:key="ui"
|
||||||
class="mb-1.5 rounded border border-orange-500/30 bg-orange-500/10 px-2 py-1.5 text-sm"
|
class="mb-1.5 rounded border border-red-500/40 px-2 py-1.5 text-sm shadow-[0_0_6px_hsl(0_84%_60%/0.18)]"
|
||||||
>
|
>
|
||||||
<div class="font-medium">{{ u.ai_name }}</div>
|
<div class="font-medium text-red-500">{{ u.ai_name }}</div>
|
||||||
|
<div
|
||||||
|
v-if="u.dose || u.unit"
|
||||||
|
class="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ u.dose }}{{ u.unit }}
|
||||||
|
<span v-if="u.usage"> · {{ u.usage }}</span>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -1369,6 +1679,106 @@ defineExpose({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</GenModal>
|
</GenModal>
|
||||||
|
|
||||||
|
<!-- 语音开方:录音转写 + A/B 模式卡片 -->
|
||||||
|
<VoiceModal>
|
||||||
|
<div class="space-y-3 text-sm text-foreground">
|
||||||
|
<AiDisclaimerBanner :text="disclaimerText" />
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
:type="voiceRecording ? 'primary' : 'default'"
|
||||||
|
:danger="voiceRecording"
|
||||||
|
:loading="voiceAsrLoading"
|
||||||
|
@click="toggleVoiceRecording"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
voiceAsrLoading
|
||||||
|
? '转写中…'
|
||||||
|
: voiceRecording
|
||||||
|
? '点击结束录音'
|
||||||
|
: '点击开始录音'
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
说完后点结束,系统会转写成文字供修改
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-xs text-muted-foreground">转写文本(可改)</div>
|
||||||
|
<Input.TextArea
|
||||||
|
v-model:value="voiceTranscript"
|
||||||
|
:rows="4"
|
||||||
|
placeholder="录音转写结果会显示在这里,也可直接粘贴/修改"
|
||||||
|
:disabled="voiceRecording || voiceAsrLoading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="voiceTranscript.trim()" class="space-y-2">
|
||||||
|
<div class="text-xs font-medium text-muted-foreground">
|
||||||
|
请选择开方方式
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border p-3 text-left transition-colors"
|
||||||
|
:class="
|
||||||
|
voiceMode === 'dialectic'
|
||||||
|
? 'border-primary/30 shadow-[0_0_6px_hsl(var(--primary)/0.18)] bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary/20'
|
||||||
|
"
|
||||||
|
@click="voiceMode = 'dialectic'"
|
||||||
|
>
|
||||||
|
<div class="font-medium">辨证出方</div>
|
||||||
|
<div class="mt-1 text-xs text-muted-foreground">
|
||||||
|
根据口述病情,AI 辅助生成处方
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-lg border p-3 text-left transition-colors"
|
||||||
|
:class="
|
||||||
|
voiceMode === 'dictation'
|
||||||
|
? 'border-primary/30 shadow-[0_0_6px_hsl(var(--primary)/0.18)] bg-primary/5'
|
||||||
|
: 'border-border hover:border-primary/20'
|
||||||
|
"
|
||||||
|
@click="voiceMode = 'dictation'"
|
||||||
|
>
|
||||||
|
<div class="font-medium">口述落方</div>
|
||||||
|
<div class="mt-1 text-xs text-muted-foreground">
|
||||||
|
识别口述药名剂量,对照药库后导入
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="isTcmRx"
|
||||||
|
class="rounded-lg border border-border bg-muted/30 p-3"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
v-model:checked="useEntrustedProcess"
|
||||||
|
@update:checked="onEntrustedToggle"
|
||||||
|
>
|
||||||
|
委托调剂
|
||||||
|
</Checkbox>
|
||||||
|
<div v-if="useEntrustedProcess" class="mt-2 space-y-3">
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 text-xs text-muted-foreground">制剂要求</div>
|
||||||
|
<div v-if="processRuleLoading" class="text-xs text-muted-foreground">
|
||||||
|
加载中…
|
||||||
|
</div>
|
||||||
|
<div v-else class="ai-process-pill-group">
|
||||||
|
<span
|
||||||
|
v-for="opt in processRuleOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
class="ai-process-pill"
|
||||||
|
:class="{ active: genProcessRuleId === opt.value }"
|
||||||
|
@click="onProcessRuleTagClick(opt.value)"
|
||||||
|
>{{ opt.label }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</VoiceModal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 中药制剂方式 / 代煎 / 用量配置
|
||||||
|
* 与小程序对齐:标题带必填星号;父页提交失败可传 errorKeys 标红
|
||||||
|
*/
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
import { MinusOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
import { MinusOutlined, PlusOutlined } from '@ant-design/icons-vue';
|
||||||
import { InputNumber } from 'ant-design-vue';
|
import { InputNumber } from 'ant-design-vue';
|
||||||
|
|
||||||
@@ -13,7 +19,8 @@ export type ChineseMedicineConfigChange = {
|
|||||||
dayDosage?: number;
|
dayDosage?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
ruleType: number;
|
ruleType: number;
|
||||||
packageMethodId?: number | null;
|
packageMethodId?: number | null;
|
||||||
processRuleId?: number | null;
|
processRuleId?: number | null;
|
||||||
@@ -25,7 +32,17 @@ const props = defineProps<{
|
|||||||
childProcessRuleList?: Array<{ id: number; name: string }>;
|
childProcessRuleList?: Array<{ id: number; name: string }>;
|
||||||
processRuleNoteList?: Array<{ id: number; note?: string; name?: string }>;
|
processRuleNoteList?: Array<{ id: number; note?: string; name?: string }>;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}>();
|
/** 未填字段名;父页提交失败传入,改选后由父清空 */
|
||||||
|
errorKeys?: string[];
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
processRuleList: () => [],
|
||||||
|
childProcessRuleList: () => [],
|
||||||
|
processRuleNoteList: () => [],
|
||||||
|
disabled: false,
|
||||||
|
errorKeys: () => [],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
change: [payload: ChineseMedicineConfigChange];
|
change: [payload: ChineseMedicineConfigChange];
|
||||||
@@ -37,6 +54,23 @@ const packageMethodList = [
|
|||||||
{ id: 2, name: '剂包' },
|
{ id: 2, name: '剂包' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 未填字段 → 区块标红 */
|
||||||
|
const errorMap = computed(() => {
|
||||||
|
const map: Record<string, boolean> = {
|
||||||
|
ruleType: false,
|
||||||
|
packageMethodId: false,
|
||||||
|
processRuleId: false,
|
||||||
|
childProcessRuleId: false,
|
||||||
|
processRuleNoteId: false,
|
||||||
|
dosage: false,
|
||||||
|
dayDosage: false,
|
||||||
|
};
|
||||||
|
for (const k of props.errorKeys || []) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(map, k)) map[k] = true;
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 统一向父组件抛出配置变更
|
* 统一向父组件抛出配置变更
|
||||||
* 父组件负责级联加载制剂/煎法/备注列表
|
* 父组件负责级联加载制剂/煎法/备注列表
|
||||||
@@ -108,9 +142,12 @@ function updateDayDosage(val: number) {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="chinese-medicine-config">
|
<div class="chinese-medicine-config">
|
||||||
<!-- 制剂方式 -->
|
<!-- 制剂方式(必填) -->
|
||||||
<div class="config-section mb-4">
|
<div class="config-section mb-4" :class="{ 'sec-error': errorMap.ruleType }">
|
||||||
<div class="section-title mb-2 text-sm font-bold text-gray-800 dark:text-gray-200">制剂方式</div>
|
<div class="section-title mb-2">
|
||||||
|
<span class="title-text">制剂方式</span>
|
||||||
|
<span class="req-star">*</span>
|
||||||
|
</div>
|
||||||
<div class="pill-group">
|
<div class="pill-group">
|
||||||
<span
|
<span
|
||||||
class="pill"
|
class="pill"
|
||||||
@@ -123,11 +160,19 @@ function updateDayDosage(val: number) {
|
|||||||
@click="handleRuleTypeChange(2)"
|
@click="handleRuleTypeChange(2)"
|
||||||
>委托调剂</span>
|
>委托调剂</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="errorMap.ruleType" class="err-tip">请选择制剂方式</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 自制剂:包法 -->
|
<!-- 自制剂:包法 -->
|
||||||
<div v-if="ruleType === 1" class="config-section mb-4 border-t border-dashed border-gray-200 dark:border-gray-700 pt-4">
|
<div
|
||||||
<div class="section-title mb-2 text-sm font-bold text-gray-800 dark:text-gray-200">包法选择</div>
|
v-if="ruleType === 1"
|
||||||
|
class="config-section mb-4 border-t border-dashed border-border pt-4"
|
||||||
|
:class="{ 'sec-error': errorMap.packageMethodId }"
|
||||||
|
>
|
||||||
|
<div class="section-title mb-2">
|
||||||
|
<span class="title-text">包法选择</span>
|
||||||
|
<span class="req-star">*</span>
|
||||||
|
</div>
|
||||||
<div class="pill-group">
|
<div class="pill-group">
|
||||||
<span
|
<span
|
||||||
v-for="item in packageMethodList"
|
v-for="item in packageMethodList"
|
||||||
@@ -137,12 +182,19 @@ function updateDayDosage(val: number) {
|
|||||||
@click="handlePackageMethodClick(item.id)"
|
@click="handlePackageMethodClick(item.id)"
|
||||||
>{{ item.name }}</span>
|
>{{ item.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="errorMap.packageMethodId" class="err-tip">请选择包法</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 委托调剂:制剂 / 煎法 / 备注 -->
|
<!-- 委托调剂:制剂 / 煎法 / 备注 -->
|
||||||
<template v-if="ruleType === 2">
|
<template v-if="ruleType === 2">
|
||||||
<div class="config-section mb-4 border-t border-dashed border-gray-200 dark:border-gray-700 pt-4">
|
<div
|
||||||
<div class="section-title mb-2 text-sm font-bold text-gray-800 dark:text-gray-200">制剂要求</div>
|
class="config-section mb-4 border-t border-dashed border-border pt-4"
|
||||||
|
:class="{ 'sec-error': errorMap.processRuleId }"
|
||||||
|
>
|
||||||
|
<div class="section-title mb-2">
|
||||||
|
<span class="title-text">制剂要求</span>
|
||||||
|
<span class="req-star">*</span>
|
||||||
|
</div>
|
||||||
<div class="pill-group">
|
<div class="pill-group">
|
||||||
<span
|
<span
|
||||||
v-for="item in processRuleList"
|
v-for="item in processRuleList"
|
||||||
@@ -152,9 +204,17 @@ function updateDayDosage(val: number) {
|
|||||||
@click="handleProcessRuleClick(item)"
|
@click="handleProcessRuleClick(item)"
|
||||||
>{{ item.name }}</span>
|
>{{ item.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="errorMap.processRuleId" class="err-tip">请选择制剂要求</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="childProcessRuleList?.length"
|
||||||
|
class="config-section mb-4"
|
||||||
|
:class="{ 'sec-error': errorMap.childProcessRuleId }"
|
||||||
|
>
|
||||||
|
<div class="section-title mb-2">
|
||||||
|
<span class="title-text">煎法说明</span>
|
||||||
|
<span class="req-star">*</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="childProcessRuleList?.length" class="config-section mb-4">
|
|
||||||
<div class="section-title mb-2 text-sm font-bold text-gray-800 dark:text-gray-200">煎法说明</div>
|
|
||||||
<div class="pill-group">
|
<div class="pill-group">
|
||||||
<span
|
<span
|
||||||
v-for="item in childProcessRuleList"
|
v-for="item in childProcessRuleList"
|
||||||
@@ -164,9 +224,17 @@ function updateDayDosage(val: number) {
|
|||||||
@click="handleChildProcessRuleClick(item)"
|
@click="handleChildProcessRuleClick(item)"
|
||||||
>{{ item.name }}</span>
|
>{{ item.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="errorMap.childProcessRuleId" class="err-tip">请选择煎法说明</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="processRuleNoteList?.length"
|
||||||
|
class="config-section mb-4"
|
||||||
|
:class="{ 'sec-error': errorMap.processRuleNoteId }"
|
||||||
|
>
|
||||||
|
<div class="section-title mb-2">
|
||||||
|
<span class="title-text">附加备注</span>
|
||||||
|
<span class="req-star">*</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="processRuleNoteList?.length" class="config-section mb-4">
|
|
||||||
<div class="section-title mb-2 text-sm font-bold text-gray-800 dark:text-gray-200">附加备注</div>
|
|
||||||
<div class="pill-group">
|
<div class="pill-group">
|
||||||
<span
|
<span
|
||||||
v-for="item in processRuleNoteList"
|
v-for="item in processRuleNoteList"
|
||||||
@@ -176,15 +244,16 @@ function updateDayDosage(val: number) {
|
|||||||
@click="handleProcessRuleNoteClick(item)"
|
@click="handleProcessRuleNoteClick(item)"
|
||||||
>{{ item.note || item.name }}</span>
|
>{{ item.note || item.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="errorMap.processRuleNoteId" class="err-tip">请选择附加备注</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- 剂数与频次 -->
|
<!-- 剂数与频次 -->
|
||||||
<div class="config-section border-t border-dashed border-gray-200 dark:border-gray-700 pt-4">
|
<div class="config-section border-t border-dashed border-border pt-4">
|
||||||
<div class="mt-2 flex flex-wrap items-center gap-4">
|
<div class="mt-2 flex flex-wrap items-center gap-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2" :class="{ 'field-error': errorMap.dosage }">
|
||||||
<span class="text-sm font-bold text-gray-800 dark:text-gray-200">剂数</span>
|
<span class="title-text">剂数</span>
|
||||||
<!-- 带加减按钮时需足够宽度,避免中间输入区过窄无法输入 -->
|
<span class="req-star">*</span>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
:value="dosage"
|
:value="dosage"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@@ -200,11 +269,11 @@ function updateDayDosage(val: number) {
|
|||||||
<PlusOutlined @click="!disabled && updateDosage(dosage < 100 ? dosage + 1 : 100)" />
|
<PlusOutlined @click="!disabled && updateDosage(dosage < 100 ? dosage + 1 : 100)" />
|
||||||
</template>
|
</template>
|
||||||
</InputNumber>
|
</InputNumber>
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">剂(天)</span>
|
<span class="text-sm text-muted-foreground">剂(天)</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2" :class="{ 'field-error': errorMap.dayDosage }">
|
||||||
<span class="text-sm font-bold text-gray-800 dark:text-gray-200">用药频次</span>
|
<span class="title-text">用药频次</span>
|
||||||
<!-- 同上:加宽以保证可正常手输数字 -->
|
<span class="req-star">*</span>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
:value="dayDosage"
|
:value="dayDosage"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@@ -220,14 +289,52 @@ function updateDayDosage(val: number) {
|
|||||||
<PlusOutlined @click="!disabled && updateDayDosage(dayDosage < 100 ? dayDosage + 1 : 100)" />
|
<PlusOutlined @click="!disabled && updateDayDosage(dayDosage < 100 ? dayDosage + 1 : 100)" />
|
||||||
</template>
|
</template>
|
||||||
</InputNumber>
|
</InputNumber>
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">次/天</span>
|
<span class="text-sm text-muted-foreground">次/天</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="errorMap.dosage" class="err-tip">请填写剂数</div>
|
||||||
|
<div v-if="errorMap.dayDosage" class="err-tip">请填写用药频次</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.section-title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.title-text {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
.req-star {
|
||||||
|
margin-left: 2px;
|
||||||
|
color: hsl(var(--destructive, 0 84% 60%));
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.err-tip {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--destructive, 0 84% 60%));
|
||||||
|
}
|
||||||
|
.sec-error {
|
||||||
|
padding: 10px;
|
||||||
|
margin-left: -10px;
|
||||||
|
margin-right: -10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid hsl(var(--destructive, 0 84% 60%) / 45%);
|
||||||
|
box-shadow: 0 0 6px hsl(var(--destructive, 0 84% 60%) / 18%);
|
||||||
|
background: hsl(var(--destructive, 0 84% 60%) / 6%);
|
||||||
|
}
|
||||||
|
.field-error {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid hsl(var(--destructive, 0 84% 60%) / 45%);
|
||||||
|
box-shadow: 0 0 6px hsl(var(--destructive, 0 84% 60%) / 18%);
|
||||||
|
}
|
||||||
.pill-group {
|
.pill-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -235,8 +342,8 @@ function updateDayDosage(val: number) {
|
|||||||
}
|
}
|
||||||
.pill {
|
.pill {
|
||||||
padding: 4px 16px;
|
padding: 4px 16px;
|
||||||
background-color: #f4f6f8;
|
background-color: hsl(var(--muted) / 0.55);
|
||||||
color: #6c7380;
|
color: hsl(var(--muted-foreground));
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -245,45 +352,24 @@ function updateDayDosage(val: number) {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.pill.active {
|
.pill.active {
|
||||||
background-color: #00a88a;
|
background-color: hsl(var(--primary));
|
||||||
color: #fff;
|
color: hsl(var(--primary-foreground, 0 0% 100%));
|
||||||
border-color: #00a88a;
|
border-color: hsl(var(--primary));
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.pill.outline {
|
.pill.outline {
|
||||||
background-color: #fff;
|
background-color: hsl(var(--card, var(--background)));
|
||||||
border-color: #e2e8f0;
|
border-color: hsl(var(--border));
|
||||||
}
|
}
|
||||||
.pill.outline.active {
|
.pill.outline.active {
|
||||||
background-color: #e8f6f4;
|
background-color: hsl(var(--primary) / 0.12);
|
||||||
color: #00a88a;
|
color: hsl(var(--primary));
|
||||||
border-color: #00a88a;
|
border-color: hsl(var(--primary));
|
||||||
}
|
}
|
||||||
.pill.disabled {
|
.pill.disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
/* 暗色模式:标签背景与选中态 */
|
|
||||||
.dark .pill {
|
|
||||||
background-color: #2a2a2a;
|
|
||||||
color: #a3a3a3;
|
|
||||||
}
|
|
||||||
.dark .pill.active {
|
|
||||||
background-color: #00a88a;
|
|
||||||
color: #fff;
|
|
||||||
border-color: #00a88a;
|
|
||||||
}
|
|
||||||
.dark .pill.outline {
|
|
||||||
background-color: #1f1f1f;
|
|
||||||
border-color: #404040;
|
|
||||||
color: #a3a3a3;
|
|
||||||
}
|
|
||||||
.dark .pill.outline.active {
|
|
||||||
background-color: rgba(0, 168, 138, 0.15);
|
|
||||||
color: #3dd6b5;
|
|
||||||
border-color: #00a88a;
|
|
||||||
}
|
|
||||||
/* 剂数/频次输入:原 w-24 过窄,加减按钮占位后中间几乎无法输入 */
|
|
||||||
.dosage-input {
|
.dosage-input {
|
||||||
width: 140px;
|
width: 140px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ import {
|
|||||||
PERSONAL_HISTORY_OPTIONS,
|
PERSONAL_HISTORY_OPTIONS,
|
||||||
PRESENT_HISTORY_OPTIONS,
|
PRESENT_HISTORY_OPTIONS,
|
||||||
} from '#/views/doctor/doctor-reception/config/health-history';
|
} from '#/views/doctor/doctor-reception/config/health-history';
|
||||||
|
import RegionAddressPicker from '#/components/form/components/region-address-picker.vue';
|
||||||
|
import {
|
||||||
|
formatAddressDisplay,
|
||||||
|
resolveAddressValue,
|
||||||
|
type AddressValue,
|
||||||
|
} from '#/util/address-index';
|
||||||
|
|
||||||
/** 创建成功回传:含 register_id / user_patient_id / is_pay,供接诊页分情况引导 */
|
/** 创建成功回传:含 register_id / user_patient_id / is_pay,供接诊页分情况引导 */
|
||||||
export type CreateUserPatientResult = {
|
export type CreateUserPatientResult = {
|
||||||
@@ -109,6 +115,11 @@ function buildEmptyForm() {
|
|||||||
guardian_id_card: '',
|
guardian_id_card: '',
|
||||||
guardian_mobile: '',
|
guardian_mobile: '',
|
||||||
guardian_relation: 0,
|
guardian_relation: 0,
|
||||||
|
// 收货地址选填:填了才建 yii_address 并绑 default_address_id
|
||||||
|
ship_region: undefined as AddressValue | undefined,
|
||||||
|
ship_detail_address: '',
|
||||||
|
ship_name: '',
|
||||||
|
ship_mobile: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,13 +235,35 @@ function setHistoryTags(historyField: keyof ReturnType<typeof buildEmptyForm>, t
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 组装提交体:不展示的月经/婚育强制清零;history 仅在 status=有 时带出
|
* 组装提交体:不展示的月经/婚育强制清零;history 仅在 status=有 时带出
|
||||||
|
* 收货地址:有详细地址才带省市区字段,后端据此建 yii_address
|
||||||
*/
|
*/
|
||||||
function buildSubmitPayload() {
|
function buildSubmitPayload() {
|
||||||
const f = form.value;
|
const f = form.value;
|
||||||
const menstrualOk = showMenstrualHistory.value;
|
const menstrualOk = showMenstrualHistory.value;
|
||||||
const maritalOk = showMaritalHistory.value;
|
const maritalOk = showMaritalHistory.value;
|
||||||
|
const resolved = resolveAddressValue(f.ship_region);
|
||||||
|
const detail = String(f.ship_detail_address || '').trim();
|
||||||
|
const shipPayload =
|
||||||
|
detail && (resolved.province || resolved.city || resolved.district)
|
||||||
|
? {
|
||||||
|
ship_province: resolved.province?.label || '',
|
||||||
|
ship_city: resolved.city?.label || '',
|
||||||
|
ship_area: resolved.district?.label || '',
|
||||||
|
ship_region: formatAddressDisplay(f.ship_region),
|
||||||
|
ship_detail_address: detail,
|
||||||
|
ship_name: String(f.ship_name || f.name || '').trim(),
|
||||||
|
ship_mobile: String(f.ship_mobile || f.mobile || '').trim(),
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
const {
|
||||||
|
ship_region: _shipRegion,
|
||||||
|
ship_detail_address: _shipDetail,
|
||||||
|
ship_name: _shipName,
|
||||||
|
ship_mobile: _shipMobile,
|
||||||
|
...rest
|
||||||
|
} = f;
|
||||||
return {
|
return {
|
||||||
...f,
|
...rest,
|
||||||
waive_register_fee: f.waive_register_fee ? 1 : 0,
|
waive_register_fee: f.waive_register_fee ? 1 : 0,
|
||||||
menstrual_status: menstrualOk ? f.menstrual_status : 0,
|
menstrual_status: menstrualOk ? f.menstrual_status : 0,
|
||||||
menstrual_history: menstrualOk && f.menstrual_status === 1 ? f.menstrual_history : [],
|
menstrual_history: menstrualOk && f.menstrual_status === 1 ? f.menstrual_history : [],
|
||||||
@@ -242,6 +275,7 @@ function buildSubmitPayload() {
|
|||||||
family_history: f.family_status === 1 ? f.family_history : [],
|
family_history: f.family_status === 1 ? f.family_history : [],
|
||||||
epidemic_history: f.epidemic_status === 1 ? f.epidemic_history : [],
|
epidemic_history: f.epidemic_status === 1 ? f.epidemic_history : [],
|
||||||
personal_history: f.personal_status === 1 ? f.personal_history : [],
|
personal_history: f.personal_status === 1 ? f.personal_history : [],
|
||||||
|
...shipPayload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +310,15 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 填了收货地址时校验收件人:最多 10 个字(含默认用姓名)
|
||||||
|
const shipDetail = String(form.value.ship_detail_address || '').trim();
|
||||||
|
if (shipDetail) {
|
||||||
|
const shipName = String(form.value.ship_name || form.value.name || '').trim();
|
||||||
|
if (shipName.length > 10) {
|
||||||
|
message.error('收件人最多10个字');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
submitting.value = true;
|
submitting.value = true;
|
||||||
modalApi.setState({ confirmLoading: true });
|
modalApi.setState({ confirmLoading: true });
|
||||||
try {
|
try {
|
||||||
@@ -393,6 +436,42 @@ function onBirthdayChange(v: Dayjs | string | null) {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- 收货地址选填:填详细地址才会落库,开方优先用该地址 -->
|
||||||
|
<div class="mb-3 mt-2 text-sm font-medium text-[hsl(var(--foreground))]">
|
||||||
|
收货地址
|
||||||
|
<span class="ml-2 text-xs font-normal text-[hsl(var(--muted-foreground))]">选填,填写后开方默认使用</span>
|
||||||
|
</div>
|
||||||
|
<Row :gutter="12">
|
||||||
|
<Col :span="12">
|
||||||
|
<FormItem label="省市区">
|
||||||
|
<RegionAddressPicker v-model:value="form.ship_region" />
|
||||||
|
</FormItem>
|
||||||
|
</Col>
|
||||||
|
<Col :span="12">
|
||||||
|
<FormItem label="详细地址">
|
||||||
|
<Input v-model:value="form.ship_detail_address" placeholder="街道门牌等" />
|
||||||
|
</FormItem>
|
||||||
|
</Col>
|
||||||
|
<Col :span="12">
|
||||||
|
<FormItem label="收件人">
|
||||||
|
<Input
|
||||||
|
v-model:value="form.ship_name"
|
||||||
|
:maxlength="10"
|
||||||
|
show-count
|
||||||
|
:placeholder="form.name ? `默认:${form.name}` : '默认就诊人姓名'"
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
</Col>
|
||||||
|
<Col :span="12">
|
||||||
|
<FormItem label="收件手机">
|
||||||
|
<Input
|
||||||
|
v-model:value="form.ship_mobile"
|
||||||
|
maxlength="11"
|
||||||
|
:placeholder="form.mobile ? `默认:${form.mobile}` : '默认就诊人手机'"
|
||||||
|
/>
|
||||||
|
</FormItem>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
<!-- 健康信息:字段与交互对齐患者小程序;一行两列排布 -->
|
<!-- 健康信息:字段与交互对齐患者小程序;一行两列排布 -->
|
||||||
<div class="mb-3 mt-2 text-sm font-medium text-[hsl(var(--foreground))]">健康信息</div>
|
<div class="mb-3 mt-2 text-sm font-medium text-[hsl(var(--foreground))]">健康信息</div>
|
||||||
<Row :gutter="12">
|
<Row :gutter="12">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -264,6 +264,22 @@ function parseRecipeContent(content: Record<string, unknown> | string) {
|
|||||||
}
|
}
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析辅料列表:优先 recipe.excipients,其次处方 content.excipients
|
||||||
|
* 旧单无辅料字段时返回空数组,不影响药味展示
|
||||||
|
*/
|
||||||
|
function resolveExcipients(prescription: Record<string, any>, recipe: Record<string, any>) {
|
||||||
|
const fromRecipe = recipe?.excipients;
|
||||||
|
if (Array.isArray(fromRecipe) && fromRecipe.length > 0) {
|
||||||
|
return fromRecipe;
|
||||||
|
}
|
||||||
|
const fromContent = prescription?.content?.excipients;
|
||||||
|
if (Array.isArray(fromContent) && fromContent.length > 0) {
|
||||||
|
return fromContent;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -334,6 +350,30 @@ function parseRecipeContent(content: Record<string, unknown> | string) {
|
|||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- 辅料:挂在 recipe.excipients / 处方 content.excipients,不进药味 content -->
|
||||||
|
<div
|
||||||
|
v-if="resolveExcipients(item, recipe).length"
|
||||||
|
class="mt-2 w-full"
|
||||||
|
>
|
||||||
|
<div class="mb-1 text-sm font-medium text-[hsl(var(--muted-foreground))]">辅料</div>
|
||||||
|
<div
|
||||||
|
v-for="(ex, exIdx) in resolveExcipients(item, recipe)"
|
||||||
|
:key="ex.drug_id || exIdx"
|
||||||
|
class="w-1/3"
|
||||||
|
style="display: inline-block"
|
||||||
|
>
|
||||||
|
<div class="medicine-item">
|
||||||
|
<span class="drug-name">{{ ex.name || ex.drug_name }}</span>
|
||||||
|
<span
|
||||||
|
v-if="ex.specification"
|
||||||
|
class="drug-spec ml-1"
|
||||||
|
>{{ ex.specification }}</span>
|
||||||
|
<span class="drug-quantity mr-5">{{ ex.number }} /{{
|
||||||
|
typeof ex.unit === 'object' ? (ex.unit?.name || 'g') : (ex.unit || 'g')
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="preparation-info">
|
<div class="preparation-info">
|
||||||
<p>
|
<p>
|
||||||
用法:每日{{ recipe.consumption }}次,共{{ recipe.dosage }}剂
|
用法:每日{{ recipe.consumption }}次,共{{ recipe.dosage }}剂
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* 中药复用 / 常用方填方前,与当前门店价对齐
|
||||||
|
* 为什么:保存快照不会跟着供货比例变,价格不一致时必须医生确认,避免静默改价或一直用旧价
|
||||||
|
*/
|
||||||
|
import { h } from 'vue';
|
||||||
|
|
||||||
|
import { Modal, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { compareChineseDrugPricesApi } from '#/views/doctor/doctor-reception/api';
|
||||||
|
|
||||||
|
type PriceDrug = Record<string, any>;
|
||||||
|
|
||||||
|
type PriceDiffItem = {
|
||||||
|
drug_id: number;
|
||||||
|
drug_name: string;
|
||||||
|
old_price?: number | string | null;
|
||||||
|
latest_price?: number | string | null;
|
||||||
|
old_buy_price?: number | string | null;
|
||||||
|
latest_buy_price?: number | string | null;
|
||||||
|
price_changed?: number;
|
||||||
|
buy_price_changed?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 弹窗里展示的金额;空快照显示「无」 */
|
||||||
|
function formatMoney(value: unknown): string {
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '无';
|
||||||
|
}
|
||||||
|
const num = Number(value);
|
||||||
|
if (Number.isNaN(num)) {
|
||||||
|
return '无';
|
||||||
|
}
|
||||||
|
return num.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 拼确认文案:只列出真正变化的供货价 / 零售价 */
|
||||||
|
function buildPriceChangeText(items: PriceDiffItem[], missing: PriceDiffItem[]): string {
|
||||||
|
const lines = ['以下药品与当前门店价不一致,是否按最新供货价和零售价计算?'];
|
||||||
|
const shown = items.slice(0, 8);
|
||||||
|
shown.forEach((item) => {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (Number(item.buy_price_changed) === 1) {
|
||||||
|
parts.push(`供货价 ${formatMoney(item.old_buy_price)}→${formatMoney(item.latest_buy_price)}`);
|
||||||
|
}
|
||||||
|
if (Number(item.price_changed) === 1) {
|
||||||
|
parts.push(`零售价 ${formatMoney(item.old_price)}→${formatMoney(item.latest_price)}`);
|
||||||
|
}
|
||||||
|
lines.push(`${item.drug_name || '药品'}:${parts.join(',')}`);
|
||||||
|
});
|
||||||
|
if (items.length > shown.length) {
|
||||||
|
lines.push(`等共 ${items.length} 味`);
|
||||||
|
}
|
||||||
|
if (missing.length > 0) {
|
||||||
|
const names = missing.slice(0, 3).map((item) => item.drug_name || '药品').join('、');
|
||||||
|
lines.push(`当前门店未上架、将保留原价:${names}${missing.length > 3 ? '等' : ''}`);
|
||||||
|
}
|
||||||
|
lines.push('取消则保留处方里的原价。');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 确认是否改用最新价;关掉弹窗视为保留原价 */
|
||||||
|
function confirmUseLatest(items: PriceDiffItem[], missing: PriceDiffItem[]): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (useLatest: boolean) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
resolve(useLatest);
|
||||||
|
};
|
||||||
|
Modal.confirm({
|
||||||
|
title: '药品价格已变化',
|
||||||
|
content: h(
|
||||||
|
'div',
|
||||||
|
{ style: 'white-space: pre-wrap; line-height: 1.7; max-height: 320px; overflow: auto;' },
|
||||||
|
buildPriceChangeText(items, missing),
|
||||||
|
),
|
||||||
|
okText: '按最新价格',
|
||||||
|
cancelText: '保留原价',
|
||||||
|
onOk: () => finish(true),
|
||||||
|
onCancel: () => finish(false),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把选中的最新价写回药品行,并锁定为后续调价的基准 */
|
||||||
|
export function applyLatestPrices(drugs: PriceDrug[], items: PriceDiffItem[]) {
|
||||||
|
const latestById = new Map<number, PriceDiffItem>();
|
||||||
|
items.forEach((item) => {
|
||||||
|
latestById.set(Number(item.drug_id), item);
|
||||||
|
});
|
||||||
|
drugs.forEach((drug) => {
|
||||||
|
const hit = latestById.get(Number(drug.id || drug.drug_id || 0));
|
||||||
|
if (!hit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
drug.price = hit.latest_price;
|
||||||
|
drug.origin_price = hit.latest_price;
|
||||||
|
// 诊所关闭查看毛利率时接口不回供货价;写成空会清掉结算用的供货价
|
||||||
|
if (hit.latest_buy_price !== null && hit.latest_buy_price !== undefined && hit.latest_buy_price !== '') {
|
||||||
|
drug.buy_price = hit.latest_buy_price;
|
||||||
|
drug.origin_buy_price = hit.latest_buy_price;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取与当前门店价不一致的中药行
|
||||||
|
* 为什么:接诊页要标出可更新的药,导入确认和批量按钮共用这一次对比
|
||||||
|
* 核对失败默认不抛错;silent 为 false 时提示后按空差异返回
|
||||||
|
*/
|
||||||
|
export async function fetchChinesePriceDiff(options: {
|
||||||
|
registerId?: number;
|
||||||
|
storeId?: number;
|
||||||
|
drugs: PriceDrug[];
|
||||||
|
/** 用调价前基准价对比,避免折扣后被当成门店价过期 */
|
||||||
|
compareOrigin?: boolean;
|
||||||
|
silent?: boolean;
|
||||||
|
}): Promise<{ items: PriceDiffItem[]; missing: PriceDiffItem[]; see_rate: number | null }> {
|
||||||
|
const drugs = options.drugs || [];
|
||||||
|
if (drugs.length === 0) {
|
||||||
|
return { items: [], missing: [], see_rate: null };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const compared = await compareChineseDrugPricesApi({
|
||||||
|
register_id: Number(options.registerId || 0),
|
||||||
|
store_id: Number(options.storeId || 0),
|
||||||
|
drugs: drugs.map((drug) => ({
|
||||||
|
id: Number(drug.id || drug.drug_id || 0),
|
||||||
|
drug_name: drug.drug_name || drug.name || '',
|
||||||
|
price: options.compareOrigin ? (drug.origin_price ?? drug.price ?? null) : (drug.price ?? null),
|
||||||
|
buy_price: options.compareOrigin
|
||||||
|
? (drug.origin_buy_price ?? drug.buy_price ?? null)
|
||||||
|
: (drug.buy_price ?? null),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
items: compared?.changed && Array.isArray(compared?.items) ? compared.items : [],
|
||||||
|
missing: Array.isArray(compared?.missing) ? compared.missing : [],
|
||||||
|
see_rate: compared?.see_rate == null ? null : (Number(compared.see_rate) === 1 ? 1 : 0),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('核对中药最新价格失败', error);
|
||||||
|
if (!options.silent) {
|
||||||
|
message.warning('价格核对失败,已按原价填入');
|
||||||
|
}
|
||||||
|
return { items: [], missing: [], see_rate: null };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 就地决定药品行用快照价还是最新门店价
|
||||||
|
* 核对失败不阻断填方,按原价继续,避免医生卡在弹窗前
|
||||||
|
*/
|
||||||
|
export async function alignChineseDrugsToLatestPrice(options: {
|
||||||
|
registerId?: number;
|
||||||
|
storeId?: number;
|
||||||
|
drugs: PriceDrug[];
|
||||||
|
}): Promise<void> {
|
||||||
|
const drugs = options.drugs || [];
|
||||||
|
const diff = await fetchChinesePriceDiff(options);
|
||||||
|
if (diff.items.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const useLatest = await confirmUseLatest(diff.items, diff.missing);
|
||||||
|
if (!useLatest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyLatestPrices(drugs, diff.items);
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/**
|
||||||
|
* 浏览器录音 → 16k 单声道 WAV(讯飞一句话识别 raw/pcm 要求)
|
||||||
|
* 为什么:MediaRecorder 默认 webm,ASR 不支持,需解码后重编码
|
||||||
|
*/
|
||||||
|
export async function encodeBlobToWav16k(blob: Blob): Promise<Blob> {
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
const audioCtx = new AudioContext();
|
||||||
|
let decoded: AudioBuffer;
|
||||||
|
try {
|
||||||
|
decoded = await audioCtx.decodeAudioData(arrayBuffer.slice(0));
|
||||||
|
} finally {
|
||||||
|
await audioCtx.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
const targetRate = 16000;
|
||||||
|
const offline = new OfflineAudioContext(
|
||||||
|
1,
|
||||||
|
Math.ceil(decoded.duration * targetRate),
|
||||||
|
targetRate,
|
||||||
|
);
|
||||||
|
const source = offline.createBufferSource();
|
||||||
|
// 混成单声道
|
||||||
|
const mono = offline.createBuffer(1, decoded.length, decoded.sampleRate);
|
||||||
|
const ch0 = mono.getChannelData(0);
|
||||||
|
const channels = decoded.numberOfChannels;
|
||||||
|
for (let i = 0; i < decoded.length; i++) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let c = 0; c < channels; c++) {
|
||||||
|
sum += decoded.getChannelData(c)[i] || 0;
|
||||||
|
}
|
||||||
|
ch0[i] = sum / channels;
|
||||||
|
}
|
||||||
|
source.buffer = mono;
|
||||||
|
source.connect(offline.destination);
|
||||||
|
source.start(0);
|
||||||
|
const rendered = await offline.startRendering();
|
||||||
|
const pcm = rendered.getChannelData(0);
|
||||||
|
return pcmToWavBlob(pcm, targetRate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Float32 PCM → 16bit LE WAV Blob
|
||||||
|
*/
|
||||||
|
function pcmToWavBlob(samples: Float32Array, sampleRate: number): Blob {
|
||||||
|
const dataLength = samples.length * 2;
|
||||||
|
const buffer = new ArrayBuffer(44 + dataLength);
|
||||||
|
const view = new DataView(buffer);
|
||||||
|
writeString(view, 0, 'RIFF');
|
||||||
|
view.setUint32(4, 36 + dataLength, true);
|
||||||
|
writeString(view, 8, 'WAVE');
|
||||||
|
writeString(view, 12, 'fmt ');
|
||||||
|
view.setUint32(16, 16, true);
|
||||||
|
view.setUint16(20, 1, true);
|
||||||
|
view.setUint16(22, 1, true);
|
||||||
|
view.setUint32(24, sampleRate, true);
|
||||||
|
view.setUint32(28, sampleRate * 2, true);
|
||||||
|
view.setUint16(32, 2, true);
|
||||||
|
view.setUint16(34, 16, true);
|
||||||
|
writeString(view, 36, 'data');
|
||||||
|
view.setUint32(40, dataLength, true);
|
||||||
|
let offset = 44;
|
||||||
|
for (let i = 0; i < samples.length; i++) {
|
||||||
|
const s = Math.max(-1, Math.min(1, samples[i] || 0));
|
||||||
|
view.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||||
|
offset += 2;
|
||||||
|
}
|
||||||
|
return new Blob([buffer], { type: 'audio/wav' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeString(view: DataView, offset: number, str: string) {
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
view.setUint8(offset + i, str.charCodeAt(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
/**
|
||||||
|
* 识图传方全屏上传区:拖拽 / 点击 / 粘贴(成功后自动停监听)
|
||||||
|
* 仅负责收图并上传得到 URL,识别由父页触发
|
||||||
|
*/
|
||||||
|
import { onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
|
import { Button, Upload, message } from 'ant-design-vue';
|
||||||
|
import {
|
||||||
|
CloudUploadOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
SnippetsOutlined,
|
||||||
|
StopOutlined,
|
||||||
|
} from '@ant-design/icons-vue';
|
||||||
|
import { preferences } from '@vben/preferences';
|
||||||
|
|
||||||
|
import { uploadFile } from '#/api/core/upload';
|
||||||
|
import { uploadToOss } from '#/utils/oss-upload';
|
||||||
|
import { usePasteUploadListen } from '#/utils/use-paste-upload-listen';
|
||||||
|
|
||||||
|
defineOptions({ name: 'FullscreenImageUpload' });
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: string[];
|
||||||
|
/** 最多张数 */
|
||||||
|
maxCount?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: () => [],
|
||||||
|
maxCount: 6,
|
||||||
|
disabled: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [urls: string[]];
|
||||||
|
/** 有图后可点「开始识别」 */
|
||||||
|
recognize: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const UploadDragger = Upload.Dragger;
|
||||||
|
const urls = ref<string[]>([...(props.modelValue || [])]);
|
||||||
|
const uploading = ref(false);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(v) => {
|
||||||
|
const next = Array.isArray(v) ? v : [];
|
||||||
|
if (JSON.stringify(next) !== JSON.stringify(urls.value)) {
|
||||||
|
urls.value = [...next];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function syncOut() {
|
||||||
|
emit('update:modelValue', [...urls.value]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFiles(files: File[]) {
|
||||||
|
if (props.disabled) return;
|
||||||
|
const remain = props.maxCount - urls.value.length;
|
||||||
|
if (remain <= 0) {
|
||||||
|
message.warning(`最多上传 ${props.maxCount} 张`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const slice = files.slice(0, remain);
|
||||||
|
uploading.value = true;
|
||||||
|
try {
|
||||||
|
const method = preferences.app.uploadMethod || 'direct';
|
||||||
|
for (const file of slice) {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
message.warning(`${file.name || '文件'} 不是图片`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const res =
|
||||||
|
method === 'direct'
|
||||||
|
? await uploadToOss({ file })
|
||||||
|
: await uploadFile({ file });
|
||||||
|
const url = String(res?.url || '');
|
||||||
|
if (!url) {
|
||||||
|
message.error('上传失败');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
urls.value = [...urls.value, url];
|
||||||
|
}
|
||||||
|
syncOut();
|
||||||
|
} catch (e: any) {
|
||||||
|
message.error(e?.message || '上传失败');
|
||||||
|
} finally {
|
||||||
|
uploading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { listening, start, stop, toggle } = usePasteUploadListen({
|
||||||
|
accept: 'image/*,.jpg,.jpeg,.png,.gif,.webp',
|
||||||
|
maxSize: 20 * 1024 * 1024,
|
||||||
|
autoStopOnSuccess: true,
|
||||||
|
onFiles: async (files) => {
|
||||||
|
await uploadFiles(files);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 进页即开粘贴;贴成功后 hook 内 autoStop
|
||||||
|
if (!props.disabled) start();
|
||||||
|
});
|
||||||
|
|
||||||
|
function onTogglePaste() {
|
||||||
|
if (props.disabled) return;
|
||||||
|
toggle();
|
||||||
|
if (listening.value) {
|
||||||
|
message.success('已开启粘贴监听,Ctrl+V 粘贴截图后会自动关闭');
|
||||||
|
} else {
|
||||||
|
message.info('已停止粘贴监听');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function beforeUpload(file: File) {
|
||||||
|
void uploadFiles([file]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeAt(idx: number) {
|
||||||
|
urls.value = urls.value.filter((_, i) => i !== idx);
|
||||||
|
syncOut();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRecognize() {
|
||||||
|
if (!urls.value.length) {
|
||||||
|
message.warning('请先上传处方图');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stop();
|
||||||
|
emit('recognize');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="fs-upload" :class="{ 'has-files': urls.length }">
|
||||||
|
<UploadDragger
|
||||||
|
class="fs-upload-dragger"
|
||||||
|
accept="image/*"
|
||||||
|
:multiple="true"
|
||||||
|
:disabled="disabled || uploading || urls.length >= maxCount"
|
||||||
|
:show-upload-list="false"
|
||||||
|
:before-upload="beforeUpload"
|
||||||
|
>
|
||||||
|
<div class="fs-upload-inner">
|
||||||
|
<CloudUploadOutlined class="fs-upload-icon" />
|
||||||
|
<div class="fs-upload-title">拖拽处方图到此处,或点击选择</div>
|
||||||
|
<div class="fs-upload-hint">
|
||||||
|
支持 JPG/PNG/WEBP,最多 {{ maxCount }} 张;也可 Ctrl+V 粘贴截图
|
||||||
|
</div>
|
||||||
|
<div v-if="listening" class="fs-upload-paste-on">
|
||||||
|
粘贴监听中…贴入成功后自动关闭
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UploadDragger>
|
||||||
|
|
||||||
|
<div v-if="urls.length" class="fs-upload-thumbs">
|
||||||
|
<div v-for="(url, idx) in urls" :key="url + idx" class="fs-thumb">
|
||||||
|
<img :src="url" alt="处方图" />
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
danger
|
||||||
|
class="fs-thumb-del"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click.stop="removeAt(idx)"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fs-upload-actions">
|
||||||
|
<Button
|
||||||
|
:type="listening ? 'primary' : 'default'"
|
||||||
|
:danger="listening"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="onTogglePaste"
|
||||||
|
>
|
||||||
|
<StopOutlined v-if="listening" />
|
||||||
|
<SnippetsOutlined v-else />
|
||||||
|
{{ listening ? '停止粘贴监听' : '开启粘贴监听' }}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="large"
|
||||||
|
:loading="uploading"
|
||||||
|
:disabled="disabled || !urls.length"
|
||||||
|
@click="onRecognize"
|
||||||
|
>
|
||||||
|
开始识别
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fs-upload {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-upload-dragger {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 180px;
|
||||||
|
max-height: 100%;
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
border: 1px dashed hsl(var(--primary) / 45%) !important;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 0 10px hsl(var(--primary) / 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-upload.has-files .fs-upload-dragger {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-upload-thumbs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-upload-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-shrink: 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 8px 0;
|
||||||
|
background: hsl(var(--background) / 0.92);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-upload-dragger :deep(.ant-upload) {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
padding: 32px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-upload-inner {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-upload-icon {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 56px;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-upload-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-upload-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-upload-paste-on {
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--warning, 38 92% 50%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-upload-thumbs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 0 6px hsl(var(--primary) / 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-thumb-del {
|
||||||
|
position: absolute !important;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
background: hsl(var(--background) / 70%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fs-upload-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
1353
apps/web-antd/src/views/doctor/image-transfer-prescription/index.vue
Normal file
1353
apps/web-antd/src/views/doctor/image-transfer-prescription/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
* 常用快捷标签(诊断 / 医嘱 / 病历词条)
|
* 常用快捷标签(诊断 / 医嘱 / 病历词条)
|
||||||
* - 正文已包含该文案时高亮(is-active)
|
* - 正文已包含该文案时高亮(is-active)
|
||||||
* - 点击:已在正文则移除,否则追加(由父级 onEntryPick 统一 toggle)
|
* - 点击:已在正文则移除,否则追加(由父级 onEntryPick 统一 toggle)
|
||||||
|
* - collapsible:可收起常用区,避免开方页底部常驻占高
|
||||||
*/
|
*/
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
@@ -25,12 +26,18 @@ const props = withDefaults(
|
|||||||
fieldValue?: string;
|
fieldValue?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** 是否可收起(开方页诊断/医嘱建议开) */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** 初始是否收起;仅 collapsible 时生效 */
|
||||||
|
defaultCollapsed?: boolean;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
fieldCode: '',
|
fieldCode: '',
|
||||||
fieldValue: '',
|
fieldValue: '',
|
||||||
limit: 12,
|
limit: 12,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
collapsible: false,
|
||||||
|
defaultCollapsed: false,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -42,6 +49,8 @@ type ChipItem = { id: string | number; text: string };
|
|||||||
|
|
||||||
const chips = ref<ChipItem[]>([]);
|
const chips = ref<ChipItem[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
|
/** 收起态:默认不铺开常用标签,点标题再展开 */
|
||||||
|
const collapsed = ref(!!props.defaultCollapsed);
|
||||||
|
|
||||||
/** 文案是否已出现在字段值中(逗号分段或整行/包含) */
|
/** 文案是否已出现在字段值中(逗号分段或整行/包含) */
|
||||||
function textInField(fieldValue: string, text: string) {
|
function textInField(fieldValue: string, text: string) {
|
||||||
@@ -86,7 +95,6 @@ async function loadChips() {
|
|||||||
chips.value = [];
|
chips.value = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 全量 my-entry-list 一次,再按 field_code 本地切片
|
|
||||||
const list = await fetchMyEntriesByField(props.fieldCode, props.limit);
|
const list = await fetchMyEntriesByField(props.fieldCode, props.limit);
|
||||||
chips.value = (list || [])
|
chips.value = (list || [])
|
||||||
.map((item: any) => {
|
.map((item: any) => {
|
||||||
@@ -133,12 +141,22 @@ function onPick(item: ChipItem) {
|
|||||||
emit('select', item.text);
|
emit('select', item.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleCollapsed() {
|
||||||
|
if (!props.collapsible) return;
|
||||||
|
collapsed.value = !collapsed.value;
|
||||||
|
}
|
||||||
|
|
||||||
const labelText = computed(() => {
|
const labelText = computed(() => {
|
||||||
if (props.source === 'diagnosis') return '常用诊断';
|
if (props.source === 'diagnosis') return '常用诊断';
|
||||||
if (props.source === 'doctor_order') return '常用医嘱';
|
if (props.source === 'doctor_order') return '常用医嘱';
|
||||||
return '常用词条';
|
return '常用词条';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const showChipList = computed(() => {
|
||||||
|
if (!props.collapsible) return true;
|
||||||
|
return !collapsed.value;
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.source, props.fieldCode] as const,
|
() => [props.source, props.fieldCode] as const,
|
||||||
() => {
|
() => {
|
||||||
@@ -154,8 +172,17 @@ defineExpose({ reload: reloadChips });
|
|||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div v-if="chips.length > 0" class="dx-order-chips">
|
<div v-if="chips.length > 0" class="dx-order-chips">
|
||||||
|
<button
|
||||||
|
v-if="collapsible"
|
||||||
|
type="button"
|
||||||
|
class="dx-order-chips__toggle"
|
||||||
|
@click="toggleCollapsed"
|
||||||
|
>
|
||||||
<span class="dx-order-chips__label">{{ labelText }}</span>
|
<span class="dx-order-chips__label">{{ labelText }}</span>
|
||||||
<div class="dx-order-chips__list">
|
<span class="dx-order-chips__meta">{{ chips.length }} · {{ collapsed ? '展开' : '收起' }}</span>
|
||||||
|
</button>
|
||||||
|
<span v-else class="dx-order-chips__label">{{ labelText }}</span>
|
||||||
|
<div v-if="showChipList" class="dx-order-chips__list">
|
||||||
<Tag
|
<Tag
|
||||||
v-for="item in chips"
|
v-for="item in chips"
|
||||||
:key="`${item.id}_${item.text}`"
|
:key="`${item.id}_${item.text}`"
|
||||||
@@ -174,7 +201,7 @@ defineExpose({ reload: reloadChips });
|
|||||||
</template>
|
</template>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.dx-order-chips {
|
.dx-order-chips {
|
||||||
margin-top: 8px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
.dx-order-chips__label {
|
.dx-order-chips__label {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -182,6 +209,26 @@ defineExpose({ reload: reloadChips });
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: hsl(var(--muted-foreground));
|
color: hsl(var(--muted-foreground));
|
||||||
}
|
}
|
||||||
|
.dx-order-chips__toggle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 0 4px;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.dx-order-chips__toggle .dx-order-chips__label {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.dx-order-chips__meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.dx-order-chips__list {
|
.dx-order-chips__list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
const prefix = 'account-able-change-log/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资金流水页「资金变动记录」:平台全量审计(audit=1)
|
||||||
|
* 与提现页「只看本主体」区分;诊所筛选由调用方把 store_id 映射为 user_id+user_type=1
|
||||||
|
*/
|
||||||
|
export async function getFundWaterAccountAbleChangeList(data: any) {
|
||||||
|
return requestClient.get<any>(`${prefix}list`, {
|
||||||
|
params: { ...data, audit: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { VbenFormProps } from '#/adapter/form';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资金流水页 · 资金变动全量筛选
|
||||||
|
* 诊所走 StorePicker → 查询时映射为 user_id + user_type=1(变动表主体字段)
|
||||||
|
*/
|
||||||
|
export const accountChangeFormOptions: VbenFormProps = {
|
||||||
|
collapsed: false,
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'StorePicker',
|
||||||
|
fieldName: 'store_id',
|
||||||
|
label: '诊所',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenSelect',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '来源类型',
|
||||||
|
allowClear: true,
|
||||||
|
options: [
|
||||||
|
{ label: '分账', value: 'settle' },
|
||||||
|
{ label: '提现', value: 'withdraw' },
|
||||||
|
{ label: '退款', value: 'refund' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
defaultValue: '',
|
||||||
|
fieldName: 'source_type',
|
||||||
|
label: '来源类型',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenSelect',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '变更字段',
|
||||||
|
allowClear: true,
|
||||||
|
options: [
|
||||||
|
{ label: '可提现金额', value: 'able_cash' },
|
||||||
|
{ label: '可提现余额', value: 'balance' },
|
||||||
|
{ label: '冻结金额(用户未确认收货)', value: 'frozen' },
|
||||||
|
{ label: '审核中金额', value: 'frozen_cash' },
|
||||||
|
{ label: '审核中金额(余额账户)', value: 'withdrawn_frozen' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
defaultValue: '',
|
||||||
|
fieldName: 'field_name',
|
||||||
|
label: '变更字段',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
fieldName: 'order_no',
|
||||||
|
label: '订单号',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入订单号',
|
||||||
|
allowClear: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCollapseButton: true,
|
||||||
|
submitButtonOptions: {
|
||||||
|
content: '查询',
|
||||||
|
},
|
||||||
|
submitOnChange: true,
|
||||||
|
submitOnEnter: false,
|
||||||
|
};
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { getFundWaterAccountAbleChangeList } from '../api/account-change';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组装全量审计查询参数
|
||||||
|
* StorePicker 的 store_id 映射为变动表的 user_id,并固定 user_type=门店,避免与其他主体 ID 撞车
|
||||||
|
*/
|
||||||
|
function buildAuditQueryParams(formValues: Record<string, any> = {}) {
|
||||||
|
const { store_id: storeId, ...rest } = formValues;
|
||||||
|
const params: Record<string, any> = { ...rest };
|
||||||
|
if (storeId) {
|
||||||
|
params.user_id = Number(storeId);
|
||||||
|
params.user_type = 1;
|
||||||
|
}
|
||||||
|
return params;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 资金流水页 · 资金变动全量列表(平台 audit=1) */
|
||||||
|
export const accountChangeGridOptions: VxeGridProps = {
|
||||||
|
columnConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
keyField: 'id',
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ field: 'created_at', title: '变动时间', width: 170 },
|
||||||
|
{
|
||||||
|
field: 'store_name',
|
||||||
|
title: '诊所',
|
||||||
|
minWidth: 140,
|
||||||
|
slots: { default: 'store_name' },
|
||||||
|
},
|
||||||
|
{ field: 'user_type_txt', title: '主体类型', width: 90 },
|
||||||
|
{ field: 'source_type_txt', title: '来源类型', width: 100 },
|
||||||
|
{ field: 'field_name_txt', title: '变更字段', width: 120 },
|
||||||
|
{ field: 'before_amount', title: '变动前', width: 110 },
|
||||||
|
{ field: 'after_amount', title: '变动后', width: 110 },
|
||||||
|
{
|
||||||
|
field: 'change_amount',
|
||||||
|
title: '变动值',
|
||||||
|
width: 110,
|
||||||
|
slots: { default: 'change_amount' },
|
||||||
|
},
|
||||||
|
{ field: 'order_no', title: '订单号', minWidth: 160 },
|
||||||
|
{ field: 'source_table_txt', title: '来源表', width: 140 },
|
||||||
|
{ field: 'remark', title: '备注', minWidth: 180 },
|
||||||
|
{ type: 'html', title: '操作', slots: { default: 'action' }, width: 100 },
|
||||||
|
],
|
||||||
|
keepSource: true,
|
||||||
|
pagerConfig: {},
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({ page }, formValues) => {
|
||||||
|
return await getFundWaterAccountAbleChangeList({
|
||||||
|
page: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
...buildAuditQueryParams(formValues),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
border: false,
|
||||||
|
toolbarConfig: {
|
||||||
|
search: true,
|
||||||
|
refresh: true,
|
||||||
|
print: false,
|
||||||
|
export: false,
|
||||||
|
zoom: true,
|
||||||
|
slots: {
|
||||||
|
buttons: 'toolbar-actions',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showOverflow: false,
|
||||||
|
};
|
||||||
@@ -2,6 +2,10 @@ import type { VbenFormProps } from '#/adapter/form';
|
|||||||
|
|
||||||
import { monthToTodayRangeDayjs } from '#/utils/search-time-range';
|
import { monthToTodayRangeDayjs } from '#/utils/search-time-range';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 资金流水搜索表单
|
||||||
|
* 保留时间范围,并增加诊所 / 订单号筛选,便于按门店或单号快速定位流水
|
||||||
|
*/
|
||||||
export const formOptions: VbenFormProps = {
|
export const formOptions: VbenFormProps = {
|
||||||
// 默认展开
|
// 默认展开
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
@@ -15,6 +19,20 @@ export const formOptions: VbenFormProps = {
|
|||||||
fieldName: 'search_time',
|
fieldName: 'search_time',
|
||||||
label: '时间范围',
|
label: '时间范围',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
component: 'StorePicker',
|
||||||
|
fieldName: 'store_id',
|
||||||
|
label: '诊所',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
fieldName: 'order_no',
|
||||||
|
label: '订单号',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入订单号',
|
||||||
|
allowClear: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// 控制表单是否显示折叠按钮
|
// 控制表单是否显示折叠按钮
|
||||||
showCollapseButton: true,
|
showCollapseButton: true,
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ export const gridOptions: VxeGridProps<RowType> = {
|
|||||||
columns: [
|
columns: [
|
||||||
{ field: 'id', align: 'left', title: 'ID' },
|
{ field: 'id', align: 'left', title: 'ID' },
|
||||||
{ field: 'store.name', title: '诊所' },
|
{ field: 'store.name', title: '诊所' },
|
||||||
|
// 订单号可点开详情,配合搜索筛选项提升「按单号查流水」体验
|
||||||
|
{
|
||||||
|
field: 'order_no',
|
||||||
|
title: '订单号',
|
||||||
|
minWidth: 160,
|
||||||
|
slots: { default: 'order_no' },
|
||||||
|
},
|
||||||
{ field: 'doctor_info.name', title: '医生' },
|
{ field: 'doctor_info.name', title: '医生' },
|
||||||
{ field: 'price', title: '金额' },
|
{ field: 'price', title: '金额' },
|
||||||
{ field: 'order_type', title: '订单类型', slots: { default: 'order-type' } },
|
{ field: 'order_type', title: '订单类型', slots: { default: 'order-type' } },
|
||||||
|
|||||||
@@ -1,20 +1,40 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { ref } from 'vue';
|
/**
|
||||||
|
* 资金流水记录页
|
||||||
|
* Tab1:支付/提现类资金流水(xk_fund_water)
|
||||||
|
* Tab2:账户资金变动全量审计(audit=1 + 诊所筛选),与提现页「只看本主体」区分
|
||||||
|
*/
|
||||||
|
import { nextTick, ref, watch } from 'vue';
|
||||||
|
|
||||||
import { Page, useVbenModal } from '@vben/common-ui';
|
import { Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
import { IconifyIcon as VbenIcon } from '@vben/icons';
|
||||||
|
|
||||||
import { Button, Tag } from 'ant-design-vue';
|
import { Button, Tabs, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
import { useFreshSearchTime } from '#/composables/use-fresh-search-time';
|
|
||||||
import { TableAction } from '#/components/table-action';
|
import { TableAction } from '#/components/table-action';
|
||||||
|
import { useFreshSearchTime } from '#/composables/use-fresh-search-time';
|
||||||
import { monthToTodayRangeDayjs } from '#/utils/search-time-range';
|
import { monthToTodayRangeDayjs } from '#/utils/search-time-range';
|
||||||
import DetailModal from '#/views/business/order/product-order/components/detail.vue';
|
import DetailModal from '#/views/business/order/product-order/components/detail.vue';
|
||||||
import StatisticsReconciliation from '#/views/finance/reconciliation/components/statistics.vue';
|
import StatisticsReconciliation from '#/views/finance/reconciliation/components/statistics.vue';
|
||||||
|
import ApplyOrdersModal from '#/views/finance/withdrawal/components/apply-orders-modal.vue';
|
||||||
|
import { useWithdrawOrderDetailModals } from '#/views/finance/withdrawal/utils/use-withdraw-order-detail';
|
||||||
|
|
||||||
|
import { accountChangeFormOptions } from './config/account-change-search';
|
||||||
|
import { accountChangeGridOptions } from './config/account-change-table';
|
||||||
import { formOptions } from './config/search';
|
import { formOptions } from './config/search';
|
||||||
import { gridOptions } from './config/table';
|
import { gridOptions } from './config/table';
|
||||||
|
|
||||||
|
defineOptions({ name: 'FinanceFundWater' });
|
||||||
|
|
||||||
|
/** 记住 Tab,刷新后仍停在上次查看的列表 */
|
||||||
|
const TAB_STORAGE_KEY = 'finance_fund_water_tab';
|
||||||
|
const TAB_FUND = 'fundWater';
|
||||||
|
const TAB_ABLE = 'accountChange';
|
||||||
|
const allowedTabs = new Set([TAB_FUND, TAB_ABLE]);
|
||||||
|
const storedTab = localStorage.getItem(TAB_STORAGE_KEY) || TAB_FUND;
|
||||||
|
const activeTab = ref(allowedTabs.has(storedTab) ? storedTab : TAB_FUND);
|
||||||
|
|
||||||
const freshSearchTimeBridge = {
|
const freshSearchTimeBridge = {
|
||||||
resetSearchTime: async () => {},
|
resetSearchTime: async () => {},
|
||||||
markSearchTimeTouched: () => {},
|
markSearchTimeTouched: () => {},
|
||||||
@@ -41,37 +61,110 @@ const freshSearchTime = useFreshSearchTime({
|
|||||||
onRefresh: () => gridApi.reload(),
|
onRefresh: () => gridApi.reload(),
|
||||||
});
|
});
|
||||||
freshSearchTimeBridge.resetSearchTime = freshSearchTime.resetSearchTime;
|
freshSearchTimeBridge.resetSearchTime = freshSearchTime.resetSearchTime;
|
||||||
freshSearchTimeBridge.markSearchTimeTouched = freshSearchTime.markSearchTimeTouched;
|
freshSearchTimeBridge.markSearchTimeTouched =
|
||||||
|
freshSearchTime.markSearchTimeTouched;
|
||||||
|
|
||||||
|
/** 资金变动列表:平台全量(audit=1),可按诊所筛选 */
|
||||||
|
const [AccountChangeGrid, accountChangeGridApi] = useVbenVxeGrid({
|
||||||
|
formOptions: accountChangeFormOptions,
|
||||||
|
gridOptions: accountChangeGridOptions,
|
||||||
|
});
|
||||||
|
|
||||||
const statistics = ref();
|
const statistics = ref();
|
||||||
|
const applyOrdersModalRef = ref<InstanceType<typeof ApplyOrdersModal>>();
|
||||||
|
|
||||||
const [OrderDetailModal, OrderDetailModalApi] = useVbenModal({
|
const { OrderDetailModal, RegisterDetailModal, TraceDrawer, openOrderDetail } =
|
||||||
|
useWithdrawOrderDetailModals();
|
||||||
|
|
||||||
|
/** 资金流水行:仅商品订单详情(流水表本身偏产品单) */
|
||||||
|
const [FundOrderDetailModal, fundOrderDetailModalApi] = useVbenModal({
|
||||||
connectedComponent: DetailModal,
|
connectedComponent: DetailModal,
|
||||||
});
|
});
|
||||||
|
|
||||||
const infoModal = (id) => {
|
const infoModal = (id: number) => {
|
||||||
OrderDetailModalApi.setData({
|
if (!id) return;
|
||||||
// 表单值
|
fundOrderDetailModalApi.setData({ id });
|
||||||
id,
|
fundOrderDetailModalApi.open();
|
||||||
});
|
|
||||||
OrderDetailModalApi.open();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 提现类变动可能挂多笔订单,先列表再下钻 */
|
||||||
|
function showApplyOrders(row: Record<string, any>) {
|
||||||
|
const orders = row.orders || [];
|
||||||
|
if (orders.length === 1) {
|
||||||
|
openOrderDetail(orders[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
applyOrdersModalRef.value?.open({
|
||||||
|
title: row.order_no ? `关联订单 · ${row.order_no}` : '关联订单',
|
||||||
|
orders,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 资金变动「订单详情」:单笔直开,提现多笔走关联列表 */
|
||||||
|
function onAccountChangeOrder(row: Record<string, any>) {
|
||||||
|
const orders = row.orders || [];
|
||||||
|
if (
|
||||||
|
orders.length > 1 ||
|
||||||
|
(row.source_type === 'withdraw' && orders.length > 0)
|
||||||
|
) {
|
||||||
|
showApplyOrders(row);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openOrderDetail(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切换 Tab 并持久化;切回后重算表格高度,避免缩成一行 */
|
||||||
|
function handleTabChange(key: string | number) {
|
||||||
|
const next = String(key);
|
||||||
|
if (!allowedTabs.has(next)) return;
|
||||||
|
activeTab.value = next;
|
||||||
|
localStorage.setItem(TAB_STORAGE_KEY, next);
|
||||||
|
nextTick(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
gridApi.grid?.recalculate?.();
|
||||||
|
accountChangeGridApi.grid?.recalculate?.();
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(activeTab, (key) => {
|
||||||
|
if (key === TAB_ABLE) {
|
||||||
|
nextTick(() => accountChangeGridApi.grid?.recalculate?.());
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Page auto-content-height title="资金流水记录">
|
<Page auto-content-height title="资金流水记录">
|
||||||
|
<FundOrderDetailModal />
|
||||||
<OrderDetailModal />
|
<OrderDetailModal />
|
||||||
|
<RegisterDetailModal />
|
||||||
|
<TraceDrawer />
|
||||||
|
<ApplyOrdersModal ref="applyOrdersModalRef" @view-order="openOrderDetail" />
|
||||||
<StatisticsReconciliation v-if="statistics" :statistics="statistics" />
|
<StatisticsReconciliation v-if="statistics" :statistics="statistics" />
|
||||||
|
<Tabs
|
||||||
|
:active-key="activeTab"
|
||||||
|
class="fund-water-tabs"
|
||||||
|
type="card"
|
||||||
|
:destroy-inactive-tab-pane="false"
|
||||||
|
@change="handleTabChange"
|
||||||
|
>
|
||||||
|
<Tabs.TabPane :key="TAB_FUND" tab="资金流水">
|
||||||
<Grid>
|
<Grid>
|
||||||
<template #toolbar-actions>
|
<template #toolbar-actions>
|
||||||
<TableAction :actions="[]" :drop-down-actions="[]">
|
<TableAction :actions="[]" :drop-down-actions="[]" />
|
||||||
<template #more>
|
|
||||||
<Button style="margin-left: 16px">
|
|
||||||
批量操作
|
|
||||||
<Icon icon="ant-design:down-outlined" />
|
|
||||||
</Button>
|
|
||||||
</template>
|
</template>
|
||||||
</TableAction>
|
<!-- 订单号链接:与操作列「详情」同一套 infoModal -->
|
||||||
|
<template #order_no="{ row }">
|
||||||
|
<Button
|
||||||
|
v-if="row.order_no"
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
@click="infoModal(row.order_id)"
|
||||||
|
>
|
||||||
|
{{ row.order_no }}
|
||||||
|
</Button>
|
||||||
|
<span v-else>-</span>
|
||||||
</template>
|
</template>
|
||||||
<template #order-type="{ row }">
|
<template #order-type="{ row }">
|
||||||
<div class="mt-3">
|
<div class="mt-3">
|
||||||
@@ -97,42 +190,97 @@ const infoModal = (id) => {
|
|||||||
type: 'link',
|
type: 'link',
|
||||||
icon: 'marketeq:eye',
|
icon: 'marketeq:eye',
|
||||||
size: 'small',
|
size: 'small',
|
||||||
// auth: ['order', 'sys:role:detail'],
|
|
||||||
onClick: infoModal.bind(null, row.order_id),
|
onClick: infoModal.bind(null, row.order_id),
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
<Tabs.TabPane :key="TAB_ABLE" tab="资金变动记录">
|
||||||
|
<!-- 全量 able_change:含分账结算、历史补入、退款、提现等;非本主体视角 -->
|
||||||
|
<AccountChangeGrid>
|
||||||
|
<template #toolbar-actions></template>
|
||||||
|
<template #toolbar-tools></template>
|
||||||
|
<template #store_name="{ row }">
|
||||||
|
<span v-if="Number(row.user_type) === 1">
|
||||||
|
{{ row.store_name || `门店#${row.user_id}` }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="cell-muted">
|
||||||
|
{{ row.user_type_txt || '-' }}
|
||||||
|
<template v-if="row.user_id"> · #{{ row.user_id }}</template>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
<template #change_amount="{ row }">
|
||||||
|
<span
|
||||||
|
v-if="Number(row.change_amount) > 0"
|
||||||
|
class="amount-cell is-up"
|
||||||
|
>
|
||||||
|
<VbenIcon icon="lucide:trending-up" class="amount-icon" />
|
||||||
|
+{{ Number(row.change_amount).toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-else-if="Number(row.change_amount) < 0"
|
||||||
|
class="amount-cell is-down"
|
||||||
|
>
|
||||||
|
<VbenIcon icon="lucide:trending-down" class="amount-icon" />
|
||||||
|
{{ Number(row.change_amount).toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="cell-muted">0.00</span>
|
||||||
|
</template>
|
||||||
|
<template #action="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '订单详情',
|
||||||
|
type: 'link',
|
||||||
|
size: 'small',
|
||||||
|
ifShow:
|
||||||
|
!!row.order_id || (row.orders && row.orders.length > 0),
|
||||||
|
onClick: onAccountChangeOrder.bind(null, row),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:drop-down-actions="[]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</AccountChangeGrid>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
</Tabs>
|
||||||
</Page>
|
</Page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.custom-list {
|
.fund-water-tabs {
|
||||||
list-style-type: none;
|
:deep(.ant-tabs-content),
|
||||||
padding-left: 0;
|
:deep(.ant-tabs-tabpane) {
|
||||||
|
min-height: 480px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-list-item {
|
/* 资金变动金额:涨绿跌红走主题变量,适配暗色 */
|
||||||
background-color: rgba(64, 158, 255, 0.04);
|
.amount-cell {
|
||||||
border-radius: 4px;
|
display: inline-flex;
|
||||||
margin-bottom: 8px;
|
align-items: center;
|
||||||
padding: 8px 12px;
|
gap: 4px;
|
||||||
font-size: 14px;
|
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
transition: background-color 0.3s;
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
|
||||||
&:hover {
|
&.is-up {
|
||||||
background-color: rgba(64, 158, 255, 0.1);
|
color: hsl(var(--success));
|
||||||
}
|
}
|
||||||
|
|
||||||
&::before {
|
&.is-down {
|
||||||
content: '';
|
color: hsl(var(--destructive));
|
||||||
display: inline-block;
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
background-color: #409eff;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 8px;
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.amount-icon {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cell-muted {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -14,13 +14,16 @@ import { clearPublicAccountLock } from '#/utils/publicAccountLock';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getPublicAccountDailyBillInfo,
|
getPublicAccountDailyBillInfo,
|
||||||
|
getPublicAccountDailyMergeDetail,
|
||||||
uploadPublicAccountDailyBill,
|
uploadPublicAccountDailyBill,
|
||||||
} from '../api';
|
} from '../api';
|
||||||
import {
|
import {
|
||||||
buildDailyUploadFormData,
|
buildDailyUploadFormData,
|
||||||
|
collectDailyPaymentVouchers,
|
||||||
formatDailyPlatformAmount,
|
formatDailyPlatformAmount,
|
||||||
normalizeDailyVoucherUrls,
|
normalizeDailyVoucherUrls,
|
||||||
resolveDailyBillDateTxt,
|
resolveDailyBillDateTxt,
|
||||||
|
resolveDailyMergeId,
|
||||||
resolveDailyPlatformAmount,
|
resolveDailyPlatformAmount,
|
||||||
resolveDailyStoreName,
|
resolveDailyStoreName,
|
||||||
resolveDailyUploadId,
|
resolveDailyUploadId,
|
||||||
@@ -100,6 +103,26 @@ async function load(data?: DailyUploadFormData) {
|
|||||||
|
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
|
const mergeId = resolveDailyMergeId(normalized);
|
||||||
|
// 周期行必须打 merge 详情拿合计金额,单日 daily-detail 可能是 0
|
||||||
|
if (mergeId > 0) {
|
||||||
|
const merge = await getPublicAccountDailyMergeDetail(mergeId);
|
||||||
|
existVouchers.value = collectDailyPaymentVouchers(merge?.items || []);
|
||||||
|
storeName.value =
|
||||||
|
resolveDailyStoreName(merge?.items?.[0]) || storeName.value;
|
||||||
|
if (!presetAmount || presetAmount === '0.00') {
|
||||||
|
platformAmount.value = resolveDailyPlatformAmount({
|
||||||
|
...merge,
|
||||||
|
is_period: 1,
|
||||||
|
platform_amount_total: merge?.platform_amount_total,
|
||||||
|
platform_amount: merge?.platform_amount_total,
|
||||||
|
items: merge?.items,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!presetDate) {
|
||||||
|
billDateTxt.value = resolveDailyBillDateTxt(merge);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
const detail = await getPublicAccountDailyBillInfo(dailyId.value);
|
const detail = await getPublicAccountDailyBillInfo(dailyId.value);
|
||||||
existVouchers.value = detail?.vouchers || detail?.payment_vouchers || [];
|
existVouchers.value = detail?.vouchers || detail?.payment_vouchers || [];
|
||||||
storeName.value = resolveDailyStoreName(detail) || storeName.value;
|
storeName.value = resolveDailyStoreName(detail) || storeName.value;
|
||||||
@@ -109,6 +132,7 @@ async function load(data?: DailyUploadFormData) {
|
|||||||
if (!presetDate) {
|
if (!presetDate) {
|
||||||
billDateTxt.value = resolveDailyBillDateTxt(detail);
|
billDateTxt.value = resolveDailyBillDateTxt(detail);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,20 @@ import DailyUploadForm from './daily-upload-form.vue';
|
|||||||
|
|
||||||
const formRef = ref<InstanceType<typeof DailyUploadForm>>();
|
const formRef = ref<InstanceType<typeof DailyUploadForm>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* connectedComponent 首开时子组件可能尚未挂上 ref,一次 nextTick 不够
|
||||||
|
* 多等几帧再 load,避免应付款停在 0.00 且不打详情接口
|
||||||
|
*/
|
||||||
|
async function waitFormReady(maxTries = 12) {
|
||||||
|
for (let i = 0; i < maxTries; i++) {
|
||||||
|
if (formRef.value?.load) {
|
||||||
|
return formRef.value;
|
||||||
|
}
|
||||||
|
await nextTick();
|
||||||
|
}
|
||||||
|
return formRef.value;
|
||||||
|
}
|
||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
fullscreenButton: false,
|
fullscreenButton: false,
|
||||||
draggable: true,
|
draggable: true,
|
||||||
@@ -32,10 +46,9 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
},
|
},
|
||||||
async onOpenChange(isOpen: boolean) {
|
async onOpenChange(isOpen: boolean) {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
await nextTick();
|
const form = await waitFormReady();
|
||||||
await formRef.value?.load(
|
const row = modalApi.getData<Record<string, any>>() || {};
|
||||||
buildDailyUploadFormData(modalApi.getData<Record<string, any>>() || {}),
|
await form?.load(buildDailyUploadFormData(row));
|
||||||
);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -12,7 +12,13 @@ export interface DailyUploadRowLike {
|
|||||||
platform_amount_total?: number | string;
|
platform_amount_total?: number | string;
|
||||||
bill_date_txt?: string;
|
bill_date_txt?: string;
|
||||||
bill_date_range_txt?: string;
|
bill_date_range_txt?: string;
|
||||||
items?: Array<{ id?: number | string; platform_amount?: number | string }>;
|
items?: Array<{
|
||||||
|
id?: number | string;
|
||||||
|
platform_amount?: number | string;
|
||||||
|
store_name?: string;
|
||||||
|
vouchers?: Array<{ file_url?: string }>;
|
||||||
|
payment_vouchers?: Array<{ file_url?: string }>;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 上传接口需要的真实日账单 id(周期行 id 常为 0) */
|
/** 上传接口需要的真实日账单 id(周期行 id 常为 0) */
|
||||||
@@ -80,16 +86,60 @@ export function resolveDailyBillDateTxt(
|
|||||||
return String(row.bill_date_range_txt || row.bill_date_txt || '').trim();
|
return String(row.bill_date_range_txt || row.bill_date_txt || '').trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 周期账单主键:列表行 id 常为 0,业务 id 在 merge_id */
|
||||||
|
export function resolveDailyMergeId(row?: DailyUploadRowLike | null): number {
|
||||||
|
if (!row) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const mergeId = Number(row.merge_id || 0);
|
||||||
|
if (mergeId > 0) {
|
||||||
|
return mergeId;
|
||||||
|
}
|
||||||
|
if (Number(row.is_period || 0) === 1) {
|
||||||
|
return Number(row.id || 0);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 汇总组内付款凭证(去重),周期详情走 merge 接口时用 */
|
||||||
|
export function collectDailyPaymentVouchers(
|
||||||
|
days?: Array<{
|
||||||
|
vouchers?: Array<{ file_url?: string }>;
|
||||||
|
payment_vouchers?: Array<{ file_url?: string }>;
|
||||||
|
}>,
|
||||||
|
): { file_url: string }[] {
|
||||||
|
const seen: Record<string, boolean> = {};
|
||||||
|
const list: { file_url: string }[] = [];
|
||||||
|
for (const day of days || []) {
|
||||||
|
const vouchers = day.payment_vouchers || day.vouchers || [];
|
||||||
|
for (const v of vouchers) {
|
||||||
|
const url = String(v?.file_url || '').trim();
|
||||||
|
if (!url || seen[url]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen[url] = true;
|
||||||
|
list.push({ file_url: url });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
/** 打开上传弹窗/表单时灌入的标准数据结构 */
|
/** 打开上传弹窗/表单时灌入的标准数据结构 */
|
||||||
export function buildDailyUploadFormData(
|
export function buildDailyUploadFormData(
|
||||||
row: DailyUploadRowLike,
|
row: DailyUploadRowLike,
|
||||||
extras?: Record<string, any>,
|
extras?: Record<string, any>,
|
||||||
) {
|
) {
|
||||||
const uploadId = resolveDailyUploadId(row);
|
const uploadId = resolveDailyUploadId(row);
|
||||||
|
const mergeId = resolveDailyMergeId(row);
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
...extras,
|
...extras,
|
||||||
id: uploadId,
|
id: uploadId,
|
||||||
|
merge_id: mergeId > 0 ? mergeId : Number(row.merge_id || 0),
|
||||||
|
is_period:
|
||||||
|
Number(row.is_period || 0) === 1 || mergeId > 0
|
||||||
|
? 1
|
||||||
|
: Number(row.is_period || 0),
|
||||||
store_name: resolveDailyStoreName(row),
|
store_name: resolveDailyStoreName(row),
|
||||||
platform_amount: resolveDailyPlatformAmount(row),
|
platform_amount: resolveDailyPlatformAmount(row),
|
||||||
bill_date_txt: resolveDailyBillDateTxt(row),
|
bill_date_txt: resolveDailyBillDateTxt(row),
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
/** 诊疗费余额异常补入 API(xk-api admin) */
|
||||||
|
const prefix = 'treatment-fee-cash-backfill/';
|
||||||
|
|
||||||
|
/** 异常候选列表 GET /treatment-fee-cash-backfill/list */
|
||||||
|
export async function getTreatmentFeeCashBackfillList(data: any) {
|
||||||
|
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 详情:订单 / 分账缺口 / 分账明细 / 新&旧资金流水
|
||||||
|
* GET /treatment-fee-cash-backfill/detail
|
||||||
|
*/
|
||||||
|
export async function getTreatmentFeeCashBackfillDetail(data: {
|
||||||
|
order_id: number;
|
||||||
|
store_id: number;
|
||||||
|
}) {
|
||||||
|
return requestClient.get<any>(`${prefix}detail`, { params: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 单笔补入 POST /treatment-fee-cash-backfill/fix */
|
||||||
|
export async function fixTreatmentFeeCashBackfill(data: {
|
||||||
|
order_id: number;
|
||||||
|
store_id: number;
|
||||||
|
}) {
|
||||||
|
return requestClient.post<any>(`${prefix}fix`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量补入 POST /treatment-fee-cash-backfill/batch-fix */
|
||||||
|
export async function batchFixTreatmentFeeCashBackfill(data: {
|
||||||
|
items: Array<{ order_id: number; store_id: number }>;
|
||||||
|
}) {
|
||||||
|
return requestClient.post<any>(`${prefix}batch-fix`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 忽略缺口 POST /treatment-fee-cash-backfill/ignore */
|
||||||
|
export async function ignoreTreatmentFeeCashBackfill(data: {
|
||||||
|
order_id: number;
|
||||||
|
store_id: number;
|
||||||
|
remark?: string;
|
||||||
|
}) {
|
||||||
|
return requestClient.post<any>(`${prefix}ignore`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取消忽略 POST /treatment-fee-cash-backfill/cancel-ignore */
|
||||||
|
export async function cancelIgnoreTreatmentFeeCashBackfill(data: {
|
||||||
|
order_id: number;
|
||||||
|
store_id: number;
|
||||||
|
}) {
|
||||||
|
return requestClient.post<any>(`${prefix}cancel-ignore`, data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,670 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 诊疗费余额异常补入 - 详情弹窗
|
||||||
|
* 核对订单费用、全量分账汇总、分账明细,以及新/旧资金流水(分 Tab)
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Descriptions, Select, Table, Tabs, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { getTreatmentFeeCashBackfillDetail } from '../api/index';
|
||||||
|
|
||||||
|
const activeTab = ref('order');
|
||||||
|
/** 分账明细费用类型筛选:空串=全部,其余为 fee_type 数值字符串 */
|
||||||
|
const ledgerFeeFilter = ref('');
|
||||||
|
|
||||||
|
const detail = ref<Record<string, any>>({
|
||||||
|
order: {},
|
||||||
|
gap_info: {},
|
||||||
|
ledger_summary: {
|
||||||
|
by_fee: [],
|
||||||
|
by_party: [],
|
||||||
|
order_vs_ledger: [],
|
||||||
|
sum_money: 0,
|
||||||
|
},
|
||||||
|
ledgers: [],
|
||||||
|
ledger_logs: [],
|
||||||
|
able_changes: [],
|
||||||
|
new_able_changes: [],
|
||||||
|
legacy_ledger_logs: [],
|
||||||
|
is_ignored: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 分账明细筛选选项:全部 + 药品/诊疗/加工/代煎/快递 */
|
||||||
|
const ledgerFeeFilterOptions = [
|
||||||
|
{ label: '全部', value: '' },
|
||||||
|
{ label: '药品(1)', value: '1' },
|
||||||
|
{ label: '诊疗(6)', value: '6' },
|
||||||
|
{ label: '加工(5)', value: '5' },
|
||||||
|
{ label: '代煎(4)', value: '4' },
|
||||||
|
{ label: '快递(3)', value: '3' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 新资金流水(可提现审计) */
|
||||||
|
const newAbleChanges = computed(() => {
|
||||||
|
if (Array.isArray(detail.value.new_able_changes)) {
|
||||||
|
return detail.value.new_able_changes;
|
||||||
|
}
|
||||||
|
return (detail.value.able_changes || []).filter(
|
||||||
|
(row: Record<string, any>) => row.data_source !== 'ledger_log',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 旧资金流水(分账流水回落):优先专用字段,空则从 able_changes 兜底并补齐 amount */
|
||||||
|
const legacyLedgerLogs = computed(() => {
|
||||||
|
const dedicated = Array.isArray(detail.value.legacy_ledger_logs)
|
||||||
|
? detail.value.legacy_ledger_logs
|
||||||
|
: [];
|
||||||
|
if (dedicated.length > 0) {
|
||||||
|
return dedicated;
|
||||||
|
}
|
||||||
|
return (detail.value.able_changes || [])
|
||||||
|
.filter((row: Record<string, any>) => row.data_source === 'ledger_log')
|
||||||
|
.map((row: Record<string, any>) => {
|
||||||
|
const change = Number(row.change_amount || 0);
|
||||||
|
const amount = Math.abs(
|
||||||
|
Number(
|
||||||
|
row.amount ||
|
||||||
|
row.change_amount ||
|
||||||
|
row.after_amount ||
|
||||||
|
row.before_amount ||
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
amount,
|
||||||
|
content: row.content || row.remark || '',
|
||||||
|
type_txt:
|
||||||
|
row.type_txt || (change >= 0 ? '增加' : '减少'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅有旧流水、无新审计时提示历史口径未入可提现
|
||||||
|
*/
|
||||||
|
const onlyLegacyAbleChanges = computed(() => {
|
||||||
|
return (
|
||||||
|
newAbleChanges.value.length === 0 && legacyLedgerLogs.value.length > 0
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单各费用合计与实付是否不一致(差额 > 0.01)
|
||||||
|
* 便于一眼发现订单金额拆分异常
|
||||||
|
*/
|
||||||
|
const orderAmountMismatch = computed(() => {
|
||||||
|
const o = detail.value.order || {};
|
||||||
|
const sum =
|
||||||
|
Number(o.items_price || 0) +
|
||||||
|
Number(o.treatement_price || 0) +
|
||||||
|
Number(o.process_price || 0) +
|
||||||
|
Number(o.decoct_price || 0) +
|
||||||
|
Number(o.trans_expenses || 0);
|
||||||
|
const total = Number(o.total_pay_price || 0);
|
||||||
|
return Math.abs(sum - total) > 0.01;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 按费用类型过滤后的分账行 */
|
||||||
|
const filteredLedgers = computed(() => {
|
||||||
|
const rows = Array.isArray(detail.value.ledgers) ? detail.value.ledgers : [];
|
||||||
|
if (ledgerFeeFilter.value === '') {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
const feeType = Number(ledgerFeeFilter.value);
|
||||||
|
return rows.filter((row: Record<string, any>) => Number(row.fee_type) === feeType);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 按费用类型过滤后的分账流水 */
|
||||||
|
const filteredLedgerLogs = computed(() => {
|
||||||
|
const rows = Array.isArray(detail.value.ledger_logs)
|
||||||
|
? detail.value.ledger_logs
|
||||||
|
: [];
|
||||||
|
if (ledgerFeeFilter.value === '') {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
const feeType = Number(ledgerFeeFilter.value);
|
||||||
|
return rows.filter((row: Record<string, any>) => Number(row.fee_type) === feeType);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 当前筛选条件下有效分账金额合计(非取消口径由后端 money 原值展示,此处仅求和便于核对) */
|
||||||
|
const filteredLedgerMoneySum = computed(() => {
|
||||||
|
return filteredLedgers.value.reduce(
|
||||||
|
(acc: number, row: Record<string, any>) => acc + Number(row.money || 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 按费用类型汇总表列 */
|
||||||
|
const byFeeColumns = [
|
||||||
|
{ title: '费用类型', dataIndex: 'fee_type_txt', width: 140 },
|
||||||
|
{ title: '门店', dataIndex: 'store', width: 100 },
|
||||||
|
{ title: '平台', dataIndex: 'platform', width: 100 },
|
||||||
|
{ title: '供应商', dataIndex: 'supplier', width: 100 },
|
||||||
|
{ title: '配送仓', dataIndex: 'warehouse', width: 100 },
|
||||||
|
{ title: '有效合计', dataIndex: 'sum', width: 100 },
|
||||||
|
{ title: '已取消', dataIndex: 'canceled_sum', width: 100 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 订单金额 vs 分账对照表列 */
|
||||||
|
const orderVsLedgerColumns = [
|
||||||
|
{ title: '费用', dataIndex: 'fee_label', width: 120 },
|
||||||
|
{ title: '订单金额', dataIndex: 'order_amount', width: 120 },
|
||||||
|
{ title: '分账合计', dataIndex: 'ledger_sum', width: 120 },
|
||||||
|
{ title: '差额', dataIndex: 'delta', width: 120 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 按受益方汇总表列(可选小表) */
|
||||||
|
const byPartyColumns = [
|
||||||
|
{ title: '受益方', dataIndex: 'user_type_txt', width: 120 },
|
||||||
|
{ title: '有效合计', dataIndex: 'sum', width: 120 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 分账明细表列 */
|
||||||
|
const ledgerColumns = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||||
|
{ title: '费用类型', dataIndex: 'fee_type_txt', width: 100 },
|
||||||
|
{ title: '受益方', dataIndex: 'user_type_txt', width: 90 },
|
||||||
|
{ title: '受益方ID', dataIndex: 'user_id', width: 90 },
|
||||||
|
{ title: '金额', dataIndex: 'money', width: 100 },
|
||||||
|
{ title: '状态', dataIndex: 'status_txt', width: 90 },
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 分账流水表列(xk_ledger_log) */
|
||||||
|
const ledgerLogColumns = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||||
|
{ title: '费用类型', dataIndex: 'fee_type_txt', width: 100 },
|
||||||
|
{ title: '方向', dataIndex: 'type_txt', width: 70 },
|
||||||
|
{ title: '受益方', dataIndex: 'user_type_txt', width: 90 },
|
||||||
|
{ title: '受益方ID', dataIndex: 'user_id', width: 90 },
|
||||||
|
{ title: '金额', dataIndex: 'amount', width: 100 },
|
||||||
|
{ title: '说明', dataIndex: 'content', ellipsis: true },
|
||||||
|
{ title: '创建时间', dataIndex: 'created_at', width: 170 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 新资金流水表列(可提现审计,含余额前后快照) */
|
||||||
|
const newAbleChangeColumns = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', width: 80 },
|
||||||
|
{ title: '主体类型', dataIndex: 'user_type_txt', width: 90 },
|
||||||
|
{ title: '主体ID', dataIndex: 'user_id', width: 90 },
|
||||||
|
{ title: '字段', dataIndex: 'field_name', width: 110 },
|
||||||
|
{ title: '变动前', dataIndex: 'before_amount', width: 110 },
|
||||||
|
{ title: '变动后', dataIndex: 'after_amount', width: 110 },
|
||||||
|
{ title: '变动额', dataIndex: 'change_amount', width: 110 },
|
||||||
|
{ title: '来源', dataIndex: 'source_type_txt', width: 110 },
|
||||||
|
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||||
|
{ title: '时间', dataIndex: 'created_at', width: 170 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 旧资金流水表列(分账流水回落,对齐 ledger_log 语义) */
|
||||||
|
const legacyFundWaterColumns = [
|
||||||
|
{ title: 'ID', dataIndex: 'id', key: 'id', width: 80 },
|
||||||
|
{ title: '方向', dataIndex: 'type_txt', key: 'type_txt', width: 70 },
|
||||||
|
{ title: '受益方', dataIndex: 'user_type_txt', key: 'user_type_txt', width: 90 },
|
||||||
|
{ title: '受益方ID', dataIndex: 'user_id', key: 'user_id', width: 90 },
|
||||||
|
{ title: '费用类型', dataIndex: 'fee_type_txt', key: 'fee_type_txt', width: 100 },
|
||||||
|
{ title: '金额', dataIndex: 'amount', key: 'amount', width: 110 },
|
||||||
|
{ title: '来源', dataIndex: 'source_type_txt', key: 'source_type_txt', width: 90 },
|
||||||
|
{ title: '说明', dataIndex: 'content', key: 'content', ellipsis: true },
|
||||||
|
{ title: '时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧流水金额展示:兼容 amount / change_amount / after_amount
|
||||||
|
*/
|
||||||
|
function formatLegacyAmount(record: Record<string, any>) {
|
||||||
|
const n = Number(
|
||||||
|
record?.amount ??
|
||||||
|
record?.change_amount ??
|
||||||
|
record?.after_amount ??
|
||||||
|
record?.before_amount ??
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
return Number.isFinite(n) ? n.toFixed(2) : '0.00';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新审计行键
|
||||||
|
*/
|
||||||
|
function newAbleChangeRowKey(record: Record<string, any>) {
|
||||||
|
return `able_${record.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 旧流水平键(可能与审计 id 数字相同,前缀区分)
|
||||||
|
*/
|
||||||
|
function legacyFundWaterRowKey(record: Record<string, any>) {
|
||||||
|
return `legacy_${record.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 订单 vs 分账差额行样式:|delta|>0.01 时高亮,便于核对异常
|
||||||
|
*/
|
||||||
|
function orderVsLedgerRowClass(record: Record<string, any>) {
|
||||||
|
return Math.abs(Number(record.delta || 0)) > 0.01 ? 'row-mismatch' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
title: '诊疗费补入详情',
|
||||||
|
class: 'w-[min(960px,96vw)]',
|
||||||
|
draggable: true,
|
||||||
|
showConfirmButton: false,
|
||||||
|
cancelText: '关闭',
|
||||||
|
onCancel() {
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
activeTab.value = 'order';
|
||||||
|
ledgerFeeFilter.value = '';
|
||||||
|
detail.value = {
|
||||||
|
order: {},
|
||||||
|
gap_info: {},
|
||||||
|
ledger_summary: {
|
||||||
|
by_fee: [],
|
||||||
|
by_party: [],
|
||||||
|
order_vs_ledger: [],
|
||||||
|
sum_money: 0,
|
||||||
|
},
|
||||||
|
ledgers: [],
|
||||||
|
ledger_logs: [],
|
||||||
|
able_changes: [],
|
||||||
|
new_able_changes: [],
|
||||||
|
legacy_ledger_logs: [],
|
||||||
|
is_ignored: 0,
|
||||||
|
};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = modalApi.getData<{ order_id?: number; store_id?: number }>() || {};
|
||||||
|
const orderId = Number(data.order_id || 0);
|
||||||
|
const storeId = Number(data.store_id || 0);
|
||||||
|
if (orderId <= 0 || storeId <= 0) {
|
||||||
|
message.warning('缺少订单或门店参数');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadDetail(orderId, storeId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拉取详情并填充 Tabs(含 ledger_summary、新/旧资金流水)
|
||||||
|
*/
|
||||||
|
async function loadDetail(orderId: number, storeId: number) {
|
||||||
|
modalApi.setState({ loading: true });
|
||||||
|
try {
|
||||||
|
const res = await getTreatmentFeeCashBackfillDetail({
|
||||||
|
order_id: orderId,
|
||||||
|
store_id: storeId,
|
||||||
|
});
|
||||||
|
const summary = res?.ledger_summary || {};
|
||||||
|
detail.value = {
|
||||||
|
order: res?.order || {},
|
||||||
|
gap_info: res?.gap_info || {},
|
||||||
|
ledger_summary: {
|
||||||
|
by_fee: Array.isArray(summary.by_fee) ? summary.by_fee : [],
|
||||||
|
by_party: Array.isArray(summary.by_party) ? summary.by_party : [],
|
||||||
|
order_vs_ledger: Array.isArray(summary.order_vs_ledger)
|
||||||
|
? summary.order_vs_ledger
|
||||||
|
: [],
|
||||||
|
sum_money: Number(summary.sum_money || 0),
|
||||||
|
},
|
||||||
|
ledgers: Array.isArray(res?.ledgers) ? res.ledgers : [],
|
||||||
|
ledger_logs: Array.isArray(res?.ledger_logs) ? res.ledger_logs : [],
|
||||||
|
able_changes: Array.isArray(res?.able_changes) ? res.able_changes : [],
|
||||||
|
new_able_changes: Array.isArray(res?.new_able_changes)
|
||||||
|
? res.new_able_changes
|
||||||
|
: [],
|
||||||
|
legacy_ledger_logs: Array.isArray(res?.legacy_ledger_logs)
|
||||||
|
? res.legacy_ledger_logs
|
||||||
|
: [],
|
||||||
|
is_ignored: Number(res?.is_ignored || 0),
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
modalApi.setState({ loading: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal>
|
||||||
|
<div class="backfill-detail">
|
||||||
|
<Tabs v-model:active-key="activeTab">
|
||||||
|
<Tabs.TabPane key="order" tab="订单信息">
|
||||||
|
<p class="sub-title first-sub">基本信息</p>
|
||||||
|
<Descriptions bordered size="small" :column="2">
|
||||||
|
<Descriptions.Item label="订单ID">
|
||||||
|
{{ detail.order.id || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="订单号">
|
||||||
|
{{ detail.order.order_no || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="订单诊所ID">
|
||||||
|
{{ detail.order.store_id || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="订单诊所名称">
|
||||||
|
{{ detail.order.store_name || detail.order.clinic_name || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="分账受益门店ID">
|
||||||
|
{{ detail.order.ledger_store_id || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="分账受益诊所">
|
||||||
|
{{ detail.order.ledger_store_name || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<p class="sub-title">金额明细</p>
|
||||||
|
<Descriptions bordered size="small" :column="2">
|
||||||
|
<Descriptions.Item label="药品金额(items_price)">
|
||||||
|
{{ detail.order.items_price ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="市场价(market_price)">
|
||||||
|
{{ detail.order.market_price ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="诊疗费">
|
||||||
|
{{ detail.order.treatement_price ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="加工费">
|
||||||
|
{{ detail.order.process_price ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="代煎费">
|
||||||
|
{{ detail.order.decoct_price ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="运费">
|
||||||
|
{{ detail.order.trans_expenses ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="实付总额">
|
||||||
|
{{ detail.order.total_pay_price ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="是否代煎">
|
||||||
|
{{ Number(detail.order.is_decoct) === 1 ? '是' : '否' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<p v-if="orderAmountMismatch" class="hint mismatch-hint">
|
||||||
|
订单金额合计与实付不一致
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="sub-title">状态</p>
|
||||||
|
<Descriptions bordered size="small" :column="2">
|
||||||
|
<Descriptions.Item label="是否支付">
|
||||||
|
{{ Number(detail.order.is_pay) === 1 ? '是' : '否' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="是否已分账结算">
|
||||||
|
{{ Number(detail.order.is_settled) === 1 ? '是' : '否' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="订单状态">
|
||||||
|
{{ detail.order.status_txt || (detail.order.status ?? '-') }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="退款状态">
|
||||||
|
{{
|
||||||
|
detail.order.refund_status_txt ||
|
||||||
|
(detail.order.refund_status ?? '-')
|
||||||
|
}}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="支付时间" :span="2">
|
||||||
|
{{ detail.order.pay_time || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
|
||||||
|
<Tabs.TabPane key="gap" tab="分账信息">
|
||||||
|
<Descriptions bordered size="small" :column="2">
|
||||||
|
<Descriptions.Item label="订单ID">
|
||||||
|
{{ detail.gap_info.order_id || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="订单号">
|
||||||
|
{{ detail.gap_info.order_no || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="诊所ID">
|
||||||
|
{{ detail.gap_info.store_id || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="诊所名称">
|
||||||
|
{{ detail.gap_info.store_name || detail.gap_info.clinic_name || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="分账时间">
|
||||||
|
{{ detail.gap_info.settled_at || '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="诊疗费分账合计">
|
||||||
|
{{ detail.gap_info.ledger_sum ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="已入可提现">
|
||||||
|
{{ detail.gap_info.cash_net ?? '-' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="缺口" :span="2">
|
||||||
|
<span class="gap-value">{{ detail.gap_info.gap ?? '-' }}</span>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item v-if="Number(detail.is_ignored) === 1" label="忽略状态" :span="2">
|
||||||
|
已忽略(不能补入)
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
<p class="hint">
|
||||||
|
缺口 = 已结算诊疗费分账合计 − 账户 able
|
||||||
|
变动中结算/补入备注净额;>0 表示可补入。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="sub-title">按费用类型汇总</p>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
:columns="byFeeColumns"
|
||||||
|
:data-source="detail.ledger_summary.by_fee"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="{ x: 740 }"
|
||||||
|
row-key="fee_type"
|
||||||
|
/>
|
||||||
|
<p class="hint">
|
||||||
|
有效分账总额(非取消):{{ detail.ledger_summary.sum_money ?? 0 }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="sub-title">订单金额 vs 分账</p>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
:columns="orderVsLedgerColumns"
|
||||||
|
:data-source="detail.ledger_summary.order_vs_ledger"
|
||||||
|
:pagination="false"
|
||||||
|
:row-class-name="orderVsLedgerRowClass"
|
||||||
|
row-key="fee_key"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="sub-title">按受益方汇总</p>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
:columns="byPartyColumns"
|
||||||
|
:data-source="detail.ledger_summary.by_party"
|
||||||
|
:pagination="false"
|
||||||
|
row-key="user_type"
|
||||||
|
class="by-party-table"
|
||||||
|
/>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
|
||||||
|
<Tabs.TabPane key="ledgers" tab="分账明细">
|
||||||
|
<div class="filter-bar">
|
||||||
|
<span class="filter-label">费用类型</span>
|
||||||
|
<Select
|
||||||
|
v-model:value="ledgerFeeFilter"
|
||||||
|
:options="ledgerFeeFilterOptions"
|
||||||
|
style="width: 160px"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<span class="filter-sum">
|
||||||
|
筛选合计:{{ filteredLedgerMoneySum.toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="sub-title first-sub">分账行(xk_ledger)</p>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
class="ledger-detail-table"
|
||||||
|
:columns="ledgerColumns"
|
||||||
|
:data-source="filteredLedgers"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="{ x: 800 }"
|
||||||
|
row-key="id"
|
||||||
|
/>
|
||||||
|
<p class="sub-title">分账流水(xk_ledger_log)</p>
|
||||||
|
<p class="hint ledger-log-hint">
|
||||||
|
若流水 amount 历史为 0,后端会按同主体同费用的有效分账 money 回填展示。
|
||||||
|
</p>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
class="ledger-detail-table mt-table"
|
||||||
|
:columns="ledgerLogColumns"
|
||||||
|
:data-source="filteredLedgerLogs"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="{ x: 900 }"
|
||||||
|
row-key="id"
|
||||||
|
/>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
|
||||||
|
<Tabs.TabPane key="new_able" tab="新资金流水">
|
||||||
|
<p class="hint first-hint">
|
||||||
|
可提现审计(xk_account_able_change_log),含余额前后快照;缺口计算只认本表结算/补入净额。
|
||||||
|
</p>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
class="fund-water-table"
|
||||||
|
:columns="newAbleChangeColumns"
|
||||||
|
:data-source="newAbleChanges"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="{ x: 1080 }"
|
||||||
|
:row-key="newAbleChangeRowKey"
|
||||||
|
/>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
|
||||||
|
<Tabs.TabPane key="legacy_able" tab="旧资金流水">
|
||||||
|
<p v-if="onlyLegacyAbleChanges" class="hint legacy-hint first-hint">
|
||||||
|
本单无新审计流水,以下为分账结算流水回落(历史口径诊疗费可能未入可提现)
|
||||||
|
</p>
|
||||||
|
<p v-else class="hint first-hint">
|
||||||
|
分账流水回落(xk_ledger_log);若流水金额为 0 会用同主体分账行 money 回填。
|
||||||
|
</p>
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
class="fund-water-table"
|
||||||
|
:columns="legacyFundWaterColumns"
|
||||||
|
:data-source="legacyLedgerLogs"
|
||||||
|
:pagination="false"
|
||||||
|
:scroll="{ x: 1100 }"
|
||||||
|
:row-key="legacyFundWaterRowKey"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'amount'">
|
||||||
|
{{ formatLegacyAmount(record) }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</Table>
|
||||||
|
</Tabs.TabPane>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.backfill-detail {
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint.first-hint {
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mismatch-hint {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid hsl(var(--warning) / 30%);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 0 6px hsl(var(--warning) / 18%);
|
||||||
|
background: hsl(var(--warning) / 0.08);
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.legacy-hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid hsl(var(--warning) / 30%);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 0 6px hsl(var(--warning) / 18%);
|
||||||
|
background: hsl(var(--warning) / 0.08);
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-title {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-title.first-sub {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--warning));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mt-table {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 分账明细表:去掉固定 scroll.y,避免少行时行高被撑乱 */
|
||||||
|
.ledger-detail-table :deep(.ant-table-tbody > tr > td) {
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-log-hint {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 资金流水表:不固定纵向高度,避免少行时行高被 scroll.y 撑乱 */
|
||||||
|
.fund-water-table {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fund-water-table :deep(.ant-table-tbody > tr > td) {
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.by-party-table {
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-sum {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 订单 vs 分账差额行高亮(暗色跟主题变量) */
|
||||||
|
:deep(.row-mismatch) > td {
|
||||||
|
background: hsl(var(--warning) / 0.12) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { VbenFormProps } from '#/adapter/form';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊疗费余额异常补入 - 搜索表单
|
||||||
|
* 按订单号 / 门店 ID / 忽略状态缩小扫描范围
|
||||||
|
*/
|
||||||
|
export const formOptions: VbenFormProps = {
|
||||||
|
collapsed: false,
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '请输入订单号',
|
||||||
|
allowClear: true,
|
||||||
|
},
|
||||||
|
fieldName: 'order_no',
|
||||||
|
label: '订单号',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'InputNumber',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '门店 ID',
|
||||||
|
min: 1,
|
||||||
|
precision: 0,
|
||||||
|
class: 'w-full',
|
||||||
|
style: { width: '100%' },
|
||||||
|
},
|
||||||
|
fieldName: 'store_id',
|
||||||
|
label: '门店ID',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Select',
|
||||||
|
componentProps: {
|
||||||
|
allowClear: false,
|
||||||
|
options: [
|
||||||
|
{ label: '待处理(不含忽略)', value: 'pending' },
|
||||||
|
{ label: '仅已忽略', value: 'ignored' },
|
||||||
|
{ label: '全部(含已忽略)', value: 'all' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
defaultValue: 'pending',
|
||||||
|
fieldName: 'ignore_filter',
|
||||||
|
label: '忽略状态',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCollapseButton: false,
|
||||||
|
submitButtonOptions: {
|
||||||
|
content: '查询',
|
||||||
|
},
|
||||||
|
submitOnEnter: true,
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { getTreatmentFeeCashBackfillList } from '../api/index';
|
||||||
|
|
||||||
|
interface RowType {
|
||||||
|
order_id: number;
|
||||||
|
order_no: string;
|
||||||
|
store_id: number;
|
||||||
|
store_name: string;
|
||||||
|
/** 诊疗费分账结算时间(ledger 更新时间,缺省回落流水) */
|
||||||
|
settled_at: string;
|
||||||
|
ledger_sum: number;
|
||||||
|
cash_net: number;
|
||||||
|
gap: number;
|
||||||
|
/** 订单+门店唯一键,供勾选/跨页识别 */
|
||||||
|
row_key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 诊疗费余额异常补入 - 表格列与远程加载
|
||||||
|
* 勾选用于批量修复;金额列展示分账/已入账/缺口便于核对
|
||||||
|
*/
|
||||||
|
export const gridOptions: VxeGridProps<RowType> = {
|
||||||
|
checkboxConfig: {
|
||||||
|
highlight: true,
|
||||||
|
labelField: '',
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ type: 'checkbox', width: 50 },
|
||||||
|
{ field: 'order_id', title: '订单ID', width: 100 },
|
||||||
|
// 订单号用插槽渲染为链接,点击打开商品订单详情弹窗
|
||||||
|
{ field: 'order_no', title: '订单号', minWidth: 160, slots: { default: 'order_no' } },
|
||||||
|
{ field: 'store_id', title: '门店ID', width: 100 },
|
||||||
|
// 诊所名称可点开 StoreCardModal,与订单号点开商品详情同一交互习惯
|
||||||
|
{ field: 'store_name', title: '诊所名称', minWidth: 160, slots: { default: 'store_name' } },
|
||||||
|
{ field: 'settled_at', title: '分账时间', minWidth: 170 },
|
||||||
|
{ field: 'ledger_sum', title: '诊疗费分账合计', minWidth: 130 },
|
||||||
|
{ field: 'cash_net', title: '已入可提现', minWidth: 120 },
|
||||||
|
{ field: 'gap', title: '缺口', minWidth: 100 },
|
||||||
|
{
|
||||||
|
field: 'is_ignored',
|
||||||
|
title: '忽略',
|
||||||
|
width: 80,
|
||||||
|
slots: { default: 'is_ignored' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
align: 'right',
|
||||||
|
slots: { default: 'action' },
|
||||||
|
// 三按钮横向排列,禁止 showOverflow 裁切导致叠字
|
||||||
|
width: 200,
|
||||||
|
fixed: 'right',
|
||||||
|
showOverflow: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
keepSource: true,
|
||||||
|
pagerConfig: {},
|
||||||
|
columnConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
useKey: true,
|
||||||
|
keyField: 'row_key',
|
||||||
|
// 行高随内容自适应,避免操作列被固定行高挤压
|
||||||
|
isHover: true,
|
||||||
|
}, proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({ page }, formValues) => {
|
||||||
|
return await getTreatmentFeeCashBackfillList({
|
||||||
|
page: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
...formValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
height: 'auto',
|
||||||
|
border: false,
|
||||||
|
toolbarConfig: {
|
||||||
|
// @ts-ignore
|
||||||
|
search: true,
|
||||||
|
refresh: true,
|
||||||
|
print: false,
|
||||||
|
export: false,
|
||||||
|
zoom: true,
|
||||||
|
slots: {
|
||||||
|
buttons: 'toolbar-actions',
|
||||||
|
},
|
||||||
|
custom: {
|
||||||
|
icon: 'vxe-icon-menu',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
showOverflow: true,
|
||||||
|
};
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 诊疗费余额异常补入
|
||||||
|
* 扫描「已结算诊疗费分账但未入门店可提现」的缺口,支持单笔/批量补入(超管)
|
||||||
|
* 支持忽略(忽略后不能补入)与取消忽略;订单号/诊所名可点开详情
|
||||||
|
*/
|
||||||
|
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Button, Modal, Tag, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
|
||||||
|
import StoreNameLink from '#/components/store-card/StoreNameLink.vue';
|
||||||
|
import { TableAction } from '#/components/table-action';
|
||||||
|
import DetailModal from '#/views/business/order/product-order/components/detail.vue';
|
||||||
|
|
||||||
|
import {
|
||||||
|
batchFixTreatmentFeeCashBackfill,
|
||||||
|
cancelIgnoreTreatmentFeeCashBackfill,
|
||||||
|
fixTreatmentFeeCashBackfill,
|
||||||
|
ignoreTreatmentFeeCashBackfill,
|
||||||
|
} from './api/index';
|
||||||
|
import BackfillDetailModal from './components/detail-modal.vue';
|
||||||
|
import { formOptions as searchFormOptions } from './config/search';
|
||||||
|
import { gridOptions } from './config/table';
|
||||||
|
|
||||||
|
defineOptions({ name: 'FinanceTreatmentFeeCashBackfill' });
|
||||||
|
|
||||||
|
const hasSelected = ref(false);
|
||||||
|
const fixing = ref(false);
|
||||||
|
|
||||||
|
/** 补入核对详情(分账 vs 资金变动) */
|
||||||
|
const [DetailBackfillModal, detailModalApi] = useVbenModal({
|
||||||
|
connectedComponent: BackfillDetailModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 商品订单详情弹窗:与资金流水/提现页同一套 DetailModal,避免重复造轮子 */
|
||||||
|
const [OrderDetailModal, orderDetailModalApi] = useVbenModal({
|
||||||
|
connectedComponent: DetailModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 诊所详情弹窗:复用全局 StoreCardModal,与订单列表诊所链接一致 */
|
||||||
|
const [StoreCardModalComp, storeCardModalApi] = useVbenModal({
|
||||||
|
connectedComponent: StoreCardModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const gridEvents: VxeGridListeners<any> = {
|
||||||
|
checkboxChange() {
|
||||||
|
hasSelected.value = gridApi.grid.getCheckboxRecords().length > 0;
|
||||||
|
},
|
||||||
|
checkboxAll() {
|
||||||
|
hasSelected.value = gridApi.grid.getCheckboxRecords().length > 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
formOptions: searchFormOptions,
|
||||||
|
gridOptions,
|
||||||
|
gridEvents,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 重新扫描异常列表 */
|
||||||
|
const reloadList = () => {
|
||||||
|
gridApi.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击订单号打开商品订单详情
|
||||||
|
* 用 order_id 传给详情弹窗(与 fund-water 的 infoModal 一致)
|
||||||
|
*/
|
||||||
|
const openOrderDetail = (row: Record<string, any>) => {
|
||||||
|
if (!row?.order_id) {
|
||||||
|
message.warning('记录缺少订单 ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
orderDetailModalApi.setData({ id: Number(row.order_id) });
|
||||||
|
orderDetailModalApi.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 点击诊所名称打开门店详情卡
|
||||||
|
* 入参用 storeId,与 StoreCardModal / 订单列表 openStoreCard 约定一致
|
||||||
|
*/
|
||||||
|
const openStoreCard = (storeId?: number) => {
|
||||||
|
if (!storeId) {
|
||||||
|
message.warning('记录缺少门店 ID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
storeCardModalApi.setData({ storeId: Number(storeId) });
|
||||||
|
storeCardModalApi.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 打开补入核对详情弹窗,核对分账与资金变动
|
||||||
|
*/
|
||||||
|
const handleDetail = (row: Record<string, any>) => {
|
||||||
|
if (!row?.order_id || !row?.store_id) {
|
||||||
|
message.warning('记录缺少订单或门店');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
detailModalApi.setData({
|
||||||
|
order_id: Number(row.order_id),
|
||||||
|
store_id: Number(row.store_id),
|
||||||
|
});
|
||||||
|
detailModalApi.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单笔修复:已忽略行不可补入(后端也会拦截)
|
||||||
|
*/
|
||||||
|
const handleFixOne = (row: Record<string, any>) => {
|
||||||
|
if (!row?.order_id || !row?.store_id) {
|
||||||
|
message.warning('记录缺少订单或门店');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Number(row.is_ignored) === 1) {
|
||||||
|
message.warning('已忽略,不能补入');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认补入',
|
||||||
|
content: `确定补入订单 ${row.order_no || row.order_id}(门店 #${row.store_id})诊疗费缺口 ${row.gap ?? '-'} 元?`,
|
||||||
|
okText: '确定补入',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
fixing.value = true;
|
||||||
|
try {
|
||||||
|
await fixTreatmentFeeCashBackfill({
|
||||||
|
order_id: Number(row.order_id),
|
||||||
|
store_id: Number(row.store_id),
|
||||||
|
});
|
||||||
|
message.success('补入成功');
|
||||||
|
gridApi.query();
|
||||||
|
} finally {
|
||||||
|
fixing.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 忽略:默认列表不再展示,且禁止补入
|
||||||
|
*/
|
||||||
|
const handleIgnore = (row: Record<string, any>) => {
|
||||||
|
if (!row?.order_id || !row?.store_id) {
|
||||||
|
message.warning('记录缺少订单或门店');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Modal.confirm({
|
||||||
|
title: '确认忽略',
|
||||||
|
content: `忽略后订单 ${row.order_no || row.order_id}(门店 #${row.store_id})将不再出现在待处理列表,且不能补入。可在「仅已忽略」中取消忽略。`,
|
||||||
|
okText: '确定忽略',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
fixing.value = true;
|
||||||
|
try {
|
||||||
|
await ignoreTreatmentFeeCashBackfill({
|
||||||
|
order_id: Number(row.order_id),
|
||||||
|
store_id: Number(row.store_id),
|
||||||
|
});
|
||||||
|
message.success('已忽略');
|
||||||
|
gridApi.query();
|
||||||
|
} finally {
|
||||||
|
fixing.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消忽略:恢复可扫描/可补入
|
||||||
|
*/
|
||||||
|
const handleCancelIgnore = (row: Record<string, any>) => {
|
||||||
|
if (!row?.order_id || !row?.store_id) {
|
||||||
|
message.warning('记录缺少订单或门店');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Modal.confirm({
|
||||||
|
title: '取消忽略',
|
||||||
|
content: `取消忽略后该记录将重新出现在待处理列表,并允许补入。`,
|
||||||
|
okText: '取消忽略',
|
||||||
|
cancelText: '关闭',
|
||||||
|
onOk: async () => {
|
||||||
|
fixing.value = true;
|
||||||
|
try {
|
||||||
|
await cancelIgnoreTreatmentFeeCashBackfill({
|
||||||
|
order_id: Number(row.order_id),
|
||||||
|
store_id: Number(row.store_id),
|
||||||
|
});
|
||||||
|
message.success('已取消忽略');
|
||||||
|
gridApi.query();
|
||||||
|
} finally {
|
||||||
|
fixing.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量修复勾选行(已忽略项由后端 skipped)
|
||||||
|
*/
|
||||||
|
const handleBatchFix = () => {
|
||||||
|
const records = gridApi.grid.getCheckboxRecords() || [];
|
||||||
|
if (records.length === 0) {
|
||||||
|
message.warning('请至少勾选一条记录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ignoredCount = records.filter(
|
||||||
|
(r: any) => Number(r.is_ignored) === 1,
|
||||||
|
).length;
|
||||||
|
Modal.confirm({
|
||||||
|
title: '批量补入确认',
|
||||||
|
content:
|
||||||
|
ignoredCount > 0
|
||||||
|
? `将补入选中的 ${records.length} 条(其中 ${ignoredCount} 条已忽略会被跳过),是否继续?`
|
||||||
|
: `将补入选中的 ${records.length} 条诊疗费余额缺口,是否继续?`,
|
||||||
|
okText: '确定补入',
|
||||||
|
cancelText: '取消',
|
||||||
|
onOk: async () => {
|
||||||
|
fixing.value = true;
|
||||||
|
try {
|
||||||
|
const result = await batchFixTreatmentFeeCashBackfill({
|
||||||
|
items: records.map((r: any) => ({
|
||||||
|
order_id: Number(r.order_id),
|
||||||
|
store_id: Number(r.store_id),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
const applied = result?.applied ?? 0;
|
||||||
|
const skipped = result?.skipped ?? 0;
|
||||||
|
message.success(`批量完成:成功 ${applied},跳过/失败 ${skipped}`);
|
||||||
|
gridApi.query();
|
||||||
|
hasSelected.value = false;
|
||||||
|
} finally {
|
||||||
|
fixing.value = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行操作按钮:已忽略时隐藏修复、展示取消忽略
|
||||||
|
*/
|
||||||
|
function rowActions(row: Record<string, any>) {
|
||||||
|
const ignored = Number(row.is_ignored) === 1;
|
||||||
|
const actions: any[] = [
|
||||||
|
{
|
||||||
|
label: '详情',
|
||||||
|
type: 'link',
|
||||||
|
size: 'small',
|
||||||
|
onClick: () => handleDetail(row),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
if (ignored) {
|
||||||
|
actions.push({
|
||||||
|
label: '取消忽略',
|
||||||
|
type: 'link',
|
||||||
|
size: 'small',
|
||||||
|
disabled: fixing.value,
|
||||||
|
onClick: () => handleCancelIgnore(row),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
actions.push(
|
||||||
|
{
|
||||||
|
label: '修复',
|
||||||
|
type: 'link',
|
||||||
|
size: 'small',
|
||||||
|
disabled: fixing.value,
|
||||||
|
onClick: () => handleFixOne(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '忽略',
|
||||||
|
type: 'link',
|
||||||
|
size: 'small',
|
||||||
|
disabled: fixing.value,
|
||||||
|
onClick: () => handleIgnore(row),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return actions;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height title="诊疗费余额异常补入">
|
||||||
|
<DetailBackfillModal />
|
||||||
|
<OrderDetailModal />
|
||||||
|
<StoreCardModalComp />
|
||||||
|
<Grid>
|
||||||
|
<template #toolbar-actions>
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '查看异常列表',
|
||||||
|
type: 'primary',
|
||||||
|
icon: 'ant-design:search-outlined',
|
||||||
|
onClick: reloadList,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '批量修复',
|
||||||
|
type: 'default',
|
||||||
|
icon: 'ant-design:tool-outlined',
|
||||||
|
disabled: !hasSelected || fixing,
|
||||||
|
onClick: handleBatchFix,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<!-- 订单号链接:打开商品订单详情,方便运维核对 -->
|
||||||
|
<template #order_no="{ row }">
|
||||||
|
<Button
|
||||||
|
v-if="row.order_no"
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
@click="openOrderDetail(row)"
|
||||||
|
>
|
||||||
|
{{ row.order_no }}
|
||||||
|
</Button>
|
||||||
|
<span v-else>{{ row.order_id || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
<!-- 诊所名称链接:打开门店详情卡(StoreCardModal) -->
|
||||||
|
<template #store_name="{ row }">
|
||||||
|
<StoreNameLink
|
||||||
|
:store-id="Number(row.store_id || 0) || undefined"
|
||||||
|
:name="row.store_name"
|
||||||
|
@click="openStoreCard"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #is_ignored="{ row }">
|
||||||
|
<Tag v-if="Number(row.is_ignored) === 1" color="default">已忽略</Tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
<template #action="{ row }">
|
||||||
|
<!-- flex=false:三个 link 横向排列,避免默认 2 列 grid 在行高变化时叠字 -->
|
||||||
|
<TableAction :flex="false" :actions="rowActions(row)" />
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 操作列按钮单行对齐,行高被诊所名撑开时仍水平居中不叠层 */
|
||||||
|
:deep(.vxe-body--column.col--right .m-table-action) {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -64,3 +64,8 @@ export async function resetPassword(id: number) {
|
|||||||
export async function getWithdrawableOrders(data: Record<string, any>) {
|
export async function getWithdrawableOrders(data: Record<string, any>) {
|
||||||
return requestClient.get<any>(`${prefix}withdrawable-orders`, { params: data });
|
return requestClient.get<any>(`${prefix}withdrawable-orders`, { params: data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 提现规则文案 xk-api GET /withdrawal-application/rules(系统配置) */
|
||||||
|
export async function getWithdrawalRules() {
|
||||||
|
return requestClient.get<any>(`${prefix}rules`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const emit = defineEmits<{
|
|||||||
apply: [];
|
apply: [];
|
||||||
editCard: [];
|
editCard: [];
|
||||||
refresh: [];
|
refresh: [];
|
||||||
|
rules: [];
|
||||||
statClick: [key: string];
|
statClick: [key: string];
|
||||||
viewApply: [id: number];
|
viewApply: [id: number];
|
||||||
}>();
|
}>();
|
||||||
@@ -212,6 +213,7 @@ function formatMoney(val: number) {
|
|||||||
{{ subjectName }} · 账户信息
|
{{ subjectName }} · 账户信息
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
<Button size="small" @click="emit('rules')">提现规则</Button>
|
||||||
<Button type="primary" size="small" @click="emit('apply')">
|
<Button type="primary" size="small" @click="emit('apply')">
|
||||||
申请提现
|
申请提现
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import AccountInfoCard from './account-info-card.vue';
|
|||||||
import SaveMyCard from './card-modal.vue';
|
import SaveMyCard from './card-modal.vue';
|
||||||
import GapConfirmModal from './gap-confirm-modal.vue';
|
import GapConfirmModal from './gap-confirm-modal.vue';
|
||||||
import FormModalDemo from './withdrawal-modal.vue';
|
import FormModalDemo from './withdrawal-modal.vue';
|
||||||
|
import WithdrawalRulesModal from './withdrawal-rules-modal.vue';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'Statistics',
|
name: 'Statistics',
|
||||||
@@ -97,6 +98,9 @@ const [FormModal, formModalApi] = useVbenModal({
|
|||||||
const [saveMyCardModal, saveMyCardApi] = useVbenModal({
|
const [saveMyCardModal, saveMyCardApi] = useVbenModal({
|
||||||
connectedComponent: SaveMyCard,
|
connectedComponent: SaveMyCard,
|
||||||
});
|
});
|
||||||
|
const [RulesModal, rulesModalApi] = useVbenModal({
|
||||||
|
connectedComponent: WithdrawalRulesModal,
|
||||||
|
});
|
||||||
/** 差额确认与申请弹窗同级挂载;zIndex 内外都写,避免 connectedComponent 吃掉内部层级 */
|
/** 差额确认与申请弹窗同级挂载;zIndex 内外都写,避免 connectedComponent 吃掉内部层级 */
|
||||||
const [GapConfirmModalComp, gapConfirmModalApi] = useVbenModal({
|
const [GapConfirmModalComp, gapConfirmModalApi] = useVbenModal({
|
||||||
connectedComponent: GapConfirmModal,
|
connectedComponent: GapConfirmModal,
|
||||||
@@ -143,6 +147,11 @@ const showCardModal = () => {
|
|||||||
});
|
});
|
||||||
saveMyCardApi.open();
|
saveMyCardApi.open();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 打开提现规则说明(额度 / 次数 / 到账 / 审核流程) */
|
||||||
|
function showRulesModal() {
|
||||||
|
rulesModalApi.open();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -150,6 +159,7 @@ const showCardModal = () => {
|
|||||||
<FormModal />
|
<FormModal />
|
||||||
<GapConfirmModalComp />
|
<GapConfirmModalComp />
|
||||||
<saveMyCardModal />
|
<saveMyCardModal />
|
||||||
|
<RulesModal />
|
||||||
<AccountInfoCard
|
<AccountInfoCard
|
||||||
:my-card="myCard"
|
:my-card="myCard"
|
||||||
:is-store-user="isStoreUser"
|
:is-store-user="isStoreUser"
|
||||||
@@ -170,6 +180,7 @@ const showCardModal = () => {
|
|||||||
@apply="showModal"
|
@apply="showModal"
|
||||||
@edit-card="showCardModal"
|
@edit-card="showCardModal"
|
||||||
@refresh="refresh"
|
@refresh="refresh"
|
||||||
|
@rules="showRulesModal"
|
||||||
@stat-click="emit('statClick', $event)"
|
@stat-click="emit('statClick', $event)"
|
||||||
@view-apply="emit('viewApply', $event)"
|
@view-apply="emit('viewApply', $event)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 提现规则说明弹窗(液态玻璃风格)
|
||||||
|
* 优先读系统配置接口;失败/未上线时用本地默认文案兜底
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { getWithdrawalRules } from '../api';
|
||||||
|
import {
|
||||||
|
DEFAULT_WITHDRAWAL_RULES,
|
||||||
|
normalizeWithdrawalRules,
|
||||||
|
type WithdrawalRulesPayload,
|
||||||
|
} from '../config/rules';
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'WithdrawalRulesModal',
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = ref<WithdrawalRulesPayload>({
|
||||||
|
...DEFAULT_WITHDRAWAL_RULES,
|
||||||
|
flow_steps: [...DEFAULT_WITHDRAWAL_RULES.flow_steps],
|
||||||
|
rules: [...DEFAULT_WITHDRAWAL_RULES.rules],
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadRules() {
|
||||||
|
try {
|
||||||
|
const res = await getWithdrawalRules();
|
||||||
|
payload.value = normalizeWithdrawalRules(res);
|
||||||
|
} catch {
|
||||||
|
// 接口不存在或网络失败:保持默认文案,满足审核展示不空
|
||||||
|
payload.value = normalizeWithdrawalRules(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
title: '提现规则',
|
||||||
|
class: 'w-[560px] withdrawal-rules-modal',
|
||||||
|
draggable: true,
|
||||||
|
centered: true,
|
||||||
|
showCancelButton: false,
|
||||||
|
confirmText: '我知道了',
|
||||||
|
onConfirm() {
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
onOpenChange(isOpen) {
|
||||||
|
if (isOpen) {
|
||||||
|
loadRules();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal>
|
||||||
|
<div class="rules-glass">
|
||||||
|
<div class="rules-glow" aria-hidden="true"></div>
|
||||||
|
<p class="rules-lead">{{ payload.lead }}</p>
|
||||||
|
<section class="glass-panel flow-panel">
|
||||||
|
<div class="panel-label">审核流程</div>
|
||||||
|
<ol class="flow-steps">
|
||||||
|
<li
|
||||||
|
v-for="(step, idx) in payload.flow_steps"
|
||||||
|
:key="step.key"
|
||||||
|
class="flow-step"
|
||||||
|
>
|
||||||
|
<span class="flow-num">{{ idx + 1 }}</span>
|
||||||
|
<span class="flow-label">{{ step.label }}</span>
|
||||||
|
<span
|
||||||
|
v-if="idx < payload.flow_steps.length - 1"
|
||||||
|
class="flow-sep"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
<ul class="rule-list">
|
||||||
|
<li
|
||||||
|
v-for="(rule, idx) in payload.rules"
|
||||||
|
:key="rule.key"
|
||||||
|
class="glass-panel rule-item"
|
||||||
|
>
|
||||||
|
<span class="rule-idx">{{ idx + 1 }}</span>
|
||||||
|
<div class="rule-body">
|
||||||
|
<div class="rule-title">{{ rule.title }}</div>
|
||||||
|
<div class="rule-desc">{{ rule.desc }}</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p class="rules-foot">{{ payload.foot }}</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rules-glass {
|
||||||
|
position: relative;
|
||||||
|
padding: 4px 2px 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-glow {
|
||||||
|
pointer-events: none;
|
||||||
|
position: absolute;
|
||||||
|
top: -48px;
|
||||||
|
left: 50%;
|
||||||
|
width: 280px;
|
||||||
|
height: 140px;
|
||||||
|
margin-left: -140px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(
|
||||||
|
ellipse,
|
||||||
|
hsl(var(--primary) / 22%) 0%,
|
||||||
|
transparent 70%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-lead {
|
||||||
|
position: relative;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid hsl(var(--border) / 55%);
|
||||||
|
background: hsl(var(--card, var(--background)) / 72%);
|
||||||
|
box-shadow:
|
||||||
|
0 8px 24px hsl(var(--foreground) / 4%),
|
||||||
|
inset 0 1px 0 hsl(0 0% 100% / 12%);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-panel {
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-label {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-steps {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-step {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-num {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
background: hsl(var(--primary) / 12%);
|
||||||
|
border: 1px solid hsl(var(--primary) / 28%);
|
||||||
|
box-shadow: 0 0 10px hsl(var(--primary) / 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-label {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-sep {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
left: calc(50% + 18px);
|
||||||
|
right: calc(-50% + 18px);
|
||||||
|
height: 1px;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
hsl(var(--primary) / 35%),
|
||||||
|
hsl(var(--primary) / 8%)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 14px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-idx {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
background: hsl(var(--primary) / 10%);
|
||||||
|
border: 1px solid hsl(var(--primary) / 22%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-title {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.rules-foot {
|
||||||
|
position: relative;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
</style>
|
||||||
96
apps/web-antd/src/views/finance/withdrawal/config/rules.ts
Normal file
96
apps/web-antd/src/views/finance/withdrawal/config/rules.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* 提现规则默认文案(接口不可用 / 未配置时的兜底)
|
||||||
|
* 与后端 SystemConfigService::defaultWithdrawalRules 保持一致
|
||||||
|
*/
|
||||||
|
export interface WithdrawalFlowStep {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithdrawalRuleItem {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WithdrawalRulesPayload {
|
||||||
|
lead: string;
|
||||||
|
foot: string;
|
||||||
|
flow_steps: WithdrawalFlowStep[];
|
||||||
|
rules: WithdrawalRuleItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_WITHDRAWAL_RULES: WithdrawalRulesPayload = {
|
||||||
|
lead: '提现前请仔细阅读',
|
||||||
|
foot: '实际到账时间以银行处理进度为准',
|
||||||
|
flow_steps: [
|
||||||
|
{ key: 'apply', label: '提交申请' },
|
||||||
|
{ key: 'audit', label: '平台审核' },
|
||||||
|
{ key: 'pay', label: '发起打款' },
|
||||||
|
{ key: 'arrive', label: '到账' },
|
||||||
|
],
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
key: 'amount',
|
||||||
|
title: '可提现额度',
|
||||||
|
desc: '以账户「可提现余额」为准,单笔最低 50 元。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'time',
|
||||||
|
title: '提现时间',
|
||||||
|
desc: '全天可提交申请,不限制时段。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'daily',
|
||||||
|
title: '每日次数',
|
||||||
|
desc: '不限制每日提现次数。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'arrive',
|
||||||
|
title: '到账时间',
|
||||||
|
desc: '工作日审核通过后约 10 分钟内到账;节假日顺延至次日到账。',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @deprecated 请用 DEFAULT_WITHDRAWAL_RULES.rules */
|
||||||
|
export const WITHDRAWAL_RULES = DEFAULT_WITHDRAWAL_RULES.rules;
|
||||||
|
/** @deprecated 请用 DEFAULT_WITHDRAWAL_RULES.flow_steps */
|
||||||
|
export const WITHDRAWAL_FLOW_STEPS = DEFAULT_WITHDRAWAL_RULES.flow_steps;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把接口结果规范化;残缺则补默认,保证弹窗不空
|
||||||
|
*/
|
||||||
|
export function normalizeWithdrawalRules(
|
||||||
|
raw: Partial<WithdrawalRulesPayload> | null | undefined,
|
||||||
|
): WithdrawalRulesPayload {
|
||||||
|
const base = DEFAULT_WITHDRAWAL_RULES;
|
||||||
|
if (!raw || typeof raw !== 'object') {
|
||||||
|
return { ...base, flow_steps: [...base.flow_steps], rules: [...base.rules] };
|
||||||
|
}
|
||||||
|
const flow =
|
||||||
|
Array.isArray(raw.flow_steps) && raw.flow_steps.length > 0
|
||||||
|
? raw.flow_steps
|
||||||
|
.map((s, i) => ({
|
||||||
|
key: String(s?.key || `step_${i + 1}`),
|
||||||
|
label: String(s?.label || '').trim(),
|
||||||
|
}))
|
||||||
|
.filter((s) => s.label)
|
||||||
|
: base.flow_steps;
|
||||||
|
const rules =
|
||||||
|
Array.isArray(raw.rules) && raw.rules.length > 0
|
||||||
|
? raw.rules
|
||||||
|
.map((r, i) => ({
|
||||||
|
key: String(r?.key || `rule_${i + 1}`),
|
||||||
|
title: String(r?.title || '').trim(),
|
||||||
|
desc: String(r?.desc || '').trim(),
|
||||||
|
}))
|
||||||
|
.filter((r) => r.title || r.desc)
|
||||||
|
: base.rules;
|
||||||
|
return {
|
||||||
|
lead: String(raw.lead || '').trim() || base.lead,
|
||||||
|
foot: String(raw.foot || '').trim() || base.foot,
|
||||||
|
flow_steps: flow.length ? flow : [...base.flow_steps],
|
||||||
|
rules: rules.length ? rules : [...base.rules],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -157,7 +157,7 @@ function dakuanStatusPill(status: number) {
|
|||||||
<template>
|
<template>
|
||||||
<Page
|
<Page
|
||||||
auto-content-height
|
auto-content-height
|
||||||
description="提现前请确认好您的打款账户"
|
description="提现前请确认打款账户,并可查看提现规则"
|
||||||
title="提现管理"
|
title="提现管理"
|
||||||
>
|
>
|
||||||
<OrderDetailModal />
|
<OrderDetailModal />
|
||||||
|
|||||||
@@ -18,6 +18,13 @@ export async function getOldApiLogList(data: any) {
|
|||||||
return requestClient.get<any>(`${prefix}api-list`, { params: data });
|
return requestClient.get<any>(`${prefix}api-list`, { params: data });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单条日志详情。列表不返回 param/result,查看时再取,避免大 JSON 拖慢分页
|
||||||
|
*/
|
||||||
|
export async function getApiOpLogDetail(id: number) {
|
||||||
|
return requestClient.get<any>(`${prefix}api-detail`, { params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Api 访问日志操作用户搜索
|
* Api 访问日志操作用户搜索
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,19 +3,30 @@ import { ref } from 'vue';
|
|||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
import { Descriptions } from 'ant-design-vue';
|
import { Descriptions, Spin } from 'ant-design-vue';
|
||||||
|
|
||||||
import { Icon } from '#/components/icon';
|
import { Icon } from '#/components/icon';
|
||||||
import { getIcon } from '#/util/tool';
|
import { getIcon } from '#/util/tool';
|
||||||
|
import { getApiOpLogDetail } from '../api';
|
||||||
import {
|
import {
|
||||||
getApiOpLogOperatorLabel,
|
getApiOpLogOperatorLabel,
|
||||||
getApiOpLogPlatformLabel,
|
getApiOpLogPlatformLabel,
|
||||||
} from '../config/platform';
|
} from '../config/platform';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const gridApi = ref();
|
const gridApi = ref();
|
||||||
const data = ref({});
|
const data = ref<Record<string, any>>({});
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
/** 列表行没有 param/result,打开时按 id 拉详情;解析失败则原样展示,避免弹窗白屏 */
|
||||||
|
function formatJson(raw: unknown) {
|
||||||
|
if (raw == null || raw === '') return '';
|
||||||
|
if (typeof raw === 'object') return raw;
|
||||||
|
try {
|
||||||
|
return JSON.parse(String(raw));
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
fullscreenButton: false,
|
fullscreenButton: false,
|
||||||
@@ -26,13 +37,20 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
modalApi.close();
|
modalApi.close();
|
||||||
},
|
},
|
||||||
onOpenChange(isOpen: boolean) {
|
async onOpenChange(isOpen: boolean) {
|
||||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||||
if (isOpen) {
|
if (!isOpen) return;
|
||||||
const { values } = modalApi.getData<Record<string, any>>();
|
const { values } = modalApi.getData<Record<string, any>>();
|
||||||
if (values) {
|
data.value = values || {};
|
||||||
data.value = values;
|
const id = Number(values?.id || 0);
|
||||||
}
|
if (!id) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
data.value = (await getApiOpLogDetail(id)) || values || {};
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -40,6 +58,7 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Modal class="w-[80%] h-[80%]" title="Api访问日志详情">
|
<Modal class="w-[80%] h-[80%]" title="Api访问日志详情">
|
||||||
|
<Spin :spinning="loading">
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<Descriptions
|
<Descriptions
|
||||||
:column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 2 }"
|
:column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 2 }"
|
||||||
@@ -91,13 +110,14 @@ const [Modal, modalApi] = useVbenModal({
|
|||||||
bordered
|
bordered
|
||||||
>
|
>
|
||||||
<Descriptions.Item label="参数">
|
<Descriptions.Item label="参数">
|
||||||
<pre>{{ JSON.parse(data.param) }}</pre>
|
<pre>{{ formatJson(data.param) }}</pre>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="结果">
|
<Descriptions.Item label="结果">
|
||||||
<pre>{{ JSON.parse(data.result) }}</pre>
|
<pre>{{ formatJson(data.result) }}</pre>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
</div>
|
</div>
|
||||||
|
</Spin>
|
||||||
</Modal>
|
</Modal>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
12
apps/web-antd/src/views/log/mes-open-api-log/api/index.ts
Normal file
12
apps/web-antd/src/views/log/mes-open-api-log/api/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
/** 第三方 MES OpenAPI 传方日志(仅 source=open_api) */
|
||||||
|
const prefix = 'mes-open-api-log/';
|
||||||
|
|
||||||
|
export async function getMesOpenApiLogList(data: any) {
|
||||||
|
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMesOpenApiLogDetail(id: number) {
|
||||||
|
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 第三方 MES OpenAPI 传方日志详情
|
||||||
|
* 展示 AppKey、处方号、MES 回包与完整 content JSON
|
||||||
|
*/
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Descriptions, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { getMesOpenApiLogDetail } from '../api';
|
||||||
|
|
||||||
|
const data = ref<Record<string, any>>({});
|
||||||
|
const contentText = computed(() => {
|
||||||
|
const c = data.value.content;
|
||||||
|
if (c == null) return '(无)';
|
||||||
|
try {
|
||||||
|
return typeof c === 'string' ? c : JSON.stringify(c, null, 2);
|
||||||
|
} catch {
|
||||||
|
return String(c);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
showConfirmButton: false,
|
||||||
|
cancelText: '关闭',
|
||||||
|
draggable: true,
|
||||||
|
onCancel() {
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
async onOpenChange(isOpen) {
|
||||||
|
if (!isOpen) {
|
||||||
|
data.value = {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = modalApi.getData<{ id?: number; values?: any }>();
|
||||||
|
const id = Number(payload?.id || payload?.values?.id || 0);
|
||||||
|
if (id <= 0) {
|
||||||
|
data.value = payload?.values || {};
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalApi.setState({ loading: true });
|
||||||
|
try {
|
||||||
|
const res = await getMesOpenApiLogDetail(id);
|
||||||
|
data.value = res?.data || res || {};
|
||||||
|
} catch {
|
||||||
|
data.value = payload?.values || {};
|
||||||
|
} finally {
|
||||||
|
modalApi.setState({ loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Modal class="w-[90%] md:w-[800px]" title="第三方 MES 传方详情">
|
||||||
|
<Descriptions bordered :column="1" size="small">
|
||||||
|
<Descriptions.Item label="ID">{{ data.id }}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="App Key">{{ data.app_key }}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="App ID">{{ data.app_id }}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="医院处方号">
|
||||||
|
{{ data.hospital_prescription_code }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="订单ID">{{ data.order_id || '—' }}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="结果">
|
||||||
|
<Tag :color="Number(data.is_success) === 0 ? 'success' : 'error'">
|
||||||
|
{{ data.is_success_txt }}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="MES码">{{ data.mes_code || '—' }}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="MES说明">{{ data.mes_message || '—' }}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="时间">{{ data.created_at }}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="完整内容">
|
||||||
|
<pre class="max-h-80 overflow-auto whitespace-pre-wrap break-all text-xs">{{ contentText }}</pre>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { VbenFormProps } from '#/adapter/form';
|
||||||
|
|
||||||
|
/** 第三方传方日志搜索:按 AppKey / 医院处方号 / 结果筛,与订单侧同步日志区分 */
|
||||||
|
export const formOptions: VbenFormProps = {
|
||||||
|
collapsed: false,
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: { placeholder: 'App Key', allowClear: true },
|
||||||
|
fieldName: 'app_key',
|
||||||
|
label: 'App Key',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: { placeholder: '医院处方号', allowClear: true },
|
||||||
|
fieldName: 'hospital_prescription_code',
|
||||||
|
label: '处方号',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Select',
|
||||||
|
componentProps: {
|
||||||
|
allowClear: true,
|
||||||
|
options: [
|
||||||
|
{ label: '成功', value: 0 },
|
||||||
|
{ label: '失败', value: 1 },
|
||||||
|
],
|
||||||
|
placeholder: '全部',
|
||||||
|
},
|
||||||
|
fieldName: 'is_success',
|
||||||
|
label: '结果',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
componentProps: { placeholder: '内容关键词', allowClear: true },
|
||||||
|
fieldName: 'keyword',
|
||||||
|
label: '关键词',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCollapseButton: true,
|
||||||
|
submitButtonOptions: { content: '查询' },
|
||||||
|
submitOnChange: false,
|
||||||
|
submitOnEnter: true,
|
||||||
|
};
|
||||||
40
apps/web-antd/src/views/log/mes-open-api-log/config/table.ts
Normal file
40
apps/web-antd/src/views/log/mes-open-api-log/config/table.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { getMesOpenApiLogList } from '../api';
|
||||||
|
|
||||||
|
export const gridOptions: VxeGridProps<any> = {
|
||||||
|
columns: [
|
||||||
|
{ field: 'id', title: 'ID', width: 80 },
|
||||||
|
{ field: 'app_key', title: 'App Key', minWidth: 140 },
|
||||||
|
{
|
||||||
|
field: 'hospital_prescription_code',
|
||||||
|
title: '医院处方号',
|
||||||
|
minWidth: 160,
|
||||||
|
},
|
||||||
|
{ field: 'order_id', title: '订单ID', width: 100 },
|
||||||
|
{
|
||||||
|
field: 'is_success_txt',
|
||||||
|
title: '结果',
|
||||||
|
width: 90,
|
||||||
|
slots: { default: 'is_success' },
|
||||||
|
},
|
||||||
|
{ field: 'mes_code', title: 'MES码', width: 100 },
|
||||||
|
{ field: 'mes_message', title: 'MES说明', minWidth: 160 },
|
||||||
|
{ field: 'created_at', title: '传方时间', width: 180 },
|
||||||
|
{ title: '操作', width: 90, fixed: 'right', slots: { default: 'action' } },
|
||||||
|
],
|
||||||
|
keepSource: true,
|
||||||
|
pagerConfig: {},
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({ page }, formValues) =>
|
||||||
|
getMesOpenApiLogList({
|
||||||
|
page: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
...formValues,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
height: 'auto',
|
||||||
|
toolbarConfig: { search: true, refresh: true, zoom: true },
|
||||||
|
};
|
||||||
54
apps/web-antd/src/views/log/mes-open-api-log/index.vue
Normal file
54
apps/web-antd/src/views/log/mes-open-api-log/index.vue
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 第三方 MES OpenAPI 传方日志
|
||||||
|
* 仅展示开放接口推送记录,与「同步中药 ERP 日志」区分
|
||||||
|
*/
|
||||||
|
import { Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { TableAction } from '#/components/table-action';
|
||||||
|
|
||||||
|
import DetailModal from './components/detail-modal.vue';
|
||||||
|
import { formOptions } from './config/search';
|
||||||
|
import { gridOptions } from './config/table';
|
||||||
|
|
||||||
|
defineOptions({ name: 'MesOpenApiLog' });
|
||||||
|
|
||||||
|
const [Grid] = useVbenVxeGrid({ formOptions, gridOptions });
|
||||||
|
const [DetailModalComp, detailModalApi] = useVbenModal({
|
||||||
|
connectedComponent: DetailModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 打开详情弹窗 */
|
||||||
|
function openDetail(row: any) {
|
||||||
|
detailModalApi.setData({ id: Number(row?.id || 0), values: row });
|
||||||
|
detailModalApi.open();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height title="第三方 MES 传方日志">
|
||||||
|
<DetailModalComp />
|
||||||
|
<Grid>
|
||||||
|
<template #is_success="{ row }">
|
||||||
|
<Tag :color="Number(row.is_success) === 0 ? 'success' : 'error'">
|
||||||
|
{{ row.is_success_txt }}
|
||||||
|
</Tag>
|
||||||
|
</template>
|
||||||
|
<template #action="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '详情',
|
||||||
|
type: 'link',
|
||||||
|
size: 'small',
|
||||||
|
onClick: () => openDetail(row),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
@@ -7,11 +7,29 @@ import { computed, ref } from 'vue';
|
|||||||
|
|
||||||
import { useVbenModal } from '@vben/common-ui';
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
import { Button, Descriptions, message, Spin, Tag } from 'ant-design-vue';
|
import { Button, Descriptions, Image, message, Spin, Tabs, Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
import { getAiGenerationDetail } from '../api';
|
import { getAiGenerationDetail } from '../api';
|
||||||
import GenerationFlowTimeline from './generation-flow-timeline.vue';
|
import GenerationFlowTimeline from './generation-flow-timeline.vue';
|
||||||
|
|
||||||
|
const TabPane = Tabs.TabPane;
|
||||||
|
|
||||||
|
/** 详情内容区 Tab:preview=结构化预览 / json=原始快照;本地记忆 */
|
||||||
|
const CONTENT_TAB_STORAGE_KEY = 'ai_generation_detail_content_tab';
|
||||||
|
|
||||||
|
function readContentTab(): 'preview' | 'json' {
|
||||||
|
const v = localStorage.getItem(CONTENT_TAB_STORAGE_KEY);
|
||||||
|
return v === 'json' ? 'json' : 'preview';
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTab = ref<'preview' | 'json'>(readContentTab());
|
||||||
|
|
||||||
|
function onContentTabChange(key: string | number) {
|
||||||
|
const next = String(key) === 'json' ? 'json' : 'preview';
|
||||||
|
contentTab.value = next;
|
||||||
|
localStorage.setItem(CONTENT_TAB_STORAGE_KEY, next);
|
||||||
|
}
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
/** 打开页面级归档弹窗 */
|
/** 打开页面级归档弹窗 */
|
||||||
archive: [row: Record<string, any>];
|
archive: [row: Record<string, any>];
|
||||||
@@ -22,6 +40,111 @@ const emit = defineEmits<{
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const detail = ref<Record<string, any>>({});
|
const detail = ref<Record<string, any>>({});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识图场景:input_snapshot.image_urls 是上传的处方原图
|
||||||
|
* 详情里除 JSON 外单独渲染,避免只能看 URL 字符串
|
||||||
|
*/
|
||||||
|
const ocrImageUrls = computed(() => {
|
||||||
|
const snap = detail.value?.input_snapshot;
|
||||||
|
const urls = snap?.image_urls;
|
||||||
|
if (!Array.isArray(urls)) return [] as string[];
|
||||||
|
return urls
|
||||||
|
.map((u) => String(u || '').trim())
|
||||||
|
.filter((u) => /^https?:\/\//i.test(u));
|
||||||
|
});
|
||||||
|
|
||||||
|
const isOcrScene = computed(() => {
|
||||||
|
const scene = String(detail.value?.scene || '');
|
||||||
|
return (
|
||||||
|
scene === 'prescription_image_ocr' || scene === 'transfer_image_ocr'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 识图结果结构化预览字段 */
|
||||||
|
const ocrResult = computed(() => {
|
||||||
|
const rj = detail.value?.result_json || {};
|
||||||
|
const ocr = rj.ocr && typeof rj.ocr === 'object' ? rj.ocr : {};
|
||||||
|
const match = rj.match && typeof rj.match === 'object' ? rj.match : {};
|
||||||
|
const patientMatch =
|
||||||
|
rj.patient_match && typeof rj.patient_match === 'object'
|
||||||
|
? rj.patient_match
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
mode: String(rj.mode || ocr.mode || ''),
|
||||||
|
patient_name: String(ocr.patient_name || ''),
|
||||||
|
patient_mobile: String(ocr.patient_mobile || ''),
|
||||||
|
sex: Number(ocr.sex || 0),
|
||||||
|
age: Number(ocr.age || 0),
|
||||||
|
patient_address: String(ocr.patient_address || ''),
|
||||||
|
clinical_diagnose: String(
|
||||||
|
rj.clinical_diagnose || ocr.clinical_diagnose || '',
|
||||||
|
),
|
||||||
|
medical_advice: String(rj.medical_advice || ocr.medical_advice || ''),
|
||||||
|
dosage: Number(rj.dosage || ocr.dosage || 0),
|
||||||
|
day_dosage: Number(rj.day_dosage || ocr.day_dosage || 0),
|
||||||
|
matched: Array.isArray(match.matched) ? match.matched : [],
|
||||||
|
unmatched: Array.isArray(match.unmatched) ? match.unmatched : [],
|
||||||
|
patient_match: patientMatch,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function sexText(sex: number) {
|
||||||
|
if (sex === 1) return '男';
|
||||||
|
if (sex === 2) return '女';
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
|
||||||
|
function candidateDrugName(c: Record<string, any>) {
|
||||||
|
return String(
|
||||||
|
c?.drug_name || c?.drug?.drug_name || c?.matched_alias || c?.name || '',
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedCandidateName(row: Record<string, any>) {
|
||||||
|
const sid = Number(row?.selected_drug_id || 0);
|
||||||
|
const list = Array.isArray(row?.candidates) ? row.candidates : [];
|
||||||
|
const hit = list.find((c: any) => Number(c?.drug_id) === sid) || list[0];
|
||||||
|
return hit ? candidateDrugName(hit) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 未对照行展示 tokens(LLM 分词/别名候选),便于排查未匹配 */
|
||||||
|
function unmatchedTokensText(u: Record<string, any>) {
|
||||||
|
const toks = Array.isArray(u?.tokens) ? u.tokens : [];
|
||||||
|
const parts = toks
|
||||||
|
.map((t: unknown) => String(t || '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
return parts.length ? parts.join('、') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LLM 源数据:优先 result_json.llm_raw(结构化);
|
||||||
|
* 旧记录无 llm_raw 时尝试 parse raw_response;再不行展示原文
|
||||||
|
*/
|
||||||
|
const llmSourcePayload = computed(() => {
|
||||||
|
const rj = detail.value?.result_json;
|
||||||
|
if (rj && typeof rj === 'object' && rj.llm_raw != null) {
|
||||||
|
return rj.llm_raw;
|
||||||
|
}
|
||||||
|
const raw = String(detail.value?.raw_response || '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
let text = raw;
|
||||||
|
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/);
|
||||||
|
if (fence?.[1]) text = fence[1].trim();
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 处理后结果:去掉 llm_raw,避免与「LLM 源数据」重复 */
|
||||||
|
const processedResultJson = computed(() => {
|
||||||
|
const rj = detail.value?.result_json;
|
||||||
|
if (!rj || typeof rj !== 'object') return rj ?? {};
|
||||||
|
const { llm_raw: _omit, ...rest } = rj as Record<string, any>;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
const [Modal, modalApi] = useVbenModal({
|
const [Modal, modalApi] = useVbenModal({
|
||||||
title: '生成记录详情',
|
title: '生成记录详情',
|
||||||
class: 'w-[min(1280px,96vw)]',
|
class: 'w-[min(1280px,96vw)]',
|
||||||
@@ -345,19 +468,227 @@ function onArchiveClick() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="briefPreview" class="json-panel mt-3">
|
<div v-if="briefPreview && !isOcrScene" class="json-panel mt-3">
|
||||||
<div class="json-title">简报正文</div>
|
<div class="json-title">简报正文</div>
|
||||||
<pre class="json-pre">{{ briefPreview }}</pre>
|
<pre class="json-pre">{{ briefPreview }}</pre>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 grid grid-cols-1 gap-3 lg:grid-cols-2">
|
<!-- 识图开方/传方:原图始终展示,便于对照下方预览/JSON -->
|
||||||
|
<div v-if="isOcrScene" class="json-panel mt-3">
|
||||||
|
<div class="json-title">
|
||||||
|
处方原图
|
||||||
|
<span class="ml-2 text-[hsl(var(--muted-foreground))]">
|
||||||
|
{{ ocrImageUrls.length ? `${ocrImageUrls.length} 张` : '无图' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="ocrImageUrls.length" class="ocr-img-row">
|
||||||
|
<Image.PreviewGroup>
|
||||||
|
<Image
|
||||||
|
v-for="(url, idx) in ocrImageUrls"
|
||||||
|
:key="`${url}-${idx}`"
|
||||||
|
:src="url"
|
||||||
|
:width="120"
|
||||||
|
:height="120"
|
||||||
|
class="ocr-thumb"
|
||||||
|
:preview="{ src: url }"
|
||||||
|
/>
|
||||||
|
</Image.PreviewGroup>
|
||||||
|
</div>
|
||||||
|
<div v-else class="ocr-img-empty">
|
||||||
|
本条记录未写入 image_urls(请确认识图时已成功上传)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 生成内容:详情预览 / JSON,localStorage 记忆 -->
|
||||||
|
<div class="mt-3">
|
||||||
|
<Tabs
|
||||||
|
:active-key="contentTab"
|
||||||
|
size="small"
|
||||||
|
@change="onContentTabChange"
|
||||||
|
>
|
||||||
|
<TabPane key="preview" tab="详情预览">
|
||||||
|
<div v-if="isOcrScene" class="preview-body">
|
||||||
|
<Descriptions
|
||||||
|
size="small"
|
||||||
|
bordered
|
||||||
|
:column="2"
|
||||||
|
class="preview-desc"
|
||||||
|
>
|
||||||
|
<Descriptions.Item label="模式">
|
||||||
|
{{
|
||||||
|
ocrResult.mode === 'transfer' ? '识图传方' : '识图开方'
|
||||||
|
}}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
v-if="ocrResult.patient_match"
|
||||||
|
label="患者匹配"
|
||||||
|
>
|
||||||
|
<Tag
|
||||||
|
:color="
|
||||||
|
ocrResult.patient_match.matched ? 'success' : 'warning'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
ocrResult.patient_match.matched
|
||||||
|
? `已匹配 #${ocrResult.patient_match.user_patient_id || ''}`
|
||||||
|
: '未匹配(将新建)'
|
||||||
|
}}
|
||||||
|
</Tag>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
v-if="ocrResult.patient_name || ocrResult.mode === 'transfer'"
|
||||||
|
label="姓名"
|
||||||
|
>
|
||||||
|
{{ ocrResult.patient_name || '—' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
v-if="
|
||||||
|
ocrResult.patient_mobile || ocrResult.mode === 'transfer'
|
||||||
|
"
|
||||||
|
label="手机"
|
||||||
|
>
|
||||||
|
{{ ocrResult.patient_mobile || '—' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
v-if="ocrResult.mode === 'transfer'"
|
||||||
|
label="性别/年龄"
|
||||||
|
>
|
||||||
|
{{ sexText(ocrResult.sex) }} /
|
||||||
|
{{ ocrResult.age > 0 ? `${ocrResult.age} 岁` : '—' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item
|
||||||
|
v-if="ocrResult.patient_address"
|
||||||
|
label="地址"
|
||||||
|
:span="2"
|
||||||
|
>
|
||||||
|
{{ ocrResult.patient_address }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="诊断" :span="2">
|
||||||
|
{{ ocrResult.clinical_diagnose || '—' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="医嘱" :span="2">
|
||||||
|
{{ ocrResult.medical_advice || '—' }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="剂数/日次">
|
||||||
|
{{ ocrResult.dosage || '—' }} 剂 /
|
||||||
|
{{ ocrResult.day_dosage || '—' }} 次
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="药味">
|
||||||
|
已对照 {{ ocrResult.matched.length }} /
|
||||||
|
未对照 {{ ocrResult.unmatched.length }}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="ocrResult.matched.length"
|
||||||
|
class="preview-section mt-3"
|
||||||
|
>
|
||||||
|
<div class="preview-section-title">
|
||||||
|
已对照({{ ocrResult.matched.length }})
|
||||||
|
</div>
|
||||||
|
<!-- 紧凑一行多列:AI名+克数 → 对照药名 -->
|
||||||
|
<div class="preview-drug-grid">
|
||||||
|
<div
|
||||||
|
v-for="(m, mi) in ocrResult.matched"
|
||||||
|
:key="'m' + mi"
|
||||||
|
class="preview-drug-chip is-matched"
|
||||||
|
:title="
|
||||||
|
selectedCandidateName(m)
|
||||||
|
? `${m.ai_name || ''} → ${selectedCandidateName(m)}`
|
||||||
|
: m.ai_name || ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span class="chip-ai">{{ m.ai_name || '—' }}</span>
|
||||||
|
<span class="chip-dose">
|
||||||
|
{{ m.dose || '' }}{{ m.unit || '' }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="selectedCandidateName(m)"
|
||||||
|
class="chip-arrow"
|
||||||
|
>→</span>
|
||||||
|
<span
|
||||||
|
v-if="selectedCandidateName(m)"
|
||||||
|
class="chip-match"
|
||||||
|
>{{ selectedCandidateName(m) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="ocrResult.unmatched.length"
|
||||||
|
class="preview-section mt-3"
|
||||||
|
>
|
||||||
|
<div class="preview-section-title text-warn">
|
||||||
|
未对照({{ ocrResult.unmatched.length }})
|
||||||
|
</div>
|
||||||
|
<div class="preview-drug-grid">
|
||||||
|
<div
|
||||||
|
v-for="(u, ui) in ocrResult.unmatched"
|
||||||
|
:key="'u' + ui"
|
||||||
|
class="preview-drug-chip is-unmatched"
|
||||||
|
>
|
||||||
|
<span class="chip-ai">{{ u.ai_name || '—' }}</span>
|
||||||
|
<span class="chip-dose">
|
||||||
|
{{ u.dose || '' }}{{ u.unit || '' }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="unmatchedTokensText(u)"
|
||||||
|
class="chip-tokens"
|
||||||
|
>
|
||||||
|
tok:{{ unmatchedTokensText(u) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
!ocrResult.matched.length && !ocrResult.unmatched.length
|
||||||
|
"
|
||||||
|
class="ocr-img-empty"
|
||||||
|
>
|
||||||
|
暂无药品对照结果
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="briefPreview" class="preview-body">
|
||||||
|
<pre class="json-pre preview-brief">{{ briefPreview }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-else class="ocr-img-empty">
|
||||||
|
当前场景暂无结构化预览,请切换到「JSON」查看原始内容
|
||||||
|
</div>
|
||||||
|
</TabPane>
|
||||||
|
<TabPane key="json" tab="JSON">
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 gap-3"
|
||||||
|
:class="isOcrScene ? 'lg:grid-cols-3' : 'lg:grid-cols-2'"
|
||||||
|
>
|
||||||
<div class="json-panel">
|
<div class="json-panel">
|
||||||
<div class="json-title">输入快照</div>
|
<div class="json-title">输入快照</div>
|
||||||
<pre class="json-pre">{{ prettyJson(detail.input_snapshot) }}</pre>
|
<pre class="json-pre">{{
|
||||||
|
prettyJson(detail.input_snapshot)
|
||||||
|
}}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="isOcrScene" class="json-panel">
|
||||||
|
<div class="json-title">LLM 源数据</div>
|
||||||
|
<pre class="json-pre">{{
|
||||||
|
llmSourcePayload == null
|
||||||
|
? '(无)'
|
||||||
|
: typeof llmSourcePayload === 'string'
|
||||||
|
? llmSourcePayload
|
||||||
|
: prettyJson(llmSourcePayload)
|
||||||
|
}}</pre>
|
||||||
</div>
|
</div>
|
||||||
<div class="json-panel">
|
<div class="json-panel">
|
||||||
<div class="json-title">结果 JSON</div>
|
<div class="json-title">
|
||||||
<pre class="json-pre">{{ prettyJson(detail.result_json) }}</pre>
|
{{ isOcrScene ? '处理后结果' : '结果 JSON' }}
|
||||||
</div>
|
</div>
|
||||||
|
<pre class="json-pre">{{
|
||||||
|
prettyJson(
|
||||||
|
isOcrScene ? processedResultJson : detail.result_json,
|
||||||
|
)
|
||||||
|
}}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabPane>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Spin>
|
</Spin>
|
||||||
@@ -406,6 +737,115 @@ function onArchiveClick() {
|
|||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ocr-img-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-thumb {
|
||||||
|
overflow: hidden;
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-img-empty {
|
||||||
|
padding: 16px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-body {
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-desc :deep(.ant-descriptions-item-label) {
|
||||||
|
width: 96px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-section-title.text-warn {
|
||||||
|
color: hsl(var(--destructive, var(--warning)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-drug-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-drug-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
border: 1px solid hsl(var(--border));
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-drug-chip.is-matched {
|
||||||
|
border-color: hsl(var(--primary) / 30%);
|
||||||
|
box-shadow: 0 0 4px hsl(var(--primary) / 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-drug-chip.is-unmatched {
|
||||||
|
border-color: hsl(0 84% 60% / 40%);
|
||||||
|
box-shadow: 0 0 4px hsl(0 84% 60% / 14%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-ai {
|
||||||
|
font-weight: 500;
|
||||||
|
color: hsl(var(--foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-dose {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-tokens {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-arrow {
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-match {
|
||||||
|
color: hsl(var(--primary));
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted {
|
||||||
|
font-size: 12px;
|
||||||
|
color: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-brief {
|
||||||
|
max-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
.kb-hits-panel {
|
.kb-hits-panel {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: hsl(var(--card, var(--background)));
|
background: hsl(var(--card, var(--background)));
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ export const AI_GENERATION_STATUS_OPTIONS = [
|
|||||||
export const AI_GENERATION_SCENE_OPTIONS = [
|
export const AI_GENERATION_SCENE_OPTIONS = [
|
||||||
{ label: '写病历', value: 'medical_record' },
|
{ label: '写病历', value: 'medical_record' },
|
||||||
{ label: '出方', value: 'prescription' },
|
{ label: '出方', value: 'prescription' },
|
||||||
|
{ label: '语音口述开方', value: 'voice_prescription' },
|
||||||
{ label: '每日简报', value: 'daily_brief' },
|
{ label: '每日简报', value: 'daily_brief' },
|
||||||
|
{ label: '识图开方', value: 'prescription_image_ocr' },
|
||||||
|
{ label: '识图传方', value: 'transfer_image_ocr' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 是否命中业务缓存(简报按天缓存等) */
|
/** 是否命中业务缓存(简报按天缓存等) */
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 识图记录页专用常量(仅 OCR 两场景,数据仍读 xk_ai_generation)
|
||||||
|
*/
|
||||||
|
export const AI_IMAGE_OCR_SCENE_OPTIONS = [
|
||||||
|
{ label: '识图开方', value: 'prescription_image_ocr' },
|
||||||
|
{ label: '识图传方', value: 'transfer_image_ocr' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 默认拉全量识图场景(逗号分隔,后端 whereIn) */
|
||||||
|
export const AI_IMAGE_OCR_DEFAULT_SCENES = AI_IMAGE_OCR_SCENE_OPTIONS.map(
|
||||||
|
(o) => o.value,
|
||||||
|
).join(',');
|
||||||
|
|
||||||
|
export const AI_IMAGE_OCR_STATUS_OPTIONS = [
|
||||||
|
{ label: '进行中', value: 0 },
|
||||||
|
{ label: '成功', value: 1 },
|
||||||
|
{ label: '失败', value: 2 },
|
||||||
|
];
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { VbenFormProps } from '#/adapter/form';
|
||||||
|
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AI_IMAGE_OCR_DEFAULT_SCENES,
|
||||||
|
AI_IMAGE_OCR_SCENE_OPTIONS,
|
||||||
|
AI_IMAGE_OCR_STATUS_OPTIONS,
|
||||||
|
} from './constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识图记录搜索:场景仅 OCR 两枚举;未选场景时请求带 scenes 默认全集
|
||||||
|
*/
|
||||||
|
export const formOptions: VbenFormProps = {
|
||||||
|
collapsed: false,
|
||||||
|
commonConfig: {
|
||||||
|
labelWidth: 70,
|
||||||
|
componentProps: {
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
wrapperClass:
|
||||||
|
'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5',
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'RangePicker',
|
||||||
|
componentProps: {
|
||||||
|
format: 'YYYY-MM-DD',
|
||||||
|
valueFormat: 'YYYY-MM-DD',
|
||||||
|
},
|
||||||
|
defaultValue: undefined,
|
||||||
|
fieldName: 'search_time',
|
||||||
|
label: '时间范围',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Input',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '名称 / 错误摘要',
|
||||||
|
allowClear: true,
|
||||||
|
},
|
||||||
|
defaultValue: '',
|
||||||
|
fieldName: 'keyword',
|
||||||
|
label: '关键词',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Select',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '全部识图场景',
|
||||||
|
allowClear: true,
|
||||||
|
options: AI_IMAGE_OCR_SCENE_OPTIONS,
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
defaultValue: undefined,
|
||||||
|
fieldName: 'scene',
|
||||||
|
label: '场景',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'Select',
|
||||||
|
componentProps: {
|
||||||
|
placeholder: '状态',
|
||||||
|
allowClear: true,
|
||||||
|
options: AI_IMAGE_OCR_STATUS_OPTIONS,
|
||||||
|
class: 'w-full',
|
||||||
|
},
|
||||||
|
defaultValue: undefined,
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '状态',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showCollapseButton: false,
|
||||||
|
submitButtonOptions: {
|
||||||
|
content: '查询',
|
||||||
|
},
|
||||||
|
submitOnChange: true,
|
||||||
|
submitOnEnter: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表单值 → 接口参数;未选单场景时强制 scenes=两条 OCR
|
||||||
|
*/
|
||||||
|
export function normalizeImageOcrFilters(formValues: Record<string, any> = {}) {
|
||||||
|
const params: Record<string, any> = { ...formValues };
|
||||||
|
const range = params.search_time;
|
||||||
|
delete params.search_time;
|
||||||
|
if (Array.isArray(range) && range.length === 2 && range[0] && range[1]) {
|
||||||
|
params.start_time = dayjs(range[0]).startOf('day').unix();
|
||||||
|
params.end_time = dayjs(range[1]).endOf('day').unix();
|
||||||
|
}
|
||||||
|
const scene = String(params.scene || '').trim();
|
||||||
|
if (scene) {
|
||||||
|
params.scene = scene;
|
||||||
|
delete params.scenes;
|
||||||
|
} else {
|
||||||
|
delete params.scene;
|
||||||
|
params.scenes = AI_IMAGE_OCR_DEFAULT_SCENES;
|
||||||
|
}
|
||||||
|
const cleaned: Record<string, any> = {};
|
||||||
|
Object.entries(params).forEach(([k, v]) => {
|
||||||
|
if (v !== '' && v !== undefined && v !== null) {
|
||||||
|
cleaned[k] = v;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import type { VxeGridProps } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { getAiGenerationList } from '../../generation/api';
|
||||||
|
import { normalizeImageOcrFilters } from './search';
|
||||||
|
|
||||||
|
interface RowType {
|
||||||
|
id: number;
|
||||||
|
scene: string;
|
||||||
|
scene_txt: string;
|
||||||
|
name: string;
|
||||||
|
status: number;
|
||||||
|
provider: string;
|
||||||
|
model: string;
|
||||||
|
prompt_tokens: number;
|
||||||
|
completion_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
duration_ms: number;
|
||||||
|
match_drug_total: number;
|
||||||
|
match_drug_unmatched: number;
|
||||||
|
error_msg: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 识图记录表格:精简列,默认只查 OCR 场景
|
||||||
|
*/
|
||||||
|
export const gridOptions: VxeGridProps<RowType> = {
|
||||||
|
checkboxConfig: {
|
||||||
|
highlight: false,
|
||||||
|
},
|
||||||
|
columnConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
rowConfig: {
|
||||||
|
useKey: true,
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{ field: 'id', align: 'left', title: 'ID', width: 80 },
|
||||||
|
{ field: 'scene_txt', align: 'left', title: '场景', width: 100 },
|
||||||
|
{ field: 'name', align: 'left', title: '名称', minWidth: 160 },
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
align: 'left',
|
||||||
|
title: '状态',
|
||||||
|
width: 90,
|
||||||
|
slots: { default: 'status' },
|
||||||
|
},
|
||||||
|
{ field: 'provider', align: 'left', title: '供应商', width: 100 },
|
||||||
|
{ field: 'model', align: 'left', title: '模型', minWidth: 120 },
|
||||||
|
{
|
||||||
|
field: 'total_tokens',
|
||||||
|
align: 'right',
|
||||||
|
title: 'Tokens',
|
||||||
|
width: 100,
|
||||||
|
slots: { default: 'tokens' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'duration_ms',
|
||||||
|
align: 'right',
|
||||||
|
title: '耗时',
|
||||||
|
width: 90,
|
||||||
|
slots: { default: 'duration' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'match_drug_unmatched',
|
||||||
|
align: 'left',
|
||||||
|
title: '药名匹配',
|
||||||
|
width: 120,
|
||||||
|
slots: { default: 'match' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'error_msg',
|
||||||
|
align: 'left',
|
||||||
|
title: '错误',
|
||||||
|
minWidth: 160,
|
||||||
|
showOverflow: true,
|
||||||
|
},
|
||||||
|
{ field: 'created_at', align: 'left', title: '创建时间', width: 170 },
|
||||||
|
{
|
||||||
|
type: 'html',
|
||||||
|
title: '操作',
|
||||||
|
slots: { default: 'action' },
|
||||||
|
width: 100,
|
||||||
|
fixed: 'right',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
keepSource: true,
|
||||||
|
pagerConfig: {},
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({ page }, formValues) => {
|
||||||
|
const params = {
|
||||||
|
page: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
...normalizeImageOcrFilters(formValues || {}),
|
||||||
|
};
|
||||||
|
return await getAiGenerationList(params);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toolbarConfig: {
|
||||||
|
search: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
89
apps/web-antd/src/views/system/ai/image-ocr-record/index.vue
Normal file
89
apps/web-antd/src/views/system/ai/image-ocr-record/index.vue
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 识图记录:独立菜单页,只看 prescription_image_ocr / transfer_image_ocr
|
||||||
|
* 数据复用 xk_ai_generation + ai-generation/list;详情复用 AI 生成记录弹窗
|
||||||
|
*/
|
||||||
|
import { Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Tag } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { TableAction } from '#/components/table-action';
|
||||||
|
|
||||||
|
import DetailModal from '../generation/components/detail-modal.vue';
|
||||||
|
import { formatDurationMs } from '../generation/utils/format';
|
||||||
|
import { formOptions } from './config/search';
|
||||||
|
import { gridOptions as baseGridOptions } from './config/table';
|
||||||
|
|
||||||
|
defineOptions({ name: 'AiImageOcrRecord' });
|
||||||
|
|
||||||
|
const [Grid] = useVbenVxeGrid({
|
||||||
|
formOptions,
|
||||||
|
gridOptions: baseGridOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [DetailModalComp, detailModalApi] = useVbenModal({
|
||||||
|
connectedComponent: DetailModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 打开详情(只读;打分/归档按钮若点到会 emit,本页不挂二级弹窗) */
|
||||||
|
function openDetail(row: any) {
|
||||||
|
detailModalApi.setData({ id: Number(row?.id || 0) });
|
||||||
|
detailModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status: number) {
|
||||||
|
if (status === 1) return 'success';
|
||||||
|
if (status === 2) return 'error';
|
||||||
|
return 'processing';
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusText(status: number) {
|
||||||
|
if (status === 1) return '成功';
|
||||||
|
if (status === 2) return '失败';
|
||||||
|
return '进行中';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height>
|
||||||
|
<DetailModalComp />
|
||||||
|
<Grid>
|
||||||
|
<template #status="{ row }">
|
||||||
|
<Tag :color="statusColor(Number(row.status))">
|
||||||
|
{{ statusText(Number(row.status)) }}
|
||||||
|
</Tag>
|
||||||
|
</template>
|
||||||
|
<template #tokens="{ row }">
|
||||||
|
{{ row.total_tokens || 0 }}
|
||||||
|
</template>
|
||||||
|
<template #duration="{ row }">
|
||||||
|
{{ formatDurationMs(Number(row.duration_ms || 0)) }}
|
||||||
|
</template>
|
||||||
|
<template #match="{ row }">
|
||||||
|
<span
|
||||||
|
v-if="Number(row.match_drug_total) > 0"
|
||||||
|
:class="
|
||||||
|
Number(row.match_drug_unmatched) > 0
|
||||||
|
? 'text-[hsl(var(--destructive,var(--warning)))]'
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
未匹配 {{ row.match_drug_unmatched || 0 }}/{{ row.match_drug_total }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-xs text-[hsl(var(--muted-foreground))]">—</span>
|
||||||
|
</template>
|
||||||
|
<template #action="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '详情',
|
||||||
|
type: 'link',
|
||||||
|
onClick: () => openDetail(row),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
@@ -59,15 +59,15 @@ export const reportFormProps: VbenFormProps = {
|
|||||||
component: 'RadioGroup',
|
component: 'RadioGroup',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
options: [
|
options: [
|
||||||
{ label: '对公账户', value: '1' },
|
// { label: '对公账户', value: '1' },
|
||||||
{ label: '法人账户', value: '2' },
|
// { label: '法人账户', value: '2' },
|
||||||
{ label: '授权对公账户', value: '3' },
|
{ label: '授权对公账户', value: '3' },
|
||||||
{ label: '授权个人账户', value: '4' },
|
{ label: '授权个人账户', value: '4' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
fieldName: 'bank_account_type',
|
fieldName: 'bank_account_type',
|
||||||
label: '账户类型',
|
label: '账户类型',
|
||||||
defaultValue: '2',
|
defaultValue: '4',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
component: 'VbenInput',
|
component: 'VbenInput',
|
||||||
|
|||||||
@@ -111,14 +111,14 @@ export const modalFormProps: VbenFormProps = {
|
|||||||
rules: 'required',
|
rules: 'required',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 别名选填:无别名也可保存,后端 syncAliases 空串会清空别名表
|
||||||
component: 'VbenInput',
|
component: 'VbenInput',
|
||||||
componentProps: {
|
componentProps: {
|
||||||
placeholder: '请输入药品别名',
|
placeholder: '选填,多个可用顿号/逗号分隔',
|
||||||
},
|
},
|
||||||
fieldName: 'drug_alias',
|
fieldName: 'drug_alias',
|
||||||
formItemClass: 'col-span-6',
|
formItemClass: 'col-span-6',
|
||||||
label: '药品别名',
|
label: '药品别名',
|
||||||
rules: 'required',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
component: 'VbenInput',
|
component: 'VbenInput',
|
||||||
|
|||||||
32
apps/web-antd/src/views/system/mes-open-app/api/index.ts
Normal file
32
apps/web-antd/src/views/system/mes-open-app/api/index.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { requestClient } from '#/api/request';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MES OpenAPI 开放应用管理
|
||||||
|
* 创建/重置密钥接口会返回一次明文 app_secret
|
||||||
|
*/
|
||||||
|
const prefix = 'mes-open-app/';
|
||||||
|
|
||||||
|
export async function getMesOpenAppList(data: any) {
|
||||||
|
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMesOpenAppDetail(id: number) {
|
||||||
|
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createMesOpenApp(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}create`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMesOpenApp(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}update`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteMesOpenApp(data: Record<string, any>) {
|
||||||
|
return requestClient.post<any>(`${prefix}delete`, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重置签名密钥,返回一次明文 app_secret */
|
||||||
|
export async function resetMesOpenAppSecret(data: { id: number }) {
|
||||||
|
return requestClient.post<any>(`${prefix}reset-secret`, data);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* MES 开放应用新增/编辑弹窗
|
||||||
|
* 新增成功后通过 onCreated 回调把明文密钥交给父页展示;编辑不改密钥
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenForm } from '#/adapter/form';
|
||||||
|
|
||||||
|
import { createMesOpenApp, updateMesOpenApp } from '../api';
|
||||||
|
import { modalFormProps } from '../config/form';
|
||||||
|
|
||||||
|
const isUpdate = ref(false);
|
||||||
|
const gridApi = ref();
|
||||||
|
const onCreated = ref<
|
||||||
|
null | ((payload: { app_key: string; app_secret: string; name?: string }) => void)
|
||||||
|
>(null);
|
||||||
|
const [Form, formApi] = useVbenForm(modalFormProps);
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
fullscreenButton: false,
|
||||||
|
draggable: true,
|
||||||
|
onCancel() {
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
onConfirm: async () => {
|
||||||
|
const e = await formApi.validate();
|
||||||
|
if (!e.valid) return;
|
||||||
|
const values = await formApi.getValues();
|
||||||
|
modalApi.setState({ loading: true, confirmLoading: true });
|
||||||
|
try {
|
||||||
|
if (isUpdate.value) {
|
||||||
|
await updateMesOpenApp(values);
|
||||||
|
message.success('保存成功');
|
||||||
|
gridApi.value?.query?.();
|
||||||
|
modalApi.close();
|
||||||
|
} else {
|
||||||
|
const res = await createMesOpenApp(values);
|
||||||
|
const data = (res as any)?.data || res || {};
|
||||||
|
message.success('创建成功,请立即复制密钥');
|
||||||
|
gridApi.value?.query?.();
|
||||||
|
modalApi.close();
|
||||||
|
if (data.app_key && data.app_secret) {
|
||||||
|
onCreated.value?.({
|
||||||
|
app_key: String(data.app_key),
|
||||||
|
app_secret: String(data.app_secret),
|
||||||
|
name: String(data.name || values.name || ''),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
modalApi.setState({ loading: false, confirmLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onOpenChange(isOpen: boolean) {
|
||||||
|
const openData = isOpen ? modalApi.getData<Record<string, any>>() || {} : {};
|
||||||
|
gridApi.value = isOpen ? openData.gridApi : null;
|
||||||
|
onCreated.value = isOpen
|
||||||
|
? typeof openData.onCreated === 'function'
|
||||||
|
? openData.onCreated
|
||||||
|
: null
|
||||||
|
: null;
|
||||||
|
if (!isOpen) {
|
||||||
|
formApi.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { values, update } = openData;
|
||||||
|
isUpdate.value = !!update;
|
||||||
|
if (values && update) {
|
||||||
|
formApi.setValues({ ...values });
|
||||||
|
} else {
|
||||||
|
formApi.reset();
|
||||||
|
formApi.setValues({ status: 1, remark: '' });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Modal :title="`${isUpdate ? '编辑' : '新增'}MES开放应用`" class="w-[520px]">
|
||||||
|
<Form />
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* 密钥一次性展示弹窗:创建/重置后提示复制,关闭后不可再看明文
|
||||||
|
*/
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Alert, Button, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
|
||||||
|
|
||||||
|
const titleText = ref('请保存密钥');
|
||||||
|
const appKey = ref('');
|
||||||
|
const appSecret = ref('');
|
||||||
|
const appName = ref('');
|
||||||
|
|
||||||
|
const [Modal, modalApi] = useVbenModal({
|
||||||
|
fullscreenButton: false,
|
||||||
|
draggable: true,
|
||||||
|
showCancelButton: false,
|
||||||
|
confirmText: '我已复制并关闭',
|
||||||
|
onConfirm() {
|
||||||
|
modalApi.close();
|
||||||
|
},
|
||||||
|
onOpenChange(isOpen: boolean) {
|
||||||
|
if (!isOpen) {
|
||||||
|
appKey.value = '';
|
||||||
|
appSecret.value = '';
|
||||||
|
appName.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = modalApi.getData<Record<string, any>>() || {};
|
||||||
|
titleText.value = String(data.title || '请保存密钥');
|
||||||
|
appKey.value = String(data.app_key || '');
|
||||||
|
appSecret.value = String(data.app_secret || '');
|
||||||
|
appName.value = String(data.name || '');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 复制文本到剪贴板 */
|
||||||
|
async function copyText(text: string, tip: string) {
|
||||||
|
if (!text) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
message.success(tip);
|
||||||
|
} catch {
|
||||||
|
message.error('复制失败,请手动选中复制');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Modal :title="titleText" class="w-[560px]">
|
||||||
|
<Alert
|
||||||
|
type="warning"
|
||||||
|
show-icon
|
||||||
|
class="mb-4"
|
||||||
|
message="密钥仅展示一次,关闭后无法再次查看明文。请立即复制并妥善保管。"
|
||||||
|
/>
|
||||||
|
<div v-if="appName" class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
|
||||||
|
应用:{{ appName }}
|
||||||
|
</div>
|
||||||
|
<div class="cred-card mb-3">
|
||||||
|
<div class="mb-1 text-xs text-[hsl(var(--muted-foreground))]">App Key</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<code class="break-all text-sm text-[hsl(var(--foreground))]">{{
|
||||||
|
appKey
|
||||||
|
}}</code>
|
||||||
|
<Button size="small" @click="copyText(appKey, 'App Key 已复制')">
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="cred-card">
|
||||||
|
<div class="mb-1 text-xs text-[hsl(var(--muted-foreground))]">
|
||||||
|
App Secret
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<SensitiveText
|
||||||
|
class="min-w-0 flex-1 break-all text-sm"
|
||||||
|
:record="{ app_secret: appSecret }"
|
||||||
|
field="app_secret"
|
||||||
|
/>
|
||||||
|
<Button size="small" @click="copyText(appSecret, 'App Secret 已复制')">
|
||||||
|
复制
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.cred-card {
|
||||||
|
border: 1px solid hsl(var(--primary) / 30%);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: hsl(var(--card, var(--background)));
|
||||||
|
box-shadow: 0 0 6px hsl(var(--primary) / 18%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** MES 开放应用状态选项 */
|
||||||
|
|
||||||
|
export const STATUS_OPTIONS = [
|
||||||
|
{ label: '启用', value: 1 },
|
||||||
|
{ label: '禁用', value: 0 },
|
||||||
|
];
|
||||||
46
apps/web-antd/src/views/system/mes-open-app/config/form.ts
Normal file
46
apps/web-antd/src/views/system/mes-open-app/config/form.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import type { VbenFormProps } from '#/adapter/form';
|
||||||
|
|
||||||
|
import { STATUS_OPTIONS } from './constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用表单:只填名称/备注/状态,密钥由后端生成
|
||||||
|
*/
|
||||||
|
export const modalFormProps: VbenFormProps = {
|
||||||
|
wrapperClass: 'grid-cols-12',
|
||||||
|
commonConfig: {
|
||||||
|
formItemClass: 'col-span-12',
|
||||||
|
componentProps: { class: 'w-full' },
|
||||||
|
},
|
||||||
|
layout: 'horizontal',
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
fieldName: 'id',
|
||||||
|
label: 'ID',
|
||||||
|
dependencies: { show: false, triggerFields: ['id'] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
fieldName: 'name',
|
||||||
|
label: '应用名称',
|
||||||
|
rules: 'required',
|
||||||
|
componentProps: { placeholder: '如:某某医院 HIS' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
fieldName: 'remark',
|
||||||
|
label: '备注',
|
||||||
|
componentProps: { placeholder: '对接说明(可选)' },
|
||||||
|
defaultValue: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenSelect',
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '状态',
|
||||||
|
rules: 'selectRequired',
|
||||||
|
componentProps: { options: STATUS_OPTIONS },
|
||||||
|
defaultValue: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
showDefaultActions: false,
|
||||||
|
};
|
||||||
232
apps/web-antd/src/views/system/mes-open-app/index.vue
Normal file
232
apps/web-antd/src/views/system/mes-open-app/index.vue
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/**
|
||||||
|
* MES OpenAPI 开放应用管理
|
||||||
|
* 后台创建应用并生成 app_key / app_secret,供第三方 HMAC 调用中转接口
|
||||||
|
*/
|
||||||
|
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||||
|
|
||||||
|
import { ref } from 'vue';
|
||||||
|
|
||||||
|
import { Page, useVbenModal } from '@vben/common-ui';
|
||||||
|
|
||||||
|
import { Tag, message } from 'ant-design-vue';
|
||||||
|
|
||||||
|
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||||
|
import { TableAction } from '#/components/table-action';
|
||||||
|
|
||||||
|
import {
|
||||||
|
deleteMesOpenApp,
|
||||||
|
getMesOpenAppList,
|
||||||
|
resetMesOpenAppSecret,
|
||||||
|
} from './api';
|
||||||
|
import FormModal from './components/modal.vue';
|
||||||
|
import SecretModal from './components/secret-modal.vue';
|
||||||
|
import { STATUS_OPTIONS } from './config/constants';
|
||||||
|
|
||||||
|
defineOptions({ name: 'MesOpenApp' });
|
||||||
|
|
||||||
|
const hasTopTableDropDownActions = ref(false);
|
||||||
|
|
||||||
|
const [Grid, gridApi] = useVbenVxeGrid({
|
||||||
|
formOptions: {
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
fieldName: 'name',
|
||||||
|
label: '应用名称',
|
||||||
|
componentProps: { placeholder: '名称模糊搜' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenInput',
|
||||||
|
fieldName: 'app_key',
|
||||||
|
label: 'App Key',
|
||||||
|
componentProps: { placeholder: '精确/模糊' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: 'VbenSelect',
|
||||||
|
fieldName: 'status',
|
||||||
|
label: '状态',
|
||||||
|
componentProps: { allowClear: true, options: STATUS_OPTIONS },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
gridOptions: {
|
||||||
|
checkboxConfig: { highlight: true, labelField: '' },
|
||||||
|
columns: [
|
||||||
|
{ type: 'checkbox', width: 60 },
|
||||||
|
{ field: 'id', title: 'ID', width: 80 },
|
||||||
|
{ field: 'name', title: '应用名称', minWidth: 140 },
|
||||||
|
{ field: 'app_key', title: 'App Key', minWidth: 220 },
|
||||||
|
{
|
||||||
|
field: 'app_secret_mask',
|
||||||
|
title: 'App Secret',
|
||||||
|
minWidth: 140,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
field: 'status',
|
||||||
|
title: '状态',
|
||||||
|
width: 90,
|
||||||
|
slots: { default: 'status' },
|
||||||
|
},
|
||||||
|
{ field: 'remark', title: '备注', minWidth: 140 },
|
||||||
|
{ field: 'created_at', title: '创建时间', width: 170 },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
slots: { default: 'action' },
|
||||||
|
width: 220,
|
||||||
|
fixed: 'right',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
keepSource: true,
|
||||||
|
pagerConfig: {},
|
||||||
|
proxyConfig: {
|
||||||
|
ajax: {
|
||||||
|
query: async ({ page }: any, formValues: any) => {
|
||||||
|
return await getMesOpenAppList({
|
||||||
|
page: page.currentPage,
|
||||||
|
pageSize: page.pageSize,
|
||||||
|
...formValues,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
height: 'auto',
|
||||||
|
toolbarConfig: {
|
||||||
|
search: true,
|
||||||
|
refresh: true,
|
||||||
|
slots: { buttons: 'toolbar-actions' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gridEvents: {
|
||||||
|
checkboxChange() {
|
||||||
|
hasTopTableDropDownActions.value =
|
||||||
|
gridApi.grid.getCheckboxRecords().length > 0;
|
||||||
|
},
|
||||||
|
checkboxAll() {
|
||||||
|
hasTopTableDropDownActions.value =
|
||||||
|
gridApi.grid.getCheckboxRecords().length > 0;
|
||||||
|
},
|
||||||
|
} as VxeGridListeners<any>,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [FormModalComp, formModalApi] = useVbenModal({
|
||||||
|
connectedComponent: FormModal,
|
||||||
|
});
|
||||||
|
const [SecretModalComp, secretModalApi] = useVbenModal({
|
||||||
|
connectedComponent: SecretModal,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 创建成功后弹出一次性密钥 */
|
||||||
|
function onAppCreated(payload: {
|
||||||
|
app_key: string;
|
||||||
|
app_secret: string;
|
||||||
|
name?: string;
|
||||||
|
}) {
|
||||||
|
secretModalApi.setData({
|
||||||
|
title: '应用已创建,请保存密钥',
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
secretModalApi.open();
|
||||||
|
}
|
||||||
|
|
||||||
|
const showModal = (data: any = {}, isUpdate = false) => {
|
||||||
|
formModalApi.setData({
|
||||||
|
values: data,
|
||||||
|
update: isUpdate,
|
||||||
|
gridApi,
|
||||||
|
onCreated: onAppCreated,
|
||||||
|
});
|
||||||
|
formModalApi.open();
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 重置密钥并展示一次明文 */
|
||||||
|
async function handleResetSecret(row: any) {
|
||||||
|
try {
|
||||||
|
const res = await resetMesOpenAppSecret({ id: row.id });
|
||||||
|
const data = res?.data || res || {};
|
||||||
|
message.success('密钥已重置');
|
||||||
|
gridApi.query();
|
||||||
|
secretModalApi.setData({
|
||||||
|
title: '密钥已重置,请保存新密钥',
|
||||||
|
app_key: data.app_key || row.app_key,
|
||||||
|
app_secret: data.app_secret,
|
||||||
|
name: row.name,
|
||||||
|
});
|
||||||
|
secretModalApi.open();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async (ids: number[]) => {
|
||||||
|
await deleteMesOpenApp({ ids });
|
||||||
|
message.success('删除成功');
|
||||||
|
gridApi.query();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Page auto-content-height>
|
||||||
|
<FormModalComp />
|
||||||
|
<SecretModalComp />
|
||||||
|
<Grid>
|
||||||
|
<template #toolbar-actions>
|
||||||
|
<TableAction
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '新增应用',
|
||||||
|
type: 'primary',
|
||||||
|
onClick: () => showModal({}, false),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
:drop-down-actions="
|
||||||
|
hasTopTableDropDownActions
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
label: '批量删除',
|
||||||
|
popConfirm: {
|
||||||
|
title: '确认删除选中应用?',
|
||||||
|
confirm: () => {
|
||||||
|
const rows = gridApi.grid.getCheckboxRecords();
|
||||||
|
handleDelete(rows.map((r: any) => r.id));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template #status="{ row }">
|
||||||
|
<Tag :color="row.status === 1 ? 'success' : 'default'">
|
||||||
|
{{ row.status_txt || (row.status === 1 ? '启用' : '禁用') }}
|
||||||
|
</Tag>
|
||||||
|
</template>
|
||||||
|
<template #action="{ row }">
|
||||||
|
<TableAction
|
||||||
|
:flex="false"
|
||||||
|
:actions="[
|
||||||
|
{
|
||||||
|
label: '编辑',
|
||||||
|
onClick: () => showModal(row, true),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '重置密钥',
|
||||||
|
popConfirm: {
|
||||||
|
title: '重置后旧密钥立即失效,确认?',
|
||||||
|
confirm: () => handleResetSecret(row),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '删除',
|
||||||
|
danger: true,
|
||||||
|
popConfirm: {
|
||||||
|
title: '确认删除该应用?',
|
||||||
|
confirm: () => handleDelete([row.id]),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Grid>
|
||||||
|
</Page>
|
||||||
|
</template>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user