1. 优化医生、诊所的设置
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CI / CI OK (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

This commit is contained in:
李琦
2026-07-02 16:21:09 +08:00
parent a38da5ce92
commit 949167217f
42 changed files with 3414 additions and 203 deletions

View File

@@ -24,6 +24,7 @@ import {downloadByData} from "#/util/tool";
const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD); const { apiURL } = useAppConfig(import.meta.env, import.meta.env.PROD);
const arr = new Set([ const arr = new Set([
'account',
'accept_tel', 'accept_tel',
'doctor', 'doctor',
'express_mobile', 'express_mobile',
@@ -36,7 +37,18 @@ const arr = new Set([
]); ]);
// 敏感数据 // 敏感数据
const sensitiveData = new Set(['id_card', 'idcard']); const sensitiveData = new Set([
'account',
'accept_tel',
'doctor',
'express_mobile',
'id_card',
'idcard',
'mobile',
'password',
'patient_mobile',
'phone',
]);
function createRequestClient(baseURL: string) { function createRequestClient(baseURL: string) {
const client = new RequestClient({ const client = new RequestClient({
@@ -199,36 +211,37 @@ function getRes(obj: any, isDecode = true) {
} }
if (isDecode && sensitiveData.has(key)) { if (isDecode && sensitiveData.has(key)) {
// 数据脱敏,自动匹配姓名、手机号、身份证 // 数据脱敏,自动匹配姓名、手机号、身份证
switch (key) { // switch (key) {
// case 'accept_name': // case 'accept_name':
// case 'express_name': // case 'express_name':
// case 'express_region': // case 'express_region':
// case 'patient': { // case 'patient': {
// // 保留前两个字符,其余用星号代替 // // 保留前两个字符,其余用星号代替
// aseFile = aseFile.slice(0, 1).padEnd(aseFile.length, '*'); // aseFile = aseFile.slice(0, 1).padEnd(aseFile.length, '*');
// break; // break;
// } // }
// case 'express_mobile': // case 'express_mobile':
// case 'mobile': { // case 'mobile':
// // 保留前三位和后四位,中间用星号代替 // case 'phone': {
// aseFile = // // 保留前三位和后四位,中间用星号代替
// aseFile.slice(0, 3).padEnd(aseFile.length - 4, '*') + // aseFile =
// aseFile.slice(-4); // aseFile.slice(0, 3).padEnd(aseFile.length - 4, '*') +
// break; // aseFile.slice(-4);
// } // break;
case 'id_card': // }
case 'idcard': { // case 'id_card':
// 保留前六位和后四位,中间用星号代替 // case 'idcard': {
aseFile = // // 保留前六位和后四位,中间用星号代替
aseFile.slice(0, 6).padEnd(aseFile.length - 4, '*') + // aseFile =
aseFile.slice(-4); // aseFile.slice(0, 6).padEnd(aseFile.length - 4, '*') +
break; // aseFile.slice(-4);
} // break;
default: { // }
// 默认情况下,全部用星号代替 // default: {
break; // // 默认情况下,全部用星号代替
} // break;
} // }
// }
} }
obj[key] = aseFile; obj[key] = aseFile;
} }

View File

@@ -4,7 +4,7 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { Modal, Upload } from 'ant-design-vue'; import { Modal, Upload } from 'ant-design-vue';
import type { UploadFile } from 'ant-design-vue'; import type { UploadFile } from 'ant-design-vue';
// 导入上传相关API和工具函数 import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
import { uploadFile } from '#/api/core/upload'; import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload'; import { uploadToOss } from '#/utils/oss-upload';
import { import {
@@ -279,6 +279,29 @@ const handlePreview = async (file: UploadFile) => {
previewTitle.value = file.name || file.url?.split('/').pop() || ''; previewTitle.value = file.name || file.url?.split('/').pop() || '';
}; };
function onGallerySelect(urls: string[]) {
const remain = props.maxCount - fileList.value.length;
const picked = props.multiple ? urls.slice(0, remain) : urls.slice(0, 1);
for (const url of picked) {
if (fileList.value.some((f) => f.url === url)) {
continue;
}
fileList.value = [
...fileList.value,
{
uid: `gallery-${url}`,
name: url.split('/').pop() || 'file',
status: 'done' as const,
url,
},
];
}
updateModelValue();
nextTick(() => {
initSortable();
});
}
// 计算是否显示上传按钮与upload-image.vue保持一致 // 计算是否显示上传按钮与upload-image.vue保持一致
const showUploadButton = computed(() => const showUploadButton = computed(() =>
props.multiple props.multiple
@@ -286,6 +309,8 @@ const showUploadButton = computed(() =>
: fileList.value.length === 0, : fileList.value.length === 0,
); );
const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.value.length, 0));
onMounted(() => { onMounted(() => {
nextTick(() => { nextTick(() => {
initSortable(); initSortable();
@@ -294,7 +319,7 @@ onMounted(() => {
</script> </script>
<template> <template>
<div ref="uploadListRef"> <div ref="uploadListRef" class="upload-image-wrap">
<Upload <Upload
:file-list="fileList" :file-list="fileList"
:before-upload="beforeImageUpload" :before-upload="beforeImageUpload"
@@ -316,13 +341,20 @@ onMounted(() => {
</div> </div>
</Upload> </Upload>
<GalleryPickLink
v-if="showUploadButton"
:multiple="multiple"
:max-count="galleryRemainCount"
@select="onGallerySelect"
/>
<Modal <Modal
v-model:visible="previewVisible" v-model:visible="previewVisible"
:title="previewTitle" :title="previewTitle"
footer="" footer=""
width="60%" width="60%"
> >
<img alt="预览图片" style="width: 100%" :src="previewImage" />
</Modal> </Modal>
</div> </div>
</template> </template>

View File

@@ -10,6 +10,8 @@ export interface RegisterOrderItem {
is_pay: 0 | 1; is_pay: 0 | 1;
type: number; type: number;
status: number; status: number;
refund_status?: number;
refund_time?: string;
created_at: string; created_at: string;
doctor_info?: { name: string; depart?: { name: string } }; doctor_info?: { name: string; depart?: { name: string } };
user_patient?: { name: string }; user_patient?: { name: string };
@@ -25,3 +27,7 @@ export async function getRegisterListApi(data: Record<string, unknown>) {
{ params: data }, { params: data },
); );
} }
export async function refundRegisterApi(data: Record<string, unknown>) {
return requestClient.post<any>(`${prefix}refund`, data);
}

View File

@@ -0,0 +1,61 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { refundRegisterApi } from '../api';
import { modalRefundFormProps } from '../config/form';
const isUpdate = ref(false);
const gridApi = ref();
const [Form, formApi] = useVbenForm(modalRefundFormProps);
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();
modalApi.setState({ loading: true, confirmLoading: true });
refundRegisterApi(values)
.then(() => {
message.success('保存成功');
gridApi.value?.reload();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
const { values, update } = modalApi.getData<Record<string, any>>();
if (values) {
isUpdate.value = update;
formApi.setValues(values);
}
}
},
});
</script>
<template>
<Modal
title="退款"
class="w-[30%]"
>
<Form />
</Modal>
</template>

View File

@@ -1,51 +1,32 @@
import type { VbenFormProps } from '#/adapter/form'; import type { VbenFormProps } from '#/adapter/form';
export const modalFormProps: VbenFormProps = { export const modalRefundFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12', // 24栅格, wrapperClass: 'grid-cols-12',
commonConfig: { commonConfig: {
formItemClass: 'col-span-12', formItemClass: 'col-span-12',
// 所有表单项
componentProps: { componentProps: {
class: 'w-full', class: 'w-full',
}, },
}, },
// handleSubmit: onSubmit,
layout: 'horizontal', layout: 'horizontal',
schema: [ schema: [
{ {
component: 'VbenInput', component: 'VbenInput',
fieldName: 'id', fieldName: 'register_id',
label: 'ID', label: 'ID',
formItemClass: 'col-span-6',
dependencies: { dependencies: {
show: false, show: false,
triggerFields: ['id'], triggerFields: ['register_id'],
}, },
}, },
{ {
component: 'VbenInput', component: 'Textarea',
componentProps: { componentProps: {
placeholder: '请输入角色昵称', placeholder: '请输入退款备注',
}, },
fieldName: 'name', fieldName: 'refund_reason',
label: '昵称', label: '退款备注',
rules: 'required',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入角色代码',
},
fieldName: 'value',
label: '角色代码',
rules: 'required',
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入角色说明',
},
fieldName: 'desc',
label: '角色说明',
rules: 'required', rules: 'required',
}, },
], ],

View File

@@ -21,15 +21,16 @@ export const gridOptions: VxeGridProps<RegisterOrderItem> = {
{ field: 'prescription', title: '处方', width: 240, slots: { default: 'prescription'} }, { field: 'prescription', title: '处方', width: 240, slots: { default: 'prescription'} },
{ field: 'price', title: '挂号价格', width: 110, slots: { default: 'price' } }, { field: 'price', title: '挂号价格', width: 110, slots: { default: 'price' } },
{ field: 'is_pay', title: '是否支付', width: 100, slots: { default: 'is_pay' } }, { field: 'is_pay', title: '是否支付', width: 100, slots: { default: 'is_pay' } },
{ field: 'refund_status', title: '退款状态', width: 100, slots: { default: 'refund_status' } },
{ field: 'status', title: '状态', slots: { default: 'status' } }, { field: 'status', title: '状态', slots: { default: 'status' } },
{ field: 'created_at', title: '创建时间' }, { field: 'created_at', title: '创建时间' },
// { {
// type: 'html', type: 'html',
// title: '操作', title: '操作',
// align: 'right', align: 'right',
// slots: { default: 'action' }, slots: { default: 'action' },
// width: 200, width: 200,
// }, },
], ],
keepSource: true, keepSource: true,
pagerConfig: {}, pagerConfig: {},

View File

@@ -1,20 +1,21 @@
<script lang="ts" setup> <script lang="ts" setup>
import type { VxeGridListeners } from '#/adapter/vxe-table'; import type { VxeGridListeners } from "#/adapter/vxe-table";
import { nextTick, onMounted, ref } from 'vue'; import { nextTick, onMounted, ref } from "vue";
import { useRoute } from 'vue-router'; import { useRoute } from "vue-router";
import { Page, useVbenModal } from '@vben/common-ui'; import { Page, useVbenModal } from "@vben/common-ui";
import { Button, Image, message, Modal as AntdModal, Tag } from 'ant-design-vue'; import { Button, Image, message, Modal as AntdModal, Tag } from "ant-design-vue";
import { useVbenVxeGrid } from '#/adapter/vxe-table'; import { useVbenVxeGrid } from "#/adapter/vxe-table";
import { TableAction } from '#/components/table-action'; import { TableAction } from "#/components/table-action";
import { simulatePayApi } from '#/views/business/order/api/order-ops'; import { simulatePayApi } from "#/views/business/order/api/order-ops";
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue'; import PrescriptionDetail from "#/views/doctor/doctor-reception/components/PrescriptionDetail.vue";
import { formOptions } from './config/search'; import { formOptions } from "./config/search";
import { gridOptions } from './config/table'; import { gridOptions } from "./config/table";
import Refund from "./components/refund.vue";
const hasTopTableDropDownActions = ref(false); const hasTopTableDropDownActions = ref(false);
@@ -28,13 +29,13 @@ const gridEvents: VxeGridListeners<any> = {
// eslint-disable-next-line no-use-before-define // eslint-disable-next-line no-use-before-define
const records = gridApi.grid.getCheckboxRecords(); const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0; hasTopTableDropDownActions.value = records.length > 0;
}, }
}; };
const [Grid, gridApi] = useVbenVxeGrid({ const [Grid, gridApi] = useVbenVxeGrid({
formOptions, formOptions,
gridOptions, gridOptions,
gridEvents, gridEvents
}); });
const route = useRoute(); const route = useRoute();
@@ -52,34 +53,52 @@ onMounted(() => {
}); });
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({ const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
connectedComponent: PrescriptionDetail, connectedComponent: PrescriptionDetail
});
const [RefundModal, RefundModalApi] = useVbenModal({
connectedComponent: Refund
}); });
function openPrescriptionDetail(id: number) { function openPrescriptionDetail(id: number) {
PrescriptionDetailModalApi.setData({ PrescriptionDetailModalApi.setData({
values: id, values: id
}); });
PrescriptionDetailModalApi.open(); PrescriptionDetailModalApi.open();
} }
function handleSimulatePay(row: Record<string, any>) { function handleSimulatePay(row: Record<string, any>) {
AntdModal.confirm({ AntdModal.confirm({
title: '模拟支付', title: "模拟支付",
content: `确认为挂号订单 ${row.order_no} 模拟易票联支付回调?`, content: `确认为挂号订单 ${row.order_no} 模拟易票联支付回调?`,
okText: '确认', okText: "确认",
cancelText: '取消', cancelText: "取消",
onOk: async () => { onOk: async () => {
await simulatePayApi({ order_type: 'register', order_id: row.id }); await simulatePayApi({ order_type: "register", order_id: row.id });
message.success('模拟支付成功'); message.success("模拟支付成功");
await gridApi.query(); await gridApi.query();
}, }
}); });
} }
function openRefundModal(id: number) {
RefundModalApi.setData({
values: {
register_id: id
},
gridApi
});
RefundModalApi.open();
}
function canRefund(row: Record<string, any>) {
return row.is_pay === 1 && row.refund_status !== 1 && row.refund_status !== 3;
}
function formatRegisterPrice(price: unknown) { function formatRegisterPrice(price: unknown) {
const n = Number(price); const n = Number(price);
if (!Number.isFinite(n)) { if (!Number.isFinite(n)) {
return '—'; return "—";
} }
return `¥${n.toFixed(2)}`; return `¥${n.toFixed(2)}`;
} }
@@ -88,6 +107,7 @@ function formatRegisterPrice(price: unknown) {
<template> <template>
<Page auto-content-height title="订单管理"> <Page auto-content-height title="订单管理">
<PrescriptionDetailModal /> <PrescriptionDetailModal />
<RefundModal />
<Grid> <Grid>
<template #toolbar-buttons> <template #toolbar-buttons>
<TableAction :actions="[]" :drop-down-actions="[]"> <TableAction :actions="[]" :drop-down-actions="[]">
@@ -114,7 +134,7 @@ function formatRegisterPrice(price: unknown) {
:height="28" :height="28"
class="rounded-full object-cover" class="rounded-full object-cover"
/> />
<span>{{ row.salesperson.nick_name || '—' }}</span> <span>{{ row.salesperson.nick_name || "—" }}</span>
</div> </div>
<span v-else class="text-sm text-gray-500">无推广员</span> <span v-else class="text-sm text-gray-500">无推广员</span>
</template> </template>
@@ -123,9 +143,14 @@ function formatRegisterPrice(price: unknown) {
</template> </template>
<template #is_pay="{ row }"> <template #is_pay="{ row }">
<Tag :color="row.is_pay === 1 ? 'green' : 'red'"> <Tag :color="row.is_pay === 1 ? 'green' : 'red'">
{{ row.is_pay === 1 ? '已支付' : '未支付' }} {{ row.is_pay === 1 ? "已支付" : "未支付" }}
</Tag> </Tag>
</template> </template>
<template #refund_status="{ row }">
<Tag v-if="row.refund_status === 1" color="pink">退款中</Tag>
<Tag v-else-if="row.refund_status === 3" color="green">已退款</Tag>
<Tag v-else color="default">未退款</Tag>
</template>
<template #type="{ row }"> <template #type="{ row }">
<div class="mt-3"> <div class="mt-3">
<Tag v-if="row.type === 0" color="purple">线下就诊</Tag> <Tag v-if="row.type === 0" color="purple">线下就诊</Tag>
@@ -149,17 +174,16 @@ function formatRegisterPrice(price: unknown) {
<template #toolbar-tools></template> <template #toolbar-tools></template>
<template #action="{ row }"> <template #action="{ row }">
<TableAction <TableAction
:actions="[]" :actions="[
:drop-down-actions="[
{ {
label: '模拟支付', label: '退款',
type: 'link', type: 'link',
icon: 'mdi:cash-check', icon: 'mingcute:refund-dollar-fill',
auth: ['Super Admin', 'sys:user:save'], ifShow: canRefund(row),
ifShow: row.is_pay === 0, onClick: openRefundModal.bind(null, row.id),
onClick: handleSimulatePay.bind(null, row),
}, },
]" ]"
:drop-down-actions="[]"
/> />
</template> </template>
</Grid> </Grid>

View File

@@ -0,0 +1,31 @@
import { requestClient } from '#/api/request';
const prefix = 'special-prescription-category/';
export async function getSpecialPrescriptionCategoryList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
export async function getSpecialPrescriptionCategoryOption(data?: any) {
return requestClient.get<any>(`${prefix}option`, { params: data });
}
export async function getSpecialPrescriptionCategoryInfo(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
export async function createSpecialPrescriptionCategory(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
}
export async function updateSpecialPrescriptionCategory(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}
export async function deleteSpecialPrescriptionCategory(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}
export async function updateSpecialPrescriptionCategorySortOrder(data: { ids: number[] }) {
return requestClient.post<any>(`${prefix}update-sort-order`, data);
}

View File

@@ -0,0 +1,78 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createSpecialPrescriptionCategory,
updateSpecialPrescriptionCategory,
} from '../api';
import { modalFormProps } from '../config/form';
const isUpdate = ref(false);
const gridApi = ref();
const [Form, formApi] = useVbenForm(modalFormProps);
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();
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value
? updateSpecialPrescriptionCategory
: createSpecialPrescriptionCategory;
submitApi(values)
.then(() => {
message.success('保存成功');
gridApi.value?.reload();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
}
});
await formApi.validateAndSubmitForm();
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
const { values, update } = modalApi.getData<Record<string, any>>();
if (values && update) {
isUpdate.value = true;
formApi.setValues({
id: values.id || '',
name: values.name || '',
status: values.status ?? 1,
});
} else {
isUpdate.value = false;
formApi.resetForm();
formApi.setValues({
id: '',
name: '',
status: 1,
});
}
} else {
formApi.resetForm();
}
},
});
</script>
<template>
<Modal :title="`${isUpdate === true ? '编辑' : '新增'}特色方分类`" class="w-[50%]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,156 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Icon } from '#/components/icon';
import { message } from 'ant-design-vue';
import { getSpecialPrescriptionCategoryList, updateSpecialPrescriptionCategorySortOrder } from '../api';
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const ids = sortableList.value.map((item) => item.id);
if (!ids.length) {
message.warning('没有可排序的数据');
return;
}
modalApi.setState({ loading: true, confirmLoading: true });
updateSpecialPrescriptionCategorySortOrder({ ids })
.then(() => {
message.success('排序更新成功');
const gridApi = modalApi.getData()?.gridApi;
if (gridApi) {
gridApi.query();
}
modalApi.close();
})
.catch(() => {
message.error('排序更新失败');
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
},
onOpenChange(isOpen: boolean) {
if (isOpen) {
loadList();
} else {
sortableList.value = [];
}
},
});
const sortableList = ref<any[]>([]);
const draggedIndex = ref(-1);
const dragOverIndex = ref(-1);
const loadList = async () => {
try {
const res = await getSpecialPrescriptionCategoryList({
page: 1,
pageSize: 1000,
});
const data = Array.isArray(res?.items) ? res.items : [];
if (!data.length) {
message.warning('暂无数据可排序');
}
sortableList.value = [...data].sort((a, b) => (a.sort || 0) - (b.sort || 0));
} catch (error) {
console.error('加载分类失败:', error);
message.error('加载数据失败');
}
};
const onDragStart = (event: DragEvent, index: number) => {
draggedIndex.value = index;
dragOverIndex.value = -1;
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move';
}
};
const onDragEnter = (index: number) => {
if (draggedIndex.value !== -1 && draggedIndex.value !== index) {
dragOverIndex.value = index;
}
};
const onDragOver = (event: DragEvent) => {
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'move';
}
};
const onDrop = (event: DragEvent, targetIndex: number) => {
event.preventDefault();
if (draggedIndex.value === -1 || draggedIndex.value === targetIndex) {
draggedIndex.value = -1;
dragOverIndex.value = -1;
return;
}
const list = [...sortableList.value];
const itemToMove = list[draggedIndex.value];
list.splice(draggedIndex.value, 1);
let finalIndex = targetIndex;
if (draggedIndex.value < targetIndex) {
finalIndex = targetIndex - 1;
}
list.splice(finalIndex, 0, itemToMove);
sortableList.value = list;
draggedIndex.value = -1;
dragOverIndex.value = -1;
};
const onDragEnd = () => {
draggedIndex.value = -1;
dragOverIndex.value = -1;
};
</script>
<template>
<Modal title="分类拖拽排序" class="w-[600px]">
<div class="py-5">
<div class="mb-5 rounded-md border-l-4 border-blue-500 bg-blue-50 px-4 py-3">
<p class="m-0 text-sm text-gray-600">提示拖拽列表项可调整分类排序排在前面的分类在小程序中优先展示</p>
</div>
<div class="max-h-[500px] overflow-y-auto">
<template v-for="(item, index) in sortableList" :key="item.id">
<div
v-if="dragOverIndex === index && draggedIndex !== -1 && draggedIndex !== index"
class="mb-3 rounded-lg border-2 border-dashed border-blue-500 bg-blue-50/50"
@dragenter="onDragEnter(index)"
@dragover="onDragOver"
@drop="onDrop($event, index)"
>
<div class="pointer-events-none flex items-center gap-4 p-4 opacity-60">
<Icon icon="ant-design:menu-outlined" class="text-xl text-blue-500" />
<div class="flex-1">{{ sortableList[draggedIndex]?.name }}</div>
<div class="text-sm text-blue-600">释放以插入此处</div>
</div>
</div>
<div
class="mb-3 flex cursor-move items-center gap-4 rounded-lg border border-gray-200 bg-white p-4 shadow-sm transition-all hover:border-blue-400"
draggable="true"
@dragstart="onDragStart($event, index)"
@dragenter="onDragEnter(index)"
@dragover="onDragOver"
@drop="onDrop($event, index)"
@dragend="onDragEnd"
>
<Icon icon="ant-design:menu-outlined" class="text-xl text-gray-400" />
<div class="flex-1 font-medium">{{ item.name }}</div>
<div class="rounded-full bg-gray-100 px-3 py-1 text-sm text-gray-600">{{ index + 1 }}</div>
</div>
</template>
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,50 @@
import type { VbenFormProps } from '#/adapter/form';
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',
defaultValue: '',
dependencies: {
show: false,
triggerFields: ['id'],
},
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入分类名称',
},
fieldName: 'name',
label: '分类名称',
rules: 'required',
defaultValue: '',
},
{
component: 'VbenSelect',
componentProps: {
placeholder: '请选择状态',
options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
},
fieldName: 'status',
label: '状态',
formItemClass: 'col-span-6',
defaultValue: 1,
},
],
showDefaultActions: false,
};

