diff --git a/.cursor/rules/Code-Standards.mdc b/.cursor/rules/Code-Standards.mdc index ced6a183..28434c60 100644 --- a/.cursor/rules/Code-Standards.mdc +++ b/.cursor/rules/Code-Standards.mdc @@ -44,6 +44,26 @@ alwaysApply: true - HTTP 请求统一用 `requestClient`(来自 `#/api/request`),**禁止直接 axios / fetch** - 行操作和工具栏按钮统一用 `` 组件(来自 `#/components/table-action`) - 业务选择器(员工、医生、门店、推广员、地区等)必须复用 `#/components/form/components/*-picker.vue`,不要重写 +- **卡片统一用液态玻璃组件**:新增/改动的卡片一律用 `GlassCard`(`#/components/glass-card`),禁止再手写一套 `backdrop-filter` 玻璃样式或新起 antd `Card`;历史页面暂不强制回改 +- 列表页需要「列表/卡片」双视图时:`useViewMode` + `ViewModeSwitch`(`#/components/view-mode-switch`)切换,卡片模式用 `GlassCardList`(分页拉数)+ `#card` 插槽里放 `GlassCard`;搜索表单抽到页面顶部两种视图共用(参考 `business/order/product-order/index.vue`) +- **GlassCard 底部操作按钮(强制)**:统一用 `CardActions`(`#/components/table-action`),默认即玻璃胶囊(`variant` 默认 `glass`:半透明底 + blur + 1px 同色系边框微光;主操作 primary、危险 destructive);布局默认 `layout="fill"` 等分沾满 footer,需要居中时显式 `layout="center"`;尺寸用 `size`:`xs` 超级小 / `sm` 小 / `md` 中(默认) / `lg` 大 / `half` 沾满一半 / `full` 沾满整行。放在 `GlassCard` 的 `#footer` 插槽里,**禁止**再在卡片里用 antd `Button` / `TableAction` 的 link 文字链或灰框 default 按钮;禁止包一层 `flex justify-end` 把按钮挤到一角。示例: + + ```vue + + + + + ``` ## API 层规范 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index e1f3ed36..7cd20076 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@vben/desktop", - "version": "0.1.1", + "version": "0.1.3", "private": true, "description": "萧康云医 PC 桌面端(Electron 壳,打包加载 web-antd 生产构建产物)", "author": "萧康云医", diff --git a/apps/web-antd/src/api/core/file-gallery.ts b/apps/web-antd/src/api/core/file-gallery.ts index 480f6850..52cb757c 100644 --- a/apps/web-antd/src/api/core/file-gallery.ts +++ b/apps/web-antd/src/api/core/file-gallery.ts @@ -30,9 +30,26 @@ export interface FileTypeItem { sort: number; } +/** 扫描写入的引用位置(详情展示,含表/字段注释) */ +export interface FileGalleryRefLoc { + connection: string; + connection_text: string; + table: string; + /** 表 COMMENT,可能为空 */ + table_comment?: string; + column: string; + /** 字段 COMMENT,可能为空 */ + column_comment?: string; + count: number; + label: string; +} + export interface FileGalleryDetail extends FileGalleryItem { source_text?: string; updated_at?: string; + ref_scanned_at?: number | string; + /** 引用位置列表,未扫描或未命中为空数组 */ + ref_locs?: FileGalleryRefLoc[]; } export interface FileGalleryStats { @@ -43,6 +60,8 @@ export interface FileGalleryStats { recyclable_size: number; recyclable_size_text: string; has_scanned: boolean; + /** 上次 OSS 同步完成日期 YYYYMMDD,空表示尚未同步 */ + oss_last_sync_date?: string; } export interface FileGalleryListParams { @@ -53,6 +72,9 @@ export interface FileGalleryListParams { group_id?: number; keyword?: string; exclude_qrcode?: number; + /** all|referenced|unreferenced */ + ref_filter?: string; + /** @deprecated 用 ref_filter=unreferenced */ unref_only?: number; folder_id?: number; unfiled?: number; @@ -97,31 +119,36 @@ export async function getFileTypes() { return requestClient.get(`${prefix}file-types`); } -/** 规划 OSS 同步日期分组(full 全部 / incremental 从库内最新日期起) */ -export async function planFileGalleryOssSync(data?: { mode?: 'full' | 'incremental' }) { +/** 规划 OSS 同步日期分组(full 全部 / incremental 从上次同步日期起) */ +export async function planFileGalleryOssSync(data?: { + mode?: 'full' | 'incremental'; +}) { return requestClient.post<{ + last_sync_date: string; mode: 'full' | 'incremental'; prefixes: string[]; - total: number; since_date: string; + total: number; }>(`${prefix}sync-oss-plan`, data ?? {}); } -/** 从 OSS 同步一批(按日期 prefix + marker;全部组结束后 finalize=1) */ +/** 从 OSS 同步一批(按日期 prefix + marker;全部组结束后 finalize=1 并落库 last_sync_date) */ export async function syncFileGalleryFromOss(data?: { + finalize?: number; + last_sync_date?: string; limit?: number; marker?: string; prefix?: string; - finalize?: number; }) { return requestClient.post<{ + done: boolean; + folder_created: number; + last_sync_date?: string; + next_marker: string; + prefix: string; scanned: number; skipped: number; synced: number; - next_marker: string; - done: boolean; - folder_created: number; - prefix: string; }>(`${prefix}sync-oss`, data ?? {}); } @@ -157,18 +184,57 @@ export async function markFileQrcode(id: number, is_qrcode: number) { ); } -/** 扫描业务表 URL 引用(两库全表文本列) */ -export async function scanFileGalleryRefs() { +/** 扫描计划中的单表:pending 未开始 / scanning 扫描中 / done 完成 */ +export interface FileRefScanTableItem { + connection: string; + table: string; + /** 兼容字段,等同 total_columns */ + columns: number; + total_columns?: number; + done_columns?: number; + failed: number; + status?: 'done' | 'pending' | 'scanning'; +} + +/** 预览将扫描的表清单(全部 pending,不开始扫) */ +export async function planFileGalleryScanRefs() { + return requestClient.get<{ + column_total: number; + plan_tables: FileRefScanTableItem[]; + table_count: number; + }>(`${prefix}scan-refs-plan`); +} + +/** 分批扫描业务表 URL 引用;循环传 job_id 直到 done,支持断点续扫 */ +export async function scanFileGalleryRefs(data?: { + batch_size?: number; + job_id?: string; +}) { return requestClient.post<{ - elapsed_ms: number; + batch_size: number; + batch_tables?: FileRefScanTableItem[]; + current_table?: string; + done: boolean; + done_table_count?: number; + elapsed_ms?: number; + job_id: string; mysql_columns: number; mysql_failed: number; old_mysql_columns: number; old_mysql_failed: number; - referenced: number; - scanned_files: number; - unreferenced: number; - }>(`${prefix}scan-refs`); + pending_table_count?: number; + phase: string; + plan_tables?: FileRefScanTableItem[]; + processed: number; + progress: number; + referenced?: number; + scanned_files?: number; + scanned_tables?: FileRefScanTableItem[]; + scanning_table_count?: number; + table_count?: number; + total: number; + unreferenced?: number; + }>(`${prefix}scan-refs`, data ?? {}); } /** 批量加入分组(移动到文件夹) */ diff --git a/apps/web-antd/src/components/address-paste-block/AddressPasteBlock.vue b/apps/web-antd/src/components/address-paste-block/AddressPasteBlock.vue new file mode 100644 index 00000000..ecbc8762 --- /dev/null +++ b/apps/web-antd/src/components/address-paste-block/AddressPasteBlock.vue @@ -0,0 +1,516 @@ + + + + + diff --git a/apps/web-antd/src/components/form/components/api-log-route-picker.vue b/apps/web-antd/src/components/form/components/api-log-route-picker.vue new file mode 100644 index 00000000..3729c454 --- /dev/null +++ b/apps/web-antd/src/components/form/components/api-log-route-picker.vue @@ -0,0 +1,347 @@ + + + + + + + diff --git a/apps/web-antd/src/components/form/components/api-log-user-picker.vue b/apps/web-antd/src/components/form/components/api-log-user-picker.vue index 1dab28d8..17266ac7 100644 --- a/apps/web-antd/src/components/form/components/api-log-user-picker.vue +++ b/apps/web-antd/src/components/form/components/api-log-user-picker.vue @@ -1,8 +1,13 @@ + + + + diff --git a/apps/web-antd/src/components/glass-card/GlassCardList.vue b/apps/web-antd/src/components/glass-card/GlassCardList.vue new file mode 100644 index 00000000..5759388b --- /dev/null +++ b/apps/web-antd/src/components/glass-card/GlassCardList.vue @@ -0,0 +1,188 @@ + + + + + diff --git a/apps/web-antd/src/components/glass-card/index.ts b/apps/web-antd/src/components/glass-card/index.ts new file mode 100644 index 00000000..510fe494 --- /dev/null +++ b/apps/web-antd/src/components/glass-card/index.ts @@ -0,0 +1,7 @@ +/** + * 液态玻璃卡片组件出口 + * - GlassCard:卡片外壳(新增/改动的卡片统一使用) + * - GlassCardList:卡片视图列表容器(列表/卡片双视图的 card 模式) + */ +export { default as GlassCard } from './GlassCard.vue'; +export { default as GlassCardList } from './GlassCardList.vue'; diff --git a/apps/web-antd/src/components/glass-tag/GlassTag.vue b/apps/web-antd/src/components/glass-tag/GlassTag.vue new file mode 100644 index 00000000..2b1e3e22 --- /dev/null +++ b/apps/web-antd/src/components/glass-tag/GlassTag.vue @@ -0,0 +1,342 @@ + + + + + diff --git a/apps/web-antd/src/components/glass-tag/index.ts b/apps/web-antd/src/components/glass-tag/index.ts new file mode 100644 index 00000000..010be95e --- /dev/null +++ b/apps/web-antd/src/components/glass-tag/index.ts @@ -0,0 +1,5 @@ +/** + * 液态玻璃标签组件出口 + * - GlassTag:紧凑芯片(对照药名、状态标签等) + */ +export { default as GlassTag } from './GlassTag.vue'; diff --git a/apps/web-antd/src/components/store-card/StoreCardModal.vue b/apps/web-antd/src/components/store-card/StoreCardModal.vue index 76c4f3f8..73b882b1 100644 --- a/apps/web-antd/src/components/store-card/StoreCardModal.vue +++ b/apps/web-antd/src/components/store-card/StoreCardModal.vue @@ -5,12 +5,11 @@ */ import type { Dayjs } from 'dayjs'; -import { computed, ref } from 'vue'; +import { computed, onBeforeUnmount, ref } from 'vue'; import { useVbenModal } from '@vben/common-ui'; import { useUserStore } from '@vben/stores'; -import { monthToTodayRangeDayjs } from '#/utils/search-time-range'; import { Avatar, Button, @@ -18,16 +17,23 @@ import { Descriptions, Empty, Image, + message, Select, Spin, Table, Tabs, Tag, - message, } from 'ant-design-vue'; +import StoreFinancePanel from '#/components/store-card/StoreFinancePanel.vue'; +import { monthToTodayRangeDayjs } from '#/utils/search-time-range'; import DoctorCardModal from '#/views/doctor/doctor/components/DoctorCardModal.vue'; +import { + canAddStoreDoctor, + isPlatformSuperAdmin, +} from '#/views/system/admin/_shared/platform-admin-role'; import { getStoreBankCardReportDetail } from '#/views/system/store-bank-card/api'; +import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser'; import { getStoreCardApi, getStoreCardStatsApi, @@ -35,20 +41,32 @@ import { openQrCodeApi, } from '#/views/system/store/api'; import { getSalespersonList } from '#/views/system/store/api/salesperson'; -import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser'; import AddDoctorFromStoreModal from '#/views/system/store/components/AddDoctorFromStoreModal.vue'; -import { canAddStoreDoctor, isPlatformSuperAdmin } from '#/views/system/admin/_shared/platform-admin-role'; -import StoreFinancePanel from '#/components/store-card/StoreFinancePanel.vue'; + +import { + popupToBody, + STORE_CARD_BODY_CLASS, + STORE_CARD_OVERLAY_Z_INDEX, +} from './overlay-z-index'; const RangePicker = DatePicker.RangePicker; +/** 打开期间给 body 打标,抬高挂到 body 的预览/下拉层级 */ +function setStoreCardBodyOpen(open: boolean) { + document.body.classList.toggle(STORE_CARD_BODY_CLASS, open); +} + const userStore = useUserStore(); // 是否允许「添加医生」:超管/管理员/省经理/市经理/业务员 const canAddDoctor = computed(() => canAddStoreDoctor(userStore.userInfo)); // 是否可查看门店资金账户:仅超管/系统管理员 -const canViewStoreFinance = computed(() => isPlatformSuperAdmin(userStore.userInfo)); +const canViewStoreFinance = computed(() => + isPlatformSuperAdmin(userStore.userInfo), +); -const financePanelRef = ref | null>(null); +const financePanelRef = ref | null>( + null, +); const storeId = ref(0); const loading = ref(false); @@ -92,8 +110,18 @@ const drugTypeOptions = [ ]; const drugColumns = [ - { title: '药品名称', dataIndex: 'drug_name', key: 'drug_name', ellipsis: true }, - { title: '规格', dataIndex: 'specification', key: 'specification', width: 140 }, + { + title: '药品名称', + dataIndex: 'drug_name', + key: 'drug_name', + ellipsis: true, + }, + { + title: '规格', + dataIndex: 'specification', + key: 'specification', + width: 140, + }, { title: '售价', dataIndex: 'price', key: 'price', width: 100 }, { title: '进价', dataIndex: 'buy_price', key: 'buy_price', width: 100 }, ]; @@ -115,6 +143,8 @@ const [Modal, modalApi] = useVbenModal({ modalApi.close(); }, onOpenChange(isOpen: boolean) { + // 预览/下拉挂在 body 上,默认 z-index 低于 Vben 弹窗 2000,打开时抬高 + setStoreCardBodyOpen(isOpen); if (!isOpen) { financePanelRef.value?.reset?.(); return; @@ -133,6 +163,10 @@ const [Modal, modalApi] = useVbenModal({ }, }); +onBeforeUnmount(() => { + setStoreCardBodyOpen(false); +}); + const [DoctorModal, DoctorModalApi] = useVbenModal({ connectedComponent: DoctorCardModal, }); @@ -176,8 +210,8 @@ async function loadCard() { if (isPharmacy.value) { drugType.value = 2; } - } catch (e: any) { - message.error(e?.message || '加载门店详情失败'); + } catch (error: any) { + message.error(error?.message || '加载门店详情失败'); } finally { loading.value = false; } @@ -198,8 +232,8 @@ async function refreshStats() { search_time: res.search_time, }; } - } catch (e: any) { - message.error(e?.message || '刷新统计失败'); + } catch (error: any) { + message.error(error?.message || '刷新统计失败'); } } @@ -256,7 +290,7 @@ async function loadPromoters() { } } -function onTabChange(key: string | number) { +function onTabChange(key: number | string) { activeTab.value = String(key); if (key === 'bank') loadBank(); if (key === 'drugs') loadDrugs(); @@ -275,8 +309,8 @@ async function ensureQrCode() { await openQrCodeApi(storeId.value); await loadCard(); message.success('二维码已生成'); - } catch (e: any) { - message.error(e?.message || '生成二维码失败'); + } catch (error: any) { + message.error(error?.message || '生成二维码失败'); } finally { generatingQr.value = false; } @@ -290,14 +324,19 @@ function clinicTypeText(v: number) { @@ -549,16 +632,34 @@ function clinicTypeText(v: number) { :deep(.custom-vertical-tabs .ant-tabs-nav) { width: 140px; } + :deep(.custom-vertical-tabs .ant-tabs-tab) { padding: 12px 16px; margin-bottom: 4px; border-radius: 6px; transition: all 0.3s; } + :deep(.custom-vertical-tabs .ant-tabs-tab-active) { background-color: var( --ant-primary-color-active-deprecated-f-12, - rgba(22, 119, 255, 0.08) + rgb(22 119 255 / 8%) ); } + + + diff --git a/apps/web-antd/src/components/store-card/StoreFinancePanel.vue b/apps/web-antd/src/components/store-card/StoreFinancePanel.vue index 02e8c143..96b65fd7 100644 --- a/apps/web-antd/src/components/store-card/StoreFinancePanel.vue +++ b/apps/web-antd/src/components/store-card/StoreFinancePanel.vue @@ -1,22 +1,39 @@