feat: restore dashboard workspace and analytics

This commit is contained in:
2026-06-21 19:44:32 +08:00
parent f9050c95d1
commit 61f164a6f8
25 changed files with 1155 additions and 496 deletions
+2 -1
View File
@@ -197,6 +197,8 @@ web/apps/web-ele/src/views/online-dev/*
web/apps/web-ele/src/views/dashboard/ web/apps/web-ele/src/views/dashboard/
!web/apps/web-ele/src/views/dashboard/ !web/apps/web-ele/src/views/dashboard/
web/apps/web-ele/src/views/dashboard/* web/apps/web-ele/src/views/dashboard/*
!web/apps/web-ele/src/views/dashboard/analytics/
!web/apps/web-ele/src/views/dashboard/analytics/**
!web/apps/web-ele/src/views/dashboard/workspace/ !web/apps/web-ele/src/views/dashboard/workspace/
!web/apps/web-ele/src/views/dashboard/workspace/** !web/apps/web-ele/src/views/dashboard/workspace/**
web/apps/web-ele/src/views/demos/ web/apps/web-ele/src/views/demos/
@@ -246,7 +248,6 @@ web/apps/web-ele/src/components/dashboard-design/utils/*
!web/apps/web-ele/src/components/dashboard-design/utils/dataFetcher.ts !web/apps/web-ele/src/components/dashboard-design/utils/dataFetcher.ts
web/apps/web-ele/src/components/document-designer/ web/apps/web-ele/src/components/document-designer/
web/apps/web-ele/src/components/workflow/designer/ web/apps/web-ele/src/components/workflow/designer/
web/packages/effects/common-ui/src/ui/dashboard/
web/docs/src/public/guide/ web/docs/src/public/guide/
web/packages/zq-univer/ web/packages/zq-univer/
web/apps/web-ele/src/locales/langs/*/dashboard-design.json* web/apps/web-ele/src/locales/langs/*/dashboard-design.json*
@@ -2,6 +2,7 @@
"home": "首页", "home": "首页",
"controlCenter": "控制中心", "controlCenter": "控制中心",
"workspace": "工作台", "workspace": "工作台",
"analytics": "分析页",
"aiPlatform": "AI 平台", "aiPlatform": "AI 平台",
"aiAgent": "智能体", "aiAgent": "智能体",
"workflowOrchestration": "流程编排", "workflowOrchestration": "流程编排",
+1
View File
@@ -194,6 +194,7 @@ async function generateAccess(options: GenerateMenuAndRoutesOptions) {
'../views/ai-platform/workflow/editor/index.vue', '../views/ai-platform/workflow/editor/index.vue',
'../views/ai-platform/workflow/index.vue', '../views/ai-platform/workflow/index.vue',
'../views/ai-platform/workflow-runs/index.vue', '../views/ai-platform/workflow-runs/index.vue',
'../views/dashboard/analytics/index.vue',
'../views/dashboard/workspace/index.vue', '../views/dashboard/workspace/index.vue',
]); ]);
@@ -1,6 +1,7 @@
export const LIGHT_MENU_NAMES = new Set([ export const LIGHT_MENU_NAMES = new Set([
'ControlCenter', 'ControlCenter',
'Dashboard', 'Dashboard',
'DashboardAnalytics',
'DashboardWorkspace', 'DashboardWorkspace',
'AIAgent', 'AIAgent',
'AIKnowledgeDetail', 'AIKnowledgeDetail',
@@ -12,6 +12,15 @@ const routes: RouteRecordRaw[] = [
path: '/dashboard', path: '/dashboard',
redirect: '/dashboard/workspace', redirect: '/dashboard/workspace',
children: [ children: [
{
component: () => import('#/views/dashboard/analytics/index.vue'),
meta: {
hideInMenu: true,
title: 'menu-title.analytics',
},
name: 'DashboardAnalytics',
path: '/dashboard/analytics',
},
{ {
component: () => import('#/views/dashboard/workspace/index.vue'), component: () => import('#/views/dashboard/workspace/index.vue'),
meta: { meta: {
@@ -0,0 +1,95 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
onMounted(() => {
renderEcharts({
grid: {
bottom: 0,
containLabel: true,
left: '1%',
right: '1%',
top: '2%',
},
legend: {
data: ['智能体会话', '工作流运行'],
top: 0,
},
series: [
{
areaStyle: {},
data: [
120, 280, 560, 1100, 1680, 2360, 3100, 2860, 3520, 4180, 4950,
4620, 5280, 6100, 6800, 7200, 7600, 8300,
],
itemStyle: {
color: '#2563eb',
},
name: '智能体会话',
smooth: true,
type: 'line',
},
{
areaStyle: {},
data: [
60, 140, 320, 680, 920, 1380, 1820, 1700, 2260, 2600, 3120, 3480,
3860, 4260, 4700, 5120, 5600, 6100,
],
itemStyle: {
color: '#16a34a',
},
name: '工作流运行',
smooth: true,
type: 'line',
},
],
tooltip: {
axisPointer: {
lineStyle: {
color: '#2563eb',
width: 1,
},
},
trigger: 'axis',
},
xAxis: {
axisTick: {
show: false,
},
boundaryGap: false,
data: Array.from({ length: 18 }).map((_item, index) => `${index + 6}:00`),
splitLine: {
lineStyle: {
type: 'solid',
width: 1,
},
show: true,
},
type: 'category',
},
yAxis: [
{
axisTick: {
show: false,
},
max: 9000,
splitArea: {
show: true,
},
splitNumber: 4,
type: 'value',
},
],
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>
@@ -0,0 +1,69 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
onMounted(() => {
renderEcharts({
legend: {
bottom: 0,
data: ['节点事件', 'LLM 调用'],
},
radar: {
indicator: [
{ name: '开始' },
{ name: '节点完成' },
{ name: '并行分支' },
{ name: '子流程' },
{ name: '等待输入' },
{ name: '错误' },
],
radius: '60%',
splitNumber: 8,
},
series: [
{
areaStyle: {
opacity: 0.9,
shadowBlur: 0,
shadowColor: 'rgba(0,0,0,.2)',
shadowOffsetX: 0,
shadowOffsetY: 10,
},
data: [
{
itemStyle: {
color: '#2563eb',
},
name: '节点事件',
value: [92, 86, 68, 56, 42, 18],
},
{
itemStyle: {
color: '#16a34a',
},
name: 'LLM 调用',
value: [78, 72, 40, 36, 24, 12],
},
],
itemStyle: {
borderRadius: 10,
borderWidth: 2,
},
symbolSize: 0,
type: 'radar',
},
],
tooltip: {},
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>
@@ -0,0 +1,43 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
onMounted(() => {
renderEcharts({
series: [
{
animationDelay() {
return Math.random() * 400;
},
animationEasing: 'exponentialInOut',
animationType: 'scale',
center: ['50%', '50%'],
color: ['#2563eb', '#16a34a', '#f97316', '#7c3aed'],
data: [
{ name: '自主执行', value: 520 },
{ name: '对话流', value: 430 },
{ name: '子流程', value: 310 },
{ name: '并行协作', value: 390 },
].sort((a, b) => a.value - b.value),
name: '协作类型',
radius: '80%',
roseType: 'radius',
type: 'pie',
},
],
tooltip: {
trigger: 'item',
},
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>
@@ -0,0 +1,64 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
onMounted(() => {
renderEcharts({
legend: {
bottom: '2%',
left: 'center',
},
series: [
{
animationDelay() {
return Math.random() * 100;
},
animationEasing: 'exponentialInOut',
animationType: 'scale',
avoidLabelOverlap: false,
color: ['#2563eb', '#16a34a', '#f97316', '#7c3aed'],
data: [
{ name: '智能体聊天', value: 1048 },
{ name: '工作流运行', value: 735 },
{ name: '模型配置', value: 420 },
{ name: '知识库检索', value: 360 },
],
emphasis: {
label: {
fontSize: '12',
fontWeight: 'bold',
show: true,
},
},
itemStyle: {
borderRadius: 10,
borderWidth: 2,
},
label: {
position: 'center',
show: false,
},
labelLine: {
show: false,
},
name: '调用来源',
radius: ['40%', '65%'],
type: 'pie',
},
],
tooltip: {
trigger: 'item',
},
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>
@@ -0,0 +1,58 @@
<script lang="ts" setup>
import type { EchartsUIType } from '@vben/plugins/echarts';
import { onMounted, ref } from 'vue';
import { EchartsUI, useEcharts } from '@vben/plugins/echarts';
const chartRef = ref<EchartsUIType>();
const { renderEcharts } = useEcharts(chartRef);
onMounted(() => {
renderEcharts({
grid: {
bottom: 0,
containLabel: true,
left: '1%',
right: '1%',
top: '2%',
},
series: [
{
barMaxWidth: 80,
data: [
1200, 1800, 2600, 3200, 4100, 5200, 6100, 6800, 7600, 8400, 9300,
10_200,
],
itemStyle: {
color: '#2563eb',
},
name: '运行次数',
type: 'bar',
},
],
tooltip: {
axisPointer: {
lineStyle: {
color: '#2563eb',
width: 1,
},
},
trigger: 'axis',
},
xAxis: {
data: Array.from({ length: 12 }).map((_item, index) => `${index + 1}`),
type: 'category',
},
yAxis: {
max: 12_000,
splitNumber: 4,
type: 'value',
},
});
});
</script>
<template>
<EchartsUI ref="chartRef" />
</template>
@@ -0,0 +1,93 @@
<script lang="ts" setup>
import type { AnalysisOverviewItem } from '@vben/common-ui';
import type { TabOption } from '@vben/types';
import {
AnalysisChartCard,
AnalysisChartsTabs,
AnalysisOverview,
} from '@vben/common-ui';
import { Activity, Bot, Cpu, GitBranch } from '@vben/icons';
import AnalyticsTrends from './analytics-trends.vue';
import AnalyticsVisitsData from './analytics-visits-data.vue';
import AnalyticsVisitsSales from './analytics-visits-sales.vue';
import AnalyticsVisitsSource from './analytics-visits-source.vue';
import AnalyticsVisits from './analytics-visits.vue';
defineOptions({ name: 'DashboardAnalytics' });
const overviewItems: AnalysisOverviewItem[] = [
{
icon: Bot,
title: '智能体',
totalTitle: '已发布智能体',
totalValue: 18,
value: 6,
},
{
icon: GitBranch,
title: '工作流',
totalTitle: '启用工作流',
totalValue: 12,
value: 4,
},
{
icon: Cpu,
title: '模型调用',
totalTitle: '近 30 天调用',
totalValue: 86_400,
value: 3200,
},
{
icon: Activity,
title: '运行事件',
totalTitle: '已记录事件',
totalValue: 128_000,
value: 9800,
},
];
const chartTabs: TabOption[] = [
{
label: '协作趋势',
value: 'trends',
},
{
label: '月度运行',
value: 'visits',
},
];
</script>
<template>
<div class="p-3">
<AnalysisOverview :items="overviewItems" />
<AnalysisChartsTabs :tabs="chartTabs" class="mt-5">
<template #trends>
<AnalyticsTrends />
</template>
<template #visits>
<AnalyticsVisits />
</template>
</AnalysisChartsTabs>
<div class="mt-5 w-full md:flex">
<AnalysisChartCard
class="mt-5 md:mr-4 md:mt-0 md:w-1/3"
title="事件分布"
>
<AnalyticsVisitsData />
</AnalysisChartCard>
<AnalysisChartCard
class="mt-5 md:mr-4 md:mt-0 md:w-1/3"
title="调用来源"
>
<AnalyticsVisitsSource />
</AnalysisChartCard>
<AnalysisChartCard class="mt-5 md:mt-0 md:w-1/3" title="协作类型">
<AnalyticsVisitsSales />
</AnalysisChartCard>
</div>
</div>
</template>
@@ -1,534 +1,266 @@
<script setup lang="ts"> <script lang="ts" setup>
import { computed } from 'vue'; import type {
WorkbenchProjectItem,
WorkbenchQuickNavItem,
WorkbenchTodoItem,
WorkbenchTrendItem,
} from '@vben/common-ui';
import { ref } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { Page } from '@vben/common-ui/es/page';
import { useUserStore } from '@vben/stores';
import { import {
Bell, WorkbenchHeader,
WorkbenchProject,
WorkbenchQuickNav,
WorkbenchTodo,
WorkbenchTrends,
} from '@vben/common-ui';
import {
Bot, Bot,
Brain, Database,
GitFork, GitBranch,
History,
Inbox,
KeyRound, KeyRound,
LayoutDashboard,
Megaphone, Megaphone,
MessageSquare, Menu,
Settings, Settings,
Shield, ShieldCheck,
Users, Users,
Workflow,
} from '@vben/icons'; } from '@vben/icons';
import { preferences } from '@vben/preferences';
import { ElButton, ElCard, ElTag } from 'element-plus'; import { useUserStore } from '@vben/stores';
import { openWindow } from '@vben/utils';
defineOptions({ name: 'DashboardWorkspace' }); defineOptions({ name: 'DashboardWorkspace' });
type Accent = 'blue' | 'cyan' | 'green' | 'orange' | 'violet';
interface MetricItem {
accent: Accent;
label: string;
title: string;
value: string;
}
interface NavItem {
accent: Accent;
description: string;
icon: any;
path: string;
title: string;
}
const router = useRouter(); const router = useRouter();
const userStore = useUserStore(); const userStore = useUserStore();
const displayName = computed( const headerStats = [
() => userStore.userInfo?.realName || userStore.userInfo?.username || '管理员', { label: '智能体', value: '18' },
); { label: '工作流', value: '12' },
{ label: '运行事件', value: '12.8w' },
const metrics: MetricItem[] = [
{
accent: 'blue',
label: '协作入口',
title: '智能体',
value: 'Agent',
},
{
accent: 'cyan',
label: '运行闭环',
title: '流程编排',
value: 'Flow',
},
{
accent: 'green',
label: '基础治理',
title: '权限与角色',
value: 'Auth',
},
{
accent: 'orange',
label: '运营触达',
title: '公告消息',
value: 'Notice',
},
]; ];
const aiNavItems: NavItem[] = [ const projectItems: WorkbenchProjectItem[] = [
{ {
accent: 'blue', color: '#2563eb',
description: '创建、发布和调试业务智能体', content: '管理已发布智能体,维护角色、提示词、工具能力和对话入口。',
date: 'AI 平台',
group: '智能体',
icon: Bot, icon: Bot,
path: '/ai-platform/agent',
title: '智能体管理', title: '智能体管理',
url: '/ai-platform/agent',
}, },
{ {
accent: 'cyan', color: '#16a34a',
description: '编排多智能体、LLM 和工具节点', content: '编排自主、对话流、子流程和并行节点,构建协作式任务链路。',
icon: Workflow, date: 'AI 平台',
path: '/ai-platform/workflow', group: '流程',
icon: GitBranch,
title: '流程编排', title: '流程编排',
url: '/ai-platform/workflow',
}, },
{ {
accent: 'violet', color: '#f97316',
description: '查看运行事件、节点状态和失败原因', content: '查看节点状态、LLM 调用、等待输入、错误和最终输出。',
icon: GitFork, date: '可观测',
path: '/ai-platform/workflow-runs', group: '运行',
icon: History,
title: '执行历史', title: '执行历史',
url: '/ai-platform/workflow-runs',
}, },
{ {
accent: 'green', color: '#7c3aed',
description: '维护可用模型和默认 Chat 模型', content: '维护默认 chat 模型和供应商,保障智能体运行时可解析模型。',
icon: Brain, date: '配置',
path: '/ai-platform/model', group: '模型',
title: '模型配置',
},
];
const adminNavItems: NavItem[] = [
{
accent: 'green',
description: '维护后台账号、状态和基础资料',
icon: Users,
path: '/system/user',
title: '用户管理',
},
{
accent: 'violet',
description: '管理角色权限和资源范围',
icon: Shield,
path: '/system/role',
title: '角色权限',
},
{
accent: 'blue',
description: '维护菜单、路由和页面入口',
icon: KeyRound,
path: '/system/menu',
title: '菜单管理',
},
{
accent: 'orange',
description: '发布公告并查看阅读反馈',
icon: Megaphone,
path: '/message/announcement',
title: '公告管理',
},
];
const runtimeItems = [
{
description: '聊天、流程运行和系统通知会集中进入消息中心。',
icon: MessageSquare,
path: '/message/list',
title: '消息中心',
},
{
description: '检查最新公告和未读提醒。',
icon: Bell,
path: '/message/announcement-list',
title: '公告列表',
},
{
description: '调整系统配置、模型参数和界面偏好。',
icon: Settings, icon: Settings,
path: '/system/config', title: '模型配置',
title: '系统配置', url: '/ai-platform/model',
},
{
color: '#0f766e',
content: '沉淀业务知识、提示词资料和协作过程中的可复用上下文。',
date: '知识',
group: '资料',
icon: Database,
title: '知识库',
url: '/ai-platform/knowledge-base',
},
{
color: '#475569',
content: '查看智能体、流程、模型调用和基础管理的整体运行态势。',
date: '看板',
group: '分析',
icon: LayoutDashboard,
title: '运行分析',
url: '/dashboard/analytics',
}, },
]; ];
function go(path: string) { const quickNavItems: WorkbenchQuickNavItem[] = [
router.push(path); {
color: '#2563eb',
icon: Users,
title: '用户管理',
url: '/system/user',
},
{
color: '#7c3aed',
icon: ShieldCheck,
title: '角色权限',
url: '/system/role',
},
{
color: '#0f766e',
icon: KeyRound,
title: 'API 权限',
url: '/system/new-permission',
},
{
color: '#f97316',
icon: Menu,
title: '菜单管理',
url: '/system/menu',
},
{
color: '#16a34a',
icon: Megaphone,
title: '公告管理',
url: '/message/announcement',
},
{
color: '#475569',
icon: Inbox,
title: '公告列表',
url: '/message/announcement-list',
},
];
const todoItems = ref<WorkbenchTodoItem[]>([
{
completed: false,
content: '用真实业务需求触发业务需求分析师,确认能输出可测试需求、边界和下一步。',
date: '今日',
title: '验证业务需求分析师',
},
{
completed: false,
content: '检查 start、node_start、llm_chunk、error、complete 等事件在前端是否可读。',
date: '今日',
title: '检查 SSE 运行事件',
},
{
completed: true,
content: '发布智能体允许 model_id 为空,运行时回退到启用的默认 chat 模型。',
date: '已完成',
title: '模型兜底策略',
},
{
completed: false,
content: '继续观察 AI 平台、智能体聊天和运行记录页面的首屏加载与预取效果。',
date: '本周',
title: '页面切换性能',
},
{
completed: true,
content: '保留 systemd 非 Docker 部署路径,Docker 入口仅作为可选方式保留。',
date: '已完成',
title: '部署路径收敛',
},
]);
const trendItems: WorkbenchTrendItem[] = [
{
avatar: 'svg:avatar-1',
content: '智能体运行时已增加默认 chat 模型解析,避免 <a>model_id 为空</a> 直接失败。',
date: '刚刚',
title: '运行时',
},
{
avatar: 'svg:avatar-2',
content: '工作流运行记录聚焦节点状态、错误原因、子流程和并行分支结果。',
date: '1 小时前',
title: '可观测性',
},
{
avatar: 'svg:avatar-3',
content: '用户、角色、权限、菜单和公告作为轻量后台的基础模块保留。',
date: '今天',
title: '基础后台',
},
{
avatar: 'svg:avatar-4',
content: 'AI 平台保留智能体、流程编排、模型配置、知识库和执行历史。',
date: '今天',
title: 'AI 平台',
},
{
avatar: 'svg:avatar-1',
content: '线上部署路径固定为 <a>/ai-agent-admin</a>,后端由 systemd 托管。',
date: '本周',
title: '部署',
},
];
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);
});
}
} }
</script> </script>
<template> <template>
<Page auto-content-height> <div class="p-3">
<div class="workspace-page"> <WorkbenchHeader
<section class="workspace-hero"> :avatar="userStore.userInfo?.avatar || preferences.app.defaultAvatar"
<div class="min-w-0"> :stats="headerStats"
<div class="hero-kicker">AI Agent Admin</div>
<h1>你好{{ displayName }}</h1>
<p>
当前后台保留基础治理和 AI 协作主链路优先处理智能体流程编排运行观测和权限管理
</p>
</div>
<div class="hero-actions">
<ElButton type="primary" @click="go('/ai-platform/agent')">
<Bot class="mr-1 size-4" />
智能体
</ElButton>
<ElButton @click="go('/ai-platform/workflow')">
<Workflow class="mr-1 size-4" />
流程编排
</ElButton>
</div>
</section>
<section class="metric-grid">
<ElCard
v-for="item in metrics"
:key="item.title"
class="metric-card"
shadow="never"
> >
<div class="metric-top"> <template #title>
<span :class="['accent-dot', `accent-${item.accent}`]"></span> 早安{{ userStore.userInfo?.realName || userStore.userInfo?.username || '管理员' }}这里是 AI Agent Admin 工作台
<span>{{ item.label }}</span>
</div>
<div class="metric-value">{{ item.value }}</div>
<div class="metric-title">{{ item.title }}</div>
</ElCard>
</section>
<section class="content-grid">
<ElCard class="panel-card" shadow="never">
<template #header>
<div class="panel-header">
<span>AI 协作</span>
<ElTag effect="plain" size="small">核心链路</ElTag>
</div>
</template> </template>
<div class="nav-grid"> <template #description>
<button 保留 zq-ai-admin 成熟后台体验聚焦智能体协作流程编排模型配置知识库和基础权限治理
v-for="item in aiNavItems"
:key="item.path"
class="nav-tile"
type="button"
@click="go(item.path)"
>
<span :class="['tile-icon', `tile-${item.accent}`]">
<component :is="item.icon" class="size-5" />
</span>
<span class="tile-copy">
<strong>{{ item.title }}</strong>
<span>{{ item.description }}</span>
</span>
</button>
</div>
</ElCard>
<ElCard class="panel-card" shadow="never">
<template #header>
<div class="panel-header">
<span>基础后台</span>
<ElTag effect="plain" size="small" type="success">已保留</ElTag>
</div>
</template> </template>
<div class="nav-grid"> </WorkbenchHeader>
<button
v-for="item in adminNavItems"
:key="item.path"
class="nav-tile"
type="button"
@click="go(item.path)"
>
<span :class="['tile-icon', `tile-${item.accent}`]">
<component :is="item.icon" class="size-5" />
</span>
<span class="tile-copy">
<strong>{{ item.title }}</strong>
<span>{{ item.description }}</span>
</span>
</button>
</div>
</ElCard>
</section>
<ElCard class="panel-card" shadow="never"> <div class="mt-5 flex flex-col lg:flex-row">
<template #header> <div class="mr-4 w-full lg:w-3/5">
<div class="panel-header"> <WorkbenchProject
<span>运维与消息</span> :items="projectItems"
<ElTag effect="plain" size="small" type="warning">轻量入口</ElTag> title="核心入口"
@click="navTo"
/>
<WorkbenchTrends
:items="trendItems"
class="mt-5"
title="运行动态"
/>
</div>
<div class="w-full lg:w-2/5">
<WorkbenchQuickNav
:items="quickNavItems"
class="mt-5 lg:mt-0"
title="基础管理"
@click="navTo"
/>
<WorkbenchTodo
:items="todoItems"
class="mt-5"
title="协作闭环待办"
/>
</div> </div>
</template>
<div class="runtime-list">
<button
v-for="item in runtimeItems"
:key="item.path"
class="runtime-row"
type="button"
@click="go(item.path)"
>
<span class="runtime-icon">
<component :is="item.icon" class="size-4" />
</span>
<span class="runtime-copy">
<strong>{{ item.title }}</strong>
<span>{{ item.description }}</span>
</span>
</button>
</div> </div>
</ElCard>
</div> </div>
</Page>
</template> </template>
<style scoped>
.workspace-page {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.workspace-hero {
align-items: center;
background:
linear-gradient(135deg, rgb(36 99 235 / 8%), transparent 42%),
var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
display: flex;
gap: 20px;
justify-content: space-between;
padding: 22px 24px;
}
.hero-kicker {
color: var(--el-color-primary);
font-size: 13px;
font-weight: 700;
margin-bottom: 8px;
}
.workspace-hero h1 {
color: var(--el-text-color-primary);
font-size: 26px;
font-weight: 700;
line-height: 1.25;
margin: 0;
}
.workspace-hero p {
color: var(--el-text-color-regular);
line-height: 1.7;
margin: 8px 0 0;
max-width: 720px;
}
.hero-actions {
display: flex;
flex-shrink: 0;
gap: 8px;
}
.metric-grid {
display: grid;
gap: 12px;
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.metric-card,
.panel-card {
border-radius: 8px;
}
.metric-card :deep(.el-card__body) {
padding: 16px;
}
.metric-top {
align-items: center;
color: var(--el-text-color-secondary);
display: flex;
font-size: 12px;
gap: 8px;
}
.accent-dot {
border-radius: 999px;
display: inline-flex;
height: 8px;
width: 8px;
}
.accent-blue {
background: var(--el-color-primary);
}
.accent-cyan {
background: var(--el-color-info);
}
.accent-green {
background: var(--el-color-success);
}
.accent-orange {
background: var(--el-color-warning);
}
.accent-violet {
background: var(--el-color-danger);
}
.metric-value {
color: var(--el-text-color-primary);
font-size: 24px;
font-weight: 700;
margin-top: 14px;
}
.metric-title {
color: var(--el-text-color-regular);
font-size: 13px;
margin-top: 4px;
}
.content-grid {
display: grid;
gap: 16px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.panel-header {
align-items: center;
color: var(--el-text-color-primary);
display: flex;
font-weight: 700;
justify-content: space-between;
}
.nav-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.nav-tile,
.runtime-row {
align-items: center;
background: var(--el-bg-color-page);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
color: inherit;
cursor: pointer;
display: flex;
gap: 12px;
min-width: 0;
padding: 14px;
text-align: left;
transition:
border-color 180ms ease,
box-shadow 180ms ease,
transform 180ms ease;
}
.nav-tile:hover,
.runtime-row:hover {
border-color: var(--el-color-primary-light-5);
box-shadow: 0 8px 24px rgb(15 23 42 / 8%);
transform: translateY(-1px);
}
.tile-icon,
.runtime-icon {
align-items: center;
border-radius: 8px;
display: inline-flex;
flex-shrink: 0;
height: 38px;
justify-content: center;
width: 38px;
}
.tile-blue {
background: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
.tile-cyan {
background: var(--el-color-info-light-9);
color: var(--el-color-info);
}
.tile-green {
background: var(--el-color-success-light-9);
color: var(--el-color-success);
}
.tile-orange {
background: var(--el-color-warning-light-9);
color: var(--el-color-warning);
}
.tile-violet {
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
}
.tile-copy,
.runtime-copy {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.tile-copy strong,
.runtime-copy strong {
color: var(--el-text-color-primary);
font-size: 14px;
line-height: 1.25;
}
.tile-copy span,
.runtime-copy span {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.45;
}
.runtime-list {
display: grid;
gap: 10px;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.runtime-icon {
background: var(--el-fill-color-light);
color: var(--el-text-color-regular);
}
@media (max-width: 1180px) {
.metric-grid,
.runtime-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.workspace-hero,
.hero-actions {
align-items: stretch;
flex-direction: column;
}
.content-grid,
.metric-grid,
.nav-grid,
.runtime-list {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { Card, CardContent, CardHeader, CardTitle } from '@vben-core/shadcn-ui';
interface Props {
title: string;
}
defineOptions({
name: 'AnalysisChartCard',
});
withDefaults(defineProps<Props>(), {});
</script>
<template>
<Card>
<CardHeader>
<CardTitle class="text-xl">{{ title }}</CardTitle>
</CardHeader>
<CardContent>
<slot></slot>
</CardContent>
</Card>
</template>
@@ -0,0 +1,40 @@
<script setup lang="ts">
import type { TabOption } from '@vben/types';
import { computed } from 'vue';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@vben-core/shadcn-ui';
interface Props {
tabs?: TabOption[];
}
defineOptions({
name: 'AnalysisChartsTabs',
});
const props = withDefaults(defineProps<Props>(), {
tabs: () => [],
});
const defaultValue = computed(() => {
return props.tabs?.[0]?.value;
});
</script>
<template>
<div class="card-box w-full px-4 pb-5 pt-3">
<Tabs :default-value="defaultValue">
<TabsList>
<template v-for="tab in tabs" :key="tab.label">
<TabsTrigger :value="tab.value"> {{ tab.label }} </TabsTrigger>
</template>
</TabsList>
<template v-for="tab in tabs" :key="tab.label">
<TabsContent :value="tab.value" class="pt-4">
<slot :name="tab.value"></slot>
</TabsContent>
</template>
</Tabs>
</div>
</template>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import type { AnalysisOverviewItem } from '../typing';
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
VbenCountToAnimator,
VbenIcon,
} from '@vben-core/shadcn-ui';
interface Props {
items?: AnalysisOverviewItem[];
}
defineOptions({
name: 'AnalysisOverview',
});
withDefaults(defineProps<Props>(), {
items: () => [],
});
</script>
<template>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<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
:end-val="item.value"
:start-val="1"
class="text-xl"
prefix=""
/>
<VbenIcon :icon="item.icon" class="size-8 flex-shrink-0" />
</CardContent>
<CardFooter class="justify-between">
<span>{{ item.totalTitle }}</span>
<VbenCountToAnimator
:end-val="item.totalValue"
:start-val="1"
prefix=""
/>
</CardFooter>
</Card>
</template>
</div>
</template>
@@ -0,0 +1,3 @@
export { default as AnalysisChartCard } from './analysis-chart-card.vue';
export { default as AnalysisChartsTabs } from './analysis-charts-tabs.vue';
export { default as AnalysisOverview } from './analysis-overview.vue';
@@ -0,0 +1,3 @@
export * from './analysis';
export type * from './typing';
export * from './workbench';
@@ -0,0 +1,48 @@
import type { Component } from 'vue';
interface AnalysisOverviewItem {
icon: Component | string;
title: string;
totalTitle: string;
totalValue: number;
value: number;
}
interface WorkbenchProjectItem {
color?: string;
content: string;
date: string;
group: string;
icon: Component | string;
title: string;
url?: string;
}
interface WorkbenchTrendItem {
avatar: string;
content: string;
date: string;
title: string;
}
interface WorkbenchTodoItem {
completed: boolean;
content: string;
date: string;
title: string;
}
interface WorkbenchQuickNavItem {
color?: string;
icon: Component | string;
title: string;
url?: string;
}
export type {
AnalysisOverviewItem,
WorkbenchProjectItem,
WorkbenchQuickNavItem,
WorkbenchTodoItem,
WorkbenchTrendItem,
};
@@ -0,0 +1,5 @@
export { default as WorkbenchHeader } from './workbench-header.vue';
export { default as WorkbenchProject } from './workbench-project.vue';
export { default as WorkbenchQuickNav } from './workbench-quick-nav.vue';
export { default as WorkbenchTodo } from './workbench-todo.vue';
export { default as WorkbenchTrends } from './workbench-trends.vue';
@@ -0,0 +1,67 @@
<script lang="ts" setup>
import { VbenAvatar } from '@vben-core/shadcn-ui';
interface Props {
avatar?: string;
stats?: Array<{
label: string;
value: string;
}>;
}
defineOptions({
name: 'WorkbenchHeader',
});
withDefaults(defineProps<Props>(), {
avatar: '',
stats: () => [
{ label: '待办', value: '2/10' },
{ label: '项目', value: '8' },
{ label: '团队', value: '300' },
],
});
</script>
<template>
<div class="card-box p-4 py-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center">
<div class="flex min-w-0 flex-1 flex-col gap-4 md:flex-row md:items-center">
<VbenAvatar :src="avatar" class="size-20 flex-shrink-0" />
<div
v-if="$slots.title || $slots.description"
class="flex min-w-0 flex-col justify-center"
>
<h1
v-if="$slots.title"
class="text-md text-foreground font-semibold leading-7 md:text-xl"
>
<slot name="title"></slot>
</h1>
<span
v-if="$slots.description"
class="text-foreground/80 mt-1 leading-6"
>
<slot name="description"></slot>
</span>
</div>
</div>
<div
class="grid w-full grid-cols-3 gap-3 text-right lg:w-auto lg:min-w-80"
>
<div
v-for="item in stats"
:key="item.label"
class="min-w-0 rounded-md px-2 py-1"
>
<span class="text-foreground/80 block truncate text-sm">
{{ item.label }}
</span>
<span class="text-foreground block truncate text-2xl">
{{ item.value }}
</span>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import type { WorkbenchProjectItem } from '../typing';
import {
Card,
CardContent,
CardHeader,
CardTitle,
VbenIcon,
} from '@vben-core/shadcn-ui';
interface Props {
items?: WorkbenchProjectItem[];
title: string;
}
defineOptions({
name: 'WorkbenchProject',
});
withDefaults(defineProps<Props>(), {
items: () => [],
});
defineEmits(['click']);
</script>
<template>
<Card>
<CardHeader class="py-4">
<CardTitle class="text-lg">{{ title }}</CardTitle>
</CardHeader>
<CardContent class="grid grid-cols-1 p-0 md:grid-cols-2">
<template v-for="(item, index) in items" :key="item.title">
<div
:class="{
'md:border-r': index % 2 === 0,
'rounded-bl-xl': index === items.length - 2,
'rounded-br-xl': index === items.length - 1,
}"
class="border-border group min-w-0 cursor-pointer border-t p-4 transition-all hover:shadow-xl"
@click="$emit('click', item)"
>
<div class="flex items-center">
<VbenIcon
:color="item.color"
:icon="item.icon"
class="size-8 transition-all duration-300 group-hover:scale-110"
/>
<span class="ml-4 text-lg font-medium">{{ item.title }}</span>
</div>
<div class="text-foreground/80 mt-4 flex min-h-12 leading-6">
{{ item.content }}
</div>
<div class="text-foreground/80 flex justify-between">
<span>{{ item.group }}</span>
<span>{{ item.date }}</span>
</div>
</div>
</template>
</CardContent>
</Card>
</template>
@@ -0,0 +1,56 @@
<script setup lang="ts">
import type { WorkbenchQuickNavItem } from '../typing';
import {
Card,
CardContent,
CardHeader,
CardTitle,
VbenIcon,
} from '@vben-core/shadcn-ui';
interface Props {
items?: WorkbenchQuickNavItem[];
title: string;
}
defineOptions({
name: 'WorkbenchQuickNav',
});
withDefaults(defineProps<Props>(), {
items: () => [],
});
defineEmits(['click']);
</script>
<template>
<Card>
<CardHeader class="py-4">
<CardTitle class="text-lg">{{ title }}</CardTitle>
</CardHeader>
<CardContent class="flex flex-wrap p-0">
<template v-for="(item, index) in items" :key="item.title">
<div
:class="{
'border-r-0': index % 3 === 2,
'border-b-0': index < 3,
'pb-4': index > 2,
'rounded-bl-xl': index === items.length - 3,
'rounded-br-xl': index === items.length - 1,
}"
class="flex-col-center border-border group w-1/3 cursor-pointer border-r border-t py-8 hover:shadow-xl"
@click="$emit('click', item)"
>
<VbenIcon
:color="item.color"
:icon="item.icon"
class="size-7 transition-all duration-300 group-hover:scale-125"
/>
<span class="text-md mt-2 truncate">{{ item.title }}</span>
</div>
</template>
</CardContent>
</Card>
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import type { WorkbenchTodoItem } from '../typing';
import {
Card,
CardContent,
CardHeader,
CardTitle,
VbenCheckbox,
} from '@vben-core/shadcn-ui';
interface Props {
items?: WorkbenchTodoItem[];
title: string;
}
defineOptions({
name: 'WorkbenchTodo',
});
withDefaults(defineProps<Props>(), {
items: () => [],
});
</script>
<template>
<Card>
<CardHeader class="py-4">
<CardTitle class="text-lg">{{ title }}</CardTitle>
</CardHeader>
<CardContent class="flex flex-wrap p-5 pt-0">
<ul class="divide-border w-full divide-y" role="list">
<li
v-for="item in items"
:key="item.title"
:class="{
'select-none line-through opacity-60': item.completed,
}"
class="flex cursor-pointer justify-between gap-x-6 py-5"
>
<div class="flex min-w-0 items-center gap-x-4">
<VbenCheckbox v-model:checked="item.completed" name="completed" />
<div class="min-w-0 flex-auto">
<p class="text-foreground text-sm font-semibold leading-6">
{{ item.title }}
</p>
<!-- eslint-disable vue/no-v-html -->
<p
class="text-foreground/80 *:text-primary mt-1 truncate text-xs leading-5"
v-html="item.content"
></p>
</div>
</div>
<div class="hidden h-full shrink-0 sm:flex sm:flex-col sm:items-end">
<span class="text-foreground/80 mt-6 text-xs leading-6">
{{ item.date }}
</span>
</div>
</li>
</ul>
</CardContent>
</Card>
</template>
@@ -0,0 +1,64 @@
<script setup lang="ts">
import type { WorkbenchTrendItem } from '../typing';
import {
Card,
CardContent,
CardHeader,
CardTitle,
VbenIcon,
} from '@vben-core/shadcn-ui';
interface Props {
items?: WorkbenchTrendItem[];
title: string;
}
defineOptions({
name: 'WorkbenchTrends',
});
withDefaults(defineProps<Props>(), {
items: () => [],
});
</script>
<template>
<Card>
<CardHeader class="py-4">
<CardTitle class="text-lg">{{ title }}</CardTitle>
</CardHeader>
<CardContent class="flex flex-wrap p-5 pt-0">
<ul class="divide-border w-full divide-y" role="list">
<li
v-for="item in items"
:key="item.title"
class="flex justify-between gap-x-6 py-5"
>
<div class="flex min-w-0 items-center gap-x-4">
<VbenIcon
:icon="item.avatar"
alt=""
class="size-10 flex-none rounded-full"
/>
<div class="min-w-0 flex-auto">
<p class="text-foreground text-sm font-semibold leading-6">
{{ item.title }}
</p>
<!-- eslint-disable vue/no-v-html -->
<p
class="text-foreground/80 *:text-primary mt-1 truncate text-xs leading-5"
v-html="item.content"
></p>
</div>
</div>
<div class="hidden h-full shrink-0 sm:flex sm:flex-col sm:items-end">
<span class="text-foreground/80 mt-6 text-xs leading-6">
{{ item.date }}
</span>
</div>
</li>
</ul>
</CardContent>
</Card>
</template>
@@ -1,3 +1,4 @@
export * from './about'; export * from './about';
export * from './authentication'; export * from './authentication';
export * from './dashboard';
export * from './fallback'; export * from './fallback';