网站和推流制作完成

This commit is contained in:
2025-08-08 16:07:49 +08:00
parent 3f67c118de
commit 7894b155dd
87 changed files with 26936 additions and 73 deletions

12
EyeVue/.editorconfig Normal file
View File

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

35
EyeVue/.gitignore vendored Normal file
View File

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

27
EyeVue/LICENSE Normal file
View File

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

120
EyeVue/README.md Normal file
View File

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

18
EyeVue/api/rss.ts Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

58
EyeVue/index.html Normal file
View File

@@ -0,0 +1,58 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="./src/assets/icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 基础 Meta 标签 -->
<title>Powered by Reisa</title>
<meta name="description" content="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

File diff suppressed because it is too large Load Diff

70
EyeVue/package.json Normal file
View 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

File diff suppressed because it is too large Load Diff

6
EyeVue/postcss.config.js Normal file
View File

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

View File

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

3
EyeVue/public/robots.txt Normal file
View File

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

27
EyeVue/public/sitemap.xml Normal file
View File

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

135
EyeVue/src/App.vue Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
EyeVue/src/assets/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

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

View File

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

View File

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

View 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>

View 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>

View 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>

View 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>

View 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>

View File

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

View File

@@ -0,0 +1,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>

View 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>

View File

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

View File

@@ -0,0 +1,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>

View 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>

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

17
EyeVue/src/config/font.ts Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

7
EyeVue/src/config/rss.ts Normal file
View File

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

View File

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

39
EyeVue/src/config/site.ts Normal file
View File

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

View File

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

167
EyeVue/src/env.d.ts vendored Normal file
View File

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

9
EyeVue/src/eventBus.ts Normal file
View 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
View File

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

View File

@@ -0,0 +1,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;

View File

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

8
EyeVue/src/types/blog.ts Normal file
View 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
View 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;
};
}

View File

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

View File

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

View File

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

21
EyeVue/src/utils/font.ts Normal file
View File

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

43
EyeVue/src/utils/rss.ts Normal file
View File

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

View File

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

View File

@@ -0,0 +1,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>

View 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
View 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
View 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
View 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>

View 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>

View File

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

View File

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

View 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>

View File

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

View File

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

52
EyeVue/tailwind.config.js Normal file
View File

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

18
EyeVue/tsconfig.app.json Normal file
View File

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

32
EyeVue/tsconfig.json Normal file
View 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
View File

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

147
EyeVue/vite.config.ts Normal file
View 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", // 注意这里去掉了后面的/apiVite会自动拼接
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),
},
});

View File

@@ -7,7 +7,7 @@ import java.nio.charset.StandardCharsets;
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";

View File

@@ -3,65 +3,214 @@ package org.reisa.reisaeye.controller;
import org.reisa.reisaeye.service.StreamService;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.*;
import org.springframework.web.bind.annotation.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Logger;
@RestController
@RequestMapping("/api")
public class StreamController {
private static final Logger logger = Logger.getLogger(StreamController.class.getName());
private final StreamService streamService;
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) {
this.streamService = streamService;
this.hlsBasePath = config.getHlsPath();
// 确保基础路径存在
try {
Files.createDirectories(Paths.get(hlsBasePath));
} catch (Exception e) {
logger.warning("无法创建HLS基础目录: " + e.getMessage());
}
}
// 启动流服务
@PostMapping("/stream/start")
public ResponseEntity<StreamService.StreamInfo> start(@RequestParam(required = false) String streamId) {
String id = streamService.startStream(streamId);
return ResponseEntity.ok(streamService.getStreamInfo(id));
public ResponseEntity<?> start(@RequestParam(required = false) String streamId) {
try {
String id = streamService.startStream(streamId);
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")
public ResponseEntity<Map<String, String>> stop(@RequestParam String streamId) {
streamService.stopStream(streamId);
Map<String, String> result = new HashMap<>();
result.put("status", "stopped");
result.put("streamId", streamId);
return ResponseEntity.ok(result);
try {
streamService.stopStream(streamId);
Map<String, String> result = new HashMap<>();
result.put("status", "stopped");
result.put("streamId", streamId);
return ResponseEntity.ok(result);
} catch (RuntimeException e) {
logger.warning("停止流失败: " + e.getMessage());
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
}
}
// 获取流信息
@GetMapping("/stream/info")
public ResponseEntity<StreamService.StreamInfo> 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) {
public ResponseEntity<?> info(@RequestParam String streamId) {
try {
// 构建HLS文件路径
String relativePath = filename != null ? streamId + "/" + filename : streamId + "/stream.m3u8";
Path filePath = Path.of(hlsBasePath).resolve(relativePath);
Resource resource = new FileSystemResource(filePath);
if (resource.exists() && resource.isReadable()) {
return ResponseEntity.ok(resource);
} else {
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null);
}
} catch (Exception e) {
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(null);
return ResponseEntity.ok(streamService.getStreamInfo(streamId));
} catch (RuntimeException e) {
logger.warning("获取流信息失败: " + e.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Map.of("error", e.getMessage()));
}
}
// 检查流状态
@GetMapping("/stream/status")
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 {
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) {
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 ");
}
}

View File

@@ -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>