feat:优化了门店部门财务功能,新增开票功能

This commit is contained in:
李琦
2026-07-24 09:54:44 +08:00
parent b1df6fb4e8
commit 14414bf9b0
12 changed files with 1312 additions and 3 deletions

View File

@@ -81,6 +81,7 @@ export type ComponentType =
| 'UploadImage'
| 'UploadImageSortable'
| 'UploadOssFile'
| 'UploadDraggerPaste'
| BaseFormComponentType;
async function initComponentAdapter() {

View File

@@ -0,0 +1,371 @@
<script setup lang="ts">
/**
* 通用拖拽 + 粘贴上传Dragger 点击/拖拽,下方外侧「开始监听粘贴」
* v-model 为已上传 URL 字符串数组;列表支持图片/PDF 预览blob避免直接下载
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { preferences } from '@vben/preferences';
import { Button, Image, Modal, Upload, message } from 'ant-design-vue';
import type { UploadFile } from 'ant-design-vue';
import { Icon } from '#/components/icon';
import { uploadFile } from '#/api/core/upload';
import { uploadToOss } from '#/utils/oss-upload';
import {
matchAccept,
usePasteUploadListen,
} from '#/utils/use-paste-upload-listen';
defineOptions({ name: 'UploadDraggerPaste' });
interface FileItem {
uid: string;
name: string;
status: 'uploading' | 'done' | 'error';
url: string;
}
interface Props {
modelValue?: string[];
accept?: string;
multiple?: boolean;
maxCount?: number;
/** 单文件最大 MB */
maxSizeMb?: number;
tip?: string;
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
modelValue: () => [],
accept: '.jpg,.jpeg,.png,.gif,.webp,.pdf,image/*,application/pdf',
multiple: true,
maxCount: 9,
maxSizeMb: 20,
tip: '点击或拖拽文件到此处上传',
disabled: false,
});
const emit = defineEmits<{
'update:modelValue': [value: string[]];
}>();
const UploadDragger = Upload.Dragger;
const fileList = ref<FileItem[]>([]);
const uploading = ref(false);
/** 文件预览弹窗状态 */
const previewVisible = ref(false);
const previewTitle = ref('');
const previewIsImage = ref(false);
const previewImageUrl = ref('');
const previewPdfUrl = ref('');
const previewLoading = ref(false);
const maxBytes = computed(() => props.maxSizeMb * 1024 * 1024);
function syncFromValue(urls: string[]) {
fileList.value = (urls || []).map((url) => ({
uid: url,
name: url.split('/').pop() || 'file',
status: 'done' as const,
url,
}));
}
syncFromValue(props.modelValue);
watch(
() => props.modelValue,
(val) => {
const current = fileList.value
.filter((f) => f.status === 'done')
.map((f) => f.url);
if (JSON.stringify(val || []) !== JSON.stringify(current)) {
syncFromValue(val || []);
}
},
{ deep: true },
);
function emitUrls() {
const urls = fileList.value
.filter((f) => f.status === 'done' && f.url)
.map((f) => f.url);
emit('update:modelValue', urls);
}
function isImageUrl(url: string) {
return /\.(jpg|jpeg|png|gif|webp|bmp)(\?|$)/i.test(url || '');
}
function revokePdfBlob() {
if (previewPdfUrl.value && previewPdfUrl.value.startsWith('blob:')) {
URL.revokeObjectURL(previewPdfUrl.value);
}
previewPdfUrl.value = '';
}
/**
* 预览已上传文件:图片用 ImagePDF fetch 成 blob 后 iframe避免 OSS 直接下载
*/
async function previewItem(item: FileItem) {
if (!item.url) return;
revokePdfBlob();
previewTitle.value = item.name || '预览';
if (isImageUrl(item.url)) {
previewIsImage.value = true;
previewImageUrl.value = item.url;
previewVisible.value = true;
return;
}
previewIsImage.value = false;
previewLoading.value = true;
previewVisible.value = true;
try {
const resp = await fetch(item.url);
if (!resp.ok) throw new Error('加载失败');
const blob = await resp.blob();
const pdfBlob =
blob.type && blob.type !== 'application/octet-stream'
? blob
: new Blob([blob], { type: 'application/pdf' });
previewPdfUrl.value = URL.createObjectURL(pdfBlob);
} catch {
message.error('预览失败');
previewVisible.value = false;
} finally {
previewLoading.value = false;
}
}
function onPreviewClose() {
previewVisible.value = false;
revokePdfBlob();
previewImageUrl.value = '';
}
/**
* 上传单个文件并写入列表
*/
async function uploadOneFile(file: File) {
if (props.disabled) return;
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
if (doneCount >= props.maxCount) {
message.warning(`最多上传 ${props.maxCount} 个文件`);
return;
}
if (!matchAccept(file, props.accept)) {
message.warning('文件类型不符合要求');
return;
}
if (file.size > maxBytes.value) {
message.warning(`文件不能超过 ${props.maxSizeMb}MB`);
return;
}
const uid = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
fileList.value = [
...fileList.value,
{ uid, name: file.name, status: 'uploading', url: '' },
];
uploading.value = true;
try {
const uploadMethod = preferences.app.uploadMethod || 'direct';
const res =
uploadMethod === 'direct'
? await uploadToOss({ file })
: await uploadFile({ file });
const url = res?.url || '';
if (!url) throw new Error('上传失败');
fileList.value = fileList.value.map((item) =>
item.uid === uid ? { ...item, status: 'done' as const, url } : item,
);
emitUrls();
} catch (e: any) {
fileList.value = fileList.value.filter((item) => item.uid !== uid);
message.error(e?.message || '上传失败');
} finally {
uploading.value = false;
}
}
/** Ant Design Upload customRequest */
const customRequest = async (options: any) => {
const { file, onSuccess, onError } = options;
try {
await uploadOneFile(file as File);
onSuccess?.({});
} catch (e) {
onError?.(e);
}
};
function beforeUpload(file: File) {
if (props.disabled) return Upload.LIST_IGNORE;
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
if (doneCount >= props.maxCount) {
message.warning(`最多上传 ${props.maxCount} 个文件`);
return Upload.LIST_IGNORE;
}
if (!matchAccept(file, props.accept)) {
message.warning('文件类型不符合要求');
return Upload.LIST_IGNORE;
}
if (file.size > maxBytes.value) {
message.warning(`文件不能超过 ${props.maxSizeMb}MB`);
return Upload.LIST_IGNORE;
}
return true;
}
function handleRemove(file: UploadFile) {
fileList.value = fileList.value.filter((item) => item.uid !== file.uid);
emitUrls();
return true;
}
const {
listening: pasteListening,
toggle: togglePaste,
} = usePasteUploadListen({
get accept() {
return props.accept;
},
get maxSize() {
return maxBytes.value;
},
onFiles: async (files) => {
for (const file of files) {
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
if (doneCount >= props.maxCount) {
message.warning(`最多上传 ${props.maxCount} 个文件`);
break;
}
await uploadOneFile(file);
}
},
});
function onTogglePaste() {
if (props.disabled) return;
togglePaste();
if (pasteListening.value) {
message.success('已开启粘贴监听,可 Ctrl+V 粘贴文件/截图');
} else {
message.info('已停止粘贴监听');
}
}
const showDragger = computed(() => {
if (!props.multiple) {
return fileList.value.filter((f) => f.status === 'done').length === 0;
}
return fileList.value.filter((f) => f.status === 'done').length < props.maxCount;
});
onBeforeUnmount(() => {
revokePdfBlob();
});
</script>
<template>
<div class="upload-dragger-paste">
<UploadDragger
v-if="showDragger"
:file-list="[]"
:accept="accept"
:multiple="multiple"
:disabled="disabled || uploading"
:before-upload="beforeUpload"
:custom-request="customRequest"
:show-upload-list="false"
:max-count="maxCount"
>
<p class="ant-upload-drag-icon">
<Icon icon="ant-design:inbox-outlined" class="text-4xl text-blue-500" />
</p>
<p class="ant-upload-text">{{ tip }}</p>
<p class="ant-upload-hint text-gray-400">
支持拖拽或点击选择单文件不超过 {{ maxSizeMb }}MB
</p>
</UploadDragger>
<div v-if="fileList.length" class="mt-3 space-y-2">
<div
v-for="item in fileList"
:key="item.uid"
class="flex items-center gap-2 rounded border border-gray-100 px-3 py-2 text-sm"
>
<Icon
:icon="
item.status === 'uploading'
? 'ant-design:loading-outlined'
: item.status === 'error'
? 'ant-design:close-circle-outlined'
: 'ant-design:paper-clip-outlined'
"
class="shrink-0"
/>
<button
v-if="item.url"
type="button"
class="min-w-0 flex-1 truncate text-left text-blue-600"
@click="previewItem(item)"
>
{{ item.name }}
</button>
<span v-else class="min-w-0 flex-1 truncate text-gray-500">{{ item.name }}</span>
<Button
type="link"
size="small"
danger
:disabled="disabled"
@click="handleRemove(item as any)"
>
删除
</Button>
</div>
</div>
<!-- 粘贴监听按钮放在拖拽区域下方外侧 -->
<div class="mt-3">
<Button
:type="pasteListening ? 'primary' : 'default'"
:danger="pasteListening"
:disabled="disabled"
@click="onTogglePaste"
>
<Icon
:icon="pasteListening ? 'ant-design:stop-outlined' : 'ant-design:snippets-outlined'"
class="mr-1"
/>
{{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }}
</Button>
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500">
监听中请将文件或截图 Ctrl+V 粘贴
</span>
</div>
<Modal
:open="previewVisible"
:title="previewTitle"
:footer="null"
width="80%"
destroy-on-close
@cancel="onPreviewClose"
>
<div v-if="previewLoading" class="py-16 text-center text-gray-500">加载中</div>
<div v-else-if="previewIsImage" class="flex justify-center">
<Image :src="previewImageUrl" :preview="true" style="max-height: 70vh" />
</div>
<iframe
v-else-if="previewPdfUrl"
:src="previewPdfUrl"
class="h-[70vh] w-full border-0"
title="文件预览"
/>
</Modal>
</div>
</template>

