refactor(views): 重组页面目录并调整路由引用

- 页面按功能归入 dashboard、login、workspace、system、demo、internal
- 数据大屏移入示例目录,错误页、内嵌页与刷新中转移入 internal
- 个人中心与我的通知移入 workspace,统一认证回调移入 login
- 应用中心移入 workspace,菜单组件值 iframe 解析到 internal/iframe
- 在线人数函数更名,未收到推送时显示占位符
This commit is contained in:
Ray.Hao
2026-10-22 21:50:43 +08:00
parent 4e7ac8c08e
commit 9cddb95fb7
54 changed files with 783 additions and 95 deletions

View File

@@ -20,7 +20,7 @@
import { type RouteLocationNormalized } from "vue-router";
import { useSettingsStore, useTagsViewStore } from "@/stores";
import variables from "@/styles/variables.module.scss";
import Error404 from "@/views/error/404.vue";
import Error404 from "@/views/internal/error/404.vue";
const { cachedViews } = toRefs(useTagsViewStore());

View File

@@ -1,5 +1,6 @@
import type { App } from "vue";
import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router";
import { ensureFormCreate } from "@/plugins/form-create";
/**
* 布局组件,供各路由复用(懒加载)
@@ -13,7 +14,7 @@ export const screenRoutes: RouteRecordRaw[] = [
{
path: "/data-screen",
name: "DataScreen",
component: () => import("@/views/data-screen/index.vue"),
component: () => import("@/views/demo/data-screen/index.vue"),
meta: { hidden: true, title: "数据大屏" },
},
];
@@ -27,7 +28,7 @@ export const constantRoutes: RouteRecordRaw[] = [
children: [
{
path: "/redirect/:path(.*)",
component: () => import("@/views/redirect.vue"),
component: () => import("@/views/internal/redirect.vue"),
},
],
},
@@ -38,11 +39,22 @@ export const constantRoutes: RouteRecordRaw[] = [
meta: { hidden: true },
},
// 统一认证回调页(匿名访问,守卫白名单,只用票据换取业务令牌)
{
path: "/oidc/callback",
name: "OidcCallback",
component: () => import("@/views/login/oidc-callback.vue"),
meta: { hidden: true },
},
// 公开表单分享页(匿名访问,守卫白名单,不套管理端 Layout)
{
path: "/f/:formKey",
name: "FormShare",
component: () => import("@/views/dynamic-form/share.vue"),
component: async () => {
await ensureFormCreate();
return import("@/views/dynamic-form/share.vue");
},
meta: { hidden: true, title: "表单填写" },
},
@@ -70,24 +82,24 @@ export const constantRoutes: RouteRecordRaw[] = [
},
{
path: "401",
component: () => import("@/views/error/401.vue"),
component: () => import("@/views/internal/error/401.vue"),
meta: { hidden: true },
},
{
path: "404",
component: () => import("@/views/error/404.vue"),
component: () => import("@/views/internal/error/404.vue"),
meta: { hidden: true },
},
{
path: "profile",
name: "Profile",
component: () => import("@/views/profile/index.vue"),
component: () => import("@/views/workspace/account/index.vue"),
meta: { title: "个人中心", icon: "user", hidden: true },
},
{
path: "profile/notice",
name: "MyNotice",
component: () => import("@/views/profile/notice/index.vue"),
component: () => import("@/views/workspace/notifications/index.vue"),
meta: { title: "我的通知", icon: "user", hidden: true },
},
{

View File

@@ -2,6 +2,8 @@
// 维护「菜单 component 字符串 → src/views 视图文件」的映射,路由注册与菜单表单共用
// 表单的「页面路径」选项树也由此派生:跳过错误页与页面内部组件,index.vue 归并到目录
import { ensureFormCreate } from "@/plugins/form-create";
/**
* 全部视图模块,键形如 /src/views/system/user/index.vue
*/
@@ -26,16 +28,36 @@ export function normalizeViewPath(componentPath: string): string {
.replace(/\.vue$/i, "");
}
// 内嵌外链菜单的组件标识固定为 iframe,视图文件在 internal 下
const VIEW_ALIASES: Record<string, string> = { iframe: "internal/iframe" };
/**
* 把菜单里保存的组件标识映射到实际视图路径
*/
function resolveViewAlias(path: string): string {
return VIEW_ALIASES[path] ?? path;
}
// 用到 form-create 的视图:动态表单模块本身,以及内嵌表单的业务流程页
const FORM_VIEW_PATTERN = /^(dynamic-form|workflow)(\/|$)/;
/**
* 解析组件:支持 system/user 与 system/user/index 两种写法,未命中回退 404
*/
export function resolveComponent(componentPath: string) {
const path = normalizeViewPath(componentPath);
return (
const path = resolveViewAlias(normalizeViewPath(componentPath));
const loader =
viewModules[`${VIEW_PREFIX}${path}.vue`] ||
viewModules[`${VIEW_PREFIX}${path}/index.vue`] ||
viewModules[`${VIEW_PREFIX}error/404.vue`]
);
viewModules[`${VIEW_PREFIX}internal/error/404.vue`];
if (!FORM_VIEW_PATTERN.test(path)) return loader;
// 表单页的 <form-create> 依赖全局注册,组件加载前先完成注册
return async () => {
await ensureFormCreate();
return loader();
};
}
/**
@@ -43,7 +65,7 @@ export function resolveComponent(componentPath: string) {
*/
export function isViewComponent(componentPath?: string): boolean {
if (!componentPath) return false;
const path = normalizeViewPath(componentPath);
const path = resolveViewAlias(normalizeViewPath(componentPath));
return viewPaths.has(path) || viewPaths.has(`${path}/index`);
}
@@ -64,7 +86,7 @@ export interface PageComponentNode {
function isSelectable(path: string): boolean {
const segments = path.split("/");
if (segments.includes("components")) return false;
return segments[0] !== "error" && path !== "redirect";
return segments[0] !== "internal";
}
/**

View File

@@ -3,21 +3,15 @@ import { useSse } from "@/utils/sse";
import { SseTopics } from "@/enums/sse";
/**
* 在线用户数组合式函数(页面级)
*
* 订阅 SSE 在线用户数事件,组件挂载时订阅、卸载时自动取消;
* 进入页面后需等待下一次推送才有数据
* 订阅在线用户数推送,首次推送前人数为 null
*/
export function useOnlineUsers() {
const onlineUserCount = ref(0);
export function useOnlineUserCount() {
const onlineUserCount = ref<number | null>(null);
const { isConnected, on } = useSse();
let unsubscribe: (() => void) | null = null;
/**
* 处理 SSE 推来的在线人数消息
*/
const handleOnlineUsersMessage = (count: number) => {
if (!Number.isFinite(count) || count < 0) return;
onlineUserCount.value = count;
@@ -30,7 +24,7 @@ export function useOnlineUsers() {
onUnmounted(() => {
unsubscribe?.();
unsubscribe = null;
onlineUserCount.value = 0;
onlineUserCount.value = null;
});
return {

View File

@@ -69,7 +69,7 @@
</div>
<div class="stat-card__body">
<span class="stat-card__label">在线用户</span>
<span class="stat-card__num">{{ onlineUserCount }}</span>
<span class="stat-card__num">{{ onlineUserCount ?? "--" }}</span>
</div>
<span
:class="[
@@ -261,11 +261,11 @@ import {
Document,
VideoPlay,
} from "@element-plus/icons-vue";
import { useOnlineUsers } from "./composables/useOnlineUsers";
import { useOnlineUserCount } from "./composables/useOnlineUserCount";
const userStore = useUserStore();
const settingsStore = useSettingsStore();
const { onlineUserCount, isConnected } = useOnlineUsers();
const { onlineUserCount, isConnected } = useOnlineUserCount();
const hours = new Date().getHours();
const greetings = computed(() => {

View File

@@ -1,56 +0,0 @@
/**
* 时间格式化:按 pattern 里的占位符替换为对应时间片段
*
* 支持占位符:YYYY/YY/MM/M/DD/D/HH/H/hh/h/mm/m/ss/s/SSS(A 为上午下午,dd 为星期,d 为星期序号)
*
* @param time 时间对象、时间戳或时间字符串
* @param pattern 输出格式,默认 "YYYY-MM-DD HH:mm:ss"
*/
export function parseTime(time: Date | string | number, pattern = "YYYY-MM-DD HH:mm:ss"): string {
let date: Date;
if (time instanceof Date) {
date = time;
} else if (typeof time === "number") {
// 秒级时间戳补成毫秒
date = time.toString().length < 13 ? new Date(time * 1000) : new Date(time);
} else {
date = new Date(time);
}
const weekdays = ["日", "一", "二", "三", "四", "五", "六"];
const year = date.getFullYear();
const month = date.getMonth() + 1;
const day = date.getDate();
const hours = date.getHours();
const minutes = date.getMinutes();
const seconds = date.getSeconds();
const milliseconds = date.getMilliseconds();
const hours12 = hours % 12 || 12;
const formatMap: Record<string, string> = {
YYYY: String(year),
YY: String(year).slice(-2),
MM: String(month).padStart(2, "0"),
M: String(month),
DD: String(day).padStart(2, "0"),
D: String(day),
HH: String(hours).padStart(2, "0"),
H: String(hours),
hh: String(hours12).padStart(2, "0"),
h: String(hours12),
mm: String(minutes).padStart(2, "0"),
m: String(minutes),
ss: String(seconds).padStart(2, "0"),
s: String(seconds),
SSS: String(milliseconds).padStart(3, "0"),
A: hours < 12 ? "上午" : "下午",
dd: weekdays[date.getDay()],
d: String(date.getDay()),
};
return pattern.replace(
/YYYY|YY|MM|M|DD|D|HH|H|hh|h|mm|m|ss|s|SSS|A|dd|d/g,
(match) => formatMap[match] || match
);
}

View File

Before

Width:  |  Height:  |  Size: 809 KiB

After

Width:  |  Height:  |  Size: 809 KiB

View File

Before

Width:  |  Height:  |  Size: 5.6 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

View File

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -21,14 +21,25 @@
</template>
<script lang="ts" setup>
import { useKeepTicking } from "../composables/useKeepTicking";
import { parseTime } from "../utils/time";
defineOptions({ name: "Cephalosome" });
// 看板时间格式:YYYY-MM-DD HH:mm:ss 周X 上午/下午
function formatClock(date: Date): string {
const week = ["日", "一", "二", "三", "四", "五", "六"][date.getDay()];
const pad = (n: number) => String(n).padStart(2, "0");
const meridiem = date.getHours() < 12 ? "上午" : "下午";
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())} ` +
`周${week} ${meridiem}`
);
}
// 获取当前时间
const dateTime = ref("加载中...");
const getCurrentTime = () =>
(dateTime.value = parseTime(new Date(), "YYYY-MM-DD HH:mm:ss 周dd A "));
const getCurrentTime = () => (dateTime.value = formatClock(new Date()));
// 使用定时器钩子更新时间
useKeepTicking(getCurrentTime, 1000);

View File

@@ -124,16 +124,16 @@ defineOptions({
import UserAPI from "@/api/system/user";
import type { IObject, IOperateData, CrudModalInstance } from "@/components/Crud/types";
import useCrudPage from "@/components/Crud/useCrudPage";
import addModalConfig from "./config/add";
import contentConfig from "./config/content";
import editModalConfig from "./config/edit";
import searchConfig from "./config/search";
import { initOptions } from "./config/options";
import addModalConfig from "./config/user/add";
import contentConfig from "./config/user/content";
import editModalConfig from "./config/user/edit";
import searchConfig from "./config/user/search";
import { initOptions } from "./config/user/options";
import addModalConfig2 from "./config2/add";
import contentConfig2 from "./config2/content";
import editModalConfig2 from "./config2/edit";
import searchConfig2 from "./config2/search";
import addModalConfig2 from "./config/mock/add";
import contentConfig2 from "./config/mock/content";
import editModalConfig2 from "./config/mock/edit";
import searchConfig2 from "./config/mock/search";
const {
searchRef,

View File

@@ -0,0 +1,315 @@
<!-- 统一认证回调(技术页:换票据或展示拒绝原因) -->
<template>
<div class="oidc-callback">
<div class="oidc-callback__card">
<template v-if="!hasError">
<div class="oidc-callback__icon">
<el-icon class="is-loading" :size="26"><Loading /></el-icon>
</div>
<h2 class="oidc-callback__title">正在完成统一认证登录</h2>
<p class="oidc-callback__desc">正在核对身份并建立业务会话,请稍候…</p>
</template>
<template v-else>
<div class="oidc-callback__icon" :class="`is-${errorView.level}`">
<el-icon :size="26"><component :is="errorView.icon" /></el-icon>
</div>
<h2 class="oidc-callback__title">{{ errorView.title }}</h2>
<p class="oidc-callback__desc">{{ errorView.message }}</p>
<div v-if="subjectTip" class="oidc-callback__subject">
<div class="oidc-callback__subject-head">
<span class="oidc-callback__subject-label">认证标识 subject</span>
<el-button type="primary" link @click="handleCopySubject">
<el-icon><CopyDocument /></el-icon>
<span class="ml-4px">复制</span>
</el-button>
</div>
<code class="oidc-callback__subject-code">{{ subjectTip }}</code>
<p class="oidc-callback__subject-tip">
把上面这串发给管理员,管理员在「用户管理 → 账号绑定」里录入后即可登录
</p>
</div>
<div class="oidc-callback__actions">
<el-button type="primary" size="large" @click="handleRetry">重新发起统一认证</el-button>
<el-button size="large" @click="router.replace('/login')">返回登录</el-button>
</div>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { ElMessage } from "element-plus";
import { Loading } from "@element-plus/icons-vue";
import AuthAPI from "@/api/auth";
import { AuthStorage } from "@/utils/auth";
import router from "@/router";
defineOptions({ name: "OidcCallbackPage" });
// 与登录页「统一认证」入口保持一致
const OIDC_LOGIN_URL = "/oauth2/authorization/youlai";
type ErrorView = {
/** 卡片标题 */
title: string;
/** 说明文案 */
message: string;
/** 图标底色语义 */
level: "danger" | "warning" | "info";
/** Element Plus 图标组件名 */
icon: string;
};
// 服务端拒绝统一认证登录时回传的错误码对应的展示
const ERROR_VIEWS: Record<string, ErrorView> = {
A0301: {
title: "没有该应用的访问权限",
message: "该账号不在准入组内,请联系管理员分配权限后重试",
level: "warning",
icon: "Lock",
},
A0262: {
title: "尚未开通业务账号",
message: "该统一认证账号还没有绑定业务账号,绑定后即可登录",
level: "danger",
icon: "CircleCloseFilled",
},
A0260: {
title: "登录票据无效或已过期",
message: "请重新发起统一认证",
level: "info",
icon: "Clock",
},
A0261: {
title: "登录票据已被使用",
message: "请重新发起统一认证",
level: "info",
icon: "Clock",
},
A0263: {
title: "统一认证状态核查不可用",
message: "认证中心暂时无法完成状态核查,请稍后重试",
level: "warning",
icon: "WarningFilled",
},
};
const DEFAULT_ERROR_VIEW: ErrorView = {
title: "统一认证登录失败",
message: "请重试,或返回登录页改用账号密码登录",
level: "danger",
icon: "CircleCloseFilled",
};
const errorCode = ref("");
// 前端自身产生的错误(缺票据、兑换失败),优先于错误码展示
const localError = ref("");
// 账号未绑定时服务端回传的认证标识,供用户转发给管理员
const subjectTip = ref("");
const hasError = computed(() => Boolean(localError.value || errorCode.value));
const errorView = computed<ErrorView>(() => {
if (localError.value) {
return { ...DEFAULT_ERROR_VIEW, message: localError.value };
}
return ERROR_VIEWS[errorCode.value] ?? DEFAULT_ERROR_VIEW;
});
const { copy } = useClipboard();
async function handleCopySubject(): Promise<void> {
await copy(subjectTip.value);
ElMessage.success("已复制");
}
// 重新发起授权码流程,Keycloak 已有会话时会静默回到本页
function handleRetry(): void {
window.location.href = OIDC_LOGIN_URL;
}
/**
* 取回调参数,同时兼容服务端把参数放在 hash 查询或普通查询两种形态
*/
function takeParam(name: string): string | undefined {
const fromRoute = router.currentRoute.value.query[name];
if (typeof fromRoute === "string" && fromRoute) {
return fromRoute;
}
return new URLSearchParams(window.location.search).get(name) ?? undefined;
}
/**
* 用票据换取业务令牌,后续由路由守卫加载用户信息与动态路由
*/
async function completeLogin(): Promise<void> {
const ticket = takeParam("ticket");
const code = takeParam("error");
const subject = takeParam("subject");
// 票据与错误码只用于兑换,取到后立即从地址栏移除
await router.replace({ path: "/oidc/callback" });
if (code) {
errorCode.value = code;
if (code === "A0262" && subject) {
subjectTip.value = subject;
}
return;
}
if (!ticket) {
localError.value = "缺少登录票据,请返回登录页重新发起统一认证";
return;
}
try {
const { accessToken, refreshToken } = await AuthAPI.oidcLogin(ticket);
AuthStorage.setTokens(accessToken, refreshToken, false);
await router.replace("/");
} catch (error) {
localError.value = error instanceof Error ? error.message : "统一认证登录失败,请重试";
}
}
onMounted(completeLogin);
</script>
<style scoped lang="scss">
.oidc-callback {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 24px;
// 主题色浅渐变,切换主题色板时整页跟随
background: linear-gradient(
180deg,
var(--el-bg-color) 0%,
color-mix(in srgb, var(--el-color-primary) 12%, var(--el-bg-color)) 100%
);
&__card {
box-sizing: border-box;
width: 100%;
max-width: 460px;
padding: 40px 36px;
text-align: center;
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-lighter);
border-radius: 16px;
box-shadow: 0 18px 40px -12px rgb(15 23 42 / 18%);
animation: oidc-card-in 0.32s ease-out both;
}
&__icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
margin-bottom: 18px;
color: var(--el-color-primary);
background: color-mix(in srgb, var(--el-color-primary) 12%, transparent);
border-radius: 50%;
&.is-danger {
color: var(--el-color-danger);
background: color-mix(in srgb, var(--el-color-danger) 12%, transparent);
}
&.is-warning {
color: var(--el-color-warning);
background: color-mix(in srgb, var(--el-color-warning) 14%, transparent);
}
&.is-info {
color: var(--el-color-info);
background: color-mix(in srgb, var(--el-color-info) 12%, transparent);
}
}
&__title {
margin: 0;
font-size: 20px;
font-weight: 600;
line-height: 1.4;
color: var(--el-text-color-primary);
}
&__desc {
margin: 10px 0 0;
font-size: 14px;
line-height: 1.7;
color: var(--el-text-color-secondary);
}
&__subject {
padding: 14px 16px;
margin-top: 24px;
text-align: left;
background: color-mix(in srgb, var(--el-color-primary) 5%, transparent);
border: 1px dashed color-mix(in srgb, var(--el-color-primary) 32%, transparent);
border-radius: 12px;
}
&__subject-head {
display: flex;
align-items: center;
justify-content: space-between;
}
&__subject-label {
font-size: 12px;
font-weight: 600;
color: var(--el-text-color-secondary);
letter-spacing: 0.04em;
}
&__subject-code {
display: block;
margin: 6px 0 10px;
font-family: ui-monospace, sfmono-regular, menlo, consolas, monospace;
font-size: 15px;
line-height: 1.6;
color: var(--el-text-color-primary);
word-break: break-all;
}
&__subject-tip {
margin: 0;
font-size: 12px;
line-height: 1.6;
color: var(--el-text-color-placeholder);
}
&__actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
margin-top: 28px;
:deep(.el-button + .el-button) {
margin-left: 0;
}
}
}
@keyframes oidc-card-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
</style>

View File

@@ -0,0 +1,390 @@
<!-- 应用中心 -->
<template>
<div class="page-container">
<el-card class="page-content" shadow="never">
<section v-for="group in appGroups" :key="group.category.key" class="app-section">
<header class="app-section__header">
<div class="app-section__title">
<span class="app-section__name">{{ group.category.name }}</span>
<span class="app-section__count">{{ group.apps.length }}</span>
</div>
<p class="app-section__desc">{{ group.category.description }}</p>
</header>
<div class="app-grid">
<div
v-for="app in group.apps"
:key="app.name"
class="app-card"
:class="[`app-card--${app.theme}`, { 'is-disabled': !app.ssoReady }]"
@click="handleAppClick(app)"
>
<div class="app-card__top">
<div class="app-card__icon">
<el-icon :size="26">
<component :is="app.icon" />
</el-icon>
</div>
<el-tag v-if="!app.ssoReady" type="info" size="small" effect="plain">即将接入</el-tag>
</div>
<div class="app-card__name">{{ app.name }}</div>
<div class="app-card__desc">{{ app.description }}</div>
</div>
</div>
</section>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { useRouter } from "vue-router";
defineOptions({ name: "AppCenter" });
/** 应用分类标识 */
type CategoryKey = "business" | "operation" | "smart";
/** 应用分类 */
interface AppCategory {
key: CategoryKey;
name: string;
description: string;
}
/** 应用卡片 */
interface AppCard {
/** 应用名称 */
name: string;
/** 一句话说明 */
description: string;
/** 入口地址:站内路径走路由跳转,http(s) 地址新标签打开 */
url: string;
/** Element Plus 图标组件名 */
icon: string;
/** 卡片主题色,对应 Element Plus 的状态色 */
theme: "primary" | "success" | "warning" | "info";
/** 是否已接入统一认证:未接入的卡片置灰不可跳转 */
ssoReady: boolean;
/** 所属分类 */
category: CategoryKey;
}
/** 分类清单,数组顺序即页面分组顺序 */
const categories: AppCategory[] = [
{
key: "business",
name: "业务系统",
description: "已上线的系统,账号与权限由认证中心下发",
},
{
key: "operation",
name: "经营管理",
description: "设备、资产、进销存等经营场景的系统",
},
{
key: "smart",
name: "智能与多端",
description: "智能助手与移动端、桌面端客户端",
},
];
/** 应用清单:接入统一认证后把 ssoReady 改为 true 并补上 url */
const apps: AppCard[] = [
{
name: "youlai-admin",
description: "Vue3 + Element Plus + 多语言中后台管理系统",
url: "/",
icon: "Monitor",
theme: "primary",
ssoReady: true,
category: "business",
},
{
name: "youlai-mall",
description: "Spring Cloud 微服务商城,覆盖商品、订单与支付",
url: "",
icon: "ShoppingCart",
theme: "success",
ssoReady: false,
category: "business",
},
{
name: "youlai-cms",
description: "内容管理系统,模型与字段可自定义,管理栏目、标签和媒体素材",
url: "",
icon: "Document",
theme: "warning",
ssoReady: false,
category: "business",
},
{
name: "设备管理",
description: "设备台账、点检保养与维修记录,备件出入库联动",
url: "",
icon: "Setting",
theme: "primary",
ssoReady: false,
category: "operation",
},
{
name: "资产管理",
description: "资产入库、领用调拨、折旧计提与定期盘点",
url: "",
icon: "Box",
theme: "success",
ssoReady: false,
category: "operation",
},
{
name: "进销存",
description: "采购、销售与库存管理,支持调拨和库存预警",
url: "",
icon: "Goods",
theme: "warning",
ssoReady: false,
category: "operation",
},
{
name: "客户关系",
description: "客户档案、商机跟进与合同回款",
url: "",
icon: "User",
theme: "info",
ssoReady: false,
category: "operation",
},
{
name: "工单中心",
description: "报修派单、处理时效与满意度回访",
url: "",
icon: "Tickets",
theme: "primary",
ssoReady: false,
category: "operation",
},
{
name: "项目管理",
description: "立项、任务分解、进度跟踪与工时统计",
url: "",
icon: "Files",
theme: "success",
ssoReady: false,
category: "operation",
},
{
name: "youlai-ai",
description: "基于 Spring AI Alibaba,覆盖 RAG、工作流与多智能体",
url: "",
icon: "MagicStick",
theme: "info",
ssoReady: false,
category: "smart",
},
{
name: "youlai-app",
description: "uni-app 跨平台移动端,出差在外也能处理待办",
url: "",
icon: "Cellphone",
theme: "success",
ssoReady: false,
category: "smart",
},
{
name: "youlai-electron",
description: "Electron 桌面客户端,离线也能打开常用功能",
url: "",
icon: "Platform",
theme: "info",
ssoReady: false,
category: "smart",
},
];
const router = useRouter();
const appGroups = categories
.map((category) => ({ category, apps: apps.filter((app) => app.category === category.key) }))
.filter((group) => group.apps.length > 0);
// 站内路径走路由跳转,外部地址新标签打开
function handleAppClick(app: AppCard): void {
if (!app.ssoReady) {
ElMessage.info(`${app.name} 尚未接入统一认证`);
return;
}
if (/^https?:\/\//.test(app.url)) {
window.open(app.url, "_blank", "noopener");
return;
}
router.push(app.url);
}
</script>
<style scoped lang="scss">
.app-section + .app-section {
padding-top: 26px;
margin-top: 26px;
border-top: 1px dashed var(--el-border-color-lighter);
}
.app-section__header {
margin-bottom: 16px;
}
.app-section__title {
display: flex;
gap: 8px;
align-items: center;
}
.app-section__name {
position: relative;
padding-left: 10px;
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
&::before {
position: absolute;
top: 50%;
left: 0;
width: 3px;
height: 14px;
content: "";
background: var(--el-color-primary);
border-radius: 2px;
transform: translateY(-50%);
}
}
.app-section__count {
padding: 0 7px;
font-size: 12px;
line-height: 18px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-light);
border-radius: 9px;
}
.app-section__desc {
margin: 6px 0 0 10px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.app-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 16px;
}
.app-card {
position: relative;
display: flex;
flex-direction: column;
padding: 20px;
overflow: hidden;
cursor: pointer;
background: linear-gradient(180deg, var(--app-bg) 0%, var(--el-bg-color) 72%);
border: 1px solid var(--el-border-color-lighter);
border-radius: 12px;
transition:
border-color 0.2s,
box-shadow 0.2s,
transform 0.2s;
&::before {
position: absolute;
inset: 0 0 auto;
height: 3px;
content: "";
background: linear-gradient(90deg, var(--app-color), transparent);
}
&:hover {
border-color: var(--app-color);
box-shadow: 0 10px 24px rgb(0 0 0 / 8%);
transform: translateY(-3px);
}
&.is-disabled {
cursor: not-allowed;
.app-card__icon {
opacity: 0.85;
filter: grayscale(0.35);
}
&:hover {
border-color: var(--el-border-color-lighter);
box-shadow: none;
transform: none;
}
}
&__top {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
color: #fff;
background: linear-gradient(135deg, var(--app-color), var(--app-color-light));
border-radius: 14px;
box-shadow: 0 6px 16px var(--app-shadow);
}
&__name {
margin-top: 16px;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
}
&__desc {
display: -webkit-box;
margin-top: 8px;
overflow: hidden;
-webkit-line-clamp: 2;
font-size: 13px;
line-height: 1.6;
color: var(--el-text-color-secondary);
-webkit-box-orient: vertical;
}
}
.app-card--primary {
--app-color: var(--el-color-primary);
--app-color-light: var(--el-color-primary-light-3);
--app-bg: var(--el-color-primary-light-9);
--app-shadow: rgb(64 158 255 / 30%);
}
.app-card--success {
--app-color: var(--el-color-success);
--app-color-light: var(--el-color-success-light-3);
--app-bg: var(--el-color-success-light-9);
--app-shadow: rgb(103 194 58 / 30%);
}
.app-card--warning {
--app-color: var(--el-color-warning);
--app-color-light: var(--el-color-warning-light-3);
--app-bg: var(--el-color-warning-light-9);
--app-shadow: rgb(230 162 60 / 30%);
}
.app-card--info {
--app-color: var(--el-color-info);
--app-color-light: var(--el-color-info-light-3);
--app-bg: var(--el-color-info-light-9);
--app-shadow: rgb(144 147 153 / 30%);
}
</style>