diff --git a/apps/web-antd/src/components/form/components/search-time-range-picker.vue b/apps/web-antd/src/components/form/components/search-time-range-picker.vue new file mode 100644 index 00000000..68881a28 --- /dev/null +++ b/apps/web-antd/src/components/form/components/search-time-range-picker.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/apps/web-antd/src/components/form/types/index.d.ts b/apps/web-antd/src/components/form/types/index.d.ts index d0126d3a..8c010fa9 100644 --- a/apps/web-antd/src/components/form/types/index.d.ts +++ b/apps/web-antd/src/components/form/types/index.d.ts @@ -5,5 +5,6 @@ export type CustomComponentType = | 'ApiSelect' | 'ApiTreeSelect' | 'IconPicker' + | 'SearchTimeRangePicker' | 'StoreMultiSearch' | 'WarehouseAdminDrugSearch'; diff --git a/apps/web-antd/src/components/public-account-lock/LockOrdersPanel.vue b/apps/web-antd/src/components/public-account-lock/LockOrdersPanel.vue index fd85fc92..f18e2ee4 100644 --- a/apps/web-antd/src/components/public-account-lock/LockOrdersPanel.vue +++ b/apps/web-antd/src/components/public-account-lock/LockOrdersPanel.vue @@ -1,12 +1,13 @@ + + + + diff --git a/apps/web-antd/src/components/store-card/StoreNameLink.vue b/apps/web-antd/src/components/store-card/StoreNameLink.vue new file mode 100644 index 00000000..8e981570 --- /dev/null +++ b/apps/web-antd/src/components/store-card/StoreNameLink.vue @@ -0,0 +1,55 @@ + + + + + diff --git a/apps/web-antd/src/components/table-action/card-actions.vue b/apps/web-antd/src/components/table-action/card-actions.vue index f7193758..28fb6102 100644 --- a/apps/web-antd/src/components/table-action/card-actions.vue +++ b/apps/web-antd/src/components/table-action/card-actions.vue @@ -2,33 +2,24 @@ /** * 卡片视图专用操作栏(与列表交互解耦) * - * 列表单元格里 TableAction 平铺全部按钮没问题,但卡片 footer 空间小, - * 按钮超过 2 个会触发 TableAction 的两列 grid 换行堆叠,显得杂乱。 - * 这里改成 C 端惯例:只露出前 maxVisible 个主操作(默认 1 个,通常是「详情」), - * 其余操作全部合并进「···」下拉菜单。 - * - * 权限(auth)/ 显隐(ifShow)/ 二次确认(popConfirm)逻辑不重写: - * - 挑主操作前先按与 TableAction 相同的规则过滤,保证露出的一定是可见按钮; - * - 渲染仍复用 TableAction(flex=false 关闭多列 grid),菜单项行为完全一致。 + * 列表单元格里 TableAction 平铺全部按钮没问题,但卡片 footer 空间小。 + * 只露出前 maxVisible 个主操作(默认「详情」),其余收进「更多」气泡卡片(Popover)。 */ import type { ActionItem } from './types'; -import { computed } from 'vue'; +import { computed, ref } from 'vue'; import { useAccess } from '@vben/access'; import { isBoolean, isFunction } from '@vben/utils'; -import { Button } from 'ant-design-vue'; +import { Button, Popconfirm, Popover, Space } from 'ant-design-vue'; import { Icon } from '#/components/icon'; -import TableAction from './table-action.vue'; - const props = withDefaults( defineProps<{ actions?: ActionItem[]; dropDownActions?: ActionItem[]; - /** 露出的主操作数量,其余进「···」菜单 */ maxVisible?: number; }>(), { @@ -39,6 +30,7 @@ const props = withDefaults( ); const { hasAccessByCodes } = useAccess(); +const moreOpen = ref(false); /** 与 TableAction 同规则判断单个操作是否可见(auth + ifShow) */ function isActionVisible(action: ActionItem): boolean { @@ -51,38 +43,190 @@ function isActionVisible(action: ActionItem): boolean { return true; } -/** 过滤后的可见操作:前 maxVisible 个露出,其余与原下拉操作合并进「···」 */ +function mapAction(action: ActionItem) { + const { popConfirm } = action; + return { + ...action, + ...popConfirm, + onConfirm: popConfirm?.confirm, + onCancel: popConfirm?.cancel, + enable: !!popConfirm, + }; +} + +/** 过滤后的可见操作:前 maxVisible 个露出,其余合并进「更多」 */ const splitActions = computed(() => { const visible = (props.actions || []).filter((action) => isActionVisible(action), ); + const rest = [ + ...visible.slice(props.maxVisible), + ...(props.dropDownActions || []).filter((action) => isActionVisible(action)), + ].map(mapAction); return { - primary: visible.slice(0, props.maxVisible), - // 下拉里的项 TableAction 会再过滤一遍 auth/ifShow,这里直接透传原始项即可 - rest: [...visible.slice(props.maxVisible), ...(props.dropDownActions || [])], + primary: visible.slice(0, props.maxVisible).map(mapAction), + rest, }; }); + +function getButtonProps(action: ActionItem) { + const res = { + type: action.type || 'link', + size: action.size || 'small', + ...action, + }; + delete (res as any).icon; + delete (res as any).popConfirm; + delete (res as any).onClick; + return res; +} + +function runAction(action: ReturnType) { + if (action.disabled) return; + if (!action.enable && action.onClick) { + action.onClick(); + moreOpen.value = false; + } +} + +function getPopConfirmProps(attrs: Record) { + const originAttrs: any = { ...attrs }; + delete originAttrs.icon; + if (attrs.confirm && isFunction(attrs.confirm)) { + originAttrs.onConfirm = attrs.confirm; + delete originAttrs.confirm; + } + if (attrs.cancel && isFunction(attrs.cancel)) { + originAttrs.onCancel = attrs.cancel; + delete originAttrs.cancel; + } + return originAttrs; +} diff --git a/apps/web-antd/src/composables/use-fresh-search-time.ts b/apps/web-antd/src/composables/use-fresh-search-time.ts new file mode 100644 index 00000000..76d12a73 --- /dev/null +++ b/apps/web-antd/src/composables/use-fresh-search-time.ts @@ -0,0 +1,119 @@ +import type { Ref } from 'vue'; + +import { onActivated, onMounted, ref } from 'vue'; + +import { isSameRange } from '#/utils/search-time-range'; + +type FormApiLike = { + getValues: () => Promise>; + setValues: (values: Record) => Promise; + reset?: () => Promise; +}; + +export type UseFreshSearchTimeOptions = { + formApi: FormApiLike; + searchValues?: Ref>; + fieldName?: string; + /** 返回最新默认区间(字符串或 Dayjs 均可) */ + getDefaultRange: () => unknown; + /** 写入表单并刷新列表/卡片 */ + onRefresh?: (range: unknown) => void | Promise; +}; + +/** + * 搜索时间「本月至今」跨天自动滚到今天 + * - 未手动改时间:onMounted / onActivated 刷新默认区间并触发 onRefresh + * - 用户手选或点快捷预设后:标记 touched,回页不覆盖 + * - resetSearchTime:清 touched + 写最新默认 + onRefresh + */ +export function useFreshSearchTime(options: UseFreshSearchTimeOptions) { + const { + formApi, + searchValues, + fieldName = 'search_time', + getDefaultRange, + onRefresh, + } = options; + + const searchTimeTouched = ref(false); + + async function applyFreshDefaultRange() { + const range = getDefaultRange(); + await formApi.setValues({ [fieldName]: range }); + if (searchValues) { + searchValues.value = { + ...searchValues.value, + [fieldName]: range, + }; + } + return range; + } + + async function refreshIfUntouched() { + if (searchTimeTouched.value) return; + const range = getDefaultRange(); + if (range == null) return; + const applied = await applyFreshDefaultRange(); + await onRefresh?.(applied); + } + + function markSearchTimeTouched() { + searchTimeTouched.value = true; + } + + /** + * 监听表单字段变化:与默认区间不同则视为用户改过 + */ + async function syncTouchedFromForm() { + try { + const values = await formApi.getValues(); + const current = values?.[fieldName]; + const defaults = getDefaultRange(); + if (current == null || current === '') { + return; + } + if (!isSameRange(current, defaults)) { + searchTimeTouched.value = true; + } + } catch { + // 表单未就绪时忽略 + } + } + + async function resetSearchTime() { + searchTimeTouched.value = false; + if (formApi.reset) { + await formApi.reset(); + } + const range = getDefaultRange(); + if (range != null) { + await applyFreshDefaultRange(); + } else if (searchValues) { + try { + const values = await formApi.getValues(); + searchValues.value = { ...(values || {}) }; + } catch { + // ignore + } + } + await onRefresh?.(range); + return range; + } + + onMounted(() => { + void refreshIfUntouched(); + }); + + onActivated(() => { + void refreshIfUntouched(); + }); + + return { + searchTimeTouched, + applyFreshDefaultRange, + refreshIfUntouched, + markSearchTimeTouched, + syncTouchedFromForm, + resetSearchTime, + }; +} diff --git a/apps/web-antd/src/utils/search-time-range.ts b/apps/web-antd/src/utils/search-time-range.ts new file mode 100644 index 00000000..ac55828f --- /dev/null +++ b/apps/web-antd/src/utils/search-time-range.ts @@ -0,0 +1,169 @@ +import type { Dayjs } from 'dayjs'; + +import dayjs from 'dayjs'; + +export type SearchTimePreset = + | 'today' + | 'thisWeek' + | 'thisMonth' + | 'prevMonth' + | 'nextMonth'; + +const YMD = 'YYYY-MM-DD'; + +/** 今天日期(Dayjs) */ +export function todayDayjs(): Dayjs { + return dayjs().startOf('day'); +} + +/** 本周一(国内习惯:周一为周起点) */ +export function startOfWeekMonday(base: Dayjs = todayDayjs()): Dayjs { + const d = base.startOf('day'); + const day = d.day(); + const diff = day === 0 ? 6 : day - 1; + return d.subtract(diff, 'day'); +} + +/** 本月1日 ~ 今天(字符串,供 RangePicker valueFormat) */ +export function monthToTodayRangeString(): [string, string] { + const today = todayDayjs(); + return [today.startOf('month').format(YMD), today.format(YMD)]; +} + +/** 本月1日 ~ 今天(Dayjs) */ +export function monthToTodayRangeDayjs(): [Dayjs, Dayjs] { + const today = todayDayjs(); + return [today.startOf('month'), today]; +} + +/** 近 N 天(含今天),默认 7 天 */ +export function lastNDaysRangeString(days = 7): [string, string] { + const today = todayDayjs(); + const start = today.subtract(Math.max(days - 1, 0), 'day'); + return [start.format(YMD), today.format(YMD)]; +} + +/** 近 N 天(Dayjs) */ +export function lastNDaysRangeDayjs(days = 7): [Dayjs, Dayjs] { + const today = todayDayjs(); + const start = today.subtract(Math.max(days - 1, 0), 'day'); + return [start, today]; +} + +/** 将 Dayjs / 字符串 / 时间戳统一为 YYYY-MM-DD */ +export function normalizeRangeToYmd( + value: unknown, +): [string, string] | null { + if (!value || !Array.isArray(value) || value.length < 2) { + return null; + } + const a = pickYmd(value[0]); + const b = pickYmd(value[1]); + if (!a || !b) return null; + return [a, b]; +} + +function pickYmd(v: unknown): string { + if (v == null) return ''; + if ( + typeof v === 'object' && + v !== null && + 'format' in v && + typeof (v as { format: (s: string) => string }).format === 'function' + ) { + return (v as Dayjs).format(YMD); + } + const d = dayjs(v as string | number); + return d.isValid() ? d.format(YMD) : ''; +} + +/** 比较两段范围是否相同(按 YYYY-MM-DD) */ +export function isSameRange( + a: unknown, + b: unknown, +): boolean { + const na = normalizeRangeToYmd(a); + const nb = normalizeRangeToYmd(b); + if (!na || !nb) return false; + return na[0] === nb[0] && na[1] === nb[1]; +} + +/** 翻月锚点:优先取当前范围开始日,否则今天 */ +export function getMonthAnchorDayjs(current: unknown): Dayjs { + const normalized = normalizeRangeToYmd(current); + if (normalized?.[0]) { + const d = dayjs(normalized[0], YMD); + if (d.isValid()) return d.startOf('day'); + } + return todayDayjs(); +} + +/** 自然月整月,结束日不超过今天 */ +function fullMonthRangeCapped( + month: Dayjs, +): [Dayjs, Dayjs] { + const start = month.startOf('month'); + const end = month.endOf('month').startOf('day'); + const today = todayDayjs(); + return [start, end.isAfter(today) ? today : end]; +} + +/** 按预设计算区间(Dayjs) */ +export function resolveSearchTimePreset( + preset: SearchTimePreset, + current: unknown, +): [Dayjs, Dayjs] { + const today = todayDayjs(); + switch (preset) { + case 'today': + return [today, today]; + case 'thisWeek': + return [startOfWeekMonday(today), today]; + case 'thisMonth': + return [today.startOf('month'), today]; + case 'prevMonth': { + const anchor = getMonthAnchorDayjs(current); + return fullMonthRangeCapped(anchor.subtract(1, 'month')); + } + case 'nextMonth': { + const anchor = getMonthAnchorDayjs(current); + return fullMonthRangeCapped(anchor.add(1, 'month')); + } + default: + return monthToTodayRangeDayjs(); + } +} + +/** 按预设计算区间(字符串) */ +export function resolveSearchTimePresetString( + preset: SearchTimePreset, + current: unknown, +): [string, string] { + const [a, b] = resolveSearchTimePreset(preset, current); + return [a.format(YMD), b.format(YMD)]; +} + +/** Dayjs 区间转字符串 */ +export function rangeDayjsToString( + range: [Dayjs, Dayjs], +): [string, string] { + return [range[0].format(YMD), range[1].format(YMD)]; +} + +/** 字符串区间转 Dayjs */ +export function rangeStringToDayjs( + range: [string, string], +): [Dayjs, Dayjs] { + return [dayjs(range[0], YMD), dayjs(range[1], YMD)]; +} + +/** 将区间按 valueFormat 输出 */ +export function formatRangeByValueFormat( + range: [Dayjs, Dayjs], + valueFormat?: string, +): [string, string] | [Dayjs, Dayjs] { + if (valueFormat === YMD) { + return rangeDayjsToString(range); + } + return range; +} diff --git a/apps/web-antd/src/views/business/order/components/SalespersonCommissionPanel.vue b/apps/web-antd/src/views/business/order/components/SalespersonCommissionPanel.vue new file mode 100644 index 00000000..36841964 --- /dev/null +++ b/apps/web-antd/src/views/business/order/components/SalespersonCommissionPanel.vue @@ -0,0 +1,236 @@ + + + + + diff --git a/apps/web-antd/src/views/business/order/prescription/config/search.ts b/apps/web-antd/src/views/business/order/prescription/config/search.ts index fb571765..20092b09 100644 --- a/apps/web-antd/src/views/business/order/prescription/config/search.ts +++ b/apps/web-antd/src/views/business/order/prescription/config/search.ts @@ -1,7 +1,7 @@ import type { VbenFormProps } from '#/adapter/form'; -import dayjs from 'dayjs'; import { getOrderPrescriptionTypeOption } from '#/views/business/order/product-order/api'; +import { monthToTodayRangeString } from '#/utils/search-time-range'; import { PRESCRIPTION_IS_ONLINE_OPTIONS } from './constants'; @@ -21,16 +21,12 @@ export const formOptions: VbenFormProps = { schema: [ { // 放第一位:时间范围有默认值(本月至今),默认收起时必须始终可见,否则用户不知道当前查的是哪段数据 - component: 'RangePicker', + component: 'SearchTimeRangePicker', componentProps: { format: 'YYYY-MM-DD', - // 提交纯日期,后端 getWhereBetween 会把结束日扩到当天 23:59:59 valueFormat: 'YYYY-MM-DD', }, - defaultValue: [ - dayjs().startOf('month').format('YYYY-MM-DD'), - dayjs().format('YYYY-MM-DD'), - ], + defaultValue: monthToTodayRangeString(), fieldName: 'search_time', label: '时间范围', }, diff --git a/apps/web-antd/src/views/business/order/prescription/config/table.ts b/apps/web-antd/src/views/business/order/prescription/config/table.ts index a9a2df25..84edd663 100644 --- a/apps/web-antd/src/views/business/order/prescription/config/table.ts +++ b/apps/web-antd/src/views/business/order/prescription/config/table.ts @@ -27,7 +27,7 @@ export const gridOptions: VxeGridProps = { { field: 'prescription_no', align: 'left', title: '处方订单号' }, { field: 'doctor_info.name', align: 'left', title: '开方医生' }, { field: 'user_patient.name', align: 'left', title: '就诊人名称' }, - { field: 'store.name', align: 'left', title: '开方诊所', slots: { default: 'store-name' } }, + { field: 'store.name', align: 'left', title: '开方诊所', minWidth: 140, slots: { default: 'store-name' } }, { field: 'status', title: '状态', slots: { default: 'status' } }, { field: 'is_online', diff --git a/apps/web-antd/src/views/business/order/prescription/index.vue b/apps/web-antd/src/views/business/order/prescription/index.vue index 1ad0318c..8e8e9e1e 100644 --- a/apps/web-antd/src/views/business/order/prescription/index.vue +++ b/apps/web-antd/src/views/business/order/prescription/index.vue @@ -17,9 +17,12 @@ import { Button, Tabs, Tag } from 'ant-design-vue'; import { useVbenForm } from '#/adapter/form'; import { useVbenVxeGrid } from '#/adapter/vxe-table'; +import { useFreshSearchTime } from '#/composables/use-fresh-search-time'; import { TableAction } from '#/components/table-action'; +import { monthToTodayRangeString } from '#/utils/search-time-range'; import { useViewMode, ViewModeSwitch } from '#/components/view-mode-switch'; import StoreCardModal from '#/components/store-card/StoreCardModal.vue'; +import StoreNameLink from '#/components/store-card/StoreNameLink.vue'; import PrescriptionDetail from '#/views/doctor/doctor-reception/components/PrescriptionDetail.vue'; import { getPrescriptionListApi } from './api'; @@ -60,7 +63,11 @@ function collectDefaultSearchValues(): Record { const values: Record = {}; for (const item of formOptions.schema ?? []) { if (item.fieldName && item.defaultValue !== undefined) { - values[item.fieldName] = item.defaultValue; + if (item.fieldName === 'search_time') { + values[item.fieldName] = monthToTodayRangeString(); + } else { + values[item.fieldName] = item.defaultValue; + } } } return values; @@ -69,6 +76,11 @@ function collectDefaultSearchValues(): Record { /** 列表与卡片共用的搜索条件(不含 status Tab,Tab 单独合并) */ const searchValues = ref>(collectDefaultSearchValues()); +const freshSearchTimeBridge = { + resetSearchTime: async () => {}, + markSearchTimeTouched: () => {}, +}; + /** * 读搜索表单现值(不要只用 handleSubmit 快照) * 抽出独立 SearchForm 后,列表 query 必须自己取表单值,否则点查询/翻页会丢掉刚填的条件 @@ -95,6 +107,11 @@ const cardFormValues = computed(() => ({ */ const [SearchForm, searchFormApi] = useVbenForm({ ...formOptions, + handleValuesChange(_values, fieldsChanged) { + if (fieldsChanged?.includes('search_time')) { + freshSearchTimeBridge.markSearchTimeTouched(); + } + }, handleSubmit: async () => { searchValues.value = await readLiveSearchValues(); if (viewMode.value === 'list') { @@ -102,12 +119,7 @@ const [SearchForm, searchFormApi] = useVbenForm({ } }, handleReset: async () => { - // 自定义 handleReset 会接管默认行为,必须手动还原控件到 defaultValue(reset 为新 API,resetForm 已弃用) - await searchFormApi.reset(); - searchValues.value = collectDefaultSearchValues(); - if (viewMode.value === 'list') { - gridApi.reload(); - } + await freshSearchTimeBridge.resetSearchTime(); }, }); @@ -144,6 +156,20 @@ const [Grid, gridApi] = useVbenVxeGrid({ gridEvents, }); +const freshSearchTime = useFreshSearchTime({ + formApi: searchFormApi, + searchValues, + getDefaultRange: () => monthToTodayRangeString(), + onRefresh: async () => { + searchValues.value = await readLiveSearchValues(); + if (viewMode.value === 'list') { + gridApi.reload(); + } + }, +}); +freshSearchTimeBridge.resetSearchTime = freshSearchTime.resetSearchTime; +freshSearchTimeBridge.markSearchTimeTouched = freshSearchTime.markSearchTimeTouched; + // Tab 切换:列表模式重查(卡片模式由 cardFormValues computed 触发 CardList watch) watch(statusTab, () => { if (viewMode.value === 'list') { @@ -223,16 +249,11 @@ function rowIsOnlineMeta(row: Record) {