Compare commits

..

4 Commits

Author SHA1 Message Date
a6d4c8a27b fix: 修复 Vercel 配置中的框架名称为 nuxtjs 2025-12-12 23:48:18 +08:00
82574d38b9 fix: 修复邮件发送和 Wakatime 数据获取功能,更新 Vercel 配置 2025-12-12 23:46:26 +08:00
55f4307b13 feat(ui): 添加友链申请表单中想说的话字段
在 `FriendsSection.vue` 中添加了新的表单字段“想说的话”,用户可以输入最多50字的信息。同时更新了相关的样式,以支持 `textarea` 的显示和交互,包括输入框的样式和字符计数的显示。

在 `send-mail.ts` 中添加了对新字段 `message` 的处理,确保其在邮件内容中正确显示,并在邮件的HTML部分增加了对 `message` 字段的引用。

在生成的邮件内容中增加了友链申请者的“想说的话”信息,方便接收者查看。
2025-12-12 23:29:42 +08:00
99ffc73e76 refactor: 将项目迁移到 Nuxt 4
该项目从 Vue 3 迁移到了 Nuxt 4,对代码结构和配置进行了相应调整。
- 修改了环境变量前缀,将 `VITE_` 替换为 `NUXT_PUBLIC_`,以便与 Nuxt 的环境变量配置兼容。
- 添加了新的环境变量 `WAKATIME_API_KEY` 和可选变量 `WAKATIME_API_URL` 用于配置 Wakatime API。
2025-12-12 23:19:18 +08:00
40 changed files with 2834 additions and 1169 deletions

View File

@@ -1,8 +1,14 @@
# Github Token
VITE_GITHUB_TOKEN=your-github-token
NUXT_PUBLIC_GITHUB_TOKEN=your-github-token
# 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_HOST=smtp.example.com

3
.gitignore vendored
View File

@@ -11,6 +11,9 @@ build/
.next/
.vercel/
.vite/
.nuxt/
.output/
.edgeone
# env & secrets
.env

View File

