feat:
Some checks failed
CI / Test (ubuntu-latest) (push) Has been cancelled
CI / Test (windows-latest) (push) Has been cancelled
CI / Lint (ubuntu-latest) (push) Has been cancelled
CI / Lint (windows-latest) (push) Has been cancelled
CI / Check (ubuntu-latest) (push) Has been cancelled
CI / Check (windows-latest) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Website on push / Deploy Push Playground Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Docs Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Antd Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Element Ftp (push) Has been cancelled
Deploy Website on push / Deploy Push Naive Ftp (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
CI / CI OK (push) Has been cancelled
Lock Threads / action (push) Has been cancelled
Issue Close Require / close-issues (push) Has been cancelled
Close stale issues / stale (push) Has been cancelled

1. 创建就诊人功能增强
2. 优化交互细节
This commit is contained in:
李琦
2026-08-10 15:33:08 +08:00
parent 871982fc07
commit b1f223554b
22 changed files with 1442 additions and 143 deletions

View File

@@ -1,30 +1,349 @@
<script lang="ts" setup>
/**
* 顶栏 VIP 徽标:等级徽标 + 期限丝带叠加(丝带透明底绑在徽标上),放大显示
* 顶栏 VIP 徽标:点击弹出诊所端风格会员气泡卡
* 展示等级名(不展示 CUSTOM/V1 等编码)、开通信息、已开功能,并可查看开通记录
*/
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { useUserStore } from '@vben/stores';
import { Button, Popover } from 'ant-design-vue';
import { useAuthStore } from '#/store';
import VipBadgeCombo from './VipBadgeCombo.vue';
import VipStoreRecordsModal from './VipStoreRecordsModal.vue';
const userStore = useUserStore();
const authStore = useAuthStore();
const popoverOpen = ref(false);
const refreshing = ref(false);
const vip = computed(() => (userStore.userInfo as any)?.vip || null);
const storeId = computed(() => Number((userStore.userInfo as any)?.store_id || 0));
const storeName = computed(
() =>
String(
(userStore.userInfo as any)?.store_name ||
(userStore.userInfo as any)?.store?.name ||
'',
),
);
const show = computed(() => {
if (!vip.value) return false;
return !!(vip.value.duration_badge_url || vip.value.badge_url);
const show = computed(() => !!vip.value);
/** 只展示等级名称,不展示 CUSTOM/V1 等编码 */
const levelName = computed(
() => String(vip.value?.level_name || '').trim() || '普通会员',
);
const durationLabel = computed(
() => String(vip.value?.duration_label || '').trim() || '普通会员',
);
/** 当前等级已开功能列表 */
const features = computed(() => {
const list = vip.value?.features;
return Array.isArray(list) ? list : [];
});
/** 到期文案 */
const expireText = computed(() => {
const v = vip.value;
if (!v) return '默认会员';
if (v.is_lifetime) return '永久有效';
const expireAt = Number(v.expire_at || 0);
if (!expireAt) {
if ((v.level_code || 'V0') === 'V0') return '默认会员';
return '永久有效';
}
if (typeof v.expire_at === 'string' && String(v.expire_at).includes('-')) {
return `有效期至 ${v.expire_at}`;
}
const d = new Date(expireAt * 1000);
const pad = (n: number) => String(n).padStart(2, '0');
return `有效期至 ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
});
/** 开通时间文案opened_at 为时间戳);无数据返回空串 */
const openedAtText = computed(() => {
const ts = Number(vip.value?.opened_at || 0);
if (!ts) return '';
const d = new Date(ts * 1000);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
});
const openTypeText = computed(() =>
String(vip.value?.open_type_txt || '').trim(),
);
const operatorText = computed(() => {
const name = String(vip.value?.operator_name || '').trim();
if (name) return name;
const id = Number(vip.value?.operator_id || 0);
if (id > 0) return `管理员#${id}`;
return '';
});
/** 是否有开通流水信息:无则整块隐藏,避免展示「—」占位 */
const hasOpenInfo = computed(() => {
const v = vip.value;
if (!v) return false;
return !!(
openTypeText.value ||
operatorText.value ||
Number(v.opened_at || 0) > 0 ||
Number(v.open_record_id || 0) > 0
);
});
const hasVipVisual = computed(
() => !!(vip.value?.badge_url || vip.value?.duration_badge_url),
);
const [RecordsModalComp, recordsModalApi] = useVbenModal({
connectedComponent: VipStoreRecordsModal,
});
/**
* 打开气泡时刷新 my-info
* 为什么:登录态里的 vip 可能是旧缓存,缺少开通人/开通方式
*/
watch(popoverOpen, async (open) => {
if (!open || refreshing.value) return;
refreshing.value = true;
try {
await authStore.fetchUserInfo();
} catch {
// 刷新失败仍展示本地缓存
} finally {
refreshing.value = false;
}
});
/** 打开本门店开通记录弹窗 */
function openRecords() {
if (!storeId.value) return;
popoverOpen.value = false;
recordsModalApi.setData({
store_id: storeId.value,
store_name: storeName.value,
});
recordsModalApi.open();
}
</script>
<template>
<div v-if="show" class="mr-2 flex items-center">
<VipBadgeCombo
size="sm"
:badge-url="vip.badge_url"
:duration-badge-url="vip.duration_badge_url"
:level-name="vip.level_name"
:duration-label="vip.duration_label"
/>
<Popover
v-model:open="popoverOpen"
trigger="click"
placement="bottomRight"
overlay-class-name="header-vip-popover"
:overlay-inner-style="{ padding: '0', background: 'transparent', boxShadow: 'none' }"
>
<template #content>
<div class="header-vip-bubble">
<!-- 诊所端风格会员卡头图 -->
<div class="header-vip-hero">
<VipBadgeCombo
v-if="hasVipVisual"
size="lg"
:badge-url="vip?.badge_url"
:duration-badge-url="vip?.duration_badge_url"
:level-name="levelName"
:duration-label="durationLabel"
/>
<div v-else class="header-vip-hero__placeholder">V</div>
<div class="header-vip-hero__info">
<div class="header-vip-hero__title">{{ levelName }}</div>
<div class="header-vip-hero__sub">{{ durationLabel }}</div>
<div class="header-vip-hero__expire">{{ expireText }}</div>
</div>
</div>
<div class="header-vip-body">
<div v-if="hasOpenInfo" class="header-vip-rows">
<div v-if="openTypeText" class="header-vip-row">
<span class="header-vip-row__label">开通方式</span>
<span class="header-vip-row__value">{{ openTypeText }}</span>
</div>
<div v-if="operatorText" class="header-vip-row">
<span class="header-vip-row__label">开通人</span>
<span class="header-vip-row__value">{{ operatorText }}</span>
</div>
<div v-if="openedAtText" class="header-vip-row">
<span class="header-vip-row__label">开通时间</span>
<span class="header-vip-row__value">{{ openedAtText }}</span>
</div>
</div>
<div
class="header-vip-features"
:class="{ 'header-vip-features--alone': !hasOpenInfo }"
>
<div class="header-vip-features__title">已开功能</div>
<div v-if="features.length" class="header-vip-features__list">
<span
v-for="item in features"
:key="item.code"
class="header-vip-feature-tag"
:title="item.description || item.name"
>
{{ item.name }}
</span>
</div>
<div v-else class="header-vip-features__empty">暂无开通功能</div>
</div>
<Button
v-if="storeId > 0 && hasOpenInfo"
type="primary"
block
size="small"
class="mt-3"
@click="openRecords"
>
查看开通记录
</Button>
</div>
</div>
</template>
<div
class="header-vip-trigger"
role="button"
tabindex="0"
@keydown.enter.prevent="popoverOpen = !popoverOpen"
>
<VipBadgeCombo
size="sm"
:badge-url="vip.badge_url"
:duration-badge-url="vip.duration_badge_url"
:level-name="levelName"
:duration-label="durationLabel"
/>
</div>
</Popover>
<RecordsModalComp />
</div>
</template>
<style scoped>
.header-vip-trigger {
cursor: pointer;
border-radius: 8px;
padding: 2px;
transition: background 0.15s ease;
}
.header-vip-trigger:hover {
background: hsl(var(--primary) / 0.1);
}
.header-vip-bubble {
width: 320px;
border-radius: 14px;
overflow: hidden;
border: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
box-shadow: 0 8px 24px hsl(var(--foreground) / 0.12);
}
.header-vip-hero {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 18px;
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
}
.header-vip-hero__placeholder {
width: 72px;
height: 72px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.18);
color: #fff;
font-size: 28px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.header-vip-hero__info {
min-width: 0;
color: #fff;
}
.header-vip-hero__title {
font-size: 18px;
font-weight: 700;
line-height: 1.3;
}
.header-vip-hero__sub {
margin-top: 4px;
font-size: 13px;
opacity: 0.9;
}
.header-vip-hero__expire {
margin-top: 4px;
font-size: 12px;
opacity: 0.78;
}
.header-vip-body {
padding: 12px 14px 14px;
}
.header-vip-rows {
display: flex;
flex-direction: column;
gap: 8px;
}
.header-vip-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
line-height: 1.4;
}
.header-vip-row__label {
flex-shrink: 0;
color: hsl(var(--muted-foreground));
}
.header-vip-row__value {
text-align: right;
color: hsl(var(--foreground));
word-break: break-all;
}
.header-vip-features {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid hsl(var(--border));
}
.header-vip-features--alone {
margin-top: 0;
padding-top: 0;
border-top: none;
}
.header-vip-features__title {
font-size: 12px;
font-weight: 600;
color: hsl(var(--foreground));
margin-bottom: 8px;
}
.header-vip-features__list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.header-vip-feature-tag {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.12);
border: 1px solid hsl(var(--primary) / 0.25);
}
.header-vip-features__empty {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
html.dark .header-vip-hero,
.dark .header-vip-hero {
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
}
</style>

View File

@@ -1,6 +1,6 @@
<script lang="ts" setup>
/**
* 诊所列表 VIP 单元格:徽标+会员名,点击打开升级弹窗
* 诊所列表 VIP 单元格:徽标+会员名,点击打开 VIP 入口弹窗
*/
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
@@ -30,7 +30,7 @@ const emit = defineEmits<{
<div class="store-vip-cell__text">
<div class="store-vip-cell__name">{{ vip?.level_name || '普通会员' }}</div>
<div class="store-vip-cell__sub">
{{ vip?.duration_label || '点击升级' }}
{{ vip?.duration_label || '点击管理' }}
</div>
</div>
</div>

View File

@@ -50,7 +50,6 @@ const expireText = computed(() => {
</div>
<div class="store-vip-card__sub">
{{ vip?.duration_label || '普通会员' }}
· {{ vip?.level_code || 'V0' }}
</div>
<div class="store-vip-card__expire">{{ expireText }}</div>
</div>

View File

@@ -0,0 +1,202 @@
<script lang="ts" setup>
/**
* 门店 VIP 入口弹窗
* 先展示两张操作卡片:开通/升级、开通记录;再分别打开对应子弹窗
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
import VipStoreRecordsModal from '#/components/vip/VipStoreRecordsModal.vue';
import VipUpgradeModal from '#/components/vip/VipUpgradeModal.vue';
const gridApi = ref<any>();
const onSuccess = ref<null | (() => void)>(null);
const storeId = ref(0);
const storeName = ref('');
const vip = ref<Record<string, any> | null>(null);
const title = computed(
() => `VIP管理 - ${storeName.value || storeId.value || ''}`,
);
const currentLevelText = computed(() => {
const v = vip.value;
if (!v) return '当前:普通会员';
const name = String(v.level_name || '').trim();
const code = String(v.level_code || '').trim();
const duration = String(v.duration_label || '').trim();
const level =
name && code ? `${name}${code}` : name || code || '普通会员';
return duration ? `当前:${level} · ${duration}` : `当前:${level}`;
});
const [UpgradeModalComp, upgradeModalApi] = useVbenModal({
connectedComponent: VipUpgradeModal,
});
const [RecordsModalComp, recordsModalApi] = useVbenModal({
connectedComponent: VipStoreRecordsModal,
});
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
class: 'w-[640px]',
showConfirmButton: false,
cancelText: '关闭',
onCancel() {
modalApi.close();
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
storeId.value = 0;
storeName.value = '';
vip.value = null;
gridApi.value = null;
onSuccess.value = null;
return;
}
const data = modalApi.getData<Record<string, any>>() || {};
storeId.value = Number(data.store_id || 0);
storeName.value = String(data.store_name || '');
vip.value = data.vip || null;
gridApi.value = data.gridApi || null;
onSuccess.value = data.onSuccess || null;
modalApi.setState({ title: title.value });
},
});
/**
* 打开开通/升级弹窗
* 为什么不直接嵌在本弹窗:升级表单已有独立组件,复用可保持交互一致
*/
function openUpgrade() {
if (!storeId.value) return;
upgradeModalApi.setData({
store_id: storeId.value,
store_name: storeName.value,
vip: vip.value,
gridApi: gridApi.value,
onSuccess: () => {
onSuccess.value?.();
gridApi.value?.query?.();
},
});
upgradeModalApi.open();
}
/** 打开当前门店开通记录(含开通人) */
function openRecords() {
if (!storeId.value) return;
recordsModalApi.setData({
store_id: storeId.value,
store_name: storeName.value,
});
recordsModalApi.open();
}
</script>
<template>
<Modal>
<div class="vip-hub-current mb-4">
<VipBadgeCombo
size="md"
:badge-url="vip?.badge_url"
:duration-badge-url="vip?.duration_badge_url"
:level-name="vip?.level_name"
:duration-label="vip?.duration_label"
/>
<div class="vip-hub-current__text">{{ currentLevelText }}</div>
</div>
<div class="vip-hub-grid">
<button type="button" class="vip-hub-card" @click="openUpgrade">
<div class="vip-hub-card__icon"></div>
<div class="vip-hub-card__title">开通 / 升级</div>
<div class="vip-hub-card__desc">
为该门店开通或调整会员等级与时长
</div>
</button>
<button type="button" class="vip-hub-card" @click="openRecords">
<div class="vip-hub-card__icon"></div>
<div class="vip-hub-card__title">开通记录</div>
<div class="vip-hub-card__desc">
查看历史开通流水与开通人
</div>
</button>
</div>
</Modal>
<UpgradeModalComp />
<RecordsModalComp />
</template>
<style scoped>
.vip-hub-current {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: 1px solid hsl(var(--border));
border-radius: 10px;
background: hsl(var(--muted) / 0.25);
}
.vip-hub-current__text {
font-size: 13px;
font-weight: 500;
color: hsl(var(--foreground));
}
.vip-hub-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.vip-hub-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
min-height: 148px;
padding: 18px 16px;
border: 1px solid hsl(var(--border));
border-radius: 12px;
background: hsl(var(--card, var(--background)));
text-align: left;
cursor: pointer;
transition:
border-color 0.15s,
background 0.15s,
box-shadow 0.15s;
}
.vip-hub-card:hover {
border-color: hsl(var(--primary));
background: hsl(var(--primary) / 0.08);
box-shadow: 0 0 0 1px hsl(var(--primary) / 0.2);
}
.vip-hub-card__icon {
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
color: hsl(var(--primary));
background: hsl(var(--primary) / 0.12);
}
.vip-hub-card__title {
font-size: 16px;
font-weight: 650;
color: hsl(var(--foreground));
}
.vip-hub-card__desc {
font-size: 12px;
line-height: 1.5;
color: hsl(var(--muted-foreground));
}
@media (max-width: 560px) {
.vip-hub-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,196 @@
<script lang="ts" setup>
/**
* 门店 VIP 开通记录弹窗
* 按 store_id 拉取开通流水,重点展示开通人、等级、时效与开通时间
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Empty, Pagination, Spin, Tag } from 'ant-design-vue';
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
import { getVipRecordList } from '#/views/system/vip/record/api';
const storeId = ref(0);
const storeName = ref('');
const loading = ref(false);
const items = ref<any[]>([]);
const total = ref(0);
const page = ref(1);
const pageSize = ref(10);
const title = computed(
() => `开通记录 - ${storeName.value || storeId.value || ''}`,
);
/**
* 拉取当前门店开通记录
* 为什么单独封装:弹窗打开、翻页都要复用同一套查询参数
*/
async function loadList() {
if (!storeId.value) {
items.value = [];
total.value = 0;
return;
}
loading.value = true;
try {
const res = await getVipRecordList({
page: page.value,
pageSize: pageSize.value,
store_id: storeId.value,
});
items.value = Array.isArray(res?.items) ? res.items : [];
total.value = Number(res?.total || 0);
} finally {
loading.value = false;
}
}
/** 等级展示:只展示名称,不展示 CUSTOM/V1 等编码 */
function levelLabel(row: Record<string, any>) {
return String(row?.level_name || '').trim() || '—';
}
/** 开通人兜底:无操作人时标明系统/未知 */
function operatorLabel(row: Record<string, any>) {
const name = String(row?.operator_name || '').trim();
if (name) return name;
const id = Number(row?.operator_id || 0);
if (id > 0) return `管理员#${id}`;
return '—';
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
class: 'w-[820px]',
showConfirmButton: false,
cancelText: '关闭',
onCancel() {
modalApi.close();
},
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
storeId.value = 0;
storeName.value = '';
items.value = [];
total.value = 0;
page.value = 1;
return;
}
const data = modalApi.getData<Record<string, any>>() || {};
storeId.value = Number(data.store_id || 0);
storeName.value = String(data.store_name || '');
page.value = 1;
modalApi.setState({ title: title.value });
await loadList();
},
});
/** 分页切换 */
function onPageChange(current: number, size: number) {
page.value = current;
pageSize.value = size;
loadList();
}
</script>
<template>
<Modal>
<Spin :spinning="loading">
<Empty
v-if="!loading && !items.length"
description="暂无开通记录"
/>
<div v-else class="vip-record-list">
<div
v-for="row in items"
:key="row.id"
class="vip-record-card"
>
<div class="vip-record-card__badge">
<VipBadgeCombo
size="md"
:badge-url="row.badge_url"
:duration-badge-url="row.duration_badge_url"
:level-name="row.level_name"
:duration-label="row.duration_label"
/>
</div>
<div class="vip-record-card__body">
<div class="vip-record-card__title">
{{ levelLabel(row) }}
<Tag class="ml-2">{{ row.duration_label || '—' }}</Tag>
<Tag color="blue">{{ row.open_type_txt || '—' }}</Tag>
</div>
<div class="vip-record-card__meta">
<span>开通人{{ operatorLabel(row) }}</span>
<span>开通时间{{ row.created_at || '—' }}</span>
<span>到期{{ row.expire_at || '—' }}</span>
</div>
<div class="vip-record-card__meta">
<span>原价 ¥{{ row.original_price ?? '0.00' }}</span>
<span>实付 ¥{{ row.pay_amount ?? '0.00' }}</span>
<span v-if="row.remark">备注{{ row.remark }}</span>
</div>
</div>
</div>
</div>
<div v-if="total > 0" class="mt-4 flex justify-end">
<Pagination
v-model:current="page"
v-model:page-size="pageSize"
:total="total"
size="small"
show-size-changer
@change="onPageChange"
/>
</div>
</Spin>
</Modal>
</template>
<style scoped>
.vip-record-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 520px;
overflow-y: auto;
padding-right: 4px;
}
.vip-record-card {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 12px 14px;
border: 1px solid hsl(var(--border));
border-radius: 10px;
background: hsl(var(--card, var(--background)));
}
.vip-record-card__badge {
flex-shrink: 0;
}
.vip-record-card__body {
min-width: 0;
flex: 1;
}
.vip-record-card__title {
display: flex;
flex-wrap: wrap;
align-items: center;
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
}
.vip-record-card__meta {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin-top: 6px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -370,6 +370,7 @@ watch(isPlatformAdmin, (val) => {
<Tabs.TabPane key="product" tab="商品订单分账">
<div class="mb-2 text-sm">
合计分账{{ ledgerProduct?.sum_money ?? '0' }}
<span class="ml-2 text-xs text-[hsl(var(--muted-foreground))]">不含已取消</span>
</div>
<Table
:columns="ledgerColumns"
@@ -411,6 +412,7 @@ watch(isPlatformAdmin, (val) => {
<Tabs.TabPane key="register" tab="挂号订单分账">
<div class="mb-2 text-sm">
合计分账:{{ ledgerRegister?.sum_money ?? '0' }}
<span class="ml-2 text-xs text-[hsl(var(--muted-foreground))]">(不含已取消)</span>
</div>
<Table
:columns="ledgerColumns"

View File

@@ -3,7 +3,7 @@
* 订单列表:下单用户 / 医生 / 就诊人信息单元
* 下单用户 → 该用户就诊人列表 Modal就诊人 → 详情 Modal
*/
import { Avatar, Button } from 'ant-design-vue';
import { Avatar, Button, Tag } from 'ant-design-vue';
import { resolveAvatarUrl } from '#/views/system/store-input/utils/inputUser';
@@ -227,6 +227,14 @@ function handleOpenPayUser() {
<span class="text-[11px] leading-tight text-gray-400 dark:text-slate-500">
代付
</span>
<!-- 医生代支付醒目 Tag避免只显示代付被忽略 -->
<!-- <Tag-->
<!-- v-if="row.is_proxy_pay || Number(row.pay_user_type) === 2"-->
<!-- color="magenta"-->
<!-- class="!mr-1 !leading-none"-->
<!-- >-->
<!-- 代支付-->
<!-- </Tag>-->
<template v-if="hasPayUserInfo(row)">
<span class="mr-1 text-[11px] text-gray-500 dark:text-slate-400">
[{{ payUserTypeLabel(row) }}]

View File

@@ -383,6 +383,19 @@ function prescriptionStatusColor() {
<Tag v-else-if="data.status === 7" color="green">确认收货</Tag>
<Tag v-else-if="data.status === 8" color="#B22222">拒绝退款</Tag>
<Tag v-else-if="data.status === 9" color="gray">已取消</Tag>
<Tag
v-if="data.is_proxy_pay || Number(data.pay_user_type) === 2"
color="magenta"
class="ml-1"
>
代支付
</Tag>
</Descriptions.Item>
<Descriptions.Item
v-if="data.is_proxy_pay || Number(data.pay_user_type) === 2"
label="付款方式"
>
医生代支付
</Descriptions.Item>
<Descriptions.Item label="配送方式">
{{ deliveryMethod === 0 ? '快递到家' : '药店自提' }}

View File

@@ -46,6 +46,20 @@ export const formOptions: VbenFormProps = {
fieldName: 'status',
label: '订单状态',
},
{
// 医生代支付:按 pay_user_type=2 筛选,方便在列表中定位代付单
component: 'VbenSelect',
componentProps: {
allowClear: true,
options: [
{ label: '代支付', value: 2 },
{ label: '用户自付', value: 1 },
],
placeholder: '全部',
},
fieldName: 'pay_user_type',
label: '付款方式',
},
{
component: 'VbenSelect',
componentProps: {

View File

@@ -919,14 +919,21 @@ const openOrderAmountVerify = () => {
</template>
<template #order-store="{ row }">
<div class="leading-snug">
<div class="mb-1">
<div class="mb-1 flex flex-wrap items-center gap-1">
<Tag v-if="row.is_online === 1" color="blue">在线问诊</Tag>
<Tag v-else-if="row.is_online === 2" color="green">在线复诊诊所</Tag>
<Tag v-else-if="row.is_online === 3" color="green">在线复诊药店</Tag>
<Tag v-else color="default">线下就诊</Tag>
<!-- 代支付标识放订单号列状态列过窄且 showOverflow 会裁切底部 Tag -->
<Tag
v-if="row.is_proxy_pay || Number(row.pay_user_type) === 2"
color="magenta"
>
代支付
</Tag>
</div>
<div class="font-medium">{{ row.order_no }}</div>
<div class="text-xs text-gray-500">
<div class="text-xs text-[hsl(var(--muted-foreground))]">
<Button
v-if="row.store?.id"
class="!h-auto max-w-[xxx] whitespace-normal break-words !px-0 text-left"
@@ -938,7 +945,9 @@ const openOrderAmountVerify = () => {
</Button>
<span v-else>{{ row.store?.name || '' }}</span>
</div>
<div class="text-xs text-gray-600">下单时间 {{ row.created_at || '—' }}</div>
<div class="text-xs text-[hsl(var(--muted-foreground))]">
下单时间 {{ row.created_at || '—' }}
</div>
</div>
</template>
<template #express-info="{ row }">

View File

@@ -551,9 +551,16 @@ export async function goldenFormulaSaveMatchApi(data: {
/**
* 医生新建就诊人并自动挂号
* waive_register_fee=1 时免除挂号费
* 返回user_patient_id / register_id / is_pay1 可进左侧列表)/ waive_register_fee / price
*/
export async function createUserPatientApi(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create-user-patient`, data);
return requestClient.post<{
user_patient_id: number;
register_id: number;
waive_register_fee: number;
price: string;
is_pay: number;
}>(`${prefix}create-user-patient`, data);
}
/** 当前医生未认领就诊人列表 */

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
/**
* 医生新建就诊人:基础信息 + 健康信息 + 可选免除挂号费,创建后自动挂号
* 医生新建就诊人:基础信息 + 健康信息(与患者小程序 myinfo-add 对齐)+ 可选免除挂号费
* 健康信息采用「有/无 + 标签多选(可自定义)」交互,提交后自动挂号
*/
import { computed, ref } from 'vue';
@@ -23,13 +24,56 @@ import {
import dayjs, { type Dayjs } from 'dayjs';
import { createUserPatientApi } from '#/views/doctor/doctor-reception/api';
import {
ALLERGIC_HISTORY_OPTIONS,
EPIDEMIC_HISTORY_OPTIONS,
FAMILY_HISTORY_OPTIONS,
HISTORY_STATUS_OPTIONS,
LIVER_RENAL_OPTIONS,
MARITAL_HISTORY_OPTIONS,
MENSTRUAL_HISTORY_OPTIONS,
PERSON_HISTORY_OPTIONS,
PERSONAL_HISTORY_OPTIONS,
PRESENT_HISTORY_OPTIONS,
} from '#/views/doctor/doctor-reception/config/health-history';
/** 创建成功回传:含 register_id / user_patient_id / is_pay供接诊页分情况引导 */
export type CreateUserPatientResult = {
user_patient_id: number;
register_id: number;
waive_register_fee: number;
price: string;
is_pay: number;
};
/** 健康史字段名status 与 history 成对) */
type HistoryKey =
| 'allergic'
| 'present'
| 'person'
| 'family'
| 'epidemic'
| 'personal'
| 'menstrual'
| 'marital';
const submitting = ref(false);
const form = ref(buildEmptyForm());
const onSuccessCb = ref<null | (() => void)>(null);
const onSuccessCb = ref<null | ((res: CreateUserPatientResult) => void)>(null);
/** ≤6 岁需填监护人(与小程序、后端 UnclaimedPatientService 一致) */
const showGuardian = computed(() => Number(form.value.age) > 0 && Number(form.value.age) <= 6);
/** 月经史仅女性展示 */
const showMenstrualHistory = computed(() => Number(form.value.sex) === 2);
/** 婚育史:男>22 或 女>20 */
const showMaritalHistory = computed(() => {
const age = Number(form.value.age) || 0;
const sex = Number(form.value.sex);
return (sex === 1 && age > 22) || (sex === 2 && age > 20);
});
function buildEmptyForm() {
return {
name: '',
@@ -52,14 +96,15 @@ function buildEmptyForm() {
menstrual_status: 0,
marital_status: 0,
present_status: 0,
person_history: '',
allergic_history: '',
family_history: '',
epidemic_history: '',
personal_history: '',
menstrual_history: '',
marital_history: '',
present_history: '',
// history 用字符串数组,与小程序提交结构一致,后端会逗号拼接
person_history: [] as string[],
allergic_history: [] as string[],
family_history: [] as string[],
epidemic_history: [] as string[],
personal_history: [] as string[],
menstrual_history: [] as string[],
marital_history: [] as string[],
present_history: [] as string[],
guardian_name: '',
guardian_id_card: '',
guardian_mobile: '',
@@ -78,14 +123,133 @@ const relationOptions = [
{ label: '其他', value: 7 },
];
const yesNoOptions = [
{ label: '无', value: 0 },
{ label: '有', value: 1 },
/** 各类史status 字段名 + history 字段名 + 预设选项 */
const historyBlocks: Array<{
key: HistoryKey;
label: string;
statusField: keyof ReturnType<typeof buildEmptyForm>;
historyField: keyof ReturnType<typeof buildEmptyForm>;
options: Array<{ label: string; value: string }>;
visible: () => boolean;
}> = [
{
key: 'allergic',
label: '过敏史',
statusField: 'allergic_status',
historyField: 'allergic_history',
options: ALLERGIC_HISTORY_OPTIONS,
visible: () => true,
},
{
key: 'present',
label: '现病史',
statusField: 'present_status',
historyField: 'present_history',
options: PRESENT_HISTORY_OPTIONS,
visible: () => true,
},
{
key: 'person',
label: '既往史',
statusField: 'person_status',
historyField: 'person_history',
options: PERSON_HISTORY_OPTIONS,
visible: () => true,
},
{
key: 'family',
label: '家族史',
statusField: 'family_status',
historyField: 'family_history',
options: FAMILY_HISTORY_OPTIONS,
visible: () => true,
},
{
key: 'epidemic',
label: '流行病学史',
statusField: 'epidemic_status',
historyField: 'epidemic_history',
options: EPIDEMIC_HISTORY_OPTIONS,
visible: () => true,
},
{
key: 'personal',
label: '个人史',
statusField: 'personal_status',
historyField: 'personal_history',
options: PERSONAL_HISTORY_OPTIONS,
visible: () => true,
},
{
key: 'menstrual',
label: '月经史',
statusField: 'menstrual_status',
historyField: 'menstrual_history',
options: MENSTRUAL_HISTORY_OPTIONS,
visible: () => showMenstrualHistory.value,
},
{
key: 'marital',
label: '婚育史',
statusField: 'marital_status',
historyField: 'marital_history',
options: MARITAL_HISTORY_OPTIONS,
visible: () => showMaritalHistory.value,
},
];
/**
* 切换「有/无」时清空标签,避免 status=无 仍带上旧标签
*/
function onHistoryStatusChange(
statusField: keyof ReturnType<typeof buildEmptyForm>,
historyField: keyof ReturnType<typeof buildEmptyForm>,
status: number,
) {
(form.value[statusField] as number) = status;
if (status === 0) {
(form.value[historyField] as string[]) = [];
}
}
/** 读取某条史的标签数组(模板里避免复杂断言) */
function getHistoryTags(historyField: keyof ReturnType<typeof buildEmptyForm>): string[] {
return (form.value[historyField] as string[]) || [];
}
/** 写入某条史的标签数组 */
function setHistoryTags(historyField: keyof ReturnType<typeof buildEmptyForm>, tags: string[]) {
(form.value[historyField] as string[]) = tags || [];
}
/**
* 组装提交体:不展示的月经/婚育强制清零history 仅在 status=有 时带出
*/
function buildSubmitPayload() {
const f = form.value;
const menstrualOk = showMenstrualHistory.value;
const maritalOk = showMaritalHistory.value;
return {
...f,
waive_register_fee: f.waive_register_fee ? 1 : 0,
menstrual_status: menstrualOk ? f.menstrual_status : 0,
menstrual_history: menstrualOk && f.menstrual_status === 1 ? f.menstrual_history : [],
marital_status: maritalOk ? f.marital_status : 0,
marital_history: maritalOk && f.marital_status === 1 ? f.marital_history : [],
allergic_history: f.allergic_status === 1 ? f.allergic_history : [],
present_history: f.present_status === 1 ? f.present_history : [],
person_history: f.person_status === 1 ? f.person_history : [],
family_history: f.family_status === 1 ? f.family_history : [],
epidemic_history: f.epidemic_status === 1 ? f.epidemic_history : [],
personal_history: f.personal_status === 1 ? f.personal_history : [],
};
}
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
// 健康信息字段较多,限制内容区高度可滚动,避免弹窗撑出视口
contentClass: 'max-h-[70vh] overflow-y-auto',
onCancel() {
modalApi.close();
},
@@ -115,12 +279,10 @@ const [Modal, modalApi] = useVbenModal({
submitting.value = true;
modalApi.setState({ confirmLoading: true });
try {
await createUserPatientApi({
...form.value,
waive_register_fee: form.value.waive_register_fee ? 1 : 0,
});
const res = await createUserPatientApi(buildSubmitPayload());
message.success('创建成功,已自动挂号');
onSuccessCb.value?.();
// 先回调再关窗:父级按 is_pay 决定选中列表或弹认领码
onSuccessCb.value?.(res as CreateUserPatientResult);
modalApi.close();
} catch (e: any) {
message.error(e?.message || '创建失败');
@@ -132,7 +294,10 @@ const [Modal, modalApi] = useVbenModal({
onOpenChange(isOpen: boolean) {
if (isOpen) {
form.value = buildEmptyForm();
const data = modalApi.getData<{ onSuccess?: () => void }>() || {};
const data =
modalApi.getData<{
onSuccess?: (res: CreateUserPatientResult) => void;
}>() || {};
onSuccessCb.value = data.onSuccess || null;
}
},
@@ -151,7 +316,7 @@ function onBirthdayChange(v: Dayjs | string | null) {
</script>
<template>
<Modal class="w-[720px]" title="新建就诊人">
<Modal class="w-[800px]" title="新建就诊人">
<Form layout="vertical" class="create-user-patient-form">
<div class="mb-3 text-sm font-medium text-[hsl(var(--foreground))]">基础信息</div>
<Row :gutter="12">
@@ -228,46 +393,41 @@ function onBirthdayChange(v: Dayjs | string | null) {
</Col>
</Row>
</template>
<!-- 健康信息字段与交互对齐患者小程序一行两列排布 -->
<div class="mb-3 mt-2 text-sm font-medium text-[hsl(var(--foreground))]">健康信息</div>
<Row :gutter="12">
<Col :span="8">
<FormItem label="肝功能异常">
<Select v-model:value="form.liver_function" :options="yesNoOptions" />
</FormItem>
</Col>
<Col :span="8">
<FormItem label="肾功能异常">
<Select v-model:value="form.renal_function" :options="yesNoOptions" />
</FormItem>
</Col>
<Col :span="8">
<FormItem label="既往史">
<Select v-model:value="form.person_status" :options="yesNoOptions" />
</FormItem>
</Col>
<Col :span="8">
<FormItem label="过敏史">
<Select v-model:value="form.allergic_status" :options="yesNoOptions" />
</FormItem>
</Col>
<Col :span="8">
<FormItem label="家族史">
<Select v-model:value="form.family_status" :options="yesNoOptions" />
</FormItem>
</Col>
<Col :span="8">
<FormItem label="现病史">
<Select v-model:value="form.present_status" :options="yesNoOptions" />
<Col :span="12">
<FormItem label="肝功能">
<RadioGroup v-model:value="form.liver_function" :options="LIVER_RENAL_OPTIONS" />
</FormItem>
</Col>
<Col :span="12">
<FormItem label="过敏史说明">
<Input v-model:value="form.allergic_history" placeholder="有则填写" />
<FormItem label="肾功能">
<RadioGroup v-model:value="form.renal_function" :options="LIVER_RENAL_OPTIONS" />
</FormItem>
</Col>
<Col :span="12">
<FormItem label="既往史说明">
<Input v-model:value="form.person_history" placeholder="有则填写" />
<Col
v-for="block in historyBlocks"
v-show="block.visible()"
:key="block.key"
:span="12"
>
<FormItem :label="block.label">
<RadioGroup
:value="Number(form[block.statusField])"
:options="HISTORY_STATUS_OPTIONS"
@update:value="(v: number) => onHistoryStatusChange(block.statusField, block.historyField, v)"
/>
<Select
v-if="Number(form[block.statusField]) === 1"
class="mt-2 w-full"
mode="tags"
:value="getHistoryTags(block.historyField)"
:options="block.options"
placeholder="选择或输入后回车添加"
allow-clear
@update:value="(v: string[]) => setHistoryTags(block.historyField, v)"
/>
</FormItem>
</Col>
</Row>

View File

@@ -242,6 +242,13 @@ defineExpose({ drawerApi });
<Tag class="ml-2">{{ typeLabel(row.prescription_type) }}</Tag>
<Tag>{{ categoryLabel(row.category) }}</Tag>
<Tag :color="payColor(row)">{{ row.order_pay_text || '—' }}</Tag>
<!-- 医生代支付订单醒目标识后端 is_proxy_pay / pay_user_type=2 -->
<Tag
v-if="row.is_proxy_pay || Number(row.pay_user_type) === 2"
color="magenta"
>
代支付
</Tag>
</div>
<div class="rx-history-meta text-muted-foreground">
<span>开方时间{{ row.created_at || '—' }}</span>

View File

@@ -0,0 +1,89 @@
/**
* 就诊人健康信息预设标签(与患者小程序 myinfo-add 保持一致)
* 提交时 history 为字符串数组,后端 normalizeHistoryField 会用逗号拼接入库
*/
/** 肝肾功能0 正常 / 1 异常 */
export const LIVER_RENAL_OPTIONS = [
{ label: '正常', value: 0 },
{ label: '异常', value: 1 },
];
/** 各类史有无0 无 / 1 有 */
export const HISTORY_STATUS_OPTIONS = [
{ label: '无', value: 0 },
{ label: '有', value: 1 },
];
/** 过敏史预设 */
export const ALLERGIC_HISTORY_OPTIONS = [
'鱼虾 海鲜产品',
'尘螨',
'花粉',
'动物皮屑',
'奶制品',
'鸡蛋',
'阿司匹林',
].map((name) => ({ label: name, value: name }));
/** 现病史预设 */
export const PRESENT_HISTORY_OPTIONS = [
'起病急',
'病程迁延',
'反复发作',
'自行用药',
'症状加重',
].map((name) => ({ label: name, value: name }));
/** 既往史预设 */
export const PERSON_HISTORY_OPTIONS = [
'先天性心脏病',
'肿瘤',
'动脉硬化',
'冠心病',
'糖尿病',
'脑血管病',
'哮喘',
].map((name) => ({ label: name, value: name }));
/** 家族史预设 */
export const FAMILY_HISTORY_OPTIONS = [
'高血压',
'心脏病',
'糖尿病',
'血脂异常',
'白癜风',
'癫痫',
'哮喘',
'近视',
'肝炎',
'结核病',
].map((name) => ({ label: name, value: name }));
/** 流行病学史预设 */
export const EPIDEMIC_HISTORY_OPTIONS = [
'疫区旅居',
'确诊病例接触',
'发热病例接触',
].map((name) => ({ label: name, value: name }));
/** 个人史预设 */
export const PERSONAL_HISTORY_OPTIONS = [
'吸烟',
'饮酒',
'熬夜',
].map((name) => ({ label: name, value: name }));
/** 月经史预设(仅女性) */
export const MENSTRUAL_HISTORY_OPTIONS = [
'月经规律',
'痛经',
'月经不调',
].map((name) => ({ label: name, value: name }));
/** 婚育史预设(男>22 或 女>20 */
export const MARITAL_HISTORY_OPTIONS = [
'未婚',
'已婚',
'已育',
].map((name) => ({ label: name, value: name }));

View File

@@ -1387,10 +1387,47 @@ function openCommonPrescriptionModal() {
CommonPrescriptionModalApi.open();
}
/** 打开新建就诊人弹窗 */
/**
* 打开新建就诊人弹窗
* 成功后按 is_pay 分情况:已支付 → 切当前列表并自动选中;未支付 → 弹认领码
*/
function openCreateUserPatientModal() {
CreateUserPatientModalApi.setData({
onSuccess: () => refreshPatientList(),
onSuccess: (res: {
user_patient_id?: number;
register_id?: number;
is_pay?: number;
}) => {
const isPay = Number(res?.is_pay || 0) === 1;
const registerId = Number(res?.register_id || 0);
const userPatientId = Number(res?.user_patient_id || 0);
if (isPay) {
// 新人 status=1落在「当前」列表
listType.value = 1;
getPatientList({ type: listType.value })
.then((list) => {
patients.value = Array.isArray(list) ? list : [];
const hit = patients.value.find(
(p) => Number(p.id) === registerId,
);
if (hit) {
selectPatient(hit);
} else {
message.warning('已创建,请在列表中手动选择');
}
})
.catch((e: any) => {
message.error(e?.message || '刷新患者列表失败');
});
return;
}
// 未支付进不了左侧列表:直接弹认领码并选中刚创建的就诊人
ClaimQrcodeModalApi.setData({
user_patient_id: userPatientId || undefined,
});
ClaimQrcodeModalApi.open();
refreshPatientList();
},
});
CreateUserPatientModalApi.open();
}

View File

@@ -170,6 +170,12 @@ export async function getFormulasByDrugName(drugName: string) {
has_unmatched: number;
status: number;
drug_count: number;
/** 命中味克数(拆分后每味继承) */
match_dose: string;
match_unit: string;
match_ancient_dose: string;
/** 展示用:如 9g三两 */
match_dose_text: string;
}[]
>(`${missingPrefix}formulas-by-drug-name`, {
params: { drug_name: drugName },
@@ -177,20 +183,24 @@ export async function getFormulasByDrugName(drugName: string) {
}
/**
* 替换为药材表已有药材:金方 name 改为目标药名,旧名保留 origin_name 并挂别名
* 替换为药材表已有药材(支持 1→N 拆分)
* xk-api POST /golden-formula-missing-drug/replace-with-drug
*/
export async function replaceMissingWithDrug(data: {
id?: number;
drug_name?: string;
target_drug_id: number;
/** 预览勾选的金方 ID 列表(必填) */
formula_ids: number[];
/** 目标药材列表;多味时把错误合并名拆成多行 */
targets: Array<{ target_drug_id: number; dose?: string }>;
/** 兼容旧单目标(可选) */
target_drug_id?: number;
}) {
return requestClient.post<{
drug_id: number;
old_name: string;
new_name: string;
new_names: string[];
formula_count: number;
refreshed: number;
}>(`${missingPrefix}replace-with-drug`, data);

View File

@@ -3,7 +3,7 @@
* 缺失药处理面板
* - 添加药材:打开中药管理「新增中药」模态框
* - 挂别名ChineseDrugNameBubble 选目标药
* - 替换已有:选目标药 → 预览勾选含同名药的金方 → 确认
* - 替换已有:可 1→N错误合并名拆多味→ 预览勾选金方 → 确认
*/
import { computed, ref, watch } from 'vue';
@@ -25,6 +25,13 @@ import {
resolveMissingByName,
} from '../api';
/** 替换目标行:只选药名,克数各方继承原味(不在此填写) */
interface ReplaceTargetRow {
key: string;
displayName: string;
drugId: number;
}
const props = defineProps<{
open: boolean;
missingId?: number;
@@ -46,26 +53,71 @@ const submitting = ref(false);
const selectedDrug = ref<ChineseDrugBubbleItem | null>(null);
const selectedDisplayName = ref('');
/** 替换目标列表(可多味) */
const replaceTargets = ref<ReplaceTargetRow[]>([]);
/** 当前正在搜索选择的那一行 */
const activeReplaceKey = ref('');
/** 替换预览:含该药名的金方 */
const previewLoading = ref(false);
const previewFormulas = ref<any[]>([]);
const selectedFormulaIds = ref<number[]>([]);
const previewColumns = [
{ title: 'ID', dataIndex: 'id', key: 'id', width: 70 },
{ title: '药方名字', dataIndex: 'name', key: 'name', ellipsis: true },
{ title: '条文', dataIndex: 'clause_number', key: 'clause_number', width: 90 },
{ title: '来源', dataIndex: 'source_name', key: 'source_name', width: 100 },
{ title: '药材速览', dataIndex: 'herb_overview', key: 'herb_overview', ellipsis: true },
{ title: '标记', key: 'flags', width: 120 },
{ title: 'ID', dataIndex: 'id', key: 'id', width: 60 },
{ title: '药方名字', dataIndex: 'name', key: 'name', width: 120, ellipsis: true },
{ title: '条文', dataIndex: 'clause_number', key: 'clause_number', width: 70 },
{ title: '原克数', dataIndex: 'match_dose_text', key: 'match_dose_text', width: 110 },
{ title: '拆分后预览', key: 'split_preview', ellipsis: true },
{ title: '标记', key: 'flags', width: 110 },
];
const selectedFormulaCount = computed(() => selectedFormulaIds.value.length);
const validReplaceTargets = computed(() =>
replaceTargets.value.filter((r) => r.drugId > 0 && r.displayName),
);
const replaceTargetNamesText = computed(() =>
validReplaceTargets.value.map((r) => r.displayName).join('、'),
);
/**
* 按该方原味克数生成拆分预览文案(各方克数可能不同,统一继承本方原味)
*/
function buildSplitPreview(record: {
match_dose_text?: string;
match_dose?: string;
match_unit?: string;
match_ancient_dose?: string;
}): string {
const names = validReplaceTargets.value.map((r) => r.displayName);
if (!names.length) return '请先选择目标药材';
const doseText =
String(record?.match_dose_text || '').trim() ||
(() => {
const d = String(record?.match_dose || '').trim();
const u = String(record?.match_unit || 'g').trim() || 'g';
const a = String(record?.match_ancient_dose || '').trim();
let t = d ? `${d}${u}` : '';
if (a) t = t ? `${t}${a}` : a;
return t;
})();
return names
.map((n) => (doseText ? `${n} ${doseText}` : n))
.join('、');
}
const [ChinaMedicineModal, chinaMedicineModalApi] = useVbenModal({
connectedComponent: ChinaMedicineFormModal,
});
function newReplaceRow(): ReplaceTargetRow {
return {
key: `rt-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
displayName: '',
drugId: 0,
};
}
watch(
() => props.open,
(v) => {
@@ -73,6 +125,8 @@ watch(
step.value = 'choose';
selectedDrug.value = null;
selectedDisplayName.value = '';
replaceTargets.value = [newReplaceRow()];
activeReplaceKey.value = replaceTargets.value[0]?.key || '';
previewFormulas.value = [];
selectedFormulaIds.value = [];
}
@@ -145,6 +199,57 @@ function resolveTargetDrugId(): number {
return Number(item.drug_id || item.drug?.id || 0);
}
/**
* 替换行选中药材(必须带 key避免多行时写错位
* 克数不在此填写:确认后由后端按各方原味 dose 继承
*/
function onSelectReplaceTarget(key: string, item: ChineseDrugBubbleItem) {
activeReplaceKey.value = key;
const row = replaceTargets.value.find((r) => r.key === key);
if (!row) return;
const drugId = Number(item.drug_id || item.drug?.id || 0);
const displayName = String(
item?.drug?.drug_name || item?.drug_name || '',
).trim();
if (!drugId || !displayName) {
message.warning('未识别到有效药材');
return;
}
// 同一列表禁止重复选同一味
const dup = replaceTargets.value.some(
(r) => r.key !== key && r.drugId === drugId,
);
if (dup) {
message.warning(`${displayName}」已在目标列表中`);
return;
}
row.drugId = drugId;
row.displayName = displayName;
}
function focusReplaceRow(key: string) {
activeReplaceKey.value = key;
}
function addReplaceRow() {
const row = newReplaceRow();
replaceTargets.value.push(row);
activeReplaceKey.value = row.key;
}
function removeReplaceRow(key: string) {
if (replaceTargets.value.length <= 1) {
// 至少保留一行空位
replaceTargets.value = [newReplaceRow()];
activeReplaceKey.value = replaceTargets.value[0]!.key;
return;
}
replaceTargets.value = replaceTargets.value.filter((r) => r.key !== key);
if (!replaceTargets.value.some((r) => r.key === activeReplaceKey.value)) {
activeReplaceKey.value = replaceTargets.value[0]?.key || '';
}
}
/**
* 加载含该缺失药名的金方预览,并默认勾选
* - 有 formulaId默认只勾当前方可再加选
@@ -162,11 +267,10 @@ async function loadFormulaPreview() {
previewFormulas.value = Array.isArray(list) ? list : [];
const allIds = previewFormulas.value.map((r) => Number(r.id)).filter((id) => id > 0);
if (props.formulaId && props.formulaId > 0) {
// 当前方优先勾选;若列表里没有当前方(异常),仍尽量带上
selectedFormulaIds.value = allIds.includes(props.formulaId)
? [props.formulaId]
: allIds.length
? [allIds[0]]
? [allIds[0]!]
: [];
} else {
selectedFormulaIds.value = allIds;
@@ -185,6 +289,8 @@ async function goPick(next: 'alias' | 'replace') {
selectedDrug.value = null;
selectedDisplayName.value = '';
if (next === 'replace') {
replaceTargets.value = [newReplaceRow()];
activeReplaceKey.value = replaceTargets.value[0]!.key;
await loadFormulaPreview();
} else {
previewFormulas.value = [];
@@ -229,12 +335,12 @@ async function confirmAlias() {
}
/**
* 确认替换:只改预览勾选的金方
* 确认替换:支持多味拆分,只改预览勾选的金方
*/
async function confirmReplace() {
const targetDrugId = resolveTargetDrugId();
if (!targetDrugId) {
message.warning('请先搜索并选择目标药材');
const targets = validReplaceTargets.value;
if (!targets.length) {
message.warning('请至少选择一味目标药材');
return;
}
if (!selectedFormulaIds.value.length) {
@@ -243,14 +349,23 @@ async function confirmReplace() {
}
submitting.value = true;
try {
// 不传 dose后端按各方原味克数继承到拆出的每一味
const res = await replaceMissingWithDrug({
...buildBasePayload(),
target_drug_id: targetDrugId,
formula_ids: selectedFormulaIds.value,
targets: targets.map((t) => ({
target_drug_id: t.drugId,
})),
});
message.success(
`已将「${res?.old_name || props.drugName}」替换为「${res?.new_name || selectedDisplayName.value}」(共 ${Number(res?.formula_count || 0)} 个金方,旧名已保留)`,
);
const names =
Array.isArray(res?.new_names) && res.new_names.length
? res.new_names.join('、')
: res?.new_name || replaceTargetNamesText.value;
const tip =
targets.length > 1
? `已将「${res?.old_name || props.drugName}」拆分为「${names}」(共 ${Number(res?.formula_count || 0)} 个金方)`
: `已将「${res?.old_name || props.drugName}」替换为「${names}」(共 ${Number(res?.formula_count || 0)} 个金方,旧名已保留)`;
message.success(tip);
emit('done');
close();
} catch (e: any) {
@@ -267,7 +382,7 @@ async function confirmReplace() {
:title="`处理缺失药:${drugName || ''}`"
:footer="null"
destroy-on-close
:width="step === 'replace' ? 860 : 520"
:width="step === 'replace' ? 960 : 520"
@cancel="close"
>
<div v-if="step === 'choose'" class="gf-resolve-choose">
@@ -282,7 +397,7 @@ async function confirmReplace() {
放入药材别名表挂到已有药不改金方药名
</Button>
<Button block @click="goPick('replace')">
替换为已有药材预览勾选金方后确认
替换为已有药材可拆成多味预览勾选金方后确认
</Button>
</Space>
</div>
@@ -313,23 +428,42 @@ async function confirmReplace() {
</div>
<div v-else class="gf-resolve-replace">
<p class="gf-resolve-tip">
1搜索并选择目标药材2预览勾选要一起修改的金方3确认后把{{ drugName }}替换为目标药名旧名写入
origin_name 并挂别名
1添加目标药材可多味2下方预览各方原克数拆分后每味都继承该方原味克数各方可能不同无需手填3勾选金方后确认
</p>
<div class="gf-resolve-search">
<span class="gf-resolve-search-label">搜索中药</span>
<ChineseDrugNameBubble
:display-name="selectedDisplayName"
:type="1"
:store-id="searchStoreId"
placeholder="输入药名搜索"
@select="onSelectDrug"
/>
<div class="gf-replace-targets">
<div
v-for="(row, idx) in replaceTargets"
:key="row.key"
class="gf-replace-row"
:class="{ 'is-active': activeReplaceKey === row.key }"
@click="focusReplaceRow(row.key)"
>
<span class="gf-replace-idx">{{ idx + 1 }}</span>
<div class="gf-replace-search" @mousedown="focusReplaceRow(row.key)">
<ChineseDrugNameBubble
:display-name="row.displayName"
:type="1"
:store-id="searchStoreId"
placeholder="搜索并选择目标药"
@select="(item) => onSelectReplaceTarget(row.key, item)"
/>
</div>
<Button
type="link"
danger
size="small"
@click.stop="removeReplaceRow(row.key)"
>
删除
</Button>
</div>
<Button type="dashed" block class="gf-replace-add" @click="addReplaceRow">
+ 再加一味目标药
</Button>
<p v-if="validReplaceTargets.length" class="gf-resolve-selected">
将拆分为{{ replaceTargetNamesText }} {{ validReplaceTargets.length }} 克数各方继承原味
</p>
</div>
<p v-if="selectedDisplayName" class="gf-resolve-selected">
已选目标{{ selectedDisplayName }}
<span v-if="resolveTargetDrugId()">ID {{ resolveTargetDrugId() }}</span>
</p>
<div class="gf-preview-toolbar">
<span class="gf-preview-title">
受影响金方预览已勾选 {{ selectedFormulaCount }} / {{ previewFormulas.length }}
@@ -357,7 +491,13 @@ async function confirmReplace() {
class="gf-preview-table"
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'flags'">
<template v-if="column.key === 'match_dose_text'">
{{ record.match_dose_text || '' }}
</template>
<template v-else-if="column.key === 'split_preview'">
<span class="gf-split-preview">{{ buildSplitPreview(record) }}</span>
</template>
<template v-else-if="column.key === 'flags'">
<Tag v-if="record.is_abnormal === 1" color="error">异常</Tag>
<Tag v-if="record.has_unmatched === 1" color="warning">未匹配</Tag>
<Tag
@@ -374,10 +514,13 @@ async function confirmReplace() {
<Button
type="primary"
:loading="submitting"
:disabled="!selectedDisplayName || !selectedFormulaCount"
:disabled="!validReplaceTargets.length || !selectedFormulaCount"
@click="confirmReplace"
>
确认替换{{ selectedFormulaCount }} 个金方
确认{{ validReplaceTargets.length > 1 ? '拆分' : '替换' }}{{
selectedFormulaCount
}}
个金方
</Button>
</Space>
</div>
@@ -401,9 +544,11 @@ async function confirmReplace() {
font-size: 13px;
}
.gf-resolve-search :deep(.chinese-drug-name-bubble),
.gf-resolve-search :deep(.chinese-drug-name-bubble--compact) {
.gf-resolve-search :deep(.chinese-drug-name-bubble--compact),
.gf-replace-search :deep(.chinese-drug-name-bubble),
.gf-replace-search :deep(.chinese-drug-name-bubble--compact) {
width: 100% !important;
min-width: 220px !important;
min-width: 180px !important;
max-width: none !important;
flex: 1 1 auto !important;
}
@@ -412,6 +557,43 @@ async function confirmReplace() {
color: hsl(var(--primary));
font-size: 13px;
}
.gf-replace-targets {
margin-bottom: 16px;
}
.gf-replace-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding: 8px 10px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--card, var(--background)));
}
.gf-replace-row.is-active {
border-color: hsl(var(--primary) / 0.55);
background: hsl(var(--primary) / 0.06);
}
.gf-replace-idx {
flex-shrink: 0;
width: 20px;
color: hsl(var(--muted-foreground));
font-size: 12px;
text-align: center;
}
.gf-replace-search {
flex: 1 1 auto;
min-width: 0;
}
.gf-replace-add {
margin-top: 4px;
margin-bottom: 8px;
}
.gf-split-preview {
color: hsl(var(--primary));
font-size: 12px;
line-height: 1.5;
}
.gf-preview-toolbar {
display: flex;
align-items: center;

View File

@@ -272,6 +272,7 @@ async function handleConfirmImport(payload: { rows: any[]; sourceId: number }) {
<Grid>
<template #toolbar-buttons>
<TableAction
:flex="false"
:actions="[
{
label: '新增',

View File

@@ -43,7 +43,7 @@ import SalespersonCommissionDrawer from './components/SalespersonCommissionDrawe
import AddDoctorFromStoreModal from './components/AddDoctorFromStoreModal.vue';
import StoreCardModal from '#/components/store-card/StoreCardModal.vue';
import StoreVipCell from '#/components/vip/StoreVipCell.vue';
import VipUpgradeModal from '#/components/vip/VipUpgradeModal.vue';
import VipHubModal from '#/components/vip/VipHubModal.vue';
import BankCardReportModal from '#/views/system/store-bank-card/components/BankCardReportModal.vue';
import BankCardStatusModal from '#/views/system/store-bank-card/components/BankCardStatusModal.vue';
import BankCardStoreEditModal from '#/views/system/store-bank-card/components/BankCardStoreEditModal.vue';
@@ -105,20 +105,23 @@ const [StoreCardModalComp, StoreCardModalApi] = useVbenModal({
connectedComponent: StoreCardModal,
});
const [VipUpgradeModalComp, vipUpgradeModalApi] = useVbenModal({
connectedComponent: VipUpgradeModal,
const [VipHubModalComp, vipHubModalApi] = useVbenModal({
connectedComponent: VipHubModal,
});
/** 打开门店 VIP 升级弹窗(列表徽标点击) */
function openVipUpgrade(row: Record<string, any>) {
/**
* 打开门店 VIP 入口弹窗(两张卡片:开通/升级、开通记录)
* 为什么先走 Hub需要同时支持升级操作与查看开通人/流水,避免直接进升级页看不到记录
*/
function openVipHub(row: Record<string, any>) {
if (!row?.id) return;
vipUpgradeModalApi.setData({
vipHubModalApi.setData({
store_id: Number(row.id),
store_name: row.name || '',
vip: row.vip || null,
gridApi,
});
vipUpgradeModalApi.open();
vipHubModalApi.open();
}
/** 添加医生弹窗:诊所列表行操作和详情医生团队共用 */
@@ -491,7 +494,7 @@ const handleSwitchClinicType = (row: any) => {
<BankCardStatusModalComponent />
<BankCardStoreEditModalComponent />
<StoreCardModalComp />
<VipUpgradeModalComp />
<VipHubModalComp />
<AddDoctorModalComp />
<QrCodePreviewModal />
<DrugPriceModalComponent />
@@ -561,7 +564,7 @@ const handleSwitchClinicType = (row: any) => {
/>
</template>
<template #vip="{ row }">
<StoreVipCell :vip="row.vip" @click="openVipUpgrade(row)" />
<StoreVipCell :vip="row.vip" @click="openVipHub(row)" />
</template>
<template #store_config_1="{ row }">
<StoreConfigTogglesCell

View File

@@ -187,10 +187,13 @@ const [Modal, modalApi] = useVbenModal({
{{ (opt.name || opt.code || '?').slice(0, 1) }}
</div>
</div>
<div class="vip-level-card__name">{{ opt.name }}</div>
<div class="vip-level-card__meta">
{{ opt.code }} · 权重 {{ opt.weight }}
<div class="vip-level-card__name">
{{ opt.name || opt.code || '—' }}
<span v-if="opt.code" class="vip-level-card__code">{{
opt.code
}}</span>
</div>
<div class="vip-level-card__meta">权重 {{ opt.weight }}</div>
</div>
</div>
</Modal>
@@ -267,11 +270,21 @@ const [Modal, modalApi] = useVbenModal({
font-weight: 600;
}
.vip-level-card__name {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: center;
gap: 4px 6px;
font-size: 14px;
font-weight: 600;
color: hsl(var(--foreground));
text-align: center;
}
.vip-level-card__code {
font-size: 12px;
font-weight: 500;
color: hsl(var(--primary));
}
.vip-level-card__meta {
margin-top: 4px;
font-size: 11px;

View File

@@ -81,6 +81,18 @@ const showModal = (data: any = {}, isUpdate = false) => {
formModalApi.open();
};
/**
* 已绑等级展示文案:同名等级(如多档「黄金会员」)需同时带上 codeV1/V2…才能区分
*/
function formatBoundLevelLabel(lv: Record<string, any>) {
const name = String(lv?.name || '').trim();
const code = String(lv?.code || '').trim();
if (name && code) {
return `${name}${code}`;
}
return name || code || '—';
}
/** 打开反绑等级弹窗 */
function openBindLevels(item: Record<string, any>) {
bindLevelsModalApi.setData({
@@ -176,20 +188,25 @@ onMounted(loadList);
v-for="lv in item.bound_levels"
:key="lv.id"
class="bound-vip-chip"
:title="lv.name || lv.code"
:title="formatBoundLevelLabel(lv)"
>
<img
v-if="lv.badge_url"
:src="lv.badge_url"
:alt="lv.name || lv.code"
:alt="formatBoundLevelLabel(lv)"
class="bound-vip-chip__img"
/>
<span v-else class="bound-vip-chip__fallback">{{
(lv.name || lv.code || '?').slice(0, 1)
}}</span>
<span class="bound-vip-chip__name">{{
lv.name || lv.code
(lv.code || lv.name || '?').slice(0, 1)
}}</span>
<span class="bound-vip-chip__text">
<span class="bound-vip-chip__name">{{
lv.name || lv.code || '—'
}}</span>
<span v-if="lv.code" class="bound-vip-chip__code">{{
lv.code
}}</span>
</span>
</div>
</div>
<span v-else class="text-xs">未绑定任何会员等级</span>
@@ -238,12 +255,22 @@ onMounted(loadList);
background: hsl(var(--muted) / 0.5);
color: hsl(var(--muted-foreground));
}
.bound-vip-chip__text {
display: inline-flex;
flex-direction: column;
min-width: 0;
line-height: 1.2;
}
.bound-vip-chip__name {
font-size: 12px;
color: hsl(var(--foreground));
max-width: 88px;
max-width: 96px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bound-vip-chip__code {
font-size: 11px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -1,13 +1,13 @@
<script lang="ts" setup>
/**
* 门店 VIP 开通页:复用全局 VipUpgradeModal
* 门店 VIP 开通页:点击徽标先进入 VIP 入口(升级 / 开通记录)
*/
import { Page, useVbenModal } from '@vben/common-ui';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
import VipUpgradeModal from '#/components/vip/VipUpgradeModal.vue';
import VipHubModal from '#/components/vip/VipHubModal.vue';
import { formOptions } from './config/search';
import { gridOptions } from './config/table';
@@ -15,23 +15,24 @@ import { gridOptions } from './config/table';
defineOptions({ name: 'VipStore' });
const [Grid, gridApi] = useVbenVxeGrid({ formOptions, gridOptions });
const [UpgradeModalComp, upgradeModalApi] = useVbenModal({
connectedComponent: VipUpgradeModal,
const [HubModalComp, hubModalApi] = useVbenModal({
connectedComponent: VipHubModal,
});
/** 打开 VIP 入口弹窗 */
const openVip = (row: any) => {
upgradeModalApi.setData({
hubModalApi.setData({
store_id: row.store_id,
store_name: row.store_name,
vip: row.vip,
gridApi,
});
upgradeModalApi.open();
hubModalApi.open();
};
</script>
<template>
<Page auto-content-height title="门店VIP开通">
<UpgradeModalComp />
<HubModalComp />
<div class="p-4">
<Grid>
<template #vip_badge="{ row }">
@@ -50,7 +51,7 @@ const openVip = (row: any) => {
</template>
<template #action="{ row }">
<TableAction
:actions="[{ label: '开通/升级', onClick: () => openVip(row) }]"
:actions="[{ label: 'VIP管理', onClick: () => openVip(row) }]"
/>
</template>
</Grid>