initialˆ

This commit is contained in:
2025-09-30 12:54:29 +08:00
commit acdf544b08
117 changed files with 20260 additions and 0 deletions

12
reijm-read/.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

35
reijm-read/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
!.vscode/settings.json
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
# 环境变量文件
.env*
!.env.example
.env

8
reijm-read/.idea/.gitignore generated vendored Normal file
View File

@@ -0,0 +1,8 @@
# 默认忽略的文件
/shelf/
/workspace.xml
# 基于编辑器的 HTTP 客户端请求
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

8
reijm-read/.idea/modules.xml generated Normal file
View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/unionvue.iml" filepath="$PROJECT_DIR$/.idea/unionvue.iml" />
</modules>
</component>
</project>

12
reijm-read/.idea/unionvue.iml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
reijm-read/.idea/vcs.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>

27
reijm-read/LICENSE Normal file
View File

@@ -0,0 +1,27 @@
MIT License
Copyright (c) 2024 Reisa
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Additional Terms:
1. The footer copyright notice and author attribution must be preserved.
2. Any modifications to the footer must maintain the original author's credit.
3. Commercial use requires explicit permission from the author.

120
reijm-read/README.md Normal file
View File

@@ -0,0 +1,120 @@
# ReisaWork0604
一个使用 Vue 3 + TypeScript + Vite 构建的现代化个人主页,具有博客文章展示、项目展示、联系表单等功能。
- Reisa 改编
## 特性
- 🚀 使用 Vue 3 + TypeScript + Vite 构建
- 🎨 支持深色模式
- 📱 响应式设计,支持移动端
- ⚡️ 快速加载和页面切换
- 🔍 SEO 友好
- 🌐 支持多语言
- 📝 Markdown 博客支持
- 📦 组件自动导入
- 🎯 TypeScript 类型安全
- 🔧 可配置的主题
## 技术栈
- Vue 3
- TypeScript
- Vite
- Vue Router
- TailwindCSS
- PostCSS
- ESLint + Prettier
- Husky + lint-staged
## 开发
```bash
# 克隆项目
git clone https://github.com/Spaso1/ReisaPage.git
# 安装依赖
pnpm install
# 启动开发服务器
pnpm dev
# 构建生产版本
pnpm build
# 预览生产构建
pnpm preview
# 代码格式化
pnpm format
# 代码检查
pnpm lint
```
## 项目结构
```
├── public/ # 静态资源
├── src/
│ ├── assets/ # 项目资源
│ ├── components/ # 组件
│ ├── config/ # 配置文件
│ ├── layouts/ # 布局组件
│ ├── pages/ # 页面
│ ├── router/ # 路由配置
│ ├── styles/ # 样式文件
│ ├── types/ # TypeScript 类型
│ ├── utils/ # 工具函数
│ ├── App.vue # 根组件
│ └── main.ts # 入口文件
├── .env # 环境变量
├── index.html # HTML 模板
├── package.json # 项目配置
├── tsconfig.json # TypeScript 配置
├── vite.config.ts # Vite 配置
└── README.md # 项目说明
```
## 配置
### 站点配置
`src/config/site.ts` 中配置站点基本信息:
```typescript
export const siteConfig = {
name: "Your Site Name",
description: "Your site description",
// ...其他配置
};
```
### 主题配置
`src/config/theme.ts` 中配置主题相关选项:
```typescript
export const themeConfig = {
colors: {
primary: "#2196f3",
// ...其他颜色
},
// ...其他主题配置
};
```
## 部署
项目可以部署到任何静态网站托管服务:
```bash
# 构建项目
pnpm build
# 部署 dist 目录
```
## 许可证
[MIT](./LICENSE)

BIN
reijm-read/dist.zip Normal file

Binary file not shown.

BIN
reijm-read/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

58
reijm-read/index.html Normal file
View File

@@ -0,0 +1,58 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="./src/assets/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 基础 Meta 标签 -->
<title>Powered by Reisa</title>
<meta name="description" content="Union 官网" />
<meta name="keywords" content="ReisaPage,Vue,Vite,ServerMonitoring,FindMaimai,Maimai,Reisa,Spasol" />
<meta name="author" content="Reisa" />
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website" />
<meta property="og:url" content="https://www.godserver.cn" />
<meta property="og:title" content="Reisa Spasol" />
<meta property="og:description" content="Union 网站" />
<meta property="og:image" content="/src/assets/logo.png" />
<meta property="og:locale" content="zh_CN" />
<meta property="og:site_name" content="Reisa Spasol" />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@Spaso1" />
<meta name="twitter:title" content="Reisa Spasol" />
<meta name="twitter:description" content="Reisa 个人网站" />
<meta name="twitter:image" content="/src/assets/logo.png" />
<!-- 主题色 -->
<meta name="theme-color" content="#42b983" />
<!-- Schema.org 结构化数据 -->
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "Organization",
"name": " Powered by Reisa",
"url": "https://www.godserver.cn",
"logo": "/src/assets/logo.png",
"sameAs": ["https://github.com/Spaso1", "https://twitter.com/Spaso1"]
}
</script>
<!-- 字体预加载 -->
<link
rel="preload"
href="https://cdn.godserver.cn/resource/lxwk.woff2"
as="font"
type="font/woff2"
crossorigin="anonymous"
/>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

71
reijm-read/package.json Normal file
View File

@@ -0,0 +1,71 @@
{
"name": "home-for-vue",
"version": "1.0.0",
"license": "MIT",
"author": {
"name": "Reisa Spasol",
"url": "https://www.godserver.cn/"
},
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"build-only": "vite build",
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
"preview": "vite preview",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"format": "prettier --write src/",
"serve": "vite run"
},
"dependencies": {
"@emailjs/browser": "^4.4.1",
"axios": "^1.8.4",
"crypto-js": "^4.2.0",
"element-plus": "^2.9.9",
"jsqr": "^1.4.0",
"marked": "^15.0.10",
"mitt": "^3.0.1",
"otplib": "^12.0.1",
"pinia": "^2.1.7",
"qrcode.vue": "^3.6.0",
"rss-parser": "^3.13.0",
"uuid": "^11.1.0",
"vue": "^3.4.3",
"vue-router": "^4.2.5",
"vuetify": "^3.8.0-beta.0"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.3",
"@tsconfig/node18": "^18.2.2",
"@types/node": "^20.17.10",
"@vitejs/plugin-vue": "^4.5.2",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.5.0",
"autoprefixer": "^10.4.16",
"cesium": "^1.129.0",
"eslint": "^8.49.0",
"eslint-plugin-vue": "^9.17.0",
"imagemin": "^9.0.0",
"imagemin-gifsicle": "^7.0.0",
"imagemin-mozjpeg": "^10.0.0",
"imagemin-optipng": "^8.0.0",
"imagemin-pngquant": "^10.0.0",
"imagemin-svgo": "^11.0.1",
"noise": "~0.0.0",
"npm-run-all": "^4.1.5",
"postcss": "^8.4.32",
"prettier": "^3.0.3",
"sharp": "^0.33.5",
"tailwindcss": "^3.4.0",
"terser": "^5.37.0",
"typescript": "~5.3.0",
"vite": "^5.4.19",
"vite-plugin-cesium": "^1.2.23",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-image-optimizer": "^1.1.8",
"vite-plugin-imagemin": "^0.6.1",
"vue-tsc": "^1.8.25"
}
}

8137
reijm-read/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

@@ -0,0 +1,22 @@
{
"name": "ReisaSpasol | MaimaiDX",
"short_name": "ReisaSpasol",
"description": "专注于Java、Spring Boot、微服务等后端技术开发的个人作品集网站",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#4F46E5",
"icons": [
{
"src": "./assets/icon.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "./assets/icon.png",
"sizes": "512x512",
"type": "image/png"
}
]
}

View File

@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: <%= config.siteUrl %>/sitemap.xml

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc><%= config.siteUrl %>/</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc><%= config.siteUrl %>/blog</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc><%= config.siteUrl %>/skills</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc><%= config.siteUrl %>/contact</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
</urlset>

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

148
reijm-read/src/App.vue Normal file
View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import { useRoute, useRouter, type RouteMeta } from "vue-router";
import { RouterView } from "vue-router";
import TheHeader from "./components/layout/TheHeader.vue";
import TheFooter from "./components/layout/TheFooter.vue";
import PageTransition from "./components/PageTransition.vue";
import Toast from "./components/ui/Toast.vue";
import Modal from "./components/ui/Modal.vue";
import type { NoticeButton } from "./types/notice";
import { siteConfig } from "@/config";
import { siteInfo } from "./config/site-info";
import { printConsoleInfo } from "@/utils/console";
const route = useRoute();
const router = useRouter();
// 是否为开发环境
const isDev = import.meta.env.DEV;
document.documentElement.classList.add("dark-mode");
// 监听路由变化更新页面标题和描述
watch(
() => route.meta,
(meta: RouteMeta) => {
if (meta.title) {
document.title = `${meta.title} | ${siteConfig.name}`;
}
if (meta.description) {
document
.querySelector('meta[name="description"]')
?.setAttribute("content", meta.description as string);
}
if (meta.keywords) {
document
.querySelector('meta[name="keywords"]')
?.setAttribute("content", meta.keywords as string);
}
// 更新 Open Graph 标签
document
.querySelector('meta[property="og:title"]')
?.setAttribute("content", meta.title as string);
document
.querySelector('meta[property="og:description"]')
?.setAttribute("content", meta.description as string);
},
);
const showNotice = ref(false);
// 处理按钮点击
const handleNoticeAction = (button: NoticeButton) => {
const now = Date.now();
// 处理按钮动作
switch (button.action) {
case "close":
showNotice.value = false;
break;
case "navigate":
showNotice.value = false;
if (button.to) {
router.push(button.to);
}
break;
case "link":
if (button.href) {
window.open(button.href, "_blank");
}
showNotice.value = false;
break;
case "custom":
if (button.handler) {
button.handler();
}
showNotice.value = false;
break;
}
};
import { computed } from 'vue'
// 计算是否应该隐藏头部
const shouldHideHeader = computed(() => {
return route.meta.hideHeader === true
})
onMounted(() => {
// 打印控制台信息
printConsoleInfo({
text: siteInfo.text,
version: siteInfo.version,
link: siteInfo.link,
});
});
</script>
<template>
<div class="min-h-screen flex flex-col loading='lazy' dark:bg-gray-900/50">
<TheHeader v-if="!shouldHideHeader" />
<main class="flex-grow" :class="{ 'pt-0': shouldHideHeader, 'pt-16': !shouldHideHeader }">
<router-view v-slot="{ Component }">
<PageTransition :name="(route.meta.transition as string) || 'fade'">
<component :is="Component" />
</PageTransition>
</router-view>
</main>
<!-- <TheFooter />-->
<Toast />
</div>
</template>
<style scoped>
.min-h-screen {
position: relative; /* 添加相对定位 */
background-image: url('@/assets/a.jpg');
background-size: cover; /* 背景图片覆盖整个元素 */
background-position: center; /* 背景图片居中 */
background-repeat: no-repeat; /* 防止背景图片重复 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
.dark .min-h-screen::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5); /* 半透明黑色遮罩 */
z-index: -1;
}
.min-h-screen::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: inherit; /* 继承背景图片 */
background-size: cover; /* 背景图片覆盖整个元素 */
background-position: center; /* 背景图片居中 */
background-repeat: no-repeat; /* 防止背景图片重复 */
opacity: 0.3; /* 调整透明度 */
z-index: -1; /* 确保伪元素在内容下方 */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 KiB

BIN
reijm-read/src/assets/a.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 746 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 910 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -0,0 +1,48 @@
:root {
/* 主色调 */
--color-primary: #3b82f6;
--color-primary-dark: #2563eb;
--color-primary-light: #60a5fa;
--color-primary-10: rgba(59, 130, 246, 0.1);
/* 背景色 */
--color-bg-main: #ffffff;
--color-bg-secondary: #f9fafb;
--color-bg-tertiary: #f3f4f6;
/* 文本颜色 */
--color-text-primary: #111827;
--color-text-secondary: #4b5563;
--color-text-tertiary: #6b7280;
/* 边框颜色 */
--color-border: #e5e7eb;
--color-border-light: #f3f4f6;
/* 卡片阴影 */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
}
/* 深色模式 */
:root[class~="dark"] {
/* 背景色 */
--color-bg-main: #111827;
--color-bg-secondary: #1f2937;
--color-bg-tertiary: #374151;
/* 文本颜色 */
--color-text-primary: #f9fafb;
--color-text-secondary: #e5e7eb;
--color-text-tertiary: #d1d5db;
/* 边框颜色 */
--color-border: #374151;
--color-border-light: #1f2937;
/* 卡片阴影 */
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.3);
--shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3), 0 1px 2px -1px rgb(0 0 0 / 0.3);
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.3), 0 2px 4px -2px rgb(0 0 0 / 0.3);
}

View File

@@ -0,0 +1,72 @@
@import "./colors.css";
@import "./variables.css";
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
/* 添加字体定义 */
@font-face {
font-family: "LXWK";
font-weight: 100 900;
font-display: swap;
font-style: normal;
src: url("https://cdn.jsdmirror.com/gh/acanyo/mmm.sd@master/assets/font/lxwk.woff2")
format("woff2");
}
@layer base {
html {
font-family:
"LXWK",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
scroll-behavior: smooth;
-webkit-tap-highlight-color: transparent;
}
body {
@apply bg-main text-primary antialiased;
}
/* 移动端优化 */
@media (max-width: 768px) {
html {
font-size: 14px;
}
}
/* 移动端点击态优化 */
@media (hover: none) {
.hover\:scale-105:active {
transform: scale(1.02);
}
}
}
@layer components {
.btn-primary {
@apply inline-block px-6 py-3 bg-primary text-white rounded-lg
hover:bg-primary-dark transition-colors duration-300;
}
.btn-secondary {
@apply inline-block px-6 py-3 border-2 border-primary text-primary rounded-lg
hover:bg-primary hover:text-white transition-colors duration-300;
}
.card {
@apply bg-main border border-light rounded-2xl shadow-sm
hover:shadow-md transition-all duration-300;
}
}
/* 移动端滚动优化 */
.smooth-scroll {
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
}

