forked from Reisa/Reisaye
master #37
2
.gitattributes
vendored
2
.gitattributes
vendored
@@ -1,2 +0,0 @@
|
|||||||
/mvnw text eol=lf
|
|
||||||
*.cmd text eol=crlf
|
|
||||||
46
.gitignore
vendored
46
.gitignore
vendored
@@ -1,33 +1,15 @@
|
|||||||
HELP.md
|
|
||||||
target/
|
|
||||||
.mvn/wrapper/maven-wrapper.jar
|
|
||||||
!**/src/main/**/target/
|
|
||||||
!**/src/test/**/target/
|
|
||||||
|
|
||||||
### STS ###
|
|
||||||
.apt_generated
|
|
||||||
.classpath
|
|
||||||
.factorypath
|
|
||||||
.project
|
|
||||||
.settings
|
|
||||||
.springBeans
|
|
||||||
.sts4-cache
|
|
||||||
|
|
||||||
### IntelliJ IDEA ###
|
|
||||||
.idea
|
|
||||||
*.iws
|
|
||||||
*.iml
|
*.iml
|
||||||
*.ipr
|
.gradle
|
||||||
|
/local.properties
|
||||||
### NetBeans ###
|
/.idea/caches
|
||||||
/nbproject/private/
|
/.idea/libraries
|
||||||
/nbbuild/
|
/.idea/modules.xml
|
||||||
/dist/
|
/.idea/workspace.xml
|
||||||
/nbdist/
|
/.idea/navEditor.xml
|
||||||
/.nb-gradle/
|
/.idea/assetWizardSettings.xml
|
||||||
build/
|
.DS_Store
|
||||||
!**/src/main/**/build/
|
/build
|
||||||
!**/src/test/**/build/
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
### VS Code ###
|
.cxx
|
||||||
.vscode/
|
local.properties
|
||||||
|
|||||||
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
6
.idea/compiler.xml
generated
Normal file
6
.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<bytecodeTargetLevel target="17" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
16
.idea/gradle.xml
generated
Normal file
16
.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GradleSettings">
|
||||||
|
<option name="linkedExternalProjectsSettings">
|
||||||
|
<GradleProjectSettings>
|
||||||
|
<option name="externalProjectPath" value="$PROJECT_DIR$" />
|
||||||
|
<option name="modules">
|
||||||
|
<set>
|
||||||
|
<option value="$PROJECT_DIR$" />
|
||||||
|
<option value="$PROJECT_DIR$/app" />
|
||||||
|
</set>
|
||||||
|
</option>
|
||||||
|
</GradleProjectSettings>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
13
.idea/misc.xml
generated
Normal file
13
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="FrameworkDetectionExcludesConfiguration">
|
||||||
|
<file type="web" url="file://$PROJECT_DIR$" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/build/classes" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectType">
|
||||||
|
<option name="id" value="Android" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
19
.mvn/wrapper/maven-wrapper.properties
vendored
19
.mvn/wrapper/maven-wrapper.properties
vendored
@@ -1,19 +0,0 @@
|
|||||||
# Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
# or more contributor license agreements. See the NOTICE file
|
|
||||||
# distributed with this work for additional information
|
|
||||||
# regarding copyright ownership. The ASF licenses this file
|
|
||||||
# to you under the Apache License, Version 2.0 (the
|
|
||||||
# "License"); you may not use this file except in compliance
|
|
||||||
# with the License. You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing,
|
|
||||||
# software distributed under the License is distributed on an
|
|
||||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
# KIND, either express or implied. See the License for the
|
|
||||||
# specific language governing permissions and limitations
|
|
||||||
# under the License.
|
|
||||||
wrapperVersion=3.3.2
|
|
||||||
distributionType=only-script
|
|
||||||
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 2
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
|
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
35
EyeVue/.gitignore
vendored
35
EyeVue/.gitignore
vendored
@@ -1,35 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
.DS_Store
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
coverage
|
|
||||||
*.local
|
|
||||||
|
|
||||||
/cypress/videos/
|
|
||||||
/cypress/screenshots/
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
!.vscode/settings.json
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# 环境变量文件
|
|
||||||
.env*
|
|
||||||
!.env.example
|
|
||||||
.env
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2024 Reisa
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
|
|
||||||
Additional Terms:
|
|
||||||
|
|
||||||
1. The footer copyright notice and author attribution must be preserved.
|
|
||||||
2. Any modifications to the footer must maintain the original author's credit.
|
|
||||||
3. Commercial use requires explicit permission from the author.
|
|
||||||
120
EyeVue/README.md
120
EyeVue/README.md
@@ -1,120 +0,0 @@
|
|||||||
# ReisaWork0604
|
|
||||||
|
|
||||||
一个使用 Vue 3 + TypeScript + Vite 构建的现代化个人主页,具有博客文章展示、项目展示、联系表单等功能。
|
|
||||||
- Reisa 改编
|
|
||||||
|
|
||||||
## 特性
|
|
||||||
|
|
||||||
- 🚀 使用 Vue 3 + TypeScript + Vite 构建
|
|
||||||
- 🎨 支持深色模式
|
|
||||||
- 📱 响应式设计,支持移动端
|
|
||||||
- ⚡️ 快速加载和页面切换
|
|
||||||
- 🔍 SEO 友好
|
|
||||||
- 🌐 支持多语言
|
|
||||||
- 📝 Markdown 博客支持
|
|
||||||
- 📦 组件自动导入
|
|
||||||
- 🎯 TypeScript 类型安全
|
|
||||||
- 🔧 可配置的主题
|
|
||||||
|
|
||||||
## 技术栈
|
|
||||||
|
|
||||||
- Vue 3
|
|
||||||
- TypeScript
|
|
||||||
- Vite
|
|
||||||
- Vue Router
|
|
||||||
- TailwindCSS
|
|
||||||
- PostCSS
|
|
||||||
- ESLint + Prettier
|
|
||||||
- Husky + lint-staged
|
|
||||||
|
|
||||||
## 开发
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 克隆项目
|
|
||||||
git clone https://github.com/Spaso1/ReisaPage.git
|
|
||||||
|
|
||||||
# 安装依赖
|
|
||||||
pnpm install
|
|
||||||
|
|
||||||
# 启动开发服务器
|
|
||||||
pnpm dev
|
|
||||||
|
|
||||||
# 构建生产版本
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# 预览生产构建
|
|
||||||
pnpm preview
|
|
||||||
|
|
||||||
# 代码格式化
|
|
||||||
pnpm format
|
|
||||||
|
|
||||||
# 代码检查
|
|
||||||
pnpm lint
|
|
||||||
```
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
```
|
|
||||||
├── public/ # 静态资源
|
|
||||||
├── src/
|
|
||||||
│ ├── assets/ # 项目资源
|
|
||||||
│ ├── components/ # 组件
|
|
||||||
│ ├── config/ # 配置文件
|
|
||||||
│ ├── layouts/ # 布局组件
|
|
||||||
│ ├── pages/ # 页面
|
|
||||||
│ ├── router/ # 路由配置
|
|
||||||
│ ├── styles/ # 样式文件
|
|
||||||
│ ├── types/ # TypeScript 类型
|
|
||||||
│ ├── utils/ # 工具函数
|
|
||||||
│ ├── App.vue # 根组件
|
|
||||||
│ └── main.ts # 入口文件
|
|
||||||
├── .env # 环境变量
|
|
||||||
├── index.html # HTML 模板
|
|
||||||
├── package.json # 项目配置
|
|
||||||
├── tsconfig.json # TypeScript 配置
|
|
||||||
├── vite.config.ts # Vite 配置
|
|
||||||
└── README.md # 项目说明
|
|
||||||
```
|
|
||||||
|
|
||||||
## 配置
|
|
||||||
|
|
||||||
### 站点配置
|
|
||||||
|
|
||||||
在 `src/config/site.ts` 中配置站点基本信息:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const siteConfig = {
|
|
||||||
name: "Your Site Name",
|
|
||||||
description: "Your site description",
|
|
||||||
// ...其他配置
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
### 主题配置
|
|
||||||
|
|
||||||
在 `src/config/theme.ts` 中配置主题相关选项:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export const themeConfig = {
|
|
||||||
colors: {
|
|
||||||
primary: "#2196f3",
|
|
||||||
// ...其他颜色
|
|
||||||
},
|
|
||||||
// ...其他主题配置
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## 部署
|
|
||||||
|
|
||||||
项目可以部署到任何静态网站托管服务:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 构建项目
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
# 部署 dist 目录
|
|
||||||
```
|
|
||||||
|
|
||||||
## 许可证
|
|
||||||
|
|
||||||
[MIT](./LICENSE)
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
// Vercel Serverless Function
|
|
||||||
export default async function handler(res) {
|
|
||||||
try {
|
|
||||||
const rssUrl = process.env.RSS_URL;
|
|
||||||
if (!rssUrl) {
|
|
||||||
throw new Error("RSS_URL environment variable is not defined");
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetch(rssUrl);
|
|
||||||
const data = await response.text();
|
|
||||||
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
||||||
res.setHeader("Content-Type", "application/xml");
|
|
||||||
res.status(200).send(data);
|
|
||||||
} catch (error) {
|
|
||||||
res.status(500).json({ error: "Failed to fetch RSS" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 4.2 KiB |
@@ -1,58 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<link rel="icon" href="./src/assets/icon.png" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
|
|
||||||
<!-- 基础 Meta 标签 -->
|
|
||||||
<title>Powered by Reisa</title>
|
|
||||||
<meta name="description" content="Reisa 个人网站" />
|
|
||||||
<meta name="keywords" content="ReisaPage,Vue,Vite,ServerMonitoring,FindMaimai,Maimai,Reisa,Spasol" />
|
|
||||||
<meta name="author" content="Reisa" />
|
|
||||||
|
|
||||||
<!-- Open Graph / Facebook -->
|
|
||||||
<meta property="og:type" content="website" />
|
|
||||||
<meta property="og:url" content="https://www.godserver.cn" />
|
|
||||||
<meta property="og:title" content="Reisa Spasol" />
|
|
||||||
<meta property="og:description" content="Reisa 个人网站" />
|
|
||||||
<meta property="og:image" content="/src/assets/logo.png" />
|
|
||||||
<meta property="og:locale" content="zh_CN" />
|
|
||||||
<meta property="og:site_name" content="Reisa Spasol" />
|
|
||||||
|
|
||||||
<!-- Twitter -->
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
|
||||||
<meta name="twitter:site" content="@Spaso1" />
|
|
||||||
<meta name="twitter:title" content="Reisa Spasol" />
|
|
||||||
<meta name="twitter:description" content="Reisa 个人网站" />
|
|
||||||
<meta name="twitter:image" content="/src/assets/logo.png" />
|
|
||||||
|
|
||||||
<!-- 主题色 -->
|
|
||||||
<meta name="theme-color" content="#42b983" />
|
|
||||||
|
|
||||||
<!-- Schema.org 结构化数据 -->
|
|
||||||
<script type="application/ld+json">
|
|
||||||
{
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "Organization",
|
|
||||||
"name": " Powered by Reisa",
|
|
||||||
"url": "https://www.godserver.cn",
|
|
||||||
"logo": "/src/assets/logo.png",
|
|
||||||
"sameAs": ["https://github.com/Spaso1", "https://twitter.com/Spaso1"]
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- 字体预加载 -->
|
|
||||||
<link
|
|
||||||
rel="preload"
|
|
||||||
href="https://cdn.godserver.cn/resource/lxwk.woff2"
|
|
||||||
as="font"
|
|
||||||
type="font/woff2"
|
|
||||||
crossorigin="anonymous"
|
|
||||||
/>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="app"></div>
|
|
||||||
<script type="module" src="/src/main.ts"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
13678
EyeVue/package-lock.json
generated
13678
EyeVue/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,70 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "home-for-vue",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"license": "MIT",
|
|
||||||
"author": {
|
|
||||||
"name": "Reisa Spasol",
|
|
||||||
"url": "https://www.godserver.cn/"
|
|
||||||
},
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "run-p type-check \"build-only {@}\" --",
|
|
||||||
"build-only": "vite build",
|
|
||||||
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
|
|
||||||
"preview": "vite preview",
|
|
||||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
|
||||||
"format": "prettier --write src/",
|
|
||||||
"serve": "vite run"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@emailjs/browser": "^4.4.1",
|
|
||||||
"axios": "^1.8.4",
|
|
||||||
"chart": "^0.1.2",
|
|
||||||
"chart.js": "^4.4.9",
|
|
||||||
"element-plus": "^2.9.9",
|
|
||||||
"jsqr": "^1.4.0",
|
|
||||||
"marked": "^15.0.10",
|
|
||||||
"mitt": "^3.0.1",
|
|
||||||
"pinia": "^2.1.7",
|
|
||||||
"qrcode.vue": "^3.6.0",
|
|
||||||
"rss-parser": "^3.13.0",
|
|
||||||
"uuid": "^11.1.0",
|
|
||||||
"vue": "^3.4.3",
|
|
||||||
"vue-router": "^4.2.5",
|
|
||||||
"vuetify": "^3.8.0-beta.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@rushstack/eslint-patch": "^1.3.3",
|
|
||||||
"@tsconfig/node18": "^18.2.2",
|
|
||||||
"@types/node": "^20.17.10",
|
|
||||||
"@vitejs/plugin-vue": "^4.5.2",
|
|
||||||
"@vue/eslint-config-prettier": "^8.0.0",
|
|
||||||
"@vue/eslint-config-typescript": "^12.0.0",
|
|
||||||
"@vue/tsconfig": "^0.5.0",
|
|
||||||
"autoprefixer": "^10.4.16",
|
|
||||||
"cesium": "^1.129.0",
|
|
||||||
"eslint": "^8.49.0",
|
|
||||||
"eslint-plugin-vue": "^9.17.0",
|
|
||||||
"imagemin": "^9.0.0",
|
|
||||||
"imagemin-gifsicle": "^7.0.0",
|
|
||||||
"imagemin-mozjpeg": "^10.0.0",
|
|
||||||
"imagemin-optipng": "^8.0.0",
|
|
||||||
"imagemin-pngquant": "^10.0.0",
|
|
||||||
"imagemin-svgo": "^11.0.1",
|
|
||||||
"npm-run-all": "^4.1.5",
|
|
||||||
"postcss": "^8.4.32",
|
|
||||||
"prettier": "^3.0.3",
|
|
||||||
"sharp": "^0.33.5",
|
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"terser": "^5.37.0",
|
|
||||||
"typescript": "~5.3.0",
|
|
||||||
"vite": "^5.4.19",
|
|
||||||
"vite-plugin-cesium": "^1.2.23",
|
|
||||||
"vite-plugin-compression": "^0.5.1",
|
|
||||||
"vite-plugin-image-optimizer": "^1.1.8",
|
|
||||||
"vite-plugin-imagemin": "^0.6.1",
|
|
||||||
"vue-tsc": "^1.8.25"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
6983
EyeVue/pnpm-lock.yaml
generated
6983
EyeVue/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ReisaSpasol | MaimaiDX",
|
|
||||||
"short_name": "ReisaSpasol",
|
|
||||||
"description": "专注于Java、Spring Boot、微服务等后端技术开发的个人作品集网站",
|
|
||||||
"start_url": "/",
|
|
||||||
"display": "standalone",
|
|
||||||
"background_color": "#ffffff",
|
|
||||||
"theme_color": "#4F46E5",
|
|
||||||
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "./assets/icon.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "./assets/icon.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
User-agent: *
|
|
||||||
Allow: /
|
|
||||||
Sitemap: <%= config.siteUrl %>/sitemap.xml
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
||||||
<url>
|
|
||||||
<loc><%= config.siteUrl %>/</loc>
|
|
||||||
<lastmod>2024-03-21</lastmod>
|
|
||||||
<changefreq>weekly</changefreq>
|
|
||||||
<priority>1.0</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc><%= config.siteUrl %>/blog</loc>
|
|
||||||
<lastmod>2024-03-21</lastmod>
|
|
||||||
<changefreq>weekly</changefreq>
|
|
||||||
<priority>0.8</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc><%= config.siteUrl %>/skills</loc>
|
|
||||||
<lastmod>2024-03-21</lastmod>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.8</priority>
|
|
||||||
</url>
|
|
||||||
<url>
|
|
||||||
<loc><%= config.siteUrl %>/contact</loc>
|
|
||||||
<lastmod>2024-03-21</lastmod>
|
|
||||||
<changefreq>monthly</changefreq>
|
|
||||||
<priority>0.7</priority>
|
|
||||||
</url>
|
|
||||||
</urlset>
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
<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>
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 65 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 41 KiB |
@@ -1,48 +0,0 @@
|
|||||||
: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);
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
@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;
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
: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");
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<!-- 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>
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
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",
|
|
||||||
};
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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 加速 / 云存储服务",
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
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`;
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
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: "🔖" },
|
|
||||||
];
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
interface RssConfig {
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const rssConfig: RssConfig = {
|
|
||||||
url: "https://www.godserver.cn/rss.xml", // 直接使用完整 URL
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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;",
|
|
||||||
};
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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", // 联系邮箱
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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
167
EyeVue/src/env.d.ts
vendored
@@ -1,167 +0,0 @@
|
|||||||
/// <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";
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import mitt from 'mitt';
|
|
||||||
|
|
||||||
type Events = {
|
|
||||||
'refresh-user-info': void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const eventBus = mitt<Events>();
|
|
||||||
|
|
||||||
export default eventBus;
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
export interface BlogPost {
|
|
||||||
title: string;
|
|
||||||
link: string;
|
|
||||||
date: Date;
|
|
||||||
description: string;
|
|
||||||
category?: string;
|
|
||||||
image?: string;
|
|
||||||
}
|
|
||||||
8
EyeVue/src/types/global.d.ts
vendored
8
EyeVue/src/types/global.d.ts
vendored
@@ -1,8 +0,0 @@
|
|||||||
interface Window {
|
|
||||||
$toast: {
|
|
||||||
show: (text: string, type?: "success" | "error" | "info") => void;
|
|
||||||
success: (text: string) => void;
|
|
||||||
error: (text: string) => void;
|
|
||||||
info: (text: string) => void;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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[];
|
|
||||||
}
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
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;`);
|
|
||||||
};
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
/**
|
|
||||||
* @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;
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
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';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
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 [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// 禁用开发者工具和快捷键
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,449 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from "vue";
|
|
||||||
import ToolLayout from "@/components/layout/ToolLayout.vue";
|
|
||||||
|
|
||||||
const input = ref("");
|
|
||||||
const output = ref("");
|
|
||||||
const indentSize = ref(2);
|
|
||||||
const error = ref("");
|
|
||||||
|
|
||||||
const formatJson = () => {
|
|
||||||
error.value = "";
|
|
||||||
if (!input.value.trim()) {
|
|
||||||
output.value = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(input.value);
|
|
||||||
output.value = JSON.stringify(parsed, null, indentSize.value);
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof Error ? e.message : "无效的 JSON 格式";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const compressJson = () => {
|
|
||||||
error.value = "";
|
|
||||||
if (!input.value.trim()) {
|
|
||||||
output.value = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(input.value);
|
|
||||||
output.value = JSON.stringify(parsed);
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof Error ? e.message : "无效的 JSON 格式";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyToClipboard = async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(output.value);
|
|
||||||
alert("已复制到剪贴板");
|
|
||||||
} catch (e) {
|
|
||||||
alert("复制失败,请手动复制");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAll = () => {
|
|
||||||
input.value = "";
|
|
||||||
output.value = "";
|
|
||||||
error.value = "";
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasContent = computed(() => input.value.trim().length > 0);
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<ToolLayout
|
|
||||||
title="JSON 格式化工具"
|
|
||||||
description="在线 JSON 格式化工具,支持压缩、美化、验证和转换等功能"
|
|
||||||
>
|
|
||||||
<div class="grid md:grid-cols-2 gap-6">
|
|
||||||
<!-- 输入区域 -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-semibold">输入 JSON</h2>
|
|
||||||
<button
|
|
||||||
v-if="hasContent"
|
|
||||||
@click="clearAll"
|
|
||||||
class="text-sm text-gray-500 hover:text-primary transition-colors"
|
|
||||||
>
|
|
||||||
清空
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
v-model="input"
|
|
||||||
class="w-full h-[400px] p-4 font-mono text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 focus:ring-2 focus:ring-primary focus:border-transparent transition-colors resize-none"
|
|
||||||
placeholder="在此输入 JSON 字符串..."
|
|
||||||
spellcheck="false"
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 输出区域 -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-semibold">格式化结果</h2>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<select
|
|
||||||
v-model="indentSize"
|
|
||||||
class="px-2 py-1 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800"
|
|
||||||
>
|
|
||||||
<option :value="2">缩进 2 空格</option>
|
|
||||||
<option :value="4">缩进 4 空格</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
v-if="output"
|
|
||||||
@click="copyToClipboard"
|
|
||||||
class="text-sm text-primary hover:text-primary/80 transition-colors"
|
|
||||||
>
|
|
||||||
复制
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<pre
|
|
||||||
class="w-full h-[400px] p-4 font-mono text-sm rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 overflow-auto"
|
|
||||||
:class="{ 'border-red-500': error }"
|
|
||||||
><code>{{ error || output }}</code></pre>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
|
||||||
<div class="md:col-span-2 flex justify-center gap-4">
|
|
||||||
<button @click="formatJson" class="btn-primary" :disabled="!hasContent">
|
|
||||||
格式化
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
@click="compressJson"
|
|
||||||
class="btn-secondary"
|
|
||||||
:disabled="!hasContent"
|
|
||||||
>
|
|
||||||
压缩
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ToolLayout>
|
|
||||||
</template>
|
|
||||||
@@ -1,221 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
// 完全保持原有数据处理逻辑,仅在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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
import { fontFamily } from "tailwindcss/defaultTheme";
|
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
darkMode: "class",
|
|
||||||
content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: {
|
|
||||||
DEFAULT: "var(--color-primary)",
|
|
||||||
dark: "var(--color-primary-dark)",
|
|
||||||
light: "var(--color-primary-light)",
|
|
||||||
10: "var(--color-primary-10)",
|
|
||||||
50: '#e6f7ff',
|
|
||||||
100: '#ccf0ff',
|
|
||||||
200: '#99e6ff',
|
|
||||||
300: '#66dbff',
|
|
||||||
400: '#33cfff',
|
|
||||||
500: '#00b3ff',
|
|
||||||
600: '#0099e6',
|
|
||||||
700: '#007fbf',
|
|
||||||
800: '#006699',
|
|
||||||
900: '#004d73',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
backgroundColor: {
|
|
||||||
main: "var(--color-bg-main)",
|
|
||||||
secondary: "var(--color-bg-secondary)",
|
|
||||||
tertiary: "var(--color-bg-tertiary)",
|
|
||||||
},
|
|
||||||
textColor: {
|
|
||||||
primary: "var(--color-text-primary)",
|
|
||||||
secondary: "var(--color-text-secondary)",
|
|
||||||
tertiary: "var(--color-text-tertiary)",
|
|
||||||
},
|
|
||||||
borderColor: {
|
|
||||||
DEFAULT: "var(--color-border)",
|
|
||||||
light: "var(--color-border-light)",
|
|
||||||
},
|
|
||||||
boxShadow: {
|
|
||||||
sm: "var(--shadow-sm)",
|
|
||||||
DEFAULT: "var(--shadow)",
|
|
||||||
md: "var(--shadow-md)",
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
sans: ["Inter var", ...fontFamily.sans],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
};
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
|
||||||
"include": [
|
|
||||||
"env.d.ts",
|
|
||||||
"src/**/*",
|
|
||||||
"src/**/*.ts",
|
|
||||||
"src/**/*.tsx",
|
|
||||||
"src/**/*.vue"
|
|
||||||
],
|
|
||||||
"exclude": ["src/**/__tests__/*"],
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ESNext",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Node",
|
|
||||||
"strict": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"lib": ["ESNext", "DOM"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
},
|
|
||||||
"types": [
|
|
||||||
"node",
|
|
||||||
"vite/client",
|
|
||||||
"@types/node"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"src/**/*.ts",
|
|
||||||
"src/**/*.d.ts",
|
|
||||||
"src/**/*.tsx",
|
|
||||||
"src/**/*.vue"
|
|
||||||
],
|
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowSyntheticDefaultImports": true,
|
|
||||||
"strict": true,
|
|
||||||
"types": ["vite/client"]
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts", "src/config/**/*", "src/types/**/*"]
|
|
||||||
}
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
import { defineConfig } from "vite";
|
|
||||||
import vue from "@vitejs/plugin-vue";
|
|
||||||
import path from "path";
|
|
||||||
import viteCompression from "vite-plugin-compression";
|
|
||||||
import { ViteImageOptimizer } from "vite-plugin-image-optimizer";
|
|
||||||
import { fontConfig } from "./src/config/font";
|
|
||||||
|
|
||||||
import cesium from 'vite-plugin-cesium'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
base: "/",
|
|
||||||
build: {
|
|
||||||
outDir: "dist",
|
|
||||||
assetsDir: "assets",
|
|
||||||
minify: "terser",
|
|
||||||
sourcemap: false,
|
|
||||||
chunkSizeWarningLimit: 1500,
|
|
||||||
terserOptions: {
|
|
||||||
compress: {
|
|
||||||
drop_console: true,
|
|
||||||
drop_debugger: true,
|
|
||||||
pure_funcs: ["console.log"],
|
|
||||||
},
|
|
||||||
format: {
|
|
||||||
comments: /@license/i,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
rollupOptions: {
|
|
||||||
output: {
|
|
||||||
manualChunks: {
|
|
||||||
vendor: ["vue", "vue-router"],
|
|
||||||
},
|
|
||||||
chunkFileNames: "assets/js/[name]-[hash].js",
|
|
||||||
entryFileNames: "assets/js/[name]-[hash].js",
|
|
||||||
assetFileNames: "assets/[ext]/[name]-[hash].[ext]",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
cssCodeSplit: true,
|
|
||||||
cssMinify: true,
|
|
||||||
},
|
|
||||||
plugins: [
|
|
||||||
cesium(),
|
|
||||||
vue(),
|
|
||||||
viteCompression({
|
|
||||||
verbose: true,
|
|
||||||
disable: false,
|
|
||||||
threshold: 10240,
|
|
||||||
algorithm: "gzip",
|
|
||||||
ext: ".gz",
|
|
||||||
}),
|
|
||||||
ViteImageOptimizer({
|
|
||||||
test: /\.(jpe?g|png|gif|svg)$/i,
|
|
||||||
exclude: undefined,
|
|
||||||
include: undefined,
|
|
||||||
includePublic: true,
|
|
||||||
logStats: true,
|
|
||||||
ansiColors: true,
|
|
||||||
svg: {
|
|
||||||
multipass: true,
|
|
||||||
plugins: [
|
|
||||||
{
|
|
||||||
name: "preset-default",
|
|
||||||
params: {
|
|
||||||
overrides: {
|
|
||||||
removeViewBox: false,
|
|
||||||
removeEmptyAttrs: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
png: {
|
|
||||||
quality: 80,
|
|
||||||
},
|
|
||||||
jpeg: {
|
|
||||||
quality: 80,
|
|
||||||
},
|
|
||||||
jpg: {
|
|
||||||
quality: 80,
|
|
||||||
},
|
|
||||||
tiff: {
|
|
||||||
quality: 80,
|
|
||||||
},
|
|
||||||
gif: undefined,
|
|
||||||
webp: {
|
|
||||||
quality: 80,
|
|
||||||
},
|
|
||||||
avif: {
|
|
||||||
quality: 80,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
resolve: {
|
|
||||||
alias: {
|
|
||||||
"@": path.resolve(__dirname, "src"),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
host: "0.0.0.0", // 监听所有网络接口
|
|
||||||
port: 5173, // 确保端口设置正确
|
|
||||||
allowedHosts: ["w.godserver.cn",'godserver.cn','www.godserver.cn','rbq.college'], // 允许的主机列表
|
|
||||||
proxy: {
|
|
||||||
"/api": {
|
|
||||||
target: "http://127.0.0.1:8080", // 注意这里去掉了后面的/api,Vite会自动拼接
|
|
||||||
changeOrigin: true, // 关键:修改请求头中的Origin为目标服务器地址
|
|
||||||
rewrite: (path) => path.replace(/^\/api/, '/api'), // 保留/api前缀
|
|
||||||
headers: {
|
|
||||||
// 添加可能需要的请求头,解决403问题
|
|
||||||
"Origin": "http://127.0.0.1:8080",
|
|
||||||
"Referer": "http://127.0.0.1:8080",
|
|
||||||
"Accept": "application/json",
|
|
||||||
"Cache-Control": "no-cache",
|
|
||||||
"Pragma": "no-cache"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// 代理HLS流请求
|
|
||||||
"/hls": {
|
|
||||||
target: "http://127.0.0.1:8080",
|
|
||||||
changeOrigin: true,
|
|
||||||
rewrite: (path) => path, // 不修改路径
|
|
||||||
headers: {
|
|
||||||
"Origin": "http://127.0.0.1:8080",
|
|
||||||
"Referer": "http://127.0.0.1:8080",
|
|
||||||
"Cache-Control": "no-cache"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
// 代理历史视频请求
|
|
||||||
"/saved": {
|
|
||||||
target: "http://127.0.0.1:8080",
|
|
||||||
changeOrigin: true,
|
|
||||||
rewrite: (path) => path,
|
|
||||||
headers: {
|
|
||||||
"Origin": "http://127.0.0.1:8080",
|
|
||||||
"Referer": "http://127.0.0.1:8080",
|
|
||||||
"Cache-Control": "no-cache"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
define: {
|
|
||||||
__VUE_OPTIONS_API__: true,
|
|
||||||
__VUE_PROD_DEVTOOLS__: false,
|
|
||||||
"process.env.VITE_FONT_URL": JSON.stringify(fontConfig.url),
|
|
||||||
"process.env.VITE_FONT_ENABLED": JSON.stringify(fontConfig.enabled),
|
|
||||||
"process.env.VITE_FONT_PRELOAD": JSON.stringify(fontConfig.preload),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
1
app/.gitignore
vendored
Normal file
1
app/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
46
app/build.gradle
Normal file
46
app/build.gradle
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'org.ast.reisa'
|
||||||
|
compileSdk 33
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "org.ast.reisa"
|
||||||
|
minSdk 30
|
||||||
|
targetSdk 33
|
||||||
|
versionCode 1
|
||||||
|
versionName "1.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
|
||||||
|
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||||
|
implementation 'com.google.android.material:material:1.8.0'
|
||||||
|
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.6.1'
|
||||||
|
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.6.1'
|
||||||
|
implementation 'androidx.navigation:navigation-fragment:2.5.3'
|
||||||
|
implementation 'androidx.navigation:navigation-ui:2.5.3'
|
||||||
|
testImplementation 'junit:junit:4.13.2'
|
||||||
|
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
|
||||||
|
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
|
||||||
|
}
|
||||||
21
app/proguard-rules.pro
vendored
Normal file
21
app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package org.ast.reisa;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
public void useAppContext() {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
assertEquals("org.ast.reisa", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/src/main/AndroidManifest.xml
Normal file
28
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.ReisaEyeAndroid"
|
||||||
|
tools:targetApi="31">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:theme="@style/Theme.ReisaEyeAndroid.NoActionBar">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
62
app/src/main/java/org/ast/reisa/MainActivity.java
Normal file
62
app/src/main/java/org/ast/reisa/MainActivity.java
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package org.ast.reisa;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.Menu;
|
||||||
|
import com.google.android.material.snackbar.Snackbar;
|
||||||
|
import com.google.android.material.navigation.NavigationView;
|
||||||
|
import androidx.navigation.NavController;
|
||||||
|
import androidx.navigation.Navigation;
|
||||||
|
import androidx.navigation.ui.AppBarConfiguration;
|
||||||
|
import androidx.navigation.ui.NavigationUI;
|
||||||
|
import androidx.drawerlayout.widget.DrawerLayout;
|
||||||
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
|
import org.ast.reisa.databinding.ActivityMainBinding;
|
||||||
|
|
||||||
|
public class MainActivity extends AppCompatActivity {
|
||||||
|
|
||||||
|
private AppBarConfiguration mAppBarConfiguration;
|
||||||
|
private ActivityMainBinding binding;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
binding = ActivityMainBinding.inflate(getLayoutInflater());
|
||||||
|
setContentView(binding.getRoot());
|
||||||
|
|
||||||
|
setSupportActionBar(binding.appBarMain.toolbar);
|
||||||
|
binding.appBarMain.fab.setOnClickListener(new View.OnClickListener() {
|
||||||
|
@Override
|
||||||
|
public void onClick(View view) {
|
||||||
|
Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
|
||||||
|
.setAction("Action", null).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
DrawerLayout drawer = binding.drawerLayout;
|
||||||
|
NavigationView navigationView = binding.navView;
|
||||||
|
// Passing each menu ID as a set of Ids because each
|
||||||
|
// menu should be considered as top level destinations.
|
||||||
|
mAppBarConfiguration = new AppBarConfiguration.Builder(
|
||||||
|
R.id.nav_home, R.id.nav_gallery, R.id.nav_slideshow)
|
||||||
|
.setOpenableLayout(drawer)
|
||||||
|
.build();
|
||||||
|
NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_main);
|
||||||
|
NavigationUI.setupActionBarWithNavController(this, navController, mAppBarConfiguration);
|
||||||
|
NavigationUI.setupWithNavController(navigationView, navController);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onCreateOptionsMenu(Menu menu) {
|
||||||
|
// Inflate the menu; this adds items to the action bar if it is present.
|
||||||
|
getMenuInflater().inflate(R.menu.main, menu);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onSupportNavigateUp() {
|
||||||
|
NavController navController = Navigation.findNavController(this, R.id.nav_host_fragment_content_main);
|
||||||
|
return NavigationUI.navigateUp(navController, mAppBarConfiguration)
|
||||||
|
|| super.onSupportNavigateUp();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package org.ast.reisa.ui.gallery;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
|
import org.ast.reisa.databinding.FragmentGalleryBinding;
|
||||||
|
|
||||||
|
public class GalleryFragment extends Fragment {
|
||||||
|
|
||||||
|
private FragmentGalleryBinding binding;
|
||||||
|
|
||||||
|
public View onCreateView(@NonNull LayoutInflater inflater,
|
||||||
|
ViewGroup container, Bundle savedInstanceState) {
|
||||||
|
GalleryViewModel galleryViewModel =
|
||||||
|
new ViewModelProvider(this).get(GalleryViewModel.class);
|
||||||
|
|
||||||
|
binding = FragmentGalleryBinding.inflate(inflater, container, false);
|
||||||
|
View root = binding.getRoot();
|
||||||
|
|
||||||
|
final TextView textView = binding.textGallery;
|
||||||
|
galleryViewModel.getText().observe(getViewLifecycleOwner(), textView::setText);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroyView() {
|
||||||
|
super.onDestroyView();
|
||||||
|
binding = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package org.ast.reisa.ui.gallery;
|
||||||
|
|
||||||
|
import androidx.lifecycle.LiveData;
|
||||||
|
import androidx.lifecycle.MutableLiveData;
|
||||||
|
import androidx.lifecycle.ViewModel;
|
||||||
|
|
||||||
|
public class GalleryViewModel extends ViewModel {
|
||||||
|
|
||||||
|
private final MutableLiveData<String> mText;
|
||||||
|
|
||||||
|
public GalleryViewModel() {
|
||||||
|
mText = new MutableLiveData<>();
|
||||||
|
mText.setValue("This is gallery fragment");
|
||||||
|
}
|
||||||
|
|
||||||
|
public LiveData<String> getText() {
|
||||||
|
return mText;
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/src/main/java/org/ast/reisa/ui/home/HomeFragment.java
Normal file
35
app/src/main/java/org/ast/reisa/ui/home/HomeFragment.java
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package org.ast.reisa.ui.home;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.TextView;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
|
import org.ast.reisa.databinding.FragmentHomeBinding;
|
||||||
|
|
||||||
|
public class HomeFragment extends Fragment {
|
||||||
|
|
||||||
|
private FragmentHomeBinding binding;
|
||||||
|
|
||||||
|
public View onCreateView(@NonNull LayoutInflater inflater,
|
||||||
|
ViewGroup container, Bundle savedInstanceState) {
|
||||||
|
HomeViewModel homeViewModel =
|
||||||
|
new ViewModelProvider(this).get(HomeViewModel.class);
|
||||||
|
|
||||||
|
binding = FragmentHomeBinding.inflate(inflater, container, false);
|
||||||
|
View root = binding.getRoot();
|
||||||
|
|
||||||
|
final TextView textView = binding.textHome;
|
||||||
|
homeViewModel.getText().observe(getViewLifecycleOwner(), textView::setText);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroyView() {
|
||||||
|
super.onDestroyView();
|
||||||
|
binding = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user