1. 更新框架
2. 修复框架更新后导致的问题
3. 消息系统升级
This commit is contained in:
李琦
2026-08-10 20:08:50 +08:00
parent f0a33a628b
commit b82754e36b
33 changed files with 5202 additions and 886 deletions

View File

@@ -1,12 +1,18 @@
<script setup lang="ts">
/**
* 门店多选搜索组件
* - 普通输入框输入,下方气泡卡片展示匹配结果(名称 / 拼音首拼)
* - 选中后在输入框下方用可关闭 Tag 展示「名称【id】」
* - 搜索结果默认高亮第一项Enter 可直接选中
* - 样式使用主题 CSS 变量,自动适配暗色
* 门店多选搜索(对齐开方关键字气泡交互)
* - Teleport + fixed 锚定,避免搜索栏 overflow 裁切导致「气泡不见」
* - 多选:点选后不关闭面板,已选项从列表剔除并以 Tag 展示
* - Enter / ↑↓ 键盘选用Esc 关闭
*/
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import {
computed,
nextTick,
onMounted,
onUnmounted,
ref,
watch,
} from 'vue';
import { LoadingOutlined, SearchOutlined } from '@ant-design/icons-vue';
import { useDebounceFn, useVModel } from '@vueuse/core';
@@ -28,16 +34,11 @@ export type StoreSearchItem = {
const props = withDefaults(
defineProps<{
/** 已选门店 ID 列表 */
value?: number[];
/** 限定类型:0诊所 1药店不传则诊所+药店都可搜 */
/** 0诊所 1药店不传则诊所+药店 */
storeType?: 0 | 1;
placeholder?: string;
disabled?: boolean;
/**
* 外部已选门店详情(打开绑定弹窗时回填名称用)
* 仅用于 Tag 展示,不改变 value
*/
initialItems?: StoreSearchItem[];
}>(),
{
@@ -59,13 +60,15 @@ const mValue = useVModel(props, 'value', emits, {
});
const containerRef = ref<HTMLElement | null>(null);
const panelRef = ref<HTMLElement | null>(null);
const listRef = ref<HTMLElement | null>(null);
const searchKeyword = ref('');
const loading = ref(false);
const showDropdown = ref(false);
const highlightIndex = ref(-1);
const options = ref<StoreSearchItem[]>([]);
/** 已选门店详情(用于 Tag 展示名称) */
const selectedMap = ref<Record<number, StoreSearchItem>>({});
const panelStyle = ref<Record<string, string>>({});
const selectedIds = computed(() =>
Array.isArray(mValue.value) ? mValue.value.map(Number) : [],
@@ -78,28 +81,68 @@ const selectedItems = computed(() =>
}),
);
/** 下拉中过滤掉已选 */
/** 下拉中过滤掉已选,方便连续多选 */
const visibleOptions = computed(() =>
options.value.filter((item) => !selectedIds.value.includes(item.id)),
);
/**
* 将初始/回填门店写入 selectedMap便于 Tag 显示真实名称
*/
function mergeInitialItems(items: StoreSearchItem[]) {
if (!items?.length) return;
const next = { ...selectedMap.value };
for (const item of items) {
if (item?.id) {
next[item.id] = item;
}
if (item?.id) next[item.id] = item;
}
selectedMap.value = next;
}
/**
* 请求后端门店搜索;有结果时默认高亮第一项
* 相对输入框视口坐标放置面板(与 EntryKeywordBubble 同思路)
*/
function updatePanelPlacement() {
const el = containerRef.value;
if (!el) return;
const rect = el.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
const gap = 4;
const pad = 8;
const spaceBelow = vh - rect.bottom - pad;
const spaceAbove = rect.top - pad;
const placeTop = spaceBelow < 200 && spaceAbove > spaceBelow;
const avail = Math.max(160, placeTop ? spaceAbove : spaceBelow);
const maxH = Math.min(320, avail);
const width = Math.min(Math.max(rect.width, 360), vw - pad * 2);
let left = rect.left;
if (left + width > vw - pad) left = vw - pad - width;
if (left < pad) left = pad;
const base: Record<string, string> = {
position: 'fixed',
zIndex: '4000',
width: `${width}px`,
maxHeight: `${maxH}px`,
left: `${left}px`,
right: 'auto',
};
panelStyle.value = placeTop
? {
...base,
top: 'auto',
bottom: `${Math.max(pad, vh - rect.top + gap)}px`,
}
: {
...base,
top: `${rect.bottom + gap}px`,
bottom: 'auto',
};
}
async function openPanel() {
showDropdown.value = true;
await nextTick();
updatePanelPlacement();
}
async function fetchOptions() {
const keyword = searchKeyword.value.trim();
if (!keyword) {
@@ -109,7 +152,7 @@ async function fetchOptions() {
return;
}
loading.value = true;
showDropdown.value = true;
await openPanel();
try {
const res = await searchStoreOption({
keyword,
@@ -119,8 +162,9 @@ async function fetchOptions() {
limit: 20,
});
options.value = res?.items ?? [];
// 默认高亮第一项Enter 可直接选中
highlightIndex.value = options.value.length > 0 ? 0 : -1;
highlightIndex.value = visibleOptions.value.length > 0 ? 0 : -1;
await nextTick();
updatePanelPlacement();
} catch {
options.value = [];
highlightIndex.value = -1;
@@ -136,30 +180,36 @@ function handleInput() {
}
function handleFocus() {
if (visibleOptions.value.length > 0) {
showDropdown.value = true;
if (highlightIndex.value < 0) {
if (searchKeyword.value.trim() && options.value.length > 0) {
openPanel();
if (highlightIndex.value < 0 && visibleOptions.value.length > 0) {
highlightIndex.value = 0;
}
}
}
/**
* 选中一项:追加到 value缓存名称清空输入继续搜
* 多选:追加 ID保留关键词与面板不关闭气泡
*/
function selectOption(item: StoreSearchItem) {
if (selectedIds.value.includes(item.id)) return;
selectedMap.value = { ...selectedMap.value, [item.id]: item };
mValue.value = [...selectedIds.value, item.id];
searchKeyword.value = '';
options.value = [];
showDropdown.value = false;
highlightIndex.value = -1;
// 高亮下一项(列表已剔除刚选的)
nextTick(() => {
if (visibleOptions.value.length === 0) {
highlightIndex.value = -1;
return;
}
highlightIndex.value = Math.min(
Math.max(highlightIndex.value, 0),
visibleOptions.value.length - 1,
);
updatePanelPlacement();
scrollHighlightIntoView();
});
}
/**
* 取消某个已选门店
*/
function removeSelected(id: number) {
mValue.value = selectedIds.value.filter((x) => x !== id);
const next = { ...selectedMap.value };
@@ -167,19 +217,52 @@ function removeSelected(id: number) {
selectedMap.value = next;
}
function closePanel() {
showDropdown.value = false;
highlightIndex.value = -1;
}
/** 仅改 list 的 scrollTop表头固定不动 */
function scrollHighlightIntoView() {
nextTick(() => {
const listEl = listRef.value;
if (!listEl || highlightIndex.value < 0) return;
const el = listEl.querySelector(
`[data-store-opt-index="${highlightIndex.value}"]`,
) as HTMLElement | null;
if (!el) return;
const listRect = listEl.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const deltaTop = elRect.top - listRect.top;
const deltaBottom = elRect.bottom - listRect.bottom;
if (deltaTop < 0) {
listEl.scrollTop += deltaTop;
} else if (deltaBottom > 0) {
listEl.scrollTop += deltaBottom;
}
});
}
function handleKeydown(e: KeyboardEvent) {
if (!showDropdown.value || visibleOptions.value.length === 0) return;
if (!showDropdown.value || visibleOptions.value.length === 0) {
if (e.key === 'Escape') closePanel();
return;
}
const len = visibleOptions.value.length;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
highlightIndex.value = Math.min(
Math.max(highlightIndex.value, 0) + 1,
visibleOptions.value.length - 1,
);
// 头尾循环:最后一项再按下 → 回到第一项
highlightIndex.value =
highlightIndex.value < 0 ? 0 : (highlightIndex.value + 1) % len;
scrollHighlightIntoView();
break;
case 'ArrowUp':
e.preventDefault();
highlightIndex.value = Math.max(highlightIndex.value - 1, 0);
// 头尾循环:第一项再按上 → 跳到最后一项
highlightIndex.value =
highlightIndex.value <= 0 ? len - 1 : highlightIndex.value - 1;
scrollHighlightIntoView();
break;
case 'Enter':
e.preventDefault();
@@ -190,35 +273,42 @@ function handleKeydown(e: KeyboardEvent) {
}
break;
case 'Escape':
showDropdown.value = false;
highlightIndex.value = -1;
e.preventDefault();
closePanel();
break;
}
}
function handleClickOutside(e: MouseEvent) {
if (containerRef.value && !containerRef.value.contains(e.target as Node)) {
showDropdown.value = false;
highlightIndex.value = -1;
}
if (!showDropdown.value) return;
const t = e.target as Node;
if (containerRef.value?.contains(t) || panelRef.value?.contains(t)) return;
closePanel();
}
function onWinChange() {
if (showDropdown.value) updatePanelPlacement();
}
onMounted(() => {
document.addEventListener('click', handleClickOutside);
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('resize', onWinChange);
window.addEventListener('scroll', onWinChange, true);
mergeInitialItems(props.initialItems || []);
});
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('resize', onWinChange);
window.removeEventListener('scroll', onWinChange, true);
});
watch(
() => props.storeType,
() => {
options.value = [];
showDropdown.value = false;
closePanel();
searchKeyword.value = '';
highlightIndex.value = -1;
},
);
@@ -227,6 +317,17 @@ watch(
(items) => mergeInitialItems(items || []),
{ deep: true },
);
watch(visibleOptions, (list) => {
if (!showDropdown.value) return;
if (list.length === 0) {
highlightIndex.value = -1;
return;
}
if (highlightIndex.value >= list.length) {
highlightIndex.value = list.length - 1;
}
});
</script>
<template>
@@ -246,33 +347,50 @@ watch(
</template>
</Input>
<!-- 气泡建议列表 -->
<div v-if="showDropdown" class="store-multi-search__dropdown">
<Spin :spinning="loading">
<Empty
v-if="!loading && visibleOptions.length === 0"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无匹配门店"
class="py-3"
/>
<div
v-for="(item, index) in visibleOptions"
:key="item.id"
class="store-multi-search__option"
:class="{ 'is-active': index === highlightIndex }"
@mousedown.prevent="selectOption(item)"
>
<div class="store-multi-search__option-name">
{{ item.name }}{{ item.id }}
</div>
<div v-if="item.shouzimu" class="store-multi-search__option-sub">
首拼{{ item.shouzimu }}
</div>
<Teleport to="body">
<div
v-if="showDropdown"
ref="panelRef"
class="store-multi-search__panel"
:style="panelStyle"
>
<div class="store-multi-search__hint">
<span> 循环切换 · Enter 选用 · Esc 关闭 · 可连续多选</span>
<span class="store-multi-search__count">
{{ visibleOptions.length }} 条可选
</span>
</div>
</Spin>
</div>
<div class="store-multi-search__body">
<Spin :spinning="loading">
<Empty
v-if="!loading && visibleOptions.length === 0"
:image="Empty.PRESENTED_IMAGE_SIMPLE"
description="暂无匹配门店(或已全部选中)"
class="py-3"
/>
<div ref="listRef" class="store-multi-search__list">
<button
v-for="(item, index) in visibleOptions"
:key="item.id"
type="button"
class="store-multi-search__option"
:class="{ 'is-active': index === highlightIndex }"
:data-store-opt-index="index"
@mousedown.prevent="selectOption(item)"
>
<div class="store-multi-search__option-name">
{{ item.name }}{{ item.id }}
</div>
<div v-if="item.shouzimu" class="store-multi-search__option-sub">
首拼{{ item.shouzimu }}
</div>
</button>
</div>
</Spin>
</div>
</div>
</Teleport>
<!-- 已选 Tag -->
<div v-if="selectedItems.length > 0" class="store-multi-search__tags">
<Tag
v-for="item in selectedItems"
@@ -288,40 +406,93 @@ watch(
</template>
<style scoped lang="scss">
/* 只用框架主题变量,随亮暗/主题色自动适配 */
.store-multi-search {
position: relative;
width: 100%;
}
.store-multi-search__dropdown {
position: absolute;
z-index: 1050;
top: calc(100% + 4px);
left: 0;
right: 0;
max-height: 280px;
overflow-y: auto;
padding: 6px;
border-radius: 8px;
background: hsl(var(--card));
.store-multi-search__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
</style>
<style lang="scss">
/* Teleport 到 body非 scoped避免气泡被裁切/丢样式 */
.store-multi-search__panel {
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 10px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
box-shadow:
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
0 8px 24px hsl(0 0% 0% / 0.12),
0 2px 8px hsl(0 0% 0% / 0.06);
}
.store-multi-search__hint {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-shrink: 0;
padding: 8px 12px;
font-size: 12px;
color: hsl(var(--muted-foreground));
border-bottom: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.35);
}
.store-multi-search__count {
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
/* 表头固定,仅 body/list 滚动 */
.store-multi-search__body {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.store-multi-search__body .ant-spin-nested-loading,
.store-multi-search__body .ant-spin-container {
flex: 1;
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.store-multi-search__list {
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: auto;
padding: 6px;
}
.store-multi-search__option {
display: block;
width: 100%;
text-align: left;
padding: 8px 10px;
border: 0;
border-radius: 6px;
cursor: pointer;
background: transparent;
transition: background 0.15s;
}
&:hover,
&.is-active {
background: hsl(var(--accent-hover));
}
.store-multi-search__option:hover,
.store-multi-search__option.is-active {
background: hsl(var(--accent) / 0.85);
}
.store-multi-search__option-name {
@@ -336,11 +507,4 @@ watch(
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.store-multi-search__tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
</style>

View File

@@ -17,7 +17,7 @@ import {
openVipStore,
} from '#/views/system/vip/store/api';
/** 时长预设 → 时效类型 code与后端 VipDurationHelper 对齐) */
/** 时长预设 → 时效类型 code与后端 VipDurationHelper 对齐keep 走当前会员 duration_type */
const PRESET_TO_DURATION: Record<string, string> = {
'7d': 'trial',
'1w': 'week',
@@ -33,6 +33,8 @@ const gridApi = ref<any>();
const onSuccess = ref<null | (() => void)>(null);
const storeId = ref(0);
const storeName = ref('');
/** 打开弹窗时带入的当前门店 VIP用于判断能否「继承剩余时间」 */
const currentVip = ref<Record<string, any> | null>(null);
const levelId = ref<number | undefined>();
const durationPreset = ref<string | undefined>();
const customYears = ref<number | undefined>();
@@ -46,8 +48,28 @@ const selectedLevel = computed(() =>
levels.value.find((i) => i.id === levelId.value),
);
/**
* 是否可继承剩余时间:非普通会员(非 V0且未过期
* 普通会员没有可继承的到期时间,不展示/不默认 keep
*/
const canKeepDuration = computed(() => {
const v = currentVip.value;
if (!v) return false;
const code = String(v.level_code || 'V0').trim() || 'V0';
if (code === 'V0') return false;
const expireAt = Number(v.expire_at || 0);
// expire_at=0 表示终身,视为可继承;>0 须未过期
if (expireAt > 0 && expireAt * 1000 <= Date.now()) return false;
return true;
});
/** 按时长预设解析对应丝带资源 */
function ribbonByPreset(preset?: string) {
if (preset === 'keep') {
const code = String(currentVip.value?.duration_type || '').trim();
if (!code) return null;
return durationTypes.value.find((i) => i.code === code) || null;
}
const code = PRESET_TO_DURATION[preset || ''] || '';
if (!code) return null;
return durationTypes.value.find((i) => i.code === code) || null;
@@ -57,16 +79,18 @@ const selectedDurationType = computed(() =>
ribbonByPreset(durationPreset.value),
);
/** 时长卡片数据:附带丝带图,避免模板重复查找 */
/** 时长卡片:普通会员隐藏 keep其它预设照常 */
const durationCards = computed(() =>
presets.value.map((p) => {
const ribbon = ribbonByPreset(p.value);
return {
...p,
ribbonUrl: ribbon?.badge_url || '',
ribbonName: ribbon?.name || '',
};
}),
presets.value
.filter((p) => p.value !== 'keep' || canKeepDuration.value)
.map((p) => {
const ribbon = ribbonByPreset(p.value);
return {
...p,
ribbonUrl: ribbon?.badge_url || '',
ribbonName: ribbon?.name || '',
};
}),
);
const previewBadgeUrl = computed(() => selectedLevel.value?.badge_url || '');
@@ -78,10 +102,36 @@ const previewLevelName = computed(
);
const previewDurationLabel = computed(() => {
if (!durationPreset.value) return '请选择时长';
if (durationPreset.value === 'keep') {
const label = String(currentVip.value?.duration_label || '').trim();
const expireHint = formatVipExpireHint(currentVip.value);
if (label && expireHint) return `继承剩余:${label}${expireHint}`;
if (label) return `继承剩余:${label}`;
return '继承剩余时间(不改动)';
}
const preset = presets.value.find((p) => p.value === durationPreset.value);
return preset?.label || selectedDurationType.value?.name || '请选择时长';
});
/**
* 格式化当前会员到期提示(终身 / 已格式化字符串 / 时间戳)
*/
function formatVipExpireHint(v: Record<string, any> | null | undefined): string {
if (!v) return '';
if (v.is_lifetime) return '终身';
const raw = v.expire_at;
if (raw == null || raw === '' || raw === 0) return '';
if (typeof raw === 'string' && Number.isNaN(Number(raw))) {
return `${raw}`;
}
const n = Number(raw);
if (!(n > 0)) return '';
const d = new Date(n * 1000);
if (Number.isNaN(d.getTime())) return '';
const pad = (x: number) => String(x).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
const title = computed(() => `升级VIP - ${storeName.value || storeId.value}`);
/** 选择会员等级 */
@@ -139,6 +189,7 @@ const [Modal, modalApi] = useVbenModal({
durationPreset.value = undefined;
customYears.value = undefined;
remark.value = '';
currentVip.value = null;
return;
}
const data = modalApi.getData<Record<string, any>>() || {};
@@ -146,6 +197,7 @@ const [Modal, modalApi] = useVbenModal({
onSuccess.value = data.onSuccess || null;
storeId.value = Number(data.store_id || 0);
storeName.value = data.store_name || '';
currentVip.value = data.vip || null;
const [levelList, presetList, durationList] = await Promise.all([
getVipLevelOption(),
getVipDurationPresets(),
@@ -157,10 +209,14 @@ const [Modal, modalApi] = useVbenModal({
value: i.value,
}));
durationTypes.value = durationList || [];
// 回填当前 VIP 等级(便于续期)
// 回填当前 VIP 等级(便于续期/改档
if (data.vip?.level_id) {
levelId.value = Number(data.vip.level_id);
}
// 非普通会员:默认选「继承剩余时间」,到期日不改动
if (canKeepDuration.value) {
durationPreset.value = 'keep';
}
},
});

View File

@@ -1,15 +1,15 @@
<script lang="ts" setup>
/**
* 管理端基础布局:铃铛站内信轮询 + 动作宿主挂载
*/
import type { NotificationItem } from '@vben/layouts';
import {computed, onUnmounted, ref, watch} from 'vue';
import { computed, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { AuthenticationLoginExpiredModal } from '@vben/common-ui';
// import { VBEN_DOC_URL, VBEN_GITHUB_URL } from '@vben/constants';
import { AuthenticationLoginExpiredModal, VbenIcon } from '@vben/common-ui';
import { useWatermark } from '@vben/hooks';
// import { BookOpenText, CircleHelp, MdiGithub } from '@vben/icons';
// import { CircleHelp } from '@vben/icons';
// import { BookOpenText, CircleHelp, SvgGithubIcon } from '@vben/icons';
import { $t } from '@vben/locales';
import {
BasicLayout,
LockScreen,
@@ -18,173 +18,165 @@ import {
} from '@vben/layouts';
import { preferences, usePreferences } from '@vben/preferences';
import { useAccessStore, useUserStore } from '@vben/stores';
// import { openWindow } from '@vben/utils';
import {
Bell,
CalendarClock,
Megaphone,
Users,
} from 'lucide-vue-next';
import { notification } from 'ant-design-vue';
import { Bell, Users } from 'lucide-vue-next';
// import { $t } from '#/locales';
import DoctorTransferFloat from '#/components/doctor-transfer-float/DoctorTransferFloat.vue';
import HeaderVipBadge from '#/components/vip/HeaderVipBadge.vue';
import { useAuthStore } from '#/store';
import LoginForm from '#/views/_core/authentication/login.vue';
import SwitchAccountModal from '#/layouts/components/SwitchAccountModal.vue';
import { useRouter } from 'vue-router';
import {getNoticeListApi, readAllApi} from "#/views/notice/api";
import {formatTimeToRelative} from "#/util/tool";
import {notification} from "ant-design-vue";
import { $t } from "@vben/locales";
import { useAuthStore } from '#/store';
import { formatTimeToRelative } from '#/util/tool';
import LoginForm from '#/views/_core/authentication/login.vue';
import NoticeActionHost from '#/views/notice/action/NoticeActionHost.vue';
import {
emitNoticeRefresh,
onNoticeRefresh,
openNoticeAction,
} from '#/views/notice/action/bus';
import { resolveNoticeColor } from '#/views/notice/action/types';
import {
getNoticeListApi,
getNoticeUnreadCountApi,
readAllApi,
readApi,
} from '#/views/notice/api';
const router = useRouter();
const switchAccountModalRef = ref<InstanceType<typeof SwitchAccountModal>>();
// 样式相关
const typeIcons = {
0: Megaphone,
1: Bell,
2: CalendarClock,
};
const typeColors = {
0: 'text-sky-500',
1: 'text-amber-500',
2: 'text-emerald-500',
};
const typeBgColors = {
0: 'bg-sky-50',
1: 'bg-amber-50',
2: 'bg-emerald-50',
};
const typeBorderColors = {
0: 'border-sky-200',
1: 'border-amber-200',
2: 'border-emerald-200',
};
const notifications = ref<NotificationItem[]>([]);
const unreadCount = ref(0);
// 获取消息列表数据
/** 拉取未读列表 + 角标数字 */
const getNoticeList = async () => {
try {
const res = await getNoticeListApi({
status: 0,
});
let message = '未读消息';
if (res.items.length > 0) {
if (res.items.length > notifications.value.length) {
if (notifications.value.length === 0) {
message = '新消息';
}
notification.info({
message: `您有${message}`,
description: `您有 ${res.items.length}${message}请注意查收`,
duration: 5,
});
}
notifications.value = res.items.map(item => ({
id: item.id,
icon: typeIcons[item.type],
// 将API返回的数据映射到组件需要的格式
message: item.title,
title: getMessageTypeName(item.type),
date: formatTimeToRelative(item.created_at),
color: typeColors[item.type],
isRead: item.status
}));
} else {
notifications.value = [];
const [res, countRes] = await Promise.all([
getNoticeListApi({ status: 0, page: 1, pageSize: 20 }),
getNoticeUnreadCountApi().catch(() => null),
]);
const items = res?.items || [];
const prevLen = notifications.value.length;
if (items.length > prevLen && prevLen > 0) {
notification.info({
message: '您有新消息',
description: `您有 ${items.length} 条未读消息请注意查收`,
duration: 5,
});
}
notifications.value = items.map((item: any) => {
const color = resolveNoticeColor(item.type_color);
return {
id: item.id,
// Notification 默认用组件当 icon我们走自定义 content 槽,这里占位即可
icon: Bell as any,
message: item.detail || item.type_name || '',
title: item.title || '通知',
date: formatTimeToRelative(item.created_at),
color: color.text,
colorBg: color.bg,
isRead: Number(item.status) === 1,
status: item.status,
type_code: item.type_code,
type_name: item.type_name,
type_icon: item.type_icon,
type_color: item.type_color,
action_type: item.action_type,
action_payload: item.action_payload,
detail: item.detail,
content: item.content,
edit_type: item.edit_type,
priority: item.priority,
raw: item,
};
});
unreadCount.value = Number(
countRes?.count ?? res?.unread_total ?? items.length,
);
} catch (error) {
console.error('获取消息列表失败:', error);
}
};
getNoticeList();
// 每隔二十秒刷新一次
let intervalId = setInterval(getNoticeList, 20_000);
// 封装定时器重启逻辑
const restartInterval = () => {
clearInterval(intervalId);
getNoticeList(); // 立即获取最新数据
getNoticeList();
intervalId = setInterval(getNoticeList, 20_000);
};
// 处理跨标签页修改
const handleStorageChange = (event) => {
const handleStorageChange = (event: StorageEvent) => {
if (event.key === 'readStatus') {
restartInterval();
}
};
// 处理当前标签页修改(核心新增部分)
const handleLocalChange = () => {
restartInterval();
};
// 添加双重监听
window.addEventListener('storage', handleStorageChange); // 跨标签页监听
const originalSetItem = localStorage.setItem.bind(localStorage);
// 拦截当前页的 localStorage.setItem 操作
localStorage.setItem = (key, value) => {
originalSetItem(key, value);
if (key === 'readStatus') {
handleLocalChange(); // 手动触发当前页回调
restartInterval();
}
};
// 组件卸载时清理
window.addEventListener('storage', handleStorageChange);
const stopRefreshListen = onNoticeRefresh(() => restartInterval());
onUnmounted(() => {
clearInterval(intervalId);
window.removeEventListener('storage', handleStorageChange);
localStorage.setItem = originalSetItem; // 恢复原始方法
localStorage.setItem = originalSetItem;
stopRefreshListen();
});
// 消息类型定义
const messageTypes = [
{ id: -1, name: '全部消息' },
{ id: 0, name: '系统公告', icon: Megaphone },
{ id: 1, name: '系统通知', icon: Bell },
{ id: 2, name: '周报提醒', icon: CalendarClock },
];
// 根据类型ID获取类型名称
const getMessageTypeName = (typeId) => {
const type = messageTypes.find(t => t.id === typeId);
return type ? type.name : '未知类型';
};
const goNoticeList = () => {
// TODO 跳转通知列表
router.push('/notice/list');
};
// 处理消息点击
const handleItemClick = (item) => {
notifications.value.forEach((value) => {
if (value.id === item.id) {
value.status = 1;
value.isRead = 1;
}
/** 从铃铛列表移除并扣减角标 */
function removeFromBell(item: NotificationItem) {
notifications.value = notifications.value.filter((n) => n.id !== item.id);
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
/** 行点击:立刻取消未读并打开场景 */
const handleItemClick = async (item: NotificationItem) => {
const raw = (item as any).raw || item;
const wasUnread = !item.isRead;
item.isRead = true;
if (wasUnread) {
removeFromBell(item);
}
await openNoticeAction({
...raw,
id: item.id,
status: wasUnread ? 0 : 1,
});
router.push(`/notice/detail/${item.id}`);
};
const handleClick = async (item: NotificationItem) => {
await handleItemClick(item);
};
/** 勾选已读:只标已读,不打开详情 */
async function handleMarkRead(item: NotificationItem) {
if (item.isRead) return;
item.isRead = true;
removeFromBell(item);
try {
await readApi(Number(item.id));
emitNoticeRefresh();
} catch {
/* ignore */
}
}
const userStore = useUserStore();
const authStore = useAuthStore();
const accessStore = useAccessStore();
const { destroyWatermark, updateWatermark } = useWatermark();
const { isDark } = usePreferences();
const showDot = computed(() =>
notifications.value.some((item) => !item.isRead),
);
const showDot = computed(() => unreadCount.value > 0);
/** 与原先悬浮窗一致:有 doctor_id 且绑定门店才显示传方入口 */
const showDoctorTransfer = computed(() => {
const info = userStore.userInfo as Record<string, any> | null | undefined;
if (!info) return false;
@@ -220,7 +212,20 @@ async function handleLogout() {
function handleNoticeClear() {
readAllApi().then(() => {
notifications.value = [];
// 成功后可以重新获取数据或者直接更新本地状态
unreadCount.value = 0;
notification.success({
message: '标记成功',
duration: 2,
});
emitNoticeRefresh();
});
}
/** 全部已读:必须走后端 API */
function handleMakeAll() {
readAllApi().then(() => {
notifications.value = [];
unreadCount.value = 0;
notification.success({
message: '标记成功',
duration: 2,
@@ -228,72 +233,21 @@ function handleNoticeClear() {
});
}
function markRead(id: number | string) {
const item = notifications.value.find((item) => item.id === id);
if (item) {
item.isRead = true;
}
}
function remove(id: number | string) {
notifications.value = notifications.value.filter((item) => item.id !== id);
}
function handleMakeAll() {
notifications.value.forEach((item) => (item.isRead = true));
}
const viewAll = () => {};
const handleClick = (item: NotificationItem) => {
// 如果通知项有链接,点击时跳转
if (item.link) {
navigateTo(item.link, item.query, item.state);
}
};
function navigateTo(
link: string,
query?: Record<string, any>,
state?: Record<string, any>,
) {
if (link.startsWith('http://') || link.startsWith('https://')) {
// 外部链接,在新标签页打开
window.open(link, '_blank');
} else {
// 内部路由链接,支持 query 参数和 state
router.push({
path: link,
query: query || {},
state,
});
}
}
watch(
() => ({
enable: preferences.app.watermark,
content: preferences.app.watermarkContent,
isDark: isDark.value,
}),
async ({ enable, content, isDark: isDarkValue }) => {
async ({ enable }) => {
if (enable) {
const watermarkColor = isDarkValue
? 'rgba(255, 255, 255, 0.12)'
: 'rgba(0, 0, 0, 0.12)';
await updateWatermark({
// 这里更改水印内容
// content: `${userStore.userInfo?.nick_name || import.meta.env.VITE_APP_TITLE}`,
content: `${userStore.userInfo?.nick_name}\r\n${userStore.userInfo?.phone || import.meta.env.VITE_APP_TITLE}`,
});
} else {
destroyWatermark();
}
},
{
immediate: true,
},
{ immediate: true },
);
</script>
@@ -321,13 +275,54 @@ watch(
<template #notification>
<Notification
:dot="showDot"
:count="unreadCount"
:notifications="notifications"
@clear="handleNoticeClear"
@make-all="handleMakeAll"
@read="handleItemClick"
@read="handleMarkRead"
@view-all="goNoticeList"
@on-click="handleClick"
/>
>
<template #content="{ item }">
<span
v-if="!item.isRead"
class="absolute top-2 right-2 size-2 rounded-sm bg-primary"
></span>
<span
class="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-xl"
:class="[(item as any).colorBg || 'bg-muted', (item as any).color]"
>
<VbenIcon
v-if="(item as any).type_icon"
:icon="(item as any).type_icon"
class="size-5"
/>
<Bell v-else class="size-5" />
</span>
<div class="min-w-0 flex-1 pr-8 leading-none">
<div class="mb-1 flex items-center gap-2">
<span
v-if="(item as any).type_name"
class="truncate text-[11px] font-semibold"
:class="(item as any).color"
>
{{ (item as any).type_name }}
</span>
</div>
<p class="truncate text-sm font-semibold text-foreground">
{{ item.title }}
</p>
<p
v-if="item.message"
class="mt-1 line-clamp-2 text-xs text-muted-foreground"
>
{{ item.message }}
</p>
<p class="mt-1 text-xs text-muted-foreground">{{ item.date }}</p>
</div>
</template>
</Notification>
<NoticeActionHost />
</template>
<template #extra>
<AuthenticationLoginExpiredModal

View File

@@ -55,7 +55,11 @@ const [OpLogModalComp, opLogModalApi] = useVbenModal({
});
function openDetail(row: Record<string, any>) {
detailModalApi.setData({ id: Number(row?.id || 0), values: row });
detailModalApi.setData({
id: Number(row?.id || 0),
values: row,
onRetried: () => gridApi.query(),
});
detailModalApi.open();
}

View File

@@ -1,18 +1,21 @@
<script lang="ts" setup>
/**
* 队列任务详情:展示 payload、错误信息、重跑链路
* 队列任务详情:展示 payload、错误信息、重跑链路;失败/取消可重跑
* 供队列列表与站内信 queue_failure 共用
*/
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { Descriptions, Tag } from 'ant-design-vue';
import { Button, Descriptions, Modal, Tag, message } from 'ant-design-vue';
import { getQueueJobDetail } from '../api';
import { getQueueJobDetail, retryQueueJob } from '../api';
import { statusTagColor } from '../config/constants';
const data = ref<Record<string, any>>({});
const loading = ref(false);
const retrying = ref(false);
const onRetried = ref<null | (() => void)>(null);
function formatJson(val: unknown): string {
if (val == null || val === '') {
@@ -25,7 +28,12 @@ function formatJson(val: unknown): string {
}
}
const [Modal, modalApi] = useVbenModal({
const canRetry = computed(() => {
const status = Number(data.value?.status);
return status === 3 || status === 4;
});
const [ModalComp, modalApi] = useVbenModal({
fullscreenButton: true,
draggable: true,
showConfirmButton: false,
@@ -36,12 +44,15 @@ const [Modal, modalApi] = useVbenModal({
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
data.value = {};
onRetried.value = null;
return;
}
const payload = modalApi.getData<{
id?: number;
values?: Record<string, any>;
onRetried?: () => void;
}>();
onRetried.value = payload?.onRetried || null;
const id = Number(payload?.id || payload?.values?.id || 0);
if (id <= 0) {
data.value = payload?.values || {};
@@ -60,10 +71,39 @@ const [Modal, modalApi] = useVbenModal({
}
},
});
/**
* 失败/取消后重跑:新建任务投递,与列表页逻辑一致
*/
function onRetry() {
const id = Number(data.value?.id || 0);
if (id <= 0) return;
Modal.confirm({
title: '确认重新执行?',
content:
`将基于 #${id} 新建一条任务并重新投递。` +
'订单/退款等业务重跑可能产生副作用,请确认业务幂等后再操作。',
okType: 'danger',
onOk: async () => {
retrying.value = true;
try {
const res = await retryQueueJob(id);
const newId = res?.id ?? res?.data?.id;
message.success(newId ? `已重新投递,新任务 #${newId}` : '已重新投递');
onRetried.value?.();
// 刷新当前详情(原任务状态可能仍失败,链路会多一条)
const detail = await getQueueJobDetail(id);
data.value = detail?.data || detail || data.value;
} finally {
retrying.value = false;
}
},
});
}
</script>
<template>
<Modal class="w-[90%] md:w-[780px]" title="队列任务详情">
<ModalComp class="w-[90%] md:w-[780px]" title="队列任务详情">
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
@@ -100,7 +140,7 @@ const [Modal, modalApi] = useVbenModal({
</Tag>
<span
v-if="Number(data.is_delayed) === 1 && Number(data.delay_seconds) > 0"
class="ml-1 text-xs text-gray-500"
class="ml-1 text-xs text-muted-foreground"
>
相对创建 {{ data.delay_type_txt || '延迟' }}
</span>
@@ -115,7 +155,7 @@ const [Modal, modalApi] = useVbenModal({
<code class="break-all text-xs">{{ data.job_name || '-' }}</code>
</Descriptions.Item>
<Descriptions.Item label="错误信息" :span="2">
<pre class="snap-pre">{{ data.error_message || '(无)' }}</pre>
<pre class="snap-pre error-pre">{{ data.error_message || '(无)' }}</pre>
</Descriptions.Item>
<Descriptions.Item label="重跑参数 payload" :span="2">
<pre class="snap-pre">{{ formatJson(data.payload) }}</pre>
@@ -132,8 +172,8 @@ const [Modal, modalApi] = useVbenModal({
{{ item.status_txt }}
</Tag>
{{ item.title }}
<span class="text-gray-400">{{ item.created_at }}</span>
<span v-if="item.retry_from_id" class="text-gray-400">
<span class="text-muted-foreground">{{ item.created_at }}</span>
<span v-if="item.retry_from_id" class="text-muted-foreground">
来自 #{{ item.retry_from_id }}
</span>
</div>
@@ -141,7 +181,13 @@ const [Modal, modalApi] = useVbenModal({
<span v-else>-</span>
</Descriptions.Item>
</Descriptions>
</Modal>
<div v-if="canRetry" class="mt-4 flex justify-end">
<Button type="primary" danger :loading="retrying" @click="onRetry">
重新执行
</Button>
</div>
</ModalComp>
</template>
<style scoped>
@@ -153,5 +199,14 @@ const [Modal, modalApi] = useVbenModal({
word-break: break-all;
font-size: 12px;
line-height: 1.5;
color: hsl(var(--foreground));
}
.error-pre {
max-height: 200px;
padding: 8px 10px;
border-radius: 8px;
background: hsl(0 72% 50% / 0.08);
color: hsl(0 72% 40%);
}
</style>

View File

@@ -0,0 +1,122 @@
<script lang="ts" setup>
/**
* 布局级站内信动作宿主
* - 消息详情走自定义 NoticeSceneHost非 VbenModal
* - 业务审核表单仍复用列表页 Vben Modal仓品完整审核 / 提现审核等)
*/
import { onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useVbenModal } from '@vben/common-ui';
import WithdrawalAudit from '#/views/finance/withdrawal-audit/components/WithdrawalAudit.vue';
import WarehouseAuditModal from '#/views/system/delivery-warehouse-product-audit/components/audit-modal.vue';
import { readApi } from '#/views/notice/api';
import NoticeSceneHost from '#/views/notice/scenes/NoticeSceneHost.vue';
import { emitNoticeRefresh, registerNoticeActionHandler } from './bus';
import { NOTICE_ACTION, type NoticeItemLike } from './types';
const router = useRouter();
const sceneOpen = ref(false);
const sceneNotice = ref<Record<string, any> | null>(null);
const [WithdrawAuditModal, withdrawAuditApi] = useVbenModal({
connectedComponent: WithdrawalAudit,
});
const [WarehouseModal, warehouseModalApi] = useVbenModal({
connectedComponent: WarehouseAuditModal,
});
const readSentIds = new Set<number>();
async function markRead(item: NoticeItemLike) {
if (!item?.id) return;
const id = Number(item.id);
item.status = 1;
if (readSentIds.has(id)) return;
readSentIds.add(id);
try {
await readApi(id);
emitNoticeRefresh();
} catch {
readSentIds.delete(id);
}
}
function openWithdrawAudit(bizId: number, modalType: number) {
if (!bizId) return;
withdrawAuditApi.setData({
id: bizId,
modalType,
gridApi: { reload: emitNoticeRefresh, query: emitNoticeRefresh },
});
withdrawAuditApi.open();
}
function openWarehouseAudit(bizId: number) {
warehouseModalApi.setData({
id: bizId,
values: { id: bizId },
gridApi: { query: emitNoticeRefresh, reload: emitNoticeRefresh },
});
warehouseModalApi.open();
}
/** 打开消息专属场景弹层 */
function openScene(item: NoticeItemLike) {
sceneNotice.value = { ...item };
sceneOpen.value = true;
}
async function handleAction(item: NoticeItemLike) {
void markRead(item);
const action = String(item.action_type || '');
const typeCode = String(item.type_code || '');
const payload = (item.action_payload || {}) as Record<string, any>;
if (
action === NOTICE_ACTION.OPEN_ROUTE &&
typeCode !== 'queue_failure' &&
typeCode !== 'invoice_todo'
) {
const route = String(payload.route || '');
if (route) {
router.push(route);
return;
}
}
// 全部消息类型统一走自定义场景弹层(含队列失败详情+重试)
openScene(item);
}
function onWithdrawAudit(payload: { bizId: number; modalType: number }) {
openWithdrawAudit(payload.bizId, payload.modalType);
}
function onOpenBizAudit(payload: { typeCode: string; bizId: number }) {
if (payload.typeCode === 'warehouse_product_audit') {
openWarehouseAudit(payload.bizId);
}
}
onMounted(() => {
registerNoticeActionHandler(handleAction);
});
onUnmounted(() => {
registerNoticeActionHandler(null);
});
</script>
<template>
<NoticeSceneHost
v-model:open="sceneOpen"
:notice="sceneNotice"
:on-withdraw-audit="onWithdrawAudit"
:on-open-biz-audit="onOpenBizAudit"
/>
<WithdrawAuditModal />
<WarehouseModal />
</template>

View File

@@ -0,0 +1,40 @@
/**
* 站内信动作总线
* NoticeActionHost 挂载后注册 handler铃铛/消息中心调用 openNoticeAction
*/
import type { NoticeItemLike } from './types';
type NoticeActionHandler = (item: NoticeItemLike) => void | Promise<void>;
let handler: NoticeActionHandler | null = null;
const refreshListeners = new Set<() => void>();
/** Host 注册:打开业务弹窗 / 详情抽屉 */
export function registerNoticeActionHandler(fn: NoticeActionHandler | null) {
handler = fn;
}
/** 打开站内信动作(先已读由调用方或 Host 内处理) */
export async function openNoticeAction(item: NoticeItemLike) {
if (!handler) {
console.warn('[notice] NoticeActionHost 未挂载');
return;
}
await handler(item);
}
/** 审核成功后刷新铃铛 */
export function onNoticeRefresh(fn: () => void) {
refreshListeners.add(fn);
return () => refreshListeners.delete(fn);
}
export function emitNoticeRefresh() {
refreshListeners.forEach((fn) => {
try {
fn();
} catch {
/* ignore */
}
});
}

View File

@@ -0,0 +1,143 @@
/**
* 站内信动作类型常量(与后端 AdminNoticeActionTypeEnum 对齐)
*/
export const NOTICE_ACTION = {
OPEN_WITHDRAW_AUDIT: 'open_withdraw_audit',
OPEN_WAREHOUSE_PRODUCT_AUDIT: 'open_warehouse_product_audit',
OPEN_STORE_INPUT_AUDIT: 'open_store_input_audit',
OPEN_DOCTOR_INPUT_AUDIT: 'open_doctor_input_audit',
OPEN_INVOICE_TODO: 'open_invoice_todo',
OPEN_PERIOD_REPORT: 'open_period_report',
OPEN_VIP_CARD: 'open_vip_card',
OPEN_ROUTE: 'open_route',
OPEN_QUEUE_JOB_DETAIL: 'open_queue_job_detail',
} as const;
export type NoticeActionType =
(typeof NOTICE_ACTION)[keyof typeof NOTICE_ACTION];
export interface NoticeItemLike {
id: number;
title?: string;
detail?: string;
content?: string;
type?: number;
type_code?: string;
type_name?: string;
type_icon?: string;
type_color?: string;
action_type?: string;
action_payload?: Record<string, any> | null;
cover_url?: string;
priority?: number;
status?: number;
edit_type?: number;
created_at?: string;
[key: string]: any;
}
/** 主题色 → Tailwind 类名(亮/暗自适应) */
export const NOTICE_COLOR_MAP: Record<
string,
{ text: string; bg: string; border: string; bar: string }
> = {
sky: {
text: 'text-sky-500',
bg: 'bg-sky-500/10',
border: 'border-sky-500/30',
bar: 'bg-sky-500',
},
amber: {
text: 'text-amber-500',
bg: 'bg-amber-500/10',
border: 'border-amber-500/30',
bar: 'bg-amber-500',
},
emerald: {
text: 'text-emerald-500',
bg: 'bg-emerald-500/10',
border: 'border-emerald-500/30',
bar: 'bg-emerald-500',
},
teal: {
text: 'text-teal-500',
bg: 'bg-teal-500/10',
border: 'border-teal-500/30',
bar: 'bg-teal-500',
},
cyan: {
text: 'text-cyan-500',
bg: 'bg-cyan-500/10',
border: 'border-cyan-500/30',
bar: 'bg-cyan-500',
},
orange: {
text: 'text-orange-500',
bg: 'bg-orange-500/10',
border: 'border-orange-500/30',
bar: 'bg-orange-500',
},
violet: {
text: 'text-violet-500',
bg: 'bg-violet-500/10',
border: 'border-violet-500/30',
bar: 'bg-violet-500',
},
indigo: {
text: 'text-indigo-500',
bg: 'bg-indigo-500/10',
border: 'border-indigo-500/30',
bar: 'bg-indigo-500',
},
blue: {
text: 'text-blue-500',
bg: 'bg-blue-500/10',
border: 'border-blue-500/30',
bar: 'bg-blue-500',
},
green: {
text: 'text-green-500',
bg: 'bg-green-500/10',
border: 'border-green-500/30',
bar: 'bg-green-500',
},
red: {
text: 'text-red-500',
bg: 'bg-red-500/10',
border: 'border-red-500/30',
bar: 'bg-red-500',
},
lime: {
text: 'text-lime-500',
bg: 'bg-lime-500/10',
border: 'border-lime-500/30',
bar: 'bg-lime-500',
},
};
/**
* 未读重要卡片微光色相(与 type_color token 对齐,供 CSS hsl(var(--notice-glow) / a)
*/
export const NOTICE_GLOW_HSL: Record<string, string> = {
sky: '199 89% 48%',
amber: '38 92% 50%',
emerald: '160 84% 39%',
teal: '173 80% 40%',
cyan: '189 94% 43%',
orange: '25 95% 53%',
violet: '262 83% 58%',
indigo: '239 84% 67%',
blue: '217 91% 60%',
green: '142 71% 45%',
red: '0 84% 60%',
lime: '84 81% 44%',
};
export function resolveNoticeColor(color?: string) {
return NOTICE_COLOR_MAP[color || 'sky'] || NOTICE_COLOR_MAP.sky;
}
/** 解析类型微光 HSL 通道 */
export function resolveNoticeGlow(color?: string) {
return NOTICE_GLOW_HSL[color || 'sky'] || NOTICE_GLOW_HSL.sky;
}

View File

@@ -1,62 +1,57 @@
import { requestClient } from '#/api/request';
const prefix = 'notice/';
/**
* 查询通知列表
* @param data
* 查询通知列表xk-api
*/
export async function getNoticeListApi(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
/**
* 未读总数(铃铛角标)
*/
export async function getNoticeUnreadCountApi() {
return requestClient.get<any>(`${prefix}unread-count`);
}
/**
* 获取接受消息用户下拉列表
*/
export async function getUserOptionApi() {
return requestClient.get<any>(`${prefix}user-option`);
}
/**
* 查询通知详情
* @param id
* 查询通知详情(会标记已读)
*/
export async function getNoticeDetailApi(id: number) {
const result = requestClient.get<any>(`${prefix}detail`, { params: {id} });
localStorage.setItem(
`readStatus`,
// 随机数
Math.random().toString(36).slice(2),
);
const result = requestClient.get<any>(`${prefix}detail`, { params: { id } });
localStorage.setItem(`readStatus`, Math.random().toString(36).slice(2));
return result;
}
/**
* 设置已读某条消息
* @param id
*/
export async function readApi(id: number) {
const result = requestClient.post<any>(`${prefix}read`, {id});
localStorage.setItem(
`readStatus`,
// 随机数
Math.random().toString(36).slice(2),
);
const result = requestClient.post<any>(`${prefix}read`, { id });
localStorage.setItem(`readStatus`, Math.random().toString(36).slice(2));
return result;
}
/**
* 设置已读全部消息
*/
export async function readAllApi() {
const result = requestClient.post<any>(`${prefix}read-all`);
localStorage.setItem(
`readStatus`,
// 随机数
Math.random().toString(36).slice(2),
);
localStorage.setItem(`readStatus`, Math.random().toString(36).slice(2));
return result;
}
/**
* 群发消息
* @param data
*/
export async function sendNoticeApi(data: any) {
return requestClient.post<any>(`${prefix}send`, data);

View File

@@ -18,6 +18,7 @@ import {
import { uploadFile } from '#/api/core/upload';
import { getUserOptionApi, sendNoticeApi } from '#/views/notice/api';
import { getAdminNoticeTypeOption } from '#/views/system/admin-notice-type/api';
// Import Markdown editor (using a popular Vue 3 Markdown editor)
import { MdEditor } from 'md-editor-v3';
@@ -32,15 +33,10 @@ const title = ref('');
const detail = ref('');
const content = ref('');
const markdownContent = ref(''); // For 1 content
const type = ref(0);
const typeCode = ref('system');
const userIds = ref([]);
const editorMode = ref(0); // 0 or 1
const typeOption = ref([
{
label: '公告',
value: 0,
},
]);
const typeOption = ref<{ label: string; value: string }[]>([]);
// 用户选项数据
const userOption = ref([]);
@@ -56,6 +52,16 @@ const getUserOption = () => {
};
getUserOption();
getAdminNoticeTypeOption().then((res) => {
typeOption.value = (res || []).map((item: any) => ({
label: item.label,
value: item.value,
}));
if (!typeOption.value.find((t) => t.value === typeCode.value)) {
typeCode.value = typeOption.value[0]?.value || 'system';
}
});
// Watch for editor mode changes to convert content between formats
watch(editorMode, (newMode, oldMode) => {
if (newMode === oldMode) return;
@@ -93,7 +99,7 @@ const send = () => {
sendNoticeApi({
title: title.value,
type: type.value,
type_code: typeCode.value,
edit_type: editorMode.value,
detail: detail.value,
content: finalContent,
@@ -333,9 +339,9 @@ const setupQuillPasteHandler = () => {
<!-- 消息标题输入框 -->
<Input v-model:value="title" placeholder="请输入消息标题" />
<!-- 消息类型选择器 -->
<!-- 消息类型选择器字典 type_code -->
<Select
v-model:value="type"
v-model:value="typeCode"
class="mt-5 w-full"
placeholder="请选择消息类型"
>
@@ -344,9 +350,9 @@ const setupQuillPasteHandler = () => {
</template>
</Select>
<!-- 用户选择器仅在类型为2时显示 -->
<!-- 非广播类型需指定接收人 -->
<Select
v-if="type === 2"
v-if="typeCode !== 'broadcast'"
v-model:value="userIds"
class="mt-5 w-full"
mode="multiple"

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,128 @@
<script lang="ts" setup>
/**
* 消息场景宿主:自定义弹层 + 按 type_code 挂载专属视图(彻底不走 VbenModal
*/
import { computed, ref, watch } from 'vue';
import { getNoticeDetailApi } from '#/views/notice/api';
import { resolveNoticeView } from '#/views/notice/views/registry';
import NoticeSceneShell from './NoticeSceneShell.vue';
import { resolveNoticeSceneMeta } from './meta';
const open = defineModel<boolean>('open', { default: false });
const props = defineProps<{
notice: Record<string, any> | null;
onWithdrawAudit?: (payload: { bizId: number; modalType: number }) => void;
onOpenBizAudit?: (payload: { typeCode: string; bizId: number }) => void;
}>();
const emit = defineEmits<{
close: [];
}>();
const loading = ref(false);
const detail = ref<Record<string, any>>({});
const merged = computed(() => ({
...(props.notice || {}),
...detail.value,
}));
const meta = computed(() =>
resolveNoticeSceneMeta(String(merged.value?.type_code || '')),
);
const sceneTitle = computed(() => {
const t = meta.value.title;
const noticeTitle = String(merged.value?.title || '');
// VIP / 富文本用消息标题更贴切
const code = String(merged.value?.type_code || '');
if (code === 'system' || code === 'broadcast' || code === 'vip') {
return noticeTitle || t;
}
return t;
});
const ViewComp = computed(() =>
resolveNoticeView(String(merged.value?.type_code || '')),
);
watch(
() => [open.value, props.notice?.id] as const,
async ([isOpen, id]) => {
if (!isOpen) {
detail.value = {};
loading.value = false;
return;
}
detail.value = {};
const nid = Number(id || 0);
if (nid <= 0) return;
loading.value = true;
try {
const res = await getNoticeDetailApi(nid);
detail.value = res || {};
} catch {
detail.value = {};
} finally {
loading.value = false;
}
},
);
function close() {
open.value = false;
emit('close');
}
function onViewAudit(modalType?: number) {
const typeCode = String(merged.value?.type_code || '');
const bizId = Number(merged.value?.action_payload?.biz_id || 0);
if (typeCode === 'withdraw_audit') {
props.onWithdrawAudit?.({
bizId,
modalType: typeof modalType === 'number' ? modalType : 0,
});
close();
return;
}
props.onOpenBizAudit?.({ typeCode, bizId });
close();
}
function onViewDone() {
close();
}
</script>
<template>
<NoticeSceneShell
v-model:open="open"
:title="sceneTitle"
:kicker="meta.kicker"
:tone="meta.tone"
:size="meta.size"
:bare-header="!!meta.bareHeader"
@close="emit('close')"
>
<div v-if="loading" class="loading">加载消息内容</div>
<component
:is="ViewComp"
v-else-if="merged.id || merged.title"
:notice="merged"
@audit="onViewAudit"
@done="onViewDone"
/>
</NoticeSceneShell>
</template>
<style scoped>
.loading {
padding: 48px 0;
text-align: center;
color: hsl(var(--muted-foreground));
font-size: 14px;
}
</style>

View File

@@ -0,0 +1,260 @@
<script lang="ts" setup>
/**
* 消息专用场景弹层外壳(非 VbenModal
* 遮罩 + 类型色调面板 + 关闭;内容由各场景组件填充
*/
import { onMounted, onUnmounted, watch } from 'vue';
import { X } from 'lucide-vue-next';
import type { NoticeSceneSize, NoticeSceneTone } from './meta';
const props = withDefaults(
defineProps<{
open: boolean;
title?: string;
kicker?: string;
tone?: NoticeSceneTone;
size?: NoticeSceneSize;
bareHeader?: boolean;
}>(),
{
title: '消息详情',
kicker: 'NOTICE',
tone: 'default',
size: 'md',
bareHeader: false,
},
);
const emit = defineEmits<{
'update:open': [boolean];
close: [];
}>();
function close() {
emit('update:open', false);
emit('close');
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && props.open) {
close();
}
}
watch(
() => props.open,
(v) => {
document.body.style.overflow = v ? 'hidden' : '';
},
);
onMounted(() => window.addEventListener('keydown', onKeydown));
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown);
document.body.style.overflow = '';
});
</script>
<template>
<Teleport to="body">
<Transition name="ns">
<div
v-if="open"
class="ns-root"
role="presentation"
>
<div class="ns-mask" @click="close" />
<div
class="ns-panel"
:class="[`tone-${tone}`, `size-${size}`, { bare: bareHeader }]"
role="dialog"
aria-modal="true"
:aria-label="title"
@click.stop
>
<button
type="button"
class="ns-close"
aria-label="关闭"
@click="close"
>
<X class="size-4" />
</button>
<header v-if="!bareHeader" class="ns-head">
<div class="ns-kicker">{{ kicker }}</div>
<h2 class="ns-title">{{ title }}</h2>
</header>
<div class="ns-body">
<slot />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.ns-root {
position: fixed;
inset: 0;
z-index: 1200;
display: grid;
place-items: center;
padding: 24px 16px;
}
.ns-mask {
position: absolute;
inset: 0;
background: hsl(0 0% 0% / 0.48);
backdrop-filter: blur(6px);
}
.ns-panel {
position: relative;
z-index: 1;
width: min(720px, 96vw);
max-height: min(88vh, 920px);
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 20px;
border: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
box-shadow:
0 24px 64px hsl(0 0% 0% / 0.28),
0 0 0 1px hsl(var(--foreground) / 0.03);
}
.ns-panel.size-lg {
width: min(860px, 96vw);
}
.ns-panel.size-xl {
width: min(960px, 96vw);
}
.ns-panel.tone-vip {
border-color: hsl(160 50% 40% / 0.35);
box-shadow:
0 24px 64px hsl(160 40% 12% / 0.45),
0 0 40px hsl(160 60% 40% / 0.12);
}
.ns-panel.tone-danger {
border-color: hsl(0 72% 50% / 0.35);
box-shadow:
0 24px 64px hsl(0 40% 10% / 0.4),
0 0 36px hsl(0 72% 50% / 0.12);
}
.ns-panel.tone-money {
border-color: hsl(var(--primary) / 0.35);
}
.ns-panel.tone-report {
border-color: hsl(160 60% 40% / 0.3);
}
.ns-panel.tone-invoice {
border-color: hsl(250 60% 55% / 0.3);
}
.ns-panel.tone-audit {
border-color: hsl(190 70% 42% / 0.28);
}
.ns-close {
position: absolute;
top: 14px;
right: 14px;
z-index: 3;
display: grid;
place-items: center;
width: 32px;
height: 32px;
border: 0;
border-radius: 999px;
cursor: pointer;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.55);
transition:
background 0.15s ease,
color 0.15s ease,
transform 0.15s ease;
}
.ns-close:hover {
color: hsl(var(--foreground));
background: hsl(var(--muted));
transform: scale(1.04);
}
.ns-head {
flex-shrink: 0;
padding: 20px 48px 0 22px;
}
.ns-kicker {
font-size: 11px;
font-weight: 750;
letter-spacing: 0.12em;
color: hsl(var(--primary));
}
.tone-danger .ns-kicker {
color: hsl(0 72% 52%);
}
.tone-vip .ns-kicker {
color: hsl(160 55% 42%);
}
.tone-invoice .ns-kicker {
color: hsl(250 60% 55%);
}
.ns-title {
margin: 6px 0 0;
font-size: 18px;
font-weight: 720;
letter-spacing: -0.02em;
color: hsl(var(--foreground));
}
.ns-body {
flex: 1;
overflow: auto;
padding: 16px 22px 22px;
}
.ns-panel.bare .ns-body {
padding-top: 18px;
}
/* enter / leave */
.ns-enter-active,
.ns-leave-active {
transition: opacity 0.2s ease;
}
.ns-enter-active .ns-panel,
.ns-leave-active .ns-panel {
transition:
transform 0.22s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.22s ease;
}
.ns-enter-from,
.ns-leave-to {
opacity: 0;
}
.ns-enter-from .ns-panel,
.ns-leave-to .ns-panel {
opacity: 0;
transform: translateY(12px) scale(0.98);
}
</style>

View File

@@ -0,0 +1,113 @@
/**
* 消息场景弹层元信息:标题 / 色调 / 尺寸(非 Vben消息专用
*/
export type NoticeSceneTone =
| 'default'
| 'vip'
| 'danger'
| 'money'
| 'report'
| 'audit'
| 'invoice';
export type NoticeSceneSize = 'md' | 'lg' | 'xl';
export interface NoticeSceneMeta {
title: string;
kicker: string;
tone: NoticeSceneTone;
size: NoticeSceneSize;
/** true内容自带头图外壳只留关闭钮 */
bareHeader?: boolean;
}
const DEFAULT_META: NoticeSceneMeta = {
title: '消息详情',
kicker: 'NOTICE',
tone: 'default',
size: 'md',
};
export const NOTICE_SCENE_META: Record<string, NoticeSceneMeta> = {
vip: {
title: 'VIP 通知',
kicker: 'MEMBERSHIP',
tone: 'vip',
size: 'lg',
bareHeader: true,
},
period_report: {
title: '运营总结',
kicker: 'REPORT',
tone: 'report',
size: 'lg',
},
withdraw_audit: {
title: '提现待审',
kicker: 'FINANCE',
tone: 'money',
size: 'md',
},
invoice_todo: {
title: '开票待办',
kicker: 'INVOICE',
tone: 'invoice',
size: 'md',
},
queue_failure: {
title: '队列失败',
kicker: 'QUEUE',
tone: 'danger',
size: 'lg',
},
warehouse_product_audit: {
title: '仓品待审',
kicker: 'WAREHOUSE',
tone: 'audit',
size: 'lg',
},
store_input_audit: {
title: '门店录入待审',
kicker: 'STORE',
tone: 'audit',
size: 'xl',
},
doctor_input_audit: {
title: '医生预填待审',
kicker: 'DOCTOR',
tone: 'audit',
size: 'xl',
},
price_change: {
title: '药价变更',
kicker: 'PRICE',
tone: 'report',
size: 'md',
},
system: {
title: '系统通知',
kicker: 'SYSTEM',
tone: 'default',
size: 'md',
},
broadcast: {
title: '系统公告',
kicker: 'BROADCAST',
tone: 'default',
size: 'md',
},
prescription_audit: {
title: '处方审核',
kicker: 'RX',
tone: 'audit',
size: 'md',
},
};
export function resolveNoticeSceneMeta(typeCode?: string): NoticeSceneMeta {
const code = String(typeCode || '').trim();
return NOTICE_SCENE_META[code] || {
...DEFAULT_META,
title: DEFAULT_META.title,
};
}

View File

@@ -0,0 +1,434 @@
<script lang="ts" setup>
/**
* 消息中心 · 医生预填快捷审核
* 拉取预填详情展示后直接通过/拒绝,避免只弹出空审核表单
*/
import { computed, onMounted, ref, watch } from 'vue';
import {
Avatar,
Descriptions,
DescriptionsItem,
Image,
Radio,
RadioGroup,
Spin,
Textarea,
Tag,
message,
Button,
} from 'ant-design-vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { emitNoticeRefresh } from '#/views/notice/action/bus';
import { auditDoctorInput, getDoctorInputDetail } from '#/views/system/doctor-input/api';
import {
inputUserDisplayName,
normalizeInputUser,
resolveAvatarUrl,
} from '#/views/system/store-input/utils/inputUser';
const props = defineProps<{
notice: Record<string, any>;
}>();
const emit = defineEmits<{
/** 审核完成或无需再操作时关闭外层消息 Modal */
done: [];
}>();
const loading = ref(false);
const submitting = ref(false);
const detail = ref<Record<string, any> | null>(null);
const auditStatus = ref(1);
const auditRemark = ref('');
const bizId = computed(() => Number(props.notice?.action_payload?.biz_id || 0));
const canAudit = computed(() => Number(detail.value?.status) === 0);
function typeText(type: number) {
if (type === 1) return '中医医生';
if (type === 2) return '西医医生';
return '-';
}
function signTypeText(signType: number) {
if (signType === 1) return '电子签名';
if (signType === 2) return '手写签名';
return '-';
}
function statusMeta(status: number) {
if (status === 0) return { color: 'orange', text: '待审核' };
if (status === 1) return { color: 'success', text: '审核通过' };
if (status === 2) return { color: 'error', text: '审核拒绝' };
return { color: 'default', text: '-' };
}
/** 兼容后端已格式化字符串或时间戳 */
function formatAuditTime(val: unknown) {
if (val == null || val === '' || val === 0) return '-';
if (typeof val === 'string' && Number.isNaN(Number(val))) return val;
const n = Number(val);
if (!n) return '-';
if (n < 1e12) {
const d = new Date(n * 1000);
return Number.isNaN(d.getTime()) ? String(val) : d.toLocaleString();
}
return new Date(n).toLocaleString();
}
function imgSrc(url?: string | null) {
return resolveAvatarUrl(url) || String(url || '');
}
/**
* 按消息 payload 中的预填 ID 拉详情
*/
async function loadDetail() {
const id = bizId.value;
if (id <= 0) {
detail.value = null;
return;
}
loading.value = true;
detail.value = null;
try {
const res = await getDoctorInputDetail(id);
detail.value = res || null;
normalizeInputUser(detail.value);
} catch {
message.error('加载医生预填详情失败');
detail.value = null;
} finally {
loading.value = false;
}
}
/**
* 提交快捷审核:通过=1 / 拒绝=2
*/
async function submitAudit() {
const id = Number(detail.value?.id || bizId.value || 0);
if (id <= 0) {
message.error('缺少预填 ID');
return;
}
if (!canAudit.value) {
message.warning('该预填已审核,无需重复操作');
return;
}
submitting.value = true;
try {
await auditDoctorInput({
id,
status: auditStatus.value,
audit_remark: auditRemark.value,
});
message.success(auditStatus.value === 1 ? '已通过审核' : '已拒绝');
emitNoticeRefresh();
emit('done');
} catch {
message.error('审核失败,请稍后重试');
} finally {
submitting.value = false;
}
}
watch(
() => bizId.value,
() => {
auditStatus.value = 1;
auditRemark.value = '';
loadDetail();
},
);
onMounted(loadDetail);
</script>
<template>
<div class="doctor-quick">
<div class="head">
<div>
<div class="kicker">医生预填待审</div>
<div class="title">{{ notice.title || '医生预填审核' }}</div>
<p class="desc">{{ notice.detail || '请核对证件与资料后完成审核' }}</p>
</div>
<Tag v-if="detail" :color="statusMeta(Number(detail.status)).color">
{{ statusMeta(Number(detail.status)).text }}
</Tag>
</div>
<Spin :spinning="loading">
<div v-if="!bizId" class="empty">消息未携带预填 ID无法加载详情</div>
<div v-else-if="!loading && !detail" class="empty">未找到预填记录</div>
<template v-else-if="detail">
<Descriptions
class="detail-grid"
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
size="small"
>
<DescriptionsItem label="预填 ID">#{{ detail.id }}</DescriptionsItem>
<DescriptionsItem label="医生姓名">{{ detail.name || '-' }}</DescriptionsItem>
<DescriptionsItem label="手机号">
<SensitiveText :record="detail" field="mobile" />
</DescriptionsItem>
<DescriptionsItem label="身份证">
<SensitiveText :record="detail" field="idcard" />
</DescriptionsItem>
<DescriptionsItem label="身份">{{ typeText(Number(detail.type)) }}</DescriptionsItem>
<DescriptionsItem label="签名类型">
{{ signTypeText(Number(detail.sign_type)) }}
</DescriptionsItem>
<DescriptionsItem label="科室">{{ detail.depart?.name || '-' }}</DescriptionsItem>
<DescriptionsItem label="职称">{{ detail.titleInfo?.name || '-' }}</DescriptionsItem>
<DescriptionsItem label="挂号费">
{{ detail.register_price != null ? detail.register_price : '-' }}
</DescriptionsItem>
<DescriptionsItem label="录入人">
<div class="input-user">
<Avatar
v-if="resolveAvatarUrl(detail.inputUser?.avatar)"
:src="resolveAvatarUrl(detail.inputUser?.avatar)"
:size="28"
/>
<Avatar v-else :size="28">
{{ inputUserDisplayName(detail.inputUser).charAt(0) }}
</Avatar>
<span>{{ inputUserDisplayName(detail.inputUser) }}</span>
</div>
</DescriptionsItem>
<DescriptionsItem label="擅长" :span="2">
{{ detail.good_at || '-' }}
</DescriptionsItem>
<DescriptionsItem label="简介" :span="2">
{{ detail.intro || '-' }}
</DescriptionsItem>
<DescriptionsItem v-if="detail.audit_remark" label="历史备注" :span="2">
{{ detail.audit_remark }}
</DescriptionsItem>
<DescriptionsItem v-if="detail.audit_time" label="审核时间">
{{ formatAuditTime(detail.audit_time) }}
</DescriptionsItem>
<DescriptionsItem v-if="detail.auditAdmin" label="审核人">
{{ detail.auditAdmin.nick_name || '-' }}
</DescriptionsItem>
</Descriptions>
<div class="certs">
<div v-if="detail.avatar" class="cert">
<div class="cert-label">头像</div>
<Image :src="imgSrc(detail.avatar)" :width="88" :height="88" :preview="true" />
</div>
<div v-if="detail.qualification" class="cert">
<div class="cert-label">资格证书</div>
<Image
:src="imgSrc(detail.qualification)"
:width="88"
:height="88"
:preview="true"
/>
</div>
<div v-if="detail.practicing" class="cert">
<div class="cert-label">执业证书</div>
<Image
:src="imgSrc(detail.practicing)"
:width="88"
:height="88"
:preview="true"
/>
</div>
<div v-if="detail.title" class="cert">
<div class="cert-label">职称证书</div>
<Image :src="imgSrc(detail.title)" :width="88" :height="88" :preview="true" />
</div>
<div v-if="detail.card_up" class="cert">
<div class="cert-label">身份证正面</div>
<Image :src="imgSrc(detail.card_up)" :width="88" :height="88" :preview="true" />
</div>
<div v-if="detail.card_down" class="cert">
<div class="cert-label">身份证反面</div>
<Image :src="imgSrc(detail.card_down)" :width="88" :height="88" :preview="true" />
</div>
<div v-if="detail.sign_image" class="cert">
<div class="cert-label">签名</div>
<Image
:src="imgSrc(detail.sign_image)"
:width="88"
:height="88"
:preview="true"
/>
</div>
</div>
<div v-if="canAudit" class="audit-box">
<div class="audit-title">快捷审核</div>
<RadioGroup v-model:value="auditStatus" class="audit-radio">
<Radio :value="1">审核通过</Radio>
<Radio :value="2">审核拒绝</Radio>
</RadioGroup>
<Textarea
v-model:value="auditRemark"
class="audit-remark"
:rows="3"
placeholder="审核备注(可选)"
/>
<div class="actions">
<Button
danger
size="large"
:loading="submitting && auditStatus === 2"
:disabled="submitting"
@click="
() => {
auditStatus = 2;
submitAudit();
}
"
>
拒绝
</Button>
<Button
type="primary"
size="large"
:loading="submitting && auditStatus === 1"
:disabled="submitting"
@click="
() => {
auditStatus = 1;
submitAudit();
}
"
>
通过审核
</Button>
</div>
</div>
<div v-else class="done-tip">该预填已处理可关闭本窗口</div>
</template>
</Spin>
</div>
</template>
<style scoped>
.doctor-quick {
padding: 2px 0 4px;
}
.head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
padding: 14px 16px;
border-radius: 14px;
background:
radial-gradient(
70% 120% at 0% 0%,
hsl(var(--primary) / 0.14),
transparent 55%
),
hsl(var(--muted) / 0.35);
}
.kicker {
font-size: 12px;
font-weight: 650;
color: hsl(var(--primary));
}
.title {
margin-top: 4px;
font-size: 16px;
font-weight: 700;
color: hsl(var(--foreground));
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.empty {
padding: 36px 0;
text-align: center;
color: hsl(var(--muted-foreground));
font-size: 14px;
}
.detail-grid {
margin-top: 2px;
}
.input-user {
display: inline-flex;
align-items: center;
gap: 8px;
}
.certs {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 14px;
}
.cert {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border-radius: 10px;
border: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
}
.cert-label {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.audit-box {
margin-top: 16px;
padding: 14px;
border-radius: 12px;
border: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.28);
}
.audit-title {
margin-bottom: 10px;
font-size: 14px;
font-weight: 650;
color: hsl(var(--foreground));
}
.audit-radio {
display: block;
margin-bottom: 10px;
}
.audit-remark {
margin-bottom: 12px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.done-tip {
margin-top: 14px;
padding: 12px;
border-radius: 10px;
text-align: center;
font-size: 13px;
color: hsl(var(--muted-foreground));
background: hsl(var(--muted) / 0.3);
}
</style>

View File

@@ -0,0 +1,135 @@
<script lang="ts" setup>
/**
* 开票待办定制视图:摘要 + 跳转开票中心
*/
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { Button } from 'ant-design-vue';
import { FileText } from 'lucide-vue-next';
const props = defineProps<{
notice: Record<string, any>;
}>();
const router = useRouter();
const payload = computed(() => props.notice?.action_payload || {});
function goInvoice() {
const route = String(payload.value.route || '/finance/invoice-center');
router.push(route);
}
</script>
<template>
<div class="invoice-view">
<div class="hero">
<div class="ico">
<FileText class="size-7" />
</div>
<div>
<div class="kicker">开票待办</div>
<h3 class="title">{{ notice.title || '开票申请待处理' }}</h3>
<p class="desc">{{ notice.detail || '有新的开票申请需要处理' }}</p>
</div>
</div>
<dl class="facts">
<div class="fact">
<dt>开票单 ID</dt>
<dd>#{{ payload.biz_id || '—' }}</dd>
</div>
<div class="fact">
<dt>时间</dt>
<dd>{{ notice.created_at || '—' }}</dd>
</div>
</dl>
<div class="actions">
<Button type="primary" size="large" @click="goInvoice">
打开开票中心
</Button>
</div>
</div>
</template>
<style scoped>
.invoice-view {
padding: 2px 0;
}
.hero {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 16px;
border-radius: 14px;
background:
radial-gradient(
70% 120% at 0% 0%,
hsl(250 70% 55% / 0.16),
transparent 55%
),
hsl(var(--muted) / 0.35);
}
.ico {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 14px;
color: hsl(250 60% 48%);
background: hsl(250 70% 55% / 0.12);
}
.kicker {
font-size: 12px;
font-weight: 650;
color: hsl(250 60% 48%);
}
.title {
margin: 6px 0 0;
font-size: 18px;
font-weight: 720;
color: hsl(var(--foreground));
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.facts {
margin: 14px 0 0;
display: grid;
gap: 8px;
}
.fact {
display: flex;
justify-content: space-between;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
}
.fact dt {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.fact dd {
margin: 0;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: 18px;
}
</style>

View File

@@ -0,0 +1,147 @@
<script lang="ts" setup>
/**
* 周期总结定制视图:指标网格 + 区间说明
*/
import { computed } from 'vue';
const props = defineProps<{
notice: Record<string, any>;
}>();
const payload = computed(() => props.notice?.action_payload || {});
const metrics = computed(() => payload.value?.metrics || {});
const period = computed(() => payload.value?.period || {});
const cards = computed(() => {
const m = metrics.value;
return [
{ label: '活跃门店', value: m.active_store_count ?? 0, tone: 'normal' },
{ label: '挂号总数', value: m.register_total ?? 0, tone: 'normal' },
{ label: '处方总数', value: m.rx_total ?? 0, tone: 'normal' },
{ label: '线上 / 线下', value: `${m.rx_online ?? 0} / ${m.rx_offline ?? 0}`, tone: 'normal' },
{ label: '待审处方', value: m.rx_pending ?? 0, tone: 'warn' },
{ label: '已通过', value: m.rx_passed ?? 0, tone: 'ok' },
{ label: '已驳回', value: m.rx_rejected ?? 0, tone: 'warn' },
{ label: '临期待审', value: m.expire_soon ?? 0, tone: 'warn' },
];
});
</script>
<template>
<div class="report-view">
<div class="period-banner">
<div class="period-kicker">运营总结</div>
<div class="period-label">{{ period.label || notice.detail || '-' }}</div>
</div>
<div class="grid">
<div
v-for="card in cards"
:key="card.label"
class="metric"
:class="card.tone"
>
<div class="metric-label">{{ card.label }}</div>
<div class="metric-value">{{ card.value }}</div>
</div>
</div>
<div
v-if="notice.content"
class="md-summary whitespace-pre-wrap text-sm text-muted-foreground"
>
{{ notice.content }}
</div>
</div>
</template>
<style scoped>
.report-view {
padding: 2px 0 8px;
}
.period-banner {
margin-bottom: 16px;
padding: 16px 18px;
border-radius: 16px;
border: 1px solid hsl(var(--border));
background:
radial-gradient(
80% 120% at 0% 0%,
hsl(var(--primary) / 0.18),
transparent 60%
),
linear-gradient(
165deg,
hsl(var(--muted) / 0.5),
hsl(var(--card, var(--background)))
);
}
.period-kicker {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: hsl(var(--primary));
}
.period-label {
margin-top: 6px;
font-size: 18px;
font-weight: 720;
letter-spacing: -0.02em;
color: hsl(var(--foreground));
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
@media (min-width: 640px) {
.grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
.metric {
padding: 12px;
border-radius: 12px;
border: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
}
.metric.warn {
border-color: hsl(var(--warning, 38 92% 50%) / 0.35);
background: hsl(var(--warning, 38 92% 50%) / 0.08);
}
.metric.ok {
border-color: hsl(142 70% 40% / 0.3);
background: hsl(142 70% 40% / 0.08);
}
.metric-label {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.metric-value {
margin-top: 6px;
font-size: 22px;
font-weight: 700;
letter-spacing: -0.03em;
font-variant-numeric: tabular-nums;
color: hsl(var(--foreground));
}
.metric.warn .metric-value {
color: hsl(var(--warning, 38 92% 40%));
}
.md-summary {
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid hsl(var(--border));
}
</style>

View File

@@ -0,0 +1,89 @@
<script lang="ts" setup>
/**
* 药价变更定制视图
*/
import { computed } from 'vue';
import { usePreferences } from '@vben/preferences';
import { MdPreview } from 'md-editor-v3';
import 'md-editor-v3/lib/style.css';
const props = defineProps<{
notice: Record<string, any>;
}>();
const { isDark } = usePreferences();
const isMarkdown = computed(() => Number(props.notice?.edit_type) === 1);
const mdTheme = computed(() => (isDark.value ? 'dark' : 'light'));
const contentHtml = computed(() =>
String(props.notice?.content || '').replaceAll('\n', '<br>'),
);
</script>
<template>
<div class="price-view">
<div class="hero">
<div class="kicker">药价变更</div>
<h3 class="title">{{ notice.title || '药价变更通知' }}</h3>
<p class="desc">{{ notice.detail || '配送仓药品价格已调整' }}</p>
</div>
<div class="body">
<MdPreview
v-if="isMarkdown"
:model-value="String(notice.content || '')"
:theme="mdTheme"
/>
<div
v-else
class="prose prose-sm dark:prose-invert max-w-none"
v-html="contentHtml"
></div>
</div>
</div>
</template>
<style scoped>
.price-view {
padding: 2px 0;
}
.hero {
margin-bottom: 14px;
padding: 16px;
border-radius: 14px;
background:
radial-gradient(
70% 120% at 100% 0%,
hsl(84 70% 40% / 0.18),
transparent 55%
),
hsl(var(--muted) / 0.35);
}
.kicker {
font-size: 12px;
font-weight: 650;
color: hsl(84 55% 38%);
}
.title {
margin: 8px 0 0;
font-size: 18px;
font-weight: 720;
color: hsl(var(--foreground));
}
.desc {
margin: 8px 0 0;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.body {
padding: 14px;
border-radius: 14px;
border: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
}
</style>

View File

@@ -0,0 +1,234 @@
<script lang="ts" setup>
/**
* 消息场景 · 队列失败
* 在自定义弹层内展示任务详情 / 错误 / 重跑(不套 VbenModal
*/
import { computed, onMounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
Button,
Descriptions,
Modal,
Spin,
Tag,
message,
} from 'ant-design-vue';
import { TriangleAlert } from 'lucide-vue-next';
import {
getQueueJobDetail,
retryQueueJob,
} from '#/views/log/queue-job/api';
import { statusTagColor } from '#/views/log/queue-job/config/constants';
import { emitNoticeRefresh } from '#/views/notice/action/bus';
const props = defineProps<{
notice: Record<string, any>;
}>();
const router = useRouter();
const loading = ref(false);
const retrying = ref(false);
const job = ref<Record<string, any> | null>(null);
const payload = computed(() => props.notice?.action_payload || {});
const bizId = computed(() => Number(payload.value.biz_id || 0));
const canRetry = computed(() => {
const status = Number(job.value?.status);
return status === 3 || status === 4;
});
function formatJson(val: unknown): string {
if (val == null || val === '') return '(无)';
try {
return JSON.stringify(val, null, 2);
} catch {
return String(val);
}
}
async function loadJob() {
const id = bizId.value;
if (id <= 0) {
job.value = null;
return;
}
loading.value = true;
try {
const res = await getQueueJobDetail(id);
job.value = res?.data || res || null;
} catch {
message.error('加载队列详情失败');
job.value = null;
} finally {
loading.value = false;
}
}
function onRetry() {
const id = Number(job.value?.id || bizId.value || 0);
if (id <= 0) return;
Modal.confirm({
title: '确认重新执行?',
content:
`将基于 #${id} 新建一条任务并重新投递。` +
'订单/退款等业务重跑可能产生副作用,请确认业务幂等后再操作。',
okType: 'danger',
onOk: async () => {
retrying.value = true;
try {
const res = await retryQueueJob(id);
const newId = res?.id ?? res?.data?.id;
message.success(newId ? `已重新投递,新任务 #${newId}` : '已重新投递');
emitNoticeRefresh();
await loadJob();
} finally {
retrying.value = false;
}
},
});
}
function goMonitor() {
router.push(String(payload.value.route || '/log/queue-job/monitor'));
}
watch(() => bizId.value, loadJob);
onMounted(loadJob);
</script>
<template>
<div class="queue-scene">
<div class="alert">
<TriangleAlert class="size-6 shrink-0" />
<div>
<div class="alert-title">{{ notice.title || '队列任务失败' }}</div>
<p class="alert-desc">
{{ notice.detail || '请查看错误信息并视情况重跑' }}
</p>
</div>
</div>
<Spin :spinning="loading">
<div v-if="!bizId" class="empty">
<p>历史消息缺少任务 ID</p>
<Button type="primary" danger @click="goMonitor">打开队列监控</Button>
</div>
<template v-else-if="job">
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
size="small"
>
<Descriptions.Item label="ID">{{ job.id }}</Descriptions.Item>
<Descriptions.Item label="状态">
<Tag :color="statusTagColor(Number(job.status))">
{{ job.status_txt || job.status }}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="标题" :span="2">
{{ job.title || '-' }}
</Descriptions.Item>
<Descriptions.Item label="任务类型">
{{ job.job_name_txt || '-' }}
</Descriptions.Item>
<Descriptions.Item label="分类">
{{ job.category_txt || job.category }}
</Descriptions.Item>
<Descriptions.Item label="耗时">
{{ job.time_difference || '-' }}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{{ job.created_at || '-' }}
</Descriptions.Item>
<Descriptions.Item label="任务类名" :span="2">
<code class="break-all text-xs">{{ job.job_name || '-' }}</code>
</Descriptions.Item>
<Descriptions.Item label="错误信息" :span="2">
<pre class="snap-pre error-pre">{{
job.error_message || '(无)'
}}</pre>
</Descriptions.Item>
<Descriptions.Item label="payload" :span="2">
<pre class="snap-pre">{{ formatJson(job.payload) }}</pre>
</Descriptions.Item>
</Descriptions>
<div class="actions">
<Button @click="goMonitor">队列监控</Button>
<Button
v-if="canRetry"
type="primary"
danger
:loading="retrying"
@click="onRetry"
>
重新执行
</Button>
</div>
</template>
<div v-else-if="!loading" class="empty">未找到队列任务</div>
</Spin>
</div>
</template>
<style scoped>
.queue-scene {
padding: 2px 0 4px;
}
.alert {
display: flex;
gap: 12px;
align-items: flex-start;
margin-bottom: 14px;
padding: 14px;
border-radius: 14px;
border: 1px solid hsl(0 72% 50% / 0.28);
background: hsl(0 72% 50% / 0.08);
color: hsl(0 72% 42%);
}
.alert-title {
font-size: 15px;
font-weight: 700;
color: hsl(var(--foreground));
}
.alert-desc {
margin: 4px 0 0;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.empty {
padding: 28px 0;
text-align: center;
color: hsl(var(--muted-foreground));
}
.snap-pre {
margin: 0;
max-height: 220px;
overflow: auto;
white-space: pre-wrap;
word-break: break-all;
font-size: 12px;
line-height: 1.5;
color: hsl(var(--foreground));
}
.error-pre {
padding: 8px 10px;
border-radius: 8px;
background: hsl(0 72% 50% / 0.08);
color: hsl(0 72% 40%);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,103 @@
<script lang="ts" setup>
/**
* 通用富文本/Markdown 消息视图(系统通知 / 公告 / 处方等)
*/
import { computed } from 'vue';
import { usePreferences } from '@vben/preferences';
import { MdPreview } from 'md-editor-v3';
import 'md-editor-v3/lib/style.css';
const props = defineProps<{
notice: Record<string, any>;
}>();
const { isDark } = usePreferences();
const isMarkdown = computed(() => Number(props.notice?.edit_type) === 1);
const mdTheme = computed(() => (isDark.value ? 'dark' : 'light'));
const contentHtml = computed(() =>
String(props.notice?.content || '').replaceAll('\n', '<br>'),
);
const typeLabel = computed(
() => props.notice?.type_name || props.notice?.title || '通知',
);
</script>
<template>
<div class="rich-view">
<div class="hero">
<div class="kicker">{{ typeLabel }}</div>
<h3 class="title">{{ notice.title || '消息详情' }}</h3>
<p v-if="notice.detail" class="desc">{{ notice.detail }}</p>
<time v-if="notice.created_at" class="time">{{ notice.created_at }}</time>
</div>
<div class="body">
<MdPreview
v-if="isMarkdown"
:model-value="String(notice.content || '')"
:theme="mdTheme"
/>
<div
v-else
class="prose prose-sm dark:prose-invert max-w-none"
v-html="contentHtml"
></div>
</div>
</div>
</template>
<style scoped>
.rich-view {
padding: 2px 0;
}
.hero {
margin-bottom: 14px;
padding: 16px;
border-radius: 14px;
background:
radial-gradient(
80% 120% at 0% 0%,
hsl(var(--primary) / 0.12),
transparent 60%
),
hsl(var(--muted) / 0.35);
}
.kicker {
font-size: 12px;
font-weight: 650;
color: hsl(var(--primary));
}
.title {
margin: 8px 0 0;
font-size: 18px;
font-weight: 720;
letter-spacing: -0.02em;
color: hsl(var(--foreground));
}
.desc {
margin: 8px 0 0;
font-size: 13px;
line-height: 1.5;
color: hsl(var(--muted-foreground));
}
.time {
display: inline-block;
margin-top: 10px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.body {
padding: 14px;
border-radius: 14px;
border: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
min-height: 88px;
}
</style>

View File

@@ -0,0 +1,422 @@
<script lang="ts" setup>
/**
* 消息中心 · 门店录入快捷审核
* 拉详情展示门店资料/合同/关联医生,支持 ERP/MES + 一并审医生
*/
import { computed, onMounted, ref, watch } from 'vue';
import {
Avatar,
Button,
Descriptions,
DescriptionsItem,
Image,
Input,
Radio,
RadioGroup,
Spin,
Textarea,
Tag,
message,
} from 'ant-design-vue';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { emitNoticeRefresh } from '#/views/notice/action/bus';
import {
auditStoreInput,
getStoreInputDetail,
} from '#/views/system/store-input/api';
import DoctorInputCardGrid from '#/views/system/store-input/components/DoctorInputCardGrid.vue';
import {
getLinkedDoctors,
isDoctorPending,
} from '#/views/system/store-input/utils/doctorInputDisplay';
import {
inputUserDisplayName,
normalizeInputUser,
resolveAvatarUrl,
} from '#/views/system/store-input/utils/inputUser';
const props = defineProps<{
notice: Record<string, any>;
}>();
const emit = defineEmits<{
done: [];
}>();
const loading = ref(false);
const submitting = ref(false);
const detail = ref<Record<string, any> | null>(null);
const auditStatus = ref(1);
const auditRemark = ref('');
const erpId = ref('');
const mesId = ref('');
const selectedDoctorIds = ref<number[]>([]);
const bizId = computed(() => Number(props.notice?.action_payload?.biz_id || 0));
const canAudit = computed(() => Number(detail.value?.status) === 0);
const doctors = computed(() =>
detail.value ? getLinkedDoctors(detail.value) : [],
);
const pendingDoctorIds = computed(() =>
doctors.value
.filter((d) => isDoctorPending(d))
.map((d) => Number(d.id))
.filter((id) => id > 0),
);
const carousel = computed(() => {
const url = detail.value?.url;
return Array.isArray(url) ? url.filter(Boolean) : [];
});
const contracts = computed(() => {
const files = detail.value?.contract_files;
return Array.isArray(files) ? files : [];
});
function statusMeta(status: number) {
if (status === 0) return { color: 'orange', text: '待审核' };
if (status === 1) return { color: 'success', text: '审核通过' };
if (status === 2) return { color: 'error', text: '审核拒绝' };
return { color: 'default', text: '-' };
}
async function loadDetail() {
const id = bizId.value;
if (id <= 0) {
detail.value = null;
return;
}
loading.value = true;
detail.value = null;
try {
const res = await getStoreInputDetail(id);
detail.value = res || null;
normalizeInputUser(detail.value);
erpId.value =
res?.erp_id != null && String(res.erp_id) !== ''
? String(res.erp_id)
: '';
mesId.value =
res?.mes_id != null && String(res.mes_id) !== ''
? String(res.mes_id)
: '';
selectedDoctorIds.value = getLinkedDoctors(detail.value)
.filter((d) => isDoctorPending(d))
.map((d) => Number(d.id))
.filter((id) => id > 0);
} catch {
message.error('加载门店录入详情失败');
detail.value = null;
} finally {
loading.value = false;
}
}
watch(auditStatus, (v) => {
if (v !== 1) {
selectedDoctorIds.value = [];
} else {
selectedDoctorIds.value = [...pendingDoctorIds.value];
}
});
async function submitAudit(forceStatus?: number) {
const id = Number(detail.value?.id || bizId.value || 0);
if (id <= 0) {
message.error('缺少录入 ID');
return;
}
if (!canAudit.value) {
message.warning('该录入已审核');
return;
}
if (typeof forceStatus === 'number') {
auditStatus.value = forceStatus;
}
if (auditStatus.value === 1 && !erpId.value.trim()) {
message.error('审核通过时请填写 ERP ID');
return;
}
submitting.value = true;
try {
const payload: Parameters<typeof auditStoreInput>[0] = {
id,
status: auditStatus.value,
audit_remark: auditRemark.value,
erp_id: auditStatus.value === 1 ? erpId.value.trim() : undefined,
mes_id:
auditStatus.value === 1 && mesId.value.trim()
? mesId.value.trim()
: undefined,
};
if (auditStatus.value === 1 && selectedDoctorIds.value.length > 0) {
payload.doctor_input_ids = [...selectedDoctorIds.value];
}
await auditStoreInput(payload);
const n = auditStatus.value === 1 ? selectedDoctorIds.value.length : 0;
message.success(
n > 0 ? `审核成功,已一并通过 ${n} 位医生预填` : '审核成功',
);
emitNoticeRefresh();
emit('done');
} catch {
message.error('审核失败,请稍后重试');
} finally {
submitting.value = false;
}
}
watch(() => bizId.value, loadDetail);
onMounted(loadDetail);
</script>
<template>
<div class="store-quick">
<div class="head">
<div>
<div class="kicker">门店录入待审</div>
<div class="title">{{ detail?.name || notice.title || '门店录入' }}</div>
<p class="desc">{{ notice.detail || '请核对门店资料后完成审核' }}</p>
</div>
<Tag v-if="detail" :color="statusMeta(Number(detail.status)).color">
{{ statusMeta(Number(detail.status)).text }}
</Tag>
</div>
<Spin :spinning="loading">
<div v-if="!bizId" class="empty">消息未携带录入 ID</div>
<div v-else-if="!loading && !detail" class="empty">未找到录入记录</div>
<template v-else-if="detail">
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
size="small"
>
<DescriptionsItem label="录入 ID">#{{ detail.id }}</DescriptionsItem>
<DescriptionsItem label="类型">
{{ Number(detail.type) === 1 ? '药店' : '诊所' }}
</DescriptionsItem>
<DescriptionsItem label="诊所类型">
{{
Number(detail.clinic_type) === 1
? '西医诊所'
: Number(detail.clinic_type) === 2
? '中医诊所'
: '未设置'
}}
</DescriptionsItem>
<DescriptionsItem label="联系人">{{ detail.contact || '-' }}</DescriptionsItem>
<DescriptionsItem label="联系电话">
<SensitiveText :record="detail" field="mobile" />
</DescriptionsItem>
<DescriptionsItem label="省市区">
{{ detail.province?.name || '' }} {{ detail.city?.name || '' }}
</DescriptionsItem>
<DescriptionsItem label="地址" :span="2">
{{ detail.position || '-' }}
</DescriptionsItem>
<DescriptionsItem label="录入人">
<div class="input-user">
<Avatar
v-if="resolveAvatarUrl(detail.inputUser?.avatar)"
:src="resolveAvatarUrl(detail.inputUser?.avatar)"
:size="28"
/>
<span>{{ inputUserDisplayName(detail.inputUser) }}</span>
</div>
</DescriptionsItem>
</Descriptions>
<div v-if="carousel.length || detail.see_rate || contracts.length" class="media">
<div v-if="detail.see_rate" class="media-item">
<div class="media-label">公章</div>
<Image
:src="resolveAvatarUrl(detail.see_rate) || detail.see_rate"
:width="88"
:height="88"
:preview="true"
/>
</div>
<div v-for="(img, idx) in carousel" :key="idx" class="media-item">
<div class="media-label">轮播 {{ idx + 1 }}</div>
<Image
:src="resolveAvatarUrl(img) || img"
:width="88"
:height="88"
:preview="true"
/>
</div>
<div v-for="(f, idx) in contracts" :key="`c-${idx}`" class="media-item file">
<div class="media-label">合同</div>
<a :href="f.file_url" target="_blank" rel="noreferrer">{{
f.file_name || '查看文件'
}}</a>
</div>
</div>
<div v-if="doctors.length" class="doctors">
<div class="section-title">关联医生预填</div>
<DoctorInputCardGrid
v-model:selected-ids="selectedDoctorIds"
:doctors="doctors"
:selectable="canAudit && auditStatus === 1"
only-pending-selectable
/>
</div>
<div v-if="canAudit" class="audit-box">
<div class="section-title">快捷审核</div>
<RadioGroup v-model:value="auditStatus" class="mb-3">
<Radio :value="1">审核通过</Radio>
<Radio :value="2">审核拒绝</Radio>
</RadioGroup>
<div v-if="auditStatus === 1" class="field">
<div class="field-label">ERP ID必填</div>
<Input v-model:value="erpId" placeholder="审核通过必填" />
</div>
<div v-if="auditStatus === 1" class="field">
<div class="field-label">MES ID可选</div>
<Input v-model:value="mesId" placeholder="煎药中心编码" />
</div>
<div class="field">
<div class="field-label">审核备注</div>
<Textarea v-model:value="auditRemark" :rows="3" placeholder="可选" />
</div>
<div class="actions">
<Button
danger
size="large"
:loading="submitting && auditStatus === 2"
:disabled="submitting"
@click="submitAudit(2)"
>
拒绝
</Button>
<Button
type="primary"
size="large"
:loading="submitting && auditStatus === 1"
:disabled="submitting"
@click="submitAudit(1)"
>
通过审核
</Button>
</div>
</div>
<div v-else class="done-tip">该录入已处理</div>
</template>
</Spin>
</div>
</template>
<style scoped>
.store-quick {
padding: 2px 0 4px;
}
.head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
padding: 14px 16px;
border-radius: 14px;
background:
radial-gradient(
70% 120% at 0% 0%,
hsl(var(--primary) / 0.14),
transparent 55%
),
hsl(var(--muted) / 0.35);
}
.kicker {
font-size: 12px;
font-weight: 650;
color: hsl(var(--primary));
}
.title {
margin-top: 4px;
font-size: 17px;
font-weight: 700;
color: hsl(var(--foreground));
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.empty,
.done-tip {
padding: 28px 0;
text-align: center;
color: hsl(var(--muted-foreground));
}
.input-user {
display: inline-flex;
align-items: center;
gap: 8px;
}
.media {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 14px;
}
.media-item {
padding: 8px;
border-radius: 10px;
border: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
}
.media-item.file {
min-width: 120px;
}
.media-label,
.field-label,
.section-title {
margin-bottom: 6px;
font-size: 12px;
font-weight: 650;
color: hsl(var(--muted-foreground));
}
.section-title {
margin-top: 16px;
margin-bottom: 10px;
font-size: 14px;
color: hsl(var(--foreground));
}
.doctors {
margin-top: 4px;
}
.audit-box {
margin-top: 16px;
padding: 14px;
border-radius: 12px;
border: 1px solid hsl(var(--border));
background: hsl(var(--muted) / 0.28);
}
.field {
margin-bottom: 10px;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 12px;
}
</style>

View File

@@ -0,0 +1,340 @@
<script lang="ts" setup>
/**
* 消息中心 · VIP 通知高级视图
* 优先用 payload 徽标;缺失时按 level_id / duration_type 回补,避免演示/旧数据只显示「-」
*/
import { computed, onMounted, ref, watch } from 'vue';
import { Spin } from 'ant-design-vue';
import VipBadgeCombo from '#/components/vip/VipBadgeCombo.vue';
import { getVipDurationOption } from '#/views/system/vip/duration/api';
import { getVipLevelInfo, getVipLevelOption } from '#/views/system/vip/level/api';
const props = defineProps<{
notice: Record<string, any>;
}>();
const loading = ref(false);
const card = ref<Record<string, any>>({});
function parsePayload(): Record<string, any> {
const p = props.notice?.action_payload;
if (p && typeof p === 'object') return { ...p };
try {
return JSON.parse(String(props.notice?.content || '')) || {};
} catch {
return {};
}
}
const scene = computed(() => {
const type = String(card.value?.type || '');
if (type === 'vip_open_success') {
return { label: '开通成功', tone: 'success' };
}
if (type === 'vip_expiring') {
return { label: '即将到期', tone: 'warn' };
}
if (type === 'vip_expired') {
return { label: '已到期', tone: 'danger' };
}
return { label: props.notice?.type_name || 'VIP 通知', tone: 'normal' };
});
const hasBadge = computed(
() => !!(card.value?.badge_url || card.value?.duration_badge_url),
);
const monogram = computed(() => {
const name = String(card.value?.level_name || card.value?.level_code || 'VIP');
return name.slice(0, 2).toUpperCase();
});
const expireText = computed(() => {
if (card.value?.is_lifetime || Number(card.value?.expire_at) === 0) {
return card.value?.duration_label || '终身有效';
}
const at = Number(card.value?.expire_at || 0);
if (!at) return card.value?.duration_label || '—';
if (typeof card.value?.expire_at === 'string' && String(card.value.expire_at).includes('-')) {
return String(card.value.expire_at);
}
const d = new Date(at < 1e12 ? at * 1000 : at);
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
});
/**
* 补齐等级徽标 / 期限丝带 URL演示 SQL 常缺图)
*/
async function hydrateBadges(base: Record<string, any>) {
const next = { ...base };
try {
if (!next.badge_url) {
if (Number(next.level_id) > 0) {
const level = await getVipLevelInfo(Number(next.level_id));
next.badge_url = level?.badge_url || next.badge_url;
next.level_name = next.level_name || level?.name;
next.level_code = next.level_code || level?.code;
} else if (next.level_code) {
const opts = (await getVipLevelOption()) || [];
const hit = (Array.isArray(opts) ? opts : []).find(
(o: any) => String(o.code) === String(next.level_code),
);
if (hit) {
next.badge_url = hit.badge_url || '';
next.level_id = hit.id || next.level_id;
next.level_name = next.level_name || hit.name;
}
}
}
if (!next.duration_badge_url) {
const durations = (await getVipDurationOption()) || [];
const list = Array.isArray(durations) ? durations : [];
const hit =
list.find((o: any) => String(o.code) === String(next.duration_type)) ||
list.find((o: any) => String(o.name) === String(next.duration_label));
if (hit) {
next.duration_badge_url = hit.badge_url || '';
next.duration_label = next.duration_label || hit.name;
}
}
} catch {
/* 徽标补齐失败仍展示文案卡 */
}
return next;
}
async function loadCard() {
loading.value = true;
try {
card.value = await hydrateBadges(parsePayload());
} finally {
loading.value = false;
}
}
watch(
() => [props.notice?.id, props.notice?.action_payload],
() => loadCard(),
{ deep: true },
);
onMounted(loadCard);
</script>
<template>
<Spin :spinning="loading">
<div class="vip-view">
<div class="vip-card" :class="`tone-${scene.tone}`">
<div class="vip-card__glow" aria-hidden="true"></div>
<div class="vip-card__top">
<span class="scene-pill">{{ scene.label }}</span>
<span v-if="card.level_code" class="code-pill">{{ card.level_code }}</span>
</div>
<div class="vip-card__body">
<div class="badge-wrap">
<VipBadgeCombo
v-if="hasBadge"
size="xl"
:badge-url="card.badge_url"
:duration-badge-url="card.duration_badge_url"
:level-name="card.level_name"
:duration-label="card.duration_label"
/>
<div v-else class="mono">{{ monogram }}</div>
</div>
<div class="info">
<div class="level-name">{{ card.level_name || '会员' }}</div>
<div class="duration">{{ card.duration_label || '会员权益' }}</div>
<div class="store">{{ card.store_name || '门店' }}</div>
<div class="expire">有效期至 {{ expireText }}</div>
</div>
</div>
</div>
<div class="price-row">
<div class="price-item">
<span class="label">原价</span>
<span class="val">¥{{ card.original_price || '0.00' }}</span>
</div>
<div class="price-item accent">
<span class="label">实付</span>
<span class="val">¥{{ card.pay_amount || '0.00' }}</span>
</div>
</div>
<p v-if="notice.detail" class="detail">{{ notice.detail }}</p>
<div v-if="card.store_id || card.open_record_id" class="meta-line">
<span v-if="card.store_id">门店 ID #{{ card.store_id }}</span>
<span v-if="card.open_record_id">开通记录 #{{ card.open_record_id }}</span>
</div>
</div>
</Spin>
</template>
<style scoped>
.vip-view {
padding: 2px 0 6px;
}
.vip-card {
position: relative;
overflow: hidden;
padding: 20px 22px 22px;
border-radius: 18px;
color: #fff;
background:
linear-gradient(135deg, #1f2a37 0%, #2d4a3e 48%, #3d9b88 100%);
box-shadow:
0 12px 32px hsl(160 40% 20% / 0.28),
inset 0 1px 0 hsl(0 0% 100% / 0.12);
}
.vip-card.tone-warn {
background: linear-gradient(135deg, #2a2418 0%, #5c4020 52%, #b8860b 100%);
}
.vip-card.tone-danger {
background: linear-gradient(135deg, #2a181c 0%, #5a2430 52%, #a33b4a 100%);
}
.vip-card__glow {
position: absolute;
inset: -40% auto auto -20%;
width: 70%;
height: 90%;
background: radial-gradient(circle, hsl(0 0% 100% / 0.18), transparent 68%);
pointer-events: none;
}
.vip-card__top {
position: relative;
z-index: 1;
display: flex;
gap: 8px;
align-items: center;
margin-bottom: 16px;
}
.scene-pill,
.code-pill {
display: inline-flex;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
background: hsl(0 0% 100% / 0.16);
backdrop-filter: blur(6px);
}
.vip-card__body {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 22px;
}
.badge-wrap {
flex-shrink: 0;
filter: drop-shadow(0 10px 18px hsl(0 0% 0% / 0.35));
}
.mono {
width: 120px;
height: 120px;
border-radius: 50%;
display: grid;
place-items: center;
font-size: 36px;
font-weight: 800;
letter-spacing: 0.04em;
color: #fff;
background: hsl(0 0% 100% / 0.16);
border: 1px solid hsl(0 0% 100% / 0.22);
}
.info {
min-width: 0;
flex: 1;
}
.level-name {
font-size: 24px;
font-weight: 780;
letter-spacing: -0.03em;
line-height: 1.2;
}
.duration {
margin-top: 6px;
font-size: 14px;
opacity: 0.92;
}
.store {
margin-top: 10px;
font-size: 13px;
opacity: 0.86;
}
.expire {
margin-top: 4px;
font-size: 12px;
opacity: 0.72;
}
.price-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 14px;
}
.price-item {
padding: 12px 14px;
border-radius: 12px;
border: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
}
.price-item.accent {
border-color: hsl(var(--primary) / 0.35);
background: hsl(var(--primary) / 0.08);
}
.price-item .label {
display: block;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.price-item .val {
display: block;
margin-top: 4px;
font-size: 20px;
font-weight: 720;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
color: hsl(var(--foreground));
}
.detail {
margin: 14px 0 0;
font-size: 13px;
line-height: 1.55;
color: hsl(var(--muted-foreground));
}
.meta-line {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 10px;
font-size: 12px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -0,0 +1,197 @@
<script lang="ts" setup>
/**
* 消息中心 · 仓品待审预览
* 展示药品图/规格/报价;完整审核表单较复杂,点「去审核」打开业务审核 Modal
*/
import { computed, onMounted, ref, watch } from 'vue';
import { Button, Descriptions, Image, Spin, Tag, message } from 'ant-design-vue';
import { getDeliveryWarehouseProductInfo } from '#/views/system/delivery-warehouse-product-audit/api';
const props = defineProps<{
notice: Record<string, any>;
}>();
const emit = defineEmits<{
audit: [];
}>();
const loading = ref(false);
const detail = ref<Record<string, any> | null>(null);
const bizId = computed(() => Number(props.notice?.action_payload?.biz_id || 0));
async function loadDetail() {
const id = bizId.value;
if (id <= 0) {
detail.value = null;
return;
}
loading.value = true;
try {
const res = await getDeliveryWarehouseProductInfo(id);
detail.value = res || null;
} catch {
message.error('加载仓品详情失败');
detail.value = null;
} finally {
loading.value = false;
}
}
watch(() => bizId.value, loadDetail);
onMounted(loadDetail);
</script>
<template>
<div class="wh-view">
<div class="head">
<div>
<div class="kicker">仓品待审</div>
<div class="title">
{{ detail?.drug_name || notice.title || '仓品上传待审核' }}
</div>
<p class="desc">{{ notice.detail || '请核对药品信息后完成分区分类审核' }}</p>
</div>
<div class="tags">
<Tag v-if="detail?.type_txt">{{ detail.type_txt }}</Tag>
<Tag v-if="detail?.audit_status_txt" color="orange">{{
detail.audit_status_txt
}}</Tag>
</div>
</div>
<Spin :spinning="loading">
<div v-if="!bizId" class="empty">消息未携带商品 ID</div>
<div v-else-if="!loading && !detail" class="empty">未找到仓品记录</div>
<div v-else-if="detail" class="body">
<div class="preview">
<div v-if="detail.image" class="thumb">
<Image
:src="detail.image"
:width="120"
:height="120"
:preview="true"
/>
</div>
<div v-else class="thumb placeholder">无图</div>
<Descriptions
class="facts"
:column="1"
size="small"
bordered
>
<Descriptions.Item label="商品 ID">#{{ detail.id }}</Descriptions.Item>
<Descriptions.Item label="规格">{{
detail.specification || '—'
}}</Descriptions.Item>
<Descriptions.Item label="上传报价">
¥{{ detail.upload_quote ?? '—' }}
</Descriptions.Item>
<Descriptions.Item label="当前状态">
{{ detail.status_txt || '—' }}
<span v-if="detail.audit_status_txt">
· {{ detail.audit_status_txt }}
</span>
</Descriptions.Item>
</Descriptions>
</div>
<div class="actions">
<Button type="primary" size="large" @click="emit('audit')">
打开完整审核
</Button>
</div>
</div>
</Spin>
</div>
</template>
<style scoped>
.wh-view {
padding: 2px 0 4px;
}
.head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 14px;
padding: 14px 16px;
border-radius: 14px;
background:
radial-gradient(
70% 120% at 100% 0%,
hsl(190 80% 45% / 0.16),
transparent 55%
),
hsl(var(--muted) / 0.35);
}
.kicker {
font-size: 12px;
font-weight: 650;
color: hsl(var(--primary));
}
.title {
margin-top: 4px;
font-size: 17px;
font-weight: 700;
color: hsl(var(--foreground));
}
.desc {
margin: 6px 0 0;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
justify-content: flex-end;
}
.empty {
padding: 32px 0;
text-align: center;
color: hsl(var(--muted-foreground));
}
.preview {
display: grid;
grid-template-columns: 120px 1fr;
gap: 14px;
align-items: start;
}
@media (max-width: 640px) {
.preview {
grid-template-columns: 1fr;
}
}
.thumb {
border-radius: 12px;
overflow: hidden;
border: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
}
.thumb.placeholder {
width: 120px;
height: 120px;
display: grid;
place-items: center;
color: hsl(var(--muted-foreground));
font-size: 13px;
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,136 @@
<script lang="ts" setup>
/**
* 提现待审定制视图:金额强调 + 通过/拒绝交给 ActionHost 打开业务审核
*/
import { computed } from 'vue';
import { Button } from 'ant-design-vue';
const props = defineProps<{
notice: Record<string, any>;
}>();
const emit = defineEmits<{
audit: [modalType: number];
}>();
const payload = computed(() => props.notice?.action_payload || {});
</script>
<template>
<div class="withdraw-view">
<div class="hero">
<div class="kicker">提现待审</div>
<div class="amount">
<span class="currency">¥</span>{{ payload.amount || '—' }}
</div>
<p class="subject">{{ notice.detail || '有一笔提现申请等待你处理' }}</p>
</div>
<dl class="facts">
<div class="fact">
<dt>单号</dt>
<dd>{{ payload.order_no || '—' }}</dd>
</div>
<div class="fact">
<dt>申请 ID</dt>
<dd>#{{ payload.biz_id || '—' }}</dd>
</div>
<div class="fact">
<dt>时间</dt>
<dd>{{ notice.created_at || '—' }}</dd>
</div>
</dl>
<div class="actions">
<Button danger size="large" @click="emit('audit', 1)">拒绝</Button>
<Button type="primary" size="large" @click="emit('audit', 0)">
通过审核
</Button>
</div>
</div>
</template>
<style scoped>
.withdraw-view {
padding: 4px 0;
}
.hero {
padding: 20px 18px;
border-radius: 16px;
background:
radial-gradient(
70% 100% at 100% 0%,
hsl(var(--primary) / 0.2),
transparent 55%
),
linear-gradient(
160deg,
hsl(var(--muted) / 0.55),
hsl(var(--card, var(--background)))
);
border: 1px solid hsl(var(--border));
}
.kicker {
font-size: 12px;
font-weight: 650;
color: hsl(var(--primary));
}
.amount {
margin-top: 10px;
font-size: 40px;
font-weight: 780;
letter-spacing: -0.04em;
font-variant-numeric: tabular-nums;
color: hsl(var(--foreground));
}
.currency {
margin-right: 2px;
font-size: 22px;
font-weight: 650;
}
.subject {
margin: 8px 0 0;
font-size: 13px;
color: hsl(var(--muted-foreground));
}
.facts {
display: grid;
gap: 10px;
margin: 16px 0 0;
}
.fact {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 10px;
background: hsl(var(--card, var(--background)));
border: 1px solid hsl(var(--border));
}
.fact dt {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
.fact dd {
margin: 0;
font-size: 13px;
font-weight: 600;
color: hsl(var(--foreground));
font-variant-numeric: tabular-nums;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 18px;
}
</style>

View File

@@ -0,0 +1,43 @@
/**
* 消息类型 → 定制展示组件注册表
* 点击消息时优先按 type_code 选视图,再叠加 action_type 打开业务审核
*/
import type { Component } from 'vue';
import DoctorInputNoticeView from './DoctorInputNoticeView.vue';
import InvoiceNoticeView from './InvoiceNoticeView.vue';
import PeriodReportView from './PeriodReportView.vue';
import PriceChangeNoticeView from './PriceChangeNoticeView.vue';
import QueueFailureView from './QueueFailureView.vue';
import RichTextNoticeView from './RichTextNoticeView.vue';
import StoreInputNoticeView from './StoreInputNoticeView.vue';
import VipNoticeView from './VipNoticeView.vue';
import WarehouseProductNoticeView from './WarehouseProductNoticeView.vue';
import WithdrawNoticeView from './WithdrawNoticeView.vue';
export const NOTICE_TYPE_VIEW: Record<string, Component> = {
vip: VipNoticeView,
period_report: PeriodReportView,
withdraw_audit: WithdrawNoticeView,
invoice_todo: InvoiceNoticeView,
queue_failure: QueueFailureView,
doctor_input_audit: DoctorInputNoticeView,
store_input_audit: StoreInputNoticeView,
warehouse_product_audit: WarehouseProductNoticeView,
price_change: PriceChangeNoticeView,
system: RichTextNoticeView,
broadcast: RichTextNoticeView,
prescription_audit: RichTextNoticeView,
};
export function resolveNoticeView(typeCode?: string): Component {
const code = String(typeCode || '').trim();
return NOTICE_TYPE_VIEW[code] || RichTextNoticeView;
}
/** 仍需继续打开外部业务审核 Modal 的类型(视图内 emit audit */
export const AUDIT_TYPE_CODES = new Set([
'withdraw_audit',
'warehouse_product_audit',
'invoice_todo',
]);

View File

@@ -0,0 +1,24 @@
import { requestClient } from '#/api/request';
/** 站内信类型字典 API */
const prefix = 'admin-notice-type/';
export async function getAdminNoticeTypeList(data: any) {
return requestClient.get<any>(`${prefix}list`, { params: data });
}
export async function getAdminNoticeTypeOption(data?: any) {
return requestClient.get<any>(`${prefix}option`, { params: data });
}
export async function createAdminNoticeType(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}create`, data);
}
export async function updateAdminNoticeType(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}update`, data);
}
export async function deleteAdminNoticeType(data: Record<string, any>) {
return requestClient.post<any>(`${prefix}delete`, data);
}

View File

@@ -0,0 +1,69 @@
<script lang="ts" setup>
/**
* 站内信类型字典新增/编辑弹窗
*/
import { ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createAdminNoticeType, updateAdminNoticeType } from '../api';
import { modalFormProps } from '../config/form';
const isUpdate = ref(false);
const gridApi = ref();
const [Form, formApi] = useVbenForm(modalFormProps);
const [Modal, modalApi] = useVbenModal({
fullscreenButton: false,
draggable: true,
onCancel() {
modalApi.close();
},
onConfirm: async () => {
const e = await formApi.validate();
if (!e.valid) return;
const values = await formApi.getValues();
modalApi.setState({ confirmLoading: true });
try {
const api = isUpdate.value ? updateAdminNoticeType : createAdminNoticeType;
await api(values);
message.success('保存成功');
gridApi.value?.query?.();
gridApi.value?.reload?.();
modalApi.close();
} finally {
modalApi.setState({ confirmLoading: false });
}
},
onOpenChange(isOpen: boolean) {
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
if (!isOpen) {
formApi.resetForm();
return;
}
const { values, update } = modalApi.getData<Record<string, any>>() || {};
isUpdate.value = !!update;
// 编辑时锁定 code避免业务硬编码失效
formApi.updateSchema([
{
fieldName: 'code',
componentProps: { disabled: !!update },
},
]);
if (values && update) {
formApi.setValues({ ...values });
} else {
formApi.resetForm();
formApi.setValues({ sort: 0, status: 1, color: 'sky', icon: '', cover_url: '' });
}
},
});
</script>
<template>
<Modal :title="`${isUpdate ? '编辑' : '新增'}消息类型`" class="w-[560px]">
<Form />
</Modal>
</template>

View File

@@ -0,0 +1,22 @@
/** 站内信类型字典常量 */
export const STATUS_OPTIONS = [
{ label: '启用', value: 1 },
{ label: '禁用', value: 0 },
];
/** 主题色 token与铃铛/卡片 CSS 映射一致 */
export const COLOR_OPTIONS = [
{ label: '天蓝 sky', value: 'sky' },
{ label: '琥珀 amber', value: 'amber' },
{ label: '翠绿 emerald', value: 'emerald' },
{ label: '青绿 teal', value: 'teal' },
{ label: '青色 cyan', value: 'cyan' },
{ label: '橙色 orange', value: 'orange' },
{ label: '紫色 violet', value: 'violet' },
{ label: '靛蓝 indigo', value: 'indigo' },
{ label: '蓝色 blue', value: 'blue' },
{ label: '绿色 green', value: 'green' },
{ label: '红色 red', value: 'red' },
{ label: '酸橙 lime', value: 'lime' },
];

View File

@@ -0,0 +1,75 @@
import type { VbenFormProps } from '#/adapter/form';
import { COLOR_OPTIONS, STATUS_OPTIONS } from './constants';
/** 站内信类型表单 */
export const modalFormProps: VbenFormProps = {
wrapperClass: 'grid-cols-12',
commonConfig: {
formItemClass: 'col-span-12',
componentProps: { class: 'w-full' },
},
layout: 'horizontal',
schema: [
{
component: 'VbenInput',
fieldName: 'id',
label: 'ID',
dependencies: { show: false, triggerFields: ['id'] },
},
{
component: 'VbenInput',
fieldName: 'code',
label: '类型编码',
rules: 'required',
componentProps: { placeholder: 'snake_case如 withdraw_audit' },
},
{
component: 'VbenInput',
fieldName: 'name',
label: '展示名称',
rules: 'required',
componentProps: { placeholder: '如:提现待审' },
},
{
component: 'VbenInput',
fieldName: 'icon',
label: '图标',
componentProps: {
placeholder: 'iconify 名,如 ant-design:bell-outlined',
},
defaultValue: '',
},
{
component: 'VbenInput',
fieldName: 'cover_url',
label: '封面图',
componentProps: { placeholder: '可选图片 URL' },
defaultValue: '',
},
{
component: 'VbenSelect',
fieldName: 'color',
label: '主题色',
formItemClass: 'col-span-6',
componentProps: { options: COLOR_OPTIONS },
defaultValue: 'sky',
},
{
component: 'InputNumber',
fieldName: 'sort',
label: '排序',
formItemClass: 'col-span-6',
componentProps: { min: 0 },
defaultValue: 0,
},
{
component: 'VbenSelect',
fieldName: 'status',
label: '状态',
componentProps: { options: STATUS_OPTIONS },
defaultValue: 1,
},
],
showDefaultActions: false,
};

View File

@@ -0,0 +1,178 @@
<script lang="ts" setup>
/**
* 站内信类型字典:维护 code/图标/主题色,供消息中心与铃铛展示
*/
import type { VxeGridListeners } from '#/adapter/vxe-table';
import { ref } from 'vue';
import { Page, useVbenModal } from '@vben/common-ui';
import { message } from 'ant-design-vue';
import { useVbenVxeGrid } from '#/adapter/vxe-table';
import { TableAction } from '#/components/table-action';
import {
deleteAdminNoticeType,
getAdminNoticeTypeList,
} from './api';
import FormModal from './components/modal.vue';
import { STATUS_OPTIONS } from './config/constants';
defineOptions({ name: 'AdminNoticeType' });
const hasTopTableDropDownActions = ref(false);
const formOptions = {
schema: [
{
component: 'VbenInput',
fieldName: 'code',
label: '编码',
componentProps: { placeholder: 'type_code' },
},
{
component: 'VbenInput',
fieldName: 'name',
label: '名称',
componentProps: { placeholder: '展示名' },
},
{
component: 'VbenSelect',
fieldName: 'status',
label: '状态',
componentProps: { options: STATUS_OPTIONS, allowClear: true },
},
],
};
const gridOptions = {
checkboxConfig: { highlight: true, labelField: '' },
columns: [
{ type: 'checkbox', width: 60 },
{ field: 'id', title: 'ID', width: 80 },
{ field: 'code', title: '编码', minWidth: 160 },
{ field: 'name', title: '名称', minWidth: 120 },
{ field: 'icon', title: '图标', minWidth: 180 },
{ field: 'color', title: '主题色', width: 100 },
{ field: 'sort', title: '排序', width: 80 },
{ field: 'status_txt', title: '状态', width: 80 },
{ field: 'created_at', title: '创建时间', width: 170 },
{ title: '操作', slots: { default: 'action' }, width: 140, fixed: 'right' },
],
keepSource: true,
pagerConfig: {},
proxyConfig: {
ajax: {
query: async ({ page }: any, formValues: any) => {
return await getAdminNoticeTypeList({
page: page.currentPage,
pageSize: page.pageSize,
...formValues,
});
},
},
},
height: 'auto',
toolbarConfig: {
search: true,
refresh: true,
slots: { buttons: 'toolbar-actions' },
},
};
const gridEvents: VxeGridListeners<any> = {
checkboxChange() {
hasTopTableDropDownActions.value =
gridApi.grid.getCheckboxRecords().length > 0;
},
checkboxAll() {
hasTopTableDropDownActions.value =
gridApi.grid.getCheckboxRecords().length > 0;
},
};
const [Grid, gridApi] = useVbenVxeGrid({
formOptions,
gridOptions: gridOptions as any,
gridEvents,
});
const [FormModalComp, formModalApi] = useVbenModal({
connectedComponent: FormModal,
});
const showModal = (data: any = {}, isUpdate = false) => {
formModalApi.setData({
values: data,
update: isUpdate,
gridApi,
});
formModalApi.open();
};
const handleDelete = (row: any) => {
deleteAdminNoticeType({ ids: [row.id] }).then(() => {
message.success('删除成功');
gridApi.query();
});
};
const handleBatchDelete = () => {
const rows = gridApi.grid.getCheckboxRecords();
if (!rows.length) return;
deleteAdminNoticeType({ ids: rows.map((r: any) => r.id) }).then(() => {
message.success('删除成功');
gridApi.query();
});
};
</script>
<template>
<Page auto-content-height>
<FormModalComp />
<Grid>
<template #toolbar-actions>
<TableAction
:actions="[
{
label: '新增',
type: 'primary',
onClick: () => showModal({}, false),
},
]"
:drop-down-actions="
hasTopTableDropDownActions
? [
{
label: '批量删除',
popConfirm: {
title: '确认删除选中类型',
confirm: handleBatchDelete,
},
},
]
: []
"
/>
</template>
<template #action="{ row }">
<TableAction
:actions="[
{
label: '编辑',
onClick: () => showModal(row, true),
},
{
label: '删除',
popConfirm: {
title: '确认删除该类型?',
confirm: () => handleDelete(row),
},
},
]"
/>
</template>
</Grid>
</Page>
</template>

View File

@@ -1,38 +1,93 @@
<script lang="ts" setup>
import { ref } from 'vue';
/**
* 医生预填审核弹窗(列表页)
* 打开时按 ID 拉取详情,避免只显示通过/拒绝而看不到预填资料
*/
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
import { message, Radio, RadioGroup, Textarea } from 'ant-design-vue';
import {
Avatar,
Descriptions,
DescriptionsItem,
Image,
Radio,
RadioGroup,
Spin,
Textarea,
message,
} from 'ant-design-vue';
import { auditDoctorInput } from '../api';
import SensitiveText from '#/components/sensitive-text/SensitiveText.vue';
import { auditDoctorInput, getDoctorInputDetail } from '../api';
import {
inputUserDisplayName,
normalizeInputUser,
resolveAvatarUrl,
} from '../../store-input/utils/inputUser';
const auditStatus = ref<number>(1);
const auditRemark = ref<string>('');
const currentRow = ref<any>(null);
const detail = ref<Record<string, any> | null>(null);
const gridApi = ref<any>(null);
const loading = ref(false);
const detailLoading = ref(false);
const display = computed(() => detail.value || currentRow.value || null);
function typeText(type: number) {
if (type === 1) return '中医医生';
if (type === 2) return '西医医生';
return '-';
}
function signTypeText(signType: number) {
if (signType === 1) return '电子签名';
if (signType === 2) return '手写签名';
return '-';
}
function imgSrc(url?: string | null) {
return resolveAvatarUrl(url) || String(url || '');
}
async function loadDetail(id: number) {
detailLoading.value = true;
detail.value = null;
try {
const res = await getDoctorInputDetail(id);
detail.value = res || null;
normalizeInputUser(detail.value);
} catch {
message.error('加载预填详情失败');
} finally {
detailLoading.value = false;
}
}
/**
* 提交医生预填审核结果
* 通过 onConfirm 注册,与 Vben Modal 确认按钮对接
*/
const handleAudit = async () => {
if (!currentRow.value) {
const id = Number(display.value?.id || currentRow.value?.id || 0);
if (!id) {
message.error('数据错误');
return;
}
loading.value = true;
modalApi.setState({ confirmLoading: true });
try {
await auditDoctorInput({
id: currentRow.value.id,
id,
status: auditStatus.value,
audit_remark: auditRemark.value,
});
message.success('审核成功');
gridApi.value?.reload();
gridApi.value?.reload?.();
gridApi.value?.query?.();
modalApi.close();
auditStatus.value = 1;
auditRemark.value = '';
@@ -52,13 +107,20 @@ const [ModalComponent, modalApi] = useVbenModal({
modalApi.close();
},
onConfirm: handleAudit,
onOpenChange(isOpen: boolean) {
if (isOpen) {
const { values, gridApi: api } = modalApi.getData<Record<string, any>>();
currentRow.value = values;
gridApi.value = api;
auditStatus.value = 1;
auditRemark.value = '';
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
detail.value = null;
currentRow.value = null;
return;
}
const { values, gridApi: api } = modalApi.getData<Record<string, any>>() || {};
currentRow.value = values;
gridApi.value = api;
auditStatus.value = 1;
auditRemark.value = '';
const id = Number(values?.id || 0);
if (id > 0) {
await loadDetail(id);
}
},
});
@@ -67,25 +129,121 @@ const [ModalComponent, modalApi] = useVbenModal({
<template>
<ModalComponent
title="审核医生预填"
class="w-[500px]"
class="w-[820px] max-w-[96vw]"
:confirm-loading="loading"
>
<div class="space-y-4 p-4">
<div>
<div class="mb-2 font-semibold">审核结果</div>
<RadioGroup v-model:value="auditStatus">
<Radio :value="1">审核通过</Radio>
<Radio :value="2">审核拒绝</Radio>
</RadioGroup>
<Spin :spinning="detailLoading">
<div v-if="display" class="space-y-4 p-2">
<Descriptions
:column="{ xxl: 2, xl: 2, lg: 2, md: 1, sm: 1, xs: 1 }"
bordered
size="small"
>
<DescriptionsItem label="预填 ID">#{{ display.id }}</DescriptionsItem>
<DescriptionsItem label="医生姓名">{{ display.name || '-' }}</DescriptionsItem>
<DescriptionsItem label="手机号">
<SensitiveText :record="display" field="mobile" />
</DescriptionsItem>
<DescriptionsItem label="身份证">
<SensitiveText :record="display" field="idcard" />
</DescriptionsItem>
<DescriptionsItem label="身份">{{ typeText(Number(display.type)) }}</DescriptionsItem>
<DescriptionsItem label="签名类型">
{{ signTypeText(Number(display.sign_type)) }}
</DescriptionsItem>
<DescriptionsItem label="科室">{{ display.depart?.name || '-' }}</DescriptionsItem>
<DescriptionsItem label="职称">{{ display.titleInfo?.name || '-' }}</DescriptionsItem>
<DescriptionsItem label="录入人">
<div class="flex items-center gap-2">
<Avatar
v-if="resolveAvatarUrl(display.inputUser?.avatar)"
:src="resolveAvatarUrl(display.inputUser?.avatar)"
:size="28"
/>
<span>{{ inputUserDisplayName(display.inputUser) }}</span>
</div>
</DescriptionsItem>
<DescriptionsItem label="擅长" :span="2">{{ display.good_at || '-' }}</DescriptionsItem>
<DescriptionsItem label="简介" :span="2">{{ display.intro || '-' }}</DescriptionsItem>
</Descriptions>
<div class="flex flex-wrap gap-3">
<div v-if="display.avatar" class="cert">
<div class="cert-label">头像</div>
<Image :src="imgSrc(display.avatar)" :width="80" :height="80" :preview="true" />
</div>
<div v-if="display.qualification" class="cert">
<div class="cert-label">资格证书</div>
<Image
:src="imgSrc(display.qualification)"
:width="80"
:height="80"
:preview="true"
/>
</div>
<div v-if="display.practicing" class="cert">
<div class="cert-label">执业证书</div>
<Image
:src="imgSrc(display.practicing)"
:width="80"
:height="80"
:preview="true"
/>
</div>
<div v-if="display.title" class="cert">
<div class="cert-label">职称证书</div>
<Image :src="imgSrc(display.title)" :width="80" :height="80" :preview="true" />
</div>
<div v-if="display.card_up" class="cert">
<div class="cert-label">身份证正面</div>
<Image :src="imgSrc(display.card_up)" :width="80" :height="80" :preview="true" />
</div>
<div v-if="display.card_down" class="cert">
<div class="cert-label">身份证反面</div>
<Image :src="imgSrc(display.card_down)" :width="80" :height="80" :preview="true" />
</div>
<div v-if="display.sign_image" class="cert">
<div class="cert-label">签名</div>
<Image :src="imgSrc(display.sign_image)" :width="80" :height="80" :preview="true" />
</div>
</div>
<div>
<div class="mb-2 font-semibold">审核结果</div>
<RadioGroup v-model:value="auditStatus">
<Radio :value="1">审核通过</Radio>
<Radio :value="2">审核拒绝</Radio>
</RadioGroup>
</div>
<div>
<div class="mb-2 font-semibold">审核备注</div>
<Textarea
v-model:value="auditRemark"
:rows="4"
placeholder="请输入审核备注(可选)"
/>
</div>
</div>
<div>
<div class="mb-2 font-semibold">审核备注</div>
<Textarea
v-model:value="auditRemark"
:rows="4"
placeholder="请输入审核备注(可选)"
/>
<div v-else-if="!detailLoading" class="p-8 text-center text-muted-foreground">
暂无预填数据
</div>
</div>
</Spin>
</ModalComponent>
</template>
<style scoped>
.cert {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border-radius: 10px;
border: 1px solid hsl(var(--border));
background: hsl(var(--card, var(--background)));
}
.cert-label {
font-size: 12px;
color: hsl(var(--muted-foreground));
}
</style>

View File

@@ -103,6 +103,14 @@ const [Modal, modalApi] = useVbenModal({
gridApi.value = data.gridApi;
storeId.value = Number(data.store_id || 0);
storeName.value = data.store_name || '';
const vip = data.vip || null;
const code = String(vip?.level_code || 'V0').trim() || 'V0';
const expireAt = Number(vip?.expire_at || 0);
// 非普通且未过期(含终身 expire_at=0可继承剩余时间
const canKeep =
!!vip &&
code !== 'V0' &&
!(expireAt > 0 && expireAt * 1000 <= Date.now());
const [levels, presets] = await Promise.all([
getVipLevelOption(),
getVipDurationPresets(),
@@ -111,10 +119,12 @@ const [Modal, modalApi] = useVbenModal({
label: `${i.name}${i.code})¥${i.price}`,
value: i.id,
}));
presetOptions.value = (presets || []).map((i: any) => ({
label: i.label,
value: i.value,
}));
presetOptions.value = (presets || [])
.filter((i: any) => i.value !== 'keep' || canKeep)
.map((i: any) => ({
label: i.label,
value: i.value,
}));
formApi.updateSchema([
{
fieldName: 'level_id',
@@ -125,7 +135,13 @@ const [Modal, modalApi] = useVbenModal({
componentProps: { options: presetOptions.value },
},
]);
formApi.setValues({ remark: '', custom_years: undefined });
formApi.setValues({
remark: '',
custom_years: undefined,
level_id: vip?.level_id ? Number(vip.level_id) : undefined,
// 非普通会员默认继承剩余时间,不改动到期
duration_preset: canKeep ? 'keep' : undefined,
});
},
});
</script>

View File

@@ -19,11 +19,14 @@ withDefaults(
defineProps<{
/** 显示圆点 */
dot?: boolean;
/** 未读数量角标(优先于圆点) */
count?: number;
/** 消息列表 */
notifications?: NotificationItem[];
}>(),
{
dot: false,
count: 0,
notifications: () => [],
},
);
@@ -64,7 +67,13 @@ defineExpose({ toggle });
<div class="mr-2 flex-center h-full" @click.stop="toggle()">
<VbenIconButton class="bell-button relative text-foreground">
<span
v-if="dot"
v-if="count > 0"
class="absolute -top-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none text-primary-foreground"
>
{{ count > 99 ? '99+' : count }}
</span>
<span
v-else-if="dot"
class="absolute top-0.5 right-0.5 size-2 rounded-sm bg-primary"
></span>
<Bell class="size-4" />
@@ -97,19 +106,20 @@ defineExpose({ toggle });
></span>
<span
class="relative flex size-10 shrink-0 overflow-hidden rounded-full"
class="relative flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full bg-muted"
>
<component
:is="item.icon"
v-if="item.icon"
:class="item.status === 1 ? 'text-gray-400 group-hover:text-gray-600': item.color"
class="aspect-square h-full w-full object-cover"
:class="
item.status === 1
? 'text-gray-400 group-hover:text-gray-600'
: item.color
"
class="size-5"
role="img"
/>
<!-- <img-->
<!-- :src="item.avatar"-->
<!-- class="aspect-square size-full object-cover"-->
<!-- />-->
<Bell v-else class="size-5 text-muted-foreground" />
</span>
<div class="flex flex-col gap-1 leading-none">
<p class="font-semibold">{{ item.title }}</p>