View File

@@ -0,0 +1,16 @@
:root {
--font-family-custom: "LXWK";
--font-family-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
--font-family: var(--font-family-custom), var(--font-family-system);
}
/* 添加字体定义 */
@font-face {
font-family: "LXWK";
font-weight: 100 900;
font-display: swap;
font-style: normal;
src: url("https://cdn.jsdmirror.com/gh/acanyo/mmm.sd@master/assets/font/lxwk.woff2")
format("woff2");
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,86 @@
<script setup lang="ts">
defineProps<{
name?: string;
}>();
</script>
<template>
<transition :name="name || 'fade'" mode="out-in" appear>
<slot></slot>
</transition>
</template>
<style>
/* 淡入淡出 */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
/* 滑动 */
.slide-left-enter-active,
.slide-left-leave-active,
.slide-right-enter-active,
.slide-right-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide-left-enter-from {
opacity: 0;
transform: translateX(30px);
}
.slide-left-leave-to {
opacity: 0;
transform: translateX(-30px);
}
.slide-right-enter-from {
opacity: 0;
transform: translateX(-30px);
}
.slide-right-leave-to {
opacity: 0;
transform: translateX(30px);
}
/* 缩放 */
.scale-enter-active,
.scale-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.scale-enter-from,
.scale-leave-to {
opacity: 0;
transform: scale(0.95);
}
/* 弹跳 */
.bounce-enter-active {
animation: bounce-in 0.5s;
}
.bounce-leave-active {
animation: bounce-in 0.5s reverse;
}
@keyframes bounce-in {
0% {
transform: scale(0.95);
opacity: 0;
}
50% {
transform: scale(1.05);
opacity: 0.5;
}
100% {
transform: scale(1);
opacity: 1;
}
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<div class="progress-bar-container">
<div class="progress-bar-label">
<span>{{ label }}</span>
<span class="progress-bar-value">{{ value }}%</span>
</div>
<div class="progress-bar">
<div
class="progress-bar-fill"
:style="{ width: `${value}%`, opacity: isVisible ? 1 : 0 }"
></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
interface ProgressBarProps {
label: string;
value: number;
}
const props = defineProps<ProgressBarProps>();
const isVisible = ref(false);
onMounted(() => {
setTimeout(() => {
isVisible.value = true;
}, 500);
});
</script>
<style scoped>
.progress-bar-container {
@apply mb-4;
}
.progress-bar-label {
@apply flex justify-between items-center mb-1;
}
.progress-bar-value {
@apply font-medium text-blue-500;
}
.progress-bar {
@apply h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden relative;
}
.progress-bar-fill {
@apply h-full bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 transition-all duration-1000 ease-out;
}
</style>

View File

@@ -0,0 +1,82 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
const isDark = ref(false);
const toggleTheme = () => {
isDark.value = !isDark.value;
document.documentElement.classList.toggle("dark", isDark.value);
localStorage.setItem("theme", isDark.value ? "dark" : "light");
};
onMounted(() => {
// 检查系统主题偏好
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
// 获取保存的主题设置
const savedTheme = localStorage.getItem("theme");
// 如果有保存的主题设置就使用,否则跟随系统
isDark.value = savedTheme === "dark" || (!savedTheme && prefersDark);
document.documentElement.classList.toggle("dark", isDark.value);
// 监听系统主题变化
window
.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", (e) => {
if (!localStorage.getItem("theme")) {
isDark.value = e.matches;
document.documentElement.classList.toggle("dark", isDark.value);
}
});
});
</script>
<template>
<button
@click="toggleTheme"
class="relative w-10 h-10 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors duration-200"
aria-label="切换主题"
>
<div class="absolute inset-0 flex items-center justify-center">
<transition name="theme-toggle" mode="out-in">
<!-- 暗色主题图标 -->
<svg
v-if="isDark"
key="dark"
class="w-5 h-5 text-yellow-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
/>
</svg>
<!-- 亮色主题图标 -->
<svg
v-else
key="light"
class="w-5 h-5 text-gray-500"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"
/>
</svg>
</transition>
</div>
</button>
</template>
<style scoped>
.theme-toggle-enter-active,
.theme-toggle-leave-active {
transition: all 0.3s ease;
}
.theme-toggle-enter-from,
.theme-toggle-leave-to {
opacity: 0;
transform: rotate(30deg) scale(0.8);
}
</style>

View File

@@ -0,0 +1,159 @@
<template>
<canvas
ref="canvas"
class="fixed inset-0 pointer-events-none z-[100] transition-opacity duration-1000"
:class="{ 'opacity-0': shouldFadeOut }"
></canvas>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from "vue";
interface Props {
enabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
enabled: true,
});
const canvas = ref<HTMLCanvasElement | null>(null);
let ctx: CanvasRenderingContext2D | null = null;
let confetti: Confetti[] = [];
let animationId: number;
const shouldFadeOut = ref(false);
interface Confetti {
x: number;
y: number;
rotation: number;
rotationSpeed: number;
vx: number;
vy: number;
width: number;
height: number;
color: string;
opacity: number;
}
// 彩带颜色 - 使用亮色系
const colors = [
"#FF69B4", // 粉红
"#87CEEB", // 天蓝
"#98FB98", // 浅绿
"#DDA0DD", // 梅红
"#F0E68C", // 卡其
"#FFB6C1", // 浅粉
"#87CEFA", // 浅天蓝
"#FFA07A", // 浅鲑鱼色
];
const createConfetti = () => {
const x = Math.random() * canvas.value!.width;
const y = canvas.value!.height;
return {
x,
y,
rotation: Math.random() * 360,
rotationSpeed: (Math.random() - 0.5) * 2,
vx: (Math.random() - 0.5) * 3,
vy: -Math.random() * 15 - 10, // 向上的初始速度
width: Math.random() * 10 + 5,
height: Math.random() * 6 + 3,
color: colors[Math.floor(Math.random() * colors.length)],
opacity: 1,
};
};
const drawConfetti = (confetti: Confetti) => {
if (!ctx) return;
ctx.save();
ctx.translate(confetti.x, confetti.y);
ctx.rotate((confetti.rotation * Math.PI) / 180);
ctx.globalAlpha = confetti.opacity;
ctx.fillStyle = confetti.color;
ctx.fillRect(
-confetti.width / 2,
-confetti.height / 2,
confetti.width,
confetti.height,
);
ctx.restore();
};
const animate = () => {
if (!ctx || !canvas.value) return;
// 清除画布,保持透明背景
ctx.clearRect(0, 0, canvas.value.width, canvas.value.height);
// 更新和绘制所有彩带
for (let i = confetti.length - 1; i >= 0; i--) {
const conf = confetti[i];
// 更新位置
conf.x += conf.vx;
conf.y += conf.vy;
conf.vy += 0.2; // 重力
conf.rotation += conf.rotationSpeed;
// 轻微的左右摆动
conf.vx += (Math.random() - 0.5) * 0.1;
// 减少透明度
conf.opacity -= 0.005;
// 如果彩带消失或飞出屏幕,则移除
if (conf.opacity <= 0 || conf.y > canvas.value.height) {
confetti.splice(i, 1);
continue;
}
drawConfetti(conf);
}
// 持续添加新的彩带
if (Math.random() < 0.1) {
for (let i = 0; i < 3; i++) {
confetti.push(createConfetti());
}
}
animationId = requestAnimationFrame(animate);
};
const resizeCanvas = () => {
if (!canvas.value || !ctx) return;
canvas.value.width = window.innerWidth;
canvas.value.height = window.innerHeight;
};
onMounted(() => {
if (!canvas.value) return;
ctx = canvas.value.getContext("2d");
resizeCanvas();
window.addEventListener("resize", resizeCanvas);
// 初始发射一批彩带
for (let i = 0; i < 50; i++) {
confetti.push(createConfetti());
}
animate();
});
onBeforeUnmount(() => {
window.removeEventListener("resize", resizeCanvas);
if (animationId) {
cancelAnimationFrame(animationId);
}
});
setTimeout(() => {
shouldFadeOut.value = true;
}, 3000);
</script>

View File

@@ -0,0 +1,82 @@
<template>
<footer
class="bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700"
ref="footerRef"
>
<div class="container mx-auto px-4 py-6">
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
<!-- 左侧版权信息 -->
<div
class="flex items-center gap-1 text-sm text-gray-500 dark:text-gray-400"
ref="copyrightRef"
>
<span>© {{ currentYear }}</span>
<a
href="https://www.godserver.cn/"
target="_blank"
class="font-medium hover:text-blue-500 transition-colors"
>
Reisa
</a>
<span>. All rights reserved. 鄂ICP备2021014649号-2</span>
</div>
</div>
</div>
</footer>
</template>
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, computed } from "vue";
import type { RouterLinkProps } from "vue-router";
import { useRouter } from "vue-router";
import { footerConfig } from "@/config/footer";
import { createCopyrightGuard } from "@/utils/copyright";
import { siteConfig } from "@/config/site";
const router = useRouter();
const footerRef = ref<HTMLElement | null>(null);
const copyrightRef = ref<HTMLElement | null>(null);
const guard = createCopyrightGuard;
// 定期检查版权信息
let intervalId: number;
let randomInterval: number;
const currentYear = computed(() => new Date().getFullYear());
onMounted(() => {
// 初始检查
guard(copyrightRef.value);
// 随机间隔检查
const check = () => {
guard(copyrightRef.value);
randomInterval = window.setTimeout(check, Math.random() * 2000 + 1000);
};
check();
// 固定间隔检查
intervalId = window.setInterval(() => guard(copyrightRef.value), 1000);
// 添加DOM变化监听
const observer = new MutationObserver(() => guard(copyrightRef.value));
if (copyrightRef.value) {
observer.observe(copyrightRef.value, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
});
}
});
onBeforeUnmount(() => {
if (intervalId) {
window.clearInterval(intervalId);
}
if (randomInterval) {
window.clearTimeout(randomInterval);
}
});
</script>

View File

