feat: 优化了若干接口页面,可用性存疑
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Markdown 编辑器表单组件(封装 md-editor-v3)
|
||||
* 经 component-map 自动注册,schema 中以 component: 'MarkdownEditor' 使用(vben form 默认 v-model:value 绑定)
|
||||
* 为什么封装:统一精简工具栏(轻量备注场景不需要图片/公式等重型功能)+ 主题跟随系统亮暗切换,避免各表单重复配置
|
||||
*/
|
||||
import type { ToolbarNames } from 'md-editor-v3';
|
||||
|
||||
import { computed, defineAsyncComponent } from 'vue';
|
||||
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
|
||||
import { useVModel } from '@vueuse/core';
|
||||
|
||||
import 'md-editor-v3/lib/style.css';
|
||||
|
||||
// 本文件经 component-map 的 eager glob 全量打进主包,md-editor-v3(含 CodeMirror)体积大,
|
||||
// 必须异步加载:外壳进主包、库本体在编辑器真正渲染时才拉取,避免拖累全站首屏
|
||||
const MdEditor = defineAsyncComponent(() =>
|
||||
import('md-editor-v3').then((m) => m.MdEditor),
|
||||
);
|
||||
|
||||
const props = defineProps({
|
||||
value: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: '请输入内容(支持 Markdown 格式)',
|
||||
},
|
||||
/** 编辑器整体高度(px),弹窗场景建议 200-300 */
|
||||
height: {
|
||||
type: Number,
|
||||
default: 240,
|
||||
},
|
||||
});
|
||||
const emits = defineEmits(['update:value']);
|
||||
const mValue = useVModel(props, 'value', emits, {
|
||||
defaultValue: props.value,
|
||||
passive: true,
|
||||
});
|
||||
/** 主题必须跟随系统亮/暗切换,不能写死(与消息详情 MdPreview 的处理一致) */
|
||||
const { isDark } = usePreferences();
|
||||
const theme = computed(() => (isDark.value ? 'dark' : 'light'));
|
||||
/** 同页面可能挂载多个编辑器实例(如弹窗嵌套),随机 id 避免内部 DOM id 冲突 */
|
||||
const editorId = `md-form-${Math.random().toString(36).slice(2, 9)}`;
|
||||
/** 精简工具栏:加粗/斜体/删除线、标题/引用/列表/任务、代码/链接/表格,右侧仅保留预览切换 */
|
||||
const toolbars: ToolbarNames[] = [
|
||||
'bold',
|
||||
'italic',
|
||||
'strikeThrough',
|
||||
'-',
|
||||
'title',
|
||||
'quote',
|
||||
'unorderedList',
|
||||
'orderedList',
|
||||
'task',
|
||||
'-',
|
||||
'code',
|
||||
'link',
|
||||
'table',
|
||||
'=',
|
||||
'preview',
|
||||
];
|
||||
</script>
|
||||
<template>
|
||||
<MdEditor
|
||||
:id="editorId"
|
||||
v-model="mValue"
|
||||
:theme="theme"
|
||||
:placeholder="placeholder"
|
||||
:toolbars="toolbars"
|
||||
:footers="[]"
|
||||
:preview="false"
|
||||
:style="{ height: `${height}px` }"
|
||||
class="md-form-editor"
|
||||
/>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* 圆角与边框贴近 antd 输入组件,融入表单视觉 */
|
||||
.md-form-editor {
|
||||
width: 100%;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -145,8 +145,9 @@ function openRecords() {
|
||||
>
|
||||
<template #content>
|
||||
<div class="header-vip-bubble">
|
||||
<!-- 诊所端风格会员卡头图 -->
|
||||
<!-- 黑金会员卡头图:与诊所设置/小程序会员卡同一视觉语言 -->
|
||||
<div class="header-vip-hero">
|
||||
<div class="header-vip-hero__glow"></div>
|
||||
<VipBadgeCombo
|
||||
v-if="hasVipVisual"
|
||||
size="lg"
|
||||
@@ -157,8 +158,10 @@ function openRecords() {
|
||||
/>
|
||||
<div v-else class="header-vip-hero__placeholder">V</div>
|
||||
<div class="header-vip-hero__info">
|
||||
<div class="header-vip-hero__title">{{ levelName }}</div>
|
||||
<div class="header-vip-hero__sub">{{ durationLabel }}</div>
|
||||
<div class="header-vip-hero__title-row">
|
||||
<span class="header-vip-hero__title">{{ levelName }}</span>
|
||||
<span class="header-vip-hero__chip">{{ durationLabel }}</span>
|
||||
</div>
|
||||
<div class="header-vip-hero__expire">{{ expireText }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,19 +247,37 @@ function openRecords() {
|
||||
background: hsl(var(--card, var(--background)));
|
||||
box-shadow: 0 8px 24px hsl(var(--foreground) / 0.12);
|
||||
}
|
||||
/* 黑金卡头:深色卡面亮暗主题通用 */
|
||||
.header-vip-hero {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 16px 18px;
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(120% 160% at 100% 0%, rgba(212, 175, 55, 0.22) 0%, rgba(212, 175, 55, 0) 45%),
|
||||
linear-gradient(135deg, #23201a 0%, #12100c 55%, #1f1a12 100%);
|
||||
border-bottom: 1px solid rgba(220, 186, 110, 0.3);
|
||||
}
|
||||
.header-vip-hero__glow {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
right: -40px;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(240, 217, 168, 0.26) 0%, rgba(240, 217, 168, 0) 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.header-vip-hero__placeholder {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
background: radial-gradient(circle, rgba(212, 175, 55, 0.16) 0%, rgba(212, 175, 55, 0.04) 100%);
|
||||
border: 1px solid rgba(220, 186, 110, 0.4);
|
||||
color: #f0d9a8;
|
||||
text-shadow: 0 2px 8px rgba(212, 175, 55, 0.4);
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
@@ -265,23 +286,36 @@ function openRecords() {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.header-vip-hero__info {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
color: #fff;
|
||||
}
|
||||
.header-vip-hero__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.header-vip-hero__title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
color: #f5e3bd;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.header-vip-hero__sub {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
.header-vip-hero__chip {
|
||||
flex-shrink: 0;
|
||||
padding: 0 8px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
color: #e8cf9a;
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border: 1px solid rgba(232, 207, 154, 0.5);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.header-vip-hero__expire {
|
||||
margin-top: 4px;
|
||||
margin-top: 6px;
|
||||
font-size: 12px;
|
||||
opacity: 0.78;
|
||||
color: rgba(240, 224, 190, 0.75);
|
||||
}
|
||||
.header-vip-body {
|
||||
padding: 12px 14px 14px;
|
||||
@@ -342,8 +376,4 @@ function openRecords() {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
html.dark .header-vip-hero,
|
||||
.dark .header-vip-hero {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -34,7 +34,10 @@ const expireText = computed(() => {
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<!-- 黑金会员卡:与小程序端 vip-member-card 同一套视觉语言,深色卡面亮暗主题通用 -->
|
||||
<div class="store-vip-card">
|
||||
<div class="store-vip-card__glow"></div>
|
||||
<div class="store-vip-card__sheen"></div>
|
||||
<VipBadgeCombo
|
||||
v-if="hasVipVisual"
|
||||
size="xl"
|
||||
@@ -45,11 +48,9 @@ const expireText = computed(() => {
|
||||
/>
|
||||
<div v-else class="store-vip-card__placeholder">V</div>
|
||||
<div class="store-vip-card__info">
|
||||
<div class="store-vip-card__title">
|
||||
{{ vip?.level_name || '普通会员' }}
|
||||
</div>
|
||||
<div class="store-vip-card__sub">
|
||||
{{ vip?.duration_label || '普通会员' }}
|
||||
<div class="store-vip-card__title-row">
|
||||
<span class="store-vip-card__title">{{ vip?.level_name || '普通会员' }}</span>
|
||||
<span class="store-vip-card__chip">{{ vip?.duration_label || '普通会员' }}</span>
|
||||
</div>
|
||||
<div class="store-vip-card__expire">{{ expireText }}</div>
|
||||
</div>
|
||||
@@ -57,50 +58,96 @@ const expireText = computed(() => {
|
||||
</template>
|
||||
<style scoped>
|
||||
.store-vip-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 20px 28px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
|
||||
box-shadow: 0 8px 24px rgba(44, 62, 80, 0.16);
|
||||
margin-bottom: 24px;
|
||||
overflow: hidden;
|
||||
border-radius: 18px;
|
||||
/* 深色曜石底 + 右上暗金光晕,比单一渐变更有实体卡层次 */
|
||||
background:
|
||||
radial-gradient(120% 160% at 100% 0%, rgba(212, 175, 55, 0.22) 0%, rgba(212, 175, 55, 0) 45%),
|
||||
linear-gradient(135deg, #23201a 0%, #12100c 55%, #1f1a12 100%);
|
||||
border: 1px solid rgba(220, 186, 110, 0.35);
|
||||
box-shadow:
|
||||
0 12px 32px rgba(0, 0, 0, 0.28),
|
||||
0 2px 8px rgba(212, 175, 55, 0.12);
|
||||
}
|
||||
/* 右上金色光晕:模拟灯光打在卡面的高光 */
|
||||
.store-vip-card__glow {
|
||||
position: absolute;
|
||||
top: -70px;
|
||||
right: -50px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(240, 217, 168, 0.26) 0%, rgba(240, 217, 168, 0) 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 斜切静态流光条:金属光泽感 */
|
||||
.store-vip-card__sheen {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 32%;
|
||||
width: 90px;
|
||||
transform: skewX(-24deg);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 0.05) 50%,
|
||||
rgba(255, 255, 255, 0) 100%
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
.store-vip-card__placeholder {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #f0d9a8;
|
||||
text-shadow: 0 2px 8px rgba(212, 175, 55, 0.4);
|
||||
background: radial-gradient(circle, rgba(212, 175, 55, 0.16) 0%, rgba(212, 175, 55, 0.04) 100%);
|
||||
border: 1px solid rgba(220, 186, 110, 0.4);
|
||||
border-radius: 50%;
|
||||
box-shadow: inset 0 0 16px rgba(212, 175, 55, 0.15);
|
||||
}
|
||||
.store-vip-card__info {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
color: #fff;
|
||||
}
|
||||
.store-vip-card__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.store-vip-card__title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 6px;
|
||||
color: #f5e3bd;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.store-vip-card__sub {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-bottom: 6px;
|
||||
/* 期限徽章:金字描边小胶囊 */
|
||||
.store-vip-card__chip {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 10px;
|
||||
font-size: 12px;
|
||||
color: #e8cf9a;
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
border: 1px solid rgba(232, 207, 154, 0.5);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.store-vip-card__expire {
|
||||
font-size: 13px;
|
||||
opacity: 0.78;
|
||||
}
|
||||
html.dark .store-vip-card,
|
||||
.dark .store-vip-card {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
color: rgba(240, 224, 190, 0.75);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -226,8 +226,9 @@ watch(durationPreset, (v) => {
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="title" class="w-[50%]">
|
||||
<!-- 预览:徽标卡片 + 丝带(随时长自动适配) -->
|
||||
<!-- 预览:黑金卡面 + 徽标丝带(随时长自动适配) -->
|
||||
<div class="vip-upgrade-preview mb-5">
|
||||
<div class="vip-upgrade-preview__glow"></div>
|
||||
<VipBadgeCombo
|
||||
size="xl"
|
||||
:badge-url="previewBadgeUrl"
|
||||
@@ -329,28 +330,53 @@ watch(durationPreset, (v) => {
|
||||
</Modal>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* 黑金预览卡:与会员卡组件同一视觉语言,深色卡面亮暗主题通用 */
|
||||
.vip-upgrade-preview {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 20px 24px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #3d5a40 50%, #6acdbb 100%);
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
border-radius: 16px;
|
||||
background:
|
||||
radial-gradient(120% 160% at 100% 0%, rgba(212, 175, 55, 0.22) 0%, rgba(212, 175, 55, 0) 45%),
|
||||
linear-gradient(135deg, #23201a 0%, #12100c 55%, #1f1a12 100%);
|
||||
border: 1px solid rgba(220, 186, 110, 0.35);
|
||||
box-shadow:
|
||||
0 10px 26px rgba(0, 0, 0, 0.24),
|
||||
0 2px 8px rgba(212, 175, 55, 0.12);
|
||||
color: #f5e3bd;
|
||||
}
|
||||
.vip-upgrade-preview__glow {
|
||||
position: absolute;
|
||||
top: -60px;
|
||||
right: -40px;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(240, 217, 168, 0.26) 0%, rgba(240, 217, 168, 0) 70%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.vip-upgrade-preview__text {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
.vip-upgrade-preview__title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
letter-spacing: 1px;
|
||||
text-shadow: 0 2px 6px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
.vip-upgrade-preview__sub {
|
||||
font-size: 13px;
|
||||
opacity: 0.9;
|
||||
color: rgba(240, 224, 190, 0.85);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.vip-upgrade-preview__price {
|
||||
font-size: 12px;
|
||||
opacity: 0.8;
|
||||
color: rgba(240, 224, 190, 0.68);
|
||||
}
|
||||
/* 两行 + 横向滑动的单选卡片轨道 */
|
||||
.vip-pick-scroll {
|
||||
@@ -453,11 +479,7 @@ watch(durationPreset, (v) => {
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* ========== 暗色适配(html.dark / .dark)========== */
|
||||
html.dark .vip-upgrade-preview,
|
||||
.dark .vip-upgrade-preview {
|
||||
background: linear-gradient(135deg, #1a2332 0%, #243528 50%, #2d6b5f 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(106, 205, 187, 0.18);
|
||||
}
|
||||
/* 黑金预览卡为深色卡面,亮暗主题通用,无需暗色分支 */
|
||||
html.dark .vip-pick-scroll::-webkit-scrollbar-thumb,
|
||||
.dark .vip-pick-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
|
||||
20
apps/web-antd/src/views/_core/profile/api/index.ts
Normal file
20
apps/web-antd/src/views/_core/profile/api/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
// 个人中心接口(后端 routes/admin.php 的 auth 分组,AdminController → common AdminProfileService)
|
||||
const prefix = 'auth/';
|
||||
|
||||
/**
|
||||
* 获取我的个人中心资料
|
||||
* 与 auth/my-info(Redis 会话快照)不同,这里返回实时库数据 + 只读展示字段(登录账号/所属门店/最后登录时间等)
|
||||
*/
|
||||
export async function getMyProfile() {
|
||||
return requestClient.get<any>(`${prefix}my-profile`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改我的资料(后端白名单:仅昵称 nick_name、头像 avatar)
|
||||
* 保存成功后后端会同步刷新 Redis 会话,前端只需重拉 my-info 即可让顶栏生效
|
||||
*/
|
||||
export async function updateMyProfile(data: any) {
|
||||
return requestClient.post<any>(`${prefix}update-my-profile`, data);
|
||||
}
|
||||
@@ -1,65 +1,184 @@
|
||||
<script setup lang="ts">
|
||||
import type { BasicOption } from '@vben/types';
|
||||
/**
|
||||
* 个人中心 - 基本资料
|
||||
* 可编辑:头像(复用表单 Avatar 上传组件)、昵称(与后端 update-my-profile 白名单一致)
|
||||
* 只读展示:登录账号/手机号/角色/所属门店/最后登录时间等(账号身份信息不允许自助修改)
|
||||
* 保存成功后重拉 my-info 刷新全局登录态,顶栏/工作台头像昵称立即生效
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { useAuthStore } from '#/store/auth';
|
||||
|
||||
import { ProfileBaseSetting } from '@vben/common-ui';
|
||||
import { updateMyProfile } from './api';
|
||||
|
||||
import { getUserInfoApi } from '#/api';
|
||||
|
||||
const profileBaseSettingRef = ref();
|
||||
|
||||
const MOCK_ROLES_OPTIONS: BasicOption[] = [
|
||||
{
|
||||
label: '管理员',
|
||||
value: 'super',
|
||||
const props = defineProps({
|
||||
profile: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
{
|
||||
label: '用户',
|
||||
value: 'user',
|
||||
},
|
||||
{
|
||||
label: '测试',
|
||||
value: 'test',
|
||||
},
|
||||
];
|
||||
});
|
||||
const emits = defineEmits(['saved']);
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
const authStore = useAuthStore();
|
||||
|
||||
/** 可编辑字段表单:仅头像 + 昵称,其余字段一律只读展示 */
|
||||
const [Form, formApi] = useVbenForm({
|
||||
wrapperClass: 'grid-cols-12',
|
||||
commonConfig: {
|
||||
formItemClass: 'col-span-12',
|
||||
labelWidth: 80,
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
},
|
||||
},
|
||||
layout: 'horizontal',
|
||||
schema: [
|
||||
{
|
||||
fieldName: 'realName',
|
||||
component: 'Input',
|
||||
label: '姓名',
|
||||
component: 'Avatar',
|
||||
fieldName: 'avatar',
|
||||
label: '头像',
|
||||
},
|
||||
{
|
||||
fieldName: 'username',
|
||||
component: 'Input',
|
||||
label: '用户名',
|
||||
},
|
||||
{
|
||||
fieldName: 'roles',
|
||||
component: 'Select',
|
||||
component: 'VbenInput',
|
||||
fieldName: 'nick_name',
|
||||
label: '昵称',
|
||||
rules: 'required',
|
||||
componentProps: {
|
||||
mode: 'tags',
|
||||
options: MOCK_ROLES_OPTIONS,
|
||||
placeholder: '请输入昵称',
|
||||
maxlength: 20,
|
||||
},
|
||||
label: '角色',
|
||||
},
|
||||
{
|
||||
fieldName: 'introduction',
|
||||
component: 'Textarea',
|
||||
label: '个人简介',
|
||||
},
|
||||
];
|
||||
],
|
||||
showDefaultActions: false,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
const data = await getUserInfoApi();
|
||||
profileBaseSettingRef.value.getFormApi().setValues(data);
|
||||
// 父组件拉取到实时资料后回填表单(保存成功刷新后同样走这里)
|
||||
watch(
|
||||
() => props.profile,
|
||||
(val: any) => {
|
||||
if (val && val.id) {
|
||||
formApi.setValues({
|
||||
avatar: val.avatar,
|
||||
nick_name: val.nick_name,
|
||||
});
|
||||
}
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
/** 只读信息行:为空的可选字段(门店/工号/邮箱)不展示,避免一排「-」 */
|
||||
const readonlyRows = computed(() => {
|
||||
const p: any = props.profile || {};
|
||||
const rows: { label: string; value: string }[] = [
|
||||
{ label: '登录账号', value: p.login_account },
|
||||
{ label: '手机号', value: p.phone },
|
||||
{ label: '角色', value: p.role_name },
|
||||
];
|
||||
if (p.store_name) {
|
||||
rows.push({ label: '所属门店', value: p.store_name });
|
||||
}
|
||||
if (p.job_number) {
|
||||
rows.push({ label: '工号', value: p.job_number });
|
||||
}
|
||||
if (p.email) {
|
||||
rows.push({ label: '邮箱', value: p.email });
|
||||
}
|
||||
rows.push({ label: '最后登录', value: p.last_login_time });
|
||||
rows.push({ label: '创建时间', value: p.created_at });
|
||||
return rows.map((row) => ({ ...row, value: row.value || '-' }));
|
||||
});
|
||||
|
||||
const saving = ref(false);
|
||||
|
||||
/**
|
||||
* 保存资料:校验 → 提交白名单字段 → 重拉全局登录态 → 通知父组件刷新
|
||||
* 为什么要 fetchUserInfo:后端已刷新 Redis 会话,前端重拉 my-info 才能让顶栏/工作台立即显示新头像昵称
|
||||
*/
|
||||
function handleSave() {
|
||||
formApi.validate().then(async (e: any) => {
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
saving.value = true;
|
||||
try {
|
||||
await updateMyProfile({
|
||||
nick_name: values.nick_name,
|
||||
avatar: values.avatar || '',
|
||||
});
|
||||
message.success('保存成功');
|
||||
await authStore.fetchUserInfo();
|
||||
emits('saved');
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ProfileBaseSetting ref="profileBaseSettingRef" :form-schema="formSchema" />
|
||||
<div class="profile-base w-full lg:w-2/3">
|
||||
<Form />
|
||||
<Button type="primary" :loading="saving" @click="handleSave">
|
||||
保存资料
|
||||
</Button>
|
||||
<div class="profile-base__section-title">账号信息</div>
|
||||
<div class="profile-base__grid">
|
||||
<div
|
||||
v-for="row in readonlyRows"
|
||||
:key="row.label"
|
||||
class="profile-base__row"
|
||||
>
|
||||
<span class="profile-base__label">{{ row.label }}</span>
|
||||
<span class="profile-base__value">{{ row.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* 只读信息区:全部走主题变量,暗色模式自动适配 */
|
||||
.profile-base__section-title {
|
||||
padding-bottom: 10px;
|
||||
margin-top: 28px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.profile-base__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 24px;
|
||||
}
|
||||
|
||||
.profile-base__row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px dashed hsl(var(--border) / 0.6);
|
||||
}
|
||||
|
||||
.profile-base__label {
|
||||
flex-shrink: 0;
|
||||
width: 72px;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.profile-base__value {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--foreground));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.profile-base__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,49 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
/**
|
||||
* 个人中心:左侧账号卡(头像/昵称/角色)+ 右侧 Tab(基本资料 / 修改密码)
|
||||
* 入口:顶栏头像下拉「个人中心」、工作台问候头;静态路由 /profile(hideInMenu,不走 xk_menu)
|
||||
* 资料数据统一从 auth/my-profile 实时接口拉取,子组件保存成功后回调刷新
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { Profile } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { getMyProfile } from './api';
|
||||
import ProfileBase from './base-setting.vue';
|
||||
import ProfileNotificationSetting from './notification-setting.vue';
|
||||
import ProfilePasswordSetting from './password-setting.vue';
|
||||
import ProfileSecuritySetting from './security-setting.vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
|
||||
const tabsValue = ref<string>('basic');
|
||||
|
||||
const tabs = ref([
|
||||
{
|
||||
label: '基本设置',
|
||||
label: '基本资料',
|
||||
value: 'basic',
|
||||
},
|
||||
{
|
||||
label: '安全设置',
|
||||
value: 'security',
|
||||
},
|
||||
{
|
||||
label: '修改密码',
|
||||
value: 'password',
|
||||
},
|
||||
{
|
||||
label: '新消息提醒',
|
||||
value: 'notice',
|
||||
},
|
||||
]);
|
||||
|
||||
/** 个人中心资料(basic tab 编辑 + 左侧账号卡展示共用一份数据) */
|
||||
const profile = ref<any>({});
|
||||
|
||||
/**
|
||||
* 左侧账号卡数据映射
|
||||
* Profile 壳组件展示 realName(大字)与 username(小字),这里映射为 昵称 + 角色名
|
||||
* avatar 为空时必须传 undefined(不能传空串),Profile 内部才会 ?? 兜底系统默认头像
|
||||
*/
|
||||
const cardUserInfo = computed(() => ({
|
||||
avatar: profile.value.avatar || undefined,
|
||||
nick_name: profile.value.nick_name || '',
|
||||
userId: String(profile.value.id || ''),
|
||||
username: profile.value.role_name || '',
|
||||
realName: profile.value.nick_name || '',
|
||||
}));
|
||||
|
||||
/** 拉取实时资料(初始化 + 保存成功后刷新) */
|
||||
async function loadProfile() {
|
||||
profile.value = (await getMyProfile()) || {};
|
||||
}
|
||||
|
||||
onMounted(loadProfile);
|
||||
</script>
|
||||
<template>
|
||||
<Profile
|
||||
v-model:model-value="tabsValue"
|
||||
title="个人中心"
|
||||
:user-info="userStore.userInfo"
|
||||
:user-info="cardUserInfo"
|
||||
:tabs="tabs"
|
||||
>
|
||||
<template #content>
|
||||
<ProfileBase v-if="tabsValue === 'basic'" />
|
||||
<ProfileSecuritySetting v-if="tabsValue === 'security'" />
|
||||
<ProfileBase
|
||||
v-if="tabsValue === 'basic'"
|
||||
:profile="profile"
|
||||
@saved="loadProfile"
|
||||
/>
|
||||
<ProfilePasswordSetting v-if="tabsValue === 'password'" />
|
||||
<ProfileNotificationSetting v-if="tabsValue === 'notice'" />
|
||||
</template>
|
||||
</Profile>
|
||||
</template>
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ProfileNotificationSetting } from '@vben/common-ui';
|
||||
|
||||
const formSchema = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'accountPassword',
|
||||
label: '账户密码',
|
||||
description: '其他用户的消息将以站内信的形式通知',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'systemMessage',
|
||||
label: '系统消息',
|
||||
description: '系统消息将以站内信的形式通知',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'todoTask',
|
||||
label: '待办任务',
|
||||
description: '待办任务将以站内信的形式通知',
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ProfileNotificationSetting :form-schema="formSchema" />
|
||||
</template>
|
||||
@@ -1,63 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import type { VbenFormSchema } from '#/adapter/form';
|
||||
/**
|
||||
* 个人中心 - 修改密码
|
||||
* 复用顶栏「修改密码」弹窗同一份表单配置(layouts/config/form.ts 的 passwordModalForm)
|
||||
* 与同一个接口(admin/update-password),保证两个入口口径完全一致,不重复造轮子
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { computed } from 'vue';
|
||||
import { Button, message } from 'ant-design-vue';
|
||||
|
||||
import { ProfilePasswordSetting, z } from '@vben/common-ui';
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import { passwordModalForm } from '#/layouts/config/form';
|
||||
import { updatePassword } from '#/views/system/admin/api';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
const [Form, formApi] = useVbenForm(passwordModalForm);
|
||||
|
||||
const formSchema = computed((): VbenFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'oldPassword',
|
||||
label: '旧密码',
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
placeholder: '请输入旧密码',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'newPassword',
|
||||
label: '新密码',
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: '请输入新密码',
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'confirmPassword',
|
||||
label: '确认密码',
|
||||
component: 'VbenInputPassword',
|
||||
componentProps: {
|
||||
passwordStrength: true,
|
||||
placeholder: '请再次输入新密码',
|
||||
},
|
||||
dependencies: {
|
||||
rules(values) {
|
||||
const { newPassword } = values;
|
||||
return z
|
||||
.string({ error: '请再次输入新密码' })
|
||||
.min(1, { message: '请再次输入新密码' })
|
||||
.refine((value) => value === newPassword, {
|
||||
message: '两次输入的密码不一致',
|
||||
});
|
||||
},
|
||||
triggerFields: ['newPassword'],
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
const submitting = ref(false);
|
||||
|
||||
/**
|
||||
* 提交改密:校验 → 调 admin/update-password → 清空表单
|
||||
* 成功提示与顶栏弹窗一致(token 仍有效,下次登录使用新密码)
|
||||
*/
|
||||
function handleSubmit() {
|
||||
message.success('密码修改成功');
|
||||
formApi.validate().then(async (e: any) => {
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
submitting.value = true;
|
||||
try {
|
||||
await updatePassword(values);
|
||||
message.success('修改成功,请重新登录');
|
||||
formApi.resetForm();
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<ProfilePasswordSetting
|
||||
class="w-1/3"
|
||||
:form-schema="formSchema"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
<div class="w-full lg:w-1/2">
|
||||
<Form />
|
||||
<Button
|
||||
class="mt-4"
|
||||
type="primary"
|
||||
:loading="submitting"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
修改密码
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { ProfileSecuritySetting } from '@vben/common-ui';
|
||||
|
||||
const formSchema = computed(() => {
|
||||
return [
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'accountPassword',
|
||||
label: '账户密码',
|
||||
description: '当前密码强度:强',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityPhone',
|
||||
label: '密保手机',
|
||||
description: '已绑定手机:138****8293',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityQuestion',
|
||||
label: '密保问题',
|
||||
description: '未设置密保问题,密保问题可有效保护账户安全',
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
fieldName: 'securityEmail',
|
||||
label: '备用邮箱',
|
||||
description: '已绑定邮箱:ant***sign.com',
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
fieldName: 'securityMfa',
|
||||
label: 'MFA 设备',
|
||||
description: '未绑定 MFA 设备,绑定后,可以进行二次确认',
|
||||
},
|
||||
];
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<ProfileSecuritySetting :form-schema="formSchema" />
|
||||
</template>
|
||||
@@ -867,7 +867,7 @@ const openOrderAmountVerify = () => {
|
||||
<PrescriptionDetailModal />
|
||||
<TraceDrawer />
|
||||
<ChinaErpSyncDrawer @synced="() => gridApi.query()" />
|
||||
<AnalysisOverview :items="overviewItems" :my-card="false" />
|
||||
<AnalysisOverview :items="overviewItems" />
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
|
||||
@@ -65,7 +65,7 @@ const chartTabs: TabOption[] = [
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<AnalysisOverview :items="overviewItems" :my-card="false" />
|
||||
<AnalysisOverview :items="overviewItems" />
|
||||
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
|
||||
<template #trends>
|
||||
<AnalyticsTrends />
|
||||
|
||||
29
apps/web-antd/src/views/dashboard/calendar/api/index.ts
Normal file
29
apps/web-antd/src/views/dashboard/calendar/api/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/** 日历待办 API(后端 admin/system/CalendarTodoController) */
|
||||
const prefix = 'calendar-todo/';
|
||||
|
||||
/** 按时间范围获取本人待办列表(日历面板整月渲染,传面板首尾时间戳秒) */
|
||||
export async function getCalendarTodoList(data: { end_time: number; start_time: number }) {
|
||||
return requestClient.get<any>(`${prefix}get-list`, { params: data });
|
||||
}
|
||||
|
||||
/** 新建待办 */
|
||||
export async function createCalendarTodo(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
/** 编辑待办 */
|
||||
export async function updateCalendarTodo(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
/** 删除待办(软删) */
|
||||
export async function deleteCalendarTodo(data: { id: number }) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
/** 切换完成状态 */
|
||||
export async function toggleCalendarTodoDone(data: { id: number; is_done: number }) {
|
||||
return requestClient.post<any>(`${prefix}toggle-done`, data);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 当日待办列表弹窗
|
||||
* 日历格子空间有限只露出前几条,这里查看/管理某一天的全部待办:勾选完成、编辑、删除
|
||||
* 打开时通过 modalApi.setData 传入:date(YYYY-MM-DD)、todos、onEdit(todo)、onCreate()、onChanged()
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Checkbox, Empty, message, Modal as AntModal, Tag } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { deleteCalendarTodo, toggleCalendarTodoDone } from '../api';
|
||||
|
||||
const date = ref('');
|
||||
const todos = ref<any[]>([]);
|
||||
const handlers = ref<Record<string, any>>({});
|
||||
|
||||
/** 弹窗标题带上星期几,快速确认日期没点错 */
|
||||
const title = computed(() => {
|
||||
if (!date.value) return '当日待办';
|
||||
const d = dayjs(date.value);
|
||||
const weeks = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
return `${d.format('M月D日')}(周${weeks[d.day()]})待办`;
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
fullscreenButton: false,
|
||||
draggable: true,
|
||||
footer: false,
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) return;
|
||||
const data = (modalApi.getData() || {}) as Record<string, any>;
|
||||
date.value = data.date || '';
|
||||
todos.value = data.todos || [];
|
||||
handlers.value = {
|
||||
onEdit: data.onEdit,
|
||||
onCreate: data.onCreate,
|
||||
onChanged: data.onChanged,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/** 勾选切换完成状态:本地同步更新,避免整月重拉造成闪烁 */
|
||||
async function onToggleDone(todo: any, checked: boolean) {
|
||||
await toggleCalendarTodoDone({ id: todo.id, is_done: checked ? 1 : 0 });
|
||||
todo.is_done = checked ? 1 : 0;
|
||||
handlers.value.onChanged?.();
|
||||
}
|
||||
|
||||
/** 删除单条待办 */
|
||||
function onDelete(todo: any) {
|
||||
AntModal.confirm({
|
||||
title: '删除待办',
|
||||
content: `确定删除「${todo.title}」吗?`,
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await deleteCalendarTodo({ id: todo.id });
|
||||
message.success('删除成功');
|
||||
todos.value = todos.value.filter((t) => t.id !== todo.id);
|
||||
handlers.value.onChanged?.();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 编辑:关闭本弹窗后打开编辑弹窗,避免弹窗叠弹窗 */
|
||||
function onEdit(todo: any) {
|
||||
modalApi.close();
|
||||
handlers.value.onEdit?.(todo);
|
||||
}
|
||||
|
||||
/** 在该日期新建待办 */
|
||||
function onCreate() {
|
||||
modalApi.close();
|
||||
handlers.value.onCreate?.(date.value);
|
||||
}
|
||||
|
||||
/** 时间点展示:全天(00:00)不显示具体时间 */
|
||||
function timeText(todo: any) {
|
||||
const t = dayjs.unix(Number(todo.todo_time));
|
||||
return t.format('HH:mm') === '00:00' ? '全天' : t.format('HH:mm');
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="title" class="w-[480px]">
|
||||
<div class="day-todos">
|
||||
<Empty v-if="todos.length === 0" description="这一天还没有待办" />
|
||||
<div v-for="todo in todos" :key="todo.id" class="todo-item">
|
||||
<Checkbox
|
||||
:checked="Number(todo.is_done) === 1"
|
||||
@change="(e: any) => onToggleDone(todo, e.target.checked)"
|
||||
/>
|
||||
<div class="todo-main" @click="onEdit(todo)">
|
||||
<div class="todo-title" :class="{ done: Number(todo.is_done) === 1 }">
|
||||
{{ todo.title }}
|
||||
</div>
|
||||
<div class="todo-meta">
|
||||
<span>{{ timeText(todo) }}</span>
|
||||
<Tag v-if="Number(todo.remind_at) > 0" color="processing" class="remind-tag">
|
||||
提醒 {{ dayjs.unix(Number(todo.remind_at)).format('MM-DD HH:mm') }}
|
||||
</Tag>
|
||||
<Tag v-if="Number(todo.remind_status) === 2" color="error" class="remind-tag">
|
||||
提醒失败
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="text" size="small" danger @click="onDelete(todo)">删除</Button>
|
||||
</div>
|
||||
<div class="day-todos-footer">
|
||||
<Button type="dashed" block @click="onCreate">+ 新建待办</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.day-todos {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
max-height: 420px;
|
||||
padding: 4px 2px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
border-color: hsl(var(--primary) / 50%);
|
||||
}
|
||||
}
|
||||
|
||||
.todo-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.todo-title {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
color: hsl(var(--foreground));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.done {
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
.todo-meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.remind-tag {
|
||||
margin-inline-end: 0;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.day-todos-footer {
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 日历待办新增/编辑弹窗
|
||||
* 打开时通过 modalApi.setData 传入:
|
||||
* - update: 是否编辑态
|
||||
* - values: 编辑回填数据(时间戳字段已由本组件转为格式串)
|
||||
* - defaultDate: 新建时的默认日期(右键格子带入,默认当天 09:00)
|
||||
* - onSaved: 保存/删除成功后的刷新回调(父页面重拉当月待办)
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Modal as AntModal } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
import { createCalendarTodo, deleteCalendarTodo, updateCalendarTodo } from '../api';
|
||||
import { todoModalFormProps } from '../config/form';
|
||||
|
||||
const isUpdate = ref(false);
|
||||
const currentId = ref(0);
|
||||
const onSavedRef = ref<null | (() => void)>(null);
|
||||
|
||||
const [Form, formApi] = useVbenForm(todoModalFormProps);
|
||||
|
||||
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();
|
||||
// 时间字段由格式串转秒级时间戳提交,与后端 int 字段对齐
|
||||
const payload: Record<string, any> = {
|
||||
id: currentId.value || undefined,
|
||||
title: values.title,
|
||||
content: values.content || '',
|
||||
todo_time: values.todo_time ? dayjs(values.todo_time).unix() : 0,
|
||||
remind_at: values.remind_at ? dayjs(values.remind_at).unix() : 0,
|
||||
remind_channels: values.remind_at ? values.remind_channels || [] : [],
|
||||
remind_email: values.remind_email || '',
|
||||
remind_phone: values.remind_phone || '',
|
||||
};
|
||||
modalApi.lock();
|
||||
try {
|
||||
await (isUpdate.value ? updateCalendarTodo(payload) : createCalendarTodo(payload));
|
||||
message.success(isUpdate.value ? '保存成功' : '创建成功');
|
||||
onSavedRef.value?.();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formApi.resetForm();
|
||||
return;
|
||||
}
|
||||
const data = (modalApi.getData() || {}) as Record<string, any>;
|
||||
isUpdate.value = !!data.update;
|
||||
currentId.value = Number(data.values?.id || 0);
|
||||
onSavedRef.value = data.onSaved || null;
|
||||
formApi.resetForm();
|
||||
if (data.update && data.values) {
|
||||
const v = data.values;
|
||||
formApi.setValues({
|
||||
id: v.id,
|
||||
title: v.title,
|
||||
content: v.content,
|
||||
todo_time: v.todo_time ? dayjs.unix(Number(v.todo_time)).format('YYYY-MM-DD HH:mm') : '',
|
||||
remind_at:
|
||||
Number(v.remind_at) > 0 ? dayjs.unix(Number(v.remind_at)).format('YYYY-MM-DD HH:mm') : '',
|
||||
remind_channels: v.remind_channels ? String(v.remind_channels).split(',').filter(Boolean) : [],
|
||||
remind_email: v.remind_email || '',
|
||||
remind_phone: v.remind_phone || '',
|
||||
});
|
||||
} else {
|
||||
// 新建:右键格子带入日期,默认上午 9 点
|
||||
const base = data.defaultDate ? dayjs(data.defaultDate) : dayjs();
|
||||
formApi.setValues({
|
||||
todo_time: base.hour(9).minute(0).format('YYYY-MM-DD HH:mm'),
|
||||
remind_channels: ['notice'],
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/** 编辑态底部左侧的删除入口:待办属于轻量数据,允许在编辑弹窗内直接删除 */
|
||||
function handleDelete() {
|
||||
if (!currentId.value) return;
|
||||
AntModal.confirm({
|
||||
title: '删除待办',
|
||||
content: '确定删除这条待办吗?删除后不可恢复。',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await deleteCalendarTodo({ id: currentId.value });
|
||||
message.success('删除成功');
|
||||
onSavedRef.value?.();
|
||||
modalApi.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<!-- Markdown 编辑器工具栏需要更宽的容器,弹窗放宽到 640px -->
|
||||
<Modal :title="isUpdate ? '编辑待办' : '新建待办'" class="w-[640px]">
|
||||
<Form />
|
||||
<template #prepend-footer>
|
||||
<Button v-if="isUpdate" danger @click="handleDelete">删除</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* 日历待办模块常量
|
||||
*/
|
||||
|
||||
/** 提醒渠道选项(值与后端 CalendarTodoRemindChannelEnum 对齐) */
|
||||
export const REMIND_CHANNEL_OPTIONS = [
|
||||
{ label: '站内信', value: 'notice' },
|
||||
{ label: '邮箱', value: 'email' },
|
||||
{ label: '短信', value: 'sms' },
|
||||
];
|
||||
|
||||
/** 提醒渠道值常量,避免魔法字符串 */
|
||||
export const REMIND_CHANNEL = {
|
||||
NOTICE: 'notice',
|
||||
EMAIL: 'email',
|
||||
SMS: 'sms',
|
||||
} as const;
|
||||
118
apps/web-antd/src/views/dashboard/calendar/config/form.ts
Normal file
118
apps/web-antd/src/views/dashboard/calendar/config/form.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { REMIND_CHANNEL, REMIND_CHANNEL_OPTIONS } from './constants';
|
||||
|
||||
/**
|
||||
* 待办新增/编辑弹窗表单配置
|
||||
* 提醒相关字段级联显隐:设置了提醒时间才显示渠道选择,勾选邮箱/短信才显示对应收件人输入框
|
||||
*/
|
||||
export const todoModalFormProps: VbenFormProps = {
|
||||
commonConfig: {
|
||||
labelWidth: 90,
|
||||
},
|
||||
layout: 'vertical',
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'id',
|
||||
label: 'ID',
|
||||
dependencies: {
|
||||
show: false,
|
||||
triggerFields: ['id'],
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '请输入待办标题',
|
||||
maxlength: 200,
|
||||
},
|
||||
fieldName: 'title',
|
||||
label: '待办标题',
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
format: 'YYYY-MM-DD HH:mm',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm',
|
||||
showTime: { format: 'HH:mm' },
|
||||
placeholder: '请选择待办时间',
|
||||
},
|
||||
fieldName: 'todo_time',
|
||||
label: '待办时间',
|
||||
rules: 'selectRequired',
|
||||
},
|
||||
{
|
||||
// Markdown 编辑器(自动注册的表单组件),内容以 MD 源码入库,站内信/邮件提醒按 MD 渲染
|
||||
component: 'MarkdownEditor',
|
||||
componentProps: {
|
||||
placeholder: '详情备注(选填,支持 Markdown 格式)',
|
||||
height: 220,
|
||||
},
|
||||
fieldName: 'content',
|
||||
label: '详情备注',
|
||||
},
|
||||
{
|
||||
component: 'DatePicker',
|
||||
componentProps: {
|
||||
class: 'w-full',
|
||||
format: 'YYYY-MM-DD HH:mm',
|
||||
valueFormat: 'YYYY-MM-DD HH:mm',
|
||||
showTime: { format: 'HH:mm' },
|
||||
placeholder: '不选则不提醒',
|
||||
allowClear: true,
|
||||
},
|
||||
fieldName: 'remind_at',
|
||||
help: '到达提醒时间后按所选渠道自动发送提醒,需晚于当前时间',
|
||||
label: '提醒时间',
|
||||
},
|
||||
{
|
||||
component: 'CheckboxGroup',
|
||||
componentProps: {
|
||||
options: REMIND_CHANNEL_OPTIONS,
|
||||
},
|
||||
// 只有设置了提醒时间才需要选择渠道
|
||||
dependencies: {
|
||||
show: (values) => !!values.remind_at,
|
||||
rules: (values) => (values.remind_at ? 'required' : null),
|
||||
triggerFields: ['remind_at'],
|
||||
},
|
||||
fieldName: 'remind_channels',
|
||||
label: '提醒方式',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '不填则使用个人资料中的邮箱',
|
||||
},
|
||||
dependencies: {
|
||||
show: (values) =>
|
||||
!!values.remind_at &&
|
||||
Array.isArray(values.remind_channels) &&
|
||||
values.remind_channels.includes(REMIND_CHANNEL.EMAIL),
|
||||
triggerFields: ['remind_at', 'remind_channels'],
|
||||
},
|
||||
fieldName: 'remind_email',
|
||||
label: '提醒邮箱',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
componentProps: {
|
||||
placeholder: '不填则使用个人资料中的手机号',
|
||||
maxlength: 11,
|
||||
},
|
||||
dependencies: {
|
||||
show: (values) =>
|
||||
!!values.remind_at &&
|
||||
Array.isArray(values.remind_channels) &&
|
||||
values.remind_channels.includes(REMIND_CHANNEL.SMS),
|
||||
triggerFields: ['remind_at', 'remind_channels'],
|
||||
},
|
||||
fieldName: 'remind_phone',
|
||||
label: '提醒手机号',
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
597
apps/web-antd/src/views/dashboard/calendar/index.vue
Normal file
597
apps/web-antd/src/views/dashboard/calendar/index.vue
Normal file
@@ -0,0 +1,597 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 日程日历页
|
||||
* - 基于 ant-design-vue Calendar 封装:农历/节气/节假日(中国节日按农历推算,国际节日按公历)
|
||||
* - 右键格子弹出菜单:新建待办 / 查看当日待办;格子内待办条目点击直接编辑
|
||||
* - 待办支持站内信/邮箱/短信定时提醒(后端 calendar:todo-remind 每分钟扫描发送)
|
||||
*/
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Calendar, Spin, Tooltip } from 'ant-design-vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { Solar } from 'lunar-typescript';
|
||||
|
||||
import { getCalendarTodoList } from './api';
|
||||
import DayTodosModalComp from './components/day-todos-modal.vue';
|
||||
import TodoModalComp from './components/todo-modal.vue';
|
||||
import { getDayAlmanac } from './utils/almanac';
|
||||
|
||||
defineOptions({ name: 'WorkCalendar' });
|
||||
|
||||
/** 日历面板当前值(含选中日期与展示月份) */
|
||||
const panelDate = ref<Dayjs>(dayjs());
|
||||
const loading = ref(false);
|
||||
/** 待办按日期分组:key = YYYY-MM-DD */
|
||||
const todosMap = ref<Map<string, any[]>>(new Map());
|
||||
|
||||
/** 待办新建/编辑弹窗 */
|
||||
const [TodoFormModal, todoModalApi] = useVbenModal({
|
||||
connectedComponent: TodoModalComp,
|
||||
});
|
||||
/** 当日待办列表弹窗 */
|
||||
const [DayTodosModal, dayTodosModalApi] = useVbenModal({
|
||||
connectedComponent: DayTodosModalComp,
|
||||
});
|
||||
|
||||
/** 头部标题:公历年月 + 农历干支生肖年,如「2026年2月 · 丙午马年」 */
|
||||
const headerTitle = computed(() => {
|
||||
const d = panelDate.value;
|
||||
const lunar = Solar.fromYmd(d.year(), d.month() + 1, 1).getLunar();
|
||||
return {
|
||||
solar: d.format('YYYY年M月'),
|
||||
lunar: `农历${lunar.getYearInGanZhi()}${lunar.getYearShengXiao()}年`,
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 拉取面板范围内的待办并按日期分组
|
||||
* 范围取当月前后各留一段:日历 42 格会露出上月末尾与下月开头,宽松范围一次覆盖
|
||||
*/
|
||||
async function loadTodos() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const start = panelDate.value.startOf('month').subtract(7, 'day').startOf('day');
|
||||
const end = panelDate.value.endOf('month').add(14, 'day').endOf('day');
|
||||
const list = await getCalendarTodoList({
|
||||
start_time: start.unix(),
|
||||
end_time: end.unix(),
|
||||
});
|
||||
const map = new Map<string, any[]>();
|
||||
(list || []).forEach((todo: any) => {
|
||||
const key = dayjs.unix(Number(todo.todo_time)).format('YYYY-MM-DD');
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(todo);
|
||||
});
|
||||
todosMap.value = map;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 月份变化时重拉(同月内点选日期不触发,避免无谓请求)
|
||||
watch(() => panelDate.value.format('YYYY-MM'), loadTodos, { immediate: true });
|
||||
|
||||
/** 组装单个格子的渲染数据:黄历信息 + 当日待办 */
|
||||
function cellData(current: Dayjs) {
|
||||
return {
|
||||
almanac: getDayAlmanac(current.year(), current.month() + 1, current.date()),
|
||||
todos: todosMap.value.get(current.format('YYYY-MM-DD')) || [],
|
||||
isToday: current.isSame(dayjs(), 'day'),
|
||||
};
|
||||
}
|
||||
|
||||
/** 待办时间点文案:00:00 视为全天 */
|
||||
function todoTimeText(todo: any) {
|
||||
const t = dayjs.unix(Number(todo.todo_time));
|
||||
return t.format('HH:mm') === '00:00' ? '' : t.format('HH:mm');
|
||||
}
|
||||
|
||||
// ---------------- 右键菜单 ----------------
|
||||
/** 右键菜单状态:位置 + 目标日期 */
|
||||
const ctxMenu = reactive({ visible: false, x: 0, y: 0, date: '' });
|
||||
|
||||
/** 打开右键菜单:贴边时向内收,避免菜单溢出视口 */
|
||||
function openCtxMenu(e: MouseEvent, current: Dayjs) {
|
||||
ctxMenu.date = current.format('YYYY-MM-DD');
|
||||
ctxMenu.x = Math.min(e.clientX, window.innerWidth - 180);
|
||||
ctxMenu.y = Math.min(e.clientY, window.innerHeight - 120);
|
||||
ctxMenu.visible = true;
|
||||
}
|
||||
|
||||
function closeCtxMenu() {
|
||||
ctxMenu.visible = false;
|
||||
}
|
||||
|
||||
/** 右键菜单目标日期的待办数量 */
|
||||
const ctxTodoCount = computed(() => (todosMap.value.get(ctxMenu.date) || []).length);
|
||||
|
||||
onMounted(() => {
|
||||
// 点击任意处 / 滚动时关闭右键菜单
|
||||
window.addEventListener('click', closeCtxMenu);
|
||||
window.addEventListener('scroll', closeCtxMenu, true);
|
||||
});
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('click', closeCtxMenu);
|
||||
window.removeEventListener('scroll', closeCtxMenu, true);
|
||||
});
|
||||
|
||||
// ---------------- 待办操作 ----------------
|
||||
/** 新建待办:date 为默认日期(右键格子/当日弹窗/头部按钮带入) */
|
||||
function createTodo(date?: string) {
|
||||
closeCtxMenu();
|
||||
todoModalApi
|
||||
.setData({
|
||||
update: false,
|
||||
defaultDate: date || panelDate.value.format('YYYY-MM-DD'),
|
||||
onSaved: loadTodos,
|
||||
})
|
||||
.open();
|
||||
}
|
||||
|
||||
/** 编辑待办 */
|
||||
function editTodo(todo: any) {
|
||||
closeCtxMenu();
|
||||
todoModalApi.setData({ update: true, values: todo, onSaved: loadTodos }).open();
|
||||
}
|
||||
|
||||
/** 打开当日待办列表弹窗 */
|
||||
function openDay(date: string) {
|
||||
closeCtxMenu();
|
||||
dayTodosModalApi
|
||||
.setData({
|
||||
date,
|
||||
todos: todosMap.value.get(date) || [],
|
||||
onEdit: editTodo,
|
||||
onCreate: createTodo,
|
||||
onChanged: loadTodos,
|
||||
})
|
||||
.open();
|
||||
}
|
||||
|
||||
// ---------------- 月份切换 ----------------
|
||||
function prevMonth() {
|
||||
panelDate.value = panelDate.value.subtract(1, 'month');
|
||||
}
|
||||
function nextMonth() {
|
||||
panelDate.value = panelDate.value.add(1, 'month');
|
||||
}
|
||||
function backToday() {
|
||||
panelDate.value = dayjs();
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<div class="work-calendar">
|
||||
<!-- 自定义头部:年月标题 + 农历年 + 图例 + 操作区 -->
|
||||
<div class="wc-header">
|
||||
<div class="wc-header-left">
|
||||
<span class="wc-title">{{ headerTitle.solar }}</span>
|
||||
<span class="wc-lunar-year">{{ headerTitle.lunar }}</span>
|
||||
</div>
|
||||
<div class="wc-header-right">
|
||||
<div class="wc-legend">
|
||||
<span class="wc-legend-item"><i class="dot festival"></i>节日</span>
|
||||
<span class="wc-legend-item"><i class="dot jieqi"></i>节气</span>
|
||||
<span class="wc-legend-item"><i class="badge rest">休</i>法定假</span>
|
||||
<span class="wc-legend-item"><i class="badge work">班</i>补班</span>
|
||||
</div>
|
||||
<Button size="small" @click="prevMonth">‹ 上月</Button>
|
||||
<Button size="small" @click="backToday">今天</Button>
|
||||
<Button size="small" @click="nextMonth">下月 ›</Button>
|
||||
<Button type="primary" size="small" @click="createTodo()">+ 新建待办</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Spin :spinning="loading">
|
||||
<Calendar v-model:value="panelDate" :fullscreen="true">
|
||||
<!-- 隐藏 antd 默认头部,统一用上方自定义头部 -->
|
||||
<template #headerRender><span></span></template>
|
||||
<template #dateFullCellRender="{ current }">
|
||||
<div
|
||||
class="cal-cell"
|
||||
:class="{ 'is-today': cellData(current).isToday }"
|
||||
@contextmenu.prevent="openCtxMenu($event, current)"
|
||||
>
|
||||
<div class="cal-cell-head">
|
||||
<span class="cal-day">{{ current.date() }}</span>
|
||||
<span
|
||||
v-if="cellData(current).almanac.holiday"
|
||||
class="cal-badge"
|
||||
:class="cellData(current).almanac.holiday!.isWork ? 'work' : 'rest'"
|
||||
>
|
||||
{{ cellData(current).almanac.holiday!.isWork ? '班' : '休' }}
|
||||
</span>
|
||||
<Tooltip
|
||||
v-if="cellData(current).almanac.festivals.length > 0"
|
||||
:title="cellData(current).almanac.festivals.join('、')"
|
||||
>
|
||||
<span class="cal-lunar" :class="cellData(current).almanac.displayType">
|
||||
{{ cellData(current).almanac.displayText }}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<span v-else class="cal-lunar" :class="cellData(current).almanac.displayType">
|
||||
{{ cellData(current).almanac.displayText }}
|
||||
</span>
|
||||
<button
|
||||
class="cal-add"
|
||||
title="新建待办"
|
||||
@click.stop="createTodo(current.format('YYYY-MM-DD'))"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<div class="cal-todos">
|
||||
<div
|
||||
v-for="todo in cellData(current).todos.slice(0, 3)"
|
||||
:key="todo.id"
|
||||
class="cal-todo"
|
||||
:class="{ done: Number(todo.is_done) === 1 }"
|
||||
@click.stop="editTodo(todo)"
|
||||
>
|
||||
<i class="cal-todo-dot"></i>
|
||||
<span class="cal-todo-text">
|
||||
<template v-if="todoTimeText(todo)">{{ todoTimeText(todo) }} </template>{{ todo.title }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="cellData(current).todos.length > 3"
|
||||
class="cal-more"
|
||||
@click.stop="openDay(current.format('YYYY-MM-DD'))"
|
||||
>
|
||||
还有 {{ cellData(current).todos.length - 3 }} 项…
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Calendar>
|
||||
</Spin>
|
||||
<!-- 右键菜单:Teleport 到 body 避免被日历容器裁剪 -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="ctxMenu.visible"
|
||||
class="wc-ctx-menu"
|
||||
:style="{ left: `${ctxMenu.x}px`, top: `${ctxMenu.y}px` }"
|
||||
@click.stop
|
||||
>
|
||||
<div class="wc-ctx-date">{{ dayjs(ctxMenu.date).format('M月D日') }}</div>
|
||||
<div class="wc-ctx-item" @click="createTodo(ctxMenu.date)">
|
||||
<span>+ 新建待办</span>
|
||||
</div>
|
||||
<div class="wc-ctx-item" @click="openDay(ctxMenu.date)">
|
||||
<span>☰ 查看当日待办</span>
|
||||
<span v-if="ctxTodoCount > 0" class="wc-ctx-count">{{ ctxTodoCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<TodoFormModal />
|
||||
<DayTodosModal />
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
<style lang="scss" scoped>
|
||||
.work-calendar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* ---------- 头部 ---------- */
|
||||
.wc-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.wc-header-left {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.wc-title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.wc-lunar-year {
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.wc-header-right {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.wc-legend {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.wc-legend-item {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
|
||||
.dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
|
||||
&.festival {
|
||||
background: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
&.jieqi {
|
||||
background: hsl(var(--primary));
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
border-radius: 4px;
|
||||
|
||||
&.rest {
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 12%);
|
||||
}
|
||||
|
||||
&.work {
|
||||
color: hsl(var(--warning));
|
||||
background: hsl(var(--warning) / 14%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- 日历格子 ---------- */
|
||||
.cal-cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 104px;
|
||||
padding: 6px 8px;
|
||||
margin: 2px;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--muted) / 40%);
|
||||
|
||||
.cal-add {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-today {
|
||||
background: hsl(var(--primary) / 8%);
|
||||
box-shadow: inset 0 0 0 1.5px hsl(var(--primary) / 55%);
|
||||
|
||||
.cal-day {
|
||||
color: hsl(var(--primary-foreground, 0 0% 100%));
|
||||
background: hsl(var(--primary));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cal-cell-head {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cal-day {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.cal-lunar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&.festival {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
&.jieqi {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
}
|
||||
|
||||
.cal-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 0 4px;
|
||||
font-size: 10px;
|
||||
line-height: 16px;
|
||||
border-radius: 4px;
|
||||
|
||||
&.rest {
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 12%);
|
||||
}
|
||||
|
||||
&.work {
|
||||
color: hsl(var(--warning));
|
||||
background: hsl(var(--warning) / 14%);
|
||||
}
|
||||
}
|
||||
|
||||
/* hover 才出现的快捷新建按钮,避免视觉噪音 */
|
||||
.cal-add {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
background: hsl(var(--primary) / 10%);
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.cal-todos {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.cal-todo {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 1px 6px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--foreground) / 85%);
|
||||
cursor: pointer;
|
||||
background: hsl(var(--primary) / 8%);
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: hsl(var(--primary) / 16%);
|
||||
}
|
||||
|
||||
&.done {
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-decoration: line-through;
|
||||
|
||||
.cal-todo-dot {
|
||||
background: hsl(var(--muted-foreground));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.cal-todo-dot {
|
||||
flex-shrink: 0;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
background: hsl(var(--primary));
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.cal-todo-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cal-more {
|
||||
padding-left: 6px;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--primary));
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 非当月格子整体淡化 */
|
||||
:deep(.ant-picker-cell:not(.ant-picker-cell-in-view)) {
|
||||
.cal-cell {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* 去掉 antd 日历自带的格子上边框与内边距,让自定义格子撑满 */
|
||||
:deep(.ant-picker-calendar .ant-picker-panel) {
|
||||
background: transparent;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
:deep(.ant-picker-calendar .ant-picker-cell) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-picker-calendar .ant-picker-content th) {
|
||||
padding: 10px 8px 6px;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* ---------- 右键菜单 ---------- */
|
||||
.wc-ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 2100;
|
||||
min-width: 168px;
|
||||
padding: 6px;
|
||||
background: hsl(var(--popover, var(--background)));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 14%);
|
||||
}
|
||||
|
||||
.wc-ctx-date {
|
||||
padding: 4px 10px 6px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.wc-ctx-item {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 7px 10px;
|
||||
margin-top: 2px;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--foreground));
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
|
||||
&:hover {
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 10%);
|
||||
}
|
||||
}
|
||||
|
||||
.wc-ctx-count {
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 12%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
</style>
|
||||
79
apps/web-antd/src/views/dashboard/calendar/utils/almanac.ts
Normal file
79
apps/web-antd/src/views/dashboard/calendar/utils/almanac.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { HolidayUtil, Solar } from 'lunar-typescript';
|
||||
|
||||
/**
|
||||
* 黄历信息计算工具(基于 lunar-typescript)
|
||||
* 设计说明:
|
||||
* - 中国传统节日(春节/端午/中秋等)由农历推算,国际节日(元旦/圣诞/母亲节等)按公历,
|
||||
* 正好满足「中国节日算农历、其他节日按公历」的需求
|
||||
* - 法定节假日调休(休/班)数据由 HolidayUtil 内置,覆盖国务院历年放假安排
|
||||
* - 按「y-m-d」缓存计算结果:日历一屏 42 格、频繁切月,避免重复实例化 Solar/Lunar
|
||||
*/
|
||||
|
||||
/** 单日黄历信息 */
|
||||
export interface DayAlmanac {
|
||||
/** 格子副标题:优先级 节日 > 节气 > 初一显示月名 > 农历日 */
|
||||
displayText: string;
|
||||
/** 副标题类型,用于差异化配色:festival节日 / jieqi节气 / lunar农历 */
|
||||
displayType: 'festival' | 'jieqi' | 'lunar';
|
||||
/** 完整农历日期,如「正月初一」,悬浮提示用 */
|
||||
lunarDate: string;
|
||||
/** 当天节气(无则空串) */
|
||||
jieQi: string;
|
||||
/** 当天全部节日(农历节日 + 公历/国际节日),悬浮提示用 */
|
||||
festivals: string[];
|
||||
/** 法定节假日安排:null=正常工作日/周末,否则含名称与是否补班 */
|
||||
holiday: null | { isWork: boolean; name: string };
|
||||
}
|
||||
|
||||
/** 计算结果缓存:跨月切换时同一天不重复计算 */
|
||||
const cache = new Map<string, DayAlmanac>();
|
||||
|
||||
/**
|
||||
* 获取某个公历日期的黄历信息(农历/节气/节日/法定调休)
|
||||
* @param year 公历年
|
||||
* @param month 公历月(1-12)
|
||||
* @param day 公历日
|
||||
*/
|
||||
export function getDayAlmanac(year: number, month: number, day: number): DayAlmanac {
|
||||
const key = `${year}-${month}-${day}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached;
|
||||
|
||||
const solar = Solar.fromYmd(year, month, day);
|
||||
const lunar = solar.getLunar();
|
||||
|
||||
// 节日合并:农历节日在前(春节等中国节日优先展示),其后公历节日与国际纪念日
|
||||
const festivals = [
|
||||
...lunar.getFestivals(),
|
||||
...solar.getFestivals(),
|
||||
...lunar.getOtherFestivals(),
|
||||
...solar.getOtherFestivals(),
|
||||
];
|
||||
const jieQi = lunar.getJieQi();
|
||||
// 初一显示月名(如「二月」),其余显示农历日(如「十五」)
|
||||
const lunarDay =
|
||||
lunar.getDay() === 1 ? `${lunar.getMonthInChinese()}月` : lunar.getDayInChinese();
|
||||
|
||||
// 副标题择优:节日 > 节气 > 农历日,格子空间有限只显示一条
|
||||
let displayText = lunarDay;
|
||||
let displayType: DayAlmanac['displayType'] = 'lunar';
|
||||
if (festivals.length > 0) {
|
||||
displayText = festivals[0] as string;
|
||||
displayType = 'festival';
|
||||
} else if (jieQi) {
|
||||
displayText = jieQi;
|
||||
displayType = 'jieqi';
|
||||
}
|
||||
|
||||
const h = HolidayUtil.getHoliday(year, month, day);
|
||||
const result: DayAlmanac = {
|
||||
displayText,
|
||||
displayType,
|
||||
lunarDate: `${lunar.getMonthInChinese()}月${lunar.getDayInChinese()}`,
|
||||
jieQi,
|
||||
festivals,
|
||||
holiday: h ? { isWork: h.isWork(), name: h.getName() } : null,
|
||||
};
|
||||
cache.set(key, result);
|
||||
return result;
|
||||
}
|
||||
117
apps/web-antd/src/views/dashboard/workspace/api/index.ts
Normal file
117
apps/web-antd/src/views/dashboard/workspace/api/index.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
const prefix = 'workbench/';
|
||||
|
||||
/** 工作台布局项(后端按角色下发) */
|
||||
export type WorkbenchLayoutItem = {
|
||||
id: number;
|
||||
widget_code: string;
|
||||
title: string;
|
||||
sort: number;
|
||||
config: null | Record<string, any>;
|
||||
};
|
||||
|
||||
/** 医生今日摘要(与医生小程序工作台同口径) */
|
||||
export type DoctorSummaryResult = {
|
||||
range: string;
|
||||
prescription_count: number;
|
||||
wait_accept: number;
|
||||
accepting: number;
|
||||
completed: number;
|
||||
prescription_amount: string;
|
||||
};
|
||||
|
||||
/** 药师待审摘要 */
|
||||
export type PharmacistSummaryResult = {
|
||||
pending_total: number;
|
||||
};
|
||||
|
||||
/** 诊所今日摘要 */
|
||||
export type ClinicSummaryResult = {
|
||||
wait_accept: number;
|
||||
accepting: number;
|
||||
completed: number;
|
||||
wait_delivery: number;
|
||||
};
|
||||
|
||||
/** 订单管理员摘要 */
|
||||
export type OrderSummaryResult = {
|
||||
wait_delivery: number;
|
||||
refunding: number;
|
||||
};
|
||||
|
||||
/** 平台今日总览(超管/系统管理员) */
|
||||
export type PlatformOverviewResult = {
|
||||
order_count: number;
|
||||
register_count: number;
|
||||
revenue: number;
|
||||
store_input_count: number;
|
||||
};
|
||||
|
||||
/** 待办中心单项(count>0 需要处理,path 为跳转路由) */
|
||||
export type TodoCenterItem = {
|
||||
code: string;
|
||||
label: string;
|
||||
count: number;
|
||||
path: string;
|
||||
};
|
||||
|
||||
/** 待办中心结果 */
|
||||
export type TodoCenterResult = {
|
||||
items: TodoCenterItem[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取当前登录角色的工作台布局
|
||||
*/
|
||||
export async function getWorkbenchLayoutApi() {
|
||||
return requestClient.get<WorkbenchLayoutItem[]>(`${prefix}layout`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 医生今日摘要
|
||||
*/
|
||||
export async function getDoctorSummaryApi(range: string = 'today') {
|
||||
return requestClient.get<DoctorSummaryResult>(`${prefix}doctor-summary`, {
|
||||
params: { range },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 药师待审摘要
|
||||
*/
|
||||
export async function getPharmacistSummaryApi() {
|
||||
return requestClient.get<PharmacistSummaryResult>(
|
||||
`${prefix}pharmacist-summary`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 诊所今日摘要
|
||||
*/
|
||||
export async function getClinicSummaryApi() {
|
||||
return requestClient.get<ClinicSummaryResult>(`${prefix}clinic-summary`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 订单管理员摘要
|
||||
*/
|
||||
export async function getOrderSummaryApi() {
|
||||
return requestClient.get<OrderSummaryResult>(`${prefix}order-summary`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台今日总览(超管/系统管理员)
|
||||
*/
|
||||
export async function getPlatformOverviewApi() {
|
||||
return requestClient.get<PlatformOverviewResult>(
|
||||
`${prefix}platform-overview`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 待办中心(超管/系统管理员)
|
||||
*/
|
||||
export async function getTodoCenterApi() {
|
||||
return requestClient.get<TodoCenterResult>(`${prefix}todo-center`);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* KPI 卡片壳:统一「渐变竖条标题 + 芯片区」的 Premium 外壳
|
||||
* 为什么:各角色 KPI 组件共用同一外壳,避免每个组件复制标题/容器样式
|
||||
* 同时统一收口加载骨架 / 失败重试 / 手动刷新,KPI 组件只管拉数与渲染芯片
|
||||
*/
|
||||
import { IconifyIcon as VbenIcon } from '@vben/icons';
|
||||
|
||||
interface Props {
|
||||
/** 卡片标题 */
|
||||
title: string;
|
||||
/** 标题右侧补充说明(如「近7天」) */
|
||||
subtitle?: string;
|
||||
/** 加载中:渲染骨架占位,不再整卡消失 */
|
||||
loading?: boolean;
|
||||
/** 请求失败:渲染错误行 + 重试按钮 */
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
subtitle: '',
|
||||
loading: false,
|
||||
error: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
/** 点击刷新/重试时触发,由外部组件重新拉数 */
|
||||
refresh: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-card__head">
|
||||
<span class="kpi-card__title-bar"></span>
|
||||
<span class="kpi-card__title">{{ title }}</span>
|
||||
<span v-if="subtitle" class="kpi-card__subtitle">{{ subtitle }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="kpi-card__refresh"
|
||||
:class="{ 'is-loading': loading }"
|
||||
:disabled="loading"
|
||||
aria-label="刷新"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<VbenIcon icon="lucide:refresh-cw" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 加载骨架:占位形状与统计芯片一致,避免布局跳动 -->
|
||||
<div v-if="loading" class="kpi-card__chips" aria-label="加载中">
|
||||
<div v-for="n in 3" :key="n" class="kpi-skeleton">
|
||||
<div class="sk sk-icon"></div>
|
||||
<div class="sk-lines">
|
||||
<div class="sk sk-value"></div>
|
||||
<div class="sk sk-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 失败态:可重试,不再静默消失 -->
|
||||
<div v-else-if="error" class="kpi-card__error">
|
||||
<VbenIcon icon="lucide:cloud-off" class="kpi-card__error-icon" />
|
||||
<span>数据加载失败</span>
|
||||
<button type="button" class="kpi-card__retry" @click="emit('refresh')">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="kpi-card__chips">
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kpi-card {
|
||||
padding: 18px 20px;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border) / 0.7);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.kpi-card__head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.kpi-card__title-bar {
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: linear-gradient(180deg, hsl(var(--primary)), hsl(var(--primary) / 0.4));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.kpi-card__title {
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: hsl(var(--foreground));
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.kpi-card__subtitle {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* 刷新按钮:靠右,hover 才显主色,避免抢视觉 */
|
||||
.kpi-card__refresh {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
|
||||
.kpi-card__refresh:hover:not(:disabled) {
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.kpi-card__refresh :deep(svg) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.kpi-card__refresh.is-loading :deep(svg) {
|
||||
animation: kpi-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.kpi-card__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* ===== 骨架占位 ===== */
|
||||
.kpi-skeleton {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 128px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.sk {
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(var(--muted) / 0.5) 25%,
|
||||
hsl(var(--muted) / 0.3) 50%,
|
||||
hsl(var(--muted) / 0.5) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: kpi-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sk-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sk-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sk-value {
|
||||
width: 56px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.sk-label {
|
||||
width: 40px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* ===== 失败态 ===== */
|
||||
.kpi-card__error {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 14px 4px;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.kpi-card__error-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.kpi-card__retry {
|
||||
height: 26px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid hsl(var(--primary) / 0.35);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--primary) / 0.06);
|
||||
color: hsl(var(--primary));
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.kpi-card__retry:hover {
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
@keyframes kpi-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes kpi-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sk,
|
||||
.kpi-card__refresh.is-loading :deep(svg) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* KPI 统计卡:小图标 + 大数字 + 小标签,整卡可点跳转业务页
|
||||
* Premium 视觉:tinted 图标容器、hover 轻抬升 + primary 描边透出;颜色全走主题变量,亮/暗自适应
|
||||
*/
|
||||
import { IconifyIcon as VbenIcon } from '@vben/icons';
|
||||
|
||||
interface Props {
|
||||
/** 指标标签 */
|
||||
label: string;
|
||||
/** 指标值(数字或金额字符串) */
|
||||
value: number | string;
|
||||
/** 高亮强调(如待办数 > 0 时用主题色) */
|
||||
highlight?: boolean;
|
||||
/** iconify 图标名(可选) */
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
highlight: false,
|
||||
icon: '',
|
||||
});
|
||||
|
||||
defineEmits(['click']);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="stat-chip" :class="{ 'stat-chip--hl': highlight }" @click="$emit('click')">
|
||||
<div v-if="icon" class="stat-chip__icon-box">
|
||||
<VbenIcon :icon="icon" class="stat-chip__icon" />
|
||||
</div>
|
||||
<div class="stat-chip__meta">
|
||||
<span class="stat-chip__value">{{ value }}</span>
|
||||
<span class="stat-chip__label">{{ label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-chip {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
min-width: 128px;
|
||||
padding: 14px 16px;
|
||||
cursor: pointer;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border) / 0.7);
|
||||
border-radius: 12px;
|
||||
transition:
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: hsl(var(--primary) / 0.35);
|
||||
box-shadow:
|
||||
0 1px 2px hsl(var(--foreground) / 0.04),
|
||||
0 8px 24px hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
/* 高亮卡:待办类指标底色轻着色,暗色下同样柔和 */
|
||||
.stat-chip--hl {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--primary) / 0.08),
|
||||
hsl(var(--card)) 65%
|
||||
);
|
||||
border-color: hsl(var(--primary) / 0.25);
|
||||
}
|
||||
|
||||
.stat-chip__icon-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-chip:hover .stat-chip__icon-box {
|
||||
background: hsl(var(--primary) / 0.16);
|
||||
}
|
||||
|
||||
.stat-chip__icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.stat-chip__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stat-chip__value {
|
||||
overflow: hidden;
|
||||
font-size: 22px;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
color: hsl(var(--foreground));
|
||||
letter-spacing: -0.02em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-chip--hl .stat-chip__value {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.stat-chip__label {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* 用户偏好减少动效时关闭位移动画 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.stat-chip {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.stat-chip:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,405 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 管理驾驶舱合并卡(超管/系统管理员):平台总览 + 待办中心整合为一张卡
|
||||
* 为什么合并:两个模块都是「今日经营快照」,拆成两张卡会让首屏纵向过长;
|
||||
* 合并后放在工作台顶部左列,与右列公告构成一行,信息密度更高
|
||||
* 数据仍走 workbench/platform-overview 与 workbench/todo-center 两个接口并行拉取
|
||||
*/
|
||||
import type { PlatformOverviewResult, TodoCenterItem } from '../api';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { IconifyIcon as VbenIcon } from '@vben/icons';
|
||||
|
||||
import { getPlatformOverviewApi, getTodoCenterApi } from '../api';
|
||||
import StatChip from './StatChip.vue';
|
||||
|
||||
interface Props {
|
||||
/** 总览分区标题(来自布局下发的 platform_overview.title) */
|
||||
overviewTitle: string;
|
||||
/** 待办分区标题(来自布局下发的 todo_center.title),未配置时兜底「待办中心」 */
|
||||
todoTitle?: string;
|
||||
}
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
todoTitle: '待办中心',
|
||||
});
|
||||
|
||||
/** 待办 code → 图标映射(后端只下发 code,图标属于前端展示细节) */
|
||||
const TODO_ICON_MAP: Record<string, string> = {
|
||||
prescription_pending: 'lucide:shield-check',
|
||||
withdraw_pending: 'lucide:banknote',
|
||||
store_input_pending: 'lucide:store',
|
||||
wait_delivery: 'lucide:package',
|
||||
refunding: 'lucide:rotate-ccw',
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const overview = ref<null | PlatformOverviewResult>(null);
|
||||
const todoItems = ref<TodoCenterItem[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
|
||||
/** 待办全部清零时展示鼓励空态,而不是一排 0 */
|
||||
const allClear = computed(
|
||||
() => todoItems.value.length > 0 && todoItems.value.every((it) => it.count <= 0),
|
||||
);
|
||||
|
||||
/**
|
||||
* 并行拉取总览与待办;任一失败进入整卡错误态可重试
|
||||
* 为什么整卡而不是分区错误:两个接口来自同一后端服务,同挂同恢复,分区错误徒增复杂度
|
||||
*/
|
||||
async function fetchAll() {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
const [ov, todo] = await Promise.all([
|
||||
getPlatformOverviewApi(),
|
||||
getTodoCenterApi(),
|
||||
]);
|
||||
overview.value = ov;
|
||||
todoItems.value = todo.items || [];
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳转指定路由(失败静默,避免无权限菜单时报错) */
|
||||
function go(path: string) {
|
||||
if (!path) return;
|
||||
router.push(path).catch(() => {});
|
||||
}
|
||||
|
||||
onMounted(fetchAll);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-hub">
|
||||
<!-- 总览分区头:标题 + 今日说明 + 整卡刷新 -->
|
||||
<div class="admin-hub__head">
|
||||
<span class="admin-hub__title-bar"></span>
|
||||
<span class="admin-hub__title">{{ props.overviewTitle }}</span>
|
||||
<span class="admin-hub__subtitle">今日</span>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-hub__refresh"
|
||||
:class="{ 'is-loading': loading }"
|
||||
:disabled="loading"
|
||||
aria-label="刷新"
|
||||
@click="fetchAll"
|
||||
>
|
||||
<VbenIcon icon="lucide:refresh-cw" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 加载骨架:总览与待办两组芯片占位,避免布局跳动 -->
|
||||
<template v-if="loading">
|
||||
<div class="admin-hub__chips" aria-label="加载中">
|
||||
<div v-for="n in 4" :key="n" class="hub-skeleton">
|
||||
<div class="sk sk-icon"></div>
|
||||
<div class="sk-lines">
|
||||
<div class="sk sk-value"></div>
|
||||
<div class="sk sk-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-hub__divider"></div>
|
||||
<div class="admin-hub__chips" aria-label="加载中">
|
||||
<div v-for="n in 5" :key="n" class="hub-skeleton">
|
||||
<div class="sk sk-icon"></div>
|
||||
<div class="sk-lines">
|
||||
<div class="sk sk-value"></div>
|
||||
<div class="sk sk-label"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- 失败态:整卡重试 -->
|
||||
<div v-else-if="error" class="admin-hub__error">
|
||||
<VbenIcon icon="lucide:cloud-off" class="admin-hub__error-icon" />
|
||||
<span>数据加载失败</span>
|
||||
<button type="button" class="admin-hub__retry" @click="fetchAll">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- 平台总览:四个统计芯片撑满一行 -->
|
||||
<div v-if="overview" class="admin-hub__chips">
|
||||
<StatChip
|
||||
:value="overview.order_count"
|
||||
icon="lucide:shopping-cart"
|
||||
label="今日订单"
|
||||
@click="go('/order/product-order')"
|
||||
/>
|
||||
<StatChip
|
||||
:value="`¥${overview.revenue}`"
|
||||
icon="lucide:banknote"
|
||||
label="今日营业额"
|
||||
@click="go('/order/product-order')"
|
||||
/>
|
||||
<StatChip
|
||||
:value="overview.register_count"
|
||||
icon="lucide:stethoscope"
|
||||
label="今日挂号"
|
||||
@click="go('/business/register/list')"
|
||||
/>
|
||||
<StatChip
|
||||
:value="overview.store_input_count"
|
||||
icon="lucide:store"
|
||||
label="新增门店录入"
|
||||
@click="go('/system/store-input')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-hub__divider"></div>
|
||||
|
||||
<!-- 待办中心分区 -->
|
||||
<div class="admin-hub__sub-head">
|
||||
<span class="admin-hub__title-bar admin-hub__title-bar--sub"></span>
|
||||
<span class="admin-hub__sub-title">{{ props.todoTitle }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="allClear" class="admin-hub__clear">
|
||||
<VbenIcon icon="lucide:party-popper" class="admin-hub__clear-icon" />
|
||||
<span>今日无待办,全部处理完毕</span>
|
||||
</div>
|
||||
|
||||
<!-- 待办改用统计芯片(与总览同视觉),count>0 高亮提示优先处理 -->
|
||||
<div v-else class="admin-hub__chips">
|
||||
<StatChip
|
||||
v-for="item in todoItems"
|
||||
:key="item.code"
|
||||
:value="item.count > 999 ? '999+' : item.count"
|
||||
:label="item.label"
|
||||
:icon="TODO_ICON_MAP[item.code] || 'lucide:list-todo'"
|
||||
:highlight="item.count > 0"
|
||||
@click="go(item.path)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-hub {
|
||||
height: 100%;
|
||||
padding: 18px 20px;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border) / 0.7);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.admin-hub__head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-hub__title-bar {
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: linear-gradient(180deg, hsl(var(--primary)), hsl(var(--primary) / 0.4));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.admin-hub__title-bar--sub {
|
||||
height: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.admin-hub__title {
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: hsl(var(--foreground));
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.admin-hub__subtitle {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* 刷新按钮:靠右,hover 才显主色 */
|
||||
.admin-hub__refresh {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background 0.15s ease;
|
||||
}
|
||||
|
||||
.admin-hub__refresh:hover:not(:disabled) {
|
||||
color: hsl(var(--primary));
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.admin-hub__refresh :deep(svg) {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.admin-hub__refresh.is-loading :deep(svg) {
|
||||
animation: hub-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* 统计芯片区(总览与待办共用):芯片等分撑满一行,放不下自动换行 */
|
||||
.admin-hub__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-hub__chips :deep(.stat-chip) {
|
||||
flex: 1;
|
||||
min-width: 128px;
|
||||
}
|
||||
|
||||
.admin-hub__divider {
|
||||
height: 1px;
|
||||
margin: 14px 0;
|
||||
background: hsl(var(--border) / 0.6);
|
||||
}
|
||||
|
||||
.admin-hub__sub-head {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.admin-hub__sub-title {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* 空态:全部清零 */
|
||||
.admin-hub__clear {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 14px 4px;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.admin-hub__clear-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* ===== 骨架占位 ===== */
|
||||
.hub-skeleton {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 128px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid hsl(var(--border) / 0.5);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.sk {
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(var(--muted) / 0.5) 25%,
|
||||
hsl(var(--muted) / 0.3) 50%,
|
||||
hsl(var(--muted) / 0.5) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: hub-shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sk-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sk-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sk-value {
|
||||
width: 56px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.sk-label {
|
||||
width: 40px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
/* ===== 失败态 ===== */
|
||||
.admin-hub__error {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 14px 4px;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.admin-hub__error-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.admin-hub__retry {
|
||||
height: 26px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid hsl(var(--primary) / 0.35);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--primary) / 0.06);
|
||||
color: hsl(var(--primary));
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.admin-hub__retry:hover {
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
@keyframes hub-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes hub-shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sk,
|
||||
.admin-hub__refresh.is-loading :deep(svg) {
|
||||
animation: none;
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 诊所今日摘要:今日挂号(待接诊/接诊中/已完成)+ 待发货订单
|
||||
* 数据走 workbench/clinic-summary;挂号跳挂号订单页,待发货跳商品订单页
|
||||
*/
|
||||
import type { ClinicSummaryResult } from '../api';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getClinicSummaryApi } from '../api';
|
||||
import KpiCard from './KpiCard.vue';
|
||||
import StatChip from './StatChip.vue';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
defineProps<Props>();
|
||||
|
||||
const router = useRouter();
|
||||
const data = ref<ClinicSummaryResult | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
|
||||
/**
|
||||
* 拉取今日摘要;失败进入错误态可重试(不再静默消失)
|
||||
*/
|
||||
async function fetchSummary() {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
data.value = await getClinicSummaryApi();
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转挂号订单列表
|
||||
*/
|
||||
function goRegister() {
|
||||
router.push('/business/register/list').catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转商品订单列表
|
||||
*/
|
||||
function goProductOrder() {
|
||||
router.push('/order/product-order').catch(() => {});
|
||||
}
|
||||
|
||||
onMounted(fetchSummary);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KpiCard
|
||||
:title="title"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
@refresh="fetchSummary"
|
||||
>
|
||||
<template v-if="data">
|
||||
<StatChip
|
||||
:highlight="data.wait_accept > 0"
|
||||
:value="data.wait_accept"
|
||||
icon="lucide:clock"
|
||||
label="待接诊"
|
||||
@click="goRegister"
|
||||
/>
|
||||
<StatChip
|
||||
:value="data.accepting"
|
||||
icon="lucide:stethoscope"
|
||||
label="接诊中"
|
||||
@click="goRegister"
|
||||
/>
|
||||
<StatChip
|
||||
:value="data.completed"
|
||||
icon="lucide:check-circle-2"
|
||||
label="已完成"
|
||||
@click="goRegister"
|
||||
/>
|
||||
<StatChip
|
||||
:highlight="data.wait_delivery > 0"
|
||||
:value="data.wait_delivery"
|
||||
icon="lucide:package"
|
||||
label="待发货"
|
||||
@click="goProductOrder"
|
||||
/>
|
||||
</template>
|
||||
</KpiCard>
|
||||
</template>
|
||||
@@ -0,0 +1,91 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 医生今日摘要:待接诊/接诊中/已完成/今日开方/有效处方金额
|
||||
* 数据走 workbench/doctor-summary(与医生小程序工作台同口径),整卡可点跳医生接诊页
|
||||
*/
|
||||
import type { DoctorSummaryResult } from '../api';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getDoctorSummaryApi } from '../api';
|
||||
import KpiCard from './KpiCard.vue';
|
||||
import StatChip from './StatChip.vue';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
defineProps<Props>();
|
||||
|
||||
const router = useRouter();
|
||||
const data = ref<DoctorSummaryResult | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
|
||||
/**
|
||||
* 拉取今日摘要;失败进入错误态可重试(不再静默消失)
|
||||
*/
|
||||
async function fetchSummary() {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
data.value = await getDoctorSummaryApi('today');
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转医生接诊页
|
||||
*/
|
||||
function goReception() {
|
||||
router.push('/doctor/reception').catch(() => {});
|
||||
}
|
||||
|
||||
onMounted(fetchSummary);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KpiCard
|
||||
:title="title"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
@refresh="fetchSummary"
|
||||
>
|
||||
<template v-if="data">
|
||||
<StatChip
|
||||
:highlight="data.wait_accept > 0"
|
||||
:value="data.wait_accept"
|
||||
icon="lucide:clock"
|
||||
label="待接诊"
|
||||
@click="goReception"
|
||||
/>
|
||||
<StatChip
|
||||
:value="data.accepting"
|
||||
icon="lucide:stethoscope"
|
||||
label="接诊中"
|
||||
@click="goReception"
|
||||
/>
|
||||
<StatChip
|
||||
:value="data.completed"
|
||||
icon="lucide:check-circle-2"
|
||||
label="已完成"
|
||||
@click="goReception"
|
||||
/>
|
||||
<StatChip
|
||||
:value="data.prescription_count"
|
||||
icon="lucide:file-text"
|
||||
label="今日开方"
|
||||
@click="goReception"
|
||||
/>
|
||||
<StatChip
|
||||
:value="data.prescription_amount"
|
||||
icon="lucide:wallet"
|
||||
label="有效处方金额"
|
||||
@click="goReception"
|
||||
/>
|
||||
</template>
|
||||
</KpiCard>
|
||||
</template>
|
||||
@@ -0,0 +1,407 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 最新动态(Premium):紧凑公告列表,tinted 图标 + 类型 pill + 点击弹详情
|
||||
* 类型为什么前端推导:announcement/latest(xk_notice 表)没有类型字段,
|
||||
* 按标题关键词映射出「更新/活动/维护/公告」纯展示标签,后端无需改动
|
||||
* 详情为什么不请求接口:latest 已全量返回 content,点击直接弹层展示即可
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { IconifyIcon as VbenIcon } from '@vben/icons';
|
||||
|
||||
import { getLatestNotice } from '#/views/system/notice/api';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
defineProps<Props>();
|
||||
|
||||
/** 公告项(announcement/latest 返回结构) */
|
||||
type NoticeItem = {
|
||||
avatar: string;
|
||||
content: string;
|
||||
date: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
/** 展示用公告项:附加前端推导的类型标签 */
|
||||
type DisplayNoticeItem = NoticeItem & {
|
||||
typeLabel: string;
|
||||
typeTone: 'danger' | 'info' | 'primary' | 'warning';
|
||||
};
|
||||
|
||||
/**
|
||||
* 按标题关键词推导公告类型(纯前端展示,后端无类型字段)
|
||||
* 顺序:维护类优先(最需要注意)→ 更新 → 活动 → 默认公告
|
||||
*/
|
||||
function resolveNoticeType(title: string): Pick<DisplayNoticeItem, 'typeLabel' | 'typeTone'> {
|
||||
const t = title || '';
|
||||
if (/维护|停机|故障/.test(t)) return { typeLabel: '维护', typeTone: 'danger' };
|
||||
if (/更新|升级|版本|上线/.test(t)) return { typeLabel: '更新', typeTone: 'info' };
|
||||
if (/活动|福利|优惠|节/.test(t)) return { typeLabel: '活动', typeTone: 'warning' };
|
||||
return { typeLabel: '公告', typeTone: 'primary' };
|
||||
}
|
||||
|
||||
const items = ref<DisplayNoticeItem[]>([]);
|
||||
/** 当前弹层展示的公告 */
|
||||
const activeItem = ref<DisplayNoticeItem | null>(null);
|
||||
|
||||
/** 公告详情弹层:只读展示,关闭默认页脚按钮 */
|
||||
const [DetailModal, detailModalApi] = useVbenModal({
|
||||
footer: false,
|
||||
header: false,
|
||||
closable: true,
|
||||
class: 'w-[560px]',
|
||||
});
|
||||
|
||||
/**
|
||||
* 拉取最新公告并附加类型标签;失败静默展示空态
|
||||
*/
|
||||
async function fetchLatest() {
|
||||
try {
|
||||
const res = await getLatestNotice(10);
|
||||
const list: NoticeItem[] = Array.isArray(res) ? res : [];
|
||||
items.value = list.map((it) => ({ ...it, ...resolveNoticeType(it.title) }));
|
||||
} catch {
|
||||
items.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击公告行:打开详情弹层(content 已全量在手,无需再请求)
|
||||
*/
|
||||
function openDetail(item: DisplayNoticeItem) {
|
||||
activeItem.value = item;
|
||||
detailModalApi.open();
|
||||
}
|
||||
|
||||
/** 弹层类型 pill 的 tone class(避免模板里拼接字符串) */
|
||||
const activeToneClass = computed(() =>
|
||||
activeItem.value ? `pill--${activeItem.value.typeTone}` : '',
|
||||
);
|
||||
|
||||
onMounted(fetchLatest);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="notice-widget">
|
||||
<div class="notice-widget__title-wrap">
|
||||
<span class="notice-widget__title-bar"></span>
|
||||
<span class="notice-widget__title">{{ title }}</span>
|
||||
</div>
|
||||
<ul v-if="items.length > 0" class="notice-widget__list">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.title + item.date"
|
||||
class="notice-widget__item"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="openDetail(item)"
|
||||
@keydown.enter="openDetail(item)"
|
||||
>
|
||||
<span class="notice-widget__icon-box">
|
||||
<VbenIcon
|
||||
:icon="item.avatar || 'lucide:megaphone'"
|
||||
class="notice-widget__icon"
|
||||
/>
|
||||
</span>
|
||||
<div class="notice-widget__body">
|
||||
<div class="notice-widget__item-head">
|
||||
<span class="pill" :class="`pill--${item.typeTone}`">
|
||||
{{ item.typeLabel }}
|
||||
</span>
|
||||
<span class="notice-widget__item-title">{{ item.title }}</span>
|
||||
</div>
|
||||
<!-- 公告内容后端可控,沿用原工作台渲染方式 -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="notice-widget__content" v-html="item.content"></div>
|
||||
</div>
|
||||
<span class="notice-widget__date">{{ item.date }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notice-widget__empty">
|
||||
<VbenIcon class="notice-widget__empty-icon" icon="lucide:inbox" />
|
||||
暂无公告
|
||||
</div>
|
||||
|
||||
<!-- 公告详情弹层:premium 风格 hero 头 + 富文本正文 -->
|
||||
<DetailModal>
|
||||
<div v-if="activeItem" class="notice-detail">
|
||||
<div class="notice-detail__hero">
|
||||
<span class="notice-detail__hero-icon">
|
||||
<VbenIcon :icon="activeItem.avatar || 'lucide:megaphone'" />
|
||||
</span>
|
||||
<div class="notice-detail__hero-meta">
|
||||
<div class="notice-detail__hero-top">
|
||||
<span class="pill" :class="activeToneClass">
|
||||
{{ activeItem.typeLabel }}
|
||||
</span>
|
||||
<span class="notice-detail__date">{{ activeItem.date }}</span>
|
||||
</div>
|
||||
<div class="notice-detail__title">{{ activeItem.title }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 公告内容后端可控,沿用列表同源富文本渲染 -->
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div class="notice-detail__content" v-html="activeItem.content"></div>
|
||||
</div>
|
||||
</DetailModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notice-widget {
|
||||
padding: 18px 20px;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border) / 0.7);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.notice-widget__title-wrap {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.notice-widget__title-bar {
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: linear-gradient(180deg, hsl(var(--primary)), hsl(var(--primary) / 0.4));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.notice-widget__title {
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: hsl(var(--foreground));
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.notice-widget__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.notice-widget__item {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin: 0 -10px;
|
||||
padding: 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.notice-widget__item:hover,
|
||||
.notice-widget__item:focus-visible {
|
||||
background: hsl(var(--muted) / 0.4);
|
||||
}
|
||||
|
||||
.notice-widget__item + .notice-widget__item {
|
||||
border-top: 1px solid hsl(var(--border) / 0.5);
|
||||
}
|
||||
|
||||
.notice-widget__icon-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
margin-top: 1px;
|
||||
background: hsl(var(--primary) / 0.09);
|
||||
border-radius: 9px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notice-widget__icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.notice-widget__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notice-widget__item-head {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notice-widget__item-title {
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 550;
|
||||
color: hsl(var(--foreground));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notice-widget__content {
|
||||
overflow: hidden;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.notice-widget__content :deep(a) {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.notice-widget__date {
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground) / 0.8);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notice-widget__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 28px 0;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.notice-widget__empty-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: hsl(var(--muted-foreground) / 0.6);
|
||||
}
|
||||
|
||||
/* ===== 类型 pill:主题变量着色,亮/暗自适应 ===== */
|
||||
.pill {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.pill--primary {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.pill--info {
|
||||
background: hsl(210 90% 50% / 0.12);
|
||||
color: hsl(210 90% 45%);
|
||||
}
|
||||
|
||||
.pill--warning {
|
||||
background: hsl(var(--warning, 38 92% 50%) / 0.14);
|
||||
color: hsl(var(--warning, 38 92% 40%));
|
||||
}
|
||||
|
||||
.pill--danger {
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
/* ===== 详情弹层 ===== */
|
||||
.notice-detail {
|
||||
padding: 4px 2px 8px;
|
||||
}
|
||||
|
||||
/* hero 头:柔和主色光斑背景,呼应消息详情场景弹层的 premium 语言 */
|
||||
.notice-detail__hero {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px 18px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 14px;
|
||||
background:
|
||||
radial-gradient(
|
||||
80% 120% at 0% 0%,
|
||||
hsl(var(--primary) / 0.14),
|
||||
transparent 60%
|
||||
),
|
||||
linear-gradient(
|
||||
165deg,
|
||||
hsl(var(--muted) / 0.4),
|
||||
hsl(var(--card, var(--background)))
|
||||
);
|
||||
}
|
||||
|
||||
.notice-detail__hero-icon {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
place-items: center;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.notice-detail__hero-icon :deep(svg) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.notice-detail__hero-meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notice-detail__hero-top {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.notice-detail__date {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.notice-detail__title {
|
||||
margin-top: 6px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
color: hsl(var(--foreground));
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.notice-detail__content {
|
||||
max-height: 52vh;
|
||||
overflow-y: auto;
|
||||
padding: 0 2px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.75;
|
||||
color: hsl(var(--foreground) / 0.88);
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.notice-detail__content :deep(a) {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.notice-detail__content :deep(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.notice-widget__item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 订单管理员摘要:平台待发货 / 退款处理中,整卡可点跳商品订单列表
|
||||
* 数据走 workbench/order-summary(轻量 count)
|
||||
*/
|
||||
import type { OrderSummaryResult } from '../api';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getOrderSummaryApi } from '../api';
|
||||
import KpiCard from './KpiCard.vue';
|
||||
import StatChip from './StatChip.vue';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
defineProps<Props>();
|
||||
|
||||
const router = useRouter();
|
||||
const data = ref<null | OrderSummaryResult>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
|
||||
/**
|
||||
* 拉取订单摘要;失败进入错误态可重试(不再静默消失)
|
||||
*/
|
||||
async function fetchSummary() {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
data.value = await getOrderSummaryApi();
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转商品订单列表
|
||||
*/
|
||||
function goProductOrder() {
|
||||
router.push('/order/product-order').catch(() => {});
|
||||
}
|
||||
|
||||
onMounted(fetchSummary);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KpiCard
|
||||
:title="title"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
@refresh="fetchSummary"
|
||||
>
|
||||
<template v-if="data">
|
||||
<StatChip
|
||||
:highlight="data.wait_delivery > 0"
|
||||
:value="data.wait_delivery"
|
||||
icon="lucide:package"
|
||||
label="待发货"
|
||||
@click="goProductOrder"
|
||||
/>
|
||||
<StatChip
|
||||
:highlight="data.refunding > 0"
|
||||
:value="data.refunding"
|
||||
icon="lucide:rotate-ccw"
|
||||
label="退款处理中"
|
||||
@click="goProductOrder"
|
||||
/>
|
||||
</template>
|
||||
</KpiCard>
|
||||
</template>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 药师待审摘要:待审处方总数,整卡可点跳审方列表
|
||||
* 数据走 workbench/pharmacist-summary(与审方列表 status=0 同口径)
|
||||
*/
|
||||
import type { PharmacistSummaryResult } from '../api';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getPharmacistSummaryApi } from '../api';
|
||||
import KpiCard from './KpiCard.vue';
|
||||
import StatChip from './StatChip.vue';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
defineProps<Props>();
|
||||
|
||||
const router = useRouter();
|
||||
const data = ref<null | PharmacistSummaryResult>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
|
||||
/**
|
||||
* 拉取待审数;失败进入错误态可重试(不再静默消失)
|
||||
*/
|
||||
async function fetchSummary() {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
data.value = await getPharmacistSummaryApi();
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转审方列表
|
||||
*/
|
||||
function goAudit() {
|
||||
router.push('/pharmacist/review-prescription').catch(() => {});
|
||||
}
|
||||
|
||||
onMounted(fetchSummary);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KpiCard
|
||||
:title="title"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
@refresh="fetchSummary"
|
||||
>
|
||||
<template v-if="data">
|
||||
<StatChip
|
||||
:highlight="data.pending_total > 0"
|
||||
:value="data.pending_total"
|
||||
icon="lucide:shield-check"
|
||||
label="待审处方"
|
||||
@click="goAudit"
|
||||
/>
|
||||
</template>
|
||||
</KpiCard>
|
||||
</template>
|
||||
@@ -0,0 +1,83 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 平台今日总览(超管/系统管理员):今日订单/今日营业额/今日挂号/新增门店录入
|
||||
* 数据走 workbench/platform-overview(轻量 count/sum,营业额口径与业务员看板一致)
|
||||
*/
|
||||
import type { PlatformOverviewResult } from '../api';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getPlatformOverviewApi } from '../api';
|
||||
import KpiCard from './KpiCard.vue';
|
||||
import StatChip from './StatChip.vue';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
defineProps<Props>();
|
||||
|
||||
const router = useRouter();
|
||||
const data = ref<null | PlatformOverviewResult>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
|
||||
/**
|
||||
* 拉取平台总览;失败进入错误态可重试(不再静默消失)
|
||||
*/
|
||||
async function fetchSummary() {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
data.value = await getPlatformOverviewApi();
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳转指定路由(失败静默,避免无权限菜单时报错) */
|
||||
function go(path: string) {
|
||||
router.push(path).catch(() => {});
|
||||
}
|
||||
|
||||
onMounted(fetchSummary);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KpiCard
|
||||
:title="title"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
subtitle="今日"
|
||||
@refresh="fetchSummary"
|
||||
>
|
||||
<template v-if="data">
|
||||
<StatChip
|
||||
:value="data.order_count"
|
||||
icon="lucide:shopping-cart"
|
||||
label="今日订单"
|
||||
@click="go('/order/product-order')"
|
||||
/>
|
||||
<StatChip
|
||||
:value="`¥${data.revenue}`"
|
||||
icon="lucide:banknote"
|
||||
label="今日营业额"
|
||||
@click="go('/order/product-order')"
|
||||
/>
|
||||
<StatChip
|
||||
:value="data.register_count"
|
||||
icon="lucide:stethoscope"
|
||||
label="今日挂号"
|
||||
@click="go('/business/register/list')"
|
||||
/>
|
||||
<StatChip
|
||||
:value="data.store_input_count"
|
||||
icon="lucide:store"
|
||||
label="新增门店录入"
|
||||
@click="go('/system/store-input')"
|
||||
/>
|
||||
</template>
|
||||
</KpiCard>
|
||||
</template>
|
||||
@@ -0,0 +1,392 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 快捷入口(Premium):角色钉住导航(图标瓷砖网格)+ 本地搜索全部授权菜单
|
||||
* 视觉:40px tinted 圆角图标容器,hover 抬升 + 容器着色加深;搜索结果行式列表
|
||||
* 数据源:钉住项走 admin/get-quick-menu;搜索池 = 钉住项 ∪ accessMenus 拍平叶子(纯本地,无接口)
|
||||
*/
|
||||
import type { SearchableMenuItem } from '../utils/flatten-menus';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { IconifyIcon as VbenIcon } from '@vben/icons';
|
||||
import { useAccessStore } from '@vben/stores';
|
||||
import { openWindow } from '@vben/utils';
|
||||
|
||||
import { Input } from 'ant-design-vue';
|
||||
|
||||
import { getQuickMenuApi } from '#/views/system/admin/api';
|
||||
|
||||
import { filterMenusByKeyword, flattenMenus } from '../utils/flatten-menus';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
defineProps<Props>();
|
||||
|
||||
/** 钉住的快捷导航项(角色配置) */
|
||||
type PinnedItem = {
|
||||
title: string;
|
||||
icon: string;
|
||||
url: string;
|
||||
color?: string;
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const accessStore = useAccessStore();
|
||||
|
||||
const pinnedItems = ref<PinnedItem[]>([]);
|
||||
const keyword = ref('');
|
||||
|
||||
/** 全部授权叶子菜单(本地拍平,一次计算) */
|
||||
const searchableMenus = computed<SearchableMenuItem[]>(() =>
|
||||
flattenMenus(accessStore.accessMenus),
|
||||
);
|
||||
|
||||
/**
|
||||
* 搜索结果:钉住项 ∪ 叶子菜单,按 path 去重
|
||||
*/
|
||||
const searchResults = computed<SearchableMenuItem[]>(() => {
|
||||
const kw = keyword.value.trim();
|
||||
if (!kw) return [];
|
||||
const fromPinned: SearchableMenuItem[] = pinnedItems.value
|
||||
.filter((item) => item.title.toLowerCase().includes(kw.toLowerCase()))
|
||||
.map((item) => ({ title: item.title, path: item.url, icon: item.icon }));
|
||||
const fromMenus = filterMenusByKeyword(searchableMenus.value, kw);
|
||||
const seen = new Set<string>();
|
||||
const merged: SearchableMenuItem[] = [];
|
||||
for (const item of [...fromPinned, ...fromMenus]) {
|
||||
if (!item.path || seen.has(item.path)) continue;
|
||||
seen.add(item.path);
|
||||
merged.push(item);
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
|
||||
/** 是否处于搜索态 */
|
||||
const isSearching = computed(() => keyword.value.trim() !== '');
|
||||
|
||||
/**
|
||||
* 拉取角色钉住的快捷导航
|
||||
*/
|
||||
async function fetchPinned() {
|
||||
try {
|
||||
const res = await getQuickMenuApi();
|
||||
pinnedItems.value = Array.isArray(res) ? res : [];
|
||||
} catch {
|
||||
pinnedItems.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转:外链新窗口,内部路由 push
|
||||
*/
|
||||
function navTo(url: string) {
|
||||
if (!url) return;
|
||||
if (url.startsWith('http')) {
|
||||
openWindow(url);
|
||||
return;
|
||||
}
|
||||
router.push(url).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 回车快捷跳转第一条搜索结果
|
||||
*/
|
||||
function onEnter() {
|
||||
const first = searchResults.value[0];
|
||||
if (first) navTo(first.path);
|
||||
}
|
||||
|
||||
onMounted(fetchPinned);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="quick-entry">
|
||||
<div class="quick-entry__head">
|
||||
<div class="quick-entry__title-wrap">
|
||||
<span class="quick-entry__title-bar"></span>
|
||||
<span class="quick-entry__title">{{ title }}</span>
|
||||
</div>
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
allow-clear
|
||||
class="quick-entry__search"
|
||||
placeholder="搜索菜单,回车直达"
|
||||
@press-enter="onEnter"
|
||||
>
|
||||
<template #prefix>
|
||||
<VbenIcon class="quick-entry__search-icon" icon="lucide:search" />
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
|
||||
<!-- 搜索态:紧凑结果列表 -->
|
||||
<div v-if="isSearching" class="quick-entry__results">
|
||||
<template v-if="searchResults.length > 0">
|
||||
<div
|
||||
v-for="item in searchResults"
|
||||
:key="item.path"
|
||||
class="quick-entry__result-item"
|
||||
@click="navTo(item.path)"
|
||||
>
|
||||
<span class="quick-entry__result-icon-box">
|
||||
<VbenIcon :icon="item.icon || 'lucide:file'" class="quick-entry__result-icon" />
|
||||
</span>
|
||||
<span class="quick-entry__result-title">{{ item.title }}</span>
|
||||
<span class="quick-entry__result-path">{{ item.path }}</span>
|
||||
<VbenIcon class="quick-entry__result-arrow" icon="lucide:arrow-right" />
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="quick-entry__empty">
|
||||
<VbenIcon class="quick-entry__empty-icon" icon="lucide:search-x" />
|
||||
未找到匹配菜单
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 默认态:钉住项瓷砖网格 -->
|
||||
<div v-else-if="pinnedItems.length > 0" class="quick-entry__grid">
|
||||
<div
|
||||
v-for="item in pinnedItems"
|
||||
:key="item.url + item.title"
|
||||
class="quick-entry__cell"
|
||||
@click="navTo(item.url)"
|
||||
>
|
||||
<span class="quick-entry__cell-icon-box">
|
||||
<VbenIcon
|
||||
:icon="item.icon || 'lucide:link'"
|
||||
:style="item.color ? { color: item.color, opacity: 0.9 } : undefined"
|
||||
class="quick-entry__cell-icon"
|
||||
/>
|
||||
</span>
|
||||
<span class="quick-entry__cell-title">{{ item.title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="quick-entry__empty">
|
||||
<VbenIcon class="quick-entry__empty-icon" icon="lucide:compass" />
|
||||
请在「角色管理」配置快捷导航,或直接搜索菜单
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.quick-entry {
|
||||
padding: 18px 20px;
|
||||
background: hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border) / 0.7);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.quick-entry__head {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
/* 标题前竖条:低成本的高级感层级标识 */
|
||||
.quick-entry__title-wrap {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.quick-entry__title-bar {
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
background: linear-gradient(180deg, hsl(var(--primary)), hsl(var(--primary) / 0.4));
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.quick-entry__title {
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: hsl(var(--foreground));
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.quick-entry__search {
|
||||
max-width: 280px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.quick-entry__search-icon {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* 瓷砖网格:桌面 8 列 → 平板 6 列 → 手机 4 列 */
|
||||
.quick-entry__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.quick-entry__grid {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.quick-entry__grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.quick-entry__cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 14px 4px 12px;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 12px;
|
||||
transition:
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.quick-entry__cell:hover {
|
||||
background: hsl(var(--card));
|
||||
border-color: hsl(var(--primary) / 0.25);
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 1px 2px hsl(var(--foreground) / 0.04),
|
||||
0 8px 20px hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
/* 图标容器:tinted 圆角方块,hover 着色加深 */
|
||||
.quick-entry__cell-icon-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: hsl(var(--primary) / 0.09);
|
||||
border-radius: 11px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.quick-entry__cell:hover .quick-entry__cell-icon-box {
|
||||
background: hsl(var(--primary) / 0.16);
|
||||
}
|
||||
|
||||
.quick-entry__cell-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.quick-entry__cell-title {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--foreground) / 0.85);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-entry__results {
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.quick-entry__result-item {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 9px 12px;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.quick-entry__result-item:hover {
|
||||
background: hsl(var(--primary) / 0.07);
|
||||
}
|
||||
|
||||
.quick-entry__result-icon-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: hsl(var(--primary) / 0.09);
|
||||
border-radius: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-entry__result-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.quick-entry__result-title {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quick-entry__result-path {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 结果行箭头:hover 时滑入,提示可点 */
|
||||
.quick-entry__result-arrow {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: hsl(var(--primary));
|
||||
opacity: 0;
|
||||
transform: translateX(-4px);
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.quick-entry__result-item:hover .quick-entry__result-arrow {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.quick-entry__empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 28px 0;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.quick-entry__empty-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
color: hsl(var(--muted-foreground) / 0.6);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.quick-entry__cell,
|
||||
.quick-entry__result-arrow {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.quick-entry__cell:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 门店录入摘要:录入总数/环比/待审核/有效营业额,整卡可点跳完整业务员看板
|
||||
* 复用 store-input/dashboard 接口,仅取 summary(趋势图等留在完整看板页)
|
||||
*/
|
||||
import type { SalespersonDashboardResult } from '#/views/system/salesperson-dashboard/api';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { getSalespersonDashboardApi } from '#/views/system/salesperson-dashboard/api';
|
||||
|
||||
import KpiCard from './KpiCard.vue';
|
||||
import StatChip from './StatChip.vue';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
defineProps<Props>();
|
||||
|
||||
const router = useRouter();
|
||||
const data = ref<null | SalespersonDashboardResult>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
|
||||
/** 环比文案:正数带 +,空值显示 — */
|
||||
const momText = computed(() => {
|
||||
const rate = data.value?.summary?.input_mom_rate;
|
||||
if (rate === null || rate === undefined) return '—';
|
||||
return `${rate > 0 ? '+' : ''}${rate}%`;
|
||||
});
|
||||
|
||||
/**
|
||||
* 拉取近 7 天录入摘要;失败进入错误态可重试(不再静默消失)
|
||||
*/
|
||||
async function fetchSummary() {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
data.value = await getSalespersonDashboardApi({ period: 'last7days' });
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转完整业务员看板
|
||||
*/
|
||||
function goDashboard() {
|
||||
router.push('/system/salesperson-dashboard').catch(() => {});
|
||||
}
|
||||
|
||||
onMounted(fetchSummary);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KpiCard
|
||||
:title="title"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
subtitle="近7天"
|
||||
@refresh="fetchSummary"
|
||||
>
|
||||
<template v-if="data">
|
||||
<StatChip
|
||||
:value="data.summary.input_total"
|
||||
icon="lucide:clipboard-list"
|
||||
label="录入总数"
|
||||
@click="goDashboard"
|
||||
/>
|
||||
<StatChip
|
||||
:value="momText"
|
||||
icon="lucide:trending-up"
|
||||
label="录入环比"
|
||||
@click="goDashboard"
|
||||
/>
|
||||
<StatChip
|
||||
:highlight="data.summary.pending_count > 0"
|
||||
:value="data.summary.pending_count"
|
||||
icon="lucide:hourglass"
|
||||
label="待审核"
|
||||
@click="goDashboard"
|
||||
/>
|
||||
<StatChip
|
||||
:value="data.summary.valid_amount_total"
|
||||
icon="lucide:banknote"
|
||||
label="有效营业额"
|
||||
@click="goDashboard"
|
||||
/>
|
||||
</template>
|
||||
</KpiCard>
|
||||
</template>
|
||||
@@ -0,0 +1,239 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 待办中心(超管/系统管理员):平台各类待处理事项行式列表
|
||||
* 数量 > 0 高亮并可点击直达处理页;全为 0 展示「今日无待办」空态
|
||||
* 数据走 workbench/todo-center,path 由后端下发(与菜单路由对齐)
|
||||
*/
|
||||
import type { TodoCenterItem } from '../api';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { IconifyIcon as VbenIcon } from '@vben/icons';
|
||||
|
||||
import { getTodoCenterApi } from '../api';
|
||||
import KpiCard from './KpiCard.vue';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
}
|
||||
defineProps<Props>();
|
||||
|
||||
/** code → 图标映射(后端只下发 code,图标属于前端展示细节) */
|
||||
const ICON_MAP: Record<string, string> = {
|
||||
prescription_pending: 'lucide:shield-check',
|
||||
withdraw_pending: 'lucide:banknote',
|
||||
store_input_pending: 'lucide:store',
|
||||
wait_delivery: 'lucide:package',
|
||||
refunding: 'lucide:rotate-ccw',
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const items = ref<TodoCenterItem[]>([]);
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
|
||||
/** 是否全部待办为 0(展示空态而不是一排 0) */
|
||||
const allClear = computed(
|
||||
() => items.value.length > 0 && items.value.every((it) => it.count <= 0),
|
||||
);
|
||||
|
||||
/**
|
||||
* 拉取待办中心;失败进入错误态可重试
|
||||
*/
|
||||
async function fetchTodo() {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
try {
|
||||
const res = await getTodoCenterApi();
|
||||
items.value = res.items || [];
|
||||
} catch {
|
||||
error.value = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 跳转对应处理页(失败静默,避免无权限菜单时报错) */
|
||||
function go(item: TodoCenterItem) {
|
||||
if (!item.path) return;
|
||||
router.push(item.path).catch(() => {});
|
||||
}
|
||||
|
||||
onMounted(fetchTodo);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<KpiCard
|
||||
:title="title"
|
||||
:loading="loading"
|
||||
:error="error"
|
||||
@refresh="fetchTodo"
|
||||
>
|
||||
<!-- 全部清零:空态鼓励,不渲染一排 0 -->
|
||||
<div v-if="allClear" class="todo-clear">
|
||||
<VbenIcon icon="lucide:party-popper" class="todo-clear__icon" />
|
||||
<span>今日无待办,全部处理完毕</span>
|
||||
</div>
|
||||
|
||||
<ul v-else class="todo-list">
|
||||
<li
|
||||
v-for="item in items"
|
||||
:key="item.code"
|
||||
class="todo-row"
|
||||
:class="{ 'is-active': item.count > 0 }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="go(item)"
|
||||
@keydown.enter="go(item)"
|
||||
>
|
||||
<span class="todo-row__icon-box">
|
||||
<VbenIcon :icon="ICON_MAP[item.code] || 'lucide:list-todo'" />
|
||||
</span>
|
||||
<span class="todo-row__label">{{ item.label }}</span>
|
||||
<span class="todo-row__count" :class="{ 'is-zero': item.count <= 0 }">
|
||||
{{ item.count > 999 ? '999+' : item.count }}
|
||||
</span>
|
||||
<VbenIcon icon="lucide:chevron-right" class="todo-row__arrow" />
|
||||
</li>
|
||||
</ul>
|
||||
</KpiCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 撑满 KpiCard 芯片区,改为纵向行式布局 */
|
||||
.todo-list {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.todo-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid hsl(var(--border) / 0.6);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.todo-row:hover,
|
||||
.todo-row:focus-visible {
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
/* 有待办的行:主色轻着色突出优先级 */
|
||||
.todo-row.is-active {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--primary) / 0.06),
|
||||
hsl(var(--card)) 70%
|
||||
);
|
||||
border-color: hsl(var(--primary) / 0.22);
|
||||
}
|
||||
|
||||
.todo-row.is-active:hover,
|
||||
.todo-row.is-active:focus-visible {
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
}
|
||||
|
||||
.todo-row__icon-box {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.todo-row__icon-box :deep(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.todo-row__label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13.5px;
|
||||
color: hsl(var(--foreground));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.todo-row__count {
|
||||
min-width: 26px;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
line-height: 22px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* 数量为 0:弱化成灰底,不抢注意力 */
|
||||
.todo-row__count.is-zero {
|
||||
background: hsl(var(--muted) / 0.6);
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.todo-row__arrow {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
transition: transform 0.15s ease;
|
||||
}
|
||||
|
||||
.todo-row:hover .todo-row__arrow {
|
||||
transform: translateX(2px);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* 空态:全部清零 */
|
||||
.todo-clear {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 16px 4px;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.todo-clear__icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.todo-row,
|
||||
.todo-row__arrow {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.todo-row:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,297 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 工作台问候头(Premium):primary 渐变底 + 装饰光斑,头像光环,按时段问候
|
||||
* 右侧铃铛未读角标跳消息中心;头像/姓名可点击进入个人中心,铃铛旁另有「个人中心」按钮
|
||||
* 颜色全走主题变量,暗色自动柔化
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { IconifyIcon as VbenIcon } from '@vben/icons';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { Avatar, Badge } from 'ant-design-vue';
|
||||
|
||||
import { getNoticeUnreadCountApi } from '#/views/notice/api';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
/** 未读消息数(铃铛角标同源接口) */
|
||||
const unreadCount = ref(0);
|
||||
|
||||
/** 按时段生成问候语,比「哈喽」更有质感 */
|
||||
const greeting = computed(() => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 6) return '夜深了';
|
||||
if (hour < 12) return '早上好';
|
||||
if (hour < 14) return '中午好';
|
||||
if (hour < 18) return '下午好';
|
||||
return '晚上好';
|
||||
});
|
||||
|
||||
/**
|
||||
* 拉取未读消息数;失败静默(角标非关键路径,不打断工作台加载)
|
||||
*/
|
||||
async function fetchUnreadCount() {
|
||||
try {
|
||||
const res = await getNoticeUnreadCountApi();
|
||||
unreadCount.value = Number(res?.count || 0);
|
||||
} catch {
|
||||
unreadCount.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击铃铛跳转消息中心
|
||||
*/
|
||||
function goNoticeCenter() {
|
||||
router.push('/notice/list');
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入个人中心(静态路由 /profile,与顶栏头像下拉同一入口)
|
||||
* 头像/姓名区域与右侧按钮都走这里
|
||||
*/
|
||||
function goProfile() {
|
||||
router.push({ name: 'Profile' });
|
||||
}
|
||||
|
||||
/**
|
||||
* 当天日期文案(中文长格式)
|
||||
*/
|
||||
function getTodayDate() {
|
||||
const today = new Date();
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
};
|
||||
return today.toLocaleDateString('zh-CN', options);
|
||||
}
|
||||
const todayDate = getTodayDate();
|
||||
|
||||
onMounted(fetchUnreadCount);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="ws-header">
|
||||
<!-- 装饰光斑:纯视觉元素,随主题色变化 -->
|
||||
<div aria-hidden="true" class="ws-header__orb ws-header__orb--1"></div>
|
||||
<div aria-hidden="true" class="ws-header__orb ws-header__orb--2"></div>
|
||||
|
||||
<div
|
||||
class="ws-header__left ws-header__left--clickable"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
title="进入个人中心"
|
||||
@click="goProfile"
|
||||
@keydown.enter="goProfile"
|
||||
>
|
||||
<div class="ws-header__avatar-ring">
|
||||
<Avatar
|
||||
:size="44"
|
||||
:src="userStore.userInfo?.avatar || preferences.app.defaultAvatar"
|
||||
/>
|
||||
</div>
|
||||
<div class="ws-header__text">
|
||||
<div class="ws-header__title">
|
||||
{{ greeting }},{{ userStore.userInfo?.nick_name }}
|
||||
<span class="ws-header__role">
|
||||
{{ userStore.userInfo?.roles?.name }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="ws-header__date">{{ todayDate }} · 开始您一天的工作吧</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ws-header__right">
|
||||
<button
|
||||
aria-label="个人中心"
|
||||
class="ws-header__bell"
|
||||
title="个人中心"
|
||||
type="button"
|
||||
@click="goProfile"
|
||||
>
|
||||
<VbenIcon class="ws-header__bell-icon" icon="lucide:user" />
|
||||
</button>
|
||||
<Badge :count="unreadCount" :offset="[-2, 2]" :overflow-count="99">
|
||||
<button
|
||||
aria-label="消息中心"
|
||||
class="ws-header__bell"
|
||||
type="button"
|
||||
@click="goNoticeCenter"
|
||||
>
|
||||
<VbenIcon class="ws-header__bell-icon" icon="lucide:bell" />
|
||||
</button>
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ws-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px;
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(
|
||||
120deg,
|
||||
hsl(var(--primary) / 0.1),
|
||||
hsl(var(--primary) / 0.03) 45%,
|
||||
transparent 70%
|
||||
),
|
||||
hsl(var(--card));
|
||||
border: 1px solid hsl(var(--border) / 0.7);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 装饰光斑:低透明度 + 大模糊,暗色主题下自然减弱 */
|
||||
.ws-header__orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(64px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ws-header__orb--1 {
|
||||
top: -60px;
|
||||
right: 10%;
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: hsl(var(--primary) / 0.18);
|
||||
}
|
||||
|
||||
.ws-header__orb--2 {
|
||||
right: -40px;
|
||||
bottom: -80px;
|
||||
width: 160px;
|
||||
height: 160px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
.ws-header__left {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 头像/姓名整块可点击进入个人中心:hover 给出轻微反馈但不破坏问候头质感 */
|
||||
.ws-header__left--clickable {
|
||||
padding: 4px 10px 4px 4px;
|
||||
margin: -4px -10px -4px -4px;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.ws-header__left--clickable:hover {
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.ws-header__left--clickable:hover .ws-header__title {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* 头像光环:primary 低透明双环 */
|
||||
.ws-header__avatar-ring {
|
||||
display: flex;
|
||||
padding: 3px;
|
||||
background: hsl(var(--card));
|
||||
border-radius: 50%;
|
||||
box-shadow:
|
||||
0 0 0 2px hsl(var(--primary) / 0.25),
|
||||
0 4px 12px hsl(var(--primary) / 0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ws-header__text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ws-header__title {
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
color: hsl(var(--foreground));
|
||||
letter-spacing: -0.01em;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ws-header__role {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 1px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--primary));
|
||||
vertical-align: 2px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border: 1px solid hsl(var(--primary) / 0.2);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.ws-header__date {
|
||||
margin-top: 3px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.ws-header__right {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* 铃铛按钮:圆形玻璃感容器 */
|
||||
.ws-header__bell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
cursor: pointer;
|
||||
background: hsl(var(--muted) / 0.4);
|
||||
border: 1px solid hsl(var(--border) / 0.7);
|
||||
border-radius: 50%;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.ws-header__bell:hover {
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.ws-header__bell-icon {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
color: hsl(var(--foreground) / 0.75);
|
||||
}
|
||||
|
||||
.ws-header__bell:hover .ws-header__bell-icon {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ws-header__bell {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.ws-header__bell:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import WidgetClinicToday from '../components/WidgetClinicToday.vue';
|
||||
import WidgetDoctorToday from '../components/WidgetDoctorToday.vue';
|
||||
import WidgetNotice from '../components/WidgetNotice.vue';
|
||||
import WidgetOrderPending from '../components/WidgetOrderPending.vue';
|
||||
import WidgetPharmacistPending from '../components/WidgetPharmacistPending.vue';
|
||||
import WidgetPlatformOverview from '../components/WidgetPlatformOverview.vue';
|
||||
import WidgetQuickEntry from '../components/WidgetQuickEntry.vue';
|
||||
import WidgetSalespersonSummary from '../components/WidgetSalespersonSummary.vue';
|
||||
import WidgetTodoCenter from '../components/WidgetTodoCenter.vue';
|
||||
|
||||
/**
|
||||
* widget_code → 组件映射(与后端 WorkbenchWidgetEnum 对齐)
|
||||
* 后端 layout 下发未知 code 时前端直接跳过,保证两端可独立灰度上线
|
||||
*/
|
||||
export const WIDGET_COMPONENT_MAP: Record<string, Component> = {
|
||||
quick_nav: WidgetQuickEntry,
|
||||
notice: WidgetNotice,
|
||||
doctor_today: WidgetDoctorToday,
|
||||
pharmacist_pending: WidgetPharmacistPending,
|
||||
salesperson_summary: WidgetSalespersonSummary,
|
||||
clinic_today: WidgetClinicToday,
|
||||
order_pending: WidgetOrderPending,
|
||||
platform_overview: WidgetPlatformOverview,
|
||||
todo_center: WidgetTodoCenter,
|
||||
};
|
||||
|
||||
/** KPI 类模块(渲染在中部 KPI 区,其余按固定位置渲染) */
|
||||
export const KPI_WIDGET_CODES = new Set([
|
||||
'doctor_today',
|
||||
'pharmacist_pending',
|
||||
'salesperson_summary',
|
||||
'clinic_today',
|
||||
'order_pending',
|
||||
'platform_overview',
|
||||
'todo_center',
|
||||
]);
|
||||
@@ -1,185 +1,207 @@
|
||||
<script lang="ts" setup>
|
||||
import type {
|
||||
WorkbenchProjectItem,
|
||||
WorkbenchQuickNavItem,
|
||||
WorkbenchTodoItem,
|
||||
WorkbenchTrendItem,
|
||||
} from '@vben/common-ui';
|
||||
/**
|
||||
* 工作台首页:按角色 layout 动态装载模块
|
||||
* 超管/系统管理员(布局含 platform_overview / todo_center):
|
||||
* 问候头 → 顶部双列(左:总览+待办合并卡;右:公告)→ 快捷入口 → 其余 KPI
|
||||
* 其他角色:问候头 → 快捷入口 → 角色 KPI 摘要 → 公告(维持原布局)
|
||||
* 布局由 workbench/layout 下发(xk_role_workbench_widget 按角色配置),未配置的模块不渲染
|
||||
*/
|
||||
import type { WorkbenchLayoutItem } from './api';
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
|
||||
import {
|
||||
AnalysisChartCard,
|
||||
WorkbenchHeader,
|
||||
WorkbenchProject,
|
||||
WorkbenchQuickNav,
|
||||
WorkbenchTodo,
|
||||
WorkbenchTrends,
|
||||
} from '@vben/common-ui';
|
||||
import { LogoSvgICON } from '@vben/icons';
|
||||
import { preferences } from '@vben/preferences';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
import { openWindow } from '@vben/utils';
|
||||
import { getWorkbenchLayoutApi } from './api';
|
||||
import WidgetAdminHub from './components/WidgetAdminHub.vue';
|
||||
import WorkspaceHeader from './components/WorkspaceHeader.vue';
|
||||
import { KPI_WIDGET_CODES, WIDGET_COMPONENT_MAP } from './config/widgets';
|
||||
|
||||
import AnalyticsVisitsSource from '../analytics/analytics-visits-source.vue';
|
||||
import {getQuickMenuApi} from "#/views/system/admin/api";
|
||||
import {getLatestNotice} from "#/views/system/notice/api";
|
||||
/** 角色布局(后端已按 sort 排序并过滤非法 code) */
|
||||
const layout = ref<WorkbenchLayoutItem[]>([]);
|
||||
const loaded = ref(false);
|
||||
|
||||
const userStore = useUserStore();
|
||||
/** 有效模块:仅保留前端已注册的组件 */
|
||||
const validItems = computed(() =>
|
||||
layout.value.filter((item) => WIDGET_COMPONENT_MAP[item.widget_code]),
|
||||
);
|
||||
|
||||
// 这是一个示例数据,实际项目中需要根据实际情况进行调整
|
||||
// url 也可以是内部路由,在 navTo 方法中识别处理,进行内部跳转
|
||||
// 例如:url: /dashboard/workspace
|
||||
const projectItems: WorkbenchProjectItem[] = [
|
||||
{
|
||||
color: 'blue',
|
||||
content: '不要等待机会,而要创造机会。',
|
||||
date: '2021-04-01',
|
||||
group: '萧康云医科技',
|
||||
icon: LogoSvgICON,
|
||||
title: '萧康后台管理系统-旧版本',
|
||||
url: 'https://admin.xiaokang88.com',
|
||||
},
|
||||
{
|
||||
color: 'blue',
|
||||
content: '数据可视化平台。',
|
||||
date: '2021-04-01',
|
||||
group: '萧康云医科技',
|
||||
icon: 'fluent-color:data-bar-vertical-ascending-20',
|
||||
title: '数据大屏',
|
||||
url: 'http://dls.xiaokang88.com',
|
||||
},
|
||||
];
|
||||
/** 平台总览项(超管/系统管理员) */
|
||||
const overviewItem = computed(() =>
|
||||
validItems.value.find((item) => item.widget_code === 'platform_overview'),
|
||||
);
|
||||
|
||||
// 同样,这里的 url 也可以使用以 http 开头的外部链接
|
||||
const quickNavItems: WorkbenchQuickNavItem[] = ref([]);
|
||||
/** 待办中心项(超管/系统管理员) */
|
||||
const todoItem = computed(() =>
|
||||
validItems.value.find((item) => item.widget_code === 'todo_center'),
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取快捷菜单
|
||||
* 是否渲染顶部管理驾驶舱行:布局含总览或待办任一即启用
|
||||
* 为什么合并渲染:总览+待办同属「今日经营快照」,合并成一张卡放左列信息密度更高
|
||||
*/
|
||||
function getQuickMenu() {
|
||||
getQuickMenuApi().then((res) => {
|
||||
quickNavItems.value = res;
|
||||
});
|
||||
}
|
||||
getQuickMenu();
|
||||
const todoItems = ref<WorkbenchTodoItem[]>([
|
||||
{
|
||||
completed: false,
|
||||
content: `审查最近提交到Git仓库的前端代码,确保代码质量和规范。`,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '审查前端代码提交',
|
||||
},
|
||||
{
|
||||
completed: true,
|
||||
content: `检查并优化系统性能,降低CPU使用率。`,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '系统性能优化',
|
||||
},
|
||||
{
|
||||
completed: false,
|
||||
content: `进行系统安全检查,确保没有安全漏洞或未授权的访问。 `,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '安全检查',
|
||||
},
|
||||
{
|
||||
completed: false,
|
||||
content: `更新项目中的所有npm依赖包,确保使用最新版本。`,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '更新项目依赖',
|
||||
},
|
||||
{
|
||||
completed: false,
|
||||
content: `修复用户报告的页面UI显示问题,确保在不同浏览器中显示一致。 `,
|
||||
date: '2024-07-30 11:00:00',
|
||||
title: '修复UI显示问题',
|
||||
},
|
||||
]);
|
||||
const trendItems = ref<WorkbenchTrendItem[]>([]);
|
||||
const hasAdminHub = computed(() => !!overviewItem.value || !!todoItem.value);
|
||||
|
||||
/** 快捷入口区(全宽) */
|
||||
const quickNavItem = computed(() =>
|
||||
validItems.value.find((item) => item.widget_code === 'quick_nav'),
|
||||
);
|
||||
|
||||
/** KPI 摘要区(中部,按 sort 依次渲染;总览/待办已合并进顶部驾驶舱,需排除) */
|
||||
const kpiItems = computed(() =>
|
||||
validItems.value.filter(
|
||||
(item) =>
|
||||
KPI_WIDGET_CODES.has(item.widget_code) &&
|
||||
item.widget_code !== 'platform_overview' &&
|
||||
item.widget_code !== 'todo_center',
|
||||
),
|
||||
);
|
||||
|
||||
/** 公告区:有驾驶舱时放顶部右列,否则维持底部 */
|
||||
const noticeItem = computed(() =>
|
||||
validItems.value.find((item) => item.widget_code === 'notice'),
|
||||
);
|
||||
|
||||
/**
|
||||
* 获取最新公告
|
||||
* 拉取角色布局;失败时兜底展示快捷入口 + 公告(保证工作台不白屏)
|
||||
*/
|
||||
function getLatestNoticeList() {
|
||||
getLatestNotice(10).then((res) => {
|
||||
trendItems.value = res;
|
||||
});
|
||||
}
|
||||
getLatestNoticeList();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
// 这是一个示例方法,实际项目中需要根据实际情况进行调整
|
||||
// This is a sample method, adjust according to the actual project requirements
|
||||
function navTo(nav: WorkbenchProjectItem | WorkbenchQuickNavItem) {
|
||||
if (nav.url?.startsWith('http')) {
|
||||
openWindow(nav.url);
|
||||
return;
|
||||
}
|
||||
if (nav.url?.startsWith('/')) {
|
||||
router.push(nav.url).catch((error) => {
|
||||
console.error('Navigation failed:', error);
|
||||
});
|
||||
} else {
|
||||
console.warn(`Unknown URL for navigation item: ${nav.title} -> ${nav.url}`);
|
||||
async function fetchLayout() {
|
||||
try {
|
||||
const res = await getWorkbenchLayoutApi();
|
||||
layout.value = Array.isArray(res) ? res : [];
|
||||
} catch {
|
||||
layout.value = [
|
||||
{ id: 0, widget_code: 'quick_nav', title: '快捷入口', sort: 10, config: null },
|
||||
{ id: 0, widget_code: 'notice', title: '最新动态', sort: 90, config: null },
|
||||
];
|
||||
} finally {
|
||||
loaded.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当天的日期和天气
|
||||
function getTodayDate() {
|
||||
const today = new Date();
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
weekday: 'long',
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
};
|
||||
return today.toLocaleDateString('zh-CN', options);
|
||||
}
|
||||
const todayDate = getTodayDate();
|
||||
onMounted(fetchLayout);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-5">
|
||||
<WorkbenchHeader
|
||||
:avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar"
|
||||
>
|
||||
<template #title>
|
||||
哈喽, {{ userStore.userInfo?.nick_name }} -
|
||||
{{ userStore.userInfo?.roles?.name }}, 开始您一天的工作吧!
|
||||
<!-- <Button type="primary" v-access:code="['Super Admin']">测我是超级管理员</Button>-->
|
||||
<!-- <Button type="primary" v-access:code="['Admin']">测我是管理员</Button>-->
|
||||
</template>
|
||||
<template #description>
|
||||
{{ todayDate }}
|
||||
</template>
|
||||
</WorkbenchHeader>
|
||||
<div class="workspace-page">
|
||||
<div class="workspace-page__inner">
|
||||
<div class="workspace-page__section" style="--delay: 0">
|
||||
<WorkspaceHeader />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row">
|
||||
<div class="mr-4 w-full lg:w-3/5">
|
||||
<!-- <WorkbenchProject :items="projectItems" title="项目" @click="navTo" />-->
|
||||
<!-- <WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" />-->
|
||||
<WorkbenchQuickNav
|
||||
:items="quickNavItems"
|
||||
class="lg:mt-0"
|
||||
title="快捷导航"
|
||||
@click="navTo"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-full lg:w-2/5">
|
||||
<WorkbenchTrends :items="trendItems" class="mt-5" title="最新动态" />
|
||||
<!-- <WorkbenchQuickNav-->
|
||||
<!-- :items="quickNavItems"-->
|
||||
<!-- class="mt-5 lg:mt-0"-->
|
||||
<!-- title="快捷导航"-->
|
||||
<!-- @click="navTo"-->
|
||||
<!-- />-->
|
||||
<!-- <WorkbenchTodo :items="todoItems" class="mt-5" title="待办事项" />-->
|
||||
<!-- <AnalysisChartCard class="mt-5" title="访问来源">-->
|
||||
<!-- <AnalyticsVisitsSource />-->
|
||||
<!-- </AnalysisChartCard>-->
|
||||
</div>
|
||||
<template v-if="loaded">
|
||||
<!-- 管理驾驶舱行(超管/系统管理员):左合并卡 + 右公告 -->
|
||||
<div v-if="hasAdminHub" class="workspace-page__section" style="--delay: 1">
|
||||
<div class="workspace-hero">
|
||||
<div class="workspace-hero__main">
|
||||
<WidgetAdminHub
|
||||
:overview-title="overviewItem?.title || '平台总览'"
|
||||
:todo-title="todoItem?.title || '待办中心'"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="noticeItem" class="workspace-hero__side">
|
||||
<component
|
||||
:is="WIDGET_COMPONENT_MAP[noticeItem.widget_code]"
|
||||
:title="noticeItem.title"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快捷入口:全宽 -->
|
||||
<div v-if="quickNavItem" class="workspace-page__section" style="--delay: 2">
|
||||
<component
|
||||
:is="WIDGET_COMPONENT_MAP[quickNavItem.widget_code]"
|
||||
:title="quickNavItem.title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 角色 KPI 摘要 -->
|
||||
<div
|
||||
v-for="(item, index) in kpiItems"
|
||||
:key="item.widget_code"
|
||||
:style="{ '--delay': index + 3 }"
|
||||
class="workspace-page__section"
|
||||
>
|
||||
<component
|
||||
:is="WIDGET_COMPONENT_MAP[item.widget_code]"
|
||||
:title="item.title"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 最新动态:无驾驶舱行时维持原底部位置 -->
|
||||
<div
|
||||
v-if="noticeItem && !hasAdminHub"
|
||||
:style="{ '--delay': kpiItems.length + 3 }"
|
||||
class="workspace-page__section"
|
||||
>
|
||||
<component
|
||||
:is="WIDGET_COMPONENT_MAP[noticeItem.widget_code]"
|
||||
:title="noticeItem.title"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-page {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 居中限宽:大屏下不至于被拉太宽,更显精致 */
|
||||
.workspace-page__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* 顶部驾驶舱行:左合并卡(58%)+ 右公告(42%),窄屏退化为上下堆叠 */
|
||||
.workspace-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 58fr) minmax(0, 42fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.workspace-hero__main,
|
||||
.workspace-hero__side {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 公告卡撑满行高,与左侧合并卡底边对齐 */
|
||||
.workspace-hero__side :deep(.notice-widget) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.workspace-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* 区块交错入场:轻微上移淡入,节奏 60ms/块 */
|
||||
.workspace-page__section {
|
||||
animation: ws-fade-up 0.4s cubic-bezier(0.4, 0, 0.2, 1) both;
|
||||
animation-delay: calc(var(--delay, 0) * 60ms);
|
||||
}
|
||||
|
||||
@keyframes ws-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.workspace-page__section {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { MenuRecordRaw } from '@vben/types';
|
||||
|
||||
/** 可搜索的菜单项(拍平后的叶子菜单) */
|
||||
export type SearchableMenuItem = {
|
||||
title: string;
|
||||
path: string;
|
||||
icon: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 将 accessMenus 递归拍平为叶子菜单列表
|
||||
* 为什么:工作台本地搜索需要覆盖当前账号全部授权页面,叶子节点才是可跳转页
|
||||
*/
|
||||
export function flattenMenus(menus: MenuRecordRaw[]): SearchableMenuItem[] {
|
||||
const result: SearchableMenuItem[] = [];
|
||||
const walk = (list: MenuRecordRaw[]) => {
|
||||
for (const menu of list) {
|
||||
if (menu.children && menu.children.length > 0) {
|
||||
walk(menu.children);
|
||||
} else if (menu.path) {
|
||||
result.push({
|
||||
title: String(menu.name || ''),
|
||||
path: menu.path,
|
||||
icon: typeof menu.icon === 'string' ? menu.icon : '',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(menus);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地关键字过滤(忽略大小写的标题包含匹配)
|
||||
*/
|
||||
export function filterMenusByKeyword(
|
||||
items: SearchableMenuItem[],
|
||||
keyword: string,
|
||||
): SearchableMenuItem[] {
|
||||
const kw = keyword.trim().toLowerCase();
|
||||
if (!kw) return [];
|
||||
return items.filter((item) => item.title.toLowerCase().includes(kw));
|
||||
}
|
||||
@@ -8,3 +8,11 @@ const prefix = 'charge-cash-pay-record/';
|
||||
export async function getChargeCashPayRecordListApi(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
/**
|
||||
* 代支付手续费详情(含关联提现申请,供互通抽屉)
|
||||
* @param id
|
||||
*/
|
||||
export async function getChargeCashPayRecordDetailApi(id: number) {
|
||||
return requestClient.get<any>(`${prefix}detail`, { params: { id } });
|
||||
}
|
||||
|
||||
@@ -16,20 +16,20 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
labelField: '',
|
||||
},
|
||||
columns: [
|
||||
// { type: 'checkbox', width: 60 },
|
||||
{ field: 'id', align: 'left', title: 'ID' },
|
||||
{ field: 'order_no', title: '挂号订单号' },
|
||||
{ field: 'store.name', title: '诊所' },
|
||||
{ field: 'id', align: 'left', title: 'ID', width: 70 },
|
||||
{ field: 'order_no', title: '提现订单号' },
|
||||
{ field: 'subject_name', title: '申请主体', slots: { default: 'subject' } },
|
||||
{ field: 'apply_cash', title: '关联提现', slots: { default: 'apply-info' }, minWidth: 140 },
|
||||
{ field: 'charge_cash', title: '手续费' },
|
||||
{ field: 'status', title: '状态', slots: { default: 'status' } },
|
||||
{ field: 'status', title: '代付状态', slots: { default: 'status' } },
|
||||
{ field: 'created_at', title: '记录时间' },
|
||||
// {
|
||||
// type: 'html',
|
||||
// title: '操作',
|
||||
// align: 'right',
|
||||
// slots: { default: 'action' },
|
||||
// width: 200,
|
||||
// },
|
||||
{
|
||||
type: 'html',
|
||||
title: '操作',
|
||||
align: 'right',
|
||||
slots: { default: 'action' },
|
||||
width: 100,
|
||||
},
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
@@ -39,13 +39,8 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
rowConfig: {
|
||||
useKey: true,
|
||||
},
|
||||
// scrollY: {
|
||||
// enabled: true,
|
||||
// gt: 0,
|
||||
// },
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
// 请求后端接口方法
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getChargeCashPayRecordListApi({
|
||||
page: page.currentPage,
|
||||
@@ -58,24 +53,19 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
height: 'auto',
|
||||
border: false,
|
||||
toolbarConfig: {
|
||||
// 是否显示搜索表单控制按钮
|
||||
// @ts-ignore 正式环境时有完整的类型声明
|
||||
search: true,
|
||||
refresh: true, // 刷新
|
||||
print: false, // 打印
|
||||
export: false, // 导出
|
||||
// custom: true, // 自定义列
|
||||
zoom: true, // 最大化最小化
|
||||
refresh: true,
|
||||
print: false,
|
||||
export: false,
|
||||
zoom: true,
|
||||
slots: {
|
||||
buttons: 'toolbar-actions',
|
||||
},
|
||||
custom: {
|
||||
// 自定义列-图标
|
||||
icon: 'vxe-icon-menu',
|
||||
},
|
||||
},
|
||||
expandConfig: {
|
||||
// expandAll: true,
|
||||
},
|
||||
expandConfig: {},
|
||||
showOverflow: false,
|
||||
};
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
<script lang="ts" setup>
|
||||
import type { VxeGridListeners } from '#/adapter/vxe-table';
|
||||
|
||||
/**
|
||||
* 代支付手续费记录:展示关联提现申请,详情与审核页互通
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Button, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import { getChargeCashPayRecordListApi } from '#/views/finance/charge-cash-pay-record/api';
|
||||
import StatisticsReconciliation from '#/views/finance/reconciliation/components/statistics.vue';
|
||||
import WithdrawalRelationDetail from '#/views/finance/withdrawal/components/withdrawal-relation-detail.vue';
|
||||
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
import DetailModal from '#/views/business/order/product-order/components/detail.vue';
|
||||
import { getChargeCashPayRecordListApi } from '#/views/finance/charge-cash-pay-record/api';
|
||||
import StatisticsReconciliation from "#/views/finance/reconciliation/components/statistics.vue";
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
formOptions,
|
||||
@@ -22,18 +23,21 @@ const [Grid, gridApi] = useVbenVxeGrid({
|
||||
});
|
||||
|
||||
const statistics = ref();
|
||||
|
||||
const [RelationDetailModal, relationDetailApi] = useVbenModal({
|
||||
connectedComponent: WithdrawalRelationDetail,
|
||||
});
|
||||
|
||||
const initTableAjax = () => {
|
||||
gridApi.setGridOptions({
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
// 请求后端接口方法
|
||||
query: async ({ page }, formValues) => {
|
||||
return await getChargeCashPayRecordListApi({
|
||||
page: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...formValues,
|
||||
}).then((res) => {
|
||||
statistics.value = null;
|
||||
statistics.value = [
|
||||
{
|
||||
title: '总销代付额',
|
||||
@@ -52,20 +56,24 @@ const initTableAjax = () => {
|
||||
};
|
||||
|
||||
initTableAjax();
|
||||
|
||||
/** 打开提现+代付互通详情 */
|
||||
const openDetail = (id: number) => {
|
||||
relationDetailApi.setData({ id, source: 'charge' });
|
||||
relationDetailApi.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="代支付手续费记录">
|
||||
<RelationDetailModal />
|
||||
<StatisticsReconciliation
|
||||
v-if="statistics"
|
||||
:statistics="statistics"
|
||||
/>
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
:actions="[]"
|
||||
:drop-down-actions="[]"
|
||||
>
|
||||
<TableAction :actions="[]" :drop-down-actions="[]">
|
||||
<template #more>
|
||||
<Button style="margin-left: 16px">
|
||||
批量操作
|
||||
@@ -74,8 +82,25 @@ initTableAjax();
|
||||
</template>
|
||||
</TableAction>
|
||||
</template>
|
||||
<template #subject="{ row }">
|
||||
<div>
|
||||
<Tag color="green">{{ row.type_txt || '-' }}</Tag>
|
||||
<div class="mt-2">{{ row.subject_name || row.store?.name || '-' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #apply-info="{ row }">
|
||||
<div>申请:¥{{ row.apply_cash ?? '-' }}</div>
|
||||
<div class="mt-1">打款:¥{{ row.true_cash ?? '-' }}</div>
|
||||
<div class="mt-1">
|
||||
<Tag v-if="row.apply_check_status === 1" color="purple">待审核</Tag>
|
||||
<Tag v-else-if="row.apply_check_status === 2" color="green">审核成功</Tag>
|
||||
<Tag v-else-if="row.apply_check_status === 3" color="error">拒绝</Tag>
|
||||
<Tag v-else-if="row.apply_check_status === 4" color="error">提现失败</Tag>
|
||||
<Tag v-else color="default">无申请</Tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #status="{ row }">
|
||||
<div class="mt-3">
|
||||
<div>
|
||||
<Tag v-if="row.status === 1" color="purple">已记录</Tag>
|
||||
<Tag v-else-if="row.status === 2" color="green">已支付</Tag>
|
||||
<Tag v-else-if="row.status === 3" color="orange">已取消</Tag>
|
||||
@@ -84,39 +109,25 @@ initTableAjax();
|
||||
<template #toolbar-tools></template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[]"
|
||||
:actions="[
|
||||
{
|
||||
label: '详情',
|
||||
type: 'link',
|
||||
icon: 'mdi:file-document-outline',
|
||||
size: 'small',
|
||||
onClick: openDetail.bind(null, row.id),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.custom-list {
|
||||
list-style-type: none;
|
||||
padding-left: 0;
|
||||
.mt-1 {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.custom-list-item {
|
||||
background-color: rgba(64, 158, 255, 0.04);
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(64, 158, 255, 0.1);
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: #409eff;
|
||||
border-radius: 50%;
|
||||
margin-right: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 提现审核通过/拒绝弹窗
|
||||
* 打开时拉取申请详情预览金额与手续费,再填备注提交
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
import { message, Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
import {
|
||||
passApplicationApi,
|
||||
refuseApplicationApi,
|
||||
} from '#/views/finance/withdrawal-audit/api';
|
||||
import { getWithdrawalApplicationInfo } from '#/views/finance/withdrawal/api';
|
||||
|
||||
import { withdrawalAudit } from '../config/form';
|
||||
|
||||
const gridApi = ref();
|
||||
const type = ref(1);
|
||||
/** 0=通过 1=拒绝(与页面传入 modalType 一致) */
|
||||
const type = ref(0);
|
||||
const loading = ref(false);
|
||||
const preview = ref<Record<string, any> | null>(null);
|
||||
|
||||
const [WithdrawalAuditForm, WithdrawalAuditFormApi] =
|
||||
useVbenForm(withdrawalAudit);
|
||||
@@ -27,40 +35,135 @@ const [Modal, modalApi] = useVbenModal({
|
||||
},
|
||||
onConfirm: async () => {
|
||||
const formApi = WithdrawalAuditFormApi;
|
||||
formApi.validate().then(async (e: any) => {
|
||||
if (e.valid) {
|
||||
const values = await formApi.getValues();
|
||||
modalApi.setState({ loading: true, confirmLoading: true });
|
||||
const submitApi =
|
||||
type.value === 1 ? refuseApplicationApi : passApplicationApi;
|
||||
submitApi(values)
|
||||
.then(() => {
|
||||
message.success('保存成功');
|
||||
gridApi.value?.reload();
|
||||
modalApi.close();
|
||||
})
|
||||
.finally(() => {
|
||||
modalApi.setState({ loading: false, confirmLoading: false });
|
||||
});
|
||||
}
|
||||
});
|
||||
await formApi.validateAndSubmitForm();
|
||||
const e = await formApi.validate();
|
||||
if (!e.valid) return;
|
||||
const values = await formApi.getValues();
|
||||
modalApi.lock();
|
||||
const submitApi =
|
||||
type.value === 1 ? refuseApplicationApi : passApplicationApi;
|
||||
try {
|
||||
await submitApi(values);
|
||||
message.success('保存成功');
|
||||
gridApi.value?.reload();
|
||||
modalApi.close();
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
onOpenChange(isOpen: boolean) {
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
gridApi.value = isOpen ? modalApi.getData()?.gridApi : null;
|
||||
if (isOpen) {
|
||||
const { id, modalType } = modalApi.getData<Record<string, any>>();
|
||||
type.value = modalType;
|
||||
WithdrawalAuditFormApi.setValues({
|
||||
id,
|
||||
});
|
||||
if (!isOpen) {
|
||||
preview.value = null;
|
||||
return;
|
||||
}
|
||||
const { id, modalType } = modalApi.getData<Record<string, any>>();
|
||||
type.value = modalType;
|
||||
WithdrawalAuditFormApi.setValues({ id, reason: '' });
|
||||
loading.value = true;
|
||||
try {
|
||||
// 审核前预览真实手续费(来自代付关联 display_charge_cash)
|
||||
preview.value = await getWithdrawalApplicationInfo(id);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function formatMoney(val: any) {
|
||||
const n = Number(val || 0);
|
||||
return Number.isFinite(n) ? n.toFixed(2) : '0.00';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="`提现审核【${type === 1 ? '拒绝' : '通过'}】`" class="w-[80%] md:w-[50%] lg:w-[30%] h-[60%]">
|
||||
<WithdrawalAuditForm />
|
||||
<Modal
|
||||
:title="`提现审核【${type === 1 ? '拒绝' : '通过'}】`"
|
||||
class="w-[80%] md:w-[50%] lg:w-[36%]"
|
||||
>
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="preview" class="audit-preview">
|
||||
<div class="preview-row">
|
||||
<span class="label">申请主体</span>
|
||||
<span class="value">
|
||||
<Tag color="green">{{ preview.user_type_txt || '-' }}</Tag>
|
||||
{{
|
||||
preview.store?.name ||
|
||||
preview.supplier?.name ||
|
||||
preview.delivery_warehouse?.name ||
|
||||
preview.deliveryWarehouse?.name ||
|
||||
(Number(preview.user_type) === 2 ? '萧康云医' : '-')
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<span class="label">订单号</span>
|
||||
<span class="value mono">{{ preview.order_no || '-' }}</span>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<span class="label">申请金额</span>
|
||||
<span class="value">¥{{ formatMoney(preview.apply_cash) }}</span>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<span class="label">打款金额</span>
|
||||
<span class="value">¥{{ formatMoney(preview.true_cash) }}</span>
|
||||
</div>
|
||||
<div class="preview-row">
|
||||
<span class="label">代付手续费</span>
|
||||
<span class="value fee">
|
||||
¥{{ formatMoney(preview.display_charge_cash) }}
|
||||
<Tag v-if="preview.charge_status_txt" class="ml-2" color="processing">
|
||||
{{ preview.charge_status_txt }}
|
||||
</Tag>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<WithdrawalAuditForm />
|
||||
</Spin>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.audit-preview {
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 10px;
|
||||
background: hsl(var(--muted) / 0.25);
|
||||
}
|
||||
|
||||
.preview-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.value {
|
||||
text-align: right;
|
||||
color: hsl(var(--foreground));
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.value.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.value.fee {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,7 +21,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
{ field: 'order_no', title: '订单号' },
|
||||
{ field: 'apply_cash', title: '申请金额' },
|
||||
{ field: 'true_cash', title: '打款金额' },
|
||||
{ field: 'charge_cash', title: '手续费' },
|
||||
{ field: 'display_charge_cash', title: '手续费', slots: { default: 'charge-cash' } },
|
||||
{ field: 'check_id', title: '审核信息', slots: { default: 'check-id' } },
|
||||
{ field: 'dakuan_status', title: '打款信息', slots: { default: 'dakuan-status'} },
|
||||
{ field: 'apply_time', title: '申请时间' },
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 提现审核列表:通过/拒绝 + 详情(与代付手续费互通)
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import {Button, message, Tag} from 'ant-design-vue';
|
||||
import { Button, Tag } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
import WithdrawalAudit from './components/WithdrawalAudit.vue';
|
||||
import StatisticsReconciliation from '#/views/finance/reconciliation/components/statistics.vue';
|
||||
import WithdrawalRelationDetail from '#/views/finance/withdrawal/components/withdrawal-relation-detail.vue';
|
||||
|
||||
import WithdrawalAudit from './components/WithdrawalAudit.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
|
||||
@@ -24,24 +28,35 @@ const [WithdrawalAuditModal, WithdrawalAuditModalApi] = useVbenModal({
|
||||
connectedComponent: WithdrawalAudit,
|
||||
});
|
||||
|
||||
const infoModal = (id, modalType = 0) => {
|
||||
if (modalType === 0) {
|
||||
message.info('正在开发');
|
||||
return;
|
||||
}
|
||||
const [RelationDetailModal, relationDetailApi] = useVbenModal({
|
||||
connectedComponent: WithdrawalRelationDetail,
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开审核弹窗
|
||||
* @param id 申请 id
|
||||
* @param modalType 0=通过 1=拒绝
|
||||
*/
|
||||
const infoModal = (id: number, modalType = 0) => {
|
||||
WithdrawalAuditModalApi.setData({
|
||||
// 表单值
|
||||
id,
|
||||
modalType,
|
||||
gridApi,
|
||||
});
|
||||
WithdrawalAuditModalApi.open();
|
||||
};
|
||||
|
||||
/** 打开提现+代付互通详情 */
|
||||
const openDetail = (id: number) => {
|
||||
relationDetailApi.setData({ id, source: 'apply' });
|
||||
relationDetailApi.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height title="提现审核">
|
||||
<WithdrawalAuditModal />
|
||||
<RelationDetailModal />
|
||||
<StatisticsReconciliation v-if="statistics" :statistics="statistics" />
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
@@ -66,21 +81,27 @@ const infoModal = (id, modalType = 0) => {
|
||||
}}</Tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #charge-cash="{ row }">
|
||||
<span>{{ row.display_charge_cash ?? row.charge_cash_pay_record?.charge_cash ?? 0 }}</span>
|
||||
<div v-if="row.charge_status_txt" class="mt-1">
|
||||
<Tag color="processing">{{ row.charge_status_txt }}</Tag>
|
||||
</div>
|
||||
</template>
|
||||
<template #check-id="{ row }">
|
||||
<div v-if="row.check_id !== 0">
|
||||
<span v-if="row.is_new === 0">{{ row.check_admin?.nickname || '旧后台' }}</span>
|
||||
<span v-else-if="row.is_new === 1">{{ row.new_check_admin?.nick_name || '新后台' }}</span>
|
||||
</div>
|
||||
<div :class="row.check_id !== 0? 'mt-3': ''">
|
||||
<div :class="row.check_id !== 0 ? 'mt-3' : ''">
|
||||
<Tag v-if="row.check_status === 1" color="purple">待审核</Tag>
|
||||
<Tag v-else-if="row.check_status === 2" color="green">审核通过</Tag>
|
||||
<Tag v-else-if="row.check_status === 3" color="error">审核拒绝</Tag>
|
||||
<Tag v-else-if="row.check_status === 4" color="error">提现失败</Tag>
|
||||
</div>
|
||||
<div v-if="row.check_result" :class="row.check_id !== 0? 'mt-3': ''">
|
||||
<div v-if="row.check_result" :class="row.check_id !== 0 ? 'mt-3' : ''">
|
||||
备注:{{ row.check_result }}
|
||||
</div>
|
||||
<div v-if="row.check_time" :class="row.check_id !== 0? 'mt-3': ''">
|
||||
<div v-if="row.check_time" :class="row.check_id !== 0 ? 'mt-3' : ''">
|
||||
{{ row.check_time }}
|
||||
</div>
|
||||
</template>
|
||||
@@ -98,14 +119,20 @@ const infoModal = (id, modalType = 0) => {
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '详情',
|
||||
type: 'link',
|
||||
icon: 'mdi:file-document-outline',
|
||||
size: 'small',
|
||||
onClick: openDetail.bind(null, row.id),
|
||||
},
|
||||
{
|
||||
label: '审核通过',
|
||||
type: 'link',
|
||||
icon: 'mdi:success-bold',
|
||||
ifShow: row.check_status === 1,
|
||||
size: 'small',
|
||||
// auth: ['order', 'sys:role:detail'],
|
||||
onClick: infoModal.bind(null, row.id),
|
||||
onClick: infoModal.bind(null, row.id, 0),
|
||||
},
|
||||
{
|
||||
label: '拒绝审核',
|
||||
@@ -113,7 +140,6 @@ const infoModal = (id, modalType = 0) => {
|
||||
icon: 'icon-park-solid:error',
|
||||
ifShow: row.check_status === 1,
|
||||
size: 'small',
|
||||
// auth: ['order', 'sys:role:detail'],
|
||||
onClick: infoModal.bind(null, row.id, 1),
|
||||
},
|
||||
]"
|
||||
|
||||
@@ -0,0 +1,712 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 提现管理账户信息卡
|
||||
* 模拟真实银行卡质感(炭灰金质感,非蓝紫)+ 带右上角图标的玻璃质感统计岛
|
||||
* 设计规范:fintech skeuomorphic bank card + glassmorphism stat tiles
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon as VbenIcon } from '@vben/icons';
|
||||
|
||||
import { Button } from 'ant-design-vue';
|
||||
|
||||
import { desensitize } from '#/util/tool';
|
||||
|
||||
defineOptions({
|
||||
name: 'AccountInfoCard',
|
||||
});
|
||||
|
||||
const props = defineProps<{
|
||||
/** 银行卡信息 */
|
||||
myCard: Record<string, any> | null;
|
||||
/** 是否门店账号(隐藏未确认收货冻结) */
|
||||
isStoreUser: boolean;
|
||||
/** 是否平台账号(展示累计代付手续费) */
|
||||
isPlatformUser: boolean;
|
||||
balance: number;
|
||||
pendingEarnings: number;
|
||||
totalEarnings: number;
|
||||
withdrawnAmount: number;
|
||||
withdrawnFrozenAmount: number;
|
||||
frozenAmount: number;
|
||||
/** 累计代付手续费(平台 charge) */
|
||||
fee: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
apply: [];
|
||||
editCard: [];
|
||||
refresh: [];
|
||||
}>();
|
||||
|
||||
/** 鼠标在银行卡上的相对位置(用于光泽跟随),-1 表示未进入 */
|
||||
const glowX = ref(-1);
|
||||
const glowY = ref(-1);
|
||||
|
||||
/** 尊重用户的动效偏好(accessibility: prefers-reduced-motion) */
|
||||
const prefersReduced = ref(false);
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
prefersReduced.value = mq.matches;
|
||||
}
|
||||
|
||||
function onCardMove(e: MouseEvent) {
|
||||
if (prefersReduced.value) return;
|
||||
const el = e.currentTarget as HTMLElement;
|
||||
const rect = el.getBoundingClientRect();
|
||||
glowX.value = e.clientX - rect.left;
|
||||
glowY.value = e.clientY - rect.top;
|
||||
}
|
||||
|
||||
function onCardLeave() {
|
||||
glowX.value = -1;
|
||||
glowY.value = -1;
|
||||
}
|
||||
|
||||
/** 主体展示名:优先卡上用户名,再回落到门店/供应商 */
|
||||
const subjectName = computed(() => {
|
||||
const card = props.myCard;
|
||||
if (!card) return '账户信息';
|
||||
return (
|
||||
card.user_name ||
|
||||
card.store?.name ||
|
||||
card.supplier?.name ||
|
||||
card.platform?.name ||
|
||||
'账户信息'
|
||||
);
|
||||
});
|
||||
|
||||
/** 脱敏卡号,无卡时占位 */
|
||||
const maskedCard = computed(() => {
|
||||
const raw = props.myCard?.bank_card;
|
||||
if (!raw) return '**** **** **** ----';
|
||||
return desensitize(String(raw), 'bankCard') || '**** **** **** ----';
|
||||
});
|
||||
|
||||
/**
|
||||
* 组装统计岛:核心字段优先,平台额外展示累计代付手续费
|
||||
* 每项配一个 SVG iconify 图标(规范:不用 emoji),放在卡片右上角
|
||||
*/
|
||||
const statItems = computed(() => {
|
||||
const items: {
|
||||
key: string;
|
||||
label: string;
|
||||
value: number;
|
||||
icon: string;
|
||||
emphasize?: boolean;
|
||||
}[] = [
|
||||
{
|
||||
key: 'balance',
|
||||
label: '可提现余额',
|
||||
value: Number(props.balance || 0),
|
||||
icon: 'lucide:scale',
|
||||
},
|
||||
{
|
||||
key: 'reviewing',
|
||||
label: '审核中金额',
|
||||
value: Number(props.withdrawnFrozenAmount || 0),
|
||||
icon: 'lucide:clock',
|
||||
},
|
||||
{
|
||||
key: 'pending',
|
||||
label: '待结算收益',
|
||||
value: Number(props.pendingEarnings || 0),
|
||||
icon: 'lucide:hourglass',
|
||||
},
|
||||
{
|
||||
key: 'withdrawn',
|
||||
label: '已提现金额',
|
||||
value: Number(props.withdrawnAmount || 0),
|
||||
icon: 'lucide:check-circle',
|
||||
},
|
||||
{
|
||||
key: 'total',
|
||||
label: '累计收益',
|
||||
value: Number(props.totalEarnings || 0),
|
||||
icon: 'lucide:trending-up',
|
||||
},
|
||||
];
|
||||
if (!props.isStoreUser) {
|
||||
items.splice(3, 0, {
|
||||
key: 'frozen',
|
||||
label: '未确认收货冻结',
|
||||
value: Number(props.frozenAmount || 0),
|
||||
icon: 'lucide:snowflake',
|
||||
});
|
||||
}
|
||||
if (props.isPlatformUser) {
|
||||
items.push({
|
||||
key: 'fee',
|
||||
label: '累计代付手续费',
|
||||
value: Number(props.fee || 0),
|
||||
icon: 'lucide:receipt',
|
||||
emphasize: true,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
/** 金额展示为两位小数 */
|
||||
function formatMoney(val: number) {
|
||||
const n = Number(val || 0);
|
||||
return n.toFixed(2);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="account-info-card">
|
||||
<div class="card-header">
|
||||
<div class="header-title">
|
||||
<span class="title-dot" />
|
||||
{{ subjectName }} · 账户信息
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<Button type="primary" size="small" @click="emit('apply')">申请提现</Button>
|
||||
<Button size="small" @click="emit('editCard')">编辑账户</Button>
|
||||
<Button size="small" type="text" @click="emit('refresh')">
|
||||
<template #icon>
|
||||
<VbenIcon icon="lucide:refresh-cw" />
|
||||
</template>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
左右布局:左侧银行卡纵向撑满,右侧统计岛两列网格(平台 7 个 → 4 行,门店/供应商 5/6 个)
|
||||
响应式:窄屏折回上下,统计岛变两列
|
||||
-->
|
||||
<div class="card-layout">
|
||||
<!--
|
||||
真实银行卡质感:
|
||||
- 深炭灰底(#1a1d29 → #0d0f17),非蓝紫
|
||||
- 金色细节(芯片、品牌点、分隔线),高端卡常用配色
|
||||
- 多层:拉丝纹理 + 装饰几何 + 鼠标光泽 + 扫光
|
||||
-->
|
||||
<section
|
||||
class="bank-card"
|
||||
aria-label="银行卡信息"
|
||||
@mousemove="onCardMove"
|
||||
@mouseleave="onCardLeave"
|
||||
>
|
||||
<div class="bank-bg" />
|
||||
<div class="bank-brush" />
|
||||
<div class="bank-decorator decorator-a" />
|
||||
<div class="bank-decorator decorator-b" />
|
||||
<div
|
||||
v-if="glowX >= 0"
|
||||
class="bank-glow"
|
||||
:style="{ left: `${glowX}px`, top: `${glowY}px` }"
|
||||
/>
|
||||
<div v-if="!prefersReduced" class="bank-sheen" />
|
||||
|
||||
<div class="bank-content">
|
||||
<header class="bank-row bank-row--top">
|
||||
<div class="bank-brand">
|
||||
<span class="bank-chip" />
|
||||
<span class="bank-name">{{ myCard?.bank_name || '未绑定银行卡' }}</span>
|
||||
</div>
|
||||
<div class="bank-logo">
|
||||
<VbenIcon icon="lucide:credit-card" />
|
||||
<span class="bank-tag">{{ myCard?.bank_account_type_txt || '未配置' }}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="bank-number">{{ maskedCard }}</div>
|
||||
|
||||
<div class="bank-row bank-row--bottom">
|
||||
<div class="bank-meta">
|
||||
<VbenIcon class="bank-meta-icon" icon="lucide:user" />
|
||||
<span class="bank-holder">{{ myCard?.bank_user_name || '-' }}</span>
|
||||
</div>
|
||||
<span class="bank-currency">CNY</span>
|
||||
</div>
|
||||
|
||||
<div v-if="myCard?.bank_no" class="bank-row bank-row--extra">
|
||||
<div class="bank-meta">
|
||||
<VbenIcon class="bank-meta-icon" icon="lucide:building-2" />
|
||||
<span class="bank-no">{{ myCard.bank_no }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 右侧统计岛:固定两列网格,平台 7 个 → 4 行 -->
|
||||
<div class="stats-col">
|
||||
<article
|
||||
v-for="item in statItems"
|
||||
:key="item.key"
|
||||
class="stat-island"
|
||||
:class="{ 'stat-island--fee': item.emphasize }"
|
||||
:aria-label="item.label"
|
||||
>
|
||||
<div class="stat-island-glow" />
|
||||
<!-- 右上角图标(规范:SVG 图标,固定尺寸) -->
|
||||
<VbenIcon class="stat-icon" :icon="item.icon" />
|
||||
<div class="stat-label">{{ item.label }}</div>
|
||||
<div class="stat-val">
|
||||
<span class="stat-val-prefix">¥</span>{{ formatMoney(item.value) }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.account-info-card {
|
||||
margin-bottom: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 14px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.title-dot {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
/* 金色,呼应银行卡金质感 */
|
||||
background: linear-gradient(180deg, #d4a73a 0%, #b88a2a 100%);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
/* ===== 左右布局:左侧银行卡收紧,右侧统计岛一行四个 ===== */
|
||||
.card-layout {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 银行卡:左侧适中宽度,纵向撑满 */
|
||||
.bank-card {
|
||||
flex: 0 0 360px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 统计岛列:右侧一行四个,自动折行 */
|
||||
.stats-col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 窄屏:折回上下,银行卡在上 */
|
||||
@media (max-width: 1199px) {
|
||||
.card-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.bank-card {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.stats-col {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.stats-col {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 银行卡:炭灰金质感(模拟真实银行卡) ===== */
|
||||
.bank-card {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
border-radius: 14px;
|
||||
color: #f5f5f7;
|
||||
isolation: isolate;
|
||||
cursor: default;
|
||||
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
||||
box-shadow:
|
||||
0 8px 20px rgb(0 0 0 / 30%),
|
||||
0 2px 6px rgb(0 0 0 / 20%);
|
||||
}
|
||||
|
||||
.bank-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow:
|
||||
0 14px 28px rgb(0 0 0 / 40%),
|
||||
0 4px 10px rgb(0 0 0 / 25%);
|
||||
}
|
||||
|
||||
/* 底层:深炭灰渐变,模拟高端银行卡底色 */
|
||||
.bank-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background: linear-gradient(135deg, #2a2d3a 0%, #1a1d29 45%, #0d0f17 100%);
|
||||
}
|
||||
|
||||
/* 暗色模式:略提亮以保持对比 */
|
||||
.dark .bank-bg {
|
||||
background: linear-gradient(135deg, #353846 0%, #252836 45%, #161821 100%);
|
||||
}
|
||||
|
||||
/* 拉丝纹理(极淡),增强材质感 */
|
||||
.bank-brush {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
background:
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
rgb(255 255 255 / 0.012) 0px,
|
||||
rgb(255 255 255 / 0.012) 1px,
|
||||
transparent 1px,
|
||||
transparent 3px
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 顶部金色高光 + 底部暗影 */
|
||||
.bank-bg::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(120% 80% at 0% 0%, rgb(212 167 58 / 14%), transparent 55%),
|
||||
radial-gradient(100% 80% at 100% 100%, rgb(0 0 0 / 35%), transparent 60%);
|
||||
}
|
||||
|
||||
/* 装饰几何 */
|
||||
.bank-decorator {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.decorator-a {
|
||||
top: -40px;
|
||||
right: -30px;
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
border: 20px solid rgb(212 167 58 / 8%);
|
||||
}
|
||||
|
||||
.decorator-b {
|
||||
bottom: -55px;
|
||||
right: 50px;
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
border: 16px solid rgb(255 255 255 / 5%);
|
||||
}
|
||||
|
||||
/* 鼠标光泽跟随(毛玻璃质感的核心) */
|
||||
.bank-glow {
|
||||
position: absolute;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
transform: translate(-50%, -50%);
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgb(255 255 255 / 18%) 0%,
|
||||
rgb(255 255 255 / 6%) 35%,
|
||||
transparent 70%
|
||||
);
|
||||
mix-blend-mode: screen;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
/* 周期扫光(金色,更克制) */
|
||||
.bank-sheen {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -60%;
|
||||
z-index: 2;
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 0%,
|
||||
rgb(212 167 58 / 14%) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
transform: skewX(-18deg);
|
||||
animation: bank-sheen 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes bank-sheen {
|
||||
0%,
|
||||
60% {
|
||||
left: -60%;
|
||||
}
|
||||
100% {
|
||||
left: 130%;
|
||||
}
|
||||
}
|
||||
|
||||
.bank-content {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.bank-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.bank-brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 金色芯片,模拟实体卡芯片 */
|
||||
.bank-chip {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 18px;
|
||||
border-radius: 3px;
|
||||
background: linear-gradient(135deg, #f0d27a 0%, #c89a35 50%, #a37c28 100%);
|
||||
box-shadow:
|
||||
inset 0 0 0 1px rgb(0 0 0 / 25%),
|
||||
inset 0 -2px 4px rgb(0 0 0 / 20%);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 芯片纹理 */
|
||||
.bank-chip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px 4px;
|
||||
border-left: 1px solid rgb(0 0 0 / 30%);
|
||||
border-right: 1px solid rgb(0 0 0 / 30%);
|
||||
}
|
||||
|
||||
.bank-name {
|
||||
overflow: hidden;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.4px;
|
||||
text-overflow: ellipsis;
|
||||
text-shadow: 0 1px 2px rgb(0 0 0 / 40%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bank-logo {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #d4a73a;
|
||||
}
|
||||
|
||||
.bank-logo :deep(svg) {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.bank-tag {
|
||||
padding: 2px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 82%);
|
||||
background: rgb(255 255 255 / 8%);
|
||||
border: 1px solid rgb(212 167 58 / 30%);
|
||||
border-radius: 999px;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.bank-number {
|
||||
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2.5px;
|
||||
text-shadow: 0 1px 3px rgb(0 0 0 / 50%);
|
||||
}
|
||||
|
||||
.bank-row--bottom,
|
||||
.bank-row--extra {
|
||||
font-size: 12px;
|
||||
color: rgb(255 255 255 / 72%);
|
||||
}
|
||||
|
||||
.bank-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bank-meta-icon {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: #d4a73a;
|
||||
}
|
||||
|
||||
.bank-holder,
|
||||
.bank-no {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.bank-currency {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 6px;
|
||||
font-size: 11px;
|
||||
letter-spacing: 1px;
|
||||
color: #d4a73a;
|
||||
border: 1px solid rgb(212 167 58 / 40%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ===== 统计岛:玻璃质感 + 右上角图标 + 淡荧光 ===== */
|
||||
.stat-island {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 14px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--primary) / 0.04),
|
||||
hsl(var(--card, var(--background)))
|
||||
);
|
||||
backdrop-filter: blur(8px);
|
||||
cursor: default;
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-island:hover {
|
||||
transform: translateY(-3px);
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
box-shadow:
|
||||
0 8px 20px hsl(var(--primary) / 0.14),
|
||||
0 2px 6px rgb(0 0 0 / 6%);
|
||||
}
|
||||
|
||||
/* 角落局部高光,强化荧光感 */
|
||||
.stat-island-glow {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
right: -40px;
|
||||
width: 110px;
|
||||
height: 110px;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
hsl(var(--primary) / 0.18) 0%,
|
||||
transparent 70%
|
||||
);
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-island:hover .stat-island-glow {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* 累计代付手续费:稍强荧光 + primary 色字 */
|
||||
.stat-island--fee {
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
hsl(var(--primary) / 0.1),
|
||||
hsl(var(--card, var(--background)))
|
||||
);
|
||||
}
|
||||
|
||||
.stat-island--fee .stat-island-glow {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* 右上角图标:固定 24x24,主色调 */
|
||||
.stat-icon {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: hsl(var(--primary) / 0.7);
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.stat-island:hover .stat-icon {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.stat-island--fee .stat-icon {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 6px;
|
||||
padding-right: 32px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-val {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-val-prefix {
|
||||
margin-right: 2px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.stat-island--fee .stat-val {
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
</style>
|
||||
@@ -1,17 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
/**
|
||||
* 提现管理顶部账户区:拉取余额/银行卡,渲染紧凑账户卡并挂载申请/绑卡弹窗
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { type AnalysisOverviewItem, useVbenModal } from '@vben/common-ui';
|
||||
import { AnalysisOverview } from '@vben/common-ui';
|
||||
import { SvgCakeIcon } from '@vben/icons';
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
import { useUserStore } from '@vben/stores';
|
||||
|
||||
import { Button, Card, message } from 'ant-design-vue';
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { getAdminAccountBalance, getMyCard } from '#/views/system/admin/api';
|
||||
|
||||
import SaveMyCard from '../components/card-modal.vue';
|
||||
import FormModalDemo from '../components/withdrawal-modal.vue';
|
||||
import AccountInfoCard from './account-info-card.vue';
|
||||
import SaveMyCard from './card-modal.vue';
|
||||
import FormModalDemo from './withdrawal-modal.vue';
|
||||
|
||||
defineOptions({
|
||||
name: 'Statistics',
|
||||
@@ -24,10 +26,13 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const userStore = useUserStore();
|
||||
const userType = computed(() =>
|
||||
Number((userStore.userInfo as Record<string, any> | null)?.roles?.user_type),
|
||||
);
|
||||
/** 门店账号无「未确认收货」冻结概念,不展示该卡片(提现冻结走「审核中金额」) */
|
||||
const isStoreUser =
|
||||
Number((userStore.userInfo as Record<string, any> | null)?.roles?.user_type) ===
|
||||
1;
|
||||
const isStoreUser = computed(() => userType.value === 1);
|
||||
/** 平台账号需展示累计代付手续费 */
|
||||
const isPlatformUser = computed(() => userType.value === 2);
|
||||
|
||||
const balance = ref(0);
|
||||
const pendingEarnings = ref(0);
|
||||
@@ -36,88 +41,37 @@ const totalEarnings = ref(0);
|
||||
const withdrawnAmount = ref(0);
|
||||
const withdrawnFrozenAmount = ref(0);
|
||||
const frozenAmount = ref(0);
|
||||
// const arrivedAmount = ref(0);
|
||||
const fee = ref(0);
|
||||
const isShow = ref(false);
|
||||
const myCard = ref();
|
||||
|
||||
const overviewItems = ref<AnalysisOverviewItem[]>([]);
|
||||
const myCard = ref<Record<string, any> | null>(null);
|
||||
|
||||
/**
|
||||
* 获取余额卡片列表数据
|
||||
* 门店不组装「冻结金额(用户未确认收货)」,避免把提现审核款误展示成未确认收货
|
||||
* 拉取余额与银行卡,供账户卡展示
|
||||
* 平台 charge 映射为累计代付手续费
|
||||
*/
|
||||
function showCard() {
|
||||
showMyCard();
|
||||
getAdminAccountBalance().then((res) => {
|
||||
balance.value = res.balance;
|
||||
pendingEarnings.value = res.pending_earnings; // 待结算
|
||||
pendingEarnings.value = res.pending_earnings;
|
||||
settledEarnings.value = res.settled_earnings;
|
||||
totalEarnings.value = res.total; // 累计收益
|
||||
withdrawnAmount.value = res.withdrawn; // 已提现金额
|
||||
withdrawnFrozenAmount.value = res.withdrawn_frozen; // 审核中金额
|
||||
frozenAmount.value = res.frozen; // 未确认收货款(仅供应商/配送仓有意义)
|
||||
// arrivedAmount.value = res.data.arrived_amount.toNumber();
|
||||
totalEarnings.value = res.total;
|
||||
withdrawnAmount.value = res.withdrawn;
|
||||
withdrawnFrozenAmount.value = res.withdrawn_frozen;
|
||||
frozenAmount.value = res.frozen;
|
||||
fee.value = res.charge;
|
||||
const items: AnalysisOverviewItem[] = [
|
||||
{
|
||||
icon: SvgCakeIcon,
|
||||
title: '累计收益',
|
||||
totalTitle: '累计收益',
|
||||
totalValue: totalEarnings.value,
|
||||
value: totalEarnings.value,
|
||||
},
|
||||
{
|
||||
icon: 'fluent-emoji:balance-scale',
|
||||
title: '账户余额',
|
||||
totalTitle: '账户余额',
|
||||
totalValue: balance.value,
|
||||
value: balance.value,
|
||||
},
|
||||
];
|
||||
// 供应商/配送仓才有确认收货前冻结;门店现金账户无此字段
|
||||
if (!isStoreUser) {
|
||||
items.push({
|
||||
icon: 'game-icons:frozen-orb',
|
||||
title: '冻结金额(用户未确认收货)',
|
||||
totalTitle: '冻结金额(用户未确认收货)',
|
||||
totalValue: frozenAmount.value,
|
||||
value: frozenAmount.value,
|
||||
});
|
||||
}
|
||||
items.push(
|
||||
{
|
||||
icon: 'fxemoji:hourglassflowingsand',
|
||||
title: '待结算收益',
|
||||
totalTitle: '待结算收益',
|
||||
totalValue: pendingEarnings.value,
|
||||
value: pendingEarnings.value,
|
||||
},
|
||||
{
|
||||
icon: 'fluent-emoji:alarm-clock',
|
||||
title: '审核中金额',
|
||||
totalTitle: '审核中金额',
|
||||
totalValue: withdrawnFrozenAmount.value,
|
||||
value: withdrawnFrozenAmount.value,
|
||||
},
|
||||
{
|
||||
icon: 'flat-color-icons:ok',
|
||||
title: '已提现金额',
|
||||
totalTitle: '已提现金额',
|
||||
totalValue: withdrawnAmount.value,
|
||||
value: withdrawnAmount.value,
|
||||
},
|
||||
);
|
||||
overviewItems.value = items;
|
||||
});
|
||||
}
|
||||
|
||||
function showMyCard() {
|
||||
getMyCard().then((res) => {
|
||||
res.user_name = res?.user_name || res?.store?.name || res?.supplier?.name || res?.platform?.name;
|
||||
res.user_name =
|
||||
res?.user_name || res?.store?.name || res?.supplier?.name || res?.platform?.name;
|
||||
myCard.value = res;
|
||||
});
|
||||
}
|
||||
|
||||
showCard();
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: FormModalDemo,
|
||||
});
|
||||
@@ -130,13 +84,8 @@ function refresh() {
|
||||
showCard();
|
||||
}
|
||||
|
||||
function updateShowStatus() {
|
||||
isShow.value = !isShow.value;
|
||||
}
|
||||
|
||||
const showModal = (data = {}) => {
|
||||
formModalApi.setData({
|
||||
// 表单值
|
||||
values: data,
|
||||
balance,
|
||||
gridApi: props.gridApi,
|
||||
@@ -147,7 +96,6 @@ const showModal = (data = {}) => {
|
||||
|
||||
const showCardModal = () => {
|
||||
saveMyCardApi.setData({
|
||||
// 表单值
|
||||
values: myCard.value,
|
||||
balance,
|
||||
gridApi: props.gridApi,
|
||||
@@ -158,20 +106,23 @@ const showCardModal = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card
|
||||
:title="`${myCard?.user_name || myCard?.store?.name || myCard?.supplier?.name}--账户信息`"
|
||||
class="p-5"
|
||||
>
|
||||
<!-- <template #extra>-->
|
||||
<!-- <Button type="link" @click="updateShowStatus">{{ isShow === false ? '展开' : '收起' }}</Button>-->
|
||||
<!-- </template>-->
|
||||
<div>
|
||||
<FormModal />
|
||||
<saveMyCardModal />
|
||||
<Button class="ml-5" type="primary" @click="showModal"> 申请提现 </Button>
|
||||
<Button class="ml-3" type="primary" @click="showCardModal">
|
||||
编辑账户
|
||||
</Button>
|
||||
<Button type="link" @click="refresh">刷新</Button>
|
||||
<AnalysisOverview :items="overviewItems" :my-card="myCard" class="mt-5" />
|
||||
</Card>
|
||||
<AccountInfoCard
|
||||
:my-card="myCard"
|
||||
:is-store-user="isStoreUser"
|
||||
:is-platform-user="isPlatformUser"
|
||||
:balance="balance"
|
||||
:pending-earnings="pendingEarnings"
|
||||
:total-earnings="totalEarnings"
|
||||
:withdrawn-amount="withdrawnAmount"
|
||||
:withdrawn-frozen-amount="withdrawnFrozenAmount"
|
||||
:frozen-amount="frozenAmount"
|
||||
:fee="fee"
|
||||
@apply="showModal"
|
||||
@edit-card="showCardModal"
|
||||
@refresh="refresh"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 提现申请 ↔ 代付手续费 互通详情弹窗
|
||||
* 两边列表共用,按 source 拉取对应详情并展示完整关联信息
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { Spin, Tag } from 'ant-design-vue';
|
||||
|
||||
import { getChargeCashPayRecordDetailApi } from '#/views/finance/charge-cash-pay-record/api';
|
||||
import { getWithdrawalApplicationInfo } from '#/views/finance/withdrawal/api';
|
||||
|
||||
defineOptions({
|
||||
name: 'WithdrawalRelationDetail',
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
/** 归一化后的展示数据 */
|
||||
const view = ref<Record<string, any> | null>(null);
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
footer: false,
|
||||
draggable: true,
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
loadDetail();
|
||||
} else {
|
||||
view.value = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const heroAmount = computed(() => {
|
||||
const v = view.value;
|
||||
if (!v) return '0.00';
|
||||
return formatMoney(v.true_cash ?? v.apply_cash ?? 0);
|
||||
});
|
||||
|
||||
const checkStatusColor = computed(() => {
|
||||
const s = Number(view.value?.check_status ?? 0);
|
||||
if (s === 2) return 'success';
|
||||
if (s === 3 || s === 4) return 'error';
|
||||
return 'warning';
|
||||
});
|
||||
|
||||
/**
|
||||
* 按入口源加载:apply 走提现详情,charge 走代付详情再归一化
|
||||
*/
|
||||
async function loadDetail() {
|
||||
const data = modalApi.getData<{
|
||||
id: number;
|
||||
source: 'apply' | 'charge';
|
||||
}>();
|
||||
if (!data?.id) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
if (data.source === 'charge') {
|
||||
const res = await getChargeCashPayRecordDetailApi(data.id);
|
||||
view.value = normalizeFromCharge(res);
|
||||
} else {
|
||||
const res = await getWithdrawalApplicationInfo(data.id);
|
||||
view.value = normalizeFromApply(res);
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从提现申请详情归一化 */
|
||||
function normalizeFromApply(res: Record<string, any>) {
|
||||
const charge =
|
||||
res?.charge_cash_pay_record || res?.chargeCashPayRecord || null;
|
||||
return {
|
||||
order_no: res?.order_no,
|
||||
apply_id: res?.id,
|
||||
charge_id: charge?.id,
|
||||
apply_cash: res?.apply_cash,
|
||||
true_cash: res?.true_cash,
|
||||
display_charge_cash: res?.display_charge_cash ?? charge?.charge_cash ?? 0,
|
||||
check_status: res?.check_status,
|
||||
check_status_txt: res?.check_status_txt,
|
||||
check_result: res?.check_result,
|
||||
check_time: res?.check_time,
|
||||
apply_time: res?.apply_time,
|
||||
dakuan_status: res?.dakuan_status,
|
||||
dakuan_status_txt: res?.dakuan_status_txt,
|
||||
dakuan_time: res?.dakuan_time,
|
||||
user_type_txt: res?.user_type_txt,
|
||||
subject_name:
|
||||
res?.store?.name ||
|
||||
res?.supplier?.name ||
|
||||
res?.delivery_warehouse?.name ||
|
||||
res?.deliveryWarehouse?.name ||
|
||||
(Number(res?.user_type) === 2 ? '萧康云医' : '-'),
|
||||
charge_status: res?.charge_status ?? charge?.status,
|
||||
charge_status_txt: res?.charge_status_txt,
|
||||
charge_created_at: charge?.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** 从代付记录详情归一化 */
|
||||
function normalizeFromCharge(res: Record<string, any>) {
|
||||
const apply = res?.cash_apply || res?.cashApply || null;
|
||||
return {
|
||||
order_no: res?.order_no,
|
||||
apply_id: res?.apply_id || apply?.id,
|
||||
charge_id: res?.id,
|
||||
apply_cash: res?.apply_cash ?? apply?.apply_cash,
|
||||
true_cash: res?.true_cash ?? apply?.true_cash,
|
||||
display_charge_cash: res?.charge_cash,
|
||||
check_status: res?.apply_check_status ?? apply?.check_status,
|
||||
check_status_txt: res?.apply_check_status_txt,
|
||||
check_result: apply?.check_result,
|
||||
check_time: apply?.check_time,
|
||||
apply_time: apply?.apply_time,
|
||||
dakuan_status: res?.apply_dakuan_status ?? apply?.dakuan_status,
|
||||
dakuan_status_txt: res?.apply_dakuan_status_txt,
|
||||
dakuan_time: apply?.dakuan_time,
|
||||
user_type_txt: res?.type_txt,
|
||||
subject_name: res?.subject_name || '-',
|
||||
charge_status: res?.status,
|
||||
charge_status_txt: res?.status_txt,
|
||||
charge_created_at: res?.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
function formatMoney(val: any) {
|
||||
const n = Number(val || 0);
|
||||
return Number.isFinite(n) ? n.toFixed(2) : '0.00';
|
||||
}
|
||||
|
||||
function chargeTagColor(status: number) {
|
||||
if (status === 2) return 'success';
|
||||
if (status === 3) return 'default';
|
||||
return 'processing';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal class="w-[640px]" title="提现与代付详情">
|
||||
<Spin :spinning="loading">
|
||||
<div v-if="view" class="relation-detail">
|
||||
<div class="hero">
|
||||
<div class="hero-label">打款金额(元)</div>
|
||||
<div class="hero-amount">{{ heroAmount }}</div>
|
||||
<div class="hero-tags">
|
||||
<Tag :color="checkStatusColor">
|
||||
{{ view.check_status_txt || '未知审核状态' }}
|
||||
</Tag>
|
||||
<Tag :color="chargeTagColor(Number(view.charge_status || 0))">
|
||||
手续费{{ view.charge_status_txt || '无记录' }}
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<div class="section-title">提现申请</div>
|
||||
<div class="rows">
|
||||
<div class="row">
|
||||
<span class="label">订单号</span>
|
||||
<span class="value mono">{{ view.order_no || '-' }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">申请主体</span>
|
||||
<span class="value">
|
||||
{{ view.user_type_txt || '-' }} · {{ view.subject_name || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">申请金额</span>
|
||||
<span class="value">¥{{ formatMoney(view.apply_cash) }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">打款金额</span>
|
||||
<span class="value">¥{{ formatMoney(view.true_cash) }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">申请时间</span>
|
||||
<span class="value">{{ view.apply_time || '-' }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">打款状态</span>
|
||||
<span class="value">{{ view.dakuan_status_txt || '-' }}</span>
|
||||
</div>
|
||||
<div v-if="view.dakuan_time" class="row">
|
||||
<span class="label">打款时间</span>
|
||||
<span class="value">{{ view.dakuan_time }}</span>
|
||||
</div>
|
||||
<div v-if="view.check_result" class="row">
|
||||
<span class="label">审核说明</span>
|
||||
<span
|
||||
class="value"
|
||||
:class="{ danger: Number(view.check_status) === 3 }"
|
||||
>
|
||||
{{ view.check_result }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section section--fee">
|
||||
<div class="section-title">代付手续费</div>
|
||||
<div class="rows">
|
||||
<div class="row">
|
||||
<span class="label">手续费金额</span>
|
||||
<span class="value fee">
|
||||
¥{{ formatMoney(view.display_charge_cash) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">代付状态</span>
|
||||
<span class="value">{{ view.charge_status_txt || '无关联记录' }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">记录时间</span>
|
||||
<span class="value">{{ view.charge_created_at || '-' }}</span>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span class="label">代付记录ID</span>
|
||||
<span class="value">{{ view.charge_id || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="empty">暂无详情</div>
|
||||
</Spin>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.relation-detail {
|
||||
padding: 4px 4px 8px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
padding: 16px 12px;
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
}
|
||||
|
||||
.hero-label {
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.hero-amount {
|
||||
margin-bottom: 10px;
|
||||
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 14px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 10px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
}
|
||||
|
||||
.section--fee {
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
background: hsl(var(--primary) / 0.06);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex-shrink: 0;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.value {
|
||||
text-align: right;
|
||||
color: hsl(var(--foreground));
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.value.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
.value.fee {
|
||||
font-weight: 600;
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.value.danger {
|
||||
color: hsl(var(--destructive, 0 84% 60%));
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 32px 0;
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
@@ -33,7 +33,7 @@ export const gridOptions: VxeGridProps<RowType> = {
|
||||
{ field: 'user_id', title: '名称', slots: { default: 'user_id' } },
|
||||
{ field: 'apply_cash', title: '申请金额' },
|
||||
{ field: 'true_cash', title: '实际到账金额' },
|
||||
{ field: 'charge_cash', title: '手续费' },
|
||||
{ field: 'display_charge_cash', title: '手续费' },
|
||||
{ field: 'apply_time', title: '申请时间' },
|
||||
{ field: 'check_id', title: '审核人', slots: { default: 'check_id' } },
|
||||
{
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 提现管理页
|
||||
* 顶部账户信息卡(银行卡质感)+ 四个记录列表(统一走 useVbenVxeGrid 封装)
|
||||
* 状态列统一用主题化状态胶囊(StatusPill 样式),金额涨跌用 --success/--destructive 变量,全量适配暗色
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { AnalysisChartsTabs, Page, useVbenModal } from '@vben/common-ui';
|
||||
import { IconifyIcon as VbenIcon } from '@vben/icons';
|
||||
|
||||
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons-vue';
|
||||
import {
|
||||
FloatButton,
|
||||
FloatButtonGroup,
|
||||
message,
|
||||
Popover,
|
||||
Tag,
|
||||
} from 'ant-design-vue';
|
||||
import { FloatButton, FloatButtonGroup, message, Popover } from 'ant-design-vue';
|
||||
|
||||
import { useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { TableAction } from '#/components/table-action';
|
||||
@@ -95,6 +94,23 @@ const isShow = ref(true);
|
||||
function updateShowStatus() {
|
||||
isShow.value = !isShow.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 审核状态 → 胶囊配色映射
|
||||
* 1待审核=warning 2审核成功=success 3拒绝=danger,统一走主题变量适配暗色
|
||||
*/
|
||||
function checkStatusPill(status: number) {
|
||||
if (status === 2) return { cls: 'is-success', text: '审核成功' };
|
||||
if (status === 3) return { cls: 'is-danger', text: '已拒绝' };
|
||||
return { cls: 'is-warning', text: '待审核' };
|
||||
}
|
||||
|
||||
/** 打款状态 → 胶囊配色映射:-1失败 0处理中 1成功 */
|
||||
function dakuanStatusPill(status: number) {
|
||||
if (status === 1) return { cls: 'is-success', text: '打款成功' };
|
||||
if (status === -1) return { cls: 'is-danger', text: '打款失败' };
|
||||
return { cls: 'is-info', text: '处理中' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -106,9 +122,8 @@ function updateShowStatus() {
|
||||
<OrderDetailModal />
|
||||
<Statistics v-show="isShow" :grid-api="GridApi" />
|
||||
|
||||
<div style="min-height: 500px">
|
||||
|
||||
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
|
||||
<div class="withdrawal-lists" style="min-height: 500px">
|
||||
<AnalysisChartsTabs :tabs="chartTabs" class="mt-3">
|
||||
<template #trends>
|
||||
<div style="min-height: 500px">
|
||||
<Grid>
|
||||
@@ -118,35 +133,44 @@ function updateShowStatus() {
|
||||
<span v-if="row.user_type === 1">{{ row.store?.name }}</span>
|
||||
<span v-else-if="row.user_type === 2">萧康平台</span>
|
||||
<span v-else-if="row.user_type === 3">{{
|
||||
row.supplier?.name
|
||||
}}</span>
|
||||
row.supplier?.name
|
||||
}}</span>
|
||||
<span v-else-if="row.user_type === 4">{{
|
||||
row.delivery_warehouse?.name || row.deliveryWarehouse?.name
|
||||
}}</span>
|
||||
row.delivery_warehouse?.name || row.deliveryWarehouse?.name
|
||||
}}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template #check_status="{ row }">
|
||||
<Tag v-if="row.check_status === 1" color="blue">待审核</Tag>
|
||||
<Tag v-else-if="row.check_status === 2" color="green">
|
||||
审核成功
|
||||
</Tag>
|
||||
<Tag v-else-if="row.check_status === 3" color="red">拒绝</Tag>
|
||||
<span
|
||||
class="status-pill"
|
||||
:class="checkStatusPill(row.check_status).cls"
|
||||
>
|
||||
<i class="pill-dot" />
|
||||
{{ checkStatusPill(row.check_status).text }}
|
||||
</span>
|
||||
</template>
|
||||
<template #check_result="{ row }">
|
||||
<Tag v-if="row.check_status === 2" color="green">
|
||||
<span
|
||||
v-if="row.check_status === 2 || row.check_status === 3"
|
||||
class="status-pill"
|
||||
:class="row.check_status === 2 ? 'is-success' : 'is-danger'"
|
||||
>
|
||||
<i class="pill-dot" />
|
||||
{{ row.check_result }}
|
||||
</Tag>
|
||||
<Tag v-else-if="row.check_status === 3" color="red">
|
||||
{{ row.check_result }}
|
||||
</Tag>
|
||||
</span>
|
||||
<span v-else class="cell-muted">-</span>
|
||||
</template>
|
||||
<template #dakuan_status="{ row }">
|
||||
<span v-if="row.dakuan_status === -1">打款失败</span>
|
||||
<span v-else-if="row.dakuan_status === 0">处理中</span>
|
||||
<span v-else-if="row.dakuan_status === 1">打款成功</span>
|
||||
<span
|
||||
class="status-pill"
|
||||
:class="dakuanStatusPill(row.dakuan_status).cls"
|
||||
>
|
||||
<i class="pill-dot" />
|
||||
{{ dakuanStatusPill(row.dakuan_status).text }}
|
||||
</span>
|
||||
</template>
|
||||
<template #check_id="{ row }">
|
||||
<span>{{
|
||||
<span>{{
|
||||
row.check_admin?.username ||
|
||||
row.check_admin?.nick_name ||
|
||||
'暂无'
|
||||
@@ -166,19 +190,19 @@ function updateShowStatus() {
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '查看明细',
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: showSettlementDetail.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '订单详情',
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: openOrderDetail.bind(null, row),
|
||||
},
|
||||
]"
|
||||
{
|
||||
label: '查看明细',
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: showSettlementDetail.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '订单详情',
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
onClick: openOrderDetail.bind(null, row),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[]"
|
||||
/>
|
||||
</template>
|
||||
@@ -195,11 +219,11 @@ function updateShowStatus() {
|
||||
<span v-if="row.user_type === 1">{{ row.store?.name }}</span>
|
||||
<span v-else-if="row.user_type === 2">萧康平台</span>
|
||||
<span v-else-if="row.user_type === 3">{{
|
||||
row.supplier?.name
|
||||
}}</span>
|
||||
row.supplier?.name
|
||||
}}</span>
|
||||
<span v-else-if="row.user_type === 4">{{
|
||||
row.delivery_warehouse?.name || row.deliveryWarehouse?.name
|
||||
}}</span>
|
||||
row.delivery_warehouse?.name || row.deliveryWarehouse?.name
|
||||
}}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
@@ -214,45 +238,33 @@ function updateShowStatus() {
|
||||
<template #toolbar-actions></template>
|
||||
<template #toolbar-tools></template>
|
||||
<template #change_amount="{ row }">
|
||||
<span
|
||||
v-if="Number(row.change_amount) > 0"
|
||||
style="
|
||||
color: #52c41a;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
"
|
||||
>
|
||||
<ArrowUpOutlined />
|
||||
+{{ Number(row.change_amount).toFixed(2) }}
|
||||
</span>
|
||||
|
||||
<span
|
||||
v-if="Number(row.change_amount) > 0"
|
||||
class="amount-cell is-up"
|
||||
>
|
||||
<VbenIcon icon="lucide:trending-up" class="amount-icon" />
|
||||
+{{ Number(row.change_amount).toFixed(2) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="Number(row.change_amount) < 0"
|
||||
style="
|
||||
color: #ff4d4f;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
"
|
||||
class="amount-cell is-down"
|
||||
>
|
||||
<ArrowDownOutlined />
|
||||
{{ Number(row.change_amount).toFixed(2) }}
|
||||
</span>
|
||||
|
||||
<span v-else style="color: #999">0</span>
|
||||
<VbenIcon icon="lucide:trending-down" class="amount-icon" />
|
||||
{{ Number(row.change_amount).toFixed(2) }}
|
||||
</span>
|
||||
<span v-else class="cell-muted">0.00</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '订单详情',
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
ifShow: !!row.order_id,
|
||||
onClick: openOrderDetail.bind(null, row),
|
||||
},
|
||||
]"
|
||||
{
|
||||
label: '订单详情',
|
||||
type: 'link',
|
||||
size: 'small',
|
||||
ifShow: !!row.order_id,
|
||||
onClick: openOrderDetail.bind(null, row),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[]"
|
||||
/>
|
||||
</template>
|
||||
@@ -300,3 +312,83 @@ function updateShowStatus() {
|
||||
</FloatButtonGroup>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* ===== 列表区卡片壳:与账户卡同一套圆角/描边视觉 ===== */
|
||||
.withdrawal-lists {
|
||||
:deep(.card-box) {
|
||||
border-radius: 14px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 状态胶囊:主题变量配色(success/warning/destructive),自动适配暗色 ===== */
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 22px;
|
||||
padding: 0 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
|
||||
.pill-dot {
|
||||
flex-shrink: 0;
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
&.is-success {
|
||||
color: hsl(var(--success));
|
||||
background: hsl(var(--success) / 0.12);
|
||||
}
|
||||
|
||||
&.is-warning {
|
||||
color: hsl(var(--warning));
|
||||
background: hsl(var(--warning) / 0.14);
|
||||
}
|
||||
|
||||
&.is-danger {
|
||||
color: hsl(var(--destructive));
|
||||
background: hsl(var(--destructive) / 0.1);
|
||||
}
|
||||
|
||||
&.is-info {
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted) / 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
/* ===== 资金变动金额:涨绿跌红走主题变量,数字用等宽字体对齐 ===== */
|
||||
.amount-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-family: 'DIN Alternate', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
&.is-up {
|
||||
color: hsl(var(--success));
|
||||
}
|
||||
|
||||
&.is-down {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
}
|
||||
|
||||
.amount-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.cell-muted {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 通知详情页(路由 /notice/detail/:id)
|
||||
* 与收件箱 / 场景弹窗同一套视觉语言:氛围渐变 + hero 卡片 + 主题变量配色(全量适配暗色)
|
||||
* 保留原有逻辑:VIP content JSON 解析、未读人员名单、Markdown/富文本双渲染
|
||||
*/
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { useTabs } from '@vben/hooks';
|
||||
import { usePreferences } from '@vben/preferences';
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
@@ -13,6 +19,7 @@ import {
|
||||
Crown,
|
||||
Megaphone,
|
||||
User,
|
||||
Users,
|
||||
} from 'lucide-vue-next';
|
||||
import { MdPreview } from 'md-editor-v3';
|
||||
|
||||
@@ -30,60 +37,32 @@ const router = useRouter();
|
||||
const route = useRoute();
|
||||
const noticeId = ref(route.params.id);
|
||||
|
||||
const notice = ref(null);
|
||||
const notice = ref<Record<string, any> | null>(null);
|
||||
const loading = ref(true);
|
||||
const error = ref(null);
|
||||
const error = ref<null | string>(null);
|
||||
|
||||
const { setTabTitle } = useTabs();
|
||||
/** Markdown 预览主题必须跟随系统亮/暗切换,不能写死 dark */
|
||||
const { isDark } = usePreferences();
|
||||
const mdTheme = computed(() => (isDark.value ? 'dark' : 'light'));
|
||||
|
||||
// 消息类型定义(含 VIP 通知 type=3)
|
||||
/**
|
||||
* 消息类型定义(含 VIP 通知 type=3)
|
||||
* tone 为 HSL 三元组字符串,通过 CSS 变量注入,亮暗两种主题下均有足够对比度
|
||||
*/
|
||||
const messageTypes = [
|
||||
{
|
||||
id: 0,
|
||||
name: '系统公告',
|
||||
icon: Megaphone,
|
||||
color: 'text-sky-500',
|
||||
darkColor: 'dark:text-sky-400',
|
||||
bgColor: 'bg-sky-50',
|
||||
darkBgColor: 'dark:bg-sky-900/30',
|
||||
borderColor: 'border-sky-200',
|
||||
darkBorderColor: 'dark:border-sky-800',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: '系统通知',
|
||||
icon: Bell,
|
||||
color: 'text-amber-500',
|
||||
darkColor: 'dark:text-amber-400',
|
||||
bgColor: 'bg-amber-50',
|
||||
darkBgColor: 'dark:bg-amber-900/30',
|
||||
borderColor: 'border-amber-200',
|
||||
darkBorderColor: 'dark:border-amber-800',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '周报提醒',
|
||||
icon: CalendarClock,
|
||||
color: 'text-emerald-500',
|
||||
darkColor: 'dark:text-emerald-400',
|
||||
bgColor: 'bg-emerald-50',
|
||||
darkBgColor: 'dark:bg-emerald-900/30',
|
||||
borderColor: 'border-emerald-200',
|
||||
darkBorderColor: 'dark:border-emerald-800',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'VIP通知',
|
||||
icon: Crown,
|
||||
color: 'text-violet-500',
|
||||
darkColor: 'dark:text-violet-400',
|
||||
bgColor: 'bg-violet-50',
|
||||
darkBgColor: 'dark:bg-violet-900/30',
|
||||
borderColor: 'border-violet-200',
|
||||
darkBorderColor: 'dark:border-violet-800',
|
||||
},
|
||||
{ id: 0, name: '系统公告', icon: Megaphone, tone: '199 89% 48%' },
|
||||
{ id: 1, name: '系统通知', icon: Bell, tone: '38 92% 50%' },
|
||||
{ id: 2, name: '周报提醒', icon: CalendarClock, tone: '160 60% 40%' },
|
||||
{ id: 3, name: 'VIP通知', icon: Crown, tone: '258 70% 60%' },
|
||||
];
|
||||
|
||||
/** 当前通知的类型信息(默认回落到系统公告) */
|
||||
const typeInfo = computed(() => {
|
||||
const typeId = Number(notice.value?.type);
|
||||
return messageTypes.find((t) => t.id === typeId) || messageTypes[0];
|
||||
});
|
||||
|
||||
/** 解析 VIP 站内信 content JSON */
|
||||
const vipContent = computed(() => {
|
||||
if (!notice.value || Number(notice.value.type) !== 3) return null;
|
||||
@@ -105,7 +84,7 @@ const vipSceneLabel = computed(() => {
|
||||
return 'VIP通知';
|
||||
});
|
||||
|
||||
// 获取通知详情
|
||||
/** 获取通知详情,成功后同步 Tab 标题 */
|
||||
const getNoticeDetail = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
@@ -122,18 +101,12 @@ const getNoticeDetail = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
// 返回列表
|
||||
const goBack = () => {
|
||||
router.push('/notice');
|
||||
};
|
||||
|
||||
// 获取类型信息
|
||||
const getTypeInfo = (typeId) => {
|
||||
return messageTypes.find((t) => t.id === typeId) || messageTypes[0];
|
||||
};
|
||||
|
||||
// 格式化内容,将换行符转换为<br>
|
||||
const formatContent = (content) => {
|
||||
/** 富文本内容换行符转 <br>(后端存的是纯文本换行) */
|
||||
const formatContent = (content: string) => {
|
||||
return content ? content.replaceAll('\n', '<br>') : '';
|
||||
};
|
||||
|
||||
@@ -147,134 +120,73 @@ const [UnreadUserListModal, UnreadUserListModalApi] = useVbenModal({
|
||||
|
||||
const showUnreadList = () => {
|
||||
UnreadUserListModalApi.setData({
|
||||
values: notice.value.user_list,
|
||||
unread_count: notice.value.unread_count,
|
||||
values: notice.value?.user_list,
|
||||
unread_count: notice.value?.unread_count,
|
||||
});
|
||||
UnreadUserListModalApi.open();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page>
|
||||
<Page auto-content-height content-class="!p-0">
|
||||
<UnreadUserListModal />
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="flex h-8 w-8 items-center justify-center rounded-full text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-300"
|
||||
@click="goBack"
|
||||
>
|
||||
<ArrowLeft class="h-5 w-5" />
|
||||
</button>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
通知详情
|
||||
</h1>
|
||||
</div>
|
||||
</template>
|
||||
<div class="notice-detail" :style="{ '--tone': typeInfo.tone }">
|
||||
<!-- 顶部氛围渐变:跟消息类型色走 -->
|
||||
<div class="detail-atmosphere" aria-hidden="true"></div>
|
||||
|
||||
<div class="mx-auto max-w-3xl">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex justify-center py-20">
|
||||
<div
|
||||
class="h-10 w-10 animate-spin rounded-full border-4 border-gray-200 border-t-sky-500 dark:border-gray-700 dark:border-t-sky-400"
|
||||
></div>
|
||||
</div>
|
||||
<div class="detail-wrap">
|
||||
<!-- 顶栏:返回 + 页面标识 -->
|
||||
<header class="detail-topbar">
|
||||
<button type="button" class="back-btn" aria-label="返回列表" @click="goBack">
|
||||
<ArrowLeft class="size-4" />
|
||||
<span>消息</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-4 text-center text-red-600 transition-colors dark:border-red-800 dark:bg-red-900/20 dark:text-red-400"
|
||||
>
|
||||
{{ error }}
|
||||
<button
|
||||
class="mt-2 rounded-md bg-red-100 px-3 py-1 text-sm font-medium text-red-700 transition-colors hover:bg-red-200 dark:bg-red-900/30 dark:text-red-300 dark:hover:bg-red-900/50"
|
||||
@click="getNoticeDetail"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="state-box">
|
||||
<div class="state-spinner"></div>
|
||||
<p>正在加载通知…</p>
|
||||
</div>
|
||||
|
||||
<!-- 通知详情 -->
|
||||
<div v-else-if="notice" class="space-y-6">
|
||||
<!-- 标题和类型 -->
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-colors dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="mb-4 flex items-start justify-between">
|
||||
<h2
|
||||
class="text-xl font-bold text-gray-900 transition-colors dark:text-gray-100"
|
||||
>
|
||||
{{ notice.title }}
|
||||
</h2>
|
||||
<div
|
||||
:class="[
|
||||
getTypeInfo(notice.type).bgColor,
|
||||
getTypeInfo(notice.type).darkBgColor,
|
||||
]"
|
||||
class="flex items-center gap-1.5 rounded-full px-3 py-1 transition-colors"
|
||||
>
|
||||
<component
|
||||
:is="getTypeInfo(notice.type).icon"
|
||||
:class="[
|
||||
getTypeInfo(notice.type).color,
|
||||
getTypeInfo(notice.type).darkColor,
|
||||
]"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<span
|
||||
:class="[
|
||||
getTypeInfo(notice.type).color,
|
||||
getTypeInfo(notice.type).darkColor,
|
||||
]"
|
||||
class="text-xs font-medium"
|
||||
>
|
||||
{{ getTypeInfo(notice.type).name }}
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="state-box is-error">
|
||||
<p>{{ error }}</p>
|
||||
<button type="button" class="retry-btn" @click="getNoticeDetail">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- 通知详情 -->
|
||||
<template v-else-if="notice">
|
||||
<!-- Hero:类型徽标 + 标题 + 元信息 -->
|
||||
<section class="hero-card">
|
||||
<div class="hero-kicker">
|
||||
<span class="type-pill">
|
||||
<component :is="typeInfo.icon" class="size-3.5" />
|
||||
{{ typeInfo.name }}
|
||||
</span>
|
||||
<time class="hero-time">
|
||||
<Clock class="size-3.5" />
|
||||
{{ notice.created_at }} · {{ formatTimeToRelative(notice.created_at) }}
|
||||
</time>
|
||||
</div>
|
||||
<h1 class="hero-title">{{ notice.title }}</h1>
|
||||
<p v-if="notice.detail" class="hero-desc">{{ formatContent(notice.detail).replaceAll('<br>', ' ') }}</p>
|
||||
<div class="hero-meta">
|
||||
<span class="meta-chip">
|
||||
<Icon icon="svg:logo" />
|
||||
萧康云医
|
||||
</span>
|
||||
<span class="meta-chip">
|
||||
<User class="size-3.5" />
|
||||
{{ notice.admin?.nick_name || '系统' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 元信息 -->
|
||||
<div
|
||||
class="mb-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-500 transition-colors dark:text-gray-400"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
来自:
|
||||
<Icon icon="svg:logo" />
|
||||
<span>萧康云医</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1.5">
|
||||
收件人:
|
||||
<User class="h-4 w-4" />
|
||||
<span>{{ notice.admin?.nick_name || '系统' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mb-6 flex flex-wrap items-center gap-x-6 gap-y-2 text-sm text-gray-500 transition-colors dark:text-gray-400"
|
||||
>
|
||||
<div v-html="formatContent(notice.detail)"></div>
|
||||
<div class="flex items-center gap-1.5" style="margin-left: auto">
|
||||
<Clock class="h-4 w-4" />
|
||||
<span>{{ notice.created_at }}({{
|
||||
formatTimeToRelative(notice.created_at)
|
||||
}})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div
|
||||
class="mb-6 h-px w-full bg-gray-100 transition-colors dark:bg-gray-700"
|
||||
></div>
|
||||
</section>
|
||||
|
||||
<!-- VIP 通知:解析 content JSON,展示时效徽标 + 等级徽标 -->
|
||||
<div
|
||||
v-if="vipContent"
|
||||
class="rounded-lg bg-violet-50/80 p-4 transition-colors dark:bg-violet-900/20"
|
||||
>
|
||||
<div class="mb-3 text-sm font-medium text-violet-700 dark:text-violet-300">
|
||||
{{ vipSceneLabel }}
|
||||
</div>
|
||||
<div class="mb-3 flex items-center gap-4">
|
||||
<section v-if="vipContent" class="body-card vip-card">
|
||||
<div class="vip-scene">{{ vipSceneLabel }}</div>
|
||||
<div class="vip-main">
|
||||
<VipBadgeCombo
|
||||
size="lg"
|
||||
:badge-url="vipContent.badge_url"
|
||||
@@ -282,117 +194,408 @@ const showUnreadList = () => {
|
||||
:level-name="vipContent.level_name"
|
||||
:duration-label="vipContent.duration_label"
|
||||
/>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-200">
|
||||
<div>{{ vipContent.duration_label || '-' }} · {{ vipContent.level_name || '-' }}({{ vipContent.level_code || '-' }})</div>
|
||||
<div class="mt-1 text-gray-500">门店:{{ vipContent.store_name || '-' }}</div>
|
||||
<div class="vip-info">
|
||||
<div class="vip-line">
|
||||
{{ vipContent.duration_label || '-' }} · {{ vipContent.level_name || '-' }}({{ vipContent.level_code || '-' }})
|
||||
</div>
|
||||
<div class="vip-line is-sub">门店:{{ vipContent.store_name || '-' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<div class="vip-price">
|
||||
原价 ¥{{ vipContent.original_price || '0.00' }}
|
||||
· 实付 ¥{{ vipContent.pay_amount || '0.00' }}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div
|
||||
v-else-if="notice.edit_type === 0"
|
||||
class="rounded-lg bg-gray-50 p-4 transition-colors dark:bg-gray-700"
|
||||
>
|
||||
<!-- 富文本 / Markdown 内容 -->
|
||||
<section v-else class="body-card">
|
||||
<div
|
||||
class="prose prose-sm dark:prose-invert max-w-none text-gray-700 transition-colors dark:text-gray-300"
|
||||
v-if="notice.edit_type === 0"
|
||||
class="prose prose-sm dark:prose-invert content-text max-w-none"
|
||||
v-html="formatContent(notice.content)"
|
||||
></div>
|
||||
</div>
|
||||
<MdPreview
|
||||
v-else-if="notice.edit_type === 1"
|
||||
v-model="notice.content"
|
||||
style="padding: 10px 20px"
|
||||
theme="dark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<div class="flex justify-between">
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
@click="goBack"
|
||||
>
|
||||
<ArrowLeft class="h-4 w-4" />
|
||||
返回列表
|
||||
</button>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<!-- 这里可以添加其他操作按钮,如删除、转发等 -->
|
||||
<MdPreview
|
||||
v-else-if="notice.edit_type === 1"
|
||||
:model-value="String(notice.content || '')"
|
||||
:theme="mdTheme"
|
||||
class="md-body"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- 底部操作 -->
|
||||
<footer class="detail-actions">
|
||||
<button type="button" class="ghost-btn" @click="goBack">
|
||||
<ArrowLeft class="size-4" />
|
||||
返回列表
|
||||
</button>
|
||||
<button
|
||||
v-if="notice.is_my === 1"
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
type="button"
|
||||
class="ghost-btn"
|
||||
:class="{ 'is-accent': notice.is_all_read !== 1 }"
|
||||
@click="showUnreadList"
|
||||
>
|
||||
<Users class="size-4" />
|
||||
{{
|
||||
notice.is_all_read === 1
|
||||
? '全部已读'
|
||||
: `有 ${notice.unread_count} 人未读`
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<!-- 通知不存在 -->
|
||||
<div
|
||||
v-else
|
||||
class="rounded-lg border border-gray-200 bg-white p-8 text-center shadow-sm transition-colors dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<Bell class="mx-auto mb-4 h-12 w-12 text-gray-300 dark:text-gray-600" />
|
||||
<h3
|
||||
class="mb-2 text-lg font-medium text-gray-900 transition-colors dark:text-gray-100"
|
||||
>
|
||||
通知不存在
|
||||
</h3>
|
||||
<p class="mb-4 text-gray-500 transition-colors dark:text-gray-400">
|
||||
该通知可能已被删除或您没有权限查看
|
||||
</p>
|
||||
<button
|
||||
class="rounded-md bg-sky-500 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-sky-600 dark:bg-sky-600 dark:hover:bg-sky-500"
|
||||
@click="goBack"
|
||||
>
|
||||
返回通知列表
|
||||
</button>
|
||||
<!-- 通知不存在 -->
|
||||
<div v-else class="state-box">
|
||||
<div class="state-ico">
|
||||
<Bell class="size-7" />
|
||||
</div>
|
||||
<p class="state-title">通知不存在</p>
|
||||
<p>该通知可能已被删除或您没有权限查看</p>
|
||||
<button type="button" class="retry-btn" @click="goBack">返回通知列表</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 新增 Markdown 样式(如果使用 GitHub 风格) */
|
||||
|
||||
/* 调整 Markdown 容器样式 */
|
||||
.markdown-body {
|
||||
padding: 20px;
|
||||
}
|
||||
/* 平滑过渡效果 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
.notice-detail {
|
||||
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
padding: 24px 20px 48px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
/* 顶部氛围渐变:按消息类型色晕染,暗色下同样成立(低透明度) */
|
||||
.detail-atmosphere {
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
inset: 0 0 auto 0;
|
||||
height: 240px;
|
||||
background:
|
||||
radial-gradient(55% 90% at 15% 0%, hsl(var(--tone) / 0.14), transparent 70%),
|
||||
radial-gradient(40% 60% at 85% 8%, hsl(var(--tone) / 0.07), transparent 65%);
|
||||
mask-image: linear-gradient(to bottom, #000 40%, transparent);
|
||||
}
|
||||
|
||||
/* 确保内容中的换行正确显示 */
|
||||
:deep(.prose) {
|
||||
.detail-wrap {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-topbar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--card, var(--background)) / 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s var(--ease),
|
||||
border-color 0.2s var(--ease);
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
border-color: hsl(var(--foreground) / 0.18);
|
||||
}
|
||||
|
||||
/* ===== Hero 卡片 ===== */
|
||||
.hero-card {
|
||||
padding: 22px 24px;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 18px;
|
||||
background:
|
||||
radial-gradient(80% 130% at 0% 0%, hsl(var(--tone) / 0.08), transparent 60%),
|
||||
hsl(var(--card, var(--background)));
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
|
||||
animation: rise 0.4s var(--ease) both;
|
||||
}
|
||||
|
||||
.hero-kicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.type-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--tone));
|
||||
background: hsl(var(--tone) / 0.12);
|
||||
}
|
||||
|
||||
.hero-time {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 0;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.3;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
margin: 10px 0 0;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.55;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.meta-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 26px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
}
|
||||
|
||||
/* ===== 内容卡片 ===== */
|
||||
.body-card {
|
||||
margin-top: 14px;
|
||||
padding: 20px 24px;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 18px;
|
||||
background: hsl(var(--card, var(--background)));
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 4%);
|
||||
animation: rise 0.4s var(--ease) 0.06s both;
|
||||
}
|
||||
|
||||
.content-text {
|
||||
color: hsl(var(--foreground) / 0.85);
|
||||
white-space: pre-line;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
/* 暗黑模式特有样式 */
|
||||
.dark .bg-gray-750 {
|
||||
background-color: #1e293b;
|
||||
/* MdPreview 背景交给卡片,避免深浅两层底色打架 */
|
||||
.md-body {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dark .bg-gray-650 {
|
||||
background-color: #334155;
|
||||
.md-body :deep(.md-editor-preview-wrapper) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ===== VIP 卡片:紫调走类型 tone 变量 ===== */
|
||||
.vip-card {
|
||||
background:
|
||||
radial-gradient(90% 140% at 100% 0%, hsl(var(--tone) / 0.1), transparent 55%),
|
||||
hsl(var(--card, var(--background)));
|
||||
}
|
||||
|
||||
.vip-scene {
|
||||
margin-bottom: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: hsl(var(--tone));
|
||||
}
|
||||
|
||||
.vip-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vip-line {
|
||||
font-size: 14px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.vip-line.is-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.vip-price {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
/* ===== 底部操作 ===== */
|
||||
.detail-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
animation: rise 0.4s var(--ease) 0.12s both;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 36px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--card, var(--background)) / 0.7);
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s var(--ease),
|
||||
border-color 0.2s var(--ease),
|
||||
color 0.2s var(--ease);
|
||||
}
|
||||
|
||||
.ghost-btn:hover {
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
border-color: hsl(var(--foreground) / 0.18);
|
||||
}
|
||||
|
||||
/* 有未读时强调:主色描边提醒发送者跟进 */
|
||||
.ghost-btn.is-accent {
|
||||
border-color: hsl(var(--primary) / 0.4);
|
||||
background: hsl(var(--primary) / 0.08);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.ghost-btn.is-accent:hover {
|
||||
background: hsl(var(--primary) / 0.14);
|
||||
}
|
||||
|
||||
/* ===== 加载 / 错误 / 空状态 ===== */
|
||||
.state-box {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 8px;
|
||||
padding: 72px 20px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.state-spinner {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid hsl(var(--primary) / 0.25);
|
||||
border-top-color: hsl(var(--primary));
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.state-ico {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
margin-bottom: 8px;
|
||||
border-radius: 22px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: hsl(var(--muted) / 0.45);
|
||||
color: hsl(var(--foreground) / 0.55);
|
||||
}
|
||||
|
||||
.state-title {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 650;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.state-box.is-error {
|
||||
color: hsl(var(--destructive));
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
margin-top: 8px;
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--primary));
|
||||
color: hsl(var(--primary-foreground));
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
opacity: 0.88;
|
||||
}
|
||||
|
||||
@keyframes rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hero-card,
|
||||
.body-card,
|
||||
.detail-actions {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.notice-detail {
|
||||
padding: 16px 12px 36px;
|
||||
}
|
||||
|
||||
.hero-card,
|
||||
.body-card {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 19px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -323,9 +323,15 @@ async function loadMore() {
|
||||
</nav>
|
||||
|
||||
<div class="timeline">
|
||||
<div v-if="loading && messages.length === 0" class="empty-state">
|
||||
<div class="empty-pulse"></div>
|
||||
<p>正在同步消息…</p>
|
||||
<!-- 首次加载骨架屏:占位形状与真实消息行一致,避免布局跳动 -->
|
||||
<div v-if="loading && messages.length === 0" class="skeleton-list" aria-label="加载中">
|
||||
<div v-for="n in 5" :key="n" class="skeleton-row" :style="{ '--i': n - 1 }">
|
||||
<div class="sk sk-avatar"></div>
|
||||
<div class="sk-lines">
|
||||
<div class="sk sk-title"></div>
|
||||
<div class="sk sk-text"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="messages.length === 0" class="empty-state">
|
||||
@@ -662,13 +668,20 @@ async function loadMore() {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
/* 日期分组标题:滚动时粘性吸顶,毛玻璃底保证盖在消息行上仍可读 */
|
||||
.day-label {
|
||||
margin: 0 0 10px 4px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
margin: 0 0 10px;
|
||||
padding: 6px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: hsl(var(--background) / 0.82);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.day-list {
|
||||
@@ -911,13 +924,67 @@ async function loadMore() {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.empty-pulse {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid hsl(var(--primary) / 0.25);
|
||||
border-top-color: hsl(var(--primary));
|
||||
animation: spin 0.8s linear infinite;
|
||||
/* ===== 骨架屏:与 .msg 同布局同圆角,闪烁动画走主题 muted 色 ===== */
|
||||
.skeleton-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.skeleton-row {
|
||||
--i: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 14px 14px 14px 12px;
|
||||
border-radius: 18px;
|
||||
background: hsl(var(--card, var(--background)) / 0.55);
|
||||
animation: rise 0.45s var(--ease) both;
|
||||
animation-delay: calc(var(--i) * 50ms);
|
||||
}
|
||||
|
||||
.sk {
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
hsl(var(--muted) / 0.5) 25%,
|
||||
hsl(var(--muted) / 0.3) 50%,
|
||||
hsl(var(--muted) / 0.5) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.sk-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.sk-lines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sk-title {
|
||||
width: 45%;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.sk-text {
|
||||
width: 80%;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
.more-wrap {
|
||||
@@ -952,15 +1019,11 @@ async function loadMore() {
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.msg,
|
||||
.search-bar {
|
||||
.search-bar,
|
||||
.skeleton-row,
|
||||
.sk {
|
||||
animation: none;
|
||||
}
|
||||
.msg:hover,
|
||||
|
||||
@@ -7,21 +7,21 @@ 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 });
|
||||
import NoticeSceneShell from './NoticeSceneShell.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
notice: Record<string, any> | null;
|
||||
notice: null | Record<string, any>;
|
||||
onOpenBizAudit?: (payload: { bizId: number; typeCode: string; }) => void;
|
||||
onWithdrawAudit?: (payload: { bizId: number; modalType: number }) => void;
|
||||
onOpenBizAudit?: (payload: { typeCode: string; bizId: number }) => void;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
}>();
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false });
|
||||
|
||||
const loading = ref(false);
|
||||
const detail = ref<Record<string, any>>({});
|
||||
|
||||
@@ -30,24 +30,34 @@ const merged = computed(() => ({
|
||||
...detail.value,
|
||||
}));
|
||||
|
||||
const meta = computed(() =>
|
||||
resolveNoticeSceneMeta(String(merged.value?.type_code || '')),
|
||||
);
|
||||
/**
|
||||
* 有效类型编码:决定用哪套场景 meta 与视图
|
||||
* 特殊处理:人工群发的个人日报/周报是富文本,但旧 type=2 被后端映射成
|
||||
* period_report;只有自动运营总结才带 action_payload.metrics,
|
||||
* 没有 metrics 的一律降级按富文本(system)渲染,避免显示一堆 0 的「运营总结」
|
||||
*/
|
||||
const effectiveTypeCode = computed(() => {
|
||||
const code = String(merged.value?.type_code || '');
|
||||
if (code === 'period_report' && !merged.value?.action_payload?.metrics) {
|
||||
return 'system';
|
||||
}
|
||||
return code;
|
||||
});
|
||||
|
||||
const meta = computed(() => resolveNoticeSceneMeta(effectiveTypeCode.value));
|
||||
|
||||
const sceneTitle = computed(() => {
|
||||
const t = meta.value.title;
|
||||
const noticeTitle = String(merged.value?.title || '');
|
||||
// VIP / 富文本用消息标题更贴切
|
||||
const code = String(merged.value?.type_code || '');
|
||||
const code = effectiveTypeCode.value;
|
||||
if (code === 'system' || code === 'broadcast' || code === 'vip') {
|
||||
return noticeTitle || t;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
|
||||
const ViewComp = computed(() =>
|
||||
resolveNoticeView(String(merged.value?.type_code || '')),
|
||||
);
|
||||
const ViewComp = computed(() => resolveNoticeView(effectiveTypeCode.value));
|
||||
|
||||
watch(
|
||||
() => [open.value, props.notice?.id] as const,
|
||||
|
||||
@@ -1,54 +1,203 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 周期总结定制视图:指标网格 + 区间说明
|
||||
* 周期总结定制视图(分受众版本 + 毛玻璃视觉)
|
||||
* 后端 action_payload.audience 区分 平台/诊所/药店/供应商/配送仓库 五个版本,
|
||||
* 各受众一套指标卡配置驱动渲染(不复制五份模板);
|
||||
* 旧数据无 audience 字段时按平台版兼容展示
|
||||
*/
|
||||
import { computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { IconifyIcon as VbenIcon } from '@vben/icons';
|
||||
|
||||
const props = defineProps<{
|
||||
notice: Record<string, any>;
|
||||
}>();
|
||||
|
||||
/** 单个指标瓷砖配置:取值 key + 展示样式 */
|
||||
type MetricTile = {
|
||||
/** money 时值加 ¥ 前缀 */
|
||||
format?: 'money';
|
||||
icon: string;
|
||||
/** metrics 里的字段名 */
|
||||
key: string;
|
||||
label: string;
|
||||
/** normal 常规 / ok 正向 / warn 需关注 */
|
||||
tone: 'normal' | 'ok' | 'warn';
|
||||
};
|
||||
|
||||
/** 受众元信息:徽标文案/图标 + 指标瓷砖布局 */
|
||||
type AudienceMeta = {
|
||||
icon: string;
|
||||
kicker: string;
|
||||
label: string;
|
||||
tiles: MetricTile[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 五套受众配置:每个受众关心的指标不同
|
||||
* 平台看全局运营,诊所看挂号/处方,药店看订单/发货,供应商看结算,仓库看包裹
|
||||
*/
|
||||
const AUDIENCE_META: Record<string, AudienceMeta> = {
|
||||
platform: {
|
||||
label: '平台',
|
||||
icon: 'lucide:globe',
|
||||
kicker: '平台运营总结',
|
||||
tiles: [
|
||||
{ key: 'active_store_count', label: '活跃门店', icon: 'lucide:store', tone: 'normal' },
|
||||
{ key: 'register_total', label: '挂号总数', icon: 'lucide:stethoscope', tone: 'normal' },
|
||||
{ key: 'rx_total', label: '处方总数', icon: 'lucide:file-text', tone: 'normal' },
|
||||
{ key: '_rx_split', label: '线上 / 线下', icon: 'lucide:split', tone: 'normal' },
|
||||
{ key: 'rx_pending', label: '待审处方', icon: 'lucide:hourglass', tone: 'warn' },
|
||||
{ key: 'rx_passed', label: '已通过', icon: 'lucide:check-circle', tone: 'ok' },
|
||||
{ key: 'rx_rejected', label: '已驳回', icon: 'lucide:x-circle', tone: 'warn' },
|
||||
{ key: 'expire_soon', label: '临期待审', icon: 'lucide:alarm-clock', tone: 'warn' },
|
||||
],
|
||||
},
|
||||
clinic: {
|
||||
label: '诊所',
|
||||
icon: 'lucide:stethoscope',
|
||||
kicker: '诊所运营总结',
|
||||
tiles: [
|
||||
{ key: 'register_total', label: '挂号总数', icon: 'lucide:clipboard-list', tone: 'normal' },
|
||||
{ key: 'register_completed', label: '已完成挂号', icon: 'lucide:check-circle', tone: 'ok' },
|
||||
{ key: 'rx_total', label: '处方总数', icon: 'lucide:file-text', tone: 'normal' },
|
||||
{ key: 'rx_pending', label: '当前待审', icon: 'lucide:hourglass', tone: 'warn' },
|
||||
{ key: 'settled_amount', label: '已结算金额', icon: 'lucide:banknote', tone: 'ok', format: 'money' },
|
||||
{ key: 'pending_amount', label: '待结算金额', icon: 'lucide:wallet', tone: 'warn', format: 'money' },
|
||||
],
|
||||
},
|
||||
pharmacy: {
|
||||
label: '药店',
|
||||
icon: 'lucide:pill',
|
||||
kicker: '药店运营总结',
|
||||
tiles: [
|
||||
{ key: 'order_total', label: '有效订单', icon: 'lucide:shopping-cart', tone: 'normal' },
|
||||
{ key: 'order_amount', label: '营业额', icon: 'lucide:banknote', tone: 'ok', format: 'money' },
|
||||
{ key: 'wait_delivery', label: '当前待发货', icon: 'lucide:package', tone: 'warn' },
|
||||
{ key: 'refunding', label: '退款处理中', icon: 'lucide:rotate-ccw', tone: 'warn' },
|
||||
{ key: 'settled_amount', label: '已结算金额', icon: 'lucide:circle-check-big', tone: 'ok', format: 'money' },
|
||||
{ key: 'pending_amount', label: '待结算金额', icon: 'lucide:wallet', tone: 'warn', format: 'money' },
|
||||
],
|
||||
},
|
||||
supplier: {
|
||||
label: '供应商',
|
||||
icon: 'lucide:factory',
|
||||
kicker: '供应商结算总结',
|
||||
tiles: [
|
||||
{ key: 'ledger_count', label: '分账笔数', icon: 'lucide:receipt', tone: 'normal' },
|
||||
{ key: 'settled_amount', label: '已结算金额', icon: 'lucide:banknote', tone: 'ok', format: 'money' },
|
||||
{ key: 'pending_amount', label: '待结算金额', icon: 'lucide:wallet', tone: 'warn', format: 'money' },
|
||||
],
|
||||
},
|
||||
warehouse: {
|
||||
label: '配送仓库',
|
||||
icon: 'lucide:warehouse',
|
||||
kicker: '配送仓运营总结',
|
||||
tiles: [
|
||||
{ key: 'shipment_new', label: '新增包裹', icon: 'lucide:package-plus', tone: 'normal' },
|
||||
{ key: 'shipment_sent', label: '已发货', icon: 'lucide:truck', tone: 'ok' },
|
||||
{ key: 'shipment_wait', label: '当前待发货', icon: 'lucide:package', tone: 'warn' },
|
||||
{ key: 'settled_amount', label: '已结算金额', icon: 'lucide:banknote', tone: 'ok', format: 'money' },
|
||||
{ key: 'pending_amount', label: '待结算金额', icon: 'lucide:wallet', tone: 'warn', format: 'money' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
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' },
|
||||
];
|
||||
/** 旧数据无 audience 时兜底平台版,保证历史消息可读 */
|
||||
const meta = computed(
|
||||
() => AUDIENCE_META[String(payload.value?.audience || 'platform')] || AUDIENCE_META.platform!,
|
||||
);
|
||||
/** 报告主体名称(诊所/药店/供应商/仓库名,平台为空不展示) */
|
||||
const subjectName = computed(() => {
|
||||
const name = String(payload.value?.subject?.name || '');
|
||||
return name === '平台' ? '' : name;
|
||||
});
|
||||
|
||||
/** 周期粒度徽标:日报/周报/月报/年报 */
|
||||
const grainLabel = computed(() => {
|
||||
const grain = String(period.value?.grain || '');
|
||||
const map: Record<string, string> = {
|
||||
day: '日报',
|
||||
week: '周报',
|
||||
month: '月报',
|
||||
year: '年报',
|
||||
};
|
||||
return map[grain] || '总结';
|
||||
});
|
||||
|
||||
/**
|
||||
* 指标瓷砖取值:money 格式加 ¥;平台版「线上/线下」是拼接虚拟字段特殊处理
|
||||
*/
|
||||
function tileValue(tile: MetricTile): string {
|
||||
const m = metrics.value;
|
||||
if (tile.key === '_rx_split') {
|
||||
return `${m.rx_online || 0} / ${m.rx_offline || 0}`;
|
||||
}
|
||||
const raw = m[tile.key];
|
||||
const val = raw === undefined || raw === null ? 0 : raw;
|
||||
return tile.format === 'money' ? `¥${val}` : String(val);
|
||||
}
|
||||
|
||||
/** Markdown 摘要折叠状态:默认收起,指标卡已表达核心信息 */
|
||||
const summaryOpen = ref(false);
|
||||
</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>
|
||||
<!-- Hero:受众徽标 + 周期 + 主体名称,毛玻璃底 + 主色光斑 -->
|
||||
<div class="hero">
|
||||
<div class="hero__orb hero__orb--a"></div>
|
||||
<div class="hero__orb hero__orb--b"></div>
|
||||
<div class="hero__inner">
|
||||
<div class="hero__icon">
|
||||
<VbenIcon :icon="meta.icon" />
|
||||
</div>
|
||||
<div class="hero__meta">
|
||||
<div class="hero__badges">
|
||||
<span class="badge badge--audience">{{ meta.label }}</span>
|
||||
<span class="badge badge--grain">{{ grainLabel }}</span>
|
||||
</div>
|
||||
<div class="hero__label">{{ period.label || notice.detail || '-' }}</div>
|
||||
<div v-if="subjectName" class="hero__subject">{{ subjectName }}</div>
|
||||
</div>
|
||||
<div class="hero__kicker">{{ meta.kicker }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="notice.content"
|
||||
class="md-summary whitespace-pre-wrap text-sm text-muted-foreground"
|
||||
>
|
||||
{{ notice.content }}
|
||||
|
||||
<!-- 指标瓷砖:毛玻璃质感,按受众配置驱动 -->
|
||||
<div class="grid">
|
||||
<div
|
||||
v-for="tile in meta.tiles"
|
||||
:key="tile.key"
|
||||
class="metric"
|
||||
:class="[`metric--${tile.tone}`]"
|
||||
>
|
||||
<div class="metric-head">
|
||||
<span class="metric-icon">
|
||||
<VbenIcon :icon="tile.icon" />
|
||||
</span>
|
||||
<span class="metric-label">{{ tile.label }}</span>
|
||||
</div>
|
||||
<div class="metric-value">{{ tileValue(tile) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Markdown 摘要:折叠玻璃面板,默认收起(指标卡已表达核心信息) -->
|
||||
<div v-if="notice.content" class="summary">
|
||||
<button type="button" class="summary__toggle" @click="summaryOpen = !summaryOpen">
|
||||
<VbenIcon
|
||||
icon="lucide:chevron-right"
|
||||
class="summary__chevron"
|
||||
:class="{ 'is-open': summaryOpen }"
|
||||
/>
|
||||
<span>文字摘要</span>
|
||||
</button>
|
||||
<div v-if="summaryOpen" class="summary__body whitespace-pre-wrap">
|
||||
{{ notice.content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -58,40 +207,133 @@ const cards = computed(() => {
|
||||
padding: 2px 0 8px;
|
||||
}
|
||||
|
||||
.period-banner {
|
||||
/* ===== Hero:毛玻璃 + 双光斑 ===== */
|
||||
.hero {
|
||||
position: relative;
|
||||
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)))
|
||||
);
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border) / 0.8);
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(
|
||||
165deg,
|
||||
hsl(var(--muted) / 0.45),
|
||||
hsl(var(--card, var(--background)))
|
||||
);
|
||||
}
|
||||
|
||||
.period-kicker {
|
||||
/* 柔和光斑:主色低透明度大半径模糊,撑起高级氛围 */
|
||||
.hero__orb {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(38px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero__orb--a {
|
||||
top: -70px;
|
||||
left: -40px;
|
||||
width: 220px;
|
||||
height: 220px;
|
||||
background: hsl(var(--primary) / 0.22);
|
||||
}
|
||||
|
||||
.hero__orb--b {
|
||||
right: -60px;
|
||||
bottom: -90px;
|
||||
width: 240px;
|
||||
height: 240px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
}
|
||||
|
||||
/* 毛玻璃层:内容压在光斑上再 blur,形成磨砂质感 */
|
||||
.hero__inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
padding: 18px 20px;
|
||||
backdrop-filter: blur(18px);
|
||||
-webkit-backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.hero__icon {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
place-items: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border: 1px solid hsl(var(--primary) / 0.25);
|
||||
border-radius: 14px;
|
||||
background: hsl(var(--primary) / 0.12);
|
||||
color: hsl(var(--primary));
|
||||
box-shadow: inset 0 1px 0 hsl(var(--background) / 0.5);
|
||||
}
|
||||
|
||||
.hero__icon :deep(svg) {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
.hero__meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero__badges {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 650;
|
||||
line-height: 17px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.badge--audience {
|
||||
border: 1px solid hsl(var(--primary) / 0.3);
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.period-label {
|
||||
margin-top: 6px;
|
||||
.badge--grain {
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--muted) / 0.5);
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.hero__label {
|
||||
margin-top: 8px;
|
||||
font-size: 18px;
|
||||
font-weight: 720;
|
||||
letter-spacing: -0.02em;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.hero__subject {
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hero__kicker {
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: hsl(var(--primary) / 0.75);
|
||||
text-transform: uppercase;
|
||||
writing-mode: horizontal-tb;
|
||||
}
|
||||
|
||||
/* ===== 指标瓷砖网格 ===== */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@@ -100,25 +342,87 @@ const cards = computed(() => {
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* 玻璃瓷砖:半透明底 + blur + 顶部内侧高光细线 */
|
||||
.metric {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid hsl(var(--border));
|
||||
background: hsl(var(--card, var(--background)));
|
||||
position: relative;
|
||||
padding: 13px 14px;
|
||||
border: 1px solid hsl(var(--border) / 0.55);
|
||||
border-radius: 14px;
|
||||
background: hsl(var(--card, var(--background)) / 0.55);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
box-shadow:
|
||||
inset 0 1px 0 hsl(var(--background) / 0.6),
|
||||
0 1px 2px hsl(var(--foreground) / 0.03);
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.metric.warn {
|
||||
.metric:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: hsl(var(--primary) / 0.3);
|
||||
box-shadow:
|
||||
inset 0 1px 0 hsl(var(--background) / 0.6),
|
||||
0 8px 24px hsl(var(--primary) / 0.08);
|
||||
}
|
||||
|
||||
.metric--warn {
|
||||
border-color: hsl(var(--warning, 38 92% 50%) / 0.35);
|
||||
background: hsl(var(--warning, 38 92% 50%) / 0.08);
|
||||
background:
|
||||
linear-gradient(
|
||||
150deg,
|
||||
hsl(var(--warning, 38 92% 50%) / 0.09),
|
||||
transparent 55%
|
||||
),
|
||||
hsl(var(--card, var(--background)) / 0.55);
|
||||
}
|
||||
|
||||
.metric.ok {
|
||||
border-color: hsl(142 70% 40% / 0.3);
|
||||
background: hsl(142 70% 40% / 0.08);
|
||||
.metric--ok {
|
||||
border-color: hsl(var(--success) / 0.35);
|
||||
background:
|
||||
linear-gradient(
|
||||
150deg,
|
||||
hsl(var(--success) / 0.1),
|
||||
transparent 55%
|
||||
),
|
||||
hsl(var(--card, var(--background)) / 0.55);
|
||||
}
|
||||
|
||||
.metric-head {
|
||||
display: flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.metric-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 7px;
|
||||
background: hsl(var(--primary) / 0.1);
|
||||
color: hsl(var(--primary));
|
||||
}
|
||||
|
||||
.metric--warn .metric-icon {
|
||||
background: hsl(var(--warning, 38 92% 50%) / 0.14);
|
||||
color: hsl(var(--warning, 38 92% 40%));
|
||||
}
|
||||
|
||||
.metric--ok .metric-icon {
|
||||
background: hsl(var(--success) / 0.14);
|
||||
color: hsl(var(--success));
|
||||
}
|
||||
|
||||
.metric-icon :deep(svg) {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
@@ -127,21 +431,83 @@ const cards = computed(() => {
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
margin-top: 6px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.03em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: hsl(var(--foreground));
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.metric.warn .metric-value {
|
||||
.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));
|
||||
/* 正向色走主题 --success 变量:亮/暗自适应,禁止 :global 泄漏全局 */
|
||||
.metric--ok .metric-value {
|
||||
color: hsl(var(--success));
|
||||
}
|
||||
|
||||
/* ===== Markdown 摘要折叠面板 ===== */
|
||||
.summary {
|
||||
margin-top: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid hsl(var(--border) / 0.55);
|
||||
border-radius: 14px;
|
||||
background: hsl(var(--card, var(--background)) / 0.55);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.summary__toggle {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 11px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.summary__toggle:hover {
|
||||
background: hsl(var(--muted) / 0.35);
|
||||
}
|
||||
|
||||
.summary__chevron {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.summary__chevron.is-open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.summary__body {
|
||||
padding: 12px 14px 14px;
|
||||
font-size: 13px;
|
||||
line-height: 1.8;
|
||||
color: hsl(var(--muted-foreground));
|
||||
border-top: 1px solid hsl(var(--border) / 0.4);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.metric,
|
||||
.summary__chevron {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.metric:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -67,3 +67,59 @@ export async function updateRole(data: Record<string, any>) {
|
||||
export async function deleteRole(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
|
||||
const workbenchPrefix = 'role-workbench-widget/';
|
||||
|
||||
/**
|
||||
* 工作台模块可选字典(code + 中文名)
|
||||
*/
|
||||
export async function getWorkbenchWidgetOptions() {
|
||||
return requestClient.get<any>(`${workbenchPrefix}options`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按角色获取已配置的工作台模块(配置回显)
|
||||
*/
|
||||
export async function getWorkbenchWidgetByRoleId(roleId: number) {
|
||||
return requestClient.get<any>(`${workbenchPrefix}get-by-role-id`, {
|
||||
params: { role_id: roleId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量保存角色工作台模块(按数组顺序写排序)
|
||||
*/
|
||||
export async function batchSaveWorkbenchWidget(data: {
|
||||
role_id: number;
|
||||
widget_codes: string[];
|
||||
}) {
|
||||
return requestClient.post<any>(`${workbenchPrefix}batch-save`, data);
|
||||
}
|
||||
|
||||
const wxEntryPrefix = 'wx-workbench-entry/';
|
||||
|
||||
/**
|
||||
* 小程序功能入口全量字典(角色分配抽屉可选项,含分组)
|
||||
*/
|
||||
export async function getWxWorkbenchEntryOptions() {
|
||||
return requestClient.get<any>(`${wxEntryPrefix}options`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按角色获取已分配的小程序功能入口(配置回显,按排序返回)
|
||||
*/
|
||||
export async function getWxWorkbenchEntryByRoleId(roleId: number) {
|
||||
return requestClient.get<any>(`${wxEntryPrefix}get-by-role-id`, {
|
||||
params: { role_id: roleId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量保存角色的小程序功能入口分配(按数组顺序写排序)
|
||||
*/
|
||||
export async function batchSaveWxWorkbenchEntry(data: {
|
||||
entry_ids: number[];
|
||||
role_id: number;
|
||||
}) {
|
||||
return requestClient.post<any>(`${wxEntryPrefix}batch-save`, data);
|
||||
}
|
||||
|
||||
283
apps/web-antd/src/views/system/role/components/mp-entry.vue
Normal file
283
apps/web-antd/src/views/system/role/components/mp-entry.vue
Normal file
@@ -0,0 +1,283 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 角色小程序功能入口分配抽屉:勾选入口 + 上移/下移排序,保存调 wx-workbench-entry/batch-save
|
||||
* 交互完整复刻 workbench-widget.vue(模块少用箭头排序,不引拖拽库);
|
||||
* 与之的差异:可选项来自 xk_wx_workbench_entry 字典并按 group_name 分组展示
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { IconifyIcon as VbenIcon } from '@vben/icons';
|
||||
|
||||
import { Button, Checkbox, message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
batchSaveWxWorkbenchEntry,
|
||||
getWxWorkbenchEntryByRoleId,
|
||||
getWxWorkbenchEntryOptions,
|
||||
} from '../api';
|
||||
|
||||
/** 字典入口项 */
|
||||
type EntryOption = {
|
||||
code: string;
|
||||
description: string;
|
||||
group_name: string;
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
const record = ref<any>({});
|
||||
const options = ref<EntryOption[]>([]);
|
||||
/** 已分配的入口 id(数组顺序即小程序展示顺序) */
|
||||
const enabledIds = ref<number[]>([]);
|
||||
|
||||
const drawerTitle = computed(() => {
|
||||
const roleName = record.value?.name || '';
|
||||
return roleName ? `小程序入口 - ${roleName}` : '小程序入口';
|
||||
});
|
||||
|
||||
/** 未分配的入口,按分组聚合(供勾选添加) */
|
||||
const disabledGroups = computed(() => {
|
||||
const rest = options.value.filter(
|
||||
(item) => !enabledIds.value.includes(item.id),
|
||||
);
|
||||
const groups: { items: EntryOption[]; name: string }[] = [];
|
||||
rest.forEach((item) => {
|
||||
const name = item.group_name || '未分组';
|
||||
let group = groups.find((g) => g.name === name);
|
||||
if (!group) {
|
||||
group = { name, items: [] };
|
||||
groups.push(group);
|
||||
}
|
||||
group.items.push(item);
|
||||
});
|
||||
return groups;
|
||||
});
|
||||
|
||||
/**
|
||||
* 入口 id → 展示名(带分组前缀便于区分不同端的同名入口,如两端都有「提现管理」)
|
||||
*/
|
||||
function labelOf(id: number) {
|
||||
const item = options.value.find((option) => option.id === id);
|
||||
if (!item) return `#${id}`;
|
||||
return item.group_name ? `【${item.group_name}】${item.name}` : item.name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 勾选/取消某入口
|
||||
*/
|
||||
function toggle(id: number, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!enabledIds.value.includes(id)) {
|
||||
enabledIds.value.push(id);
|
||||
}
|
||||
} else {
|
||||
enabledIds.value = enabledIds.value.filter((item) => item !== id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上移/下移调整顺序(offset -1 上移 / 1 下移)
|
||||
*/
|
||||
function move(index: number, offset: number) {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= enabledIds.value.length) return;
|
||||
const list = [...enabledIds.value];
|
||||
const [item] = list.splice(index, 1);
|
||||
list.splice(target, 0, item!);
|
||||
enabledIds.value = list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取字典 + 角色已分配入口
|
||||
*/
|
||||
async function fetchData() {
|
||||
const roleId = Number(record.value?.id || 0);
|
||||
if (!roleId) return;
|
||||
try {
|
||||
const [opts, assigned] = await Promise.all([
|
||||
getWxWorkbenchEntryOptions(),
|
||||
getWxWorkbenchEntryByRoleId(roleId),
|
||||
]);
|
||||
options.value = Array.isArray(opts) ? opts : [];
|
||||
enabledIds.value = (Array.isArray(assigned) ? assigned : []).map(
|
||||
(item: any) => Number(item.entry_id),
|
||||
);
|
||||
} catch {
|
||||
message.error('获取小程序入口配置失败');
|
||||
options.value = [];
|
||||
enabledIds.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置(空数组表示清空该角色全部入口,小程序端会回落硬编码菜单保可用)
|
||||
*/
|
||||
async function handleSave() {
|
||||
const roleId = Number(record.value?.id || 0);
|
||||
if (!roleId) {
|
||||
message.error('角色ID不能为空');
|
||||
return;
|
||||
}
|
||||
DrawerApi.lock();
|
||||
try {
|
||||
await batchSaveWxWorkbenchEntry({
|
||||
role_id: roleId,
|
||||
entry_ids: enabledIds.value,
|
||||
});
|
||||
message.success('保存成功');
|
||||
DrawerApi.close();
|
||||
} catch {
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
DrawerApi.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
const [Drawer, DrawerApi] = useVbenDrawer({
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
record.value = DrawerApi.getData()?.record || {};
|
||||
options.value = [];
|
||||
enabledIds.value = [];
|
||||
DrawerApi.setState({ loading: true });
|
||||
fetchData().finally(() => {
|
||||
DrawerApi.setState({ loading: false });
|
||||
});
|
||||
}
|
||||
},
|
||||
onConfirm: handleSave,
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
DrawerApi,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer :title="drawerTitle" class="w-[420px]">
|
||||
<div class="entry-config">
|
||||
<div class="entry-config__section-title">已分配(自上而下为展示顺序)</div>
|
||||
<div v-if="enabledIds.length > 0" class="entry-config__list">
|
||||
<div
|
||||
v-for="(id, index) in enabledIds"
|
||||
:key="id"
|
||||
class="entry-config__row"
|
||||
>
|
||||
<span class="entry-config__label">{{ labelOf(id) }}</span>
|
||||
<span class="entry-config__ops">
|
||||
<Button
|
||||
:disabled="index === 0"
|
||||
size="small"
|
||||
type="text"
|
||||
@click="move(index, -1)"
|
||||
>
|
||||
<VbenIcon icon="lucide:arrow-up" />
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="index === enabledIds.length - 1"
|
||||
size="small"
|
||||
type="text"
|
||||
@click="move(index, 1)"
|
||||
>
|
||||
<VbenIcon icon="lucide:arrow-down" />
|
||||
</Button>
|
||||
<Button danger size="small" type="text" @click="toggle(id, false)">
|
||||
<VbenIcon icon="lucide:x" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="entry-config__empty">
|
||||
未分配任何入口,小程序端将回落默认菜单
|
||||
</div>
|
||||
|
||||
<div class="entry-config__section-title mt-4">可添加</div>
|
||||
<template v-if="disabledGroups.length > 0">
|
||||
<div
|
||||
v-for="group in disabledGroups"
|
||||
:key="group.name"
|
||||
class="entry-config__group"
|
||||
>
|
||||
<div class="entry-config__group-title">{{ group.name }}</div>
|
||||
<div class="entry-config__list">
|
||||
<div
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
class="entry-config__row"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="false"
|
||||
@update:checked="(val: boolean) => toggle(item.id, val)"
|
||||
>
|
||||
{{ item.name }}
|
||||
<span v-if="item.description" class="entry-config__desc">
|
||||
{{ item.description }}
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="entry-config__empty">全部入口已分配</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.entry-config__section-title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.entry-config__group {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.entry-config__group-title {
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.entry-config__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.entry-config__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: hsl(var(--muted) / 0.25);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.entry-config__label {
|
||||
font-size: 13px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.entry-config__desc {
|
||||
margin-left: 6px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.entry-config__ops {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.entry-config__empty {
|
||||
padding: 12px 0;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,234 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 角色工作台模块配置抽屉:勾选模块 + 上移/下移排序,保存调 batch-save
|
||||
* 为什么用上下移而不是拖拽:模块总数少(≤7 个),箭头排序更轻,无需引 SortableJS
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenDrawer } from '@vben/common-ui';
|
||||
import { IconifyIcon as VbenIcon } from '@vben/icons';
|
||||
|
||||
import { Button, Checkbox, message } from 'ant-design-vue';
|
||||
|
||||
import {
|
||||
batchSaveWorkbenchWidget,
|
||||
getWorkbenchWidgetByRoleId,
|
||||
getWorkbenchWidgetOptions,
|
||||
} from '../api';
|
||||
|
||||
/** 模块字典项 */
|
||||
type WidgetOption = {
|
||||
code: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const record = ref<any>({});
|
||||
const options = ref<WidgetOption[]>([]);
|
||||
/** 已启用的模块 code(数组顺序即展示顺序) */
|
||||
const enabledCodes = ref<string[]>([]);
|
||||
|
||||
const drawerTitle = computed(() => {
|
||||
const roleName = record.value?.name || '';
|
||||
return roleName ? `工作台模块 - ${roleName}` : '工作台模块';
|
||||
});
|
||||
|
||||
/** 未启用的模块(供勾选添加) */
|
||||
const disabledOptions = computed(() =>
|
||||
options.value.filter((item) => !enabledCodes.value.includes(item.code)),
|
||||
);
|
||||
|
||||
/**
|
||||
* code → 中文名
|
||||
*/
|
||||
function labelOf(code: string) {
|
||||
return options.value.find((item) => item.code === code)?.label || code;
|
||||
}
|
||||
|
||||
/**
|
||||
* 勾选/取消某模块
|
||||
*/
|
||||
function toggle(code: string, checked: boolean) {
|
||||
if (checked) {
|
||||
if (!enabledCodes.value.includes(code)) {
|
||||
enabledCodes.value.push(code);
|
||||
}
|
||||
} else {
|
||||
enabledCodes.value = enabledCodes.value.filter((item) => item !== code);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 上移/下移调整顺序(offset -1 上移 / 1 下移)
|
||||
*/
|
||||
function move(index: number, offset: number) {
|
||||
const target = index + offset;
|
||||
if (target < 0 || target >= enabledCodes.value.length) return;
|
||||
const list = [...enabledCodes.value];
|
||||
const [item] = list.splice(index, 1);
|
||||
list.splice(target, 0, item!);
|
||||
enabledCodes.value = list;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取字典 + 角色已配置模块
|
||||
*/
|
||||
async function fetchData() {
|
||||
const roleId = Number(record.value?.id || 0);
|
||||
if (!roleId) return;
|
||||
try {
|
||||
const [opts, layout] = await Promise.all([
|
||||
getWorkbenchWidgetOptions(),
|
||||
getWorkbenchWidgetByRoleId(roleId),
|
||||
]);
|
||||
options.value = Array.isArray(opts) ? opts : [];
|
||||
enabledCodes.value = (Array.isArray(layout) ? layout : []).map(
|
||||
(item: any) => item.widget_code,
|
||||
);
|
||||
} catch {
|
||||
message.error('获取工作台模块配置失败');
|
||||
options.value = [];
|
||||
enabledCodes.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置(空数组表示清空该角色全部模块)
|
||||
*/
|
||||
async function handleSave() {
|
||||
const roleId = Number(record.value?.id || 0);
|
||||
if (!roleId) {
|
||||
message.error('角色ID不能为空');
|
||||
return;
|
||||
}
|
||||
DrawerApi.lock();
|
||||
try {
|
||||
await batchSaveWorkbenchWidget({
|
||||
role_id: roleId,
|
||||
widget_codes: enabledCodes.value,
|
||||
});
|
||||
message.success('保存成功');
|
||||
DrawerApi.close();
|
||||
} catch {
|
||||
message.error('保存失败');
|
||||
} finally {
|
||||
DrawerApi.unlock();
|
||||
}
|
||||
}
|
||||
|
||||
const [Drawer, DrawerApi] = useVbenDrawer({
|
||||
onOpenChange(isOpen) {
|
||||
if (isOpen) {
|
||||
record.value = DrawerApi.getData()?.record || {};
|
||||
options.value = [];
|
||||
enabledCodes.value = [];
|
||||
DrawerApi.setState({ loading: true });
|
||||
fetchData().finally(() => {
|
||||
DrawerApi.setState({ loading: false });
|
||||
});
|
||||
}
|
||||
},
|
||||
onConfirm: handleSave,
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
DrawerApi,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer :title="drawerTitle" class="w-[420px]">
|
||||
<div class="widget-config">
|
||||
<div class="widget-config__section-title">已启用(自上而下为展示顺序)</div>
|
||||
<div v-if="enabledCodes.length > 0" class="widget-config__list">
|
||||
<div
|
||||
v-for="(code, index) in enabledCodes"
|
||||
:key="code"
|
||||
class="widget-config__row"
|
||||
>
|
||||
<span class="widget-config__label">{{ labelOf(code) }}</span>
|
||||
<span class="widget-config__ops">
|
||||
<Button
|
||||
:disabled="index === 0"
|
||||
size="small"
|
||||
type="text"
|
||||
@click="move(index, -1)"
|
||||
>
|
||||
<VbenIcon icon="lucide:arrow-up" />
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="index === enabledCodes.length - 1"
|
||||
size="small"
|
||||
type="text"
|
||||
@click="move(index, 1)"
|
||||
>
|
||||
<VbenIcon icon="lucide:arrow-down" />
|
||||
</Button>
|
||||
<Button danger size="small" type="text" @click="toggle(code, false)">
|
||||
<VbenIcon icon="lucide:x" />
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="widget-config__empty">未启用任何模块,工作台将为空</div>
|
||||
|
||||
<div class="widget-config__section-title mt-4">可添加</div>
|
||||
<div v-if="disabledOptions.length > 0" class="widget-config__list">
|
||||
<div
|
||||
v-for="item in disabledOptions"
|
||||
:key="item.code"
|
||||
class="widget-config__row"
|
||||
>
|
||||
<Checkbox
|
||||
:checked="false"
|
||||
@update:checked="(val: boolean) => toggle(item.code, val)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="widget-config__empty">全部模块已启用</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.widget-config__section-title {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.widget-config__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.widget-config__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 6px 10px;
|
||||
background: hsl(var(--muted) / 0.25);
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.widget-config__label {
|
||||
font-size: 13px;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.widget-config__ops {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.widget-config__empty {
|
||||
padding: 12px 0;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,12 @@ import FormModalDemo from './components/modal.vue';
|
||||
import { formOptions } from './config/search';
|
||||
import { gridOptions } from './config/table';
|
||||
import AuthMenu from "#/views/system/role/components/auth-menu.vue";
|
||||
import MpEntry from "#/views/system/role/components/mp-entry.vue";
|
||||
import QuickNav from "#/views/system/role/components/quick-nav.vue";
|
||||
import WorkbenchWidget from "#/views/system/role/components/workbench-widget.vue";
|
||||
|
||||
// 支持配置小程序工作台入口的角色(1/2平台 3/4/6业务员线 8诊所管理员 14诊所推广员)
|
||||
const MP_ENTRY_ROLE_IDS = [1, 2, 3, 4, 6, 8, 14];
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
@@ -77,6 +82,28 @@ const handleQuickNav = (record: any) => {
|
||||
quickNavRef.value.DrawerApi.open();
|
||||
}
|
||||
};
|
||||
|
||||
// 工作台模块配置
|
||||
const workbenchWidgetRef = ref();
|
||||
const handleWorkbenchWidget = (record: any) => {
|
||||
if (workbenchWidgetRef.value && workbenchWidgetRef.value.DrawerApi) {
|
||||
workbenchWidgetRef.value.DrawerApi.setData({
|
||||
record,
|
||||
});
|
||||
workbenchWidgetRef.value.DrawerApi.open();
|
||||
}
|
||||
};
|
||||
|
||||
// 小程序功能入口分配
|
||||
const mpEntryRef = ref();
|
||||
const handleMpEntry = (record: any) => {
|
||||
if (mpEntryRef.value && mpEntryRef.value.DrawerApi) {
|
||||
mpEntryRef.value.DrawerApi.setData({
|
||||
record,
|
||||
});
|
||||
mpEntryRef.value.DrawerApi.open();
|
||||
}
|
||||
};
|
||||
const deleteApi = (row: any) => {
|
||||
let ids = [];
|
||||
if (row) {
|
||||
@@ -96,6 +123,8 @@ const deleteApi = (row: any) => {
|
||||
<FormModal />
|
||||
<AuthMenu ref="authMenuRef" />
|
||||
<QuickNav ref="quickNavRef" />
|
||||
<WorkbenchWidget ref="workbenchWidgetRef" />
|
||||
<MpEntry ref="mpEntryRef" />
|
||||
<Grid>
|
||||
<template #toolbar-actions>
|
||||
<TableAction
|
||||
@@ -160,6 +189,22 @@ const deleteApi = (row: any) => {
|
||||
// auth: ['admin', 'sys:role:detail'],
|
||||
onClick: handleQuickNav.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '工作台模块',
|
||||
type: 'link',
|
||||
icon: 'mdi:view-dashboard-outline',
|
||||
size: 'small',
|
||||
// auth: ['admin', 'sys:role:detail'],
|
||||
onClick: handleWorkbenchWidget.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: '小程序入口',
|
||||
type: 'link',
|
||||
icon: 'mdi:cellphone-cog',
|
||||
size: 'small',
|
||||
ifShow: MP_ENTRY_ROLE_IDS.includes(Number(row.id)),
|
||||
onClick: handleMpEntry.bind(null, row),
|
||||
},
|
||||
]"
|
||||
:drop-down-actions="[
|
||||
{
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/** 小程序工作台功能入口字典 API */
|
||||
const prefix = 'wx-workbench-entry/';
|
||||
|
||||
export async function getWxWorkbenchEntryList(data: any) {
|
||||
return requestClient.get<any>(`${prefix}list`, { params: data });
|
||||
}
|
||||
|
||||
export async function createWxWorkbenchEntry(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}create`, data);
|
||||
}
|
||||
|
||||
export async function updateWxWorkbenchEntry(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}update`, data);
|
||||
}
|
||||
|
||||
export async function deleteWxWorkbenchEntry(data: Record<string, any>) {
|
||||
return requestClient.post<any>(`${prefix}delete`, data);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 小程序工作台功能入口新增/编辑弹窗
|
||||
* theme_color 为虚拟字段:回显时从存库 theme JSON 解析主色,提交时组装回 {bg,color}
|
||||
*/
|
||||
import { ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm } from '#/adapter/form';
|
||||
|
||||
import { createWxWorkbenchEntry, updateWxWorkbenchEntry } from '../api';
|
||||
import { buildThemeByColor, parseThemeColor } from '../config/constants';
|
||||
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: Record<string, any> = await formApi.getValues();
|
||||
// 虚拟字段 theme_color → 完整主题对象;未选时不覆盖已有主题
|
||||
const theme = buildThemeByColor(values.theme_color || '');
|
||||
if (theme) {
|
||||
values.theme = theme;
|
||||
}
|
||||
delete values.theme_color;
|
||||
modalApi.setState({ confirmLoading: true });
|
||||
try {
|
||||
const api = isUpdate.value ? updateWxWorkbenchEntry : createWxWorkbenchEntry;
|
||||
await api(values);
|
||||
message.success('保存成功');
|
||||
gridApi.value?.query?.();
|
||||
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,
|
||||
theme_color: parseThemeColor(values.theme),
|
||||
});
|
||||
} else {
|
||||
formApi.resetForm();
|
||||
formApi.setValues({ sort: 0, status: 1, icon: 'grid-fill', description: '', path: '' });
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<Modal :title="`${isUpdate ? '编辑' : '新增'}功能入口`" class="w-[560px]">
|
||||
<Form />
|
||||
</Modal>
|
||||
</template>
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* 小程序工作台功能入口字典:常量定义
|
||||
* 主题预设与小程序端 xk-service-card 现用配色一一对应,避免后台随手填出不协调的颜色
|
||||
*/
|
||||
|
||||
/** 启用状态选项 */
|
||||
export const STATUS_OPTIONS = [
|
||||
{ label: '启用', value: 1 },
|
||||
{ label: '禁用', value: 0 },
|
||||
];
|
||||
|
||||
/** 主题预设:value 为图标主色,bg 为图标区渐变底(与小程序端现有配色一致) */
|
||||
export const THEME_PRESETS = [
|
||||
{ label: '橙金', value: '#F97316', bg: 'linear-gradient(135deg, #FFF7ED, #FFEDD5)' },
|
||||
{ label: '科技蓝', value: '#3B82F6', bg: 'linear-gradient(135deg, #EFF6FF, #DBEAFE)' },
|
||||
{ label: '优雅紫', value: '#A855F7', bg: 'linear-gradient(135deg, #FAF5FF, #F3E8FF)' },
|
||||
{ label: '安全绿', value: '#22C55E', bg: 'linear-gradient(135deg, #F0FDF4, #DCFCE7)' },
|
||||
{ label: '青色', value: '#06B6D4', bg: 'linear-gradient(135deg, #ECFEFF, #CFFAFE)' },
|
||||
{ label: '琥珀', value: '#D97706', bg: 'linear-gradient(135deg, #FFFBEB, #FEF3C7)' },
|
||||
{ label: '警戒红', value: '#EF4444', bg: 'linear-gradient(135deg, #FEE2E2, #FECACA)' },
|
||||
{ label: '翡翠绿', value: '#10B981', bg: 'linear-gradient(135deg, #ECFDF5, #D1FAE5)' },
|
||||
{ label: '天空蓝', value: '#0EA5E9', bg: 'linear-gradient(135deg, #F0F9FF, #E0F2FE)' },
|
||||
{ label: '中性灰', value: '#6B7280', bg: 'linear-gradient(135deg, #F3F4F6, #E5E7EB)' },
|
||||
];
|
||||
|
||||
/** 主题下拉选项(label + value) */
|
||||
export const THEME_OPTIONS = THEME_PRESETS.map((item) => ({
|
||||
label: item.label,
|
||||
value: item.value,
|
||||
}));
|
||||
|
||||
/**
|
||||
* 按主色反查完整主题对象(提交时组装 {bg,color} 存库)
|
||||
*/
|
||||
export function buildThemeByColor(color: string) {
|
||||
const preset = THEME_PRESETS.find((item) => item.value === color);
|
||||
if (!preset) return null;
|
||||
return { bg: preset.bg, color: preset.value };
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析存库的 theme(可能是 JSON 字符串或对象),返回主色用于表单回显
|
||||
*/
|
||||
export function parseThemeColor(theme: any): string {
|
||||
let parsed = theme;
|
||||
if (typeof theme === 'string' && theme) {
|
||||
try {
|
||||
parsed = JSON.parse(theme);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
}
|
||||
return parsed?.color || '';
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { VbenFormProps } from '#/adapter/form';
|
||||
|
||||
import { STATUS_OPTIONS, THEME_OPTIONS } from './constants';
|
||||
|
||||
/**
|
||||
* 小程序工作台功能入口新增/编辑表单
|
||||
* theme_color 是表单虚拟字段:选主色,提交时由 modal 组装成 {bg,color} JSON 存 theme 列
|
||||
*/
|
||||
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 全局唯一,按端加前缀:clinic_admin_ / platform_ / sp_ / cs_',
|
||||
},
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'name',
|
||||
label: '入口名称',
|
||||
rules: 'required',
|
||||
componentProps: { placeholder: '卡片标题,如:提现管理' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'description',
|
||||
label: '入口描述',
|
||||
componentProps: { placeholder: '卡片副标题,如:提现流水查询' },
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'group_name',
|
||||
label: '分组标题',
|
||||
rules: 'required',
|
||||
componentProps: { placeholder: '首页分区块标题,如:财务管理' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'icon',
|
||||
label: '图标',
|
||||
componentProps: { placeholder: 'uView 图标名,如 red-packet-fill' },
|
||||
defaultValue: 'grid-fill',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'theme_color',
|
||||
label: '主题配色',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { options: THEME_OPTIONS, allowClear: true },
|
||||
},
|
||||
{
|
||||
component: 'InputNumber',
|
||||
fieldName: 'sort',
|
||||
label: '排序',
|
||||
formItemClass: 'col-span-6',
|
||||
componentProps: { min: 0 },
|
||||
defaultValue: 0,
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'path',
|
||||
label: '跳转路径',
|
||||
componentProps: {
|
||||
placeholder: '小程序页面路径;留空表示前端按编码特殊处理(如传方弹窗)',
|
||||
},
|
||||
defaultValue: '',
|
||||
},
|
||||
{
|
||||
component: 'VbenSelect',
|
||||
fieldName: 'status',
|
||||
label: '状态',
|
||||
componentProps: { options: STATUS_OPTIONS },
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
showDefaultActions: false,
|
||||
};
|
||||
200
apps/web-antd/src/views/system/wx-workbench-entry/index.vue
Normal file
200
apps/web-antd/src/views/system/wx-workbench-entry/index.vue
Normal file
@@ -0,0 +1,200 @@
|
||||
<script lang="ts" setup>
|
||||
/**
|
||||
* 小程序工作台功能入口字典:维护四个业务角色(诊所管理员/平台管理员/业务员/诊所推广员)
|
||||
* 工作台宫格入口的展示信息(编码/名称/描述/图标/主题/路径/分组),
|
||||
* 角色可见性在「角色管理 → 小程序入口」抽屉里按角色勾选分配
|
||||
*/
|
||||
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 { deleteWxWorkbenchEntry, getWxWorkbenchEntryList } from './api';
|
||||
import FormModal from './components/modal.vue';
|
||||
import { STATUS_OPTIONS } from './config/constants';
|
||||
|
||||
defineOptions({ name: 'WxWorkbenchEntry' });
|
||||
|
||||
const hasTopTableDropDownActions = ref(false);
|
||||
|
||||
const formOptions = {
|
||||
schema: [
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'code',
|
||||
label: '编码',
|
||||
componentProps: { placeholder: '如 clinic_admin_withdrawal' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'name',
|
||||
label: '名称',
|
||||
componentProps: { placeholder: '入口名称' },
|
||||
},
|
||||
{
|
||||
component: 'VbenInput',
|
||||
fieldName: 'group_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: 70 },
|
||||
{ field: 'code', title: '编码', minWidth: 200 },
|
||||
{ field: 'name', title: '名称', minWidth: 120 },
|
||||
{ field: 'description', title: '描述', minWidth: 160 },
|
||||
{ field: 'group_name', title: '分组', width: 110 },
|
||||
{ field: 'icon', title: '图标', width: 140 },
|
||||
{ field: 'path', title: '跳转路径', minWidth: 240 },
|
||||
{ field: 'sort', title: '排序', width: 70 },
|
||||
{ field: 'status', title: '状态', width: 80, slots: { default: 'status' } },
|
||||
{ field: 'created_at', title: '创建时间', width: 170 },
|
||||
{ title: '操作', slots: { default: 'action' }, width: 130, fixed: 'right' },
|
||||
],
|
||||
keepSource: true,
|
||||
pagerConfig: {},
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }: any, formValues: any) => {
|
||||
return await getWxWorkbenchEntryList({
|
||||
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) => {
|
||||
deleteWxWorkbenchEntry({ ids: [row.id] }).then(() => {
|
||||
message.success('删除成功');
|
||||
gridApi.query();
|
||||
});
|
||||
};
|
||||
|
||||
const handleBatchDelete = () => {
|
||||
const rows = gridApi.grid.getCheckboxRecords();
|
||||
if (!rows.length) return;
|
||||
deleteWxWorkbenchEntry({ 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 #status="{ row }">
|
||||
<span :class="row.status === 1 ? 'entry-status-on' : 'entry-status-off'">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</span>
|
||||
</template>
|
||||
<template #action="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: '编辑',
|
||||
onClick: () => showModal(row, true),
|
||||
},
|
||||
{
|
||||
label: '删除',
|
||||
popConfirm: {
|
||||
title: '确认删除该入口?删除后各角色将不再展示',
|
||||
confirm: () => handleDelete(row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
<style scoped>
|
||||
/* 状态文案:主题变量着色,暗色自动适配 */
|
||||
.entry-status-on {
|
||||
color: hsl(var(--success, 142 71% 45%));
|
||||
}
|
||||
|
||||
.entry-status-off {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user