网站和推流制作完成

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

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
});
}
};