chore: 格式化代码

This commit is contained in:
2025-12-19 19:38:01 +08:00
parent 618723a689
commit 4d8644629b
37 changed files with 6516 additions and 6200 deletions

473
README.md
View File

@@ -1,231 +1,242 @@
# Cloud Home # Cloud Home
一款基于 Nuxt 4 的个人主页模板,内置友链申请、网站展示、项目展示、友链随机展示、自定义配置,支持 Vercel 部署与邮件通知。 一款基于 Nuxt 4 的个人主页模板,内置友链申请、网站展示、项目展示、友链随机展示、自定义配置,支持 Vercel 部署与邮件通知。
## 特性 ## 特性
- 🎨 个性化主页:头像、社交链接、技能、站点/项目列表可配置。 - 🎨 个性化主页:头像、社交链接、技能、站点/项目列表可配置。
- 🔗 友链模块:支持申请表单、邮件通知、随机顺序展示。 - 🔗 友链模块:支持申请表单、邮件通知、随机顺序展示。
- 📱 响应式设计:适配桌面与移动端。 - 📱 响应式设计:适配桌面与移动端。
- ⚙️ Serverless 部署支持 - ⚙️ Serverless 部署支持
## 技术栈 ## 技术栈
- 前端Nuxt 4 + Tailwind CSS - 前端Nuxt 4 + Tailwind CSS
- 构建 / 运行Nuxt 4 + Nitro - 构建 / 运行Nuxt 4 + Nitro
- 部署VercelNuxt 构建 + Nitro 函数) - 部署VercelNuxt 构建 + Nitro 函数)
## TODO ## TODO
- [ ] 增加主题色配置 - [ ] 增加主题色配置
- [ ] 增加追番模块 - [ ] 增加追番模块
- [ ] 增加留言板模块 - [ ] 增加留言板模块
## 致谢 ## 致谢
排名不分先后 排名不分先后
- [Skill Icons](https://github.com/tandpfun/skill-icons):技能图标库,本项目的技能图标来源。 - [Skill Icons](https://github.com/tandpfun/skill-icons):技能图标库,本项目的技能图标来源。
- [Netease Mini Player](https://github.com/numakkiyu/NeteaseMiniPlayer):迷你网易云播放器组件,为本项目的音乐播放功能提供支持。(本项目使用[本人fork的版本](https://github.com/RhenCloud/NeteaseMiniPlayer) - [Netease Mini Player](https://github.com/numakkiyu/NeteaseMiniPlayer):迷你网易云播放器组件,为本项目的音乐播放功能提供支持。(本项目使用[本人fork的版本](https://github.com/RhenCloud/NeteaseMiniPlayer)
感谢以上开源项目原作者与维护者的贡献。 感谢以上开源项目原作者与维护者的贡献。
## 配置指南 ## 配置指南
### 站点配置文件 (`src/config/siteConfig.ts`) ### 站点配置文件 (`src/config/siteConfig.ts`)
本项目的所有静态内容配置均集中在 `src/config/siteConfig.ts` 文件中。 本项目的所有静态内容配置均集中在 `src/config/siteConfig.ts` 文件中。
```typescript ```typescript
const siteConfig: SiteConfig = { 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: "", // 你的性别,可为空
pronouns: "", // 你希望别人如何称呼你,可为空 pronouns: "", // 你希望别人如何称呼你,可为空
location: "", // 你的居住地,可为空 location: "", // 你的居住地,可为空
}, },
// 社交链接,预定义的社交链接可在 `src/components/SocialLink.vue` 中查阅 // 社交链接,预定义的社交链接可在 `src/components/SocialLink.vue` 中查阅
socialLinks: [ socialLinks: [
{ name: "GitHub", url: "https://github.com/ExampleUser" }, { name: "GitHub", url: "https://github.com/ExampleUser" },
{ name: "Email", url: "mailto:you@example.com" }, { name: "Email", url: "mailto:you@example.com" },
{ name: "Telegram", url: "https://t.me/ExampleUser" }, { name: "Telegram", url: "https://t.me/ExampleUser" },
{ name: "Bilibili", url: "https://space.bilibili.com/1502883335" }, { name: "Bilibili", url: "https://space.bilibili.com/1502883335" },
{ name: "Blog", url: "https://blog.example.com" }, { name: "Blog", url: "https://blog.example.com" },
], ],
github: { github: {
username: "ExampleUser", // 你的 GitHub 用户名 username: "ExampleUser", // 你的 GitHub 用户名
}, },
// 个人介绍卡片 // 个人介绍卡片
about: [ about: [
{ title: "Example", desc: "Example description", icon: "🧠" }, { title: "Example", desc: "Example description", icon: "🧠" },
{ title: "Example", desc: "Example description", icon: "🛠️" }, { title: "Example", desc: "Example description", icon: "🛠️" },
{ title: "Example", desc: "Example description", icon: "🎬" }, { title: "Example", desc: "Example description", icon: "🎬" },
{ title: "Example", desc: "Example description", icon: "🎮" }, { title: "Example", desc: "Example description", icon: "🎮" },
], ],
siteMeta: { siteMeta: {
title: "Example Title", // 网站标题 title: "Example Title", // 网站标题
icon: "/favicon.ico", // 网站图标可为public目录下的文件或外部链接 icon: "/favicon.ico", // 网站图标可为public目录下的文件或外部链接
startDate:"xxxx-xx-xx", // 网站创建日期 startDate: "xxxx-xx-xx", // 网站创建日期
}, },
music: { music: {
// 是否启用音乐播放器 // 是否启用音乐播放器
enable: true, enable: true,
// floating - 浮动模式播放器(推荐)- 用于播放网易云歌单 // floating - 浮动模式播放器(推荐)- 用于播放网易云歌单
// embed - 嵌入模式播放器 - 用于播放网易云单曲 // embed - 嵌入模式播放器 - 用于播放网易云单曲
mode: "floating", // "floating" 或 "embed" mode: "floating", // "floating" 或 "embed"
// 歌单ID从网易云音乐链接获取如 https://music.163.com/#/playlist?id=14273792576 // 歌单ID从网易云音乐链接获取如 https://music.163.com/#/playlist?id=14273792576
playlistId: undefined, // 例如: "14273792576" playlistId: undefined, // 例如: "14273792576"
// 歌曲ID仅在嵌入模式下使用 // 歌曲ID仅在嵌入模式下使用
songId: undefined, // 例如: "554242291" songId: undefined, // 例如: "554242291"
// 播放器位置(浮动模式): "bottom-left" | "bottom-right" | "top-left" | "top-right" // 播放器位置(浮动模式): "bottom-left" | "bottom-right" | "top-left" | "top-right"
position: "bottom-left", position: "bottom-left",
// 是否显示歌词 // 是否显示歌词
lyric: true, lyric: true,
// 主题: "light" | "dark" | "auto" // 主题: "light" | "dark" | "auto"
theme: "dark", theme: "dark",
// 是否自动播放 // 是否自动播放
autoplay: false, autoplay: false,
// 是否默认以黑胶唱片状态启动(仅浮动模式) // 是否默认以黑胶唱片状态启动(仅浮动模式)
defaultMinimized: true, defaultMinimized: true,
// 标签页非激活时是否自动暂停 // 标签页非激活时是否自动暂停
autoPause: false, autoPause: false,
// Music API 配置 // Music API 配置
apiUrls: ["https://www.bilibili.uno/api", "https://meting-api.wangcy.site/api"], apiUrls: ["https://www.bilibili.uno/api", "https://meting-api.wangcy.site/api"],
}, },
umami: { umami: {
enable: true, // 是否启用 Umami 分析 enable: true, // 是否启用 Umami 分析
url: "https://cloud.umami.is/script.js", // Umami 分析脚本 URL一般无需修改 url: "https://cloud.umami.is/script.js", // Umami 分析脚本 URL一般无需修改
websiteId: "YOUR_WEBSITE_ID", // Umami 网站 ID websiteId: "YOUR_WEBSITE_ID", // Umami 网站 ID
apiBase: "https://api.umami.is", // Umami API 地址,一般无需修改 apiBase: "https://api.umami.is", // Umami API 地址,一般无需修改
}, },
wakatime: { wakatime: {
enable: true, // 是否启用 Wakatime 统计 enable: true, // 是否启用 Wakatime 统计
apiUrl: "https://wakatime.com/api/v1", // Wakatime API 地址,默认官方地址 apiUrl: "https://wakatime.com/api/v1", // Wakatime API 地址,默认官方地址
}, },
// 技能图标展示详见https://github.com/tandpfun/skill-icons#icons-list // 技能图标展示详见https://github.com/tandpfun/skill-icons#icons-list
skills: [ skills: [
{ title: "前端", items: ["css", "html", "javascript", "typescript", "vue"] }, { title: "前端", items: ["css", "html", "javascript", "typescript", "vue"] },
{ title: "后端 / 云", items: ["cpp", "cloudflare", "docker", "java", "mysql", "nodejs", "python", "vercel"] }, {
{ title: "工具", items: ["ae", "au", "git", "github", "md", "ps", "pr", "vscode"] }, title: "后端 / 云",
{ title: "操作系统", items: ["arch", "linux", "windows"] }, items: ["cpp", "cloudflare", "docker", "java", "mysql", "nodejs", "python", "vercel"],
], },
{ title: "工具", items: ["ae", "au", "git", "github", "md", "ps", "pr", "vscode"] },
sites: [ { title: "操作系统", items: ["arch", "linux", "windows"] },
{ ],
name: "Example Site 1",
desc: "Example Site 1", sites: [
url: "https://example1.com", {
}, name: "Example Site 1",
{ desc: "Example Site 1",
name: "Example Site 2", url: "https://example1.com",
desc: "Example Site 2", },
url: "https://example2.com", {
}, name: "Example Site 2",
], desc: "Example Site 2",
url: "https://example2.com",
projects: [ },
{ name: "Example Project 1", url: "https://github.com/ExampleUser/example-project-1", desc: "Example Project 1" }, ],
{ name: "Example Project 2", url: "https://github.com/ExampleUser/example-project-2", desc: "Example Project 2" },
], projects: [
{
friends: [ name: "Example Project 1",
{ url: "https://github.com/ExampleUser/example-project-1",
name: "Example Site 1", desc: "Example Project 1",
desc: "Example Site 1", },
url: "https://example1.com", {
avatar: "https://example1.com/avatar.png", name: "Example Project 2",
}, url: "https://github.com/ExampleUser/example-project-2",
{ desc: "Example Project 2",
name: "Example Site 2", },
desc: "Example Site 2", ],
url: "https://example2.com",
avatar: "https://example2.com/avatar.png", friends: [
}, {
], name: "Example Site 1",
desc: "Example Site 1",
footer: { url: "https://example1.com",
beian: "备案号", // 备案号,留空则不显示 avatar: "https://example1.com/avatar.png",
beianLink: "https://beian.miit.gov.cn/", // 备案号链接,一般无需修改 },
customHtml: '', // 自定义 HTML 代码,如统计代码等 {
hitokoto: { name: "Example Site 2",
enable: true, // 是否启用一言 desc: "Example Site 2",
type: "a&b&c&d&j", // 一言类型,详见 https://developer.hitokoto.cn/sentence/#%E5%8F%A5%E5%AD%90%E7%B1%BB%E5%9E%8B-%E5%8F%82%E6%95%B0 url: "https://example2.com",
}, avatar: "https://example2.com/avatar.png",
}, },
}; ],
```
footer: {
### 其他配置 beian: "备案号", // 备案号,留空则不显示
beianLink: "https://beian.miit.gov.cn/", // 备案号链接,一般无需修改
- **404 页面**:修改 `public/404.html` 来自定义 404 错误页面的样式与内容。 customHtml: "", // 自定义 HTML 代码,如统计代码等
- **友链展示逻辑**`FriendsSection.vue` 默认使用随机顺序渲染 `siteConfig.friends`,如需固定顺序请修改该组件。 hitokoto: {
enable: true, // 是否启用一言
## 环境变量 type: "a&b&c&d&j", // 一言类型,详见 https://developer.hitokoto.cn/sentence/#%E5%8F%A5%E5%AD%90%E7%B1%BB%E5%9E%8B-%E5%8F%82%E6%95%B0
},
在 Vercel 控制台或本地 `.env` 配置: },
};
- `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`: 邮件服务器主机名 - **404 页面**:修改 `public/404.html` 来自定义 404 错误页面的样式与内容。
- `SMTP_PORT`: 端口(如 465 或 587 - **友链展示逻辑**`FriendsSection.vue` 默认使用随机顺序渲染 `siteConfig.friends`,如需固定顺序请修改该组件。
- `SMTP_USER`: 发件人邮箱账号
- `SMTP_PASS`: 邮箱授权码或密码 ## 环境变量
- `SENDER_EMAIL`: 发件人地址(通常同 SMTP_USER
- `ADMIN_EMAIL`: 接收通知的邮箱地址 在 Vercel 控制台或本地 `.env` 配置:
- `SMTP_SECURE`:是否强制启用 SSL/TLS默认为 `true` 当端口为 465
- `NUXT_PUBLIC_GITHUB_TOKEN`: 具有仓库读取权限的 GitHub Token用于绕过 GitHub API 速率限制。(可选)
## 本地开发 - `NUXT_PUBLIC_UMAMI_API_KEY`: 可选的 Umami API Key用于展示访问量统计数据。
- `WAKATIME_API_KEY`: Wakatime API Key用于获取编码统计数据。
```bash - `WAKATIME_API_URL`: Wakatime API 地址,覆盖默认 `https://wakatime.com/api/v1`(可选)。
pnpm install - `SMTP_HOST`: 邮件服务器主机名
pnpm dev - `SMTP_PORT`: 端口(如 465 或 587
``` - `SMTP_USER`: 发件人邮箱账号
- `SMTP_PASS`: 邮箱授权码或密码
访问 `http://localhost:3000/` - `SENDER_EMAIL`: 发件人地址(通常同 SMTP_USER
- `ADMIN_EMAIL`: 接收通知的邮箱地址
## 构建 - `SMTP_SECURE`:是否强制启用 SSL/TLS默认为 `true` 当端口为 465
```bash ## 本地开发
pnpm build
``` ```bash
pnpm install
产物输出到 Nuxt 的 `.output/` 目录,该目录同时包含静态资源与 Nitro 服务器入口。 pnpm dev
```
## 部署到 Vercel
访问 `http://localhost:3000/`
1. 导入仓库到 Vercel。
2. 设置上文的环境变量。 ## 构建
## API ```bash
pnpm build
雁型的 Nitro 路由位于 `server/api`,依旧暴露同样的 `/api` 前缀。 ```
- `POST /api/send-mail`:友链申请邮件发送。请求体示例: 产物输出到 Nuxt 的 `.output/` 目录,该目录同时包含静态资源与 Nitro 服务器入口。
```json ## 部署到 Vercel
{
"name": "RhenCloud", 1. 导入仓库到 Vercel。
"url": "https://example.com", 2. 设置上文的环境变量。
"desc": "个人博客",
"email": "you@example.com", ## API
"avatar": "https://example.com/avatar.png"
} 雁型的 Nitro 路由位于 `server/api`,依旧暴露同样的 `/api` 前缀。
```
- `POST /api/send-mail`:友链申请邮件发送。请求体示例:
## 许可
```json
MIT License. {
"name": "RhenCloud",
"url": "https://example.com",
"desc": "个人博客",
"email": "you@example.com",
"avatar": "https://example.com/avatar.png"
}
```
## 许可
MIT License.

View File

@@ -1,75 +1,75 @@
<template> <template>
<div class="app-shell" :style="backgroundStyle"> <div class="app-shell" :style="backgroundStyle">
<div class="background-overlay" :style="overlayStyle"/> <div class="background-overlay" :style="overlayStyle" />
<button <button
class="background-toggle" class="background-toggle"
:title="hideComponents ? '显示内容' : '隐藏内容'" :title="hideComponents ? '显示内容' : '隐藏内容'"
:class="{ active: hideComponents }" :class="{ active: hideComponents }"
@click="hideComponents = !hideComponents" @click="hideComponents = !hideComponents"
> >
<span class="toggle-icon">{{ hideComponents ? "👁️" : "🙈" }}</span> <span class="toggle-icon">{{ hideComponents ? "👁️" : "🙈" }}</span>
<span class="toggle-label">{{ hideComponents ? "显示" : "隐藏" }}</span> <span class="toggle-label">{{ hideComponents ? "显示" : "隐藏" }}</span>
</button> </button>
<div class="content-stack"> <div class="content-stack">
<Transition name="fade-down"> <Transition name="fade-down">
<main v-if="!hideComponents" key="content" class="app-body"> <main v-if="!hideComponents" key="content" class="app-body">
<NuxtPage /> <NuxtPage />
</main> </main>
</Transition> </Transition>
<Transition name="fade-up"> <Transition name="fade-up">
<PageSwitcher v-if="!hideComponents" key="switcher" /> <PageSwitcher v-if="!hideComponents" key="switcher" />
</Transition> </Transition>
<Transition name="fade-down"> <Transition name="fade-down">
<FooterSection v-if="!hideComponents" key="footer" :contact="contact" /> <FooterSection v-if="!hideComponents" key="footer" :contact="contact" />
</Transition> </Transition>
</div> </div>
<MusicPlayer /> <MusicPlayer />
</div> </div>
</template> </template>
<script setup> <script setup>
import { onMounted, computed, ref } from "vue"; import { onMounted, computed, ref } from "vue";
import PageSwitcher from "~/components/PageSwitcher.vue"; import PageSwitcher from "~/components/PageSwitcher.vue";
import FooterSection from "~/components/FooterSection.vue"; import FooterSection from "~/components/FooterSection.vue";
import MusicPlayer from "~/components/MusicPlayer.vue"; import MusicPlayer from "~/components/MusicPlayer.vue";
import siteConfig from "~/config/siteConfig"; import siteConfig from "~/config/siteConfig";
const contact = siteConfig.footer; const contact = siteConfig.footer;
const bg = siteConfig.appearance.background; const bg = siteConfig.appearance.background;
const isMobile = ref(false); const isMobile = ref(false);
const hideComponents = ref(false); const hideComponents = ref(false);
const checkIfMobile = () => { const checkIfMobile = () => {
isMobile.value = typeof window !== "undefined" && window.innerWidth <= 768; isMobile.value = typeof window !== "undefined" && window.innerWidth <= 768;
}; };
onMounted(() => { onMounted(() => {
checkIfMobile(); checkIfMobile();
window.addEventListener("resize", checkIfMobile); window.addEventListener("resize", checkIfMobile);
}); });
const getBackgroundImage = () => { const getBackgroundImage = () => {
if (!bg.enable) return undefined; if (!bg.enable) return undefined;
const image = isMobile.value && bg.mobileImage ? bg.mobileImage : bg.image; const image = isMobile.value && bg.mobileImage ? bg.mobileImage : bg.image;
if (!image) return undefined; if (!image) return undefined;
return image.startsWith("http") ? image : `/${image}`; return image.startsWith("http") ? image : `/${image}`;
}; };
const backgroundStyle = computed(() => ({ backgroundColor: "#0f1629" })); const backgroundStyle = computed(() => ({ backgroundColor: "#0f1629" }));
const overlayStyle = computed(() => { const overlayStyle = computed(() => {
const imageUrl = getBackgroundImage(); const imageUrl = getBackgroundImage();
if (!bg.enable || !imageUrl) return {}; if (!bg.enable || !imageUrl) return {};
return { return {
backgroundImage: `linear-gradient(${bg.overlay}, ${bg.overlay}), url('${imageUrl}')`, backgroundImage: `linear-gradient(${bg.overlay}, ${bg.overlay}), url('${imageUrl}')`,
backgroundSize: "cover", backgroundSize: "cover",
backgroundPosition: "center", backgroundPosition: "center",
backgroundAttachment: "fixed", backgroundAttachment: "fixed",
filter: bg.blur ? `blur(${bg.blur}px)` : undefined, filter: bg.blur ? `blur(${bg.blur}px)` : undefined,
}; };
}); });
</script> </script>
<!-- <style> <!-- <style>
@import "/css/netease-mini-player-v2.css"; @import "/css/netease-mini-player-v2.css";
</style> --> </style> -->

View File

@@ -1,101 +1,109 @@
<template> <template>
<section class="card flex flex-col gap-2.5"> <section class="card flex flex-col gap-2.5">
<h2 class="m-0 mb-1">个人简介</h2> <h2 class="m-0 mb-1">个人简介</h2>
<p class="text-text-muted text-sm m-0 mb-3 block">关于我 · About Me</p> <p class="text-text-muted text-sm m-0 mb-3 block">关于我 · About Me</p>
<div class="flex flex-wrap justify-center gap-3.5"> <div class="flex flex-wrap justify-center gap-3.5">
<article <article
v-if="age" v-if="age"
class="flex-1 min-w-[140px] flex items-center justify-between gap-2 bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-2.5 px-3.5 shadow-md-dark transition-all duration-300 hover:-translate-y-1 hover:border-primary/40 hover:shadow-lg-dark hover:bg-gradient-to-br hover:from-primary/6" class="flex-1 min-w-[140px] flex items-center justify-between gap-2 bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-2.5 px-3.5 shadow-md-dark transition-all duration-300 hover:-translate-y-1 hover:border-primary/40 hover:shadow-lg-dark hover:bg-gradient-to-br hover:from-primary/6"
> >
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="text-xl leading-none">🎂</span> <span class="text-xl leading-none">🎂</span>
<h3 class="m-0 text-sm font-semibold text-white/90">年龄</h3> <h3 class="m-0 text-sm font-semibold text-white/90">年龄</h3>
</div> </div>
<p class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60"> <p
{{ age }} class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60"
</p> >
</article> {{ age }}
</p>
<article </article>
v-if="profile?.gender"
class="flex-1 min-w-[140px] flex items-center justify-between gap-2 bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-2.5 px-3.5 shadow-md-dark transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-lg-dark" <article
> v-if="profile?.gender"
<div class="flex items-center gap-2"> class="flex-1 min-w-[140px] flex items-center justify-between gap-2 bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-2.5 px-3.5 shadow-md-dark transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-lg-dark"
<span class="text-xl leading-none"></span> >
<h3 class="m-0 text-sm font-semibold text-white/90">性别</h3> <div class="flex items-center gap-2">
</div> <span class="text-xl leading-none"></span>
<p class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60"> <h3 class="m-0 text-sm font-semibold text-white/90">性别</h3>
{{ profile.gender }} </div>
</p> <p
</article> class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60"
>
<article {{ profile.gender }}
v-if="profile?.pronouns" </p>
class="flex-1 min-w-[140px] flex items-center justify-between gap-2 bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-2.5 px-3.5 shadow-md-dark transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-lg-dark" </article>
>
<div class="flex items-center gap-2"> <article
<span class="text-xl leading-none">🗣</span> v-if="profile?.pronouns"
<h3 class="m-0 text-sm font-semibold text-white/90">代词</h3> class="flex-1 min-w-[140px] flex items-center justify-between gap-2 bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-2.5 px-3.5 shadow-md-dark transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-lg-dark"
</div> >
<p class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60"> <div class="flex items-center gap-2">
{{ profile.pronouns }} <span class="text-xl leading-none">🗣</span>
</p> <h3 class="m-0 text-sm font-semibold text-white/90">代词</h3>
</article> </div>
<p
<article class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60"
v-if="profile?.location" >
class="flex-1 min-w-[140px] flex items-center justify-between gap-2 bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-2.5 px-3.5 shadow-md-dark transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-lg-dark" {{ profile.pronouns }}
> </p>
<div class="flex items-center gap-2"> </article>
<span class="text-xl leading-none">📍</span>
<h3 class="m-0 text-sm font-semibold text-white/90">地区</h3> <article
</div> v-if="profile?.location"
<p class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60"> class="flex-1 min-w-[140px] flex items-center justify-between gap-2 bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-2.5 px-3.5 shadow-md-dark transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-lg-dark"
{{ profile.location }} >
</p> <div class="flex items-center gap-2">
</article> <span class="text-xl leading-none">📍</span>
</div> <h3 class="m-0 text-sm font-semibold text-white/90">地区</h3>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3.5 mt-2.5"> <p
<article class="text-text-muted text-xs m-0 text-right whitespace-nowrap font-medium text-white/60"
v-for="item in items" >
:key="item.title" {{ profile.location }}
class="bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-3 shadow-md-dark transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-lg-dark" </p>
> </article>
<div class="flex items-center gap-2 mb-1.5"> </div>
<span class="text-2xl leading-none">{{ item.icon }}</span>
<h3 class="m-0 text-base font-semibold">{{ item.title }}</h3> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3.5 mt-2.5">
</div> <article
<p class="text-text-muted text-sm m-0">{{ item.desc }}</p> v-for="item in items"
</article> :key="item.title"
</div> class="bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-3 shadow-md-dark transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:shadow-lg-dark"
</section> >
</template> <div class="flex items-center gap-2 mb-1.5">
<span class="text-2xl leading-none">{{ item.icon }}</span>
<script setup> <h3 class="m-0 text-base font-semibold">{{ item.title }}</h3>
import { computed } from "vue"; </div>
<p class="text-text-muted text-sm m-0">{{ item.desc }}</p>
const props = defineProps({ </article>
items: { </div>
type: Array, </section>
default: () => [], </template>
},
profile: { <script setup>
type: Object, import { computed } from "vue";
default: () => ({}),
}, const props = defineProps({
}); items: {
type: Array,
const age = computed(() => { default: () => [],
if (!props.profile?.birthday) return null; },
const birthDate = new Date(props.profile.birthday); profile: {
const today = new Date(); type: Object,
let age = today.getFullYear() - birthDate.getFullYear(); default: () => ({}),
const m = today.getMonth() - birthDate.getMonth(); },
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) { });
age--;
} const age = computed(() => {
return age; if (!props.profile?.birthday) return null;
}); const birthDate = new Date(props.profile.birthday);
</script> const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const m = today.getMonth() - birthDate.getMonth();
if (m < 0 || (m === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age;
});
</script>

View File

@@ -1,142 +1,146 @@
<template> <template>
<footer class="card text-center mt-auto w-full flex flex-col gap-1"> <footer class="card text-center mt-auto w-full flex flex-col gap-1">
<!-- 一言 --> <!-- 一言 -->
<p v-if="showHitokoto && quote" class="text-text-muted text-sm m-0 italic"> <p v-if="showHitokoto && quote" class="text-text-muted text-sm m-0 italic">
{{ quote }}<span v-if="from" class="ml-1.5"> {{ from }}</span> {{ quote }}<span v-if="from" class="ml-1.5"> {{ from }}</span>
</p> </p>
<!-- 访问统计 --> <!-- 访问统计 -->
<p v-if="showStats && !statsError" class="text-text-muted text-xs m-0"> <p v-if="showStats && !statsError" class="text-text-muted text-xs m-0">
👁 {{ visitors }} · 📊 {{ pageviews }} 👁 {{ visitors }} · 📊 {{ pageviews }}
</p> </p>
<!-- 备案信息 --> <!-- 备案信息 -->
<p v-if="contact?.beian" class="text-text-muted text-xs m-0"> <p v-if="contact?.beian" class="text-text-muted text-xs m-0">
<NuxtLink <NuxtLink
:to="contact.beianLink || '/'" :to="contact.beianLink || '/'"
class="opacity-85 transition-all duration-200 hover:text-primary hover:opacity-100" class="opacity-85 transition-all duration-200 hover:text-primary hover:opacity-100"
> >
{{ contact.beian }} {{ contact.beian }}
</NuxtLink> </NuxtLink>
</p> </p>
<!-- 框架与技术栈信息 --> <!-- 框架与技术栈信息 -->
<p class="text-text-muted text-xs m-0"> <p class="text-text-muted text-xs m-0">
Powered by Powered by
<a <a
href="https://nuxt.com" href="https://nuxt.com"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="text-primary hover:text-accent transition-colors" class="text-primary hover:text-accent transition-colors"
>Nuxt 4</a >Nuxt 4</a
> >
· ·
<a <a
href="https://tailwindcss.com" href="https://tailwindcss.com"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="text-primary hover:text-accent transition-colors" class="text-primary hover:text-accent transition-colors"
>Tailwind CSS</a >Tailwind CSS</a
> >
· ·
<a <a
href="https://vuejs.org" href="https://vuejs.org"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
class="text-primary hover:text-accent transition-colors" class="text-primary hover:text-accent transition-colors"
>Vue 3</a >Vue 3</a
> >
</p> </p>
<!-- eslint-disable-next-line vue/no-v-html --> <!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="contact?.customHtml" v-html="contact.customHtml" /> <div v-if="contact?.customHtml" v-html="contact.customHtml" />
</footer> </footer>
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { useRuntimeConfig } from "#imports"; import { useRuntimeConfig } from "#imports";
import siteConfig from "~/config/siteConfig"; import siteConfig from "~/config/siteConfig";
const contact = siteConfig.footer || {}; const contact = siteConfig.footer || {};
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const quote = ref(""); const quote = ref("");
const from = ref(""); const from = ref("");
const pageviews = ref(0); const pageviews = ref(0);
const visitors = ref(0); const visitors = ref(0);
const statsError = ref(true); const statsError = ref(true);
const showHitokoto = siteConfig.footer?.hitokoto?.enable; const showHitokoto = siteConfig.footer?.hitokoto?.enable;
const showStats = ref(siteConfig.umami?.enable); const showStats = ref(siteConfig.umami?.enable);
const buildHitokotoUrl = () => { const buildHitokotoUrl = () => {
const type = siteConfig.footer?.hitokoto?.type; const type = siteConfig.footer?.hitokoto?.type;
const url = new URL("https://v1.hitokoto.cn/"); const url = new URL("https://v1.hitokoto.cn/");
if (Array.isArray(type)) { if (Array.isArray(type)) {
type.filter(Boolean).forEach((t) => url.searchParams.append("c", t)); type.filter(Boolean).forEach((t) => url.searchParams.append("c", t));
} else if (typeof type === "string") { } else if (typeof type === "string") {
type.split("&") type
.map((t) => t.trim()) .split("&")
.filter(Boolean) .map((t) => t.trim())
.forEach((t) => url.searchParams.append("c", t)); .filter(Boolean)
} .forEach((t) => url.searchParams.append("c", t));
return url.toString(); }
}; return url.toString();
};
const fetchHitokoto = async () => {
try { const fetchHitokoto = async () => {
const resp = await fetch(buildHitokotoUrl()); try {
const data = await resp.json(); const resp = await fetch(buildHitokotoUrl());
quote.value = data.hitokoto || ""; const data = await resp.json();
from.value = data.from || ""; quote.value = data.hitokoto || "";
} catch (e) { from.value = data.from || "";
console.warn("Hitokoto fetch failed", e); } catch (e) {
} console.warn("Hitokoto fetch failed", e);
}; }
};
const fetchStats = async () => {
try { const fetchStats = async () => {
if (!siteConfig.umami?.apiBase || !siteConfig.umami?.websiteId) { try {
return; if (!siteConfig.umami?.apiBase || !siteConfig.umami?.websiteId) {
} return;
const apiBase = siteConfig.umami.apiBase; }
const websiteId = siteConfig.umami.websiteId; const apiBase = siteConfig.umami.apiBase;
const apiKey = config.public.umamiApiKey; const websiteId = siteConfig.umami.websiteId;
const apiKey = config.public.umamiApiKey;
if (!apiKey) return;
if (!apiKey) return;
// 获取统计数据
const endAt = Date.now(); // 获取统计数据
const startAt = new Date(siteConfig.siteMeta.startDate).getTime(); const endAt = Date.now();
const startAt = new Date(siteConfig.siteMeta.startDate).getTime();
const resp = await fetch(`${apiBase}/v1/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}`, {
headers: { const resp = await fetch(
Authorization: `Bearer ${apiKey}`, `${apiBase}/v1/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}`,
}, {
}); headers: {
Authorization: `Bearer ${apiKey}`,
if (!resp.ok) { },
console.warn(`Stats API returned ${resp.status}`); }
statsError.value = true; );
return;
} if (!resp.ok) {
console.warn(`Stats API returned ${resp.status}`);
const data = await resp.json(); statsError.value = true;
if (data) { return;
statsError.value = false; }
pageviews.value = data.pageviews;
visitors.value = data.visitors; const data = await resp.json();
} if (data) {
statsError.value = false;
if (pageviews.value === 0 && visitors.value === 0) { pageviews.value = data.pageviews;
showStats.value = false; visitors.value = data.visitors;
} }
} catch (e) {
statsError.value = true; if (pageviews.value === 0 && visitors.value === 0) {
console.debug("Stats fetch failed (this is normal if blocked by ad blocker):", e.message); showStats.value = false;
} }
}; } catch (e) {
statsError.value = true;
onMounted(() => { console.debug("Stats fetch failed (this is normal if blocked by ad blocker):", e.message);
if (showHitokoto) fetchHitokoto(); }
if (showStats.value) fetchStats(); };
});
</script> onMounted(() => {
if (showHitokoto) fetchHitokoto();
if (showStats.value) fetchStats();
});
</script>

View File

@@ -1,241 +1,283 @@
<template> <template>
<div class="card panel flex flex-col gap-2.5"> <div class="card panel flex flex-col gap-2.5">
<h2 class="m-0 mb-1 text-lg font-semibold">友情链接</h2> <h2 class="m-0 mb-1 text-lg font-semibold">友情链接</h2>
<p class="text-text-muted text-sm m-0 mb-3 block">欢迎互换友链 · Friends</p> <p class="text-text-muted text-sm m-0 mb-3 block">欢迎互换友链 · Friends</p>
<div class="grid grid-cols-1 gap-4 w-full max-w-[1100px] mx-auto sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 w-full max-w-[1100px] mx-auto sm:grid-cols-2">
<article <article
v-for="f in displayedFriends" :key="f.url" v-for="f in displayedFriends"
class="rounded-[14px] border border-white/10 bg-gradient-to-br from-white/5 to-white/0 px-4 py-3.5 transition-all duration-200 hover:-translate-y-[3px] hover:border-pink-400/50 w-[290px] h-[145px] flex flex-col"> :key="f.url"
<div class="flex items-center justify-between mb-1.5"> class="rounded-[14px] border border-white/10 bg-gradient-to-br from-white/5 to-white/0 px-4 py-3.5 transition-all duration-200 hover:-translate-y-[3px] hover:border-pink-400/50 w-[290px] h-[145px] flex flex-col"
<div class="flex items-center gap-2 min-w-0"> >
<NuxtImg <div class="flex items-center justify-between mb-1.5">
v-if="f.avatar" :src="f.avatar" :alt="f.name" loading="lazy" <div class="flex items-center gap-2 min-w-0">
class="w-12 h-12 rounded-full object-cover border border-white/15" /> <NuxtImg
<h3 class="m-0 font-semibold text-base whitespace-nowrap overflow-hidden text-ellipsis"> v-if="f.avatar"
{{ f.name }} :src="f.avatar"
</h3> :alt="f.name"
</div> loading="lazy"
<span class="rounded-full px-2.5 py-1 text-xs bg-purple-400/15 text-purple-300">友链</span> class="w-12 h-12 rounded-full object-cover border border-white/15"
</div> />
<h3 class="m-0 font-semibold text-base whitespace-nowrap overflow-hidden text-ellipsis">
<p class="text-sm text-white/60 flex-1 overflow-hidden truncate-lines-2 mb-2"> {{ f.name }}
{{ f.desc || "一个有趣的站点" }} </h3>
</p> </div>
<span class="rounded-full px-2.5 py-1 text-xs bg-purple-400/15 text-purple-300"
<NuxtLink >友链</span
:to="f.url" >
class="inline-flex items-center gap-1.5 mt-auto shrink-0 font-semibold text-pink-300 hover:text-pink-400 transition-all duration-200 hover:gap-2"> </div>
访问
</NuxtLink> <p class="text-sm text-white/60 flex-1 overflow-hidden truncate-lines-2 mb-2">
</article> {{ f.desc || "一个有趣的站点" }}
</div> </p>
</div>
<section class="card flex flex-col gap-2.5"> <NuxtLink
<div class="flex justify-center items-center align-center flex-wrap"> :to="f.url"
class="inline-flex items-center gap-1.5 mt-auto shrink-0 font-semibold text-pink-300 hover:text-pink-400 transition-all duration-200 hover:gap-2"
>
访问
</NuxtLink>
</article>
</div>
</div>
<section class="card flex flex-col gap-2.5">
<div class="flex justify-center items-center align-center flex-wrap">
<button
class="px-3 py-2 rounded-2xl border border-primary/50 bg-primary/12 text-text-primary cursor-pointer transition-all duration-200 hover:bg-primary/20 hover:border-primary/80 hover:shadow-lg hover:shadow-primary/25"
@click="openForm"
>
申请友链
</button>
</div>
</section>
<Teleport to="body">
<div
v-if="showDialog"
class="fixed inset-0 bg-black/45 backdrop-blur-sm flex items-center justify-center z-50"
@click.self="closeDialog"
>
<div
class="min-w-[280px] max-w-[420px] bg-gradient-to-br from-pink-500/12 to-white/8 border border-white/15 rounded-2xl p-4 shadow-xl"
>
<h3 class="m-0 mb-2">{{ dialogTitle }}</h3>
<p class="text-text-muted text-sm mb-4">{{ dialogText }}</p>
<div class="flex justify-end">
<button
class="px-3 py-2 rounded-2xl border border-primary/50 bg-primary/12 text-text-primary cursor-pointer hover:bg-primary/20 transition-all"
@click="closeDialog"
>
好的
</button>
</div>
</div>
</div>
</Teleport>
<!-- 申请友链模态弹窗 -->
<Teleport to="body">
<div
v-if="showFormModal"
class="fixed inset-0 bg-black/45 backdrop-blur-sm flex items-center justify-center z-50"
@click.self="showFormModal = false"
>
<div
class="w-[92%] max-w-[540px] bg-gradient-to-br from-white/8 to-primary/6 border border-white/15 rounded-2xl p-6 shadow-xl"
>
<h3 class="m-0 mb-4 text-center">申请友链</h3>
<div class="mb-4 text-sm text-text-primary">
<div class="mb-2 font-semibold">请在申请前在你站点添加以下信息示例 JSON</div>
<pre class="bg-white/6 border border-white/10 rounded-lg p-3 text-xs overflow-auto">
<code>{{ exampleJson }}</code>
</pre>
</div>
<form class="grid grid-cols-1 sm:grid-cols-2 gap-3" @submit.prevent="submitForm">
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold sm:col-span-2">
网站名称 *
<input
v-model="form.name"
required
placeholder="网站名称"
class="px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary focus:outline-none"
/>
</label>
<!-- URL Email 同行 -->
<label class="flex fl ex-col gap-1 text-sm text-text-primary font-semibold">
网站链接 *
<input
v-model="form.url"
type="url"
required
placeholder="https://example.com"
class="px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary focus:outline-none"
/>
</label>
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold">
联系邮箱 *
<input
v-model="form.email"
type="email"
required
placeholder="example@example.com"
class="px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary focus:outline-none"
/>
</label>
<!-- 描述 头像 同行 -->
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold">
网站描述
<input
v-model="form.desc"
placeholder="可选"
class="px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary focus:outline-none"
/>
</label>
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold">
头像链接
<input
v-model="form.avatar"
type="url"
placeholder="可选,展示头像"
class="px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary focus:outline-none"
/>
</label>
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold sm:col-span-2">
想说的话
<div class="flex items-center gap-2">
<textarea
v-model="form.message"
placeholder="可选最多50字"
maxlength="50"
class="flex-1 px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary h-24 resize-none"
/>
</div>
</label>
<div class="sm:col-span-2 flex items-center justify-center gap-3 mt-2">
<button <button
class="px-3 py-2 rounded-2xl border border-primary/50 bg-primary/12 text-text-primary cursor-pointer transition-all duration-200 hover:bg-primary/20 hover:border-primary/80 hover:shadow-lg hover:shadow-primary/25" type="button"
@click="openForm"> class="px-3 py-2 rounded-2xl border border-white/10 bg-white/6"
申请友链 @click="showFormModal = false"
</button> >
</div> 取消
</section> </button>
<Teleport to="body"> <button
<div type="submit"
v-if="showDialog" class="fixed inset-0 bg-black/45 backdrop-blur-sm flex items-center justify-center z-50" :disabled="loading"
@click.self="closeDialog"> class="px-3 py-2 rounded-2xl border border-primary/50 bg-primary/12 text-text-primary disabled:opacity-50"
<div >
class="min-w-[280px] max-w-[420px] bg-gradient-to-br from-pink-500/12 to-white/8 border border-white/15 rounded-2xl p-4 shadow-xl"> {{ loading ? "提交中..." : "提交" }}
<h3 class="m-0 mb-2">{{ dialogTitle }}</h3> </button>
<p class="text-text-muted text-sm mb-4">{{ dialogText }}</p> </div>
<div class="flex justify-end"> <div class="sm:col-span-2">
<button <span class="text-text-muted text-sm">{{ message }}</span>
class="px-3 py-2 rounded-2xl border border-primary/50 bg-primary/12 text-text-primary cursor-pointer hover:bg-primary/20 transition-all" </div>
@click="closeDialog"> </form>
好的 </div>
</button> </div>
</div> </Teleport>
</div> </template>
</div>
</Teleport> <script setup>
import { reactive, ref, watch, computed } from "vue";
<!-- 申请友链模态弹窗 --> import siteConfig from "../config/siteConfig";
<Teleport to="body"> const props = defineProps({ friends: { type: Array, default: () => [] } });
<div const showFormModal = ref(false);
v-if="showFormModal" const loading = ref(false);
class="fixed inset-0 bg-black/45 backdrop-blur-sm flex items-center justify-center z-50" const message = ref("");
@click.self="showFormModal = false"> const showDialog = ref(false);
<div const dialogTitle = ref("");
class="w-[92%] max-w-[540px] bg-gradient-to-br from-white/8 to-primary/6 border border-white/15 rounded-2xl p-6 shadow-xl"> const dialogText = ref("");
<h3 class="m-0 mb-4 text-center">申请友链</h3> const form = reactive({
name: "",
<div class="mb-4 text-sm text-text-primary"> url: "",
<div class="mb-2 font-semibold">请在申请前在你站点添加以下信息示例 JSON</div> desc: "",
<pre class="bg-white/6 border border-white/10 rounded-lg p-3 text-xs overflow-auto"> email: "",
<code>{{ exampleJson }}</code> avatar: "",
</pre> message: "",
</div> });
const displayedFriends = ref([]);
<form class="grid grid-cols-1 sm:grid-cols-2 gap-3" @submit.prevent="submitForm">
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold sm:col-span-2"> const shuffle = (list) => {
网站名称 * const arr = [...list];
<input for (let i = arr.length - 1; i > 0; i--) {
v-model="form.name" required placeholder="网站名称" const j = Math.floor(Math.random() * (i + 1));
class="px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary focus:outline-none" > [arr[i], arr[j]] = [arr[j], arr[i]];
</label> }
return arr;
<!-- URL Email 同行 --> };
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold">
网站链接 * watch(
<input () => props.friends,
v-model="form.url" type="url" required placeholder="https://example.com" (val) => {
class="px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary focus:outline-none" > displayedFriends.value = shuffle(val || []);
</label> },
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold"> { immediate: true }
联系邮箱 * );
<input
v-model="form.email" type="email" required placeholder="example@example.com" const exampleJson = computed(() => {
class="px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary focus:outline-none" > const name = siteConfig.profile?.name || siteConfig.siteMeta?.title || "";
</label> const url = siteConfig.siteMeta?.url || "";
const desc = siteConfig.profile?.bio || "";
<!-- 描述 头像 同行 --> const email = siteConfig.profile.email || "";
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold"> const avatarRaw = siteConfig.profile?.avatar || "";
网站描述 const avatar = resolveUrl(avatarRaw);
<input return JSON.stringify({ name, url, desc, email, avatar }, null, 2);
v-model="form.desc" placeholder="可选" });
class="px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary focus:outline-none" >
</label> const openForm = () => {
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold"> showFormModal.value = true;
头像链接 };
<input
v-model="form.avatar" type="url" placeholder="可选,展示头像" // resolve possible local paths to absolute URLs using site meta URL
class="px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary focus:outline-none" > const resolveUrl = (p) => {
</label> if (!p) return "";
const s = String(p).trim();
<label class="flex flex-col gap-1 text-sm text-text-primary font-semibold sm:col-span-2"> if (/^https?:\/\//i.test(s) || /^\/\//.test(s)) return s;
想说的话 const base =
<div class="flex items-center gap-2"> siteConfig.siteMeta && siteConfig.siteMeta.url
<textarea ? String(siteConfig.siteMeta.url).replace(/\/$/, "")
v-model="form.message" placeholder="可选最多50字" maxlength="50" : "";
class="flex-1 px-2.5 py-2 rounded-xl border border-white/20 bg-white/8 text-text-primary h-24 resize-none"/> if (!base) return s;
</div> if (s.startsWith("/")) return base + s;
</label> return base + "/" + s;
};
<div class="sm:col-span-2 flex items-center justify-center gap-3 mt-2">
<button const submitForm = async () => {
type="button" class="px-3 py-2 rounded-2xl border border-white/10 bg-white/6" loading.value = true;
@click="showFormModal = false"> message.value = "";
取消 try {
</button> const resp = await fetch("/api/send-mail", {
<button method: "POST",
type="submit" :disabled="loading" headers: { "Content-Type": "application/json" },
class="px-3 py-2 rounded-2xl border border-primary/50 bg-primary/12 text-text-primary disabled:opacity-50"> body: JSON.stringify(form),
{{ loading ? "提交中..." : "提交" }} });
</button> if (!resp.ok) throw new Error("send failed");
</div> form.name = "";
<div class="sm:col-span-2"> form.url = "";
<span class="text-text-muted text-sm">{{ message }}</span> form.desc = "";
</div> form.email = "";
</form> form.avatar = "";
</div> form.message = "";
</div> message.value = "提交成功,已发送申请邮件";
</Teleport> showFormModal.value = false;
</template> dialogTitle.value = "提交成功";
dialogText.value = "已发送申请邮件,感谢你的提交,将会尽快审核并在通过后通过邮件联系。";
<script setup> showDialog.value = true;
import { reactive, ref, watch, computed } from "vue"; } catch (e) {
import siteConfig from "../config/siteConfig"; message.value = "提交失败,请稍后重试";
const props = defineProps({ friends: { type: Array, default: () => [] } }); dialogTitle.value = "提交失败";
const showFormModal = ref(false); dialogText.value = "邮件发送失败,请稍后重试或检查网络连接。";
const loading = ref(false); showDialog.value = true;
const message = ref(""); console.error(e);
const showDialog = ref(false); } finally {
const dialogTitle = ref(""); loading.value = false;
const dialogText = ref(""); }
const form = reactive({ };
name: "",
url: "", const closeDialog = () => {
desc: "", showDialog.value = false;
email: "", };
avatar: "", </script>
message: "",
});
const displayedFriends = ref([]);
const shuffle = (list) => {
const arr = [...list];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
};
watch(
() => props.friends,
(val) => {
displayedFriends.value = shuffle(val || []);
},
{ immediate: true }
);
const exampleJson = computed(() => {
const name = siteConfig.profile?.name || siteConfig.siteMeta?.title || "";
const url = siteConfig.siteMeta?.url || "";
const desc = siteConfig.profile?.bio || "";
const email = siteConfig.profile.email || "";
const avatarRaw = siteConfig.profile?.avatar || "";
const avatar = resolveUrl(avatarRaw);
return JSON.stringify({ name, url, desc, email, avatar }, null, 2);
});
const openForm = () => {
showFormModal.value = true;
};
// resolve possible local paths to absolute URLs using site meta URL
const resolveUrl = (p) => {
if (!p) return "";
const s = String(p).trim();
if (/^https?:\/\//i.test(s) || /^\/\//.test(s)) return s;
const base = (siteConfig.siteMeta && siteConfig.siteMeta.url) ? String(siteConfig.siteMeta.url).replace(/\/$/, "") : "";
if (!base) return s;
if (s.startsWith("/")) return base + s;
return base + "/" + s;
};
const submitForm = async () => {
loading.value = true;
message.value = "";
try {
const resp = await fetch("/api/send-mail", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
if (!resp.ok) throw new Error("send failed");
form.name = "";
form.url = "";
form.desc = "";
form.email = "";
form.avatar = "";
form.message = "";
message.value = "提交成功,已发送申请邮件";
showFormModal.value = false;
dialogTitle.value = "提交成功";
dialogText.value = "已发送申请邮件,感谢你的提交,将会尽快审核并在通过后通过邮件联系。";
showDialog.value = true;
} catch (e) {
message.value = "提交失败,请稍后重试";
dialogTitle.value = "提交失败";
dialogText.value = "邮件发送失败,请稍后重试或检查网络连接。";
showDialog.value = true;
console.error(e);
} finally {
loading.value = false;
}
};
const closeDialog = () => {
showDialog.value = false;
};
</script>

View File

@@ -1,64 +1,69 @@
<template> <template>
<section class="card flex flex-col gap-2.5"> <section class="card flex flex-col gap-2.5">
<h2 class="m-0 mb-1">GitHub</h2> <h2 class="m-0 mb-1">GitHub</h2>
<div class="mt-3"> <div class="mt-3">
<h3 class="m-0 mb-1">提交热力图</h3> <h3 class="m-0 mb-1">提交热力图</h3>
<p class="text-text-muted text-sm m-0 mb-3 block">我的提交热力图 · Acitivity Heatmap</p> <p class="text-text-muted text-sm m-0 mb-3 block">我的提交热力图 · Acitivity Heatmap</p>
<NuxtImg <NuxtImg
:src="github.heatmapUrl" :src="github.heatmapUrl"
alt="GitHub Heatmap" alt="GitHub Heatmap"
loading="lazy" loading="lazy"
class="rounded-xl border border-white/10 hover:border-primary/30 transition-all duration-200" class="rounded-xl border border-white/10 hover:border-primary/30 transition-all duration-200"
/> />
</div> </div>
<div class="mt-3"> <div class="mt-3">
<h3 class="m-0 mb-1">常用语言</h3> <h3 class="m-0 mb-1">常用语言</h3>
<p class="text-text-muted text-sm m-0 mb-3 block">我常用的语言 · Languages</p> <p class="text-text-muted text-sm m-0 mb-3 block">我常用的语言 · Languages</p>
<ul class="list-none p-0 m-0 flex flex-col gap-2.5"> <ul class="list-none p-0 m-0 flex flex-col gap-2.5">
<li <li
v-for="lang in topLanguages" v-for="lang in topLanguages"
:key="lang.name" :key="lang.name"
class="bg-white/5 border border-white/10 rounded-xl p-2.5" class="bg-white/5 border border-white/10 rounded-xl p-2.5"
> >
<div class="flex items-center gap-2 font-semibold mb-1.5"> <div class="flex items-center gap-2 font-semibold mb-1.5">
<span <span
class="w-2.5 h-2.5 rounded-full inline-block" class="w-2.5 h-2.5 rounded-full inline-block"
:style="{ background: colorFor(lang.name) }" :style="{ background: colorFor(lang.name) }"
/> />
<span class="text-text-primary">{{ lang.name }}</span> <span class="text-text-primary">{{ lang.name }}</span>
<span class="text-text-muted text-sm">{{ lang.percent }}%</span> <span class="text-text-muted text-sm">{{ lang.percent }}%</span>
</div> </div>
<div class="h-2 rounded-full bg-white/5 overflow-hidden"> <div class="h-2 rounded-full bg-white/5 overflow-hidden">
<span class="block h-full rounded-full transition-all duration-300" :style="barStyle(lang)" /> <span
</div> class="block h-full rounded-full transition-all duration-300"
</li> :style="barStyle(lang)"
</ul> />
</div> </div>
</section> </li>
</template> </ul>
</div>
<script setup> </section>
import { computed } from "vue"; </template>
const props = defineProps({
github: { <script setup>
type: Object, import { computed } from "vue";
required: false, const props = defineProps({
default: () => ({ languages: [] }), github: {
}, type: Object,
}); required: false,
default: () => ({ languages: [] }),
const github = props.github; },
});
const palette = ["#7cc1ff", "#6bdba6", "#ffd166", "#f497da", "#9b8cfc", "#5ce1e6", "#ffa3a3"];
const github = props.github;
const topLanguages = computed(() => (Array.isArray(github.languages) ? github.languages.slice(0, 5) : []));
const palette = ["#7cc1ff", "#6bdba6", "#ffd166", "#f497da", "#9b8cfc", "#5ce1e6", "#ffa3a3"];
const colorFor = (name) => {
const idx = github.languages.findIndex((l) => l.name === name); const topLanguages = computed(() =>
return palette[(idx >= 0 ? idx : 0) % palette.length]; Array.isArray(github.languages) ? github.languages.slice(0, 5) : []
}; );
const barStyle = (lang) => ({
width: `${Math.max(8, lang.percent)}%`, const colorFor = (name) => {
background: colorFor(lang.name), const idx = github.languages.findIndex((l) => l.name === name);
}); return palette[(idx >= 0 ? idx : 0) % palette.length];
</script> };
const barStyle = (lang) => ({
width: `${Math.max(8, lang.percent)}%`,
background: colorFor(lang.name),
});
</script>

View File

@@ -1,32 +1,32 @@
<template> <template>
<section class="card grid grid-cols-[120px_1fr] gap-4 items-center hover:shadow-lg-dark group"> <section class="card grid grid-cols-[120px_1fr] gap-4 items-center hover:shadow-lg-dark group">
<div class="relative"> <div class="relative">
<div <div
class="absolute inset-0 rounded-full bg-gradient-to-br from-primary/30 to-accent/20 blur-xl group-hover:blur-2xl transition-all duration-300 opacity-0 group-hover:opacity-100" class="absolute inset-0 rounded-full bg-gradient-to-br from-primary/30 to-accent/20 blur-xl group-hover:blur-2xl transition-all duration-300 opacity-0 group-hover:opacity-100"
/> />
<NuxtImg <NuxtImg
class="relative w-30 h-30 rounded-full object-cover border-2 border-primary/40 shadow-md-dark bg-white transition-transform duration-300 group-hover:scale-105" class="relative w-30 h-30 rounded-full object-cover border-2 border-primary/40 shadow-md-dark bg-white transition-transform duration-300 group-hover:scale-105"
:src="profile.avatar" :src="profile.avatar"
alt="avatar" alt="avatar"
loading="eager" loading="eager"
/> />
</div> </div>
<div class="overflow-hidden"> <div class="overflow-hidden">
<h1 class="text-2xl font-bold">{{ profile.name }}</h1> <h1 class="text-2xl font-bold">{{ profile.name }}</h1>
<p class="text-text-muted text-sm mt-1">{{ profile.title }}</p> <p class="text-text-muted text-sm mt-1">{{ profile.title }}</p>
<p class="mt-2 line-clamp-2">{{ profile.bio }}</p> <p class="mt-2 line-clamp-2">{{ profile.bio }}</p>
</div> </div>
</section> </section>
</template> </template>
<script setup> <script setup>
import siteConfig from "../config/siteConfig"; import siteConfig from "../config/siteConfig";
const { profile } = defineProps({ const { profile } = defineProps({
profile: { profile: {
type: Object, type: Object,
required: false, required: false,
default: () => siteConfig.profile || {}, default: () => siteConfig.profile || {},
}, },
}); });
</script> </script>

View File

@@ -1,25 +1,32 @@
<template> <template>
<div <div
v-if="music.enable && (music.playlistId || music.songId)" class="netease-mini-player" v-if="music.enable && (music.playlistId || music.songId)"
:data-playlist-id="music.mode === 'floating' ? music.playlistId : undefined" class="netease-mini-player"
:data-song-id="music.mode === 'embed' ? music.songId : undefined" :data-embed="music.mode === 'embed'" :data-playlist-id="music.mode === 'floating' ? music.playlistId : undefined"
:data-position="music.position" :data-lyric="music.lyric" :data-theme="music.theme" :data-song-id="music.mode === 'embed' ? music.songId : undefined"
:data-autoplay="music.autoplay" :data-default-minimized="music.defaultMinimized" :data-embed="music.mode === 'embed'"
:data-auto-pause="music.autoPause" :data-api-urls="JSON.stringify(music.apiUrls)"/> :data-position="music.position"
</template> :data-lyric="music.lyric"
:data-theme="music.theme"
<script setup lang="ts"> :data-autoplay="music.autoplay"
import siteConfig from "~/config/siteConfig"; :data-default-minimized="music.defaultMinimized"
:data-auto-pause="music.autoPause"
const music = siteConfig.music; :data-api-urls="JSON.stringify(music.apiUrls)"
</script> />
</template>
<!-- <style scoped>
/* 音乐播放器样式由 NeteaseMiniPlayer 提供 */ <script setup lang="ts">
/* 使用 display: contents 使外层容器不占用空间 */ import siteConfig from "~/config/siteConfig";
/* 确保播放器浮动定位,不影响页面布局 */
:deep(.netease-mini-player) { const music = siteConfig.music;
position: fixed !important; </script>
z-index: 999 !important;
} <!-- <style scoped>
</style> --> /* 音乐播放器样式由 NeteaseMiniPlayer 提供 */
/* 使用 display: contents 使外层容器不占用空间 */
/* 确保播放器浮动定位,不影响页面布局 */
:deep(.netease-mini-player) {
position: fixed !important;
z-index: 999 !important;
}
</style> -->

View File

@@ -1,55 +1,65 @@
<template> <template>
<div class="my-4 mx-auto max-w-3xl w-full px-4 py-3 grid grid-cols-[auto_1fr_auto] gap-3 items-center"> <div
<button class="my-4 mx-auto max-w-3xl w-full px-4 py-3 grid grid-cols-[auto_1fr_auto] gap-3 items-center"
:disabled="currentIndex <= 0" class="bg-white/10 text-text-primary border border-white/15 rounded-2xl px-3 py-2 cursor-pointer transition-all duration-200 hover:bg-primary/20 hover:border-primary/40 hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed" >
@click="goPrev"> <button
上一页 :disabled="currentIndex <= 0"
</button> class="bg-white/10 text-text-primary border border-white/15 rounded-2xl px-3 py-2 cursor-pointer transition-all duration-200 hover:bg-primary/20 hover:border-primary/40 hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed"
<div class="flex gap-2 flex-wrap justify-center"> @click="goPrev"
<button >
v-for="item in pages" :key="item.name" :class="{ 上一页
'bg-primary/30 border-primary/60 text-primary shadow-lg shadow-primary/25': </button>
item.name === route.name, <div class="flex gap-2 flex-wrap justify-center">
}" class="px-2.5 py-2 bg-white/10 text-text-primary border border-white/15 rounded-2xl cursor-pointer transition-all duration-200 hover:bg-white/15 hover:border-primary/40" <button
@click="router.push({ name: item.name })"> v-for="item in pages"
{{ item.label }} :key="item.name"
</button> :class="{
</div> 'bg-primary/30 border-primary/60 text-primary shadow-lg shadow-primary/25':
<button item.name === route.name,
:disabled="currentIndex >= pages.length - 1" class="bg-white/10 text-text-primary border border-white/15 rounded-2xl px-3 py-2 cursor-pointer transition-all duration-200 hover:bg-primary/20 hover:border-primary/40 hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed" }"
@click="goNext"> class="px-2.5 py-2 bg-white/10 text-text-primary border border-white/15 rounded-2xl cursor-pointer transition-all duration-200 hover:bg-white/15 hover:border-primary/40"
下一页 @click="router.push({ name: item.name })"
</button> >
</div> {{ item.label }}
</template> </button>
</div>
<script setup> <button
import { computed } from "vue"; :disabled="currentIndex >= pages.length - 1"
import { useRoute, useRouter } from "vue-router"; class="bg-white/10 text-text-primary border border-white/15 rounded-2xl px-3 py-2 cursor-pointer transition-all duration-200 hover:bg-primary/20 hover:border-primary/40 hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed"
@click="goNext"
const router = useRouter(); >
const route = useRoute(); 下一页
</button>
const pages = [ </div>
{ name: "index", label: "首页" }, </template>
{ name: "about", label: "关于" },
{ name: "sites", label: "网站" }, <script setup>
{ name: "projects", label: "项目" }, import { computed } from "vue";
{ name: "friends", label: "友链" }, import { useRoute, useRouter } from "vue-router";
{ name: "comments", label: "留言" },
]; const router = useRouter();
const route = useRoute();
const currentIndex = computed(() => pages.findIndex((item) => item.name === route.name));
const pages = [
const goPrev = () => { { name: "index", label: "首页" },
if (currentIndex.value > 0) { { name: "about", label: "关于" },
router.push({ name: pages[currentIndex.value - 1].name }); { name: "sites", label: "网站" },
} { name: "projects", label: "项目" },
}; { name: "friends", label: "友链" },
{ name: "comments", label: "留言" },
const goNext = () => { ];
if (currentIndex.value < pages.length - 1) {
router.push({ name: pages[currentIndex.value + 1].name }); const currentIndex = computed(() => pages.findIndex((item) => item.name === route.name));
}
}; const goPrev = () => {
</script> if (currentIndex.value > 0) {
router.push({ name: pages[currentIndex.value - 1].name });
}
};
const goNext = () => {
if (currentIndex.value < pages.length - 1) {
router.push({ name: pages[currentIndex.value + 1].name });
}
};
</script>

View File

@@ -1,41 +1,41 @@
<template> <template>
<section class="card panel flex flex-col gap-2.5"> <section class="card panel flex flex-col gap-2.5">
<h2 class="m-0 mb-1 text-lg font-semibold">项目作品</h2> <h2 class="m-0 mb-1 text-lg font-semibold">项目作品</h2>
<p class="text-sm text-white/60 mb-3">一些正在维护或已发布的项目 · Projects</p> <p class="text-sm text-white/60 mb-3">一些正在维护或已发布的项目 · Projects</p>
<div class="grid grid-cols-1 gap-4 w-full max-w-[1100px] mx-auto sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 w-full max-w-[1100px] mx-auto sm:grid-cols-2">
<article <article
v-for="p in projects" v-for="p in projects"
:key="p.url" :key="p.url"
class="rounded-[14px] border border-white/10 bg-gradient-to-br from-white/5 to-white/0 px-4 py-3.5 transition-all duration-200 hover:-translate-y-[3px] hover:border-yellow-400/50 w-[290px] h-[145px] flex flex-col" class="rounded-[14px] border border-white/10 bg-gradient-to-br from-white/5 to-white/0 px-4 py-3.5 transition-all duration-200 hover:-translate-y-[3px] hover:border-yellow-400/50 w-[290px] h-[145px] flex flex-col"
> >
<div class="flex items-center justify-between mb-1.5"> <div class="flex items-center justify-between mb-1.5">
<h3 class="font-medium truncate"> <h3 class="font-medium truncate">
{{ p.name }} {{ p.name }}
</h3> </h3>
<span class="rounded-full px-2.5 py-1 text-xs bg-sky-400/15 text-sky-300"> 项目 </span> <span class="rounded-full px-2.5 py-1 text-xs bg-sky-400/15 text-sky-300"> 项目 </span>
</div>
<p class="text-sm text-white/60 flex-1 overflow-hidden truncate-lines-2 mb-2">
{{ p.desc }}
</p>
<NuxtLink
:to="p.url"
class="inline-flex items-center gap-1.5 mt-auto shrink-0 font-semibold text-yellow-300 hover:text-yellow-400 transition-all duration-200 hover:gap-2"
>
查看仓库
</NuxtLink>
</article>
</div> </div>
</section>
<p class="text-sm text-white/60 flex-1 overflow-hidden truncate-lines-2 mb-2">
{{ p.desc }}
</p>
<NuxtLink
:to="p.url"
class="inline-flex items-center gap-1.5 mt-auto shrink-0 font-semibold text-yellow-300 hover:text-yellow-400 transition-all duration-200 hover:gap-2"
>
查看仓库
</NuxtLink>
</article>
</div>
</section>
</template> </template>
<script setup> <script setup>
defineProps({ defineProps({
projects: { projects: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
}); });
</script> </script>

View File

@@ -1,43 +1,43 @@
<template> <template>
<section class="card panel flex flex-col gap-2.5"> <section class="card panel flex flex-col gap-2.5">
<h2 class="m-0 mb-1 text-lg font-semibold">我的网站</h2> <h2 class="m-0 mb-1 text-lg font-semibold">我的网站</h2>
<p class="text-sm text-white/60 mb-3">正在运行的站点 · Websites</p> <p class="text-sm text-white/60 mb-3">正在运行的站点 · Websites</p>
<div class="grid grid-cols-1 gap-4 w-full max-w-[1100px] mx-auto sm:grid-cols-2"> <div class="grid grid-cols-1 gap-4 w-full max-w-[1100px] mx-auto sm:grid-cols-2">
<article <article
v-for="site in sites" v-for="site in sites"
:key="site.url" :key="site.url"
class="rounded-[14px] border border-white/10 bg-gradient-to-br from-white/5 to-white/0 px-4 py-3.5 transition-all duration-200 hover:-translate-y-[3px] hover:border-blue-400/50 w-[290px] h-[145px] flex flex-col" class="rounded-[14px] border border-white/10 bg-gradient-to-br from-white/5 to-white/0 px-4 py-3.5 transition-all duration-200 hover:-translate-y-[3px] hover:border-blue-400/50 w-[290px] h-[145px] flex flex-col"
> >
<div class="flex items-center justify-between mb-1.5"> <div class="flex items-center justify-between mb-1.5">
<h3 class="font-medium truncate"> <h3 class="font-medium truncate">
{{ site.name }} {{ site.name }}
</h3> </h3>
<span class="rounded-full px-2.5 py-1 text-xs bg-sky-400/15 text-green-300"> 在线 </span> <span class="rounded-full px-2.5 py-1 text-xs bg-sky-400/15 text-green-300"> 在线 </span>
</div> </div>
<p class="text-sm text-white/60 flex-1 overflow-hidden truncate-lines-2 mb-2"> <p class="text-sm text-white/60 flex-1 overflow-hidden truncate-lines-2 mb-2">
{{ site.desc }} {{ site.desc }}
</p> </p>
<NuxtLink <NuxtLink
:to="site.url" :to="site.url"
class="inline-flex items-center gap-1.5 mt-auto shrink-0 font-semibold text-blue-300 hover:text-blue-400 transition-all duration-200 hover:gap-2" class="inline-flex items-center gap-1.5 mt-auto shrink-0 font-semibold text-blue-300 hover:text-blue-400 transition-all duration-200 hover:gap-2"
> >
查看 查看
</NuxtLink> </NuxtLink>
</article> </article>
</div> </div>
</section> </section>
</template> </template>
<script setup> <script setup>
defineProps({ defineProps({
sites: { sites: {
type: Array, type: Array,
default: () => [], default: () => [],
}, },
}); });
</script> </script>

View File

@@ -1,31 +1,39 @@
<template> <template>
<section class="card flex flex-col gap-2.5"> <section class="card flex flex-col gap-2.5">
<div> <div>
<h2 class="m-0 mb-1 font-semibold">技能专长</h2> <h2 class="m-0 mb-1 font-semibold">技能专长</h2>
<p class="text-text-muted text-sm m-0 mb-3">我常用的工具与技术 · Skills & Technologies</p> <p class="text-text-muted text-sm m-0 mb-3">我常用的工具与技术 · Skills & Technologies</p>
</div> </div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<article <article
v-for="group in skills" :key="group.title" v-for="group in skills"
class="bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-3.5 shadow-md-dark transition-all duration-300 hover:-translate-y-1 hover:border-primary/50 hover:shadow-lg-dark hover:bg-gradient-to-br hover:from-primary/8"> :key="group.title"
<header class="mb-3"> class="bg-gradient-to-br from-white/5 to-white/2 border border-white/10 rounded-2xl p-3.5 shadow-md-dark transition-all duration-300 hover:-translate-y-1 hover:border-primary/50 hover:shadow-lg-dark hover:bg-gradient-to-br hover:from-primary/8"
<h3 class="text-base font-semibold m-0">{{ group.title }}</h3> >
</header> <header class="mb-3">
<div class="flex flex-wrap gap-2"> <h3 class="text-base font-semibold m-0">{{ group.title }}</h3>
<span </header>
v-for="item in group.items" :key="item" <div class="flex flex-wrap gap-2">
class="inline-flex items-center p-1.5 rounded-2xl bg-primary/14 border border-primary/18 transition-all duration-200 hover:bg-primary/24 hover:border-primary/40 hover:scale-110"> <span
<NuxtImg v-for="item in group.items"
:src="iconSrc(item)" :alt="item" :title="item" loading="lazy" :key="item"
class="w-7 h-7 rounded-2xl" /> class="inline-flex items-center p-1.5 rounded-2xl bg-primary/14 border border-primary/18 transition-all duration-200 hover:bg-primary/24 hover:border-primary/40 hover:scale-110"
</span> >
</div> <NuxtImg
</article> :src="iconSrc(item)"
</div> :alt="item"
</section> :title="item"
</template> loading="lazy"
class="w-7 h-7 rounded-2xl"
<script setup> />
defineProps({ skills: { type: Array, default: () => [] } }); </span>
const iconSrc = (id) => `https://skillicons.dev/icons?i=${encodeURIComponent(id)}&theme=dark`; </div>
</script> </article>
</div>
</section>
</template>
<script setup>
defineProps({ skills: { type: Array, default: () => [] } });
const iconSrc = (id) => `https://skillicons.dev/icons?i=${encodeURIComponent(id)}&theme=dark`;
</script>

