mirror of
https://github.com/RhenCloud/Cloud-Home.git
synced 2026-01-22 17:39:07 +08:00
Compare commits
4 Commits
b5b5509320
...
a6d4c8a27b
| Author | SHA1 | Date | |
|---|---|---|---|
| a6d4c8a27b | |||
| 82574d38b9 | |||
| 55f4307b13 | |||
| 99ffc73e76 |
10
.env.example
10
.env.example
@@ -1,8 +1,14 @@
|
|||||||
# Github Token
|
# Github Token
|
||||||
VITE_GITHUB_TOKEN=your-github-token
|
NUXT_PUBLIC_GITHUB_TOKEN=your-github-token
|
||||||
|
|
||||||
# UMAMI API KEY
|
# UMAMI API KEY
|
||||||
VITE_UMAMI_API_KEY=your-umami-api-key
|
NUXT_PUBLIC_UMAMI_API_KEY=your-umami-api-key
|
||||||
|
|
||||||
|
# Wakatime API Key
|
||||||
|
WAKATIME_API_KEY=your-wakatime-api-key
|
||||||
|
|
||||||
|
# Wakatime API Base(可选)
|
||||||
|
WAKATIME_API_URL=https://wakatime.com/api/v1
|
||||||
|
|
||||||
# SMTP 服务器地址
|
# SMTP 服务器地址
|
||||||
SMTP_HOST=smtp.example.com
|
SMTP_HOST=smtp.example.com
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,6 +11,9 @@ build/
|
|||||||
.next/
|
.next/
|
||||||
.vercel/
|
.vercel/
|
||||||
.vite/
|
.vite/
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
|
.edgeone
|
||||||
|
|
||||||
# env & secrets
|
# env & secrets
|
||||||
.env
|
.env
|
||||||
|
|||||||
33
README.md
33
README.md
@@ -1,6 +1,6 @@
|
|||||||
# Cloud Home
|
# Cloud Home
|
||||||
|
|
||||||
一个基于 Vue 的个人主页模板,内置友链申请、网站展示、项目展示、友链随机展示、自定义配置,支持 Vercel 部署与邮件通知。
|
一个基于 Nuxt 3 (Vue 3) 的个人主页模板,内置友链申请、网站展示、项目展示、友链随机展示、自定义配置,支持 Vercel 部署与邮件通知。
|
||||||
|
|
||||||
## 特性
|
## 特性
|
||||||
|
|
||||||
@@ -11,16 +11,16 @@
|
|||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|
||||||
- 前端:Vue 3 + HTML + CSS
|
- 前端:Nuxt 3(Vue 3)+ HTML + CSS
|
||||||
- 构建:Vite
|
- 构建 / 运行:Nuxt 3 + Nitro
|
||||||
- 部署:Vercel(静态构建 + Serverless Functions)
|
- 部署:Vercel(Nuxt 构建 + Nitro 函数)
|
||||||
|
|
||||||
## 致谢
|
## 致谢
|
||||||
|
|
||||||
排名不分先后
|
排名不分先后
|
||||||
|
|
||||||
- [Skill Icons](https://github.com/tandpfun/skill-icons):技能图标库,本项目的技能图标来源。
|
- [Skill Icons](https://github.com/tandpfun/skill-icons):技能图标库,本项目的技能图标来源。
|
||||||
- [Netease Mini Player](https://github.com/numakkiyu/NeteaseMiniPlayer):迷你网易云播放器组件,为本项目的音乐播放功能提供支持。
|
- [Netease Mini Player](https://github.com/numakkiyu/NeteaseMiniPlayer):迷你网易云播放器组件,为本项目的音乐播放功能提供支持。(本项目使用[本人fork的版本](https://github.com/RhenCloud/NeteaseMiniPlayer))
|
||||||
|
|
||||||
感谢以上开源项目原作者与维护者的贡献。
|
感谢以上开源项目原作者与维护者的贡献。
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ const siteConfig: SiteConfig = {
|
|||||||
profile: {
|
profile: {
|
||||||
name: "Example User", // 你的名字
|
name: "Example User", // 你的名字
|
||||||
title: "I'm a software developer.", // 你的简介,可为空
|
title: "I'm a software developer.", // 你的简介,可为空
|
||||||
avatar: "avatar.webp", // 你的头像,可为public目录下的文件或外部链接
|
avatar: "/avatar.webp", // 你的头像,可为public目录下的文件或外部链接
|
||||||
bio: "Hello World", // 你的喜欢的一句话,可为空
|
bio: "Hello World", // 你的喜欢的一句话,可为空
|
||||||
birthday: "xxxx-xx-xx", // 你的生日,可为空
|
birthday: "xxxx-xx-xx", // 你的生日,可为空
|
||||||
gender: "", // 你的性别,可为空
|
gender: "", // 你的性别,可为空
|
||||||
@@ -66,7 +66,7 @@ const siteConfig: SiteConfig = {
|
|||||||
|
|
||||||
siteMeta: {
|
siteMeta: {
|
||||||
title: "Example Title", // 网站标题
|
title: "Example Title", // 网站标题
|
||||||
icon: "favicon.ico", // 网站图标,可为public目录下的文件或外部链接
|
icon: "/favicon.ico", // 网站图标,可为public目录下的文件或外部链接
|
||||||
startDate:"xxxx-xx-xx", // 网站创建日期
|
startDate:"xxxx-xx-xx", // 网站创建日期
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -90,6 +90,10 @@ const siteConfig: SiteConfig = {
|
|||||||
autoplay: false,
|
autoplay: false,
|
||||||
// 是否默认以黑胶唱片状态启动(仅浮动模式)
|
// 是否默认以黑胶唱片状态启动(仅浮动模式)
|
||||||
defaultMinimized: true,
|
defaultMinimized: true,
|
||||||
|
// 标签页非激活时是否自动暂停
|
||||||
|
autoPause: false,
|
||||||
|
// Music API 配置
|
||||||
|
apiUrls: ["https://www.bilibili.uno/api", "https://meting-api.wangcy.site/api"],
|
||||||
},
|
},
|
||||||
|
|
||||||
umami: {
|
umami: {
|
||||||
@@ -166,16 +170,17 @@ const siteConfig: SiteConfig = {
|
|||||||
|
|
||||||
在 Vercel 控制台或本地 `.env` 配置:
|
在 Vercel 控制台或本地 `.env` 配置:
|
||||||
|
|
||||||
- `VITE_GITHUB_TOKEN`: 具有仓库读取权限的 GitHub Token,用于绕过 GitHub API 速率限制。(可选)
|
- `NUXT_PUBLIC_GITHUB_TOKEN`: 具有仓库读取权限的 GitHub Token,用于绕过 GitHub API 速率限制。(可选)
|
||||||
- `UMAMI_API_KEY`: Umami 分析的 API Key。
|
- `NUXT_PUBLIC_UMAMI_API_KEY`: 可选的 Umami API Key,用于展示访问量统计数据。
|
||||||
- `WAKATIME_API_KEY`: Wakatime API Key,用于获取编码统计数据。
|
- `WAKATIME_API_KEY`: Wakatime API Key,用于获取编码统计数据。
|
||||||
|
- `WAKATIME_API_URL`: Wakatime API 地址,覆盖默认 `https://wakatime.com/api/v1`(可选)。
|
||||||
- `SMTP_HOST`: 邮件服务器主机名
|
- `SMTP_HOST`: 邮件服务器主机名
|
||||||
- `SMTP_PORT`: 端口(如 465 或 587)
|
- `SMTP_PORT`: 端口(如 465 或 587)
|
||||||
- `SMTP_USER`: 发件人邮箱账号
|
- `SMTP_USER`: 发件人邮箱账号
|
||||||
- `SMTP_PASS`: 邮箱授权码或密码
|
- `SMTP_PASS`: 邮箱授权码或密码
|
||||||
- `MAIL_FROM`: 发件人地址(通常同 SMTP_USER)
|
- `SENDER_EMAIL`: 发件人地址(通常同 SMTP_USER)
|
||||||
- `ADMIN_EMAIL`: 接收通知的邮箱地址
|
- `ADMIN_EMAIL`: 接收通知的邮箱地址
|
||||||
- `SMTP_SECURE`:是否启用 SSL/TLS 加密(默认 `true`)
|
- `SMTP_SECURE`:是否强制启用 SSL/TLS(默认为 `true` 当端口为 465)。
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
@@ -184,7 +189,7 @@ pnpm install
|
|||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
访问 `http://localhost:5173/`。
|
访问 `http://localhost:3000/`。
|
||||||
|
|
||||||
## 构建
|
## 构建
|
||||||
|
|
||||||
@@ -192,7 +197,7 @@ pnpm dev
|
|||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
产物输出到
|
产物输出到 Nuxt 的 `.output/` 目录,该目录同时包含静态资源与 Nitro 服务器入口。
|
||||||
|
|
||||||
## 部署到 Vercel
|
## 部署到 Vercel
|
||||||
|
|
||||||
@@ -201,6 +206,8 @@ pnpm build
|
|||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
|
雁型的 Nitro 路由位于 `server/api`,依旧暴露同样的 `/api` 前缀。
|
||||||
|
|
||||||
- `POST /api/send-mail`:友链申请邮件发送。请求体示例:
|
- `POST /api/send-mail`:友链申请邮件发送。请求体示例:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
|
||||||
import nodemailer from "nodemailer";
|
|
||||||
|
|
||||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
||||||
// CORS 处理
|
|
||||||
// res.setHeader("Access-Control-Allow-Origin", "*");
|
|
||||||
// res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
|
|
||||||
// res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
||||||
|
|
||||||
// if (req.method === "OPTIONS") {
|
|
||||||
// return res.status(200).end();
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (req.method === "GET") {
|
|
||||||
// return res.status(200).json({ status: "ok", message: "use POST to send mail" });
|
|
||||||
// }
|
|
||||||
|
|
||||||
// if (req.method !== "POST") {
|
|
||||||
// return res.status(405).json({ error: "Method Not Allowed" });
|
|
||||||
// }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = typeof req.body === "string" ? JSON.parse(req.body) : req.body;
|
|
||||||
const { name, email, site, desc, avatar } = data || {};
|
|
||||||
if (!name || !email || !desc) {
|
|
||||||
return res.status(400).json({ error: "Missing required fields: name, email, desc" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
|
||||||
host: process.env.SMTP_HOST,
|
|
||||||
port: Number(process.env.SMTP_PORT || 465),
|
|
||||||
secure: Number(process.env.SMTP_PORT || 465) === 465,
|
|
||||||
auth: {
|
|
||||||
user: process.env.SMTP_USER,
|
|
||||||
pass: process.env.SMTP_PASS,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const info = await transporter.sendMail({
|
|
||||||
from: process.env.SENDER_EMAIL,
|
|
||||||
to: process.env.ADMIN_EMAIL,
|
|
||||||
subject: "友链申请 / 联系表单",
|
|
||||||
html: `
|
|
||||||
<p><strong>名称:</strong>${name}</p>
|
|
||||||
<p><strong>邮箱:</strong>${email}</p>
|
|
||||||
<p><strong>站点:</strong>${site || "未填写"}</p>
|
|
||||||
<p><strong>描述:</strong>${desc}</p>
|
|
||||||
<p><strong>头像:</strong>${avatar || "未填写"}</p>
|
|
||||||
<p><strong>时间:</strong>${new Date().toISOString()}</p>
|
|
||||||
`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.status(200).json({
|
|
||||||
message: "Mail sent",
|
|
||||||
id: info.messageId,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
return res.status(500).json({ error: "Send mail failed", detail: (err as Error).message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
import type { VercelRequest, VercelResponse } from "@vercel/node";
|
|
||||||
|
|
||||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
|
||||||
// CORS 处理
|
|
||||||
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
||||||
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
|
||||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
||||||
|
|
||||||
if (req.method === "OPTIONS") {
|
|
||||||
return res.status(200).end();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (req.method !== "GET") {
|
|
||||||
return res.status(405).json({ error: "Method Not Allowed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = process.env.WAKATIME_API_KEY;
|
|
||||||
const defaultApiUrl = process.env.WAKATIME_API_URL || "https://wakatime.com/api/v1";
|
|
||||||
const apiUrl = (req.query.apiUrl as string) || defaultApiUrl;
|
|
||||||
if (!apiKey) {
|
|
||||||
return res.status(500).json({ error: "Wakatime API Key not configured" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 获取一周统计数据
|
|
||||||
const weeklyStatsResponse = await fetch(`${apiUrl}/users/current/stats/last_7_days`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!weeklyStatsResponse.ok) {
|
|
||||||
throw new Error(`Wakatime API error: ${weeklyStatsResponse.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const weeklyStatsData = await weeklyStatsResponse.json();
|
|
||||||
|
|
||||||
// 获取所有时间统计数据
|
|
||||||
const allTimeStatsResponse = await fetch(`${apiUrl}/users/current/stats/all_time`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let allTimeStatsData = null;
|
|
||||||
if (allTimeStatsResponse.ok) {
|
|
||||||
allTimeStatsData = await allTimeStatsResponse.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取当前用户状态
|
|
||||||
const statusResponse = await fetch(`${apiUrl}/users/current/status`, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
let statusData = null;
|
|
||||||
if (statusResponse.ok) {
|
|
||||||
statusData = await statusResponse.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(200).json({
|
|
||||||
weekly: weeklyStatsData.data,
|
|
||||||
allTime: allTimeStatsData ? allTimeStatsData.data : null,
|
|
||||||
status: statusData,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Wakatime API error:", error);
|
|
||||||
res.status(500).json({ error: "Failed to fetch Wakatime data" });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
35
nuxt.config.ts
Normal file
35
nuxt.config.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { defineNuxtConfig } from "nuxt/config";
|
||||||
|
import siteConfig from "./src/config/siteConfig";
|
||||||
|
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: "2025-12-12",
|
||||||
|
srcDir: "src/",
|
||||||
|
css: ["~/styles.css"],
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
title: siteConfig.siteMeta.title,
|
||||||
|
link: [{ rel: "icon", href: siteConfig.siteMeta.icon }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// nitro: {
|
||||||
|
// prerender: {
|
||||||
|
// crawlLinks: true,
|
||||||
|
// routes: ["/sitemap.xml", "/rss.xml"],
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
runtimeConfig: {
|
||||||
|
smtpHost: process.env.SMTP_HOST ?? "",
|
||||||
|
smtpPort: Number(process.env.SMTP_PORT ?? 465),
|
||||||
|
smtpUser: process.env.SMTP_USER ?? "",
|
||||||
|
smtpPass: process.env.SMTP_PASS ?? "",
|
||||||
|
senderEmail: process.env.SENDER_EMAIL ?? "",
|
||||||
|
adminEmail: process.env.ADMIN_EMAIL ?? "",
|
||||||
|
smtpSecure: process.env.SMTP_SECURE ? process.env.SMTP_SECURE === "true" : undefined,
|
||||||
|
wakatimeApiKey: process.env.WAKATIME_API_KEY ?? "",
|
||||||
|
wakatimeApiUrl: process.env.WAKATIME_API_URL ?? "https://wakatime.com/api/v1",
|
||||||
|
public: {
|
||||||
|
githubToken: process.env.NUXT_PUBLIC_GITHUB_TOKEN ?? "",
|
||||||
|
umamiApiKey: process.env.NUXT_PUBLIC_UMAMI_API_KEY ?? "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
20
package.json
20
package.json
@@ -3,27 +3,19 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "nuxt dev",
|
||||||
"build": "vite build",
|
"build": "nuxt build",
|
||||||
"preview": "vite preview"
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jaseeey/vue-umami-plugin": "^1.4.0",
|
"@jaseeey/vue-umami-plugin": "^1.4.0",
|
||||||
"@vercel/node": "^5.5.15",
|
|
||||||
"cros": "^1.1.0",
|
|
||||||
"express": "^5.2.1",
|
|
||||||
"he": "^1.2.0",
|
|
||||||
"nodemailer": "^7.0.11",
|
"nodemailer": "^7.0.11",
|
||||||
"vue": "^3.5.25",
|
"nuxt": "^4.2.2"
|
||||||
"vue-router": "^4.6.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^5.0.6",
|
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/nodemailer": "^7.0.4",
|
"@types/nodemailer": "^7.0.4",
|
||||||
"@vitejs/plugin-vue": "^6.0.2",
|
"typescript": "^5.9.3"
|
||||||
"typescript": "^5.9.3",
|
|
||||||
"vite": "^7.2.6",
|
|
||||||
"vue-tsc": "^3.1.6"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1340
public/css/netease-mini-player-v2.css
Normal file
1340
public/css/netease-mini-player-v2.css
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
103
server/api/send-mail.ts
Normal file
103
server/api/send-mail.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { defineEventHandler, createError, readBody } from "h3";
|
||||||
|
import nodemailer from "nodemailer";
|
||||||
|
import type SMTPTransport from "nodemailer/lib/smtp-transport";
|
||||||
|
import { useRuntimeConfig } from "#imports";
|
||||||
|
|
||||||
|
type MailConfig = {
|
||||||
|
smtpHost?: string;
|
||||||
|
smtpPort?: number | string;
|
||||||
|
smtpUser?: string;
|
||||||
|
smtpPass?: string;
|
||||||
|
senderEmail?: string;
|
||||||
|
adminEmail?: string;
|
||||||
|
smtpSecure?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SendMailPayload = {
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
desc?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureValue = (value?: string, fallback = "未填写") => (value?.trim() ? value.trim() : fallback);
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const method = event.node.req.method;
|
||||||
|
if (method === "OPTIONS") {
|
||||||
|
event.node.res.statusCode = 200;
|
||||||
|
return { status: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method !== "POST") {
|
||||||
|
throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await readBody<SendMailPayload>(event)) || {};
|
||||||
|
const { name, url, desc, email, avatar, message } = payload;
|
||||||
|
|
||||||
|
if (!name?.trim() || !url?.trim() || !email?.trim()) {
|
||||||
|
throw createError({
|
||||||
|
statusCode: 400,
|
||||||
|
statusMessage: "Missing required fields: name, url, and email",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = useRuntimeConfig() as MailConfig;
|
||||||
|
const { smtpHost, smtpPort: configSmtpPort, smtpUser, smtpPass, senderEmail, adminEmail, smtpSecure } = config;
|
||||||
|
|
||||||
|
const smtpPort = Number(configSmtpPort ?? 465);
|
||||||
|
if (!smtpHost || !smtpUser || !smtpPass || !senderEmail || !adminEmail) {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: "SMTP server is not fully configured" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const secure = typeof smtpSecure === "boolean" ? smtpSecure : smtpPort === 465;
|
||||||
|
const smtpOptions: SMTPTransport.Options = {
|
||||||
|
host: smtpHost,
|
||||||
|
port: smtpPort,
|
||||||
|
secure,
|
||||||
|
auth: {
|
||||||
|
user: smtpUser,
|
||||||
|
pass: smtpPass,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport(smtpOptions);
|
||||||
|
const friendEntry = `{
|
||||||
|
name: "${ensureValue(name).replace(/"/g, '\\"')}",
|
||||||
|
url: "${ensureValue(url).replace(/"/g, '\\"')}",
|
||||||
|
desc: "${ensureValue(desc).replace(/"/g, '\\"')}",
|
||||||
|
avatar: "${ensureValue(avatar).replace(/"/g, '\\"')}",
|
||||||
|
},`;
|
||||||
|
|
||||||
|
const htmlMessage = `
|
||||||
|
<p>一个新的友链申请已提交,以下是可直接复制到项目中的配置:</p>
|
||||||
|
<pre style="background: #f5f5f5; padding: 12px; border-radius: 4px; overflow: auto;">
|
||||||
|
<code>${friendEntry}</code>
|
||||||
|
</pre>
|
||||||
|
<hr style="margin: 20px 0;" />
|
||||||
|
<p><strong>申请者信息:</strong></p>
|
||||||
|
<p><strong>名称:</strong>${ensureValue(name)}</p>
|
||||||
|
<p><strong>邮箱:</strong>${ensureValue(email)}</p>
|
||||||
|
<p><strong>站点:</strong><a href="${ensureValue(url)}">${ensureValue(url)}</a></p>
|
||||||
|
<p><strong>描述:</strong>${ensureValue(desc)}</p>
|
||||||
|
<p><strong>头像:</strong>${ensureValue(avatar)}</p>
|
||||||
|
<p><strong>想说的话:</strong>${ensureValue(message)}</p>
|
||||||
|
<p><strong>时间:</strong>${new Date().toISOString()}</p>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const info = await transporter.sendMail({
|
||||||
|
from: senderEmail,
|
||||||
|
to: adminEmail,
|
||||||
|
replyTo: email,
|
||||||
|
subject: `友链申请 / 联系表单 · ${ensureValue(name)}`,
|
||||||
|
html: htmlMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: "Mail sent",
|
||||||
|
id: info.messageId,
|
||||||
|
};
|
||||||
|
});
|
||||||
56
server/api/wakatime.ts
Normal file
56
server/api/wakatime.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { defineEventHandler, getQuery, createError } from "h3";
|
||||||
|
import { useRuntimeConfig } from "#imports";
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const res = event.node.res;
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
||||||
|
|
||||||
|
if (event.node.req.method === "OPTIONS") {
|
||||||
|
res.statusCode = 200;
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.node.req.method !== "GET") {
|
||||||
|
throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const apiKey = config.wakatimeApiKey;
|
||||||
|
if (typeof apiKey !== "string") {
|
||||||
|
throw createError({ statusCode: 500, statusMessage: "Invalid WakaTime API Key configuration" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = getQuery(event);
|
||||||
|
const apiUrl = (query.apiUrl as string) || config.wakatimeApiUrl;
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [weeklyStatsResponse, allTimeStatsResponse, statusResponse] = await Promise.all([
|
||||||
|
fetch(`${apiUrl}/users/current/stats/last_7_days`, { headers }),
|
||||||
|
fetch(`${apiUrl}/users/current/stats/all_time`, { headers }),
|
||||||
|
fetch(`${apiUrl}/users/current/status`, { headers }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!weeklyStatsResponse.ok) {
|
||||||
|
throw new Error(`Wakatime API error: ${weeklyStatsResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeklyStatsData = await weeklyStatsResponse.json();
|
||||||
|
const allTimeStatsData = allTimeStatsResponse.ok ? await allTimeStatsResponse.json() : null;
|
||||||
|
const statusData = statusResponse.ok ? await statusResponse.json() : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
weekly: weeklyStatsData.data,
|
||||||
|
allTime: allTimeStatsData ? allTimeStatsData.data : null,
|
||||||
|
status: statusData,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Wakatime API error:", error);
|
||||||
|
throw createError({ statusCode: 500, statusMessage: "Failed to fetch Wakatime data" });
|
||||||
|
}
|
||||||
|
});
|
||||||
215
src/App.vue
215
src/App.vue
@@ -1,215 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="app-shell" :style="backgroundStyle">
|
|
||||||
<div class="background-overlay" :style="overlayStyle"></div>
|
|
||||||
<button
|
|
||||||
class="background-toggle"
|
|
||||||
@click="hideComponents = !hideComponents"
|
|
||||||
:title="hideComponents ? '显示内容' : '隐藏内容'"
|
|
||||||
:class="{ active: hideComponents }"
|
|
||||||
>
|
|
||||||
<span class="toggle-icon">{{ hideComponents ? "👁️" : "🙈" }}</span>
|
|
||||||
<span class="toggle-label">{{ hideComponents ? "显示" : "隐藏" }}</span>
|
|
||||||
</button>
|
|
||||||
<Transition name="fade-down">
|
|
||||||
<main class="app-body" v-if="!hideComponents" key="content">
|
|
||||||
<router-view />
|
|
||||||
</main>
|
|
||||||
</Transition>
|
|
||||||
<Transition name="fade-up">
|
|
||||||
<PageSwitcher v-if="!hideComponents" key="switcher" />
|
|
||||||
</Transition>
|
|
||||||
<Transition name="fade-down">
|
|
||||||
<FooterSection v-if="!hideComponents" :contact="contact" key="footer" />
|
|
||||||
</Transition>
|
|
||||||
<!-- 音乐播放器 -->
|
|
||||||
<MusicPlayer />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { onMounted, computed, ref } from "vue";
|
|
||||||
import PageSwitcher from "./components/PageSwitcher.vue";
|
|
||||||
import FooterSection from "./components/FooterSection.vue";
|
|
||||||
import MusicPlayer from "./components/MusicPlayer.vue";
|
|
||||||
import siteConfig from "./config/siteConfig";
|
|
||||||
|
|
||||||
const contact = siteConfig.footer;
|
|
||||||
const bg = siteConfig.appearance.background;
|
|
||||||
const isMobile = ref(false);
|
|
||||||
const hideComponents = ref(false);
|
|
||||||
|
|
||||||
// 检测是否为移动设备
|
|
||||||
const checkIfMobile = () => {
|
|
||||||
isMobile.value = window.innerWidth <= 768;
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
checkIfMobile();
|
|
||||||
window.addEventListener("resize", checkIfMobile);
|
|
||||||
});
|
|
||||||
|
|
||||||
const getBackgroundImage = () => {
|
|
||||||
if (!bg.enable) return undefined;
|
|
||||||
|
|
||||||
// 根据屏幕尺寸选择图片
|
|
||||||
const image = isMobile.value && bg.mobileImage ? bg.mobileImage : bg.image;
|
|
||||||
|
|
||||||
if (!image) return undefined;
|
|
||||||
|
|
||||||
return image.startsWith("http") ? image : `/${image}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const backgroundStyle = computed(() => {
|
|
||||||
const imageUrl = getBackgroundImage();
|
|
||||||
|
|
||||||
if (!imageUrl) return {};
|
|
||||||
|
|
||||||
return {
|
|
||||||
backgroundImage: `url('${imageUrl}')`,
|
|
||||||
backgroundSize: "cover",
|
|
||||||
backgroundPosition: "center",
|
|
||||||
backgroundAttachment: "fixed",
|
|
||||||
filter: `blur(${bg.blur}px)`,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const overlayStyle = computed(() => {
|
|
||||||
if (!bg.enable || !getBackgroundImage()) return {};
|
|
||||||
return { backgroundColor: bg.overlay };
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.app-shell {
|
|
||||||
min-height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.background-overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
z-index: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.background-toggle {
|
|
||||||
position: fixed;
|
|
||||||
bottom: 24px;
|
|
||||||
right: 24px;
|
|
||||||
width: auto;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 50px;
|
|
||||||
background: linear-gradient(135deg, rgba(124, 193, 255, 0.15), rgba(124, 193, 255, 0.05));
|
|
||||||
border: 1.5px solid rgba(124, 193, 255, 0.3);
|
|
||||||
color: #7cc1ff;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 999;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
|
||||||
box-shadow: 0 8px 32px rgba(124, 193, 255, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
.background-toggle:hover {
|
|
||||||
background: linear-gradient(135deg, rgba(124, 193, 255, 0.25), rgba(124, 193, 255, 0.1));
|
|
||||||
border-color: rgba(124, 193, 255, 0.5);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 12px 40px rgba(124, 193, 255, 0.25), inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.background-toggle:active {
|
|
||||||
transform: translateY(0px);
|
|
||||||
box-shadow: 0 4px 16px rgba(124, 193, 255, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.background-toggle.active {
|
|
||||||
background: linear-gradient(135deg, rgba(255, 107, 107, 0.2), rgba(255, 107, 107, 0.05));
|
|
||||||
border-color: rgba(255, 107, 107, 0.4);
|
|
||||||
color: #ff6b6b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.background-toggle.active:hover {
|
|
||||||
background: linear-gradient(135deg, rgba(255, 107, 107, 0.3), rgba(255, 107, 107, 0.1));
|
|
||||||
border-color: rgba(255, 107, 107, 0.6);
|
|
||||||
box-shadow: 0 12px 40px rgba(255, 107, 107, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-icon {
|
|
||||||
display: inline-block;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-label {
|
|
||||||
font-size: 12px;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.background-toggle {
|
|
||||||
bottom: 16px;
|
|
||||||
right: 16px;
|
|
||||||
padding: 8px 10px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-label {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.toggle-icon {
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-body {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
position: relative;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 过渡动画 */
|
|
||||||
/* 上段组件:向上淡出,向下淡入 */
|
|
||||||
.fade-up-enter-active,
|
|
||||||
.fade-up-leave-active {
|
|
||||||
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-up-enter-from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-up-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 下段组件:向下淡出,向上淡入 */
|
|
||||||
.fade-down-enter-active,
|
|
||||||
.fade-down-leave-active {
|
|
||||||
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-down-enter-from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fade-down-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-20px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
83
src/app.vue
Normal file
83
src/app.vue
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div class="app-shell" :style="backgroundStyle">
|
||||||
|
<div class="background-overlay" :style="overlayStyle"></div>
|
||||||
|
<button
|
||||||
|
class="background-toggle"
|
||||||
|
@click="hideComponents = !hideComponents"
|
||||||
|
:title="hideComponents ? '显示内容' : '隐藏内容'"
|
||||||
|
:class="{ active: hideComponents }"
|
||||||
|
>
|
||||||
|
<span class="toggle-icon">{{ hideComponents ? "👁️" : "🙈" }}</span>
|
||||||
|
<span class="toggle-label">{{ hideComponents ? "显示" : "隐藏" }}</span>
|
||||||
|
</button>
|
||||||
|
<div class="content-stack">
|
||||||
|
<Transition name="fade-down">
|
||||||
|
<main class="app-body" v-if="!hideComponents" key="content">
|
||||||
|
<NuxtPage />
|
||||||
|
</main>
|
||||||
|
</Transition>
|
||||||
|
<Transition name="fade-up">
|
||||||
|
<PageSwitcher v-if="!hideComponents" key="switcher" />
|
||||||
|
</Transition>
|
||||||
|
<Transition name="fade-down">
|
||||||
|
<FooterSection v-if="!hideComponents" :contact="contact" key="footer" />
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
<MusicPlayer />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, computed, ref } from "vue";
|
||||||
|
import PageSwitcher from "~/components/PageSwitcher.vue";
|
||||||
|
import FooterSection from "~/components/FooterSection.vue";
|
||||||
|
import MusicPlayer from "~/components/MusicPlayer.vue";
|
||||||
|
import siteConfig from "~/config/siteConfig";
|
||||||
|
|
||||||
|
const contact = siteConfig.footer;
|
||||||
|
const bg = siteConfig.appearance.background;
|
||||||
|
const isMobile = ref(false);
|
||||||
|
const hideComponents = ref(false);
|
||||||
|
|
||||||
|
const checkIfMobile = () => {
|
||||||
|
isMobile.value = typeof window !== "undefined" && window.innerWidth <= 768;
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
checkIfMobile();
|
||||||
|
window.addEventListener("resize", checkIfMobile);
|
||||||
|
// const script = document.createElement("script");
|
||||||
|
// script.src = "/js/netease-mini-player-v2.js";
|
||||||
|
// document.head.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getBackgroundImage = () => {
|
||||||
|
if (!bg.enable) return undefined;
|
||||||
|
|
||||||
|
const image = isMobile.value && bg.mobileImage ? bg.mobileImage : bg.image;
|
||||||
|
|
||||||
|
if (!image) return undefined;
|
||||||
|
|
||||||
|
return image.startsWith("http") ? image : `/${image}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const backgroundStyle = computed(() => ({ backgroundColor: "#0f1629" }));
|
||||||
|
|
||||||
|
const overlayStyle = computed(() => {
|
||||||
|
const imageUrl = getBackgroundImage();
|
||||||
|
|
||||||
|
if (!bg.enable || !imageUrl) return {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
backgroundImage: `linear-gradient(${bg.overlay}, ${bg.overlay}), url('${imageUrl}')`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundAttachment: "fixed",
|
||||||
|
filter: bg.blur ? `blur(${bg.blur}px)` : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- <style>
|
||||||
|
@import "/css/netease-mini-player-v2.css";
|
||||||
|
</style> -->
|
||||||
@@ -18,8 +18,10 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from "vue";
|
import { onMounted, ref } from "vue";
|
||||||
import siteConfig from "../config/siteConfig";
|
import { useRuntimeConfig } from "#imports";
|
||||||
|
import siteConfig from "~/config/siteConfig";
|
||||||
const props = defineProps({ contact: Object });
|
const props = defineProps({ contact: Object });
|
||||||
|
const config = useRuntimeConfig();
|
||||||
const quote = ref("");
|
const quote = ref("");
|
||||||
const from = ref("");
|
const from = ref("");
|
||||||
const pageviews = ref(0);
|
const pageviews = ref(0);
|
||||||
@@ -60,7 +62,7 @@ const fetchStats = async () => {
|
|||||||
}
|
}
|
||||||
const apiBase = siteConfig.umami.apiBase;
|
const apiBase = siteConfig.umami.apiBase;
|
||||||
const websiteId = siteConfig.umami.websiteId;
|
const websiteId = siteConfig.umami.websiteId;
|
||||||
const apiKey = import.meta.env.VITE_UMAMI_API_KEY;
|
const apiKey = config.public.umamiApiKey;
|
||||||
|
|
||||||
if (!apiKey) return;
|
if (!apiKey) return;
|
||||||
|
|
||||||
@@ -105,7 +107,8 @@ onMounted(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.footer {
|
.footer {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin-top: 8px;
|
margin-top: auto;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.from {
|
.from {
|
||||||
|
|||||||
@@ -26,6 +26,13 @@
|
|||||||
头像链接
|
头像链接
|
||||||
<input v-model="form.avatar" type="url" placeholder="可选,展示头像" />
|
<input v-model="form.avatar" type="url" placeholder="可选,展示头像" />
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
想说的话
|
||||||
|
<div class="textarea-wrapper">
|
||||||
|
<textarea v-model="form.message" placeholder="可选,最多50字" maxlength="50"></textarea>
|
||||||
|
<span class="char-count">{{ form.message?.length || 0 }}/50</span>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="primary" :disabled="loading">
|
<button type="submit" class="primary" :disabled="loading">
|
||||||
{{ loading ? "提交中..." : "提交申请" }}
|
{{ loading ? "提交中..." : "提交申请" }}
|
||||||
@@ -75,6 +82,7 @@ const form = reactive({
|
|||||||
desc: "",
|
desc: "",
|
||||||
email: "",
|
email: "",
|
||||||
avatar: "",
|
avatar: "",
|
||||||
|
message: "",
|
||||||
});
|
});
|
||||||
const displayedFriends = ref([]);
|
const displayedFriends = ref([]);
|
||||||
|
|
||||||
@@ -108,6 +116,7 @@ const submitForm = async () => {
|
|||||||
desc: form.desc,
|
desc: form.desc,
|
||||||
email: form.email,
|
email: form.email,
|
||||||
avatar: form.avatar,
|
avatar: form.avatar,
|
||||||
|
message: form.message,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error("send failed");
|
if (!resp.ok) throw new Error("send failed");
|
||||||
@@ -192,15 +201,41 @@ h2 {
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||||
}
|
}
|
||||||
.friend-form input::placeholder {
|
.textarea-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.friend-form textarea {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: inherit;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
|
||||||
|
font-family: inherit;
|
||||||
|
height: 36px;
|
||||||
|
resize: none;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.friend-form input::placeholder,
|
||||||
|
.friend-form textarea::placeholder {
|
||||||
color: rgba(232, 238, 252, 0.7);
|
color: rgba(232, 238, 252, 0.7);
|
||||||
}
|
}
|
||||||
.friend-form input:focus {
|
.friend-form input:focus,
|
||||||
|
.friend-form textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: rgba(124, 193, 255, 0.8);
|
border-color: rgba(124, 193, 255, 0.8);
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
box-shadow: 0 0 0 2px rgba(124, 193, 255, 0.25);
|
box-shadow: 0 0 0 2px rgba(124, 193, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
.char-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(232, 238, 252, 0.6);
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.form-actions {
|
.form-actions {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ defineProps({ profile: Object });
|
|||||||
height: 120px;
|
height: 120px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.18);
|
border: 3px solid var(--accent);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||||
|
background: #fff;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -10,11 +10,13 @@
|
|||||||
:data-theme="music.theme"
|
:data-theme="music.theme"
|
||||||
:data-autoplay="music.autoplay"
|
:data-autoplay="music.autoplay"
|
||||||
:data-default-minimized="music.defaultMinimized"
|
:data-default-minimized="music.defaultMinimized"
|
||||||
|
:data-auto-pause="music.autoPause"
|
||||||
|
:data-api-urls="JSON.stringify(music.apiUrls)"
|
||||||
></div>
|
></div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import siteConfig from "../config/siteConfig";
|
import siteConfig from "~/config/siteConfig";
|
||||||
|
|
||||||
const music = siteConfig.music;
|
const music = siteConfig.music;
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
<button :disabled="currentIndex <= 0" @click="goPrev">上一页</button>
|
<button :disabled="currentIndex <= 0" @click="goPrev">上一页</button>
|
||||||
<div class="dots">
|
<div class="dots">
|
||||||
<button
|
<button
|
||||||
v-for="r in ordered"
|
v-for="item in pages"
|
||||||
:key="r.name"
|
:key="item.name"
|
||||||
:class="{ active: r.name === route.name }"
|
:class="{ active: item.name === route.name }"
|
||||||
@click="router.push({ name: r.name })"
|
@click="router.push({ name: item.name })"
|
||||||
>
|
>
|
||||||
{{ r.meta.label || r.name }}
|
{{ item.label }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button :disabled="currentIndex >= ordered.length - 1" @click="goNext">下一页</button>
|
<button :disabled="currentIndex >= pages.length - 1" @click="goNext">下一页</button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -22,21 +22,26 @@ import { useRoute, useRouter } from "vue-router";
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const ordered = computed(() =>
|
const pages = [
|
||||||
router
|
{ name: "index", label: "首页" },
|
||||||
.getRoutes()
|
{ name: "about", label: "关于" },
|
||||||
.filter((r) => typeof r.meta?.order === "number")
|
{ name: "sites", label: "网站" },
|
||||||
.sort((a, b) => a.meta.order - b.meta.order)
|
{ name: "projects", label: "项目" },
|
||||||
);
|
{ name: "friends", label: "友链" },
|
||||||
|
];
|
||||||
|
|
||||||
const currentIndex = computed(() => ordered.value.findIndex((r) => r.name === route.name));
|
const currentIndex = computed(() => pages.findIndex((item) => item.name === route.name));
|
||||||
|
|
||||||
const goPrev = () => {
|
const goPrev = () => {
|
||||||
if (currentIndex.value > 0) router.push({ name: ordered.value[currentIndex.value - 1].name });
|
if (currentIndex.value > 0) {
|
||||||
|
router.push({ name: pages[currentIndex.value - 1].name });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const goNext = () => {
|
const goNext = () => {
|
||||||
if (currentIndex.value < ordered.value.length - 1)
|
if (currentIndex.value < pages.length - 1) {
|
||||||
router.push({ name: ordered.value[currentIndex.value + 1].name });
|
router.push({ name: pages[currentIndex.value + 1].name });
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -53,24 +53,24 @@
|
|||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-value">{{
|
<span class="stat-value">{{
|
||||||
currentWakatimeData.total_seconds ? formatTime(currentWakatimeData.total_seconds) : "N/A"
|
currentWakatimeData?.total_seconds ? formatTime(currentWakatimeData.total_seconds) : "N/A"
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="stat-label">总时间</span>
|
<span class="stat-label">总时间</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-value">{{
|
<span class="stat-value">{{
|
||||||
currentWakatimeData.daily_average ? formatTime(currentWakatimeData.daily_average) : "N/A"
|
currentWakatimeData?.daily_average ? formatTime(currentWakatimeData.daily_average) : "N/A"
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="stat-label">日均</span>
|
<span class="stat-label">日均</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
<span class="stat-value">{{ currentWakatimeData.days_including_holidays || "N/A" }}</span>
|
<span class="stat-value">{{ currentWakatimeData?.days_including_holidays ?? "N/A" }}</span>
|
||||||
<span class="stat-label">活跃天数</span>
|
<span class="stat-label">活跃天数</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="lang-wrap" v-if="currentWakatimeData.languages && currentWakatimeData.languages.length">
|
<div class="lang-wrap" v-if="currentWakatimeData?.languages && currentWakatimeData.languages.length">
|
||||||
<h3>编程语言</h3>
|
<h3>编程语言</h3>
|
||||||
<p class="muted">语言使用统计 · Languages</p>
|
<p class="muted">语言使用统计 · Languages</p>
|
||||||
<div class="lang-chart">
|
<div class="lang-chart">
|
||||||
@@ -177,13 +177,10 @@ const fetchWakatimeData = async () => {
|
|||||||
params.append("apiUrl", wakatime.apiUrl);
|
params.append("apiUrl", wakatime.apiUrl);
|
||||||
}
|
}
|
||||||
const url = `/api/wakatime${params.toString() ? `?${params.toString()}` : ""}`;
|
const url = `/api/wakatime${params.toString() ? `?${params.toString()}` : ""}`;
|
||||||
console.log("Fetching Wakatime data from:", url);
|
|
||||||
const response = await fetch(url);
|
const response = await fetch(url);
|
||||||
console.log("Response status:", response.status);
|
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
console.log("Wakatime data:", data);
|
|
||||||
weeklyData.value = data.weekly;
|
weeklyData.value = data.weekly;
|
||||||
allTimeData.value = data.allTime;
|
allTimeData.value = data.allTime;
|
||||||
statusData.value = data.status;
|
statusData.value = data.status;
|
||||||
@@ -199,34 +196,6 @@ const fetchWakatimeData = async () => {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch Wakatime data:", error);
|
console.error("Failed to fetch Wakatime data:", error);
|
||||||
// 在开发环境中,如果 API 不可用,设置一些示例数据
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
console.log("Using mock data for development");
|
|
||||||
weeklyData.value = {
|
|
||||||
total_seconds: 36000,
|
|
||||||
daily_average: 5142,
|
|
||||||
days_including_holidays: 7,
|
|
||||||
languages: [
|
|
||||||
{ name: "TypeScript", percent: 45.2, total_seconds: 16272 },
|
|
||||||
{ name: "Vue", percent: 30.1, total_seconds: 10836 },
|
|
||||||
{ name: "JavaScript", percent: 15.3, total_seconds: 5508 },
|
|
||||||
{ name: "Python", percent: 9.4, total_seconds: 3384 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
allTimeData.value = {
|
|
||||||
total_seconds: 864000,
|
|
||||||
daily_average: 2800,
|
|
||||||
days_including_holidays: 308,
|
|
||||||
languages: [
|
|
||||||
{ name: "JavaScript", percent: 35.2, total_seconds: 304128 },
|
|
||||||
{ name: "TypeScript", percent: 28.1, total_seconds: 242688 },
|
|
||||||
{ name: "Python", percent: 20.3, total_seconds: 175392 },
|
|
||||||
{ name: "Vue", percent: 10.1, total_seconds: 87296 },
|
|
||||||
{ name: "CSS", percent: 6.3, total_seconds: 54432 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
statusData.value = { is_coding: false };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ const siteConfig = {
|
|||||||
profile: {
|
profile: {
|
||||||
name: "RhenCloud",
|
name: "RhenCloud",
|
||||||
title: "I'm RhenCloud.",
|
title: "I'm RhenCloud.",
|
||||||
avatar: "avatar.webp", // public/avatar.webp
|
avatar: "/avatar.webp", // public/avatar.webp
|
||||||
bio: "趁世界还未重启之前 约一次爱恋",
|
bio: "趁世界还未重启之前 约一次爱恋",
|
||||||
birthday: "2010-03-28",
|
birthday: "2010-03-28",
|
||||||
// gender: "女",
|
// gender: "女",
|
||||||
@@ -31,7 +31,7 @@ const siteConfig = {
|
|||||||
|
|
||||||
siteMeta: {
|
siteMeta: {
|
||||||
title: "RhenCloud",
|
title: "RhenCloud",
|
||||||
icon: "favicon.svg", // public/favicon.svg
|
icon: "/favicon.svg", // public/favicon.svg
|
||||||
startDate: "2025-12-06",
|
startDate: "2025-12-06",
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -65,6 +65,10 @@ const siteConfig = {
|
|||||||
autoplay: false,
|
autoplay: false,
|
||||||
// 是否默认以黑胶唱片状态启动(仅浮动模式)
|
// 是否默认以黑胶唱片状态启动(仅浮动模式)
|
||||||
defaultMinimized: true,
|
defaultMinimized: true,
|
||||||
|
// 标签页非激活时是否自动暂停
|
||||||
|
autoPause: false,
|
||||||
|
// Music API 配置
|
||||||
|
apiUrls: ["https://www.bilibili.uno/api", "https://meting-api.wangcy.site/api"],
|
||||||
},
|
},
|
||||||
|
|
||||||
umami: {
|
umami: {
|
||||||
|
|||||||
16
src/main.ts
16
src/main.ts
@@ -1,16 +0,0 @@
|
|||||||
import { createApp } from "vue";
|
|
||||||
import { VueUmamiPlugin } from "@jaseeey/vue-umami-plugin";
|
|
||||||
import App from "./App.vue";
|
|
||||||
import router from "./router";
|
|
||||||
import "./styles.css";
|
|
||||||
import siteConfig from "./config/siteConfig";
|
|
||||||
|
|
||||||
const app = createApp(App);
|
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== "development") {
|
|
||||||
if (siteConfig.umami?.enable) {
|
|
||||||
app.use(VueUmamiPlugin({ websiteID: siteConfig.umami.websiteId, scriptSrc: siteConfig.umami.url, router }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.use(router).mount("#app");
|
|
||||||
75
src/pages/about.vue
Normal file
75
src/pages/about.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<main class="page">
|
||||||
|
<HeroSection :profile="profile" />
|
||||||
|
<SkillsSection :skills="skills" />
|
||||||
|
<StatsSection :github="github" :wakatime="wakatime" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive } from "vue";
|
||||||
|
import { useRuntimeConfig, definePageMeta } from "#imports";
|
||||||
|
import HeroSection from "~/components/HeroSection.vue";
|
||||||
|
import SkillsSection from "~/components/SkillsSection.vue";
|
||||||
|
import StatsSection from "~/components/StatsSection.vue";
|
||||||
|
import siteConfig from "@/config/siteConfig";
|
||||||
|
|
||||||
|
const profile = siteConfig.profile;
|
||||||
|
const skills = siteConfig.skills;
|
||||||
|
const wakatime = siteConfig.wakatime;
|
||||||
|
const config = useRuntimeConfig();
|
||||||
|
const githubToken = config.public.githubToken ?? "";
|
||||||
|
|
||||||
|
type GithubHeatmap = {
|
||||||
|
username: string;
|
||||||
|
heatmapUrl: string;
|
||||||
|
languages?: { name: string; percent: number }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const github = reactive<GithubHeatmap>({
|
||||||
|
...siteConfig.github,
|
||||||
|
heatmapUrl: `https://ghchart.rshah.org/${siteConfig.github.username}`,
|
||||||
|
languages: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
order: 1,
|
||||||
|
label: "关于",
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchGithubMeta();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchGithubMeta() {
|
||||||
|
try {
|
||||||
|
const headers: HeadersInit = {};
|
||||||
|
if (githubToken) {
|
||||||
|
headers.Authorization = `Bearer ${githubToken}`;
|
||||||
|
}
|
||||||
|
const resp = await fetch(`https://api.github.com/users/${github.username}/repos?per_page=100&sort=updated`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!Array.isArray(data)) return;
|
||||||
|
type GithubRepo = { language?: string };
|
||||||
|
const repos = data as GithubRepo[];
|
||||||
|
const counts: Record<string, number> = {};
|
||||||
|
repos.forEach((repo) => {
|
||||||
|
if (!repo.language) return;
|
||||||
|
counts[repo.language] = (counts[repo.language] || 0) + 1;
|
||||||
|
});
|
||||||
|
const total = Object.values(counts).reduce((sum, value) => sum + value, 0) || 1;
|
||||||
|
const parsed = Object.entries(counts)
|
||||||
|
.map(([name, count]) => ({ name, count }))
|
||||||
|
.sort((a, b) => b.count - a.count)
|
||||||
|
.slice(0, 5);
|
||||||
|
github.languages = parsed.map((item) => ({
|
||||||
|
name: item.name,
|
||||||
|
percent: Math.round((item.count / total) * 100),
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch GitHub metadata:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
17
src/pages/friends.vue
Normal file
17
src/pages/friends.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<main class="page">
|
||||||
|
<FriendsSection :friends="friends" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import FriendsSection from "~/components/FriendsSection.vue";
|
||||||
|
import siteConfig from "~/config/siteConfig";
|
||||||
|
|
||||||
|
const friends = siteConfig.friends;
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
order: 4,
|
||||||
|
label: "友链",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
23
src/pages/index.vue
Normal file
23
src/pages/index.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<main class="page">
|
||||||
|
<HeroSection :profile="profile" />
|
||||||
|
<SocialLinks :links="socialLinks" />
|
||||||
|
<AboutSection :items="about" :profile="profile" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import HeroSection from "~/components/HeroSection.vue";
|
||||||
|
import SocialLinks from "~/components/SocialLinks.vue";
|
||||||
|
import AboutSection from "~/components/AboutSection.vue";
|
||||||
|
import siteConfig from "~/config/siteConfig";
|
||||||
|
|
||||||
|
const profile = siteConfig.profile;
|
||||||
|
const socialLinks = siteConfig.socialLinks;
|
||||||
|
const about = siteConfig.about;
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
order: 0,
|
||||||
|
label: "首页",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
17
src/pages/projects.vue
Normal file
17
src/pages/projects.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<main class="page">
|
||||||
|
<ProjectsSection :projects="projects" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import ProjectsSection from "~/components/ProjectsSection.vue";
|
||||||
|
import siteConfig from "~/config/siteConfig";
|
||||||
|
|
||||||
|
const projects = siteConfig.projects;
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
order: 3,
|
||||||
|
label: "项目",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
17
src/pages/sites.vue
Normal file
17
src/pages/sites.vue
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<template>
|
||||||
|
<main class="page">
|
||||||
|
<SitesSection :sites="sites" />
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import SitesSection from "~/components/SitesSection.vue";
|
||||||
|
import siteConfig from "~/config/siteConfig";
|
||||||
|
|
||||||
|
const sites = siteConfig.sites;
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
order: 2,
|
||||||
|
label: "网站",
|
||||||
|
});
|
||||||
|
</script>
|
||||||
91
src/plugins/neteaseMiniPlayer.client.ts
Normal file
91
src/plugins/neteaseMiniPlayer.client.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { defineNuxtPlugin } from "#app";
|
||||||
|
import siteConfig from "~/config/siteConfig";
|
||||||
|
|
||||||
|
export default defineNuxtPlugin(() => {
|
||||||
|
if (import.meta.server) return;
|
||||||
|
|
||||||
|
// 检查配置是否启用了音乐播放器
|
||||||
|
if (!siteConfig.music?.enable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在本地开发环境禁用网易音乐播放器,避免网络超时
|
||||||
|
// if (
|
||||||
|
// typeof window !== "undefined" &&
|
||||||
|
// (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1")
|
||||||
|
// ) {
|
||||||
|
// console.log("Netease Music Player disabled on localhost");
|
||||||
|
// return;
|
||||||
|
// }
|
||||||
|
|
||||||
|
const cssHref = "/css/netease-mini-player-v2.css";
|
||||||
|
const scriptSrc = "/js/netease-mini-player-v2.js";
|
||||||
|
|
||||||
|
const ensureStyle = () => {
|
||||||
|
if (document.querySelector(`link[href="${cssHref}"]`)) return;
|
||||||
|
const link = document.createElement("link");
|
||||||
|
link.rel = "stylesheet";
|
||||||
|
link.href = cssHref;
|
||||||
|
link.onerror = () => {
|
||||||
|
console.warn("Failed to load Netease music player styles");
|
||||||
|
};
|
||||||
|
document.head.appendChild(link);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensureScript = () =>
|
||||||
|
new Promise<void>((resolve) => {
|
||||||
|
// 检查全局对象是否已存在,表示脚本已加载
|
||||||
|
const anyWin = window as any;
|
||||||
|
if (anyWin.NeteaseMiniPlayer) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = document.querySelector(`script[src="${scriptSrc}"]`) as HTMLScriptElement | null;
|
||||||
|
if (existing) {
|
||||||
|
// 脚本已存在但未加载,等待它加载
|
||||||
|
existing.addEventListener("load", () => resolve(), { once: true });
|
||||||
|
existing.addEventListener("error", () => resolve(), { once: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 脚本不存在,创建并加载
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = scriptSrc;
|
||||||
|
script.async = true;
|
||||||
|
script.onload = () => resolve();
|
||||||
|
script.onerror = () => {
|
||||||
|
console.warn("Failed to load Netease music player script");
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
document.body.appendChild(script);
|
||||||
|
});
|
||||||
|
|
||||||
|
const initPlayer = () => {
|
||||||
|
const anyWin = window as any;
|
||||||
|
|
||||||
|
// 将 siteConfig 的音乐配置传递给全局 window 对象
|
||||||
|
if (!anyWin.__NETEASE_MUSIC_CONFIG__) {
|
||||||
|
anyWin.__NETEASE_MUSIC_CONFIG__ = siteConfig.music;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anyWin.NeteaseMiniPlayer?.init) {
|
||||||
|
try {
|
||||||
|
anyWin.NeteaseMiniPlayer.init();
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to initialize Netease music player:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用超时机制防止永久挂起
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
console.warn("Netease music player initialization timeout");
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
ensureStyle();
|
||||||
|
ensureScript().then(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
initPlayer();
|
||||||
|
});
|
||||||
|
});
|
||||||
29
src/plugins/umami.client.ts
Normal file
29
src/plugins/umami.client.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { defineNuxtPlugin } from "#app";
|
||||||
|
import { VueUmamiPlugin } from "@jaseeey/vue-umami-plugin";
|
||||||
|
import type { Router } from "vue-router";
|
||||||
|
import siteConfig from "~/config/siteConfig";
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
if (!process.client) return;
|
||||||
|
if (!siteConfig.umami?.enable) return;
|
||||||
|
|
||||||
|
// 跳过在 localhost 环境下加载 Umami
|
||||||
|
if (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
(window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1")
|
||||||
|
) {
|
||||||
|
console.log("Umami plugin skipped on localhost");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const router = nuxtApp.$router as Router | undefined;
|
||||||
|
if (!router) return;
|
||||||
|
|
||||||
|
nuxtApp.vueApp.use(
|
||||||
|
VueUmamiPlugin({
|
||||||
|
websiteID: siteConfig.umami.websiteId,
|
||||||
|
scriptSrc: siteConfig.umami.url,
|
||||||
|
router,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router";
|
|
||||||
import Home from "../views/Home.vue";
|
|
||||||
import About from "../views/About.vue";
|
|
||||||
import Sites from "../views/Sites.vue";
|
|
||||||
import Projects from "../views/Projects.vue";
|
|
||||||
import Friends from "../views/Friends.vue";
|
|
||||||
|
|
||||||
const routes: RouteRecordRaw[] = [
|
|
||||||
{ path: "/", name: "Home", component: Home, meta: { order: 0, label: "首页" } },
|
|
||||||
{ path: "/about", name: "About", component: About, meta: { order: 1, label: "关于" } },
|
|
||||||
{ path: "/sites", name: "Sites", component: Sites, meta: { order: 2, label: "网站" } },
|
|
||||||
{ path: "/projects", name: "Projects", component: Projects, meta: { order: 3, label: "项目" } },
|
|
||||||
{ path: "/friends", name: "Friends", component: Friends, meta: { order: 4, label: "友链" } },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default createRouter({
|
|
||||||
history: createWebHistory(),
|
|
||||||
routes,
|
|
||||||
});
|
|
||||||
106
src/styles.css
106
src/styles.css
@@ -15,6 +15,42 @@ body {
|
|||||||
background: radial-gradient(circle at 20% 20%, #1b2b4b, #0f1629);
|
background: radial-gradient(circle at 20% 20%, #1b2b4b, #0f1629);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Layout shell keeps background and toggle visible even when content is hidden */
|
||||||
|
|
||||||
|
.app-shell {
|
||||||
|
position: relative;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #e8eefc;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-stack {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: #7cc1ff;
|
color: #7cc1ff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@@ -106,7 +142,7 @@ p {
|
|||||||
/* do not force width here — let the player's own minimized/expanded
|
/* do not force width here — let the player's own minimized/expanded
|
||||||
styles control sizing. Only constrain max width as a safety net. */
|
styles control sizing. Only constrain max width as a safety net. */
|
||||||
max-width: calc(100% - 40px) !important;
|
max-width: calc(100% - 40px) !important;
|
||||||
z-index: 9999 !important;
|
z-index: 10001 !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
}
|
}
|
||||||
@@ -127,7 +163,7 @@ p {
|
|||||||
bottom: 20px !important;
|
bottom: 20px !important;
|
||||||
left: 20px !important;
|
left: 20px !important;
|
||||||
right: auto !important;
|
right: auto !important;
|
||||||
z-index: 9999 !important;
|
z-index: 10001 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fix: prevent playlist dropdown from increasing document height
|
/* Fix: prevent playlist dropdown from increasing document height
|
||||||
@@ -141,7 +177,7 @@ p {
|
|||||||
width: 290px !important;
|
width: 290px !important;
|
||||||
max-height: 50vh !important;
|
max-height: 50vh !important;
|
||||||
overflow: auto !important;
|
overflow: auto !important;
|
||||||
z-index: 10001 !important;
|
z-index: 10002 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.netease-mini-player[data-position="top-left"] .playlist-container,
|
.netease-mini-player[data-position="top-left"] .playlist-container,
|
||||||
@@ -153,7 +189,7 @@ p {
|
|||||||
width: 290px !important;
|
width: 290px !important;
|
||||||
max-height: 50vh !important;
|
max-height: 50vh !important;
|
||||||
overflow: auto !important;
|
overflow: auto !important;
|
||||||
z-index: 10001 !important;
|
z-index: 10002 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* If player is docked to the right, align playlist to right edge */
|
/* If player is docked to the right, align playlist to right edge */
|
||||||
@@ -189,3 +225,65 @@ p {
|
|||||||
object-fit: cover !important;
|
object-fit: cover !important;
|
||||||
border-radius: 50% !important;
|
border-radius: 50% !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Floating background show/hide toggle (bottom-right) */
|
||||||
|
.background-toggle {
|
||||||
|
position: fixed;
|
||||||
|
right: 18px;
|
||||||
|
bottom: 18px;
|
||||||
|
z-index: 10000;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
color: #f7fbff;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, border-color 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-toggle:hover,
|
||||||
|
.background-toggle:focus-visible {
|
||||||
|
background: rgba(124, 193, 255, 0.25);
|
||||||
|
border-color: rgba(124, 193, 255, 0.65);
|
||||||
|
box-shadow: 0 14px 36px rgba(124, 193, 255, 0.28);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-toggle:active {
|
||||||
|
transform: translateY(1px) scale(0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-toggle .toggle-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-toggle .toggle-label {
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-toggle.active {
|
||||||
|
background: linear-gradient(135deg, rgba(124, 193, 255, 0.4), rgba(255, 255, 255, 0.2));
|
||||||
|
border-color: rgba(124, 193, 255, 0.8);
|
||||||
|
color: #0f1629;
|
||||||
|
box-shadow: 0 16px 42px rgba(124, 193, 255, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.background-toggle {
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
padding: 9px 12px;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
.background-toggle .toggle-label {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="page">
|
|
||||||
<HeroSection :profile="profile" />
|
|
||||||
<SkillsSection :skills="skills" />
|
|
||||||
<StatsSection :github="github" :wakatime="wakatime" />
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { onMounted, reactive } from "vue";
|
|
||||||
import HeroSection from "../components/HeroSection.vue";
|
|
||||||
import SkillsSection from "../components/SkillsSection.vue";
|
|
||||||
import StatsSection from "../components/StatsSection.vue";
|
|
||||||
import siteConfig from "../config/siteConfig";
|
|
||||||
|
|
||||||
const profile = siteConfig.profile;
|
|
||||||
const siteMeta = siteConfig.siteMeta;
|
|
||||||
const skills = siteConfig.skills;
|
|
||||||
const wakatime = siteConfig.wakatime;
|
|
||||||
|
|
||||||
const github = reactive({
|
|
||||||
...siteConfig.github,
|
|
||||||
heatmapUrl: `https://ghchart.rshah.org/${siteConfig.github.username}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 修改此处:使用 VITE_ 前缀
|
|
||||||
const githubToken = import.meta.env.VITE_GITHUB_TOKEN ?? "";
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.title = siteMeta.title;
|
|
||||||
const link = document.querySelector("link[rel~='icon']") || document.createElement("link");
|
|
||||||
link.rel = "icon";
|
|
||||||
link.href = siteMeta.icon;
|
|
||||||
document.head.appendChild(link);
|
|
||||||
fetchGithubMeta();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function fetchGithubMeta() {
|
|
||||||
try {
|
|
||||||
const headers = githubToken ? { Authorization: `Bearer ${githubToken}` } : {};
|
|
||||||
const resp = await fetch(`https://api.github.com/users/${github.username}/repos?per_page=100&sort=updated`, {
|
|
||||||
headers,
|
|
||||||
});
|
|
||||||
const repos = await resp.json();
|
|
||||||
if (!Array.isArray(repos)) return;
|
|
||||||
const counts = repos.reduce((acc, repo) => {
|
|
||||||
if (!repo.language) return acc;
|
|
||||||
acc[repo.language] = (acc[repo.language] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
const total = Object.values(counts).reduce((a, b) => a + b, 0) || 1;
|
|
||||||
const parsed = Object.entries(counts)
|
|
||||||
.map(([name, count]) => ({ name, count }))
|
|
||||||
.sort((a, b) => b.count - a.count)
|
|
||||||
.slice(0, 5);
|
|
||||||
github.languages = parsed.map((item) => ({
|
|
||||||
name: item.name,
|
|
||||||
percent: Math.round((item.count / total) * 100),
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch GitHub metadata:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="page">
|
|
||||||
<FriendsSection :friends="friends" />
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import FriendsSection from "../components/FriendsSection.vue";
|
|
||||||
import siteConfig from "../config/siteConfig";
|
|
||||||
|
|
||||||
const friends = siteConfig.friends;
|
|
||||||
</script>
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="page">
|
|
||||||
<HeroSection :profile="profile" />
|
|
||||||
<SocialLinks :links="socialLinks" />
|
|
||||||
<AboutSection :items="about" :profile="profile" />
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { onMounted, reactive } from "vue";
|
|
||||||
import HeroSection from "../components/HeroSection.vue";
|
|
||||||
import SocialLinks from "../components/SocialLinks.vue";
|
|
||||||
import AboutSection from "../components/AboutSection.vue";
|
|
||||||
import siteConfig from "../config/siteConfig";
|
|
||||||
|
|
||||||
const profile = siteConfig.profile;
|
|
||||||
const socialLinks = siteConfig.socialLinks;
|
|
||||||
const siteMeta = siteConfig.siteMeta;
|
|
||||||
const about = siteConfig.about;
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.title = siteMeta.title;
|
|
||||||
const link = document.querySelector("link[rel~='icon']") || document.createElement("link");
|
|
||||||
link.rel = "icon";
|
|
||||||
link.href = siteMeta.icon;
|
|
||||||
document.head.appendChild(link);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.page {
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="page">
|
|
||||||
<ProjectsSection :projects="projects" />
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import ProjectsSection from "../components/ProjectsSection.vue";
|
|
||||||
import siteConfig from "../config/siteConfig";
|
|
||||||
|
|
||||||
const projects = siteConfig.projects;
|
|
||||||
</script>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<template>
|
|
||||||
<main class="page">
|
|
||||||
<SitesSection :sites="sites" />
|
|
||||||
</main>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import SitesSection from "../components/SitesSection.vue";
|
|
||||||
import siteConfig from "../config/siteConfig";
|
|
||||||
|
|
||||||
const sites = siteConfig.sites;
|
|
||||||
</script>
|
|
||||||
@@ -1,25 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"extends": "./.nuxt/tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2020",
|
"types": ["node"]
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
|
"include": ["nuxt.config.ts", "src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts", "server/**/*.ts"]
|
||||||
"references": [{ "path": "./tsconfig.node.json" }]
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowSyntheticDefaultImports": true
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"types": ["node"]
|
||||||
},
|
},
|
||||||
"include": ["vite.config.ts"]
|
"include": ["nuxt.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
{
|
{
|
||||||
"version": 2,
|
"version": 2,
|
||||||
"framework": "vite",
|
"framework": "nuxtjs"
|
||||||
"routes": [
|
|
||||||
{
|
|
||||||
"src": "/api/(.*)",
|
|
||||||
"dest": "/api/$1"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
import { defineConfig } from "vite";
|
|
||||||
import vue from "@vitejs/plugin-vue";
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [vue()],
|
|
||||||
server: { port: 5173 },
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user