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 { 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());
|
||||
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
<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);
|
||||
@@ -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,
|
||||
|
||||
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>
|
||||