View File

@@ -0,0 +1,31 @@
import type { VbenFormProps } from '@vben/common-ui';
export const formOptions: VbenFormProps = {
layout: 'inline',
showResetButton: true,
showSubmitButton: true,
schemas: [
{
fieldName: 'name',
component: 'Input',
label: '分类名称',
componentProps: {
placeholder: '请输入分类名称',
allowClear: true,
},
},
{
fieldName: 'status',
component: 'Select',
label: '状态',
componentProps: {
placeholder: '请选择状态',
allowClear: true,
options: [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
],
},
},
],
};

View File

@@ -0,0 +1,65 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getSpecialPrescriptionCategoryList } from '../api';
interface RowType {
id: number;
name: string;
sort: number;
status: number;
status_txt: string;
created_at: string;
updated_at: string;
}
export const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
labelField: '',
},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
isHover: true,
isCurrent: true,
},
columns: [
{ type: 'checkbox', width: 60 },
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{ field: 'name', align: 'left', title: '分类名称', minWidth: 160 },
{ field: 'sort', align: 'left', title: '排序', width: 100 },
{ field: 'status_txt', align: 'left', title: '状态', width: 100 },
{ field: 'created_at', title: '创建时间', width: 180 },
{ type: 'html', title: '操作', slots: { default: 'action' }, width: 160 },
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getSpecialPrescriptionCategoryList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: {
buttons: 'toolbar-buttons',
},
custom: {
icon: 'vxe-icon-menu',
},
},
showOverflow: false,
};

View File

@@ -0,0 +1,129 @@
<script lang="ts" setup>
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import { deleteSpecialPrescriptionCategory } from './api';
import CategoryModal from './components/modal.vue';
import CategorySortModal from './components/sort-modal.vue';
import { formOptions as searchFormOptions } from './config/search';
import { gridOptions } from './config/table';
const hasTopTableDropDownActions = ref(false);
const gridEvents: VxeGridListeners<any> = {
checkboxChange() {
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
checkboxAll() {
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: searchFormOptions,
gridOptions,
gridEvents,
});
const [CategoryFormModal, categoryFormModalApi] = useVbenModal({
connectedComponent: CategoryModal,
});
const [SortModal, sortModalApi] = useVbenModal({
connectedComponent: CategorySortModal,
});
const showSortModal = () => {
sortModalApi.setData({ gridApi });
sortModalApi.open();
};
const showCategoryModal = (data = {}, isUpdate = false) => {
categoryFormModalApi.setData({
values: data,
update: isUpdate,
gridApi,
});
categoryFormModalApi.open();
};
const deleteCategoryApi = (row: any) => {
let ids: (string | number)[] = [];
if (row) {
ids.push(row);
} else {
ids = gridApi.grid.getCheckboxRecords().map((item: any) => item.id);
}
deleteSpecialPrescriptionCategory({ ids }).then(() => {
message.success('删除成功!');
gridApi.query();
});
};
</script>
<template>
<Page auto-content-height title="特色方分类管理">
<CategoryFormModal />
<SortModal />
<div class="p-4">
<TableAction
:actions="[
{
label: '新增分类',
type: 'primary',
icon: 'ant-design:plus-outlined',
onClick: () => showCategoryModal({}, false),
},
{
label: '排序',
icon: 'ant-design:ordered-list-outlined',
onClick: showSortModal,
},
]"
/>
<Grid>
<template #toolbar-buttons>
<Button
v-if="hasTopTableDropDownActions"
danger
type="primary"
@click="deleteCategoryApi()"
>
删除
</Button>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '编辑',
onClick: () => showCategoryModal(row, true),
},
{
label: '删除',
color: 'error',
popConfirm: {
title: '确定要删除该分类吗?',
onConfirm: () => deleteCategoryApi(row.id),
},
},
]"
/>
</template>
</Grid>
</div>
</Page>
</template>

View File

@@ -0,0 +1,248 @@
<script lang="ts" setup>
import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, Input, InputNumber, message, Radio, Switch, Tabs } from 'ant-design-vue';
import FormAvatar from '#/components/form/components/avatar.vue';
import { getSystemConfigList, saveSystemConfig } from '#/views/system/system-config/api';
defineOptions({ name: 'SpecialPrescriptionDisplayConfig' });
const TAB_STORAGE_KEY = 'special_prescription_config_active_tab';
const loading = ref(false);
const saving = ref(false);
const activeKey = ref('global');
const specialPrescriptionEnabled = ref(true);
const specialPrescriptionTitleImage = ref('');
const specialPrescriptionMascotImage = ref('');
const specialPrescriptionSubtitle = ref('精选时令养生茶饮');
const specialPrescriptionHomeLimit = ref(4);
const specialPrescriptionListLayout = ref<'sidebar' | 'tabs'>('sidebar');
const specialPrescriptionListHeaderStyle = ref<'simple' | 'card'>('simple');
const specialPrescriptionListSort = ref<'id_desc' | 'sales_desc'>('id_desc');
const specialPrescriptionListTitleImage = ref('');
const specialPrescriptionListMascotImage = ref('');
const specialPrescriptionListSubtitle = ref('精选时令养生茶饮');
function readStoredTab() {
const stored = localStorage.getItem(TAB_STORAGE_KEY);
if (stored === 'global' || stored === 'home' || stored === 'list') {
activeKey.value = stored;
}
}
function handleTabChange(key: string | number) {
activeKey.value = String(key);
localStorage.setItem(TAB_STORAGE_KEY, String(key));
}
async function load() {
loading.value = true;
try {
const list = await getSystemConfigList();
const rows = Array.isArray(list) ? list : list?.data || [];
for (const row of rows) {
if (row.config_key === 'special_prescription_enabled') {
specialPrescriptionEnabled.value =
row.config_value === '1' || row.config_value === true || row.config_value === 'true';
}
if (row.config_key === 'special_prescription_title_image') {
specialPrescriptionTitleImage.value = String(row.config_value || '');
}
if (row.config_key === 'special_prescription_mascot_image') {
specialPrescriptionMascotImage.value = String(row.config_value || '');
}
if (row.config_key === 'special_prescription_subtitle') {
specialPrescriptionSubtitle.value = String(row.config_value || '精选时令养生茶饮');
}
if (row.config_key === 'special_prescription_home_limit') {
specialPrescriptionHomeLimit.value = Math.max(1, Number(row.config_value) || 4);
}
if (row.config_key === 'special_prescription_list_layout') {
specialPrescriptionListLayout.value = row.config_value === 'tabs' ? 'tabs' : 'sidebar';
}
if (row.config_key === 'special_prescription_list_header_style') {
specialPrescriptionListHeaderStyle.value = row.config_value === 'card' ? 'card' : 'simple';
}
if (row.config_key === 'special_prescription_list_sort') {
specialPrescriptionListSort.value = row.config_value === 'sales_desc' ? 'sales_desc' : 'id_desc';
}
if (row.config_key === 'special_prescription_list_title_image') {
specialPrescriptionListTitleImage.value = String(row.config_value || '');
}
if (row.config_key === 'special_prescription_list_mascot_image') {
specialPrescriptionListMascotImage.value = String(row.config_value || '');
}
if (row.config_key === 'special_prescription_list_subtitle') {
specialPrescriptionListSubtitle.value = String(row.config_value || '精选时令养生茶饮');
}
}
} finally {
loading.value = false;
}
}
async function handleSave() {
saving.value = true;
try {
await saveSystemConfig([
{
config_key: 'special_prescription_enabled',
config_value: specialPrescriptionEnabled.value ? '1' : '0',
},
{
config_key: 'special_prescription_title_image',
config_value: specialPrescriptionTitleImage.value,
},
{
config_key: 'special_prescription_mascot_image',
config_value: specialPrescriptionMascotImage.value,
},
{
config_key: 'special_prescription_subtitle',
config_value: specialPrescriptionSubtitle.value,
},
{
config_key: 'special_prescription_home_limit',
config_value: String(specialPrescriptionHomeLimit.value),
},
{
config_key: 'special_prescription_list_layout',
config_value: specialPrescriptionListLayout.value,
},
{
config_key: 'special_prescription_list_header_style',
config_value: specialPrescriptionListHeaderStyle.value,
},
{
config_key: 'special_prescription_list_sort',
config_value: specialPrescriptionListSort.value,
},
{
config_key: 'special_prescription_list_title_image',
config_value: specialPrescriptionListTitleImage.value,
},
{
config_key: 'special_prescription_list_mascot_image',
config_value: specialPrescriptionListMascotImage.value,
},
{
config_key: 'special_prescription_list_subtitle',
config_value: specialPrescriptionListSubtitle.value,
},
]);
message.success('保存成功');
} finally {
saving.value = false;
}
}
onMounted(() => {
readStoredTab();
load();
});
</script>
<template>
<Page auto-content-height title="特色方展示配置">
<Card :loading="loading">
<Tabs :active-key="activeKey" @change="handleTabChange">
<Tabs.TabPane key="global" tab="全局">
<div class="py-4">
<div class="mb-2 font-medium">特色方功能开关</div>
<Switch
v-model:checked="specialPrescriptionEnabled"
checked-children="开启"
un-checked-children="关闭"
/>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="home" tab="首页">
<div class="py-4">
<div class="mb-4">
<div class="mb-2 font-medium">首页标题图</div>
<FormAvatar v-model:value="specialPrescriptionTitleImage" />
</div>
<div class="mb-4">
<div class="mb-2 font-medium">吉祥物图</div>
<FormAvatar v-model:value="specialPrescriptionMascotImage" />
</div>
<div class="mb-4">
<div class="mb-2 font-medium">副标题</div>
<Input
v-model:value="specialPrescriptionSubtitle"
placeholder="如:精选时令养生茶饮"
allow-clear
/>
</div>
<div class="mb-4">
<div class="mb-2 font-medium">首页展示数量</div>
<InputNumber v-model:value="specialPrescriptionHomeLimit" :min="1" :max="20" />
</div>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="list" tab="列表页">
<div class="py-4">
<div class="mb-4">
<div class="mb-2 font-medium">列表筛选布局</div>
<Radio.Group v-model:value="specialPrescriptionListLayout">
<Radio value="sidebar">左侧分类默认</Radio>
<Radio value="tabs">顶部标签</Radio>
</Radio.Group>
</div>
<div class="mb-4">
<div class="mb-2 font-medium">头部样式</div>
<Radio.Group v-model:value="specialPrescriptionListHeaderStyle">
<Radio value="simple">简洁横幅默认</Radio>
<Radio value="card">异形卡片</Radio>
</Radio.Group>
</div>
<div class="mb-4">
<div class="mb-2 font-medium">列表排序</div>
<Radio.Group v-model:value="specialPrescriptionListSort">
<Radio value="id_desc"> ID 倒序默认</Radio>
<Radio value="sales_desc">按销量倒序</Radio>
</Radio.Group>
</div>
<div class="mb-4">
<div class="mb-2 font-medium">列表页标题图</div>
<FormAvatar v-model:value="specialPrescriptionListTitleImage" />
</div>
<div class="mb-4">
<div class="mb-2 font-medium">列表页 IP </div>
<FormAvatar v-model:value="specialPrescriptionListMascotImage" />
</div>
<div class="mb-4">
<div class="mb-2 font-medium">列表页副标题</div>
<Input
v-model:value="specialPrescriptionListSubtitle"
placeholder="如:精选时令养生茶饮"
allow-clear
/>
</div>
</div>
</Tabs.TabPane>
</Tabs>
<div class="mt-4">
<Button type="primary" :loading="saving" @click="handleSave">保存配置</Button>
</div>
</Card>
</Page>
</template>