@@ -1,6 +1,6 @@
# Cloud Home
一个基于 Vue 的个人主页模板,内置友链申请、网站展示、项目展示、友链随机展示、自定义配置,支持 Vercel 部署与邮件通知。
一个基于 Nuxt 3 (Vue 3) 的个人主页模板,内置友链申请、网站展示、项目展示、友链随机展示、自定义配置,支持 Vercel 部署与邮件通知。
## 特性
@@ -11,16 +11,16 @@
## 技术栈
- 前端Vue 3 + HTML + CSS
- 构建Vite
- 部署Vercel静态构建 + Serverless Functions
- 前端:Nuxt 3Vue 3+ HTML + CSS
- 构建 / 运行Nuxt 3 + Nitro
- 部署VercelNuxt 构建 + Nitro 函数
## 致谢
排名不分先后
- [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: {
name: "Example User", // 你的名字
title: "I'm a software developer.", // 你的简介,可为空
avatar: "avatar.webp", // 你的头像可为public目录下的文件或外部链接
avatar: "/avatar.webp", // 你的头像可为public目录下的文件或外部链接
bio: "Hello World", // 你的喜欢的一句话,可为空
birthday: "xxxx-xx-xx", // 你的生日,可为空
gender: "", // 你的性别,可为空
@@ -66,7 +66,7 @@ const siteConfig: SiteConfig = {
siteMeta: {
title: "Example Title", // 网站标题
icon: "favicon.ico", // 网站图标可为public目录下的文件或外部链接
icon: "/favicon.ico", // 网站图标可为public目录下的文件或外部链接
startDate:"xxxx-xx-xx", // 网站创建日期
},
@@ -90,6 +90,10 @@ const siteConfig: SiteConfig = {
autoplay: false,
// 是否默认以黑胶唱片状态启动(仅浮动模式)
defaultMinimized: true,
// 标签页非激活时是否自动暂停
autoPause: false,
// Music API 配置
apiUrls: ["https://www.bilibili.uno/api", "https://meting-api.wangcy.site/api"],
},
umami: {
@@ -166,16 +170,17 @@ const siteConfig: SiteConfig = {
在 Vercel 控制台或本地 `.env` 配置:
- `VITE_GITHUB_TOKEN`: 具有仓库读取权限的 GitHub Token用于绕过 GitHub API 速率限制。(可选)
- `UMAMI_API_KEY`: Umami 分析的 API Key。
- `NUXT_PUBLIC_GITHUB_TOKEN`: 具有仓库读取权限的 GitHub Token用于绕过 GitHub API 速率限制。(可选)
- `NUXT_PUBLIC_UMAMI_API_KEY`: 可选的 Umami API Key,用于展示访问量统计数据
- `WAKATIME_API_KEY`: Wakatime API Key用于获取编码统计数据。
- `WAKATIME_API_URL`: Wakatime API 地址,覆盖默认 `https://wakatime.com/api/v1`(可选)。
- `SMTP_HOST`: 邮件服务器主机名
- `SMTP_PORT`: 端口(如 465 或 587
- `SMTP_USER`: 发件人邮箱账号
- `SMTP_PASS`: 邮箱授权码或密码
- `MAIL_FROM`: 发件人地址(通常同 SMTP_USER
- `SENDER_EMAIL`: 发件人地址(通常同 SMTP_USER
- `ADMIN_EMAIL`: 接收通知的邮箱地址
- `SMTP_SECURE`:是否启用 SSL/TLS 加密(默认 `true`
- `SMTP_SECURE`:是否强制启用 SSL/TLS默认 `true` 当端口为 465
## 本地开发
@@ -184,7 +189,7 @@ pnpm install
pnpm dev
```
访问 `http://localhost:5173/`
访问 `http://localhost:3000/`
## 构建
@@ -192,7 +197,7 @@ pnpm dev
pnpm build
```
产物输出到
产物输出到 Nuxt 的 `.output/` 目录,该目录同时包含静态资源与 Nitro 服务器入口。
## 部署到 Vercel
@@ -201,6 +206,8 @@ pnpm build
## API
雁型的 Nitro 路由位于 `server/api`,依旧暴露同样的 `/api` 前缀。
- `POST /api/send-mail`:友链申请邮件发送。请求体示例:
```json

View File

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

View File

@@ -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
View 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 ?? "",
},
},
});

View File

@@ -3,27 +3,19 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
"dev": "nuxt dev",
"build": "nuxt build",
"generate": "nuxt generate",
"preview": "nuxt preview"
},
"dependencies": {
"@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",
"vue": "^3.5.25",
"vue-router": "^4.6.3"
"nuxt": "^4.2.2"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^24.10.1",
"@types/nodemailer": "^7.0.4",
"@vitejs/plugin-vue": "^6.0.2",
"typescript": "^5.9.3",
"vite": "^7.2.6",
"vue-tsc": "^3.1.6"
"typescript": "^5.9.3"
}
}

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

View File

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

View File

@@ -18,8 +18,10 @@
<script setup>
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 config = useRuntimeConfig();
const quote = ref("");
const from = ref("");
const pageviews = ref(0);
@@ -60,7 +62,7 @@ const fetchStats = async () => {
}
const apiBase = siteConfig.umami.apiBase;
const websiteId = siteConfig.umami.websiteId;
const apiKey = import.meta.env.VITE_UMAMI_API_KEY;
const apiKey = config.public.umamiApiKey;
if (!apiKey) return;
@@ -105,7 +107,8 @@ onMounted(() => {
<style scoped>
.footer {
text-align: center;
margin-top: 8px;
margin-top: auto;
width: 100%;
}
.from {

View File

@@ -26,6 +26,13 @@
头像链接
<input v-model="form.avatar" type="url" placeholder="可选,展示头像" />
</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">
<button type="submit" class="primary" :disabled="loading">
{{ loading ? "提交中..." : "提交申请" }}
@@ -75,6 +82,7 @@ const form = reactive({
desc: "",
email: "",
avatar: "",
message: "",
});
const displayedFriends = ref([]);
@@ -108,6 +116,7 @@ const submitForm = async () => {
desc: form.desc,
email: form.email,
avatar: form.avatar,
message: form.message,
}),
});
if (!resp.ok) throw new Error("send failed");
@@ -192,15 +201,41 @@ h2 {
color: inherit;
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);
}
.friend-form input:focus {
.friend-form input:focus,
.friend-form textarea:focus {
outline: none;
border-color: rgba(124, 193, 255, 0.8);
background: rgba(255, 255, 255, 0.1);
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 {
grid-column: 1 / -1;
display: flex;

View File

@@ -25,6 +25,8 @@ defineProps({ profile: Object });
height: 120px;
border-radius: 50%;
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>

View File

@@ -10,11 +10,13 @@
:data-theme="music.theme"
:data-autoplay="music.autoplay"
:data-default-minimized="music.defaultMinimized"
:data-auto-pause="music.autoPause"
:data-api-urls="JSON.stringify(music.apiUrls)"
></div>
</template>
<script setup lang="ts">
import siteConfig from "../config/siteConfig";
import siteConfig from "~/config/siteConfig";
const music = siteConfig.music;
</script>

View File

@@ -3,15 +3,15 @@
<button :disabled="currentIndex <= 0" @click="goPrev">上一页</button>
<div class="dots">
<button
v-for="r in ordered"
:key="r.name"
:class="{ active: r.name === route.name }"
@click="router.push({ name: r.name })"
v-for="item in pages"
:key="item.name"
:class="{ active: item.name === route.name }"
@click="router.push({ name: item.name })"
>
{{ r.meta.label || r.name }}
{{ item.label }}
</button>
</div>
<button :disabled="currentIndex >= ordered.length - 1" @click="goNext">下一页</button>
<button :disabled="currentIndex >= pages.length - 1" @click="goNext">下一页</button>
</div>
</template>
@@ -22,21 +22,26 @@ import { useRoute, useRouter } from "vue-router";
const router = useRouter();
const route = useRoute();
const ordered = computed(() =>
router
.getRoutes()
.filter((r) => typeof r.meta?.order === "number")
.sort((a, b) => a.meta.order - b.meta.order)
);
const pages = [
{ name: "index", label: "首页" },
{ name: "about", label: "关于" },
{ name: "sites", label: "网站" },
{ 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 = () => {
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 = () => {
if (currentIndex.value < ordered.value.length - 1)
router.push({ name: ordered.value[currentIndex.value + 1].name });
if (currentIndex.value < pages.length - 1) {
router.push({ name: pages[currentIndex.value + 1].name });
}
};
</script>

View File

@@ -53,24 +53,24 @@
<div class="stats-grid">
<div class="stat-item">
<span class="stat-value">{{
currentWakatimeData.total_seconds ? formatTime(currentWakatimeData.total_seconds) : "N/A"
currentWakatimeData?.total_seconds ? formatTime(currentWakatimeData.total_seconds) : "N/A"
}}</span>
<span class="stat-label">总时间</span>
</div>
<div class="stat-item">
<span class="stat-value">{{
currentWakatimeData.daily_average ? formatTime(currentWakatimeData.daily_average) : "N/A"
currentWakatimeData?.daily_average ? formatTime(currentWakatimeData.daily_average) : "N/A"
}}</span>
<span class="stat-label">日均</span>
</div>
<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>
</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>
<p class="muted">语言使用统计 · Languages</p>
<div class="lang-chart">
@@ -177,13 +177,10 @@ const fetchWakatimeData = async () => {
params.append("apiUrl", wakatime.apiUrl);
}
const url = `/api/wakatime${params.toString() ? `?${params.toString()}` : ""}`;
console.log("Fetching Wakatime data from:", url);
const response = await fetch(url);
console.log("Response status:", response.status);
if (response.ok) {
const data = await response.json();
console.log("Wakatime data:", data);
weeklyData.value = data.weekly;
allTimeData.value = data.allTime;
statusData.value = data.status;
@@ -199,34 +196,6 @@ const fetchWakatimeData = async () => {
}
} catch (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 };
}
}
};

View File

@@ -2,7 +2,7 @@ const siteConfig = {
profile: {
name: "RhenCloud",
title: "I'm RhenCloud.",
avatar: "avatar.webp", // public/avatar.webp
avatar: "/avatar.webp", // public/avatar.webp
bio: "趁世界还未重启之前 约一次爱恋",
birthday: "2010-03-28",
// gender: "女",
@@ -31,7 +31,7 @@ const siteConfig = {
siteMeta: {
title: "RhenCloud",
icon: "favicon.svg", // public/favicon.svg
icon: "/favicon.svg", // public/favicon.svg
startDate: "2025-12-06",
},
@@ -65,6 +65,10 @@ const siteConfig = {
autoplay: false,
// 是否默认以黑胶唱片状态启动(仅浮动模式)
defaultMinimized: true,
// 标签页非激活时是否自动暂停
autoPause: false,
// Music API 配置
apiUrls: ["https://www.bilibili.uno/api", "https://meting-api.wangcy.site/api"],
},
umami: {

View File

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

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

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

View File

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

View File

@@ -15,6 +15,42 @@ body {
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 {
color: #7cc1ff;
text-decoration: none;
@@ -106,7 +142,7 @@ p {
/* do not force width here — let the player's own minimized/expanded
styles control sizing. Only constrain max width as a safety net. */
max-width: calc(100% - 40px) !important;
z-index: 9999 !important;
z-index: 10001 !important;
margin: 0 !important;
transform: none !important;
}
@@ -127,7 +163,7 @@ p {
bottom: 20px !important;
left: 20px !important;
right: auto !important;
z-index: 9999 !important;
z-index: 10001 !important;
}
/* Fix: prevent playlist dropdown from increasing document height
@@ -141,7 +177,7 @@ p {
width: 290px !important;
max-height: 50vh !important;
overflow: auto !important;
z-index: 10001 !important;
z-index: 10002 !important;
}
.netease-mini-player[data-position="top-left"] .playlist-container,
@@ -153,7 +189,7 @@ p {
width: 290px !important;
max-height: 50vh !important;
overflow: auto !important;
z-index: 10001 !important;
z-index: 10002 !important;
}
/* If player is docked to the right, align playlist to right edge */
@@ -189,3 +225,65 @@ p {
object-fit: cover !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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,25 +1,7 @@
{
"extends": "./.nuxt/tsconfig.json",
"compilerOptions": {
"target": "ES2020",
"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
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
"include": ["nuxt.config.ts", "src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts", "server/**/*.ts"]
}

View File

@@ -4,7 +4,8 @@
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
"allowSyntheticDefaultImports": true,
"types": ["node"]
},
"include": ["vite.config.ts"]
"include": ["nuxt.config.ts"]
}

View File

@@ -1,10 +1,4 @@
{
"version": 2,
"framework": "vite",
"routes": [
{
"src": "/api/(.*)",
"dest": "/api/$1"
}
]
"framework": "nuxtjs"
}

View File

@@ -1,7 +0,0 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
export default defineConfig({
plugins: [vue()],
server: { port: 5173 },
});