View File

@@ -1,6 +1,9 @@
<script setup lang="ts">
/**
* 多图上传picture-card+ 下方「开始监听粘贴」支持截图/图片粘贴
*/
import { computed, ref, watch } from 'vue';
import { Modal, Upload } from 'ant-design-vue';
import { Button, Modal, Upload, message } from 'ant-design-vue';
import type { UploadFile } from 'ant-design-vue';
import GalleryPickLink from '#/components/form/components/gallery-pick-link.vue';
@@ -10,9 +13,12 @@ import {
beforeImageUpload,
resolveUploadFile,
} from '#/utils/use-image-upload-pending';
import { usePasteUploadListen } from '#/utils/use-paste-upload-listen';
import { preferences } from '@vben/preferences';
import { Icon } from '#/components/icon';
defineOptions({ name: 'UploadImage' });
interface FileItem {
uid: string;
name: string;
@@ -36,6 +42,8 @@ const emit = defineEmits<{
'update:modelValue': [value: string[]];
}>();
const IMAGE_ACCEPT = 'image/*,.jpg,.jpeg,.png,.gif,.webp,.bmp';
const fileList = ref<FileItem[]>([]);
const initFileList = () => {
@@ -74,6 +82,41 @@ const updateModelValue = () => {
emit('update:modelValue', urls);
};
/**
* 上传单个图片文件(点击上传与粘贴共用)
*/
async function uploadOneImage(file: File) {
const remain = props.maxCount - fileList.value.filter((f) => f.status === 'done').length;
if (remain <= 0) {
message.warning(`最多上传 ${props.maxCount} 张图片`);
return;
}
const actualFile = resolveUploadFile(file);
const uid = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const uploadingFile: FileItem = {
uid,
name: actualFile.name || file.name || 'paste.png',
status: 'uploading',
url: '',
};
fileList.value = [...fileList.value, uploadingFile];
try {
const uploadMethod = preferences.app.uploadMethod || 'direct';
const res =
uploadMethod === 'direct'
? await uploadToOss({ file: actualFile })
: await uploadFile({ file: actualFile });
fileList.value = fileList.value.map((item) =>
item.uid === uid ? { ...item, status: 'done' as const, url: res.url } : item,
);
updateModelValue();
} catch (error) {
console.error('上传失败', error);
fileList.value = fileList.value.filter((item) => item.uid !== uid);
message.error('上传失败');
}
}
const customRequest = async (options: any) => {
const { file, onProgress, onSuccess, onError } = options;
const actualFile = resolveUploadFile(file as File);
@@ -155,6 +198,30 @@ const showUploadButton = computed(() =>
);
const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.value.length, 0));
const { listening: pasteListening, toggle: togglePaste } = usePasteUploadListen({
accept: IMAGE_ACCEPT,
maxSize: 20 * 1024 * 1024,
onFiles: async (files) => {
for (const file of files) {
const doneCount = fileList.value.filter((f) => f.status === 'done').length;
if (doneCount >= props.maxCount) {
message.warning(`最多上传 ${props.maxCount} 张图片`);
break;
}
await uploadOneImage(file);
}
},
});
function onTogglePaste() {
togglePaste();
if (pasteListening.value) {
message.success('已开启粘贴监听,可 Ctrl+V 粘贴图片/截图');
} else {
message.info('已停止粘贴监听');
}
}
</script>
<template>
@@ -187,6 +254,25 @@ const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.val
@select="onGallerySelect"
/>
<!-- 粘贴监听放在上传区下方外侧 -->
<div class="mt-2">
<Button
size="small"
:type="pasteListening ? 'primary' : 'default'"
:danger="pasteListening"
@click="onTogglePaste"
>
<Icon
:icon="pasteListening ? 'ant-design:stop-outlined' : 'ant-design:snippets-outlined'"
class="mr-1"
/>
{{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }}
</Button>
<span v-if="pasteListening" class="ml-2 text-xs text-orange-500">
监听中Ctrl+V 粘贴图片
</span>
</div>
<Modal v-model:visible="previewVisible" :title="previewTitle" footer="" width="60%">
<img alt="预览图片" style="width: 100%" :src="previewImage" />
</Modal>

View File

@@ -115,6 +115,23 @@ export function generateObjectName(file: File): string {
return objectName;
}
/**
* 根据 File 推断上传用 Content-Type浏览器可能给空 type
*/
function resolveUploadContentType(file: File): string {
if (file.type && file.type !== 'application/octet-stream') {
return file.type;
}
const name = (file.name || '').toLowerCase();
if (name.endsWith('.pdf')) return 'application/pdf';
if (name.endsWith('.png')) return 'image/png';
if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'image/jpeg';
if (name.endsWith('.gif')) return 'image/gif';
if (name.endsWith('.webp')) return 'image/webp';
if (name.endsWith('.bmp')) return 'image/bmp';
return file.type || 'application/octet-stream';
}
/**
* 从后端获取OSS上传签名信息
*
@@ -253,6 +270,12 @@ export async function uploadToOss(options: UploadOptions): Promise<UploadResult>
// 注意此字段必须与后端Policy中的条件一致否则会上传失败
formData.append('x-oss-object-acl', 'public-read');
// Content-Type按文件 MIME 写入,避免 PDF 被当成 octet-stream 导致浏览器直接下载
const contentType = resolveUploadContentType(file);
formData.append('Content-Type', contentType);
// Content-Disposition: inline —— 浏览器倾向预览而非附件下载
formData.append('Content-Disposition', 'inline');
// file: 要上传的文件
// 必须是最后一个字段OSS要求file字段在FormData的最后
formData.append('file', file);

View File

@@ -0,0 +1,160 @@
import { onUnmounted, ref, type Ref } from 'vue';
import { message } from 'ant-design-vue';
export interface UsePasteUploadListenOptions {
/** accept 字符串,如 `.jpg,.png,image/*,application/pdf`;支持 getter */
accept?: string | (() => string);
/** 单文件最大体积(字节);支持 getter */
maxSize?: number | (() => number);
/** 过滤后的文件回调 */
onFiles: (files: File[]) => void | Promise<void>;
}
/**
* 将 accept 规则解析为便于匹配的片段列表
*/
function parseAccept(accept: string): string[] {
return accept
.split(',')
.map((s) => s.trim().toLowerCase())
.filter(Boolean);
}
/**
* 判断文件是否匹配 accept扩展名 / mime / image/* 等)
*/
export function matchAccept(file: File, accept: string): boolean {
const rules = parseAccept(accept);
if (!rules.length) return true;
const name = (file.name || '').toLowerCase();
const type = (file.type || '').toLowerCase();
const ext = name.includes('.') ? `.${name.split('.').pop()}` : '';
return rules.some((rule) => {
if (rule === '*/*') return true;
if (rule.endsWith('/*')) {
const prefix = rule.slice(0, -1); // image/
return type.startsWith(prefix);
}
if (rule.startsWith('.')) {
return ext === rule;
}
return type === rule;
});
}
/**
* 从粘贴事件中收集 Filefiles + items 截图等)
*/
export function collectPasteFiles(event: ClipboardEvent): File[] {
const result: File[] = [];
const seen = new Set<string>();
const push = (file: File | null | undefined) => {
if (!file) return;
const key = `${file.name}|${file.size}|${file.type}|${file.lastModified}`;
if (seen.has(key)) return;
seen.add(key);
result.push(file);
};
const files = event.clipboardData?.files;
if (files?.length) {
for (let i = 0; i < files.length; i++) {
push(files.item(i));
}
}
const items = event.clipboardData?.items;
if (items?.length) {
for (let i = 0; i < items.length; i++) {
const item = items[i];
if (item?.kind === 'file') {
push(item.getAsFile());
}
}
}
return result;
}
/**
* 开始/停止监听窗口粘贴,用于上传组件在拖拽区外主动开启粘贴上传
*/
export function usePasteUploadListen(options: UsePasteUploadListenOptions) {
const listening: Ref<boolean> = ref(false);
let handler: ((e: ClipboardEvent) => void) | null = null;
function stop() {
if (handler) {
window.removeEventListener('paste', handler);
handler = null;
}
listening.value = false;
}
function start() {
stop();
handler = (event: ClipboardEvent) => {
const acceptRaw = options.accept;
const accept =
typeof acceptRaw === 'function' ? acceptRaw() : acceptRaw || '';
const maxSizeRaw = options.maxSize;
const maxSize =
typeof maxSizeRaw === 'function'
? maxSizeRaw()
: (maxSizeRaw ?? 20 * 1024 * 1024);
const raw = collectPasteFiles(event);
if (!raw.length) {
message.warning('剪贴板中没有可上传的文件');
return;
}
const filtered: File[] = [];
for (const file of raw) {
if (accept && !matchAccept(file, accept)) {
continue;
}
if (file.size > maxSize) {
message.warning(`${file.name || '文件'} 超过大小限制`);
continue;
}
// 截图常无名为空,补一个默认名便于上传
if (!file.name) {
const ext =
file.type === 'image/png'
? 'png'
: file.type === 'image/jpeg'
? 'jpg'
: file.type === 'application/pdf'
? 'pdf'
: 'bin';
filtered.push(new File([file], `paste-${Date.now()}.${ext}`, { type: file.type }));
} else {
filtered.push(file);
}
}
if (!filtered.length) {
message.warning('剪贴板文件类型不符合要求');
return;
}
event.preventDefault();
void options.onFiles(filtered);
};
window.addEventListener('paste', handler);
listening.value = true;
}
function toggle() {
if (listening.value) stop();
else start();
}
onUnmounted(() => {
stop();
});
return {
listening,
start,
stop,
toggle,
};
}

