feat: 辅料功能、康正源拉取价格接口

This commit is contained in:
李琦
2026-09-29 10:28:12 +08:00
parent f10158b454
commit bfe50a8d67
78 changed files with 12397 additions and 118 deletions

View File

@@ -24,7 +24,10 @@ import {
splitHighlight,
type HighlightPart,
} from '#/utils/dictSearchRank';
import { getProductListDoctorReception } from '#/views/doctor/doctor-reception/api';
import {
getProductListDoctorReception,
getUsedExcipientListDoctorReception,
} from '#/views/doctor/doctor-reception/api';
export type ChineseDrugBubbleItem = {
id: number;
@@ -83,6 +86,8 @@ const open = ref(false);
const searching = ref(false);
const highlightIndex = ref(0);
const options = ref<ChineseDrugBubbleItem[]>([]);
/** 当前列表是否「用过辅料」空搜结果(用于提示文案) */
const showingUsedExcipients = ref(false);
const scrollTop = ref(0);
const listViewportH = ref(200);
const panelStyle = ref<Record<string, string>>({});
@@ -217,24 +222,39 @@ function updatePanelPlacement() {
}
/**
* 远程搜药;空关键词不请求(与原中药 Select 一致)
* 远程搜药;空关键词时:辅料(type=8)拉「用过」列表,中药仍不请求
*/
async function fetchDrugs(kw: string) {
const name = String(kw || '').trim();
if (!name) {
// 非辅料空词不请求(与原中药 Select 一致)
if (!name && Number(props.type) !== 8) {
options.value = [];
showingUsedExcipients.value = false;
loading.value = false;
return;
}
loading.value = true;
try {
const res = await getProductListDoctorReception({
name,
type: props.type,
store_id: props.storeId || 0,
...(props.registerId > 0 ? { register_id: props.registerId } : {}),
});
const list = Array.isArray(res) ? res : [];
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({
name,
type: props.type,
store_id: props.storeId || 0,
...(props.registerId > 0 ? { register_id: props.registerId } : {}),
});
list = Array.isArray(res) ? res : [];
showingUsedExcipients.value = false;
}
const mapped: ChineseDrugBubbleItem[] = list.map((item: any) => {
const drug = item.drug || {};
return {
@@ -249,30 +269,33 @@ async function fetchDrugs(kw: string) {
},
};
});
options.value = rankByMatchScore(
mapped,
name,
(it) => drugNameOf(it),
(it) =>
[
it.drug?.pinyin_simple,
it.drug?.pinyin_full,
it.drug?.pinyin_display,
it.drug?.drug_alias,
it.matched_alias || it.drug?.matched_alias,
it.matched_alias_pinyin || it.drug?.matched_alias_pinyin,
it.drug?.search_digits,
it.drug?.specification,
]
.map((v) => String(v || ''))
.join(' '),
);
options.value = name
? rankByMatchScore(
mapped,
name,
(it) => drugNameOf(it),
(it) =>
[
it.drug?.pinyin_simple,
it.drug?.pinyin_full,
it.drug?.pinyin_display,
it.drug?.drug_alias,
it.matched_alias || it.drug?.matched_alias,
it.matched_alias_pinyin || it.drug?.matched_alias_pinyin,
it.drug?.search_digits,
it.drug?.specification,
]
.map((v) => String(v || ''))
.join(' '),
)
: mapped;
highlightIndex.value = options.value.length > 0 ? 0 : -1;
scrollTop.value = 0;
if (listRef.value) listRef.value.scrollTop = 0;
} catch (e) {
console.error('中药药名搜索失败:', e);
options.value = [];
showingUsedExcipients.value = false;
} finally {
loading.value = false;
nextTick(() => updatePanelPlacement());
@@ -289,11 +312,8 @@ function openPanel() {
searching.value = true;
nextTick(() => {
updatePanelPlacement();
if (keyword.value.trim()) {
debouncedFetch(keyword.value);
} else {
options.value = [];
}
// 有关键词走搜索;空词时辅料拉「用过」,中药保持空列表
debouncedFetch(keyword.value);
});
}
@@ -301,6 +321,7 @@ function closePanel(restoreDisplay = true) {
open.value = false;
searching.value = false;
options.value = [];
showingUsedExcipients.value = false;
highlightIndex.value = 0;
if (restoreDisplay) {
keyword.value = String(props.displayName || '');
@@ -463,6 +484,7 @@ defineExpose({
<span>↑↓←→ 循环切换 · Enter 选用 · Esc 关闭</span>
<span class="chinese-drug-name-bubble__count">
<template v-if="keyword.trim()">搜到 {{ options.length }} 条</template>
<template v-else-if="showingUsedExcipients">用过 {{ options.length }} 条</template>
<template v-else>输入药名搜索</template>
</span>
</div>
@@ -473,7 +495,9 @@ defineExpose({
:description="
keyword.trim()
? '未找到匹配药材'
: '请输入药名/别名/拼音/数字搜索'
: Number(type) === 8
? '暂无用过的辅料,请输入名称搜索'
: '请输入药名/别名/拼音/数字搜索'
"
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 { 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 =
@@ -27,7 +27,7 @@ const DRUG_PLACEHOLDER_IMAGE =
interface Props {
/**
* 药品类型
* 1-中药,2-西药
* 1-中药,2-西药,8-辅料
*/
type?: number;
/**
@@ -100,6 +100,11 @@ const searchResults = ref<any[]>([]);
*/
const showDropdown = ref(false);
/**
* 当前列表是否「用过辅料」空搜结果(用于提示)
*/
const showingUsedExcipients = ref(false);
/**
* 组件容器引用
*/
@@ -127,18 +132,59 @@ function resolveDropdownImage(item: any): string {
return item._image || DRUG_PLACEHOLDER_IMAGE;
}
/**
* 是否辅料单类型模式(空搜拉「用过」)
*/
function isExcipientSingleType(): boolean {
return props.types.length === 0 && Number(props.type) === 8;
}
/**
* 把门店/主库商品行打平成下拉展示字段
*/
function mapProductRows(res: any): any[] {
if (!Array.isArray(res)) return [];
return res.map((item: any) => {
const drug = item.drug || {};
return {
...item,
_id: item.id,
_drugId: item.drug_id || drug.id || item.id,
_drugName: drug.drug_name || item.drug_name || '',
_image: drug.image || item.image || '',
_specification: drug.specification || item.specification || '',
// 辅料等按药配置的开方快捷数量(逗号串),供开方侧解析成按钮
_quickNumbers: drug.quick_numbers || item.quick_numbers || '',
_supplier: drug.supplier?.name || item.supplier?.name || '',
_price: Number(item.price ?? 0),
// 已绑定配送仓:列表展示「多仓」标识
_hasDeliveryWarehouse: Number(item.has_delivery_warehouse ?? 0) === 1,
_timeId: drug.time_id || 0,
_typeId: drug.type_id || 0,
_frequencyId: drug.frequency_id || 0,
_unitId: drug.unit_id || 0,
_number: drug.number || 1,
_useNum: drug.useNum,
_useType: drug.useType,
_useFrequency: drug.useFrequency,
};
});
}
// ==================== 方法定义 ====================
/**
* 搜索药品
* @param keyword 搜索关键词
* @description 调用后端API搜索药品,支持按名称和拼音搜索;
* @description 调用后端API搜索药品;辅料(type=8)空关键词拉「用过」列表并展开;
* 传入 types 时允许空关键词拉取该类型全量列表(用于下拉展开)
*/
const searchDrugs = debounce(async (keyword: string) => {
// 单类型模式:空关键词清空;多类型模式:空关键词仍请求后端拉全量
if ((!keyword || keyword.length < 1) && props.types.length === 0) {
const kw = String(keyword || '').trim();
// 单类型非辅料:空关键词清空并收起
if (!kw && props.types.length === 0 && !isExcipientSingleType()) {
searchResults.value = [];
showingUsedExcipients.value = false;
showDropdown.value = false;
return;
}
@@ -147,49 +193,35 @@ const searchDrugs = debounce(async (keyword: string) => {
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 || {};
return {
...item,
_id: item.id,
_drugId: item.drug_id || drug.id || item.id,
_drugName: drug.drug_name || item.drug_name || '',
_image: drug.image || item.image || '',
_specification: drug.specification || item.specification || '',
_supplier: drug.supplier?.name || item.supplier?.name || '',
_price: Number(item.price ?? 0),
// 已绑定配送仓:列表展示「多仓」标识
_hasDeliveryWarehouse: Number(item.has_delivery_warehouse ?? 0) === 1,
_timeId: drug.time_id || 0,
_typeId: drug.type_id || 0,
_frequencyId: drug.frequency_id || 0,
_unitId: drug.unit_id || 0,
_number: drug.number || 1,
_useNum: drug.useNum,
_useType: drug.useType,
_useFrequency: drug.useFrequency,
};
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 {
searchResults.value = [];
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) {
console.error('搜索药品失败:', error);
searchResults.value = [];
showingUsedExcipients.value = false;
} finally {
isSearching.value = false;
}
@@ -208,7 +240,7 @@ function handleInput(e: Event) {
/**
* 处理输入框获得焦点
* @description 多类型模式聚焦即拉取列表并展开;单类型模式仅在已有结果时展示下拉
* @description 多类型 / 辅料:聚焦即拉取列表并展开;其它单类型仅在已有结果时展示下拉
*/
function handleFocus() {
if (props.types.length > 0) {
@@ -216,6 +248,12 @@ function handleFocus() {
showDropdown.value = true;
return;
}
// 辅料:聚焦空词直接拉「用过」并展开气泡
if (isExcipientSingleType()) {
searchDrugs(searchKeyword.value || '');
showDropdown.value = true;
return;
}
if (searchResults.value.length > 0) {
showDropdown.value = true;
}
@@ -231,6 +269,7 @@ function handleSelectDrug(drug: any) {
searchKeyword.value = '';
searchResults.value = [];
showDropdown.value = false;
showingUsedExcipients.value = false;
highlightIndex.value = -1;
if (props.isCard) {
selectedDrug.value = drug;
@@ -319,6 +358,7 @@ watch(
() => {
searchResults.value = [];
showDropdown.value = false;
showingUsedExcipients.value = false;
searchKeyword.value = '';
selectedDrug.value = null;
},
@@ -392,7 +432,7 @@ watch(
<!-- 加载中 -->
<div v-if="isSearching" class="drug-dropdown__loading">
<Spin size="small" />
<span>搜索中...</span>
<span>{{ showingUsedExcipients ? '加载用过辅料...' : '搜索中...' }}</span>
</div>
<!-- 无结果 -->
@@ -400,11 +440,21 @@ watch(
v-else-if="searchResults.length === 0"
class="drug-dropdown__empty"
>
暂无匹配药品
{{
showingUsedExcipients
? '暂无用过的辅料,请输入名称搜索'
: '暂无匹配药品'
}}
</div>
<!-- 结果列表 -->
<div v-else class="drug-dropdown__list">
<div
v-if="showingUsedExcipients"
class="drug-dropdown__hint"
>
最近用过的辅料
</div>
<div
v-for="(item, index) in searchResults"
:key="item._id"
@@ -505,6 +555,12 @@ watch(
background: transparent;
}
}
&__hint {
padding: 6px 12px 2px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
}
.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

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

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

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

@@ -502,7 +502,9 @@ function prescriptionStatusColor() {
<Descriptions.Item
: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>
{{
data.express_name || data.patient?.name || data.cancel_remark
@@ -513,12 +515,24 @@ function prescriptionStatusColor() {
<SensitiveText
:record="data.address"
field="mobile"
:fallback="{ record: data, field: 'patient_mobile' }"
:fallback="{ record: data, field: 'express_mobile' }"
/>
</Descriptions.Item>
<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 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"
>
<div class="font-medium">
<Tag v-if="Number(item.type) === 8" color="lime" class="mr-1">辅料</Tag>
【{{ item.drug?.drug_number }}】{{ item.drug_name }}
</div>
<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' },
6: { label: '非药品', color: 'cyan' },
7: { label: '医疗器械', color: 'geekblue' },
8: { label: '辅料', color: 'lime' },
};
/** 订单状态(status)映射,与列表原 if-else 链颜色语义一致 */

View File

@@ -1503,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"
>
<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 }}
</div>
<div class="mt-1 text-gray-600 dark:text-gray-400">

View File

@@ -44,14 +44,14 @@ export const modalFormProps: VbenFormProps = {
rules: 'required',
},
{
// 别名选填:无别名也可保存,后端 syncAliases 空串会清空别名表
component: 'VbenInput',
componentProps: {
placeholder: '请输入药品别名',
placeholder: '选填,多个可用顿号/逗号分隔',
},
fieldName: 'drug_alias',
formItemClass: 'col-span-6',
label: '药品别名',
rules: 'required',
},
{
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', // 必填验证
},
{
// 保健食品别名字段:必填,保健食品的其他名称
// 别名选填:无别名也可保存,后端 syncAliases 空串会清空别名表
component: 'VbenInput',
componentProps: {
placeholder: '请输入保健食品别名',
placeholder: '选填,多个可用顿号/逗号分隔',
},
fieldName: 'drug_alias',
formItemClass: 'col-span-6',
label: '保健食品别名',
rules: 'required', // 必填验证
},
{
// 保健食品编号字段:必填,保健食品的唯一编号

View File

@@ -121,14 +121,14 @@ export const modalFormProps: VbenFormProps = {
rules: 'required',
},
{
// 别名选填:无别名也可保存,后端 syncAliases 空串会清空别名表
component: 'VbenInput',
componentProps: {
placeholder: '请输入器械别名',
placeholder: '选填,多个可用顿号/逗号分隔',
},
fieldName: 'drug_alias',
formItemClass: 'col-span-6',
label: '器械别名',
rules: 'required',
},
{
component: 'VbenInput',

View File

@@ -121,14 +121,14 @@ export const modalFormProps: VbenFormProps = {
rules: 'required',
},
{
// 别名选填:无别名也可保存,后端 syncAliases 空串会清空别名表
component: 'VbenInput',
componentProps: {
placeholder: '请输入产品别名',
placeholder: '选填,多个可用顿号/逗号分隔',
},
fieldName: 'drug_alias',
formItemClass: 'col-span-6',
label: '产品别名',
rules: 'required',
},
{
component: 'VbenInput',

View File

@@ -134,14 +134,14 @@ export const modalFormProps: VbenFormProps = {
rules: 'required',
},
{
// 别名选填:无别名也可保存,后端 syncAliases 空串会清空别名表
component: 'VbenInput',
componentProps: {
placeholder: '请输入服务包别名',
placeholder: '选填,多个可用顿号/逗号分隔',
},
fieldName: 'drug_alias',
formItemClass: 'col-span-6',
label: '服务包别名',
rules: 'required',
},
{
component: 'VbenInput',

View File

@@ -125,14 +125,14 @@ export const modalFormProps: VbenFormProps = {
rules: 'required',
},
{
// 别名选填:无别名也可保存,后端 syncAliases 空串会清空别名表
component: 'VbenInput',
componentProps: {
placeholder: '请输入药品别名',
placeholder: '选填,多个可用顿号/逗号分隔',
},
fieldName: 'drug_alias',
formItemClass: 'col-span-6',
label: '药品别名',
rules: 'required',
},
{
component: 'VbenInput',

View File

@@ -4,7 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
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 {
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,18 @@ export async function getProductListDoctorReception(data: any) {
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 });
}
/**
* 复用/常用方填方前对比中药快照价与当前门店价
* 为什么:供货比例变更后关系表已更新,快照价不一致时要让医生确认是否按最新价计算
@@ -702,3 +714,26 @@ export async function confirmTransferImageOcrApi(data: Record<string, any>) {
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

@@ -36,6 +36,12 @@ import {
PERSONAL_HISTORY_OPTIONS,
PRESENT_HISTORY_OPTIONS,
} 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,供接诊页分情况引导 */
export type CreateUserPatientResult = {
@@ -109,6 +115,11 @@ function buildEmptyForm() {
guardian_id_card: '',
guardian_mobile: '',
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=有 时带出
* 收货地址:有详细地址才带省市区字段,后端据此建 yii_address
*/
function buildSubmitPayload() {
const f = form.value;
const menstrualOk = showMenstrualHistory.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 {
...f,
...rest,
waive_register_fee: f.waive_register_fee ? 1 : 0,
menstrual_status: menstrualOk ? f.menstrual_status : 0,
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 : [],
epidemic_history: f.epidemic_status === 1 ? f.epidemic_history : [],
personal_history: f.personal_status === 1 ? f.personal_history : [],
...shipPayload,
};
}
@@ -276,6 +310,15 @@ const [Modal, modalApi] = useVbenModal({
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;
modalApi.setState({ confirmLoading: true });
try {
@@ -393,6 +436,42 @@ function onBirthdayChange(v: Dayjs | string | null) {
</Col>
</Row>
</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>
<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;
}
/**
* 解析辅料列表:优先 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>
<template>
@@ -334,6 +350,30 @@ function parseRecipeContent(content: Record<string, unknown> | string) {
}}</span>
</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">
<p>
用法:每日{{ recipe.consumption }}次,共{{ recipe.dosage }}剂

View File

@@ -871,27 +871,35 @@ function onChineseDrugWayChange(index: number, id: number) {
*/
const totalProductCost = computed(() => {
const expected = appliedSpecialPrescriptionExpectedTotal.value;
let herbTotal = 0;
if (
expected != null
&& !Number.isNaN(Number(expected))
&& Number(expected) > 0
) {
return Number(expected);
}
if (currentDrugs.value.length === 0) {
return 0;
}
// 中药:与后端 PrescriptionService / format_price 口径一致
if (activeCategory.value === 1) {
return calculateChineseProductPriceLikeBackend(
herbTotal = Number(expected);
} else if (currentDrugs.value.length === 0) {
herbTotal = 0;
} else if (activeCategory.value === 1) {
// 中药:与后端 PrescriptionService / format_price 口径一致
herbTotal = calculateChineseProductPriceLikeBackend(
currentDrugs.value,
dosage.value,
);
} else {
herbTotal = currentDrugs.value.reduce(
(sum, drug) => sum + drug.price * (drug.select_number || 1),
0,
);
}
return currentDrugs.value.reduce(
(sum, drug) => sum + drug.price * (drug.select_number || 1),
0,
);
// 辅料同样按 单价×数量×剂数 累加(仅中药方)
if (activeCategory.value === 1 && currentExcipients.value.length > 0) {
herbTotal += calculateChineseProductPriceLikeBackend(
currentExcipients.value,
dosage.value,
);
}
return herbTotal;
});
/**
* 计算总价
@@ -1167,6 +1175,8 @@ function clearReceptionDraft(registerId?: number) {
const id = registerId || getRegisterId();
if (id) {
localStorage.removeItem(getDraftKey(id));
// 与 getExcipientStorageKey 同串,避免函数声明顺序问题
localStorage.removeItem(`doctorReceptionExcipients_${id}`);
clearMedicalRecordDraft(String(id));
}
}
@@ -1186,12 +1196,44 @@ const getStorageKey = (registerId: number | string | undefined, category: number
return `prescriptionData_${category}_${registerId}`;
};
/** 辅料草稿独立 key,避免污染药味数组 */
const getExcipientStorageKey = (registerId: number | string | undefined) => {
return `doctorReceptionExcipients_${registerId}`;
};
/** 持久化当前辅料清单(仅中药方有意义) */
const saveExcipientsToLocalStorage = () => {
const registerId = getRegisterId();
if (!registerId) return;
localStorage.setItem(
getExcipientStorageKey(registerId),
JSON.stringify(activeCategory.value === 1 ? currentExcipients.value : []),
);
};
/** 从本地恢复辅料;非中药 Tab 清空内存列表 */
const loadExcipientsFromLocalStorage = (registerId?: number | string) => {
const rid = registerId ?? getRegisterId();
if (!rid || activeCategory.value !== 1) {
currentExcipients.value = [];
return;
}
try {
const raw = localStorage.getItem(getExcipientStorageKey(rid)) || '[]';
const list = JSON.parse(raw);
currentExcipients.value = Array.isArray(list) ? list : [];
} catch {
currentExcipients.value = [];
}
};
// 保存到本地存储(按类型分开存储)
const saveToLocalStorage = () => {
localStorage.setItem(
getStorageKey(getRegisterId(), activeCategory.value),
JSON.stringify(currentDrugs.value),
);
saveExcipientsToLocalStorage();
saveReceptionDraft();
};
@@ -1250,11 +1292,14 @@ const updateLocalStorage = () => {
getStorageKey(getRegisterId(), activeCategory.value),
JSON.stringify(currentDrugs.value),
);
saveExcipientsToLocalStorage();
saveReceptionDraft();
getCurrentDrugs();
};
const currentDrugs = ref([]);
/** 中药方辅料(type=8),与药味分开维护,提交时走 excipients */
const currentExcipients = ref<any[]>([]);
/** P4:最近一次 AI 导入的生成记录 ID,发处方时回传后端做采纳率/修改率统计(清空/切换/发送后重置) */
const aiRxGenerationId = ref(0);
/**
@@ -1264,6 +1309,7 @@ const getCurrentDrugs = () => {
currentDrugs.value = JSON.parse(
localStorage.getItem(getStorageKey(getRegisterId(), activeCategory.value)) || '[]',
);
loadExcipientsFromLocalStorage();
};
getCurrentDrugs();
@@ -1456,6 +1502,16 @@ const sendPrescription = () => {
patient: activePatient.value,
// 当前处方中的药品列表
drugs: currentDrugs.value,
// 中药辅料(type=8),不进药味表
excipients: activeCategory.value === 1
? currentExcipients.value.map((e: any) => ({
id: e.id,
drug_id: e.id,
number: e.number || e.select_number || 1,
price: e.price,
buy_price: e.buy_price,
}))
: [],
// 诊断信息
diagnosis: diagnosis.value,
// 医嘱信息
@@ -2524,6 +2580,7 @@ function tabChange(id) {
getStorageKey(registerId, activeCategory.value),
JSON.stringify(currentDrugs.value),
);
saveExcipientsToLocalStorage();
saveReceptionDraft();
localStorage.setItem(`activeCategory${registerId}`, String(id));
activeCategory.value = id;
@@ -2533,6 +2590,7 @@ function tabChange(id) {
currentDrugs.value = JSON.parse(
localStorage.getItem(getStorageKey(registerId, id)) || '[]',
);
loadExcipientsFromLocalStorage(registerId);
if (id === 1) {
checkAndShowTransferTip();
}
@@ -2886,6 +2944,37 @@ async function applyHistoricalPrescriptionDetail(detail: any): Promise<boolean>
});
currentDrugs.value = mappedRows;
// 辅料:优先 recipe.excipients,否则处方顶层;无则清空防残留
const rawExcipients = Array.isArray(rep0.excipients)
? rep0.excipients
: (Array.isArray(content.excipients) ? content.excipients : []);
const mappedExcipients = rawExcipients
.map((ex: any) => {
const drugId = Number(ex.drug_id ?? ex.id);
return {
index_id: ex.id ?? drugId,
id: drugId,
drug_name: ex.name || ex.drug_name || '',
specification: ex.specification || '',
quick_numbers: parseQuickNumbers(ex.quick_numbers),
number: Number(ex.number ?? 1) || 1,
price: Number(ex.price ?? 0),
buy_price: ex.buy_price ?? null,
unit: ex.unit || { name: 'g' },
type: 8,
select_number: Number(ex.number ?? 1) || 1,
};
})
.filter((row: any) => row.id > 0);
if (mappedExcipients.length > 0) {
await alignChineseDrugsToLatestPrice({
registerId: pid,
storeId: Number(myStoreId.value || 0),
drugs: mappedExcipients,
});
}
currentExcipients.value = mappedExcipients;
ruleType.value = rep0.deployment === 2 ? 1 : 2;
dosage.value = Number(rep0.dosage ?? 7);
dayDosage.value = Number(rep0.consumption ?? 2);
@@ -2917,6 +3006,8 @@ async function applyHistoricalPrescriptionDetail(detail: any): Promise<boolean>
return false;
}
currentDrugs.value = mapped;
// 非中药复用清空辅料,避免残留
currentExcipients.value = [];
}
diagnosis.value =
@@ -2932,6 +3023,7 @@ async function applyHistoricalPrescriptionDetail(detail: any): Promise<boolean>
getStorageKey(pid, activeCategory.value),
JSON.stringify(currentDrugs.value),
);
saveExcipientsToLocalStorage();
localStorage.setItem(`activeCategory${pid}`, String(activeCategory.value));
saveReceptionDraft();
getCurrentDrugs();
@@ -3196,6 +3288,7 @@ watch(
*/
function clearPrescriptionCart(showToast = true) {
currentDrugs.value = [];
currentExcipients.value = [];
// 处方清空后 AI 导入内容已不存在,解除采纳关联
aiRxGenerationId.value = 0;
diagnosis.value = '';
@@ -3674,6 +3767,105 @@ function handleSimpleProductSelect(drug: any) {
message.success(`已将${newProduct.drug_name}添加到清单中!`);
}
/**
* 中药方添加辅料(type=8),数量按克/单位,合计乘剂数
*/
function handleExcipientSelect(drug: any) {
if (guardSpecialPrescriptionCartEdit()) return;
const drugId = drug._drugId || drug.drug_id || drug.drug?.id;
if (currentExcipients.value.some((item) => item.id === drugId)) {
message.warning('该辅料已在列表中');
return;
}
// 主档 quick_numbers 解析为快捷按钮;无配置则只留手输
const quickNumbers = parseQuickNumbers(
drug._quickNumbers ||
drug.drug?.quick_numbers ||
drug.quick_numbers ||
'',
);
currentExcipients.value.push({
index_id: drug._id || drug.id,
id: drugId,
drug_name: drug._drugName || drug.drug?.drug_name || drug.drug_name,
specification:
drug._specification ||
drug.drug?.specification ||
drug.specification ||
'',
quick_numbers: quickNumbers,
number: 1,
price: drug._price || drug.price,
buy_price: drug._buyPrice || drug.buy_price || drug.drug_store_relations?.buy_price,
type: 8,
select_number: 1,
});
saveExcipientsToLocalStorage();
message.success('已添加辅料');
}
/**
* 解析主档快捷数量串(逗号/顿号分隔)为数字数组
* 为什么:开方侧按药展示快捷按钮,非法值忽略
*/
function parseQuickNumbers(raw: unknown): number[] {
if (Array.isArray(raw)) {
return raw
.map((v) => Number(v))
.filter((n) => Number.isFinite(n) && n > 0);
}
const s = String(raw || '').trim();
if (!s) return [];
const seen = new Set<number>();
const out: number[] = [];
for (const part of s.split(/[,,、;\s]+/)) {
const n = Number(part.trim());
if (!Number.isFinite(n) || n <= 0 || seen.has(n)) continue;
seen.add(n);
out.push(n);
}
return out;
}
/**
* 手输/步进器改辅料数量
*/
function setExcipientNumber(index: number, value: number | null) {
const row = currentExcipients.value[index];
if (!row) return;
const n = Number(value);
row.number = Number.isFinite(n) && n > 0 ? n : 1;
row.select_number = row.number;
saveExcipientsToLocalStorage();
}
/**
* 点击主档快捷数量填入
*/
function applyExcipientQuickNumber(index: number, qty: number) {
if (isSpecialPrescriptionCartLocked.value) return;
setExcipientNumber(index, qty);
}
function incrementExcipient(index: number) {
const row = currentExcipients.value[index];
if (!row) return;
row.number = Number(row.number || 1) + 1;
row.select_number = row.number;
}
function decrementExcipient(index: number) {
const row = currentExcipients.value[index];
if (!row || Number(row.number || 1) <= 1) return;
row.number = Number(row.number || 1) - 1;
row.select_number = row.number;
}
function removeExcipient(index: number) {
currentExcipients.value.splice(index, 1);
saveExcipientsToLocalStorage();
}
const newDrugInfo = ref({});
/**
@@ -4767,6 +4959,61 @@ function onStoreSelectOpenChange(open: boolean) {
</div>
</div>
<!-- 中药辅料:挂在药味下方、制剂配置上方 -->
<div v-if="activeCategory === 1" class="mb-5 rounded border border-[hsl(var(--border))] p-3">
<div class="mb-2 flex items-center justify-between font-medium text-[hsl(var(--foreground))]">
<span>辅料(可选)</span>
<span class="text-sm font-normal text-[hsl(var(--muted-foreground))]">单价×数量×剂数,分账全归平台</span>
</div>
<DrugSearchSelect
v-if="!isSpecialPrescriptionCartLocked"
:type="8"
:store-id="myStoreId"
:register-id="getRegisterId()"
placeholder="搜索辅料名称添加"
class="mb-3"
@select="handleExcipientSelect"
/>
<div
v-for="(item, index) in currentExcipients"
:key="item.id"
class="mb-2 flex flex-wrap items-center justify-between gap-3 text-sm"
>
<span class="min-w-0 flex-1 truncate">
{{ item.drug_name }}
<span
v-if="item.specification"
class="ml-1 text-[hsl(var(--muted-foreground))]"
>({{ item.specification }})</span>
</span>
<div class="quantity-control flex flex-wrap items-center gap-2">
<Button type="primary" size="small" :disabled="isSpecialPrescriptionCartLocked" @click="decrementExcipient(index)">-</Button>
<InputNumber
:value="item.number"
:min="0.01"
:step="1"
:disabled="isSpecialPrescriptionCartLocked"
size="small"
class="w-[88px]"
@update:value="(v) => setExcipientNumber(index, v as number | null)"
/>
<Button type="primary" size="small" :disabled="isSpecialPrescriptionCartLocked" @click="incrementExcipient(index)">+</Button>
<template v-if="item.quick_numbers?.length">
<Tag
v-for="qty in item.quick_numbers"
:key="`${item.id}-${qty}`"
class="m-0 cursor-pointer"
:color="Number(item.number) === Number(qty) ? 'blue' : undefined"
@click="applyExcipientQuickNumber(index, qty)"
>{{ qty }}</Tag>
</template>
</div>
<span class="w-20 text-right">¥{{ item.price }}</span>
<Button v-if="!isSpecialPrescriptionCartLocked" type="link" size="small" @click="removeExcipient(index)">删除</Button>
</div>
<div v-if="currentExcipients.length === 0" class="text-sm text-[hsl(var(--muted-foreground))]">未添加辅料</div>
</div>
<!-- 诊断和医嘱 -->
<div class="diagnosis-area">
<ChineseMedicineConfig
@@ -5163,8 +5410,14 @@ function onStoreSelectOpenChange(open: boolean) {
font-size: 20px;
font-weight: bold;
position: absolute;
left: 10px;
/* 略靠左,并预留两位数宽度,避免「10、」被药名输入框遮住 */
left: 2px;
top: 0;
width: 32px;
text-align: right;
line-height: 1.2;
z-index: 1;
pointer-events: none;
}
/* 中药卡片:第一行只放药名 + 克数(含单位),煎法下放到第二行 */
@@ -5181,7 +5434,8 @@ function onStoreSelectOpenChange(open: boolean) {
gap: 4px;
width: 100%;
max-width: 100%;
padding-left: 28px;
/* 与 .card-index 两位数宽度对齐(left 2 + width 32 + 间隙) */
padding-left: 38px;
/* 右侧留给删除按钮,避免克数单位被图标盖住 */
padding-right: 36px;
box-sizing: border-box;
@@ -5196,7 +5450,7 @@ function onStoreSelectOpenChange(open: boolean) {
justify-content: space-between;
gap: 4px 8px;
margin-top: 2px;
padding-left: 28px;
padding-left: 38px;
min-height: 22px;
}

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

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

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

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

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

@@ -107,6 +107,44 @@ function selectedCandidateName(row: Record<string, any>) {
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({
title: '生成记录详情',
class: 'w-[min(1280px,96vw)]',
@@ -591,6 +629,12 @@ function onArchiveClick() {
<span class="chip-dose">
{{ u.dose || '' }}{{ u.unit || '' }}
</span>
<span
v-if="unmatchedTokensText(u)"
class="chip-tokens"
>
tok:{{ unmatchedTokensText(u) }}
</span>
</div>
</div>
</div>
@@ -612,17 +656,34 @@ function onArchiveClick() {
</div>
</TabPane>
<TabPane key="json" tab="JSON">
<div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
<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-title">输入快照</div>
<pre class="json-pre">{{
prettyJson(detail.input_snapshot)
}}</pre>
</div>
<div class="json-panel">
<div class="json-title">结果 JSON</div>
<div v-if="isOcrScene" class="json-panel">
<div class="json-title">LLM 源数据</div>
<pre class="json-pre">{{
prettyJson(detail.result_json)
llmSourcePayload == null
? '(无)'
: typeof llmSourcePayload === 'string'
? llmSourcePayload
: prettyJson(llmSourcePayload)
}}</pre>
</div>
<div class="json-panel">
<div class="json-title">
{{ isOcrScene ? '处理后结果' : '结果 JSON' }}
</div>
<pre class="json-pre">{{
prettyJson(
isOcrScene ? processedResultJson : detail.result_json,
)
}}</pre>
</div>
</div>
@@ -758,6 +819,15 @@ function onArchiveClick() {
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));
}

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

@@ -111,14 +111,14 @@ export const modalFormProps: VbenFormProps = {
rules: 'required',
},
{
// 别名选填:无别名也可保存,后端 syncAliases 空串会清空别名表
component: 'VbenInput',
componentProps: {
placeholder: '请输入药品别名',
placeholder: '选填,多个可用顿号/逗号分隔',
},
fieldName: 'drug_alias',
formItemClass: 'col-span-6',
label: '药品别名',
rules: 'required',
},
{
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>

View File

@@ -0,0 +1,119 @@
<script lang="ts" setup>
/**
* 诊所订单分账设置弹窗
* 用于诊所列表快捷入口、独立「诊所分账设置」页共用,只改类型与平台占比。
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Form, FormItem, InputNumber, RadioGroup, message } from 'ant-design-vue';
import { STORE_LEDGER_SPLIT_TYPE_OPTIONS } from '../config/constants';
import { updateStoreLedgerSplitApi } from '../api';
const storeId = ref(0);
const storeName = ref('');
const ledgerSplitType = ref(0);
const ledgerSplitPercent = ref(0);
const gridApiRef = ref<any>(null);
const showPercent = computed(() => Number(ledgerSplitType.value) === 1);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
if (!storeId.value) {
message.warning('缺少诊所 ID');
return;
}
const percent =
Number(ledgerSplitType.value) === 1
? Number(ledgerSplitPercent.value ?? 0)
: 0;
if (percent < 0 || percent > 100) {
message.warning('平台占比须在 0~100 之间');
return;
}
modalApi.setState({ loading: true, confirmLoading: true });
try {
await updateStoreLedgerSplitApi({
id: storeId.value,
ledger_split_type: Number(ledgerSplitType.value),
ledger_split_percent: percent,
});
message.success('保存成功');
gridApiRef.value?.query?.();
gridApiRef.value?.reload?.();
modalApi.close();
} catch (err) {
console.error(err);
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<{
gridApi?: any;
values?: Record<string, any>;
}>();
gridApiRef.value = data?.gridApi;
const values = data?.values || {};
storeId.value = Number(values.id || 0);
storeName.value = String(values.name || '');
ledgerSplitType.value = Number(values.ledger_split_type ?? 0);
ledgerSplitPercent.value = Number(values.ledger_split_percent ?? 0);
} else {
storeId.value = 0;
storeName.value = '';
ledgerSplitType.value = 0;
ledgerSplitPercent.value = 0;
gridApiRef.value = null;
}
},
});
</script>
<template>
<Modal :title="storeName ? `分账设置 - ${storeName}` : '分账设置'" class="w-[480px]">
<Form layout="vertical">
<FormItem label="订单分账类型" required>
<RadioGroup
v-model:value="ledgerSplitType"
:options="STORE_LEDGER_SPLIT_TYPE_OPTIONS"
/>
<div class="form-hint">
按比例仅对中药/颗粒订单生效;西药等仍走正常分账
</div>
</FormItem>
<FormItem v-if="showPercent" label="平台分账占比" required>
<InputNumber
v-model:value="ledgerSplitPercent"
:min="0"
:max="100"
:precision="2"
class="w-full"
placeholder="平台占实付百分比"
addon-after="%"
/>
<div class="form-hint">
平台占订单实付总额的百分比,其余归诊所;需手动调整
</div>
</FormItem>
</Form>
</Modal>
</template>
<style scoped>
.form-hint {
margin-top: 6px;
font-size: 12px;
line-height: 1.4;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,168 @@
<script lang="ts" setup>
/**
* 门店轮播图配置弹窗(平台管理员)
* 液态玻璃风格:支持上传、删除、拖拽排序;保存时全量同步到 yii_nav
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import UploadImageSortable from '#/components/form/components/upload-image-sortable.vue';
import { getStoreNavListApi, saveStoreNavUrlApi } from '../api';
defineOptions({
name: 'StoreNavModal',
});
const MAX_COUNT = 9;
const storeId = ref(0);
const storeName = ref('');
const urls = ref<string[]>([]);
const loading = ref(false);
async function loadNav() {
if (!storeId.value) return;
loading.value = true;
try {
const list = await getStoreNavListApi(storeId.value);
const rows = Array.isArray(list) ? list : list?.data || [];
urls.value = rows
.map((item: { pic?: string }) => String(item?.pic || '').trim())
.filter(Boolean);
} catch (err) {
console.error(err);
urls.value = [];
} finally {
loading.value = false;
}
}
const [Modal, modalApi] = useVbenModal({
title: '门店轮播图',
class: 'w-[640px] store-nav-modal',
draggable: true,
centered: true,
confirmText: '保存',
onConfirm: async () => {
if (!storeId.value) return;
modalApi.setState({ loading: true, confirmLoading: true });
try {
await saveStoreNavUrlApi({
store_id: storeId.value,
url: urls.value.filter(Boolean),
});
message.success('轮播图已保存');
modalApi.close();
} catch (err) {
console.error(err);
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen) {
if (!isOpen) {
storeId.value = 0;
storeName.value = '';
urls.value = [];
return;
}
const data = modalApi.getData<Record<string, any>>() || {};
storeId.value = Number(data.storeId || data.id || 0);
storeName.value = String(data.storeName || data.name || '');
loadNav();
},
});
</script>
<template>
<Modal>
<div class="nav-glass" :class="{ 'is-loading': loading }">
<div class="nav-glow" aria-hidden="true"></div>
<div class="nav-head">
<div class="nav-title">{{ storeName || '门店' }} · 轮播图</div>
<div class="nav-sub">
拖拽可调整顺序,最多 {{ MAX_COUNT }} 张;保存后患者端首页立即生效
</div>
</div>
<div class="nav-panel">
<UploadImageSortable
v-model="urls"
:multiple="true"
:max-count="MAX_COUNT"
/>
</div>
<div class="nav-foot">当前 {{ urls.length }} / {{ MAX_COUNT }} 张</div>
</div>
</Modal>
</template>
<style scoped>
.nav-glass {
position: relative;
padding: 4px 2px 8px;
overflow: hidden;
min-height: 220px;
}
.nav-glass.is-loading {
opacity: 0.65;
pointer-events: none;
}
.nav-glow {
pointer-events: none;
position: absolute;
top: -56px;
left: 50%;
width: 320px;
height: 160px;
margin-left: -160px;
border-radius: 50%;
background: radial-gradient(
ellipse,
hsl(var(--primary) / 22%) 0%,
transparent 70%
);
}
.nav-head {
position: relative;
margin-bottom: 16px;
}
.nav-title {
font-size: 15px;
font-weight: 600;
color: hsl(var(--foreground));
}
.nav-sub {
margin-top: 6px;
font-size: 13px;
line-height: 1.5;
color: hsl(var(--muted-foreground));
}
.nav-panel {
position: relative;
padding: 16px;
border-radius: 16px;
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);
}
.nav-foot {
position: relative;
margin-top: 12px;
text-align: right;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,22 @@
/**
* 诊所订单分账类型(与后端 StoreLedgerSplitTypeEnum 对齐)
* 0=正常分账;1=按比例(平台占实付总额百分比,仅中药/颗粒订单生效)
*/
export const STORE_LEDGER_SPLIT_TYPE_OPTIONS = [
{ label: '正常分账', value: 0 },
{ label: '按比例分账', value: 1 },
];
/**
* 列表 Tag 文案:正常分账 / 按比例X%
*/
export function storeLedgerSplitTagText(row: {
ledger_split_type?: number | string;
ledger_split_percent?: number | string;
}): string {
if (Number(row.ledger_split_type) === 1) {
const pct = Number(row.ledger_split_percent ?? 0);
return `按比例${pct}%`;
}
return '正常分账';
}

View File

@@ -0,0 +1,89 @@
<script lang="ts" setup>
/**
* 系统配置 - 辅料定价模式
* platform:门店直接用总仓价;store_ratio:按门店中药 z_buy/z_sale 比例折算
*/
import { onMounted, ref } from 'vue';
import { Button, Radio, message } from 'ant-design-vue';
import { getSystemConfigList, saveSystemConfig } from '../api';
defineOptions({ name: 'ExcipientPriceConfigPanel' });
const loading = ref(false);
const saving = ref(false);
/** 辅料定价模式 */
const mode = ref<'platform' | 'store_ratio'>('platform');
async function load() {
loading.value = true;
try {
const list = await getSystemConfigList();
const rows = Array.isArray(list) ? list : list?.data || [];
for (const row of rows) {
if (row.config_key === 'excipient_price_mode') {
mode.value = row.config_value === 'store_ratio' ? 'store_ratio' : 'platform';
}
}
} finally {
loading.value = false;
}
}
async function handleSave() {
saving.value = true;
try {
await saveSystemConfig([
{
config_key: 'excipient_price_mode',
config_value: mode.value,
value_type: 'string',
config_group: 'product',
description: '辅料定价模式:platform=平台统一,store_ratio=按门店中药比例',
},
]);
message.success('保存成功');
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
<div class="cfg-panel-body">
<div class="mb-6">
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">辅料门店价计算方式</div>
<p class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
影响辅料同步到门店仓时的销售价/供货价;分账时辅料金额始终全归平台。
</p>
<Radio.Group v-model:value="mode">
<Radio value="platform">平台统一定价(直接用总仓价)</Radio>
<Radio value="store_ratio">按门店中药比例(z_buy / z_sale)</Radio>
</Radio.Group>
</div>
</div>
<div class="cfg-panel-footer">
<Button type="primary" :loading="saving" @click="handleSave">保存</Button>
</div>
</div>
</template>
<style scoped>
.cfg-panel {
display: flex;
flex-direction: column;
min-height: 280px;
}
.cfg-panel-body {
flex: 1;
padding: 8px 4px 24px;
}
.cfg-panel-footer {
padding-top: 12px;
border-top: 1px solid hsl(var(--border));
}
</style>

View File

@@ -0,0 +1,181 @@
<script lang="ts" setup>
/**
* 系统配置 - 传方:进接诊 / 审方 / 小程序价格可见范围
* 价格门禁只约束诊所小程序;PC 审方端始终可见,不读这两项
*/
import { onMounted, ref, watch } from 'vue';
import { Button, Switch, message } from 'ant-design-vue';
import StoreMultiSearch from '#/components/form/components/store-multi-search.vue';
import { getSystemConfigList, saveSystemConfig } from '../api';
defineOptions({ name: 'ImageTransferConfigPanel' });
const loading = ref(false);
const saving = ref(false);
const toReception = ref(false);
const adminAudit = ref(true);
/** 全部诊所小程序可见传方报价 */
const priceVisibleAll = ref(false);
/** all=关时生效的门店白名单 */
const priceVisibleStoreIds = ref<number[]>([]);
function isOn(value: unknown) {
return value === '1' || value === 1 || value === true || value === 'true';
}
/** 把配置里的 json/数组/逗号串归一成 number[] */
function parseStoreIds(raw: unknown): number[] {
let list: unknown = raw;
if (typeof raw === 'string') {
const text = raw.trim();
if (!text) return [];
try {
list = JSON.parse(text);
} catch {
list = text.split(',');
}
}
if (!Array.isArray(list)) return [];
const ids: number[] = [];
for (const item of list) {
const n = Number(item);
if (Number.isFinite(n) && n > 0) ids.push(n);
}
return [...new Set(ids)];
}
async function load() {
loading.value = true;
try {
const list = await getSystemConfigList();
const rows = Array.isArray(list) ? list : list?.data || [];
for (const row of rows) {
if (row.config_key === 'image_transfer_to_reception') {
toReception.value = isOn(row.config_value);
}
if (row.config_key === 'image_transfer_admin_audit') {
adminAudit.value = isOn(row.config_value);
}
if (row.config_key === 'image_transfer_price_visible_all') {
priceVisibleAll.value = isOn(row.config_value);
}
if (row.config_key === 'image_transfer_price_visible_store_ids') {
priceVisibleStoreIds.value = parseStoreIds(row.config_value);
}
}
} finally {
loading.value = false;
}
}
/** 打开「全部可见」时清空白名单,避免误解成还要再勾门店 */
watch(priceVisibleAll, (on) => {
if (on) priceVisibleStoreIds.value = [];
});
/** 四个配置一起保存;门店名单存 json 数组字符串 */
async function handleSave() {
saving.value = true;
try {
await saveSystemConfig([
{
config_key: 'image_transfer_to_reception',
config_value: toReception.value ? '1' : '0',
value_type: 'bool',
config_group: 'image_transfer',
description: '识图传方确认后是否进入接诊',
},
{
config_key: 'image_transfer_admin_audit',
config_value: adminAudit.value ? '1' : '0',
value_type: 'bool',
config_group: 'image_transfer',
description: '不进接诊时是否需要后台审方',
},
{
config_key: 'image_transfer_price_visible_all',
config_value: priceVisibleAll.value ? '1' : '0',
value_type: 'bool',
config_group: 'image_transfer',
description: '识图传方小程序是否对全部诊所展示报价',
},
{
config_key: 'image_transfer_price_visible_store_ids',
config_value: JSON.stringify(
priceVisibleAll.value ? [] : priceVisibleStoreIds.value,
),
value_type: 'json',
config_group: 'image_transfer',
description: '识图传方价格可见门店ID白名单(json数组)',
},
]);
message.success('保存成功');
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
<div class="cfg-panel-body">
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
识图传方确认后是否进入接诊
</div>
<div class="mb-4 text-sm text-[hsl(var(--muted-foreground))]">
关闭后只生成传方订单,不挂号、不跳接诊
</div>
<Switch
v-model:checked="toReception"
checked-children="进接诊"
un-checked-children="不进"
/>
<div class="mb-2 mt-6 font-medium text-[hsl(var(--foreground))]">
不进接诊时是否需要后台审方
</div>
<div class="mb-4 text-sm text-[hsl(var(--muted-foreground))]">
关闭后用户提交即推 MES;驳回仍会退回诊所和上传人
</div>
<Switch
v-model:checked="adminAudit"
checked-children="需要"
un-checked-children="跳过"
/>
<div class="mb-2 mt-6 font-medium text-[hsl(var(--foreground))]">
小程序是否全部诊所可见传方价格
</div>
<div class="mb-4 text-sm text-[hsl(var(--muted-foreground))]">
开启后所有诊所医生/管理员都能看药价、代煎费、运费;关闭则仅下方白名单门店可见。PC
审方端始终可见,不受此开关影响。
</div>
<Switch
v-model:checked="priceVisibleAll"
checked-children="全部可见"
un-checked-children="按白名单"
/>
<div v-if="!priceVisibleAll" class="mt-6">
<div class="mb-2 font-medium text-[hsl(var(--foreground))]">
可看价格的诊所白名单
</div>
<div class="mb-3 text-sm text-[hsl(var(--muted-foreground))]">
未勾选任何诊所时,小程序端不展示报价(仍可正常传方)
</div>
<StoreMultiSearch
v-model:value="priceVisibleStoreIds"
placeholder="搜索并添加可看价格的诊所"
/>
</div>
</div>
<div class="cfg-panel-footer">
<Button type="primary" :loading="saving" @click="handleSave">保存本模块配置</Button>
</div>
</div>
</template>

View File

@@ -0,0 +1,379 @@
<script lang="ts" setup>
/**
* 系统配置 - 提现规则:审核流程步骤 + 规则条目结构化编辑
* 存 xk_system_config.withdrawal_rules(json),PC/小程序提现页读取
*/
import { onMounted, ref } from 'vue';
import { Button, Input, message } from 'ant-design-vue';
import { getSystemConfigByKeys, saveSystemConfig } from '../api';
defineOptions({ name: 'WithdrawalRulesConfigPanel' });
interface FlowStepRow {
key: string;
label: string;
_uid: string;
}
interface RuleRow {
key: string;
title: string;
desc: string;
_uid: string;
}
const DEFAULT_LEAD = '提现前请仔细阅读';
const DEFAULT_FOOT = '实际到账时间以银行处理进度为准';
const DEFAULT_FLOW: Array<{ key: string; label: string }> = [
{ key: 'apply', label: '提交申请' },
{ key: 'audit', label: '平台审核' },
{ key: 'pay', label: '发起打款' },
{ key: 'arrive', label: '到账' },
];
const DEFAULT_RULES: Array<{ key: string; title: string; desc: string }> = [
{
key: 'amount',
title: '可提现额度',
desc: '以账户「可提现余额」为准,单笔最低 50 元。',
},
{
key: 'time',
title: '提现时间',
desc: '全天可提交申请,不限制时段。',
},
{
key: 'daily',
title: '每日次数',
desc: '不限制每日提现次数。',
},
{
key: 'arrive',
title: '到账时间',
desc: '工作日审核通过后约 10 分钟内到账;节假日顺延至次日到账。',
},
];
const loading = ref(false);
const saving = ref(false);
const lead = ref(DEFAULT_LEAD);
const foot = ref(DEFAULT_FOOT);
const flowSteps = ref<FlowStepRow[]>([]);
const rules = ref<RuleRow[]>([]);
let uidSeq = 0;
function nextUid(prefix: string) {
uidSeq += 1;
return `${prefix}_${uidSeq}`;
}
function applyDefault() {
lead.value = DEFAULT_LEAD;
foot.value = DEFAULT_FOOT;
flowSteps.value = DEFAULT_FLOW.map((s) => ({
...s,
_uid: nextUid('flow'),
}));
rules.value = DEFAULT_RULES.map((r) => ({
...r,
_uid: nextUid('rule'),
}));
}
function parsePayload(raw: unknown) {
let data: any = raw;
if (typeof data === 'string') {
try {
data = JSON.parse(data);
} catch {
data = null;
}
}
if (!data || typeof data !== 'object') {
applyDefault();
return;
}
lead.value = String(data.lead || DEFAULT_LEAD);
foot.value = String(data.foot || DEFAULT_FOOT);
const steps = Array.isArray(data.flow_steps) ? data.flow_steps : [];
const items = Array.isArray(data.rules) ? data.rules : [];
flowSteps.value =
steps.length > 0
? steps.map((s: any, i: number) => ({
key: String(s?.key || `step_${i + 1}`),
label: String(s?.label || ''),
_uid: nextUid('flow'),
}))
: DEFAULT_FLOW.map((s) => ({ ...s, _uid: nextUid('flow') }));
rules.value =
items.length > 0
? items.map((r: any, i: number) => ({
key: String(r?.key || `rule_${i + 1}`),
title: String(r?.title || ''),
desc: String(r?.desc || ''),
_uid: nextUid('rule'),
}))
: DEFAULT_RULES.map((r) => ({ ...r, _uid: nextUid('rule') }));
}
async function load() {
loading.value = true;
try {
const res = await getSystemConfigByKeys(['withdrawal_rules']);
const map = res && typeof res === 'object' ? res : {};
parsePayload(map.withdrawal_rules);
} catch {
applyDefault();
} finally {
loading.value = false;
}
}
function addFlowStep() {
const n = flowSteps.value.length + 1;
flowSteps.value.push({
key: `step_${n}`,
label: '',
_uid: nextUid('flow'),
});
}
function removeFlowStep(uid: string) {
if (flowSteps.value.length <= 1) {
message.warning('至少保留一个流程步骤');
return;
}
flowSteps.value = flowSteps.value.filter((s) => s._uid !== uid);
}
function addRule() {
const n = rules.value.length + 1;
rules.value.push({
key: `rule_${n}`,
title: '',
desc: '',
_uid: nextUid('rule'),
});
}
function removeRule(uid: string) {
if (rules.value.length <= 1) {
message.warning('至少保留一条规则');
return;
}
rules.value = rules.value.filter((r) => r._uid !== uid);
}
function resetToDefault() {
applyDefault();
message.success('已恢复默认文案,请点击保存生效');
}
async function handleSave() {
const cleanedFlow = flowSteps.value
.map((s, i) => ({
key: (s.key || `step_${i + 1}`).trim() || `step_${i + 1}`,
label: s.label.trim(),
}))
.filter((s) => s.label);
const cleanedRules = rules.value
.map((r, i) => ({
key: (r.key || `rule_${i + 1}`).trim() || `rule_${i + 1}`,
title: r.title.trim(),
desc: r.desc.trim(),
}))
.filter((r) => r.title || r.desc);
if (!cleanedFlow.length) {
message.error('请至少填写一个审核流程步骤');
return;
}
if (!cleanedRules.length) {
message.error('请至少填写一条提现规则');
return;
}
saving.value = true;
try {
await saveSystemConfig([
{
config_key: 'withdrawal_rules',
config_value: JSON.stringify({
lead: lead.value.trim() || DEFAULT_LEAD,
foot: foot.value.trim() || DEFAULT_FOOT,
flow_steps: cleanedFlow,
rules: cleanedRules,
}),
value_type: 'json',
config_group: 'finance',
description: '提现规则:审核流程步骤 + 规则条目',
sort: 100,
},
]);
message.success('保存成功');
await load();
} finally {
saving.value = false;
}
}
onMounted(load);
</script>
<template>
<div class="cfg-panel" :class="{ 'opacity-60': loading }">
<div class="cfg-panel-body">
<div class="mb-1 font-medium text-[hsl(var(--foreground))]">提现规则文案</div>
<div class="mb-4 text-sm text-[hsl(var(--muted-foreground))]">
展示在 PC 提现管理与医生端小程序提现页;改完保存后客户端下次打开即可看到。
</div>
<div class="field-block">
<div class="field-label">引导语</div>
<Input v-model:value="lead" placeholder="提现前请仔细阅读" allow-clear />
</div>
<div class="field-block">
<div class="field-label">底部提示</div>
<Input
v-model:value="foot"
placeholder="实际到账时间以银行处理进度为准"
allow-clear
/>
</div>
<div class="section-hd">
<span class="section-title">审核流程</span>
<Button size="small" type="dashed" @click="addFlowStep">添加步骤</Button>
</div>
<div
v-for="(step, idx) in flowSteps"
:key="step._uid"
class="row-card"
>
<span class="row-idx">{{ idx + 1 }}</span>
<Input
v-model:value="step.label"
class="flex-1"
placeholder="步骤名称,如:平台审核"
allow-clear
/>
<Button type="link" danger size="small" @click="removeFlowStep(step._uid)">
删除
</Button>
</div>
<div class="section-hd mt-5">
<span class="section-title">规则条目</span>
<Button size="small" type="dashed" @click="addRule">添加规则</Button>
</div>
<div
v-for="(rule, idx) in rules"
:key="rule._uid"
class="rule-card"
>
<div class="rule-card-hd">
<span class="row-idx">{{ idx + 1 }}</span>
<Input
v-model:value="rule.title"
class="flex-1"
placeholder="规则标题,如:到账时间"
allow-clear
/>
<Button type="link" danger size="small" @click="removeRule(rule._uid)">
删除
</Button>
</div>
<Input.TextArea
v-model:value="rule.desc"
:rows="2"
placeholder="规则说明"
allow-clear
/>
</div>
</div>
<div class="cfg-panel-footer">
<Button @click="resetToDefault">恢复默认</Button>
<Button type="primary" :loading="saving" @click="handleSave">保存</Button>
</div>
</div>
</template>
<style scoped>
.cfg-panel {
display: flex;
flex-direction: column;
min-height: 360px;
}
.cfg-panel-body {
flex: 1;
padding: 4px 2px 24px;
}
.cfg-panel-footer {
display: flex;
justify-content: flex-end;
gap: 12px;
padding-top: 12px;
border-top: 1px solid hsl(var(--border));
}
.field-block {
margin-bottom: 16px;
}
.field-label {
margin-bottom: 6px;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.section-hd {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.section-title {
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
}
.row-card,
.rule-card {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
padding: 12px;
border-radius: 10px;
border: 1px solid hsl(var(--border) / 60%);
background: hsl(var(--muted) / 0.2);
}
.rule-card {
flex-direction: column;
align-items: stretch;
}
.rule-card-hd {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.row-idx {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 6px;
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%);
}
.flex-1 {
flex: 1;
min-width: 0;
}
.mt-5 {
margin-top: 20px;
}
</style>

View File

@@ -28,6 +28,7 @@ import MiniprogramConfigPanel from './components/miniprogram-config-panel.vue';
import ModuleTabsPanel from './components/module-tabs-panel.vue';
import OaNotifyConfigPanel from './components/oa-notify-config-panel.vue';
import PriceAdjustConfigPanel from './components/price-adjust-config-panel.vue';
import ExcipientPriceConfigPanel from './components/excipient-price-config-panel.vue';
import PublicAccountPayConfigPanel from './components/public-account-pay-config-panel.vue';
import SalespersonConfigPanel from './components/salesperson-config-panel.vue';
import WorkbenchConfigPanel from './components/workbench-config-panel.vue';
@@ -85,6 +86,11 @@ const menuItems: MenuItem[] = [
title: '订单调价',
component: PriceAdjustConfigPanel,
},
{
key: 'excipient_price',
title: '辅料定价',
component: ExcipientPriceConfigPanel,
},
{ key: 'logistics', title: '物流展示', component: LogisticsConfigPanel },
{
key: 'free_shipping',