fix: 接诊UI
This commit is contained in:
196
apps/web-antd/src/components/file-preview/FilePreviewModal.vue
Normal file
196
apps/web-antd/src/components/file-preview/FilePreviewModal.vue
Normal file
@@ -0,0 +1,196 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 通用文件预览:图片用 Ant Image;PDF 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 中医三项:用新搜索气泡选中后写入旧字段 id(diseasesSelected / 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 />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
/** 处方|病历 Tab(v-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();
|
||||
|
||||
@@ -88,8 +88,37 @@ const props = withDefaults(
|
||||
const emit = defineEmits<{
|
||||
'update:diagnosis': [string];
|
||||
'update:medicalAdvice': [string];
|
||||
/** 病历关键字段同步到处方(主诉/诊断/中医三典名称) */
|
||||
'sync-to-prescription': [Record<string, any>];
|
||||
/** AI 校正后的开方旧字段 id(diseases_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'),
|
||||
];
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' } },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/** 传给弹窗的伪 gridApi,reload 时同步刷新徽标 */
|
||||
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 = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 预览发票文件:图片走 Image;PDF 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>
|
||||
|
||||
26
apps/web-antd/src/views/finance/my-invoice/api/index.ts
Normal file
26
apps/web-antd/src/views/finance/my-invoice/api/index.ts
Normal 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);
|
||||
}
|
||||
@@ -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>
|
||||
30
apps/web-antd/src/views/finance/my-invoice/config/search.ts
Normal file
30
apps/web-antd/src/views/finance/my-invoice/config/search.ts
Normal 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: '查询' },
|
||||
};
|
||||
57
apps/web-antd/src/views/finance/my-invoice/config/table.ts
Normal file
57
apps/web-antd/src/views/finance/my-invoice/config/table.ts
Normal 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,
|
||||
};
|
||||
108
apps/web-antd/src/views/finance/my-invoice/index.vue
Normal file
108
apps/web-antd/src/views/finance/my-invoice/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user