fix: 接诊UI

This commit is contained in:
李琦
2026-08-07 20:24:49 +08:00
parent 33dcd3f5d8
commit 7a504433da
32 changed files with 2638 additions and 297 deletions

View File

@@ -0,0 +1,196 @@
<script lang="ts" setup>
/**
* 通用文件预览:图片用 Ant ImagePDF fetch→blob→iframe支持多文件切换
* 开票中心与素材库复用,避免两套预览逻辑
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { Button, Image, Modal, Spin, message } from 'ant-design-vue';
const props = withDefaults(
defineProps<{
/** 是否显示 */
open: boolean;
/** 文件 URL 列表 */
files?: string[];
/** 弹窗标题前缀 */
title?: string;
}>(),
{
files: () => [],
title: '文件预览',
},
);
const emit = defineEmits<{
(e: 'update:open', v: boolean): void;
}>();
const currentIndex = ref(0);
const previewIsImage = ref(false);
const previewImageUrl = ref('');
const previewPdfUrl = ref('');
const previewLoading = ref(false);
const visible = computed({
get: () => props.open,
set: (v: boolean) => emit('update:open', v),
});
const currentUrl = computed(() => props.files?.[currentIndex.value] || '');
const displayTitle = computed(() => {
const name = (currentUrl.value || '').split('/').pop() || '';
const total = props.files?.length || 0;
if (total > 1) {
return `${props.title}${currentIndex.value + 1}/${total}${name ? ' - ' + name : ''}`;
}
return name ? `${props.title} - ${name}` : props.title;
});
function isImageUrl(url: string) {
return /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(url || '');
}
function revokePdfBlob() {
if (previewPdfUrl.value && previewPdfUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewPdfUrl.value);
}
previewPdfUrl.value = '';
}
/**
* 加载当前文件到预览区
*/
async function loadCurrent() {
const url = currentUrl.value;
revokePdfBlob();
if (!url) {
message.info('暂无文件');
visible.value = false;
return;
}
if (isImageUrl(url)) {
previewIsImage.value = true;
previewImageUrl.value = url;
previewLoading.value = false;
return;
}
previewIsImage.value = false;
previewLoading.value = true;
try {
const resp = await fetch(url);
if (!resp.ok) throw new Error('文件加载失败');
const blob = await resp.blob();
const pdfBlob =
blob.type && blob.type !== 'application/octet-stream'
? blob
: new Blob([blob], { type: 'application/pdf' });
previewPdfUrl.value = URL.createObjectURL(pdfBlob);
} catch {
message.error('预览失败,请尝试下载后查看');
visible.value = false;
} finally {
previewLoading.value = false;
}
}
function prevFile() {
if (currentIndex.value > 0) {
currentIndex.value -= 1;
}
}
function nextFile() {
if (props.files && currentIndex.value < props.files.length - 1) {
currentIndex.value += 1;
}
}
function handleClose() {
visible.value = false;
}
watch(
() => [props.open, props.files] as const,
([open]) => {
if (open) {
currentIndex.value = 0;
loadCurrent();
} else {
revokePdfBlob();
}
},
);
watch(currentIndex, () => {
if (props.open) {
loadCurrent();
}
});
onBeforeUnmount(() => {
revokePdfBlob();
});
</script>
<template>
<Modal
v-model:open="visible"
:title="displayTitle"
:footer="null"
width="860px"
destroy-on-close
@cancel="handleClose"
>
<div class="preview-wrap">
<div v-if="(files?.length || 0) > 1" class="nav">
<Button size="small" :disabled="currentIndex <= 0" @click="prevFile">上一份</Button>
<Button
size="small"
:disabled="currentIndex >= (files?.length || 0) - 1"
@click="nextFile"
>
下一份
</Button>
</div>
<Spin :spinning="previewLoading">
<div v-if="previewIsImage" class="img-box">
<Image :src="previewImageUrl" :preview="true" style="max-width: 100%" />
</div>
<iframe
v-else-if="previewPdfUrl"
:src="previewPdfUrl"
class="pdf-frame"
title="pdf-preview"
/>
</Spin>
</div>
</Modal>
</template>
<style scoped>
.preview-wrap {
min-height: 240px;
color: hsl(var(--foreground));
}
.nav {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.img-box {
text-align: center;
padding: 8px;
border-radius: 8px;
background: hsl(var(--muted) / 0.25);
border: 1px solid hsl(var(--border));
}
.pdf-frame {
width: 100%;
height: 70vh;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--background));
}
</style>

View File