View File

@@ -0,0 +1,42 @@
import { requestClient } from '#/api/request';
const prefix = 'invoice-center/';
/** 开票申请列表 */
export async function getInvoiceCenterListApi(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/** 开票详情 */
export async function getInvoiceCenterDetailApi(data: any) {
return requestClient.get<any>(`${prefix}detail`, { params: data });
}
/** 受理 */
export async function acceptInvoiceApi(data: { id: number }) {
return requestClient.post<any>(`${prefix}accept`, data);
}
/** 拒绝 */
export async function rejectInvoiceApi(data: { id: number; reject_reason: string }) {
return requestClient.post<any>(`${prefix}reject`, data);
}
/** 完成开票(上传文件并按需发邮) */
export async function completeInvoiceApi(data: {
id: number;
invoice_code: string;
invoice_files: string[];
}) {
return requestClient.post<any>(`${prefix}complete`, data);
}
/** 重发邮件 */
export async function resendInvoiceEmailApi(data: { id: number }) {
return requestClient.post<any>(`${prefix}resend-email`, data);
}
/** 各状态数量Tab 徽标) */
export async function getInvoiceStatusStatsApi() {
return requestClient.get<Record<string, number>>(`${prefix}status-stats`);
}

View File

@@ -0,0 +1,96 @@
<script lang="ts" setup>
/**
* 完成开票:填写票据代码 + 拖拽/粘贴上传发票文件(图片/PDF
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Input, message } from 'ant-design-vue';
import UploadDraggerPaste from '#/components/form/components/upload-dragger-paste.vue';
import { completeInvoiceApi } from '../api';
const invoiceCode = ref('');
const fileUrls = ref<string[]>([]);
const rowId = ref(0);
const receiveType = ref(1);
const email = ref('');
const gridApi = ref<any>();
const emailHint = computed(() => {
if (email.value) {
return `填写了邮箱则会发送至:${email.value};同时可在小程序查收`;
}
return '未填写邮箱则仅小程序可查收;完成后将通知患者';
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
async onConfirm() {
const code = invoiceCode.value.trim();
if (!code) {
message.warning('请填写票据代码');
return;
}
if (!fileUrls.value.length) {
message.warning('请上传发票文件');
return;
}
modalApi.setState({ loading: true, confirmLoading: true });
try {
const res: any = await completeInvoiceApi({
id: rowId.value,
invoice_code: code,
invoice_files: fileUrls.value,
});
if (res?.subscribe_warn) {
message.warning(String(res.subscribe_warn));
} else {
message.success('开票完成');
}
gridApi.value?.reload?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<Record<string, any>>() || {};
rowId.value = Number(data.id || 0);
receiveType.value = Number(data.receive_type || 1);
email.value = String(data.email || '');
gridApi.value = data.gridApi;
invoiceCode.value = '';
fileUrls.value = [];
},
});
</script>
<template>
<Modal title="完成开票" class="w-[560px]">
<div class="space-y-4 py-2">
<div class="text-sm text-gray-500">{{ emailHint }}</div>
<div>
<div class="mb-1 font-medium">票据代码</div>
<Input v-model:value="invoiceCode" placeholder="请输入发票代码/号码" allow-clear />
</div>
<div>
<div class="mb-1 font-medium">发票文件图片或 PDF</div>
<UploadDraggerPaste
v-model="fileUrls"
accept=".jpg,.jpeg,.png,.webp,.pdf,image/*,application/pdf"
tip="点击或拖拽发票文件到此处建议上传图片小程序内可直接预览PDF 将通过微信打开)"
:max-count="9"
:max-size-mb="20"
/>
</div>
</div>
</Modal>
</template>

View File

@@ -0,0 +1,56 @@
<script lang="ts" setup>
/**
* 拒绝开票申请弹窗
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Input, message } from 'ant-design-vue';
import { rejectInvoiceApi } from '../api';
const reason = ref('');
const rowId = ref(0);
const gridApi = ref<any>();
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
async onConfirm() {
const text = reason.value.trim();
if (!text) {
message.warning('请填写拒绝原因');
return;
}
modalApi.setState({ loading: true, confirmLoading: true });
try {
await rejectInvoiceApi({ id: rowId.value, reject_reason: text });
message.success('已拒绝');
gridApi.value?.reload?.();
modalApi.close();
} finally {
modalApi.setState({ loading: false, confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
if (!isOpen) return;
const data = modalApi.getData<Record<string, any>>() || {};
rowId.value = Number(data.id || 0);
gridApi.value = data.gridApi;
reason.value = '';
},
});
</script>
<template>
<Modal title="拒绝开票申请" class="w-[480px]">
<div class="py-2">
<div class="mb-2 text-sm text-gray-500">拒绝后患者可重新申请开票</div>
<Input.TextArea v-model:value="reason" :rows="4" placeholder="请填写拒绝原因" />
</div>
</Modal>
</template>

View File

@@ -0,0 +1,47 @@
import type { VbenFormProps } from '#/adapter/form';
/**
* 开票中心列表搜索表单
*/
export const formOptions: VbenFormProps = {
collapsed: false,
schema: [
{
component: 'Input',
componentProps: {
placeholder: '订单号 / 抬头 / 邮箱',
allowClear: true,
},
fieldName: 'keyword',
label: '关键词',
},
{
component: 'Input',
componentProps: {
placeholder: '订单号',
allowClear: true,
},
fieldName: 'order_no',
label: '订单号',
},
{
component: 'Select',
componentProps: {
placeholder: '接收方式',
allowClear: true,
options: [
{ label: '全部', value: '' },
{ label: '小程序下载', value: 1 },
{ label: '邮箱接收', value: 2 },
],
},
defaultValue: '',
fieldName: 'receive_type',
label: '接收方式',
},
],
showCollapseButton: false,
submitButtonOptions: { content: '查询' },
submitOnChange: false,
submitOnEnter: true,
};

View File

@@ -0,0 +1,69 @@
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getInvoiceCenterListApi } from '../api';
interface RowType {
id: number;
order_no: string;
status: number;
}
/**
* 开票中心表格配置statusFilter 由页面注入当前 Tab 状态
*/
export function createGridOptions(getStatus: () => string | number): VxeGridProps<RowType> {
return {
checkboxConfig: {
highlight: true,
labelField: '',
},
columns: [
{ field: 'id', title: 'ID', width: 70 },
{ field: 'order_no', title: '订单号', minWidth: 160 },
{ field: 'store_name', title: '诊所', minWidth: 140 },
{ field: 'title_type_text', title: '抬头类型', width: 90 },
{ field: 'title_name', title: '抬头名称', minWidth: 120 },
{ field: 'amount', title: '金额', width: 100 },
{ field: 'receive_type_text', title: '接收方式', width: 100 },
{ field: 'email', title: '邮箱', minWidth: 160 },
{ field: 'status_text', title: '状态', width: 90, slots: { default: 'status' } },
{ field: 'created_at', title: '申请时间', minWidth: 160 },
{
title: '操作',
align: 'right',
slots: { default: 'action' },
width: 260,
fixed: 'right',
},
],
keepSource: true,
pagerConfig: {},
columnConfig: { useKey: true },
rowConfig: { useKey: true },
proxyConfig: {
ajax: {
query: async ({ page }, formValues) => {
const status = getStatus();
return await getInvoiceCenterListApi({
page: page.currentPage,
pageSize: page.pageSize,
status: status === '' || status === 'all' ? '' : status,
...formValues,
});
},
},
},
height: 'auto',
border: false,
toolbarConfig: {
// @ts-ignore
search: true,
refresh: true,
print: false,
export: false,
zoom: true,
slots: { buttons: 'toolbar-buttons' },
},
showOverflow: false,
};
}

