diff --git a/apps/web-antd/src/api/core/auth.ts b/apps/web-antd/src/api/core/auth.ts index 53cf9ebc..845a7352 100644 --- a/apps/web-antd/src/api/core/auth.ts +++ b/apps/web-antd/src/api/core/auth.ts @@ -96,6 +96,7 @@ export async function sendVerificationCode(params: { if (params.account) { return requestClient.post('send-verification-code', { account: params.account, + password: params.password, admin_id: params.admin_id, }); } diff --git a/apps/web-antd/src/api/order/priceAdjust.ts b/apps/web-antd/src/api/order/priceAdjust.ts new file mode 100644 index 00000000..1bb9da54 --- /dev/null +++ b/apps/web-antd/src/api/order/priceAdjust.ts @@ -0,0 +1,13 @@ +import { requestClient } from '#/api/request'; + +const orderPrefix = 'order/'; + +export async function adjustOrderPercent(data: Record) { + return requestClient.post(`${orderPrefix}adjust-order-percent`, data); +} + +export async function getOrderPriceAdjustConfig(storeId?: number) { + return requestClient.get(`${orderPrefix}price-adjust-config`, { + params: storeId ? { store_id: storeId } : {}, + }); +} 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 new file mode 100644 index 00000000..35e2a87d --- /dev/null +++ b/apps/web-antd/src/components/form/components/api-log-user-picker.vue @@ -0,0 +1,346 @@ + + + + + + + + \ No newline at end of file diff --git a/apps/web-antd/src/components/form/components/express-company-select.vue b/apps/web-antd/src/components/form/components/express-company-select.vue new file mode 100644 index 00000000..5b629210 --- /dev/null +++ b/apps/web-antd/src/components/form/components/express-company-select.vue @@ -0,0 +1,232 @@ + + + + + + + diff --git a/apps/web-antd/src/components/form/components/picker-card-theme.scss b/apps/web-antd/src/components/form/components/picker-card-theme.scss new file mode 100644 index 00000000..d574629f --- /dev/null +++ b/apps/web-antd/src/components/form/components/picker-card-theme.scss @@ -0,0 +1,143 @@ +// Shared picker layout mixins — property-only dark variants, no global class selectors. + +@mixin picker-card-base { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + padding: 10px 6px; + border: 1px solid #e5e6eb; + border-radius: 8px; + background: #fff; + cursor: pointer; + text-align: center; + min-width: 0; + transition: + border-color 0.2s, + background-color 0.2s; + + &:hover, + &.active { + border-color: #165dff; + background: #f2f7ff; + } +} + +@mixin picker-card-name { + width: 100%; + font-size: 12px; + color: #1d2129; + line-height: 1.3; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@mixin picker-card-sub { + font-size: 11px; + color: #86909c; + line-height: 1.2; +} + +@mixin picker-selected-card { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 12px; + border: 1px solid #e5e6eb; + border-radius: 8px; + background: #fafafa; + cursor: pointer; + transition: + border-color 0.2s, + background-color 0.2s; + + &:hover { + border-color: #165dff; + background: #f2f7ff; + } +} + +@mixin picker-trigger-placeholder { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + min-height: 40px; + padding: 8px 12px; + border: 1px dashed #d9d9d9; + border-radius: 8px; + color: #86909c; + font-size: 14px; + cursor: pointer; + transition: + border-color 0.2s, + color 0.2s; + + &:hover { + border-color: #165dff; + color: #165dff; + } +} + +@mixin picker-clear-btn { + pointer-events: auto; + cursor: pointer; + color: #86909c; + font-size: 16px; + line-height: 1; + padding: 0 4px; + flex-shrink: 0; + + &:hover { + color: #1d2129; + } +} + +@mixin picker-card-dark-props { + background: #1f2937; + border-color: #374151; + + &:hover, + &.active { + background: #374151; + border-color: #60a5fa; + } +} + +@mixin picker-selected-card-dark-props { + background: #1f2937; + border-color: #374151; + + &:hover { + background: #374151; + border-color: #60a5fa; + } +} + +@mixin picker-text-primary-dark { + color: #f3f4f6; +} + +@mixin picker-text-secondary-dark { + color: #9ca3af; +} + +@mixin picker-placeholder-dark-props { + border-color: #4b5563; + color: #9ca3af; + + &:hover { + border-color: #60a5fa; + color: #60a5fa; + } +} + +@mixin picker-clear-dark-props { + color: #9ca3af; + + &:hover { + color: #f3f4f6; + } +} diff --git a/apps/web-antd/src/components/form/components/promoter-picker.vue b/apps/web-antd/src/components/form/components/promoter-picker.vue index ad6ebff2..dbe48a5c 100644 --- a/apps/web-antd/src/components/form/components/promoter-picker.vue +++ b/apps/web-antd/src/components/form/components/promoter-picker.vue @@ -2,9 +2,11 @@ import { computed, nextTick, onMounted, ref, watch } from 'vue'; import { useVModel } from '@vueuse/core'; -import { Avatar, Empty, Input, Popover, Spin } from 'ant-design-vue'; +import { Avatar, Empty, Input, Popover, Spin, Tag } from 'ant-design-vue'; +import PromoterInfoCard from '#/components/promoter/PromoterInfoCard.vue'; import { getPromoterOptions } from '#/views/system/store-input/api'; +import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser'; defineOptions({ name: 'PromoterPicker', @@ -27,6 +29,7 @@ type PromoterOption = { nick_name: string; avatar: string; code: string; + phone: string; role_id: number; role_name: string; }; @@ -35,7 +38,6 @@ const open = ref(false); const loading = ref(false); const searchKeyword = ref(''); const options = ref([]); -const triggerRef = ref(null); const searchInputRef = ref | null>(null); function filterPromoterOptions(keyword: string, list: PromoterOption[]) { @@ -44,8 +46,14 @@ function filterPromoterOptions(keyword: string, list: PromoterOption[]) { return list.filter((item) => { const name = (item.nick_name || '').toLowerCase(); const code = (item.code || '').toLowerCase(); + const phone = (item.phone || '').toLowerCase(); const role = (item.role_name || '').toLowerCase(); - return name.includes(q) || code.includes(q) || role.includes(q); + return ( + name.includes(q) || + code.includes(q) || + phone.includes(q) || + role.includes(q) + ); }); } @@ -57,12 +65,6 @@ const selectedOption = computed(() => options.value.find((item) => item.code === mValue.value), ); -const displayText = computed(() => { - const item = selectedOption.value; - if (!item) return ''; - return `${item.nick_name || '-'} · ${item.code} · ${item.role_name || ''}`; -}); - async function loadOptions() { loading.value = true; try { @@ -81,8 +83,7 @@ function selectOption(item: PromoterOption) { searchKeyword.value = ''; } -function clearSelection(e: Event) { - e.stopPropagation(); +function clearSelection() { mValue.value = undefined; } @@ -124,120 +125,144 @@ onMounted(() => { ref="searchInputRef" v-model:value="searchKeyword" allow-clear - placeholder="搜索业务员姓名" + placeholder="搜索业务员姓名、业务码、手机号" class="promoter-search" /> -
+
-
- + - - + :clickable="false" + @clear="clearSelection" + /> +
请选择业务员
- - diff --git a/apps/web-antd/src/components/promoter/PromoterDetailModal.vue b/apps/web-antd/src/components/promoter/PromoterDetailModal.vue new file mode 100644 index 00000000..f204d496 --- /dev/null +++ b/apps/web-antd/src/components/promoter/PromoterDetailModal.vue @@ -0,0 +1,109 @@ + + + + + diff --git a/apps/web-antd/src/components/promoter/PromoterInfoCard.vue b/apps/web-antd/src/components/promoter/PromoterInfoCard.vue new file mode 100644 index 00000000..207ed10f --- /dev/null +++ b/apps/web-antd/src/components/promoter/PromoterInfoCard.vue @@ -0,0 +1,237 @@ + + + + + diff --git a/apps/web-antd/src/composables/useOrderPriceAdjust.ts b/apps/web-antd/src/composables/useOrderPriceAdjust.ts new file mode 100644 index 00000000..1781fe4a --- /dev/null +++ b/apps/web-antd/src/composables/useOrderPriceAdjust.ts @@ -0,0 +1,71 @@ +import { ref } from 'vue'; + +import { getPriceAdjustConfig } from '#/views/system/system-config/api'; + +import type { QuickDiscountOption } from '#/utils/pricePercentAdjust'; +import { applyRatioToDrug, applyRatioToDrugs, formatPriceDiscountLabel, normalizeQuickOptions } from '#/utils/pricePercentAdjust'; + +export type PriceAdjustConfig = { + order_price_adjust_scope: 'both' | 'sale_only'; + order_discount_quick_options: QuickDiscountOption[]; + enable_order_price_percent_adjust: boolean; + store_id?: number; +}; + +const defaultConfig: PriceAdjustConfig = { + order_price_adjust_scope: 'sale_only', + order_discount_quick_options: normalizeQuickOptions([]), + enable_order_price_percent_adjust: false, +}; + +export function useOrderPriceAdjust() { + const config = ref({ ...defaultConfig }); + const loading = ref(false); + + async function loadByStoreId(storeId?: number) { + if (!storeId) { + config.value = { ...defaultConfig }; + return config.value; + } + loading.value = true; + try { + const res = await getPriceAdjustConfig(storeId); + config.value = { + order_price_adjust_scope: res?.order_price_adjust_scope === 'both' ? 'both' : 'sale_only', + order_discount_quick_options: normalizeQuickOptions(res?.order_discount_quick_options), + enable_order_price_percent_adjust: !!res?.enable_order_price_percent_adjust, + store_id: storeId, + }; + return config.value; + } finally { + loading.value = false; + } + } + + function setFromOrderStore(store?: { enable_order_price_percent_adjust?: number | boolean }) { + config.value = { + ...config.value, + enable_order_price_percent_adjust: Number(store?.enable_order_price_percent_adjust) === 1, + }; + } + + async function loadGlobalOptions() { + const res = await getPriceAdjustConfig(); + config.value = { + ...config.value, + order_price_adjust_scope: res?.order_price_adjust_scope === 'both' ? 'both' : 'sale_only', + order_discount_quick_options: normalizeQuickOptions(res?.order_discount_quick_options), + }; + } + + return { + config, + loading, + loadByStoreId, + loadGlobalOptions, + setFromOrderStore, + applyRatioToDrug, + applyRatioToDrugs, + formatPriceDiscountLabel, + }; +} diff --git a/apps/web-antd/src/utils/pricePercentAdjust.ts b/apps/web-antd/src/utils/pricePercentAdjust.ts new file mode 100644 index 00000000..44d09aa7 --- /dev/null +++ b/apps/web-antd/src/utils/pricePercentAdjust.ts @@ -0,0 +1,108 @@ +export type QuickDiscountOption = { name: string; value: number }; + +export const DEFAULT_QUICK_OPTIONS: QuickDiscountOption[] = [ + { name: '九五折', value: 95 }, + { name: '九折', value: 90 }, + { name: '八五折', value: 85 }, + { name: '八折', value: 80 }, + { name: '涨10%', value: 110 }, + { name: '涨20%', value: 120 }, +]; + +const DEFAULT_MARKUP_OPTIONS: QuickDiscountOption[] = [ + { name: '涨10%', value: 110 }, + { name: '涨20%', value: 120 }, +]; + +export function normalizeQuickOptions(raw: unknown): QuickDiscountOption[] { + if (!Array.isArray(raw)) return [...DEFAULT_QUICK_OPTIONS]; + const list: QuickDiscountOption[] = []; + for (const item of raw) { + if (item && typeof item === 'object' && 'value' in item) { + const val = Number((item as QuickDiscountOption).value); + if (val > 0) { + list.push({ + name: String((item as QuickDiscountOption).name || `${val}%`), + value: val, + }); + } + } else if (typeof item === 'number' && item > 0) { + list.push({ name: `${item}%`, value: item }); + } + } + if (!list.length) return [...DEFAULT_QUICK_OPTIONS]; + if (!list.some((o) => o.value > 100)) { + return [...list, ...DEFAULT_MARKUP_OPTIONS]; + } + return list; +} + +export function applyRatioPrice(originPrice: number, discount: number): number { + if (!originPrice || originPrice <= 0) return 0; + const ratio = discount > 0 ? discount : 100; + return Math.round(originPrice * (ratio / 100) * 10000) / 10000; +} + +export function inferDiscountKind(discount: number): 'none' | 'discount' | 'markup' { + const d = Number(discount) || 100; + if (d === 100) return 'none'; + return d < 100 ? 'discount' : 'markup'; +} + +export function formatPriceDiscountLabel(discount: number): string | null { + const d = Number(discount) || 100; + if (d <= 0 || d === 100) return null; + if (d < 100) { + if (d % 10 === 0 && d >= 10) return `${d / 10}折`; + return `折扣 ${d}%`; + } + return `涨 ${d - 100}%`; +} + +export function resolvePriceDiscount(data?: { + priceDiscount?: number | string; + price_discount?: number | string; +}): number { + const raw = data?.priceDiscount ?? data?.price_discount; + const n = Number(raw); + return n > 0 ? n : 100; +} + +export function applyRatioToDrug( + drug: T, + discount: number, + scope: 'both' | 'sale_only', +): T { + const originPrice = Number(drug.origin_price ?? drug.price ?? 0); + const originBuy = Number(drug.origin_buy_price ?? drug.buy_price ?? 0); + const next: T = { + ...drug, + origin_price: drug.origin_price ?? originPrice, + origin_buy_price: drug.origin_buy_price ?? originBuy, + price: applyRatioPrice(originPrice, discount), + }; + if (scope === 'both' && originBuy > 0) { + next.buy_price = applyRatioPrice(originBuy, discount); + } + return next; +} + +export function applyRatioToDrugs( + drugs: T[], + discount: number, + scope: 'both' | 'sale_only', +): T[] { + return drugs.map((d) => applyRatioToDrug(d, discount, scope)); +} + +/** @deprecated */ +export type AdjustType = 'markup' | 'discount'; + +/** @deprecated use applyRatioPrice */ +export function applyPercentPrice(oldPrice: number, adjustType: AdjustType, percent: number): number { + if (!oldPrice || oldPrice <= 0) return 0; + if (adjustType === 'markup') { + return applyRatioPrice(oldPrice, Math.round(100 + percent)); + } + return applyRatioPrice(oldPrice, percent); +} diff --git a/apps/web-antd/src/views/business/order/components/OrderPricePercentAdjustDrawer.vue b/apps/web-antd/src/views/business/order/components/OrderPricePercentAdjustDrawer.vue new file mode 100644 index 00000000..e7e904f6 --- /dev/null +++ b/apps/web-antd/src/views/business/order/components/OrderPricePercentAdjustDrawer.vue @@ -0,0 +1,173 @@ + + + diff --git a/apps/web-antd/src/views/business/order/product-order/components/detail.vue b/apps/web-antd/src/views/business/order/product-order/components/detail.vue index fd2343d7..77232787 100644 --- a/apps/web-antd/src/views/business/order/product-order/components/detail.vue +++ b/apps/web-antd/src/views/business/order/product-order/components/detail.vue @@ -1,5 +1,5 @@ diff --git a/apps/web-antd/src/views/system/store/components/cells/StoreConfigTogglesCell.vue b/apps/web-antd/src/views/system/store/components/cells/StoreConfigTogglesCell.vue new file mode 100644 index 00000000..f2d108d3 --- /dev/null +++ b/apps/web-antd/src/views/system/store/components/cells/StoreConfigTogglesCell.vue @@ -0,0 +1,244 @@ + + + + + diff --git a/apps/web-antd/src/views/system/store/config/form.ts b/apps/web-antd/src/views/system/store/config/form.ts index 0feb7e3d..587364f9 100644 --- a/apps/web-antd/src/views/system/store/config/form.ts +++ b/apps/web-antd/src/views/system/store/config/form.ts @@ -114,6 +114,19 @@ export const modalFormProps: VbenFormProps = { label: '是否包邮', rules: 'required', }, + { + component: 'RadioGroup', + formItemClass: 'col-span-6', + componentProps: { + options: [ + { label: '关闭', value: 0 }, + { label: '开启', value: 1 }, + ], + }, + fieldName: 'enable_order_price_percent_adjust', + label: '订单打折/涨幅', + defaultValue: 0, + }, { // 诊所类型(单选) component: 'RadioGroup', @@ -167,13 +180,10 @@ export const modalFormProps: VbenFormProps = { rules: 'required', }, { - component: 'VbenInput', + component: 'PromoterPicker', formItemClass: 'col-span-6', - componentProps: { - placeholder: '请输入业务码', - }, fieldName: 'code', - label: '业务码', + label: '业务员', rules: 'required', }, { diff --git a/apps/web-antd/src/views/system/store/config/table.ts b/apps/web-antd/src/views/system/store/config/table.ts index 96a8495b..e5af637c 100644 --- a/apps/web-antd/src/views/system/store/config/table.ts +++ b/apps/web-antd/src/views/system/store/config/table.ts @@ -23,14 +23,12 @@ export const gridOptions: VxeGridProps = { }, columns: [ { type: 'checkbox', width: 60 }, - { field: 'id', align: 'left', title: 'ID', width: 100 }, - { field: 'name', align: 'left', title: '诊所名称', width: 140 }, { - field: 'clinic_type', + field: 'store_basic_info', align: 'left', - title: '诊所类型', - width: 120, - slots: { default: 'clinic_type' }, + title: '基础信息', + width: 280, + slots: { default: 'store_basic_info' }, }, { field: 'online_consultation_config', @@ -40,47 +38,18 @@ export const gridOptions: VxeGridProps = { slots: { default: 'online_consultation_config' }, }, { - field: 'is_shipping_free', + field: 'store_config_1', align: 'left', - title: '是否包邮', - slots: { default: 'is_shipping_free' }, - width: 120, + title: '开关配置', + width: 330, + slots: { default: 'store_config_1' }, }, { - field: 'bank_card_report_status', + field: 'store_config_2', align: 'left', - title: '银行卡报备', - slots: { default: 'bank_card_report_status' }, - width: 120, - }, - { - // 订阅价格波动列(显示为开关) - field: 'subscribe_price_change', - align: 'left', - title: '订阅价格', - slots: { default: 'subscribe_price_change' }, - width: 120, - }, - { - field: 'see_rate', - align: 'left', - title: '查看毛利率', - slots: { default: 'see_rate' }, - width: 120, - }, - { - field: 'allow_insurance_category', - align: 'left', - title: '医保可选', - slots: { default: 'allow_insurance_category' }, - width: 110, - }, - { - field: 'salesperson_see_price', - align: 'left', - title: '推广员可见价格', - slots: { default: 'salesperson_see_price' }, - width: 130, + title: '业务配置', + width: 330, + slots: { default: 'store_config_2' }, }, { field: 'qr_code', @@ -89,24 +58,8 @@ export const gridOptions: VxeGridProps = { slots: { default: 'qr_code' }, width: 130, }, - { - field: 'erp_id', - align: 'left', - title: 'ERP ID', - width: 140, - slots: { default: 'erp_id_cell' }, - }, - // { - // field: 'mes_id', - // align: 'left', - // title: 'MES ID', - // width: 140, - // slots: { default: 'mes_id_cell' }, - // }, - // { field: 'shouzimu', title: '诊所首字母' }, { field: 'position', title: '详细地址', width: 140 }, - { field: 'new_admin.nick_name', title: '业务员', slots: { default: 'sales_admin' }, width: 140 }, - { field: 'store_admin.phone', title: '管理员手机', slots: { default: 'store_admin_phone' }, width: 140 }, + { field: 'new_admin.nick_name', title: '业务员', slots: { default: 'sales_admin' }, width: 160 }, { field: 'mobile', title: '联系人、电话', slots: { default: 'mobile' }, width: 140 }, { field: 'start_time', @@ -127,13 +80,11 @@ export const gridOptions: VxeGridProps = { pagerConfig: {}, proxyConfig: { ajax: { - // 请求后端接口方法 - // 注意:会自动添加type=0参数,只查询诊所类型的数据 query: async ({ page }, formValues) => { return await getStoreList({ page: page.currentPage, pageSize: page.pageSize, - type: 0, // 固定为诊所类型 + type: 0, ...formValues, }); }, @@ -142,19 +93,16 @@ export const gridOptions: VxeGridProps = { height: 'auto', border: false, toolbarConfig: { - // 是否显示搜索表单控制按钮 // @ts-ignore 正式环境时有完整的类型声明 search: true, - refresh: true, // 刷新 - print: false, // 打印 - export: false, // 导出 - // custom: true, // 自定义列 - zoom: true, // 最大化最小化 + refresh: true, + print: false, + export: false, + zoom: true, slots: { buttons: 'toolbar-buttons', }, custom: { - // 自定义列-图标 icon: 'vxe-icon-menu', }, }, diff --git a/apps/web-antd/src/views/system/store/index.vue b/apps/web-antd/src/views/system/store/index.vue index 80165894..b9096e46 100644 --- a/apps/web-antd/src/views/system/store/index.vue +++ b/apps/web-antd/src/views/system/store/index.vue @@ -8,11 +8,12 @@ import { Page, useVbenModal } from '@vben/common-ui'; import { useUserStore } from '@vben/stores'; import { useClipboard } from '@vueuse/core'; -import { Button, Image, message, Modal, Switch, Tag } from 'ant-design-vue'; +import { Button, Image, message, Modal, Tag } from 'ant-design-vue'; import { useVbenVxeGrid } from '#/adapter/vxe-table'; import { Icon } from '#/components/icon'; import QrCodePreview from '#/components/modal/QrCodePreview.vue'; +import PromoterInfoCard from '#/components/promoter/PromoterInfoCard.vue'; import { TableAction } from '#/components/table-action'; import { @@ -26,8 +27,11 @@ import { updateStoreShippingFree, updateStoreSeeRateStatus, updateStoreSubscribeStatus, + updateStoreOrderPricePercentAdjust, } from './api'; import FormModalDemo from './components/modal.vue'; +import StoreBasicInfoCell from './components/cells/StoreBasicInfoCell.vue'; +import StoreConfigTogglesCell from './components/cells/StoreConfigTogglesCell.vue'; import DrugPriceModal from './components/DrugPriceModal.vue'; import BindConsultationModal from './components/BindConsultationModal.vue'; import StoreExternalFieldModal from './components/StoreExternalFieldModal.vue'; @@ -296,6 +300,13 @@ const updateSalespersonSeePrice = (storeId: number) => { }); }; +const updateOrderPricePercentAdjust = (id: number) => { + updateStoreOrderPricePercentAdjust({ id }).then(() => { + message.success('修改成功!'); + gridApi.query(); + }); +}; + /** * 批量同步总仓库药品 */ @@ -408,14 +419,48 @@ const handleSwitchClinicType = (row: any) => { -