@@ -0,0 +1,205 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { useRoute } from "vue-router";
import ThemeToggle from "@/components/ThemeToggle.vue";
import eventBus from "@/eventBus";
import axios from "axios";
const route = useRoute();
const isMenuOpen = ref(false);
// 用户信息
const closeMenu = () => {
isMenuOpen.value = false;
};
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
closeMenu();
}
};
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest(".mobile-menu") && !target.closest(".menu-button")) {
closeMenu();
}
};
let refreshInterval: number | null = null;
// 新增刷新token的函数
const refreshToken = async () => {
try {
const token = localStorage.getItem('token');
if (!token) return;
const response = await axios.post('/api/user/ref', {
data: token,
timestamp: Date.now()
}, {
headers: {
'Token' : token,
'Content-Type': 'application/json'
}
});
} catch (error) {
console.error('Refresh token failed:', error);
}
};
// 在组件挂载时添加事件监听
onMounted(() => {
// 每30秒刷新一次token
refreshInterval = window.setInterval(refreshToken, 30000);
window.addEventListener("click", handleClickOutside);
window.addEventListener("keydown", handleKeydown);});
// 在组件卸载时移除事件监听,防止内存泄漏
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval);
}
window.removeEventListener("click", handleClickOutside);
window.removeEventListener("keydown", handleKeydown);});
const navItems = [
{ name: "首页", path: "/" },
{ name: "用户", path: "/user" },
];
const toggleMenu = () => {
isMenuOpen.value = !isMenuOpen.value;
};
</script>
<template>
<header class="fixed w-full top-0 z-50 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm">
<nav class="container mx-auto px-4 py-3 md:py-4">
<div class="flex items-center justify-between">
<router-link to="/" class="logo-link flex items-center space-x-2">
<span
class="text-xl md:text-2xl font-bold bg-gradient-to-r from-blue-500 via-purple-500 to-pink-500 bg-clip-text text-transparent bg-[length:200%_auto] hover:animate-gradient whitespace-nowrap"
>
Union
</span>
</router-link>
<!-- 桌面端导航 -->
<div class="hidden md:flex items-center space-x-6">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="nav-link"
:class="{ 'text-primary': route.path === item.path }"
>
{{ item.name }}
</router-link>
<ThemeToggle />
</div>
<!-- 移动端菜单按钮 -->
<div class="md:hidden flex items-center space-x-2">
<ThemeToggle />
<button
class="menu-button p-1.5 hover:bg-gray-100 dark:hover:bg-gray-800 rounded-lg transition-colors"
@click.stop="toggleMenu"
aria-label="Toggle menu"
>
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
v-if="!isMenuOpen"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"
/>
<path
v-else
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
<!-- 移动端导航菜单 -->
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-show="isMenuOpen" class="mobile-menu md:hidden">
<div class="py-2 space-y-1">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="block px-4 py-2 text-base hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
:class="{
'bg-primary/10 text-primary': route.path === item.path,
}"
@click="closeMenu"
>
{{ item.name }}
</router-link>
</div>
</div>
</transition>
</nav>
</header>
</template>
<style scoped>
.nav-link {
@apply text-gray-600 dark:text-gray-300 hover:text-primary dark:hover:text-primary transition-colors;
}
.mobile-menu {
@apply absolute top-full left-0 right-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm
border-t border-gray-200 dark:border-gray-700 shadow-lg;
}
/* 移动端导航链接悬停效果 */
@media (hover: hover) {
.mobile-menu .router-link-active {
@apply bg-primary-10 text-primary;
}
}
/* Logo 悬停动画 */
.logo-link {
@apply inline-block py-1;
}
.logo-link:hover span:first-child {
@apply transform scale-105 transition-transform duration-300;
}
@keyframes gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
.hover\:animate-gradient:hover {
animation: gradient 3s linear infinite;
}
</style>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
import PageTransition from "@/components/PageTransition.vue";
defineProps<{
title: string;
description?: string;
}>();
</script>
<template>
<div class="container mx-auto px-4 py-12">
<PageTransition name="bounce">
<div class="max-w-4xl mx-auto text-center mb-12">
<h1 class="text-4xl font-bold mb-4">{{ title }}</h1>
<p v-if="description" class="text-gray-600 dark:text-gray-300">
{{ description }}
</p>
</div>
</PageTransition>
<PageTransition name="fade">
<div class="max-w-6xl mx-auto">
<slot></slot>
</div>
</PageTransition>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
const props = defineProps<{
src: string;
alt: string;
width?: string | number;
height?: string | number;
}>();
const isLoaded = ref(false);
const observer = ref<IntersectionObserver | null>(null);
const imgRef = ref<HTMLImageElement | null>(null);
onMounted(() => {
observer.value = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && imgRef.value) {
imgRef.value.src = props.src;
isLoaded.value = true;
observer.value?.disconnect();
}
});
});
if (imgRef.value) {
observer.value.observe(imgRef.value);
}
});
</script>
<template>
<div class="relative overflow-hidden">
<img
ref="imgRef"
:alt="alt"
:width="width"
:height="height"
class="transition-opacity duration-300"
:class="{ 'opacity-0': !isLoaded, 'opacity-100': isLoaded }"
loading="lazy"
/>
<div
v-if="!isLoaded"
class="absolute inset-0 bg-gray-200 dark:bg-gray-700 animate-pulse"
></div>
</div>
</template>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import Fireworks from "@/components/effects/Fireworks.vue";
import { noticeConfig } from "@/config/notice";
interface Props {
show: boolean;
title?: string;
width?: string;
maskClosable?: boolean;
showClose?: boolean;
showFireworks?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
show: false,
title: "",
width: "400px",
maskClosable: true,
showClose: true,
showFireworks: false,
});
const emit = defineEmits<{
(e: "update:show", value: boolean): void;
}>();
const modalRef = ref<HTMLElement | null>(null);
const showFireworks = ref(false);
// 处理点击遮罩层关闭
const handleMaskClick = (e: MouseEvent) => {
if (props.maskClosable && e.target === modalRef.value) {
emit("update:show", false);
}
};
// 处理ESC键关闭
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === "Escape" && props.show) {
emit("update:show", false);
}
};
// 监听键盘事件
watch(
() => props.show,
(val: boolean) => {
if (val) {
document.addEventListener("keydown", handleKeydown);
} else {
document.removeEventListener("keydown", handleKeydown);
}
},
);
// 监听显示状态
watch(
() => props.show,
(newVal: boolean) => {
if (newVal && props.showFireworks && noticeConfig.showFireworks) {
showFireworks.value = true;
setTimeout(() => {
showFireworks.value = false;
}, 3000);
}
},
);
</script>
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="show" class="fixed inset-0 z-[90]">
<Fireworks v-if="showFireworks" />
<div
ref="modalRef"
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/50 backdrop-blur-sm"
@click="handleMaskClick"
>
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl transform transition-all"
:style="{ width }"
>
<!-- 标题栏 -->
<div
v-if="title || showClose"
class="flex items-center justify-between px-6 py-4 border-b dark:border-gray-700"
>
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">
{{ title }}
</h3>
<button
v-if="showClose"
class="text-gray-400 hover:text-gray-500 dark:hover:text-gray-300 transition-colors"
@click="emit('update:show', false)"
>
<span class="sr-only">关闭</span>
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<!-- 内容区 -->
<div class="px-6 py-4">
<slot></slot>
</div>
<!-- 按钮区 -->
<div
v-if="$slots.footer"
class="px-6 py-4 bg-gray-50 dark:bg-gray-700/50 rounded-b-lg"
>
<slot name="footer"></slot>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: all 0.3s ease;
}
.modal-enter-from {
opacity: 0;
transform: scale(0.9);
}
.modal-leave-to {
opacity: 0;
transform: scale(1.1);
}
</style>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import { ref } from "vue";
interface Tab {
id: string;
label: string;
icon?: string;
}
const props = defineProps<{
tabs: Tab[];
modelValue: string;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
}>();
const activeTab = ref(props.modelValue);
const switchTab = (tabId: string) => {
activeTab.value = tabId;
emit("update:modelValue", tabId);
};
</script>
<template>
<div class="flex" :class="$attrs.class">
<button
v-for="tab in tabs"
:key="tab.id"
@click="$emit('update:modelValue', tab.id)"
class="flex items-center gap-2 px-4 py-2 rounded-full transition-all duration-300 min-w-[120px] justify-center"
:class="[
modelValue === tab.id
? 'bg-white dark:bg-gray-800 shadow-md text-primary dark:text-primary-light'
: 'text-gray-600 dark:text-gray-400 hover:text-primary dark:hover:text-primary-light',
]"
>
<span class="text-lg">{{ tab.icon }}</span>
<span>{{ tab.label }}</span>
</button>
</div>
</template>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
const isVisible = ref(false);
const message = ref("");
const type = ref<"success" | "error" | "info">("info");
let timeoutId: number;
const show = (
text: string,
messageType: "success" | "error" | "info" = "info",
) => {
clearTimeout(timeoutId);
message.value = text;
type.value = messageType;
isVisible.value = true;
timeoutId = window.setTimeout(() => {
isVisible.value = false;
}, 3000);
};
// 创建全局方法
const toast = {
show,
success: (text: string) => show(text, "success"),
error: (text: string) => show(text, "error"),
info: (text: string) => show(text, "info"),
};
// 挂载到全局
window.$toast = toast;
onUnmounted(() => {
clearTimeout(timeoutId);
});
</script>
<template>
<Teleport to="body">
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform translate-y-2 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="transform translate-y-0 opacity-100"
leave-to-class="transform translate-y-2 opacity-0"
>
<div
v-show="isVisible"
class="fixed bottom-4 right-4 z-50 px-4 py-2 rounded-lg shadow-lg"
:class="{
'bg-green-500 text-white': type === 'success',
'bg-red-500 text-white': type === 'error',
'bg-blue-500 text-white': type === 'info',
}"
>
{{ message }}
</div>
</transition>
</Teleport>
</template>

View File

@@ -0,0 +1,89 @@
<template>
<Transition name="fade">
<div
v-if="isOpen"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
>
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6 space-y-6"
>
<div class="text-center">
<span class="text-5xl block mb-4"></span>
<h3 class="text-2xl font-bold mb-4">友情提示</h3>
<div class="space-y-3 text-gray-600 dark:text-gray-300">
<p>为了确保最佳的浏览体验我们暂时禁用了以下功能</p>
<ul class="text-left list-disc list-inside space-y-2">
<li>开发者工具 (F12)</li>
<li>查看源代码 (Ctrl/Cmd + U)</li>
<li>检查元素 (Ctrl/Cmd + Shift + C)</li>
</ul>
<p class="mt-4 text-sm">
如果您是开发者需要调试请访问我们的
<a
href="https://github.com/your-repo"
target="_blank"
class="text-primary hover:underline"
>
GitHub 仓库
</a>
</p>
</div>
</div>
<div class="flex justify-center space-x-4">
<button
@click="close"
class="px-6 py-2.5 bg-primary text-white rounded-lg hover:bg-primary-dark transition-all duration-300 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 dark:focus:ring-offset-gray-800"
>
我知道了
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref } from "vue";
const isOpen = ref(false);
const open = () => {
isOpen.value = true;
document.addEventListener("keydown", handleEscape);
};
const close = () => {
isOpen.value = false;
document.removeEventListener("keydown", handleEscape);
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
close();
}
};
defineExpose({
open,
close,
});
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: all 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: scale(0.95);
}
.fade-enter-to,
.fade-leave-from {
opacity: 1;
transform: scale(1);
}
</style>

View File

@@ -0,0 +1,42 @@
export interface BlogPost {
title: string;
category: string;
date: Date;
link: string;
data: string;
summary: string; // 添加简介字段
}
export const blogPosts: BlogPost[] = [
{
title: "FindMaimaiDX",
data: `
# FindMaimai
`,
summary: "FindMaimai是一个基于Java开发全平台客户端",
link: "https://example.com/vue3-features",
date: new Date("2023-10-01"),
category: "Maimai",
},
{
title: "TypeScript 在大型项目中的应用",
data: `## TypeScript 在大型项目中的应用
探讨如何在大型项目中使用 TypeScript 提高代码质量和开发效率。
### 类型检查
TypeScript 提供了强大的类型检查功能,帮助开发者减少运行时错误。
### 代码重构
使用 TypeScript 可以更容易地进行代码重构,提高代码的可维护性。`,
link: "https://example.com/typescript-large-projects",
summary: "TypeScript 在大型项目中的应用",
date: new Date("2023-09-15"),
category: "TypeScript",
},
// 添加更多博客文章...
];

View File

@@ -0,0 +1,25 @@
import emailjs from "@emailjs/browser";
interface EmailConfig {
serviceId: string;
templateId: string;
publicKey: string;
}
export const emailConfig: EmailConfig = {
serviceId: import.meta.env.VITE_EMAILJS_SERVICE_ID,
templateId: import.meta.env.VITE_EMAILJS_TEMPLATE_ID,
publicKey: import.meta.env.VITE_EMAILJS_PUBLIC_KEY,
};
export const initEmailJS = () => {
emailjs.init(emailConfig.publicKey);
};
if (
!emailConfig.serviceId ||
!emailConfig.templateId ||
!emailConfig.publicKey
) {
throw new Error("Missing required EmailJS configuration");
}

View File

@@ -0,0 +1,17 @@
interface FontConfig {
enabled: boolean;
name: string;
url: string;
preload?: boolean;
display?: "auto" | "block" | "swap" | "fallback" | "optional";
weights?: string;
}
export const fontConfig: FontConfig = {
enabled: true,
name: "LXWK",
url: "https://cdn.jsdmirror.com/gh/acanyo/mmm.sd@master/assets/font/lxwk.woff2",
preload: true,
display: "swap",
weights: "100 900",
};

View File

@@ -0,0 +1,39 @@
interface FooterLink {
text: string;
to?: string; // 内部路由
href?: string; // 外部链接
target?: string;
}
interface FooterConfig {
links: FooterLink[];
provider: {
name: string;
link: string;
logo: string;
text: string;
};
}
export const footerConfig: FooterConfig = {
links: [
{
text: "博客",
href: "https://www.godserver.cn",
target: "_blank",
},
{
text: "GitHub",
href: "https://github.com/Spaso1",
target: "_blank",
},
],
provider: {
name: "Aliyun",
link: "https://www.aliyun.com/",
logo: "https://avatars.githubusercontent.com/u/172407636?v=4",
text: "提供 CDN 加速 / 云存储服务",
},
};

View File

@@ -0,0 +1,46 @@
import { siteConfig } from "./site";
// 导出所有配置
export { siteConfig };
// 合并基础配置
export const config = {
...siteConfig,
siteUrl: "https://www.godserver.cn", // 默认值
};
// 生成 sitemap.xml 内容
export const generateSitemap = (
siteUrl: string,
) => `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>${siteUrl}/</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>weekly</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>${siteUrl}/blog</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>${siteUrl}/skills</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>${siteUrl}/contact</loc>
<lastmod>2024-03-21</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
</urlset>`;
// 生成 robots.txt 内容
export const generateRobots = (siteUrl: string) => `User-agent: *
Allow: /
Sitemap: ${siteUrl}/sitemap.xml`;

View File

@@ -0,0 +1,11 @@
export interface Tab {
id: string;
label: string;
icon: string;
}
export const tabs: Tab[] = [
{ id: "projects", label: "项目展示", icon: "🎨" },
{ id: "tools", label: "在线工具", icon: "🛠" },
{ id: "bookmarks", label: "网址导航", icon: "🔖" },
];

View File

@@ -0,0 +1,53 @@
import type { NoticeButton, NoticeConfig } from "../types/notice";
interface ExtendedNoticeButton extends NoticeButton {
type: "primary" | "secondary" | "danger";
}
interface ExtendedNoticeConfig extends NoticeConfig {
enabled: boolean;
showFireworks: boolean;
defaultShowAfter?: number | "refresh" | null;
buttons: ExtendedNoticeButton[];
}
export const noticeConfig: ExtendedNoticeConfig = {
id: "site_notice_v1",
enabled: true,
showFireworks: true,
title: "网站公告",
content: `
<div class="text-center">
<p class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">
🎉 网站改版升级公告
</p>
<div class="text-gray-600 dark:text-gray-400 space-y-2">
<p>网站已完成改版升级,新增以下功能:</p>
<ul class="list-disc list-inside">
<li>全新的深色模式支持</li>
<li>性能优化与体验提升</li>
<li>更多实用工具正在开发中</li>
</ul>
</div>
</div>
`,
width: "500px",
maskClosable: true,
showClose: true,
defaultShowAfter: null,
buttons: [
{
text: "稍后查看",
type: "secondary",
action: "close",
showAfter: "refresh",
},
{
text: "立即体验",
type: "primary",
action: "navigate",
to: "/projects",
showAfter: 3 * 60 * 60 * 1000,
},
],
};

View File

