feat:
1. 更新框架 2. 修复框架更新后导致的问题 3. 消息系统升级
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
122
apps/web-antd/src/views/notice/action/NoticeActionHost.vue
Normal file
122
apps/web-antd/src/views/notice/action/NoticeActionHost.vue
Normal 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>
|
||||
40
apps/web-antd/src/views/notice/action/bus.ts
Normal file
40
apps/web-antd/src/views/notice/action/bus.ts
Normal 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 */
|
||||
}
|
||||
});
|
||||
}
|
||||
143
apps/web-antd/src/views/notice/action/types.ts
Normal file
143
apps/web-antd/src/views/notice/action/types.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
128
apps/web-antd/src/views/notice/scenes/NoticeSceneHost.vue
Normal file
128
apps/web-antd/src/views/notice/scenes/NoticeSceneHost.vue
Normal 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>
|
||||
260
apps/web-antd/src/views/notice/scenes/NoticeSceneShell.vue
Normal file
260
apps/web-antd/src/views/notice/scenes/NoticeSceneShell.vue
Normal 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>
|
||||
113
apps/web-antd/src/views/notice/scenes/meta.ts
Normal file
113
apps/web-antd/src/views/notice/scenes/meta.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
434
apps/web-antd/src/views/notice/views/DoctorInputNoticeView.vue
Normal file
434
apps/web-antd/src/views/notice/views/DoctorInputNoticeView.vue
Normal 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>
|
||||
135
apps/web-antd/src/views/notice/views/InvoiceNoticeView.vue
Normal file
135
apps/web-antd/src/views/notice/views/InvoiceNoticeView.vue
Normal 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>
|
||||
147
apps/web-antd/src/views/notice/views/PeriodReportView.vue
Normal file
147
apps/web-antd/src/views/notice/views/PeriodReportView.vue
Normal 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>
|
||||
@@ -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>
|
||||
234
apps/web-antd/src/views/notice/views/QueueFailureView.vue
Normal file
234
apps/web-antd/src/views/notice/views/QueueFailureView.vue
Normal 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>
|
||||
103
apps/web-antd/src/views/notice/views/RichTextNoticeView.vue
Normal file
103
apps/web-antd/src/views/notice/views/RichTextNoticeView.vue
Normal 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>
|
||||
422
apps/web-antd/src/views/notice/views/StoreInputNoticeView.vue
Normal file
422
apps/web-antd/src/views/notice/views/StoreInputNoticeView.vue
Normal 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>
|
||||
340
apps/web-antd/src/views/notice/views/VipNoticeView.vue
Normal file
340
apps/web-antd/src/views/notice/views/VipNoticeView.vue
Normal 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>
|
||||
@@ -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>
|
||||
136
apps/web-antd/src/views/notice/views/WithdrawNoticeView.vue
Normal file
136
apps/web-antd/src/views/notice/views/WithdrawNoticeView.vue
Normal 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>
|
||||
43
apps/web-antd/src/views/notice/views/registry.ts
Normal file
43
apps/web-antd/src/views/notice/views/registry.ts
Normal 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',
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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' },
|
||||
];
|
||||
@@ -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,
|
||||
};
|
||||
178
apps/web-antd/src/views/system/admin-notice-type/index.vue
Normal file
178
apps/web-antd/src/views/system/admin-notice-type/index.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user