View File

@@ -0,0 +1,27 @@
import { requestClient } from '#/api/request';
const prefix = 'special-prescription/';
export async function getSpecialPrescriptionList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
export async function getSpecialPrescriptionInfo(id: number) {
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
}
export async function createSpecialPrescription(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
}
export async function updateSpecialPrescription(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}
export async function deleteSpecialPrescription(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}
export async function applySpecialPrescriptionApi(data: { register_id: number }) {
return requestClient.post<any>(`${prefix}apply`, data);
}

View File

@@ -0,0 +1,451 @@
<script lang="ts" setup>
import { nextTick, onMounted, ref } from 'vue';
import { useUserStore } from '@vben/stores';
import { DeleteTwoTone } from '@ant-design/icons-vue';
import {
Button,
Card,
Col,
FormItem,
InputNumber,
message,
Row,
Select,
SelectOption,
} from 'ant-design-vue';
import { debounce } from 'lodash-es';
import {
getDrugUseList,
getProductListDoctorReception,
} from '#/views/doctor/doctor-reception/api';
export interface ChineseDrugItem {
_key?: number;
id: number;
drug_id: number;
drug_name: string;
number: number;
price: number;
way_id: number;
}
const props = withDefaults(
defineProps<{
modelValue?: ChineseDrugItem[];
dosage?: number;
dayDosage?: number;
}>(),
{
modelValue: () => [],
dosage: 7,
dayDosage: 2,
},
);
const emit = defineEmits<{
'update:modelValue': [value: ChineseDrugItem[]];
'update:dosage': [value: number];
'update:dayDosage': [value: number];
}>();
const userStore = useUserStore();
const drugList = ref<ChineseDrugItem[]>([]);
const dosageLocal = ref(props.dosage);
const dayDosageLocal = ref(props.dayDosage);
const drugUseWay = ref<any[]>([]);
const chineseSearchResults = ref<any[]>([]);
const newDrugInfo = ref<{
id?: number;
name?: string;
number?: number;
price?: number;
way_id?: number;
}>({});
onMounted(async () => {
drugList.value = [...(props.modelValue || [])];
try {
const res = await getDrugUseList();
drugUseWay.value = res.drug_use_way || [];
} catch (error) {
console.error('加载药品用法失败:', error);
}
});
function syncToParent() {
emit('update:modelValue', [...drugList.value]);
emit('update:dosage', dosageLocal.value);
emit('update:dayDosage', dayDosageLocal.value);
}
const searchChineseDrugs = debounce(async (keyword: string) => {
if (!keyword || keyword.length < 1) {
chineseSearchResults.value = [];
return;
}
try {
const res = await getProductListDoctorReception({
name: keyword,
type: 1,
store_id: userStore.userInfo?.store_id || 2,
});
chineseSearchResults.value = Array.isArray(res) ? res : [];
} catch {
chineseSearchResults.value = [];
}
}, 300);
function selectNewDrug(drugId: number) {
const exists = drugList.value.some(
(item) => item.id === drugId || item.drug_id === drugId,
);
if (exists) {
message.warning('该药品已在列表中');
newDrugInfo.value.id = undefined;
return;
}
const selectedItem = chineseSearchResults.value.find(
(item) => item.drug?.id === drugId || item.drug_id === drugId,
);
if (selectedItem) {
newDrugInfo.value.name = selectedItem.drug?.drug_name || '';
newDrugInfo.value.price = selectedItem.price || 0;
newDrugInfo.value.way_id = 0;
nextTick(() => {
const numberInput = document.querySelector('.new-chinese-number input') as HTMLInputElement;
numberInput?.focus();
});
}
}
function addChineseDrug() {
if (!newDrugInfo.value.id) {
message.warning('请先选择药品');
return;
}
if (!newDrugInfo.value.number || newDrugInfo.value.number <= 0) {
message.warning('请输入正确的克数');
return;
}
drugList.value.push({
_key: Date.now(),
id: newDrugInfo.value.id,
drug_id: newDrugInfo.value.id,
drug_name: newDrugInfo.value.name || '',
number: newDrugInfo.value.number,
price: newDrugInfo.value.price || 0,
way_id: newDrugInfo.value.way_id || 0,
});
newDrugInfo.value = {};
chineseSearchResults.value = [];
syncToParent();
message.success('已添加药品');
nextTick(() => {
const nameInput = document.querySelector('.new-chinese-name input') as HTMLInputElement;
nameInput?.focus();
});
}
function handleChineseKeydown(event: KeyboardEvent, isNew: boolean) {
if (event.key === 'Enter') {
event.preventDefault();
if (isNew) {
addChineseDrug();
}
}
}
function changeChineseDrug(index: number, drugId: number) {
const exists = drugList.value.some(
(item, i) => i !== index && (item.id === drugId || item.drug_id === drugId),
);
if (exists) {
message.warning('该药品已在列表中');
const drug = drugList.value[index];
if (drug) drug.id = drug.drug_id;
return;
}
const selectedItem = chineseSearchResults.value.find(
(item) => item.drug?.id === drugId || item.drug_id === drugId,
);
if (selectedItem) {
const drug = drugList.value[index];
if (drug) {
drug.id = drugId;
drug.drug_id = drugId;
drug.drug_name = selectedItem.drug?.drug_name || '';
drug.price = selectedItem.price || 0;
syncToParent();
nextTick(() => {
const numberInput = document.querySelector(`.chinese-number-${index} input`) as HTMLInputElement;
numberInput?.focus();
});
}
}
}
function removeDrug(index: number) {
drugList.value.splice(index, 1);
syncToParent();
}
function onDrugNumberChange() {
syncToParent();
}
function onDosageChange() {
syncToParent();
}
function loadDrugs(recipes: any[], dosage?: number, dayDosage?: number) {
drugList.value = (recipes || []).map((recipe) => ({
_key: Date.now() + Math.random(),
id: recipe.drug_id || recipe.id,
drug_id: recipe.drug_id || recipe.id,
drug_name: recipe.drug_name || recipe.name || '',
number: recipe.number || 1,
price: recipe.price || 0,
way_id: recipe.way_id || 0,
}));
if (dosage !== undefined) dosageLocal.value = dosage;
if (dayDosage !== undefined) dayDosageLocal.value = dayDosage;
syncToParent();
}
function getDrugsPayload() {
return drugList.value.map((drug) => ({
drug_id: drug.drug_id || drug.id,
drug_name: drug.drug_name,
number: drug.number || 1,
price: drug.price || 0,
way_id: drug.way_id || 0,
}));
}
defineExpose({
getDrugsPayload,
loadDrugs,
drugList,
});
</script>
<template>
<div class="chinese-drug-editor">
<Row :gutter="16" class="mb-4">
<Col :span="12">
<FormItem label="剂量(天/剂)">
<InputNumber
v-model:value="dosageLocal"
:min="1"
:max="100"
style="width: 100%"
@change="onDosageChange"
/>
</FormItem>
</Col>
<Col :span="12">
<FormItem label="频次(次/天)">
<InputNumber
v-model:value="dayDosageLocal"
:min="1"
:max="10"
style="width: 100%"
@change="onDosageChange"
/>
</FormItem>
</Col>
</Row>
<FormItem label="药品列表" required>
<Row :gutter="[12, 12]">
<Col
v-for="(drug, index) in drugList"
:key="drug._key"
:xs="24"
:sm="12"
:md="8"
:lg="8"
>
<Card size="small" class="chinese-drug-card">
<div class="chinese-drug-content">
<span class="chinese-drug-index">{{ index + 1 }}</span>
<div class="chinese-drug-form">
<Select
v-model:value="drug.id"
show-search
:filter-option="false"
placeholder="药名"
class="chinese-drug-name"
@search="searchChineseDrugs"
@change="(val) => changeChineseDrug(index, val as number)"
>
<SelectOption v-if="chineseSearchResults.length === 0" :value="drug.id">
{{ drug.drug_name }}
</SelectOption>
<SelectOption
v-for="item in chineseSearchResults"
v-else
:key="item.drug?.id || item.id"
:value="item.drug?.id || item.id"
>
{{ item.drug?.drug_name || item.drug_name }}
</SelectOption>
</Select>
<span class="chinese-drug-comma"></span>
<InputNumber
v-model:value="drug.number"
:class="`chinese-number-${index}`"
:controls="false"
:min="1"
style="width: 60px"
@change="onDrugNumberChange"
/>
<span class="chinese-drug-unit">g</span>
<span class="chinese-drug-comma"></span>
<Select
v-model:value="drug.way_id"
placeholder="用法"
style="width: 70px"
@change="onDrugNumberChange"
>
<SelectOption :value="0">煎服</SelectOption>
<SelectOption
v-for="item in drugUseWay"
:key="item.id"
:value="item.id"
>
{{ item.name }}
</SelectOption>
</Select>
</div>
<div class="chinese-drug-price">
{{ ((drug.price || 0) * (drug.number || 1)).toFixed(2) }}
</div>
<Button type="link" class="chinese-drug-delete" @click="removeDrug(index)">
<DeleteTwoTone two-tone-color="#ff4d4f" />
</Button>
</div>
</Card>
</Col>
<Col :xs="24" :sm="12" :md="8" :lg="8">
<Card size="small" class="chinese-drug-card chinese-drug-card-new">
<div class="chinese-drug-content">
<span class="chinese-drug-index">{{ drugList.length + 1 }}</span>
<div class="chinese-drug-form">
<Select
v-model:value="newDrugInfo.id"
show-search
:filter-option="false"
placeholder="药名"
class="chinese-drug-name new-chinese-name"
@search="searchChineseDrugs"
@change="selectNewDrug"
>
<SelectOption
v-for="item in chineseSearchResults"
:key="item.drug?.id || item.id"
:value="item.drug?.id || item.id"
>
{{ item.drug?.drug_name || item.drug_name }}
</SelectOption>
</Select>
<span class="chinese-drug-comma"></span>
<InputNumber
v-model:value="newDrugInfo.number"
class="new-chinese-number"
:controls="false"
:min="1"
style="width: 60px"
@keydown="(e) => handleChineseKeydown(e, true)"
/>
<span class="chinese-drug-unit">g</span>
<span class="chinese-drug-comma"></span>
<Select
v-model:value="newDrugInfo.way_id"
placeholder="用法"
style="width: 70px"
@keydown="(e) => handleChineseKeydown(e, true)"
>
<SelectOption :value="0">煎服</SelectOption>
<SelectOption v-for="item in drugUseWay" :key="item.id" :value="item.id">
{{ item.name }}
</SelectOption>
</Select>
</div>
<div class="chinese-drug-price">
{{ ((newDrugInfo.price || 0) * (newDrugInfo.number || 1)).toFixed(2) }}
</div>
</div>
</Card>
</Col>
</Row>
</FormItem>
</div>
</template>
<style lang="scss" scoped>
.chinese-drug-card {
position: relative;
min-height: 80px;
:deep(.ant-card-body) {
padding: 12px;
}
}
.chinese-drug-card-new {
border-style: dashed;
}
.chinese-drug-content {
position: relative;
}
.chinese-drug-index {
position: absolute;
left: 0;
top: 0;
font-size: 16px;
font-weight: bold;
@apply text-primary;
}
.chinese-drug-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
padding-left: 28px;
}
.chinese-drug-name {
min-width: 80px;
max-width: 120px;
}
.chinese-drug-comma {
@apply text-muted-foreground;
}
.chinese-drug-unit {
@apply text-muted-foreground text-xs;
}
.chinese-drug-price {
position: absolute;
right: 24px;
bottom: 0;
@apply text-destructive font-medium;
}
.chinese-drug-delete {
position: absolute;
right: -8px;
top: -8px;
padding: 4px;
}
</style>

View File

