refactor(views): 重组页面目录并调整路由引用
- 页面按功能归入 dashboard、login、workspace、system、demo、internal - 数据大屏移入示例目录,错误页、内嵌页与刷新中转移入 internal - 个人中心与我的通知移入 workspace,统一认证回调移入 login - 应用中心移入 workspace,菜单组件值 iframe 解析到 internal/iframe - 在线人数函数更名,未收到推送时显示占位符
@@ -20,7 +20,7 @@
|
|||||||
import { type RouteLocationNormalized } from "vue-router";
|
import { type RouteLocationNormalized } from "vue-router";
|
||||||
import { useSettingsStore, useTagsViewStore } from "@/stores";
|
import { useSettingsStore, useTagsViewStore } from "@/stores";
|
||||||
import variables from "@/styles/variables.module.scss";
|
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());
|
const { cachedViews } = toRefs(useTagsViewStore());
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { App } from "vue";
|
import type { App } from "vue";
|
||||||
import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router";
|
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",
|
path: "/data-screen",
|
||||||
name: "DataScreen",
|
name: "DataScreen",
|
||||||
component: () => import("@/views/data-screen/index.vue"),
|
component: () => import("@/views/demo/data-screen/index.vue"),
|
||||||
meta: { hidden: true, title: "数据大屏" },
|
meta: { hidden: true, title: "数据大屏" },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -27,7 +28,7 @@ export const constantRoutes: RouteRecordRaw[] = [
|
|||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: "/redirect/:path(.*)",
|
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 },
|
meta: { hidden: true },
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 统一认证回调页(匿名访问,守卫白名单,只用票据换取业务令牌)
|
||||||
|
{
|
||||||
|
path: "/oidc/callback",
|
||||||
|
name: "OidcCallback",
|
||||||
|
component: () => import("@/views/login/oidc-callback.vue"),
|
||||||
|
meta: { hidden: true },
|
||||||
|
},
|
||||||
|
|
||||||
// 公开表单分享页(匿名访问,守卫白名单,不套管理端 Layout)
|
// 公开表单分享页(匿名访问,守卫白名单,不套管理端 Layout)
|
||||||
{
|
{
|
||||||
path: "/f/:formKey",
|
path: "/f/:formKey",
|
||||||
name: "FormShare",
|
name: "FormShare",
|
||||||
component: () => import("@/views/dynamic-form/share.vue"),
|
component: async () => {
|
||||||
|
await ensureFormCreate();
|
||||||
|
return import("@/views/dynamic-form/share.vue");
|
||||||
|
},
|
||||||
meta: { hidden: true, title: "表单填写" },
|
meta: { hidden: true, title: "表单填写" },
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -70,24 +82,24 @@ export const constantRoutes: RouteRecordRaw[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "401",
|
path: "401",
|
||||||
component: () => import("@/views/error/401.vue"),
|
component: () => import("@/views/internal/error/401.vue"),
|
||||||
meta: { hidden: true },
|
meta: { hidden: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "404",
|
path: "404",
|
||||||
component: () => import("@/views/error/404.vue"),
|
component: () => import("@/views/internal/error/404.vue"),
|
||||||
meta: { hidden: true },
|
meta: { hidden: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "profile",
|
path: "profile",
|
||||||
name: "Profile",
|
name: "Profile",
|
||||||
component: () => import("@/views/profile/index.vue"),
|
component: () => import("@/views/workspace/account/index.vue"),
|
||||||
meta: { title: "个人中心", icon: "user", hidden: true },
|
meta: { title: "个人中心", icon: "user", hidden: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "profile/notice",
|
path: "profile/notice",
|
||||||
name: "MyNotice",
|
name: "MyNotice",
|
||||||
component: () => import("@/views/profile/notice/index.vue"),
|
component: () => import("@/views/workspace/notifications/index.vue"),
|
||||||
meta: { title: "我的通知", icon: "user", hidden: true },
|
meta: { title: "我的通知", icon: "user", hidden: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
// 维护「菜单 component 字符串 → src/views 视图文件」的映射,路由注册与菜单表单共用
|
// 维护「菜单 component 字符串 → src/views 视图文件」的映射,路由注册与菜单表单共用
|
||||||
// 表单的「页面路径」选项树也由此派生:跳过错误页与页面内部组件,index.vue 归并到目录
|
// 表单的「页面路径」选项树也由此派生:跳过错误页与页面内部组件,index.vue 归并到目录
|
||||||
|
|
||||||
|
import { ensureFormCreate } from "@/plugins/form-create";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全部视图模块,键形如 /src/views/system/user/index.vue
|
* 全部视图模块,键形如 /src/views/system/user/index.vue
|
||||||
*/
|
*/
|
||||||
@@ -26,16 +28,36 @@ export function normalizeViewPath(componentPath: string): string {
|
|||||||
.replace(/\.vue$/i, "");
|
.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
|
* 解析组件:支持 system/user 与 system/user/index 两种写法,未命中回退 404
|
||||||
*/
|
*/
|
||||||
export function resolveComponent(componentPath: string) {
|
export function resolveComponent(componentPath: string) {
|
||||||
const path = normalizeViewPath(componentPath);
|
const path = resolveViewAlias(normalizeViewPath(componentPath));
|
||||||
return (
|
const loader =
|
||||||
viewModules[`${VIEW_PREFIX}${path}.vue`] ||
|
viewModules[`${VIEW_PREFIX}${path}.vue`] ||
|
||||||
viewModules[`${VIEW_PREFIX}${path}/index.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 {
|
export function isViewComponent(componentPath?: string): boolean {
|
||||||
if (!componentPath) return false;
|
if (!componentPath) return false;
|
||||||
const path = normalizeViewPath(componentPath);
|
const path = resolveViewAlias(normalizeViewPath(componentPath));
|
||||||
return viewPaths.has(path) || viewPaths.has(`${path}/index`);
|
return viewPaths.has(path) || viewPaths.has(`${path}/index`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +86,7 @@ export interface PageComponentNode {
|
|||||||
function isSelectable(path: string): boolean {
|
function isSelectable(path: string): boolean {
|
||||||
const segments = path.split("/");
|
const segments = path.split("/");
|
||||||
if (segments.includes("components")) return false;
|
if (segments.includes("components")) return false;
|
||||||
return segments[0] !== "error" && path !== "redirect";
|
return segments[0] !== "internal";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,21 +3,15 @@ import { useSse } from "@/utils/sse";
|
|||||||
import { SseTopics } from "@/enums/sse";
|
import { SseTopics } from "@/enums/sse";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 在线用户数组合式函数(页面级)
|
* 订阅在线用户数推送,首次推送前人数为 null
|
||||||
*
|
|
||||||
* 订阅 SSE 在线用户数事件,组件挂载时订阅、卸载时自动取消;
|
|
||||||
* 进入页面后需等待下一次推送才有数据
|
|
||||||
*/
|
*/
|
||||||
export function useOnlineUsers() {
|
export function useOnlineUserCount() {
|
||||||
const onlineUserCount = ref(0);
|
const onlineUserCount = ref<number | null>(null);
|
||||||
|
|
||||||
const { isConnected, on } = useSse();
|
const { isConnected, on } = useSse();
|
||||||
|
|
||||||
let unsubscribe: (() => void) | null = null;
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理 SSE 推来的在线人数消息
|
|
||||||
*/
|
|
||||||
const handleOnlineUsersMessage = (count: number) => {
|
const handleOnlineUsersMessage = (count: number) => {
|
||||||
if (!Number.isFinite(count) || count < 0) return;
|
if (!Number.isFinite(count) || count < 0) return;
|
||||||
onlineUserCount.value = count;
|
onlineUserCount.value = count;
|
||||||
@@ -30,7 +24,7 @@ export function useOnlineUsers() {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
unsubscribe?.();
|
unsubscribe?.();
|
||||||
unsubscribe = null;
|
unsubscribe = null;
|
||||||
onlineUserCount.value = 0;
|
onlineUserCount.value = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="stat-card__body">
|
<div class="stat-card__body">
|
||||||
<span class="stat-card__label">在线用户</span>
|
<span class="stat-card__label">在线用户</span>
|
||||||
<span class="stat-card__num">{{ onlineUserCount }}</span>
|
<span class="stat-card__num">{{ onlineUserCount ?? "--" }}</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
:class="[
|
:class="[
|
||||||
@@ -261,11 +261,11 @@ import {
|
|||||||
Document,
|
Document,
|
||||||
VideoPlay,
|
VideoPlay,
|
||||||
} from "@element-plus/icons-vue";
|
} from "@element-plus/icons-vue";
|
||||||
import { useOnlineUsers } from "./composables/useOnlineUsers";
|
import { useOnlineUserCount } from "./composables/useOnlineUserCount";
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const settingsStore = useSettingsStore();
|
const settingsStore = useSettingsStore();
|
||||||
const { onlineUserCount, isConnected } = useOnlineUsers();
|
const { onlineUserCount, isConnected } = useOnlineUserCount();
|
||||||
|
|
||||||
const hours = new Date().getHours();
|
const hours = new Date().getHours();
|
||||||
const greetings = computed(() => {
|
const greetings = computed(() => {
|
||||||
|
|||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 809 KiB After Width: | Height: | Size: 809 KiB |
|
Before Width: | Height: | Size: 5.6 KiB After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
@@ -21,14 +21,25 @@
|
|||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { useKeepTicking } from "../composables/useKeepTicking";
|
import { useKeepTicking } from "../composables/useKeepTicking";
|
||||||
import { parseTime } from "../utils/time";
|
|
||||||
|
|
||||||
defineOptions({ name: "Cephalosome" });
|
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 dateTime = ref("加载中...");
|
||||||
const getCurrentTime = () =>
|
const getCurrentTime = () => (dateTime.value = formatClock(new Date()));
|
||||||
(dateTime.value = parseTime(new Date(), "YYYY-MM-DD HH:mm:ss 周dd A "));
|
|
||||||
|
|
||||||
// 使用定时器钩子更新时间
|
// 使用定时器钩子更新时间
|
||||||
useKeepTicking(getCurrentTime, 1000);
|
useKeepTicking(getCurrentTime, 1000);
|
||||||
@@ -124,16 +124,16 @@ defineOptions({
|
|||||||
import UserAPI from "@/api/system/user";
|
import UserAPI from "@/api/system/user";
|
||||||
import type { IObject, IOperateData, CrudModalInstance } from "@/components/Crud/types";
|
import type { IObject, IOperateData, CrudModalInstance } from "@/components/Crud/types";
|
||||||
import useCrudPage from "@/components/Crud/useCrudPage";
|
import useCrudPage from "@/components/Crud/useCrudPage";
|
||||||
import addModalConfig from "./config/add";
|
import addModalConfig from "./config/user/add";
|
||||||
import contentConfig from "./config/content";
|
import contentConfig from "./config/user/content";
|
||||||
import editModalConfig from "./config/edit";
|
import editModalConfig from "./config/user/edit";
|
||||||
import searchConfig from "./config/search";
|
import searchConfig from "./config/user/search";
|
||||||
import { initOptions } from "./config/options";
|
import { initOptions } from "./config/user/options";
|
||||||
|
|
||||||
import addModalConfig2 from "./config2/add";
|
import addModalConfig2 from "./config/mock/add";
|
||||||
import contentConfig2 from "./config2/content";
|
import contentConfig2 from "./config/mock/content";
|
||||||
import editModalConfig2 from "./config2/edit";
|
import editModalConfig2 from "./config/mock/edit";
|
||||||
import searchConfig2 from "./config2/search";
|
import searchConfig2 from "./config/mock/search";
|
||||||
|
|
||||||
const {
|
const {
|
||||||
searchRef,
|
searchRef,
|
||||||
|
|||||||
315
src/views/login/oidc-callback.vue
Normal 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>
|
||||||
390
src/views/workspace/apps/index.vue
Normal 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>
|
||||||