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-infoRedis 会话快照)不同,这里返回实时库数据 + 只读展示字段(登录账号/所属门店/最后登录时间等)
*/
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基本资料 / 修改密码)
* 入口:顶栏头像下拉「个人中心」、工作台问候头;静态路由 /profilehideInMenu不走 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/latestxk_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-centerpath 由后端下发(与菜单路由对齐)
*/
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>
/**
* 工作台问候头Premiumprimary 渐变底 + 装饰光斑,头像光环,按时段问候
* 右侧铃铛未读角标跳消息中心;头像/姓名可点击进入个人中心,铃铛旁另有「个人中心」按钮
* 颜色全走主题变量,暗色自动柔化
*/
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>

View File

@@ -0,0 +1,206 @@
# Design System Master File
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
> If that file exists, its rules **override** this Master file.
> If not, strictly follow the rules below.
---
**Project:** XK Admin
**Generated:** 2026-08-11 14:59:17
**Category:** Financial Dashboard
---
## Global Rules
### Color Palette
| Role | Hex | CSS Variable |
|------|-----|--------------|
| Primary | `#0F172A` | `--color-primary` |
| Secondary | `#1E293B` | `--color-secondary` |
| CTA/Accent | `#22C55E` | `--color-cta` |
| Background | `#020617` | `--color-background` |
| Text | `#F8FAFC` | `--color-text` |
**Color Notes:** Dark bg + green positive indicators
### Typography
- **Heading Font:** Fira Code
- **Body Font:** Fira Sans
- **Mood:** dashboard, data, analytics, code, technical, precise
- **Google Fonts:** [Fira Code + Fira Sans](https://fonts.google.com/share?selection.family=Fira+Code:wght@400;500;600;700|Fira+Sans:wght@300;400;500;600;700)
**CSS Import:**
```css
@import url('https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;500;600;700&family=Fira+Sans:wght@300;400;500;600;700&display=swap');
```
### Spacing Variables
| Token | Value | Usage |
|-------|-------|-------|
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
| `--space-md` | `16px` / `1rem` | Standard padding |
| `--space-lg` | `24px` / `1.5rem` | Section padding |
| `--space-xl` | `32px` / `2rem` | Large gaps |
| `--space-2xl` | `48px` / `3rem` | Section margins |
| `--space-3xl` | `64px` / `4rem` | Hero padding |
### Shadow Depths
| Level | Value | Usage |
|-------|-------|-------|
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
---
## Component Specs
### Buttons
```css
/* Primary Button */
.btn-primary {
background: #22C55E;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Secondary Button */
.btn-secondary {
background: transparent;
color: #0F172A;
border: 2px solid #0F172A;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
```
### Cards
```css
.card {
background: #020617;
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow-md);
transition: all 200ms ease;
cursor: pointer;
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
```
### Inputs
```css
.input {
padding: 12px 16px;
border: 1px solid #E2E8F0;
border-radius: 8px;
font-size: 16px;
transition: border-color 200ms ease;
}
.input:focus {
border-color: #0F172A;
outline: none;
box-shadow: 0 0 0 3px #0F172A20;
}
```
### Modals
```css
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-xl);
max-width: 500px;
width: 90%;
}
```
---
## Style Guidelines
**Style:** Dark Mode (OLED)
**Keywords:** Dark theme, low light, high contrast, deep black, midnight blue, eye-friendly, OLED, night mode, power efficient
**Best For:** Night-mode apps, coding platforms, entertainment, eye-strain prevention, OLED devices, low-light
**Key Effects:** Minimal glow (text-shadow: 0 0 10px), dark-to-light transitions, low white emission, high readability, visible focus
### Page Pattern
**Pattern Name:** Horizontal Scroll Journey
- **Conversion Strategy:** Immersive product discovery. High engagement. Keep navigation visible.
28,Bento Grid Showcase,bento, grid, features, modular, apple-style, showcase", 1. Hero, 2. Bento Grid (Key Features), 3. Detail Cards, 4. Tech Specs, 5. CTA, Floating Action Button or Bottom of Grid, Card backgrounds: #F5F5F7 or Glass. Icons: Vibrant brand colors. Text: Dark., Hover card scale (1.02), video inside cards, tilt effect, staggered reveal, Scannable value props. High information density without clutter. Mobile stack.
29,Interactive 3D Configurator,3d, configurator, customizer, interactive, product", 1. Hero (Configurator), 2. Feature Highlight (synced), 3. Price/Specs, 4. Purchase, Inside Configurator UI + Sticky Bottom Bar, Neutral studio background. Product: Realistic materials. UI: Minimal overlay., Real-time rendering, material swap animation, camera rotate/zoom, light reflection, Increases ownership feeling. 360 view reduces return rates. Direct add-to-cart.
30,AI-Driven Dynamic Landing,ai, dynamic, personalized, adaptive, generative", 1. Prompt/Input Hero, 2. Generated Result Preview, 3. How it Works, 4. Value Prop, Input Field (Hero) + 'Try it' Buttons, Adaptive to user input. Dark mode for compute feel. Neon accents., Typing text effects, shimmering generation loaders, morphing layouts, Immediate value demonstration. 'Show, don't tell'. Low friction start.
- **CTA Placement:** Floating Sticky CTA or End of Horizontal Track
- **Section Order:** 1. Intro (Vertical), 2. The Journey (Horizontal Track), 3. Detail Reveal, 4. Vertical Footer
---
## Anti-Patterns (Do NOT Use)
- ❌ Light mode default
- ❌ Slow rendering
### Additional Forbidden Patterns
-**Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
-**Missing cursor:pointer** — All clickable elements must have cursor:pointer
-**Layout-shifting hovers** — Avoid scale transforms that shift layout
-**Low contrast text** — Maintain 4.5:1 minimum contrast ratio
-**Instant state changes** — Always use transitions (150-300ms)
-**Invisible focus states** — Focus states must be visible for a11y
---
## Pre-Delivery Checklist
Before delivering any UI code, verify:
- [ ] No emojis used as icons (use SVG instead)
- [ ] All icons from consistent icon set (Heroicons/Lucide)
- [ ] `cursor-pointer` on all clickable elements
- [ ] Hover states with smooth transitions (150-300ms)
- [ ] Light mode: text contrast 4.5:1 minimum
- [ ] Focus states visible for keyboard navigation
- [ ] `prefers-reduced-motion` respected
- [ ] Responsive: 375px, 768px, 1024px, 1440px
- [ ] No content hidden behind fixed navbars
- [ ] No horizontal scroll on mobile

View File

@@ -0,0 +1,73 @@
# Workspace Page Overrides
> **PROJECT:** XK Admin
> **Generated:** 2026-08-11 14:59:17人工校准对齐 Vben 主题变量)
> **Page Type:** Dashboard / Workbench角色差异化工作台
> ⚠️ **IMPORTANT:** Rules in this file **override** the Master file (`design-system/MASTER.md`).
> 本页规则以 **Vben 主题 CSS 变量** 为唯一色源Master 中写死的 hex 色板对本页 **不适用**。
---
## Page-Specific Rules
### Color Overrides强制亮/暗双主题)
一律使用 Vben 语义变量,禁止写死 hex
| 用途 | 变量 |
|------|------|
| 页面/卡片背景 | `hsl(var(--background))` / `hsl(var(--card))` |
| 次级底色 | `hsl(var(--muted) / 0.25)` |
| 主文字 | `hsl(var(--foreground))` |
| 次要文字 | `hsl(var(--muted-foreground))` |
| 边框/分割线 | `hsl(var(--border))` |
| 强调/选中/hover | `hsl(var(--primary))``hsl(var(--primary) / 0.1)` |
| 警示 | `hsl(var(--warning))``hsl(var(--destructive))` |
角色配置的 accent color 仅用于图标点缀,暗色下加透明度(如 `color-mix` 或 opacity 0.85)。
### Typography Overrides
- 不引入 Fira Code / Google Fonts沿用项目现有字体栈
- KPI 数字:`text-2xl font-semibold`;标签:`text-xs text-muted-foreground`
### Layout OverridesDense Workbench
- 不使用 Master 的 Horizontal Scroll Journey采用纵向密集布局
1. 紧凑问候条(头像 40px单行
2. 快捷入口卡(搜索框 + 密集网格:桌面 6~8 列 / 移动 4 列)
3. 角色 KPI 摘要条stat chips横向 wrap
4. 公告紧凑列表
- 区块间距 `gap-4`16px卡片内 padding `p-4`
- 快捷入口单元:图标 20px + 单行标题 12px`py-3`,禁止旧版 `w-1/3 py-8` 大卡
### Component OverridesPremium Elegant 视觉语言)
参考 ui-ux-pro-max「Liquid Glass / Premium」方向做克制版管理台不牺牲可读性
- **卡片壳**:圆角 12px边框 `hsl(var(--border)/0.7)`;柔和双层阴影 `0 1px 2px + 0 8px 24pxprimary 低透明)`hover 轻抬升 `translateY(-1px)`
- **问候头**primary 色相柔和渐变底 + 右上装饰性模糊光斑(`blur(64px)`、低透明度,暗色自动柔化);头像带 2px primary/20 光环
- **快捷入口单元**:图标坐 40px 圆角容器(`hsl(var(--primary)/0.1)` 底色hover 抬升 + 图标容器变实色200ms `cubic-bezier(0.4,0,0.2,1)`
- **KPI 卡**左上小图标tinted 容器)+ 大数字 + 小标签hover 抬升 + 边框透出 primary
- **公告行**:圆点/图标前缀hover 整行 `hsl(var(--muted)/0.4)` 底色
- 搜索框 antd `Input` allowClear 圆角 8px聚焦态跟 `--primary`
- 空态文案 `text-muted-foreground`
- 动效统一 200ms`prefers-reduced-motion: reduce` 时关闭 transform 动效
- 禁止:彩虹渐变/iridescent、重 backdrop-filter 大面积使用性能与对比度、Google Fonts 引入
---
## Interaction Rules
- 搜索输入即过滤本地Enter 跳转第一条结果
- 所有可点击元素 `cursor-pointer`,键盘 focus 可见
- 遵守 `prefers-reduced-motion`
---
## Pre-Delivery Checklist本页
- [ ] 亮色 + 暗色主题各自检查一遍对比度
- [ ] 无写死 `#fff` / `#000` / `#0f172a` 等色值
- [ ] 375px / 768px / 1024px / 1440px 响应式

View File

@@ -0,0 +1,28 @@
# 2026-08-11 更新说明个人中心PC
## 一、功能概述
顶栏头像下拉的「个人中心」此前指向 Vben 模板假数据页,本次重做为真实功能页,并在工作台新增入口。
- **基本资料 tab**:头像(复用表单 Avatar 上传组件,走 `upload/image`+ 昵称可编辑;登录账号 / 手机号 / 角色 / 所属门店 / 工号 / 最后登录等只读展示(身份信息不允许自助修改)
- **修改密码 tab**:复用顶栏「修改密码」弹窗同一份表单 schema`layouts/config/form.ts``passwordModalForm`)与同一个接口(`admin/update-password`),两个入口口径完全一致
- 保存资料成功后后端同步刷新 Redis 会话,前端重拉 `auth/my-info`,顶栏与工作台头像昵称立即生效
## 二、入口
1. 顶栏头像下拉「个人中心」(已有,指向静态路由 `/profile`,不走 `xk_menu`
2. 工作台问候头:头像/姓名区域整块可点击hover 反馈),铃铛旁新增「个人中心」圆形图标按钮(`lucide:user`,同 bell 样式)
## 三、涉及文件
- 重做 `views/_core/profile/index.vue`:左侧账号卡(头像/昵称/角色)+ 右侧 tab 容器,资料统一从 `auth/my-profile` 实时接口拉取;删除模板假 tab「安全设置」「新消息提醒」连同 `security-setting.vue``notification-setting.vue`
- 重写 `views/_core/profile/base-setting.vue``useVbenForm`Avatar + VbenInput+ 只读信息栅格(主题变量适配暗色)
- 重写 `views/_core/profile/password-setting.vue`:复用 `passwordModalForm` schema + `views/system/admin/api``updatePassword`
- 新建 `views/_core/profile/api/index.ts``getMyProfile` / `updateMyProfile`(后端 `auth/my-profile``auth/update-my-profile`
- 修改 `views/dashboard/workspace/components/WorkspaceHeader.vue`:左侧区域可点 + 右侧个人中心按钮,颜色全走主题变量
## 四、后端配套xk-api
- 新增 `app/Service/common/profile/AdminProfileService.php`五端共用PC + 四个小程序管理端)
- `AdminController``myProfile` / `updateMyProfile``routes/admin.php` auth 分组注册
- 详细见 `sql/doc/0811-个人中心.md`

View File

@@ -0,0 +1,144 @@
# 2026-08-11 更新说明:角色工作台 + 后台 UI 优化
> 约定:以后每轮增强都在 `docs/updates/` 下按日期新增一份更新说明。
## 一、角色工作台模块体系(新功能)
工作台首页从「所有人一样」升级为「按角色装载模块」,模块配置存库、后台可视化调整。
- 新表 `xk_role_workbench_widget`:按 `role_id` 配置模块widget_code / 标题覆盖 / 排序 / 启停)
- 后端 `workbench/layout` 按当前登录角色下发布局,前端按 code 动态挂组件
- 角色管理页新增「工作台模块」配置抽屉:勾选模块、上下排序、保存即生效
- 各角色专属 KPI 摘要接口(轻量 count与对应列表页同口径
- 医生:今日接诊(待接诊/接诊中/已完成/开方数/有效处方金额,与医生小程序同口径)
- 药师:待审方总数
- 业务员/省/市经理:门店录入摘要(近 7 天,复用业务员看板接口)
- 诊所管理员:今日挂号 + 待发货
- 订单管理员:平台待发货 / 退款处理中
## 二、超管/系统管理员工作台修正(本次反馈重点)
**问题**:超管看到的是业务员视角的「门店录入」摘要(近 7 天无新录入时全是 0不是角色判断 bug而是初版种子把该模块也配给了超管/系统管理员,内容错配。
**修正**:超管/系统管理员换成两个专属模块——
1. **平台总览**`platform_overview`):今日订单数、今日营业额(有效商品订单 + 有效挂号,与业务员看板同口径)、今日挂号数、今日新增门店录入,点击直达对应列表页
2. **待办中心**`todo_center`):行式待办列表,数量 > 0 高亮,点击直达处理页
- 待审方 → 审方列表
- 待审核提现 → 提现审核
- 待审核门店录入 → 录入审核
- 待发货订单 / 退款处理中 → 商品订单
- 全部清零时展示「今日无待办」空态
省/市经理和业务员仍保留「门店录入」摘要(语义匹配)。超管如需再看门店录入,可在「角色管理 → 工作台模块」自助勾回。
## 三、工作台 KPI 组件健壮性(功能性补强)
此前 KPI 卡接口失败或加载中会**整卡消失**(用户感知为「功能坏了」),本次统一收口到 `KpiCard` 外壳:
- 加载中:渲染与统计芯片同形状的骨架占位,无布局跳动
- 失败:显示「数据加载失败 + 重试」,可点击重拉
- 标题栏新增手动刷新按钮(加载时图标旋转)
- 七个 KPI 模块(医生/药师/业务员/诊所/订单/平台总览/待办中心)全部接入
## 四、提现管理页 UI 优化
- 状态列(审核状态/审核结果/打款状态)从素 Tag、裸文本换成带圆点的主题化状态胶囊颜色走 `--success/--warning/--destructive` 变量,暗色自动适配
- 资金变动金额列去掉写死的 `#52c41a/#ff4d4f`(暗色下刺眼),改主题变量 + 涨跌趋势图标 + 等宽数字
- 列表区外壳与顶部账户卡统一 14px 圆角 + 主题描边
- 四个列表本就走自有封装 `useVbenVxeGrid`,保持不变
## 五、消息中心优化
- **消息列表**:首次加载改为与消息行同布局的 shimmer 骨架屏;「今天/昨天/近 7 天」日期分组滚动吸顶(毛玻璃底)
- **通知详情页**`/notice/detail/:id`)整页重做:
- 与收件箱同一套视觉:按消息类型色晕染的氛围渐变 + Hero 卡片 + 主题变量配色,暗色全适配(原来写死 gray/sky 色值)
- 修复 bugMarkdown 预览原来写死暗色主题,亮色模式下是一块黑底,现在跟随系统亮/暗切换
- 保留全部逻辑VIP 站内信解析、未读人员名单、富文本/Markdown 双渲染
## 六、工作台布局重排 + 周期总结分受众(第三轮增强)
### 6.1 超管工作台顶部双列布局
- **平台总览 + 待办中心合并为一张「管理驾驶舱」卡**`WidgetAdminHub.vue`上半是四个总览统计芯片分隔线下半是待办统计芯片同视觉count>0 主色高亮),两个接口并行拉取、共用一个刷新/骨架/重试
- 合并卡放在**快捷入口上方左列(约 58%**,右列(约 42%)是公告卡,窄屏(<1024px自动退化为上下堆叠
- 其他角色(布局不含这两个模块)保持原布局不变(快捷入口在顶、公告在底)
### 6.2 公告卡增强(后端零改动)
- 每条公告新增**类型胶囊**:按标题关键词前端推导(维护/停机 → 维护、更新/升级 → 更新、活动/福利 → 活动、默认 → 公告),主题色变量着色
- **点击公告行弹出详情弹层**`announcement/latest` 本就全量返回 content前端直接用 premium 风格弹层(光斑 Hero 头 + 富文本正文)展示,暗色全适配
### 6.3 周期总结分受众版本(后端新能力)
原周期总结只发平台管理员、只有平台指标。现在一次 `notice:period-report` 执行推送**五个受众版本**,各自看各自的经营数据:
| 受众 | 接收人 | 指标 |
|---|---|---|
| 平台 | 超管/系统管理员/总部财务 | 活跃门店、挂号、处方(线上/线下、待审/通过/驳回)、临期待审 |
| 诊所 | 诊所管理员yii_store.type=0 | 本店挂号(总数/已完成)、处方(总数/待审)、结算收入 |
| 药店 | 诊所管理员yii_store.type=1 | 本店有效订单/营业额、待发货、退款处理中、结算收入 |
| 供应商 | 供应商账号role 7 | 分账笔数、已结算/待结算金额yii_ledger user_type=3 |
| 配送仓库 | 仓库管理员role 16 | 新增/已发/待发包裹xk_product_order_shipment、结算收入 |
- 指标全部 `whereIn + groupBy` 批量聚合(`AudienceOpsSummaryService`),不逐店查库
- `action_payload` 新增 `audience` / `subject`(主体 id+名称)字段,旧数据无 audience 按平台版兼容渲染
- crontab 不变,同一条命令自动多受众分发
### 6.4 周期总结详情视图重做(毛玻璃)
`PeriodReportView.vue` 整体重写:
-`audience` 切换五套指标瓷砖配置 + 受众徽标(平台/诊所/药店/供应商/配送仓库),一套模板配置驱动
- 毛玻璃视觉Hero 区双主色光斑 + `backdrop-filter: blur`,指标瓷砖半透明底 + 磨砂 + 内侧高光细线warn/ok 色调走 `--warning`/成功色低透明度,暗色全适配
- Markdown 文字摘要收进可折叠玻璃面板(默认收起,指标卡已表达核心信息)
## 七、SQL 与上线步骤
按顺序执行(均幂等,可重复执行):
1. `sql/20260811/xk_role_workbench_widget.sql` — 建表 + 全角色初版种子
2. `sql/20260811/xk_role_workbench_widget_fix_admin.sql` — 软删超管/系统的门店录入,换上平台总览 + 待办中心
后端无需额外操作(路由自动注册);前端正常发版即可。
## 涉及文件清单
### xk-api后端
| 文件 | 变更 |
|---|---|
| `app/Enum/WorkbenchWidgetEnum.php` | 新增 `platform_overview` / `todo_center` |
| `app/Service/admin/system/WorkbenchService.php` | 新增 `platformOverview()` / `todoCenter()` |
| `app/Http/Controllers/admin/system/WorkbenchController.php` | 新增两个 GET 端点 |
| `app/Service/admin/system/RoleWorkbenchWidgetService.php` | (前轮)角色模块配置 CRUD |
| `app/Service/common/doctor/DoctorWorkbenchStatsService.php` | (前轮)医生统计公共口径 |
| `app/Enum/PeriodReportAudienceEnum.php` | 新增:周期总结受众枚举 |
| `app/Service/common/notice/AudienceOpsSummaryService.php` | 新增:诊所/药店/供应商/仓库指标批量聚合 |
| `app/Service/common/notice/AdminNoticePeriodReportService.php` | 重构五受众分发payload 带 audience/subject |
| `app/Console/Commands/NoticePeriodReportCommand.php` | 描述更新签名不变crontab 无需调整) |
### xk-admin前端
| 文件 | 变更 |
|---|---|
| `views/dashboard/workspace/components/WidgetPlatformOverview.vue` | 新增:平台总览 |
| `views/dashboard/workspace/components/WidgetTodoCenter.vue` | 新增:待办中心 |
| `views/dashboard/workspace/components/KpiCard.vue` | 收口加载骨架/失败重试/手动刷新 |
| `views/dashboard/workspace/components/Widget*.vue`5 个角色 KPI | 接入 loading/error/refresh |
| `views/dashboard/workspace/api/index.ts``config/widgets.ts` | 新接口 + 组件注册 |
| `views/dashboard/workspace/components/WidgetAdminHub.vue` | 新增:总览+待办合并驾驶舱卡 |
| `views/dashboard/workspace/index.vue` | 顶部双列布局(左驾驶舱右公告) |
| `views/dashboard/workspace/components/WidgetNotice.vue` | 类型胶囊 + 点击弹公告详情 |
| `views/notice/views/PeriodReportView.vue` | 重写:五受众配置 + 毛玻璃视觉 |
| `views/finance/withdrawal/index.vue` | 状态胶囊 + 暗色修复 + 列表壳统一 |
| `views/notice/index.vue` | 骨架屏 + 粘性日期分组 |
| `views/notice/compoents/detail.vue` | 整页重做 + MdPreview 主题跟随修复 |
### sql
| 文件 | 变更 |
|---|---|
| `20260811/xk_role_workbench_widget.sql` | (前轮)建表 + 初版种子 |
| `20260811/xk_role_workbench_widget_fix_admin.sql` | 新增:超管/系统模块修正 |
| `z_xk数据结构.sql` | (前轮)同步新表结构 |

View File

@@ -0,0 +1,55 @@
# 20260812 更新:日程日历(待办 + 提醒)与 VIP 组件黑金质感
## 一、日程日历页(新增)
菜单:「概览」组下「日程日历」(`/calendar`name `WorkCalendar`component `/dashboard/calendar/index`SQL 见 sql 仓库 `20260812/xk_calendar_todo.sql`)。
### 目录结构
```
views/dashboard/calendar/
├── index.vue # 日历页(自定义头部 + antd Calendar + 右键菜单)
├── api/index.ts # calendar-todo CRUD API
├── config/
│ ├── constants.ts # 提醒渠道选项notice/email/sms
│ └── form.ts # 待办弹窗表单 schema级联显隐
├── components/
│ ├── todo-modal.vue # 新建/编辑待办弹窗(含删除)
│ └── day-todos-modal.vue # 当日待办列表弹窗(勾选完成/编辑/删除)
└── utils/almanac.ts # 黄历计算封装lunar-typescript带缓存
```
### 功能点
- **黄历**:农历(初一显示月名)、二十四节气、节日、法定调休「休/班」徽标;格子副标题按 节日 > 节气 > 农历日 择优展示,多个节日 Tooltip 全量提示
- 中国传统节日(春节/端午/中秋…)按农历推算;国际节日(元旦/圣诞/母亲节…)按公历——由 `lunar-typescript` 内置数据支持(新增依赖)
- **右键菜单**:任意格子右键 → 新建待办 / 查看当日待办Teleport 到 body贴边自动内收点击/滚动关闭)
- **待办**:格子内直接显示前 3 条(完成划线),超出显示「还有 N 项」hover 格子出现快捷「+」
- **详情备注为 Markdown**:编辑用新封装的 `MarkdownEditor` 表单组件(`components/form/components/markdown-editor.vue`,自动注册、全项目可复用:精简工具栏 + 主题跟随亮暗),弹窗放宽到 640px站内信提醒以 `edit_type=1` 发送、消息详情 MdPreview 渲染,邮件提醒后端转 HTML
- **提醒**:待办可设提醒时间 + 多选渠道(站内信/邮箱/短信),表单级联显隐(选邮箱/短信渠道时出现收件人输入,不填回落个人资料);后端每分钟扫描发送
- **头部**:公历年月 + 农历干支生肖年、图例、上月/今天/下月、新建待办
- 全部配色走 `hsl(var(--xxx))` 主题变量,暗色模式自动适配
### 新依赖
- `lunar-typescript@1.8.6`(农历/节气/节假日计算,纯 TS 无副作用)
## 二、VIP 组件黑金质感升级
旧版蓝绿渐变卡面(#2c3e50#6acdbb)统一升级为「黑金贵宾卡」视觉:深色曜石渐变底 + 右上暗金光晕 + 斜切流光 + 金色描边 + 香槟金文字。深色卡面亮/暗主题通用,删除了原 `.dark` 渐变分支。
| 组件 | 改动 |
| --- | --- |
| `components/vip/StoreVipMemberCard.vue` | 诊所设置会员卡:黑金卡面 + glow/sheen 装饰层,期限改金字胶囊并入标题行 |
| `components/vip/HeaderVipBadge.vue` | 顶栏 VIP 气泡卡头图:黑金化 + glow期限胶囊化 |
| `components/vip/VipUpgradeModal.vue` | 开通/升级弹窗预览卡:黑金化 + glow |
`VipHubModal` / `VipStoreRecordsModal` / `StoreVipCell`(主题变量)与 `VipBadgeCombo`(纯图片)/ `VipGate`(纯逻辑)无需调整。
小程序端xk-doctor-wx同步升级`vip-member-card.vue`(我的页会员卡)与 `my/vip-detail.vue`(会员详情头卡)同款黑金视觉。
## 三、后端接口xk-api
- `GET calendar-todo/get-list?start_time&end_time`:按范围查本人待办
- `POST calendar-todo/create|update|delete|toggle-done`
- 提醒调度:`calendar:todo-remind` 每分钟routes/console.php短信模板需配 `SMS_TODO_REMIND_TEMPLATE_ID`,邮箱需配 SMTP

View File

@@ -11,10 +11,7 @@ import {
VbenIcon,
} from '@vben-core/shadcn-ui';
import { desensitize } from '../../../../../../../apps/web-antd/src/util/tool';
interface Props {
myCard: any;
items?: AnalysisOverviewItem[];
}
@@ -29,31 +26,11 @@ withDefaults(defineProps<Props>(), {
<template>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card v-if="myCard" class="bank-card w-full" title="我的银行卡">
<CardHeader>
<CardTitle class="text-xl">{{ myCard?.bank_name }}</CardTitle>
</CardHeader>
<CardContent class="card-content flex items-center justify-between">
<span class="card-number">
{{ desensitize(myCard?.bank_card, 'bankCard') }}
</span>
<VbenIcon
class="credit-card-icon size-8 flex-shrink-0"
icon="fluent-emoji-flat:credit-card"
/>
</CardContent>
<CardFooter class="card-footer justify-between">
<span>{{ myCard?.bank_user_name }}</span>
<span>{{ myCard?.bank_account_type_txt }}</span>
</CardFooter>
</Card>
<template v-for="item in items" :key="item.title">
<Card :title="item.title" class="w-full">
<CardHeader>
<CardTitle class="text-xl">{{ item.title }}</CardTitle>
</CardHeader>
<CardContent class="flex items-center justify-between">
<VbenCountToAnimator
:decimals="3"
@@ -77,71 +54,3 @@ withDefaults(defineProps<Props>(), {
</template>
</div>
</template>
<style scoped>
.bank-card {
padding: 20px;
color: white;
background: linear-gradient(135deg, #6a11cb 0%, #2575fc 100%);
border-radius: 12px;
box-shadow: 0 4px 8px rgb(0 0 0 / 10%);
transition: transform 0.2s ease-in-out;
}
.dark .bank-card {
padding: 20px;
color: #cd853f; /* 白色文字 */
background: linear-gradient(
135deg,
#3f3f3f 0%,
#1a1a1a 100%
); /* 深色渐变背景 */
border-radius: 12px;
box-shadow: 0 4px 8px rgb(0 0 0 / 60%); /* 更深的阴影 */
transition: transform 0.2s ease-in-out;
}
.bank-card:hover {
transform: translateY(-5px);
}
.card-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
background-color: rgb(255 255 255 / 10%);
border-radius: 8px;
}
.card-number {
font-family:
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
'Helvetica Neue',
Arial,
'Noto Sans',
sans-serif,
'Apple Color Emoji',
'Segoe UI Emoji',
'Segoe UI Symbol',
'Noto Color Emoji' !important;
font-size: 24px;
font-weight: bold;
}
.card-footer {
display: flex;
justify-content: space-between;
margin-top: 16px;
}
.credit-card-icon {
font-size: 24px;
}
</style>

17
pnpm-lock.yaml generated
View File

@@ -700,6 +700,9 @@ importers:
lucide-vue-next:
specifier: ^0.487.0
version: 0.487.0(vue@3.5.41(typescript@6.0.3))
lunar-typescript:
specifier: ^1.8.6
version: 1.8.6
markdown-it:
specifier: ^14.1.0
version: 14.3.0
@@ -5246,6 +5249,11 @@ packages:
cpu: [x64]
os: [darwin]
'@turbo/darwin-arm64@2.10.9':
resolution: {integrity: sha512-aqtpPkiIC4IUas8Vv27oJ3aDfTuP1d5wofd01dZ7gfhHRrIKuFdqQb4imvNnVFahcOViF9Jh8Oi7feDoz8/ciA==}
cpu: [arm64]
os: [darwin]
'@turbo/linux-64@2.10.9':
resolution: {integrity: sha512-XyAneUBsS5uNOUOjBSs81zyigMVwwhVUd3u7F2JFMKGQk6F7eNAiOEBASJ+aHLldjYsOEjhfW+5gWIqwziyFFw==}
cpu: [x64]
@@ -8701,6 +8709,9 @@ packages:
peerDependencies:
vue: ^3.5.40
lunar-typescript@1.8.6:
resolution: {integrity: sha512-5Eo4T/cnuXfrgO4k5LCpOGHIUOuz5hCF/IfNv0T29WY2shR36Hiz+ecN9WjnUuxUKhql9gbOkPaQoqLFKtPRNA==}
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
@@ -14773,6 +14784,9 @@ snapshots:
'@turbo/darwin-64@2.10.9':
optional: true
'@turbo/darwin-arm64@2.10.9':
optional: true
'@turbo/linux-64@2.10.9':
optional: true
@@ -18531,6 +18545,8 @@ snapshots:
dependencies:
vue: 3.5.41(typescript@6.0.3)
lunar-typescript@1.8.6: {}
lz-string@1.5.0: {}
magic-string-ast@1.0.3:
@@ -20696,6 +20712,7 @@ snapshots:
turbo@2.10.9:
optionalDependencies:
'@turbo/darwin-64': 2.10.9
'@turbo/darwin-arm64': 2.10.9
'@turbo/linux-64': 2.10.9
'@turbo/linux-arm64': 2.10.9
'@turbo/windows-64': 2.10.9