View File

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

View File

@@ -3,7 +3,7 @@ import { onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, Input, InputNumber, message, Radio, Space, Switch, Tabs, Tag } from 'ant-design-vue';
import { Button, Card, Checkbox, Input, InputNumber, message, Radio, Space, Switch, Tabs, Tag } from 'ant-design-vue';
import type { QuickDiscountOption } from '#/utils/pricePercentAdjust';
@@ -37,6 +37,8 @@ const logisticsShowWhNameUser = ref(false);
const logisticsShowWhNameClinic = ref(true);
/** 业务员是否允许编辑/改价/配置所属诊所(默认否) */
const salespersonStoreEditEnabled = ref(false);
/** 开票通知渠道subscribe / sms默认仅订阅消息 */
const invoiceNoticeChannels = ref<string[]>(['subscribe']);
function readStoredTab() {
const stored = localStorage.getItem(TAB_STORAGE_KEY);
@@ -45,7 +47,8 @@ function readStoredTab() {
stored === 'input_audit' ||
stored === 'miniprogram' ||
stored === 'logistics' ||
stored === 'salesperson'
stored === 'salesperson' ||
stored === 'invoice_notice'
) {
activeKey.value = stored;
}
@@ -110,6 +113,22 @@ async function load() {
if (row.config_key === 'salesperson_store_edit_enabled') {
salespersonStoreEditEnabled.value = parseBoolConfig(row.config_value, false);
}
if (row.config_key === 'invoice_notice_channels') {
let channels: unknown = row.config_value;
if (typeof channels === 'string') {
try {
channels = JSON.parse(channels);
} catch {
channels = ['subscribe'];
}
}
if (Array.isArray(channels) && channels.length) {
invoiceNoticeChannels.value = channels.map(String).filter((c) => c === 'subscribe' || c === 'sms');
}
if (!invoiceNoticeChannels.value.length) {
invoiceNoticeChannels.value = ['subscribe'];
}
}
}
} finally {
loading.value = false;
@@ -167,6 +186,16 @@ async function handleSave() {
description: '允许业务员编辑/改价/配置所属诊所',
sort: 300,
},
{
config_key: 'invoice_notice_channels',
config_value: JSON.stringify(
invoiceNoticeChannels.value.length ? invoiceNoticeChannels.value : ['subscribe'],
),
value_type: 'json',
config_group: 'invoice',
description: '开票通知渠道subscribe订阅消息 / sms短信',
sort: 200,
},
]);
message.success('保存成功');
} finally {
@@ -271,6 +300,19 @@ onMounted(() => {
/>
</div>
</Tabs.TabPane>
<Tabs.TabPane key="invoice_notice" tab="开票通知">
<div class="py-4">
<div class="mb-2 font-medium">患者开票通知渠道</div>
<div class="mb-2 text-sm text-gray-500">
受理与开票完成时按所选渠道发送可多选全选默认仅小程序订阅消息
</div>
<Checkbox.Group v-model:value="invoiceNoticeChannels">
<Checkbox value="subscribe">小程序订阅消息</Checkbox>
<Checkbox value="sms">短信</Checkbox>
</Checkbox.Group>
</div>
</Tabs.TabPane>
</Tabs>
<div class="mt-4">