7 Commits

Author SHA1 Message Date
李琦
bfe50a8d67 feat: 辅料功能、康正源拉取价格接口 2026-09-29 10:28:12 +08:00
李琦
f10158b454 feat: 语音开方、修复分账问题、识图成方、识图传方、优化订单卡片展示 2026-09-23 16:22:14 +08:00
李琦
6c6922c400 Merge remote-tracking branch 'origin/main' into vben-dev 2026-09-14 15:31:32 +08:00
李琦
55a71bfbe3 feat: 语音开方 2026-09-14 15:19:31 +08:00
李琦
443e501da5 feat: 语音开方 2026-08-31 16:35:57 +08:00
李琦
c2040c2b15 feat: 更新前端时间范围组件默认开始时间和结束时间的秒 2026-08-27 17:00:51 +08:00
李琦
0d18089f71 feat: 包邮费用设置 2026-08-27 09:38:11 +08:00
117 changed files with 16021 additions and 870 deletions

View File

@@ -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"

View File

@@ -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>

View File

@@ -0,0 +1,5 @@
/**
* 中药煎法/用法气泡选择器
*/
export { default as ChineseDrugWayBubble } from './chinese-drug-way-bubble.vue';
export type { DrugWayOption } from './chinese-drug-way-bubble.vue';

View File

@@ -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 {

View File

@@ -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>

View File

@@ -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"
/> />

View 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>

View File

@@ -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>

View File

@@ -0,0 +1,4 @@
/**
* 处方原图独立预览浮窗
*/
export { default as OcrOriginImageFloat } from './OcrOriginImageFloat.vue';

View File

@@ -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>

View File

@@ -0,0 +1,4 @@
/**
* 处方原图对照模块(内嵌布局栏,非浮窗)
*/
export { default as OcrOriginImagePanel } from './OcrOriginImagePanel.vue';

View File

@@ -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;
} }

View File

@@ -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)) {

View 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 };
}

View File

@@ -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';
/** /**

View File

@@ -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;
} }

View File

@@ -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,
); );

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 链颜色语义一致 */

View File

@@ -102,6 +102,7 @@ export const gridOptions: VxeGridProps<RowType> = {
], ],
keepSource: true, keepSource: true,
pagerConfig: {}, pagerConfig: {},
height: 'auto',
columnConfig: { columnConfig: {
useKey: true, useKey: true,
}, },

View File

@@ -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>

View File

@@ -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',

View 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`, {});
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,
};

View 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>

View File

@@ -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);
}

View File

@@ -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', // 必填验证
}, },
{ {
// 保健食品编号字段:必填,保健食品的唯一编号 // 保健食品编号字段:必填,保健食品的唯一编号

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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 = [];
} }
}, },
}); });

View File

@@ -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();
}; };

View File

@@ -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;

View File

@@ -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 });
}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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;
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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));
}
}

View File

@@ -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>

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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 },
});
}

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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' } },

View File

@@ -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>

View File

@@ -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;
} }

View File

@@ -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>

View File

@@ -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),

View File

@@ -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);
}

View File

@@ -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
变动中结算/补入备注净额;&gt;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>

View File

@@ -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,
};

View File

@@ -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,
};

View File

@@ -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>

View File

@@ -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`);
}

View File

@@ -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>

View File

@@ -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)"
/> />

View File

@@ -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>

View 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],
};
}

View File

@@ -157,7 +157,7 @@ function dakuanStatusPill(status: number) {
<template> <template>
<Page <Page
auto-content-height auto-content-height
description="提现前请确认好您的打款账户" description="提现前请确认打款账户,并可查看提现规则"
title="提现管理" title="提现管理"
> >
<OrderDetailModal /> <OrderDetailModal />

View File

@@ -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 访问日志操作用户搜索
*/ */

View File

@@ -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>

View 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 } });
}

View File

@@ -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>

View File

@@ -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,
};

View 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 },
};

View 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>

View File

@@ -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)));

View File

@@ -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' },
]; ];
/** 是否命中业务缓存(简报按天缓存等) */ /** 是否命中业务缓存(简报按天缓存等) */

View File

@@ -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 },
];

View File

@@ -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;
}

View File

@@ -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,
},
};

View 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>

View File

@@ -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',

View File

@@ -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',

View 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);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,6 @@
/** MES 开放应用状态选项 */
export const STATUS_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
];

View 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,
};

View 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