@@ -0,0 +1,184 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
createSpecialPrescription,
getSpecialPrescriptionInfo,
updateSpecialPrescription,
} from '../api';
import ChineseDrugEditor from './ChineseDrugEditor.vue';
import { modalFormProps } from '../config/form';
const isUpdate = ref(false);
const gridApi = ref();
const prescriptionType = ref<'chinese' | 'west' | 'granular'>('chinese');
const chineseDrugEditorRef = ref<InstanceType<typeof ChineseDrugEditor>>();
const dosage = ref(7);
const dayDosage = ref(2);
const drugsChanged = ref(false);
const [Form, formApi] = useVbenForm(modalFormProps);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: true,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const validation = await formApi.validate();
if (!validation.valid) {
return;
}
const values = await formApi.getValues();
prescriptionType.value = values.prescription_type || 'chinese';
let drugs: any[] = [];
if (prescriptionType.value === 'chinese') {
drugs = chineseDrugEditorRef.value?.getDrugsPayload() || [];
if (!isUpdate.value && drugs.length === 0) {
message.warning('请至少添加一种中药');
return;
}
if (drugs.length > 0) {
drugsChanged.value = true;
}
} else if (!isUpdate.value) {
message.warning('当前仅支持在后台配置中药类型处方,请选择中药类型');
return;
}
const payload: Record<string, any> = {
...values,
tags: Array.isArray(values.tags)
? values.tags
: String(values.tags || '')
.split(',')
.map((s: string) => s.trim())
.filter(Boolean),
};
if (prescriptionType.value === 'chinese' && drugs.length > 0) {
payload.drugs = drugs;
payload.dosage = dosage.value;
payload.day_dosage = dayDosage.value;
payload.rule_type = 1;
payload.package_method_id = 2;
} else if (isUpdate.value && !drugsChanged.value) {
delete payload.drugs;
}
modalApi.setState({ loading: true, confirmLoading: true });
const submitApi = isUpdate.value ? updateSpecialPrescription : createSpecialPrescription;
submitApi(payload)
.then(() => {
message.success('保存成功');
gridApi.value?.reload();
modalApi.close();
})
.finally(() => {
modalApi.setState({ loading: false, confirmLoading: false });
});
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (isOpen) {
bindPrescriptionTypeChange();
const { values, update } = modalApi.getData<Record<string, any>>();
drugsChanged.value = false;
if (values && update) {
isUpdate.value = true;
getSpecialPrescriptionInfo(values.id).then((res: any) => {
if (!res) return;
prescriptionType.value = res.prescription_type || 'chinese';
formApi.setValues({
id: res.id,
name: res.name || '',
category_id: res.category_id,
cover_image: res.cover_image || '',
tags: Array.isArray(res.tags) ? res.tags : [],
price_per_dose: res.price_per_dose ?? 0,
sales_count: res.sales_count ?? 0,
status: res.status ?? 1,
prescription_type: res.prescription_type || 'chinese',
intro_text: res.intro_text || '',
introduction_images: res.introduction_images || [],
});
if (res.prescription_type === 'chinese' && res.prescription_detail?.length) {
const first = res.prescription_detail[0];
chineseDrugEditorRef.value?.loadDrugs(
res.prescription_detail,
first?.dosage ?? 7,
first?.consumption ?? 2,
);
dosage.value = first?.dosage ?? 7;
dayDosage.value = first?.consumption ?? 2;
}
});
} else {
isUpdate.value = false;
prescriptionType.value = 'chinese';
dosage.value = 7;
dayDosage.value = 2;
formApi.resetForm();
formApi.setValues({
id: '',
name: '',
category_id: undefined,
cover_image: '',
tags: [],
price_per_dose: 0,
sales_count: 0,
status: 1,
prescription_type: 'chinese',
intro_text: '',
introduction_images: [],
});
chineseDrugEditorRef.value?.loadDrugs([], 7, 2);
}
} else {
formApi.resetForm();
}
},
});
function bindPrescriptionTypeChange() {
formApi.updateSchema([
{
fieldName: 'prescription_type',
componentProps: {
onChange: (val: string) => {
prescriptionType.value = val || 'chinese';
},
},
},
]);
}
</script>
<template>
<Modal :title="`${isUpdate ? '编辑' : '新增'}特色方`" class="w-[80%]">
<Form />
<div v-if="prescriptionType === 'chinese'" class="mt-4 border-t pt-4">
<ChineseDrugEditor
ref="chineseDrugEditorRef"
v-model:dosage="dosage"
v-model:day-dosage="dayDosage"
@update:model-value="drugsChanged = true"
/>
</div>
<Alert
v-else
class="mt-4"
type="info"
show-icon
message="西药/颗粒药类型请切换为中药后在后台配置,或通过创建接口传入 drugs 数组保存。"
/>
</Modal>
</template>

View File

@@ -0,0 +1,145 @@
import type { VbenFormProps } from '#/adapter/form';
import { getSpecialPrescriptionCategoryOption } from '#/views/business/special-prescription-category/api';
export const modalFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: {
class: 'w-full',
},
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
dependencies: {
show: false,
triggerFields: ['id'],
},
},
{
component: 'VbenInput',
componentProps: {
placeholder: '请输入特色方名称',
},
fieldName: 'name',
label: '名称',
rules: 'required',
defaultValue: '',
},
{
component: 'ApiSelect',
componentProps: {
placeholder: '请选择分类',
api: getSpecialPrescriptionCategoryOption,
allowClear: true,
showSearch: true,
filterOption: (input: string, option: any) =>
option?.label?.toLowerCase().includes(input.toLowerCase()),
afterFetch: (data: { id: number; name: string }[]) =>
data.map((item) => ({
label: item.name,
value: item.id,
})),
},
fieldName: 'category_id',
label: '分类',
rules: 'required',
},
{
component: 'Avatar',
fieldName: 'cover_image',
label: '封面',
defaultValue: '',
},
{
component: 'Select',
componentProps: {
mode: 'tags',
placeholder: '输入标签后回车',
},
fieldName: 'tags',
label: '标签',
rules: 'required',
defaultValue: [],
},
{
component: 'InputNumber',
componentProps: {
placeholder: '每剂价格',
min: 0,
precision: 2,
style: { width: '100%' },
},
fieldName: 'price_per_dose',
label: '每剂价格',
formItemClass: 'col-span-6',
defaultValue: 0,
},
{
component: 'InputNumber',
componentProps: {
placeholder: '销量',
min: 0,
style: { width: '100%' },
},
fieldName: 'sales_count',
label: '销量',
formItemClass: 'col-span-6',
defaultValue: 0,
},
{
component: 'VbenSelect',
componentProps: {
placeholder: '请选择状态',
options: [
{ label: '上架', value: 1 },
{ label: '下架', value: 0 },
],
},
fieldName: 'status',
label: '状态',
formItemClass: 'col-span-6',
defaultValue: 1,
},
{
component: 'VbenSelect',
componentProps: {
placeholder: '请选择处方类型',
options: [
{ label: '中药', value: 'chinese' },
{ label: '西药', value: 'west' },
{ label: '颗粒药', value: 'granular' },
],
},
fieldName: 'prescription_type',
label: '处方类型',
rules: 'required',
defaultValue: 'chinese',
},
{
component: 'Editor',
componentProps: {
placeholder: '请输入介绍文案',
},
fieldName: 'intro_text',
label: '介绍文案',
defaultValue: '',
},
{
component: 'UploadImageSortable',
fieldName: 'introduction_images',
label: '介绍图',
formItemClass: 'col-span-12',
componentProps: {
maxCount: 20,
multiple: true,
},
},
],
showDefaultActions: false,
};

View File

@@ -0,0 +1,41 @@
import type { VbenFormProps } from '@vben/common-ui';
export const formOptions: VbenFormProps = {
layout: 'inline',
showResetButton: true,
showSubmitButton: true,
schemas: [
{
fieldName: 'name',
component: 'Input',
label: '名称',
componentProps: {
placeholder: '请输入特色方名称',
allowClear: true,
},
},
{
fieldName: 'category_id',
component: 'InputNumber',
label: '分类ID',
componentProps: {
placeholder: '分类ID',
allowClear: true,
min: 1,
},
},
{
fieldName: 'status',
component: 'Select',
label: '状态',
componentProps: {
placeholder: '请选择状态',
allowClear: true,
options: [
{ label: '上架', value: 1 },
{ label: '下架', value: 0 },
],
},
},
],
};

View File

@@ -0,0 +1,101 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getSpecialPrescriptionList } from '../api';
interface RowType {
id: number;
name: string;
category_id: number;
category_name: string;
cover_image: string;
tags: string[];
price_per_dose: number;
sales_count: number;
sort: number;
status: number;
status_txt: string;
prescription_sub_type: number;
created_at: string;
}
const prescriptionTypeMap: Record<number, string> = {
1: '中药',
2: '西药',
3: '颗粒药',
};
export const gridOptions: VxeGridProps<RowType> = {
checkboxConfig: {
highlight: true,
labelField: '',
},
columnConfig: {
useKey: true,
},
rowConfig: {
useKey: true,
isHover: true,
isCurrent: true,
},
columns: [
{ type: 'checkbox', width: 60 },
{ field: 'id', align: 'left', title: 'ID', width: 80 },
{ field: 'name', align: 'left', title: '名称', minWidth: 160 },
{ field: 'category_name', align: 'left', title: '分类', width: 120 },
{
field: 'cover_image',
align: 'left',
title: '封面',
width: 100,
slots: { default: 'cover' },
},
{
field: 'tags',
align: 'left',
title: '标签',
minWidth: 160,
slots: { default: 'tags' },
},
{
field: 'prescription_sub_type',
align: 'left',
title: '处方类型',
width: 100,
formatter: ({ cellValue }) => prescriptionTypeMap[cellValue] || '-',
},
{ field: 'price_per_dose', align: 'left', title: '每剂价格', width: 100 },
{ field: 'sales_count', align: 'left', title: '销量', width: 80 },
{ field: 'sort', align: 'left', title: '排序', width: 80 },
{ field: 'status_txt', align: 'left', title: '状态', width: 80 },
{ field: 'created_at', title: '创建时间', width: 180 },
{ type: 'html', title: '操作', slots: { default: 'action' }, width: 160 },
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
return await getSpecialPrescriptionList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
border: false,
toolbarConfig: {
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: {
buttons: 'toolbar-buttons',
},
custom: {
icon: 'vxe-icon-menu',
},
},
showOverflow: false,
};

View File

@@ -0,0 +1,129 @@
<script lang="ts" setup>
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { Button, Image, message, Tag } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import { deleteSpecialPrescription } from './api';
import SpecialPrescriptionModal from './components/modal.vue';
import { formOptions as searchFormOptions } from './config/search';
import { gridOptions } from './config/table';
const hasTopTableDropDownActions = ref(false);
const gridEvents: VxeGridListeners<any> = {
checkboxChange() {
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
checkboxAll() {
const records = gridApi.grid.getCheckboxRecords();
hasTopTableDropDownActions.value = records.length > 0;
},
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions: searchFormOptions,
gridOptions,
gridEvents,
});
const [FormModal, formModalApi] = useVbenModal({
connectedComponent: SpecialPrescriptionModal,
});
const showModal = (data = {}, isUpdate = false) => {
formModalApi.setData({
values: data,
update: isUpdate,
gridApi,
});
formModalApi.open();
};
const deleteApi = (row: any) => {
let ids: (string | number)[] = [];
if (row) {
ids.push(row);
} else {
ids = gridApi.grid.getCheckboxRecords().map((item: any) => item.id);
}
deleteSpecialPrescription({ ids }).then(() => {
message.success('删除成功!');
gridApi.query();
});
};
</script>
<template>
<Page auto-content-height title="特色方管理">
<FormModal />
<div class="p-4">
<TableAction
:actions="[
{
label: '新增特色方',
type: 'primary',
icon: 'ant-design:plus-outlined',
onClick: () => showModal({}, false),
},
]"
/>
<Grid>
<template #cover="{ row }">
<Image
v-if="row.cover_image"
:width="60"
:height="60"
:src="row.cover_image"
:fallback="'/static/mine/avatar_1.png'"
/>
</template>
<template #tags="{ row }">
<Tag v-for="tag in row.tags || []" :key="tag" class="mb-1">
{{ tag }}
</Tag>
</template>
<template #toolbar-buttons>
<Button
v-if="hasTopTableDropDownActions"
danger
type="primary"
@click="deleteApi()"
>
删除
</Button>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '编辑',
onClick: () => showModal(row, true),
},
{
label: '删除',
color: 'error',
popConfirm: {
title: '确定要删除该特色方吗?',
onConfirm: () => deleteApi(row.id),
},
},
]"
/>
</template>
</Grid>
</div>
</Page>
</template>

View File