@@ -0,0 +1,41 @@
export interface Project {
id: number;
title: string;
description: string;
tags: string[];
image: string;
link?: string;
status: "completed" | "developing" | "planning";
}
export const projects: Project[] = [
{
id: 2,
title: "FindMaimaiUltra",
description:
"全新重构版本 Powered By Reisa",
tags: ["技术分享", "Blog", "Markdown","舞萌DX","中二节奏","B50","查分器","旅行"],
image: "https://picsum.photos/800/600?random=3",
link: "https://github.com/Spaso1/FindMaimaiDX_Phone",
status: "completed",
},
{
id: 3,
title: "EasyTop",
description: "服务状态监控页面,实时监控各项服务的运行状态。",
tags: ["监控", "服务状态", "实时数据"],
image: "https://picsum.photos/800/600?random=4",
link: "https://github.com/Spaso1/EasyTop",
status: "completed",
},
{
id: 4,
title: "AsTrip",
description:
"旅行规划软件",
tags: ["数据分析", "统计", "开源","旅行"],
image: "https://picsum.photos/800/600?random=5",
link: "https://github.com/Spaso1/Astrip",
status: "completed",
},
];

View File

@@ -0,0 +1,7 @@
interface RssConfig {
url: string;
}
export const rssConfig: RssConfig = {
url: "https://www.godserver.cn/rss.xml", // 直接使用完整 URL
};

View File

@@ -0,0 +1,20 @@
interface SiteInfo {
enabled: boolean;
text: string;
link: string;
position?: "top" | "bottom";
theme?: "dark" | "light";
style?: string;
linkStyle?: string;
version?: string;
}
export const siteInfo: SiteInfo = {
enabled: true,
text: "一个使用 Vue 3 + TypeScript + Vite 构建的现代化个人主页,具有博客文章展示、项目展示、联系表单等功能。",
version: "V.2.3",
link: "https://github.com/Spaso1/ReisaPage",
position: "bottom",
theme: "dark",
style: "position: fixed; bottom: 0; left: 0; width: 100%; z-index: 1000;",
};

View File

@@ -0,0 +1,39 @@
export const siteConfig = {
// 基本信息
name: "Powered by Reisa", // 作者名称
title: "FindMaimaiDX开发者 学生", // 职位头衔
siteName: "ReisaSpasol | MaimaiDX", // 网站标题
siteDescription:
"专注于Java、Spring Boot、微服务等后端技术开发的个人作品集网站", // 网站描述
author: "ReisaSpasol", // 作者信息
// 图片资源配置
images: {
logo: "./assets/icon.png", // 网站Logo
icon: "./assets/icon.png", // 网站图标
avatar: "./assets/icon.png", // 个人头像
ogImage: "./assets/icon.png", // 社交分享图片
},
// 个性化配置
slogan: "Use FindMaimai!", // 个性签名
skills: ["Java", "Spring Boot", "MySQL", "Vue", "Docker", "Git","FindMaimai"], // 技能标签
// SEO 相关配置
language: "zh-CN", // 网站语言
themeColor: "#4F46E5", // 主题色
twitterHandle: "@Spasolmodlic", // Twitter账号
githubHandle: "Spaso1", // GitHub账号
// Schema.org 结构化数据
organization: {
name: "Reisa", // 组织名称
logo: "./assets/icon.png", // 组织Logo
},
// 社交媒体链接
social: {
github: "https://github.com/acanyo", // GitHub主页
email: "astralpath@163.com", // 联系邮箱
},
};

View File

@@ -0,0 +1,33 @@
import JsonFormatterView from "@/views/tools/JsonFormatterView.vue";
import TimestampView from "@/views/tools/TimestampView.vue";
export interface Tool {
id: number;
title: string;
description: string;
tags: string[];
image: string;
component: any;
status: "completed" | "developing" | "planning";
}
export const tools: Tool[] = [
{
id: 1,
title: "JSON 格式化工具",
description: "在线 JSON 格式化工具,支持压缩、美化、验证和转换等功能",
tags: ["JSON", "格式化", "在线工具"],
image: "https://picsum.photos/800/600?random=1",
component: JsonFormatterView,
status: "completed",
},
{
id: 2,
title: "时间戳转换器",
description: "时间戳与日期格式互转工具,支持多种格式和时区设置",
tags: ["时间戳", "日期转换", "时区"],
image: "https://picsum.photos/800/600?random=2",
component: TimestampView,
status: "completed",
},
];

167
reijm-read/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,167 @@
/// <reference types="vite/client" />
declare global {
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string;
readonly VITE_APP_DESCRIPTION: string;
readonly VITE_APP_KEYWORDS: string;
readonly VITE_APP_AUTHOR: string;
readonly VITE_APP_URL: string;
readonly VITE_APP_LOGO: string;
readonly VITE_APP_GITHUB: string;
readonly VITE_APP_TWITTER: string;
readonly VITE_APP_TWITTER_URL: string;
readonly VITE_APP_THEME_COLOR: string;
readonly VITE_EMAILJS_SERVICE_ID: string;
readonly VITE_EMAILJS_TEMPLATE_ID: string;
readonly VITE_EMAILJS_PUBLIC_KEY: string;
readonly VITE_SITE_URL: string;
readonly DEV: boolean;
readonly PROD: boolean;
readonly MODE: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
readonly hot?: {
readonly data: any;
accept(): void;
accept(cb: (mod: any) => void): void;
accept(dep: string, cb: (mod: any) => void): void;
accept(deps: string[], cb: (mods: any[]) => void): void;
prune(cb: () => void): void;
dispose(cb: (data: any) => void): void;
decline(): void;
invalidate(): void;
on(event: string, cb: (...args: any[]) => void): void;
};
readonly glob: (glob: string) => Record<string, () => Promise<any>>;
}
}
// Vue 组件类型声明
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}
// Vue 宏命令类型声明
declare module "vue" {
import type { DefineComponent, Ref } from "vue";
// 生命周期钩子
export declare const onMounted: (cb: () => void) => void;
export declare const onBeforeMount: (cb: () => void) => void;
export declare const onBeforeUnmount: (cb: () => void) => void;
export declare const onUnmounted: (cb: () => void) => void;
export declare const onActivated: (cb: () => void) => void;
export declare const onDeactivated: (cb: () => void) => void;
export declare const onBeforeUpdate: (cb: () => void) => void;
export declare const onUpdated: (cb: () => void) => void;
export declare const onErrorCaptured: (cb: (err: unknown) => void) => void;
// 组合式 API
export declare const createApp: any;
export declare const ref: <T>(value: T) => Ref<T>;
export declare const computed: <T>(getter: () => T) => Ref<T>;
export declare const watch: typeof import("vue").watch;
export declare const watchEffect: (effect: () => void) => void;
export declare const reactive: <T extends object>(target: T) => T;
export declare const readonly: <T extends object>(target: T) => Readonly<T>;
// 组件相关
export declare const defineProps: {
<T extends Record<string, any>>(): Readonly<T>;
<T extends Record<string, any>>(props: T): Readonly<T>;
};
export declare const defineEmits: {
<T extends Record<string, any>>(): T;
<T extends Record<string, any>>(emits: T): T;
};
export declare const defineExpose: (exposed?: Record<string, any>) => void;
export declare const withDefaults: <
Props,
Defaults extends { [K in keyof Props]?: Props[K] },
>(
props: Props,
defaults: Defaults,
) => {
[K in keyof Props]: K extends keyof Defaults ? Defaults[K] : Props[K];
};
}
// 第三方模块声明
declare module "vite" {
import type { UserConfig, Plugin } from "vite";
export interface ViteConfig extends UserConfig {
plugins?: Plugin[];
}
export const defineConfig: <T extends ViteConfig>(config: T) => T;
}
declare module "vue-router" {
import type { Component } from "vue";
export interface RouteMeta {
title?: string;
description?: string;
keywords?: string;
transition?: string;
requiresAuth?: boolean;
[key: string]: any;
}
export interface RouteRecordRaw {
path: string;
name?: string;
component?: Component | (() => Promise<Component>);
components?: { [key: string]: Component };
redirect?: string | { name: string };
meta?: RouteMeta;
children?: RouteRecordRaw[];
}
export interface Router {
push(to: string | { name: string; params?: any }): Promise<void>;
}
export interface Route {
meta: RouteMeta;
params: Record<string, string>;
query: Record<string, string>;
hash: string;
path: string;
fullPath: string;
matched: RouteRecordRaw[];
}
// 添加 RouterLink 组件类型
export interface RouterLinkProps {
to: string | { name: string; params?: Record<string, any> };
replace?: boolean;
activeClass?: string;
exactActiveClass?: string;
custom?: boolean;
ariaCurrentValue?: string;
}
export const RouterLink: Component<RouterLinkProps>;
export const RouterView: Component;
export const createRouter: any;
export const createWebHistory: any;
export const useRoute: () => Route;
export const useRouter: () => Router;
}
declare module "@emailjs/browser" {
const emailjs: any;
export default emailjs;
}
declare module "vite-plugin-compression";
declare module "vite-plugin-image-optimizer";

View File

@@ -0,0 +1,9 @@
import mitt from 'mitt';
type Events = {
'refresh-user-info': void;
};
const eventBus = mitt<Events>();
export default eventBus;

14
reijm-read/src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import "./assets/styles/main.css";
import { initFontLoading } from "./utils/font";
const app = createApp(App);
app.use(router);
app.mount("#app");
// 初始化字体加载
initFontLoading().then(() => {
console.log("Font initialization complete");
});

View File

@@ -0,0 +1,49 @@
import { createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
const routes: RouteRecordRaw[] = [
{
path: "/",
name: "home",
component: () => import("@/views/HomeView.vue"),
meta: {
title: "ReiJM",
},
},
{
path: "/user",
name: "user",
component: () => import("@/views/User.vue"),
meta: {
title: "user",
}
},
{
path: "/manga/:id",
name: "manga",
component: () => import("@/views/Manga.vue"),
meta: {
title: "manga",
hideHeader: true // 添加此元信息
}
}
];
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition;
}
return { top: 0 };
},
});
// 路由标题
router.beforeEach((to, from, next) => {
document.title = `${to.meta.title || "首页"} | Reisa`;
next();
});
export default router;

View File

@@ -0,0 +1,51 @@
export interface BlogPost {
title: string;
link: string;
content: string;
creator: string;
pubDate: string;
categories?: string[];
description?: string;
}
export async function fetchBlogPosts(rssUrl: string): Promise<BlogPost[]> {
try {
const response = await fetch(rssUrl, {
headers: {
Accept: "application/xml, text/xml, */*",
},
});
const xmlText = await response.text();
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlText, "text/xml");
const items = xmlDoc.querySelectorAll("item");
return Array.from(items).map((item) => {
const getElementText = (tagName: string) =>
item.querySelector(tagName)?.textContent?.trim() || "";
const getCleanContent = (content: string) => {
return content.replace("<![CDATA[", "").replace("]]>", "");
};
const description = getElementText("description");
const content = description.includes("CDATA")
? getCleanContent(description)
: description;
return {
title: getCleanContent(getElementText("title")),
link: getElementText("link"),
content: content,
creator: "Reisa",
pubDate: getElementText("pubDate"),
categories: [getElementText("category")].filter(Boolean),
description: content,
};
});
} catch (error) {
console.error("获取博客文章失败:", error);
return [];
}
}

79
reijm-read/src/style.css Normal file
View File

@@ -0,0 +1,79 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
.card {
padding: 2em;
}
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

View File

@@ -0,0 +1,8 @@
export interface BlogPost {
title: string;
link: string;
date: Date;
description: string;
category?: string;
image?: string;
}

17
reijm-read/src/types/global.d.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
export {}; // 确保文件被视为模块
interface Window {
$toast: {
show: (text: string, type?: "success" | "error" | "info") => void;
success: (text: string) => void;
error: (text: string) => void;
info: (text: string) => void;
};
liquidGlass?: {
destroy: () => void;
};
}
declare module '*.js' {
const content: any;
export default content;
}

View File

@@ -0,0 +1,19 @@
export interface NoticeButton {
text: string;
type?: string;
action: "close" | "navigate" | "link" | "custom";
to?: string;
href?: string;
handler?: () => void;
showAfter?: number | "refresh" | null;
}
export interface NoticeConfig {
id: string;
title: string;
content: string;
width?: string;
maskClosable?: boolean;
showClose?: boolean;
buttons: NoticeButton[];
}

View File

@@ -0,0 +1,77 @@
interface ConsoleInfo {
text: string;
version: string | undefined;
link: string;
style?: string;
}
export const printConsoleInfo = (info: ConsoleInfo) => {
// 标题样式
const titleStyle = [
"background: linear-gradient(45deg, #2193b0, #6dd5ed)",
"color: white",
"padding: 12px 20px",
"border-radius: 4px 0 0 4px",
"font-weight: bold",
"font-size: 13px",
"text-shadow: 0 1px 1px rgba(0,0,0,0.2)",
"box-shadow: inset 0 -3px 0 rgba(0,0,0,0.1)",
].join(";");
// 版本样式
const versionStyle = [
"background: linear-gradient(45deg, #6dd5ed, #2193b0)",
"color: white",
"padding: 12px 20px",
"font-weight: bold",
"font-size: 13px",
"text-shadow: 0 1px 1px rgba(0,0,0,0.2)",
"box-shadow: inset 0 -3px 0 rgba(0,0,0,0.1)",
].join(";");
// 链接样式
const linkStyle = [
"background: linear-gradient(45deg, #2193b0, #6dd5ed)",
"color: white",
"padding: 12px 20px",
"border-radius: 0 4px 4px 0",
"font-weight: bold",
"font-size: 13px",
"text-shadow: 0 1px 1px rgba(0,0,0,0.2)",
"box-shadow: inset 0 -3px 0 rgba(0,0,0,0.1)",
].join(";");
// 主信息
console.log(
`%c ${info.text} %c ${info.version || ""} %c ${info.link} `,
titleStyle,
versionStyle,
linkStyle,
);
// 欢迎信息
const welcomeStyle = [
"color: #2193b0",
"font-size: 14px",
"font-weight: bold",
"padding: 12px 20px",
"margin: 20px 0",
"border: 2px solid #2193b0",
"border-radius: 4px",
"background: rgba(33,147,176,0.1)",
"text-shadow: 0 1px 1px rgba(255,255,255,0.8)",
].join(";");
console.log("%c欢迎访问我的个人主页", welcomeStyle);
// 装饰线
const lineStyle = [
"font-size: 1px",
"padding: 0",
"margin: 4px 0",
"line-height: 1px",
"background: linear-gradient(to right, #2193b0, #6dd5ed)",
].join(";");
console.log("%c ", `${lineStyle}; padding: 2px 125px;`);
};