View File

@@ -1,77 +1,78 @@
<template> <template>
<section class="card flex flex-col gap-2.5"> <section class="card flex flex-col gap-2.5">
<h2 class="m-0 mb-1 text-lg font-semibold">社交链接</h2> <h2 class="m-0 mb-1 text-lg font-semibold">社交链接</h2>
<p class="text-text-muted text-sm m-0 mb-3 block">社交账号 · Links</p> <p class="text-text-muted text-sm m-0 mb-3 block">社交账号 · Links</p>
<div class="flex flex-wrap gap-2.5"> <div class="flex flex-wrap gap-2.5">
<template v-for="link in links" :key="link.url"> <template v-for="link in links" :key="link.url">
<NuxtLink <NuxtLink
:to="link.url" :to="link.url"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/10 backdrop-blur-sm border border-white/10 text-text-primary text-sm font-medium transition-all duration-200 hover:bg-primary/20 hover:border-primary/40 hover:text-primary hover:-translate-y-1" class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/10 backdrop-blur-sm border border-white/10 text-text-primary text-sm font-medium transition-all duration-200 hover:bg-primary/20 hover:border-primary/40 hover:text-primary hover:-translate-y-1"
> >
<span v-if="iconFor(link)" class="inline-flex items-center justify-center w-5 h-5"> <span v-if="iconFor(link)" class="inline-flex items-center justify-center w-5 h-5">
<i v-if="iconFor(link).fa" :class="iconFor(link).fa" /> <i v-if="iconFor(link).fa" :class="iconFor(link).fa" />
<NuxtImg <NuxtImg
v-else v-else
:src="iconFor(link).src" :src="iconFor(link).src"
:alt="link.name" :alt="link.name"
loading="lazy" loading="lazy"
class="w-full h-full" class="w-full h-full"
/> />
</span> </span>
<span>{{ link.name }}</span> <span>{{ link.name }}</span>
</NuxtLink> </NuxtLink>
</template> </template>
</div> </div>
</section> </section>
</template> </template>
<script setup> <script setup>
import { onMounted } from "vue"; import { onMounted } from "vue";
defineProps({ defineProps({
links: { links: {
type: Array, type: Array,
required: true, required: true,
}, },
}); });
const iconMap = { const iconMap = {
bilibili: "fa-brands fa-bilibili", bilibili: "fa-brands fa-bilibili",
github: "fa-brands fa-github", github: "fa-brands fa-github",
blog: "fa-solid fa-rss", blog: "fa-solid fa-rss",
email: "fa-solid fa-envelope", email: "fa-solid fa-envelope",
mail: "fa-solid fa-envelope", mail: "fa-solid fa-envelope",
telegram: "fa-brands fa-telegram", telegram: "fa-brands fa-telegram",
twitter: "fa-brands fa-x-twitter", twitter: "fa-brands fa-x-twitter",
x: "fa-brands fa-x-twitter", x: "fa-brands fa-x-twitter",
linkedin: "fa-brands fa-linkedin", linkedin: "fa-brands fa-linkedin",
youtube: "fa-brands fa-youtube", youtube: "fa-brands fa-youtube",
facebook: "fa-brands fa-facebook", facebook: "fa-brands fa-facebook",
instagram: "fa-brands fa-instagram", instagram: "fa-brands fa-instagram",
reddit: "fa-brands fa-reddit", reddit: "fa-brands fa-reddit",
discord: "fa-brands fa-discord", discord: "fa-brands fa-discord",
weibo: "fa-brands fa-weibo", weibo: "fa-brands fa-weibo",
zhihu: "fa-brands fa-zhihu", zhihu: "fa-brands fa-zhihu",
wechat: "fa-brands fa-weixin", wechat: "fa-brands fa-weixin",
weixin: "fa-brands fa-weixin", weixin: "fa-brands fa-weixin",
qq: "fa-brands fa-qq", qq: "fa-brands fa-qq",
}; };
const iconFor = (link) => { const iconFor = (link) => {
const key = (link.name || "").toLowerCase(); const key = (link.name || "").toLowerCase();
if (iconMap[key]) return { fa: iconMap[key] }; if (iconMap[key]) return { fa: iconMap[key] };
if (link.icon) return { src: link.icon }; if (link.icon) return { src: link.icon };
return null; return null;
}; };
onMounted(() => { onMounted(() => {
const id = "fa-cdn"; const id = "fa-cdn";
if (document.getElementById(id)) return; if (document.getElementById(id)) return;
const link = document.createElement("link"); const link = document.createElement("link");
link.id = id; link.id = id;
link.rel = "stylesheet"; link.rel = "stylesheet";
link.href = "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css?font-display=swap"; link.href =
link.crossOrigin = "anonymous"; "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css?font-display=swap";
link.referrerPolicy = "no-referrer"; link.crossOrigin = "anonymous";
document.head.appendChild(link); link.referrerPolicy = "no-referrer";
}); document.head.appendChild(link);
</script> });
</script>