@@ -23,6 +23,7 @@ import {
import html2canvas from 'html2canvas'; import html2canvas from 'html2canvas';
import DoctorQrCodePreview from '#/components/modal/DoctorQrCodePreview.vue'; import DoctorQrCodePreview from '#/components/modal/DoctorQrCodePreview.vue';
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
import { import {
deleteNavApi, deleteNavApi,
getStoreInfoApi, getStoreInfoApi,
@@ -67,6 +68,10 @@ const [DoctorQrCodePreviewModal, DoctorQrCodePreviewModalApi] = useVbenModal({
connectedComponent: DoctorQrCodePreview, connectedComponent: DoctorQrCodePreview,
}); });
const [DoctorCardModals, DoctorCardModalApi] = useVbenModal({
connectedComponent: DoctorCardModal,
});
const [UploadModals, UploadModalApi] = useVbenModal({ const [UploadModals, UploadModalApi] = useVbenModal({
connectedComponent: UploadModal, connectedComponent: UploadModal,
}); });
@@ -89,6 +94,16 @@ const openDoctorQrCode = (doctorId: number, isNotQr = false) => {
DoctorQrCodePreviewModalApi.open(); DoctorQrCodePreviewModalApi.open();
}; };
/** 打开医生档案卡片(诊所团队场景隐藏关联诊所 Tab */
const openDoctorCard = (doctor: any) => {
DoctorCardModalApi.setData({
id: doctor.doctor?.id,
su_id: doctor.doctor?.su_id,
hideStoresTab: true,
});
DoctorCardModalApi.open();
};
// 诊所/药店数据 // 诊所/药店数据
const data = ref<null | StoreInfo>(null); const data = ref<null | StoreInfo>(null);
@@ -341,6 +356,7 @@ const handleSwitchClinicType = () => {
<template> <template>
<Page v-if="data" :title="`${data.name}的配置中心`" auto-content-height> <Page v-if="data" :title="`${data.name}的配置中心`" auto-content-height>
<DoctorQrCodePreviewModal /> <DoctorQrCodePreviewModal />
<DoctorCardModals />
<UploadModals /> <UploadModals />
<div class="pb-2"> <div class="pb-2">
<RadioGroup <RadioGroup
@@ -537,7 +553,9 @@ const handleSwitchClinicType = () => {
/> />
<div class="ml-4"> <div class="ml-4">
<h3 class="text-lg font-semibold"> <h3 class="text-lg font-semibold">
<span>{{ doctor.doctor?.name || '未知医生' }}</span> <Button type="link" class="!p-0 !h-auto text-lg font-semibold" @click="openDoctorCard(doctor)">
{{ doctor.doctor?.name || '未知医生' }}
</Button>
<span <span
:class="doctor.is_online ? 'text-green-500' : 'text-gray-500'" :class="doctor.is_online ? 'text-green-500' : 'text-gray-500'"
class="ml-2 text-sm" class="ml-2 text-sm"

View File

@@ -577,68 +577,70 @@ async function runBatchApply() {
v-if="!parsedReady" v-if="!parsedReady"
class="flex h-[calc(100vh-220px)] w-full flex-col items-center justify-center px-4 sm:px-12 md:px-24" class="flex h-[calc(100vh-220px)] w-full flex-col items-center justify-center px-4 sm:px-12 md:px-24"
> >
<!-- 将宽度限制放开外层容器最高占屏幕85%宽度 --> <div class="w-full max-w-[85vw] xl:max-w-screen-xl relative">
<div class="w-full max-w-[85vw] xl:max-w-screen-xl"> <!-- 核心隔离State A 也采用遮罩加载彻底抛弃包裹式用法 -->
<Spin :spinning="loadingAll || parseLoading" class="w-full" wrapperClassName="w-full"> <div
<div class="flex w-full flex-col items-center gap-8"> v-if="loadingAll || parseLoading"
class="absolute inset-0 z-50 flex items-center justify-center bg-white/70 backdrop-blur-[2px] dark:bg-gray-900/70 rounded-3xl"
>
<Spin size="large" tip="解析数据中..." />
</div>
<div class="text-center"> <div class="flex w-full flex-col items-center gap-8">
<h2 class="mb-3 text-3xl font-medium text-gray-800 dark:text-gray-100">导入药品价格表格</h2>
<p class="text-lg text-gray-500">上传 Excel 自动对比并提示价格涨跌差异</p>
</div>
<!-- 拖拽上传容器配合 CSS :deep 强行 100% 宽度 --> <div class="text-center">
<div class="batch-price-upload batch-price-upload--hero w-full transition-transform duration-300 hover:scale-[1.01]"> <h2 class="mb-3 text-3xl font-medium text-gray-800 dark:text-gray-100">导入药品价格表格</h2>
<UploadDragger <p class="text-lg text-gray-500">上传 Excel 自动对比并提示价格涨跌差异</p>
v-model:file-list="fileList"
:before-upload="() => false"
:max-count="1"
:show-upload-list="false"
accept=".xlsx,.xls"
name="file"
@change="handleFileChange"
>
<div class="flex min-h-[420px] w-full flex-col items-center justify-center py-16">
<div class="mb-8 rounded-full bg-blue-50 p-6 text-blue-500 ring-8 ring-blue-50/50 dark:bg-blue-900/30 dark:ring-blue-900/20">
<svg class="h-14 w-14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<p class="text-2xl font-medium text-gray-700 dark:text-gray-200">点击或将 Excel 文件拖拽至此</p>
<p class="mt-4 text-lg text-gray-400">
纯前端本地解析,保护数据隐私,不会上传至服务器
</p>
</div>
</UploadDragger>
</div>
<div class="flex min-h-[28px] items-center gap-2">
<p v-if="selectedFile" class="text-lg font-medium text-blue-600 dark:text-blue-400">
已选文件:{{ selectedFile.name }}
<span v-if="parseLoading" class="ml-2 animate-pulse text-gray-400">解析中...</span>
</p>
</div>
<Button
:disabled="loadingAll || warehouseItems.length === 0"
:loading="loadingAll"
size="large"
type="dashed"
class="!rounded-full px-10 py-2 shadow-sm"
@click="downloadTemplate"
>
下载标准模板
</Button>
</div> </div>
</Spin>
<div class="batch-price-upload batch-price-upload--hero w-full transition-transform duration-300 hover:scale-[1.01]">
<UploadDragger
v-model:file-list="fileList"
:before-upload="() => false"
:max-count="1"
:show-upload-list="false"
accept=".xlsx,.xls"
name="file"
@change="handleFileChange"
>
<div class="flex min-h-[420px] w-full flex-col items-center justify-center py-16">
<div class="mb-8 rounded-full bg-blue-50 p-6 text-blue-500 ring-8 ring-blue-50/50 dark:bg-blue-900/30 dark:ring-blue-900/20">
<svg class="h-14 w-14" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
</div>
<p class="text-2xl font-medium text-gray-700 dark:text-gray-200">点击或将 Excel 文件拖拽至此</p>
<p class="mt-4 text-lg text-gray-400">
纯前端本地解析,保护数据隐私,不会上传至服务器
</p>
</div>
</UploadDragger>
</div>
<div class="flex min-h-[28px] items-center gap-2">
<p v-if="selectedFile" class="text-lg font-medium text-blue-600 dark:text-blue-400">
已选文件:{{ selectedFile.name }}
</p>
</div>
<Button
:disabled="loadingAll || warehouseItems.length === 0"
size="large"
type="dashed"
class="!rounded-full px-10 py-2 shadow-sm"
@click="downloadTemplate"
>
下载标准模板
</Button>
</div>
</div> </div>
</div> </div>
<!-- State B: Result Grid View --> <!-- State B: Result Grid View -->
<div v-else class="flex h-[calc(100vh-220px)] flex-col gap-4"> <div v-else class="flex flex-col gap-4 w-full" style="height: calc(100vh - 200px);">
<!-- Header Info Bar --> <!-- Header Info Bar -->
<div class="flex flex-col gap-4 rounded-xl border border-gray-100 bg-gray-50/50 p-4 shadow-sm backdrop-blur-sm dark:border-gray-800 dark:bg-gray-900/30 sm:flex-row sm:items-center sm:justify-between"> <div class="flex flex-col gap-4 rounded-xl border border-gray-100 bg-gray-50/50 p-4 shadow-sm backdrop-blur-sm dark:border-gray-800 dark:bg-gray-900/30 sm:flex-row sm:items-center sm:justify-between shrink-0">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="batch-price-upload batch-price-upload--mini"> <div class="batch-price-upload batch-price-upload--mini">
<UploadDragger <UploadDragger
@@ -669,7 +671,7 @@ async function runBatchApply() {
<div class="h-4 w-px bg-gray-300 dark:bg-gray-700"></div> <div class="h-4 w-px bg-gray-300 dark:bg-gray-700"></div>
<Button <Button
v-if="selectedFile" v-if="selectedFile"
:loading="parseLoading" :disabled="parseLoading"
size="small" size="small"
type="link" type="link"
@click="runParse" @click="runParse"
@@ -679,19 +681,28 @@ async function runBatchApply() {
</div> </div>
</div> </div>
<!-- Main Compare Grid --> <!-- Main Compare Grid Container (原生 Flex无第三方组件污染) -->
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800"> <div class="relative flex flex-1 flex-col overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-100 dark:bg-gray-900 dark:ring-gray-800 min-h-0">
<Spin :spinning="loadingAll || parseLoading" wrapperClassName="h-full flex flex-col min-h-0">
<!-- 核心隔离:绝对定位的 Loading 遮罩层,完美规避 Spin 破坏 Flex 布局高度的深坑 -->
<div
v-if="loadingAll || parseLoading"
class="absolute inset-0 z-50 flex items-center justify-center bg-white/70 backdrop-blur-[2px] dark:bg-gray-900/70"
>
<Spin size="large" tip="数据处理中..." />
</div>
<!-- 核心隔离:原生的独立滚动容器舱。不管内部自定义组件根节点什么脾气,在这里统统乖乖滚动 -->
<div class="flex-1 w-full overflow-y-auto p-4 custom-scrollbar relative">
<PriceCompareCardGrid <PriceCompareCardGrid
v-model:filter-tab="filterTab" v-model:filter-tab="filterTab"
class="flex min-h-0 flex-1 flex-col overflow-hidden p-4"
:rows="compareRows" :rows="compareRows"
:saving-row-id="savingRowId" :saving-row-id="savingRowId"
:unmatched-rows="unmatchedRows" :unmatched-rows="unmatchedRows"
@cell-change="syncRowFromGrid" @cell-change="syncRowFromGrid"
@row-save="handleRowSave" @row-save="handleRowSave"
/> />
</Spin> </div>
</div> </div>
</div> </div>
</Transition> </Transition>
@@ -728,6 +739,23 @@ async function runBatchApply() {
transform: translateY(-20px); transform: translateY(-20px);
} }
/* 美化区:全局/局部自定义优雅滚动条 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(156, 163, 175, 0.5);
border-radius: 9999px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(107, 114, 128, 0.8);
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
/* Upload Dragger overrides for modern aesthetic & FORCE FULL WIDTH */ /* Upload Dragger overrides for modern aesthetic & FORCE FULL WIDTH */
.batch-price-upload { .batch-price-upload {
width: 100%; width: 100%;

View File

@@ -1,6 +1,14 @@
import { requestClient } from '#/api/request'; import { requestClient } from '#/api/request';
const prefix = 'doctor-reception/'; const prefix = 'doctor-reception/';
/**
* 医生应用特色方到开方区
*/
export async function applySpecialPrescriptionApi(data: { register_id: number }) {
return requestClient.post<any>('special-prescription/apply', data);
}
/** /**
* 分页查询用户列表 * 分页查询用户列表
* @param data * @param data

View File

@@ -47,6 +47,7 @@ import {
getProcessRuleList, getProcessRuleList,
getProductListDoctorReception, getProductListDoctorReception,
receptionApi, receptionApi,
applySpecialPrescriptionApi,
switchStoreApi, switchStoreApi,
withdrawPrescriptionApi, withdrawPrescriptionApi,
} from '#/views/doctor/doctor-reception/api'; } from '#/views/doctor/doctor-reception/api';
@@ -94,6 +95,24 @@ interface Patient {
name: string; name: string;
mobile: string; mobile: string;
status: number; status: number;
user_patient?: {
id: number;
name: string;
age?: number;
sex?: number;
mobile?: string;
};
special_prescription_patient_record?: {
id: number;
status: number;
special_prescription_id: number;
dose_count: number;
special_prescription?: {
id: number;
name: string;
cover_image?: string;
};
};
} }
interface UserPatientHealthInquiry { interface UserPatientHealthInquiry {
@@ -319,6 +338,14 @@ const activeCategory = ref(2);
const canUseCommonPrescription = computed(() => const canUseCommonPrescription = computed(() =>
[1, 2].includes(activeCategory.value), [1, 2].includes(activeCategory.value),
); );
/** 当前挂号是否有关联的特色方选方记录(可重复导入) */
const hasSpecialPrescriptionRecord = computed(
() => !!patientInfo.value?.special_prescription_patient_record,
);
const specialPrescriptionRecord = computed(
() => patientInfo.value?.special_prescription_patient_record ?? null,
);
const applyingSpecialPrescription = ref(false);
const diagnosis = ref(''); const diagnosis = ref('');
const medicalAdvice = ref(''); const medicalAdvice = ref('');
const treatmentPrice = ref(0); const treatmentPrice = ref(0);
@@ -997,6 +1024,63 @@ async function handleSelectCommonPrescription(data: any, type: number) {
updateLocalStorage(); updateLocalStorage();
} }
/**
* 应用患者选择的特色方到处方购物车
* @param switchToPrescription 是否切换到开方 Tab患者信息页导入时为 true
*/
async function handleApplySpecialPrescription(switchToPrescription = false) {
const registerId = doctorReceptionRegisterId.value;
if (!registerId) {
message.warning('请先选择患者');
return;
}
applyingSpecialPrescription.value = true;
try {
const res = await applySpecialPrescriptionApi({ register_id: registerId });
const typeMap: Record<string, number> = {
west: 1,
chinese: 2,
granular: 3,
};
const categoryMap: Record<string, number> = {
west: 2,
chinese: 1,
granular: 1,
};
const prescriptionType = res.prescription_type || 'chinese';
activeCategory.value = categoryMap[prescriptionType] || 1;
if (switchToPrescription) {
tabType.value = 2;
}
await handleSelectCommonPrescription(
{
prescription: {
dosage: res.dosage ?? res.dose_count ?? 7,
day_dosage: res.day_dosage ?? 2,
rule_type: res.rule_type ?? 1,
package_method_id: res.package_method_id ?? 2,
clinical_diagnose: res.clinical_diagnose ?? '',
doctor_order: res.doctor_order ?? '',
},
recipes: res.recipes || [],
},
typeMap[prescriptionType] || 2,
);
if (selectPatientId.value) {
const value = await getPatientItem(selectPatientId.value);
patientInfo.value = value;
}
message.success(`已应用特色方:${res.special_prescription_name || ''}`);
} catch (error) {
console.error('应用特色方失败:', error);
message.error('应用特色方失败,请稍后重试');
} finally {
applyingSpecialPrescription.value = false;
}
}
// ==================== 保存常用方相关方法 ==================== // ==================== 保存常用方相关方法 ====================
/** /**
@@ -2109,7 +2193,19 @@ watch(
@click="selectPatient(patient)" @click="selectPatient(patient)"
> >
<div class="patient-info"> <div class="patient-info">
<h3>{{ patient.user_patient.name }}</h3> <h3 class="flex flex-wrap items-center gap-2">
{{ patient.user_patient.name }}
<Tag
v-if="patient.special_prescription_patient_record"
:color="patient.special_prescription_patient_record.status === 0 ? 'orange' : 'default'"
>
特色方{{
patient.special_prescription_patient_record.status === 0
? '·待导入'
: ''
}}
</Tag>
</h3>
<p class="phone">{{ patient.user_patient.mobile }}</p> <p class="phone">{{ patient.user_patient.mobile }}</p>
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
推广员{{ patient.salesperson?.nick_name || '无' }} 推广员{{ patient.salesperson?.nick_name || '无' }}
@@ -2183,6 +2279,52 @@ watch(
}} }}
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
<Card
v-if="specialPrescriptionRecord"
class="mt-5"
title="特色方"
>
<div class="flex flex-wrap items-center gap-4">
<Image
v-if="specialPrescriptionRecord.special_prescription?.cover_image"
:src="specialPrescriptionRecord.special_prescription.cover_image"
:width="80"
:height="80"
class="rounded object-cover"
/>
<div class="flex-1">
<div class="text-base font-medium">
{{ specialPrescriptionRecord.special_prescription?.name || '特色方' }}
</div>
<div class="mt-1 text-gray-500">
剂量{{ specialPrescriptionRecord.dose_count }}
</div>
<Tag
v-if="specialPrescriptionRecord.status === 0"
class="mt-2"
color="orange"
>
待导入
</Tag>
<Tag
v-else-if="specialPrescriptionRecord.status === 1"
class="mt-2"
color="green"
>
已应用
</Tag>
<Tag v-else class="mt-2" color="default">已完成</Tag>
</div>
<Button
v-if="hasSpecialPrescriptionRecord"
type="primary"
:loading="applyingSpecialPrescription"
@click="handleApplySpecialPrescription(true)"
>
导入
</Button>
</div>
</Card>
<Descriptions <Descriptions
v-if="userPatientHealthInquiry" v-if="userPatientHealthInquiry"
:column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 2 }" :column="{ xxl: 2, xl: 2, lg: 2, md: 2, sm: 2, xs: 2 }"
@@ -2389,6 +2531,15 @@ watch(
> >
选择常用方 选择常用方
</Button> </Button>
<Button
v-if="hasSpecialPrescriptionRecord"
type="primary"
class="ml-3"
:loading="applyingSpecialPrescription"
@click="() => handleApplySpecialPrescription()"
>
应用特色方
</Button>
<Button <Button
v-if="canUseCommonPrescription" v-if="canUseCommonPrescription"
type="default" type="default"

View File

@@ -0,0 +1,73 @@
import { requestClient } from '#/api/request';
const prefix = 'doctor/';
/** 构建医生卡片查询参数id 与 su_id 至少传一个 */
function buildDoctorCardParams(
id?: number,
suId?: number,
extra: Record<string, any> = {},
) {
const params: Record<string, any> = { ...extra };
if (id) {
params.id = id;
}
if (suId) {
params.su_id = suId;
}
return params;
}
export async function getDoctorCardApi(id?: number, suId?: number) {
return requestClient.get<any>(`${prefix}card`, {
params: buildDoctorCardParams(id, suId),
});
}
export async function getDoctorCardStatsApi(
id?: number,
suId?: number,
storeId?: number,
) {
return requestClient.get<any>(`${prefix}card-stats`, {
params: buildDoctorCardParams(id, suId, { store_id: storeId }),
});
}
export async function getDoctorCardPrescriptionsApi(
id?: number,
suId?: number,
params: Record<string, any> = {},
) {
return requestClient.get<any>(`${prefix}card-prescriptions`, {
params: buildDoctorCardParams(id, suId, params),
});
}
export async function getDoctorCardPatientsApi(
id?: number,
suId?: number,
params: Record<string, any> = {},
) {
return requestClient.get<any>(`${prefix}card-patients`, {
params: buildDoctorCardParams(id, suId, params),
});
}
export async function getDoctorCardOrdersApi(
id?: number,
suId?: number,
params: Record<string, any> = {},
) {
return requestClient.get<any>(`${prefix}card-orders`, {
params: buildDoctorCardParams(id, suId, params),
});
}
export async function updateDoctorCredentialsApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update-credentials`, data);
}
export async function updateDoctorSignatureByAdminApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update-signature-by-admin`, data);
}

View File

@@ -0,0 +1,682 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import {
Alert,
Avatar,
Button,
Card,
Col,
Descriptions,
Empty,
Form,
Image,
Input,
message,
Row,
Select,
Spin,
Table,
Tabs,
Tag,
Divider
} from 'ant-design-vue';
import AvatarUpload from '#/components/form/components/avatar.vue';
import DoctorQrCodePreview from '#/components/modal/DoctorQrCodePreview.vue';
import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue';
import OrderDetail from '#/views/business/order/product-order/components/detail.vue';
import { openPcWindowsApi, updateDoctor } from '../api';
import {
getDoctorCardApi,
getDoctorCardOrdersApi,
getDoctorCardPatientsApi,
getDoctorCardPrescriptionsApi,
updateDoctorCredentialsApi,
updateDoctorSignatureByAdminApi,
} from '../api/card';
const gridApi = ref<any>();
const doctorId = ref(0);
const suId = ref(0);
const hideStoresTab = ref(false);
const loading = ref(false);
const activeTab = ref('overview');
const cardData = ref<any>(null);
const overviewForm = ref({
avatar: '',
name: '',
mobile: '',
good_at: '',
intro: '',
});
const credentialsForm = ref({
card_up: '',
card_down: '',
sign_type: 1,
sign_image: '',
qualification: '',
practicing: '',
title: '',
});
const savingOverview = ref(false);
const savingCredentials = ref(false);
const prescriptionLoading = ref(false);
const prescriptionList = ref<any[]>([]);
const prescriptionPage = ref(1);
const prescriptionTotal = ref(0);
const patientLoading = ref(false);
const patientList = ref<any[]>([]);
const patientPage = ref(1);
const patientTotal = ref(0);
const orderLoading = ref(false);
const orderList = ref<any[]>([]);
const orderPage = ref(1);
const orderTotal = ref(0);
const stats = computed(() => cardData.value?.stats_summary ?? {});
const [QrCodeModal, QrCodeModalApi] = useVbenModal({
connectedComponent: DoctorQrCodePreview,
});
const [PrescriptionDetailModal, PrescriptionDetailModalApi] = useVbenModal({
connectedComponent: PrescriptionDetail,
});
const [OrderDetailDrawer, OrderDetailModalApi] = useVbenModal({
connectedComponent: OrderDetail,
});
/** 获取当前医生标识,列表类接口复用 */
function getCardIdentity() {
return {
id: doctorId.value || undefined,
suId: suId.value || undefined,
};
}
async function loadCard() {
if (!doctorId.value && !suId.value) return;
loading.value = true;
try {
const { id, suId: querySuId } = getCardIdentity();
const res = await getDoctorCardApi(id, querySuId);
cardData.value = res;
doctorId.value = res?.doctor_info?.id ?? doctorId.value;
suId.value = res?.doctor_info?.su_id ?? suId.value;
overviewForm.value = {
avatar: res?.doctor_info?.avatar ?? '',
name: res?.doctor_info?.name ?? '',
mobile: res?.doctor_info?.mobile ?? '',
good_at: res?.doctor_info?.good_at ?? '',
intro: res?.doctor_info?.intro ?? '',
};
credentialsForm.value = {
card_up: res?.identity?.card_up ?? '',
card_down: res?.identity?.card_down ?? '',
sign_type: res?.identity?.sign_type ?? 1,
sign_image: res?.identity?.sign_image ?? '',
qualification: res?.practicing?.qualification ?? '',
practicing: res?.practicing?.practicing ?? '',
title: res?.practicing?.title ?? '',
};
} finally {
loading.value = false;
}
}
async function saveOverview() {
savingOverview.value = true;
try {
const res = await updateDoctor({
id: doctorId.value,
su_id: suId.value,
...overviewForm.value,
});
if (res?.sync && !res.sync.admin_found) {
message.warning('资料已保存,但该医生未开通 PC 后台,管理员头像未同步');
} else {
message.success('保存成功');
}
await loadCard();
gridApi.value?.reload?.();
} finally {
savingOverview.value = false;
}
}
async function saveCredentials() {
savingCredentials.value = true;
try {
await updateDoctorCredentialsApi({
id: doctorId.value,
...credentialsForm.value,
});
if (credentialsForm.value.sign_image) {
await updateDoctorSignatureByAdminApi({
id: doctorId.value,
sign_image: credentialsForm.value.sign_image,
});
}
message.success('证书资料保存成功');
await loadCard();
} finally {
savingCredentials.value = false;
}
}
async function loadPrescriptions(page = 1) {
prescriptionLoading.value = true;
try {
const { id, suId: querySuId } = getCardIdentity();
const res = await getDoctorCardPrescriptionsApi(id, querySuId, {
page,
pageSize: 10,
});
prescriptionList.value = res.items ?? [];
prescriptionPage.value = res.page ?? 1;
prescriptionTotal.value = res.total ?? 0;
} finally {
prescriptionLoading.value = false;
}
}
async function loadPatients(page = 1) {
patientLoading.value = true;
try {
const { id, suId: querySuId } = getCardIdentity();
const res = await getDoctorCardPatientsApi(id, querySuId, {
page,
pageSize: 10,
});
patientList.value = res.items ?? [];
patientPage.value = res.page ?? 1;
patientTotal.value = res.total ?? 0;
} finally {
patientLoading.value = false;
}
}
async function loadOrders(page = 1) {
orderLoading.value = true;
try {
const { id, suId: querySuId } = getCardIdentity();
const res = await getDoctorCardOrdersApi(id, querySuId, {
page,
pageSize: 10,
});
orderList.value = res.items ?? [];
orderPage.value = res.page ?? 1;
orderTotal.value = res.total ?? 0;
} finally {
orderLoading.value = false;
}
}
function onTabChange(key: string) {
activeTab.value = key;
if (key === 'prescriptions' && prescriptionList.value.length === 0) {
loadPrescriptions();
}
if (key === 'patients' && patientList.value.length === 0) {
loadPatients();
}
if (key === 'orders' && orderList.value.length === 0) {
loadOrders();
}
}
function openPrescriptionDetail(prescriptionId: number) {
PrescriptionDetailModalApi.setData({ values: prescriptionId });
PrescriptionDetailModalApi.open();
}
function openOrderDetail(orderId: number) {
OrderDetailModalApi.setData({ id: orderId });
OrderDetailModalApi.open();
}
function openStoreQr(storeId: number) {
QrCodeModalApi.setData({
values: { doctor_id: suId.value, store_id: storeId },
});
QrCodeModalApi.open();
}
function openPcWindows() {
openPcWindowsApi(doctorId.value).then(() => {
message.success('开通成功');
loadCard();
gridApi.value?.reload?.();
});
}
const prescriptionColumns = [
{ title: '处方号', dataIndex: 'prescription_no', key: 'prescription_no' },
{ title: '患者', dataIndex: ['user_patient', 'name'], key: 'patient' },
{ title: '诊所', dataIndex: ['store', 'name'], key: 'store' },
{ title: '金额(元)', dataIndex: 'total_pay_price', key: 'amount' },
{ title: '开具时间', dataIndex: 'created_at', key: 'created_at' },
{ title: '操作', key: 'action', width: 80 },
];
const patientColumns = [
{ title: '姓名', dataIndex: 'name', key: 'name' },
{ title: '性别', dataIndex: 'sex', key: 'sex' },
{ title: '手机号', dataIndex: 'mobile', key: 'mobile' },
];
const orderColumns = [
{ title: '订单ID', dataIndex: 'id', key: 'id' },
{ title: '处方号', dataIndex: ['prescription', 'prescription_no'], key: 'pno' },
{ title: '患者', dataIndex: ['user_patient', 'name'], key: 'patient' },
{ title: '金额(元)', dataIndex: 'total_price', key: 'total_price' },
{ title: '支付状态', dataIndex: 'is_pay', key: 'is_pay' },
{ title: '操作', key: 'action', width: 80 },
];
const [Modal, modalApi] = useVbenModal({
closeOnClickModal: false,
fullscreenButton: true,
draggable: true,
class: 'w-[900px] xl:w-[1100px]',
footer: false,
onOpenChange(isOpen: boolean) {
if (isOpen) {
const data = modalApi.getData<Record<string, any>>();
gridApi.value = data?.gridApi;
doctorId.value = data?.id ?? data?.values?.id ?? 0;
suId.value = data?.su_id ?? data?.values?.su_id ?? 0;
hideStoresTab.value = !!data?.hideStoresTab;
activeTab.value = 'overview';
prescriptionList.value = [];
patientList.value = [];
orderList.value = [];
loadCard();
}
},
});
</script>
<template>
<Modal :title="cardData?.doctor_info?.name ? `医生档案 · ${cardData.doctor_info.name}` : '医生档案'">
<Spin :spinning="loading">
<!-- 顶部核心数据区图文结合 + 暗色自适应 -->
<div v-if="cardData" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6 px-1">
<!-- 接诊数 -->
<div class="relative overflow-hidden flex flex-col p-5 bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800/50 rounded-xl shadow-sm group hover:shadow-md transition-all">
<div class="flex justify-between items-start relative z-10">
<div>
<span class="text-blue-600/80 dark:text-blue-300/80 text-sm font-semibold">累计接诊</span>
<div class="text-2xl font-bold text-blue-900 dark:text-blue-100 mt-2">
{{ stats.reception_count ?? 0 }} <span class="text-xs font-normal text-blue-700/60 dark:text-blue-300/60"></span>
</div>
</div>
<div class="p-2 bg-blue-100 dark:bg-blue-800/50 rounded-lg text-blue-500 dark:text-blue-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
</div>
</div>
</div>
<!-- 拒诊数 -->
<div class="relative overflow-hidden flex flex-col p-5 bg-rose-50 dark:bg-rose-900/20 border border-rose-100 dark:border-rose-800/50 rounded-xl shadow-sm group hover:shadow-md transition-all">
<div class="flex justify-between items-start relative z-10">
<div>
<span class="text-rose-600/80 dark:text-rose-300/80 text-sm font-semibold">拒诊数量</span>
<div class="text-2xl font-bold text-rose-900 dark:text-rose-100 mt-2">
{{ stats.refused_count ?? 0 }} <span class="text-xs font-normal text-rose-700/60 dark:text-rose-300/60"></span>
</div>
</div>
<div class="p-2 bg-rose-100 dark:bg-rose-800/50 rounded-lg text-rose-500 dark:text-rose-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636" /></svg>
</div>
</div>
</div>
<!-- 处方数 -->
<div class="relative overflow-hidden flex flex-col p-5 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-100 dark:border-emerald-800/50 rounded-xl shadow-sm group hover:shadow-md transition-all">
<div class="flex justify-between items-start relative z-10">
<div>
<span class="text-emerald-600/80 dark:text-emerald-300/80 text-sm font-semibold">处方总数</span>
<div class="text-2xl font-bold text-emerald-900 dark:text-emerald-100 mt-2">
{{ stats.prescription_count ?? 0 }} <span class="text-xs font-normal text-emerald-700/60 dark:text-emerald-300/60"></span>
</div>
</div>
<div class="p-2 bg-emerald-100 dark:bg-emerald-800/50 rounded-lg text-emerald-500 dark:text-emerald-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
</div>
</div>
</div>
<!-- 处方金额 -->
<div class="relative overflow-hidden flex flex-col p-5 bg-purple-50 dark:bg-purple-900/20 border border-purple-100 dark:border-purple-800/50 rounded-xl shadow-sm group hover:shadow-md transition-all">
<div class="flex justify-between items-start relative z-10">
<div>
<span class="text-purple-600/80 dark:text-purple-300/80 text-sm font-semibold">处方总额</span>
<div class="text-2xl font-bold text-purple-900 dark:text-purple-100 mt-2">
<span class="text-lg">¥</span> {{ Number(stats.prescription_amount ?? 0).toFixed(2) }}
</div>
</div>
<div class="p-2 bg-purple-100 dark:bg-purple-800/50 rounded-lg text-purple-500 dark:text-purple-300">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</div>
</div>
</div>
</div>
<Divider class="!my-4 dark:border-slate-700/60" />
<!-- 主体内容区 -->
<Tabs
:active-key="activeTab"
@change="onTabChange"
tab-position="left"
class="min-h-[480px] custom-vertical-tabs"
>
<Tabs.TabPane key="overview" tab="概览信息">
<div class="pl-6">
<div class="flex justify-between items-end mb-4">
<h3 class="text-base font-semibold text-gray-800 dark:text-slate-200">基本资料</h3>
</div>
<div class="bg-gray-50 dark:bg-[#1f242e] p-5 rounded-lg border border-gray-100 dark:border-slate-700 mb-6 transition-colors">
<Row :gutter="32">
<Col :span="6" class="flex flex-col items-center border-r border-gray-200 dark:border-slate-700">
<div class="text-gray-500 dark:text-slate-400 mb-3 text-sm">医生头像</div>
<AvatarUpload v-model:value="overviewForm.avatar" class="shadow-sm rounded-full overflow-hidden" />
</Col>
<Col :span="18">
<Form layout="vertical" class="grid grid-cols-2 gap-x-6">
<Form.Item label="医生姓名" class="mb-4">
<Input v-model:value="overviewForm.name" placeholder="请输入姓名" size="large" />
</Form.Item>
<Form.Item label="手机号码" class="mb-4">
<Input v-model:value="overviewForm.mobile" placeholder="请输入手机号码" size="large" />
</Form.Item>
<Form.Item label="专业擅长" class="col-span-2 mb-4">
<Input.TextArea v-model:value="overviewForm.good_at" :rows="2" placeholder="填写医生擅长的领域..." />
</Form.Item>
<Form.Item label="个人简介" class="col-span-2 mb-4">
<Input.TextArea v-model:value="overviewForm.intro" :rows="3" placeholder="填写医生简介..." />
</Form.Item>
<div class="col-span-2 text-right mt-2">
<Button type="primary" size="large" :loading="savingOverview" @click="saveOverview">
保存基本信息
</Button>
</div>
</Form>
</Col>
</Row>
</div>
<h3 class="text-base font-semibold text-gray-800 dark:text-slate-200 mb-4 mt-8">系统档案 (只读)</h3>
<Descriptions bordered :column="3" size="middle" class="bg-white dark:bg-[#18181c] shadow-sm rounded-lg overflow-hidden border-gray-200 dark:border-slate-700">
<Descriptions.Item label="医生ID">{{ cardData?.doctor_info?.id }}</Descriptions.Item>
<Descriptions.Item label="微信平台ID">{{ cardData?.doctor_info?.su_id }}</Descriptions.Item>
<Descriptions.Item label="审核状态">
<Tag v-if="cardData?.service_user?.status === 2" color="success" class="!mr-0">已认证通过</Tag>
<Tag v-else color="default" class="!mr-0">{{ cardData?.service_user?.status || '未审核' }}</Tag>
</Descriptions.Item>
<Descriptions.Item label="所属科室">{{ cardData?.doctor_info?.depart?.name ?? '-' }}</Descriptions.Item>
<Descriptions.Item label="执业职称">{{ cardData?.doctor_info?.title?.name ?? '-' }}</Descriptions.Item>
<Descriptions.Item label="诊疗科目">{{ cardData?.doctor_info?.subjects?.name ?? '-' }}</Descriptions.Item>
</Descriptions>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="credentials" tab="证书与签名">
<div class="pl-6">
<div class="flex justify-between items-center mb-5">
<div>
<h3 class="text-base font-semibold text-gray-800 dark:text-slate-200">资质证书管理</h3>
<p class="text-sm text-gray-500 dark:text-slate-400 mt-1">请上传清晰无遮挡的证件扫描件或照片</p>
</div>
<Button type="primary" :loading="savingCredentials" @click="saveCredentials">
保存全部资质
</Button>
</div>
<Form layout="vertical">
<!-- 通过比例控制让上传区域更像证件 -->
<div class="grid grid-cols-2 lg:grid-cols-3 gap-6 bg-gray-50 dark:bg-[#1f242e] p-5 rounded-lg border border-gray-100 dark:border-slate-700">
<Form.Item label="身份证 (正面)" class="mb-0 bg-white dark:bg-[#18181c] p-4 rounded-md shadow-sm border border-gray-100 dark:border-slate-700/60 text-center">
<div class="aspect-[1.6/1] w-full flex items-center justify-center">
<AvatarUpload v-model:value="credentialsForm.card_up" />
</div>
</Form.Item>
<Form.Item label="身份证 (反面)" class="mb-0 bg-white dark:bg-[#18181c] p-4 rounded-md shadow-sm border border-gray-100 dark:border-slate-700/60 text-center">
<div class="aspect-[1.6/1] w-full flex items-center justify-center">
<AvatarUpload v-model:value="credentialsForm.card_down" />
</div>
</Form.Item>
<Form.Item label="执业资格证书" class="mb-0 bg-white dark:bg-[#18181c] p-4 rounded-md shadow-sm border border-gray-100 dark:border-slate-700/60 text-center">
<div class="aspect-[1.6/1] w-full flex items-center justify-center">
<AvatarUpload v-model:value="credentialsForm.qualification" />
</div>
</Form.Item>
<Form.Item label="执业证书" class="mb-0 bg-white dark:bg-[#18181c] p-4 rounded-md shadow-sm border border-gray-100 dark:border-slate-700/60 text-center">
<div class="aspect-[1.6/1] w-full flex items-center justify-center">
<AvatarUpload v-model:value="credentialsForm.practicing" />
</div>
</Form.Item>
<Form.Item label="职称证书" class="mb-0 bg-white dark:bg-[#18181c] p-4 rounded-md shadow-sm border border-gray-100 dark:border-slate-700/60 text-center">
<div class="aspect-[1.6/1] w-full flex items-center justify-center">
<AvatarUpload v-model:value="credentialsForm.title" />
</div>
</Form.Item>
<div class="flex flex-col gap-4">
<Form.Item label="医生签名图 (透明底)" class="mb-0 bg-white dark:bg-[#18181c] p-4 rounded-md shadow-sm border border-gray-100 dark:border-slate-700/60 flex-1 text-center">
<AvatarUpload v-model:value="credentialsForm.sign_image" />
</Form.Item>
<Form.Item label="签名验证类型" class="mb-0 bg-white dark:bg-[#18181c] p-3 rounded-md shadow-sm border border-gray-100 dark:border-slate-700/60">
<Select v-model:value="credentialsForm.sign_type" size="large" :options="[
{ label: '电子认证签名', value: 1 },
{ label: '手写扫描签名', value: 2 },
]" />
</Form.Item>
</div>
</div>
</Form>
</div>
</Tabs.TabPane>
<Tabs.TabPane v-if="!hideStoresTab" key="stores" tab="关联诊所">
<div class="pl-6 h-full">
<Empty v-if="!cardData?.stores?.length" description="暂无绑定的诊所记录" class="mt-20" />
<div v-else class="grid grid-cols-2 gap-5">
<Card
v-for="item in cardData.stores"
:key="item.store_id"
:title="item.store?.name ?? `诊所编号 #${item.store_id}`"
class="shadow-sm border-gray-200 dark:border-slate-700 hover:shadow-md transition-shadow dark:bg-[#18181c]"
:head-style="{ borderBottom: '1px solid var(--ant-color-split)' }"
>
<div class="text-sm text-gray-600 dark:text-slate-400 space-y-3 mb-4">
<p class="flex items-start">
<svg class="w-4 h-4 mr-2 mt-0.5 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
<span>{{ item.store?.province }}{{ item.store?.city }}{{ item.store?.position }}</span>
</p>
<p class="flex items-center">
<svg class="w-4 h-4 mr-2 text-gray-400 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /></svg>
<span>{{ item.store?.contact }} ({{ item.store?.mobile }})</span>
</p>
</div>
<div class="flex items-center justify-between border-t border-gray-100 dark:border-slate-700/60 pt-4 mt-4">
<div v-if="item.qr_code" class="border dark:border-slate-600 p-1 rounded bg-white">
<Image :src="item.qr_code" :width="56" :height="56" class="rounded-sm" />
</div>
<span v-else class="text-gray-400 text-xs">暂无专属海报</span>
<Button type="primary" ghost size="small" @click="openStoreQr(item.store_id)">查看分享海报</Button>
</div>
</Card>
</div>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="admin" tab="后台账号">
<div class="pl-6 max-w-2xl">
<template v-if="cardData?.admin">
<div class="bg-white dark:bg-[#1f242e] border border-gray-200 dark:border-slate-700 rounded-lg p-6 flex items-center gap-6 shadow-sm">
<Avatar :src="cardData.admin.avatar" :size="84" class="border-2 border-blue-50 dark:border-blue-900 shadow-sm" />
<div class="flex-1">
<h3 class="text-xl font-bold text-gray-800 dark:text-slate-100 mb-1">{{ cardData.admin.nick_name }}</h3>
<p class="text-gray-500 dark:text-slate-400 mb-4 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
{{ cardData.admin.phone }}
</p>
<Tag color="processing">PC 端权限已开通</Tag>
</div>
</div>
</template>
<template v-else>
<div class="bg-orange-50 dark:bg-orange-950/30 border border-orange-100 dark:border-orange-900/50 rounded-lg p-8 text-center mt-10">
<div class="text-orange-400 text-5xl mb-4">🖥</div>
<h3 class="text-lg font-semibold text-gray-800 dark:text-slate-200 mb-2">未开通 PC 端管理权限</h3>
<p class="text-gray-500 dark:text-slate-400 mb-6 text-sm">开通后医生可使用电脑端登录系统进行高级管理操作如查看完整报表等</p>
<Button type="primary" size="large" @click="openPcWindows">立即开通后台账号</Button>
</div>
</template>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="service" tab="服务配置">
<div class="pl-6">
<h3 class="text-base font-semibold text-gray-800 dark:text-slate-200 mb-4">出诊与挂号服务</h3>
<Descriptions v-if="cardData?.service" bordered :column="2" class="bg-white dark:bg-[#18181c] shadow-sm rounded-lg overflow-hidden border-gray-200 dark:border-slate-700">
<Descriptions.Item label="当前挂号状态">
<Tag :color="cardData.service.register_status === 1 ? 'success' : 'default'">
{{ cardData.service.status_text }}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="挂号服务费(元)">
<span class="text-lg font-bold text-orange-500 dark:text-orange-400">¥ {{ cardData.service.register_price }}</span>
</Descriptions.Item>
<Descriptions.Item label="每日号源上限">
<span class="font-medium text-gray-800 dark:text-slate-300">{{ cardData.service.register_num }} </span>
</Descriptions.Item>
</Descriptions>
<Empty v-else description="暂未配置任何服务项" class="mt-20" />
</div>
</Tabs.TabPane>
<Tabs.TabPane key="prescriptions" tab="处方记录">
<div class="pl-6 h-full">
<Table
:columns="prescriptionColumns"
:data-source="prescriptionList"
:loading="prescriptionLoading"
:pagination="{
current: prescriptionPage,
total: prescriptionTotal,
pageSize: 10,
onChange: loadPrescriptions,
showTotal: (total) => `共 ${total} 条数据`
}"
row-key="id"
size="middle"
class="border border-gray-100 dark:border-slate-700 rounded-lg shadow-sm"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'prescription_no'">
<Button type="link" size="small" @click="openPrescriptionDetail(record.id)">
{{ record.prescription_no }}
</Button>
</template>
<template v-if="column.key === 'action'">
<Button type="link" size="small" @click="openPrescriptionDetail(record.id)">查看</Button>
</template>
</template>
</Table>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="patients" tab="患者列表">
<div class="pl-6 h-full">
<Table
:columns="patientColumns"
:data-source="patientList"
:loading="patientLoading"
:pagination="{
current: patientPage,
total: patientTotal,
pageSize: 10,
onChange: loadPatients,
showTotal: (total) => `共 ${total} 名患者`
}"
row-key="id"
size="middle"
class="border border-gray-100 dark:border-slate-700 rounded-lg shadow-sm"
/>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="orders" tab="相关订单">
<div class="pl-6 h-full">
<Table
:columns="orderColumns"
:data-source="orderList"
:loading="orderLoading"
:pagination="{
current: orderPage,
total: orderTotal,
pageSize: 10,
onChange: loadOrders,
showTotal: (total) => `共 ${total} 笔订单`
}"
row-key="id"
size="middle"
class="border border-gray-100 dark:border-slate-700 rounded-lg shadow-sm"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'id'">
<Button type="link" size="small" @click="openOrderDetail(record.id)">
{{ record.id }}
</Button>
</template>
<template v-if="column.key === 'is_pay'">
<Tag :color="record.is_pay ? 'success' : 'default'">
{{ record.is_pay ? '已支付' : '待支付' }}
</Tag>
</template>
<template v-if="column.key === 'total_price'">
<span class="font-medium text-gray-700 dark:text-slate-300">¥ {{ record.total_price }}</span>
</template>
<template v-if="column.key === 'action'">
<Button type="link" size="small" @click="openOrderDetail(record.id)">查看</Button>
</template>
</template>
</Table>
</div>
</Tabs.TabPane>
</Tabs>
</Spin>
</Modal>
<QrCodeModal />
<PrescriptionDetailModal />
<OrderDetailDrawer />
</template>
<style scoped>
/* 使用 CSS 变量自适应暗黑模式的 Tabs 高亮,替代硬编码颜色 */
:deep(.custom-vertical-tabs .ant-tabs-nav) {
width: 140px;
}
:deep(.custom-vertical-tabs .ant-tabs-tab) {
padding: 12px 16px;
margin-bottom: 4px;
border-radius: 6px;
transition: all 0.3s;
}
:deep(.custom-vertical-tabs .ant-tabs-tab-active) {
background-color: var(--ant-primary-color-active-deprecated-f-12, rgba(22, 119, 255, 0.08));
}
</style>