View File

@@ -0,0 +1,42 @@
/**
* @license
* Copyright (c) 2024 Reisa
*
* This file is part of the project and must retain the author's credit.
* Modifications to this file must maintain original attribution.
* Commercial use requires explicit permission.
*/
// 使用一个自执行函数来增加混淆难度
export const createCopyrightGuard = (() => {
const key = btoa("Reisa" + new Date().getFullYear());
return (element: HTMLElement | null) => {
if (!element) return false;
// 随机检查函数
const checks = [
() => element.textContent?.includes("©"),
() => element.textContent?.includes("Reisa"),
() => element.textContent?.includes("All rights"),
() => element.querySelector("a")?.href.includes("godserver.cn"),
() => !element.textContent?.includes("Modified"),
() => element.children.length >= 3,
];
// 随机打乱检查顺序
const shuffledChecks = checks.sort(() => Math.random() - 0.5);
// 执行所有检查
const isValid = shuffledChecks.every((check) => {
try {
return check();
} catch {
return false;
}
});
return isValid;
};
})();

View File

@@ -0,0 +1,10 @@
declare module '@/utils/encryptionUtil' {
const encryptionUtil: {
methods: {
encryptAndCompress(data: string): string;
decompressAndDecrypt(encryptedData: string, key?: any, iv?: any): string;
removePadding(byteArray: Uint8Array): Uint8Array;
};
};
export default encryptionUtil;
}

View File

@@ -0,0 +1,121 @@
import CryptoJS from 'crypto-js';
import pako from 'pako';
const key2 = CryptoJS.enc.Utf8.parse(',Lscj312.;[]sc`1dsajcjc;;wislacx'); // 32字节的密钥
const iv2 = CryptoJS.enc.Utf8.parse(',>ew:[7890;,wd[2'); // 16字节的IV
// 新增PKCS5Padding移除函数更健壮
function removePadding(data) {
if (data.length === 0) return data;
const paddingLength = data[data.length - 1];
if (data.length < paddingLength) {
console.warn("Padding length greater than data length");
return data;
}
return data.slice(0, data.length - paddingLength);
}
// 辅助函数打印ArrayBuffer内容调试用
function arrayBufferToString(buffer) {
return [...new Uint8Array(buffer)].map(b => b.toString(16).padStart(2, '0')).join(' ');
}
export default {
methods: {
encryptAndCompress(data) {
try {
// 压缩数据
const dataArray = new TextEncoder().encode(data);
const compressed = pako.gzip(dataArray);
// 加密数据
const wordArray = CryptoJS.lib.WordArray.create(compressed);
const encrypted = CryptoJS.AES.encrypt(wordArray, key2, {
iv: iv2,
mode: CryptoJS.mode.CBC,
padding: CryptoJS.pad.Pkcs7
});
return encrypted.toString();
} catch (error) {
console.error("Encryption error:", error);
throw error;
}
},
decompressAndDecrypt(encryptedData, key = key2, iv = iv2) {
try {
if (!encryptedData || encryptedData.length < 16) {
throw new Error("Invalid or empty encrypted data");
}
// 确保 Base64 格式正确
const properB64Str = encryptedData.trim().replace(/\s+/g, '');
if (!/^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)?$/.test(properB64Str)) {
throw new Error("Invalid Base64 string");
}
// 解密数据
const cipherParams = CryptoJS.lib.CipherParams.create({ ciphertext: CryptoJS.enc.Base64.parse(properB64Str) });
const decrypted = CryptoJS.AES.decrypt(cipherParams, key, {
iv: iv,
padding: CryptoJS.pad.Pkcs7
});
if (!decrypted || !decrypted.words) {
throw new Error("Decryption failed or returned invalid data");
}
function wordArrayToUint8Array(wordArray) {
const byteArray = [];
const words = wordArray.words;
const sigBytes = wordArray.sigBytes;
for (let i = 0; i < sigBytes; i++) {
byteArray.push((words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff);
}
return new Uint8Array(byteArray);
}
const compressedArray = wordArrayToUint8Array(decrypted);
const debugHex = [...compressedArray.slice(0, 32)].map(b => b.toString(16).padStart(2, '0')).join(' ');
// 移除PKCS7 Padding
const decryptedWithoutPadding = removePadding(compressedArray);
// 手动验证是否是合法的GZIP格式
if (decryptedWithoutPadding.length < 10 ||
(decryptedWithoutPadding[0] !== 0x1f || decryptedWithoutPadding[1] !== 0x8b)) {
// 调试输出前32字节
const debugHexPostPadding = [...decryptedWithoutPadding.slice(0, 32)].map(b => b.toString(16).padStart(2, '0')).join('');
return '';
}
// 解压数据
let decompressed;
try {
decompressed = pako.ungzip(decryptedWithoutPadding);
} catch (e) {
console.error("GZIP decompression failed", e);
throw new Error("Failed to decompress data - invalid gzip format");
}
if (!decompressed || decompressed.length === 0) {
throw new Error("Empty decompressed data");
}
// 解压成功,返回解压后的文本数据
return new TextDecoder().decode(decompressed);
} catch (error) {
console.error("Decryption error:", error);
throw error;
}
},
// 移除PKCS7 Padding的函数
removePadding(byteArray) {
// 假设是PKCS7 padding最后一个字节的值即为填充的字节数
const paddingLength = byteArray[byteArray.length - 1];
return byteArray.slice(0, byteArray.length - paddingLength);
}
}
};

View File

@@ -0,0 +1,21 @@
export const initFontLoading = async () => {
try {
// 等待字体加载
await document.fonts.load('1em "LXWK"');
// 检查字体是否加载成功
const isLoaded = document.fonts.check('1em "LXWK"');
console.log("Font loaded:", isLoaded);
if (!isLoaded) {
// 如果字体加载失败,使用系统字体
document.documentElement.style.fontFamily =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
}
} catch (error) {
console.error("Font loading error:", error);
// 出错时使用系统字体
document.documentElement.style.fontFamily =
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif';
}
};

View File

