网站和推流制作完成
This commit is contained in:
12
EyeVue/.editorconfig
Normal file
12
EyeVue/.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
EyeVue/.gitignore
vendored
Normal file
35
EyeVue/.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
|
||||||
27
EyeVue/LICENSE
Normal file
27
EyeVue/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
EyeVue/README.md
Normal file
120
EyeVue/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)
|
||||||
18
EyeVue/api/rss.ts
Normal file
18
EyeVue/api/rss.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// Vercel Serverless Function
|
||||||
|
export default async function handler(res) {
|
||||||
|
try {
|
||||||
|
const rssUrl = process.env.RSS_URL;
|
||||||
|
if (!rssUrl) {
|
||||||
|
throw new Error("RSS_URL environment variable is not defined");
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(rssUrl);
|
||||||
|
const data = await response.text();
|
||||||
|
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader("Content-Type", "application/xml");
|
||||||
|
res.status(200).send(data);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: "Failed to fetch RSS" });
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
EyeVue/favicon.ico
Normal file
BIN
EyeVue/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
58
EyeVue/index.html
Normal file
58
EyeVue/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="Reisa 个人网站" />
|
||||||
|
<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="Reisa 个人网站" />
|
||||||
|
<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>
|
||||||
13678
EyeVue/package-lock.json
generated
Normal file
13678
EyeVue/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
70
EyeVue/package.json
Normal file
70
EyeVue/package.json
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"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",
|
||||||
|
"chart": "^0.1.2",
|
||||||
|
"chart.js": "^4.4.9",
|
||||||
|
"element-plus": "^2.9.9",
|
||||||
|
"jsqr": "^1.4.0",
|
||||||
|
"marked": "^15.0.10",
|
||||||
|
"mitt": "^3.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",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
6983
EyeVue/pnpm-lock.yaml
generated
Normal file
6983
EyeVue/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
EyeVue/postcss.config.js
Normal file
6
EyeVue/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
22
EyeVue/public/manifest.json
Normal file
22
EyeVue/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
EyeVue/public/robots.txt
Normal file
3
EyeVue/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
Sitemap: <%= config.siteUrl %>/sitemap.xml
|
||||||
27
EyeVue/public/sitemap.xml
Normal file
27
EyeVue/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>
|
||||||
135
EyeVue/src/App.vue
Normal file
135
EyeVue/src/App.vue
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
<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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 打印控制台信息
|
||||||
|
printConsoleInfo({
|
||||||
|
text: siteInfo.text,
|
||||||
|
version: siteInfo.version,
|
||||||
|
link: siteInfo.link,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col">
|
||||||
|
<TheHeader />
|
||||||
|
<main class="flex-grow pt-16 md:pt-20">
|
||||||
|
<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.png');
|
||||||
|
background-size: cover; /* 背景图片覆盖整个元素 */
|
||||||
|
background-position: center; /* 背景图片居中 */
|
||||||
|
background-repeat: no-repeat; /* 防止背景图片重复 */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
}
|
||||||
|
|
||||||
|
.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
EyeVue/src/assets/a.png
Normal file
BIN
EyeVue/src/assets/a.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
BIN
EyeVue/src/assets/flight.png
Normal file
BIN
EyeVue/src/assets/flight.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
BIN
EyeVue/src/assets/flight2.png
Normal file
BIN
EyeVue/src/assets/flight2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
BIN
EyeVue/src/assets/flight3.png
Normal file
BIN
EyeVue/src/assets/flight3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
BIN
EyeVue/src/assets/icon.png
Normal file
BIN
EyeVue/src/assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
48
EyeVue/src/assets/styles/colors.css
Normal file
48
EyeVue/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
EyeVue/src/assets/styles/main.css
Normal file
72
EyeVue/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
EyeVue/src/assets/styles/variables.css
Normal file
16
EyeVue/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");
|
||||||
|
}
|
||||||
56
EyeVue/src/components/Card.vue
Normal file
56
EyeVue/src/components/Card.vue
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-label">
|
||||||
|
<span>{{ label }}</span>
|
||||||
|
<span class="card-value">{{ value }}%</span>
|
||||||
|
</div>
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div
|
||||||
|
class="progress-bar-fill"
|
||||||
|
:style="{ width: `${value}%` }"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface CardProps {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<CardProps>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.card {
|
||||||
|
@apply bg-white dark:bg-gray-800 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 border border-gray-100 dark:border-gray-700 p-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
@apply flex justify-between items-center mb-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value {
|
||||||
|
@apply font-medium text-blue-500 dark:text-blue-400; /* 暗色模式下调整文本颜色 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 夜间模式样式 */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.card-label {
|
||||||
|
color: white; /* 文本颜色为白色 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-value {
|
||||||
|
color: white; /* 文本颜色为白色 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
62
EyeVue/src/components/CustomToast.vue
Normal file
62
EyeVue/src/components/CustomToast.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="custom-toast">
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: "CustomToast",
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
visible: false,
|
||||||
|
message: "",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
show(message, duration = 1000) {
|
||||||
|
this.message = message;
|
||||||
|
this.visible = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
this.visible = false;
|
||||||
|
}, duration);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.custom-toast {
|
||||||
|
position: fixed;
|
||||||
|
top: 80px; /* 设置距离顶部 50px */
|
||||||
|
right: 20px;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
z-index: 1000;
|
||||||
|
animation: fade-in 0.3s ease-in-out, fade-out 0.3s ease-in-out 0.7s; /* 淡入淡出动画 */
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px); /* 从上方滑入 */
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-out {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px); /* 向上滑出 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
51
EyeVue/src/components/FloatingButton.vue
Normal file
51
EyeVue/src/components/FloatingButton.vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<template>
|
||||||
|
<button class="floating-button" :style="{ backgroundColor: color }" @click="$emit('click')">
|
||||||
|
<span class="material-icons">{{ icon }}</span>
|
||||||
|
<span class="button-label">{{ label }}</span>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
icon: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
type: String,
|
||||||
|
default: 'blue'
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['click']
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.floating-button {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-label {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
67
EyeVue/src/components/LargePopup.vue
Normal file
67
EyeVue/src/components/LargePopup.vue
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<template>
|
||||||
|
<div class="popup-overlay" @click.self="$emit('close')">
|
||||||
|
<div class="popup-content">
|
||||||
|
<button class="shangji-button" @click="$emit('shang-ji')">
|
||||||
|
上机
|
||||||
|
</button>
|
||||||
|
<button class="close-button" @click="$emit('close')">
|
||||||
|
关闭弹窗
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
emits: ['close', 'shang-ji']
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.popup-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-content {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
width: 75%;
|
||||||
|
max-width: 400px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shangji-button {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: white;
|
||||||
|
background-color: #2196f3; /* Blue */
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
color: white;
|
||||||
|
background-color: #f44336; /* Red */
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
76
EyeVue/src/components/MarkdownModal.vue
Normal file
76
EyeVue/src/components/MarkdownModal.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<!-- src/components/MarkdownModal.vue -->
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50">
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6 w-full max-w-4xl relative">
|
||||||
|
<button @click="closeModal" class="absolute top-4 right-4 text-gray-500 hover:text-gray-700">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div class="markdown-content" v-html="parsedMarkdown"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue';
|
||||||
|
import { marked } from 'marked';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
isOpen: boolean;
|
||||||
|
markdownContent: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'close'): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
emit('close');
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsedMarkdown = computed(() => marked(props.markdownContent));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.markdown-content {
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h1 {
|
||||||
|
font-size: 2xl;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content h2 {
|
||||||
|
font-size: xl;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content p {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content a {
|
||||||
|
color: #1e40af;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content a:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content ul,
|
||||||
|
.markdown-content ol {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
86
EyeVue/src/components/PageTransition.vue
Normal file
86
EyeVue/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>
|
||||||
46
EyeVue/src/components/PieChart.vue
Normal file
46
EyeVue/src/components/PieChart.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup>
|
||||||
|
import { Chart } from 'chart.js/auto'
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
data: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
chartId: {
|
||||||
|
type: String,
|
||||||
|
default: 'pie-chart'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const canvasRef = ref(null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (canvasRef.value) {
|
||||||
|
new Chart(canvasRef.value.getContext('2d'), {
|
||||||
|
type: 'pie',
|
||||||
|
data: props.data,
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'right'
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<canvas :id="chartId" ref="canvasRef"></canvas>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
|
||||||
|
</style>
|
||||||
72
EyeVue/src/components/PlayerCard.vue
Normal file
72
EyeVue/src/components/PlayerCard.vue
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
<template>
|
||||||
|
<div class="player-card">
|
||||||
|
<div class="player-image">
|
||||||
|
<img
|
||||||
|
v-if="imageUrl"
|
||||||
|
:src="imageUrl"
|
||||||
|
alt="Player avatar"
|
||||||
|
class="avatar-image"
|
||||||
|
/>
|
||||||
|
<span v-else class="material-icons">{{ imageName }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="player-name">{{ playerName }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
imageUrl: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
imageName: {
|
||||||
|
type: String,
|
||||||
|
default: 'person'
|
||||||
|
},
|
||||||
|
playerName: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.player-card {
|
||||||
|
flex: 1;
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-image {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.material-icons {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #2196f3; /* Blue color */
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #212121; /* Dark gray */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
55
EyeVue/src/components/ProgressBar.vue
Normal file
55
EyeVue/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>
|
||||||
60
EyeVue/src/components/ProjectCard.vue
Normal file
60
EyeVue/src/components/ProjectCard.vue
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Project {
|
||||||
|
title: string;
|
||||||
|
image: string;
|
||||||
|
description?: string;
|
||||||
|
tags?: string[];
|
||||||
|
link?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
project: Project;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg overflow-hidden">
|
||||||
|
<img
|
||||||
|
:src="project.image"
|
||||||
|
:alt="project.title"
|
||||||
|
class="w-full h-48 object-cover"
|
||||||
|
/>
|
||||||
|
<div class="p-6">
|
||||||
|
<h3 class="text-xl font-semibold mb-2">{{ project.title }}</h3>
|
||||||
|
<p
|
||||||
|
v-if="project.description"
|
||||||
|
class="text-gray-600 dark:text-gray-300 mb-4"
|
||||||
|
>
|
||||||
|
{{ project.description }}
|
||||||
|
</p>
|
||||||
|
<div v-if="project.tags" class="flex flex-wrap gap-2 mb-4">
|
||||||
|
<span
|
||||||
|
v-for="tag in project.tags"
|
||||||
|
:key="tag"
|
||||||
|
class="px-2 py-1 text-sm bg-gray-100 dark:bg-gray-700 rounded"
|
||||||
|
>
|
||||||
|
{{ tag }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
v-if="project.link"
|
||||||
|
:href="project.link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
查看详情
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.project-card {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
93
EyeVue/src/components/QRScanner.vue
Normal file
93
EyeVue/src/components/QRScanner.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<template>
|
||||||
|
<div class="scanner-container">
|
||||||
|
<video ref="video" class="scanner-video"></video>
|
||||||
|
<div class="scanner-overlay"></div>
|
||||||
|
<button class="close-button" @click="$emit('close')">关闭</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
emits: ['scan', 'close'],
|
||||||
|
|
||||||
|
setup(props, { emit }) {
|
||||||
|
const video = ref(null)
|
||||||
|
let scanner = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
startScanner()
|
||||||
|
})
|
||||||
|
|
||||||
|
const startScanner = async () => {
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: 'environment'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
video.value.srcObject = stream
|
||||||
|
video.value.play()
|
||||||
|
|
||||||
|
// In a real app, you would use a QR scanning library here
|
||||||
|
// This is just a placeholder for the concept
|
||||||
|
const detectQR = () => {
|
||||||
|
// Simulate QR detection
|
||||||
|
// In a real app, this would use actual QR decoding
|
||||||
|
setTimeout(() => {
|
||||||
|
emit('scan', 'paika12345') // Simulated QR code
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
detectQR()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error accessing camera:', error)
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
video
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scanner-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scanner-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
border: 2px solid rgba(0, 255, 0, 0.5);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-button {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 10px 20px;
|
||||||
|
background-color: #f44336;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
82
EyeVue/src/components/ThemeToggle.vue
Normal file
82
EyeVue/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>
|
||||||
29
EyeVue/src/components/Toolbar.vue
Normal file
29
EyeVue/src/components/Toolbar.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<div class="toolbar">
|
||||||
|
<span class="toolbar-title">{{ title }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toolbar {
|
||||||
|
padding: 8px 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #ff4081; /* Pink color */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
159
EyeVue/src/components/effects/Fireworks.vue
Normal file
159
EyeVue/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
EyeVue/src/components/layout/TheFooter.vue
Normal file
82
EyeVue/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.</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>
|
||||||
173
EyeVue/src/components/layout/TheHeader.vue
Normal file
173
EyeVue/src/components/layout/TheHeader.vue
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
<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";
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 在组件挂载时添加事件监听
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("click", handleClickOutside);
|
||||||
|
window.addEventListener("keydown", handleKeydown);});
|
||||||
|
|
||||||
|
// 在组件卸载时移除事件监听,防止内存泄漏
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener("click", handleClickOutside);
|
||||||
|
window.removeEventListener("keydown", handleKeydown);});
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ name: "首页", path: "/" },
|
||||||
|
{ name: "文章", path: "/pages" },
|
||||||
|
];
|
||||||
|
|
||||||
|
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 group relative overflow-hidden">
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
Reisa Eye
|
||||||
|
</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
EyeVue/src/components/layout/ToolLayout.vue
Normal file
27
EyeVue/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
EyeVue/src/components/ui/LazyImage.vue
Normal file
48
EyeVue/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
EyeVue/src/components/ui/Modal.vue
Normal file
149
EyeVue/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
EyeVue/src/components/ui/Tabs.vue
Normal file
44
EyeVue/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
EyeVue/src/components/ui/Toast.vue
Normal file
61
EyeVue/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
EyeVue/src/components/ui/WarningDialog.vue
Normal file
89
EyeVue/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>
|
||||||
17
EyeVue/src/config/font.ts
Normal file
17
EyeVue/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
EyeVue/src/config/footer.ts
Normal file
39
EyeVue/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
EyeVue/src/config/index.ts
Normal file
46
EyeVue/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
EyeVue/src/config/navigation.ts
Normal file
11
EyeVue/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
EyeVue/src/config/notice.ts
Normal file
53
EyeVue/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
EyeVue/src/config/projects.ts
Normal file
41
EyeVue/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
EyeVue/src/config/rss.ts
Normal file
7
EyeVue/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
EyeVue/src/config/site-info.ts
Normal file
20
EyeVue/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
EyeVue/src/config/site.ts
Normal file
39
EyeVue/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
EyeVue/src/config/tools.ts
Normal file
33
EyeVue/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
EyeVue/src/env.d.ts
vendored
Normal file
167
EyeVue/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
EyeVue/src/eventBus.ts
Normal file
9
EyeVue/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
EyeVue/src/main.ts
Normal file
14
EyeVue/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");
|
||||||
|
});
|
||||||
33
EyeVue/src/router/index.ts
Normal file
33
EyeVue/src/router/index.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { createRouter, createWebHistory } from "vue-router";
|
||||||
|
import type { RouteRecordRaw } from "vue-router";
|
||||||
|
import HomeView from "@/views/HomeView.vue";
|
||||||
|
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
name: "home",
|
||||||
|
component: HomeView,
|
||||||
|
meta: {
|
||||||
|
title: "Index",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
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
EyeVue/src/services/rss.ts
Normal file
51
EyeVue/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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
8
EyeVue/src/types/blog.ts
Normal file
8
EyeVue/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;
|
||||||
|
}
|
||||||
8
EyeVue/src/types/global.d.ts
vendored
Normal file
8
EyeVue/src/types/global.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
interface Window {
|
||||||
|
$toast: {
|
||||||
|
show: (text: string, type?: "success" | "error" | "info") => void;
|
||||||
|
success: (text: string) => void;
|
||||||
|
error: (text: string) => void;
|
||||||
|
info: (text: string) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
19
EyeVue/src/types/notice.ts
Normal file
19
EyeVue/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
EyeVue/src/utils/console.ts
Normal file
77
EyeVue/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
EyeVue/src/utils/copyright.ts
Normal file
42
EyeVue/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;
|
||||||
|
};
|
||||||
|
})();
|
||||||
21
EyeVue/src/utils/font.ts
Normal file
21
EyeVue/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';
|
||||||
|
}
|
||||||
|
};
|
||||||
43
EyeVue/src/utils/rss.ts
Normal file
43
EyeVue/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
EyeVue/src/utils/security.ts
Normal file
70
EyeVue/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);
|
||||||
|
};
|
||||||
35
EyeVue/src/views/ErrorView.vue
Normal file
35
EyeVue/src/views/ErrorView.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="min-h-screen flex items-center justify-center bg-gray-900 text-white"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-6xl font-bold text-red-500 mb-4">⚠️ 警告</h1>
|
||||||
|
<p class="text-xl mb-8">检测到非法操作,已记录您的访问信息。</p>
|
||||||
|
<button
|
||||||
|
@click="goBack"
|
||||||
|
class="px-6 py-2 bg-red-500 hover:bg-red-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
返回首页
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
router.push("/");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 防止返回
|
||||||
|
onMounted(() => {
|
||||||
|
history.pushState(null, "", document.URL);
|
||||||
|
window.addEventListener("popstate", () => {
|
||||||
|
history.pushState(null, "", document.URL);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
420
EyeVue/src/views/HomeView.vue
Normal file
420
EyeVue/src/views/HomeView.vue
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
<template>
|
||||||
|
<div class="stream-player">
|
||||||
|
<div class="controls">
|
||||||
|
<input
|
||||||
|
v-model="streamId"
|
||||||
|
placeholder="输入流ID"
|
||||||
|
class="stream-id-input"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="authToken"
|
||||||
|
placeholder="输入认证Token"
|
||||||
|
class="auth-token-input"
|
||||||
|
/>
|
||||||
|
<button @click="startPlay" class="start-btn">开始播放</button>
|
||||||
|
<button @click="stopPlay" class="stop-btn" :disabled="!isPlaying">停止播放</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status" v-if="statusMessage" :class="statusClass">{{ statusMessage }}</div>
|
||||||
|
|
||||||
|
<!-- 显式显示当前加载的TS文件名称 -->
|
||||||
|
<div class="ts-info" v-if="currentTsFile || loadedTsFiles.length">
|
||||||
|
<div class="current-ts">当前加载: <strong>{{ currentTsFile || '无' }}</strong></div>
|
||||||
|
<div class="loaded-ts-list">
|
||||||
|
已加载列表:
|
||||||
|
<span
|
||||||
|
v-for="(ts, index) in loadedTsFiles"
|
||||||
|
:key="index"
|
||||||
|
:class="{ 'current': ts === currentTsFile }"
|
||||||
|
>
|
||||||
|
{{ ts }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="video-container">
|
||||||
|
<video
|
||||||
|
id="streamVideo"
|
||||||
|
class="video-js vjs-big-play-centered"
|
||||||
|
controls
|
||||||
|
autoplay
|
||||||
|
playsinline
|
||||||
|
></video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import videojs from 'video.js';
|
||||||
|
import 'video.js/dist/video-js.css';
|
||||||
|
import '@videojs/http-streaming';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'StreamPlayer',
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
streamId: '',
|
||||||
|
authToken: '',
|
||||||
|
player: null,
|
||||||
|
isPlaying: false,
|
||||||
|
statusMessage: '',
|
||||||
|
requestCounter: 0,
|
||||||
|
manifestRefreshTimer: null,
|
||||||
|
lastSegmentIndex: -1,
|
||||||
|
currentTsFile: '', // 当前正在加载的TS文件
|
||||||
|
loadedTsFiles: [] // 已加载的TS文件列表
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
statusClass() {
|
||||||
|
if (!this.statusMessage) return '';
|
||||||
|
if (this.statusMessage.includes('错误')) return 'status-error';
|
||||||
|
if (this.statusMessage.includes('播放中')) return 'status-playing';
|
||||||
|
return 'status-info';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
// 提取TS文件名(从URL中)
|
||||||
|
extractTsFileName(url) {
|
||||||
|
// 匹配URL中的xxx.ts格式文件名
|
||||||
|
const match = url.match(/([^\/]+\.ts)(\?|$)/);
|
||||||
|
return match ? match[1] : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
startPlay() {
|
||||||
|
// 输入验证
|
||||||
|
if (!this.streamId.trim()) {
|
||||||
|
this.statusMessage = '错误: 请输入流ID';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.requestCounter++;
|
||||||
|
const timestamp = new Date().getTime();
|
||||||
|
const cacheBuster = `${timestamp}-${this.requestCounter}-${Math.random().toString(36).substr(2, 8)}`;
|
||||||
|
const hlsUrl = `/api/hls/${this.streamId}/stream.m3u8?cache-buster=${cacheBuster}`;
|
||||||
|
|
||||||
|
this.statusMessage = '正在连接流...';
|
||||||
|
this.currentTsFile = '';
|
||||||
|
this.loadedTsFiles = []; // 重置已加载列表
|
||||||
|
|
||||||
|
// 销毁现有播放器
|
||||||
|
if (this.player) {
|
||||||
|
this.stopPlay();
|
||||||
|
}
|
||||||
|
// 初始化播放器
|
||||||
|
this.player = videojs('streamVideo', {
|
||||||
|
autoplay: true,
|
||||||
|
controls: true,
|
||||||
|
responsive: true,
|
||||||
|
fluid: true,
|
||||||
|
preload: 'none',
|
||||||
|
sources: [{
|
||||||
|
src: hlsUrl,
|
||||||
|
type: 'application/x-mpegURL'
|
||||||
|
}],
|
||||||
|
html5: {
|
||||||
|
vhs: {
|
||||||
|
overrideNative: true,
|
||||||
|
enableLowInitialLatency: true,
|
||||||
|
cacheDuration: 0,
|
||||||
|
maxBufferLength: 8,
|
||||||
|
maxMaxBufferLength: 15
|
||||||
|
},
|
||||||
|
hls: {
|
||||||
|
xhrSetup: (xhr, url) => {
|
||||||
|
// 添加认证Token
|
||||||
|
if (this.authToken) {
|
||||||
|
xhr.setRequestHeader('Authorization', `Bearer ${this.authToken}`);
|
||||||
|
}
|
||||||
|
// 缓存控制
|
||||||
|
xhr.setRequestHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
xhr.setRequestHeader('Pragma', 'no-cache');
|
||||||
|
xhr.setRequestHeader('Expires', '0');
|
||||||
|
|
||||||
|
// 检测TS文件请求并记录文件名
|
||||||
|
if (url.includes('.ts')) {
|
||||||
|
const tsFileName = this.extractTsFileName(url);
|
||||||
|
if (tsFileName) {
|
||||||
|
// 更新当前加载的TS文件
|
||||||
|
this.currentTsFile = tsFileName;
|
||||||
|
// 添加到已加载列表(去重)
|
||||||
|
if (!this.loadedTsFiles.includes(tsFileName)) {
|
||||||
|
this.loadedTsFiles.push(tsFileName);
|
||||||
|
// 只保留最近10个记录,避免列表过长
|
||||||
|
if (this.loadedTsFiles.length > 10) {
|
||||||
|
this.loadedTsFiles.shift();
|
||||||
|
}
|
||||||
|
console.log(`[TS加载] ${tsFileName}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
lowLatencyMode: true,
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('HLS错误:', error);
|
||||||
|
if (this.player && this.player.hls) {
|
||||||
|
this.player.hls.startLoad();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 监听播放器事件
|
||||||
|
this.player.on('loadedmetadata', () => {
|
||||||
|
this.isPlaying = true;
|
||||||
|
this.statusMessage = '播放中...';
|
||||||
|
this.startManifestRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.player.on('error', () => {
|
||||||
|
const error = this.player.error();
|
||||||
|
const errorMsg = this.getErrorMessage(error);
|
||||||
|
this.statusMessage = `播放错误: ${errorMsg}`;
|
||||||
|
this.isPlaying = false;
|
||||||
|
console.error('播放器错误详情:', error);
|
||||||
|
|
||||||
|
// 解码或加载错误时尝试重连
|
||||||
|
if (error.code === 3 || error.code === 4) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!this.isPlaying) {
|
||||||
|
this.statusMessage = '尝试重新连接...';
|
||||||
|
this.startPlay();
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.player.on('ended', () => {
|
||||||
|
this.statusMessage = '播放结束';
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.clearManifestRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 监听片段加载完成事件
|
||||||
|
this.player.on('hlsSegmentLoaded', (event, data) => {
|
||||||
|
const tsFileName = this.extractTsFileName(data.segment.uri);
|
||||||
|
if (tsFileName) {
|
||||||
|
console.log(`[TS加载完成] ${tsFileName}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
stopPlay() {
|
||||||
|
if (this.player) {
|
||||||
|
this.player.pause();
|
||||||
|
this.player.dispose();
|
||||||
|
this.player = null;
|
||||||
|
}
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.statusMessage = '已停止播放';
|
||||||
|
this.clearManifestRefresh();
|
||||||
|
this.currentTsFile = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
// 启动M3U8定期刷新
|
||||||
|
startManifestRefresh() {
|
||||||
|
this.clearManifestRefresh();
|
||||||
|
this.manifestRefreshTimer = setInterval(() => {
|
||||||
|
if (this.player && this.player.hls) {
|
||||||
|
try {
|
||||||
|
const playlist = this.player.hls.playlists.selectedVideoPlaylist;
|
||||||
|
if (playlist && playlist.segments) {
|
||||||
|
const currentSegments = playlist.segments.length;
|
||||||
|
// 检测是否有新片段
|
||||||
|
if (currentSegments > this.lastSegmentIndex + 1) {
|
||||||
|
this.lastSegmentIndex = currentSegments - 1;
|
||||||
|
const newTsFile = this.extractTsFileName(playlist.segments[this.lastSegmentIndex].uri);
|
||||||
|
console.log(`[检测到新片段] ${newTsFile || '未知片段'}`);
|
||||||
|
this.player.hls.startLoad();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('刷新播放列表时出错:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
},
|
||||||
|
|
||||||
|
// 清除刷新定时器
|
||||||
|
clearManifestRefresh() {
|
||||||
|
if (this.manifestRefreshTimer) {
|
||||||
|
clearInterval(this.manifestRefreshTimer);
|
||||||
|
this.manifestRefreshTimer = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getErrorMessage(error) {
|
||||||
|
switch (error.code) {
|
||||||
|
case 1: return '加载视频时发生错误';
|
||||||
|
case 2: return '视频格式不支持(请确认使用H.264/AAC编码)';
|
||||||
|
case 3: return '视频无法解码(可能是文件损坏或编码问题)';
|
||||||
|
case 4: return '媒体源无法加载(检查网络或服务器)';
|
||||||
|
case 5: return '未授权访问(Token无效或已过期)';
|
||||||
|
default: return `未知错误 (${error.code}): ${error.message || ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
beforeDestroy() {
|
||||||
|
this.clearManifestRefresh();
|
||||||
|
if (this.player) {
|
||||||
|
this.player.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stream-player {
|
||||||
|
max-width: 1280px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-id-input, .auth-token-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-btn, .stop-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-btn {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-btn:hover {
|
||||||
|
background-color: #45a049;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-btn {
|
||||||
|
background-color: #f44336;
|
||||||
|
color: white;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-btn:not(:disabled) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-btn:not(:disabled):hover {
|
||||||
|
background-color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stop-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-error {
|
||||||
|
background-color: #ffebee;
|
||||||
|
color: #b71c1c;
|
||||||
|
border: 1px solid #ef9a9a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-playing {
|
||||||
|
background-color: #e8f5e9;
|
||||||
|
color: #2e7d32;
|
||||||
|
border: 1px solid #a5d6a7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
color: #0d47a1;
|
||||||
|
border: 1px solid #bbdefb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TS文件信息样式 */
|
||||||
|
.ts-info {
|
||||||
|
margin: 15px 0;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-ts {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loaded-ts-list {
|
||||||
|
color: #666;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loaded-ts-list span {
|
||||||
|
padding: 3px 8px;
|
||||||
|
background-color: #e0e0e0;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loaded-ts-list span.current {
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
width: 100%;
|
||||||
|
background-color: #000;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.video-js) {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式调整 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.controls {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stream-id-input, .auth-token-input {
|
||||||
|
width: 100%;
|
||||||
|
min-width: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-btn, .stop-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.video-js) {
|
||||||
|
min-height: 250px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
338
EyeVue/src/views/edit.vue
Normal file
338
EyeVue/src/views/edit.vue
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
// 表单字段
|
||||||
|
const title = ref('')
|
||||||
|
const content = ref('')
|
||||||
|
const author = ref('')
|
||||||
|
const tags = ref<string[]>([])
|
||||||
|
const newTagName = ref('')
|
||||||
|
|
||||||
|
// 新增字段
|
||||||
|
const transformContentHuman = ref('')
|
||||||
|
|
||||||
|
// 支持多个 AI 翻译项
|
||||||
|
const transformContentMachines = ref<Array<{ prompt: string; text: string }>>([
|
||||||
|
{ prompt: '', text: '' }
|
||||||
|
])
|
||||||
|
|
||||||
|
// 所有可用标签
|
||||||
|
const availableTags = ref<any[]>([])
|
||||||
|
|
||||||
|
// 加载所有标签
|
||||||
|
const fetchTags = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/tags/getAllTags')
|
||||||
|
availableTags.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
alert('获取标签失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加标签到当前文章
|
||||||
|
const addTag = (tagId: string) => {
|
||||||
|
if (!tags.value.includes(tagId)) {
|
||||||
|
tags.value.push(tagId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除已选标签
|
||||||
|
const removeTag = (tagId: string) => {
|
||||||
|
tags.value = tags.value.filter(id => id !== tagId)
|
||||||
|
}
|
||||||
|
var articleId = ref('')
|
||||||
|
|
||||||
|
// 创建新标签并添加到当前文章
|
||||||
|
const createNewTag = async () => {
|
||||||
|
if (!newTagName.value.trim()) return
|
||||||
|
try {
|
||||||
|
const response = await axios.post('/api/tags/saveTag', {
|
||||||
|
title: newTagName.value,
|
||||||
|
pageId: [] // 初始为空
|
||||||
|
})
|
||||||
|
const newTag = response.data
|
||||||
|
availableTags.value.push(newTag)
|
||||||
|
addTag(newTag.id)
|
||||||
|
newTagName.value = ''
|
||||||
|
} catch (error) {
|
||||||
|
alert('创建标签失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var isEdit = false
|
||||||
|
|
||||||
|
// 添加一个新的 AI 翻译项
|
||||||
|
const addMachineTranslation = () => {
|
||||||
|
transformContentMachines.value.push({ prompt: '', text: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除指定索引的 AI 翻译项
|
||||||
|
const removeMachineTranslation = (index: number) => {
|
||||||
|
transformContentMachines.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交文章
|
||||||
|
const submitArticle = async () => {
|
||||||
|
if (!title.value || !content.value || !author.value) {
|
||||||
|
alert('请填写完整信息')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.log('Prompt 数据:', transformContentMachines.value.map(item => item.prompt))
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
// 编辑模式:更新现有文章
|
||||||
|
await axios.post('/api/page/updatePage', {
|
||||||
|
id: articleId,
|
||||||
|
title: title.value,
|
||||||
|
content: content.value,
|
||||||
|
author: author.value,
|
||||||
|
tags: tags.value,
|
||||||
|
transform_content_human: transformContentHuman.value,
|
||||||
|
transform_content_machine: transformContentMachines.value.map(item => item.text),
|
||||||
|
support_machine_prompt: transformContentMachines.value.map(item => item.prompt)
|
||||||
|
})
|
||||||
|
const updatePromises = tags.value.map(async (tagId) => {
|
||||||
|
const tag = availableTags.value.find(t => t.id === tagId)
|
||||||
|
if (!tag) return
|
||||||
|
|
||||||
|
const updatedPageIds = [...new Set([...tag.pageId, articleId])]
|
||||||
|
tag.pageId = updatedPageIds
|
||||||
|
|
||||||
|
await axios.post('/api/tags/saveTag', {
|
||||||
|
id: tagId,
|
||||||
|
title: tag.title,
|
||||||
|
pageId: updatedPageIds
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(updatePromises)
|
||||||
|
} else {
|
||||||
|
// 新建模式:创建新文章
|
||||||
|
const newId = uuidv4()
|
||||||
|
await axios.post('/api/page/savePage', {
|
||||||
|
id: newId,
|
||||||
|
title: title.value,
|
||||||
|
content: content.value,
|
||||||
|
author: author.value,
|
||||||
|
tags: tags.value,
|
||||||
|
transform_content_human: transformContentHuman.value,
|
||||||
|
transform_content_machine: transformContentMachines.value.map(item => item.text),
|
||||||
|
support_machine_prompt: transformContentMachines.value.map(item => item.prompt)
|
||||||
|
})
|
||||||
|
articleId = newId
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatePromises = tags.value.map(async (tagId) => {
|
||||||
|
const tag = availableTags.value.find(t => t.id === tagId)
|
||||||
|
if (!tag) return
|
||||||
|
|
||||||
|
const updatedPageIds = [...new Set([...tag.pageId, articleId])]
|
||||||
|
tag.pageId = updatedPageIds
|
||||||
|
|
||||||
|
await axios.post('/api/tags/saveTag', {
|
||||||
|
id: tagId,
|
||||||
|
title: tag.title,
|
||||||
|
pageId: updatedPageIds
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(updatePromises)
|
||||||
|
|
||||||
|
// 跳转到 read 页面
|
||||||
|
router.push(`/read?id=${articleId}`)
|
||||||
|
} catch (error) {
|
||||||
|
alert('保存失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchTags()
|
||||||
|
|
||||||
|
// 检查是否有 id 参数
|
||||||
|
const { id } = route.query
|
||||||
|
if (typeof id === 'string') {
|
||||||
|
isEdit = true
|
||||||
|
articleId = id
|
||||||
|
await loadArticle(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 加载文章详情
|
||||||
|
const loadArticle = async (id: string) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/page/getPageById', {
|
||||||
|
params: { id }
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = response.data
|
||||||
|
|
||||||
|
// 填充表单字段
|
||||||
|
title.value = data.title || ''
|
||||||
|
content.value = data.content || ''
|
||||||
|
author.value = data.author || ''
|
||||||
|
tags.value = data.tags || []
|
||||||
|
|
||||||
|
transformContentHuman.value = data.transform_content_human || ''
|
||||||
|
|
||||||
|
// 处理 AI 翻译内容和对应的 prompt
|
||||||
|
if (Array.isArray(data.transform_content_machine)) {
|
||||||
|
transformContentMachines.value = data.transform_content_machine.map((text, index) => ({
|
||||||
|
prompt: data.support_machine_prompt?.[index] || '',
|
||||||
|
text
|
||||||
|
}))
|
||||||
|
} else {
|
||||||
|
// 兼容旧数据格式(字符串)
|
||||||
|
transformContentMachines.value = [
|
||||||
|
{
|
||||||
|
prompt: data.support_machine_prompt?.[0] || '',
|
||||||
|
text: data.transform_content_machine || ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查每个标签是否存在
|
||||||
|
for (const tagId of tags.value) {
|
||||||
|
if (!availableTags.value.some(t => t.id === tagId)) {
|
||||||
|
// 如果标签不存在,则删除这个数据
|
||||||
|
tags.value = tags.value.filter(id => id !== tagId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('加载文章失败')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 统计信息
|
||||||
|
const wordCount = computed(() => content.value.trim().split(/\s+/).length)
|
||||||
|
const lineCount = computed(() => content.value.split('\n').length)
|
||||||
|
const language = computed(() => {
|
||||||
|
// 简单检测语言,可扩展为 i18n 或 ML 检测
|
||||||
|
const text = content.value.trim()
|
||||||
|
if (/[\u4e00-\u9fa5]/.test(text)) return '中文'
|
||||||
|
else if (/[a-zA-Z]/.test(text)) return '英文'
|
||||||
|
else return '未知'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row w-full bg-gray-100 dark:bg-gray-900 p-6">
|
||||||
|
<!-- 左侧信息区域 -->
|
||||||
|
<div class="w-3/10 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 overflow-y-auto">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-800 dark:text-white mb-4">内容统计</h2>
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<tbody class="bg-white dark:bg-gray-700">
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-700 dark:text-gray-300">行数</td>
|
||||||
|
<td class="px-4 py-2 text-gray-900 dark:text-gray-200">{{ lineCount }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-700 dark:text-gray-300">语言</td>
|
||||||
|
<td class="px-4 py-2 text-gray-900 dark:text-gray-200">{{ language }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-700 dark:text-gray-300">字符数</td>
|
||||||
|
<td class="px-4 py-2 text-gray-900 dark:text-gray-200">{{ content.length }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-700 dark:text-gray-300">标签数</td>
|
||||||
|
<td class="px-4 py-2 text-gray-900 dark:text-gray-200">{{ tags.length }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧编辑区域 -->
|
||||||
|
<div class="w-7/10 bg-white w-full dark:bg-gray-800 rounded-lg shadow-md p-6 ml-6 overflow-y-auto">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800 dark:text-white mb-6">编辑文章</h1>
|
||||||
|
|
||||||
|
<!-- 标题 -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-gray-700 dark:text-gray-300 font-medium mb-2">标题</label>
|
||||||
|
<input v-model="title" type="text" class="w-full border border-gray-300 dark:border-gray-600 rounded p-2 dark:bg-gray-700 dark:text-white" placeholder="请输入标题">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 内容 -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-gray-700 dark:text-gray-300 font-medium mb-2">内容</label>
|
||||||
|
<textarea v-model="content" rows="8" class="w-full border border-gray-300 dark:border-gray-600 rounded p-2 dark:bg-gray-700 dark:text-white" placeholder="请输入内容"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 新增字段 -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-gray-700 dark:text-gray-300 font-medium mb-2">人工翻译</label>
|
||||||
|
<textarea v-model="transformContentHuman" rows="6" class="w-full border border-gray-300 dark:border-gray-600 rounded p-2 dark:bg-gray-700 dark:text-white" placeholder="人工转换内容"></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-gray-700 dark:text-gray-300 font-medium mb-2">AI翻译内容</label>
|
||||||
|
<div v-for="(item, index) in transformContentMachines" :key="index" class="mb-4 border border-gray-300 dark:border-gray-600 p-2 rounded">
|
||||||
|
<input v-model="item.prompt" placeholder="提示词" class="w-full border-gray-300 dark:border-gray-600 rounded p-2 dark:bg-gray-700 dark:text-white mb-2" />
|
||||||
|
<textarea v-model="item.text" rows="6" placeholder="AI翻译内容" class="w-full border-gray-300 dark:border-gray-600 rounded p-2 dark:bg-gray-700 dark:text-white"></textarea>
|
||||||
|
<button @click="removeMachineTranslation(index)" class="mt-2 bg-red-500 hover:bg-red-600 text-white px-3 py-1 rounded transition">删除</button>
|
||||||
|
</div>
|
||||||
|
<button @click="addMachineTranslation" class="bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded transition mt-2">添加AI翻译</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 作者 -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-gray-700 dark:text-gray-300 font-medium mb-2">作者</label>
|
||||||
|
<input v-model="author" type="text" class="w-full border border-gray-300 dark:border-gray-600 rounded p-2 dark:bg-gray-700 dark:text-white" placeholder="请输入作者名">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签选择 -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-gray-700 dark:text-gray-300 font-medium mb-2">标签</label>
|
||||||
|
|
||||||
|
<!-- 已有标签选择 -->
|
||||||
|
<div class="flex flex-wrap gap-2 mb-2">
|
||||||
|
<select @change="addTag($event.target.value)" class="border border-gray-300 dark:border-gray-600 rounded p-2 dark:bg-gray-700 dark:text-white">
|
||||||
|
<option value="">请选择标签</option>
|
||||||
|
<option v-for="tag in availableTags" :key="tag.id" :value="tag.id">{{ tag.title }}</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- 新建标签 -->
|
||||||
|
<div class="flex items-center space-x-5">
|
||||||
|
<input v-model="newTagName" type="text" placeholder="新建标签名称" class="border border-gray-300 dark:border-gray-600 rounded p-2 dark:bg-gray-700 dark:text-white w-48">
|
||||||
|
<button @click="createNewTag" class="bg-blue-500 hover:bg-blue-600 text-white px-3 py-1 rounded transition">新建</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 提交按钮 -->
|
||||||
|
<div class="flex justify-end bg-clip-padding">
|
||||||
|
<button @click="submitArticle" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded transition">
|
||||||
|
提交文章
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 已选标签展示 -->
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<span v-for="tag in tags.map(id => availableTags.find(t => t.id === id)).filter(Boolean)" :key="tag.id"
|
||||||
|
class="inline-flex items-center bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-white px-3 py-1 rounded-full text-sm">
|
||||||
|
{{ tag.title }}
|
||||||
|
<button @click="removeTag(tag.id)" class="ml-2 text-red-500">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 防止滚动条干扰 */
|
||||||
|
.flex, .w-4\/10, .w-6\/10 {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
.overflow-y-auto {
|
||||||
|
max-height: calc(100vh - 3rem);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
449
EyeVue/src/views/page.vue
Normal file
449
EyeVue/src/views/page.vue
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watch } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
// 搜索查询
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
// Tags 数据
|
||||||
|
const tags = ref<any[]>([])
|
||||||
|
const selectedTag = ref<any>(null)
|
||||||
|
|
||||||
|
// 分页数据
|
||||||
|
const pages = ref<any[]>([])
|
||||||
|
const pageNum = ref(0)
|
||||||
|
const pageSize = ref(9)
|
||||||
|
const totalPage = ref(1)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
|
||||||
|
// 在 page.vue 中添加右键菜单相关变量
|
||||||
|
const showDeleteConfirm = ref(false)
|
||||||
|
const tagToDelete = ref<any>(null)
|
||||||
|
|
||||||
|
// 修改 selectTag 方法以处理右键事件
|
||||||
|
const handleTagRightClick = (tag: any, event: MouseEvent) => {
|
||||||
|
event.preventDefault() // 阻止默认右键菜单
|
||||||
|
if (tag) {
|
||||||
|
tagToDelete.value = tag
|
||||||
|
showDeleteConfirm.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加删除标签方法
|
||||||
|
const deleteTag = async () => {
|
||||||
|
if (!tagToDelete.value) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用删除API
|
||||||
|
const response = await axios.get(`/api/tags/deleteTag?id=${tagToDelete.value.id}`)
|
||||||
|
|
||||||
|
if (response.data.success) { // 假设API返回success字段表示成功
|
||||||
|
// 从标签列表中移除
|
||||||
|
tags.value = tags.value.filter(tag => tag.id !== tagToDelete.value.id)
|
||||||
|
|
||||||
|
// 如果删除的是当前选中的标签,重置选中状态
|
||||||
|
if (selectedTag.value?.id === tagToDelete.value.id) {
|
||||||
|
selectedTag.value = null
|
||||||
|
pages.value = dataPages.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示成功提示(可选)
|
||||||
|
console.log('标签删除成功')
|
||||||
|
} else {
|
||||||
|
// 处理错误情况(可选)
|
||||||
|
console.error('标签删除失败')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('调用删除API时出错:', error)
|
||||||
|
} finally {
|
||||||
|
// 重置状态
|
||||||
|
showDeleteConfirm.value = false
|
||||||
|
tagToDelete.value = null
|
||||||
|
fetchAllTags()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有标签
|
||||||
|
const fetchAllTags = async () => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/tags/getAllTags')
|
||||||
|
tags.value = response.data
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching tags:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分页文章
|
||||||
|
const fetchPages = async (resetPage = false) => {
|
||||||
|
if (resetPage) {
|
||||||
|
pageNum.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (searchQuery.value) {
|
||||||
|
// 使用搜索API
|
||||||
|
response = await axios.get('/api/page/search', {
|
||||||
|
params: {
|
||||||
|
xam1: searchQuery.value,
|
||||||
|
pageNum: pageNum.value,
|
||||||
|
pageSize: pageSize.value
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 使用普通分页API
|
||||||
|
response = await axios.get('/api/page/getPages', {
|
||||||
|
params: {
|
||||||
|
pageNum: pageNum.value,
|
||||||
|
pageSize: pageSize.value,
|
||||||
|
...(selectedTag.value && { tagId: selectedTag.value.id })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.value = response.data.pages || response.data
|
||||||
|
totalPage.value = response.data.totalPage || Math.ceil(response.data.length / pageSize.value)
|
||||||
|
total.value = response.data.total || response.data.length
|
||||||
|
dataPages.value = pages.value.slice()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching pages:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择标签
|
||||||
|
const selectTag = (tag: any) => {
|
||||||
|
if (selectedTag.value?.id === tag.id) {
|
||||||
|
selectedTag.value = null
|
||||||
|
} else {
|
||||||
|
selectedTag.value = tag
|
||||||
|
}
|
||||||
|
fliterTags()
|
||||||
|
}
|
||||||
|
//原来的数据
|
||||||
|
const dataPages = ref([])
|
||||||
|
const fliterTags = () => {
|
||||||
|
if (selectedTag.value) {
|
||||||
|
//先复制一份原来的
|
||||||
|
pages.value = dataPages.value.filter(page => page.tags.includes(selectedTag.value.id))
|
||||||
|
} else {
|
||||||
|
pages.value = dataPages.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 获取标签标题
|
||||||
|
const getTagTitle = (tagId: string) => {
|
||||||
|
const tag = tags.value.find(t => t.id === tagId)
|
||||||
|
return tag?.title || '未知标签'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页操作
|
||||||
|
const prevPage = () => {
|
||||||
|
if (pageNum.value > 0) {
|
||||||
|
pageNum.value--
|
||||||
|
fetchPages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextPage = () => {
|
||||||
|
if (pageNum.value < totalPage.value - 1) {
|
||||||
|
pageNum.value++
|
||||||
|
fetchPages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听搜索框变化
|
||||||
|
watch(searchQuery, () => {
|
||||||
|
fetchPages(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 初始化加载
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchAllTags()
|
||||||
|
await fetchPages()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col h-screen">
|
||||||
|
<div class="h-16 bg-white dark:bg-gray-800 p-4 shadow-md flex items-center">
|
||||||
|
<div class="relative w-full max-w-lg flex items-center px-4 py-2">
|
||||||
|
<!-- 发布按钮 -->
|
||||||
|
<button
|
||||||
|
@click="$router.push('/edit')"
|
||||||
|
class="mr-4 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors focus:outline-none"
|
||||||
|
>
|
||||||
|
发布
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索文档..."
|
||||||
|
class="flex-1 w-500 px-4 py-2 pr-10 rounded-lg border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showDeleteConfirm" class="fixed inset-0 bg-black bg-opacity-30 backdrop-blur-sm flex items-center justify-center p-4 z-50">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6 animate-fade-in">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">删除标签</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
确定要删除标签 "{{ tagToDelete?.title }}" 吗?此操作不可恢复。
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
@click="showDeleteConfirm = false"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-md hover:bg-gray-200 dark:hover:bg-gray-600 focus:outline-none"
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="deleteTag"
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-red-600 rounded-md hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-1 overflow-hidden">
|
||||||
|
<!-- Tags 栏 -->
|
||||||
|
<div class="w-1/4 p-4 overflow-y-auto bg-gray-50 border-r border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="text-xl font-semibold mb-4 text-gray-800">标签</h2>
|
||||||
|
<!-- 在标签栏部分修改 -->
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="tag in tags"
|
||||||
|
:key="tag.id"
|
||||||
|
@click="selectTag(tag)"
|
||||||
|
@contextmenu="handleTagRightClick(tag, $event)"
|
||||||
|
:class="[
|
||||||
|
'px-3 py-2 rounded cursor-pointer flex justify-between items-center',
|
||||||
|
selectedTag?.id === tag.id
|
||||||
|
? 'bg-blue-500 hover:bg-blue-600 text-white dark:text-white'
|
||||||
|
: 'bg-white hover:bg-gray-200 dark:bg-white dark:hover:bg-gray-200 text-gray-800 dark:text-gray-800'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
|
||||||
|
<span>{{ tag.title }}</span>
|
||||||
|
<!-- 可选:添加一个小的删除图标作为右键提示 -->
|
||||||
|
<button
|
||||||
|
@click.stop="handleTagRightClick(tag, $event)"
|
||||||
|
class="opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文档列表 -->
|
||||||
|
<div class="w-3/4 p-4 overflow-y-auto">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div
|
||||||
|
v-for="page in pages"
|
||||||
|
:key="page.id"
|
||||||
|
@click="$router.push(`/read?id=` + page.id)"
|
||||||
|
class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer bg-white dark:bg-gray-800"
|
||||||
|
>
|
||||||
|
<h3 class="text-lg font-semibold mb-2 text-gray-800 dark:text-white">{{ page.title }}</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">作者:{{ page.author || '未知' }}</p>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="tagId in page.tags"
|
||||||
|
:key="tagId"
|
||||||
|
class="px-2 py-1 text-xs rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||||
|
>
|
||||||
|
{{ getTagTitle(tagId) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分页组件 -->
|
||||||
|
<div class="mt-6 flex justify-center">
|
||||||
|
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px" aria-label="Pagination">
|
||||||
|
<button
|
||||||
|
@click="prevPage"
|
||||||
|
:disabled="pageNum === 0"
|
||||||
|
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span class="mx-2 self-center text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
第 {{ pageNum + 1 }} 页 / 共 {{ totalPage }} 页
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
@click="nextPage"
|
||||||
|
:disabled="pageNum >= totalPage - 1"
|
||||||
|
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 dark:bg-gray-700 dark:border-gray-600 dark:text-gray-300"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 搜索栏过渡效果 */
|
||||||
|
.transition-colors {
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 输入框聚焦效果 */
|
||||||
|
input:focus + svg {
|
||||||
|
transform: scale(1.1);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.flex {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
/* 卡片悬停效果 */
|
||||||
|
.card-hover:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 自定义滚动条 */
|
||||||
|
.overflow-y-auto {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: #cbd5e0 #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-track {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-y-auto::-webkit-scrollbar-thumb {
|
||||||
|
background: #cbd5e0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.mr-4 {
|
||||||
|
}
|
||||||
|
/* 在 style 部分添加 */
|
||||||
|
@keyframes fade-in {
|
||||||
|
from { opacity: 0; transform: translateY(-10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fade-in 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu {
|
||||||
|
position: fixed;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 0.25rem;
|
||||||
|
z-index: 50;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.context-menu-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
color: #1f2937;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.text-xl {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.context-menu-item:hover {
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 深色模式样式 */
|
||||||
|
.dark .context-menu {
|
||||||
|
background-color: #1f2937;
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
|
.space-y-2{
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.dark .context-menu-item:hover {
|
||||||
|
background-color: #374151;
|
||||||
|
}
|
||||||
|
.w-1\/4{
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
/* 在 style 部分添加或更新 */
|
||||||
|
.dark .bg-blue-500 {
|
||||||
|
background-color: #3b82f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-blue-600:hover {
|
||||||
|
background-color: #2563eb;
|
||||||
|
}
|
||||||
|
/* 标签项样式 */
|
||||||
|
.px-3.py-2 {
|
||||||
|
padding: 0.75rem 1rem; /* 0.75rem = 12px, 1rem = 16px */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 圆角 */
|
||||||
|
.rounded {
|
||||||
|
border-radius: 0.375rem; /* 6px */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 光标为指针 */
|
||||||
|
.cursor-pointer {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 过渡效果 */
|
||||||
|
.transition-colors {
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Flex 布局 */
|
||||||
|
.flex {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 间距和对齐 */
|
||||||
|
.justify-between {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-center {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选中状态样式 */
|
||||||
|
.bg-blue-500.text-white {
|
||||||
|
background-color: #3b82f6; /* 蓝色背景 */
|
||||||
|
color: #ffffff; /* 白色文字 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 悬停状态样式 */
|
||||||
|
.hover\:bg-gray-200:hover {
|
||||||
|
background-color: #edf2f7; /* 浅灰色悬停 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hover\:bg-gray-700:hover {
|
||||||
|
background-color: #4a5568; /* 深色模式下的悬停 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 新增:选中项的悬停效果 */
|
||||||
|
.bg-blue-500.text-white.hover\:bg-blue-600:hover {
|
||||||
|
background-color: #2563eb; /* 更深的蓝色悬停 */
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
364
EyeVue/src/views/read.vue
Normal file
364
EyeVue/src/views/read.vue
Normal file
@@ -0,0 +1,364 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-screen w-full bg-gray-100 dark:bg-gray-900 p-6 overflow-y-auto">
|
||||||
|
<div class="max-w-7xl mx-auto flex w-full">
|
||||||
|
|
||||||
|
<!-- 左侧信息区域 -->
|
||||||
|
<div class="w-3/10 bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mr-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-800 dark:text-white mb-4">内容统计</h2>
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<tbody class="bg-white dark:bg-gray-700">
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-700 dark:text-gray-300">行数</td>
|
||||||
|
<td class="px-4 py-2 text-gray-900 dark:text-gray-200">{{ lineCount }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-700 dark:text-gray-300">语言</td>
|
||||||
|
<td class="px-4 py-2 text-gray-900 dark:text-gray-200">{{ language }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-700 dark:text-gray-300">字符数</td>
|
||||||
|
<td class="px-4 py-2 text-gray-900 dark:text-gray-200">{{ article?.content?.length || 0 }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-700 dark:text-gray-300">阅读数</td>
|
||||||
|
<td class="px-4 py-2 text-gray-900 dark:text-gray-200">{{ article?.read_count || 0 }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-2 font-medium text-gray-700 dark:text-gray-300">标签数</td>
|
||||||
|
<td class="px-4 py-2 text-gray-900 dark:text-gray-200">{{ tagCount }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- 支持统计饼图 -->
|
||||||
|
<div v-if="article" class="mt-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-800 dark:text-white mb-4">翻译支持比例</h2>
|
||||||
|
<div class="w-50">
|
||||||
|
<PieChart :data="supportData" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧阅读区域 -->
|
||||||
|
<div class="w-7/10 w-full bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 overflow-y-auto">
|
||||||
|
<div v-if="loading" class="text-center text-gray-500">加载中...</div>
|
||||||
|
<div v-else-if="error" class="text-center text-red-500">{{ error }}</div>
|
||||||
|
<div v-else class="prose dark:prose-invert max-w-none">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<h1 class="text-3xl font-bold text-gray-800 dark:text-white mb-4">{{ article.title }}</h1>
|
||||||
|
<!-- 作者 -->
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">作者:{{ article.author }}</p>
|
||||||
|
<a class="flex text-sm text-gray-500 dark:text-gray-400 mb-6" :href="`/edit?id=${article.id}`">编辑文章</a>
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<!-- 内容 -->
|
||||||
|
<div class="mb-6 whitespace-pre-line text-gray-700 dark:text-gray-300">
|
||||||
|
{{ article.content }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 人工翻译 -->
|
||||||
|
<div v-if="article.transform_content_human" class="mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">人工翻译内容</h2>
|
||||||
|
<div class="whitespace-pre-line bg-gray-50 dark:bg-gray-700 p-4 rounded border dark:border-gray-600">
|
||||||
|
{{ article.transform_content_human }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="handleLike('human')"
|
||||||
|
class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 like-button"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
:class="{ 'text-red-500 dark:text-red-400': humanLikeStatus }"
|
||||||
|
class="w-4 h-4 mr-1"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.583 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" />
|
||||||
|
</svg>
|
||||||
|
点赞 {{ humanLikeStatus ? '已' : '未' }}完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 多个机器翻译 -->
|
||||||
|
<div v-for="(trans, index) in article.transform_content_machine" :key="index" class="mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">AI 翻译内容 {{ index + 1 }}</h2>
|
||||||
|
|
||||||
|
<!-- 显示 Prompt -->
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">
|
||||||
|
提示词:{{ article.support_machine_prompt[index] || '未提供' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- 翻译内容 -->
|
||||||
|
<div class="whitespace-pre-line bg-gray-50 dark:bg-gray-700 p-4 rounded border dark:border-gray-600">
|
||||||
|
{{ trans }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 点赞按钮 -->
|
||||||
|
<button
|
||||||
|
@click="handleLike(`machine-${index}`)"
|
||||||
|
class="mt-2 flex items-center text-sm text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 like-button"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
:class="{ 'text-red-500 dark:text-red-400': machineLikeStatus[index] }"
|
||||||
|
class="w-4 h-4 mr-1"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.583 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" />
|
||||||
|
</svg>
|
||||||
|
点赞 {{ machineLikeStatus[index] ? '已' : '未' }}完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签 -->
|
||||||
|
<div v-if="article.tags && article.tags.length > 0" class="mt-6">
|
||||||
|
<h2 class="text-lg font-medium text-gray-700 dark:text-gray-300 mb-2">标签</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span v-for="tagId in article.tags" :key="tagId" class="inline-flex items-center bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200 px-3 py-1 rounded-full text-sm">
|
||||||
|
{{ tagTitles[tagId] || '加载中...' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watchEffect } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import PieChart from '@/components/PieChart.vue'
|
||||||
|
import router from '@/router'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 文章数据
|
||||||
|
const article = ref<any>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
// 翻译支持统计
|
||||||
|
const supportData = ref({
|
||||||
|
labels: ['人工翻译'],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: '翻译支持比例',
|
||||||
|
data: [0],
|
||||||
|
backgroundColor: [
|
||||||
|
'rgba(54, 162, 235, 0.7)',
|
||||||
|
'rgba(255, 99, 132, 0.7)',
|
||||||
|
'rgba(75, 192, 192, 0.7)',
|
||||||
|
'rgba(153, 102, 255, 0.7)'
|
||||||
|
],
|
||||||
|
borderColor: [
|
||||||
|
'rgba(54, 162, 235, 1)',
|
||||||
|
'rgba(255, 99, 132, 1)',
|
||||||
|
'rgba(75, 192, 192, 1)',
|
||||||
|
'rgba(153, 102, 255, 1)'
|
||||||
|
],
|
||||||
|
borderWidth: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
// 统计信息计算
|
||||||
|
const wordCount = ref(0)
|
||||||
|
const lineCount = ref(0)
|
||||||
|
const language = ref('未知')
|
||||||
|
const tagCount = ref(0)
|
||||||
|
|
||||||
|
// 标签相关
|
||||||
|
const tagTitles = ref<Record<string, string>>({}) // 保存 tagId -> title 映射
|
||||||
|
|
||||||
|
// 获取标签名称
|
||||||
|
const fetchTagTitle = async (tagId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/tags/getTagById', {
|
||||||
|
params: { id: tagId }
|
||||||
|
})
|
||||||
|
tagTitles.value[tagId] = response.data.title
|
||||||
|
} catch (err) {
|
||||||
|
tagTitles.value[tagId] = '未知标签'
|
||||||
|
console.error(`获取标签 ${tagId} 失败`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量获取所有标签名
|
||||||
|
const fetchAllTagTitles = () => {
|
||||||
|
if (!article.value?.tags?.length) return
|
||||||
|
|
||||||
|
const uniqueTagIds = [...new Set(article.value.tags)] // 去重
|
||||||
|
|
||||||
|
uniqueTagIds.forEach(tagId => {
|
||||||
|
if (!tagTitles.value[tagId]) {
|
||||||
|
fetchTagTitle(tagId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载文章
|
||||||
|
const fetchArticle = async () => {
|
||||||
|
const id = route.query.id as string
|
||||||
|
if (!id) {
|
||||||
|
error.value = '缺少文章 ID'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/page/getPageById', {
|
||||||
|
params: { id }
|
||||||
|
})
|
||||||
|
article.value = response.data
|
||||||
|
|
||||||
|
// 更新本地 read_count
|
||||||
|
if (article.value) {
|
||||||
|
article.value.read_count = (article.value.read_count || 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化点赞状态
|
||||||
|
initLikeStatus()
|
||||||
|
|
||||||
|
// 更新统计数据
|
||||||
|
updateSupportData()
|
||||||
|
wordCount.value = article.value.content.trim().split(/\s+/).length
|
||||||
|
|
||||||
|
// 获取标签名称
|
||||||
|
fetchAllTagTitles()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = '加载文章失败'
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 翻译点赞状态
|
||||||
|
const humanLikeStatus = ref<boolean>(false)
|
||||||
|
const machineLikeStatus = ref<{ [index: number]: boolean }>({})
|
||||||
|
|
||||||
|
const initLikeStatus = () => {
|
||||||
|
// 人工翻译点赞状态
|
||||||
|
const humanKey = `like_human_${article.value.id}`
|
||||||
|
const storedHuman = localStorage.getItem(humanKey)
|
||||||
|
if (storedHuman) {
|
||||||
|
humanLikeStatus.value = JSON.parse(storedHuman).liked
|
||||||
|
}
|
||||||
|
|
||||||
|
// 机器翻译点赞状态
|
||||||
|
const machineLength = article.value?.transform_content_machine?.length || 0
|
||||||
|
for (let i = 0; i < machineLength; i++) {
|
||||||
|
const key = `like_machine_${article.value.id}_${i}`
|
||||||
|
const stored = localStorage.getItem(key)
|
||||||
|
if (stored) {
|
||||||
|
machineLikeStatus.value[i] = JSON.parse(stored).liked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新饼图数据
|
||||||
|
const updateSupportData = () => {
|
||||||
|
if (!article.value) return
|
||||||
|
|
||||||
|
const labels = ['人工翻译']
|
||||||
|
const data = [article.value.support_human]
|
||||||
|
|
||||||
|
if (Array.isArray(article.value.transform_content_machine)) {
|
||||||
|
article.value.transform_content_machine.forEach((_, index) => {
|
||||||
|
labels.push(`AI翻译 ${index + 1}(${article.value.support_machine_prompt[index] || '无提示词'})`)
|
||||||
|
data.push(article.value.support_machine[index])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
supportData.value.labels = labels
|
||||||
|
supportData.value.datasets[0].data = data
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLike = async (type: 'human' | `machine-${number}`) => {
|
||||||
|
let needUpdate = false
|
||||||
|
|
||||||
|
if (type === 'human') {
|
||||||
|
if (humanLikeStatus.value) return
|
||||||
|
article.value.support_human += 1
|
||||||
|
humanLikeStatus.value = true
|
||||||
|
localStorage.setItem(`like_human_${article.value.id}`, JSON.stringify({ liked: true, timestamp: Date.now() }))
|
||||||
|
needUpdate = true
|
||||||
|
} else {
|
||||||
|
const match = type.match(/machine-(\d+)/)
|
||||||
|
if (!match) return
|
||||||
|
const index = parseInt(match[1], 10)
|
||||||
|
if (machineLikeStatus.value[index]) return
|
||||||
|
|
||||||
|
article.value.support_machine[index] += 1
|
||||||
|
machineLikeStatus.value[index] = true
|
||||||
|
localStorage.setItem(`like_machine_${article.value.id}_${index}`, JSON.stringify({ liked: true, timestamp: Date.now() }))
|
||||||
|
needUpdate = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needUpdate) {
|
||||||
|
try {
|
||||||
|
await axios.post('/api/page/like', {
|
||||||
|
id: article.value.id,
|
||||||
|
human: article.value.support_human,
|
||||||
|
machine: article.value.support_machine
|
||||||
|
}, {
|
||||||
|
headers: { 'Content-Type': 'application/json' }
|
||||||
|
})
|
||||||
|
|
||||||
|
updateSupportData()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('点赞失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
router.push(`/readM?id=${route.query.id}`)
|
||||||
|
}
|
||||||
|
fetchArticle()
|
||||||
|
})
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (article.value?.content) {
|
||||||
|
wordCount.value = article.value.content.trim().split(/\s+/).length
|
||||||
|
lineCount.value = article.value.content.split('\n').length
|
||||||
|
const text = article.value.content.trim()
|
||||||
|
if (/[\u4e00-\u9fa5]/.test(text)) {
|
||||||
|
language.value = '中文'
|
||||||
|
} else if (/[a-zA-Z]/.test(text)) {
|
||||||
|
language.value = '英文'
|
||||||
|
} else {
|
||||||
|
language.value = '未知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSupportData()
|
||||||
|
|
||||||
|
tagCount.value = article.value?.tags?.length || 0
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.like-button {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-button:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-button:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex,
|
||||||
|
.w-4\/10,
|
||||||
|
.w-6\/10 {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
.whitespace-pre-line {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
408
EyeVue/src/views/readMoblie.vue
Normal file
408
EyeVue/src/views/readMoblie.vue
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, watchEffect } from 'vue'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import PieChart from "@/components/PieChart.vue";
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 文章数据
|
||||||
|
const article = ref<any>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
// 翻译支持统计
|
||||||
|
const supportData = ref({
|
||||||
|
labels: ['人工翻译', '机器翻译'],
|
||||||
|
datasets: [{
|
||||||
|
label: '翻译支持比例',
|
||||||
|
data: [0, 0],
|
||||||
|
backgroundColor: [
|
||||||
|
'rgba(54, 162, 235, 0.7)',
|
||||||
|
'rgba(255, 99, 132, 0.7)'
|
||||||
|
],
|
||||||
|
borderColor: [
|
||||||
|
'rgba(54, 162, 235, 1)',
|
||||||
|
'rgba(255, 99, 132, 1)'
|
||||||
|
],
|
||||||
|
borderWidth: 1
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
// 统计信息计算
|
||||||
|
const wordCount = ref(0)
|
||||||
|
const lineCount = ref(0)
|
||||||
|
const language = ref('未知')
|
||||||
|
const tagCount = ref(0)
|
||||||
|
|
||||||
|
// 标签相关
|
||||||
|
const tagTitles = ref<Record<string, string>>({}) // 保存 tagId -> title 映射
|
||||||
|
const loadingTags = ref(false)
|
||||||
|
|
||||||
|
// 获取标签名称
|
||||||
|
const fetchTagTitle = async (tagId: string) => {
|
||||||
|
try {
|
||||||
|
const response = await axios.get('/api/tags/getTagById', {
|
||||||
|
params: { id: tagId }
|
||||||
|
})
|
||||||
|
tagTitles.value[tagId] = response.data.title
|
||||||
|
} catch (err) {
|
||||||
|
tagTitles.value[tagId] = '未知标签'
|
||||||
|
console.error(`获取标签 ${tagId} 失败`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量获取所有标签名
|
||||||
|
const fetchAllTagTitles = () => {
|
||||||
|
if (!article.value?.tags?.length) return
|
||||||
|
|
||||||
|
loadingTags.value = true
|
||||||
|
const uniqueTagIds = [...new Set(article.value.tags)] // 去重
|
||||||
|
|
||||||
|
uniqueTagIds.forEach(tagId => {
|
||||||
|
if (!tagTitles.value[tagId]) {
|
||||||
|
fetchTagTitle(tagId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
loadingTags.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载文章
|
||||||
|
const fetchArticle = async () => {
|
||||||
|
const id = route.query.id as string
|
||||||
|
if (!id) {
|
||||||
|
error.value = '缺少文章 ID'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取文章详情
|
||||||
|
const response = await axios.get('/api/page/getPageById', {
|
||||||
|
params: { id }
|
||||||
|
})
|
||||||
|
article.value = response.data
|
||||||
|
|
||||||
|
// 更新本地 read_count
|
||||||
|
if (article.value) {
|
||||||
|
article.value.read_count = (article.value.read_count || 0) + 1
|
||||||
|
}
|
||||||
|
wordCount.value = article.value.content.trim().split(/\s+/).length
|
||||||
|
// 获取标签名称
|
||||||
|
fetchAllTagTitles()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = '加载文章失败'
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 翻译点赞状态
|
||||||
|
const humanLikeStatus = ref<boolean>(false)
|
||||||
|
const machineLikeStatus = ref<boolean>(false)
|
||||||
|
|
||||||
|
const handleLike = async (type: 'machine' | 'human') => {
|
||||||
|
// 更新本地状态
|
||||||
|
if (type === 'machine') {
|
||||||
|
article.value.support_machine++
|
||||||
|
machineLikeStatus.value = !machineLikeStatus.value
|
||||||
|
} else {
|
||||||
|
article.value.support_human++
|
||||||
|
humanLikeStatus.value = !humanLikeStatus.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存到 localStorage
|
||||||
|
const likeKey = `like_${type}_${article.value.id}`
|
||||||
|
localStorage.setItem(likeKey, JSON.stringify({
|
||||||
|
liked: type === 'machine' ? machineLikeStatus.value : humanLikeStatus.value,
|
||||||
|
timestamp: Date.now()
|
||||||
|
}))
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用 API
|
||||||
|
await axios.post(`/api/page/like?id=${article.value.id}&machine=${article.value.support_machine }&human=${article.value.support_human}`)
|
||||||
|
console.log('点赞成功')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('点赞失败:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchArticle()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 统计信息计算
|
||||||
|
watchEffect(() => {
|
||||||
|
if (article.value?.content) {
|
||||||
|
wordCount.value = article.value.content.trim().split(/\s+/).length
|
||||||
|
lineCount.value = article.value.content.split('\n').length
|
||||||
|
const text = article.value.content.trim()
|
||||||
|
if (/[\u4e00-\u9fa5]/.test(text)) {
|
||||||
|
language.value = '中文'
|
||||||
|
} else if (/[a-zA-Z]/.test(text)) {
|
||||||
|
language.value = '英文'
|
||||||
|
} else {
|
||||||
|
language.value = '未知'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watchEffect(() => {
|
||||||
|
if (article.value) {
|
||||||
|
// 更新饼图数据
|
||||||
|
if (article.value && supportData.value) {
|
||||||
|
supportData.value.datasets[0].data = [
|
||||||
|
article.value.support_human || 0,
|
||||||
|
article.value.support_machine || 0
|
||||||
|
]
|
||||||
|
}
|
||||||
|
// 读取机器翻译点赞状态
|
||||||
|
const machineLike = localStorage.getItem(`like_machine_${article.value.id}`)
|
||||||
|
if (machineLike) {
|
||||||
|
machineLikeStatus.value = JSON.parse(machineLike).liked
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取人工翻译点赞状态
|
||||||
|
const humanLike = localStorage.getItem(`like_human_${article.value.id}`)
|
||||||
|
if (humanLike) {
|
||||||
|
humanLikeStatus.value = JSON.parse(humanLike).liked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
tagCount.value = article.value?.tags?.length || 0
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex h-screen w-full bg-gray-100 dark:bg-gray-900 p-4 overflow-y-auto">
|
||||||
|
<div class="max-w-7xl mx-auto flex w-full flex-col md:flex-row">
|
||||||
|
<!-- 左侧信息区域 -->
|
||||||
|
<div class="w-full md:w-3/10 bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 mb-4 md:mb-0 md:p-6 mr-0 md:mr-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-800 dark:text-white mb-4">内容统计</h2>
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<tbody class="bg-white dark:bg-gray-700">
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<td class="px-2 py-1 font-medium text-gray-700 dark:text-gray-300">行数</td>
|
||||||
|
<td class="px-2 py-1 text-gray-900 dark:text-gray-200">{{ lineCount }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<td class="px-2 py-1 font-medium text-gray-700 dark:text-gray-300">语言</td>
|
||||||
|
<td class="px-2 py-1 text-gray-900 dark:text-gray-200">{{ language }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr class="border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<td class="px-2 py-1 font-medium text-gray-700 dark:text-gray-300">字符数</td>
|
||||||
|
<td class="px-2 py-1 text-gray-900 dark:text-gray-200">{{ article?.content?.length || 0 }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 py-1 font-medium text-gray-700 dark:text-gray-300">阅读数</td>
|
||||||
|
<td class="px-2 py-1 text-gray-900 dark:text-gray-200">{{ article?.read_count || 0 }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="px-2 py-1 font-medium text-gray-700 dark:text-gray-300">标签数</td>
|
||||||
|
<td class="px-2 py-1 text-gray-900 dark:text-gray-200">{{ tagCount }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- 支持统计饼图 -->
|
||||||
|
<div v-if="article" class="mt-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-2">翻译支持比例</h2>
|
||||||
|
<div class="w-full h-40">
|
||||||
|
<PieChart :data="supportData" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 右侧阅读区域 -->
|
||||||
|
<div class="w-full md:w-7/10 bg-white dark:bg-gray-800 rounded-lg shadow-md p-4">
|
||||||
|
<div v-if="loading" class="text-center text-gray-500">加载中...</div>
|
||||||
|
<div v-else-if="error" class="text-center text-red-500">{{ error }}</div>
|
||||||
|
<div v-else class="prose dark:prose-invert max-w-none">
|
||||||
|
<!-- 标题 -->
|
||||||
|
<h1 class="text-2xl font-bold text-gray-800 dark:text-white mb-3">{{ article.title }}</h1>
|
||||||
|
|
||||||
|
<!-- 作者 -->
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">作者:{{ article.author }}</p>
|
||||||
|
<a class="flex text-xs text-gray-500 dark:text-gray-400 mb-3" :href="`/edit?id=${article.id}`">编辑文章</a>
|
||||||
|
<hr class="mb-3">
|
||||||
|
|
||||||
|
<!-- 内容 -->
|
||||||
|
<div class="mb-4 whitespace-pre-line text-gray-700 dark:text-gray-300 text-sm">
|
||||||
|
{{ article.content }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 人工翻译 -->
|
||||||
|
<div v-if="article.transform_content_human" class="mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">人工翻译内容</h2>
|
||||||
|
<div class="whitespace-pre-line bg-gray-50 dark:bg-gray-700 p-3 rounded border dark:border-gray-600 text-sm">
|
||||||
|
{{ article.transform_content_human }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="handleLike('human')"
|
||||||
|
class="mt-2 flex items-center justify-center w-full text-sm text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 bg-gray-100 dark:bg-gray-700 rounded-md py-2 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
:class="{'text-red-500 dark:text-red-400': humanLikeStatus}"
|
||||||
|
class="w-4 h-4 mr-1"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.583 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" />
|
||||||
|
</svg>
|
||||||
|
点赞 {{ humanLikeStatus ? '已' : '未' }}完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 机器翻译 -->
|
||||||
|
<div v-if="article.transform_content_machine" class="mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-700 dark:text-gray-300 mb-2">AI 翻译内容</h2>
|
||||||
|
<div class="whitespace-pre-line bg-gray-50 dark:bg-gray-700 p-3 rounded border dark:border-gray-600 text-sm">
|
||||||
|
{{ article.transform_content_machine }}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="handleLike('machine')"
|
||||||
|
class="mt-2 flex items-center justify-center w-full text-sm text-gray-500 dark:text-gray-400 hover:text-red-500 dark:hover:text-red-400 bg-gray-100 dark:bg-gray-700 rounded-md py-2 transition-colors"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
:class="{'text-red-500 dark:text-red-400': machineLikeStatus}"
|
||||||
|
class="w-4 h-4 mr-1"
|
||||||
|
fill="currentColor"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
>
|
||||||
|
<path d="M2 10.5a1.5 1.5 0 113 0v6a1.5 1.5 0 01-3 0v-6zM6 10.333v5.43a2 2 0 001.106 1.79l.05.025A4 4 0 008.943 18h5.416a2 2 0 001.962-1.608l1.2-6A2 2 0 0015.583 8H12V4a2 2 0 00-2-2 1 1 0 00-1 1v.667a4 4 0 01-.8 2.4L6.8 7.933a4 4 0 00-.8 2.4z" />
|
||||||
|
</svg>
|
||||||
|
点赞 {{ machineLikeStatus ? '已' : '未' }}完成
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 标签 -->
|
||||||
|
<div v-if="article.tags && article.tags.length > 0" class="mt-4">
|
||||||
|
<h2 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">标签</h2>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<span
|
||||||
|
v-for="tagId in article.tags"
|
||||||
|
:key="tagId"
|
||||||
|
class="inline-flex items-center bg-gray-200 dark:bg-gray-600 text-gray-800 dark:text-gray-200 px-2 py-0.5 rounded-full text-xs">
|
||||||
|
{{ tagTitles[tagId] || '加载中...' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.like-button {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-button:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.like-button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex, .w-4\/10, .w-6\/10 {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitespace-pre-line {
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端优化 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
html {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-2xl {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-3xl {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.px-2.py-1 {
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
svg.w-4.h-4 {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-lg {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-6 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-4 {
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-4 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-3 {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-2 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-sm {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-xs {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.px-3.py-1 {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-full.text-sm {
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1.text-2xl {
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.whitespace-pre-line {
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 卡片样式优化 */
|
||||||
|
.shadow-md {
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表格适应 */
|
||||||
|
.min-w-full {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
200
EyeVue/src/views/tools/BookmarksView.vue
Normal file
200
EyeVue/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
EyeVue/src/views/tools/JsonFormatterView.vue
Normal file
126
EyeVue/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>
|
||||||
221
EyeVue/src/views/tools/TimestampView.vue
Normal file
221
EyeVue/src/views/tools/TimestampView.vue
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||||
|
import ToolLayout from "@/components/layout/ToolLayout.vue";
|
||||||
|
|
||||||
|
interface TimeFormat {
|
||||||
|
label: string;
|
||||||
|
format: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = ref("");
|
||||||
|
const dateString = ref("");
|
||||||
|
const selectedFormat = ref("YYYY-MM-DD HH:mm:ss");
|
||||||
|
|
||||||
|
const timeFormats: TimeFormat[] = [
|
||||||
|
{ label: "年-月-日 时:分:秒", format: "YYYY-MM-DD HH:mm:ss" },
|
||||||
|
{ label: "年-月-日", format: "YYYY-MM-DD" },
|
||||||
|
{ label: "年/月/日 时:分:秒", format: "YYYY/MM/DD HH:mm:ss" },
|
||||||
|
{ label: "年月日时分秒", format: "YYYYMMDDHHmmss" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 自动更新当前时间戳
|
||||||
|
let timer: number;
|
||||||
|
onMounted(() => {
|
||||||
|
timer = window.setInterval(() => {
|
||||||
|
currentTimestamp.value = Math.floor(Date.now() / 1000);
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearInterval(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 复制到剪贴板
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
window.$toast.success("已复制到剪贴板");
|
||||||
|
} catch (e) {
|
||||||
|
window.$toast.error("复制失败,请手动复制");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 时间戳转日期
|
||||||
|
const timestampToDate = () => {
|
||||||
|
if (!timestamp.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ts =
|
||||||
|
timestamp.value.length === 10
|
||||||
|
? Number(timestamp.value) * 1000
|
||||||
|
: Number(timestamp.value);
|
||||||
|
|
||||||
|
const date = new Date(ts);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
throw new Error("无效的时间戳");
|
||||||
|
}
|
||||||
|
|
||||||
|
dateString.value = formatDate(date, selectedFormat.value);
|
||||||
|
window.$toast.success("转换成功");
|
||||||
|
} catch (e) {
|
||||||
|
window.$toast.error("请输入有效的时间戳");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 日期转时间戳
|
||||||
|
const dateToTimestamp = () => {
|
||||||
|
if (!dateString.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString.value);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
throw new Error("无效的日期");
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp.value = String(Math.floor(date.getTime() / 1000));
|
||||||
|
window.$toast.success("转换成功");
|
||||||
|
} catch (e) {
|
||||||
|
window.$toast.error("请输入有效的日期");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 获取当前时间
|
||||||
|
const setCurrentTime = () => {
|
||||||
|
const now = new Date();
|
||||||
|
timestamp.value = String(Math.floor(now.getTime() / 1000));
|
||||||
|
dateString.value = formatDate(now, selectedFormat.value);
|
||||||
|
window.$toast.success("已使用当前时间");
|
||||||
|
};
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDate = (date: Date, format: string) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
const hours = String(date.getHours()).padStart(2, "0");
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, "0");
|
||||||
|
|
||||||
|
return format
|
||||||
|
.replace("YYYY", String(year))
|
||||||
|
.replace("MM", month)
|
||||||
|
.replace("DD", day)
|
||||||
|
.replace("HH", hours)
|
||||||
|
.replace("mm", minutes)
|
||||||
|
.replace("ss", seconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const currentTimestamp = ref<number>(Math.floor(Date.now() / 1000));
|
||||||
|
|
||||||
|
const updateTimestamp = () => {
|
||||||
|
currentTimestamp.value = Math.floor(Date.now() / 1000);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ToolLayout title="时间戳转换" description="Unix 时间戳与日期格式互转工具">
|
||||||
|
<div class="max-w-4xl mx-auto space-y-8">
|
||||||
|
<!-- 当前时间信息 -->
|
||||||
|
<div class="text-center space-y-2">
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">
|
||||||
|
当前时间戳:
|
||||||
|
<span class="font-mono text-primary">{{ currentTimestamp }}</span>
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
@click="setCurrentTime"
|
||||||
|
class="text-sm text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
使用当前时间
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 转换工具 -->
|
||||||
|
<div class="grid md:grid-cols-2 gap-8">
|
||||||
|
<!-- 时间戳转日期 -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold">时间戳转日期</h2>
|
||||||
|
<button
|
||||||
|
v-if="timestamp"
|
||||||
|
@click="() => copyToClipboard(dateString)"
|
||||||
|
class="text-sm text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
复制结果
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<input
|
||||||
|
v-model="timestamp"
|
||||||
|
type="text"
|
||||||
|
placeholder="请输入时间戳..."
|
||||||
|
class="w-full px-4 py-2 font-mono 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"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
v-model="selectedFormat"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
v-for="format in timeFormats"
|
||||||
|
:key="format.format"
|
||||||
|
:value="format.format"
|
||||||
|
>
|
||||||
|
{{ format.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
@click="timestampToDate"
|
||||||
|
class="w-full btn-primary"
|
||||||
|
:disabled="!timestamp"
|
||||||
|
>
|
||||||
|
转换为日期
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 日期转时间戳 -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold">日期转时间戳</h2>
|
||||||
|
<button
|
||||||
|
v-if="dateString"
|
||||||
|
@click="() => copyToClipboard(timestamp)"
|
||||||
|
class="text-sm text-primary hover:text-primary/80 transition-colors"
|
||||||
|
>
|
||||||
|
复制结果
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<input
|
||||||
|
v-model="dateString"
|
||||||
|
type="datetime-local"
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
@click="dateToTimestamp"
|
||||||
|
class="w-full btn-primary"
|
||||||
|
:disabled="!dateString"
|
||||||
|
>
|
||||||
|
转换为时间戳
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 结果展示 -->
|
||||||
|
<div
|
||||||
|
v-if="timestamp || dateString"
|
||||||
|
class="p-6 bg-gray-50 dark:bg-gray-800 rounded-lg space-y-4"
|
||||||
|
>
|
||||||
|
<div v-if="timestamp" class="space-y-2">
|
||||||
|
<h3 class="font-semibold">时间戳:</h3>
|
||||||
|
<p class="font-mono">{{ timestamp }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="dateString" class="space-y-2">
|
||||||
|
<h3 class="font-semibold">日期时间:</h3>
|
||||||
|
<p class="font-mono">{{ dateString }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ToolLayout>
|
||||||
|
</template>
|
||||||
16
EyeVue/src/workers/blockDetector.worker.js
Normal file
16
EyeVue/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
EyeVue/src/workers/flightProcessor.worker.js
Normal file
52
EyeVue/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
EyeVue/tailwind.config.js
Normal file
52
EyeVue/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
EyeVue/tsconfig.app.json
Normal file
18
EyeVue/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/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
EyeVue/tsconfig.json
Normal file
32
EyeVue/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.d.ts",
|
||||||
|
"src/**/*.tsx",
|
||||||
|
"src/**/*.vue"
|
||||||
|
],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
12
EyeVue/tsconfig.node.json
Normal file
12
EyeVue/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/**/*"]
|
||||||
|
}
|
||||||
147
EyeVue/vite.config.ts
Normal file
147
EyeVue/vite.config.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
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'], // 允许的主机列表
|
||||||
|
proxy: {
|
||||||
|
"/api": {
|
||||||
|
target: "http://127.0.0.1:8080", // 注意这里去掉了后面的/api,Vite会自动拼接
|
||||||
|
changeOrigin: true, // 关键:修改请求头中的Origin为目标服务器地址
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, '/api'), // 保留/api前缀
|
||||||
|
headers: {
|
||||||
|
// 添加可能需要的请求头,解决403问题
|
||||||
|
"Origin": "http://127.0.0.1:8080",
|
||||||
|
"Referer": "http://127.0.0.1:8080",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Pragma": "no-cache"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 代理HLS流请求
|
||||||
|
"/hls": {
|
||||||
|
target: "http://127.0.0.1:8080",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path, // 不修改路径
|
||||||
|
headers: {
|
||||||
|
"Origin": "http://127.0.0.1:8080",
|
||||||
|
"Referer": "http://127.0.0.1:8080",
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// 代理历史视频请求
|
||||||
|
"/saved": {
|
||||||
|
target: "http://127.0.0.1:8080",
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path,
|
||||||
|
headers: {
|
||||||
|
"Origin": "http://127.0.0.1:8080",
|
||||||
|
"Referer": "http://127.0.0.1:8080",
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
define: {
|
||||||
|
__VUE_OPTIONS_API__: true,
|
||||||
|
__VUE_PROD_DEVTOOLS__: false,
|
||||||
|
"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),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -7,7 +7,7 @@ import java.nio.charset.StandardCharsets;
|
|||||||
|
|
||||||
public class FullVideoPush {
|
public class FullVideoPush {
|
||||||
// 服务地址
|
// 服务地址
|
||||||
private static final String SERVER_URL = "http://localhost:8080";
|
private static final String SERVER_URL = "http://localhost:8080/api";
|
||||||
// 本地视频文件
|
// 本地视频文件
|
||||||
private static final String VIDEO_FILE = "./a.mp4";
|
private static final String VIDEO_FILE = "./a.mp4";
|
||||||
|
|
||||||
|
|||||||
@@ -3,65 +3,214 @@ package org.reisa.reisaeye.controller;
|
|||||||
import org.reisa.reisaeye.service.StreamService;
|
import org.reisa.reisaeye.service.StreamService;
|
||||||
import org.springframework.core.io.FileSystemResource;
|
import org.springframework.core.io.FileSystemResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.*;
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.logging.Logger;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
public class StreamController {
|
public class StreamController {
|
||||||
|
private static final Logger logger = Logger.getLogger(StreamController.class.getName());
|
||||||
private final StreamService streamService;
|
private final StreamService streamService;
|
||||||
private final String hlsBasePath;
|
private final String hlsBasePath;
|
||||||
|
// HLS文件最大等待时间(毫秒)
|
||||||
|
private static final int MAX_WAIT_TIME = 5000;
|
||||||
|
private static final int WAIT_INTERVAL = 300;
|
||||||
|
|
||||||
public StreamController(StreamService streamService, org.reisa.reisaeye.config.StreamConfig config) {
|
public StreamController(StreamService streamService, org.reisa.reisaeye.config.StreamConfig config) {
|
||||||
this.streamService = streamService;
|
this.streamService = streamService;
|
||||||
this.hlsBasePath = config.getHlsPath();
|
this.hlsBasePath = config.getHlsPath();
|
||||||
|
// 确保基础路径存在
|
||||||
|
try {
|
||||||
|
Files.createDirectories(Paths.get(hlsBasePath));
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.warning("无法创建HLS基础目录: " + e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动流服务
|
// 启动流服务
|
||||||
@PostMapping("/stream/start")
|
@PostMapping("/stream/start")
|
||||||
public ResponseEntity<StreamService.StreamInfo> start(@RequestParam(required = false) String streamId) {
|
public ResponseEntity<?> start(@RequestParam(required = false) String streamId) {
|
||||||
|
try {
|
||||||
String id = streamService.startStream(streamId);
|
String id = streamService.startStream(streamId);
|
||||||
return ResponseEntity.ok(streamService.getStreamInfo(id));
|
return ResponseEntity.ok(streamService.getStreamInfo(id));
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.warning("启动流失败: " + e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 停止流服务
|
// 停止流服务
|
||||||
@PostMapping("/stream/stop")
|
@PostMapping("/stream/stop")
|
||||||
public ResponseEntity<Map<String, String>> stop(@RequestParam String streamId) {
|
public ResponseEntity<Map<String, String>> stop(@RequestParam String streamId) {
|
||||||
|
try {
|
||||||
streamService.stopStream(streamId);
|
streamService.stopStream(streamId);
|
||||||
Map<String, String> result = new HashMap<>();
|
Map<String, String> result = new HashMap<>();
|
||||||
result.put("status", "stopped");
|
result.put("status", "stopped");
|
||||||
result.put("streamId", streamId);
|
result.put("streamId", streamId);
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
logger.warning("停止流失败: " + e.getMessage());
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取流信息
|
// 获取流信息
|
||||||
@GetMapping("/stream/info")
|
@GetMapping("/stream/info")
|
||||||
public ResponseEntity<StreamService.StreamInfo> info(@RequestParam String streamId) {
|
public ResponseEntity<?> info(@RequestParam String streamId) {
|
||||||
return ResponseEntity.ok(streamService.getStreamInfo(streamId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提供HLS文件访问(用于播放)
|
|
||||||
@GetMapping("/hls/{streamId}/**")
|
|
||||||
public ResponseEntity<Resource> hlsFile(
|
|
||||||
@PathVariable String streamId,
|
|
||||||
@RequestParam(required = false) String filename) {
|
|
||||||
try {
|
try {
|
||||||
// 构建HLS文件路径
|
return ResponseEntity.ok(streamService.getStreamInfo(streamId));
|
||||||
String relativePath = filename != null ? streamId + "/" + filename : streamId + "/stream.m3u8";
|
} catch (RuntimeException e) {
|
||||||
Path filePath = Path.of(hlsBasePath).resolve(relativePath);
|
logger.warning("获取流信息失败: " + e.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", e.getMessage()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Resource resource = new FileSystemResource(filePath);
|
// 检查流状态
|
||||||
if (resource.exists() && resource.isReadable()) {
|
@GetMapping("/stream/status")
|
||||||
return ResponseEntity.ok(resource);
|
public ResponseEntity<Map<String, Object>> status(@RequestParam String streamId) {
|
||||||
|
Map<String, Object> status = new HashMap<>();
|
||||||
|
status.put("streamId", streamId);
|
||||||
|
status.put("running", streamService.isStreamRunning(streamId));
|
||||||
|
status.put("timestamp", Instant.now().toEpochMilli());
|
||||||
|
return ResponseEntity.ok(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取HLS索引文件(.m3u8)
|
||||||
|
@GetMapping("/hls/{streamId}/stream.m3u8")
|
||||||
|
public ResponseEntity<Resource> getM3u8File(
|
||||||
|
@PathVariable String streamId,
|
||||||
|
@RequestHeader(value = "Authorization", required = false) String authToken) {
|
||||||
|
|
||||||
|
// 验证权限(生产环境请启用)
|
||||||
|
// if (!validateAuthToken(authToken)) {
|
||||||
|
// logger.warning("M3U8请求未授权: " + streamId);
|
||||||
|
// return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
|
// }
|
||||||
|
|
||||||
|
try {
|
||||||
|
Path streamDir = Paths.get(hlsBasePath, streamId);
|
||||||
|
Path m3u8Path = streamDir.resolve("stream.m3u8");
|
||||||
|
|
||||||
|
// 检查流是否正在运行
|
||||||
|
// if (!streamService.isStreamRunning(streamId)) {
|
||||||
|
// logger.warning("流未运行: " + streamId);
|
||||||
|
// return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
// .body(null);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 等待m3u8文件生成
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
while (System.currentTimeMillis() - startTime < MAX_WAIT_TIME) {
|
||||||
|
if (Files.exists(m3u8Path) && Files.size(m3u8Path) > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Thread.sleep(WAIT_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Files.exists(m3u8Path) && Files.isReadable(m3u8Path) && Files.size(m3u8Path) > 0) {
|
||||||
|
Resource resource = new FileSystemResource(m3u8Path);
|
||||||
|
|
||||||
|
// 构建响应头,重点禁用缓存并设置正确的MIME类型
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.parseMediaType("application/x-mpegURL"));
|
||||||
|
headers.setCacheControl("no-cache, no-store, must-revalidate");
|
||||||
|
headers.setPragma("no-cache");
|
||||||
|
headers.setExpires(0);
|
||||||
|
// 添加ETag支持,用于验证文件是否更新
|
||||||
|
headers.setETag("W/\"" + Files.size(m3u8Path) + "-" + Files.getLastModifiedTime(m3u8Path).toMillis() + "\"");
|
||||||
|
headers.setLastModified(Files.getLastModifiedTime(m3u8Path).toMillis());
|
||||||
|
|
||||||
|
logger.info("提供M3U8文件: " + m3u8Path.getFileName());
|
||||||
|
return new ResponseEntity<>(resource, headers, HttpStatus.OK);
|
||||||
} else {
|
} else {
|
||||||
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
|
logger.warning("M3U8文件不存在或为空: " + m3u8Path);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
||||||
}
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
logger.warning("等待M3U8文件被中断: " + e.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
|
logger.severe("获取M3U8文件失败: " + e.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取HLS切片文件(.ts)
|
||||||
|
@GetMapping("/hls/{streamId}/{tsFilename:.+\\.ts}")
|
||||||
|
public ResponseEntity<Resource> getTsFile(
|
||||||
|
@PathVariable String streamId,
|
||||||
|
@PathVariable String tsFilename,
|
||||||
|
@RequestHeader(value = "Authorization", required = false) String authToken) {
|
||||||
|
|
||||||
|
// 验证权限(生产环境请启用)
|
||||||
|
// if (!validateAuthToken(authToken)) {
|
||||||
|
// logger.warning("TS请求未授权: " + streamId + "/" + tsFilename);
|
||||||
|
// return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
|
||||||
|
// }
|
||||||
|
|
||||||
|
try {
|
||||||
|
Path tsPath = Paths.get(hlsBasePath, streamId, tsFilename);
|
||||||
|
|
||||||
|
// 检查流是否正在运行
|
||||||
|
// if (!streamService.isStreamRunning(streamId)) {
|
||||||
|
// logger.warning("流未运行,无法获取TS文件: " + streamId);
|
||||||
|
// return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
||||||
|
// }
|
||||||
|
|
||||||
|
// 等待TS文件生成
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
while (System.currentTimeMillis() - startTime < MAX_WAIT_TIME) {
|
||||||
|
if (Files.exists(tsPath) && Files.size(tsPath) > 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Thread.sleep(WAIT_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Files.exists(tsPath) && Files.isReadable(tsPath) && Files.size(tsPath) > 0) {
|
||||||
|
Resource resource = new FileSystemResource(tsPath);
|
||||||
|
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.setContentType(MediaType.parseMediaType("video/MP2T"));
|
||||||
|
// 禁用缓存
|
||||||
|
headers.setCacheControl("no-cache, no-store, must-revalidate");
|
||||||
|
headers.setPragma("no-cache");
|
||||||
|
headers.setExpires(0);
|
||||||
|
// 添加内容长度
|
||||||
|
headers.setContentLength(Files.size(tsPath));
|
||||||
|
// ETag用于验证文件
|
||||||
|
headers.setETag("W/\"" + Files.size(tsPath) + "-" + Files.getLastModifiedTime(tsPath).toMillis() + "\"");
|
||||||
|
headers.setLastModified(Files.getLastModifiedTime(tsPath).toMillis());
|
||||||
|
|
||||||
|
logger.info("提供TS文件: " + tsFilename + " (" + Files.size(tsPath) + " bytes)");
|
||||||
|
return new ResponseEntity<>(resource, headers, HttpStatus.OK);
|
||||||
|
} else {
|
||||||
|
logger.warning("TS文件不存在或为空: " + tsPath);
|
||||||
|
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
|
||||||
|
}
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
logger.warning("等待TS文件被中断: " + e.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.severe("获取TS文件失败: " + e.getMessage());
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 权限验证方法
|
||||||
|
private boolean validateAuthToken(String authToken) {
|
||||||
|
// 生产环境中替换为实际的JWT验证逻辑
|
||||||
|
// 例如: return jwtService.validateToken(authToken.replace("Bearer ", ""));
|
||||||
|
// 目前为了测试方便,允许空令牌
|
||||||
|
return authToken == null || authToken.startsWith("Bearer ");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<title>简易流播放器</title>
|
|
||||||
<link href="https://vjs.zencdn.net/8.6.0/video-js.css" rel="stylesheet">
|
|
||||||
<script src="https://vjs.zencdn.net/8.6.0/video.min.js"></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h3>流播放器</h3>
|
|
||||||
<input type="text" id="streamId" value="mystream" placeholder="输入流ID">
|
|
||||||
<button onclick="startPlay()">开始播放</button>
|
|
||||||
<div style="margin-top: 20px;">
|
|
||||||
<video id="player" class="video-js vjs-default-skin" controls width="800" height="450"></video>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let player = null;
|
|
||||||
|
|
||||||
function startPlay() {
|
|
||||||
const streamId = document.getElementById("streamId").value;
|
|
||||||
const playUrl = `/stream/${streamId}`;
|
|
||||||
|
|
||||||
// 销毁现有播放器
|
|
||||||
if (player) {
|
|
||||||
player.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建新播放器
|
|
||||||
player = videojs('player', {
|
|
||||||
autoplay: true,
|
|
||||||
controls: true,
|
|
||||||
sources: [{
|
|
||||||
src: playUrl,
|
|
||||||
type: 'application/x-mpegURL'
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user