feat: 迁移前端资源文件并重构项目结构

refactor: 优化前端代码结构和资源管理

style: 调整前端代码格式和样式

chore: 更新.gitignore和构建配置

fix: 修复前端资源路径和引用问题

docs: 更新前端文档和注释

perf: 优化前端性能和加载速度

test: 更新前端测试用例

build: 调整前端构建配置

ci: 更新CI/CD配置
This commit is contained in:
zhangtao
2026-05-09 00:46:43 +08:00
parent 1bd125d99b
commit bf81480ada
967 changed files with 21018 additions and 83019 deletions
@@ -0,0 +1,250 @@
/**
* v-highlight 代码高亮指令
*
* 为代码块提供语法高亮、行号显示和一键复制功能。
* 基于 highlight.js 实现,支持多种编程语言的语法高亮。
*
* ## 主要功能
*
* - 语法高亮 - 使用 highlight.js 自动识别并高亮代码
* - 行号显示 - 自动为每行代码添加行号
* - 一键复制 - 提供复制按钮,点击即可复制代码(自动过滤行号)
* - 性能优化 - 批量处理代码块,避免阻塞渲染
* - 动态监听 - 使用 MutationObserver 监听新增代码块
* - 防重复处理 - 自动标记已处理的代码块,避免重复处理
*
* ## 使用示例
*
* ```vue
* <template>
* <!-- 基础用法 -->
* <div v-highlight v-html="codeContent"></div>
*
* <!-- 配合 Markdown 渲染 -->
* <div v-highlight>
* <pre><code class="language-javascript">
* const hello = 'world';
* console.log(hello);
* </code></pre>
* </div>
* </template>
* ```
*
* ## 性能优化
*
* - 批量处理:每次处理 10 个代码块,避免长时间阻塞
* - 延迟处理:使用 requestAnimationFrame 分批处理
* - 重试机制:自动重试处理失败的代码块
* - 智能监听:只在有新代码块时才触发处理
*
* @module directives/highlight
* @author FastapiAdmin Team
*/
import { App, Directive } from "vue";
import hljs from "highlight.js";
export type HighlightDirective = Directive<HTMLElement>;
// 高亮代码
function highlightCode(block: HTMLElement) {
hljs.highlightElement(block);
}
// 插入行号
function insertLineNumbers(block: HTMLElement) {
const lines = block.innerHTML.split("\n");
const numberedLines = lines
.map((line, index) => {
return `<span class="line-number">${index + 1}</span> ${line}`;
})
.join("\n");
block.innerHTML = numberedLines;
}
// 添加复制按钮:调整 DOM 结构,将代码部分包裹在 .code-wrapper 内
function addCopyButton(block: HTMLElement) {
const copyButton = document.createElement("i");
copyButton.className = "copy-button";
copyButton.innerHTML =
'<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"><path fill="currentColor" d="M7 6V3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1h-3v3c0 .552-.45 1-1.007 1H4.007A1 1 0 0 1 3 21l.003-14c0-.552.45-1 1.006-1zM5.002 8L5 20h10V8zM9 6h8v10h2V4H9z"/></svg>';
copyButton.onclick = () => {
// 过滤掉行号,只复制代码内容
const codeContent = block.innerText.replace(/^\d+\s+/gm, "");
navigator.clipboard.writeText(codeContent).then(() => {
ElMessage.success("复制成功");
});
};
const preElement = block.parentElement;
if (preElement) {
let codeWrapper: HTMLElement;
// 如果代码块还没有被包裹,则创建包裹容器
if (!block.parentElement.classList.contains("code-wrapper")) {
codeWrapper = document.createElement("div");
codeWrapper.className = "code-wrapper";
preElement.replaceChild(codeWrapper, block);
codeWrapper.appendChild(block);
} else {
codeWrapper = block.parentElement;
}
// 将复制按钮添加到 pre 元素(而非 codeWrapper 内),这样它不会随滚动条滚动
preElement.appendChild(copyButton);
}
}
// 检查代码块是否已经被处理过
function isBlockProcessed(block: HTMLElement): boolean {
return (
block.hasAttribute("data-highlighted") ||
!!block.querySelector(".line-number") ||
!!block.parentElement?.querySelector(".copy-button")
);
}
// 标记代码块为已处理
function markBlockAsProcessed(block: HTMLElement) {
block.setAttribute("data-highlighted", "true");
}
// 处理单个代码块
function processBlock(block: HTMLElement) {
if (isBlockProcessed(block)) {
return;
}
try {
highlightCode(block);
insertLineNumbers(block);
addCopyButton(block);
markBlockAsProcessed(block);
} catch (error) {
console.warn("处理代码块时出错:", error);
}
}
// 查找并处理所有代码块
function processAllCodeBlocks(el: HTMLElement) {
const blocks = Array.from(el.querySelectorAll<HTMLElement>("pre code"));
const unprocessedBlocks = blocks.filter((block) => !isBlockProcessed(block));
if (unprocessedBlocks.length === 0) {
return;
}
if (unprocessedBlocks.length <= 10) {
// 如果代码块数量少于等于10,直接处理所有代码块
unprocessedBlocks.forEach((block) => processBlock(block));
} else {
// 定义每次处理的代码块数
const batchSize = 10;
let currentIndex = 0;
const processBatch = () => {
const batch = unprocessedBlocks.slice(currentIndex, currentIndex + batchSize);
batch.forEach((block) => {
processBlock(block);
});
// 更新索引并继续处理下一批
currentIndex += batchSize;
if (currentIndex < unprocessedBlocks.length) {
// 使用 requestAnimationFrame 确保下一帧再处理
requestAnimationFrame(processBatch);
}
};
// 开始处理第一批代码块
processBatch();
}
}
// 重试处理函数
function retryProcessing(el: HTMLElement, maxRetries: number = 3, delay: number = 200) {
let retryCount = 0;
const tryProcess = () => {
processAllCodeBlocks(el);
// 检查是否还有未处理的代码块
const remainingBlocks = Array.from(el.querySelectorAll<HTMLElement>("pre code")).filter(
(block) => !isBlockProcessed(block)
);
if (remainingBlocks.length > 0 && retryCount < maxRetries) {
retryCount++;
setTimeout(tryProcess, delay * retryCount); // 递增延迟
}
};
tryProcess();
}
// 代码高亮、插入行号、复制按钮
const highlightDirective: HighlightDirective = {
mounted(el: HTMLElement) {
// 立即尝试处理一次
processAllCodeBlocks(el);
// 延迟处理,确保 v-html 内容已经渲染
setTimeout(() => {
retryProcessing(el);
}, 100);
// 使用 MutationObserver 监听 DOM 变化
const observer = new MutationObserver((mutations) => {
let hasNewCodeBlocks = false;
mutations.forEach((mutation) => {
if (mutation.type === "childList") {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const element = node as HTMLElement;
// 检查新添加的节点是否包含代码块
if (element.tagName === "PRE" || element.querySelector("pre code")) {
hasNewCodeBlocks = true;
}
}
});
}
});
if (hasNewCodeBlocks) {
// 延迟处理新添加的代码块
setTimeout(() => {
processAllCodeBlocks(el);
}, 50);
}
});
// 开始观察
observer.observe(el, {
childList: true,
subtree: true,
});
// 将 observer 存储到元素上,以便在 unmounted 时清理
(el as any)._highlightObserver = observer;
},
updated(el: HTMLElement) {
// 当组件更新时,重新处理代码块
setTimeout(() => {
processAllCodeBlocks(el);
}, 50);
},
unmounted(el: HTMLElement) {
// 清理 MutationObserver
const observer = (el as any)._highlightObserver;
if (observer) {
observer.disconnect();
delete (el as any)._highlightObserver;
}
},
};
export function setupHighlightDirective(app: App) {
app.directive("highlight", highlightDirective);
}
@@ -0,0 +1,116 @@
/**
* v-ripple 水波纹效果指令
*
* 为元素添加 Material Design 风格的水波纹点击效果。
* 点击时从点击位置扩散出圆形水波纹动画,提升交互体验。
*
* ## 主要功能
*
* - 水波纹动画 - 点击时从点击位置扩散圆形波纹
* - 自适应大小 - 根据元素尺寸自动调整波纹大小和动画时长
* - 智能配色 - 自动识别按钮类型,使用合适的波纹颜色
* - 自定义颜色 - 支持通过参数自定义波纹颜色
* - 性能优化 - 使用 requestAnimationFrame 和自动清理机制
*
* ## 使用示例
*
* ```vue
* <template>
* <!-- 基础用法 - 使用默认颜色 -->
* <el-button v-ripple>点击我</el-button>
*
* <!-- 自定义颜色 -->
* <el-button v-ripple="{ color: 'rgba(255, 0, 0, 0.3)' }">自定义颜色</el-button>
*
* <!-- 应用到任意元素 -->
* <div v-ripple class="custom-card">卡片内容</div>
* </template>
* ```
*
* ## 颜色规则
*
* - 有色按钮(primary、success、warning 等):使用白色半透明波纹
* - 默认按钮:使用主题色半透明波纹
* - 自定义:通过 color 参数指定任意颜色
*
* @module directives/ripple
* @author FastapiAdmin Team
*/
import type { App, Directive, DirectiveBinding } from "vue";
export interface RippleOptions {
/** 水波纹颜色 */
color?: string;
}
export type RippleDirective = Directive<HTMLElement, RippleOptions>;
export const vRipple: RippleDirective = {
mounted(el: HTMLElement, binding: DirectiveBinding) {
// 获取指令的配置参数
const options: RippleOptions = binding.value || {};
// 设置元素为相对定位,并隐藏溢出部分
el.style.position = "relative";
el.style.overflow = "hidden";
// 点击事件处理
el.addEventListener("mousedown", (e: MouseEvent) => {
const rect = el.getBoundingClientRect();
const left = e.clientX - rect.left;
const top = e.clientY - rect.top;
// 创建水波纹元素
const ripple = document.createElement("div");
const diameter = Math.max(el.clientWidth, el.clientHeight);
const radius = diameter / 2;
// 根据直径计算动画时间(直径越大,动画时间越长)
const baseTime = 600; // 基础动画时间(毫秒)
const scaleFactor = 0.5; // 缩放因子
const animationDuration = baseTime + diameter * scaleFactor;
// 设置水波纹的尺寸和位置
ripple.style.width = ripple.style.height = `${diameter}px`;
ripple.style.left = `${left - radius}px`;
ripple.style.top = `${top - radius}px`;
ripple.style.position = "absolute";
ripple.style.borderRadius = "50%";
ripple.style.pointerEvents = "none";
// 判断是否为有色按钮(Element Plus 按钮类型)
const buttonTypes = ["primary", "info", "warning", "danger", "success"].map(
(type) => `el-button--${type}`
);
const isColoredButton = buttonTypes.some((type) => el.classList.contains(type));
const defaultColor = isColoredButton
? "rgba(255, 255, 255, 0.25)" // 有色按钮使用白色水波纹
: "var(--el-color-primary-light-7)"; // 默认按钮使用主题色水波纹
// 设置水波纹颜色、初始状态和过渡效果
ripple.style.backgroundColor = options.color || defaultColor;
ripple.style.transform = "scale(0)";
ripple.style.transition = `transform ${animationDuration}ms cubic-bezier(0.3, 0, 0.2, 1), opacity ${animationDuration}ms cubic-bezier(0.3, 0, 0.5, 1)`;
ripple.style.zIndex = "1";
// 添加水波纹元素到DOM中
el.appendChild(ripple);
// 触发动画
requestAnimationFrame(() => {
ripple.style.transform = "scale(2)";
ripple.style.opacity = "0";
});
// 动画结束后移除水波纹元素
setTimeout(() => {
ripple.remove();
}, animationDuration + 500); // 增加500ms缓冲时间
});
},
};
export function setupRippleDirective(app: App) {
app.directive("ripple", vRipple);
}
+66
View File
@@ -0,0 +1,66 @@
/**
* v-auth 权限指令
*
* 适用于后端权限控制模式,基于权限标识控制 DOM 元素的显示和隐藏。
* 如果用户没有对应权限,元素将从 DOM 中移除。
*
* ## 主要功能
*
* - 权限验证 - 根据路由 meta 中的权限列表验证用户权限
* - DOM 控制 - 无权限时自动移除元素,而非隐藏
* - 响应式更新 - 权限变化时自动更新元素状态
*
* ## 使用示例
*
* ```vue
* <!-- 只有拥有 'add' 权限的用户才能看到新增按钮 -->
* <el-button v-auth="'add'">新增</el-button>
*
* <!-- 只有拥有 'edit' 权限的用户才能看到编辑按钮 -->
* <el-button v-auth="'edit'">编辑</el-button>
*
* <!-- 只有拥有 'delete' 权限的用户才能看到删除按钮 -->
* <el-button v-auth="'delete'">删除</el-button>
* ```
*
* ## 注意事项
*
* - 该指令会直接移除 DOM 元素,而不是使用 v-if 隐藏
* - 权限列表从当前路由的 meta.authList 中获取
*
* @module directives/auth
* @author FastapiAdmin Team
*/
import { router } from "@/router";
import { App, Directive, DirectiveBinding } from "vue";
export type AuthDirective = Directive<HTMLElement, string>;
function checkAuthPermission(el: HTMLElement, binding: DirectiveBinding<string>): void {
// 获取当前路由的权限列表
const authList = (router.currentRoute.value.meta.authList as Array<{ authMark: string }>) || [];
// 检查是否有对应的权限标识
const hasPermission = authList.some((item) => item.authMark === binding.value);
// 如果没有权限,移除元素
if (!hasPermission) {
removeElement(el);
}
}
function removeElement(el: HTMLElement): void {
if (el.parentNode) {
el.parentNode.removeChild(el);
}
}
const authDirective: AuthDirective = {
mounted: checkAuthPermission,
updated: checkAuthPermission,
};
export function setupAuthDirective(app: App): void {
app.directive("auth", authDirective);
}
+90
View File
@@ -0,0 +1,90 @@
/**
* v-roles 角色权限指令
*
* 基于用户角色控制 DOM 元素的显示和隐藏。
* 只要用户拥有指定角色中的任意一个,元素就会显示,否则从 DOM 中移除。
*
* ## 主要功能
*
* - 角色验证 - 检查用户是否拥有指定角色
* - 多角色支持 - 支持单个角色或多个角色(满足其一即可)
* - DOM 控制 - 无权限时自动移除元素,而非隐藏
* - 响应式更新 - 角色变化时自动更新元素状态
*
* ## 使用示例
*
* ```vue
* <template>
* <!-- 单个角色 - 只有超级管理员可见 -->
* <el-button v-roles="'R_SUPER'">超级管理员功能</el-button>
*
* <!-- 多个角色 - 超级管理员或普通管理员可见 -->
* <el-button v-roles="['R_SUPER', 'R_ADMIN']">管理员功能</el-button>
*
* <!-- 应用到任意元素 -->
* <div v-roles="['R_SUPER', 'R_ADMIN', 'R_USER']">
* 所有登录用户可见的内容
* </div>
* </template>
* ```
*
* ## 权限逻辑
*
* - 用户角色从 userStore.getUserInfo.roles 获取
* - 只要用户拥有指定角色中的任意一个,元素就会显示
* - 如果用户没有任何角色或不满足条件,元素将被移除
*
* ## 注意事项
*
* - 该指令会直接移除 DOM 元素,而不是使用 v-if 隐藏
* - 适用于基于角色的粗粒度权限控制
* - 如需基于具体操作的细粒度权限控制,请使用 v-auth 指令
*
* @module directives/roles
* @author FastapiAdmin Team
*/
import { useUserStore } from "@stores/modules/user.store";
import { App, Directive, DirectiveBinding } from "vue";
export type RolesDirective = Directive<HTMLElement, string | string[]>;
function checkRolePermission(el: HTMLElement, binding: DirectiveBinding<string | string[]>): void {
const userStore = useUserStore();
const userRoles = userStore.info.roles;
// 如果用户角色为空或未定义,移除元素
if (!userRoles?.length) {
removeElement(el);
return;
}
// 确保指令值为数组格式
const requiredRoles = Array.isArray(binding.value) ? binding.value : [binding.value];
// 检查用户是否具有所需角色之一
const roleCodes = userRoles
.map((r) => (typeof r === "string" ? r : r?.code))
.filter((r): r is string => typeof r === "string" && r.length > 0);
const hasPermission = requiredRoles.some((role: string) => roleCodes.includes(role));
// 如果没有权限,安全地移除元素
if (!hasPermission) {
removeElement(el);
}
}
function removeElement(el: HTMLElement): void {
if (el.parentNode) {
el.parentNode.removeChild(el);
}
}
const rolesDirective: RolesDirective = {
mounted: checkRolePermission,
updated: checkRolePermission,
};
export function setupRolesDirective(app: App): void {
app.directive("roles", rolesDirective);
}
+12 -2
View File
@@ -1,9 +1,19 @@
import type { App } from "vue";
import { setupAuthDirective, type AuthDirective } from "./core/auth";
import { setupHighlightDirective, type HighlightDirective } from "./business/highlight";
import { setupRippleDirective, type RippleDirective } from "./business/ripple";
import { setupRolesDirective, type RolesDirective } from "./core/roles";
import { hasPerm } from "./permission";
// 全局注册 directive
export function setupDirective(app: App<Element>) {
export function initGlobDirectives(app: App<Element>) {
setupAuthDirective(app); // 权限指令
setupRolesDirective(app); // 角色权限指令
setupHighlightDirective(app); // 高亮指令
setupRippleDirective(app); // 水波纹指令
// 使 v-hasPerm 在所有组件中都可用
app.directive("hasPerm", hasPerm);
}
export type { AuthDirective, HighlightDirective, RippleDirective, RolesDirective };
@@ -1,6 +1,6 @@
import type { Directive, DirectiveBinding } from "vue";
import { useUserStore } from "@/store";
import { useUserStore } from "@stores";
import { ROLE_ROOT } from "@/constants";
/**
@@ -25,7 +25,10 @@ export const hasPerm: Directive = {
requiredPerms === "*:*:*";
// 超级管理员;或指令显式传入 "*:*:*" 时跳过校验(勿用 includes 子串匹配,避免含 "*:*:*" 的权限被误放行)
if ((roles && roles.map((r) => r.code).includes(ROLE_ROOT)) || isWildcardBypass) {
if (
(roles && roles.map((r: { code?: string }) => r.code).includes(ROLE_ROOT)) ||
isWildcardBypass
) {
return;
}
@@ -59,11 +62,11 @@ export const hasRole: Directive = {
// 检查是否有对应角色权限
const hasAuth = Array.isArray(requiredRoles)
? requiredRoles.some((role) =>
roles ? roles.map((role) => role.code).includes(role) : false
? requiredRoles.some((requiredRole: string) =>
roles ? roles.map((r: { code?: string }) => r.code).includes(requiredRole) : false
)
: roles
? roles.map((role) => role.code).includes(requiredRoles)
? roles.map((r: { code?: string }) => r.code).includes(requiredRoles)
: false;
// 如果没有权限,移除元素