@@ -0,0 +1,292 @@
// Vanilla JS Liquid Glass Effect - Paste into browser console
// Created by Shu Ding (https://github.com/shuding/liquid-glass) in 2025.
(function() {
'use strict';
// Check if liquid glass already exists and destroy it
if (window.liquidGlass) {
window.liquidGlass.destroy();
console.log('Previous liquid glass effect removed.');
}
// Utility functions
function smoothStep(a, b, t) {
t = Math.max(0, Math.min(1, (t - a) / (b - a)));
return t * t * (3 - 2 * t);
}
function length(x, y) {
return Math.sqrt(x * x + y * y);
}
function roundedRectSDF(x, y, width, height, radius) {
const qx = Math.abs(x) - width + radius;
const qy = Math.abs(y) - height + radius;
return Math.min(Math.max(qx, qy), 0) + length(Math.max(qx, 0), Math.max(qy, 0)) - radius;
}
function texture(x, y) {
return { type: 't', x, y };
}
// Generate unique ID
function generateId() {
return 'liquid-glass-' + Math.random().toString(36).substr(2, 9);
}
// Main Shader class
class Shader {
constructor(options = {}) {
this.width = options.width || 100;
this.height = options.height || 100;
this.fragment = options.fragment || ((uv) => texture(uv.x, uv.y));
this.canvasDPI = 1;
this.id = generateId();
this.offset = 10; // Viewport boundary offset
this.mouse = { x: 0, y: 0 };
this.mouseUsed = false;
this.createElement();
this.setupEventListeners();
this.updateShader();
}
createElement() {
// Create container
this.container = document.createElement('div');
this.container.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: ${this.width}px;
height: ${this.height}px;
overflow: hidden;
border-radius: 150px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.25), 0 -10px 25px inset rgba(0, 0, 0, 0.15);
cursor: grab;
backdrop-filter: url(#${this.id}_filter) blur(0.25px) contrast(1.2) brightness(1.05) saturate(1.1);
z-index: 9999;
pointer-events: auto;
`;
// Create SVG filter
this.svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
this.svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
this.svg.setAttribute('width', '0');
this.svg.setAttribute('height', '0');
this.svg.style.cssText = `
position: fixed;
top: 0;
left: 0;
pointer-events: none;
z-index: 9998;
`;
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
const filter = document.createElementNS('http://www.w3.org/2000/svg', 'filter');
filter.setAttribute('id', `${this.id}_filter`);
filter.setAttribute('filterUnits', 'userSpaceOnUse');
filter.setAttribute('colorInterpolationFilters', 'sRGB');
filter.setAttribute('x', '0');
filter.setAttribute('y', '0');
filter.setAttribute('width', this.width.toString());
filter.setAttribute('height', this.height.toString());
this.feImage = document.createElementNS('http://www.w3.org/2000/svg', 'feImage');
this.feImage.setAttribute('id', `${this.id}_map`);
this.feImage.setAttribute('width', this.width.toString());
this.feImage.setAttribute('height', this.height.toString());
this.feDisplacementMap = document.createElementNS('http://www.w3.org/2000/svg', 'feDisplacementMap');
this.feDisplacementMap.setAttribute('in', 'SourceGraphic');
this.feDisplacementMap.setAttribute('in2', `${this.id}_map`);
this.feDisplacementMap.setAttribute('xChannelSelector', 'R');
this.feDisplacementMap.setAttribute('yChannelSelector', 'G');
filter.appendChild(this.feImage);
filter.appendChild(this.feDisplacementMap);
defs.appendChild(filter);
this.svg.appendChild(defs);
// Create canvas for displacement map (hidden)
this.canvas = document.createElement('canvas');
this.canvas.width = this.width * this.canvasDPI;
this.canvas.height = this.height * this.canvasDPI;
this.canvas.style.display = 'none';
this.context = this.canvas.getContext('2d');
}
constrainPosition(x, y) {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Calculate boundaries with offset
const minX = this.offset;
const maxX = viewportWidth - this.width - this.offset;
const minY = this.offset;
const maxY = viewportHeight - this.height - this.offset;
// Constrain position
const constrainedX = Math.max(minX, Math.min(maxX, x));
const constrainedY = Math.max(minY, Math.min(maxY, y));
return { x: constrainedX, y: constrainedY };
}
setupEventListeners() {
let isDragging = false;
let startX, startY, initialX, initialY;
this.container.addEventListener('mousedown', (e) => {
isDragging = true;
this.container.style.cursor = 'grabbing';
startX = e.clientX;
startY = e.clientY;
const rect = this.container.getBoundingClientRect();
initialX = rect.left;
initialY = rect.top;
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (isDragging) {
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
// Calculate new position
const newX = initialX + deltaX;
const newY = initialY + deltaY;
// Constrain position within viewport bounds
const constrained = this.constrainPosition(newX, newY);
this.container.style.left = constrained.x + 'px';
this.container.style.top = constrained.y + 'px';
this.container.style.transform = 'none';
}
// Update mouse position for shader
const rect = this.container.getBoundingClientRect();
this.mouse.x = (e.clientX - rect.left) / rect.width;
this.mouse.y = (e.clientY - rect.top) / rect.height;
if (this.mouseUsed) {
this.updateShader();
}
});
document.addEventListener('mouseup', () => {
isDragging = false;
this.container.style.cursor = 'grab';
});
// Handle window resize to maintain constraints
window.addEventListener('resize', () => {
const rect = this.container.getBoundingClientRect();
const constrained = this.constrainPosition(rect.left, rect.top);
if (rect.left !== constrained.x || rect.top !== constrained.y) {
this.container.style.left = constrained.x + 'px';
this.container.style.top = constrained.y + 'px';
this.container.style.transform = 'none';
}
});
}
updateShader() {
const mouseProxy = new Proxy(this.mouse, {
get: (target, prop) => {
this.mouseUsed = true;
return target[prop];
}
});
this.mouseUsed = false;
const w = this.width * this.canvasDPI;
const h = this.height * this.canvasDPI;
const data = new Uint8ClampedArray(w * h * 4);
let maxScale = 0;
const rawValues = [];
for (let i = 0; i < data.length; i += 4) {
const x = (i / 4) % w;
const y = Math.floor(i / 4 / w);
const pos = this.fragment(
{ x: x / w, y: y / h },
mouseProxy
);
const dx = pos.x * w - x;
const dy = pos.y * h - y;
maxScale = Math.max(maxScale, Math.abs(dx), Math.abs(dy));
rawValues.push(dx, dy);
}
maxScale *= 0.5;
let index = 0;
for (let i = 0; i < data.length; i += 4) {
const r = rawValues[index++] / maxScale + 0.5;
const g = rawValues[index++] / maxScale + 0.5;
data[i] = r * 255;
data[i + 1] = g * 255;
data[i + 2] = 0;
data[i + 3] = 255;
}
this.context.putImageData(new ImageData(data, w, h), 0, 0);
this.feImage.setAttributeNS('http://www.w3.org/1999/xlink', 'href', this.canvas.toDataURL());
this.feDisplacementMap.setAttribute('scale', (maxScale / this.canvasDPI).toString());
}
appendTo(parent) {
parent.appendChild(this.svg);
parent.appendChild(this.container);
}
destroy() {
this.svg.remove();
this.container.remove();
this.canvas.remove();
}
}
// Create the liquid glass effect
function createLiquidGlass() {
// Create shader
const shader = new Shader({
width: 300,
height: 200,
fragment: (uv, mouse) => {
const ix = uv.x - 0.5;
const iy = uv.y - 0.5;
const distanceToEdge = roundedRectSDF(
ix,
iy,
0.3,
0.2,
0.6
);
const displacement = smoothStep(0.8, 0, distanceToEdge - 0.15);
const scaled = smoothStep(0, 1, displacement);
return texture(ix * scaled + 0.5, iy * scaled + 0.5);
}
});
// Add to page
shader.appendTo(document.body);
console.log('Liquid Glass effect created! Drag the glass around the page.');
// Return shader instance so it can be removed if needed
window.liquidGlass = shader;
}
// Initialize
createLiquidGlass();
})();

View File

@@ -0,0 +1,43 @@
import type { BlogPost } from "../types/blog";
import { rssConfig } from "@/config/rss";
// 使用 RSS2JSON API 转换 RSS 为 JSON
export const fetchBlogPosts = async (): Promise<BlogPost[]> => {
try {
if (!rssConfig.url) {
throw new Error("RSS URL is not defined");
}
// 使用 RSS2JSON 服务
const apiUrl = `https://api.rss2json.com/v1/api.json?rss_url=${encodeURIComponent(rssConfig.url)}`;
const response = await fetch(apiUrl, {
headers: {
"Cache-Control": "no-cache",
"Pragma": "no-cache"
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
if (data.status !== "ok") {
throw new Error("Failed to fetch RSS feed");
}
return data.items.map(
(item: any): BlogPost => ({
title: item.title,
link: item.link,
date: new Date(item.pubDate),
description: item.content || item.description,
category: item.category || "默认分类",
}),
);
} catch (error) {
console.error("获取博客文章失败:", error);
return [];
}
};

View File

@@ -0,0 +1,70 @@
// 禁用开发者工具和快捷键
export const initSecurityMeasures = () => {
let warningShown = false;
let warningTimeout: number | null = null;
// 显示友好提示
const showWarning = () => {
if (!warningShown) {
warningShown = true;
const warningDialog = document.querySelector<any>("#warning-dialog");
if (warningDialog) {
warningDialog.open();
// 清除之前的定时器
if (warningTimeout) {
clearTimeout(warningTimeout);
}
// 3秒后自动关闭
warningTimeout = window.setTimeout(() => {
warningDialog.close();
warningShown = false;
warningTimeout = null;
}, 3000);
}
}
};
// 监听右键菜单
document.addEventListener(
"contextmenu",
(e: MouseEvent) => {
e.preventDefault();
showWarning();
},
true,
);
// 监听开发者工具快捷键
document.addEventListener(
"keydown",
(e: KeyboardEvent) => {
const isMacCmd = e.metaKey && !e.ctrlKey;
const isWinCtrl = e.ctrlKey && !e.metaKey;
if (
e.key === "F12" ||
((isMacCmd || isWinCtrl) &&
e.shiftKey &&
["I", "J", "C", "U"].includes(e.key.toUpperCase()))
) {
e.preventDefault();
showWarning();
}
},
true,
);
// 检测开发者工具状态
const checkDevTools = () => {
const threshold = 160;
const widthThreshold = window.outerWidth - window.innerWidth > threshold;
const heightThreshold = window.outerHeight - window.innerHeight > threshold;
if (widthThreshold || heightThreshold) {
showWarning();
}
};
// 每 1000ms 检查一次
setInterval(checkDevTools, 1000);
};

View File

@@ -0,0 +1,42 @@
<template>
<!-- 点击按钮跳转到后端登录接口 -->
<a
:href="loginUrl"
class="github-login-btn"
>
<svg class="github-icon" viewBox="0 0 24 24" width="20" height="20" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"></path>
</svg>
Login with Github
</a>
</template>
<script setup>
// 后端登录接口地址(根据实际环境调整,如生产环境可能需要加域名)
const loginUrl = '/api/user/login'
</script>
<style scoped>
.github-login-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background-color: #24292e;
color: white;
text-decoration: none;
border-radius: 6px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
font-weight: 500;
transition: background-color 0.2s;
}
.github-login-btn:hover {
background-color: #1d2125;
}
.github-icon {
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<template>
<div class="home-view">
</div>
</template>
<style scoped>
.home-view {
background-color: black;
color: white;
}
</style>

View File

@@ -0,0 +1,894 @@
<!-- Manga.vue -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import axios from 'axios'
const route = useRoute()
// 漫画专辑数据
interface AlbumData {
album_id: string
name: string
image_urls: string[]
nums: number[]
authors?: string[]
tags?: string[]
}
// 漫画图片列表(从 jm.vue 中获取)
const mangaImages = ref<string[]>([])
const mangaNums = ref<number[]>([])
const albumInfo = ref<Omit<AlbumData, 'image_urls' | 'nums'> | null>(null)
// 阅读器状态
const showMenu = ref(false)
const isFullscreen = ref(false)
const showDrawer = ref(false)
const currentImageIndex = ref(0) // 当前显示的图片索引
const imageStates = ref<Array<{ scale: number; translateX: number; translateY: number }>>([])
const loading = ref(true)
const canvasImages = ref<string[]>([]) // 解码后的图片
const abortLoading = ref(false) // 取消加载标志
// 计算是否应该隐藏头部(用于样式调整)
const shouldHideHeader = computed(() => {
return route.meta?.hideHeader === true
})
// 计算当前页码信息
const currentPageInfo = computed(() => {
return `${currentImageIndex.value + 1} / ${mangaImages.value.length}`
})
// 获取专辑数据
const fetchAlbum = async (id: string) => {
console.log('尝试获取专辑数据ID:', id)
if (!id) {
console.log('ID为空无法获取专辑数据')
return
}
try {
loading.value = true
console.log('发送请求到:', `/api/manga/read?mangaId=${id}`)
const response = await axios.get(`/api/manga/read?mangaId=${id}`, {
maxRedirects: 5,
validateStatus: (status) => status < 500,
})
console.log('收到响应:', response)
let data: AlbumData
if (response.status === 307 || response.status === 302) {
const newUrl = new URL(response.headers.location, response.config.url).href
const redirectResponse = await axios.get(newUrl)
data = redirectResponse.data
} else {
data = response.data
}
// 设置专辑信息
albumInfo.value = {
album_id: data.album_id,
name: data.name,
authors: data.authors || [],
tags: data.tags || []
}
// 设置图片 URL 和解码参数
mangaImages.value = data.image_urls || []
mangaNums.value = data.nums || []
// 初始化图片状态
imageStates.value = (data.image_urls || []).map(() => ({
scale: 1,
translateX: 0,
translateY: 0
}))
// 初始化画布图片数组
canvasImages.value = new Array((data.image_urls || []).length)
// 开始加载图片
if (data.image_urls && data.nums) {
loadImagesInBatches(data.image_urls, data.nums)
} else {
loading.value = false
}
} catch (error) {
console.error('加载专辑失败', error)
loading.value = false
}
}
// 分批加载图片每次加载4张
const loadImagesInBatches = async (urls: string[], nums: number[]) => {
const batchSize = 4 // 每批加载4张图片
let currentIndex = 0
abortLoading.value = false // 开始新加载任务前重置标志
while (currentIndex < urls.length && !abortLoading.value) {
const batchUrls = urls.slice(currentIndex, currentIndex + batchSize)
const batchNums = nums.slice(currentIndex, currentIndex + batchSize)
const loadPromises = batchUrls.map((url, index) => {
return new Promise<void>((resolve) => {
if (abortLoading.value) return resolve()
const img = new Image()
img.crossOrigin = 'anonymous'
img.src = url
img.onload = () => {
if (abortLoading.value) return resolve()
if (batchNums[index] !== 0) {
const decodedCanvas = decodeImage(img, batchNums[index])
const base64 = decodedCanvas.toDataURL()
canvasImages.value[currentIndex + index] = base64
} else {
canvasImages.value[currentIndex + index] = url
}
resolve()
}
img.onerror = () => {
if (abortLoading.value) return resolve()
console.error(`图片加载失败: ${url}`)
// 加载失败时使用原始 URL
canvasImages.value[currentIndex + index] = url
resolve()
}
})
})
await Promise.all(loadPromises)
currentIndex += batchSize
}
loading.value = false
}
// JS 版 decodeImage等效 Java 方法)
const decodeImage = (imgSrc: HTMLImageElement, num: number): HTMLCanvasElement => {
if (num === 0) {
const canvas = document.createElement('canvas')
canvas.width = imgSrc.width
canvas.height = imgSrc.height
const ctx = canvas.getContext('2d')
ctx?.drawImage(imgSrc, 0, 0)
return canvas
}
const w = imgSrc.width
const h = imgSrc.height
const canvas = document.createElement('canvas')
canvas.width = w
canvas.height = h
const ctx = canvas.getContext('2d')!
const over = h % num
for (let i = 0; i < num; i++) {
let move = Math.floor(h / num)
let ySrc = h - move * (i + 1) - over
let yDst = move * i
if (i === 0) {
move += over
} else {
yDst += over
}
const srcRect = { x: 0, y: ySrc, width: w, height: move }
const dstRect = { x: 0, y: yDst, width: w, height: move }
const tempCanvas = document.createElement('canvas')
tempCanvas.width = w
tempCanvas.height = move
const tempCtx = tempCanvas.getContext('2d')!
tempCtx.drawImage(
imgSrc,
srcRect.x,
srcRect.y,
srcRect.width,
srcRect.height,
0,
0,
srcRect.width,
srcRect.height
)
ctx.drawImage(
tempCanvas,
0,
0,
tempCanvas.width,
tempCanvas.height,
dstRect.x,
dstRect.y,
dstRect.width,
dstRect.height
)
}
return canvas
}
// 切换抽屉菜单显示
const toggleDrawer = () => {
showDrawer.value = !showDrawer.value
}
// 切换顶部菜单显示
const toggleMenu = () => {
showMenu.value = !showMenu.value
}
// 进入全屏模式
const enterFullscreen = () => {
const element = document.documentElement
if (element.requestFullscreen) {
element.requestFullscreen()
isFullscreen.value = true
}
}
// 退出全屏模式
const exitFullscreen = () => {
if (document.exitFullscreen) {
document.exitFullscreen()
isFullscreen.value = false
}
}
// 重置所有图片的缩放
const resetAllImages = () => {
imageStates.value = imageStates.value.map(() => ({
scale: 1,
translateX: 0,
translateY: 0
}))
}
// 处理双指缩放(只响应真正的触摸事件)
let initialDistance = 0
let initialScale = 1
const getDistance = (touch1: Touch, touch2: Touch) => {
const dx = touch1.clientX - touch2.clientX
const dy = touch1.clientY - touch2.clientY
return Math.sqrt(dx * dx + dy * dy)
}
const handleTouchStart = (index: number, event: TouchEvent) => {
if (event.touches.length === 2) {
// 双指触摸开始
initialDistance = getDistance(event.touches[0], event.touches[1])
initialScale = imageStates.value[index].scale
} else if (event.touches.length === 1) {
// 单指触摸,可能是滚动,不阻止默认行为
return
}
}
const handleTouchMove = (index: number, event: TouchEvent) => {
if (event.touches.length === 2) {
// 双指缩放
event.preventDefault() // 只在双指操作时阻止默认行为
const currentDistance = getDistance(event.touches[0], event.touches[1])
const scale = initialScale * (currentDistance / initialDistance)
// 限制缩放范围
const clampedScale = Math.min(Math.max(scale, 1), 3)
imageStates.value[index].scale = clampedScale
}
}
// 处理滚轮缩放(仅鼠标滚轮)
const handleWheel = (index: number, event: WheelEvent) => {
// 确保是鼠标滚轮事件而不是触摸板手势
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) * 2) {
// 可能是水平滚动,不处理缩放
return
}
event.preventDefault()
const currentState = imageStates.value[index]
if (event.deltaY < 0) {
// 向上滚动放大
if (currentState.scale < 3) {
currentState.scale *= 1.1
}
} else {
// 向下滚动缩小
if (currentState.scale > 1) {
currentState.scale /= 1.1
if (currentState.scale <= 1) {
currentState.scale = 1
currentState.translateX = 0
currentState.translateY = 0
}
}
}
}
// 跳转到指定图片
const scrollToImage = (index: number) => {
if (index < 0 || index >= mangaImages.value.length) return
const element = document.getElementById(`image-${index}`)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
currentImageIndex.value = index
// 关闭抽屉菜单
showDrawer.value = false
}
}
// 跳转到下一张图片
const nextImage = () => {
if (currentImageIndex.value < mangaImages.value.length - 1) {
scrollToImage(currentImageIndex.value + 1)
}
}
// 跳转到上一张图片
const prevImage = () => {
if (currentImageIndex.value > 0) {
scrollToImage(currentImageIndex.value - 1)
}
}
// 节流函数
const throttle = (func: Function, limit: number) => {
let inThrottle: boolean
return function() {
const args = arguments
const context = this
if (!inThrottle) {
func.apply(context, args)
inThrottle = true
setTimeout(() => inThrottle = false, limit)
}
}
}
// 监听滚动事件,更新当前图片索引
const handleScroll = () => {
const containers = document.querySelectorAll('.image-container')
let currentIndex = 0
// 使用更精确的计算方式确定当前视口中的图片
for (let i = 0; i < containers.length; i++) {
const rect = containers[i].getBoundingClientRect()
// 当图片的任意部分在视口中时就认为是当前图片
if (rect.bottom > 0 && rect.top < window.innerHeight) {
// 优先选择图片中心点在视口中央的图片
if (Math.abs(rect.top + rect.height/2 - window.innerHeight/2) <
Math.abs(containers[currentIndex].getBoundingClientRect().top +
containers[currentIndex].getBoundingClientRect().height/2 - window.innerHeight/2)) {
currentIndex = i
}
}
}
// 只有当索引真正改变时才更新
if (currentImageIndex.value !== currentIndex) {
currentImageIndex.value = currentIndex
}
}
// 使用节流优化滚动处理
const throttledHandleScroll = throttle(handleScroll, 100)
// 键盘事件处理
const handleKeydown = (event: KeyboardEvent) => {
switch (event.key) {
case 'ArrowLeft':
prevImage()
event.preventDefault()
break
case 'ArrowRight':
nextImage()
event.preventDefault()
break
case 'Escape':
if (isFullscreen.value) {
exitFullscreen()
}
showDrawer.value = false
break
}
}
// 监听路由参数变化
watch(
() => route.params.id,
(newId) => {
console.log('路由参数变化:', newId)
if (newId) {
abortLoading.value = true // 取消之前的加载
fetchAlbum(newId as string)
}
},
{ immediate: true }
)
// 组件挂载时添加事件监听器
onMounted(() => {
window.addEventListener('keydown', handleKeydown)
// 直接监听 manga-reader 元素的滚动事件,而不是 manga-content
const mangaReader = document.querySelector('.manga-reader')
if (mangaReader) {
mangaReader.addEventListener('scroll', throttledHandleScroll)
}
// 初始化当前图片索引
handleScroll()
})
// 组件卸载时移除事件监听器
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown)
const mangaReader = document.querySelector('.manga-content')
if (mangaReader) {
mangaReader.removeEventListener('scroll', throttledHandleScroll)
}
abortLoading.value = true // 取消加载
})
</script>
<template>
<div class="manga-reader"
@click="toggleMenu"
:class="{ 'no-header': shouldHideHeader }">
<!-- &lt;!&ndash; 始终可见的页码显示 &ndash;&gt;-->
<!-- <div class="page-indicator">-->
<!-- {{ currentPageInfo }}-->
<!-- </div>-->
<!-- 顶部菜单栏 -->
<div
class="menu-bar"
:class="{ visible: showMenu }"
@click.stop
>
<div class="menu-content">
<span class="page-info">{{ currentPageInfo }}</span>
<button @click="prevImage" :disabled="currentImageIndex === 0 || loading">上一张</button>
<button @click="nextImage" :disabled="currentImageIndex === mangaImages.length - 1 || loading">下一张</button>
<button @click="isFullscreen ? exitFullscreen() : enterFullscreen()">
{{ isFullscreen ? '退出全屏' : '全屏' }}
</button>
<button @click="toggleDrawer">菜单</button>
</div>
</div>
<!-- 左侧抽屉菜单 -->
<div
class="drawer-overlay"
:class="{ 'drawer-open': showDrawer }"
@click="toggleDrawer"
>
<div
class="drawer"
:class="{ 'drawer-open': showDrawer }"
@click.stop
>
<div class="drawer-content">
<div class="drawer-header">
<h3>ReiJM</h3>
<button @click="toggleDrawer" class="close-btn">×</button>
</div>
<div class="drawer-body">
<button @click="isFullscreen ? exitFullscreen() : enterFullscreen()" class="drawer-item">
{{ isFullscreen ? '退出全屏' : '进入全屏' }}
</button>
<div class="page-list">
<h4>页面列表</h4>
<div
v-for="(image, index) in mangaImages"
:key="index"
class="page-item"
:class="{ 'active': index === currentImageIndex }"
@click="scrollToImage(index)"
>
{{ index + 1 }}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 漫画内容 -->
<div class="manga-content">
<!-- 专辑信息 -->
<div v-if="albumInfo" class="comic-header">
<h1 class="comic-title">{{ albumInfo.name }}</h1>
<div class="comic-meta">
<div v-if="albumInfo.authors && albumInfo.authors.length" class="meta-item">
<span class="label">作者</span>
<span class="value">{{ albumInfo.authors.join(' / ') }}</span>
</div>
<div v-if="albumInfo.tags && albumInfo.tags.length" class="meta-item">
<span class="label">标签</span>
<span class="value tags">
<span v-for="(tag, idx) in albumInfo.tags" :key="idx" class="tag">{{ tag }}</span>
</span>
</div>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading">加载中...</div>
<!-- 漫画图片 -->
<div
v-for="(image, index) in canvasImages"
:key="index"
class="image-container"
:id="`image-${index}`"
>
<img
v-if="image"
:src="image"
:alt="`漫画第 ${index + 1} 页`"
class="manga-image"
:style="{
transform: `scale(${imageStates[index].scale}) translate(${imageStates[index].translateX}px, ${imageStates[index].translateY}px)`
}"
@wheel="handleWheel(index, $event)"
@touchstart="handleTouchStart(index, $event)"
@touchmove="handleTouchMove(index, $event)"
/>
<div v-else class="image-placeholder">图片加载中...</div>
</div>
</div>
<!-- 点击屏幕显示菜单的提示区域 -->
<div
class="screen-click-area"
@click="toggleMenu"
></div>
</div>
</template>
<style scoped>
.manga-reader {
position: relative;
width: 100vw;
height: 100vh;
background-color: #000;
overflow-y: auto;
overflow-x: hidden;
user-select: none;
padding-top: 0; /* 移除顶部内边距 */
padding-bottom: 60px; /* 添加底部内边距 */
}
.manga-reader.no-header {
padding-bottom: 0; /* 无头部时也无底部内边距 */
}
/* 添加页码指示器样式 */
.page-indicator {
position: fixed;
top: 10px;
left: 50%;
transform: translateX(-50%);
background-color: rgba(0, 0, 0, 0.7);
color: white;
padding: 5px 15px;
border-radius: 20px;
font-size: 14px;
z-index: 90;
pointer-events: none; /* 不拦截点击事件 */
}
.menu-bar {
position: fixed;
bottom: 0; /* 改为底部定位 */
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.8);
color: white;
padding: 10px 20px;
z-index: 100;
transform: translateY(100%); /* 改为向上偏移 */
transition: transform 0.3s ease;
}
.menu-bar.visible {
transform: translateY(0); /* 回到正常位置 */
}
.menu-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 800px;
margin: 0 auto;
}
.menu-content button {
background-color: rgba(255, 255, 255, 0.2);
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
margin-left: 10px;
}
.menu-content button:hover:not(:disabled) {
background-color: rgba(255, 255, 255, 0.3);
}
.menu-content button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-info {
font-size: 16px;
white-space: nowrap;
}
/* 左侧抽屉菜单样式 */
.drawer-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 199;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0.3s ease;
}
.drawer-overlay.drawer-open {
opacity: 1;
visibility: visible;
}
.drawer {
position: fixed;
top: 0;
left: 0;
width: 300px;
height: 100vh;
background-color: rgba(0, 0, 0, 0.9);
color: white;
z-index: 200;
transform: translateX(-100%);
transition: transform 0.3s ease;
overflow-y: auto;
}
.drawer.drawer-open {
transform: translateX(0);
}
.drawer-content {
padding: 20px;
height: 100%;
display: flex;
flex-direction: column;
}
.drawer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #333;
flex-shrink: 0;
}
.drawer-header h3 {
margin: 0;
}
.close-btn {
background: none;
border: none;
color: white;
font-size: 24px;
cursor: pointer;
}
.drawer-body {
flex: 1;
overflow-y: auto;
}
.drawer-item {
display: block;
width: 100%;
padding: 12px;
margin-bottom: 10px;
background-color: rgba(255, 255, 255, 0.1);
color: white;
border: none;
border-radius: 4px;
text-align: left;
cursor: pointer;
}
.drawer-item:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.page-list h4 {
margin: 20px 0 10px 0;
color: #ccc;
}
.page-item {
padding: 8px;
margin: 5px 0;
background-color: rgba(255, 255, 255, 0.05);
border-radius: 4px;
cursor: pointer;
}
.page-item:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.page-item.active {
background-color: rgba(255, 255, 255, 0.2);
font-weight: bold;
}
.manga-content {
padding: 20px 0 0 0; /* 只保留顶部内边距 */
}
.image-container {
width: 100%;
display: flex;
justify-content: center;
margin-bottom: 0; /* 移除图片间的间距 */
padding: 0; /* 移除内边距 */
}
.manga-image {
max-width: 100%;
height: auto;
cursor: zoom-in;
transform-origin: center center;
transition: transform 0.2s ease;
user-select: none;
-webkit-user-drag: none;
display: block; /* 避免 inline 元素带来的空白间隙 */
}
.manga-image:active {
cursor: grabbing;
}
.image-placeholder {
color: #999;
font-size: 16px;
padding: 20px;
}
/* 顶部信息 */
.comic-header {
margin-bottom: 24px;
z-index: 2;
position: relative;
padding: 0 20px;
}
.comic-title {
font-size: 28px;
font-weight: bold;
margin-bottom: 16px;
line-height: 1.2;
color: white;
}
.comic-meta {
display: flex;
flex-direction: column;
gap: 8px;
}
.meta-item {
display: flex;
align-items: center;
}
.label {
font-weight: bold;
color: #aaa;
width: 50px;
flex-shrink: 0;
}
.value {
color: #ddd;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
background: rgba(255, 255, 255, 0.1);
padding: 4px 10px;
border-radius: 12px;
font-size: 12px;
color: #ccc;
}
.loading {
text-align: center;
padding: 40px;
color: #999;
font-size: 18px;
}
/* 屏幕点击区域 */
.screen-click-area {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
pointer-events: none;
}
/* 移动端适配 */
@media (max-width: 768px) {
.menu-content {
flex-direction: column;
gap: 10px;
}
.menu-content > * {
width: 100%;
text-align: center;
}
.manga-image {
max-width: 100%;
}
.drawer {
width: 280px;
}
.page-info {
font-size: 14px;
}
.comic-header {
padding: 0 10px;
}
.comic-title {
font-size: 24px;
}
.page-indicator {
top: 5px;
font-size: 12px;
padding: 3px 10px;
}
}
</style>