View File

@@ -208,12 +208,6 @@ export const infoModalFormProps: VbenFormProps = {
}, },
fieldName: 'mobile', fieldName: 'mobile',
formItemClass: 'col-span-6', formItemClass: 'col-span-6',
dependencies: {
show: (values) => {
return values.id == null;
},
triggerFields: ['id'],
},
label: '手机号码', label: '手机号码',
rules: 'required', rules: 'required',
}, },

View File

@@ -30,7 +30,7 @@ export const gridOptions: VxeGridProps<RowType> = {
columns: [ columns: [
{ type: 'checkbox', width: 60 }, { type: 'checkbox', width: 60 },
{ field: 'id', align: 'left', title: 'ID', width: 100 }, { field: 'id', align: 'left', title: 'ID', width: 100 },
{ field: 'name', align: 'left', title: '名称' }, { field: 'name', align: 'left', title: '名称', slots: { default: 'name' } },
{ field: 'mobile', align: 'left', title: '手机号' }, { field: 'mobile', align: 'left', title: '手机号' },
{ {
field: 'avatar', field: 'avatar',

View File

@@ -14,6 +14,7 @@ import { auditApi, openPcWindowsApi, refuseApi } from './api';
import BindStore from './components/BindStoreModal.vue'; import BindStore from './components/BindStoreModal.vue';
import BindSubjects from './components/BindSubjectsModal.vue'; import BindSubjects from './components/BindSubjectsModal.vue';
import FormModalDemo from './components/modal.vue'; import FormModalDemo from './components/modal.vue';
import DoctorCardModal from './components/DoctorCardModal.vue';
import { formOptions } from './config/search'; import { formOptions } from './config/search';
import { gridOptions } from './config/table'; import { gridOptions } from './config/table';
@@ -50,6 +51,10 @@ const [BindSubjectsModal, BindSubjectsModalApi] = useVbenModal({
connectedComponent: BindSubjects, connectedComponent: BindSubjects,
}); });
const [DoctorCardModals, DoctorCardModalApi] = useVbenModal({
connectedComponent: DoctorCardModal,
});
const showModal = (data = {}, isUpdate = false) => { const showModal = (data = {}, isUpdate = false) => {
formModalApi.setData({ formModalApi.setData({
// 表单值 // 表单值
@@ -77,6 +82,15 @@ const showBindSubjectsModal = (row: Record<string, any>) => {
BindSubjectsModalApi.open(); BindSubjectsModalApi.open();
}; };
const showDoctorCard = (row: Record<string, any>) => {
DoctorCardModalApi.setData({
id: row.id,
su_id: row.su_id,
gridApi,
});
DoctorCardModalApi.open();
};
const openPcWindows = (id: number) => { const openPcWindows = (id: number) => {
openPcWindowsApi(id).then(() => { openPcWindowsApi(id).then(() => {
message.success('开通成功!'); message.success('开通成功!');
@@ -104,6 +118,7 @@ const refuse = (id: number) => {
<FormModal /> <FormModal />
<BindStoreModal /> <BindStoreModal />
<BindSubjectsModal /> <BindSubjectsModal />
<DoctorCardModals />
<Grid> <Grid>
<template #toolbar-buttons> <template #toolbar-buttons>
<TableAction <TableAction
@@ -140,6 +155,11 @@ const refuse = (id: number) => {
<template #avatar="{ row }"> <template #avatar="{ row }">
<Image :src="row.avatar" height="30" width="30" /> <Image :src="row.avatar" height="30" width="30" />
</template> </template>
<template #name="{ row }">
<Button type="link" size="small" @click="showDoctorCard(row)">
{{ row.name }}
</Button>
</template>
<template #stores="{ row }"> <template #stores="{ row }">
<p v-for="item in row.stores" :key="item.store_id"> <p v-for="item in row.stores" :key="item.store_id">
{{ item?.store?.name }} {{ item?.store?.name }}
@@ -167,6 +187,13 @@ const refuse = (id: number) => {
<template #action="{ row }"> <template #action="{ row }">
<TableAction <TableAction
:actions="[ :actions="[
{
label: '医生卡片',
type: 'link',
icon: 'ant-design:idcard-outlined',
size: 'small',
onClick: showDoctorCard.bind(null, row),
},
{ {
label: '编辑', label: '编辑',
type: 'link', type: 'link',

View File

@@ -25,6 +25,7 @@ import { formatAddressDisplay } from '#/util/address-index';
import { createAdminGridOptions } from './table-config'; import { createAdminGridOptions } from './table-config';
import { getRoleMeta } from './role-meta'; import { getRoleMeta } from './role-meta';
import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue';
function formatAdminRegion(row: { function formatAdminRegion(row: {
province_id?: number; province_id?: number;
@@ -107,6 +108,16 @@ const [Modal, modalApi] = useVbenModal({
}, },
}); });
const [DoctorCardModals, DoctorCardModalApi] = useVbenModal({
connectedComponent: DoctorCardModal,
});
const openDoctorCard = (row: { doctor_id?: number }) => {
if (!row.doctor_id) return;
DoctorCardModalApi.setData({ su_id: row.doctor_id });
DoctorCardModalApi.open();
};
const gridEvents: VxeGridListeners<any> = { const gridEvents: VxeGridListeners<any> = {
checkboxChange() { checkboxChange() {
const records = gridApi.grid.getCheckboxRecords(); const records = gridApi.grid.getCheckboxRecords();
@@ -195,6 +206,7 @@ const copyToClipboard = async (text: string) => {
> >
<Form /> <Form />
</Modal> </Modal>
<DoctorCardModals />
<Grid> <Grid>
<template #toolbar-buttons> <template #toolbar-buttons>
<TableAction <TableAction
@@ -229,6 +241,17 @@ const copyToClipboard = async (text: string) => {
<template #avatar="{ row }"> <template #avatar="{ row }">
<Image :src="row.avatar" height="30" width="30" /> <Image :src="row.avatar" height="30" width="30" />
</template> </template>
<template #nick_name="{ row }">
<Button
v-if="meta.formType === 'doctor' && row.doctor_id"
type="link"
size="small"
@click="openDoctorCard(row)"
>
{{ row.nick_name }}
</Button>
<span v-else>{{ row.nick_name }}</span>
</template>
<template #region="{ row }"> <template #region="{ row }">
{{ formatAdminRegion(row) }} {{ formatAdminRegion(row) }}
</template> </template>

View File

@@ -26,7 +26,7 @@ function buildColumns(formType: AdminFormType) {
const cols: VxeGridProps<RowType>['columns'] = [ const cols: VxeGridProps<RowType>['columns'] = [
{ type: 'checkbox', width: 60 }, { type: 'checkbox', width: 60 },
{ field: 'id', align: 'left', title: 'ID', width: 100 }, { field: 'id', align: 'left', title: 'ID', width: 100 },
{ field: 'nick_name', align: 'left', title: '名称' }, { field: 'nick_name', align: 'left', title: '名称', ...(formType === 'doctor' ? { slots: { default: 'nick_name' } } : {}) },
{ {
field: 'avatar', field: 'avatar',
align: 'left', align: 'left',

View File

@@ -77,6 +77,7 @@ const [Modal, modalApi] = useVbenModal({
end_time: dayjs(values.end_time || '20:00', 'HH:mm'), end_time: dayjs(values.end_time || '20:00', 'HH:mm'),
// 确保订阅状态有值如果后端没有返回则默认为0订阅 // 确保订阅状态有值如果后端没有返回则默认为0订阅
subscribe_price_change: values.subscribe_price_change ?? 0, subscribe_price_change: values.subscribe_price_change ?? 0,
sync_admin_phone: false,
}); });
} else { } else {
formApi.setValues({ formApi.setValues({

View File

@@ -154,6 +154,23 @@ export const modalFormProps: VbenFormProps = {
label: '联系人手机号', label: '联系人手机号',
rules: 'required', rules: 'required',
}, },
{
// 编辑时可选:同步修改药店管理员后台登录手机号
component: 'Switch',
formItemClass: 'col-span-12',
fieldName: 'sync_admin_phone',
label: '同步管理员登录号',
defaultValue: false,
componentProps: {
checkedChildren: '是',
unCheckedChildren: '否',
},
help: '勾选后,保存时将同步修改药店管理员后台登录手机号(需已开通后台)',
dependencies: {
show: (values) => values.id != null,
triggerFields: ['id'],
},
},
{ {
component: 'PromoterPicker', component: 'PromoterPicker',
formItemClass: 'col-span-6', formItemClass: 'col-span-6',

View File

@@ -31,6 +31,16 @@ export async function bindOnlineConsultationApi(data: {
}) { }) {
return requestClient.post<any>(`${prefix}bind-online-consultation`, data); return requestClient.post<any>(`${prefix}bind-online-consultation`, data);
} }
/**
* 绑定特色方默认挂号医生
*/
export async function updateSpecialPrescriptionDoctorApi(data: {
id: number;
special_prescription_doctor_id?: number | null;
}) {
return requestClient.post<any>(`${prefix}update-special-prescription-doctor`, data);
}
/** /**
* 分页查询用户列表 * 分页查询用户列表
* @param data * @param data

View File

@@ -0,0 +1,87 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Form, FormItem, message, Select } from 'ant-design-vue';
import { getDoctorOptionApi, updateSpecialPrescriptionDoctorApi } from '../api';
const formState = ref({
id: undefined as number | undefined,
special_prescription_doctor_id: undefined as number | undefined,
});
const doctorOptions = ref<Array<{ id: number; name: string }>>([]);
const gridApiRef = ref<any>(null);
const loading = ref(false);
async function loadDoctorOptions(storeId?: number) {
if (!storeId) {
doctorOptions.value = [];
return;
}
try {
const doctors = await getDoctorOptionApi(storeId);
doctorOptions.value = doctors || [];
} catch (error) {
console.error('获取医生列表失败:', error);
doctorOptions.value = [];
}
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
loading.value = true;
modalApi.setState({ confirmLoading: true });
try {
await updateSpecialPrescriptionDoctorApi({
id: formState.value.id!,
special_prescription_doctor_id: formState.value.special_prescription_doctor_id || null,
});
message.success('绑定成功');
gridApiRef.value?.reload();
modalApi.close();
} catch {
message.error('绑定失败');
} finally {
loading.value = false;
modalApi.setState({ confirmLoading: false });
}
},
async onOpenChange(isOpen: boolean) {
if (isOpen) {
const { values, gridApi } = modalApi.getData<Record<string, any>>();
gridApiRef.value = gridApi;
formState.value = {
id: values?.id,
special_prescription_doctor_id: values?.special_prescription_doctor_id || undefined,
};
await loadDoctorOptions(formState.value.id);
}
},
});
</script>
<template>
<Modal title="绑定特色方默认医生" class="w-[500px]">
<Form :model="formState" layout="vertical">
<FormItem label="特色方默认挂号医生">
<Select
v-model:value="formState.special_prescription_doctor_id"
:options="doctorOptions"
:field-names="{ label: 'name', value: 'id' }"
placeholder="请选择特色方默认挂号医生"
allow-clear
show-search
:filter-option="(input: string, option: any) => option.name.toLowerCase().includes(input.toLowerCase())"
/>
</FormItem>
</Form>
</Modal>
</template>

View File

@@ -56,6 +56,7 @@ const [Modal, modalApi] = useVbenModal({
end_time: dayjs(values.end_time || '20:00', 'HH:mm'), end_time: dayjs(values.end_time || '20:00', 'HH:mm'),
// 确保订阅状态有值如果后端没有返回则默认为0订阅 // 确保订阅状态有值如果后端没有返回则默认为0订阅
subscribe_price_change: values.subscribe_price_change ?? 0, subscribe_price_change: values.subscribe_price_change ?? 0,
sync_admin_phone: false,
}); });
} }
} }

View File

@@ -179,6 +179,23 @@ export const modalFormProps: VbenFormProps = {
label: '联系人手机号', label: '联系人手机号',
rules: 'required', rules: 'required',
}, },
{
// 编辑时可选:同步修改诊所管理员后台登录手机号
component: 'Switch',
formItemClass: 'col-span-12',
fieldName: 'sync_admin_phone',
label: '同步管理员登录号',
defaultValue: false,
componentProps: {
checkedChildren: '是',
unCheckedChildren: '否',
},
help: '勾选后,保存时将同步修改诊所管理员后台登录手机号(需已开通后台)',
dependencies: {
show: (values) => values.id != null,
triggerFields: ['id'],
},
},
{ {
component: 'PromoterPicker', component: 'PromoterPicker',
formItemClass: 'col-span-6', formItemClass: 'col-span-6',

View File

@@ -37,6 +37,13 @@ export const gridOptions: VxeGridProps<RowType> = {
width: 160, width: 160,
slots: { default: 'online_consultation_config' }, slots: { default: 'online_consultation_config' },
}, },
{
field: 'special_prescription_config',
align: 'left',
title: '特色方配置',
width: 140,
slots: { default: 'special_prescription_config' },
},
{ {
field: 'store_config_1', field: 'store_config_1',
align: 'left', align: 'left',

View File

@@ -34,6 +34,7 @@ import StoreBasicInfoCell from './components/cells/StoreBasicInfoCell.vue';
import StoreConfigTogglesCell from './components/cells/StoreConfigTogglesCell.vue'; import StoreConfigTogglesCell from './components/cells/StoreConfigTogglesCell.vue';
import DrugPriceModal from './components/DrugPriceModal.vue'; import DrugPriceModal from './components/DrugPriceModal.vue';
import BindConsultationModal from './components/BindConsultationModal.vue'; import BindConsultationModal from './components/BindConsultationModal.vue';
import BindSpecialPrescriptionDoctorModal from './components/BindSpecialPrescriptionDoctorModal.vue';
import StoreExternalFieldModal from './components/StoreExternalFieldModal.vue'; import StoreExternalFieldModal from './components/StoreExternalFieldModal.vue';
import SalespersonCommissionDrawer from './components/SalespersonCommissionDrawer.vue'; import SalespersonCommissionDrawer from './components/SalespersonCommissionDrawer.vue';
import BankCardReportModal from '#/views/system/store-bank-card/components/BankCardReportModal.vue'; import BankCardReportModal from '#/views/system/store-bank-card/components/BankCardReportModal.vue';
@@ -102,6 +103,11 @@ const [BindConsultationModalComponent, bindConsultationModalApi] = useVbenModal(
connectedComponent: BindConsultationModal, connectedComponent: BindConsultationModal,
}); });
const [BindSpecialPrescriptionDoctorModalComponent, bindSpecialPrescriptionDoctorModalApi] =
useVbenModal({
connectedComponent: BindSpecialPrescriptionDoctorModal,
});
const [StoreExternalFieldModalComponent, storeExternalFieldModalApi] = useVbenModal({ const [StoreExternalFieldModalComponent, storeExternalFieldModalApi] = useVbenModal({
connectedComponent: StoreExternalFieldModal, connectedComponent: StoreExternalFieldModal,
}); });
@@ -216,6 +222,14 @@ const showBindConsultationModal = (row: any) => {
bindConsultationModalApi.open(); bindConsultationModalApi.open();
}; };
const showBindSpecialPrescriptionDoctorModal = (row: any) => {
bindSpecialPrescriptionDoctorModalApi.setData({
values: row,
gridApi,
});
bindSpecialPrescriptionDoctorModalApi.open();
};
const deleteApi = (row: any) => { const deleteApi = (row: any) => {
let ids = []; let ids = [];
if (row) { if (row) {
@@ -369,6 +383,7 @@ const handleSwitchClinicType = (row: any) => {
<QrCodePreviewModal /> <QrCodePreviewModal />
<DrugPriceModalComponent /> <DrugPriceModalComponent />
<BindConsultationModalComponent /> <BindConsultationModalComponent />
<BindSpecialPrescriptionDoctorModalComponent />
<StoreExternalFieldModalComponent /> <StoreExternalFieldModalComponent />
<SalespersonCommissionDrawer ref="salespersonDrawerRef" /> <SalespersonCommissionDrawer ref="salespersonDrawerRef" />
<Grid> <Grid>
@@ -512,6 +527,23 @@ const handleSwitchClinicType = (row: any) => {
绑定 绑定
</Button> </Button>
</template> </template>
<template #special_prescription_config="{ row }">
<div
v-if="row.special_prescription_doctor_id"
class="cursor-pointer hover:text-blue-500"
@click="showBindSpecialPrescriptionDoctorModal(row)"
>
<div>医生ID{{ row.special_prescription_doctor_id }}</div>
</div>
<Button
v-else
type="link"
size="small"
@click="showBindSpecialPrescriptionDoctorModal(row)"
>
绑定
</Button>
</template>
<template #start-time="{ row }"> <template #start-time="{ row }">
<Tag color="success">{{ row.start_time }}</Tag> <Tag color="success">{{ row.start_time }}</Tag>
<br /> <br />
@@ -595,6 +627,14 @@ const handleSwitchClinicType = (row: any) => {
ifShow: isPlatformAdmin, ifShow: isPlatformAdmin,
onClick: showDrugPriceModal.bind(null, row), onClick: showDrugPriceModal.bind(null, row),
}, },
{
label: '绑定特色方医生',
type: 'link',
icon: 'ant-design:user-add-outlined',
size: 'small',
auth: ['Super Admin', 'Admin'],
onClick: () => showBindSpecialPrescriptionDoctorModal(row),
},
{ {
label: '查看卡状态', label: '查看卡状态',
type: 'link', type: 'link',

View File

@@ -3,16 +3,21 @@ import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui'; import { Page } from '@vben/common-ui';
import { Button, Card, Input, InputNumber, message, Radio, Space, Switch, Tag } from 'ant-design-vue'; import { Button, Card, Input, InputNumber, message, Radio, Space, Switch, Tabs, Tag } from 'ant-design-vue';
import type { QuickDiscountOption } from '#/utils/pricePercentAdjust'; import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
import FormAvatar from '#/components/form/components/avatar.vue';
import { getSystemConfigList, saveSystemConfig } from './api'; import { getSystemConfigList, saveSystemConfig } from './api';
defineOptions({ name: 'SystemConfig' }); defineOptions({ name: 'SystemConfig' });
const TAB_STORAGE_KEY = 'system_config_active_tab';
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const activeKey = ref('price_adjust');
const scope = ref<'both' | 'sale_only'>('sale_only'); const scope = ref<'both' | 'sale_only'>('sale_only');
const quickOptions = ref<QuickDiscountOption[]>([ const quickOptions = ref<QuickDiscountOption[]>([
{ name: '九五折', value: 95 }, { name: '九五折', value: 95 },
@@ -25,6 +30,19 @@ const quickOptions = ref<QuickDiscountOption[]>([
const newQuickName = ref(''); const newQuickName = ref('');
const newQuickValue = ref<number | null>(null); const newQuickValue = ref<number | null>(null);
const inputAutoAuditPass = ref(true); const inputAutoAuditPass = ref(true);
const miniprogramEmptyImage = ref('');
function readStoredTab() {
const stored = localStorage.getItem(TAB_STORAGE_KEY);
if (stored === 'price_adjust' || stored === 'input_audit' || stored === 'miniprogram') {
activeKey.value = stored;
}
}
function handleTabChange(key: string | number) {
activeKey.value = String(key);
localStorage.setItem(TAB_STORAGE_KEY, String(key));
}
function parseQuickOptions(raw: unknown): QuickDiscountOption[] { function parseQuickOptions(raw: unknown): QuickDiscountOption[] {
if (typeof raw === 'string') { if (typeof raw === 'string') {
@@ -63,6 +81,9 @@ async function load() {
if (row.config_key === 'input_auto_audit_pass') { if (row.config_key === 'input_auto_audit_pass') {
inputAutoAuditPass.value = row.config_value === '1' || row.config_value === true || row.config_value === 'true'; inputAutoAuditPass.value = row.config_value === '1' || row.config_value === true || row.config_value === 'true';
} }
if (row.config_key === 'miniprogram_empty_image') {
miniprogramEmptyImage.value = String(row.config_value || '');
}
} }
} finally { } finally {
loading.value = false; loading.value = false;
@@ -100,6 +121,10 @@ async function handleSave() {
config_key: 'input_auto_audit_pass', config_key: 'input_auto_audit_pass',
config_value: inputAutoAuditPass.value ? '1' : '0', config_value: inputAutoAuditPass.value ? '1' : '0',
}, },
{
config_key: 'miniprogram_empty_image',
config_value: miniprogramEmptyImage.value,
},
]); ]);
message.success('保存成功'); message.success('保存成功');
} finally { } finally {
@@ -107,46 +132,64 @@ async function handleSave() {
} }
} }
onMounted(load); onMounted(() => {
readStoredTab();
load();
});
</script> </script>
<template> <template>
<Page auto-content-height title="系统配置"> <Page auto-content-height title="系统配置">
<Card :loading="loading" title="订单调价配置"> <Card :loading="loading">
<div class="mb-6"> <Tabs :active-key="activeKey" @change="handleTabChange">
<div class="mb-2 font-medium">订单打折时价格浮动范围</div> <Tabs.TabPane key="price_adjust" tab="订单调价">
<Radio.Group v-model:value="scope"> <div class="py-4">
<Radio value="sale_only">仅售价浮动</Radio> <div class="mb-6">
<Radio value="both">供货价与售价一起浮动</Radio> <div class="mb-2 font-medium">订单打折时价格浮动范围</div>
</Radio.Group> <Radio.Group v-model:value="scope">
</div> <Radio value="sale_only">仅售价浮动</Radio>
<Radio value="both">供货价与售价一起浮动</Radio>
</Radio.Group>
</div>
<div class="mb-6"> <div class="mb-6">
<div class="mb-2 font-medium">调价快捷选项name 展示名value 比例100=原价90=九折120=涨20%</div> <div class="mb-2 font-medium">调价快捷选项name 展示名value 比例100=原价90=九折120=涨20%</div>
<Space wrap class="mb-3"> <Space wrap class="mb-3">
<Tag <Tag
v-for="item in quickOptions" v-for="item in quickOptions"
:key="item.name + item.value" :key="item.name + item.value"
closable closable
:color="item.value < 100 ? 'processing' : 'warning'" :color="item.value < 100 ? 'processing' : 'warning'"
@close="removeQuick(item)" @close="removeQuick(item)"
> >
{{ item.name }}{{ item.value }}% {{ item.name }}{{ item.value }}%
</Tag> </Tag>
</Space> </Space>
<Space wrap> <Space wrap>
<Input v-model:value="newQuickName" placeholder="名称,如九折" style="width: 140px" /> <Input v-model:value="newQuickName" placeholder="名称,如九折" style="width: 140px" />
<InputNumber v-model:value="newQuickValue" :min="1" :max="500" placeholder="比例值" /> <InputNumber v-model:value="newQuickValue" :min="1" :max="500" placeholder="比例值" />
<Button @click="addQuick">添加快捷项</Button> <Button @click="addQuick">添加快捷项</Button>
</Space> </Space>
</div> </div>
</div>
</Tabs.TabPane>
<Button type="primary" :loading="saving" @click="handleSave">保存配置</Button> <Tabs.TabPane key="input_audit" tab="录入审核">
</Card> <div class="py-4">
<div class="mb-2 font-medium">诊所/医生信息预填录入后是否自动通过审核</div>
<Switch v-model:checked="inputAutoAuditPass" checked-children="" un-checked-children="" />
</div>
</Tabs.TabPane>
<Tabs.TabPane key="miniprogram" tab="小程序">
<div class="py-4">
<div class="mb-2 font-medium">空状态图片</div>
<div class="mb-2 text-sm text-gray-500">用于小程序列表等无数据时的占位图</div>
<FormAvatar v-model:value="miniprogramEmptyImage" />
</div>
</Tabs.TabPane>
</Tabs>
<Card :loading="loading" class="mt-4" title="录入审核配置">
<div class="mb-2 font-medium">诊所/医生信息预填录入后是否自动通过审核</div>
<Switch v-model:checked="inputAutoAuditPass" checked-children="" un-checked-children="" />
<div class="mt-4"> <div class="mt-4">
<Button type="primary" :loading="saving" @click="handleSave">保存配置</Button> <Button type="primary" :loading="saving" @click="handleSave">保存配置</Button>
</div> </div>