initialˆ
12
reijm-read/.editorconfig
Normal 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
@@ -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
@@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
6
reijm-read/.idea/inspectionProfiles/Project_Default.xml
generated
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
BIN
reijm-read/favicon.ico
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
58
reijm-read/index.html
Normal 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
@@ -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
6
reijm-read/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
22
reijm-read/public/manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
3
reijm-read/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Sitemap: <%= config.siteUrl %>/sitemap.xml
|
||||
27
reijm-read/public/sitemap.xml
Normal 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>
|
||||
1
reijm-read/public/vite.svg
Normal 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
@@ -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>
|
||||
BIN
reijm-read/src/assets/1photogrid.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
reijm-read/src/assets/20180621142015_5FmGZ.jpeg
Normal file
|
After Width: | Height: | Size: 649 KiB |
BIN
reijm-read/src/assets/a.jpg
Normal file
|
After Width: | Height: | Size: 8.0 MiB |
BIN
reijm-read/src/assets/icon.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
reijm-read/src/assets/icon.psd
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
reijm-read/src/assets/page/3f562d98148099080ddd72e7899c3379.png
Normal file
|
After Width: | Height: | Size: 170 KiB |
BIN
reijm-read/src/assets/page/ad387886e0895e4eff57e5813ced97b1.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
reijm-read/src/assets/page/b40bc5ad04d9d3f34fff37a1e31687b6.png
Normal file
|
After Width: | Height: | Size: 746 KiB |
BIN
reijm-read/src/assets/page/faa5939484a2004b958f4fa501633e03.png
Normal file
|
After Width: | Height: | Size: 910 KiB |
BIN
reijm-read/src/assets/page/img.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
48
reijm-read/src/assets/styles/colors.css
Normal 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);
|
||||
}
|
||||
72
reijm-read/src/assets/styles/main.css
Normal 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;
|
||||
}
|
||||
16
reijm-read/src/assets/styles/variables.css
Normal 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");
|
||||
}
|
||||
BIN
reijm-read/src/assets/styles/图层 1_PhotoGrid.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
BIN
reijm-read/src/assets/useTs1.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
1
reijm-read/src/assets/vue.svg
Normal 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 |
86
reijm-read/src/components/PageTransition.vue
Normal 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>
|
||||
55
reijm-read/src/components/ProgressBar.vue
Normal 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>
|
||||
82
reijm-read/src/components/ThemeToggle.vue
Normal 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>
|
||||
159
reijm-read/src/components/effects/Fireworks.vue
Normal 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>
|
||||
82
reijm-read/src/components/layout/TheFooter.vue
Normal 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>
|
||||
205
reijm-read/src/components/layout/TheHeader.vue
Normal 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>
|
||||
|
||||
27
reijm-read/src/components/layout/ToolLayout.vue
Normal 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>
|
||||
48
reijm-read/src/components/ui/LazyImage.vue
Normal 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>
|
||||
149
reijm-read/src/components/ui/Modal.vue
Normal 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>
|
||||
44
reijm-read/src/components/ui/Tabs.vue
Normal 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>
|
||||
61
reijm-read/src/components/ui/Toast.vue
Normal 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>
|
||||
89
reijm-read/src/components/ui/WarningDialog.vue
Normal 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>
|
||||
42
reijm-read/src/config/blog.ts
Normal 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",
|
||||
},
|
||||
// 添加更多博客文章...
|
||||
];
|
||||
25
reijm-read/src/config/email.ts
Normal 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");
|
||||
}
|
||||
17
reijm-read/src/config/font.ts
Normal 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",
|
||||
};
|
||||
39
reijm-read/src/config/footer.ts
Normal 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 加速 / 云存储服务",
|
||||
},
|
||||
};
|
||||
46
reijm-read/src/config/index.ts
Normal 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`;
|
||||
11
reijm-read/src/config/navigation.ts
Normal 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: "🔖" },
|
||||
];
|
||||
53
reijm-read/src/config/notice.ts
Normal 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,
|
||||
},
|
||||
],
|
||||
};
|
||||
41
reijm-read/src/config/projects.ts
Normal 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",
|
||||
},
|
||||
];
|
||||
7
reijm-read/src/config/rss.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
interface RssConfig {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export const rssConfig: RssConfig = {
|
||||
url: "https://www.godserver.cn/rss.xml", // 直接使用完整 URL
|
||||
};
|
||||
20
reijm-read/src/config/site-info.ts
Normal 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;",
|
||||
};
|
||||
39
reijm-read/src/config/site.ts
Normal 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", // 联系邮箱
|
||||
},
|
||||
};
|
||||
33
reijm-read/src/config/tools.ts
Normal 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
@@ -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";
|
||||
9
reijm-read/src/eventBus.ts
Normal 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
@@ -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");
|
||||
});
|
||||
49
reijm-read/src/router/index.ts
Normal 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;
|
||||
51
reijm-read/src/services/rss.ts
Normal 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
@@ -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;
|
||||
}
|
||||
}
|
||||
8
reijm-read/src/types/blog.ts
Normal 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
@@ -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;
|
||||
}
|
||||
19
reijm-read/src/types/notice.ts
Normal 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[];
|
||||
}
|
||||
77
reijm-read/src/utils/console.ts
Normal 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;`);
|
||||
};
|
||||
42
reijm-read/src/utils/copyright.ts
Normal 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;
|
||||
};
|
||||
})();
|
||||
10
reijm-read/src/utils/encryptionUtil.d.ts
vendored
Normal 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;
|
||||
}
|
||||
121
reijm-read/src/utils/encryptionUtil.js
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
};
|
||||
21
reijm-read/src/utils/font.ts
Normal 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';
|
||||
}
|
||||
};
|
||||
292
reijm-read/src/utils/liquid_glass.js
Normal 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();
|
||||
})();
|
||||
43
reijm-read/src/utils/rss.ts
Normal 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 [];
|
||||
}
|
||||
};
|
||||
70
reijm-read/src/utils/security.ts
Normal 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);
|
||||
};
|
||||
42
reijm-read/src/views/GithubLoginButton.vue
Normal 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>
|
||||
17
reijm-read/src/views/HomeView.vue
Normal 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>
|
||||
894
reijm-read/src/views/Manga.vue
Normal 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 }">
|
||||
|
||||
<!-- <!– 始终可见的页码显示 –>-->
|
||||
<!-- <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>
|
||||
227
reijm-read/src/views/User.vue
Normal 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>
|
||||
200
reijm-read/src/views/tools/BookmarksView.vue
Normal 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>
|
||||
126
reijm-read/src/views/tools/JsonFormatterView.vue
Normal 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>
|
||||
0
reijm-read/src/views/tools/TimestampView.vue
Normal file
1
reijm-read/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
16
reijm-read/src/workers/blockDetector.worker.js
Normal 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;
|
||||
}
|
||||
};
|
||||
52
reijm-read/src/workers/flightProcessor.worker.js
Normal 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
|
||||
});
|
||||
}
|
||||
};
|
||||
52
reijm-read/tailwind.config.js
Normal 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: [],
|
||||
};
|
||||
18
reijm-read/tsconfig.app.json
Normal 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
@@ -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" }]
|
||||
}
|
||||
12
reijm-read/tsconfig.node.json
Normal 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
@@ -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),
|
||||
},
|
||||
});
|
||||