From d159e514b256c7a417ad5f84c86d508cb340aabb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Tue, 18 Aug 2026 09:12:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E9=BD=90=E7=B4=A0=E6=9D=90?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=A4=B9=E3=80=81=E6=8C=82=E5=8F=B7=E6=94=B9?= =?UTF-8?q?=E6=9C=9F=E3=80=81=E7=B3=BB=E7=BB=9F=E9=85=8D=E7=BD=AE=E5=88=86?= =?UTF-8?q?=E7=BB=84=E4=B8=8E=E5=B7=A5=E4=BD=9C=E5=8F=B0=E7=AE=80=E6=8A=A5?= =?UTF-8?q?=E5=BC=80=E5=85=B3=20=E7=B4=A0=E6=9D=90=E5=BA=93=E6=8C=89=20obj?= =?UTF-8?q?ect=5Fkey=20=E7=9B=AE=E5=BD=95=E5=90=8C=E6=AD=A5=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E5=A4=B9=E4=BE=A7=E6=A0=8F=EF=BC=8C=E9=80=89=E5=9B=BE?= =?UTF-8?q?=E5=92=8C=E7=AD=9B=E9=80=89=E5=8F=AF=E6=8C=89=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=94=B6=E7=AA=84=E3=80=82=20=E6=8C=82=E5=8F=B7=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E6=94=AF=E6=8C=81=E7=AE=A1=E7=90=86=E7=AB=AF=E4=BB=A3?= =?UTF-8?q?=E6=94=B9=E6=9C=9F=EF=BC=8C=E5=8F=B7=E6=BA=90=E6=97=A5=E5=8E=86?= =?UTF-8?q?=E4=B8=8E=E6=82=A3=E8=80=85=E7=AB=AF=E5=90=8C=E6=BA=90=E3=80=82?= =?UTF-8?q?=20=E7=B3=BB=E7=BB=9F=E9=85=8D=E7=BD=AE=E6=8C=89=E7=AB=AF?= =?UTF-8?q?=E6=8B=86=E6=A8=A1=E5=9D=97=EF=BC=88=E6=82=A3=E8=80=85=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=E5=B1=95=E7=A4=BA=E3=80=81=E5=8C=BB=E7=94=9F=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=B8=B8=E7=94=A8=E5=85=A5=E5=8F=A3=E3=80=81=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8F=B0=E7=AE=80=E6=8A=A5=E6=80=BB=E5=BC=80=E5=85=B3?= =?UTF-8?q?=E3=80=81=E5=A4=84=E6=96=B9=E7=AD=BE=E5=90=8D=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E6=97=A7=E8=8F=9C=E5=8D=95=20key=20=E5=81=9A=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E4=BB=A5=E5=85=8D=E5=88=B7=E6=96=B0=E4=B8=A2=E9=80=89=E4=B8=AD?= =?UTF-8?q?=E3=80=82=20AI=20=E7=94=9F=E6=88=90=E8=AE=B0=E5=BD=95=E8=A1=A5?= =?UTF-8?q?=E6=AF=8F=E6=97=A5=E7=AE=80=E6=8A=A5=E5=9C=BA=E6=99=AF=E5=92=8C?= =?UTF-8?q?=E7=BC=93=E5=AD=98=E5=91=BD=E4=B8=AD=E7=AD=9B=E9=80=89=EF=BC=9B?= =?UTF-8?q?=E4=B8=AA=E4=BA=BA=E4=B8=AD=E5=BF=83=E6=94=B9=E8=B5=B0=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E8=8F=9C=E5=8D=95=20Profile=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web-antd/src/api/core/file-folder.ts | 27 + apps/web-antd/src/api/core/file-gallery.ts | 104 +- .../form/components/image-gallery-picker.vue | 73 +- .../composables/use-file-gallery-filter.ts | 73 +- .../src/router/routes/modules/vben.ts | 11 +- .../src/views/_core/profile/index.vue | 6 +- .../order/product-order/components/refund.vue | 37 +- .../business/order/register/api/index.ts | 48 + .../register/components/reschedule-modal.vue | 293 ++++ .../views/business/order/register/index.vue | 46 +- .../views/dashboard/workspace/api/index.ts | 58 +- .../components/WidgetAiDailyBrief.vue | 228 ++- .../workspace/components/WorkspaceHeader.vue | 42 +- .../components/PrescriptionDetail.vue | 140 +- .../components/account-info-card.vue | 222 +-- .../ai/generation/components/detail-modal.vue | 283 +++- .../components/generation-card-list.vue | 80 +- .../system/ai/generation/config/constants.ts | 7 + .../system/ai/generation/config/search.ts | 26 +- .../system/ai/generation/config/table.ts | 7 + .../src/views/system/ai/generation/index.vue | 178 ++- .../components/folder-sidebar.vue | 417 ++++++ .../src/views/system/file-gallery/index.vue | 784 ++++++++-- .../src/views/system/home-zones/index.vue | 11 +- .../views/system/system-config/api/index.ts | 28 +- .../components/ai-model-config-panel.vue | 1263 +++++++++++------ .../components/dispense-sign-config-panel.vue | 249 ++++ .../components/home-display-config-panel.vue | 100 ++ .../components/miniprogram-config-panel.vue | 10 +- .../components/module-tabs-panel.vue | 90 ++ .../components/workbench-config-panel.vue | 86 ++ .../wx-workbench-default-fav-panel.vue | 218 +++ .../src/views/system/system-config/index.vue | 230 ++- 33 files changed, 4342 insertions(+), 1133 deletions(-) create mode 100644 apps/web-antd/src/api/core/file-folder.ts create mode 100644 apps/web-antd/src/views/business/order/register/components/reschedule-modal.vue create mode 100644 apps/web-antd/src/views/system/file-gallery/components/folder-sidebar.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/dispense-sign-config-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/home-display-config-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/module-tabs-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/workbench-config-panel.vue create mode 100644 apps/web-antd/src/views/system/system-config/components/wx-workbench-default-fav-panel.vue diff --git a/apps/web-antd/src/api/core/file-folder.ts b/apps/web-antd/src/api/core/file-folder.ts new file mode 100644 index 00000000..0392d77f --- /dev/null +++ b/apps/web-antd/src/api/core/file-folder.ts @@ -0,0 +1,27 @@ +import { requestClient } from '#/api/request'; + +const prefix = 'file-folder/'; + +/** 素材库文件夹,path 来自 object_key 按 / 拆出的目录 */ +export interface FileFolderItem { + id: number; + pid: number; + name: string; + path: string; + depth: number; + sort: number; +} + +/** 文件夹列表(会先按 object_key 同步),xk-api GET /file-folder/list */ +export async function getFileFolderList() { + return requestClient.get(`${prefix}list`); +} + +/** 手动从 object_key 同步文件夹,xk-api POST /file-folder/sync */ +export async function syncFileFolder() { + return requestClient.post<{ + bound: number; + created: number; + folder_count: number; + }>(`${prefix}sync`); +} diff --git a/apps/web-antd/src/api/core/file-gallery.ts b/apps/web-antd/src/api/core/file-gallery.ts index aacc8ce3..8a0862b0 100644 --- a/apps/web-antd/src/api/core/file-gallery.ts +++ b/apps/web-antd/src/api/core/file-gallery.ts @@ -16,6 +16,9 @@ export interface FileGalleryItem { original_name?: string; object_key?: string; source?: number; + is_qrcode?: number; + ref_count?: number; + folder_id?: number; } export interface FileTypeItem { @@ -32,28 +35,47 @@ export interface FileGalleryDetail extends FileGalleryItem { updated_at?: string; } -export async function getFileGalleryList(params?: { +export interface FileGalleryStats { + total_count: number; + total_size: number; + total_size_text: string; + unref_count: number; + recyclable_size: number; + recyclable_size_text: string; + has_scanned: boolean; +} + +export interface FileGalleryListParams { page?: number; page_size?: number; pid?: number; type?: number; group_id?: number; keyword?: string; -}) { + exclude_qrcode?: number; + unref_only?: number; + folder_id?: number; + unfiled?: number; +} + +export async function getFileGalleryList(params?: FileGalleryListParams) { return requestClient.get<{ items: FileGalleryItem[]; - total: number; page: number; page_size: number; + total: number; }>(`${prefix}list`, { params }); } export async function getFileGalleryDetail(id: number) { - return requestClient.get(`${prefix}detail`, { params: { id } }); + return requestClient.get(`${prefix}detail`, { + params: { id }, + }); } -export async function deleteFileGalleryItem(id: number) { - return requestClient.post(`${prefix}delete`, { id }); +export async function deleteFileGalleryItem(id: number | number[]) { + const data = Array.isArray(id) ? { ids: id } : { id }; + return requestClient.post(`${prefix}delete`, data); } export async function renameFileGalleryItem(id: number, file_name: string) { @@ -66,8 +88,8 @@ export async function moveFileGalleryType(id: number, type: number) { export async function batchArchiveFiles() { return requestClient.post<{ - reclassified: number; file_name_filled: number; + reclassified: number; }>(`${prefix}batch-archive`); } @@ -76,15 +98,77 @@ export async function getFileTypes() { } export async function syncFileGalleryFromOss() { - return requestClient.post<{ synced: number; skipped: number; scanned: number }>(`${prefix}sync-oss`); + return requestClient.post<{ + scanned: number; + skipped: number; + synced: number; + }>(`${prefix}sync-oss`); } /** 文件加入自定义分组 */ export async function addFileToGroup(file_id: number, group_id: number) { - return requestClient.post(`${prefix}add-to-group`, { file_id, group_id }); + return requestClient.post(`${prefix}add-to-group`, { + file_id, + group_id, + }); } /** 文件移出自定义分组 */ export async function removeFileFromGroup(file_id: number, group_id: number) { - return requestClient.post(`${prefix}remove-from-group`, { file_id, group_id }); + return requestClient.post(`${prefix}remove-from-group`, { + file_id, + group_id, + }); +} + +/** 素材库统计:总数 / 容量 / 未引用 / 可回收 */ +export async function getFileGalleryStats() { + return requestClient.get(`${prefix}stats`); +} + +/** 右键标记或取消二维码 */ +export async function markFileQrcode(id: number, is_qrcode: number) { + return requestClient.post<{ id: number; is_qrcode: number }>( + `${prefix}mark-qrcode`, + { + id, + is_qrcode, + }, + ); +} + +/** 扫描业务表 URL 引用(两库全表文本列) */ +export async function scanFileGalleryRefs() { + return requestClient.post<{ + elapsed_ms: number; + mysql_columns: number; + mysql_failed: number; + old_mysql_columns: number; + old_mysql_failed: number; + referenced: number; + scanned_files: number; + unreferenced: number; + }>(`${prefix}scan-refs`); +} + +/** 批量加入分组(移动到文件夹) */ +export async function batchAddFilesToGroup(ids: number[], group_id: number) { + return requestClient.post<{ count: number; group_id: number }>( + `${prefix}batch-add-to-group`, + { + ids, + group_id, + }, + ); +} + +/** 批量移入真实文件夹 */ +export async function batchMoveFilesToFolder(ids: number[], folder_id: number) { + return requestClient.post<{ count: number; folder_id: number }>( + `${prefix}batch-move-to-folder`, + { + ids, + folder_id, + }, + ); } diff --git a/apps/web-antd/src/components/form/components/image-gallery-picker.vue b/apps/web-antd/src/components/form/components/image-gallery-picker.vue index a59841bb..b236d46c 100644 --- a/apps/web-antd/src/components/form/components/image-gallery-picker.vue +++ b/apps/web-antd/src/components/form/components/image-gallery-picker.vue @@ -1,19 +1,17 @@ diff --git a/apps/web-antd/src/views/business/order/register/api/index.ts b/apps/web-antd/src/views/business/order/register/api/index.ts index 45392ea7..92db7d02 100644 --- a/apps/web-antd/src/views/business/order/register/api/index.ts +++ b/apps/web-antd/src/views/business/order/register/api/index.ts @@ -38,3 +38,51 @@ export async function getRegisterListApi(data: Record) { export async function refundRegisterApi(data: Record) { return requestClient.post(`${prefix}refund`, data); } + +/** 号源日历时段(改期弹窗展示余号/约满/时段已过) */ +export interface RegisterCalendarSlot { + slot_id: number; + slot_name: string; + start_time: string; + end_time: string; + capacity: number; + used: number; + left: number; + is_full: boolean; + is_expired: boolean; +} + +/** 号源日历单日(与患者端挂号日历同源结构) */ +export interface RegisterCalendarDay { + date: string; + date_ts: number; + week: string; + is_today: boolean; + is_work: boolean; + total: number; + left: number; + is_full: boolean; + slots: RegisterCalendarSlot[]; +} + +/** 医生号源日历(管理端代改期选择新时段;与患者/医生端同源) */ +export async function getRegisterScheduleCalendarApi(data: { + doctor_id: number; + store_id: number; +}) { + return requestClient.get<{ + book_days: number; + days: RegisterCalendarDay[]; + is_default: boolean; + }>(`${prefix}schedule-calendar`, { params: data }); +} + +/** 管理端代患者改期(仅已支付待接诊单,管理员不受就诊当天限制) */ +export async function rescheduleRegisterApi(data: { + register_id: number; + remark?: string; + slot_id: number; + visit_date: string; +}) { + return requestClient.post(`${prefix}reschedule`, data); +} diff --git a/apps/web-antd/src/views/business/order/register/components/reschedule-modal.vue b/apps/web-antd/src/views/business/order/register/components/reschedule-modal.vue new file mode 100644 index 00000000..0f71dd4a --- /dev/null +++ b/apps/web-antd/src/views/business/order/register/components/reschedule-modal.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/apps/web-antd/src/views/business/order/register/index.vue b/apps/web-antd/src/views/business/order/register/index.vue index ff72db51..c33b3c72 100644 --- a/apps/web-antd/src/views/business/order/register/index.vue +++ b/apps/web-antd/src/views/business/order/register/index.vue @@ -19,15 +19,16 @@ import { Button, Image, Tag } from 'ant-design-vue'; import { useVbenForm } from '#/adapter/form'; import { useVbenVxeGrid } from '#/adapter/vxe-table'; +import StoreCardModal from '#/components/store-card/StoreCardModal.vue'; import { TableAction } from '#/components/table-action'; import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch'; -import StoreCardModal from '#/components/store-card/StoreCardModal.vue'; import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue'; import { getRegisterListApi } from './api'; import Refund from './components/refund.vue'; import RegisterDetailModal from './components/RegisterDetailModal.vue'; import RegisterOrderCardList from './components/RegisterOrderCardList.vue'; +import RescheduleModal from './components/reschedule-modal.vue'; import { REGISTER_REFUND_STATUS_MAP, REGISTER_STATUS_MAP, @@ -41,7 +42,9 @@ const hasTopTableDropDownActions = ref(false); /** 列表/卡片双视图:通用 useViewMode 自带 localStorage 记忆 */ const viewMode = useViewMode('register-order-view-mode'); -const cardListRef = ref | null>(null); +const cardListRef = ref | null>( + null, +); const gridEvents: VxeGridListeners = { checkboxChange() { @@ -104,7 +107,7 @@ const searchValues = ref>(collectDefaultSearchValues()); const [SearchForm, searchFormApi] = useVbenForm({ ...effectiveFormOptions, handleSubmit: async (values) => { - searchValues.value = { ...(values || {}) }; + searchValues.value = { ...values }; if (viewMode.value === 'list') { gridApi.reload(); } @@ -127,7 +130,11 @@ const [Grid, gridApi] = useVbenVxeGrid({ proxyConfig: { ajax: { /** 始终合并顶部统一搜索条件 */ - query: async ({ page }: { page: { currentPage: number; pageSize: number } }) => { + query: async ({ + page, + }: { + page: { currentPage: number; pageSize: number }; + }) => { return await getRegisterListApi({ page: page.currentPage, pageSize: page.pageSize, @@ -190,6 +197,11 @@ const [RefundModal, RefundModalApi] = useVbenModal({ connectedComponent: Refund, }); +/** 改期弹窗:管理端代患者改期(拉号源日历选新日期/时段) */ +const [RescheduleModalComp, rescheduleModalApi] = useVbenModal({ + connectedComponent: RescheduleModal, +}); + /** 挂号详情弹窗:列表行「详情」与卡片点击共用 */ const [RegisterDetailModalComp, registerDetailModalApi] = useVbenModal({ connectedComponent: RegisterDetailModal, @@ -221,6 +233,16 @@ function canRefund(row: Record) { return row.is_pay === 1 && row.refund_status !== 1 && row.refund_status !== 3; } +/** 仅「已支付 + 待接诊 + 未取消」可改期(与后端 RegisterRescheduleService 校验口径一致) */ +function canReschedule(row: Record) { + return row.is_pay === 1 && row.status === 1 && row.is_cancel !== 1; +} + +function openRescheduleModal(row: Record) { + rescheduleModalApi.setData({ row, gridApi: gridApiProxy }); + rescheduleModalApi.open(); +} + function formatRegisterPrice(price: unknown) { const n = Number(price); if (!Number.isFinite(n)) { @@ -250,6 +272,7 @@ function rowRefundMeta(row: Record) { + ) { @open-store="openStoreCard" /> -
+
@@ -268,9 +293,7 @@ function rowRefundMeta(row: Record) { @@ -340,6 +363,13 @@ function rowRefundMeta(row: Record) { icon: 'marketeq:eye', onClick: openRegisterDetail.bind(null, row), }, + { + label: '改期', + type: 'link', + icon: 'ant-design:calendar-outlined', + ifShow: canReschedule(row), + onClick: openRescheduleModal.bind(null, row), + }, { label: '退款', type: 'link', diff --git a/apps/web-antd/src/views/dashboard/workspace/api/index.ts b/apps/web-antd/src/views/dashboard/workspace/api/index.ts index 7ffde7e6..43cd408e 100644 --- a/apps/web-antd/src/views/dashboard/workspace/api/index.ts +++ b/apps/web-antd/src/views/dashboard/workspace/api/index.ts @@ -4,21 +4,21 @@ const prefix = 'workbench/'; /** 工作台布局项(后端按角色下发) */ export type WorkbenchLayoutItem = { - id: number; - widget_code: string; - title: string; - sort: number; config: null | Record; + id: number; + sort: number; + title: string; + widget_code: string; }; /** 医生今日摘要(与医生小程序工作台同口径) */ export type DoctorSummaryResult = { - range: string; - prescription_count: number; - wait_accept: number; accepting: number; completed: number; prescription_amount: string; + prescription_count: number; + range: string; + wait_accept: number; }; /** 药师待审摘要 */ @@ -28,16 +28,16 @@ export type PharmacistSummaryResult = { /** 诊所今日摘要 */ export type ClinicSummaryResult = { - wait_accept: number; accepting: number; completed: number; + wait_accept: number; wait_delivery: number; }; /** 订单管理员摘要 */ export type OrderSummaryResult = { - wait_delivery: number; refunding: number; + wait_delivery: number; }; /** 平台今日总览(超管/系统管理员) */ @@ -51,8 +51,8 @@ export type PlatformOverviewResult = { /** 待办中心单项(count>0 需要处理,path 为跳转路由) */ export type TodoCenterItem = { code: string; - label: string; count: number; + label: string; path: string; }; @@ -63,35 +63,35 @@ export type TodoCenterResult = { /** 未来7天预约:单日分布 */ export type AppointmentUpcomingDay = { + count: number; date: string; label: string; week: string; - count: number; }; /** 未来7天预约统计(卡片) */ export type AppointmentUpcomingResult = { - total: number; days: AppointmentUpcomingDay[]; + total: number; }; /** 未来7天预约明细行 */ export type AppointmentUpcomingItem = { - register_id: number; + doctor_name: string; + end_time: string; + is_pay: number; order_no: string; - visit_date: string; - week: string; + patient_name: string; + price: string; + register_id: number; + slot_label: string; slot_name: string; start_time: string; - end_time: string; - slot_label: string; - patient_name: string; - doctor_name: string; - store_name: string; - is_pay: number; - price: string; status: number; status_text: string; + store_name: string; + visit_date: string; + week: string; }; /** 未来7天预约明细分页结果 */ @@ -103,17 +103,19 @@ export type AppointmentUpcomingListResult = { /** AI 今日简报(管理员=昨日经营+今日待办,医生=今日值班+挂号+预约) */ export type AiDailyBriefResult = { brief_date: string; - /** 1=生成成功 0=生成失败(失败时 error_msg 有值,data 快照仍可展示) */ - status: number; content: string; - error_msg: string; /** 生成时收集的结构化业务数据快照(统计芯片直接用,保证与 AI 文本口径一致) */ data: Record; - provider: string; - model: string; - generated_at: string; + /** 系统配置总开关;false 时前端应隐藏且后端不会生成 */ + enabled?: boolean; + error_msg: string; /** true=命中当日缓存(非今天首次生成,前端据此决定是否弹当日简报弹窗) */ from_cache: boolean; + generated_at: string; + model: string; + provider: string; + /** 1=生成成功 0=生成失败(失败时 error_msg 有值,data 快照仍可展示) */ + status: number; }; /** diff --git a/apps/web-antd/src/views/dashboard/workspace/components/WidgetAiDailyBrief.vue b/apps/web-antd/src/views/dashboard/workspace/components/WidgetAiDailyBrief.vue index fcdc2476..9f9c87d6 100644 --- a/apps/web-antd/src/views/dashboard/workspace/components/WidgetAiDailyBrief.vue +++ b/apps/web-antd/src/views/dashboard/workspace/components/WidgetAiDailyBrief.vue @@ -39,13 +39,83 @@ const [BriefModal, briefModalApi] = useVbenModal({ * 正文分段解析:后端约定 AI 输出「【昨日概览】...【重点提醒】...【今日建议】...」格式, * 按【标题】切段渲染更易读;AI 偶发不按格式输出时兜底整段展示,保证内容不丢 */ -const sections = computed(() => { - const content = brief.value?.content || ''; +const sections = computed(() => parseBriefSections(brief.value?.content || '')); + +/** + * 简报分段:优先按【标题】切;整段是 JSON 时再拆一层(Agent 偶发包 JSON) + */ +function parseBriefSections( + raw: string, +): Array<{ text: string; title: string }> { + const content = unwrapBriefContent(raw); if (!content) return []; const matches = [...content.matchAll(/【([^】]+)】([^【]*)/g)]; if (matches.length === 0) return [{ title: '', text: content }]; return matches.map((m) => ({ title: m[1] || '', text: (m[2] || '').trim() })); -}); +} + +function unwrapBriefContent(raw: string, depth = 0): string { + let text = String(raw || '').trim(); + if (!text || depth > 4) return text; + const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/); + if (fence?.[1]) text = fence[1].trim(); + try { + const decoded = JSON.parse(text); + if (typeof decoded === 'string') + return unwrapBriefContent(decoded, depth + 1); + if (!decoded || typeof decoded !== 'object') return text; + if (Array.isArray(decoded)) { + return flattenBriefSections(decoded) || text; + } + for (const field of ['content', 'text', 'brief', 'summary']) { + const val = (decoded as Record)[field]; + if (typeof val === 'string' && val.trim()) + return unwrapBriefContent(val, depth + 1); + if (val && typeof val === 'object') { + return unwrapBriefContent(JSON.stringify(val), depth + 1); + } + } + if (Array.isArray((decoded as any).sections)) { + return flattenBriefSections((decoded as any).sections) || text; + } + const parts: string[] = []; + Object.entries(decoded).forEach(([key, value]) => { + if (typeof value !== 'string' && typeof value !== 'number') return; + const title = String(key).trim(); + const body = String(value).trim(); + if ( + !title || + !body || + [ + 'brief', + 'brief_status', + 'content', + 'status', + 'summary', + 'text', + ].includes(title) + ) { + return; + } + parts.push(`【${title}】${body}`); + }); + return parts.length > 0 ? parts.join('') : text; + } catch { + return text; + } +} + +function flattenBriefSections(list: any[]): string { + return list + .map((sec) => { + if (typeof sec === 'string') return sec.trim(); + const title = String(sec?.title || sec?.name || sec?.label || '').trim(); + const body = String(sec?.text || sec?.content || sec?.body || '').trim(); + return title && body ? `【${title}】${body}` : body; + }) + .filter(Boolean) + .join(''); +} /** * 快照统计 pill:直接读生成时落库的 data_snapshot,保证数字与 AI 文本口径一致 @@ -57,32 +127,53 @@ const chips = computed(() => { if (data.scope === 'admin') { const y = data.yesterday || {}; const todoTotal = Array.isArray(data.todo) - ? data.todo.reduce((sum: number, item: any) => sum + (Number(item.count) || 0), 0) + ? data.todo.reduce( + (sum: number, item: any) => sum + (Number(item.count) || 0), + 0, + ) : 0; return [ { icon: 'lucide:coins', label: '昨日营收', value: `¥${y.revenue ?? 0}` }, - { icon: 'lucide:stethoscope', label: '昨日挂号', value: y.register_count ?? 0 }, - { icon: 'lucide:store', label: '昨日新录入门店', value: y.store_input_count ?? 0 }, - { icon: 'lucide:list-checks', label: '今日待办', value: todoTotal, highlight: todoTotal > 0 }, + { + icon: 'lucide:stethoscope', + label: '昨日挂号', + value: y.register_count ?? 0, + }, + { + icon: 'lucide:store', + label: '昨日新录入门店', + value: y.store_input_count ?? 0, + }, + { + icon: 'lucide:list-checks', + label: '今日待办', + value: todoTotal, + highlight: todoTotal > 0, + }, ]; } const today = data.today || {}; const schedule = data.schedule || {}; - const upcoming = data.upcoming || {}; return [ { icon: 'lucide:calendar-check', label: '今日值班', - value: schedule.is_work ? `${schedule.left ?? 0}/${schedule.total ?? 0}余号` : '休诊', + value: schedule.is_work + ? `${schedule.left ?? 0}/${schedule.total ?? 0}余号` + : '休诊', + }, + { + icon: 'lucide:users', + label: '待接诊', + value: today.wait_accept ?? 0, + highlight: (today.wait_accept ?? 0) > 0, }, - { icon: 'lucide:users', label: '待接诊', value: today.wait_accept ?? 0, highlight: (today.wait_accept ?? 0) > 0 }, { icon: 'lucide:bell-ring', label: '今日到期预约', value: today.appointment_due_waiting ?? 0, highlight: (today.appointment_due_waiting ?? 0) > 0, }, - { icon: 'lucide:calendar-clock', label: '未来7天预约', value: upcoming.total ?? 0 }, ]; }); @@ -94,7 +185,13 @@ async function fetchBrief(refresh: boolean = false) { loading.value = true; fetchError.value = false; try { - brief.value = await getAiDailyBriefApi(refresh); + const data = await getAiDailyBriefApi(refresh); + // 总开关关闭:不展示、不弹窗(正常走 layout 过滤,这里是接口兜底) + if (data && data.enabled === false) { + brief.value = null; + return; + } + brief.value = data; maybeAutoPopup(); } catch { fetchError.value = true; @@ -161,7 +258,9 @@ onMounted(() => fetchBrief());
简报加载失败 - +
@@ -180,8 +279,16 @@ onMounted(() => fetchBrief());
- AI 生成失败{{ brief.error_msg ? `:${brief.error_msg}` : '' }} - + AI 生成失败{{ + brief.error_msg ? `:${brief.error_msg}` : '' + }} +
@@ -199,9 +306,21 @@ onMounted(() => fetchBrief()); {{ chip.label }} -
-
- +
+
+

{{ section.text }}

@@ -232,8 +351,14 @@ onMounted(() => fetchBrief());
-
- +
+

{{ section.text }}

@@ -267,7 +392,7 @@ onMounted(() => fetchBrief()); place-items: center; width: 26px; height: 26px; - background: hsl(var(--primary) / 0.1); + background: hsl(var(--primary) / 10%); border-radius: 8px; } @@ -297,12 +422,12 @@ onMounted(() => fetchBrief()); height: 26px; padding: 0 10px; margin-left: auto; + font-size: 12px; + color: hsl(var(--muted-foreground)); + cursor: pointer; + background: transparent; border: 1px solid hsl(var(--border)); border-radius: 999px; - background: transparent; - color: hsl(var(--muted-foreground)); - font-size: 12px; - cursor: pointer; transition: color 0.15s ease, border-color 0.15s ease, @@ -311,8 +436,8 @@ onMounted(() => fetchBrief()); .ai-brief__refresh:hover:not(:disabled) { color: hsl(var(--primary)); - border-color: hsl(var(--primary) / 0.4); - background: hsl(var(--primary) / 0.06); + background: hsl(var(--primary) / 6%); + border-color: hsl(var(--primary) / 40%); } .ai-brief__refresh:disabled { @@ -340,7 +465,7 @@ onMounted(() => fetchBrief()); .ai-brief__loading-dot { width: 6px; height: 6px; - background: hsl(var(--primary) / 0.7); + background: hsl(var(--primary) / 70%); border-radius: 50%; animation: ai-bounce 1.2s ease-in-out infinite; } @@ -370,33 +495,33 @@ onMounted(() => fetchBrief()); } .ai-brief__error :deep(svg) { + flex-shrink: 0; width: 16px; height: 16px; - flex-shrink: 0; } .ai-brief__retry { + flex-shrink: 0; height: 26px; padding: 0 12px; - border: 1px solid hsl(var(--primary) / 0.35); - border-radius: 999px; - background: hsl(var(--primary) / 0.06); - color: hsl(var(--primary)); font-size: 12px; + color: hsl(var(--primary)); cursor: pointer; - flex-shrink: 0; + background: hsl(var(--primary) / 6%); + border: 1px solid hsl(var(--primary) / 35%); + border-radius: 999px; transition: background 0.15s ease; } .ai-brief__retry:hover { - background: hsl(var(--primary) / 0.12); + background: hsl(var(--primary) / 12%); } /* ===== 快照统计 pill ===== */ .ai-brief__chips { display: flex; - gap: 8px; flex-wrap: wrap; + gap: 8px; margin-bottom: 12px; } @@ -405,14 +530,14 @@ onMounted(() => fetchBrief()); gap: 6px; align-items: center; padding: 5px 10px; - border: 1px solid hsl(var(--border) / 0.7); - border-radius: 999px; font-size: 12px; + border: 1px solid hsl(var(--border) / 70%); + border-radius: 999px; } .ai-chip--hl { - border-color: hsl(var(--warning) / 0.45); - background: hsl(var(--warning) / 0.1); + background: hsl(var(--warning) / 10%); + border-color: hsl(var(--warning) / 45%); } .ai-chip__icon { @@ -442,9 +567,9 @@ onMounted(() => fetchBrief()); max-height: 180px; padding: 12px 14px; overflow-y: auto; - background: hsl(var(--muted) / 0.25); - border-radius: 10px; cursor: pointer; + background: hsl(var(--muted) / 25%); + border-radius: 10px; } .ai-brief__section { @@ -456,18 +581,18 @@ onMounted(() => fetchBrief()); .ai-brief__section-tag { align-self: flex-start; padding: 1px 8px; - background: hsl(var(--primary) / 0.1); - border-radius: 999px; font-size: 11px; font-weight: 600; color: hsl(var(--primary)); + background: hsl(var(--primary) / 10%); + border-radius: 999px; } .ai-brief__section-text { margin: 0; font-size: 13px; line-height: 1.7; - color: hsl(var(--foreground) / 0.9); + color: hsl(var(--foreground) / 90%); white-space: pre-wrap; } @@ -475,13 +600,13 @@ onMounted(() => fetchBrief()); display: inline-flex; gap: 2px; align-items: center; - margin-top: 8px; padding: 0; - border: none; - background: transparent; + margin-top: 8px; font-size: 12px; color: hsl(var(--primary)); cursor: pointer; + background: transparent; + border: none; } .ai-brief__more :deep(svg) { @@ -528,16 +653,16 @@ onMounted(() => fetchBrief()); margin: 0; font-size: 14px; line-height: 1.8; - color: hsl(var(--foreground) / 0.92); + color: hsl(var(--foreground) / 92%); white-space: pre-wrap; } .ai-popup__meta { - margin-top: 14px; padding-top: 10px; - border-top: 1px solid hsl(var(--border) / 0.5); + margin-top: 14px; font-size: 12px; color: hsl(var(--muted-foreground)); + border-top: 1px solid hsl(var(--border) / 50%); } @keyframes ai-spin { @@ -553,6 +678,7 @@ onMounted(() => fetchBrief()); opacity: 0.35; transform: translateY(0); } + 40% { opacity: 1; transform: translateY(-3px); diff --git a/apps/web-antd/src/views/dashboard/workspace/components/WorkspaceHeader.vue b/apps/web-antd/src/views/dashboard/workspace/components/WorkspaceHeader.vue index ab7f6ef1..80a9b2ee 100644 --- a/apps/web-antd/src/views/dashboard/workspace/components/WorkspaceHeader.vue +++ b/apps/web-antd/src/views/dashboard/workspace/components/WorkspaceHeader.vue @@ -51,7 +51,7 @@ function goNoticeCenter() { } /** - * 进入个人中心(静态路由 /profile,与顶栏头像下拉同一入口) + * 进入个人中心(路由由后端 xk_menu 隐藏菜单下发,与顶栏头像下拉同一入口) * 头像/姓名区域与右侧按钮都走这里 */ function goProfile() { @@ -142,21 +142,21 @@ onMounted(fetchUnreadCount); background: linear-gradient( 120deg, - hsl(var(--primary) / 0.1), - hsl(var(--primary) / 0.03) 45%, + hsl(var(--primary) / 10%), + hsl(var(--primary) / 3%) 45%, transparent 70% ), hsl(var(--card)); - border: 1px solid hsl(var(--border) / 0.7); + border: 1px solid hsl(var(--border) / 70%); border-radius: 12px; } /* 装饰光斑:低透明度 + 大模糊,暗色主题下自然减弱 */ .ws-header__orb { position: absolute; + pointer-events: none; border-radius: 50%; filter: blur(64px); - pointer-events: none; } .ws-header__orb--1 { @@ -164,7 +164,7 @@ onMounted(fetchUnreadCount); right: 10%; width: 180px; height: 180px; - background: hsl(var(--primary) / 0.18); + background: hsl(var(--primary) / 18%); } .ws-header__orb--2 { @@ -172,7 +172,7 @@ onMounted(fetchUnreadCount); bottom: -80px; width: 160px; height: 160px; - background: hsl(var(--primary) / 0.1); + background: hsl(var(--primary) / 10%); } .ws-header__left { @@ -193,7 +193,7 @@ onMounted(fetchUnreadCount); } .ws-header__left--clickable:hover { - background: hsl(var(--primary) / 0.08); + background: hsl(var(--primary) / 8%); } .ws-header__left--clickable:hover .ws-header__title { @@ -203,13 +203,13 @@ onMounted(fetchUnreadCount); /* 头像光环:primary 低透明双环 */ .ws-header__avatar-ring { display: flex; + flex-shrink: 0; padding: 3px; background: hsl(var(--card)); border-radius: 50%; box-shadow: - 0 0 0 2px hsl(var(--primary) / 0.25), - 0 4px 12px hsl(var(--primary) / 0.12); - flex-shrink: 0; + 0 0 0 2px hsl(var(--primary) / 25%), + 0 4px 12px hsl(var(--primary) / 12%); } .ws-header__text { @@ -218,24 +218,24 @@ onMounted(fetchUnreadCount); .ws-header__title { overflow: hidden; + text-overflow: ellipsis; font-size: 16px; font-weight: 650; color: hsl(var(--foreground)); letter-spacing: -0.01em; - text-overflow: ellipsis; white-space: nowrap; } .ws-header__role { display: inline-block; - margin-left: 8px; padding: 1px 8px; + margin-left: 8px; font-size: 11px; font-weight: 500; - color: hsl(var(--primary)); vertical-align: 2px; - background: hsl(var(--primary) / 0.1); - border: 1px solid hsl(var(--primary) / 0.2); + color: hsl(var(--primary)); + background: hsl(var(--primary) / 10%); + border: 1px solid hsl(var(--primary) / 20%); border-radius: 999px; } @@ -260,8 +260,8 @@ onMounted(fetchUnreadCount); width: 38px; height: 38px; cursor: pointer; - background: hsl(var(--muted) / 0.4); - border: 1px solid hsl(var(--border) / 0.7); + background: hsl(var(--muted) / 40%); + border: 1px solid hsl(var(--border) / 70%); border-radius: 50%; transition: background-color 0.2s ease, @@ -270,15 +270,15 @@ onMounted(fetchUnreadCount); } .ws-header__bell:hover { - background: hsl(var(--primary) / 0.1); - border-color: hsl(var(--primary) / 0.3); + background: hsl(var(--primary) / 10%); + border-color: hsl(var(--primary) / 30%); transform: translateY(-1px); } .ws-header__bell-icon { width: 17px; height: 17px; - color: hsl(var(--foreground) / 0.75); + color: hsl(var(--foreground) / 75%); } .ws-header__bell:hover .ws-header__bell-icon { diff --git a/apps/web-antd/src/views/doctor/doctor-reception/components/PrescriptionDetail.vue b/apps/web-antd/src/views/doctor/doctor-reception/components/PrescriptionDetail.vue index bd009146..f456e326 100644 --- a/apps/web-antd/src/views/doctor/doctor-reception/components/PrescriptionDetail.vue +++ b/apps/web-antd/src/views/doctor/doctor-reception/components/PrescriptionDetail.vue @@ -8,12 +8,12 @@ import { computed, ref } from 'vue'; import { Page, useVbenModal } from '@vben/common-ui'; -import { Button, Popover, Select, Spin, Switch, message } from 'ant-design-vue'; +import { Button, message, Popover, Select, Spin, Switch } from 'ant-design-vue'; import { desktop, isElectron } from '#/util/desktop'; -import { getPrescriptionInfoApi } from '#/views/doctor/doctor-reception/api'; -import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder'; import { formatStoreNameWithHu } from '#/utils/formatStoreNameWithHu'; +import { formatDoctorOrderText } from '#/utils/prescriptionDoctorOrder'; +import { getPrescriptionInfoApi } from '#/views/doctor/doctor-reception/api'; import { passApi } from '#/views/pharmacist/audit-prescription/api'; import RejectionReason from '#/views/pharmacist/audit-prescription/components/modal.vue'; @@ -98,8 +98,8 @@ async function handlePass() { const refresh = onAudited.value; refresh?.(); modalApi.close(); - } catch (e) { - console.error(e); + } catch (error) { + console.error(error); } finally { passing.value = false; } @@ -163,14 +163,16 @@ function buildPrintHtml(fileName: string): null | string { const printBox = document.querySelector('.print-box'); if (!printBox) return null; const styleTags: string[] = []; - document.querySelectorAll('style, link[rel="stylesheet"]').forEach((style) => { - // link 取绝对地址(.href 属性自动补全),隐藏窗口加载临时文件时才能命中样式 - styleTags.push( - style.tagName === 'LINK' - ? `` - : style.outerHTML, - ); - }); + document + .querySelectorAll('style, link[rel="stylesheet"]') + .forEach((style) => { + // link 取绝对地址(.href 属性自动补全),隐藏窗口加载临时文件时才能命中样式 + styleTags.push( + style.tagName === 'LINK' + ? `` + : style.outerHTML, + ); + }); return `${fileName || '文档打印'}${styleTags.join('')}${printBox.outerHTML}`; } @@ -249,7 +251,7 @@ const getImageSource = (imageString) => { return `data:image/jpeg;base64,${imageString}`; }; -function parseRecipeContent(content: string | Record) { +function parseRecipeContent(content: Record | string) { if (!content) return {}; if (typeof content === 'string') { try { @@ -299,7 +301,9 @@ function parseRecipeContent(content: string | Record) { allow-clear @change=" (value: any) => - updatePrintSettings({ printerName: (value as string) || '' }) + updatePrintSettings({ + printerName: (value as string) || '', + }) " />
@@ -362,7 +366,9 @@ function parseRecipeContent(content: string | Record) { {{ drug?.name || drug?.drug_name }} - {{ drug?.number }} /{{ drug?.unit?.name || drug?.use_unit?.name || 'g' }} + {{ drug?.number }} /{{ + drug?.unit?.name || drug?.use_unit?.name || 'g' + }}
方法: @@ -393,20 +399,14 @@ function parseRecipeContent(content: string | Record) { > @@ -145,4 +141,3 @@ const deleteZonesApi = (row: any) => {
- diff --git a/apps/web-antd/src/views/system/system-config/api/index.ts b/apps/web-antd/src/views/system/system-config/api/index.ts index 862e39cb..3183ba7b 100644 --- a/apps/web-antd/src/views/system/system-config/api/index.ts +++ b/apps/web-antd/src/views/system/system-config/api/index.ts @@ -6,7 +6,16 @@ export async function getSystemConfigList() { return requestClient.get(`${prefix}list`); } -export async function saveSystemConfig(items: Array<{ config_key: string; config_value: string }>) { +export async function saveSystemConfig( + items: Array<{ + config_group?: string; + config_key: string; + config_value: any; + description?: string; + sort?: number; + value_type?: string; + }>, +) { return requestClient.post(`${prefix}save`, { items }); } @@ -22,3 +31,20 @@ export async function getSystemConfigByKeys(keys: string[]) { params: { keys: keys.join(',') }, }); } + +const wxEntryPrefix = 'wx-workbench-entry/'; + +/** 某角色已分配的医生端功能入口(含 is_default_fav) */ +export async function getWxWorkbenchEntryByRoleId(roleId: number) { + return requestClient.get(`${wxEntryPrefix}get-by-role-id`, { + params: { role_id: roleId }, + }); +} + +/** 保存某角色的默认常用入口(只改 is_default_fav) */ +export async function saveWxWorkbenchDefaultFav(data: { + entry_ids: number[]; + role_id: number; +}) { + return requestClient.post(`${wxEntryPrefix}save-default-fav`, data); +} diff --git a/apps/web-antd/src/views/system/system-config/components/ai-model-config-panel.vue b/apps/web-antd/src/views/system/system-config/components/ai-model-config-panel.vue index 2e481c2b..2dd88846 100644 --- a/apps/web-antd/src/views/system/system-config/components/ai-model-config-panel.vue +++ b/apps/web-antd/src/views/system/system-config/components/ai-model-config-panel.vue @@ -7,15 +7,28 @@ */ import { computed, onMounted, ref, watch } from 'vue'; -import { Button, Checkbox, Input, InputNumber, Select, Spin, Switch, Tabs, Tag, message } from 'ant-design-vue'; import { CheckCircleFilled, KeyOutlined } from '@ant-design/icons-vue'; +import { + Button, + Checkbox, + Input, + InputNumber, + message, + Spin, + Switch, + Tabs, + Tag, +} from 'ant-design-vue'; import { getAiFeatureCopyList, getAiPlatformConfigOptions, saveAiFeatureCopy, } from '#/views/system/ai/platform/api'; -import { getSystemConfigList, saveSystemConfig } from '#/views/system/system-config/api'; +import { + getSystemConfigList, + saveSystemConfig, +} from '#/views/system/system-config/api'; /** 二级 Tab 持久化 */ const SUB_TAB_STORAGE_KEY = 'system_config_ai_sub_tab'; @@ -29,21 +42,21 @@ interface PlatformCard { default_model: string; api_url: string; models: { - id: number; - label: string; - value: string; code: string; - name: string; description: string; + id: number; is_default: number; + label: string; + name: string; + value: string; }[]; keys: { id?: number; - key_name?: string; - name?: string; - label?: string; - value: number; is_default?: number; + key_name?: string; + label?: string; + name?: string; + value: number; }[]; } @@ -83,6 +96,10 @@ const aiAgentSecret = ref(''); const aiAgentTesting = ref(false); /** 一次 AI 出方生成几份方案(1–5,默认 1;>1 需开启 Agent 中转并由 Go 并行) */ const aiPrescriptionGenerateCount = ref(1); +/** 病历场景是否让 Go Agent 检索知识库(独立于「是否中转」) */ +const aiAgentKbEnabledMedicalRecord = ref(false); +/** 处方场景是否让 Go Agent 检索知识库 */ +const aiAgentKbEnabledPrescription = ref(false); // 分组 1:ReAct 多轮循环 const aiReactEnabled = ref(false); const aiReactMaxIterations = ref(3); @@ -111,11 +128,11 @@ const modelOptions = computed(() => currentPlatform.value?.models || []); /** 密钥展示名:key_name → name → label → 兜底 */ function keyDisplayName(k: { - key_name?: string; - name?: string; - label?: string; - value: number; id?: number; + key_name?: string; + label?: string; + name?: string; + value: number; }): string { const candidates = [k.key_name, k.name, k.label]; for (const c of candidates) { @@ -135,8 +152,12 @@ async function load() { const data = optRes?.data || optRes || {}; platforms.value = Array.isArray(data.platforms) ? data.platforms : []; const active = data.active || {}; - activeProvider.value = String(active.provider || platforms.value[0]?.code || ''); - activeApiKeyId.value = active.api_key_id ? Number(active.api_key_id) : undefined; + activeProvider.value = String( + active.provider || platforms.value[0]?.code || '', + ); + activeApiKeyId.value = active.api_key_id + ? Number(active.api_key_id) + : undefined; activeModel.value = String(active.model || ''); syncSelectionForPlatform(); const mr = data.medical_record_fields || {}; @@ -147,17 +168,19 @@ async function load() { })) : []; mrSelectedFields.value = Array.isArray(mr.selected) - ? mr.selected.map((c: any) => String(c)) + ? mr.selected.map(String) : mrFieldOptions.value.map((o) => o.value); const copies = copyRes?.data || copyRes || []; - featureCopies.value = (Array.isArray(copies) ? copies : []).map((r: any) => ({ - id: Number(r.id || 0), - feature_code: String(r.feature_code || ''), - feature_name: String(r.feature_name || r.feature_code || ''), - record_label: String(r.record_label || ''), - disclaimer_text: String(r.disclaimer_text || ''), - consent_text: String(r.consent_text || ''), - })); + featureCopies.value = (Array.isArray(copies) ? copies : []).map( + (r: any) => ({ + id: Number(r.id || 0), + feature_code: String(r.feature_code || ''), + feature_name: String(r.feature_name || r.feature_code || ''), + record_label: String(r.record_label || ''), + disclaimer_text: String(r.disclaimer_text || ''), + consent_text: String(r.consent_text || ''), + }), + ); } finally { loading.value = false; } @@ -166,7 +189,10 @@ async function load() { /** 切换平台后:密钥/模型回落到该平台默认或首个 */ function syncSelectionForPlatform() { const keys = keyOptions.value; - if (activeApiKeyId.value && !keys.some((k) => k.value === activeApiKeyId.value)) { + if ( + activeApiKeyId.value && + !keys.some((k) => k.value === activeApiKeyId.value) + ) { const def = keys.find((k) => Number(k.is_default) === 1); activeApiKeyId.value = def?.value ?? keys[0]?.value; } else if (!activeApiKeyId.value && keys[0]) { @@ -177,11 +203,17 @@ function syncSelectionForPlatform() { if (activeModel.value && !models.some((m) => m.value === activeModel.value)) { const def = models.find((m) => Number(m.is_default) === 1); activeModel.value = - def?.value || currentPlatform.value?.default_model || models[0]?.value || ''; + def?.value || + currentPlatform.value?.default_model || + models[0]?.value || + ''; } else if (!activeModel.value) { const def = models.find((m) => Number(m.is_default) === 1); activeModel.value = - def?.value || currentPlatform.value?.default_model || models[0]?.value || ''; + def?.value || + currentPlatform.value?.default_model || + models[0]?.value || + ''; } } @@ -189,7 +221,7 @@ watch(activeProvider, () => { syncSelectionForPlatform(); }); -function onSubTabChange(key: string | number) { +function onSubTabChange(key: number | string) { subTab.value = String(key); localStorage.setItem(SUB_TAB_STORAGE_KEY, String(key)); } @@ -208,9 +240,6 @@ function clearMrFields() { function parseBoolAdv(v: unknown): boolean { return v === '1' || v === 1 || v === 'true' || v === true; } -function numStr(v: number): string { - return String(v); -} function boolStr(v: boolean): string { return v ? '1' : '0'; } @@ -220,8 +249,8 @@ function boolStr(v: boolean): string { * * 与上方 load() 走 getAiPlatformConfigOptions 不同: * - 上方模型配置是 PHP AiRuntimeConfigService 单独提供的结构化接口 - * - 这里走通用 key-value 接口,读 xk_system_config 表里的 16 个 ai_* key - * (ai_active_provider / ai_agent_* / ai_react_* / ai_token_* / ai_shadow_* / ai_ab_test_*) + * - 这里走通用 key-value 接口,读 xk_system_config 表里的 ai_* key + * (ai_agent_* / ai_agent_kb_enabled_* / ai_react_* / ai_token_* / ai_shadow_* / ai_ab_test_*) * * 不在 load() 里直接合并是为了避免 getAiPlatformConfigOptions 失败时牵连这组配置加载 */ @@ -231,19 +260,37 @@ async function loadAdvancedCfg() { const rows = Array.isArray(list) ? list : list?.data || []; for (const row of rows) { switch (row.config_key) { - case 'ai_agent_via_agent': { - // 是否走 Go Agent 中转:1/true/yes 视为开 - aiAgentViaAgent.value = parseBoolAdv(row.config_value); + case 'ai_ab_test_enabled': { + aiAbTestEnabled.value = parseBoolAdv(row.config_value); + break; + } + case 'ai_ab_test_experiment_ratio': { + aiAbTestExperimentRatio.value = Number(row.config_value) || 10; break; } case 'ai_agent_base_url': { - aiAgentBaseUrl.value = String(row.config_value || 'http://127.0.0.1:18123'); + aiAgentBaseUrl.value = String( + row.config_value || 'http://127.0.0.1:18123', + ); + break; + } + case 'ai_agent_kb_enabled_medical_record': { + aiAgentKbEnabledMedicalRecord.value = parseBoolAdv(row.config_value); + break; + } + case 'ai_agent_kb_enabled_prescription': { + aiAgentKbEnabledPrescription.value = parseBoolAdv(row.config_value); break; } case 'ai_agent_secret': { aiAgentSecret.value = String(row.config_value || ''); break; } + case 'ai_agent_via_agent': { + // 是否走 Go Agent 中转:1/true/yes 视为开 + aiAgentViaAgent.value = parseBoolAdv(row.config_value); + break; + } case 'ai_prescription_generate_count': { // 钳制 1–5,非法回落默认 1 const n = Number(row.config_value); @@ -255,6 +302,10 @@ async function loadAdvancedCfg() { aiReactEnabled.value = parseBoolAdv(row.config_value); break; } + case 'ai_react_json_repair_enabled': { + aiReactJsonRepairEnabled.value = parseBoolAdv(row.config_value); + break; + } case 'ai_react_max_iterations': { aiReactMaxIterations.value = Number(row.config_value) || 3; break; @@ -271,8 +322,12 @@ async function loadAdvancedCfg() { aiReactReflectionTemperature.value = Number(row.config_value) || 0.2; break; } - case 'ai_react_json_repair_enabled': { - aiReactJsonRepairEnabled.value = parseBoolAdv(row.config_value); + case 'ai_shadow_traffic_enabled': { + aiShadowTrafficEnabled.value = parseBoolAdv(row.config_value); + break; + } + case 'ai_shadow_traffic_ratio': { + aiShadowTrafficRatio.value = Number(row.config_value) || 0; break; } case 'ai_token_budget_enabled': { @@ -287,34 +342,18 @@ async function loadAdvancedCfg() { aiTokenMaxPerCall.value = Number(row.config_value) || 2048; break; } - case 'ai_shadow_traffic_enabled': { - aiShadowTrafficEnabled.value = parseBoolAdv(row.config_value); - break; - } - case 'ai_shadow_traffic_ratio': { - aiShadowTrafficRatio.value = Number(row.config_value) || 0; - break; - } - case 'ai_ab_test_enabled': { - aiAbTestEnabled.value = parseBoolAdv(row.config_value); - break; - } - case 'ai_ab_test_experiment_ratio': { - aiAbTestExperimentRatio.value = Number(row.config_value) || 10; - break; - } default: { break; } } } - } catch (e) { + } catch (error) { // 高级配置加载失败不阻塞主流程,仅控制台告警 - console.warn('[AiModelConfigPanel] loadAdvancedCfg failed:', e); + console.warn('[AiModelConfigPanel] loadAdvancedCfg failed:', error); } } -/** 保存 Agent 高级配置(16 个 key 一次性 POST) */ +/** 保存 Agent 高级配置(接入 / 知识库 / ReAct / Token / 流量实验)一次性 POST */ async function handleSaveAdvanced() { advSaving.value = true; try { @@ -322,31 +361,94 @@ async function handleSaveAdvanced() { // 分组 0:Agent 接入 // 关键:用 ai_agent_via_agent 独立开关,不再写 ai_active_provider(避免污染模型 Tab 的真实 provider) // 这样后台"模型配置 Tab"选什么 provider/key/model,Go Agent 中转就用什么,互不影响 - { config_key: 'ai_agent_via_agent', config_value: boolStr(aiAgentViaAgent.value) }, + { + config_key: 'ai_agent_via_agent', + config_value: boolStr(aiAgentViaAgent.value), + }, { config_key: 'ai_agent_base_url', config_value: aiAgentBaseUrl.value }, { config_key: 'ai_agent_secret', config_value: aiAgentSecret.value }, { config_key: 'ai_prescription_generate_count', - config_value: numStr( - Math.min(5, Math.max(1, Math.floor(Number(aiPrescriptionGenerateCount.value) || 1))), + config_value: String( + Math.min( + 5, + Math.max( + 1, + Math.floor(Number(aiPrescriptionGenerateCount.value) || 1), + ), + ), ), }, + // 分组 0.5:知识库检索(与「是否中转」独立;必须 value_type=bool,否则 PHP (bool)"0" 会当成 true) + { + config_key: 'ai_agent_kb_enabled_medical_record', + config_value: boolStr(aiAgentKbEnabledMedicalRecord.value), + value_type: 'bool', + config_group: 'ai', + description: '病历场景是否启用 Go Agent 知识库检索', + }, + { + config_key: 'ai_agent_kb_enabled_prescription', + config_value: boolStr(aiAgentKbEnabledPrescription.value), + value_type: 'bool', + config_group: 'ai', + description: '处方场景是否启用 Go Agent 知识库检索', + }, // 分组 1:ReAct - { config_key: 'ai_react_enabled', config_value: boolStr(aiReactEnabled.value) }, - { config_key: 'ai_react_max_iterations', config_value: numStr(aiReactMaxIterations.value) }, - { config_key: 'ai_react_planning_enabled', config_value: boolStr(aiReactPlanningEnabled.value) }, - { config_key: 'ai_react_reflection_enabled', config_value: boolStr(aiReactReflectionEnabled.value) }, - { config_key: 'ai_react_reflection_temperature', config_value: numStr(aiReactReflectionTemperature.value) }, - { config_key: 'ai_react_json_repair_enabled', config_value: boolStr(aiReactJsonRepairEnabled.value) }, + { + config_key: 'ai_react_enabled', + config_value: boolStr(aiReactEnabled.value), + }, + { + config_key: 'ai_react_max_iterations', + config_value: String(aiReactMaxIterations.value), + }, + { + config_key: 'ai_react_planning_enabled', + config_value: boolStr(aiReactPlanningEnabled.value), + }, + { + config_key: 'ai_react_reflection_enabled', + config_value: boolStr(aiReactReflectionEnabled.value), + }, + { + config_key: 'ai_react_reflection_temperature', + config_value: String(aiReactReflectionTemperature.value), + }, + { + config_key: 'ai_react_json_repair_enabled', + config_value: boolStr(aiReactJsonRepairEnabled.value), + }, // 分组 2:Token 预算 - { config_key: 'ai_token_budget_enabled', config_value: boolStr(aiTokenBudgetEnabled.value) }, - { config_key: 'ai_token_budget_per_request', config_value: numStr(aiTokenBudgetPerRequest.value) }, - { config_key: 'ai_token_max_per_call', config_value: numStr(aiTokenMaxPerCall.value) }, + { + config_key: 'ai_token_budget_enabled', + config_value: boolStr(aiTokenBudgetEnabled.value), + }, + { + config_key: 'ai_token_budget_per_request', + config_value: String(aiTokenBudgetPerRequest.value), + }, + { + config_key: 'ai_token_max_per_call', + config_value: String(aiTokenMaxPerCall.value), + }, // 分组 3:流量实验 - { config_key: 'ai_shadow_traffic_enabled', config_value: boolStr(aiShadowTrafficEnabled.value) }, - { config_key: 'ai_shadow_traffic_ratio', config_value: numStr(aiShadowTrafficRatio.value) }, - { config_key: 'ai_ab_test_enabled', config_value: boolStr(aiAbTestEnabled.value) }, - { config_key: 'ai_ab_test_experiment_ratio', config_value: numStr(aiAbTestExperimentRatio.value) }, + { + config_key: 'ai_shadow_traffic_enabled', + config_value: boolStr(aiShadowTrafficEnabled.value), + }, + { + config_key: 'ai_shadow_traffic_ratio', + config_value: String(aiShadowTrafficRatio.value), + }, + { + config_key: 'ai_ab_test_enabled', + config_value: boolStr(aiAbTestEnabled.value), + }, + { + config_key: 'ai_ab_test_experiment_ratio', + config_value: String(aiAbTestExperimentRatio.value), + }, ]); message.success('Agent 高级配置已保存'); } finally { @@ -379,8 +481,10 @@ async function testAgentConnection() { } else { message.error(`Agent 响应异常:HTTP ${resp.status}`); } - } catch (e) { - message.error(`连接失败:${e instanceof Error ? e.message : String(e)}`); + } catch (error) { + message.error( + `连接失败:${error instanceof Error ? error.message : String(error)}`, + ); } finally { aiAgentTesting.value = false; } @@ -388,7 +492,7 @@ async function testAgentConnection() { /** 保存 AI 写病历输出字段勾选 */ async function handleSaveMrFields() { - if (!mrSelectedFields.value.length) { + if (mrSelectedFields.value.length === 0) { message.warning('请至少勾选一个病历字段'); return; } @@ -469,7 +573,7 @@ async function handleSave() { /** 保存 VIP 关联的 AI 醒目提示 / 知情同意文案 */ async function handleSaveCopies() { - if (!featureCopies.value.length) { + if (featureCopies.value.length === 0) { message.warning('暂无文案可保存,请先执行 SQL 初始化'); return; } @@ -493,7 +597,12 @@ async function handleSaveCopies() { onMounted(() => { const stored = localStorage.getItem(SUB_TAB_STORAGE_KEY); // 新增 advanced 二级 Tab 后,缓存值合法性扩展 - if (stored === 'model' || stored === 'copy' || stored === 'mr' || stored === 'advanced') { + if ( + stored === 'model' || + stored === 'copy' || + stored === 'mr' || + stored === 'advanced' + ) { subTab.value = stored; } load(); @@ -510,403 +619,612 @@ onMounted(() => {
-
选用 AI 平台
+
+ 选用 AI 平台 +
- 平台 / 模型 / 密钥请在「AI平台管理」维护;此处点选当前运行时组合。 + 平台 / 模型 / + 密钥请在「AI平台管理」维护;此处点选当前运行时组合。
-
-
- -
- -
-
- {{ p.name }} -
-
- {{ p.code }} · {{ p.default_model || '—' }} -
-
- {{ p.description || p.api_url }} -
-
-
-
-
- -
API Key
-
-
- -
-
- -
-
-
- - {{ keyDisplayName(k) }} - - 默认 -
-
- ID {{ k.value }} -
-
-
-
-
-
- 当前平台暂无启用密钥,请先到「AI平台管理」添加 -
- -
模型
-
-
- -
-
- - {{ m.name || m.label || m.code }} - - 默认 -
-
- {{ m.code }} -
+
- {{ m.description }} + +
+ +
+
+ {{ p.name }} +
+
+ {{ p.code }} · {{ p.default_model || '—' }} +
+
+ {{ p.description || p.api_url }} +
+
+
-
-
-
- 当前平台暂无启用模型,请先到「AI平台管理」添加 -
-
- - -
-
- 勾选后,AI 写病历只会返回这些字段(主诉仍由医生填写,不在此配置)。未勾选的字段不会生成。 -
-
- - - - 已选 {{ mrSelectedFields.length }} / {{ mrFieldOptions.length }} - -
- - - {{ opt.label }} - - -
-
+
+ API Key +
+
+
+ +
+
+ +
+
+
+ + {{ keyDisplayName(k) }} + + 默认 +
+
+ ID {{ k.value }} +
+
+
+
+
+
+ 当前平台暂无启用密钥,请先到「AI平台管理」添加 +
- -
-
- 按 VIP 功能码维护醒目提示与首次知情同意全文;医生签署后仅该功能无需再次确认。 -
-
- 暂无文案数据,请执行 sql/20260806/xk_ai_disclaimer_consent.sql -
-
-
- - {{ row.feature_name || row.feature_code }} - - - {{ row.feature_code }} - +
+ 模型 +
+
+
+ +
+
+ + {{ m.name || m.label || m.code }} + + 默认 +
+
+ {{ m.code }} +
+
+ {{ m.description }} +
+
+
+
+
+ 当前平台暂无启用模型,请先到「AI平台管理」添加 +
-
- 记录称谓(用于「本{称谓}由 AI…」) -
- -
- 醒目提示(生成记录 / 模态框顶部) -
- -
- 首次知情同意全文 -
- -
-
-
+ - - -
- -
-
-
-
Agent 接入配置
-
- 独立开关:开启后所有 AI 调用走 Go Agent 中转(含本地知识库检索 / ReAct 多轮),模型/密钥/平台始终由上方「模型配置」Tab 决定 -
+ +
+
+ 勾选后,AI + 写病历只会返回这些字段(主诉仍由医生填写,不在此配置)。未勾选的字段不会生成。
- -
-
-
-
- Agent 服务地址 - 必填 -
- -
- 仅当上方开关开启时生效;与 Go 端 config.yaml 的 server.port 对应 -
-
-
-
- 当前生效的模型组合 - {{ activeProvider || '未选择' }} -
-
- 模型/密钥/平台由「模型配置」Tab 决定,Go Agent 中转会原样使用, - 切换模型请前往模型配置 Tab -
-
-
-
-
- Agent 鉴权密钥 - - (与 Go 端 config.Agent.SharedSecret 对应;留空则不发 Authorization) +
+ + + + 已选 {{ mrSelectedFields.length }} / + {{ mrFieldOptions.length }}
- + + + {{ opt.label }} + +
-
- - - 仅校验服务可达(GET /health),不验证密钥;密钥错误会在实际业务调用时报 401 - -
-
-
一次生成处方份数
- -
- 默认 1;设为 2–5 时由 Go Agent 并行生成多套方案供医生对比(需开启上方中转;单路失败不影响其它成功方案) -
-
-
+
- -
-
-
-
ReAct 多轮推理循环
-
- 开启后单次任务会走 Think-Act-Observe 多轮(含 Planning / Reflection / JSON 修复),质量更好但延迟与 token 成本更高 + +
+
+ 按 VIP + 功能码维护醒目提示与首次知情同意全文;医生签署后仅该功能无需再次确认。 +
+
+ 暂无文案数据,请执行 sql/20260806/xk_ai_disclaimer_consent.sql +
+
+
+ + {{ row.feature_name || row.feature_code }} + + + {{ row.feature_code }} +
-
- -
-
-
-
单任务最大轮数
- -
建议 2-5,过大会显著拉高延迟和成本
-
-
-
反思步骤温度
- + 记录称谓(用于「本{称谓}由 AI…」) +
+ +
+ 醒目提示(生成记录 / 模态框顶部) +
+ +
+ 首次知情同意全文 +
+ -
低于生成温度(0.3),保证审核结果稳定
-
-
- Planning 规划阶段 - -
-
- Reflection 反思自检 - -
-
- JSON 自动修复 - -
-
-
+ - -
-
-
-
Token 预算管理
-
- 开启后单次任务累计 token 超限即中止,防 Agent 死循环烧钱(双层控制:单任务总预算 + 单次调用上限) + + +
+ +
+
+
+
+ Agent 接入配置 +
+
+ 独立开关:开启后所有 AI 调用走 Go Agent 中转(ReAct + 多轮由下方单独控制;知识库检索也是下方单独开关),模型/密钥/平台始终由上方「模型配置」Tab + 决定 +
+
+ +
+
+
+
+ Agent 服务地址 + + 必填 + +
+ +
+ 仅当上方开关开启时生效;与 Go 端 config.yaml 的 + server.port 对应 +
+
+
+
+ 当前生效的模型组合 + + {{ activeProvider || '未选择' }} + +
+
+ 模型/密钥/平台由「模型配置」Tab 决定,Go Agent + 中转会原样使用, 切换模型请前往模型配置 Tab +
+
+
+
+
+ Agent 鉴权密钥 + + (与 Go 端 config.Agent.SharedSecret 对应;留空则不发 + Authorization) + +
+ +
+
+ + + 仅校验服务可达(GET + /health),不验证密钥;密钥错误会在实际业务调用时报 401 + +
+
+
+ 一次生成处方份数 +
+ +
+ 默认 1;设为 2–5 时由 Go Agent + 并行生成多套方案供医生对比(需开启上方中转;单路失败不影响其它成功方案) +
- -
-
-
-
单任务总 token 上限
- -
prompt + completion 累加;8000 约等于 1-2 万汉字
-
-
-
单次 LLM 调用输出上限
- -
透传给厂商的 max_tokens 字段,防止单次响应爆炸
-
-
-
- -
-
流量实验
-
-
-
影子流量(Shadow Traffic)
-
- 开启后按比例异步复制请求到 Go Agent,结果不返回用户只做对比;运维用对比报表决定是否放量 + +
+
+
+ 知识库检索 +
+
+ 与上方「Agent 中转」独立:中转只决定走 Go,本开关才决定 Go + 是否检索本地知识库 / + MaxKB。需先开启中转才生效;生成记录时间轴会出现「检索知识库」步骤 +
+
+
+
+ 病历场景 + +
+
+ 处方场景 + +
- -
-
-
影子流量百分比(0-100)
- -
0=关闭;建议先用 10% 跑 1 周再考虑放量
-
-
-
-
A/B Test(实验组直接返回 Agent 结果)
-
- 开启后按比例把请求 provider 切换为 agent,实验组用户直接看到 Agent 输出 + + +
+
+
+
+ ReAct 多轮推理循环 +
+
+ 开启后单次任务会走 Think-Act-Observe 多轮(含 Planning / + Reflection / JSON 修复),质量更好但延迟与 token + 成本更高 +
+
+ +
+
+
+
+ 单任务最大轮数 +
+ +
+ 建议 2-5,过大会显著拉高延迟和成本 +
+
+
+
+ 反思步骤温度 +
+ +
+ 低于生成温度(0.3),保证审核结果稳定 +
+
+
+
+
+ Planning 规划阶段 + +
+
+ Reflection 反思自检 + +
+
+ JSON 自动修复 + +
+
+
+ + +
+
+
+
+ Token 预算管理 +
+
+ 开启后单次任务累计 token 超限即中止,防 Agent + 死循环烧钱(双层控制:单任务总预算 + 单次调用上限) +
+
+ +
+
+
+
+ 单任务总 token 上限 +
+ +
+ prompt + completion 累加;8000 约等于 1-2 万汉字 +
+
+
+
+ 单次 LLM 调用输出上限 +
+ +
+ 透传给厂商的 max_tokens 字段,防止单次响应爆炸 +
+
+
+
+ + +
+
+ 流量实验 +
+
+
+
+ 影子流量(Shadow Traffic) +
+
+ 开启后按比例异步复制请求到 Go + Agent,结果不返回用户只做对比;运维用对比报表决定是否放量 +
+
+ +
+
+
+ 影子流量百分比(0-100) +
+ +
+ 0=关闭;建议先用 10% 跑 1 周再考虑放量 +
+
+
+
+
+ A/B Test(实验组直接返回 Agent 结果) +
+
+ 开启后按比例把请求 provider 切换为 + agent,实验组用户直接看到 Agent 输出 +
+
+ +
+
+
+ 实验组流量百分比(0-100) +
+ +
+ 10 表示 10% 用户走 Agent 实验组 +
-
-
-
实验组流量百分比(0-100)
- -
10 表示 10% 用户走 Agent 实验组
-
-
-
- +
@@ -951,34 +1269,37 @@ onMounted(() => { diff --git a/apps/web-antd/src/views/system/system-config/components/home-display-config-panel.vue b/apps/web-antd/src/views/system/system-config/components/home-display-config-panel.vue new file mode 100644 index 00000000..8ebb1128 --- /dev/null +++ b/apps/web-antd/src/views/system/system-config/components/home-display-config-panel.vue @@ -0,0 +1,100 @@ + + + diff --git a/apps/web-antd/src/views/system/system-config/components/miniprogram-config-panel.vue b/apps/web-antd/src/views/system/system-config/components/miniprogram-config-panel.vue index afbc77c4..3014caeb 100644 --- a/apps/web-antd/src/views/system/system-config/components/miniprogram-config-panel.vue +++ b/apps/web-antd/src/views/system/system-config/components/miniprogram-config-panel.vue @@ -1,6 +1,6 @@ + + + + diff --git a/apps/web-antd/src/views/system/system-config/components/workbench-config-panel.vue b/apps/web-antd/src/views/system/system-config/components/workbench-config-panel.vue new file mode 100644 index 00000000..06340d35 --- /dev/null +++ b/apps/web-antd/src/views/system/system-config/components/workbench-config-panel.vue @@ -0,0 +1,86 @@ + + + diff --git a/apps/web-antd/src/views/system/system-config/components/wx-workbench-default-fav-panel.vue b/apps/web-antd/src/views/system/system-config/components/wx-workbench-default-fav-panel.vue new file mode 100644 index 00000000..38824feb --- /dev/null +++ b/apps/web-antd/src/views/system/system-config/components/wx-workbench-default-fav-panel.vue @@ -0,0 +1,218 @@ + + + + + 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 c316d05c..88fddd65 100644 --- a/apps/web-antd/src/views/system/system-config/index.vue +++ b/apps/web-antd/src/views/system/system-config/index.vue @@ -1,9 +1,13 @@