diff --git a/apps/web-antd/src/adapter/component/index.ts b/apps/web-antd/src/adapter/component/index.ts index d8c83e2c..4aa7bf67 100644 --- a/apps/web-antd/src/adapter/component/index.ts +++ b/apps/web-antd/src/adapter/component/index.ts @@ -81,6 +81,7 @@ export type ComponentType = | 'UploadImage' | 'UploadImageSortable' | 'UploadOssFile' + | 'UploadDraggerPaste' | BaseFormComponentType; async function initComponentAdapter() { diff --git a/apps/web-antd/src/components/form/components/upload-dragger-paste.vue b/apps/web-antd/src/components/form/components/upload-dragger-paste.vue new file mode 100644 index 00000000..73aeca75 --- /dev/null +++ b/apps/web-antd/src/components/form/components/upload-dragger-paste.vue @@ -0,0 +1,371 @@ + + + + + + + + + {{ tip }} + + 支持拖拽或点击选择,单文件不超过 {{ maxSizeMb }}MB + + + + + + + + {{ item.name }} + + {{ item.name }} + + 删除 + + + + + + + + + {{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }} + + + 监听中:请将文件或截图 Ctrl+V 粘贴 + + + + + 加载中… + + + + + + + diff --git a/apps/web-antd/src/components/form/components/upload-image.vue b/apps/web-antd/src/components/form/components/upload-image.vue index 8d0bc807..eafb7304 100644 --- a/apps/web-antd/src/components/form/components/upload-image.vue +++ b/apps/web-antd/src/components/form/components/upload-image.vue @@ -1,6 +1,9 @@ @@ -187,6 +254,25 @@ const galleryRemainCount = computed(() => Math.max(props.maxCount - fileList.val @select="onGallerySelect" /> + + + + + {{ pasteListening ? '停止监听粘贴' : '开始监听粘贴' }} + + + 监听中:Ctrl+V 粘贴图片 + + + diff --git a/apps/web-antd/src/utils/oss-upload.ts b/apps/web-antd/src/utils/oss-upload.ts index af0e26bf..91186780 100644 --- a/apps/web-antd/src/utils/oss-upload.ts +++ b/apps/web-antd/src/utils/oss-upload.ts @@ -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上传签名信息 * @@ -252,6 +269,12 @@ export async function uploadToOss(options: UploadOptions): Promise // 这与后端上传保持一致(后端上传后会调用putObjectAcl设置公共读) // 注意:此字段必须与后端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的最后 diff --git a/apps/web-antd/src/utils/use-paste-upload-listen.ts b/apps/web-antd/src/utils/use-paste-upload-listen.ts new file mode 100644 index 00000000..439bfe1a --- /dev/null +++ b/apps/web-antd/src/utils/use-paste-upload-listen.ts @@ -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; +} + +/** + * 将 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; + }); +} + +/** + * 从粘贴事件中收集 File(files + items 截图等) + */ +export function collectPasteFiles(event: ClipboardEvent): File[] { + const result: File[] = []; + const seen = new Set(); + 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 = 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, + }; +} diff --git a/apps/web-antd/src/views/finance/invoice-center/api/index.ts b/apps/web-antd/src/views/finance/invoice-center/api/index.ts new file mode 100644 index 00000000..64a7c0a2 --- /dev/null +++ b/apps/web-antd/src/views/finance/invoice-center/api/index.ts @@ -0,0 +1,42 @@ +import { requestClient } from '#/api/request'; + +const prefix = 'invoice-center/'; + +/** 开票申请列表 */ +export async function getInvoiceCenterListApi(data: any) { + return requestClient.get(`${prefix}list`, { params: data }); +} + +/** 开票详情 */ +export async function getInvoiceCenterDetailApi(data: any) { + return requestClient.get(`${prefix}detail`, { params: data }); +} + +/** 受理 */ +export async function acceptInvoiceApi(data: { id: number }) { + return requestClient.post(`${prefix}accept`, data); +} + +/** 拒绝 */ +export async function rejectInvoiceApi(data: { id: number; reject_reason: string }) { + return requestClient.post(`${prefix}reject`, data); +} + +/** 完成开票(上传文件并按需发邮) */ +export async function completeInvoiceApi(data: { + id: number; + invoice_code: string; + invoice_files: string[]; +}) { + return requestClient.post(`${prefix}complete`, data); +} + +/** 重发邮件 */ +export async function resendInvoiceEmailApi(data: { id: number }) { + return requestClient.post(`${prefix}resend-email`, data); +} + +/** 各状态数量(Tab 徽标) */ +export async function getInvoiceStatusStatsApi() { + return requestClient.get>(`${prefix}status-stats`); +} diff --git a/apps/web-antd/src/views/finance/invoice-center/components/CompleteModal.vue b/apps/web-antd/src/views/finance/invoice-center/components/CompleteModal.vue new file mode 100644 index 00000000..0df9cfd3 --- /dev/null +++ b/apps/web-antd/src/views/finance/invoice-center/components/CompleteModal.vue @@ -0,0 +1,96 @@ + + + + + + {{ emailHint }} + + 票据代码 + + + + 发票文件(图片或 PDF) + + + + + diff --git a/apps/web-antd/src/views/finance/invoice-center/components/RejectModal.vue b/apps/web-antd/src/views/finance/invoice-center/components/RejectModal.vue new file mode 100644 index 00000000..3db8319c --- /dev/null +++ b/apps/web-antd/src/views/finance/invoice-center/components/RejectModal.vue @@ -0,0 +1,56 @@ + + + + + + 拒绝后患者可重新申请开票 + + + + diff --git a/apps/web-antd/src/views/finance/invoice-center/config/search.ts b/apps/web-antd/src/views/finance/invoice-center/config/search.ts new file mode 100644 index 00000000..4fbf7a41 --- /dev/null +++ b/apps/web-antd/src/views/finance/invoice-center/config/search.ts @@ -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, +}; diff --git a/apps/web-antd/src/views/finance/invoice-center/config/table.ts b/apps/web-antd/src/views/finance/invoice-center/config/table.ts new file mode 100644 index 00000000..9c6a9e1a --- /dev/null +++ b/apps/web-antd/src/views/finance/invoice-center/config/table.ts @@ -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 { + 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, + }; +} diff --git a/apps/web-antd/src/views/finance/invoice-center/index.vue b/apps/web-antd/src/views/finance/invoice-center/index.vue new file mode 100644 index 00000000..b811d22b --- /dev/null +++ b/apps/web-antd/src/views/finance/invoice-center/index.vue @@ -0,0 +1,316 @@ + + + + + + + + + + + 待受理 + + + + + + + 已受理 + + + + + + + 已完成 + + + + + + + 已拒绝 + + + + + + + + + {{ row.status_text }} + + + + + 受理 + + + 拒绝 + + + 完成开票 + + + 查看文件 + + + 重发邮件 + + + + + + + 加载中… + + + + + + + diff --git a/apps/web-antd/src/views/system/system-config/index.vue b/apps/web-antd/src/views/system/system-config/index.vue index a851cdd6..6e644b54 100644 --- a/apps/web-antd/src/views/system/system-config/index.vue +++ b/apps/web-antd/src/views/system/system-config/index.vue @@ -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(['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(() => { /> + + + + 患者开票通知渠道 + + 受理与开票完成时按所选渠道发送;可多选(全选)。默认仅小程序订阅消息。 + + + 小程序订阅消息 + 短信 + + +
+ +
{{ tip }}
+ 支持拖拽或点击选择,单文件不超过 {{ maxSizeMb }}MB +