@@ -287,7 +287,7 @@ onBeforeUnmount(() => {
<Icon icon="ant-design:inbox-outlined" class="text-4xl text-blue-500" />
</p>
<p class="ant-upload-text">{{ tip }}</p>
<p class="ant-upload-hint text-gray-400">
<p class="ant-upload-hint text-muted-foreground">
支持拖拽或点击选择单文件不超过 {{ maxSizeMb }}MB
</p>
</UploadDragger>
@@ -296,7 +296,7 @@ onBeforeUnmount(() => {
<div
v-for="item in fileList"
:key="item.uid"
class="flex items-center gap-2 rounded border border-gray-100 px-3 py-2 text-sm"
class="flex items-center gap-2 rounded border border-border bg-muted/20 px-3 py-2 text-sm text-foreground"
>
<Icon
:icon="
@@ -311,12 +311,12 @@ onBeforeUnmount(() => {
<button
v-if="item.url"
type="button"
class="min-w-0 flex-1 truncate text-left text-blue-600"
class="min-w-0 flex-1 truncate text-left text-primary"
@click="previewItem(item)"
>
{{ item.name }}
</button>
<span v-else class="min-w-0 flex-1 truncate text-gray-500">{{ item.name }}</span>
<span v-else class="min-w-0 flex-1 truncate text-muted-foreground">{{ item.name }}</span>
<Button
type="link"
size="small"
@@ -343,7 +343,7 @@ onBeforeUnmount(() => {
/>
{{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }}
</Button>
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500">
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500 dark:text-orange-400">
监听中请将文件或截图 Ctrl+V 粘贴
</span>
</div>
@@ -356,14 +356,14 @@ onBeforeUnmount(() => {
destroy-on-close
@cancel="onPreviewClose"
>
<div v-if="previewLoading" class="py-16 text-center text-gray-500">加载中</div>
<div v-else-if="previewIsImage" class="flex justify-center">
<div v-if="previewLoading" class="py-16 text-center text-muted-foreground">加载中</div>
<div v-else-if="previewIsImage" class="flex justify-center rounded border border-border bg-muted/25 p-2">
<Image :src="previewImageUrl" :preview="true" style="max-height: 70vh" />
</div>
<iframe
v-else-if="previewPdfUrl"
:src="previewPdfUrl"
class="h-[70vh] w-full border-0"
class="h-[70vh] w-full rounded border border-border bg-background"
title="文件预览"
/>
</Modal>

View File

@@ -1,6 +1,6 @@
/**
* VIP 权限判断工具(前端口子)
* 当前从登录态 userInfo.vip 读取;后续可扩展为按 storeId 请求接口
* 默认从登录态 userInfo.vip 读取;在线复诊等场景可传入挂号履约诊所的 vip
*/
import { useUserStore } from '@vben/stores';
@@ -23,11 +23,24 @@ export function getCurrentVip(): StoreVipInfo | null {
return ((userStore.userInfo as any)?.vip as StoreVipInfo) || null;
}
/** 是否拥有指定权限码 */
export function hasVipPermission(code: string): boolean {
/**
* 是否拥有指定权限码
* @param code 权限码
* @param vipOrPermissions 可选:挂号侧 vip 对象,或 permissions 字符串数组;不传则用登录态
*/
export function hasVipPermission(
code: string,
vipOrPermissions?: StoreVipInfo | string[] | null,
): boolean {
if (!code) return false;
const vip = getCurrentVip();
const list = vip?.permissions;
let list: string[] | undefined;
if (Array.isArray(vipOrPermissions)) {
list = vipOrPermissions;
} else if (vipOrPermissions && typeof vipOrPermissions === 'object') {
list = vipOrPermissions.permissions;
} else {
list = getCurrentVip()?.permissions;
}
return Array.isArray(list) && list.includes(code);
}
@@ -35,12 +48,11 @@ export function hasVipPermission(code: string): boolean {
* 等级是否不低于目标(按 level_weight若无 weight 则仅精确匹配编码时返回 true
* 简化:仅当当前 level_code 与 minCode 相同,或 weight 足够时通过
*/
export function levelAtLeast(minCode: string): boolean {
const vip = getCurrentVip();
if (!vip?.level_code) return false;
if (vip.level_code === minCode) return true;
// 无完整等级表时,仅做字符串相等判断;业务侧应以后端 Gate 为准
const weight = Number(vip.level_weight ?? 0);
export function levelAtLeast(minCode: string, vip?: StoreVipInfo | null): boolean {
const current = vip ?? getCurrentVip();
if (!current?.level_code) return false;
if (current.level_code === minCode) return true;
const weight = Number(current.level_weight ?? 0);
const minWeight = parseLevelWeight(minCode);
if (minWeight >= 0) {
return weight >= minWeight;

View File

@@ -12,6 +12,7 @@ import {
MedicineBoxOutlined,
MinusOutlined,
PlusOutlined,
RobotOutlined,
SaveOutlined,
UserOutlined,
} from '@ant-design/icons-vue';
@@ -56,7 +57,7 @@ import {
import PatientBriefBar from '#/views/doctor/doctor-reception/components/PatientBriefBar.vue';
import PatientInfoDrawer from '#/views/doctor/doctor-reception/components/PatientInfoDrawer.vue';
import PatientPrescriptionHistoryDrawer from '#/views/doctor/doctor-reception/components/PatientPrescriptionHistoryDrawer.vue';
// import { getTraditionalChineseMedicineAllApi } from '#/views/doctor/doctor-reception/api';
import { getTraditionalChineseMedicineAllApi } from '#/views/doctor/doctor-reception/api';
// 导入子组件
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import WesternModal from '#/views/doctor/doctor-reception/components/WesternModal.vue';
@@ -64,6 +65,7 @@ import SimpleProductModal from '#/views/doctor/doctor-reception/components/Simpl
// 常用方选择弹窗组件
import CommonPrescriptionModal from '#/views/doctor/doctor-reception/components/CommonPrescriptionModal.vue';
import GoldenFormulaModal from '#/views/doctor/doctor-reception/components/GoldenFormulaModal.vue';
import AiPrescriptionDrawer from '#/views/doctor/doctor-reception/components/AiPrescriptionDrawer.vue';
// 信息提示模态框
import InfoModal from '#/components/modal/InfoModal.vue';
// 药品搜索选择组件
@@ -89,11 +91,18 @@ const medicalRecordPanelRef = ref<InstanceType<typeof MedicalRecordPanel> | null
// 使用 Pinia store须在依赖它的 computed/watch 之前初始化)
const prescriptionStore = usePrescriptionStore();
/**
* 在线复诊 VIP优先挂号履约诊所返回的 vip否则回落登录态
*/
const registerSideVip = computed(
() => prescriptionStore.registerStoreInfo?.vip ?? null,
);
/**
* 仅 chat-shop非诊所前缀且门店 VIP 含 medical_record 时显示病历 Tab
*/
const canUseMedicalRecord = computed(() => {
if (!hasVipPermission('medical_record')) return false;
if (!hasVipPermission('medical_record', registerSideVip.value)) return false;
if (modalData.value?.enableMedicalRecord === false) return false;
if (modalData.value?.enableMedicalRecord === true) return true;
const prefix = modalData.value?.storagePrefix || 'onlineConsultation-';
@@ -177,8 +186,49 @@ const allowInsuranceCategory = computed(
const canUseCommonPrescription = computed(() =>
[1, 2].includes(prescriptionStore.activeCategory),
);
/** 金方导入 VIP */
const canUseGoldenFormula = computed(() => hasVipPermission('golden_formula'));
/** 金方导入 VIP(优先挂号履约诊所 vip */
const canUseGoldenFormula = computed(() =>
hasVipPermission('golden_formula', registerSideVip.value),
);
/** AI 辅助出方 VIP */
const canUseAiPrescription = computed(() =>
hasVipPermission('ai_prescription', registerSideVip.value),
);
/** AI 写病历 VIP */
const canUseAiMedicalRecord = computed(() =>
hasVipPermission('ai_medical_record', registerSideVip.value),
);
const aiPrescriptionDrawerRef = ref<InstanceType<typeof AiPrescriptionDrawer> | null>(null);
/** 按字典 id 取展示名(提交仍存旧字段 id */
function tcmLabelById(
list: Array<{ id?: number | string; name?: string }> | undefined,
id: number | string | null | undefined,
) {
if (id == null || id === '') return '';
const hit = (list || []).find((x) => Number(x.id) === Number(id));
return hit?.name ? String(hit.name) : '';
}
const diseasesSelectedLabel = computed(() =>
tcmLabelById(
prescriptionStore.traditionalChineseMedicine?.diseases,
prescriptionStore.diseasesSelected,
),
);
const methodSelectedLabel = computed(() =>
tcmLabelById(
prescriptionStore.traditionalChineseMedicine?.method,
prescriptionStore.methodSelected,
),
);
const syndromeSelectedLabel = computed(() =>
tcmLabelById(
prescriptionStore.traditionalChineseMedicine?.syndrome,
prescriptionStore.syndromeSelected,
),
);
watch(allowInsuranceCategory, (allow) => {
if (!allow) {
@@ -229,6 +279,20 @@ const [Modal, modalApi] = useVbenModal({
storagePrefix,
);
prescriptionStore.loadFromLocalStorage();
// 中医三典走接口全量,替换静态 json保证搜索 id 与展示名一致
try {
const tcmRes = await getTraditionalChineseMedicineAllApi();
const tcmData = tcmRes?.data || tcmRes || {};
if (tcmData.diseases || tcmData.method || tcmData.syndrome) {
prescriptionStore.traditionalChineseMedicine = {
diseases: tcmData.diseases || [],
method: tcmData.method || [],
syndrome: tcmData.syndrome || [],
};
}
} catch (e) {
console.warn('加载中医字典失败,回落静态数据', e);
}
// 再拉一次类型,确保与门店一致并校正 Tab
await prescriptionStore.loadPrescriptionTypeOptions(
modalData.value.registerId,
@@ -294,6 +358,103 @@ function onPickDiagnosis(text: string) {
prescriptionStore.diagnosis = appendCommaText(prescriptionStore.diagnosis, t);
}
/**
* 按名称在中医字典中匹配 id精确 → 别名 → 包含)
* 在线复诊提交仍写旧字段 diseasesSelected / methodSelected / syndromeSelected
*/
function matchTcmDictId(
list: Array<{ id?: number | string; name?: string; alias?: string }> | undefined,
name: string,
): number | null {
const raw = String(name || '').trim();
if (!raw || !Array.isArray(list) || !list.length) return null;
const first =
raw
.split(/[,]/)
.map((s) => s.trim())
.filter(Boolean)[0] || '';
if (!first) return null;
let hit = list.find((it) => String(it.name || '').trim() === first);
if (hit?.id != null) return Number(hit.id);
hit = list.find((it) => {
const alias = String(it.alias || '');
return (
alias &&
(alias === first ||
alias
.split(/[,]/)
.map((s) => s.trim())
.includes(first))
);
});
if (hit?.id != null) return Number(hit.id);
hit = list.find((it) => {
const n = String(it.name || '');
return n && (n.includes(first) || first.includes(n));
});
return hit?.id != null ? Number(hit.id) : null;
}
/**
* 病历侧变更后同步到处方:诊断已双向绑定;主诉进 AI 出方;中医三典名称→旧字段 id
*/
function onMrSyncToPrescription(payload: Record<string, any>) {
const data = payload || {};
// 诊断/医嘱已由 v-model 同步;此处专补在线复诊中医三项 ID
void syncOnlineTcmIdsFromMrNames({
tcm_syndrome: data.tcm_syndrome,
tcm_disease: data.tcm_disease,
tcm_method: data.tcm_method,
});
}
/**
* 病历中医三字段名称 → diseasesSelected / methodSelected / syndromeSelected
* 与 UI 交叉绑定一致证候→diseases疾病→syndrome治法→method
*/
function syncOnlineTcmIdsFromMrNames(names: {
tcm_syndrome?: string;
tcm_disease?: string;
tcm_method?: string;
}) {
if (prescriptionStore.activeCategory !== 1) return;
const n = names || {};
const dict = prescriptionStore.traditionalChineseMedicine || {};
const diseases = dict.diseases || [];
const method = dict.method || [];
const syndrome = dict.syndrome || [];
if (n.tcm_syndrome) {
const id = matchTcmDictId(diseases, n.tcm_syndrome);
if (id != null) prescriptionStore.diseasesSelected = id;
}
if (n.tcm_method) {
const id = matchTcmDictId(method, n.tcm_method);
if (id != null) prescriptionStore.methodSelected = id;
}
if (n.tcm_disease) {
const id = matchTcmDictId(syndrome, n.tcm_disease);
if (id != null) prescriptionStore.syndromeSelected = id;
}
}
/** AI 写病历返回的字典 id 直接落到开方旧字段 */
function onApplyAiTcmIds(tcmIds: {
diseases_id?: number | null;
method_id?: number | null;
syndrome_id?: number | null;
} | null) {
if (!tcmIds || typeof tcmIds !== 'object') return;
if (tcmIds.diseases_id != null && Number(tcmIds.diseases_id) > 0) {
prescriptionStore.diseasesSelected = Number(tcmIds.diseases_id);
}
if (tcmIds.method_id != null && Number(tcmIds.method_id) > 0) {
prescriptionStore.methodSelected = Number(tcmIds.method_id);
}
if (tcmIds.syndrome_id != null && Number(tcmIds.syndrome_id) > 0) {
prescriptionStore.syndromeSelected = Number(tcmIds.syndrome_id);
}
}
/** 气泡/常用标签选中医嘱 */
function onPickDoctorOrder(text: string) {
const t = String(text || '').trim();
@@ -304,6 +465,136 @@ function onPickDoctorOrder(text: string) {
);
}
/**
* 中医三项:用新搜索气泡选中后写入旧字段 iddiseasesSelected / methodSelected / syndromeSelected
* source 与开方 UI 交叉绑定一致:证候搜 diseases疾病搜 syndrome治法搜 method
*/
function onPickOnlineTcm(
field: 'diseases' | 'method' | 'syndrome',
item: { id?: number | string; title?: string; content?: string; name?: string },
) {
const id = Number(item?.id || 0);
if (!id) return;
if (field === 'diseases') prescriptionStore.diseasesSelected = id;
else if (field === 'method') prescriptionStore.methodSelected = id;
else prescriptionStore.syndromeSelected = id;
// 若字典尚未含该行,补一条便于展示名
const name = String(item?.content || item?.title || item?.name || '').trim();
if (name) {
const dict = prescriptionStore.traditionalChineseMedicine || {};
const key =
field === 'diseases' ? 'diseases' : field === 'method' ? 'method' : 'syndrome';
const list = Array.isArray((dict as any)[key]) ? [...(dict as any)[key]] : [];
if (!list.some((x: any) => Number(x.id) === id)) {
list.push({ id, name });
prescriptionStore.traditionalChineseMedicine = { ...dict, [key]: list };
}
}
}
/** 打开 AI 写病历(复用病历面板 expose */
function openAiMedicalRecord() {
if (!canUseAiMedicalRecord.value) {
message.warning('当前门店未开通AI写病历VIP功能');
return;
}
medicalRecordPanelRef.value?.aiGenerate?.();
}
/** 打开 AI 辅助出方 */
function openAiPrescriptionDrawer() {
if (!canUseAiPrescription.value) {
message.warning('当前门店未开通AI辅助出方VIP功能');
return;
}
const registerId = Number(prescriptionStore.currentRegisterId || 0);
if (!registerId) {
message.warning('挂号无效');
return;
}
let payload: Record<string, any> =
medicalRecordPanelRef.value?.getPayload?.() || {};
if (prescriptionStore.diagnosis) payload.diagnosis = prescriptionStore.diagnosis;
if (prescriptionStore.medicalAdvice) {
payload.doctor_order = prescriptionStore.medicalAdvice;
}
aiPrescriptionDrawerRef.value?.open({
registerId,
storeId: Number(medicalRecordStoreId.value || 0) || undefined,
prescriptionType: Number(prescriptionStore.activeCategory),
patientName: String(prescriptionStore.activePatient?.name || ''),
patientSex: Number(prescriptionStore.activePatient?.sex || 0),
patientAge: Number(prescriptionStore.activePatient?.age || 0),
chiefComplaint: String(payload.chief_complaint || ''),
medicalRecord: { ...payload },
});
}
function handleSyncAiMedicalRecord(partial: Record<string, any>) {
medicalRecordPanelRef.value?.patchFields?.(partial || {});
}
/** AI/金方导入药品:覆盖写入当前处方清单 */
async function handleImportAiPrescription(payload: {
rows?: any[];
dosage?: number;
day_dosage?: number;
rule_type?: number;
process_rule_id?: number;
child_process_rule_id?: number;
process_rule_note_id?: number;
}) {
const list = Array.isArray(payload?.rows) ? payload.rows : [];
if (!list.length) {
message.warning('没有可导入的药品');
return;
}
// 复用金方导入逻辑(整表覆盖)
handleImportGoldenFormula({
rows: list,
formulaName: 'AI出方',
});
// 中药剂数/每日次数:有值用返回值,否则用处方默认 7/2
if (Number(payload?.dosage) > 0) {
prescriptionStore.dosage = Number(payload.dosage);
} else {
prescriptionStore.dosage = prescriptionStore.dosage || 7;
}
if (Number(payload?.day_dosage) > 0) {
prescriptionStore.dayDosage = Number(payload.day_dosage);
} else {
prescriptionStore.dayDosage = prescriptionStore.dayDosage || 2;
}
// 委托调剂:同步制剂要求 / 二级煎法 / 备注(与接诊端 AI 导入、常用方一致)
const prId = Number(payload?.process_rule_id || 0);
const isEntrusted = payload?.rule_type === 2 || prId > 0;
if (isEntrusted && Number(prescriptionStore.activeCategory) === 1) {
prescriptionStore.ruleType = 2;
// 先清下级,避免沿用上一次处方的煎法/备注
prescriptionStore.childProcessRuleId = undefined;
prescriptionStore.processRuleNoteId = undefined;
prescriptionStore.childProcessRuleList = [];
prescriptionStore.processRuleNoteList = [];
if (!prescriptionStore.processRuleList?.length) {
await prescriptionStore.getProcessRuleListData(0, 0);
}
if (prId > 0) {
prescriptionStore.processRuleId = prId;
await prescriptionStore.getProcessRuleListData(prId, 0);
}
const childId = Number(payload?.child_process_rule_id || 0);
if (childId > 0) {
prescriptionStore.childProcessRuleId = childId;
await prescriptionStore.getProcessRuleListData(0, childId);
}
const noteId = Number(payload?.process_rule_note_id || 0);
if (noteId > 0) {
prescriptionStore.processRuleNoteId = noteId;
}
prescriptionStore.syncToLocalStorage();
}
}
// 信息提示模态框
const [InfoModalComponent, infoModalApi] = useVbenModal({
connectedComponent: InfoModal,
@@ -1295,6 +1586,15 @@ const cancelSaveCommonPrescription = () => {
<MedicineBoxOutlined />
导入金方
</Button>
<Button
v-if="canUseAiPrescription"
type="link"
size="small"
@click="openAiPrescriptionDrawer"
>
<RobotOutlined />
AI辅助出方
</Button>
<Button
v-if="canUseCommonPrescription"
type="link"
@@ -1337,6 +1637,15 @@ const cancelSaveCommonPrescription = () => {
<HistoryOutlined />
引用历史病历
</Button>
<Button
v-if="canUseAiMedicalRecord"
type="link"
size="small"
@click="openAiMedicalRecord"
>
<RobotOutlined />
AI写病历
</Button>
<Button
type="link"
size="small"
@@ -1735,65 +2044,56 @@ const cancelSaveCommonPrescription = () => {
<!-- 诊断和医嘱区域 -->
<div class="diagnosis-area bg-gray-50 dark:bg-[#1f1f1f] p-4 rounded-lg">
<!-- 中医证候 -->
<!-- 中医证候:新搜索气泡,写入旧字段 diseasesSelected(id) -->
<div v-if="prescriptionStore.activeCategory === 1" class="mb-3">
<div class="mb-2 flex items-center justify-between">
<label class="font-medium">中医证候</label>
<div class="mr-field-head mb-2">
<label class="mr-field-label font-medium">中医证候</label>
<div class="mr-field-search">
<EntryKeywordBubble
source="tcm_disease"
placeholder="证候关键字/首拼"
compact
@select="(item) => onPickOnlineTcm('diseases', item)"
/>
</div>
</div>
<div class="text-sm text-gray-700">
{{ diseasesSelectedLabel || '请通过搜索选择中医证候' }}
</div>
<Select
v-if="prescriptionStore.traditionalChineseMedicine"
v-model:value="prescriptionStore.diseasesSelected"
:field-names="{
label: 'name',
value: 'id',
}"
:filter-option="filterOption"
:options="prescriptionStore.traditionalChineseMedicine.diseases"
class="w-full"
placeholder="中医证候"
show-search
>
</Select>
</div>
<!-- 中医治法 -->
<!-- 中医治法:写入 methodSelected(id) -->
<div v-if="prescriptionStore.activeCategory === 1" class="mb-3">
<div class="mb-2 flex items-center justify-between">
<label class="font-medium">中医治法</label>
<div class="mr-field-head mb-2">
<label class="mr-field-label font-medium">中医治法</label>
<div class="mr-field-search">
<EntryKeywordBubble
source="tcm_method"
placeholder="治法关键字/首拼"
compact
@select="(item) => onPickOnlineTcm('method', item)"
/>
</div>
</div>
<div class="text-sm text-gray-700">
{{ methodSelectedLabel || '请通过搜索选择中医治法' }}
</div>
<Select
v-if="prescriptionStore.traditionalChineseMedicine"
v-model:value="prescriptionStore.methodSelected"
:field-names="{
label: 'name',
value: 'id',
}"
:filter-option="filterOption"
:options="prescriptionStore.traditionalChineseMedicine.method"
class="w-full"
placeholder="中医治法"
show-search
/>
</div>
<!-- 中医疾病 -->
<!-- 中医疾病:写入 syndromeSelected(id) -->
<div v-if="prescriptionStore.activeCategory === 1" class="mb-3">
<div class="mb-2 flex items-center justify-between">
<label class="font-medium">中医疾病</label>
<div class="mr-field-head mb-2">
<label class="mr-field-label font-medium">中医疾病</label>
<div class="mr-field-search">
<EntryKeywordBubble
source="tcm_syndrome"
placeholder="疾病关键字/首拼"
compact
@select="(item) => onPickOnlineTcm('syndrome', item)"
/>
</div>
</div>
<div class="text-sm text-gray-700">
{{ syndromeSelectedLabel || '请通过搜索选择中医疾病' }}
</div>
<Select
v-if="prescriptionStore.traditionalChineseMedicine"
v-model:value="prescriptionStore.syndromeSelected"
:field-names="{
label: 'name',
value: 'id',
}"
:filter-option="filterOption"
:options="prescriptionStore.traditionalChineseMedicine.syndrome"
class="w-full"
placeholder="中医疾病"
show-search
/>
</div>
<!-- 诊断输入:搜索贴 label 后,与病历同源 -->
<div class="mb-3">
@@ -1913,6 +2213,8 @@ const cancelSaveCommonPrescription = () => {
:patient-age="Number(prescriptionStore.activePatient?.age || 0)"
:storage-prefix="medicalRecordStoragePrefix"
:show-toolbar="false"
@sync-to-prescription="onMrSyncToPrescription"
@apply-tcm-ids="onApplyAiTcmIds"
/>
</div>
</div>
@@ -1994,6 +2296,11 @@ const cancelSaveCommonPrescription = () => {
<!-- 常用方选择弹窗 -->
<CommonPrescriptionModals />
<GoldenFormulaModals @import="handleImportGoldenFormula" />
<AiPrescriptionDrawer
ref="aiPrescriptionDrawerRef"
@import="handleImportAiPrescription"
@sync-medical-record="handleSyncAiMedicalRecord"
/>
<!-- 药店开方选配送仓库 -->
<WarehouseSelectModals @confirm="onWarehouseSelected" />
<PatientInfoDrawerComp />

View File

@@ -7,7 +7,7 @@
import { computed, ref } from 'vue';
import { useVbenDrawer, useVbenModal } from '@vben/common-ui';
import { Button, Checkbox, Descriptions, Input, Select, Spin, Switch, Tag, message } from 'ant-design-vue';
import { Button, Checkbox, Descriptions, Input, Radio, Spin, Switch, Tag, message } from 'ant-design-vue';
import {
aiGeneratePrescriptionApi,
@@ -20,6 +20,9 @@ import AiDisclaimerBanner from '#/views/doctor/components/AiDisclaimerBanner.vue
import { ensureAiFeatureConsent } from '#/views/doctor/utils/aiFeatureGate';
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
/** 本地「正在生成中」占位 id不与真实 generation_id 冲突 */
const PENDING_GEN_ID = -1;
/** 二级弹窗通栏字段Descriptions span=2 */
const FULL_SPAN_KEYS = new Set([
'chief_complaint',
@@ -102,11 +105,17 @@ const rxProcessRuleNoteId = ref(0);
const rxProcessRuleName = ref('');
const rxChildProcessRuleName = ref('');
const rxProcessRuleNote = ref('');
/** 生成弹窗:中药委托调剂选项 */
/** 生成弹窗:中药委托调剂选项(本地记忆) */
const AI_RX_ENTRUSTED_STORAGE_KEY = 'xk_ai_rx_use_entrusted_process';
const useEntrustedProcess = ref(false);
const genProcessRuleId = ref<number | undefined>(undefined);
const processRuleOptions = ref<Array<{ label: string; value: number }>>([]);
const processRuleLoading = ref(false);
/** 委托时是否出推荐剂数/每日几次/详细制剂规则(本地记忆) */
const AI_RX_DETAIL_STORAGE_KEY = 'xk_ai_rx_return_dosage_process_detail';
const returnDosageProcessDetail = ref(true);
/** 制剂要求 id 本地记忆 */
const AI_RX_PROCESS_RULE_STORAGE_KEY = 'xk_ai_rx_process_rule_id';
/** 二级弹窗:当前正在编辑的字段 key空表示浏览态 */
const editingKey = ref('');
/** 醒目提示文案(系统配置 / 门闸下发) */
@@ -238,7 +247,6 @@ function resetState() {
medicalRecord.value = {};
chiefComplaint.value = '';
disclaimerText.value = '';
useEntrustedProcess.value = false;
genProcessRuleId.value = undefined;
processRuleOptions.value = [];
resetPreview();
@@ -270,8 +278,11 @@ function typeLabelOf(t: number | string) {
}
function applyRxMeta(data: any) {
rxDosage.value = Number(data?.dosage || 0);
rxDayDosage.value = Number(data?.day_dosage || 0);
// 中药剂数/每日次数缺省时用处方页默认,避免预览「—」和导入丢空
const dosage = Number(data?.dosage || 0);
const dayDosage = Number(data?.day_dosage || 0);
rxDosage.value = dosage > 0 ? dosage : isTcmRx.value ? 7 : 0;
rxDayDosage.value = dayDosage > 0 ? dayDosage : isTcmRx.value ? 2 : 0;
rxReason.value = String(data?.reason || '').trim();
rxBasis.value = String(data?.basis || '').trim();
rxPrescriptionName.value = String(data?.prescription_name || '').trim();
@@ -297,6 +308,7 @@ async function loadTopProcessRules() {
label: String(item.name || item.title || `#${item.id}`),
value: Number(item.id),
}));
applyProcessRulePrefFromStorage();
} catch {
processRuleOptions.value = [];
} finally {
@@ -305,14 +317,98 @@ async function loadTopProcessRules() {
}
function onEntrustedToggle(checked: boolean) {
if (checked && !processRuleOptions.value.length) {
void loadTopProcessRules();
}
if (!checked) {
useEntrustedProcess.value = !!checked;
persistUseEntrustedProcessPref(!!checked);
if (checked) {
if (!processRuleOptions.value.length) {
void loadTopProcessRules();
} else {
applyProcessRulePrefFromStorage();
}
} else {
genProcessRuleId.value = undefined;
}
}
/** 标签单选制剂要求:点选写入并记本地 */
function onProcessRuleTagClick(id: number) {
genProcessRuleId.value = Number(id) || undefined;
persistProcessRuleIdPref(genProcessRuleId.value);
}
/** 读取本地:是否勾选委托调剂(未存过默认不勾选) */
function loadUseEntrustedProcessPref() {
try {
return localStorage.getItem(AI_RX_ENTRUSTED_STORAGE_KEY) === '1';
} catch {
return false;
}
}
function persistUseEntrustedProcessPref(v: boolean) {
try {
localStorage.setItem(AI_RX_ENTRUSTED_STORAGE_KEY, v ? '1' : '0');
} catch {
/* ignore */
}
}
/** 读取本地:制剂要求 id */
function loadProcessRuleIdPref() {
try {
const id = Number(localStorage.getItem(AI_RX_PROCESS_RULE_STORAGE_KEY) || 0);
return id > 0 ? id : 0;
} catch {
return 0;
}
}
function persistProcessRuleIdPref(id?: number) {
try {
const n = Number(id || 0);
if (n > 0) {
localStorage.setItem(AI_RX_PROCESS_RULE_STORAGE_KEY, String(n));
} else {
localStorage.removeItem(AI_RX_PROCESS_RULE_STORAGE_KEY);
}
} catch {
/* ignore */
}
}
/** 列表加载后回填本地记忆的制剂要求(须仍在可选列表中) */
function applyProcessRulePrefFromStorage() {
const prefId = loadProcessRuleIdPref();
if (prefId <= 0) return;
if (processRuleOptions.value.some((o) => Number(o.value) === prefId)) {
genProcessRuleId.value = prefId;
}
}
/** 读取本地:委托时是否出剂数/每日/详细规则 */
function loadReturnDosageProcessDetailPref() {
try {
const raw = localStorage.getItem(AI_RX_DETAIL_STORAGE_KEY);
// 未存过默认「需要」;显式存 0 才为不需要
return raw === null || raw === '' || raw === '1';
} catch {
return true;
}
}
function persistReturnDosageProcessDetailPref(v: boolean) {
try {
localStorage.setItem(AI_RX_DETAIL_STORAGE_KEY, v ? '1' : '0');
} catch {
/* ignore */
}
}
function onReturnDosageProcessDetailChange(v: boolean) {
returnDosageProcessDetail.value = !!v;
persistReturnDosageProcessDetailPref(!!v);
}
function applyConflict(conflict: any) {
const msgs = Array.isArray(conflict?.messages) ? conflict.messages : [];
conflictMessages.value = msgs
@@ -363,8 +459,16 @@ function openGenerateModal() {
return;
}
editingKey.value = '';
if (isTcmRx.value && useEntrustedProcess.value && !processRuleOptions.value.length) {
void loadTopProcessRules();
useEntrustedProcess.value = loadUseEntrustedProcessPref();
returnDosageProcessDetail.value = loadReturnDosageProcessDetailPref();
if (isTcmRx.value && useEntrustedProcess.value) {
if (!processRuleOptions.value.length) {
void loadTopProcessRules();
} else {
applyProcessRulePrefFromStorage();
}
} else {
genProcessRuleId.value = undefined;
}
genModalApi.open();
}
@@ -401,6 +505,40 @@ function endEdit() {
editingKey.value = '';
}
/**
* 开始生成:取消旧选中、清空对照预览,历史顶部插入「正在生成中」占位
*/
function beginGeneratingPlaceholder() {
resetPreview();
activeId.value = PENDING_GEN_ID;
const rest = (historyList.value || []).filter(
(r) => Number(r?.id) !== PENDING_GEN_ID && !r?._pending,
);
historyList.value = [
{
id: PENDING_GEN_ID,
name: '正在生成中…',
prescription_name: '正在生成中…',
prescription_type: prescriptionType.value,
prescription_type_label: typeLabel.value,
item_count: 0,
created_at: Math.floor(Date.now() / 1000),
_pending: true,
},
...rest,
];
}
/** 生成结束:去掉本地占位 */
function endGeneratingPlaceholder() {
historyList.value = (historyList.value || []).filter(
(r) => Number(r?.id) !== PENDING_GEN_ID && !r?._pending,
);
if (Number(activeId.value) === PENDING_GEN_ID) {
activeId.value = 0;
}
}
/**
* 二级弹窗点「开始生成」:先关弹窗,回抽屉等待 AI
*/
@@ -430,6 +568,7 @@ async function confirmGenerateAndClose(): Promise<boolean> {
}
async function runGenerateInDrawer(chief: string) {
beginGeneratingPlaceholder();
generating.value = true;
try {
const req: Record<string, any> = {
@@ -442,24 +581,21 @@ async function runGenerateInDrawer(chief: string) {
chief_complaint: chief,
},
};
// 中药委托调剂:传制剂要求,后端会补全煎法/规格
// 中药委托调剂:传制剂要求;可选是否出剂数/每日/详细规则
if (isTcmRx.value && useEntrustedProcess.value && genProcessRuleId.value) {
req.use_entrusted_process = 1;
req.process_rule_id = genProcessRuleId.value;
req.return_dosage_process_detail = returnDosageProcessDetail.value ? 1 : 0;
}
const data = await aiGeneratePrescriptionApi(req);
const durationText = formatDurationMs(data?.duration_ms);
// 业务软失败HTTP 成功但 ok=false警告提示而非 error
if (data?.ok === false) {
matchedList.value = [];
unmatchedList.value = [];
conflictMessages.value = [];
applyRxMeta({
duration_ms: data.duration_ms,
});
endGeneratingPlaceholder();
resetPreview();
if (data?.generation_id) {
activeId.value = Number(data.generation_id);
await loadHistory();
activeId.value = Number(data.generation_id);
}
message.warning(String(data?.message || '生成未成功'));
return;
@@ -473,6 +609,8 @@ async function runGenerateInDrawer(chief: string) {
durationText ? `已生成(耗时 ${durationText}` : '已生成,请确认导入',
);
} catch (e: any) {
endGeneratingPlaceholder();
resetPreview();
message.error(e?.message || e?.msg || '生成失败');
} finally {
generating.value = false;
@@ -480,7 +618,7 @@ async function runGenerateInDrawer(chief: string) {
}
async function onSelectHistory(row: any) {
if (!row?.id) return;
if (!row?.id || Number(row.id) === PENDING_GEN_ID || row._pending) return;
activeId.value = Number(row.id);
matchLoading.value = true;
try {
@@ -582,9 +720,15 @@ function onConfirmImport() {
process_rule_note_id?: number;
} = {
rows: drugs,
dosage: rxDosage.value || undefined,
day_dosage: rxDayDosage.value || undefined,
};
// 中药导入必须带剂数/每日次数,缺省用处方页默认 7/2
if (isTcmRx.value) {
importPayload.dosage = rxDosage.value > 0 ? rxDosage.value : 7;
importPayload.day_dosage = rxDayDosage.value > 0 ? rxDayDosage.value : 2;
} else {
if (rxDosage.value > 0) importPayload.dosage = rxDosage.value;
if (rxDayDosage.value > 0) importPayload.day_dosage = rxDayDosage.value;
}
if (rxProcessRuleId.value > 0) {
importPayload.rule_type = 2;
importPayload.process_rule_id = rxProcessRuleId.value;
@@ -647,16 +791,27 @@ defineExpose({
class="mb-1 cursor-pointer rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-accent"
:class="{
'bg-primary/10 ring-1 ring-primary/20': activeId === Number(row.id),
'cursor-default border border-dashed border-primary/40 bg-primary/5':
Number(row.id) === PENDING_GEN_ID || row._pending,
}"
@click="onSelectHistory(row)"
>
<div class="truncate font-medium">
{{ row.name || row.prescription_name || `方案 #${row.id}` }}
{{
Number(row.id) === PENDING_GEN_ID || row._pending
? '正在生成中…'
: row.name || row.prescription_name || `方案 #${row.id}`
}}
</div>
<div class="mt-0.5 text-muted-foreground">
{{ row.prescription_type_label || typeLabelOf(row.prescription_type) }}
· {{ row.item_count || 0 }} · {{ formatTime(row.created_at) }}
<span v-if="row.duration_ms"> · {{ formatDurationMs(row.duration_ms) }}</span>
<template v-if="Number(row.id) === PENDING_GEN_ID || row._pending">
请稍候右侧为生成进度
</template>
<template v-else>
{{ row.prescription_type_label || typeLabelOf(row.prescription_type) }}
· {{ row.item_count || 0 }} · {{ formatTime(row.created_at) }}
<span v-if="row.duration_ms"> · {{ formatDurationMs(row.duration_ms) }}</span>
</template>
</div>
</div>
</div>
@@ -940,16 +1095,41 @@ defineExpose({
>
委托调剂
</Checkbox>
<div v-if="useEntrustedProcess" class="mt-2">
<div class="mb-1 text-xs text-muted-foreground">制剂要求</div>
<Select
v-model:value="genProcessRuleId"
:loading="processRuleLoading"
placeholder="请选择制剂要求"
class="w-full"
:options="processRuleOptions"
allow-clear
/>
<div v-if="useEntrustedProcess" class="mt-2 space-y-3">
<div>
<div class="mb-1 text-xs text-muted-foreground">制剂要求</div>
<div v-if="processRuleLoading" class="text-xs text-muted-foreground">加载中…</div>
<div v-else class="ai-process-pill-group">
<span
v-for="opt in processRuleOptions"
:key="opt.value"
class="ai-process-pill"
:class="{ active: genProcessRuleId === opt.value }"
@click="onProcessRuleTagClick(opt.value)"
>{{ opt.label }}</span>
</div>
<div
v-if="!processRuleLoading && !processRuleOptions.length"
class="mt-1 text-xs text-muted-foreground"
>
暂无制剂要求可选
</div>
</div>
<div>
<div class="mb-1 text-xs text-muted-foreground">
是否出详细制剂规则(二级煎法/备注)
</div>
<Radio.Group
:value="returnDosageProcessDetail"
@update:value="onReturnDosageProcessDetailChange"
>
<Radio :value="true">需要</Radio>
<Radio :value="false">不需要</Radio>
</Radio.Group>
<div class="mt-1 text-xs text-muted-foreground">
需要额外返回二级制剂说明不需要仅按一级制剂限定组方剂数/每日次数仍返回默认 7 / 2
</div>
</div>
</div>
</div>
</div>
@@ -966,4 +1146,37 @@ defineExpose({
.ai-rx-meta-desc :deep(.ant-descriptions-item-content) {
font-size: 12px;
}
.ai-process-pill-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ai-process-pill {
padding: 4px 16px;
background-color: #f4f6f8;
color: #6c7380;
border-radius: 20px;
font-size: 13px;
cursor: pointer;
border: 1px solid transparent;
user-select: none;
transition: all 0.2s;
}
.ai-process-pill.active {
background-color: #e8f6f4;
color: #00a88a;
border-color: #00a88a;
font-weight: 600;
}
/* 暗色模式:与处方页制剂标签一致 */
.dark .ai-process-pill {
background-color: #2a2a2a;
color: #a3a3a3;
border-color: #404040;
}
.dark .ai-process-pill.active {
background-color: rgba(0, 168, 138, 0.15);
color: #3dd6b5;
border-color: #00a88a;
}
</style>

View File

@@ -46,7 +46,7 @@ import {
import {debounce} from 'lodash-es'; // 或者使用自定义防抖函数
import {getRegisterStatus} from '#/util/tool';
import { hasVipPermission } from '#/utils/vip';
import { hasVipPermission, type StoreVipInfo } from '#/utils/vip';
import MedicalRecordPanel from '#/views/doctor/medical-record/MedicalRecordPanel.vue';
import EntryKeywordBubble from '#/views/doctor/medical-record/components/EntryKeywordBubble.vue';
import CommonDxOrderChips from '#/views/doctor/medical-record/components/CommonDxOrderChips.vue';
@@ -180,13 +180,23 @@ const tabType = ref(0);
/** 处方|病历 Tabv-show 切换,保持两侧状态;按挂号落本地) */
const rxMrTab = ref<'rx' | 'mr'>('rx');
const medicalRecordPanelRef = ref<InstanceType<typeof MedicalRecordPanel> | null>(null);
const canUseMedicalRecord = computed(() => hasVipPermission('medical_record'));
/** 当前接诊挂号对应履约诊所 VIP来自 getCurrentStoreType */
const receptionVip = ref<StoreVipInfo | null>(null);
const canUseMedicalRecord = computed(() =>
hasVipPermission('medical_record', receptionVip.value),
);
/** AI 辅助出方 VIP */
const canUseAiPrescription = computed(() => hasVipPermission('ai_prescription'));
const canUseAiPrescription = computed(() =>
hasVipPermission('ai_prescription', receptionVip.value),
);
/** AI 写病历 VIP */
const canUseAiMedicalRecord = computed(() => hasVipPermission('ai_medical_record'));
const canUseAiMedicalRecord = computed(() =>
hasVipPermission('ai_medical_record', receptionVip.value),
);
/** 金方导入 VIP */
const canUseGoldenFormula = computed(() => hasVipPermission('golden_formula'));
const canUseGoldenFormula = computed(() =>
hasVipPermission('golden_formula', receptionVip.value),
);
watch(rxMrTab, (tab) => {
const rid = getRegisterId();
@@ -372,6 +382,8 @@ async function fetchStoreSeeRate() {
seeRate.value = Number(res?.see_rate ?? 0);
allowInsuranceCategory.value = Number(res?.allow_insurance_category ?? 0);
priceAdjustEnabled.value = Number(res?.enable_order_price_percent_adjust ?? 0) === 1;
// 有挂号时 vip 已按履约诊所返回,供病历/AI/金方门控
receptionVip.value = res?.vip ?? null;
await loadByStoreId(myStoreId.value);
if (allowInsuranceCategory.value !== 1) {
category.value = 1;
@@ -1518,12 +1530,9 @@ async function handleImportAiPrescription(payload: {
}
currentDrugs.value = next;
if (activeCategory.value === 1) {
if (payload.dosage != null && Number(payload.dosage) > 0) {
dosage.value = Number(payload.dosage);
}
if (payload.day_dosage != null && Number(payload.day_dosage) > 0) {
dayDosage.value = Number(payload.day_dosage);
}
// 中药剂数/每日次数:有值用返回值,否则保留处方页默认 7/2
dosage.value = Number(payload.dosage) > 0 ? Number(payload.dosage) : 7;
dayDosage.value = Number(payload.day_dosage) > 0 ? Number(payload.day_dosage) : 2;
await applyAiProcessRules();
}
updateLocalStorage();

View File

@@ -88,8 +88,37 @@ const props = withDefaults(
const emit = defineEmits<{
'update:diagnosis': [string];
'update:medicalAdvice': [string];
/** 病历关键字段同步到处方(主诉/诊断/中医三典名称) */
'sync-to-prescription': [Record<string, any>];
/** AI 校正后的开方旧字段 iddiseases_id/method_id/syndrome_id */
'apply-tcm-ids': [
{
diseases_id?: number | null;
method_id?: number | null;
syndrome_id?: number | null;
},
];
}>();
/** 防抖:主诉/中医手输同步到处方,避免每个字符都触发 */
let syncToRxTimer: ReturnType<typeof setTimeout> | null = null;
function emitSyncToPrescription() {
emit('sync-to-prescription', {
diagnosis: diagnosisModel.value || form.diagnosis || '',
chief_complaint: form.chief_complaint || '',
doctor_order: medicalAdviceModel.value || form.doctor_order || '',
medicalAdvice: medicalAdviceModel.value || form.doctor_order || '',
tcm_syndrome: form.tcm_syndrome || '',
tcm_disease: form.tcm_disease || '',
tcm_method: form.tcm_method || '',
});
}
function scheduleSyncToPrescription() {
if (hydrating.value) return;
if (syncToRxTimer) clearTimeout(syncToRxTimer);
syncToRxTimer = setTimeout(() => emitSyncToPrescription(), 280);
}
const form = reactive(emptyMedicalRecord());
const loading = ref(false);
/** 加载中不写本地,避免接口回填触发草稿覆盖 */
@@ -361,11 +390,12 @@ watch(
},
);
/** 表单任意字段变更都落本地,防止刷新丢失 */
/** 表单任意字段变更都落本地,防止刷新丢失;主诉/中医同步处方 */
watch(
form,
() => {
debouncedPersistLocal();
scheduleSyncToPrescription();
},
{ deep: true },
);
@@ -440,6 +470,7 @@ function onEntryPick(fieldCode: string, item: MedicalRecordEntryItem) {
diagnosisModel.value = fieldContainsText(diagnosisModel.value, text)
? removeCommaText(diagnosisModel.value, text)
: appendCommaText(diagnosisModel.value, text);
scheduleSyncToPrescription();
return;
}
if (fieldCode === 'doctor_order') {
@@ -457,6 +488,7 @@ function onEntryPick(fieldCode: string, item: MedicalRecordEntryItem) {
(form as any)[fieldCode] = fieldContainsText(cur, text)
? removeCommaText(cur, text)
: appendCommaText(cur, text);
scheduleSyncToPrescription();
return;
}
const cur = String((form as any)[fieldCode] || '');
@@ -538,8 +570,9 @@ function openFieldExpand(fieldCode: string, label: string) {
string,
'tcm_disease' | 'tcm_syndrome' | 'tcm_method'
> = {
tcm_disease: 'tcm_disease',
tcm_syndrome: 'tcm_syndrome',
// 与开方旧字段交叉绑定:证候搜 diseases疾病搜 syndrome
tcm_syndrome: 'tcm_disease',
tcm_disease: 'tcm_syndrome',
tcm_method: 'tcm_method',
};
const tcmSource = tcmSourceMap[fieldCode];
@@ -628,6 +661,11 @@ function handleAiGenerate() {
function handleImportAiMedicalRecord(payload: {
chief_complaint: string;
fields: Record<string, string>;
tcm_ids?: {
diseases_id?: number | null;
method_id?: number | null;
syndrome_id?: number | null;
} | null;
}) {
const fields = payload?.fields || {};
Object.keys(fields).forEach((k) => {
@@ -639,6 +677,13 @@ function handleImportAiMedicalRecord(payload: {
emit('update:diagnosis', dx);
return;
}
// 医嘱与处方同源:必须 emit否则在线复诊处方侧医嘱仍为空
if (k === 'doctor_order' || k === 'medicalAdvice') {
const adv = fields[k] == null ? '' : String(fields[k]);
form.doctor_order = adv;
emit('update:medicalAdvice', adv);
return;
}
if (Object.prototype.hasOwnProperty.call(form, k)) {
(form as any)[k] = fields[k] == null ? '' : String(fields[k]);
}
@@ -647,6 +692,11 @@ function handleImportAiMedicalRecord(payload: {
form.chief_complaint = payload.chief_complaint;
}
persistLocalDraft();
emitSyncToPrescription();
// AI 已校正的字典 id直接落到开方旧字段供监管取码
if (payload?.tcm_ids && typeof payload.tcm_ids === 'object') {
emit('apply-tcm-ids', payload.tcm_ids);
}
message.success('已导入,请检查后保存');
}
@@ -701,6 +751,7 @@ function applyCitedRecord(data: Record<string, any>) {
if (data.diagnosis) emit('update:diagnosis', data.diagnosis);
if (data.doctor_order) emit('update:medicalAdvice', data.doctor_order);
persistLocalDraft();
emitSyncToPrescription();
message.success('已引用历史病历');
}
@@ -741,6 +792,7 @@ defineExpose({
}
});
persistLocalDraft();
scheduleSyncToPrescription();
},
});
@@ -763,8 +815,8 @@ function fieldBlock(
/** 中医病案内嵌:证候→疾病→治法(一行两列排布) */
const tcmCaseSubFields = [
fieldBlock('tcm_syndrome', '中医证候', undefined, 'tcm_syndrome'),
fieldBlock('tcm_disease', '中医疾病', undefined, 'tcm_disease'),
fieldBlock('tcm_syndrome', '中医证候', undefined, 'tcm_disease'),
fieldBlock('tcm_disease', '中医疾病', undefined, 'tcm_syndrome'),
fieldBlock('tcm_method', '中医治法', undefined, 'tcm_method'),
];

View File

@@ -20,6 +20,9 @@ import AiDisclaimerBanner from '#/views/doctor/components/AiDisclaimerBanner.vue
import { ensureAiFeatureConsent } from '#/views/doctor/utils/aiFeatureGate';
import PrescriptionLoading from '#/components/loading/PrescriptionLoading.vue';
/** 本地「正在生成中」占位 id不与真实 generation_id 冲突 */
const PENDING_GEN_ID = -1;
/** Descriptions 通栏字段 */
const FULL_SPAN_KEYS = new Set([
'present_illness',
@@ -80,7 +83,15 @@ function emptyGenContext(): Record<string, string> {
const emit = defineEmits<{
(
e: 'import',
payload: { chief_complaint: string; fields: Record<string, string> },
payload: {
chief_complaint: string;
fields: Record<string, string>;
tcm_ids?: {
diseases_id?: number | null;
method_id?: number | null;
syndrome_id?: number | null;
} | null;
},
): void;
}>();
@@ -98,6 +109,12 @@ const detailLoading = ref(false);
const historyList = ref<any[]>([]);
const activeId = ref(0);
const previewFields = ref<Record<string, string>>({});
/** AI 返回的开方旧字段 id导入时一并交给病历面板 */
const previewTcmIds = ref<{
diseases_id?: number | null;
method_id?: number | null;
syndrome_id?: number | null;
} | null>(null);
const previewChief = ref('');
/** 最近一次生成耗时(毫秒) */
const lastDurationMs = ref(0);
@@ -249,13 +266,14 @@ function openGenerateModal() {
}
async function onSelectHistory(row: any) {
if (!row?.id) return;
if (!row?.id || Number(row.id) === PENDING_GEN_ID || row._pending) return;
activeId.value = Number(row.id);
detailLoading.value = true;
try {
const data = await aiGenerationDetail({ id: Number(row.id) });
const fields = (data?.fields || {}) as Record<string, string>;
previewFields.value = { ...fields };
previewTcmIds.value = (data?.tcm_ids || null) as typeof previewTcmIds.value;
const snap = data?.input_snapshot || {};
previewChief.value = String(
snap.chief_complaint || chiefComplaint.value || '',
@@ -263,6 +281,7 @@ async function onSelectHistory(row: any) {
lastDurationMs.value = Number(row.duration_ms || 0);
} catch (e: any) {
previewFields.value = {};
previewTcmIds.value = null;
previewChief.value = '';
message.error(e?.message || e?.msg || '加载详情失败');
} finally {
@@ -287,11 +306,41 @@ async function confirmGenerateAndClose(): Promise<boolean> {
return true;
}
async function runGenerateInDrawer(chief: string) {
generating.value = true;
/**
* 开始生成:取消旧选中、清空预览,历史顶部插入「正在生成中」占位
*/
function beginGeneratingPlaceholder() {
activeId.value = PENDING_GEN_ID;
previewFields.value = {};
previewChief.value = chief;
previewTcmIds.value = null;
lastDurationMs.value = 0;
const rest = (historyList.value || []).filter(
(r) => Number(r?.id) !== PENDING_GEN_ID && !r?._pending,
);
historyList.value = [
{
id: PENDING_GEN_ID,
name: '正在生成中…',
created_at: Math.floor(Date.now() / 1000),
_pending: true,
},
...rest,
];
}
function endGeneratingPlaceholder() {
historyList.value = (historyList.value || []).filter(
(r) => Number(r?.id) !== PENDING_GEN_ID && !r?._pending,
);
if (Number(activeId.value) === PENDING_GEN_ID) {
activeId.value = 0;
}
}
async function runGenerateInDrawer(chief: string) {
beginGeneratingPlaceholder();
generating.value = true;
previewChief.value = chief;
try {
const data = await aiGenerateMedicalRecord({
register_id: registerId.value,
@@ -303,20 +352,27 @@ async function runGenerateInDrawer(chief: string) {
lastDurationMs.value = Number(data?.duration_ms || 0);
// 业务软失败HTTP 成功但 ok=false警告提示而非 error
if (data?.ok === false) {
endGeneratingPlaceholder();
previewFields.value = {};
previewTcmIds.value = null;
if (data?.generation_id) {
activeId.value = Number(data.generation_id);
await loadHistory();
activeId.value = Number(data.generation_id);
}
message.warning(String(data?.message || '生成未成功'));
return;
}
previewFields.value = (data?.fields || {}) as Record<string, string>;
previewTcmIds.value = (data?.tcm_ids || null) as typeof previewTcmIds.value;
await loadHistory();
activeId.value = Number(data?.generation_id || 0);
message.success(
durationText ? `已生成(耗时 ${durationText}` : '已生成,请预览后确认导入',
);
} catch (e: any) {
endGeneratingPlaceholder();
previewFields.value = {};
previewTcmIds.value = null;
message.error(e?.message || e?.msg || 'AI写病历失败');
} finally {
generating.value = false;
@@ -331,6 +387,7 @@ function onConfirmImport() {
emit('import', {
chief_complaint: String(previewChief.value || chiefComplaint.value || '').trim(),
fields: { ...previewFields.value },
tcm_ids: previewTcmIds.value || null,
});
drawerApi.close();
}
@@ -371,15 +428,26 @@ defineExpose({
class="mb-1 cursor-pointer rounded-md px-2 py-1.5 text-xs transition-colors hover:bg-accent"
:class="{
'bg-primary/10 ring-1 ring-primary/20': activeId === Number(row.id),
'cursor-default border border-dashed border-primary/40 bg-primary/5':
Number(row.id) === PENDING_GEN_ID || row._pending,
}"
@click="onSelectHistory(row)"
>
<div class="truncate font-medium">
{{ row.name || `病历 #${row.id}` }}
{{
Number(row.id) === PENDING_GEN_ID || row._pending
? '正在生成中…'
: row.name || `病历 #${row.id}`
}}
</div>
<div class="mt-0.5 text-muted-foreground">
{{ formatTime(row.created_at) }}
<span v-if="row.duration_ms"> · {{ formatDurationMs(row.duration_ms) }}</span>
<template v-if="Number(row.id) === PENDING_GEN_ID || row._pending">
请稍候右侧为生成进度
</template>
<template v-else>
{{ formatTime(row.created_at) }}
<span v-if="row.duration_ms"> · {{ formatDurationMs(row.duration_ms) }}</span>
</template>
</div>
</div>
</div>

View File

@@ -12,8 +12,8 @@ export async function getInvoiceCenterDetailApi(data: any) {
return requestClient.get<any>(`${prefix}detail`, { params: data });
}
/** 受理 */
export async function acceptInvoiceApi(data: { id: number }) {
/** 受理本方开票 */
export async function acceptInvoiceApi(data: { id: number; party_id?: number }) {
return requestClient.post<any>(`${prefix}accept`, data);
}
@@ -22,11 +22,12 @@ export async function rejectInvoiceApi(data: { id: number; reject_reason: string
return requestClient.post<any>(`${prefix}reject`, data);
}
/** 完成开票(上传文件并按需发邮 */
/** 完成本方开票(按登录角色锁定分账方 */
export async function completeInvoiceApi(data: {
id: number;
party_id?: number;
invoice_code: string;
invoice_files: string[];
id?: number;
}) {
return requestClient.post<any>(`${prefix}complete`, data);
}

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
/**
* 完成开票:填写票据代码 + 拖拽/粘贴上传发票文件(图片/PDF
* 完成本方开票:开票方由登录角色固定,不可改选
*/
import { computed, ref } from 'vue';
@@ -14,16 +14,18 @@ import { completeInvoiceApi } from '../api';
const invoiceCode = ref('');
const fileUrls = ref<string[]>([]);
const rowId = ref(0);
const receiveType = ref(1);
const invoiceId = ref(0);
const partyId = ref(0);
const amount = ref('');
const partyName = ref('');
const email = ref('');
const gridApi = ref<any>();
const emailHint = computed(() => {
if (email.value) {
return `填写了邮箱则会发送至:${email.value};同时可在小程序查收`;
return `各方齐后将发送至:${email.value}`;
}
return '未填写邮箱则仅小程序可查收;完成后将通知患者';
return '各方均开票完成后主单完成';
});
const [Modal, modalApi] = useVbenModal({
@@ -33,6 +35,10 @@ const [Modal, modalApi] = useVbenModal({
modalApi.close();
},
async onConfirm() {
if (!partyId.value && !invoiceId.value) {
message.warning('缺少本方开票信息');
return;
}
const code = invoiceCode.value.trim();
if (!code) {
message.warning('请填写票据代码');
@@ -45,14 +51,17 @@ const [Modal, modalApi] = useVbenModal({
modalApi.setState({ loading: true, confirmLoading: true });
try {
const res: any = await completeInvoiceApi({
id: rowId.value,
id: invoiceId.value,
party_id: partyId.value,
invoice_code: code,
invoice_files: fileUrls.value,
});
if (res?.subscribe_warn) {
message.warning(String(res.subscribe_warn));
} else if (res?.all_completed || res?.status === 2) {
message.success('开票完成(各方已齐)');
} else {
message.success('开票完成');
message.success('本方开票已提交');
}
gridApi.value?.reload?.();
modalApi.close();
@@ -63,8 +72,10 @@ const [Modal, modalApi] = useVbenModal({
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<Record<string, any>>() || {};
rowId.value = Number(data.id || 0);
receiveType.value = Number(data.receive_type || 1);
invoiceId.value = Number(data.id || 0);
partyId.value = Number(data.party_id || 0);
amount.value = String(data.amount || '');
partyName.value = String(data.party_name || '本方');
email.value = String(data.email || '');
gridApi.value = data.gridApi;
invoiceCode.value = '';
@@ -74,19 +85,24 @@ const [Modal, modalApi] = useVbenModal({
</script>
<template>
<Modal title="完成开票" class="w-[560px]">
<Modal title="上传本方发票" class="w-[560px]">
<div class="space-y-4 py-2">
<div class="text-sm text-gray-500">{{ emailHint }}</div>
<div class="text-sm text-muted-foreground">{{ emailHint }}</div>
<div class="rounded border border-border bg-muted/40 px-3 py-2 text-sm text-foreground">
开票方<b>{{ partyName }}</b>
金额{{ amount || '0' }}
<span class="ml-2 text-muted-foreground">按登录角色自动锁定不可更改</span>
</div>
<div>
<div class="mb-1 font-medium">票据代码</div>
<div class="mb-1 font-medium text-foreground">票据代码</div>
<Input v-model:value="invoiceCode" placeholder="请输入发票代码/号码" allow-clear />
</div>
<div>
<div class="mb-1 font-medium">发票文件图片或 PDF</div>
<div class="mb-1 font-medium text-foreground">发票文件图片或 PDF</div>
<UploadDraggerPaste
v-model="fileUrls"
accept=".jpg,.jpeg,.png,.webp,.pdf,image/*,application/pdf"
tip="点击或拖拽发票文件到此处建议上传图片小程序内可直接预览PDF 将通过微信打开)"
tip="点击或拖拽发票文件到此处"
:max-count="9"
:max-size-mb="20"
/>

View File

@@ -49,7 +49,7 @@ const [Modal, modalApi] = useVbenModal({
<template>
<Modal title="拒绝开票申请" class="w-[480px]">
<div class="py-2">
<div class="mb-2 text-sm text-gray-500">拒绝后患者可重新申请开票</div>
<div class="mb-2 text-sm text-muted-foreground">拒绝后患者可重新申请开票</div>
<Input.TextArea v-model:value="reason" :rows="4" placeholder="请填写拒绝原因" />
</div>
</Modal>

View File

@@ -24,6 +24,7 @@ export function createGridOptions(getStatus: () => string | number): VxeGridProp
{ field: 'title_type_text', title: '抬头类型', width: 90 },
{ field: 'title_name', title: '抬头名称', minWidth: 120 },
{ field: 'amount', title: '金额', width: 100 },
{ field: 'party_progress', title: '分账进度', width: 100, slots: { default: 'party_progress' } },
{ field: 'receive_type_text', title: '接收方式', width: 100 },
{ field: 'email', title: '邮箱', minWidth: 160 },
{ field: 'status_text', title: '状态', width: 90, slots: { default: 'status' } },

View File

@@ -1,16 +1,15 @@
<script lang="ts" setup>
/**
* 财务开票中心:待受理 / 已受理 / 已完成 / 已拒绝 / 全部
* Tab 记忆 localStorage各状态徽标数量0 不显示)
* 发票文件:图片用 Image 预览PDF 用 Modal + blob iframe
* 财务开票中心:各方分别受理/开票(平台仅操作本方份额)
*/
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Badge, Button, Image, Modal, Space, Tabs, Tag, message } from 'ant-design-vue';
import { Badge, Button, Modal, Space, Tabs, Tag, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import FilePreviewModal from '#/components/file-preview/FilePreviewModal.vue';
import {
acceptInvoiceApi,
@@ -24,13 +23,9 @@ import { createGridOptions } from './config/table';
defineOptions({ name: 'FinanceInvoiceCenter' });
/** Tab 记忆键,刷新后恢复上次选中状态 */
const TAB_STORAGE_KEY = 'finance-invoice-center-tab';
const TAB_KEYS = ['0', '1', '2', '9', 'all'] as const;
/**
* 从 localStorage 读取合法 Tab非法则默认待受理
*/
function readStoredTab(): string {
const stored = localStorage.getItem(TAB_STORAGE_KEY);
if (stored && (TAB_KEYS as readonly string[]).includes(stored)) {
@@ -44,7 +39,6 @@ const statusFilter = computed(() =>
activeTab.value === 'all' ? '' : activeTab.value,
);
/** 各状态数量,供徽标展示 */
const statusStats = ref<Record<string, number>>({
'0': 0,
'1': 0,
@@ -57,15 +51,11 @@ const [Grid, gridApi] = useVbenVxeGrid({
gridOptions: createGridOptions(() => statusFilter.value),
});
/**
* 列表 + 徽标一并刷新(弹窗回调与操作后复用)
*/
function reloadAll() {
gridApi.reload();
loadStatusStats();
}
/** 传给弹窗的伪 gridApireload 时同步刷新徽标 */
const gridApiWithStats = {
reload: reloadAll,
};
@@ -77,17 +67,9 @@ const [CompleteModalComp, completeModalApi] = useVbenModal({
connectedComponent: CompleteModal,
});
/** 文件预览弹窗 */
const previewVisible = ref(false);
const previewTitle = ref('');
const previewIsImage = ref(false);
const previewImageUrl = ref('');
const previewPdfUrl = ref('');
const previewLoading = ref(false);
const previewOpen = ref(false);
const previewFiles = ref<string[]>([]);
/**
* 拉取各状态数量;失败静默,避免打断列表
*/
async function loadStatusStats() {
try {
const res: any = await getInvoiceStatusStatsApi();
@@ -99,7 +81,7 @@ async function loadStatusStats() {
'9': Number(data['9'] ?? data[9] ?? 0),
};
} catch {
// 徽标非关键路径,忽略错误
// ignore
}
}
@@ -110,14 +92,18 @@ function handleTabChange(key: string | number) {
reloadAll();
}
/** 受理 */
/** 本方 party 状态0待受理 1待开票 2已开票 */
function myPartyStatus(row: any) {
return Number(row?.my_party?.status ?? -1);
}
async function handleAccept(row: any) {
Modal.confirm({
title: '确认受理该开票申请',
content: '受理后将写入站内通知(订阅消息在开票完成时发送)',
title: '确认受理本方开票',
content: `仅受理平台份额(¥${row?.my_party?.amount || '0'}),不影响其他分账方`,
async onOk() {
await acceptInvoiceApi({ id: row.id });
message.success('受理成功');
await acceptInvoiceApi({ id: row.id, party_id: row?.my_party?.id });
message.success('本方已受理');
reloadAll();
},
});
@@ -129,16 +115,22 @@ function openReject(row: any) {
}
function openComplete(row: any) {
const party = row.my_party;
if (!party?.id) {
message.warning('当前账号在该申请下无平台开票份额');
return;
}
completeModalApi.setData({
id: row.id,
receive_type: row.receive_type,
party_id: party.id,
amount: party.amount,
party_name: party.party_name || '平台',
email: row.email,
gridApi: gridApiWithStats,
});
completeModalApi.open();
}
/** 重发邮件(有邮箱即可,不限 receive_type */
async function handleResend(row: any) {
Modal.confirm({
title: '确认重发邮件?',
@@ -159,73 +151,25 @@ function statusColor(status: number) {
return 'default';
}
function isImageUrl(url: string) {
return /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(url || '');
}
function revokePdfBlob() {
if (previewPdfUrl.value && previewPdfUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewPdfUrl.value);
}
previewPdfUrl.value = '';
}
/**
* 预览发票文件:图片走 ImagePDF fetch 成 blob 后 iframe
*/
async function previewFiles(files: string[]) {
function openPreview(files: string[]) {
if (!files?.length) {
message.info('暂无文件');
return;
}
const first = files[0];
revokePdfBlob();
previewTitle.value = first.split('/').pop() || '发票文件';
if (isImageUrl(first)) {
previewIsImage.value = true;
previewImageUrl.value = first;
previewVisible.value = true;
return;
}
previewIsImage.value = false;
previewLoading.value = true;
previewVisible.value = true;
try {
const resp = await fetch(first);
if (!resp.ok) throw new Error('文件加载失败');
const blob = await resp.blob();
const pdfBlob =
blob.type && blob.type !== 'application/octet-stream'
? blob
: new Blob([blob], { type: 'application/pdf' });
previewPdfUrl.value = URL.createObjectURL(pdfBlob);
} catch (e: any) {
message.error(e?.message || '预览失败,可尝试重新上传为图片');
previewVisible.value = false;
} finally {
previewLoading.value = false;
}
}
function onPreviewClose() {
previewVisible.value = false;
revokePdfBlob();
previewImageUrl.value = '';
previewFiles.value = files;
previewOpen.value = true;
}
onMounted(() => {
loadStatusStats();
});
onBeforeUnmount(() => {
revokePdfBlob();
});
</script>
<template>
<Page auto-content-height title="开票中心">
<RejectModalComp />
<CompleteModalComp />
<FilePreviewModal v-model:open="previewOpen" :files="previewFiles" title="发票预览" />
<Tabs :active-key="activeTab" class="mb-2" @change="handleTabChange">
<Tabs.TabPane key="0">
<template #tab>
@@ -237,7 +181,7 @@ onBeforeUnmount(() => {
<Tabs.TabPane key="1">
<template #tab>
<Badge :count="statusStats['1']" :offset="[10, 0]" :number-style="{ fontSize: '12px' }">
<span>已受理</span>
<span>开票中</span>
</Badge>
</template>
</Tabs.TabPane>
@@ -262,24 +206,54 @@ onBeforeUnmount(() => {
<template #status="{ row }">
<Tag :color="statusColor(row.status)">{{ row.status_text }}</Tag>
</template>
<template #party_progress="{ row }">
<span>{{ row.party_progress || '-' }}</span>
<Tag v-if="row.my_party" class="ml-1" color="blue">
本方{{ row.my_party.status_text || '-' }}
</Tag>
</template>
<template #action="{ row }">
<Space>
<Button v-if="row.status === 0" type="link" size="small" @click="handleAccept(row)">
受理
<Button
v-if="myPartyStatus(row) === 0 && row.status !== 9"
type="link"
size="small"
@click="handleAccept(row)"
>
受理本方
</Button>
<Button v-if="row.status === 0" type="link" size="small" danger @click="openReject(row)">
<Button
v-if="row.status === 0 && myPartyStatus(row) === 0"
type="link"
size="small"
danger
@click="openReject(row)"
>
拒绝
</Button>
<Button v-if="row.status === 1" type="link" size="small" @click="openComplete(row)">
完成开票
<Button
v-if="myPartyStatus(row) === 1"
type="link"
size="small"
@click="openComplete(row)"
>
上传本方发票
</Button>
<Button
v-if="row.my_party?.invoice_files?.length"
type="link"
size="small"
@click="openPreview(row.my_party.invoice_files)"
>
预览本方
</Button>
<Button
v-if="row.status === 2 && row.invoice_files?.length"
type="link"
size="small"
@click="previewFiles(row.invoice_files)"
@click="openPreview(row.invoice_files)"
>
查看文件
查看全部
</Button>
<Button
v-if="row.status === 2 && row.email"
@@ -292,25 +266,5 @@ onBeforeUnmount(() => {
</Space>
</template>
</Grid>
<Modal
:open="previewVisible"
:title="previewTitle"
:footer="null"
width="80%"
destroy-on-close
@cancel="onPreviewClose"
>
<div v-if="previewLoading" class="py-16 text-center text-gray-500">加载中</div>
<div v-else-if="previewIsImage" class="flex justify-center">
<Image :src="previewImageUrl" :preview="true" style="max-height: 70vh" />
</div>
<iframe
v-else-if="previewPdfUrl"
:src="previewPdfUrl"
class="h-[70vh] w-full border-0"
title="发票预览"
/>
</Modal>
</Page>
</template>

View File

@@ -0,0 +1,26 @@
import { requestClient } from '#/api/request';
const prefix = 'my-invoice/';
/** 本方开票待办/已办列表 */
export async function getMyInvoiceListApi(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/** 受理本方开票 */
export async function acceptMyInvoiceApi(data: {
party_id?: number;
invoice_id?: number;
}) {
return requestClient.post<any>(`${prefix}accept`, data);
}
/** 完成本方开票 */
export async function completeMyInvoiceApi(data: {
party_id: number;
invoice_code: string;
invoice_files: string[];
invoice_id?: number;
}) {
return requestClient.post<any>(`${prefix}complete`, data);
}

View File

@@ -0,0 +1,101 @@
<script lang="ts" setup>
/**
* 本方开票弹窗:开票方锁定为当前登录角色
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Input, message } from 'ant-design-vue';
import UploadDraggerPaste from '#/components/form/components/upload-dragger-paste.vue';
import { completeMyInvoiceApi } from '../api';
const invoiceCode = ref('');
const fileUrls = ref<string[]>([]);
const partyId = ref(0);
const invoiceId = ref(0);
const amount = ref('');
const orderNo = ref('');
const partyName = ref('');
const gridApi = ref<any>();
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
async onConfirm() {
const code = invoiceCode.value.trim();
if (!code) {
message.warning('请填写票据代码');
return;
}
if (!fileUrls.value.length) {
message.warning('请上传发票文件');
return;
}
modalApi.setState({ loading: true, confirmLoading: true });
try {
const res: any = await completeMyInvoiceApi({
party_id: partyId.value,
invoice_id: invoiceId.value,
invoice_code: code,
invoice_files: fileUrls.value,
});
if (res?.subscribe_warn) {
message.warning(String(res.subscribe_warn));
} else if (res?.all_completed) {
message.success('开票完成(全部方已齐)');
} else {
message.success('本方开票已提交');
}
gridApi.value?.reload?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<Record<string, any>>() || {};
partyId.value = Number(data.id || 0);
invoiceId.value = Number(data.invoice_id || 0);
amount.value = String(data.amount || '');
orderNo.value = String(data.order_no || '');
partyName.value = String(data.party_name || '本方');
gridApi.value = data.gridApi;
invoiceCode.value = '';
fileUrls.value = [];
},
});
</script>
<template>
<Modal title="提交本方发票" class="w-[560px]">
<div class="space-y-4 py-2">
<div class="rounded border border-border bg-muted/40 px-3 py-2 text-sm text-foreground">
订单 {{ orderNo || '-' }}开票方
<b>{{ partyName }}</b>
金额 {{ amount || '0' }}
<div class="mt-1 text-muted-foreground">按登录角色自动锁定不可更改</div>
</div>
<div>
<div class="mb-1 font-medium text-foreground">票据代码</div>
<Input v-model:value="invoiceCode" placeholder="请输入发票代码/号码" allow-clear />
</div>
<div>
<div class="mb-1 font-medium text-foreground">发票文件图片或 PDF</div>
<UploadDraggerPaste
v-model="fileUrls"
accept=".jpg,.jpeg,.png,.webp,.pdf,image/*,application/pdf"
tip="点击或拖拽发票文件到此处"
:max-count="9"
:max-size-mb="20"
/>
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,30 @@
import type { VbenFormProps } from '#/adapter/form';
/** 我的开票搜索 */
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'VbenInput',
componentProps: { placeholder: '订单号' },
fieldName: 'order_no',
label: '订单号',
},
{
component: 'Select',
componentProps: {
allowClear: true,
options: [
{ label: '待受理', value: 0 },
{ label: '待开票', value: 1 },
{ label: '已开票', value: 2 },
],
placeholder: '本方状态',
},
fieldName: 'status',
label: '状态',
},
],
showCollapseButton: false,
submitButtonOptions: { content: '查询' },
};

View File

@@ -0,0 +1,57 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getMyInvoiceListApi } from '../api';
interface RowType {
id: number;
order_no: string;
status: number;
}
/** 我的开票表格 */
export const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: { highlight: true, labelField: '' },
columns: [
{ field: 'id', title: 'ID', width: 70 },
{ field: 'order_no', title: '订单号', minWidth: 160 },
{ field: 'title_name', title: '抬头', minWidth: 120 },
{ field: 'party_name', title: '开票方', minWidth: 120 },
{ field: 'amount', title: '本方金额', width: 110 },
{ field: 'status_text', title: '状态', width: 90, slots: { default: 'status' } },
{ field: 'created_at', title: '创建时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 200,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: { useKey: true },
rowConfig: { useKey: true },
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getMyInvoiceListApi({
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-buttons' },
},
showOverflow: false,
};

View File

@@ -0,0 +1,108 @@
<script lang="ts" setup>
/**
* 门店/供应商/配送仓「我的开票」:仅本方受理与开票
*/
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Modal, Space, Tag, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import FilePreviewModal from '#/components/file-preview/FilePreviewModal.vue';
import { acceptMyInvoiceApi } from './api';
import CompleteModal from './components/CompleteModal.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
defineOptions({ name: 'MyInvoiceTodo' });
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const [CompleteModalComp, completeModalApi] = useVbenModal({
connectedComponent: CompleteModal,
});
const previewOpen = ref(false);
const previewFiles = ref<string[]>([]);
function partyStatusColor(status: number) {
if (status === 0) return 'processing';
if (status === 1) return 'warning';
if (status === 2) return 'success';
return 'default';
}
function handleAccept(row: any) {
Modal.confirm({
title: '确认受理本方开票?',
content: `开票方 ${row.party_name || ''},金额 ¥${row.amount || '0'}`,
async onOk() {
await acceptMyInvoiceApi({ party_id: row.id, invoice_id: row.invoice_id });
message.success('本方已受理');
gridApi.reload();
},
});
}
function openComplete(row: any) {
completeModalApi.setData({ ...row, gridApi });
completeModalApi.open();
}
function openPreview(files: string[]) {
if (!files?.length) {
message.info('暂无文件');
return;
}
previewFiles.value = files;
previewOpen.value = true;
}
</script>
<template>
<Page auto-content-height title="我的开票">
<CompleteModalComp />
<FilePreviewModal v-model:open="previewOpen" :files="previewFiles" title="发票预览" />
<Grid>
<template #toolbar-buttons />
<template #status="{ row }">
<Tag :color="partyStatusColor(row.status)">
{{ row.status_text }}
</Tag>
</template>
<template #action="{ row }">
<Space>
<Button
v-if="row.status === 0"
type="link"
size="small"
@click="handleAccept(row)"
>
受理
</Button>
<Button
v-if="row.status === 1"
type="link"
size="small"
@click="openComplete(row)"
>
上传发票
</Button>
<Button
v-if="row.status === 2 && row.invoice_files?.length"
type="link"
size="small"
@click="openPreview(row.invoice_files)"
>
预览
</Button>
</Space>
</template>
</Grid>
</Page>
</template>

View File

@@ -237,7 +237,7 @@ async function onSelectDrug(item: any) {
class="w-[40%]"
>
<div class="mb-4">
<div class="mb-1 text-sm text-gray-600">
<div class="mb-1 text-sm text-muted-foreground">
药品
<span class="text-red-500">*</span>
</div>

View File

@@ -0,0 +1,30 @@
import { requestClient } from '#/api/request';
const prefix = 'delivery-warehouse-product/';
/**
* 平台仓品审核列表
* audit_status 为空时后端默认只返回待审
*/
export async function getDeliveryWarehouseProductAuditList(data: any) {
return requestClient.get<any>(`${prefix}audit-list`, { params: data });
}
/**
* 仓品详情(审核弹窗展示)
*/
export async function getDeliveryWarehouseProductInfo(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
/**
* 平台审核status 必为 2 下架或 3 上架;需填 zone_id
*/
export async function auditDeliveryWarehouseProduct(data: {
id: number;
status: 2 | 3;
zone_id: number;
category_id?: number;
}) {
return requestClient.post<any>(`${prefix}audit`, data);
}

View File

@@ -0,0 +1,200 @@
<script lang="ts" setup>
/**
* 平台仓品审核弹窗
* 必选上架/下架 + 所属分区;审核成功后由列表页打开仓药绑定弹窗
*/
import { nextTick, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Descriptions, Image, message, Tag } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { getDrugCategoriesTree, getZoneOptions } from '#/views/business/product/drug-categories/api';
import {
auditDeliveryWarehouseProduct,
getDeliveryWarehouseProductInfo,
} from '../api';
const gridApi = ref();
const detail = ref<Record<string, any> | null>(null);
/** 审核成功后回传给列表页,用于打开绑仓弹窗 */
const onBindPreset = ref<((preset: Record<string, any>) => void) | null>(null);
const [Form, formApi] = useVbenForm({
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: 'ApiSelect',
componentProps: {
api: getZoneOptions,
labelField: 'title',
valueField: 'id',
placeholder: '请选择分区',
allowClear: false,
},
fieldName: 'zone_id',
label: '所属分区',
rules: 'selectRequired',
},
{
component: 'ApiTreeSelect',
componentProps: {
api: getDrugCategoriesTree,
childrenField: 'children',
labelField: 'category_name',
valueField: 'id',
placeholder: '请先选择分区',
allowClear: true,
treeDefaultExpandAll: true,
immediate: false,
alwaysLoad: true,
},
fieldName: 'category_id',
label: '所属分类',
dependencies: {
componentProps(values, formApiInst) {
return {
beforeFetch: async () => {
const currentValues = await formApiInst.getValues();
return { zone_id: currentValues.zone_id };
},
placeholder: values.zone_id ? '请选择分类' : '请先选择分区',
};
},
trigger(_values, formApiInst) {
formApiInst.setFieldValue('category_id', undefined);
},
triggerFields: ['zone_id'],
},
},
{
component: 'RadioGroup',
componentProps: {
options: [
{ label: '上架', value: 3 },
{ label: '下架', value: 2 },
],
},
fieldName: 'status',
label: '审核结果',
rules: 'selectRequired',
help: '审核通过后按所选结果上架或下架,并打开绑仓弹窗',
},
],
showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
const status = Number(values.status);
if (status !== 2 && status !== 3) {
message.warning('请选择上架或下架');
return;
}
if (!values.zone_id) {
message.warning('请选择所属分区');
return;
}
modalApi.setState({ loading: true, confirmLoading: true });
auditDeliveryWarehouseProduct({
id: Number(values.id),
status: status as 2 | 3,
zone_id: Number(values.zone_id),
category_id: Number(values.category_id || 0),
})
.then((res: any) => {
message.success('审核成功,请完成仓药绑定');
gridApi.value?.query?.() ?? gridApi.value?.reload?.();
const preset = res?.bind_preset || res?.data?.bind_preset;
modalApi.close();
if (preset && onBindPreset.value) {
nextTick(() => onBindPreset.value?.(preset));
}
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
async onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
onBindPreset.value = isOpen ? modalApi.getData()?.onBindPreset || null : null;
if (!isOpen) {
detail.value = null;
return;
}
await formApi.resetForm();
const data = modalApi.getData<Record<string, any>>() || {};
const id = Number(data.id || data.values?.id || 0);
await formApi.setValues({ id, status: undefined, zone_id: undefined, category_id: undefined });
if (id > 0) {
const row = data.values || { id };
try {
const info = await getDeliveryWarehouseProductInfo(id);
detail.value = { ...row, ...(info || {}) };
} catch {
detail.value = row;
}
}
},
});
</script>
<template>
<Modal title="仓品审核" class="w-[80%] md:w-[50%] lg:w-[40%]">
<div v-if="detail" class="mb-4">
<Descriptions :column="1" size="small" bordered>
<Descriptions.Item label="药品名称">
{{ detail.drug_name || '-' }}
</Descriptions.Item>
<Descriptions.Item label="规格">
{{ detail.specification || '-' }}
</Descriptions.Item>
<Descriptions.Item label="类型">
{{ detail.type_txt || '-' }}
</Descriptions.Item>
<Descriptions.Item label="上传报价">
{{ detail.upload_quote || '0' }}
</Descriptions.Item>
<Descriptions.Item label="当前状态">
<Tag>{{ detail.status_txt || '-' }}</Tag>
<Tag color="orange" class="ml-1">
{{ detail.audit_status_txt || '待审核' }}
</Tag>
</Descriptions.Item>
<Descriptions.Item v-if="detail.image" label="产品图">
<Image :src="detail.image" :height="48" :width="48" />
</Descriptions.Item>
</Descriptions>
</div>
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,41 @@
import type { VbenFormProps } from '#/adapter/form';
/**
* 平台仓品审核搜索
* audit_status 默认空字符串:后端按待审过滤;选「已审核」可看历史
*/
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'VbenInput',
componentProps: {
placeholder: '输入药品名称',
allowClear: true,
},
fieldName: 'drug_name',
label: '药品名称',
},
{
component: 'Select',
componentProps: {
allowClear: true,
placeholder: '默认待审核',
options: [
// value 空串:后端 auditList 默认按待审过滤
{ label: '待审核', value: '' },
{ label: '已审核', value: 1 },
],
},
defaultValue: '',
fieldName: 'audit_status',
label: '审核状态',
},
],
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
submitOnChange: true,
submitOnEnter: false,
};

View File

@@ -0,0 +1,95 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getDeliveryWarehouseProductAuditList } from '../api';
interface RowType {
id: number;
image?: string;
drug_name: string;
type_txt?: string;
audit_status: number;
audit_status_txt?: string;
status: number;
status_txt?: string;
status_color?: string;
uploader_name?: string;
created_at: string;
}
/**
* 平台仓品审核表格
*/
export const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
labelField: '',
},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
},
columns: [
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{
field: 'image',
title: '图片',
width: 80,
slots: { default: 'image' },
},
{ field: 'drug_name', title: '药品名称', minWidth: 160 },
{ field: 'type_txt', title: '类型', width: 110 },
{
field: 'audit_status_txt',
title: '审核状态',
width: 110,
slots: { default: 'audit_status' },
},
{
field: 'status_txt',
title: '商品状态',
width: 100,
slots: { default: 'status' },
},
{ field: 'uploader_name', title: '上传人', width: 120 },
{ field: 'created_at', title: '创建时间', width: 170 },
{
type: 'html',
title: '操作',
fixed: 'right',
width: 120,
slots: { default: 'action' },
},
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDeliveryWarehouseProductAuditList({
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-buttons',
},
custom: {
icon: 'vxe-icon-menu',
},
},
showOverflow: false,
};

View File

@@ -0,0 +1,127 @@
<script lang="ts" setup>
/**
* 平台「仓品审核」列表页
* 审核通过后自动打开仓药绑定弹窗,预填本仓+药品+报价
*/
import { Page, useVbenModal } from '@vben/common-ui';
import { Image, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import BindModalDemo from '#/views/system/delivery-warehouse-drug/components/modal.vue';
import AuditModalDemo from './components/audit-modal.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
defineOptions({ name: 'DeliveryWarehouseProductAudit' });
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const [AuditModal, auditModalApi] = useVbenModal({
connectedComponent: AuditModalDemo,
});
const [BindModal, bindModalApi] = useVbenModal({
connectedComponent: BindModalDemo,
});
/**
* 审核成功后打开仓药绑定:锁定药品、预填仓库与报价
*/
function openBindFromPreset(preset: Record<string, any>) {
if (!preset?.drug_id || !preset?.warehouse_id) {
return;
}
bindModalApi.setData({
update: false,
lockDrug: true,
presetDrug: preset.presetDrug || {
id: preset.drug_id,
drug_name: preset.drug_name,
specification: preset.specification,
image: preset.image,
},
values: {
warehouse_id: preset.warehouse_id,
drug_id: preset.drug_id,
quote: preset.quote,
status: 2,
},
gridApi,
});
bindModalApi.open();
}
/**
* 打开审核弹窗(仅待审可点)
*/
function openAudit(row: Record<string, any>) {
if (Number(row.audit_status) === 1) {
return;
}
auditModalApi.setData({
id: row.id,
values: row,
gridApi,
onBindPreset: openBindFromPreset,
});
auditModalApi.open();
}
function auditTagColor(status: number) {
return Number(status) === 1 ? 'green' : 'orange';
}
</script>
<template>
<Page auto-content-height title="仓品审核">
<AuditModal />
<BindModal />
<Grid>
<template #toolbar-buttons>
<TableAction :actions="[]" :drop-down-actions="[]" />
</template>
<template #image="{ row }">
<Image
v-if="row.image"
:src="row.image"
:height="36"
:width="36"
:preview="true"
/>
<span v-else>-</span>
</template>
<template #audit_status="{ row }">
<Tag :color="auditTagColor(row.audit_status)">
{{ row.audit_status_txt || '-' }}
</Tag>
</template>
<template #status="{ row }">
<Tag :color="row.status_color || 'default'">
{{ row.status_txt || '-' }}
</Tag>
</template>
<template #toolbar-tools></template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '审核',
type: 'link',
icon: 'mdi:clipboard-check-outline',
size: 'small',
ifShow: Number(row.audit_status) !== 1,
onClick: openAudit.bind(null, row),
},
]"
:drop-down-actions="[]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -0,0 +1,40 @@
import { requestClient } from '#/api/request';
const prefix = 'delivery-warehouse-product/';
/**
* 仓侧「我的商品」分页列表
* 为什么单独封装:列表 / 审核列表走不同接口,避免混用参数
*/
export async function getDeliveryWarehouseProductList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/**
* 仓品详情(编辑回填 / 审核弹窗预览)
*/
export async function getDeliveryWarehouseProductInfo(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
/**
* 仓侧新建商品(后端强制草稿 + 待审)
*/
export async function createDeliveryWarehouseProduct(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
}
/**
* 仓侧编辑商品
* 未审核时后端会忽略 status已审核才可改上下架
*/
export async function updateDeliveryWarehouseProduct(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}
/**
* 仓侧可选商品类型(已排除中药)
*/
export async function getDeliveryWarehouseProductTypeOptions() {
return requestClient.get<any>(`${prefix}type-options`);
}

View File

@@ -0,0 +1,139 @@
<script lang="ts" setup>
/**
* 配送仓建品弹窗
* 新建:不传 status后端强制草稿编辑按 audit_status 控制 status 显隐
* 用法字典复用西药 getDrugUseList避免各品类重复维护
*/
import { nextTick, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { getDrugUseList } from '#/views/doctor/doctor-reception/api';
import {
createDeliveryWarehouseProduct,
getDeliveryWarehouseProductInfo,
updateDeliveryWarehouseProduct,
} from '../api';
import { modalFormProps } from '../config/form';
const isUpdate = ref(false);
const gridApi = ref();
const drugTime = ref<{ label: string; value: number }[]>([]);
const drugType = ref<{ label: string; value: number }[]>([]);
const drugUnit = ref<{ label: string; value: number }[]>([]);
const drugFrequency = ref<{ label: string; value: number }[]>([]);
/** 预拉取用法字典,打开弹窗时注入 Select options */
getDrugUseList().then((res) => {
drugTime.value = (res.drug_time || []).map((item: any) => ({
label: item.name,
value: item.id,
}));
drugType.value = (res.drug_use_type || []).map((item: any) => ({
label: item.name,
value: item.id,
}));
drugUnit.value = (res.drug_unit || []).map((item: any) => ({
label: item.name,
value: item.id,
}));
drugFrequency.value = (res.drug_use_frequency || []).map((item: any) => ({
label: item.name,
value: item.id,
}));
});
const [Form, formApi] = useVbenForm(modalFormProps);
/** 把用法字典写入四个 Select保证每次打开都有最新 options */
function injectUseDictOptions() {
formApi.updateSchema([
{ fieldName: 'time_id', componentProps: { options: drugTime.value } },
{ fieldName: 'unit_id', componentProps: { options: drugUnit.value } },
{ fieldName: 'type_id', componentProps: { options: drugType.value } },
{
fieldName: 'frequency_id',
componentProps: { options: drugFrequency.value },
},
]);
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
formApi.validate().then(async (e: any) => {
if (e.valid) {
const values = await formApi.getValues();
// 新建不提交 status未审核编辑也不提交后端也会忽略前端再挡一层
const payload = { ...values };
if (!isUpdate.value || Number(payload.audit_status) === 0) {
delete payload.status;
}
delete payload.audit_status;
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value
? updateDeliveryWarehouseProduct
: createDeliveryWarehouseProduct;
submitApi(payload)
.then(() => {
message.success('保存成功');
gridApi.value?.query?.() ?? gridApi.value?.reload?.();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
async onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (!isOpen) {
return;
}
injectUseDictOptions();
await formApi.resetForm();
const { values, update } = modalApi.getData<Record<string, any>>() || {};
isUpdate.value = !!update;
if (update && values?.id) {
// 编辑拉详情zone_id 联动会清空 category_id需 nextTick 后补回
try {
const detail = await getDeliveryWarehouseProductInfo(values.id);
const merged = {
...values,
...(detail || {}),
introduction_images: detail?.introduction_images || [],
};
const categoryId = merged.category_id;
await formApi.setValues(merged);
await nextTick();
if (categoryId) {
await formApi.setFieldValue('category_id', categoryId);
}
} catch {
await formApi.setValues(values || {});
}
} else if (values) {
await formApi.setValues(values);
}
},
});
</script>
<template>
<Modal
:title="`${isUpdate === true ? '编辑' : '新增'}仓品`"
class="w-[80%] md:w-[50%] lg:w-[40%] h-[70%]"
>
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,239 @@
import type { VbenFormProps } from '#/adapter/form';
import { getSupplierOption } from '#/views/system/supplier/api';
import { getDeliveryWarehouseProductTypeOptions } from '../api';
/**
* 仓侧建品弹窗表单
* 不填药品编号/ERP倍数/分区(后端默认);必填报价供审核绑仓
*/
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',
formItemClass: 'col-span-6',
dependencies: {
show: false,
triggerFields: ['id'],
},
},
{
// 隐藏:驱动 status 显隐audit_status==1 才可改上下架)
component: 'VbenInput',
fieldName: 'audit_status',
label: '审核状态',
formItemClass: 'col-span-6',
dependencies: {
show: false,
triggerFields: ['audit_status'],
},
},
{
component: 'ApiSelect',
componentProps: {
allowClear: false,
showSearch: true,
filterOption: (input: string, option: any) =>
option?.label?.toLowerCase?.().indexOf(input.toLowerCase()) >= 0,
afterFetch: (data: { label: string; value: number }[]) =>
(data || []).map((item) => ({
label: item.label,
value: item.value,
})),
api: getDeliveryWarehouseProductTypeOptions,
placeholder: '请选择商品类型',
},
fieldName: 'type',
formItemClass: 'col-span-6',
label: '商品类型',
rules: 'selectRequired',
},
{
component: 'Avatar',
fieldName: 'image',
label: '产品图片',
rules: 'required',
formItemClass: 'col-span-6',
},
{
component: 'ApiSelect',
componentProps: {
allowClear: true,
showSearch: true,
filterOption: (input: string, option: any) =>
option?.label?.toLowerCase?.().indexOf(input.toLowerCase()) >= 0,
afterFetch: (data: { id: number; name: string }[]) =>
(data || []).map((item: any) => ({
label: item.name,
value: item.id,
})),
api: getSupplierOption,
placeholder: '请选择',
},
fieldName: 'supplier_id',
formItemClass: 'col-span-6',
label: '所属供应商',
rules: 'required',
},
{
component: 'InputNumber',
componentProps: {
placeholder: '请输入报价',
min: 0.0001,
precision: 4,
class: 'w-full',
},
fieldName: 'upload_quote',
formItemClass: 'col-span-6',
label: '报价',
help: '审核通过后将按此报价绑定到本配送仓',
rules: 'required',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入药品昵称',
},
fieldName: 'drug_name',
formItemClass: 'col-span-6',
label: '药品昵称',
rules: 'required',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入药品别名',
},
fieldName: 'drug_alias',
formItemClass: 'col-span-6',
label: '药品别名',
rules: 'required',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入规格',
},
fieldName: 'specification',
formItemClass: 'col-span-6',
label: '规格',
rules: 'required',
},
{
component: 'VbenSelect',
componentProps: {
options: [],
placeholder: '请选择',
},
fieldName: 'time_id',
formItemClass: 'col-span-6',
label: '使用时间',
rules: 'required',
},
{
component: 'VbenSelect',
componentProps: {
options: [],
placeholder: '请选择',
},
fieldName: 'type_id',
formItemClass: 'col-span-6',
label: '使用方法',
rules: 'required',
},
{
component: 'VbenSelect',
componentProps: {
options: [],
placeholder: '请选择',
},
fieldName: 'unit_id',
formItemClass: 'col-span-6',
label: '单位',
rules: 'required',
},
{
component: 'VbenSelect',
componentProps: {
options: [],
placeholder: '请选择',
},
fieldName: 'frequency_id',
formItemClass: 'col-span-6',
label: '频率',
rules: 'required',
},
{
// 新建不展示;未审核(audit_status==0)不展示;已审核可选草稿/下架/上架
component: 'RadioGroup',
componentProps: {
options: [
{ label: '草稿', value: 1 },
{ label: '下架', value: 2 },
{ label: '上架', value: 3 },
],
},
fieldName: 'status',
formItemClass: 'col-span-12',
label: '商品状态',
dependencies: {
show(values) {
return !!values?.id && Number(values?.audit_status) === 1;
},
triggerFields: ['id', 'audit_status'],
},
},
{
component: 'RadioGroup',
componentProps: {
options: [
{ label: '是', value: 0 },
{ label: '否', value: 1 },
],
},
defaultValue: 1,
formItemClass: 'col-span-12',
fieldName: 'is_otc',
label: '是否处方药',
rules: 'required',
},
{
component: 'Textarea',
componentProps: {
placeholder: '请输入药品主治功能',
},
fieldName: 'function',
label: '药品主治功能',
rules: 'required',
},
{
component: 'Avatar',
fieldName: 'instruction',
label: '产品说明书',
rules: 'required',
formItemClass: 'col-span-6',
},
{
component: 'UploadImageSortable',
fieldName: 'introduction_images',
label: '商品介绍图',
formItemClass: 'col-span-12',
componentProps: {
maxCount: 20,
multiple: true,
},
},
],
showDefaultActions: false,
};

View File

@@ -0,0 +1,74 @@
import type { VbenFormProps } from '#/adapter/form';
import { getDeliveryWarehouseProductTypeOptions } from '../api';
/**
* 仓侧「我的商品」顶部搜索
* 字段与后端 queryField 对齐:名称模糊、类型/审核/状态精确
*/
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'VbenInput',
componentProps: {
placeholder: '输入药品名称',
allowClear: true,
},
fieldName: 'drug_name',
label: '药品名称',
},
{
component: 'ApiSelect',
componentProps: {
allowClear: true,
showSearch: true,
filterOption: (input: string, option: any) =>
option?.label?.toLowerCase?.().indexOf(input.toLowerCase()) >= 0,
// type-options 已是 { label, value }
afterFetch: (data: { label: string; value: number }[]) =>
(data || []).map((item) => ({
label: item.label,
value: item.value,
})),
api: getDeliveryWarehouseProductTypeOptions,
placeholder: '请选择类型',
},
fieldName: 'type',
label: '商品类型',
},
{
component: 'Select',
componentProps: {
allowClear: true,
placeholder: '请选择审核状态',
options: [
{ label: '待审核', value: 0 },
{ label: '已审核', value: 1 },
],
},
fieldName: 'audit_status',
label: '审核状态',
},
{
component: 'Select',
componentProps: {
allowClear: true,
placeholder: '请选择商品状态',
options: [
{ label: '草稿', value: 1 },
{ label: '下架', value: 2 },
{ label: '上架', value: 3 },
],
},
fieldName: 'status',
label: '商品状态',
},
],
showCollapseButton: true,
submitButtonOptions: {
content: '查询',
},
submitOnChange: true,
submitOnEnter: false,
};

View File

@@ -0,0 +1,95 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getDeliveryWarehouseProductList } from '../api';
interface RowType {
id: number;
image?: string;
drug_name: string;
type_txt?: string;
audit_status: number;
audit_status_txt?: string;
status: number;
status_txt?: string;
status_color?: string;
uploader_name?: string;
created_at: string;
}
/**
* 仓侧「我的商品」表格列与远程分页
*/
export const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
labelField: '',
},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
},
columns: [
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{
field: 'image',
title: '图片',
width: 80,
slots: { default: 'image' },
},
{ field: 'drug_name', title: '药品名称', minWidth: 160 },
{ field: 'type_txt', title: '类型', width: 110 },
{
field: 'audit_status_txt',
title: '审核状态',
width: 110,
slots: { default: 'audit_status' },
},
{
field: 'status_txt',
title: '商品状态',
width: 100,
slots: { default: 'status' },
},
{ field: 'uploader_name', title: '上传人', width: 120 },
{ field: 'created_at', title: '创建时间', width: 170 },
{
type: 'html',
title: '操作',
fixed: 'right',
width: 120,
slots: { default: 'action' },
},
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getDeliveryWarehouseProductList({
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-buttons',
},
custom: {
icon: 'vxe-icon-menu',
},
},
showOverflow: false,
};

View File

@@ -0,0 +1,102 @@
<script lang="ts" setup>
/**
* 配送仓「我的商品」列表页
* 仓侧建品与上下架入口;未审核商品无专用上下架按钮(仅编辑)
*/
import { Page, useVbenModal } from '@vben/common-ui';
import { Image, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import FormModalDemo from './components/modal.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
defineOptions({ name: 'DeliveryWarehouseProduct' });
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions,
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: FormModalDemo,
});
/**
* 打开新建/编辑弹窗
* @param data 行数据;新建传空对象
* @param isUpdate 是否编辑
*/
const showModal = (data: Record<string, any> = {}, isUpdate = false) => {
formModalApi.setData({
values: data,
update: isUpdate,
gridApi,
});
formModalApi.open();
};
/** 审核状态 Tag 颜色:待审橙、已审绿 */
function auditTagColor(status: number) {
return Number(status) === 1 ? 'green' : 'orange';
}
</script>
<template>
<Page auto-content-height title="我的商品">
<FormModal />
<Grid>
<template #toolbar-buttons>
<TableAction
:actions="[
{
label: '新增',
type: 'primary',
icon: 'ant-design:plus-outlined',
onClick: showModal.bind(null),
},
]"
:drop-down-actions="[]"
/>
</template>
<template #image="{ row }">
<Image
v-if="row.image"
:src="row.image"
:height="36"
:width="36"
:preview="true"
/>
<span v-else>-</span>
</template>
<template #audit_status="{ row }">
<Tag :color="auditTagColor(row.audit_status)">
{{ row.audit_status_txt || '-' }}
</Tag>
</template>
<template #status="{ row }">
<Tag :color="row.status_color || 'default'">
{{ 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),
},
]"
:drop-down-actions="[]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -5,6 +5,7 @@ import { useVbenDrawer } from '@vben/common-ui';
import { Button, Descriptions, Image, Spin } from 'ant-design-vue';
import FilePreviewModal from '#/components/file-preview/FilePreviewModal.vue';
import MIcon from '#/components/icon/icon.vue';
import { XgVideoPlayer } from '#/components/video-player';
import type { FileGalleryDetail } from '#/api/core/file-gallery';
@@ -14,6 +15,8 @@ defineOptions({ name: 'FileDetailDrawer' });
const loading = ref(false);
const detail = ref<FileGalleryDetail | null>(null);
const previewOpen = ref(false);
const previewFiles = ref<string[]>([]);
const [Drawer, drawerApi] = useVbenDrawer({
class: 'w-[520px]',
@@ -45,6 +48,15 @@ function downloadFile() {
document.body.removeChild(link);
}
/** 图片/PDF 走公共预览组件 */
function openPreview() {
if (!detail.value?.url) {
return;
}
previewFiles.value = [detail.value.url];
previewOpen.value = true;
}
async function loadDetail() {
const data = drawerApi.getData<{ id?: number }>();
if (!data?.id) {
@@ -67,10 +79,11 @@ defineExpose({
<template>
<Drawer title="文件详情">
<FilePreviewModal v-model:open="previewOpen" :files="previewFiles" title="素材预览" />
<Spin :spinning="loading">
<template v-if="detail">
<div class="preview-wrap">
<Image v-if="detail.type === 0" :src="detail.url" :width="220" />
<Image v-if="detail.type === 0" :src="detail.url" :width="220" :preview="true" />
<XgVideoPlayer
v-else-if="detail.type === 1"
:src="detail.url"
@@ -84,7 +97,10 @@ defineExpose({
/>
<div v-else-if="detail.type === 5" class="fallback-preview">
<MIcon v-if="detail.type_icon" :icon="detail.type_icon" size="48" />
<Button type="primary" @click="downloadFile">下载 PDF</Button>
<div class="btn-row">
<Button type="primary" @click="openPreview">预览 PDF</Button>
<Button @click="downloadFile">下载 PDF</Button>
</div>
</div>
<div v-else class="fallback-preview">
<MIcon v-if="detail.type_icon" :icon="detail.type_icon" size="48" />
@@ -138,11 +154,22 @@ defineExpose({
align-items: center;
gap: 12px;
padding: 24px;
width: 100%;
border-radius: 8px;
border: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.25);
color: hsl(var(--foreground));
}
.btn-row {
display: flex;
gap: 8px;
}
.type-row {
display: inline-flex;
align-items: center;
gap: 6px;
color: hsl(var(--foreground));
}
</style>

View File

@@ -357,8 +357,7 @@ watch(typesLoading, (val) => {
</template>
<style scoped lang="scss">
@use '#/components/form/components/picker-card-theme.scss' as theme;
/* 素材库卡片:用主题变量适配明/暗色,避免硬编码浅色底 */
.tab-label {
display: inline-flex;
align-items: center;
@@ -387,9 +386,10 @@ watch(typesLoading, (val) => {
flex-direction: column;
gap: 8px;
padding: 10px;
border: 1px solid #e5e6eb;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: #fff;
background: hsl(var(--card));
color: hsl(var(--card-foreground));
cursor: pointer;
}
@@ -398,7 +398,7 @@ watch(typesLoading, (val) => {
height: 120px;
object-fit: cover;
border-radius: 6px;
background: #f5f5f5;
background: hsl(var(--muted) / 0.45);
}
.gallery-file-icon {
@@ -409,8 +409,8 @@ watch(typesLoading, (val) => {
gap: 8px;
height: 120px;
border-radius: 6px;
background: #f5f5f5;
color: #4e5969;
background: hsl(var(--muted) / 0.45);
color: hsl(var(--muted-foreground));
padding: 8px;
.file-name {
@@ -425,7 +425,7 @@ watch(typesLoading, (val) => {
.gallery-meta {
font-size: 12px;
color: #86909c;
color: hsl(var(--muted-foreground));
text-align: center;
.file-title {
@@ -433,7 +433,7 @@ watch(typesLoading, (val) => {
align-items: center;
justify-content: center;
gap: 4px;
color: #1d2129;
color: hsl(var(--foreground));
font-weight: 500;
margin-bottom: 4px;
max-width: 100%;
@@ -452,23 +452,4 @@ watch(typesLoading, (val) => {
display: flex;
justify-content: center;
}
.dark {
.gallery-card {
@include theme.picker-card-dark-props;
}
.gallery-thumb,
.gallery-file-icon {
background: #374151;
}
.gallery-meta {
@include theme.picker-text-secondary-dark;
.file-title {
color: #e5e7eb;
}
}
}
</style>