feat: 优化了若干接口页面,可用性存疑

This commit is contained in:
李琦
2026-08-12 08:57:15 +08:00
parent 6db736a6f4
commit f622743661
67 changed files with 8729 additions and 1133 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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);

View 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);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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 />

View 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);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;

View 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,
};

View 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>

View 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;
}

View 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`);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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',
]);

View File

@@ -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>

View File

@@ -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));
}

View File

@@ -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 } });
}

View File

@@ -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,
};

View File

@@ -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>

View File

@@ -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>

View File

@@ -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: '申请时间' },

View File

@@ -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),
},
]"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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' } },
{

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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,

View File

@@ -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>

View File

@@ -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);
}

View 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>

View File

@@ -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>

View File

@@ -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="[
{

View File

@@ -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);
}

View File

@@ -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>

View File

@@ -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 || '';
}

View File

@@ -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,
};

View 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>