View File

@@ -0,0 +1,227 @@
<!-- User.vue -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import GithubLoginButton from './GithubLoginButton.vue'
import {useRoute} from "vue-router";
import router from "@/router";
interface UserInfo {
id: string
username: string
email: string
avatar_url: string
githubId: string
}
const userInfo = ref<UserInfo | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const isLoggedIn = ref(false)
const route = useRoute()
// 从URL参数中获取token
const getUrlParameter = (name: string): string | null => {
const urlParams = new URLSearchParams(window.location.search)
//移除值
router.replace({
query: {
...route.query,
token: undefined,
}
}); return urlParams.get(name)
}
onMounted(async () => {
const token = getUrlParameter('token')
if (!token || token === 'error') {
if (token === 'error') {
error.value = '登录失败'
}
loading.value = false
isLoggedIn.value = false
return
}
//写到localStorage
localStorage.setItem('token', token)
try {
// 调用API获取用户信息需要后端提供此接口
const response = await fetch(`/api/user/data`, { headers: { Token: `${token}` }})
if (response.ok) {
userInfo.value = await response.json()
isLoggedIn.value = true
} else {
throw new Error('获取用户信息失败')
}
} catch (err) {
error.value = err instanceof Error ? err.message : '未知错误'
isLoggedIn.value = false
} finally {
loading.value = false
}
})
</script>
<template>
<div class="user-page">
<br><br>
<div v-if="loading" class="loading">
正在加载用户信息...
</div>
<div v-else-if="error" class="error">
错误: {{ error }}
</div>
<div v-else-if="!isLoggedIn" class="login-section">
<h2>请登录</h2>
<p>您需要登录才能查看用户信息</p>
<GithubLoginButton />
</div>
<div v-else-if="userInfo" class="user-container">
<div class="user-content">
<!-- 左侧用户基本信息 (3/10宽度) -->
<div class="user-info">
<img
:src="userInfo.avatar_url"
:alt="userInfo.username"
class="avatar"
/>
<h2>{{ userInfo.username }}</h2>
<p v-if="userInfo.email" class="email">邮箱: {{ userInfo.email }}</p>
</div>
<!-- 右侧预留空间 (7/10宽度) -->
<div class="user-actions">
<div class="placeholder">
<!-- 这里可以添加用户操作功能如编辑资料登出等 -->
<h3>用户操作区</h3>
<p>此处可以添加用户相关的功能操作</p>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.user-page {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.loading, .error {
padding: 20px;
font-size: 18px;
text-align: center;
}
.error {
color: #e74c3c;
}
.login-section {
background: #f8f9fa;
border-radius: 8px;
padding: 40px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
text-align: center;
}
.login-section h2 {
margin-bottom: 10px;
color: #333;
}
.login-section p {
margin-bottom: 20px;
color: #666;
}
.user-container {
width: 100%;
}
.user-content {
display: flex;
gap: 30px;
background: #f8f9fa;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
/* 左侧用户信息区域 */
.user-info {
flex: 3;
}
/* 右侧预留区域 */
.user-actions {
flex: 7;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder {
text-align: center;
color: #666;
}
.placeholder h3 {
margin-bottom: 10px;
color: #333;
}
/* 头像样式 */
.avatar {
width: 120px;
height: 120px;
border-radius: 50%;
object-fit: cover;
margin-bottom: 20px;
}
.user-info h2 {
margin: 10px 0;
color: #333;
}
.user-info .email,
.user-info .github-id {
margin: 10px 0;
color: #666;
font-size: 16px;
}
/* 移动端响应式布局 */
@media (max-width: 768px) {
.user-content {
flex-direction: column;
}
.user-info,
.user-actions {
flex: none;
width: 100%;
}
.user-actions {
margin-top: 20px;
padding-top: 20px;
border-top: 1px solid #e0e0e0;
}
.user-page {
padding: 10px;
}
.avatar {
width: 100px;
height: 100px;
}
}
</style>

View File

@@ -0,0 +1,200 @@
<script setup lang="ts">
import { ref, computed } from "vue";
interface Bookmark {
title: string;
description: string;
url: string;
icon?: string;
tags: string[];
}
interface BookmarkCategory {
name: string;
bookmarks: Bookmark[];
}
const categories = ref<BookmarkCategory[]>([
{
name: "常用工具",
bookmarks: [
{
title: "ChatGPT",
description: "OpenAI 开发的人工智能聊天机器人",
url: "https://chat.openai.com",
icon: "🤖",
tags: ["AI", "聊天"],
},
{
title: "GitHub",
description: "全球最大的代码托管平台",
url: "https://github.com",
icon: "📦",
tags: ["开发", "代码"],
},
],
},
{
name: "学习资源",
bookmarks: [
{
title: "MDN",
description: "Mozilla 的开发者文档",
url: "https://developer.mozilla.org",
icon: "📚",
tags: ["文档", "开发"],
},
{
title: "Vue.js",
description: "渐进式 JavaScript 框架",
url: "https://vuejs.org",
icon: "💚",
tags: ["框架", "开发"],
},
],
},
{
name: "设计资源",
bookmarks: [
{
title: "Tailwind CSS",
description: "实用优先的 CSS 框架",
url: "https://tailwindcss.com",
icon: "🎨",
tags: ["CSS", "设计"],
},
{
title: "Heroicons",
description: "精美的 SVG 图标库",
url: "https://heroicons.com",
icon: "⭐",
tags: ["图标", "设计"],
},
],
},
]);
const searchQuery = ref("");
const selectedTags = ref<string[]>([]);
// 获所有标签
const allTags = [
...new Set(
categories.value.flatMap((category) =>
category.bookmarks.flatMap((bookmark) => bookmark.tags),
),
),
];
// 切换标签选择
const toggleTag = (tag: string) => {
const index = selectedTags.value.indexOf(tag);
if (index === -1) {
selectedTags.value.push(tag);
} else {
selectedTags.value.splice(index, 1);
}
};
// 过滤书签
const filteredCategories = computed(() => {
return categories.value
.map((category) => ({
...category,
bookmarks: category.bookmarks.filter((bookmark) => {
const matchesSearch = searchQuery.value
? bookmark.title
.toLowerCase()
.includes(searchQuery.value.toLowerCase()) ||
bookmark.description
.toLowerCase()
.includes(searchQuery.value.toLowerCase())
: true;
const matchesTags = selectedTags.value.length
? selectedTags.value.every((tag) => bookmark.tags.includes(tag))
: true;
return matchesSearch && matchesTags;
}),
}))
.filter((category) => category.bookmarks.length > 0);
});
</script>
<template>
<div>
<!-- 导航内容 -->
<div class="grid gap-8">
<!-- 搜索和过滤 -->
<div class="mb-8 space-y-4">
<input
v-model="searchQuery"
type="text"
placeholder="搜索网站..."
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-primary focus:border-transparent transition-colors"
/>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in allTags"
:key="tag"
class="px-3 py-1 rounded-full text-sm transition-all duration-300"
:class="[
selectedTags.includes(tag)
? 'bg-primary text-white scale-105'
: 'bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700 hover:scale-105',
]"
@click="toggleTag(tag)"
>
{{ tag }}
</button>
</div>
</div>
<!-- 书签列表 -->
<div class="space-y-8">
<div
v-for="category in filteredCategories"
:key="category.name"
class="space-y-4"
>
<h2 class="text-xl font-bold">{{ category.name }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<a
v-for="bookmark in category.bookmarks"
:key="bookmark.url"
:href="bookmark.url"
target="_blank"
rel="noopener noreferrer"
class="group p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-primary dark:hover:border-primary transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
>
<div class="flex items-start space-x-3">
<span class="text-2xl">{{ bookmark.icon }}</span>
<div class="flex-1">
<h3
class="font-semibold group-hover:text-primary transition-colors"
>
{{ bookmark.title }}
</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
{{ bookmark.description }}
</p>
<div class="flex flex-wrap gap-2 mt-2">
<span
v-for="tag in bookmark.tags"
:key="tag"
class="px-2 py-0.5 text-xs bg-primary-10 text-primary rounded-full"
>
{{ tag }}
</span>
</div>
</div>
</div>
</a>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,126 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import ToolLayout from "@/components/layout/ToolLayout.vue";
const input = ref("");
const output = ref("");
const indentSize = ref(2);
const error = ref("");
const formatJson = () => {
error.value = "";
if (!input.value.trim()) {
output.value = "";
return;
}
try {
const parsed = JSON.parse(input.value);
output.value = JSON.stringify(parsed, null, indentSize.value);
} catch (e) {
error.value = e instanceof Error ? e.message : "无效的 JSON 格式";
}
};
const compressJson = () => {
error.value = "";
if (!input.value.trim()) {
output.value = "";
return;
}
try {
const parsed = JSON.parse(input.value);
output.value = JSON.stringify(parsed);
} catch (e) {
error.value = e instanceof Error ? e.message : "无效的 JSON 格式";
}
};
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(output.value);
alert("已复制到剪贴板");
} catch (e) {
alert("复制失败,请手动复制");
}
};
const clearAll = () => {
input.value = "";
output.value = "";
error.value = "";
};
const hasContent = computed(() => input.value.trim().length > 0);
</script>
<template>
<ToolLayout
title="JSON 格式化工具"
description="在线 JSON 格式化工具,支持压缩、美化、验证和转换等功能"
>
<div class="grid md:grid-cols-2 gap-6">
<!-- 输入区域 -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">输入 JSON</h2>
<button
v-if="hasContent"
@click="clearAll"
class="text-sm text-gray-500 hover:text-primary transition-colors"
>
清空
</button>
</div>
<textarea
v-model="input"
class="w-full h-[400px] p-4 font-mono text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-primary focus:border-transparent transition-colors resize-none"
placeholder="在此输入 JSON 字符串..."
spellcheck="false"
></textarea>
</div>
<!-- 输出区域 -->
<div class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold">格式化结果</h2>
<div class="flex items-center gap-2">
<select
v-model="indentSize"
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
>
<option :value="2">缩进 2 空格</option>
<option :value="4">缩进 4 空格</option>
</select>
<button
v-if="output"
@click="copyToClipboard"
class="text-sm text-primary hover:text-primary/80 transition-colors"
>
复制
</button>
</div>
</div>
<pre
class="w-full h-[400px] p-4 font-mono text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 overflow-auto"
:class="{ 'border-red-500': error }"
><code>{{ error || output }}</code></pre>
</div>
<!-- 操作按钮 -->
<div class="md:col-span-2 flex justify-center gap-4">
<button @click="formatJson" class="btn-primary" :disabled="!hasContent">
格式化
</button>
<button
@click="compressJson"
class="btn-secondary"
:disabled="!hasContent"
>
压缩
</button>
</div>
</div>
</ToolLayout>
</template>

1
reijm-read/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,16 @@
let lastHeartbeat = Date.now();
self.onmessage = function(e) {
if (e.data.type === 'HEARTBEAT') {
const now = Date.now();
const delta = now - lastHeartbeat;
if (delta > 500) {
self.postMessage({ type: 'UI_BLOCKED' });
} else {
self.postMessage({ type: 'UI_RECOVERED' });
}
lastHeartbeat = now;
}
};

View File

@@ -0,0 +1,52 @@
// 完全保持原有数据处理逻辑仅在Worker中执行
class FlightProcessor {
constructor() {
this.previousPositions = new Map();
}
processFlights(flightData) {
const updates = [];
const currentFlights = new Set();
flightData.forEach(flight => {
if (!flight || flight.length < 7) return;
const icao24 = flight[0];
currentFlights.add(icao24);
const prevPosition = this.previousPositions.get(icao24);
const longitude = flight[5];
const latitude = flight[6];
const altitude = flight[7] || 0;
updates.push({
icao24,
flight,
prevPosition: prevPosition || { longitude, latitude, altitude }
});
this.previousPositions.set(icao24, { longitude, latitude, altitude });
});
// 清理不再存在的航班
this.previousPositions.forEach((_, icao24) => {
if (!currentFlights.has(icao24)) {
this.previousPositions.delete(icao24);
}
});
return updates;
}
}
const processor = new FlightProcessor();
self.onmessage = function(e) {
if (e.data.type === 'PROCESS_FLIGHTS') {
const updates = processor.processFlights(e.data.flightData);
self.postMessage({
type: 'UPDATE_ENTITIES',
data: updates
});
}
};

View File

@@ -0,0 +1,52 @@
import { fontFamily } from "tailwindcss/defaultTheme";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
theme: {
extend: {
colors: {
primary: {
DEFAULT: "var(--color-primary)",
dark: "var(--color-primary-dark)",
light: "var(--color-primary-light)",
10: "var(--color-primary-10)",
50: '#e6f7ff',
100: '#ccf0ff',
200: '#99e6ff',
300: '#66dbff',
400: '#33cfff',
500: '#00b3ff',
600: '#0099e6',
700: '#007fbf',
800: '#006699',
900: '#004d73',
},
},
backgroundColor: {
main: "var(--color-bg-main)",
secondary: "var(--color-bg-secondary)",
tertiary: "var(--color-bg-tertiary)",
},
textColor: {
primary: "var(--color-text-primary)",
secondary: "var(--color-text-secondary)",
tertiary: "var(--color-text-tertiary)",
},
borderColor: {
DEFAULT: "var(--color-border)",
light: "var(--color-border-light)",
},
boxShadow: {
sm: "var(--shadow-sm)",
DEFAULT: "var(--shadow)",
md: "var(--shadow-md)",
},
fontFamily: {
sans: ["Inter var", ...fontFamily.sans],
},
},
},
plugins: [],
};