View File

@@ -1,410 +1,434 @@
<template> <template>
<section class="card"> <section class="card">
<div class="header"> <div class="header">
<h2 class="m-0 mb-1 font-semibold">开发统计</h2> <h2 class="m-0 mb-1 font-semibold">开发统计</h2>
<div class="tabs"> <div class="tabs">
<button class="tab-button" :class="{ active: activeTab === 'github' }" @click="activeTab = 'github'"> <button
GitHub class="tab-button"
</button> :class="{ active: activeTab === 'github' }"
<button @click="activeTab = 'github'"
class="tab-button" >
:class="{ active: activeTab === 'wakatime' }" GitHub
@click="activeTab = 'wakatime'" </button>
> <button
Wakatime class="tab-button"
</button> :class="{ active: activeTab === 'wakatime' }"
</div> @click="activeTab = 'wakatime'"
</div> >
Wakatime
<!-- GitHub 内容 --> </button>
<div v-if="activeTab === 'github'"> </div>
<div class="heatmap"> </div>
<h3>提交热力图</h3>
<p class="muted">我的提交热力图 · Activity Heatmap</p> <!-- GitHub 内容 -->
<NuxtImg :src="github.heatmapUrl" alt="GitHub Heatmap" loading="lazy" /> <div v-if="activeTab === 'github'">
</div> <div class="heatmap">
<div class="lang-wrap"> <h3>提交热力图</h3>
<h3>常用语言</h3> <p class="muted">我的提交热力图 · Activity Heatmap</p>
<p class="muted">我常用的语言 · Languages</p> <NuxtImg :src="github.heatmapUrl" alt="GitHub Heatmap" loading="lazy" />
<div class="lang-chart"> </div>
<ul class="list lang-list"> <div class="lang-wrap">
<li v-for="lang in githubLanguages" :key="lang.name" class="lang-row"> <h3>常用语言</h3>
<div class="lang-label"> <p class="muted">我常用的语言 · Languages</p>
<span class="dot" :style="{ background: colorFor(lang.name, 'github') }" /> <div class="lang-chart">
<span class="lang-name">{{ lang.name }}</span> <ul class="list lang-list">
<span class="lang-percent">{{ lang.percent }}%</span> <li v-for="lang in githubLanguages" :key="lang.name" class="lang-row">
</div> <div class="lang-label">
<div class="lang-bar"> <span class="dot" :style="{ background: colorFor(lang.name, 'github') }" />
<span class="lang-bar-fill" :style="barStyle(lang, 'github')" /> <span class="lang-name">{{ lang.name }}</span>
</div> <span class="lang-percent">{{ lang.percent }}%</span>
</li> </div>
</ul> <div class="lang-bar">
</div> <span class="lang-bar-fill" :style="barStyle(lang, 'github')" />
</div> </div>
</div> </li>
</ul>
<!-- Wakatime 内容 --> </div>
<div v-if="activeTab === 'wakatime'"> </div>
<div class="stats-wrap"> </div>
<h3>编码统计</h3>
<p class="muted"> <!-- Wakatime 内容 -->
{{ wakatimeActiveTab === "weekly" ? "最近7天 · Last 7 Days" : "所有时间 · All Time" }} <div v-if="activeTab === 'wakatime'">
</p> <div class="stats-wrap">
<div class="stats-grid"> <h3>编码统计</h3>
<div class="stat-item"> <p class="muted">
<span class="stat-value">{{ {{ wakatimeActiveTab === "weekly" ? "最近7天 · Last 7 Days" : "所有时间 · All Time" }}
currentWakatimeData?.total_seconds ? formatTime(currentWakatimeData.total_seconds) : "N/A" </p>
}}</span> <div class="stats-grid">
<span class="stat-label">总时间</span> <div class="stat-item">
</div> <span class="stat-value">{{
<div class="stat-item"> currentWakatimeData?.total_seconds
<span class="stat-value">{{ ? formatTime(currentWakatimeData.total_seconds)
currentWakatimeData?.daily_average ? formatTime(currentWakatimeData.daily_average) : "N/A" : "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">{{
<span class="stat-label">活跃天数</span> currentWakatimeData?.daily_average
</div> ? formatTime(currentWakatimeData.daily_average)
</div> : "N/A"
</div> }}</span>
<span class="stat-label">日均</span>
<div v-if="currentWakatimeData?.languages && currentWakatimeData.languages.length" class="lang-wrap"> </div>
<h3>编程语言</h3> <div class="stat-item">
<p class="muted">语言使用统计 · Languages</p> <span class="stat-value">{{
<div class="lang-chart"> currentWakatimeData?.days_including_holidays ?? "N/A"
<ul class="list lang-list"> }}</span>
<li v-for="lang in wakatimeLanguages" :key="lang.name" class="lang-row"> <span class="stat-label">活跃天数</span>
<div class="lang-label"> </div>
<span class="dot" :style="{ background: colorFor(lang.name, 'wakatime') }" /> </div>
<span class="lang-name">{{ lang.name }}</span> </div>
<span class="lang-percent">{{ lang.percent }}%</span>
</div> <div
<div class="lang-bar"> v-if="currentWakatimeData?.languages && currentWakatimeData.languages.length"
<span class="lang-bar-fill" :style="barStyle(lang, 'wakatime')" /> class="lang-wrap"
</div> >
</li> <h3>编程语言</h3>
</ul> <p class="muted">语言使用统计 · Languages</p>
</div> <div class="lang-chart">
</div> <ul class="list lang-list">
<li v-for="lang in wakatimeLanguages" :key="lang.name" class="lang-row">
<div v-if="allTimeData" class="wakatime-tabs"> <div class="lang-label">
<div class="wakatime-mini-tabs"> <span class="dot" :style="{ background: colorFor(lang.name, 'wakatime') }" />
<button <span class="lang-name">{{ lang.name }}</span>
class="wakatime-tab-button" <span class="lang-percent">{{ lang.percent }}%</span>
:class="{ active: wakatimeActiveTab === 'weekly' }" </div>
@click="wakatimeActiveTab = 'weekly'" <div class="lang-bar">
> <span class="lang-bar-fill" :style="barStyle(lang, 'wakatime')" />
最近7天 </div>
</button> </li>
<button </ul>
class="wakatime-tab-button" </div>
:class="{ active: wakatimeActiveTab === 'allTime' }" </div>
@click="wakatimeActiveTab = 'allTime'"
> <div v-if="allTimeData" class="wakatime-tabs">
所有时间 <div class="wakatime-mini-tabs">
</button> <button
</div> class="wakatime-tab-button"
</div> :class="{ active: wakatimeActiveTab === 'weekly' }"
@click="wakatimeActiveTab = 'weekly'"
<div v-if="statusData" class="status-wrap"> >
<h3>当前状态</h3> 最近7天
<p class="muted">实时状态 · Current Status</p> </button>
<div class="status-item"> <button
<span class="status-indicator" :class="{ active: statusData.is_coding }" /> class="wakatime-tab-button"
<span class="status-text">{{ statusData.is_coding ? "正在编码" : "未在编码" }}</span> :class="{ active: wakatimeActiveTab === 'allTime' }"
<span v-if="statusData.project" class="status-project">{{ statusData.project }}</span> @click="wakatimeActiveTab = 'allTime'"
</div> >
</div> 所有时间
</div> </button>
</section> </div>
</template> </div>
<script setup> <div v-if="statusData" class="status-wrap">
import { ref, onMounted, computed } from "vue"; <h3>当前状态</h3>
<p class="muted">实时状态 · Current Status</p>
const props = defineProps({ <div class="status-item">
github: { <span class="status-indicator" :class="{ active: statusData.is_coding }" />
type: Object, <span class="status-text">{{ statusData.is_coding ? "正在编码" : "未在编码" }}</span>
default: () => ({}), <span v-if="statusData.project" class="status-project">{{ statusData.project }}</span>
}, </div>
wakatime: { </div>
type: Object, </div>
default: () => ({}), </section>
}, </template>
});
const github = props.github; <script setup>
const wakatime = props.wakatime; import { ref, onMounted, computed } from "vue";
const activeTab = ref("github"); const props = defineProps({
const wakatimeActiveTab = ref("weekly"); github: {
type: Object,
const weeklyData = ref(null); default: () => ({}),
const allTimeData = ref(null); },
const statusData = ref(null); wakatime: {
const showComponent = ref(true); type: Object,
default: () => ({}),
const githubPalette = ["#7cc1ff", "#6bdba6", "#ffd166", "#f497da", "#9b8cfc", "#5ce1e6", "#ffa3a3"]; },
const wakatimePalette = ["#7cc1ff", "#6bdba6", "#ffd166", "#f497da", "#9b8cfc", "#5ce1e6", "#ffa3a3"]; });
const github = props.github;
const githubLanguages = computed(() => (Array.isArray(github.languages) ? github.languages.slice(0, 5) : [])); const wakatime = props.wakatime;
const currentWakatimeData = computed(() => { const activeTab = ref("github");
return wakatimeActiveTab.value === "weekly" ? weeklyData.value : allTimeData.value; const wakatimeActiveTab = ref("weekly");
});
const weeklyData = ref(null);
const wakatimeLanguages = computed(() => { const allTimeData = ref(null);
if (!currentWakatimeData.value || !currentWakatimeData.value.languages) return []; const statusData = ref(null);
return currentWakatimeData.value.languages.slice(0, 5); const showComponent = ref(true);
});
const githubPalette = ["#7cc1ff", "#6bdba6", "#ffd166", "#f497da", "#9b8cfc", "#5ce1e6", "#ffa3a3"];
const colorFor = (name, type) => { const wakatimePalette = [
const palette = type === "github" ? githubPalette : wakatimePalette; "#7cc1ff",
const languages = type === "github" ? github.languages : currentWakatimeData.value?.languages || []; "#6bdba6",
const idx = languages.findIndex((l) => l.name === name); "#ffd166",
return palette[(idx >= 0 ? idx : 0) % palette.length]; "#f497da",
}; "#9b8cfc",
"#5ce1e6",
const barStyle = (lang, type) => ({ "#ffa3a3",
width: `${Math.max(8, lang.percent)}%`, ];
background: colorFor(lang.name, type),
}); const githubLanguages = computed(() =>
Array.isArray(github.languages) ? github.languages.slice(0, 5) : []
const formatTime = (seconds) => { );
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const currentWakatimeData = computed(() => {
return `${hours}h ${minutes}m`; return wakatimeActiveTab.value === "weekly" ? weeklyData.value : allTimeData.value;
}; });
const fetchWakatimeData = async () => { const wakatimeLanguages = computed(() => {
if (!wakatime.enable) return; if (!currentWakatimeData.value || !currentWakatimeData.value.languages) return [];
return currentWakatimeData.value.languages.slice(0, 5);
try { });
const params = new URLSearchParams();
if (wakatime.apiUrl && wakatime.apiUrl !== "https://wakatime.com/api/v1") { const colorFor = (name, type) => {
params.append("apiUrl", wakatime.apiUrl); const palette = type === "github" ? githubPalette : wakatimePalette;
} const languages =
const url = `/api/wakatime${params.toString() ? `?${params.toString()}` : ""}`; type === "github" ? github.languages : currentWakatimeData.value?.languages || [];
const response = await fetch(url); const idx = languages.findIndex((l) => l.name === name);
return palette[(idx >= 0 ? idx : 0) % palette.length];
if (response.ok) { };
const data = await response.json();
weeklyData.value = data.weekly; const barStyle = (lang, type) => ({
allTimeData.value = data.allTime; width: `${Math.max(8, lang.percent)}%`,
statusData.value = data.status; background: colorFor(lang.name, type),
} else { });
const errorText = await response.text();
console.error("API Error:", response.status, errorText); const formatTime = (seconds) => {
if (response.status === 500 && errorText.includes("Wakatime API Key not configured")) { const hours = Math.floor(seconds / 3600);
console.warn("Wakatime API Key not configured - hiding component"); const minutes = Math.floor((seconds % 3600) / 60);
showComponent.value = false; return `${hours}h ${minutes}m`;
return; };
}
throw new Error(`API returned ${response.status}: ${errorText}`); const fetchWakatimeData = async () => {
} if (!wakatime.enable) return;
} catch (error) {
console.error("Failed to fetch Wakatime data:", error); try {
} const params = new URLSearchParams();
}; if (wakatime.apiUrl && wakatime.apiUrl !== "https://wakatime.com/api/v1") {
params.append("apiUrl", wakatime.apiUrl);
onMounted(() => { }
fetchWakatimeData(); const url = `/api/wakatime${params.toString() ? `?${params.toString()}` : ""}`;
}); const response = await fetch(url);
</script>
if (response.ok) {
<style scoped> const data = await response.json();
.header { weeklyData.value = data.weekly;
display: flex; allTimeData.value = data.allTime;
justify-content: space-between; statusData.value = data.status;
align-items: center; } else {
margin-bottom: 1.5rem; const errorText = await response.text();
} console.error("API Error:", response.status, errorText);
if (response.status === 500 && errorText.includes("Wakatime API Key not configured")) {
.tabs { console.warn("Wakatime API Key not configured - hiding component");
display: flex; showComponent.value = false;
gap: 0.5rem; return;
} }
throw new Error(`API returned ${response.status}: ${errorText}`);
.tab-button { }
padding: 0.5rem 1rem; } catch (error) {
border: 1px solid rgba(255, 255, 255, 0.08); console.error("Failed to fetch Wakatime data:", error);
background: rgba(255, 255, 255, 0.04); }
color: #e8eefc; };
border-radius: 6px;
cursor: pointer; onMounted(() => {
transition: all 0.2s ease; fetchWakatimeData();
font-size: 0.875rem; });
} </script>
.tab-button:hover { <style scoped>
background: rgba(255, 255, 255, 0.08); .header {
} display: flex;
justify-content: space-between;
.tab-button.active { align-items: center;
background: #7cc1ff; margin-bottom: 1.5rem;
color: white; }
border-color: #7cc1ff;
} .tabs {
display: flex;
.stats-grid { gap: 0.5rem;
display: grid; }
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem; .tab-button {
margin-top: 1rem; padding: 0.5rem 1rem;
} border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
.stat-item { color: #e8eefc;
text-align: center; border-radius: 6px;
padding: 1rem; cursor: pointer;
background: rgba(255, 255, 255, 0.04); transition: all 0.2s ease;
border-radius: 8px; font-size: 0.875rem;
border: 1px solid rgba(255, 255, 255, 0.08); }
}
.tab-button:hover {
.stat-value { background: rgba(255, 255, 255, 0.08);
display: block; }
font-size: 1.5rem;
font-weight: bold; .tab-button.active {
color: #e8eefc; background: #7cc1ff;
margin-bottom: 0.5rem; color: white;
} border-color: #7cc1ff;
}
.stat-label {
font-size: 0.875rem; .stats-grid {
color: #a8b3cf; display: grid;
} grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
.wakatime-tabs { margin-top: 1rem;
margin-top: 1rem; }
}
.stat-item {
.wakatime-mini-tabs { text-align: center;
display: flex; padding: 1rem;
gap: 0.25rem; background: rgba(255, 255, 255, 0.04);
justify-content: center; border-radius: 8px;
} border: 1px solid rgba(255, 255, 255, 0.08);
}
.wakatime-tab-button {
padding: 0.25rem 0.75rem; .stat-value {
border: 1px solid rgba(255, 255, 255, 0.08); display: block;
background: rgba(255, 255, 255, 0.04); font-size: 1.5rem;
color: #e8eefc; font-weight: bold;
border-radius: 4px; color: #e8eefc;
cursor: pointer; margin-bottom: 0.5rem;
transition: all 0.2s ease; }
font-size: 0.75rem;
} .stat-label {
font-size: 0.875rem;
.wakatime-tab-button:hover { color: #a8b3cf;
background: rgba(255, 255, 255, 0.08); }
}
.wakatime-tabs {
.wakatime-tab-button.active { margin-top: 1rem;
background: #6bdba6; }
color: white;
border-color: #6bdba6; .wakatime-mini-tabs {
} display: flex;
gap: 0.25rem;
.status-item { justify-content: center;
display: flex; }
align-items: center;
gap: 0.5rem; .wakatime-tab-button {
padding: 1rem; padding: 0.25rem 0.75rem;
background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px; background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08); color: #e8eefc;
margin-top: 1rem; border-radius: 4px;
} cursor: pointer;
transition: all 0.2s ease;
.status-indicator { font-size: 0.75rem;
width: 12px; }
height: 12px;
border-radius: 50%; .wakatime-tab-button:hover {
background: #a8b3cf; background: rgba(255, 255, 255, 0.08);
} }
.status-indicator.active { .wakatime-tab-button.active {
background: #6bdba6; background: #6bdba6;
} color: white;
border-color: #6bdba6;
.status-text { }
font-weight: 500;
} .status-item {
display: flex;
.status-project { align-items: center;
color: #a8b3cf; gap: 0.5rem;
font-size: 0.875rem; padding: 1rem;
} background: rgba(255, 255, 255, 0.04);
border-radius: 8px;
.lang-wrap { border: 1px solid rgba(255, 255, 255, 0.08);
margin-top: 12px; margin-top: 1rem;
} }
.lang-chart { .status-indicator {
display: block; width: 12px;
} height: 12px;
border-radius: 50%;
.lang-list { background: #a8b3cf;
display: flex; }
flex-direction: column;
gap: 10px; .status-indicator.active {
} background: #6bdba6;
}
.lang-row {
background: rgba(255, 255, 255, 0.04); .status-text {
border: 1px solid rgba(255, 255, 255, 0.08); font-weight: 500;
border-radius: 10px; }
padding: 8px 10px;
} .status-project {
color: #a8b3cf;
.lang-label { font-size: 0.875rem;
display: flex; }
align-items: center;
gap: 8px; .lang-wrap {
font-weight: 600; margin-top: 12px;
} }
.lang-name { .lang-chart {
color: #e8eefc; display: block;
} }
.lang-percent { .lang-list {
color: #a8b3cf; display: flex;
font-size: 0.9rem; flex-direction: column;
} gap: 10px;
}
.lang-bar {
margin-top: 6px; .lang-row {
height: 8px; background: rgba(255, 255, 255, 0.04);
border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05); border-radius: 10px;
overflow: hidden; padding: 8px 10px;
} }
.lang-bar-fill { .lang-label {
display: block; display: flex;
height: 100%; align-items: center;
border-radius: 999px; gap: 8px;
transition: width 0.3s ease; font-weight: 600;
} }
.dot { .lang-name {
display: inline-block; color: #e8eefc;
width: 10px; }
height: 10px;
border-radius: 50%; .lang-percent {
margin-right: 6px; color: #a8b3cf;
} font-size: 0.9rem;
}
.heatmap {
margin-top: 12px; .lang-bar {
} margin-top: 6px;
height: 8px;
.heatmap img { border-radius: 999px;
width: 100%; background: rgba(255, 255, 255, 0.05);
display: block; overflow: hidden;
border-radius: 12px; }
border: 1px solid rgba(255, 255, 255, 0.08);
} .lang-bar-fill {
</style> display: block;
height: 100%;
border-radius: 999px;
transition: width 0.3s ease;
}
.dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
}
.heatmap {
margin-top: 12px;
}
.heatmap img {
width: 100%;
display: block;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
}
</style>

View File

@@ -1,339 +1,345 @@
<template> <template>
<section v-if="showComponent && (weeklyData || allTimeData)" class="card"> <section v-if="showComponent && (weeklyData || allTimeData)" class="card">
<div class="header"> <div class="header">
<h2>Wakatime</h2> <h2>Wakatime</h2>
<div class="tabs"> <div class="tabs">
<button class="tab-button" :class="{ active: activeTab === 'weekly' }" @click="activeTab = 'weekly'"> <button
最近7天 class="tab-button"
</button> :class="{ active: activeTab === 'weekly' }"
<button @click="activeTab = 'weekly'"
v-if="allTimeData" >
class="tab-button" 最近7天
:class="{ active: activeTab === 'allTime' }" </button>
@click="activeTab = 'allTime'" <button
> v-if="allTimeData"
所有时间 class="tab-button"
</button> :class="{ active: activeTab === 'allTime' }"
</div> @click="activeTab = 'allTime'"
</div> >
所有时间
<div class="stats-wrap"> </button>
<h3>编码统计</h3> </div>
<p class="muted">{{ activeTab === "weekly" ? "最近7天 · Last 7 Days" : "所有时间 · All Time" }}</p> </div>
<div class="stats-grid">
<div class="stat-item"> <div class="stats-wrap">
<span class="stat-value">{{ <h3>编码统计</h3>
currentData.total_seconds ? formatTime(currentData.total_seconds) : "N/A" <p class="muted">
}}</span> {{ activeTab === "weekly" ? "最近7天 · Last 7 Days" : "所有时间 · All Time" }}
<span class="stat-label">总时间</span> </p>
</div> <div class="stats-grid">
<div class="stat-item"> <div class="stat-item">
<span class="stat-value">{{ <span class="stat-value">{{
currentData.daily_average ? formatTime(currentData.daily_average) : "N/A" currentData.total_seconds ? formatTime(currentData.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">{{ currentData.days_including_holidays || "N/A" }}</span> <span class="stat-value">{{
<span class="stat-label">活跃天数</span> currentData.daily_average ? formatTime(currentData.daily_average) : "N/A"
</div> }}</span>
</div> <span class="stat-label">日均</span>
</div> </div>
<div class="stat-item">
<div v-if="currentData.languages && currentData.languages.length" class="lang-wrap"> <span class="stat-value">{{ currentData.days_including_holidays || "N/A" }}</span>
<h3>编程语言</h3> <span class="stat-label">活跃天数</span>
<p class="muted">语言使用统计 · Languages</p> </div>
<div class="lang-chart"> </div>
<ul class="list lang-list"> </div>
<li v-for="lang in topLanguages" :key="lang.name" class="lang-row">
<div class="lang-label"> <div v-if="currentData.languages && currentData.languages.length" class="lang-wrap">
<span class="dot" :style="{ background: colorFor(lang.name) }" /> <h3>编程语言</h3>
<span class="lang-name">{{ lang.name }}</span> <p class="muted">语言使用统计 · Languages</p>
<span class="lang-percent">{{ lang.percent }}%</span> <div class="lang-chart">
</div> <ul class="list lang-list">
<div class="lang-bar"> <li v-for="lang in topLanguages" :key="lang.name" class="lang-row">
<span class="lang-bar-fill" :style="barStyle(lang)" /> <div class="lang-label">
</div> <span class="dot" :style="{ background: colorFor(lang.name) }" />
</li> <span class="lang-name">{{ lang.name }}</span>
</ul> <span class="lang-percent">{{ lang.percent }}%</span>
</div> </div>
</div> <div class="lang-bar">
<span class="lang-bar-fill" :style="barStyle(lang)" />
<div v-if="statusData" class="status-wrap"> </div>
<h3>当前状态</h3> </li>
<p class="muted">实时状态 · Current Status</p> </ul>
<div class="status-item"> </div>
<span class="status-indicator" :class="{ active: statusData.is_coding }" /> </div>
<span class="status-text">{{ statusData.is_coding ? "正在编码" : "未在编码" }}</span>
<span v-if="statusData.project" class="status-project">{{ statusData.project }}</span> <div v-if="statusData" class="status-wrap">
</div> <h3>当前状态</h3>
</div> <p class="muted">实时状态 · Current Status</p>
</section> <div class="status-item">
</template> <span class="status-indicator" :class="{ active: statusData.is_coding }" />
<span class="status-text">{{ statusData.is_coding ? "正在编码" : "未在编码" }}</span>
<script setup> <span v-if="statusData.project" class="status-project">{{ statusData.project }}</span>
import { ref, onMounted, computed } from "vue"; </div>
</div>
const props = defineProps({ </section>
wakatime: { </template>
type: Object,
required: false, <script setup>
default: () => ({ languages: [] }), import { ref, onMounted, computed } from "vue";
},
}); const props = defineProps({
const wakatime = props.wakatime; wakatime: {
type: Object,
const weeklyData = ref(null); required: false,
const allTimeData = ref(null); default: () => ({ languages: [] }),
const statusData = ref(null); },
const showComponent = ref(true); });
const activeTab = ref("weekly"); const wakatime = props.wakatime;
const palette = ["#7cc1ff", "#6bdba6", "#ffd166", "#201a1fff", "#9b8cfc", "#5ce1e6", "#ffa3a3"]; const weeklyData = ref(null);
const allTimeData = ref(null);
const currentData = computed(() => { const statusData = ref(null);
return activeTab.value === "weekly" ? weeklyData.value : allTimeData.value; const showComponent = ref(true);
}); const activeTab = ref("weekly");
const topLanguages = computed(() => { const palette = ["#7cc1ff", "#6bdba6", "#ffd166", "#201a1fff", "#9b8cfc", "#5ce1e6", "#ffa3a3"];
if (!currentData.value || !currentData.value.languages) return [];
return currentData.value.languages.slice(0, 5); const currentData = computed(() => {
}); return activeTab.value === "weekly" ? weeklyData.value : allTimeData.value;
});
const colorFor = (name) => {
const idx = currentData.value.languages.findIndex((l) => l.name === name); const topLanguages = computed(() => {
return palette[(idx >= 0 ? idx : 0) % palette.length]; if (!currentData.value || !currentData.value.languages) return [];
}; return currentData.value.languages.slice(0, 5);
});
const barStyle = (lang) => ({
width: `${Math.max(8, lang.percent)}%`, const colorFor = (name) => {
background: colorFor(lang.name), const idx = currentData.value.languages.findIndex((l) => l.name === name);
}); return palette[(idx >= 0 ? idx : 0) % palette.length];
};
const formatTime = (seconds) => {
const hours = Math.floor(seconds / 3600); const barStyle = (lang) => ({
const minutes = Math.floor((seconds % 3600) / 60); width: `${Math.max(8, lang.percent)}%`,
return `${hours}h ${minutes}m`; background: colorFor(lang.name),
}; });
const fetchWakatimeData = async () => { const formatTime = (seconds) => {
if (!wakatime.enable) return; const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
try { return `${hours}h ${minutes}m`;
const params = new URLSearchParams(); };
if (wakatime.apiUrl && wakatime.apiUrl !== "https://wakatime.com/api/v1") {
params.append("apiUrl", wakatime.apiUrl); const fetchWakatimeData = async () => {
} if (!wakatime.enable) return;
const url = `/api/wakatime${params.toString() ? `?${params.toString()}` : ""}`;
console.log("Fetching Wakatime data from:", url); try {
const response = await fetch(url); const params = new URLSearchParams();
console.log("Response status:", response.status); if (wakatime.apiUrl && wakatime.apiUrl !== "https://wakatime.com/api/v1") {
console.log("Response headers:", Object.fromEntries(response.headers.entries())); params.append("apiUrl", wakatime.apiUrl);
}
if (response.ok) { const url = `/api/wakatime${params.toString() ? `?${params.toString()}` : ""}`;
const data = await response.json(); console.log("Fetching Wakatime data from:", url);
console.log("Wakatime data:", data); const response = await fetch(url);
weeklyData.value = data.weekly; console.log("Response status:", response.status);
allTimeData.value = data.allTime; console.log("Response headers:", Object.fromEntries(response.headers.entries()));
statusData.value = data.status;
} else { if (response.ok) {
const errorText = await response.text(); const data = await response.json();
console.error("API Error:", response.status, errorText); console.log("Wakatime data:", data);
if (response.status === 500 && errorText.includes("Wakatime API Key not configured")) { weeklyData.value = data.weekly;
console.warn("Wakatime API Key not configured - hiding component"); allTimeData.value = data.allTime;
showComponent.value = false; statusData.value = data.status;
return; } else {
} const errorText = await response.text();
throw new Error(`API returned ${response.status}: ${errorText}`); console.error("API Error:", response.status, errorText);
} if (response.status === 500 && errorText.includes("Wakatime API Key not configured")) {
} catch (error) { console.warn("Wakatime API Key not configured - hiding component");
console.error("Failed to fetch Wakatime data:", error); showComponent.value = false;
// 在开发环境中,如果 API 不可用,设置一些示例数据 return;
if (import.meta.env.DEV) { }
console.log("Using mock data for development"); throw new Error(`API returned ${response.status}: ${errorText}`);
weeklyData.value = { }
total_seconds: 36000, } catch (error) {
daily_average: 5142, console.error("Failed to fetch Wakatime data:", error);
days_including_holidays: 7, // 在开发环境中,如果 API 不可用,设置一些示例数据
languages: [ if (import.meta.env.DEV) {
{ name: "TypeScript", percent: 45.2, total_seconds: 16272 }, console.log("Using mock data for development");
{ name: "Vue", percent: 30.1, total_seconds: 10836 }, weeklyData.value = {
{ name: "JavaScript", percent: 15.3, total_seconds: 5508 }, total_seconds: 36000,
{ name: "Python", percent: 9.4, total_seconds: 3384 }, daily_average: 5142,
], days_including_holidays: 7,
}; languages: [
allTimeData.value = { { name: "TypeScript", percent: 45.2, total_seconds: 16272 },
total_seconds: 864000, { name: "Vue", percent: 30.1, total_seconds: 10836 },
daily_average: 2800, { name: "JavaScript", percent: 15.3, total_seconds: 5508 },
days_including_holidays: 308, { name: "Python", percent: 9.4, total_seconds: 3384 },
languages: [ ],
{ name: "JavaScript", percent: 35.2, total_seconds: 304128 }, };
{ name: "TypeScript", percent: 28.1, total_seconds: 242688 }, allTimeData.value = {
{ name: "Python", percent: 20.3, total_seconds: 175392 }, total_seconds: 864000,
{ name: "Vue", percent: 10.1, total_seconds: 87296 }, daily_average: 2800,
{ name: "CSS", percent: 6.3, total_seconds: 54432 }, days_including_holidays: 308,
], languages: [
}; { name: "JavaScript", percent: 35.2, total_seconds: 304128 },
statusData.value = { is_coding: false }; { 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 },
],
onMounted(() => { };
fetchWakatimeData(); statusData.value = { is_coding: false };
}); }
</script> }
};
<style scoped>
.header { onMounted(() => {
display: flex; fetchWakatimeData();
justify-content: space-between; });
align-items: center; </script>
margin-bottom: 1.5rem;
} <style scoped>
.header {
.tabs { display: flex;
display: flex; justify-content: space-between;
gap: 0.5rem; align-items: center;
} margin-bottom: 1.5rem;
}
.tab-button {
padding: 0.5rem 1rem; .tabs {
border: 1px solid var(--border); display: flex;
background: var(--card-bg); gap: 0.5rem;
color: var(--text); }
border-radius: 6px;
cursor: pointer; .tab-button {
transition: all 0.2s ease; padding: 0.5rem 1rem;
font-size: 0.875rem; border: 1px solid var(--border);
} background: var(--card-bg);
color: var(--text);
.tab-button:hover { border-radius: 6px;
background: var(--hover-bg); cursor: pointer;
} transition: all 0.2s ease;
font-size: 0.875rem;
.tab-button.active { }
background: var(--accent);
color: white; .tab-button:hover {
border-color: var(--accent); background: var(--hover-bg);
} }
.stats-grid { .tab-button.active {
display: grid; background: var(--accent);
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); color: white;
gap: 1rem; border-color: var(--accent);
margin-top: 1rem; }
}
.stats-grid {
.stat-item { display: grid;
text-align: center; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
padding: 1rem; gap: 1rem;
background: var(--card-bg); margin-top: 1rem;
border-radius: 8px; }
border: 1px solid var(--border);
} .stat-item {
text-align: center;
.stat-value { padding: 1rem;
display: block; background: var(--card-bg);
font-size: 1.5rem; border-radius: 8px;
font-weight: bold; border: 1px solid var(--border);
color: var(--text); }
margin-bottom: 0.5rem;
} .stat-value {
display: block;
.stat-label { font-size: 1.5rem;
font-size: 0.875rem; font-weight: bold;
color: var(--muted); color: var(--text);
} margin-bottom: 0.5rem;
}
.status-item {
display: flex; .stat-label {
align-items: center; font-size: 0.875rem;
gap: 0.5rem; color: var(--muted);
padding: 1rem; }
background: var(--card-bg);
border-radius: 8px; .status-item {
border: 1px solid var(--border); display: flex;
margin-top: 1rem; align-items: center;
} gap: 0.5rem;
padding: 1rem;
.status-indicator { background: var(--card-bg);
width: 12px; border-radius: 8px;
height: 12px; border: 1px solid var(--border);
border-radius: 50%; margin-top: 1rem;
background: var(--muted); }
}
.status-indicator {
.status-indicator.active { width: 12px;
background: #6bdba6; height: 12px;
} border-radius: 50%;
background: var(--muted);
.status-text { }
font-weight: 500;
} .status-indicator.active {
background: #6bdba6;
.status-project { }
color: var(--muted);
font-size: 0.875rem; .status-text {
} font-weight: 500;
}
.lang-wrap {
margin-top: 12px; .status-project {
} color: var(--muted);
font-size: 0.875rem;
.lang-chart { }
display: block;
} .lang-wrap {
margin-top: 12px;
.lang-list { }
display: flex;
flex-direction: column; .lang-chart {
gap: 10px; display: block;
} }
.lang-row { .lang-list {
background: rgba(255, 255, 255, 0.04); display: flex;
border: 1px solid rgba(255, 255, 255, 0.08); flex-direction: column;
border-radius: 10px; gap: 10px;
padding: 8px 10px; }
}
.lang-row {
.lang-label { background: rgba(255, 255, 255, 0.04);
display: flex; border: 1px solid rgba(255, 255, 255, 0.08);
align-items: center; border-radius: 10px;
gap: 8px; padding: 8px 10px;
font-weight: 600; }
}
.lang-label {
.lang-name { display: flex;
color: #e8eefc; align-items: center;
} gap: 8px;
font-weight: 600;
.lang-percent { }
color: #a8b3cf;
font-size: 0.9rem; .lang-name {
} color: #e8eefc;
}
.lang-bar {
margin-top: 6px; .lang-percent {
height: 8px; color: #a8b3cf;
border-radius: 999px; font-size: 0.9rem;
background: rgba(255, 255, 255, 0.05); }
overflow: hidden;
} .lang-bar {
margin-top: 6px;
.lang-bar-fill { height: 8px;
display: block; border-radius: 999px;
height: 100%; background: rgba(255, 255, 255, 0.05);
border-radius: 999px; overflow: hidden;
transition: width 0.3s ease; }
}
.lang-bar-fill {
.dot { display: block;
display: inline-block; height: 100%;
width: 10px; border-radius: 999px;
height: 10px; transition: width 0.3s ease;
border-radius: 50%; }
margin-right: 6px;
} .dot {
</style> display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 6px;
}
</style>

View File

@@ -1,168 +1,179 @@
const siteConfig = { 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: "趁世界还未重启之前 约一次爱恋",
email: "i@rhen.cloud", email: "i@rhen.cloud",
birthday: "2010-03-28", birthday: "2010-03-28",
// gender: "女", // gender: "女",
pronouns: "她", pronouns: "她",
location: "中国 · 天津", location: "中国 · 天津",
}, },
socialLinks: [ socialLinks: [
{ name: "GitHub", url: "https://github.com/RhenCloud" }, { name: "GitHub", url: "https://github.com/RhenCloud" },
{ name: "Email", url: "mailto:i@rhen.cloud" }, { name: "Email", url: "mailto:i@rhen.cloud" },
{ name: "Telegram", url: "https://t.me/RhenCloud" }, { name: "Telegram", url: "https://t.me/RhenCloud" },
{ name: "Bilibili", url: "https://space.bilibili.com/1502883335" }, { name: "Bilibili", url: "https://space.bilibili.com/1502883335" },
{ name: "Blog", url: "https://blog.rhen.cloud" }, { name: "Blog", url: "https://blog.rhen.cloud" },
], ],
github: { github: {
username: "RhenCloud", username: "RhenCloud",
}, },
about: [ about: [
{ title: "Pro-LGBT", desc: "我相信性别多样性是人们应有的自由和权利。", icon: "🧠" }, { title: "Pro-LGBT", desc: "我相信性别多样性是人们应有的自由和权利。", icon: "🧠" },
{ title: "Developer", desc: "专注后端 / 云原生,热爱自动化与高可用。", icon: "🛠️" }, { title: "Developer", desc: "专注后端 / 云原生,热爱自动化与高可用。", icon: "🛠️" },
{ title: "Anime Fan", desc: "二次元爱好者,享受故事与想象力。", icon: "🎬" }, { title: "Anime Fan", desc: "二次元爱好者,享受故事与想象力。", icon: "🎬" },
{ title: "Just For Fun", desc: "我喜欢尝试新鲜事物,折腾小众技术", icon: "🎮" }, { title: "Just For Fun", desc: "我喜欢尝试新鲜事物,折腾小众技术", icon: "🎮" },
], ],
siteMeta: { siteMeta: {
title: "RhenCloud", title: "RhenCloud",
url: "https://rhen.cloud", url: "https://rhen.cloud",
icon: "/favicon.svg", // public/favicon.svg icon: "/favicon.svg", // public/favicon.svg
startDate: "2025-12-06", startDate: "2025-12-06",
}, },
appearance: { appearance: {
background: { background: {
enable: true, enable: true,
// URL 支持:可使用外部 URL 或本地路径 // URL 支持:可使用外部 URL 或本地路径
// 例如: "https://example.com/bg.jpg" 或 "background.webp" // 例如: "https://example.com/bg.jpg" 或 "background.webp"
image: "background.webp", // 背景图片 URL 或本地路径(桌面端) image: "background.webp", // 背景图片 URL 或本地路径(桌面端)
mobileImage: "https://www.loliapi.com/acg/pe/", // 移动端背景图片(可选,不设置则使用 image mobileImage: "https://www.loliapi.com/acg/pe/", // 移动端背景图片(可选,不设置则使用 image
blur: 0, // 背景模糊程度 (0-100) blur: 0, // 背景模糊程度 (0-100)
overlay: "rgba(70, 59, 82, 0.4)", // 背景遮罩颜色与透明度 overlay: "rgba(70, 59, 82, 0.4)", // 背景遮罩颜色与透明度
}, },
}, },
music: { music: {
enable: true, enable: true,
// 浮动模式播放器(推荐)- 用于播放网易云歌单 // 浮动模式播放器(推荐)- 用于播放网易云歌单
mode: "floating", // "floating" 或 "embed" mode: "floating", // "floating" 或 "embed"
// 歌单ID从网易云音乐链接获取如 https://music.163.com/#/playlist?id=14273792576 // 歌单ID从网易云音乐链接获取如 https://music.163.com/#/playlist?id=14273792576
playlistId: "14366453940", // 例如: "14273792576" playlistId: "14366453940", // 例如: "14273792576"
// 歌曲ID仅在嵌入模式下使用 // 歌曲ID仅在嵌入模式下使用
songId: undefined, // 例如: "554242291" songId: undefined, // 例如: "554242291"
// 播放器位置(浮动模式): "bottom-left" | "bottom-right" | "top-left" | "top-right" // 播放器位置(浮动模式): "bottom-left" | "bottom-right" | "top-left" | "top-right"
position: "bottom-left", position: "bottom-left",
// 是否显示歌词 // 是否显示歌词
lyric: true, lyric: true,
// 主题: "light" | "dark" | "auto" // 主题: "light" | "dark" | "auto"
theme: "dark", theme: "dark",
// 是否自动播放 // 是否自动播放
autoplay: false, autoplay: false,
// 是否默认以黑胶唱片状态启动(仅浮动模式) // 是否默认以黑胶唱片状态启动(仅浮动模式)
defaultMinimized: true, defaultMinimized: true,
// 标签页非激活时是否自动暂停 // 标签页非激活时是否自动暂停
autoPause: false, autoPause: false,
// Music API 配置 // Music API 配置
apiUrls: ["https://www.bilibili.uno/api", "https://meting-api.wangcy.site/api"], apiUrls: ["https://www.bilibili.uno/api", "https://meting-api.wangcy.site/api"],
}, },
umami: { umami: {
enable: true, enable: true,
url: "https://cloud.umami.is/script.js", url: "https://cloud.umami.is/script.js",
websiteId: "ddcd51c3-ccc7-45e4-81e6-11567027f69b", websiteId: "ddcd51c3-ccc7-45e4-81e6-11567027f69b",
apiBase: "https://api.umami.is", apiBase: "https://api.umami.is",
}, },
wakatime: { wakatime: {
enable: true, enable: true,
apiUrl: "https://wakapi.rhen.cloud/api/v1", apiUrl: "https://wakapi.rhen.cloud/api/v1",
}, },
skills: [ skills: [
{ title: "前端", items: ["css", "html", "javascript", "typescript", "vue"] }, { title: "前端", items: ["css", "html", "javascript", "typescript", "vue"] },
{ title: "后端 / 云", items: ["cpp", "cloudflare", "docker", "java", "mysql", "nodejs", "python", "vercel"] }, {
{ title: "工具", items: ["ae", "au", "git", "github", "md", "ps", "pr", "vscode"] }, title: "后端 / 云",
{ title: "操作系统", items: ["arch", "linux", "windows"] }, items: ["cpp", "cloudflare", "docker", "java", "mysql", "nodejs", "python", "vercel"],
], },
{ title: "工具", items: ["ae", "au", "git", "github", "md", "ps", "pr", "vscode"] },
sites: [ { title: "操作系统", items: ["arch", "linux", "windows"] },
{ ],
name: "个人主页",
desc: "个人主页", sites: [
url: "https://rhen.cloud", {
}, name: "个人主页",
{ desc: "个人主页",
name: "我的博客", url: "https://rhen.cloud",
desc: "分享与记录", },
url: "https://blog.rhen.cloud", {
}, name: "我的博客",
{ desc: "分享与记录",
name: "来视奸我", url: "https://blog.rhen.cloud",
desc: "使用Sleepy项目搭建的视奸网站", },
url: "https://sleepy.rhen.cloud", {
}, name: "来视奸我",
{ desc: "使用Sleepy项目搭建的视奸网站",
name: "网站监控", url: "https://sleepy.rhen.cloud",
desc: "网站运行状态监控", },
url: "https://status.rhen.cloud", {
}, name: "网站监控",
], desc: "网站运行状态监控",
url: "https://status.rhen.cloud",
projects: [ },
{ name: "Cloud Home", url: "https://github.com/RhenCloud/cloud-home", desc: "个人主页模板" }, ],
{ name: "ILP", url: "https://github.com/RhenCloud/ILP", desc: "跨平台、多网站、模块化的小说下载器" },
{ name: "ILP-C++", url: "https://github.com/RhenCloud/ILP-Cpp", desc: "跨平台、多网站、模块化的小说下载器" }, projects: [
], { name: "Cloud Home", url: "https://github.com/RhenCloud/cloud-home", desc: "个人主页模板" },
{
friends: [ name: "ILP",
{ url: "https://github.com/RhenCloud/ILP",
name: "wuxian", desc: "跨平台、多网站、模块化的小说下载器",
desc: "wuxian's web", },
url: "https://www.alxian.cn", {
avatar: "https://www.alxian.cn/_next/image?url=%2Fimages%2Favatar.jpg&w=256&q=75", name: "ILP-C++",
}, url: "https://github.com/RhenCloud/ILP-Cpp",
{ desc: "跨平台、多网站、模块化的小说下载器",
name: "鈴奈咲桜のBlog", },
desc: "一个普普通通的Blog", ],
url: "https://blog.sakura.ink",
avatar: "https://q2.qlogo.cn/headimg_dl?dst_uin=2731443459&spec=5", friends: [
}, {
], name: "wuxian",
desc: "wuxian's web",
comments: { url: "https://www.alxian.cn",
enable: true, avatar: "https://www.alxian.cn/_next/image?url=%2Fimages%2Favatar.jpg&w=256&q=75",
// twikoo: { },
// url: "https://twikoo.rhen.cloud", {
// }, name: "鈴奈咲桜のBlog",
giscus: { desc: "一个普普通通的Blog",
repo: "RhenCloud/Cloud-Home", url: "https://blog.sakura.ink",
repoId: "R_kgDOQjx8rQ", avatar: "https://q2.qlogo.cn/headimg_dl?dst_uin=2731443459&spec=5",
category: "Announcements", },
categoryId: "DIC_kwDOQjx8rc4Cz4Qb", ],
mapping: "pathname",
reactionsEnabled: "1", comments: {
emitMetadata: "0", enable: true,
inputPosition: "bottom", // twikoo: {
theme: "preferred_color_scheme", // url: "https://twikoo.rhen.cloud",
}, // },
}, giscus: {
repo: "RhenCloud/Cloud-Home",
footer: { repoId: "R_kgDOQjx8rQ",
beian: "津ICP备2025039003号-1", category: "Announcements",
beianLink: "https://beian.miit.gov.cn/", categoryId: "DIC_kwDOQjx8rc4Cz4Qb",
customHtml: '<span style="opacity:.8">© 2025 <a href="https://rhen.cloud">RhenCloud</a></span>', mapping: "pathname",
hitokoto: { reactionsEnabled: "1",
enable: true, emitMetadata: "0",
type: "a&b&c&d&j", inputPosition: "bottom",
}, theme: "preferred_color_scheme",
}, },
}; },
export default siteConfig; footer: {
beian: "津ICP备2025039003号-1",
beianLink: "https://beian.miit.gov.cn/",
customHtml: '<span style="opacity:.8">© 2025 <a href="https://rhen.cloud">RhenCloud</a></span>',
hitokoto: {
enable: true,
type: "a&b&c&d&j",
},
},
};
export default siteConfig;

View File

@@ -1,84 +1,87 @@
<template> <template>
<main class="page"> <main class="page">
<HeroSection :profile="profile" /> <HeroSection :profile="profile" />
<SkillsSection :skills="skills" /> <SkillsSection :skills="skills" />
<Suspense> <Suspense>
<template #default> <template #default>
<StatsSection :github="github" :wakatime="wakatime" /> <StatsSection :github="github" :wakatime="wakatime" />
</template> </template>
<template #fallback> <template #fallback>
<div class="card" style="text-align: center; padding: 40px"> <div class="card" style="text-align: center; padding: 40px">
<p>加载统计数据中...</p> <p>加载统计数据中...</p>
</div> </div>
</template> </template>
</Suspense> </Suspense>
</main> </main>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, reactive } from "vue"; import { onMounted, reactive } from "vue";
import { useRuntimeConfig, definePageMeta } from "#imports"; import { useRuntimeConfig, definePageMeta } from "#imports";
import HeroSection from "~/components/HeroSection.vue"; import HeroSection from "~/components/HeroSection.vue";
import SkillsSection from "~/components/SkillsSection.vue"; import SkillsSection from "~/components/SkillsSection.vue";
import StatsSection from "~/components/StatsSection.vue"; import StatsSection from "~/components/StatsSection.vue";
import siteConfig from "@/config/siteConfig"; import siteConfig from "@/config/siteConfig";
const profile = siteConfig.profile; const profile = siteConfig.profile;
const skills = siteConfig.skills; const skills = siteConfig.skills;
const wakatime = siteConfig.wakatime; const wakatime = siteConfig.wakatime;
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const githubToken = config.public.githubToken ?? ""; const githubToken = config.public.githubToken ?? "";
type GithubHeatmap = { type GithubHeatmap = {
username: string; username: string;
heatmapUrl: string; heatmapUrl: string;
languages?: { name: string; percent: number }[]; languages?: { name: string; percent: number }[];
}; };
const github = reactive<GithubHeatmap>({ const github = reactive<GithubHeatmap>({
...siteConfig.github, ...siteConfig.github,
heatmapUrl: `https://ghchart.rshah.org/${siteConfig.github.username}`, heatmapUrl: `https://ghchart.rshah.org/${siteConfig.github.username}`,
languages: [], languages: [],
}); });
definePageMeta({ definePageMeta({
order: 1, order: 1,
label: "关于", label: "关于",
}); });
onMounted(() => { onMounted(() => {
fetchGithubMeta(); fetchGithubMeta();
}); });
async function fetchGithubMeta() { async function fetchGithubMeta() {
try { try {
const headers: HeadersInit = {}; const headers: HeadersInit = {};
if (githubToken) { if (githubToken) {
headers.Authorization = `Bearer ${githubToken}`; headers.Authorization = `Bearer ${githubToken}`;
} }
const resp = await fetch(`https://api.github.com/users/${github.username}/repos?per_page=100&sort=updated`, { const resp = await fetch(
headers, `https://api.github.com/users/${github.username}/repos?per_page=100&sort=updated`,
}); {
const data = await resp.json(); headers,
if (!Array.isArray(data)) return; }
type GithubRepo = { language?: string }; );
const repos = data as GithubRepo[]; const data = await resp.json();
const counts: Record<string, number> = {}; if (!Array.isArray(data)) return;
repos.forEach((repo) => { type GithubRepo = { language?: string };
if (!repo.language) return; const repos = data as GithubRepo[];
counts[repo.language] = (counts[repo.language] || 0) + 1; const counts: Record<string, number> = {};
}); repos.forEach((repo) => {
const total = Object.values(counts).reduce((sum, value) => sum + value, 0) || 1; if (!repo.language) return;
const parsed = Object.entries(counts) counts[repo.language] = (counts[repo.language] || 0) + 1;
.map(([name, count]) => ({ name, count })) });
.sort((a, b) => b.count - a.count) const total = Object.values(counts).reduce((sum, value) => sum + value, 0) || 1;
.slice(0, 5); const parsed = Object.entries(counts)
github.languages = parsed.map((item) => ({ .map(([name, count]) => ({ name, count }))
name: item.name, .sort((a, b) => b.count - a.count)
percent: Math.round((item.count / total) * 100), .slice(0, 5);
})); github.languages = parsed.map((item) => ({
} catch (error) { name: item.name,
console.error("Failed to fetch GitHub metadata:", error); percent: Math.round((item.count / total) * 100),
} }));
} } catch (error) {
</script> console.error("Failed to fetch GitHub metadata:", error);
}
}
</script>

View File

@@ -1,82 +1,82 @@
<template> <template>
<section class="container mx-auto py-8"> <section class="container mx-auto py-8">
<p v-if="!giscus || !giscus.repo" class="text-sm text-red-500 mb-4">Giscus 未配置</p> <p v-if="!giscus || !giscus.repo" class="text-sm text-red-500 mb-4">Giscus 未配置</p>
<ClientOnly> <ClientOnly>
<section class="card panel flex flex-col gap-2.5"> <section class="card panel flex flex-col gap-2.5">
<h2 class="m-0 mb-1 text-lg font-semibold">留言板</h2> <h2 class="m-0 mb-1 text-lg font-semibold">留言板</h2>
<p class="text-sm text-white/60 mb-3">在这里留下想说的话吧 · Comments</p> <p class="text-sm text-white/60 mb-3">在这里留下想说的话吧 · Comments</p>
<div class="giscus-wrapper"> <div class="giscus-wrapper">
<component <component
:is="GiscusComponent" :is="GiscusComponent"
v-if="GiscusComponent" v-if="GiscusComponent"
:repo="giscus.repo" :repo="giscus.repo"
:repo-id="giscus.repoId" :repo-id="giscus.repoId"
:category="giscus.category" :category="giscus.category"
:category-id="giscus.categoryId" :category-id="giscus.categoryId"
:mapping="giscus.mapping" :mapping="giscus.mapping"
:strict="giscus.strict" :strict="giscus.strict"
:reactions-enabled="giscus.reactionsEnabled" :reactions-enabled="giscus.reactionsEnabled"
:emit-metadata="giscus.emitMetadata" :emit-metadata="giscus.emitMetadata"
:input-position="giscus.inputPosition" :input-position="giscus.inputPosition"
:theme="'/css/giscus.css'" :theme="'/css/giscus.css'"
lang="zh-CN" lang="zh-CN"
class="giscus" class="giscus"
/> />
<div v-else id="giscus-container" class="giscus" /> <div v-else id="giscus-container" class="giscus" />
</div> </div>
</section> </section>
</ClientOnly> </ClientOnly>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { definePageMeta } from "#imports"; import { definePageMeta } from "#imports";
import { onMounted, shallowRef, markRaw } from "vue"; import { onMounted, shallowRef, markRaw } from "vue";
import type { Component } from "vue"; import type { Component } from "vue";
import siteConfig from "~/config/siteConfig"; import siteConfig from "~/config/siteConfig";
const giscus = siteConfig.comments.giscus || {}; const giscus = siteConfig.comments.giscus || {};
const GiscusComponent = shallowRef<Component | null>(null); const GiscusComponent = shallowRef<Component | null>(null);
async function tryUseOfficialComponent() { async function tryUseOfficialComponent() {
try { try {
const mod = await import("@giscus/vue"); const mod = await import("@giscus/vue");
const comp = mod.default ?? mod; const comp = mod.default ?? mod;
if (comp) GiscusComponent.value = markRaw(comp); if (comp) GiscusComponent.value = markRaw(comp);
return !!comp; return !!comp;
} catch (e) { } catch (e) {
console.error("Failed to import Giscus component:", e); console.error("Failed to import Giscus component:", e);
return false; return false;
} }
} }
onMounted(async () => { onMounted(async () => {
// 如果没有配置 giscus显示提示由模板处理 // 如果没有配置 giscus显示提示由模板处理
if (!siteConfig.comments.enable) return; if (!siteConfig.comments.enable) return;
if (!giscus || !giscus.repo) return; if (!giscus || !giscus.repo) return;
const ok = await tryUseOfficialComponent(); const ok = await tryUseOfficialComponent();
if (!ok) { if (!ok) {
console.error("Failed to load Giscus component."); console.error("Failed to load Giscus component.");
} }
}); });
definePageMeta({ definePageMeta({
order: 5, order: 5,
label: "留言", label: "留言",
}); });
</script> </script>
<style scoped> <style scoped>
.container { .container {
max-width: 880px; max-width: 880px;
padding-left: 1rem; padding-left: 1rem;
padding-right: 1rem; padding-right: 1rem;
} }
h1 { h1 {
color: #e6eef8; color: #e6eef8;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.35); text-shadow: 0 1px 0 rgba(0, 0, 0, 0.35);
} }
</style> </style>
<!-- <style src="../styles/giscus.css"></style> --> <!-- <style src="../styles/giscus.css"></style> -->

View File

@@ -1,17 +1,17 @@
<template> <template>
<main class="page"> <main class="page">
<FriendsSection :friends="friends" /> <FriendsSection :friends="friends" />
</main> </main>
</template> </template>
<script setup> <script setup>
import FriendsSection from "~/components/FriendsSection.vue"; import FriendsSection from "~/components/FriendsSection.vue";
import siteConfig from "~/config/siteConfig"; import siteConfig from "~/config/siteConfig";
const friends = siteConfig.friends; const friends = siteConfig.friends;
definePageMeta({ definePageMeta({
order: 4, order: 4,
label: "友链", label: "友链",
}); });
</script> </script>

View File

@@ -1,23 +1,23 @@
<template> <template>
<main class="page"> <main class="page">
<HeroSection :profile="profile" /> <HeroSection :profile="profile" />
<SocialLinks :links="socialLinks" /> <SocialLinks :links="socialLinks" />
<AboutSection :items="about" :profile="profile" /> <AboutSection :items="about" :profile="profile" />
</main> </main>
</template> </template>
<script setup> <script setup>
import HeroSection from "~/components/HeroSection.vue"; import HeroSection from "~/components/HeroSection.vue";
import SocialLinks from "~/components/SocialLinks.vue"; import SocialLinks from "~/components/SocialLinks.vue";
import AboutSection from "~/components/AboutSection.vue"; import AboutSection from "~/components/AboutSection.vue";
import siteConfig from "~/config/siteConfig"; import siteConfig from "~/config/siteConfig";
const profile = siteConfig.profile; const profile = siteConfig.profile;
const socialLinks = siteConfig.socialLinks; const socialLinks = siteConfig.socialLinks;
const about = siteConfig.about; const about = siteConfig.about;
definePageMeta({ definePageMeta({
order: 0, order: 0,
label: "首页", label: "首页",
}); });
</script> </script>

View File

@@ -1,17 +1,17 @@
<template> <template>
<main class="page"> <main class="page">
<ProjectsSection :projects="projects" /> <ProjectsSection :projects="projects" />
</main> </main>
</template> </template>
<script setup> <script setup>
import ProjectsSection from "~/components/ProjectsSection.vue"; import ProjectsSection from "~/components/ProjectsSection.vue";
import siteConfig from "~/config/siteConfig"; import siteConfig from "~/config/siteConfig";
const projects = siteConfig.projects; const projects = siteConfig.projects;
definePageMeta({ definePageMeta({
order: 3, order: 3,
label: "项目", label: "项目",
}); });
</script> </script>

View File

@@ -1,17 +1,17 @@
<template> <template>
<main class="page"> <main class="page">
<SitesSection :sites="sites" /> <SitesSection :sites="sites" />
</main> </main>
</template> </template>
<script setup> <script setup>
import SitesSection from "~/components/SitesSection.vue"; import SitesSection from "~/components/SitesSection.vue";
import siteConfig from "~/config/siteConfig"; import siteConfig from "~/config/siteConfig";
const sites = siteConfig.sites; const sites = siteConfig.sites;
definePageMeta({ definePageMeta({
order: 2, order: 2,
label: "网站", label: "网站",
}); });
</script> </script>

View File

@@ -1,100 +1,102 @@
import { defineNuxtPlugin } from "#app"; import { defineNuxtPlugin } from "#app";
import siteConfig from "~/config/siteConfig"; import siteConfig from "~/config/siteConfig";
type NeteaseMiniPlayerGlobal = { type NeteaseMiniPlayerGlobal = {
init?: () => void; init?: () => void;
}; };
type NeteaseWindow = Window & { type NeteaseWindow = Window & {
NeteaseMiniPlayer?: NeteaseMiniPlayerGlobal; NeteaseMiniPlayer?: NeteaseMiniPlayerGlobal;
__NETEASE_MUSIC_CONFIG__?: unknown; __NETEASE_MUSIC_CONFIG__?: unknown;
}; };
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
if (import.meta.server) return; if (import.meta.server) return;
// 检查配置是否启用了音乐播放器 // 检查配置是否启用了音乐播放器
if (!siteConfig.music?.enable) { if (!siteConfig.music?.enable) {
return; return;
} }
// 在本地开发环境禁用网易音乐播放器,避免网络超时 // 在本地开发环境禁用网易音乐播放器,避免网络超时
// if ( // if (
// typeof window !== "undefined" && // typeof window !== "undefined" &&
// (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") // (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1")
// ) { // ) {
// console.log("Netease Music Player disabled on localhost"); // console.log("Netease Music Player disabled on localhost");
// return; // return;
// } // }
const cssHref = "/css/netease-mini-player-v2.css"; const cssHref = "/css/netease-mini-player-v2.css";
const scriptSrc = "/js/netease-mini-player-v2.js"; const scriptSrc = "/js/netease-mini-player-v2.js";
const ensureStyle = () => { const ensureStyle = () => {
if (document.querySelector(`link[href="${cssHref}"]`)) return; if (document.querySelector(`link[href="${cssHref}"]`)) return;
const link = document.createElement("link"); const link = document.createElement("link");
link.rel = "stylesheet"; link.rel = "stylesheet";
link.href = cssHref; link.href = cssHref;
link.onerror = () => { link.onerror = () => {
console.warn("Failed to load Netease music player styles"); console.warn("Failed to load Netease music player styles");
}; };
document.head.appendChild(link); document.head.appendChild(link);
}; };
const ensureScript = () => const ensureScript = () =>
new Promise<void>((resolve) => { new Promise<void>((resolve) => {
// 检查全局对象是否已存在,表示脚本已加载 // 检查全局对象是否已存在,表示脚本已加载
const anyWin = window as NeteaseWindow; const anyWin = window as NeteaseWindow;
if (anyWin.NeteaseMiniPlayer) { if (anyWin.NeteaseMiniPlayer) {
resolve(); resolve();
return; return;
} }
const existing = document.querySelector(`script[src="${scriptSrc}"]`) as HTMLScriptElement | null; const existing = document.querySelector(
if (existing) { `script[src="${scriptSrc}"]`
// 脚本已存在但未加载,等待它加载 ) as HTMLScriptElement | null;
existing.addEventListener("load", () => resolve(), { once: true }); if (existing) {
existing.addEventListener("error", () => resolve(), { once: true }); // 脚本已存在但未加载,等待它加载
return; existing.addEventListener("load", () => resolve(), { once: true });
} existing.addEventListener("error", () => resolve(), { once: true });
return;
// 脚本不存在,创建并加载 }
const script = document.createElement("script");
script.src = scriptSrc; // 脚本不存在,创建并加载
script.async = true; const script = document.createElement("script");
script.onload = () => resolve(); script.src = scriptSrc;
script.onerror = () => { script.async = true;
console.warn("Failed to load Netease music player script"); script.onload = () => resolve();
resolve(); script.onerror = () => {
}; console.warn("Failed to load Netease music player script");
document.body.appendChild(script); resolve();
}); };
document.body.appendChild(script);
const initPlayer = () => { });
const anyWin = window as NeteaseWindow;
const initPlayer = () => {
// 将 siteConfig 的音乐配置传递给全局 window 对象 const anyWin = window as NeteaseWindow;
if (!anyWin.__NETEASE_MUSIC_CONFIG__) {
anyWin.__NETEASE_MUSIC_CONFIG__ = siteConfig.music; // 将 siteConfig 的音乐配置传递给全局 window 对象
} if (!anyWin.__NETEASE_MUSIC_CONFIG__) {
anyWin.__NETEASE_MUSIC_CONFIG__ = siteConfig.music;
if (anyWin.NeteaseMiniPlayer?.init) { }
try {
anyWin.NeteaseMiniPlayer.init(); if (anyWin.NeteaseMiniPlayer?.init) {
} catch (error) { try {
console.warn("Failed to initialize Netease music player:", error); 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); const timeout = setTimeout(() => {
console.warn("Netease music player initialization timeout");
ensureStyle(); }, 15000);
ensureScript().then(() => {
clearTimeout(timeout); ensureStyle();
initPlayer(); ensureScript().then(() => {
}); clearTimeout(timeout);
}); initPlayer();
});
});

View File

@@ -1,29 +1,29 @@
import { defineNuxtPlugin } from "#app"; import { defineNuxtPlugin } from "#app";
import { VueUmamiPlugin } from "@jaseeey/vue-umami-plugin"; import { VueUmamiPlugin } from "@jaseeey/vue-umami-plugin";
import type { Router } from "vue-router"; import type { Router } from "vue-router";
import siteConfig from "~/config/siteConfig"; import siteConfig from "~/config/siteConfig";
export default defineNuxtPlugin((nuxtApp) => { export default defineNuxtPlugin((nuxtApp) => {
if (!import.meta.client) return; if (!import.meta.client) return;
if (!siteConfig.umami?.enable) return; if (!siteConfig.umami?.enable) return;
// 跳过在 localhost 环境下加载 Umami // 跳过在 localhost 环境下加载 Umami
if ( if (
typeof window !== "undefined" && typeof window !== "undefined" &&
(window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1") (window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1")
) { ) {
console.log("Umami plugin skipped on localhost"); console.log("Umami plugin skipped on localhost");
return; return;
} }
const router = nuxtApp.$router as Router | undefined; const router = nuxtApp.$router as Router | undefined;
if (!router) return; if (!router) return;
nuxtApp.vueApp.use( nuxtApp.vueApp.use(
VueUmamiPlugin({ VueUmamiPlugin({
websiteID: siteConfig.umami.websiteId, websiteID: siteConfig.umami.websiteId,
scriptSrc: siteConfig.umami.url, scriptSrc: siteConfig.umami.url,
router, router,
}) })
); );
}); });

View File

@@ -1,353 +1,359 @@
@import "tailwindcss"; @import "tailwindcss";
/* Font Awesome 字体优化 */ /* Font Awesome 字体优化 */
@font-face { @font-face {
font-family: "Font Awesome 6 Solid"; font-family: "Font Awesome 6 Solid";
src: url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/webfonts/fa-solid-900.woff2") format("woff2"); src: url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/webfonts/fa-solid-900.woff2")
font-display: swap; format("woff2");
font-weight: 400; font-display: swap;
font-style: normal; font-weight: 400;
} font-style: normal;
}
@font-face {
font-family: "Font Awesome 6 Brands"; @font-face {
src: url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/webfonts/fa-brands-400.woff2") format("woff2"); font-family: "Font Awesome 6 Brands";
font-display: swap; src: url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/webfonts/fa-brands-400.woff2")
font-weight: 400; format("woff2");
font-style: normal; font-display: swap;
} font-weight: 400;
font-style: normal;
@layer base { }
html {
height: 100%; @layer base {
scroll-behavior: smooth; html {
} height: 100%;
scroll-behavior: smooth;
:root { }
color-scheme: light dark;
background: #0f1629; :root {
color: #e8eefc; color-scheme: light dark;
} background: #0f1629;
color: #e8eefc;
body { }
margin: 0;
min-height: 100%; body {
background: radial-gradient(circle at 20% 20%, #1b2b4b, #0f1629); margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", sans-serif; min-height: 100%;
-webkit-font-smoothing: antialiased; background: radial-gradient(circle at 20% 20%, #1b2b4b, #0f1629);
-moz-osx-font-smoothing: grayscale; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", sans-serif;
} -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
h1, }
h2,
h3 { h1,
margin: 0; h2,
margin-bottom: 0.5rem; h3 {
font-weight: 600; margin: 0;
} margin-bottom: 0.5rem;
font-weight: 600;
p { }
margin-top: 0.375rem;
margin-bottom: 0.375rem; p {
} margin-top: 0.375rem;
margin-bottom: 0.375rem;
a { }
color: #7cc1ff;
text-decoration: none; a {
transition: color 0.2s ease; color: #7cc1ff;
} text-decoration: none;
transition: color 0.2s ease;
a:hover { }
color: #a8d5ff;
} a:hover {
} color: #a8d5ff;
}
@layer components { }
.info-card {
@apply rounded-[14px] border border-white/10 @layer components {
bg-gradient-to-br from-white/5 to-white/0 .info-card {
px-4 py-3.5 transition-all duration-200; @apply rounded-[14px] border border-white/10
} bg-gradient-to-br from-white/5 to-white/0
} px-4 py-3.5 transition-all duration-200;
}
@layer components { }
.app-shell {
position: relative; @layer components {
min-height: 100vh; .app-shell {
color: #e8eefc; position: relative;
overflow: hidden; min-height: 100vh;
display: flex; color: #e8eefc;
flex-direction: column; overflow: hidden;
z-index: 0; display: flex;
isolation: isolate; flex-direction: column;
} z-index: 0;
isolation: isolate;
.background-overlay { }
position: fixed;
inset: 0; .background-overlay {
pointer-events: none; position: fixed;
z-index: -10; inset: 0;
background-repeat: no-repeat; pointer-events: none;
} z-index: -10;
background-repeat: no-repeat;
.content-stack { }
position: relative;
z-index: 10; .content-stack {
display: flex; position: relative;
flex-direction: column; z-index: 10;
min-height: 100vh; display: flex;
} flex-direction: column;
min-height: 100vh;
.app-body { }
position: relative;
z-index: 10; .app-body {
flex: 1; position: relative;
display: flex; z-index: 10;
flex-direction: column; flex: 1;
} display: flex;
flex-direction: column;
.background-toggle { }
position: fixed;
right: 1.125rem; .background-toggle {
bottom: 1.125rem; position: fixed;
z-index: 40; right: 1.125rem;
display: inline-flex; bottom: 1.125rem;
align-items: center; z-index: 40;
gap: 0.375rem; display: inline-flex;
padding: 0.625rem 0.875rem; align-items: center;
border-radius: 999px; gap: 0.375rem;
border: 1px solid rgba(255, 255, 255, 0.25); padding: 0.625rem 0.875rem;
background: rgba(255, 255, 255, 0.14); border-radius: 999px;
backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.25);
color: #f7fbff; background: rgba(255, 255, 255, 0.14);
font-weight: 600; backdrop-filter: blur(10px);
cursor: pointer; color: #f7fbff;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28); font-weight: 600;
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, border-color 0.18s ease; cursor: pointer;
} box-shadow: 0 10px 30px rgba(0, 0, 0, 0.28);
transition:
.background-toggle:hover, transform 0.18s ease,
.background-toggle:focus-visible { box-shadow 0.18s ease,
background: rgba(124, 193, 255, 0.25); background 0.18s ease,
border-color: rgba(124, 193, 255, 0.65); border-color 0.18s ease;
box-shadow: 0 14px 36px rgba(124, 193, 255, 0.28); }
outline: none;
} .background-toggle:hover,
.background-toggle:focus-visible {
.background-toggle:active { background: rgba(124, 193, 255, 0.25);
transform: translateY(1px) scale(0.99); border-color: rgba(124, 193, 255, 0.65);
} box-shadow: 0 14px 36px rgba(124, 193, 255, 0.28);
outline: none;
.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); .background-toggle:active {
color: #0f1629; transform: translateY(1px) scale(0.99);
box-shadow: 0 16px 42px rgba(124, 193, 255, 0.32); }
}
.background-toggle.active {
.background-toggle .toggle-icon { background: linear-gradient(135deg, rgba(124, 193, 255, 0.4), rgba(255, 255, 255, 0.2));
font-size: 18px; border-color: rgba(124, 193, 255, 0.8);
line-height: 1; color: #0f1629;
} box-shadow: 0 16px 42px rgba(124, 193, 255, 0.32);
}
.background-toggle .toggle-label {
font-size: 14px; .background-toggle .toggle-icon {
letter-spacing: 0.2px; font-size: 18px;
} line-height: 1;
}
.card {
background: rgba(255, 255, 255, 0.04); .background-toggle .toggle-label {
border: 1px solid rgba(255, 255, 255, 0.08); font-size: 14px;
border-radius: 1rem; letter-spacing: 0.2px;
padding: 1.125rem 1.25rem; }
backdrop-filter: blur(8px);
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.25); .card {
transition: all 0.3s ease; background: rgba(255, 255, 255, 0.04);
} border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 1rem;
.card:hover { padding: 1.125rem 1.25rem;
border-color: rgba(255, 255, 255, 0.15); backdrop-filter: blur(8px);
box-shadow: 0 12px 48px rgba(124, 193, 255, 0.15); box-shadow: 0 10px 40px rgba(0, 0, 0, 0.25);
} transition: all 0.3s ease;
}
.page {
max-width: 960px; .card:hover {
margin: 0 auto; border-color: rgba(255, 255, 255, 0.15);
padding: 2rem 1rem 3rem; box-shadow: 0 12px 48px rgba(124, 193, 255, 0.15);
display: flex; }
flex-direction: column;
gap: 1rem; .page {
} max-width: 960px;
margin: 0 auto;
.chips { padding: 2rem 1rem 3rem;
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 0.625rem; gap: 1rem;
} }
.chips a, .chips {
.chip { display: flex;
padding: 0.375rem 0.75rem; flex-wrap: wrap;
border-radius: 999px; gap: 0.625rem;
background: rgba(255, 255, 255, 0.08); }
border: 1px solid rgba(255, 255, 255, 0.1);
color: #e8eefc; .chips a,
font-size: 0.875rem; .chip {
font-weight: 500; padding: 0.375rem 0.75rem;
transition: all 0.2s ease; border-radius: 999px;
} background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
.chips a:hover, color: #e8eefc;
.chip:hover { font-size: 0.875rem;
background: rgba(124, 193, 255, 0.2); font-weight: 500;
border-color: rgba(124, 193, 255, 0.4); transition: all 0.2s ease;
color: #a8d5ff; }
transform: translateY(-2px);
} .chips a:hover,
.chip:hover {
.list { background: rgba(124, 193, 255, 0.2);
list-style: none; border-color: rgba(124, 193, 255, 0.4);
padding: 0; color: #a8d5ff;
margin: 0; transform: translateY(-2px);
} }
.list li { .list {
margin-bottom: 0.5rem; list-style: none;
} padding: 0;
margin: 0;
.netease-mini-player, }
.netease-mini-player-embed,
.nmpv2-player, .list li {
.nmpv2-root { margin-bottom: 0.5rem;
position: fixed !important; }
bottom: 20px !important;
left: 20px !important; .netease-mini-player,
right: auto !important; .netease-mini-player-embed,
max-width: calc(100% - 40px) !important; .nmpv2-player,
z-index: 40001 !important; .nmpv2-root {
margin: 0 !important; position: fixed !important;
transform: none !important; bottom: 20px !important;
} left: 20px !important;
right: auto !important;
.netease-mini-player > * { max-width: calc(100% - 40px) !important;
box-sizing: border-box; z-index: 40001 !important;
} margin: 0 !important;
transform: none !important;
.netease-mini-player.minimized { }
width: 80px !important;
height: 80px !important; .netease-mini-player > * {
border-radius: 50% !important; box-sizing: border-box;
padding: 0 !important; }
overflow: hidden !important;
box-shadow: none !important; .netease-mini-player.minimized {
} width: 80px !important;
height: 80px !important;
.netease-mini-player.minimized .album-cover-container { border-radius: 50% !important;
width: 100% !important; padding: 0 !important;
height: 100% !important; overflow: hidden !important;
position: absolute !important; box-shadow: none !important;
top: 0 !important; }
left: 0 !important;
border-radius: 50% !important; .netease-mini-player.minimized .album-cover-container {
overflow: hidden !important; width: 100% !important;
} height: 100% !important;
position: absolute !important;
.netease-mini-player.minimized .album-cover { top: 0 !important;
width: 100% !important; left: 0 !important;
height: 100% !important; border-radius: 50% !important;
object-fit: cover !important; overflow: hidden !important;
border-radius: 50% !important; }
}
.netease-mini-player.minimized .album-cover {
.netease-mini-player[data-position="bottom-left"] .playlist-container, width: 100% !important;
.netease-mini-player[data-position="bottom-right"] .playlist-container { height: 100% !important;
position: fixed !important; object-fit: cover !important;
bottom: calc(20px + 80px) !important; border-radius: 50% !important;
} }
.muted { .netease-mini-player[data-position="bottom-left"] .playlist-container,
color: #a8b3cf; .netease-mini-player[data-position="bottom-right"] .playlist-container {
font-size: 0.875rem; position: fixed !important;
} bottom: calc(20px + 80px) !important;
}
.fade-enter-active,
.fade-leave-active { .muted {
transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1); color: #a8b3cf;
} font-size: 0.875rem;
}
.fade-enter-from,
.fade-leave-to { .fade-enter-active,
opacity: 0; .fade-leave-active {
} transition: opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.fade-down-enter-active,
.fade-down-leave-active { .fade-enter-from,
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); .fade-leave-to {
} opacity: 0;
}
.fade-down-enter-from {
opacity: 0; .fade-down-enter-active,
transform: translateY(0.5rem); .fade-down-leave-active {
} transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.fade-down-leave-to {
opacity: 0; .fade-down-enter-from {
transform: translateY(-0.5rem); opacity: 0;
} transform: translateY(0.5rem);
}
.fade-up-enter-active,
.fade-up-leave-active { .fade-down-leave-to {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); opacity: 0;
} transform: translateY(-0.5rem);
}
.fade-up-enter-from {
opacity: 0; .fade-up-enter-active,
transform: translateY(-0.5rem); .fade-up-leave-active {
} transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.fade-up-leave-to {
opacity: 0; .fade-up-enter-from {
transform: translateY(0.5rem); opacity: 0;
} transform: translateY(-0.5rem);
} }
@layer utilities { .fade-up-leave-to {
.truncate-lines-2 { opacity: 0;
display: -webkit-box; transform: translateY(0.5rem);
-webkit-line-clamp: 2; }
-webkit-box-orient: vertical; }
overflow: hidden;
} @layer utilities {
.truncate-lines-2 {
.truncate-lines-3 { display: -webkit-box;
display: -webkit-box; -webkit-line-clamp: 2;
-webkit-line-clamp: 3; -webkit-box-orient: vertical;
-webkit-box-orient: vertical; overflow: hidden;
overflow: hidden; }
}
.truncate-lines-3 {
.glass { display: -webkit-box;
background: rgba(255, 255, 255, 0.05); -webkit-line-clamp: 3;
backdrop-filter: blur(8px); -webkit-box-orient: vertical;
border: 1px solid rgba(255, 255, 255, 0.1); overflow: hidden;
} }
.glass-sm { .glass {
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(4px); backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid rgba(255, 255, 255, 0.1);
} }
@media (max-width: 640px) { .glass-sm {
.background-toggle { background: rgba(255, 255, 255, 0.05);
right: 0.75rem; backdrop-filter: blur(4px);
bottom: 0.75rem; border: 1px solid rgba(255, 255, 255, 0.1);
padding: 0.5625rem 0.75rem; }
gap: 0.3125rem;
} @media (max-width: 640px) {
.background-toggle {
.background-toggle .toggle-label { right: 0.75rem;
font-size: 0.8125rem; bottom: 0.75rem;
} padding: 0.5625rem 0.75rem;
} gap: 0.3125rem;
} }
.background-toggle .toggle-label {
font-size: 0.8125rem;
}
}
}

View File

@@ -1,9 +1,9 @@
/** /**
* CSS 加载工具 (已禁用) * CSS 加载工具 (已禁用)
* *
* 此工具不再使用因为Nuxt会自动处理CSS加载和优化 * 此工具不再使用因为Nuxt会自动处理CSS加载和优化
* 保留此文件以供参考,但不会被导入 * 保留此文件以供参考,但不会被导入
*/ */
// 所有导出的函数已被删除 // 所有导出的函数已被删除
// 使用 nuxt.config.ts 的 css 配置代替 // 使用 nuxt.config.ts 的 css 配置代替

View File

@@ -1,3 +1,3 @@
onlyBuiltDependencies: onlyBuiltDependencies:
- esbuild - esbuild
- sharp@0.34.5 - sharp@0.34.5

View File

@@ -1,80 +1,86 @@
<!doctype html> <!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
<head>
<head> <meta charset="UTF-8" />
<meta charset="UTF-8" /> <meta name="viewport" content="width=device-width,initial-scale=1.0" />
<meta name="viewport" content="width=device-width,initial-scale=1.0" /> <title>404 - Not Found</title>
<title>404 - Not Found</title> <style>
<style> :root {
:root { color-scheme: dark;
color-scheme: dark; font-family:
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif; "Inter",
} "Segoe UI",
system-ui,
* { -apple-system,
box-sizing: border-box; sans-serif;
} }
body { * {
margin: 0; box-sizing: border-box;
min-height: 100vh; }
display: grid;
place-items: center; body {
background: radial-gradient(circle at 20% 20%, rgba(244, 151, 218, 0.14), transparent 32%), margin: 0;
radial-gradient(circle at 80% 10%, rgba(124, 193, 255, 0.16), transparent 32%), min-height: 100vh;
#0f1116; display: grid;
color: #e8eefc; place-items: center;
} background:
radial-gradient(circle at 20% 20%, rgba(244, 151, 218, 0.14), transparent 32%),
.card { radial-gradient(circle at 80% 10%, rgba(124, 193, 255, 0.16), transparent 32%), #0f1116;
width: min(480px, 92vw); color: #e8eefc;
padding: 24px 26px; }
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12); .card {
background: linear-gradient(135deg, rgba(244, 151, 218, 0.12), rgba(255, 255, 255, 0.05)); width: min(480px, 92vw);
box-shadow: 0 16px 46px rgba(0, 0, 0, 0.28); padding: 24px 26px;
text-align: center; border-radius: 16px;
} border: 1px solid rgba(255, 255, 255, 0.12);
background: linear-gradient(135deg, rgba(244, 151, 218, 0.12), rgba(255, 255, 255, 0.05));
h1 { box-shadow: 0 16px 46px rgba(0, 0, 0, 0.28);
margin: 0 0 8px; text-align: center;
font-size: 32px; }
}
h1 {
p { margin: 0 0 8px;
margin: 0 0 18px; font-size: 32px;
color: rgba(232, 238, 252, 0.82); }
}
p {
a.btn { margin: 0 0 18px;
display: inline-flex; color: rgba(232, 238, 252, 0.82);
align-items: center; }
justify-content: center;
gap: 8px; a.btn {
padding: 10px 16px; display: inline-flex;
border-radius: 12px; align-items: center;
border: 1px solid rgba(124, 193, 255, 0.5); justify-content: center;
background: rgba(124, 193, 255, 0.12); gap: 8px;
color: #e8eefc; padding: 10px 16px;
text-decoration: none; border-radius: 12px;
font-weight: 600; border: 1px solid rgba(124, 193, 255, 0.5);
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; background: rgba(124, 193, 255, 0.12);
} color: #e8eefc;
text-decoration: none;
a.btn:hover { font-weight: 600;
transform: translateY(-2px); transition:
border-color: rgba(244, 151, 218, 0.6); transform 0.15s ease,
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.25); box-shadow 0.15s ease,
} border-color 0.15s ease;
</style> }
</head>
a.btn:hover {
<body> transform: translateY(-2px);
<main class="card"> border-color: rgba(244, 151, 218, 0.6);
<h1>404</h1> box-shadow: 0 10px 28px rgba(0, 0, 0, 0.25);
<p>页面不见了,或已被移除。</p> }
<a class="btn" href="/">返回首页</a> </style>
</main> </head>
</body>
<body>
</html> <main class="card">
<h1>404</h1>
<p>页面不见了,或已被移除。</p>
<a class="btn" href="/">返回首页</a>
</main>
</body>
</html>

View File

@@ -1,167 +1,167 @@
/*! /*!
* Modified from GitHub's Dark Dimmed theme, adapted for Cloud-Home * Modified from GitHub's Dark Dimmed theme, adapted for Cloud-Home
* License: MIT (see original project primer/primitives) * License: MIT (see original project primer/primitives)
* Modified from GitHub's Dark Dimmed theme, licensed under the MIT License * Modified from GitHub's Dark Dimmed theme, licensed under the MIT License
* Copyright (c) 2018 GitHub Inc. * Copyright (c) 2018 GitHub Inc.
* https://github.com/primer/primitives/blob/main/LICENSE * https://github.com/primer/primitives/blob/main/LICENSE
*/ */
:root { :root {
--ch-bg: #0a0c14; /* 深色卡片背景 */ --ch-bg: #0a0c14; /* 深色卡片背景 */
--ch-bg-2: rgba(6, 8, 15, 0.55); --ch-bg-2: rgba(6, 8, 15, 0.55);
--ch-border: rgba(255, 255, 255, 0.04); --ch-border: rgba(255, 255, 255, 0.04);
--muted: #768390; --muted: #768390;
--fg: #e6eef8; --fg: #e6eef8;
/* 添加 1.css 中的颜色变量 */ /* 添加 1.css 中的颜色变量 */
--color-prettylights-syntax-comment: #768390; --color-prettylights-syntax-comment: #768390;
--color-prettylights-syntax-constant: #6cb6ff; --color-prettylights-syntax-constant: #6cb6ff;
--color-prettylights-syntax-entity: #dcbdfb; --color-prettylights-syntax-entity: #dcbdfb;
--color-prettylights-syntax-storage-modifier-import: #adbac7; --color-prettylights-syntax-storage-modifier-import: #adbac7;
--color-prettylights-syntax-entity-tag: #8ddb8c; --color-prettylights-syntax-entity-tag: #8ddb8c;
--color-prettylights-syntax-keyword: #f47067; --color-prettylights-syntax-keyword: #f47067;
--color-prettylights-syntax-string: #96d0ff; --color-prettylights-syntax-string: #96d0ff;
--color-prettylights-syntax-variable: #f69d50; --color-prettylights-syntax-variable: #f69d50;
--color-prettylights-syntax-brackethighlighter-unmatched: #e5534b; --color-prettylights-syntax-brackethighlighter-unmatched: #e5534b;
--color-prettylights-syntax-invalid-illegal-text: #cdd9e5; --color-prettylights-syntax-invalid-illegal-text: #cdd9e5;
--color-prettylights-syntax-invalid-illegal-bg: #922323; --color-prettylights-syntax-invalid-illegal-bg: #922323;
--color-prettylights-syntax-carriage-return-text: #cdd9e5; --color-prettylights-syntax-carriage-return-text: #cdd9e5;
--color-prettylights-syntax-carriage-return-bg: #ad2e2c; --color-prettylights-syntax-carriage-return-bg: #ad2e2c;
--color-prettylights-syntax-string-regexp: #8ddb8c; --color-prettylights-syntax-string-regexp: #8ddb8c;
--color-prettylights-syntax-markup-list: #eac55f; --color-prettylights-syntax-markup-list: #eac55f;
--color-prettylights-syntax-markup-heading: #316dca; --color-prettylights-syntax-markup-heading: #316dca;
--color-prettylights-syntax-markup-italic: #adbac7; --color-prettylights-syntax-markup-italic: #adbac7;
--color-prettylights-syntax-markup-bold: #adbac7; --color-prettylights-syntax-markup-bold: #adbac7;
--color-prettylights-syntax-markup-deleted-text: #ffd8d3; --color-prettylights-syntax-markup-deleted-text: #ffd8d3;
--color-prettylights-syntax-markup-deleted-bg: #78191b; --color-prettylights-syntax-markup-deleted-bg: #78191b;
--color-prettylights-syntax-markup-inserted-text: #b4f1b4; --color-prettylights-syntax-markup-inserted-text: #b4f1b4;
--color-prettylights-syntax-markup-inserted-bg: #1b4721; --color-prettylights-syntax-markup-inserted-bg: #1b4721;
--color-prettylights-syntax-markup-changed-text: #ffddb0; --color-prettylights-syntax-markup-changed-text: #ffddb0;
--color-prettylights-syntax-markup-changed-bg: #682d0f; --color-prettylights-syntax-markup-changed-bg: #682d0f;
--color-prettylights-syntax-markup-ignored-text: #adbac7; --color-prettylights-syntax-markup-ignored-text: #adbac7;
--color-prettylights-syntax-markup-ignored-bg: #255ab2; --color-prettylights-syntax-markup-ignored-bg: #255ab2;
--color-prettylights-syntax-meta-diff-range: #dcbdfb; --color-prettylights-syntax-meta-diff-range: #dcbdfb;
--color-prettylights-syntax-brackethighlighter-angle: #768390; --color-prettylights-syntax-brackethighlighter-angle: #768390;
--color-prettylights-syntax-sublimelinter-gutter-mark: #545d68; --color-prettylights-syntax-sublimelinter-gutter-mark: #545d68;
--color-prettylights-syntax-constant-other-reference-link: #96d0ff; --color-prettylights-syntax-constant-other-reference-link: #96d0ff;
--color-btn-text: #adbac7; --color-btn-text: #adbac7;
--color-btn-bg: #373e47; --color-btn-bg: #373e47;
--color-btn-border: #cdd9e51a; --color-btn-border: #cdd9e51a;
--color-btn-shadow: 0 0 #0000; --color-btn-shadow: 0 0 #0000;
--color-btn-inset-shadow: 0 0 #0000; --color-btn-inset-shadow: 0 0 #0000;
--color-btn-hover-bg: #444c56; --color-btn-hover-bg: #444c56;
--color-btn-hover-border: #768390; --color-btn-hover-border: #768390;
--color-btn-active-bg: #3d444d; --color-btn-active-bg: #3d444d;
--color-btn-active-border: #636e7b; --color-btn-active-border: #636e7b;
--color-btn-selected-bg: #2d333b; --color-btn-selected-bg: #2d333b;
--color-btn-primary-text: #fff; --color-btn-primary-text: #fff;
--color-btn-primary-bg: #347d39; --color-btn-primary-bg: #347d39;
--color-btn-primary-border: #cdd9e51a; --color-btn-primary-border: #cdd9e51a;
--color-btn-primary-shadow: 0 0 #0000; --color-btn-primary-shadow: 0 0 #0000;
--color-btn-primary-inset-shadow: 0 0 #0000; --color-btn-primary-inset-shadow: 0 0 #0000;
--color-btn-primary-hover-bg: #46954a; --color-btn-primary-hover-bg: #46954a;
--color-btn-primary-hover-border: #cdd9e51a; --color-btn-primary-hover-border: #cdd9e51a;
--color-btn-primary-selected-bg: #347d39; --color-btn-primary-selected-bg: #347d39;
--color-btn-primary-selected-shadow: 0 0 #0000; --color-btn-primary-selected-shadow: 0 0 #0000;
--color-btn-primary-disabled-text: #cdd9e580; --color-btn-primary-disabled-text: #cdd9e580;
--color-btn-primary-disabled-bg: #347d3999; --color-btn-primary-disabled-bg: #347d3999;
--color-btn-primary-disabled-border: #cdd9e51a; --color-btn-primary-disabled-border: #cdd9e51a;
--color-action-list-item-default-hover-bg: #909dab1f; --color-action-list-item-default-hover-bg: #909dab1f;
--color-segmented-control-bg: #636e7b1a; --color-segmented-control-bg: #636e7b1a;
--color-segmented-control-button-bg: #22272e; --color-segmented-control-button-bg: #22272e;
--color-segmented-control-button-selected-border: #636e7b; --color-segmented-control-button-selected-border: #636e7b;
} }
/* 外部容器(在组件中也有一层) */ /* 外部容器(在组件中也有一层) */
.giscus-wrapper { .giscus-wrapper {
background: linear-gradient(180deg, rgba(6, 8, 15, 0.55), rgba(10, 12, 20, 0.45)); background: linear-gradient(180deg, rgba(6, 8, 15, 0.55), rgba(10, 12, 20, 0.45));
border: 1px solid var(--ch-border); border: 1px solid var(--ch-border);
border-radius: 12px; border-radius: 12px;
padding: 0.75rem; padding: 0.75rem;
box-shadow: 0 8px 24px rgba(2, 6, 23, 0.6); box-shadow: 0 8px 24px rgba(2, 6, 23, 0.6);
backdrop-filter: blur(6px) saturate(120%); backdrop-filter: blur(6px) saturate(120%);
-webkit-backdrop-filter: blur(6px) saturate(120%); -webkit-backdrop-filter: blur(6px) saturate(120%);
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
} }
.giscus { .giscus {
width: 100%; width: 100%;
color-scheme: dark; color-scheme: dark;
overflow: hidden; overflow: hidden;
} }
/* 兼容非组件 fallback 容器 */ /* 兼容非组件 fallback 容器 */
#giscus-container { #giscus-container {
padding: 0.5rem; padding: 0.5rem;
} }
/* GitHub Dark Dimmed inspired tweaks adapted to site theme */ /* GitHub Dark Dimmed inspired tweaks adapted to site theme */
.gsc-reactions-count { .gsc-reactions-count {
display: none !important; display: none !important;
} }
.gsc-timeline { .gsc-timeline {
flex-direction: column-reverse; flex-direction: column-reverse;
} }
.gsc-header { .gsc-header {
padding-bottom: 1rem; padding-bottom: 1rem;
color: var(--muted) !important; color: var(--muted) !important;
} }
.gsc-comments > .gsc-comment-box { .gsc-comments > .gsc-comment-box {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.gsc-comments > .gsc-header { .gsc-comments > .gsc-header {
order: 1; order: 1;
} }
.gsc-comments > .gsc-timeline { .gsc-comments > .gsc-timeline {
order: 3; order: 3;
} }
/* 卡片风格:半透明+圆角+内阴影,契合站点 */ /* 卡片风格:半透明+圆角+内阴影,契合站点 */
.gsc-comment, .gsc-comment,
.gsc-comment-body, .gsc-comment-body,
.gsc-comment .gsc-comment-body { .gsc-comment .gsc-comment-body {
background: linear-gradient(180deg, rgba(6, 8, 15, 0.5), rgba(10, 12, 20, 0.45)) !important; background: linear-gradient(180deg, rgba(6, 8, 15, 0.5), rgba(10, 12, 20, 0.45)) !important;
border: 1px solid var(--ch-border) !important; border: 1px solid var(--ch-border) !important;
border-radius: 10px !important; border-radius: 10px !important;
padding: 0.75rem !important; padding: 0.75rem !important;
box-shadow: 0 8px 20px rgba(2, 6, 23, 0.55) !important; box-shadow: 0 8px 20px rgba(2, 6, 23, 0.55) !important;
} }
/* 评论作者/元信息颜色 */ /* 评论作者/元信息颜色 */
.gsc-comment .gsc-comment-header, .gsc-comment .gsc-comment-header,
.gsc-comment .gsc-comment-meta { .gsc-comment .gsc-comment-meta {
color: var(--muted) !important; color: var(--muted) !important;
} }
/* 按钮 / 交互控件微调 */ /* 按钮 / 交互控件微调 */
.gsc-reaction-button, .gsc-reaction-button,
.gsc-input button { .gsc-input button {
background: var(--color-btn-bg) !important; background: var(--color-btn-bg) !important;
border: var(--color-btn-border) !important; border: var(--color-btn-border) !important;
color: var(--color-btn-text) !important; color: var(--color-btn-text) !important;
border-radius: 6px !important; border-radius: 6px !important;
box-shadow: var(--color-btn-shadow) !important; box-shadow: var(--color-btn-shadow) !important;
border-radius: 6px !important; border-radius: 6px !important;
} }
/* 输入框样式 */ /* 输入框样式 */
.gsc-input textarea, .gsc-input textarea,
.gsc-input input { .gsc-input input {
background: rgba(0, 0, 0, 0.35) !important; background: rgba(0, 0, 0, 0.35) !important;
color: var(--fg) !important; color: var(--fg) !important;
border: 1px solid rgba(255, 255, 255, 0.06) !important; border: 1px solid rgba(255, 255, 255, 0.06) !important;
border-radius: 8px !important; border-radius: 8px !important;
} }
/* 加载图像和首页背景微调 */ /* 加载图像和首页背景微调 */
main .gsc-loading-image { main .gsc-loading-image {
background-image: url(https://github.githubassets.com/images/mona-loading-dimmed.gif) !important; background-image: url(https://github.githubassets.com/images/mona-loading-dimmed.gif) !important;
} }
.gsc-homepage-bg { .gsc-homepage-bg {
background-color: var(--color-canvas-subtle) !important; background-color: var(--color-canvas-subtle) !important;
} }
/* 语义辅助:让嵌入内容更好地适配窄屏 */ /* 语义辅助:让嵌入内容更好地适配窄屏 */
.giscus { .giscus {
overflow: hidden; overflow: hidden;
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,103 +1,112 @@
import { defineEventHandler, createError, readBody } from "h3"; import { defineEventHandler, createError, readBody } from "h3";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport"; import type SMTPTransport from "nodemailer/lib/smtp-transport";
import { useRuntimeConfig } from "#imports"; import { useRuntimeConfig } from "#imports";
type MailConfig = { type MailConfig = {
smtpHost?: string; smtpHost?: string;
smtpPort?: number | string; smtpPort?: number | string;
smtpUser?: string; smtpUser?: string;
smtpPass?: string; smtpPass?: string;
senderEmail?: string; senderEmail?: string;
adminEmail?: string; adminEmail?: string;
smtpSecure?: boolean; smtpSecure?: boolean;
}; };
type SendMailPayload = { type SendMailPayload = {
name?: string; name?: string;
url?: string; url?: string;
desc?: string; desc?: string;
email?: string; email?: string;
avatar?: string; avatar?: string;
message?: string; message?: string;
}; };
const ensureValue = (value?: string, fallback = "未填写") => (value?.trim() ? value.trim() : fallback); const ensureValue = (value?: string, fallback = "未填写") =>
value?.trim() ? value.trim() : fallback;
export default defineEventHandler(async (event) => {
const method = event.node.req.method; export default defineEventHandler(async (event) => {
if (method === "OPTIONS") { const method = event.node.req.method;
event.node.res.statusCode = 200; if (method === "OPTIONS") {
return { status: "ok" }; event.node.res.statusCode = 200;
} return { status: "ok" };
}
if (method !== "POST") {
throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" }); 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; const payload = (await readBody<SendMailPayload>(event)) || {};
const { name, url, desc, email, avatar, message } = payload;
if (!name?.trim() || !url?.trim() || !email?.trim()) {
throw createError({ if (!name?.trim() || !url?.trim() || !email?.trim()) {
statusCode: 400, throw createError({
statusMessage: "Missing required fields: name, url, and email", 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 config = useRuntimeConfig() as MailConfig;
const {
const smtpPort = Number(configSmtpPort ?? 465); smtpHost,
if (!smtpHost || !smtpUser || !smtpPass || !senderEmail || !adminEmail) { smtpPort: configSmtpPort,
throw createError({ statusCode: 500, statusMessage: "SMTP server is not fully configured" }); smtpUser,
} smtpPass,
senderEmail,
const secure = typeof smtpSecure === "boolean" ? smtpSecure : smtpPort === 465; adminEmail,
const smtpOptions: SMTPTransport.Options = { smtpSecure,
host: smtpHost, } = config;
port: smtpPort,
secure, const smtpPort = Number(configSmtpPort ?? 465);
auth: { if (!smtpHost || !smtpUser || !smtpPass || !senderEmail || !adminEmail) {
user: smtpUser, throw createError({ statusCode: 500, statusMessage: "SMTP server is not fully configured" });
pass: smtpPass, }
},
}; const secure = typeof smtpSecure === "boolean" ? smtpSecure : smtpPort === 465;
const smtpOptions: SMTPTransport.Options = {
const transporter = nodemailer.createTransport(smtpOptions); host: smtpHost,
const friendEntry = `{ port: smtpPort,
name: "${ensureValue(name).replace(/"/g, '\\"')}", secure,
url: "${ensureValue(url).replace(/"/g, '\\"')}", auth: {
desc: "${ensureValue(desc).replace(/"/g, '\\"')}", user: smtpUser,
avatar: "${ensureValue(avatar).replace(/"/g, '\\"')}", pass: smtpPass,
},`; },
};
const htmlMessage = `
<p>一个新的友链申请已提交,以下是可直接复制到项目中的配置:</p> const transporter = nodemailer.createTransport(smtpOptions);
<pre style="background: #f5f5f5; padding: 12px; border-radius: 4px; overflow: auto;"> const friendEntry = `{
<code>${friendEntry}</code> name: "${ensureValue(name).replace(/"/g, '\\"')}",
</pre> url: "${ensureValue(url).replace(/"/g, '\\"')}",
<hr style="margin: 20px 0;" /> desc: "${ensureValue(desc).replace(/"/g, '\\"')}",
<p><strong>申请者信息:</strong></p> avatar: "${ensureValue(avatar).replace(/"/g, '\\"')}",
<p><strong>名称:</strong>${ensureValue(name)}</p> },`;
<p><strong>邮箱:</strong>${ensureValue(email)}</p>
<p><strong>站点:</strong><a href="${ensureValue(url)}">${ensureValue(url)}</a></p> const htmlMessage = `
<p><strong>描述:</strong>${ensureValue(desc)}</p> <p>一个新的友链申请已提交,以下是可直接复制到项目中的配置:</p>
<p><strong>头像:</strong>${ensureValue(avatar)}</p> <pre style="background: #f5f5f5; padding: 12px; border-radius: 4px; overflow: auto;">
<p><strong>想说的话:</strong>${ensureValue(message)}</p> <code>${friendEntry}</code>
<p><strong>时间:</strong>${new Date().toISOString()}</p> </pre>
`; <hr style="margin: 20px 0;" />
<p><strong>申请者信息:</strong></p>
const info = await transporter.sendMail({ <p><strong>名称:</strong>${ensureValue(name)}</p>
from: senderEmail, <p><strong>邮箱:</strong>${ensureValue(email)}</p>
to: adminEmail, <p><strong>站点:</strong><a href="${ensureValue(url)}">${ensureValue(url)}</a></p>
replyTo: email, <p><strong>描述:</strong>${ensureValue(desc)}</p>
subject: `友链申请 / 联系表单 · ${ensureValue(name)}`, <p><strong>头像:</strong>${ensureValue(avatar)}</p>
html: htmlMessage, <p><strong>想说的话:</strong>${ensureValue(message)}</p>
}); <p><strong>时间:</strong>${new Date().toISOString()}</p>
`;
return {
message: "Mail sent", const info = await transporter.sendMail({
id: info.messageId, from: senderEmail,
}; to: adminEmail,
}); replyTo: email,
subject: `友链申请 / 联系表单 · ${ensureValue(name)}`,
html: htmlMessage,
});
return {
message: "Mail sent",
id: info.messageId,
};
});

View File

@@ -1,56 +1,56 @@
import { defineEventHandler, getQuery, createError } from "h3"; import { defineEventHandler, getQuery, createError } from "h3";
import { useRuntimeConfig } from "#imports"; import { useRuntimeConfig } from "#imports";
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const res = event.node.res; const res = event.node.res;
res.setHeader("Access-Control-Allow-Origin", "*"); res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS"); res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type"); res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (event.node.req.method === "OPTIONS") { if (event.node.req.method === "OPTIONS") {
res.statusCode = 200; res.statusCode = 200;
return "ok"; return "ok";
} }
if (event.node.req.method !== "GET") { if (event.node.req.method !== "GET") {
throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" }); throw createError({ statusCode: 405, statusMessage: "Method Not Allowed" });
} }
const config = useRuntimeConfig(); const config = useRuntimeConfig();
const apiKey = config.wakatimeApiKey; const apiKey = config.wakatimeApiKey;
if (typeof apiKey !== "string") { if (typeof apiKey !== "string") {
throw createError({ statusCode: 500, statusMessage: "Invalid WakaTime API Key configuration" }); throw createError({ statusCode: 500, statusMessage: "Invalid WakaTime API Key configuration" });
} }
const query = getQuery(event); const query = getQuery(event);
const apiUrl = (query.apiUrl as string) || config.wakatimeApiUrl; const apiUrl = (query.apiUrl as string) || config.wakatimeApiUrl;
const headers = { const headers = {
Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`, Authorization: `Basic ${Buffer.from(apiKey).toString("base64")}`,
}; };
try { try {
const [weeklyStatsResponse, allTimeStatsResponse, statusResponse] = await Promise.all([ const [weeklyStatsResponse, allTimeStatsResponse, statusResponse] = await Promise.all([
fetch(`${apiUrl}/users/current/stats/last_7_days`, { headers }), fetch(`${apiUrl}/users/current/stats/last_7_days`, { headers }),
fetch(`${apiUrl}/users/current/stats/all_time`, { headers }), fetch(`${apiUrl}/users/current/stats/all_time`, { headers }),
fetch(`${apiUrl}/users/current/status`, { headers }), fetch(`${apiUrl}/users/current/status`, { headers }),
]); ]);
if (!weeklyStatsResponse.ok) { if (!weeklyStatsResponse.ok) {
throw new Error(`Wakatime API error: ${weeklyStatsResponse.status}`); throw new Error(`Wakatime API error: ${weeklyStatsResponse.status}`);
} }
const weeklyStatsData = await weeklyStatsResponse.json(); const weeklyStatsData = await weeklyStatsResponse.json();
const allTimeStatsData = allTimeStatsResponse.ok ? await allTimeStatsResponse.json() : null; const allTimeStatsData = allTimeStatsResponse.ok ? await allTimeStatsResponse.json() : null;
const statusData = statusResponse.ok ? await statusResponse.json() : null; const statusData = statusResponse.ok ? await statusResponse.json() : null;
return { return {
weekly: weeklyStatsData.data, weekly: weeklyStatsData.data,
allTime: allTimeStatsData ? allTimeStatsData.data : null, allTime: allTimeStatsData ? allTimeStatsData.data : null,
status: statusData, status: statusData,
}; };
} catch (error) { } catch (error) {
console.error("Wakatime API error:", error); console.error("Wakatime API error:", error);
throw createError({ statusCode: 500, statusMessage: "Failed to fetch Wakatime data" }); throw createError({ statusCode: 500, statusMessage: "Failed to fetch Wakatime data" });
} }
}); });

View File

@@ -1,39 +1,51 @@
import type { Config } from "tailwindcss"; import type { Config } from "tailwindcss";
export default { export default {
content: ["./app/**/*.{vue,js,ts}", "./app/components/**/*.vue", "./app/pages/**/*.vue", "./app/layouts/**/*.vue"], content: [
theme: { "./app/**/*.{vue,js,ts}",
extend: { "./app/components/**/*.vue",
colors: { "./app/pages/**/*.vue",
// 自定义颜色变量(对应现有的 CSS 变量) "./app/layouts/**/*.vue",
primary: "rgb(124, 193, 255)", ],
accent: "rgb(124, 193, 255)", theme: {
"surface-primary": "rgb(15, 22, 41)", extend: {
"surface-secondary": "rgb(27, 43, 75)", colors: {
"text-primary": "rgb(232, 238, 252)", // 自定义颜色变量(对应现有的 CSS 变量)
"text-secondary": "rgb(159, 172, 200)", primary: "rgb(124, 193, 255)",
"text-muted": "rgb(104, 120, 152)", accent: "rgb(124, 193, 255)",
}, "surface-primary": "rgb(15, 22, 41)",
fontFamily: { "surface-secondary": "rgb(27, 43, 75)",
sans: ['"Inter"', "system-ui", "-apple-system", "BlinkMacSystemFont", '"Segoe UI"', "sans-serif"], "text-primary": "rgb(232, 238, 252)",
}, "text-secondary": "rgb(159, 172, 200)",
spacing: { "text-muted": "rgb(104, 120, 152)",
"safe-x": "max(1rem, env(safe-area-inset-left))", },
"safe-y": "max(1rem, env(safe-area-inset-top))", fontFamily: {
}, sans: [
boxShadow: { '"Inter"',
"sm-dark": "0 4px 12px rgba(0, 0, 0, 0.15)", "system-ui",
"md-dark": "0 8px 24px rgba(0, 0, 0, 0.18)", "-apple-system",
"lg-dark": "0 12px 32px rgba(0, 0, 0, 0.22)", "BlinkMacSystemFont",
"xl-dark": "0 16px 48px rgba(0, 0, 0, 0.25)", '"Segoe UI"',
}, "sans-serif",
backgroundImage: { ],
"gradient-dark": "radial-gradient(circle at 20% 20%, #1b2b4b, #0f1629)", },
}, spacing: {
backdropBlur: { "safe-x": "max(1rem, env(safe-area-inset-left))",
xs: "2px", "safe-y": "max(1rem, env(safe-area-inset-top))",
}, },
}, boxShadow: {
}, "sm-dark": "0 4px 12px rgba(0, 0, 0, 0.15)",
plugins: [], "md-dark": "0 8px 24px rgba(0, 0, 0, 0.18)",
} satisfies Config; "lg-dark": "0 12px 32px rgba(0, 0, 0, 0.22)",
"xl-dark": "0 16px 48px rgba(0, 0, 0, 0.25)",
},
backgroundImage: {
"gradient-dark": "radial-gradient(circle at 20% 20%, #1b2b4b, #0f1629)",
},
backdropBlur: {
xs: "2px",
},
},
},
plugins: [],
} satisfies Config;

View File

@@ -1,10 +1,10 @@
{ {
"extends": "./.nuxt/tsconfig.json", "extends": "./.nuxt/tsconfig.json",
"compilerOptions": { "compilerOptions": {
"types": ["node"] "types": ["node"]
}, },
"vueCompilerOptions": { "vueCompilerOptions": {
"globalTypesPath": "./node_modules/.vue-global-types" "globalTypesPath": "./node_modules/.vue-global-types"
}, },
"include": ["nuxt.config.ts", "app/**/*.ts", "app/**/*.vue", "app/**/*.d.ts", "server/**/*.ts"] "include": ["nuxt.config.ts", "app/**/*.ts", "app/**/*.vue", "app/**/*.d.ts", "server/**/*.ts"]
} }

View File

@@ -1,11 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"composite": true, "composite": true,
"skipLibCheck": true, "skipLibCheck": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"types": ["node"] "types": ["node"]
}, },
"include": ["nuxt.config.ts"] "include": ["nuxt.config.ts"]
} }

View File

@@ -1,4 +1,4 @@
{ {
"version": 2, "version": 2,
"framework": "nuxtjs" "framework": "nuxtjs"
} }