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
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
202
apps/web-antd/src/components/vip/VipHubModal.vue
Normal file
202
apps/web-antd/src/components/vip/VipHubModal.vue
Normal 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>
|
||||
196
apps/web-antd/src/components/vip/VipStoreRecordsModal.vue
Normal file
196
apps/web-antd/src/components/vip/VipStoreRecordsModal.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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) }}]
|
||||
|
||||
@@ -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 ? '快递到家' : '药店自提' }}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 }">
|
||||
|
||||
@@ -551,9 +551,16 @@ export async function goldenFormulaSaveMatchApi(data: {
|
||||
/**
|
||||
* 医生新建就诊人并自动挂号
|
||||
* waive_register_fee=1 时免除挂号费
|
||||
* 返回:user_patient_id / register_id / is_pay(1 可进左侧列表)/ 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);
|
||||
}
|
||||
|
||||
/** 当前医生未认领就诊人列表 */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }));
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -272,6 +272,7 @@ async function handleConfirmImport(payload: { rows: any[]; sourceId: number }) {
|
||||
<Grid>
|
||||
<template #toolbar-buttons>
|
||||
<TableAction
|
||||
:flex="false"
|
||||
:actions="[
|
||||
{
|
||||
label: '新增',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -81,6 +81,18 @@ const showModal = (data: any = {}, isUpdate = false) => {
|
||||
formModalApi.open();
|
||||
};
|
||||
|
||||
/**
|
||||
* 已绑等级展示文案:同名等级(如多档「黄金会员」)需同时带上 code(V1/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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user