View File

@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"env.d.ts",
"src/**/*",
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

33
reijm-read/tsconfig.json Normal file
View File

@@ -0,0 +1,33 @@
{
"compilerOptions": {
"target": "ESNext",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "Node",
"strict": true,
"jsx": "preserve",
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ESNext", "DOM"],
"skipLibCheck": true,
"noEmit": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": [
"node",
"vite/client",
"@types/node",
"vite-plugin-obfuscator"
]
},
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["vite/client"]
},
"include": ["vite.config.ts", "src/config/**/*", "src/types/**/*"]
}

141
reijm-read/vite.config.ts Normal file
View File

@@ -0,0 +1,141 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import path from "path";
import viteCompression from "vite-plugin-compression";
import { ViteImageOptimizer } from "vite-plugin-image-optimizer";
import { fontConfig } from "./src/config/font";
import cesium from 'vite-plugin-cesium'
export default defineConfig({
base: "/",
build: {
outDir: "dist",
assetsDir: "assets",
minify: "terser",
sourcemap: false,
chunkSizeWarningLimit: 1500,
terserOptions: {
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ["console.log"],
},
format: {
comments: /@license/i,
},
},
rollupOptions: {
output: {
manualChunks: {
vendor: ["vue", "vue-router"],
},
chunkFileNames: "assets/js/[name]-[hash].js",
entryFileNames: "assets/js/[name]-[hash].js",
assetFileNames: "assets/[ext]/[name]-[hash].[ext]",
},
},
cssCodeSplit: true,
cssMinify: true,
},
plugins: [
cesium(),
vue(),
viteCompression({
verbose: true,
disable: false,
threshold: 10240,
algorithm: "gzip",
ext: ".gz",
}),
ViteImageOptimizer({
test: /\.(jpe?g|png|gif|svg)$/i,
exclude: undefined,
include: undefined,
includePublic: true,
logStats: true,
ansiColors: true,
svg: {
multipass: true,
plugins: [
{
name: "preset-default",
params: {
overrides: {
removeViewBox: false,
removeEmptyAttrs: false,
},
},
},
],
},
png: {
quality: 80,
},
jpeg: {
quality: 80,
},
jpg: {
quality: 80,
},
tiff: {
quality: 80,
},
gif: undefined,
webp: {
quality: 80,
},
avif: {
quality: 80,
},
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
server: {
host: "0.0.0.0",
port: 5173,
allowedHosts: ["w.godserver.cn",'godserver.cn','www.godserver.cn','rbq.college','mai.godserver.cn'],
proxy: {
"/api": {
target: "http://127.0.0.1:8981/api",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
headers: {
"User-Agent": "Mozilla/5.0",
"Cache-Control": "no-cache",
"Pragma": "no-cache"
// 移除 Accept 头部,避免覆盖客户端发送的 Accept: text/event-stream
},
configure: (proxy, options) => {
proxy.on('proxyReq', (proxyReq, req, res) => {
// 确保不覆盖客户端的 Accept 头部
if (req.headers.accept) {
proxyReq.setHeader('Accept', req.headers.accept);
}
});
proxy.on('proxyRes', (proxyRes, req, res) => {
// 确保流式响应的头部设置正确
proxyRes.headers['Content-Type'] = 'text/event-stream';
proxyRes.headers['Cache-Control'] = 'no-cache';
proxyRes.headers['Connection'] = 'keep-alive';
proxyRes.headers['X-Accel-Buffering'] = 'no';
});
}
},
},
},
define: {
__VUE_OPTIONS_API__: true,
__VUE_PROD_DEVTOOLS__: false,
'process.env.VITE_API_BASE_URL': JSON.stringify(process.env.VITE_API_BASE_URL),
"process.env.VITE_FONT_URL": JSON.stringify(fontConfig.url),
"process.env.VITE_FONT_ENABLED": JSON.stringify(fontConfig.enabled),
"process.env.VITE_FONT_PRELOAD": JSON.stringify(fontConfig.preload),
},
});